(feature): webapi implementation and containerization

This commit is contained in:
Maksym Sadovnychyy 2024-06-01 01:10:21 +02:00
parent 150b8e76fc
commit d75265c621
18 changed files with 1473 additions and 625 deletions

View File

@ -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": []
}
]
}

102
README.md
View File

@ -6,3 +6,105 @@ Simple client to obtain Let's Encrypt HTTPS certificates developed with .net cor
* 29 Jun, 2019 - V1.0 * 29 Jun, 2019 - V1.0
* 01 Nov, 2019 - V2.0 (Dependency Injection pattern impelemtation) * 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
```

View File

@ -13,4 +13,8 @@
<None Remove="Abstractions\**" /> <None Remove="Abstractions\**" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" />
</ItemGroup>
</Project> </Project>

View File

@ -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>(TState state) where TState : notnull {
throw new NotImplementedException();
}
public bool IsEnabled(LogLevel logLevel) {
throw new NotImplementedException();
}
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) {
throw new NotImplementedException();
}
}

View File

@ -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();
}
}

View File

@ -0,0 +1,8 @@
using Microsoft.Extensions.Logging;
namespace MaksIT.Core.Logger;
public static class MyCustomLoggerExtensions {
}

View File

@ -4,5 +4,8 @@ namespace MaksIT.LetsEncrypt.Entities;
public class CachedCertificateResult { public class CachedCertificateResult {
public RSACryptoServiceProvider? PrivateKey { get; set; } public RSACryptoServiceProvider? PrivateKey { get; set; }
public string PrivateKeyPem => PrivateKey == null ? "" : PrivateKey.ExportRSAPrivateKeyPem();
public string? Certificate { get; set; } public string? Certificate { get; set; }
} }

View File

@ -9,6 +9,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="DomainResult.Common" Version="3.2.0" /> <PackageReference Include="DomainResult.Common" Version="3.2.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.1" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />

View File

@ -1,25 +1,17 @@
using System; namespace MaksIT.LetsEncrypt.Models.Responses;
namespace MaksIT.LetsEncrypt.Models.Responses;
public class AcmeDirectory { 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 Uri KeyChange { get; set; }
public AcmeDirectoryMeta Meta { 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 class AcmeDirectoryMeta {
public string[] CaaIdentities { get; set; }
public string TermsOfService { get; set; } public string TermsOfService { get; set; }
public string Website { get; set; }
} }

View File

@ -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.Text;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates; using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using MaksIT.LetsEncrypt.Entities; using MaksIT.LetsEncrypt.Entities;
using MaksIT.LetsEncrypt.Exceptions; using MaksIT.LetsEncrypt.Exceptions;
using MaksIT.Core.Extensions; using MaksIT.Core.Extensions;
using MaksIT.LetsEncrypt.Models.Responses; using MaksIT.LetsEncrypt.Models.Responses;
using MaksIT.LetsEncrypt.Models.Interfaces; using MaksIT.LetsEncrypt.Models.Interfaces;
using MaksIT.LetsEncrypt.Models.Requests; using MaksIT.LetsEncrypt.Models.Requests;
using MaksIT.LetsEncrypt.Entities.Jws; using MaksIT.LetsEncrypt.Entities.Jws;
using DomainResults.Common; using DomainResults.Common;
using System.Net.Http.Headers;
namespace MaksIT.LetsEncrypt.Services; namespace MaksIT.LetsEncrypt.Services;
public interface ILetsEncryptService { public interface ILetsEncryptService {
Task<(AcmeDirectory?, IDomainResult)> ConfigureClient(string url); Task<IDomainResult> ConfigureClient(Guid sessionId, string url);
Task<(RegistrationCache?, IDomainResult)> Init(Uri newAccount, Uri newNonce, string[] contacts); Task<IDomainResult> Init(Guid sessionId, string[] contacts, RegistrationCache? registrationCache);
Task<((Order?, Dictionary<string, string>?, List<AuthorizationChallenge>?), IDomainResult)> NewOrder(Uri newOrder, Uri newNonce, byte[] accountKeyBytes, string location, string[] hostnames, string challengeType); RegistrationCache? GetRegistrationCache(Guid sessionId);
Task<IDomainResult> CompleteChallenges(Uri newNonce, byte[] accountKeyBytes, string location, Order currentOrder, List<AuthorizationChallenge> _challenges); (string?, IDomainResult) GetTermsOfServiceUri(Guid sessionId);
Task<(Order?, IDomainResult)> GetOrder(Uri newOrder, Uri newNonce, byte[] accountKeyBytes, string location, string[] hostnames); Task<(Dictionary<string, string>?, IDomainResult)> NewOrder(Guid sessionId, string[] hostnames, string challengeType);
Task<(Dictionary<string, CertificateCache>?, IDomainResult)> GetCertificate(Uri newOrder, Uri newNonce, byte[] accountKeyBytes, Order currentOrder, string location, string [] subjects); Task<IDomainResult> CompleteChallenges(Guid sessionId);
Task<IDomainResult> GetOrder(Guid sessionId, string[] hostnames);
Task<IDomainResult> GetCertificate(Guid sessionId, string subject);
(CachedCertificateResult?, IDomainResult) TryGetCachedCertificate(Guid sessionId, string subject);
} }
public class LetsEncryptService : ILetsEncryptService { public class LetsEncryptService : ILetsEncryptService {
private readonly ILogger<LetsEncryptService> _logger; private readonly ILogger<LetsEncryptService> _logger;
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
private readonly IMemoryCache _cache;
public LetsEncryptService( public LetsEncryptService(
ILogger<LetsEncryptService> logger, ILogger<LetsEncryptService> logger,
HttpClient httpClient HttpClient httpClient,
) { IMemoryCache cache) {
_logger = logger; _logger = logger;
_httpClient = httpClient; _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;
}
#region ConfigureClient
/// <summary> public async Task<IDomainResult> ConfigureClient(Guid sessionId, string url) {
///
/// </summary>
/// <param name="url"></param>
/// <param name="contacts"></param>
/// <returns></returns>
public async Task<(AcmeDirectory?, IDomainResult)> ConfigureClient(string url) {
try { try {
var state = GetOrCreateState(sessionId);
_httpClient.BaseAddress ??= new Uri(url); _httpClient.BaseAddress ??= new Uri(url);
var (directory, getAcmeDirectoryResult) = await SendAsync<AcmeDirectory>(HttpMethod.Get, new Uri("directory", UriKind.Relative), false, null, null, null, null); if (state.Directory == null) {
if (!getAcmeDirectoryResult.IsSuccess) var (directory, getAcmeDirectoryResult) = await SendAsync<AcmeDirectory>(sessionId, HttpMethod.Get, new Uri("directory", UriKind.Relative), false, null);
return (null, getAcmeDirectoryResult); 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) { catch (Exception ex) {
_logger.LogError(ex, "Let's Encrypt client unhandled exception"); _logger.LogError(ex, "Let's Encrypt client unhandled exception");
return IDomainResult.CriticalDependencyError<AcmeDirectory>(); return IDomainResult.CriticalDependencyError();
} }
} }
#endregion
/// <summary> #region Init
/// Account creation or Initialization from cache public async Task<IDomainResult> Init(Guid sessionId, string[] contacts, RegistrationCache? cache) {
/// </summary> if (sessionId == Guid.Empty) {
/// <param name="contacts"></param> _logger.LogError("Invalid sessionId");
/// <param name="token"></param> return IDomainResult.Failed();
/// <returns></returns> }
public async Task<(RegistrationCache?, IDomainResult)> Init(Uri newAccount, Uri newNonce, string[] contacts) {
try { 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)}..."); _logger.LogInformation($"Executing {nameof(Init)}...");
try {
var accountKey = new RSACryptoServiceProvider(4096); 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 { var letsEncryptOrder = new Account {
TermsOfServiceAgreed = true, TermsOfServiceAgreed = true,
Contacts = contacts.Select(contact => $"mailto:{contact}").ToArray() Contacts = contacts.Select(contact => $"mailto:{contact}").ToArray()
}; };
var (account, postAccuntResult) = await SendAsync<Account>(HttpMethod.Post, newAccount, false, letsEncryptOrder, accountKey, null, newNonce); var (account, postAccountResult) = await SendAsync<Account>(sessionId, HttpMethod.Post, state.Directory.NewAccount, false, letsEncryptOrder);
if (!postAccuntResult.IsSuccess || account == null) state.JwsService.SetKeyId(account.Result.Location.ToString());
return (null, postAccuntResult);
// Probably non necessary here
// jwsService.SetKeyId(account.Result.Location.ToString());
if (account.Result.Status != "valid") { if (account.Result.Status != "valid") {
_logger.LogError($"Account status is not valid, was: {account.Result.Status} \r\n {account.ResponseText}"); _logger.LogError($"Account status is not valid, was: {account.Result.Status} \r\n {account.ResponseText}");
return IDomainResult.Failed<RegistrationCache>(); return IDomainResult.Failed();
} }
var cache = new RegistrationCache { state.Cache = new RegistrationCache {
Location = account.Result.Location, Location = account.Result.Location,
AccountKey = accountKey.ExportCspBlob(true), AccountKey = accountKey.ExportCspBlob(true),
Id = account.Result.Id, Id = account.Result.Id,
Key = account.Result.Key Key = account.Result.Key
}; };
}
return IDomainResult.Success(cache); return IDomainResult.Success();
}
catch (Exception ex) {
var message = "Let's Encrypt client unhandled exception";
_logger.LogError(ex, message);
return IDomainResult.CriticalDependencyError(message);
}
}
#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<string?>();
}
return IDomainResult.Success(state.Directory.Meta.TermsOfService);
} }
catch (Exception ex) { catch (Exception ex) {
var message = "Let's Encrypt client unhandled exception"; var message = "Let's Encrypt client unhandled exception";
_logger.LogError(ex, message); _logger.LogError(ex, message);
return IDomainResult.CriticalDependencyError<RegistrationCache>(message); return IDomainResult.CriticalDependencyError<string?>(message);
} }
} }
#endregion
/// <summary> #region NewOrder
/// Create new Certificate Order. In case you want the wildcard-certificate you must select dns-01 challange. public async Task<(Dictionary<string, string>?, IDomainResult)> NewOrder(Guid sessionId, string[] hostnames, string challengeType) {
/// <para>
/// Available challange types:
/// <list type="number">
/// <item>dns-01</item>
/// <item>http-01</item>
/// <item>tls-alpn-01</item>
/// </list>
/// </para>
/// </summary>
/// <param name="hostnames"></param>
/// <param name="challengeType"></param>
/// <param name="token"></param>
/// <returns></returns>
public async Task<((Order?, Dictionary<string, string>?, List<AuthorizationChallenge>?), IDomainResult)> NewOrder(Uri newOrder, Uri newNonce, byte[] accountKeyBytes, string location, string[] hostnames, string challengeType) {
try { try {
var state = GetOrCreateState(sessionId);
var accountKey = new RSACryptoServiceProvider(4096);
accountKey.ImportCspBlob(accountKeyBytes);
var jwsService = new JwsService(accountKey);
_logger.LogInformation($"Executing {nameof(NewOrder)}..."); _logger.LogInformation($"Executing {nameof(NewOrder)}...");
var currentOrder = default(Order); state.Challenges.Clear();
var results = new Dictionary<string, string>();
var challenges = new List<AuthorizationChallenge>();
var letsEncryptOrder = new Order { var letsEncryptOrder = new Order {
Expires = DateTime.UtcNow.AddDays(2), Expires = DateTime.UtcNow.AddDays(2),
@ -157,132 +182,97 @@ public class LetsEncryptService : ILetsEncryptService {
}).ToArray() }).ToArray()
}; };
var (order, postNewOrderResult) = await SendAsync<Order>(HttpMethod.Post, newOrder, false, letsEncryptOrder, accountKey, location, newNonce); var (order, postNewOrderResult) = await SendAsync<Order>(sessionId, HttpMethod.Post, state.Directory.NewOrder, false, letsEncryptOrder);
if (!postNewOrderResult.IsSuccess) { if (!postNewOrderResult.IsSuccess) {
return ((null, null, null), postNewOrderResult); return (null, postNewOrderResult);
} }
if (order.Result.Status == "ready") if (order.Result.Status == "ready")
return IDomainResult.Success((currentOrder, results, challenges)); return IDomainResult.Success(new Dictionary<string, string>());
if (order.Result.Status != "pending") { if (order.Result.Status != "pending") {
_logger.LogError($"Created new order and expected status 'pending', but got: {order.Result.Status} \r\n {order.Result}"); _logger.LogError($"Created new order and expected status 'pending', but got: {order.Result.Status} \r\n {order.Result}");
return IDomainResult.Failed<(Order?, Dictionary<string, string>?, List<AuthorizationChallenge>?)>(); return IDomainResult.Failed<Dictionary<string, string>?>();
} }
currentOrder = order.Result; state.CurrentOrder = order.Result;
var results = new Dictionary<string, string>();
foreach (var item in currentOrder.Authorizations) { foreach (var item in order.Result.Authorizations) {
var (challengeResponse, postAuthorisationChallengeResult) = await SendAsync<AuthorizationChallengeResponse>(sessionId, HttpMethod.Post, item, true, null);
var (challengeResponse, postAuthorisationChallengeResult) = await SendAsync<AuthorizationChallengeResponse>(HttpMethod.Post, item, true, null, accountKey, location, newNonce);
if (!postAuthorisationChallengeResult.IsSuccess) { if (!postAuthorisationChallengeResult.IsSuccess) {
return ((null, null, null), postAuthorisationChallengeResult); return (null, postAuthorisationChallengeResult);
} }
if (challengeResponse.Result.Status == "valid") if (challengeResponse.Result.Status == "valid")
continue; continue;
if (challengeResponse.Result.Status != "pending") { if (challengeResponse.Result.Status != "pending") {
_logger.LogError($"Expected autorization status 'pending', but got: {currentOrder.Status} \r\n {challengeResponse.ResponseText}"); _logger.LogError($"Expected authorization status 'pending', but got: {order.Result.Status} \r\n {challengeResponse.ResponseText}");
return IDomainResult.Failed<(Order?, Dictionary<string, string>?, List<AuthorizationChallenge>?)>(); return IDomainResult.Failed<Dictionary<string, string>?>();
} }
var challenge = challengeResponse.Result.Challenges.First(x => x.Type == challengeType); 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) { switch (challengeType) {
case "dns-01":
// 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()) { using (var sha256 = SHA256.Create()) {
var dnsToken = jwsService.Base64UrlEncoded(sha256.ComputeHash(Encoding.UTF8.GetBytes(keyToken))); var dnsToken = state.JwsService.Base64UrlEncoded(sha256.ComputeHash(Encoding.UTF8.GetBytes(keyToken)));
results[challengeResponse.Result.Identifier.Value] = dnsToken; results[challengeResponse.Result.Identifier.Value] = dnsToken;
} }
break; break;
}
case "http-01":
// 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; results[challengeResponse.Result.Identifier.Value] = keyToken;
break; break;
}
default: default:
throw new NotImplementedException(); throw new NotImplementedException();
} }
} }
// TODO: reurn challenges return IDomainResult.Success(results);
return IDomainResult.Success((currentOrder, results, challenges));
} }
catch (Exception ex) { catch (Exception ex) {
var message = "Let's Encrypt client unhandled exception"; var message = "Let's Encrypt client unhandled exception";
_logger.LogError(ex, message); _logger.LogError(ex, message);
return IDomainResult.CriticalDependencyError<(Order?, Dictionary<string, string>?, List<AuthorizationChallenge>?)>(message); return IDomainResult.CriticalDependencyError<Dictionary<string, string>?>(message);
} }
} }
#endregion
/// <summary> #region CompleteChallenges
/// public async Task<IDomainResult> CompleteChallenges(Guid sessionId) {
/// </summary>
/// <returns></returns>
/// <exception cref="InvalidOperationException"></exception>
public async Task<IDomainResult> CompleteChallenges(Uri newNonce, byte[] accountKeyBytes, string location, Order currentOrder, List<AuthorizationChallenge> challenges) {
try { try {
var state = GetOrCreateState(sessionId);
var accountKey = new RSACryptoServiceProvider(4096);
accountKey.ImportCspBlob(accountKeyBytes);
var jwsService = new JwsService(accountKey);
_logger.LogInformation($"Executing {nameof(CompleteChallenges)}..."); _logger.LogInformation($"Executing {nameof(CompleteChallenges)}...");
if (currentOrder?.Identifiers == null) { if (state.CurrentOrder?.Identifiers == null) {
return IDomainResult.Failed(); return IDomainResult.Failed();
} }
for (var index = 0; index < challenges.Count; index++) { for (var index = 0; index < state.Challenges.Count; index++) {
var challenge = state.Challenges[index];
var challenge = challenges[index];
var start = DateTime.UtcNow; var start = DateTime.UtcNow;
while (true) { while (true) {
var authorizeChallenge = new AuthorizeChallenge(); var authorizeChallenge = new AuthorizeChallenge();
switch (challenge.Type) { switch (challenge.Type) {
case "dns-01": { case "dns-01":
authorizeChallenge.KeyAuthorization = jwsService.GetKeyAuthorization(challenge.Token); authorizeChallenge.KeyAuthorization = state.JwsService.GetKeyAuthorization(challenge.Token);
//var (result, responseText) = await SendAsync<AuthorizationChallengeResponse>(HttpMethod.Post, challenge.Url, authorizeChallenge, token); break;
case "http-01":
break; break;
} }
case "http-01": { var (authChallenge, postAuthChallengeResult) = await SendAsync<AuthorizationChallengeResponse>(sessionId, HttpMethod.Post, challenge.Url, false, "{}");
break;
}
}
var (authChallenge, postAuthChallengeResult) = await SendAsync<AuthorizationChallengeResponse>(HttpMethod.Post, challenge.Url, false, "{}", accountKey, location, newNonce);
if (!postAuthChallengeResult.IsSuccess) { if (!postAuthChallengeResult.IsSuccess) {
return postAuthChallengeResult; return postAuthChallengeResult;
} }
@ -291,7 +281,7 @@ public class LetsEncryptService : ILetsEncryptService {
break; break;
if (authChallenge.Result.Status != "pending") { 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(); return IDomainResult.Failed();
} }
@ -311,20 +301,15 @@ public class LetsEncryptService : ILetsEncryptService {
return IDomainResult.CriticalDependencyError(message); return IDomainResult.CriticalDependencyError(message);
} }
} }
#endregion
/// <summary> #region GetOrder
/// public async Task<IDomainResult> GetOrder(Guid sessionId, string[] hostnames) {
/// </summary>
/// <param name="hostnames"></param>
/// <returns></returns>
public async Task<(Order?, IDomainResult)> GetOrder(Uri newOrder, Uri newNonce, byte[] accountKeyBytes, string location, string[] hostnames) {
try { try {
var accountKey = new RSACryptoServiceProvider(4096);
accountKey.ImportCspBlob(accountKeyBytes);
_logger.LogInformation($"Executing {nameof(GetOrder)}"); _logger.LogInformation($"Executing {nameof(GetOrder)}");
var state = GetOrCreateState(sessionId);
var letsEncryptOrder = new Order { var letsEncryptOrder = new Order {
Expires = DateTime.UtcNow.AddDays(2), Expires = DateTime.UtcNow.AddDays(2),
Identifiers = hostnames.Select(hostname => new OrderIdentifier { Identifiers = hostnames.Select(hostname => new OrderIdentifier {
@ -333,48 +318,32 @@ public class LetsEncryptService : ILetsEncryptService {
}).ToArray() }).ToArray()
}; };
var (order, postOrderResult) = await SendAsync<Order>(HttpMethod.Post, newOrder, false, letsEncryptOrder, accountKey, location, newNonce); var (order, postOrderResult) = await SendAsync<Order>(sessionId, HttpMethod.Post, state.Directory.NewOrder, false, letsEncryptOrder);
if (!postOrderResult.IsSuccess) 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) { catch (Exception ex) {
var message = "Let's Encrypt client unhandled exception"; var message = "Let's Encrypt client unhandled exception";
_logger.LogError(ex, message); _logger.LogError(ex, message);
return IDomainResult.CriticalDependencyError<Order?>(message); return IDomainResult.CriticalDependencyError(message);
} }
} }
#endregion
/// <summary> #region GetCertificates
/// public async Task<IDomainResult> GetCertificate(Guid sessionId, string subject) {
/// </summary>
/// <param name="subject"></param>
/// <returns>Cert and Private key</returns>
/// <exception cref="InvalidOperationException"></exception>
public async Task<(Dictionary<string, CertificateCache>?, IDomainResult)> GetCertificate(Uri newOrder, Uri newNonce, byte[] accountKeyBytes, Order currentOrder, string location, string [] subjects) {
try { try {
var state = GetOrCreateState(sessionId);
var accountKey = new RSACryptoServiceProvider(4096);
accountKey.ImportCspBlob(accountKeyBytes);
var jwsService = new JwsService(accountKey);
_logger.LogInformation($"Executing {nameof(GetCertificate)}..."); _logger.LogInformation($"Executing {nameof(GetCertificate)}...");
var cachedCerts = new Dictionary<string, CertificateCache>(); if (state.CurrentOrder == null) {
return IDomainResult.Failed();
foreach (var subject in subjects) {
if (currentOrder == null) {
return IDomainResult.Failed<Dictionary<string, CertificateCache>>();
} }
var key = new RSACryptoServiceProvider(4096); var key = new RSACryptoServiceProvider(4096);
@ -382,34 +351,32 @@ public class LetsEncryptService : ILetsEncryptService {
key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
var san = new SubjectAlternativeNameBuilder(); var san = new SubjectAlternativeNameBuilder();
foreach (var host in currentOrder.Identifiers) foreach (var host in state.CurrentOrder.Identifiers)
san.AddDnsName(host.Value); san.AddDnsName(host.Value);
csr.CertificateExtensions.Add(san.Build()); csr.CertificateExtensions.Add(san.Build());
var letsEncryptOrder = new FinalizeRequest { var letsEncryptOrder = new FinalizeRequest {
Csr = jwsService.Base64UrlEncoded(csr.CreateSigningRequest()) Csr = state.JwsService.Base64UrlEncoded(csr.CreateSigningRequest())
}; };
Uri? certificateUrl = default; Uri? certificateUrl = default;
var start = DateTime.UtcNow; var start = DateTime.UtcNow;
while (certificateUrl == null) { while (certificateUrl == null) {
// https://community.letsencrypt.org/t/breaking-changes-in-asynchronous-order-finalization-api/195882 // 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()); await GetOrder(sessionId, state.CurrentOrder.Identifiers.Select(x => x.Value).ToArray());
if (currentOrder.Status == "ready") { if (state.CurrentOrder.Status == "ready") {
var (order, postOrderResult) = await SendAsync<Order>(HttpMethod.Post, currentOrder.Finalize, false, letsEncryptOrder, accountKey, location, newNonce); var (order, postOrderResult) = await SendAsync<Order>(sessionId, HttpMethod.Post, state.CurrentOrder.Finalize, false, letsEncryptOrder);
if (!postOrderResult.IsSuccess || order?.Result == null) if (!postOrderResult.IsSuccess || order?.Result == null)
return (null, postOrderResult); return postOrderResult;
if (order.Result.Status == "processing") { if (order.Result.Status == "processing") {
(order, postOrderResult) = await SendAsync<Order>(HttpMethod.Post, currentOrder.Location, true, null, accountKey, location, newNonce); (order, postOrderResult) = await SendAsync<Order>(sessionId, HttpMethod.Post, state.CurrentOrder.Location, true, null);
if (!postOrderResult.IsSuccess || order?.Result == null) if (!postOrderResult.IsSuccess || order?.Result == null)
return (null, postOrderResult); return postOrderResult;
} }
if (order.Result.Status == "valid") { if (order.Result.Status == "valid") {
@ -423,64 +390,209 @@ public class LetsEncryptService : ILetsEncryptService {
await Task.Delay(1000); await Task.Delay(1000);
} }
var (pem, postPemResult) = await SendAsync<string>(HttpMethod.Post, certificateUrl, true, null, accountKey, location, newNonce); var (pem, postPemResult) = await SendAsync<string>(sessionId, HttpMethod.Post, certificateUrl, true, null);
if (!postPemResult.IsSuccess || pem?.Result == null) if (!postPemResult.IsSuccess || pem?.Result == null)
return (null, postPemResult); return postPemResult;
cachedCerts.Add(subject, new CertificateCache {
Cert = pem.Result,
Private = key.ExportCspBlob(true)
});
//var cert = new X509Certificate2(Encoding.UTF8.GetBytes(pem.Result));
if (state.Cache == null) {
_logger.LogError($"{nameof(state.Cache)} is null");
return IDomainResult.Failed();
} }
return IDomainResult.Success(cachedCerts); state.Cache.CachedCerts ??= new Dictionary<string, CertificateCache>();
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) { catch (Exception ex) {
var message = "Let's Encrypt client unhandled exception"; var message = "Let's Encrypt client unhandled exception";
_logger.LogError(ex, message); _logger.LogError(ex, message);
return IDomainResult.CriticalDependencyError<Dictionary<string, CertificateCache>?>(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);
}
/// <summary> return IDomainResult.Failed<CachedCertificateResult?>();
/// }
/// </summary> #endregion
/// <param name="token"></param>
/// <returns></returns>
public Task<IDomainResult> KeyChange() { public Task<IDomainResult> KeyChange(Guid sessionId) {
throw new NotImplementedException(); throw new NotImplementedException();
} }
/// <summary> public Task<IDomainResult> RevokeCertificate(Guid sessionId) {
///
/// </summary>
/// <param name="token"></param>
/// <returns></returns>
public Task<IDomainResult> RevokeCertificate() {
throw new NotImplementedException(); throw new NotImplementedException();
} }
#region SendAsync
/// <summary> /// <summary>
/// Request New Nonce to be able to start POST requests ///
/// </summary> /// </summary>
/// <param name="token"></param> /// <typeparam name="TResult"></typeparam>
/// <param name="sessionId"></param>
/// <param name="method"></param>
/// <param name="uri"></param>
/// <param name="isPostAsGet"></param>
/// <param name="requestModel"></param>
/// <returns></returns> /// <returns></returns>
private async Task<(string?, IDomainResult)> NewNonce(Uri newNonce) { //private async Task<(SendResult<TResult>?, IDomainResult)> SendAsync<TResult>(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<Problem>(), response);
// if (response.Content.Headers.ContentType?.MediaType == "application/pem-certificate-chain" && typeof(TResult) == typeof(string)) {
// return IDomainResult.Success(new SendResult<TResult> {
// Result = (TResult)(object)responseText
// });
// }
// var responseContent = responseText.ToObject<TResult>();
// if (responseContent is IHasLocation ihl) {
// if (response.Headers.Location != null)
// ihl.Location = response.Headers.Location;
// }
// return IDomainResult.Success(new SendResult<TResult> {
// Result = responseContent,
// ResponseText = responseText
// });
// }
// catch (Exception ex) {
// var message = "Let's Encrypt client unhandled exception";
// _logger.LogError(ex, message);
// return IDomainResult.CriticalDependencyError<SendResult<TResult>?>(message);
// }
//}
private async Task<(SendResult<TResult>?, IDomainResult)> SendAsync<TResult>(
Guid sessionId,
HttpMethod method,
Uri uri,
bool isPostAsGet,
object? requestModel
) {
try { 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<TResult>(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<SendResult<TResult>?>(message);
}
}
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;
}
}
private async Task<(string?, IDomainResult)> NewNonce(Guid sessionId) {
try {
var state = GetOrCreateState(sessionId);
_logger.LogInformation($"Executing {nameof(NewNonce)}..."); _logger.LogInformation($"Executing {nameof(NewNonce)}...");
var result = await _httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Head, newNonce)); if (state.Directory == null)
return IDomainResult.Success(result.Headers.GetValues("Replay-Nonce").First()); 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) { catch (Exception ex) {
var message = "Let's Encrypt client unhandled exception"; var message = "Let's Encrypt client unhandled exception";
@ -490,98 +602,62 @@ public class LetsEncryptService : ILetsEncryptService {
} }
} }
/// <summary> private JwsHeader CreateJwsHeader(Uri uri, string? nonce) {
/// Main method used to send data to LetsEncrypt return new JwsHeader {
/// </summary>
/// <typeparam name="TResult"></typeparam>
/// <param name="method"></param>
/// <param name="uri"></param>
/// <param name="requestModel"></param>
/// <param name="token"></param>
/// <returns></returns>
private async Task<(SendResult<TResult>?, IDomainResult)> SendAsync<TResult>(HttpMethod method, Uri uri, bool isPostAsGet, object? requestModel, RSACryptoServiceProvider? accountKey, string? location, Uri? newNonce) {
try {
var _nonce = default(string?);
_logger.LogInformation($"Executing {nameof(SendAsync)}...");
var request = new HttpRequestMessage(method, uri);
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<SendResult<TResult>?>();
var jwsService = new JwsService(accountKey);
if(location != null)
jwsService.SetKeyId(location);
var jwsHeader = new JwsHeader {
Url = uri, Url = uri,
Nonce = nonce
}; };
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); private string EncodeMessage(bool isPostAsGet, object? requestModel, State state, JwsHeader jwsHeader) {
return isPostAsGet
? state.JwsService.Encode(jwsHeader).ToJson()
: state.JwsService.Encode(requestModel, jwsHeader).ToJson();
}
if (method == HttpMethod.Post) private void PrepareRequestContent(HttpRequestMessage request, string json, HttpMethod method) {
_nonce = response.Headers.GetValues("Replay-Nonce").First(); request.Content = new StringContent(json);
var contentType = method == HttpMethod.Post ? "application/jose+json" : "application/json";
request.Content.Headers.ContentType = new MediaTypeHeaderValue(contentType);
}
var responseText = await response.Content.ReadAsStringAsync(); 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();
}
}
if (response.Content.Headers.ContentType?.MediaType == "application/problem+json") private async Task HandleProblemResponseAsync(HttpResponseMessage response, string responseText) {
if (response.Content.Headers.ContentType?.MediaType == "application/problem+json") {
throw new LetsEncrytException(responseText.ToObject<Problem>(), response); throw new LetsEncrytException(responseText.ToObject<Problem>(), response);
}
}
private SendResult<TResult> ProcessResponseContent<TResult>(HttpResponseMessage response, string responseText) {
if (response.Content.Headers.ContentType?.MediaType == "application/pem-certificate-chain" && typeof(TResult) == typeof(string)) { if (response.Content.Headers.ContentType?.MediaType == "application/pem-certificate-chain" && typeof(TResult) == typeof(string)) {
return IDomainResult.Success(new SendResult<TResult> { return new SendResult<TResult> {
Result = (TResult)(object)responseText Result = (TResult)(object)responseText
}); };
} }
var responseContent = responseText.ToObject<TResult>(); var responseContent = responseText.ToObject<TResult>();
if (responseContent is IHasLocation ihl && response.Headers.Location != null) {
if (responseContent is IHasLocation ihl) {
if (response.Headers.Location != null)
ihl.Location = response.Headers.Location; ihl.Location = response.Headers.Location;
} }
return IDomainResult.Success(new SendResult<TResult> { return new SendResult<TResult> {
Result = responseContent, Result = responseContent,
ResponseText = responseText ResponseText = responseText
}); };
}
#endregion
} private class State {
catch (Exception ex) { public AcmeDirectory? Directory { get; set; }
var message = "Let's Encrypt client unhandled exception"; public JwsService? JwsService { get; set; }
public Order? CurrentOrder { get; set; }
_logger.LogError(ex, message); public List<AuthorizationChallenge> Challenges { get; } = new List<AuthorizationChallenge>();
return IDomainResult.CriticalDependencyError<SendResult<TResult>?>(message); public string? Nonce { get; set; }
} public RegistrationCache? Cache { get; set; }
} }
} }

View File

@ -1,36 +1,21 @@
namespace LetsEncryptServer { namespace MaksIT.LetsEncryptServer {
public class Site { public class SSHClientConfing {
public required string Name { get; set; } public required string User { get; set; }
public required string[] Hosts { get; set; } public required string Key { get; set; }
public required string Challenge { 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 class Server {
public required string Address { get; set; } public required string Ip { get; set; }
public required string PrivateKey { get; set; } public required int Port { get; set; }
public required string Path { get; set; } public string Path { get; set; }
public required SSHClientConfing SSH { get; set; }
} }
public class Configuration { public class Configuration {
public required string Production { get; set; } public required string Production { get; set; }
public required string Staging { get; set; } public required string Staging { get; set; }
public required bool DevMode { get; set; }
public required Server Server { get; set; } public required Server Server { get; set; }
public Customer[]? Customers { get; set; }
} }
} }

View File

@ -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.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using System.IO;
using System.Security.Cryptography.X509Certificates;
using System.Text;
namespace LetsEncryptServer.Controllers; using DomainResults.Mvc;
public class LetsEncryptSession { using MaksIT.LetsEncryptServer.Models.Requests;
public RegistrationCache? RegistrationCache { get; set; } using MaksIT.LetsEncryptServer.Services;
public Order? CurrentOrder { get; set; }
public List<AuthorizationChallenge>? Challenges { get; set; }
public string[] Hostnames { get; set; } namespace MaksIT.LetsEncryptServer.Controllers;
}
[ApiController] [ApiController]
[Route("[controller]")] [Route("[controller]")]
public class CertsFlowController : ControllerBase { public class CertsFlowController : ControllerBase {
private readonly Configuration _appSettings; private readonly IOptions<Configuration> _appSettings;
private readonly IMemoryCache _memoryCache; private readonly ICertsFlowService _certsFlowService;
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)
};
public CertsFlowController( public CertsFlowController(
IOptions<Configuration> appSettings, IOptions<Configuration> appSettings,
IMemoryCache memoryCache, ICertsFlowService certsFlowService
ILetsEncryptService letsEncryptService
) { ) {
_memoryCache = memoryCache; _appSettings = appSettings;
_appSettings = appSettings.Value; _certsFlowService = certsFlowService;
_letsEncryptService = letsEncryptService;
if (!Directory.Exists(_acmePath))
Directory.CreateDirectory(_acmePath);
Console.WriteLine(_acmePath);
} }
[HttpGet("[action]")] /// <summary>
public async Task<IActionResult> TermsOfService() { /// Initialize certificate flow session
var (config, configResult) = await _letsEncryptService.ConfigureClient("https://acme-staging-v02.api.letsencrypt.org/directory"); /// </summary>
/// <returns>sessionId</returns>
if (!configResult.IsSuccess || config == null)
return configResult.ToActionResult();
return Ok(config.Meta.TermsOfService);
}
[HttpPost("[action]")] [HttpPost("[action]")]
public async Task<IActionResult> Init([FromBody] InitRequest requestData) { public async Task<IActionResult> ConfigureClient() {
var result = await _certsFlowService.ConfigureClientAsync();
var (config, configResult) = await _letsEncryptService.ConfigureClient("https://acme-staging-v02.api.letsencrypt.org/directory"); return result.ToActionResult();
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);
} }
[HttpPost("[action]/{accountId}")] [HttpGet("[action]/{sessionId}")]
public async Task<IActionResult> NewOrder(string accountId, [FromBody] NewOrderRequest requestData) { public IActionResult TermsOfService(Guid sessionId) {
var result = _certsFlowService.GetTermsOfService(sessionId);
var cacheData = (LetsEncryptSession?)_memoryCache.Get(accountId); return result.ToActionResult();
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<string>();
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; /// <summary>
cacheData.Challenges = challenges; /// When new certificate session is created, create or retrieve cache data by accountId
cacheData.Hostnames = requestData.Hostnames; /// </summary>
/// <param name="sessionId"></param>
_memoryCache.Set(accountId, cacheData, _cacheEntryOptions); /// <param name="accountId"></param>
/// <param name="requestData"></param>
return Ok(fullPaths); /// <returns>accountId</returns>
[HttpPost("[action]/{sessionId}/{accountId?}")]
public async Task<IActionResult> Init(Guid sessionId, Guid? accountId, [FromBody] InitRequest requestData) {
var resurt = await _certsFlowService.InitAsync(sessionId, accountId, requestData);
return resurt.ToActionResult();
} }
[HttpPut("[action]/{accountId}")] /// <summary>
public async Task<IActionResult> CompleteChallenges(string accountId) { /// After account initialization create new order request
/// </summary>
var cacheData = (LetsEncryptSession?)_memoryCache.Get(accountId); /// <param name="sessionId"></param>
if (cacheData?.RegistrationCache?.AccountKey == null) /// <param name="requestData"></param>
return BadRequest(); /// <returns></returns>
[HttpPost("[action]/{sessionId}")]
var (config, configResult) = await _letsEncryptService.ConfigureClient("https://acme-staging-v02.api.letsencrypt.org/directory"); public async Task<IActionResult> NewOrder(Guid sessionId, [FromBody] NewOrderRequest requestData) {
if (!configResult.IsSuccess || config == null) var result = await _certsFlowService.NewOrderAsync(sessionId, requestData);
return configResult.ToActionResult(); return result.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();
} }
[HttpGet("[action]/{accountId}")] /// <summary>
public async Task<IActionResult> GetOrder(string accountId) { /// After new order request complete challenges
/// </summary>
var cacheData = (LetsEncryptSession?)_memoryCache.Get(accountId); /// <param name="sessionId"></param>
if (cacheData?.RegistrationCache?.AccountKey == null) /// <returns></returns>
return BadRequest(); [HttpPost("[action]/{sessionId}")]
public async Task<IActionResult> CompleteChallenges(Guid sessionId) {
var (config, configResult) = await _letsEncryptService.ConfigureClient("https://acme-staging-v02.api.letsencrypt.org/directory"); var result = await _certsFlowService.CompleteChallengesAsync(sessionId);
if (!configResult.IsSuccess || config == null) return result.ToActionResult();
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();
} }
[HttpPost("[action]/{accountId}")] /// <summary>
public async Task<IActionResult> GetCertificate(string accountId) { /// Get order status before certs retrieval
/// </summary>
var cacheData = (LetsEncryptSession?)_memoryCache.Get(accountId); /// <param name="sessionId"></param>
if (cacheData?.RegistrationCache?.AccountKey == null) /// <param name="requestData"></param>
return BadRequest(); /// <returns></returns>
[HttpPost("[action]/{sessionId}")]
var (config, configResult) = await _letsEncryptService.ConfigureClient("https://acme-staging-v02.api.letsencrypt.org/directory"); public async Task<IActionResult> GetOrder(Guid sessionId, [FromBody] GetOrderRequest requestData) {
if (!configResult.IsSuccess || config == null) var result = await _certsFlowService.GetOrderAsync(sessionId, requestData);
return configResult.ToActionResult(); return result.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) /// <summary>
return BadRequest(); /// Download certs to local cache
/// </summary>
return Ok(); /// <param name="sessionId"></param>
/// <param name="requestData"></param>
/// <returns></returns>
[HttpPost("[action]/{sessionId}")]
public async Task<IActionResult> GetCertificates(Guid sessionId, [FromBody] GetCertificatesRequest requestData) {
var result = await _certsFlowService.GetCertificatesAsync(sessionId, requestData);
return result.ToActionResult();
} }
/// <summary>
/// Apply certs from local cache to remote server
/// </summary>
/// <param name="sessionId"></param>
/// <param name="requestData"></param>
/// <returns></returns>
[HttpPost("[action]/{sessionId}")]
public IActionResult ApplyCertificates(Guid sessionId, [FromBody] GetCertificatesRequest requestData) {
var result = _certsFlowService.ApplyCertificates(sessionId, requestData);
return result.ToActionResult();
}
} }

View File

@ -1,24 +1,28 @@
using MaksIT.LetsEncrypt.Services; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace LetsEncryptServer.Controllers; using DomainResults.Mvc;
using MaksIT.LetsEncryptServer.Services;
namespace MaksIT.LetsEncryptServer.Controllers;
[ApiController] [ApiController]
[Route(".well-known")] [Route(".well-known")]
public class WellKnownController : ControllerBase { public class WellKnownController : ControllerBase {
private readonly Configuration _appSettings; private readonly Configuration _appSettings;
private readonly ILetsEncryptService _letsEncryptService; private readonly ICertsFlowServiceBase _certsFlowService;
private readonly string _acmePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "acme"); private readonly string _acmePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "acme");
public WellKnownController( public WellKnownController(
IOptions<Configuration> appSettings, IOptions<Configuration> appSettings,
ILetsEncryptService letsEncryptService ICertsFlowService certsFlowService
) { ) {
_appSettings = appSettings.Value; _appSettings = appSettings.Value;
_letsEncryptService = letsEncryptService; _certsFlowService = certsFlowService;
if (!Directory.Exists(_acmePath)) if (!Directory.Exists(_acmePath))
Directory.CreateDirectory(_acmePath); Directory.CreateDirectory(_acmePath);
@ -27,12 +31,8 @@ public class WellKnownController : ControllerBase {
[HttpGet("acme-challenge/{fileName}")] [HttpGet("acme-challenge/{fileName}")]
public IActionResult AcmeChallenge(string fileName) { public IActionResult AcmeChallenge(string fileName) {
var result = _certsFlowService.AcmeChallenge(fileName);
var fileContent = System.IO.File.ReadAllText(Path.Combine(_acmePath, fileName)); return result.ToActionResult();
if (fileContent == null)
return NotFound();
return Ok(fileContent);
} }
} }

View File

@ -0,0 +1,5 @@
namespace MaksIT.LetsEncryptServer.Models.Requests {
public class GetCertificatesRequest {
public string[] Hostnames { get; set; }
}
}

View File

@ -0,0 +1,5 @@
namespace MaksIT.LetsEncryptServer.Models.Requests {
public class GetOrderRequest {
public string[] Hostnames { get; set; }
}
}

View File

@ -1,7 +1,21 @@
using MaksIT.LetsEncryptServer;
using MaksIT.LetsEncrypt.Services; using MaksIT.LetsEncrypt.Services;
using Microsoft.Extensions.DependencyInjection;
using MaksIT.LetsEncryptServer.Services;
var builder = WebApplication.CreateBuilder(args); 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<Configuration>() ?? throw new ArgumentNullException();
// Allow configurations to be available through IOptions<Configuration>
builder.Services.Configure<Configuration>(configurationSection);
// Add services to the container. // Add services to the container.
builder.Services.AddControllers(); builder.Services.AddControllers();
@ -12,6 +26,7 @@ builder.Services.AddSwaggerGen();
builder.Services.AddMemoryCache(); builder.Services.AddMemoryCache();
builder.Services.AddHttpClient<ILetsEncryptService, LetsEncryptService>(); builder.Services.AddHttpClient<ILetsEncryptService, LetsEncryptService>();
builder.Services.AddScoped<ICertsFlowService, CertsFlowService>();
var app = builder.Build(); var app = builder.Build();

View File

@ -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<string>?, IDomainResult)> NewOrderAsync(Guid sessionId, NewOrderRequest requestData);
Task<IDomainResult> CompleteChallengesAsync(Guid sessionId);
Task<IDomainResult> GetOrderAsync(Guid sessionId, GetOrderRequest requestData);
Task<IDomainResult> GetCertificatesAsync(Guid sessionId, GetCertificatesRequest requestData);
(Dictionary<string, string>?, IDomainResult) ApplyCertificates(Guid sessionId, GetCertificatesRequest requestData);
}
public class CertsFlowService : ICertsFlowService {
private readonly Configuration _appSettings;
private readonly ILogger<CertsFlowService> _logger;
private readonly ILetsEncryptService _letsEncryptService;
private readonly string _acmePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "acme");
public CertsFlowService(
IOptions<Configuration> appSettings,
ILogger<CertsFlowService> 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<string>(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<Guid>(accountId.Value) : (null, result);
}
public async Task<(List<string>?, 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<string>();
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<IDomainResult> CompleteChallengesAsync(Guid sessionId) {
return await _letsEncryptService.CompleteChallenges(sessionId);
}
public async Task<IDomainResult> GetOrderAsync(Guid sessionId, GetOrderRequest requestData) {
return await _letsEncryptService.GetOrder(sessionId, requestData.Hostnames);
}
public async Task<IDomainResult> 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<string, string>?, IDomainResult) ApplyCertificates(Guid sessionId, GetCertificatesRequest requestData) {
var haproxyHelper = new HaproxyCertificateUpdater();
var result = new Dictionary<string, string>();
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<string?>();
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}");
}
}
}
}

View File

@ -11,6 +11,16 @@
"Production": "https://acme-v02.api.letsencrypt.org/directory", "Production": "https://acme-v02.api.letsencrypt.org/directory",
"Staging": "https://acme-staging-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": ""
}
}
} }
} }