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": ""
+ }
+ }
}
}