From d75265c62132d149a829ac4664bdf54646d1e2cd Mon Sep 17 00:00:00 2001 From: Maksym Sadovnychyy Date: Sat, 1 Jun 2024 01:10:21 +0200 Subject: [PATCH] (feature): webapi implementation and containerization --- LetsEncrypt.postman_collection.json | 501 +++++++++++ README.md | 102 +++ src/Core/Core.csproj | 4 + src/Core/Logger/ConsoleLogger.cs | 24 + src/Core/Logger/ConsoleLoggerProvider.cs | 19 + .../Logger/ConsoleLoggerServiceExtension.cs | 8 + .../LetsEncrypt/CachedCertificateResult.cs | 3 + src/LetsEncrypt/LetsEncrypt.csproj | 1 + .../Models/Responses/AcmeDirectory.cs | 24 +- .../Services/LetsEncryptService.cs | 826 ++++++++++-------- src/LetsEncryptServer/Configuration.cs | 35 +- .../Controllers/CertsFlowController.cs | 282 ++---- .../Controllers/WellKnownController.cs | 24 +- .../Models/Requests/GetCerificatesRequest.cs | 5 + .../Models/Requests/GetOrderRequest.cs | 5 + src/LetsEncryptServer/Program.cs | 15 + .../Services/CertsFlowService.cs | 208 +++++ src/LetsEncryptServer/appsettings.json | 12 +- 18 files changed, 1473 insertions(+), 625 deletions(-) create mode 100644 LetsEncrypt.postman_collection.json create mode 100644 src/Core/Logger/ConsoleLogger.cs create mode 100644 src/Core/Logger/ConsoleLoggerProvider.cs create mode 100644 src/Core/Logger/ConsoleLoggerServiceExtension.cs create mode 100644 src/LetsEncryptServer/Models/Requests/GetCerificatesRequest.cs create mode 100644 src/LetsEncryptServer/Models/Requests/GetOrderRequest.cs create mode 100644 src/LetsEncryptServer/Services/CertsFlowService.cs diff --git a/LetsEncrypt.postman_collection.json b/LetsEncrypt.postman_collection.json new file mode 100644 index 0000000..d92f62e --- /dev/null +++ b/LetsEncrypt.postman_collection.json @@ -0,0 +1,501 @@ +{ + "info": { + "_postman_id": "728f64b6-893b-43fa-802e-ee836d1dc372", + "name": "LetsEncrypt", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "33635244" + }, + "item": [ + { + "name": "letsencrypt staging", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "https://acme-staging-v02.api.letsencrypt.org/directory", + "protocol": "https", + "host": [ + "acme-staging-v02", + "api", + "letsencrypt", + "org" + ], + "path": [ + "directory" + ] + }, + "description": "[https://letsencrypt.status.io/](https://letsencrypt.status.io/)" + }, + "response": [] + }, + { + "name": "letsencrypt production", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "https://acme-v02.api.letsencrypt.org/directory", + "protocol": "https", + "host": [ + "acme-v02", + "api", + "letsencrypt", + "org" + ], + "path": [ + "directory" + ] + } + }, + "response": [] + }, + { + "name": "acme-challenge", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://maks-it.com/.well-known/acme-challenge/{{challenge}}", + "protocol": "http", + "host": [ + "maks-it", + "com" + ], + "path": [ + ".well-known", + "acme-challenge", + "{{challenge}}" + ] + } + }, + "response": [] + }, + { + "name": "acme-challenge local", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:8080/.well-known/acme-challenge/{{challenge}}", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + ".well-known", + "acme-challenge", + "{{challenge}}" + ] + } + }, + "response": [] + }, + { + "name": "terms of service", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:8080/CertsFlow/TermsOfService/{{sessionId}}", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "CertsFlow", + "TermsOfService", + "{{sessionId}}" + ] + } + }, + "response": [] + }, + { + "name": "configure client", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "// Ensure the response status code is 200 (OK)\r", + "if (pm.response.code === 200) {\r", + " // Get the plain text response\r", + " let responseBody = pm.response.text();\r", + " \r", + " // Remove the surrounding quotes if present\r", + " responseBody = responseBody.replace(/^\"|\"$/g, '');\r", + " \r", + " // Check if the response body is a valid GUID\r", + " if (/^[0-9a-fA-F-]{36}$/.test(responseBody)) {\r", + " // Set the environment variable sessionId with the response\r", + " pm.environment.set(\"sessionId\", responseBody);\r", + " console.log(`sessionId set to: ${responseBody}`);\r", + " } else {\r", + " console.log(\"Response body is not a valid GUID\");\r", + " }\r", + "} else {\r", + " console.log(`Request failed with status code: ${pm.response.code}`);\r", + "}\r", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "http://localhost:8080/CertsFlow/ConfigureClient", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "CertsFlow", + "ConfigureClient" + ] + } + }, + "response": [] + }, + { + "name": "init", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "// Ensure the response status code is 200 (OK)\r", + "if (pm.response.code === 200) {\r", + " // Get the plain text response\r", + " let responseBody = pm.response.text();\r", + " \r", + " // Remove the surrounding quotes if present\r", + " responseBody = responseBody.replace(/^\"|\"$/g, '');\r", + " \r", + " // Check if the response body is a valid GUID\r", + " if (/^[0-9a-fA-F-]{36}$/.test(responseBody)) {\r", + " // Set the environment variable accountId with the response\r", + " pm.environment.set(\"accountId\", responseBody);\r", + " console.log(`accountId set to: ${responseBody}`);\r", + " } else {\r", + " console.log(\"Response body is not a valid GUID\");\r", + " }\r", + "} else {\r", + " console.log(`Request failed with status code: ${pm.response.code}`);\r", + "}\r", + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "// Retrieve sessionId and accountId from environment variables or global variables\r", + "var sessionId = pm.environment.get(\"sessionId\") || pm.globals.get(\"sessionId\");\r", + "var accountId = pm.environment.get(\"accountId\") || pm.globals.get(\"accountId\");\r", + "\r", + "// Base URL without the optional accountId parameter\r", + "var baseUrl = `http://localhost:8080/CertsFlow/Init/${sessionId}`;\r", + "\r", + "// Append the accountId if it is provided\r", + "if (accountId) {\r", + " pm.request.url = `${baseUrl}/${accountId}`;\r", + "} else {\r", + " pm.request.url = baseUrl;\r", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"contacts\": [\r\n \"maksym.sadovnychyy@gmail.com\"\r\n ]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:8080/CertsFlow/Init/{{sessionId}}/{{accountId}}", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "CertsFlow", + "Init", + "{{sessionId}}", + "{{accountId}}" + ] + } + }, + "response": [] + }, + { + "name": "new order", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "// Ensure the response status code is 200 (OK)\r", + "if (pm.response.code === 200) {\r", + " // Parse the JSON response\r", + " let responseBody;\r", + " try {\r", + " responseBody = pm.response.json();\r", + " } catch (e) {\r", + " console.error(\"Failed to parse JSON response:\", e);\r", + " return;\r", + " }\r", + "\r", + " // Check if the response is an array and has at least one element\r", + " if (Array.isArray(responseBody) && responseBody.length > 0) {\r", + " // Get the first element of the array\r", + " const firstElement = responseBody[0];\r", + " \r", + " // Set the environment variable challenge with the first element\r", + " pm.environment.set(\"challenge\", firstElement);\r", + " console.log(`challenge set to: ${firstElement}`);\r", + " } else {\r", + " console.log(\"Response body is not an array or is empty\");\r", + " }\r", + "} else {\r", + " console.log(`Request failed with status code: ${pm.response.code}`);\r", + "}\r", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"hostnames\": [\r\n \"maks-it.com\",\r\n \"www.maks-it.com\"\r\n ],\r\n \"challengeType\": \"http-01\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:8080/CertsFlow/NewOrder/{{sessionId}}", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "CertsFlow", + "NewOrder", + "{{sessionId}}" + ] + } + }, + "response": [] + }, + { + "name": "complete challenges", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:8080/CertsFlow/CompleteChallenges/{{sessionId}}", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "CertsFlow", + "CompleteChallenges", + "{{sessionId}}" + ] + } + }, + "response": [] + }, + { + "name": "get order", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"hostnames\": [\r\n \"maks-it.com\",\r\n \"www.maks-it.com\"\r\n ]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:8080/CertsFlow/GetOrder/{{sessionId}}", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "CertsFlow", + "GetOrder", + "{{sessionId}}" + ] + } + }, + "response": [] + }, + { + "name": "get certificates", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"hostnames\": [\r\n \"maks-it.com\",\r\n \"www.maks-it.com\"\r\n ]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:8080/CertsFlow/GetCertificates/{{sessionId}}", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "CertsFlow", + "GetCertificates", + "{{sessionId}}" + ] + } + }, + "response": [] + }, + { + "name": "apply certificates", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"hostnames\": [\r\n \"maks-it.com\",\r\n \"www.maks-it.com\"\r\n ]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:8080/CertsFlow/ApplyCertificates/{{sessionId}}", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "CertsFlow", + "ApplyCertificates", + "{{sessionId}}" + ] + } + }, + "response": [] + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md index c056564..b286e52 100644 --- a/README.md +++ b/README.md @@ -6,3 +6,105 @@ Simple client to obtain Let's Encrypt HTTPS certificates developed with .net cor * 29 Jun, 2019 - V1.0 * 01 Nov, 2019 - V2.0 (Dependency Injection pattern impelemtation) +* 31 May, 2024 - V3.0 (Webapi and containerization) + +## Haproxy configuration + +```bash +#--------------------------------------------------------------------- +# Example configuration for a possible web application. See the +# full configuration options online. +# +# https://www.haproxy.org/download/1.8/doc/configuration.txt +# +#--------------------------------------------------------------------- + +#--------------------------------------------------------------------- +# Global settings +#--------------------------------------------------------------------- +global + # to have these messages end up in /var/log/haproxy.log you will + # need to: + # + # 1) configure syslog to accept network log events. This is done + # by adding the '-r' option to the SYSLOGD_OPTIONS in + # /etc/sysconfig/syslog + # + # 2) configure local2 events to go to the /var/log/haproxy.log + # file. A line like the following can be added to + # /etc/sysconfig/syslog + # + # local2.* /var/log/haproxy.log + # + log 127.0.0.1 local2 + + chroot /var/lib/haproxy + pidfile /var/run/haproxy.pid + maxconn 4000 + user haproxy + group haproxy + daemon + + # Adjust the maxconn value based on your server\'s capacity + maxconn 2048 + + # SSL certificates directory + # ca-base /etc/ssl/certs + #crt-base /etc/ssl/private + + # Default SSL certificate (used if no SNI match) + #ssl-default-bind-crt /etc/haproxy/certs/default.pem + + # turn on stats unix socket + # stats socket /var/lib/haproxy/stats level admin mode 660 + #stats socket /var/run/haproxy/admin.sock level admin mode 660 user haproxy group haproxy + + setenv ACCOUNT_THUMBPRINT \'\' + + # utilize system-wide crypto-policies + ssl-default-bind-ciphers PROFILE=SYSTEM + ssl-default-server-ciphers PROFILE=SYSTEM + + +#--------------------------------------------------------------------- +# common defaults that all the \'listen\' and \'backend\' sections will +# use if not designated in their block +#--------------------------------------------------------------------- +defaults + mode http + log global + option httplog + option dontlognull + option http-server-close + option forwardfor except 127.0.0.0/8 + option redispatch + retries 3 + timeout http-request 10s + timeout queue 1m + timeout connect 10s + timeout client 1m + timeout server 1m + timeout http-keep-alive 10s + timeout check 10s + maxconn 3000 + + +#--------------------------------------------------------------------- +# Frontend configuration for handling multiple domains with SNI +#--------------------------------------------------------------------- +frontend web + bind :80 + bind :443 ssl crt /etc/haproxy/certs/ strict-sni + + # Handling for ACME challenge paths + acl acme_challenge path_beg /.well-known/acme-challenge/ + use_backend acme_challenge_backend if acme_challenge + + + +#--------------------------------------------------------------------- +# Backend configuration for ACME challenge +#--------------------------------------------------------------------- +backend acme_challenge_backend + server acme_challenge 127.0.0.1:8080 +``` \ No newline at end of file diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 94e11cb..3d31787 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -13,4 +13,8 @@ + + + + diff --git a/src/Core/Logger/ConsoleLogger.cs b/src/Core/Logger/ConsoleLogger.cs new file mode 100644 index 0000000..5804482 --- /dev/null +++ b/src/Core/Logger/ConsoleLogger.cs @@ -0,0 +1,24 @@ + +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace MaksIT.Core.Logger; + +public class MyCustomLogger : ILogger { + public IDisposable? BeginScope(TState state) where TState : notnull { + throw new NotImplementedException(); + } + + public bool IsEnabled(LogLevel logLevel) { + throw new NotImplementedException(); + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { + throw new NotImplementedException(); + } +} + diff --git a/src/Core/Logger/ConsoleLoggerProvider.cs b/src/Core/Logger/ConsoleLoggerProvider.cs new file mode 100644 index 0000000..9aaa2b4 --- /dev/null +++ b/src/Core/Logger/ConsoleLoggerProvider.cs @@ -0,0 +1,19 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace MaksIT.Core.Logger; + +public class MyCustomLoggerProvider : ILoggerProvider { + public ILogger CreateLogger(string categoryName) { + throw new NotImplementedException(); + } + + public void Dispose() { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/src/Core/Logger/ConsoleLoggerServiceExtension.cs b/src/Core/Logger/ConsoleLoggerServiceExtension.cs new file mode 100644 index 0000000..c9d2f37 --- /dev/null +++ b/src/Core/Logger/ConsoleLoggerServiceExtension.cs @@ -0,0 +1,8 @@ +using Microsoft.Extensions.Logging; + + +namespace MaksIT.Core.Logger; + +public static class MyCustomLoggerExtensions { + +} \ No newline at end of file diff --git a/src/LetsEncrypt/Entities/LetsEncrypt/CachedCertificateResult.cs b/src/LetsEncrypt/Entities/LetsEncrypt/CachedCertificateResult.cs index 5b8fbe2..c149212 100644 --- a/src/LetsEncrypt/Entities/LetsEncrypt/CachedCertificateResult.cs +++ b/src/LetsEncrypt/Entities/LetsEncrypt/CachedCertificateResult.cs @@ -4,5 +4,8 @@ namespace MaksIT.LetsEncrypt.Entities; public class CachedCertificateResult { public RSACryptoServiceProvider? PrivateKey { get; set; } + + public string PrivateKeyPem => PrivateKey == null ? "" : PrivateKey.ExportRSAPrivateKeyPem(); + public string? Certificate { get; set; } } diff --git a/src/LetsEncrypt/LetsEncrypt.csproj b/src/LetsEncrypt/LetsEncrypt.csproj index 92e7515..c4a3748 100644 --- a/src/LetsEncrypt/LetsEncrypt.csproj +++ b/src/LetsEncrypt/LetsEncrypt.csproj @@ -9,6 +9,7 @@ + diff --git a/src/LetsEncrypt/Models/Responses/AcmeDirectory.cs b/src/LetsEncrypt/Models/Responses/AcmeDirectory.cs index fc35900..48bf7f8 100644 --- a/src/LetsEncrypt/Models/Responses/AcmeDirectory.cs +++ b/src/LetsEncrypt/Models/Responses/AcmeDirectory.cs @@ -1,25 +1,17 @@ -using System; - -namespace MaksIT.LetsEncrypt.Models.Responses; +namespace MaksIT.LetsEncrypt.Models.Responses; public class AcmeDirectory { - public Uri NewNonce { get; set; } - - public Uri NewAccount { get; set; } - - public Uri NewOrder { get; set; } - - // New authorization If the ACME server does not implement pre-authorization - // (Section 7.4.1) it MUST omit the "newAuthz" field of the directory. - // [JsonProperty("newAuthz")] - // public Uri NewAuthz { get; set; } - public Uri RevokeCertificate { get; set; } - public Uri KeyChange { get; set; } - public AcmeDirectoryMeta Meta { get; set; } + public Uri NewAccount { get; set; } + public Uri NewNonce { get; set; } + public Uri NewOrder { get; set; } + public Uri RenewalInfo { get; set; } + public Uri RevokeCertificate { get; set; } } public class AcmeDirectoryMeta { + public string[] CaaIdentities { get; set; } public string TermsOfService { get; set; } + public string Website { get; set; } } \ No newline at end of file diff --git a/src/LetsEncrypt/Services/LetsEncryptService.cs b/src/LetsEncrypt/Services/LetsEncryptService.cs index cd60150..a09f6ed 100644 --- a/src/LetsEncrypt/Services/LetsEncryptService.cs +++ b/src/LetsEncrypt/Services/LetsEncryptService.cs @@ -1,153 +1,178 @@ -/** -* https://community.letsencrypt.org/t/trying-to-do-post-as-get-but-getting-post-jws-not-signed/108371 -* https://tools.ietf.org/html/rfc8555#section-6.2 -* -*/ using System.Text; - using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; - +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; - using MaksIT.LetsEncrypt.Entities; using MaksIT.LetsEncrypt.Exceptions; using MaksIT.Core.Extensions; - using MaksIT.LetsEncrypt.Models.Responses; using MaksIT.LetsEncrypt.Models.Interfaces; using MaksIT.LetsEncrypt.Models.Requests; using MaksIT.LetsEncrypt.Entities.Jws; using DomainResults.Common; +using System.Net.Http.Headers; namespace MaksIT.LetsEncrypt.Services; public interface ILetsEncryptService { - Task<(AcmeDirectory?, IDomainResult)> ConfigureClient(string url); - Task<(RegistrationCache?, IDomainResult)> Init(Uri newAccount, Uri newNonce, string[] contacts); - Task<((Order?, Dictionary?, List?), IDomainResult)> NewOrder(Uri newOrder, Uri newNonce, byte[] accountKeyBytes, string location, string[] hostnames, string challengeType); - Task CompleteChallenges(Uri newNonce, byte[] accountKeyBytes, string location, Order currentOrder, List _challenges); - Task<(Order?, IDomainResult)> GetOrder(Uri newOrder, Uri newNonce, byte[] accountKeyBytes, string location, string[] hostnames); - Task<(Dictionary?, IDomainResult)> GetCertificate(Uri newOrder, Uri newNonce, byte[] accountKeyBytes, Order currentOrder, string location, string [] subjects); + Task ConfigureClient(Guid sessionId, string url); + Task Init(Guid sessionId, string[] contacts, RegistrationCache? registrationCache); + RegistrationCache? GetRegistrationCache(Guid sessionId); + (string?, IDomainResult) GetTermsOfServiceUri(Guid sessionId); + Task<(Dictionary?, IDomainResult)> NewOrder(Guid sessionId, string[] hostnames, string challengeType); + Task CompleteChallenges(Guid sessionId); + Task GetOrder(Guid sessionId, string[] hostnames); + Task GetCertificate(Guid sessionId, string subject); + (CachedCertificateResult?, IDomainResult) TryGetCachedCertificate(Guid sessionId, string subject); } public class LetsEncryptService : ILetsEncryptService { - private readonly ILogger _logger; private readonly HttpClient _httpClient; + private readonly IMemoryCache _cache; public LetsEncryptService( - ILogger logger, - HttpClient httpClient - ) { + ILogger logger, + HttpClient httpClient, + IMemoryCache cache) { _logger = logger; _httpClient = httpClient; + _cache = cache; } + private State GetOrCreateState(Guid sessionId) { + if (!_cache.TryGetValue(sessionId, out State state)) { + state = new State(); + _cache.Set(sessionId, state, TimeSpan.FromHours(1)); + } + return state; + } - - /// - /// - /// - /// - /// - /// - public async Task<(AcmeDirectory?, IDomainResult)> ConfigureClient(string url) { + #region ConfigureClient + public async Task ConfigureClient(Guid sessionId, string url) { try { + var state = GetOrCreateState(sessionId); + _httpClient.BaseAddress ??= new Uri(url); - var (directory, getAcmeDirectoryResult) = await SendAsync(HttpMethod.Get, new Uri("directory", UriKind.Relative), false, null, null, null, null); - if (!getAcmeDirectoryResult.IsSuccess) - return (null, getAcmeDirectoryResult); + if (state.Directory == null) { + var (directory, getAcmeDirectoryResult) = await SendAsync(sessionId, HttpMethod.Get, new Uri("directory", UriKind.Relative), false, null); + if (!getAcmeDirectoryResult.IsSuccess || directory == null) + return getAcmeDirectoryResult; - var result = directory?.Result; + state.Directory = directory.Result; + } - return IDomainResult.Success(result); + return IDomainResult.Success(); } catch (Exception ex) { _logger.LogError(ex, "Let's Encrypt client unhandled exception"); - return IDomainResult.CriticalDependencyError(); + return IDomainResult.CriticalDependencyError(); } } + #endregion - /// - /// Account creation or Initialization from cache - /// - /// - /// - /// - public async Task<(RegistrationCache?, IDomainResult)> Init(Uri newAccount, Uri newNonce, string[] contacts) { + #region Init + public async Task Init(Guid sessionId, string[] contacts, RegistrationCache? cache) { + if (sessionId == Guid.Empty) { + _logger.LogError("Invalid sessionId"); + return IDomainResult.Failed(); + } + + if (contacts == null || contacts.Length == 0) { + _logger.LogError("Contacts are null or empty"); + return IDomainResult.Failed(); + } + + var state = GetOrCreateState(sessionId); + + if (state.Directory == null) { + _logger.LogError("State directory is null"); + return IDomainResult.Failed(); + } + + _logger.LogInformation($"Executing {nameof(Init)}..."); try { - - _logger.LogInformation($"Executing {nameof(Init)}..."); - var accountKey = new RSACryptoServiceProvider(4096); - var jwsService = new JwsService(accountKey); + if (cache != null && cache.AccountKey != null) { + state.Cache = cache; + accountKey.ImportCspBlob(cache.AccountKey); + } + else { + // New Account request + state.JwsService = new JwsService(accountKey); - var letsEncryptOrder = new Account { - TermsOfServiceAgreed = true, - Contacts = contacts.Select(contact => $"mailto:{contact}").ToArray() - }; + var letsEncryptOrder = new Account { + TermsOfServiceAgreed = true, + Contacts = contacts.Select(contact => $"mailto:{contact}").ToArray() + }; - var (account, postAccuntResult) = await SendAsync(HttpMethod.Post, newAccount, false, letsEncryptOrder, accountKey, null, newNonce); - if (!postAccuntResult.IsSuccess || account == null) - return (null, postAccuntResult); + var (account, postAccountResult) = await SendAsync(sessionId, HttpMethod.Post, state.Directory.NewAccount, false, letsEncryptOrder); + state.JwsService.SetKeyId(account.Result.Location.ToString()); - // Probably non necessary here - // jwsService.SetKeyId(account.Result.Location.ToString()); + if (account.Result.Status != "valid") { + _logger.LogError($"Account status is not valid, was: {account.Result.Status} \r\n {account.ResponseText}"); + return IDomainResult.Failed(); + } - if (account.Result.Status != "valid") { - _logger.LogError($"Account status is not valid, was: {account.Result.Status} \r\n {account.ResponseText}"); - return IDomainResult.Failed(); + state.Cache = new RegistrationCache { + Location = account.Result.Location, + AccountKey = accountKey.ExportCspBlob(true), + Id = account.Result.Id, + Key = account.Result.Key + }; } - var cache = new RegistrationCache { - Location = account.Result.Location, - AccountKey = accountKey.ExportCspBlob(true), - Id = account.Result.Id, - Key = account.Result.Key - }; + return IDomainResult.Success(); + } + catch (Exception ex) { + var message = "Let's Encrypt client unhandled exception"; + _logger.LogError(ex, message); + return IDomainResult.CriticalDependencyError(message); + } - return IDomainResult.Success(cache); + } + + #endregion + + public RegistrationCache? GetRegistrationCache(Guid sessionId) { + var state = GetOrCreateState(sessionId); + return state.Cache; + } + + #region GetTermsOfService + public (string?, IDomainResult) GetTermsOfServiceUri(Guid sessionId) { + try { + var state = GetOrCreateState(sessionId); + + _logger.LogInformation($"Executing {nameof(GetTermsOfServiceUri)}..."); + + if (state.Directory == null) { + return IDomainResult.Failed(); + } + + return IDomainResult.Success(state.Directory.Meta.TermsOfService); } catch (Exception ex) { var message = "Let's Encrypt client unhandled exception"; _logger.LogError(ex, message); - return IDomainResult.CriticalDependencyError(message); + return IDomainResult.CriticalDependencyError(message); } } + #endregion - /// - /// Create new Certificate Order. In case you want the wildcard-certificate you must select dns-01 challange. - /// - /// Available challange types: - /// - /// dns-01 - /// http-01 - /// tls-alpn-01 - /// - /// - /// - /// - /// - /// - /// - public async Task<((Order?, Dictionary?, List?), IDomainResult)> NewOrder(Uri newOrder, Uri newNonce, byte[] accountKeyBytes, string location, string[] hostnames, string challengeType) { + #region NewOrder + public async Task<(Dictionary?, IDomainResult)> NewOrder(Guid sessionId, string[] hostnames, string challengeType) { try { - - var accountKey = new RSACryptoServiceProvider(4096); - accountKey.ImportCspBlob(accountKeyBytes); - - var jwsService = new JwsService(accountKey); + var state = GetOrCreateState(sessionId); _logger.LogInformation($"Executing {nameof(NewOrder)}..."); - var currentOrder = default(Order); - var results = new Dictionary(); - var challenges = new List(); + state.Challenges.Clear(); var letsEncryptOrder = new Order { Expires = DateTime.UtcNow.AddDays(2), @@ -157,132 +182,97 @@ public class LetsEncryptService : ILetsEncryptService { }).ToArray() }; - var (order, postNewOrderResult) = await SendAsync(HttpMethod.Post, newOrder, false, letsEncryptOrder, accountKey, location, newNonce); + var (order, postNewOrderResult) = await SendAsync(sessionId, HttpMethod.Post, state.Directory.NewOrder, false, letsEncryptOrder); if (!postNewOrderResult.IsSuccess) { - return ((null, null, null), postNewOrderResult); + return (null, postNewOrderResult); } if (order.Result.Status == "ready") - return IDomainResult.Success((currentOrder, results, challenges)); + return IDomainResult.Success(new Dictionary()); if (order.Result.Status != "pending") { _logger.LogError($"Created new order and expected status 'pending', but got: {order.Result.Status} \r\n {order.Result}"); - return IDomainResult.Failed<(Order?, Dictionary?, List?)>(); + return IDomainResult.Failed?>(); } - currentOrder = order.Result; + state.CurrentOrder = order.Result; - - foreach (var item in currentOrder.Authorizations) { - - var (challengeResponse, postAuthorisationChallengeResult) = await SendAsync(HttpMethod.Post, item, true, null, accountKey, location, newNonce); + var results = new Dictionary(); + foreach (var item in order.Result.Authorizations) { + var (challengeResponse, postAuthorisationChallengeResult) = await SendAsync(sessionId, HttpMethod.Post, item, true, null); if (!postAuthorisationChallengeResult.IsSuccess) { - return ((null, null, null), postAuthorisationChallengeResult); + return (null, postAuthorisationChallengeResult); } if (challengeResponse.Result.Status == "valid") continue; if (challengeResponse.Result.Status != "pending") { - _logger.LogError($"Expected autorization status 'pending', but got: {currentOrder.Status} \r\n {challengeResponse.ResponseText}"); - return IDomainResult.Failed<(Order?, Dictionary?, List?)>(); + _logger.LogError($"Expected authorization status 'pending', but got: {order.Result.Status} \r\n {challengeResponse.ResponseText}"); + return IDomainResult.Failed?>(); } var challenge = challengeResponse.Result.Challenges.First(x => x.Type == challengeType); - challenges.Add(challenge); + state.Challenges.Add(challenge); - var keyToken = jwsService.GetKeyAuthorization(challenge.Token); + var keyToken = state.JwsService.GetKeyAuthorization(challenge.Token); switch (challengeType) { - - // A client fulfills this challenge by constructing a key authorization - // from the "token" value provided in the challenge and the client's - // account key. The client then computes the SHA-256 digest [FIPS180-4] - // of the key authorization. - // - // The record provisioned to the DNS contains the base64url encoding of - // this digest. - - case "dns-01": { - using (var sha256 = SHA256.Create()) { - var dnsToken = jwsService.Base64UrlEncoded(sha256.ComputeHash(Encoding.UTF8.GetBytes(keyToken))); - results[challengeResponse.Result.Identifier.Value] = dnsToken; - } - break; + case "dns-01": + using (var sha256 = SHA256.Create()) { + var dnsToken = state.JwsService.Base64UrlEncoded(sha256.ComputeHash(Encoding.UTF8.GetBytes(keyToken))); + results[challengeResponse.Result.Identifier.Value] = dnsToken; } + break; - - // A client fulfills this challenge by constructing a key authorization - // from the "token" value provided in the challenge and the client's - // account key. The client then provisions the key authorization as a - // resource on the HTTP server for the domain in question. - // - // The path at which the resource is provisioned is comprised of the - // fixed prefix "/.well-known/acme-challenge/", followed by the "token" - // value in the challenge. The value of the resource MUST be the ASCII - // representation of the key authorization. - - case "http-01": { - results[challengeResponse.Result.Identifier.Value] = keyToken; - break; - } + case "http-01": + results[challengeResponse.Result.Identifier.Value] = keyToken; + break; default: throw new NotImplementedException(); } } - // TODO: reurn challenges - return IDomainResult.Success((currentOrder, results, challenges)); + return IDomainResult.Success(results); } catch (Exception ex) { var message = "Let's Encrypt client unhandled exception"; _logger.LogError(ex, message); - return IDomainResult.CriticalDependencyError<(Order?, Dictionary?, List?)>(message); + return IDomainResult.CriticalDependencyError?>(message); } } + #endregion - /// - /// - /// - /// - /// - public async Task CompleteChallenges(Uri newNonce, byte[] accountKeyBytes, string location, Order currentOrder, List challenges) { + #region CompleteChallenges + public async Task CompleteChallenges(Guid sessionId) { try { - - var accountKey = new RSACryptoServiceProvider(4096); - accountKey.ImportCspBlob(accountKeyBytes); - var jwsService = new JwsService(accountKey); + var state = GetOrCreateState(sessionId); _logger.LogInformation($"Executing {nameof(CompleteChallenges)}..."); - if (currentOrder?.Identifiers == null) { + if (state.CurrentOrder?.Identifiers == null) { return IDomainResult.Failed(); } - for (var index = 0; index < challenges.Count; index++) { - - var challenge = challenges[index]; - + for (var index = 0; index < state.Challenges.Count; index++) { + var challenge = state.Challenges[index]; var start = DateTime.UtcNow; while (true) { var authorizeChallenge = new AuthorizeChallenge(); switch (challenge.Type) { - case "dns-01": { - authorizeChallenge.KeyAuthorization = jwsService.GetKeyAuthorization(challenge.Token); - //var (result, responseText) = await SendAsync(HttpMethod.Post, challenge.Url, authorizeChallenge, token); - break; - } + case "dns-01": + authorizeChallenge.KeyAuthorization = state.JwsService.GetKeyAuthorization(challenge.Token); + break; - case "http-01": { - break; - } + case "http-01": + break; } - var (authChallenge, postAuthChallengeResult) = await SendAsync(HttpMethod.Post, challenge.Url, false, "{}", accountKey, location, newNonce); + var (authChallenge, postAuthChallengeResult) = await SendAsync(sessionId, HttpMethod.Post, challenge.Url, false, "{}"); if (!postAuthChallengeResult.IsSuccess) { return postAuthChallengeResult; } @@ -291,7 +281,7 @@ public class LetsEncryptService : ILetsEncryptService { break; if (authChallenge.Result.Status != "pending") { - _logger.LogError($"Failed autorization of {currentOrder.Identifiers[index].Value} \r\n {authChallenge.ResponseText}"); + _logger.LogError($"Challenge failed with status {authChallenge.Result.Status} \r\n {authChallenge.ResponseText}"); return IDomainResult.Failed(); } @@ -311,20 +301,15 @@ public class LetsEncryptService : ILetsEncryptService { return IDomainResult.CriticalDependencyError(message); } } + #endregion - /// - /// - /// - /// - /// - public async Task<(Order?, IDomainResult)> GetOrder(Uri newOrder, Uri newNonce, byte[] accountKeyBytes, string location, string[] hostnames) { - + #region GetOrder + public async Task GetOrder(Guid sessionId, string[] hostnames) { try { - var accountKey = new RSACryptoServiceProvider(4096); - accountKey.ImportCspBlob(accountKeyBytes); - _logger.LogInformation($"Executing {nameof(GetOrder)}"); + var state = GetOrCreateState(sessionId); + var letsEncryptOrder = new Order { Expires = DateTime.UtcNow.AddDays(2), Identifiers = hostnames.Select(hostname => new OrderIdentifier { @@ -333,154 +318,281 @@ public class LetsEncryptService : ILetsEncryptService { }).ToArray() }; - var (order, postOrderResult) = await SendAsync(HttpMethod.Post, newOrder, false, letsEncryptOrder, accountKey, location, newNonce); + var (order, postOrderResult) = await SendAsync(sessionId, HttpMethod.Post, state.Directory.NewOrder, false, letsEncryptOrder); if (!postOrderResult.IsSuccess) - return (null, postOrderResult); + return postOrderResult; - var currentOrder = order.Result; + state.CurrentOrder = order.Result; - return IDomainResult.Success(currentOrder); + return IDomainResult.Success(); } catch (Exception ex) { var message = "Let's Encrypt client unhandled exception"; _logger.LogError(ex, message); - return IDomainResult.CriticalDependencyError(message); + return IDomainResult.CriticalDependencyError(message); } } + #endregion - /// - /// - /// - /// - /// Cert and Private key - /// - public async Task<(Dictionary?, IDomainResult)> GetCertificate(Uri newOrder, Uri newNonce, byte[] accountKeyBytes, Order currentOrder, string location, string [] subjects) { - + #region GetCertificates + public async Task GetCertificate(Guid sessionId, string subject) { try { - - var accountKey = new RSACryptoServiceProvider(4096); - accountKey.ImportCspBlob(accountKeyBytes); - - var jwsService = new JwsService(accountKey); - + var state = GetOrCreateState(sessionId); _logger.LogInformation($"Executing {nameof(GetCertificate)}..."); - var cachedCerts = new Dictionary(); - - - foreach (var subject in subjects) { - - - if (currentOrder == null) { - return IDomainResult.Failed>(); - } - - var key = new RSACryptoServiceProvider(4096); - var csr = new CertificateRequest("CN=" + subject, - key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - - var san = new SubjectAlternativeNameBuilder(); - foreach (var host in currentOrder.Identifiers) - san.AddDnsName(host.Value); - - csr.CertificateExtensions.Add(san.Build()); - - var letsEncryptOrder = new FinalizeRequest { - Csr = jwsService.Base64UrlEncoded(csr.CreateSigningRequest()) - }; - - Uri? certificateUrl = default; - - - var start = DateTime.UtcNow; - - while (certificateUrl == null) { - // https://community.letsencrypt.org/t/breaking-changes-in-asynchronous-order-finalization-api/195882 - await GetOrder(newOrder, newNonce, accountKeyBytes, location, currentOrder.Identifiers.Select(x => x.Value).ToArray()); - - if (currentOrder.Status == "ready") { - var (order, postOrderResult) = await SendAsync(HttpMethod.Post, currentOrder.Finalize, false, letsEncryptOrder, accountKey, location, newNonce); - if (!postOrderResult.IsSuccess || order?.Result == null) - return (null, postOrderResult); - - - if (order.Result.Status == "processing") { - (order, postOrderResult) = await SendAsync(HttpMethod.Post, currentOrder.Location, true, null, accountKey, location, newNonce); - if (!postOrderResult.IsSuccess || order?.Result == null) - return (null, postOrderResult); - } - - if (order.Result.Status == "valid") { - certificateUrl = order.Result.Certificate; - } - } - - if ((DateTime.UtcNow - start).Seconds > 120) - throw new TimeoutException(); - - await Task.Delay(1000); - } - - var (pem, postPemResult) = await SendAsync(HttpMethod.Post, certificateUrl, true, null, accountKey, location, newNonce); - if (!postPemResult.IsSuccess || pem?.Result == null) - return (null, postPemResult); - - - - cachedCerts.Add(subject, new CertificateCache { - Cert = pem.Result, - Private = key.ExportCspBlob(true) - }); - - //var cert = new X509Certificate2(Encoding.UTF8.GetBytes(pem.Result)); - + if (state.CurrentOrder == null) { + return IDomainResult.Failed(); } - return IDomainResult.Success(cachedCerts); + var key = new RSACryptoServiceProvider(4096); + var csr = new CertificateRequest("CN=" + subject, + key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + var san = new SubjectAlternativeNameBuilder(); + foreach (var host in state.CurrentOrder.Identifiers) + san.AddDnsName(host.Value); + + csr.CertificateExtensions.Add(san.Build()); + + var letsEncryptOrder = new FinalizeRequest { + Csr = state.JwsService.Base64UrlEncoded(csr.CreateSigningRequest()) + }; + + Uri? certificateUrl = default; + + var start = DateTime.UtcNow; + + while (certificateUrl == null) { + // https://community.letsencrypt.org/t/breaking-changes-in-asynchronous-order-finalization-api/195882 + await GetOrder(sessionId, state.CurrentOrder.Identifiers.Select(x => x.Value).ToArray()); + + if (state.CurrentOrder.Status == "ready") { + var (order, postOrderResult) = await SendAsync(sessionId, HttpMethod.Post, state.CurrentOrder.Finalize, false, letsEncryptOrder); + if (!postOrderResult.IsSuccess || order?.Result == null) + return postOrderResult; + + if (order.Result.Status == "processing") { + (order, postOrderResult) = await SendAsync(sessionId, HttpMethod.Post, state.CurrentOrder.Location, true, null); + if (!postOrderResult.IsSuccess || order?.Result == null) + return postOrderResult; + } + + if (order.Result.Status == "valid") { + certificateUrl = order.Result.Certificate; + } + } + + if ((DateTime.UtcNow - start).Seconds > 120) + throw new TimeoutException(); + + await Task.Delay(1000); + } + + var (pem, postPemResult) = await SendAsync(sessionId, HttpMethod.Post, certificateUrl, true, null); + if (!postPemResult.IsSuccess || pem?.Result == null) + return postPemResult; + + if (state.Cache == null) { + _logger.LogError($"{nameof(state.Cache)} is null"); + return IDomainResult.Failed(); + } + + state.Cache.CachedCerts ??= new Dictionary(); + state.Cache.CachedCerts[subject] = new CertificateCache { + Cert = pem.Result, + Private = key.ExportCspBlob(true) + }; + + var cert = new X509Certificate2(Encoding.UTF8.GetBytes(pem.Result)); + + return IDomainResult.Success(); } catch (Exception ex) { var message = "Let's Encrypt client unhandled exception"; _logger.LogError(ex, message); - return IDomainResult.CriticalDependencyError?>(message); + return IDomainResult.CriticalDependencyError(message); + } + } + #endregion + + #region TryGetCachedCertificate + public (CachedCertificateResult?, IDomainResult) TryGetCachedCertificate(Guid sessionId, string subject) { + + var state = GetOrCreateState(sessionId); + + var certRes = new CachedCertificateResult(); + if (state.Cache != null && state.Cache.TryGetCachedCertificate(subject, out certRes)) { + return IDomainResult.Success(certRes); + } + + return IDomainResult.Failed(); + } + #endregion + + + public Task KeyChange(Guid sessionId) { + throw new NotImplementedException(); + } + + public Task RevokeCertificate(Guid sessionId) { + throw new NotImplementedException(); + } + + #region SendAsync + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + //private async Task<(SendResult?, IDomainResult)> SendAsync(Guid sessionId, HttpMethod method, Uri uri, bool isPostAsGet, object? requestModel) { + // try { + // var state = GetOrCreateState(sessionId); + + // _logger.LogInformation($"Executing {nameof(SendAsync)}..."); + + // var request = new HttpRequestMessage(method, uri); + + // if (uri.OriginalString != "directory") { + // var (nonce, newNonceResult) = await NewNonce(sessionId); + // if (!newNonceResult.IsSuccess || nonce == null) { + // return (null, newNonceResult); + // } + + // state.Nonce = nonce; + // } + // else { + // state.Nonce = default; + // } + + // if (requestModel != null || isPostAsGet) { + // var jwsHeader = new JwsHeader { + // Url = uri, + // }; + + // if (state.Nonce != null) + // jwsHeader.Nonce = state.Nonce; + + // var encodedMessage = isPostAsGet + // ? state.JwsService.Encode(jwsHeader) + // : state.JwsService.Encode(requestModel, jwsHeader); + + // var json = encodedMessage.ToJson(); + + // request.Content = new StringContent(json); + + // var requestType = "application/json"; + // if (method == HttpMethod.Post) + // requestType = "application/jose+json"; + + // request.Content.Headers.Remove("Content-Type"); + // request.Content.Headers.Add("Content-Type", requestType); + // } + + // var response = await _httpClient.SendAsync(request); + + // if (method == HttpMethod.Post) + // state.Nonce = response.Headers.GetValues("Replay-Nonce").First(); + + // var responseText = await response.Content.ReadAsStringAsync(); + + // if (response.Content.Headers.ContentType?.MediaType == "application/problem+json") + // throw new LetsEncrytException(responseText.ToObject(), response); + + // if (response.Content.Headers.ContentType?.MediaType == "application/pem-certificate-chain" && typeof(TResult) == typeof(string)) { + // return IDomainResult.Success(new SendResult { + // Result = (TResult)(object)responseText + // }); + // } + + // var responseContent = responseText.ToObject(); + + // if (responseContent is IHasLocation ihl) { + // if (response.Headers.Location != null) + // ihl.Location = response.Headers.Location; + // } + + // return IDomainResult.Success(new SendResult { + // Result = responseContent, + // ResponseText = responseText + // }); + + // } + // catch (Exception ex) { + // var message = "Let's Encrypt client unhandled exception"; + + // _logger.LogError(ex, message); + // return IDomainResult.CriticalDependencyError?>(message); + // } + //} + + private async Task<(SendResult?, IDomainResult)> SendAsync( + Guid sessionId, + HttpMethod method, + Uri uri, + bool isPostAsGet, + object? requestModel + ) { + try { + var state = GetOrCreateState(sessionId); + _logger.LogInformation($"Executing {nameof(SendAsync)}..."); + + var request = new HttpRequestMessage(method, uri); + await HandleNonceAsync(sessionId, uri, state); + + if (requestModel != null || isPostAsGet) { + var jwsHeader = CreateJwsHeader(uri, state.Nonce); + var json = EncodeMessage(isPostAsGet, requestModel, state, jwsHeader); + PrepareRequestContent(request, json, method); + } + + var response = await _httpClient.SendAsync(request); + await UpdateStateNonceIfNeededAsync(response, state, method); + + var responseText = await response.Content.ReadAsStringAsync(); + await HandleProblemResponseAsync(response, responseText); + + var result = ProcessResponseContent(response, responseText); + return IDomainResult.Success(result); + } + catch (Exception ex) { + const string message = "Let's Encrypt client unhandled exception"; + _logger.LogError(ex, message); + return IDomainResult.CriticalDependencyError?>(message); } } - /// - /// - /// - /// - /// - public Task KeyChange() { - throw new NotImplementedException(); + private async Task HandleNonceAsync(Guid sessionId, Uri uri, State state) { + if (uri.OriginalString != "directory") { + var (nonce, newNonceResult) = await NewNonce(sessionId); + if (!newNonceResult.IsSuccess || nonce == null) { + throw new InvalidOperationException("Failed to retrieve nonce."); + } + state.Nonce = nonce; + } + else { + state.Nonce = default; + } } - /// - /// - /// - /// - /// - public Task RevokeCertificate() { - throw new NotImplementedException(); - } - - - /// - /// Request New Nonce to be able to start POST requests - /// - /// - /// - private async Task<(string?, IDomainResult)> NewNonce(Uri newNonce) { - + private async Task<(string?, IDomainResult)> NewNonce(Guid sessionId) { try { + var state = GetOrCreateState(sessionId); _logger.LogInformation($"Executing {nameof(NewNonce)}..."); - var result = await _httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Head, newNonce)); - return IDomainResult.Success(result.Headers.GetValues("Replay-Nonce").First()); + if (state.Directory == null) + IDomainResult.Failed(); + var result = await _httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Head, state.Directory.NewNonce)); + return IDomainResult.Success(result.Headers.GetValues("Replay-Nonce").First()); } catch (Exception ex) { var message = "Let's Encrypt client unhandled exception"; @@ -490,98 +602,62 @@ public class LetsEncryptService : ILetsEncryptService { } } - /// - /// Main method used to send data to LetsEncrypt - /// - /// - /// - /// - /// - /// - /// - private async Task<(SendResult?, IDomainResult)> SendAsync(HttpMethod method, Uri uri, bool isPostAsGet, object? requestModel, RSACryptoServiceProvider? accountKey, string? location, Uri? newNonce) { - try { - var _nonce = default(string?); + private JwsHeader CreateJwsHeader(Uri uri, string? nonce) { + return new JwsHeader { + Url = uri, + Nonce = nonce + }; + } - _logger.LogInformation($"Executing {nameof(SendAsync)}..."); + private string EncodeMessage(bool isPostAsGet, object? requestModel, State state, JwsHeader jwsHeader) { + return isPostAsGet + ? state.JwsService.Encode(jwsHeader).ToJson() + : state.JwsService.Encode(requestModel, jwsHeader).ToJson(); + } - var request = new HttpRequestMessage(method, uri); + private void PrepareRequestContent(HttpRequestMessage request, string json, HttpMethod method) { + request.Content = new StringContent(json); + var contentType = method == HttpMethod.Post ? "application/jose+json" : "application/json"; + request.Content.Headers.ContentType = new MediaTypeHeaderValue(contentType); + } - if (uri.OriginalString != "directory") { - var (nonce, newNonceResult) = await NewNonce(newNonce); - if (!newNonceResult.IsSuccess || nonce == null) { - return (null, newNonceResult); - } - - _nonce = nonce; - } - - if (requestModel != null || isPostAsGet) { - - if (accountKey == null) - return IDomainResult.Failed?>(); - - var jwsService = new JwsService(accountKey); - if(location != null) - jwsService.SetKeyId(location); - - var jwsHeader = new JwsHeader { - Url = uri, - }; - - if (_nonce != null) - jwsHeader.Nonce = _nonce; - - var encodedMessage = isPostAsGet - ? jwsService.Encode(jwsHeader) - : jwsService.Encode(requestModel, jwsHeader); - - var json = encodedMessage.ToJson(); - - request.Content = new StringContent(json); - - var requestType = "application/json"; - if (method == HttpMethod.Post) - requestType = "application/jose+json"; - - request.Content.Headers.Remove("Content-Type"); - request.Content.Headers.Add("Content-Type", requestType); - } - - var response = await _httpClient.SendAsync(request); - - if (method == HttpMethod.Post) - _nonce = response.Headers.GetValues("Replay-Nonce").First(); - - var responseText = await response.Content.ReadAsStringAsync(); - - if (response.Content.Headers.ContentType?.MediaType == "application/problem+json") - throw new LetsEncrytException(responseText.ToObject(), response); - - if (response.Content.Headers.ContentType?.MediaType == "application/pem-certificate-chain" && typeof(TResult) == typeof(string)) { - return IDomainResult.Success(new SendResult { - Result = (TResult)(object)responseText - }); - } - - var responseContent = responseText.ToObject(); - - if (responseContent is IHasLocation ihl) { - if (response.Headers.Location != null) - ihl.Location = response.Headers.Location; - } - - return IDomainResult.Success(new SendResult { - Result = responseContent, - ResponseText = responseText - }); - - } - catch (Exception ex) { - var message = "Let's Encrypt client unhandled exception"; - - _logger.LogError(ex, message); - return IDomainResult.CriticalDependencyError?>(message); + private async Task UpdateStateNonceIfNeededAsync(HttpResponseMessage response, State state, HttpMethod method) { + if (method == HttpMethod.Post && response.Headers.Contains("Replay-Nonce")) { + state.Nonce = response.Headers.GetValues("Replay-Nonce").First(); } } + + private async Task HandleProblemResponseAsync(HttpResponseMessage response, string responseText) { + if (response.Content.Headers.ContentType?.MediaType == "application/problem+json") { + throw new LetsEncrytException(responseText.ToObject(), response); + } + } + + private SendResult ProcessResponseContent(HttpResponseMessage response, string responseText) { + if (response.Content.Headers.ContentType?.MediaType == "application/pem-certificate-chain" && typeof(TResult) == typeof(string)) { + return new SendResult { + Result = (TResult)(object)responseText + }; + } + + var responseContent = responseText.ToObject(); + if (responseContent is IHasLocation ihl && response.Headers.Location != null) { + ihl.Location = response.Headers.Location; + } + + return new SendResult { + Result = responseContent, + ResponseText = responseText + }; + } + #endregion + + private class State { + public AcmeDirectory? Directory { get; set; } + public JwsService? JwsService { get; set; } + public Order? CurrentOrder { get; set; } + public List Challenges { get; } = new List(); + public string? Nonce { get; set; } + public RegistrationCache? Cache { get; set; } + } } diff --git a/src/LetsEncryptServer/Configuration.cs b/src/LetsEncryptServer/Configuration.cs index 675188a..86ac89a 100644 --- a/src/LetsEncryptServer/Configuration.cs +++ b/src/LetsEncryptServer/Configuration.cs @@ -1,36 +1,21 @@ -namespace LetsEncryptServer { +namespace MaksIT.LetsEncryptServer { - public class Site { - public required string Name { get; set; } - public required string[] Hosts { get; set; } - public required string Challenge { get; set; } + public class SSHClientConfing { + public required string User { get; set; } + public required string Key { get; set; } } - public class Customer { - private string? _id; - public string Id { - get => _id ?? string.Empty; - set => _id = value; - } - - public bool Active { get; set; } - public string[]? Contacts { get; set; } - public string? Name { get; set; } - public string? LastName { get; set; } - public Site[]? Sites { get; set; } - } - - public class Server { - public required string Address { get; set; } - public required string PrivateKey { get; set; } - public required string Path { get; set; } + public class Server { + public required string Ip { get; set; } + public required int Port { get; set; } + public string Path { get; set; } + public required SSHClientConfing SSH { get; set; } } public class Configuration { public required string Production { get; set; } public required string Staging { get; set; } + public required bool DevMode { get; set; } public required Server Server { get; set; } - - public Customer[]? Customers { get; set; } } } diff --git a/src/LetsEncryptServer/Controllers/CertsFlowController.cs b/src/LetsEncryptServer/Controllers/CertsFlowController.cs index 4b29a30..222bda6 100644 --- a/src/LetsEncryptServer/Controllers/CertsFlowController.cs +++ b/src/LetsEncryptServer/Controllers/CertsFlowController.cs @@ -1,225 +1,115 @@ -using DomainResults.Mvc; -using MaksIT.LetsEncrypt.Entities; -using MaksIT.LetsEncrypt.Models.Responses; -using MaksIT.LetsEncrypt.Services; -using MaksIT.LetsEncryptServer.Models.Requests; -using Microsoft.AspNetCore.Identity.Data; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; -using System.IO; -using System.Security.Cryptography.X509Certificates; -using System.Text; -namespace LetsEncryptServer.Controllers; +using DomainResults.Mvc; -public class LetsEncryptSession { - public RegistrationCache? RegistrationCache { get; set; } - public Order? CurrentOrder { get; set; } - public List? Challenges { get; set; } - public string[] Hostnames { get; set; } -} +using MaksIT.LetsEncryptServer.Models.Requests; +using MaksIT.LetsEncryptServer.Services; + + +namespace MaksIT.LetsEncryptServer.Controllers; [ApiController] [Route("[controller]")] public class CertsFlowController : ControllerBase { - private readonly Configuration _appSettings; - private readonly IMemoryCache _memoryCache; - private readonly ILetsEncryptService _letsEncryptService; - - private readonly string _acmePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "acme"); - private readonly string _certPath = Path.Combine(); - - MemoryCacheEntryOptions _cacheEntryOptions = new MemoryCacheEntryOptions { - AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5), - SlidingExpiration = TimeSpan.FromMinutes(2) - }; + private readonly IOptions _appSettings; + private readonly ICertsFlowService _certsFlowService; public CertsFlowController( IOptions appSettings, - IMemoryCache memoryCache, - ILetsEncryptService letsEncryptService + ICertsFlowService certsFlowService ) { - _memoryCache = memoryCache; - _appSettings = appSettings.Value; - _letsEncryptService = letsEncryptService; - - if (!Directory.Exists(_acmePath)) - Directory.CreateDirectory(_acmePath); - - Console.WriteLine(_acmePath); + _appSettings = appSettings; + _certsFlowService = certsFlowService; } - [HttpGet("[action]")] - public async Task TermsOfService() { - var (config, configResult) = await _letsEncryptService.ConfigureClient("https://acme-staging-v02.api.letsencrypt.org/directory"); - - if (!configResult.IsSuccess || config == null) - return configResult.ToActionResult(); - - return Ok(config.Meta.TermsOfService); - } - - + /// + /// Initialize certificate flow session + /// + /// sessionId [HttpPost("[action]")] - public async Task Init([FromBody] InitRequest requestData) { - - var (config, configResult) = await _letsEncryptService.ConfigureClient("https://acme-staging-v02.api.letsencrypt.org/directory"); - if (!configResult.IsSuccess || config == null) - return configResult.ToActionResult(); - - var (cache, cacheResult) = await _letsEncryptService.Init(config.NewAccount, config.NewNonce, requestData.Contacts); - if(!cacheResult.IsSuccess || cache == null) - return cacheResult.ToActionResult(); - - var cacheData = new LetsEncryptSession { - RegistrationCache = cache, - }; - - var accountId = Guid.NewGuid().ToString(); - - _memoryCache.Set(accountId, cacheData, _cacheEntryOptions); - - return Ok(accountId); + public async Task ConfigureClient() { + var result = await _certsFlowService.ConfigureClientAsync(); + return result.ToActionResult(); } - [HttpPost("[action]/{accountId}")] - public async Task NewOrder(string accountId, [FromBody] NewOrderRequest requestData) { - - var cacheData = (LetsEncryptSession?)_memoryCache.Get(accountId); - if (cacheData?.RegistrationCache?.AccountKey == null) - return BadRequest(); - - var (config, configResult) = await _letsEncryptService.ConfigureClient("https://acme-staging-v02.api.letsencrypt.org/directory"); - if (!configResult.IsSuccess || config == null) - return configResult.ToActionResult(); - - - var (orderData, newOrderResult) = await _letsEncryptService.NewOrder( - config.NewOrder, - config.NewNonce, - cacheData.RegistrationCache.AccountKey, - cacheData.RegistrationCache.Location.ToString(), - requestData.Hostnames, - requestData.ChallengeType); - - if (!newOrderResult.IsSuccess) - return newOrderResult.ToActionResult(); - - var(currentOrder, results, challenges) = orderData; - - if (results?.Count == 0) - return StatusCode(500); - - // TODO: save results to disk - var fullPaths = new List(); - foreach (var result in results) { - string[] splitToken = result.Value.Split('.'); - - System.IO.File.WriteAllText(Path.Combine(_acmePath, splitToken[0]), result.Value); - - fullPaths.Add(splitToken[0]); - } - - cacheData.CurrentOrder = currentOrder; - cacheData.Challenges = challenges; - cacheData.Hostnames = requestData.Hostnames; - - _memoryCache.Set(accountId, cacheData, _cacheEntryOptions); - - return Ok(fullPaths); + [HttpGet("[action]/{sessionId}")] + public IActionResult TermsOfService(Guid sessionId) { + var result = _certsFlowService.GetTermsOfService(sessionId); + return result.ToActionResult(); } - [HttpPut("[action]/{accountId}")] - public async Task CompleteChallenges(string accountId) { - - var cacheData = (LetsEncryptSession?)_memoryCache.Get(accountId); - if (cacheData?.RegistrationCache?.AccountKey == null) - return BadRequest(); - - var (config, configResult) = await _letsEncryptService.ConfigureClient("https://acme-staging-v02.api.letsencrypt.org/directory"); - if (!configResult.IsSuccess || config == null) - return configResult.ToActionResult(); - - var challengeResult = await _letsEncryptService.CompleteChallenges( - config.NewNonce, - cacheData.RegistrationCache.AccountKey, - cacheData.RegistrationCache.Location.ToString(), - cacheData.CurrentOrder, - cacheData.Challenges - ); - - if (!challengeResult.IsSuccess) - return challengeResult.ToActionResult(); - - return Ok(); + /// + /// When new certificate session is created, create or retrieve cache data by accountId + /// + /// + /// + /// + /// accountId + [HttpPost("[action]/{sessionId}/{accountId?}")] + public async Task Init(Guid sessionId, Guid? accountId, [FromBody] InitRequest requestData) { + var resurt = await _certsFlowService.InitAsync(sessionId, accountId, requestData); + return resurt.ToActionResult(); } - [HttpGet("[action]/{accountId}")] - public async Task GetOrder(string accountId) { - - var cacheData = (LetsEncryptSession?)_memoryCache.Get(accountId); - if (cacheData?.RegistrationCache?.AccountKey == null) - return BadRequest(); - - var (config, configResult) = await _letsEncryptService.ConfigureClient("https://acme-staging-v02.api.letsencrypt.org/directory"); - if (!configResult.IsSuccess || config == null) - return configResult.ToActionResult(); - - - var (currentOrder, currentOrderResult) = await _letsEncryptService.GetOrder( - config.NewOrder, - config.NewNonce, - cacheData.RegistrationCache.AccountKey, - cacheData.RegistrationCache.Location.ToString(), - cacheData.Hostnames - ); - - if(!currentOrderResult.IsSuccess) - return currentOrderResult.ToActionResult(); - - cacheData.CurrentOrder = currentOrder; - - _memoryCache.Set(accountId, cacheData, _cacheEntryOptions); - - return Ok(); + /// + /// After account initialization create new order request + /// + /// + /// + /// + [HttpPost("[action]/{sessionId}")] + public async Task NewOrder(Guid sessionId, [FromBody] NewOrderRequest requestData) { + var result = await _certsFlowService.NewOrderAsync(sessionId, requestData); + return result.ToActionResult(); } - [HttpPost("[action]/{accountId}")] - public async Task GetCertificate(string accountId) { - - var cacheData = (LetsEncryptSession?)_memoryCache.Get(accountId); - if (cacheData?.RegistrationCache?.AccountKey == null) - return BadRequest(); - - var (config, configResult) = await _letsEncryptService.ConfigureClient("https://acme-staging-v02.api.letsencrypt.org/directory"); - if (!configResult.IsSuccess || config == null) - return configResult.ToActionResult(); - - var (cachedCerts, certsResult) = await _letsEncryptService.GetCertificate( - config.NewOrder, - config.NewNonce, - cacheData.RegistrationCache.AccountKey, - cacheData.CurrentOrder, - cacheData.RegistrationCache.Location.ToString(), - cacheData.Hostnames - ); - - if (!certsResult.IsSuccess || cachedCerts == null) - return certsResult.ToActionResult(); - - // TODO: write certs to filesystem - foreach (var (subject, cachedCert) in cachedCerts) { - var cert = new X509Certificate2(Encoding.UTF8.GetBytes(cachedCert.Cert)); - } - - if (!certsResult.IsSuccess) - return BadRequest(); - - return Ok(); + /// + /// After new order request complete challenges + /// + /// + /// + [HttpPost("[action]/{sessionId}")] + public async Task CompleteChallenges(Guid sessionId) { + var result = await _certsFlowService.CompleteChallengesAsync(sessionId); + return result.ToActionResult(); } + /// + /// Get order status before certs retrieval + /// + /// + /// + /// + [HttpPost("[action]/{sessionId}")] + public async Task GetOrder(Guid sessionId, [FromBody] GetOrderRequest requestData) { + var result = await _certsFlowService.GetOrderAsync(sessionId, requestData); + return result.ToActionResult(); + } + /// + /// Download certs to local cache + /// + /// + /// + /// + [HttpPost("[action]/{sessionId}")] + public async Task GetCertificates(Guid sessionId, [FromBody] GetCertificatesRequest requestData) { + var result = await _certsFlowService.GetCertificatesAsync(sessionId, requestData); + return result.ToActionResult(); + } + + /// + /// Apply certs from local cache to remote server + /// + /// + /// + /// + [HttpPost("[action]/{sessionId}")] + public IActionResult ApplyCertificates(Guid sessionId, [FromBody] GetCertificatesRequest requestData) { + var result = _certsFlowService.ApplyCertificates(sessionId, requestData); + return result.ToActionResult(); + } } diff --git a/src/LetsEncryptServer/Controllers/WellKnownController.cs b/src/LetsEncryptServer/Controllers/WellKnownController.cs index 794105c..36f1710 100644 --- a/src/LetsEncryptServer/Controllers/WellKnownController.cs +++ b/src/LetsEncryptServer/Controllers/WellKnownController.cs @@ -1,24 +1,28 @@ -using MaksIT.LetsEncrypt.Services; -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; -namespace LetsEncryptServer.Controllers; +using DomainResults.Mvc; + +using MaksIT.LetsEncryptServer.Services; + + +namespace MaksIT.LetsEncryptServer.Controllers; [ApiController] [Route(".well-known")] public class WellKnownController : ControllerBase { private readonly Configuration _appSettings; - private readonly ILetsEncryptService _letsEncryptService; + private readonly ICertsFlowServiceBase _certsFlowService; private readonly string _acmePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "acme"); public WellKnownController( IOptions appSettings, - ILetsEncryptService letsEncryptService + ICertsFlowService certsFlowService ) { _appSettings = appSettings.Value; - _letsEncryptService = letsEncryptService; + _certsFlowService = certsFlowService; if (!Directory.Exists(_acmePath)) Directory.CreateDirectory(_acmePath); @@ -27,12 +31,8 @@ public class WellKnownController : ControllerBase { [HttpGet("acme-challenge/{fileName}")] public IActionResult AcmeChallenge(string fileName) { - - var fileContent = System.IO.File.ReadAllText(Path.Combine(_acmePath, fileName)); - if (fileContent == null) - return NotFound(); - - return Ok(fileContent); + var result = _certsFlowService.AcmeChallenge(fileName); + return result.ToActionResult(); } } diff --git a/src/LetsEncryptServer/Models/Requests/GetCerificatesRequest.cs b/src/LetsEncryptServer/Models/Requests/GetCerificatesRequest.cs new file mode 100644 index 0000000..a93b758 --- /dev/null +++ b/src/LetsEncryptServer/Models/Requests/GetCerificatesRequest.cs @@ -0,0 +1,5 @@ +namespace MaksIT.LetsEncryptServer.Models.Requests { + public class GetCertificatesRequest { + public string[] Hostnames { get; set; } + } +} diff --git a/src/LetsEncryptServer/Models/Requests/GetOrderRequest.cs b/src/LetsEncryptServer/Models/Requests/GetOrderRequest.cs new file mode 100644 index 0000000..e4f76fc --- /dev/null +++ b/src/LetsEncryptServer/Models/Requests/GetOrderRequest.cs @@ -0,0 +1,5 @@ +namespace MaksIT.LetsEncryptServer.Models.Requests { + public class GetOrderRequest { + public string[] Hostnames { get; set; } + } +} diff --git a/src/LetsEncryptServer/Program.cs b/src/LetsEncryptServer/Program.cs index 293c729..3c87b9a 100644 --- a/src/LetsEncryptServer/Program.cs +++ b/src/LetsEncryptServer/Program.cs @@ -1,7 +1,21 @@ +using MaksIT.LetsEncryptServer; using MaksIT.LetsEncrypt.Services; +using Microsoft.Extensions.DependencyInjection; +using MaksIT.LetsEncryptServer.Services; var builder = WebApplication.CreateBuilder(args); +// Extract configuration +var configuration = builder.Configuration; + +// Configure strongly typed settings objects +var configurationSection = configuration.GetSection("Configuration"); +var appSettings = configurationSection.Get() ?? throw new ArgumentNullException(); + +// Allow configurations to be available through IOptions +builder.Services.Configure(configurationSection); + + // Add services to the container. builder.Services.AddControllers(); @@ -12,6 +26,7 @@ builder.Services.AddSwaggerGen(); builder.Services.AddMemoryCache(); builder.Services.AddHttpClient(); +builder.Services.AddScoped(); var app = builder.Build(); diff --git a/src/LetsEncryptServer/Services/CertsFlowService.cs b/src/LetsEncryptServer/Services/CertsFlowService.cs new file mode 100644 index 0000000..81aa80b --- /dev/null +++ b/src/LetsEncryptServer/Services/CertsFlowService.cs @@ -0,0 +1,208 @@ +using System.Text; +using System.Net.Sockets; +using Microsoft.Extensions.Options; + +using DomainResults.Common; + +using MaksIT.LetsEncrypt.Entities; +using MaksIT.LetsEncrypt.Services; +using MaksIT.LetsEncryptServer.Models.Requests; +using System.Security.Cryptography.X509Certificates; +using System.Security.Cryptography; + + +namespace MaksIT.LetsEncryptServer.Services; + +public interface ICertsFlowServiceBase { + (string?, IDomainResult) AcmeChallenge(string fileName); +} + +public interface ICertsFlowService : ICertsFlowServiceBase { + Task<(Guid?, IDomainResult)> ConfigureClientAsync(); + (string?, IDomainResult) GetTermsOfService(Guid sessionId); + Task<(Guid?, IDomainResult)> InitAsync(Guid sessionId, Guid? accountId, InitRequest requestData); + Task<(List?, IDomainResult)> NewOrderAsync(Guid sessionId, NewOrderRequest requestData); + Task CompleteChallengesAsync(Guid sessionId); + Task GetOrderAsync(Guid sessionId, GetOrderRequest requestData); + Task GetCertificatesAsync(Guid sessionId, GetCertificatesRequest requestData); + (Dictionary?, IDomainResult) ApplyCertificates(Guid sessionId, GetCertificatesRequest requestData); +} + +public class CertsFlowService : ICertsFlowService { + + private readonly Configuration _appSettings; + private readonly ILogger _logger; + private readonly ILetsEncryptService _letsEncryptService; + private readonly string _acmePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "acme"); + + public CertsFlowService( + IOptions appSettings, + ILogger logger, + ILetsEncryptService letsEncryptService + ) { + _appSettings = appSettings.Value; + _logger = logger; + _letsEncryptService = letsEncryptService; + + if (!Directory.Exists(_acmePath)) + Directory.CreateDirectory(_acmePath); + } + + public async Task<(Guid?, IDomainResult)> ConfigureClientAsync() { + var sessionId = Guid.NewGuid(); + + var url = _appSettings.DevMode + ? _appSettings.Staging + : _appSettings.Production; + + var result = await _letsEncryptService.ConfigureClient(sessionId, url); + if (!result.IsSuccess) + return (null, result); + + return IDomainResult.Success(sessionId); + } + + public (string?, IDomainResult) GetTermsOfService(Guid sessionId) { + var (terms, getTermsResult) = _letsEncryptService.GetTermsOfServiceUri(sessionId); + if (!getTermsResult.IsSuccess || terms == null) + return (null, getTermsResult); + + return IDomainResult.Success(terms); + } + + public async Task<(Guid?, IDomainResult)> InitAsync(Guid sessionId, Guid? accountId, InitRequest requestData) { + var cache = default(RegistrationCache); + if (accountId == null) { + accountId = Guid.NewGuid(); + } + + var result = await _letsEncryptService.Init(sessionId, requestData.Contacts, cache); + return result.IsSuccess ? IDomainResult.Success(accountId.Value) : (null, result); + } + + public async Task<(List?, IDomainResult)> NewOrderAsync(Guid sessionId, NewOrderRequest requestData) { + var (results, newOrderResult) = await _letsEncryptService.NewOrder(sessionId, requestData.Hostnames, requestData.ChallengeType); + if (!newOrderResult.IsSuccess || results == null) + return (null, newOrderResult); + + var challenges = new List(); + foreach (var result in results) { + string[] splitToken = result.Value.Split('.'); + File.WriteAllText(Path.Combine(_acmePath, splitToken[0]), result.Value); + challenges.Add(splitToken[0]); + } + + return IDomainResult.Success(challenges); + } + + public async Task CompleteChallengesAsync(Guid sessionId) { + return await _letsEncryptService.CompleteChallenges(sessionId); + } + + public async Task GetOrderAsync(Guid sessionId, GetOrderRequest requestData) { + return await _letsEncryptService.GetOrder(sessionId, requestData.Hostnames); + } + + public async Task GetCertificatesAsync(Guid sessionId, GetCertificatesRequest requestData) { + foreach (var subject in requestData.Hostnames) { + var result = await _letsEncryptService.GetCertificate(sessionId, subject); + if (!result.IsSuccess) + return result; + + Thread.Sleep(1000); + } + + return IDomainResult.Success(); + } + + public (Dictionary?, IDomainResult) ApplyCertificates(Guid sessionId, GetCertificatesRequest requestData) { + var haproxyHelper = new HaproxyCertificateUpdater(); + + var result = new Dictionary(); + + foreach (var subject in requestData.Hostnames) { + + var (cert, getCertResult) = _letsEncryptService.TryGetCachedCertificate(sessionId, subject); + if (!getCertResult.IsSuccess || cert == null) + return (null, getCertResult); + + //haproxyHelper.ApplyCertificates(subject, cert.Certificate, cert.PrivateKeyPem); + var content = $"{cert.Certificate}\n{cert.PrivateKeyPem}"; + result.Add(subject, content); + } + + return IDomainResult.Success(result); + } + + + public (string?, IDomainResult) AcmeChallenge(string fileName) { + + //var currentDate = DateTime.Now; + + //foreach (var file in Directory.GetFiles(_acmePath)) { + // var creationTime = System.IO.File.GetCreationTime(file); + + // // Calculate the time difference + // var timeDifference = currentDate - creationTime; + + // // If the file is older than 1 day, delete it + // if (timeDifference.TotalDays > 1) { + // File.Delete(file); + // _logger.LogInformation($"Deleted file: {file}"); + // } + //} + + var fileContent = File.ReadAllText(Path.Combine(_acmePath, fileName)); + if (fileContent == null) + return IDomainResult.NotFound(); + + return IDomainResult.Success(fileContent); + } + + +} + + + + + +public class HaproxyCertificateUpdater { + private readonly string haproxySocketAddress = "192.168.1.4"; + private readonly int haproxySocketPort = 9999; + + public void ApplyCertificates(string subject, string certPem, string keyPem) { + if (string.IsNullOrEmpty(certPem) || string.IsNullOrEmpty(keyPem)) { + Console.WriteLine($"Certificate or key for {subject} is invalid"); + return; + } + + string certFileName = $"/etc/haproxy/certs/{subject}.pem"; + string fullCert = $"{certPem}\n{keyPem}"; + + try { + SendCommand($"new ssl cert {certFileName}"); + SendCommand($"set ssl cert {certFileName} <<\n{fullCert}\n"); + SendCommand($"commit ssl cert {certFileName}"); + + Console.WriteLine($"Certificate for {subject} updated successfully"); + } + catch (Exception ex) { + Console.WriteLine($"Exception while updating certificate for {subject}: {ex.Message}"); + } + } + + private void SendCommand(string command) { + using (var client = new TcpClient(haproxySocketAddress, haproxySocketPort)) + using (var stream = client.GetStream()) + using (var writer = new StreamWriter(stream)) + using (var reader = new StreamReader(stream)) { + writer.WriteLine(command); + writer.Flush(); + + string response = reader.ReadToEnd(); + if (!response.Contains("Success")) { + throw new Exception($"Command failed: {response}"); + } + } + } +} diff --git a/src/LetsEncryptServer/appsettings.json b/src/LetsEncryptServer/appsettings.json index 654f4ba..5697a0c 100644 --- a/src/LetsEncryptServer/appsettings.json +++ b/src/LetsEncryptServer/appsettings.json @@ -11,6 +11,16 @@ "Production": "https://acme-v02.api.letsencrypt.org/directory", "Staging": "https://acme-staging-v02.api.letsencrypt.org/directory", - "ServerPath": "/etc/haproxy/certs" + "DevMode": true, + + "Server": { + "Ip": "192.168.1.4", + "Port": 9999, + "Path": "/etc/haproxy/certs", + "SSH": { + "User": "", + "Key": "" + } + } } }