(feature): migrate from files to database, domain driven design, certs renew cooldown improvements

This commit is contained in:
Maksym Sadovnychyy 2026-04-16 10:39:55 +02:00
parent 1b22b8688d
commit 3c9b432bd1
45 changed files with 0 additions and 802 deletions

View File

@ -1,300 +0,0 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MaksIT.Core.Extensions;
using MaksIT.LetsEncrypt.Services;
using MaksIT.LetsEncrypt.Entities;
using MaksIT.LetsEncryptConsole.Services;
using MaksIT.SSHProvider;
namespace MaksIT.LetsEncryptConsole;
public interface IApp {
Task Run(string[] args);
}
public class App : IApp {
private readonly string _appPath = AppDomain.CurrentDomain.BaseDirectory;
private readonly ILogger<App> _logger;
private readonly Configuration _appSettings;
private readonly ILetsEncryptService _letsEncryptService;
private readonly ITerminalService _terminalService;
private static readonly string _registerAccount = "--register-account";
private static readonly string _server = "--server";
private static readonly string _mail = "-m";
public App(
ILogger<App> logger,
IOptions<Configuration> appSettings,
ILetsEncryptService letsEncryptService,
ITerminalService terminalService
) {
_logger = logger;
_appSettings = appSettings.Value;
_letsEncryptService = letsEncryptService;
_terminalService = terminalService;
}
public async Task Run(string[] args) {
var parsedArgs = args.Select(x => x.Split(' ')).ToDictionary(x => x[0].Trim(), x => x[1].Trim());
if (parsedArgs.ContainsKey(_registerAccount)) {
_logger.LogInformation("Registring accoount");
if(!parsedArgs.ContainsKey(_server))
throw new ArgumentNullException("Server is required");
if(!parsedArgs.ContainsKey(_mail))
throw new ArgumentNullException("Mail is required");
var mail = parsedArgs[_mail];
if (parsedArgs[_server] == "staging")
await _letsEncryptService.ConfigureClient("https://acme-staging-v02.api.letsencrypt.org/");
else if(parsedArgs[_server] == "production")
await _letsEncryptService.ConfigureClient("https://acme-v02.api.letsencrypt.org/");
else
throw new ArgumentException("Invalid server");
return;
}
try {
_logger.LogInformation("Let's Encrypt client. Started...");
foreach (var env in _appSettings.Environments?.Where(x => x.Active) ?? new List<LetsEncryptEnvironment>()) {
_logger.LogInformation($"Let's Encrypt C# .Net Core Client, environment: {env.Name}");
//loop all customers
foreach (Customer customer in _appSettings.Customers?.Where(x => x.Active) ?? new List<Customer>()) {
_logger.LogInformation($"Managing customer: {customer.Id} - {customer.Name} {customer.LastName}");
//define cache folder
string cachePath = Path.Combine(_appPath, customer.Id, env.Name, "cache");
if (!Directory.Exists(cachePath)) {
Directory.CreateDirectory(cachePath);
}
//check acme directory
var acmePath = Path.Combine(_appPath, customer.Id, env.Name, "acme");
if (!Directory.Exists(acmePath)) {
Directory.CreateDirectory(acmePath);
}
//loop each customer website
foreach (Site site in customer.Sites?.Where(s => s.Active) ?? new List<Site>()) {
_logger.LogInformation($"Managing site: {site.Name}");
//create folder for ssl
string sslPath = Path.Combine(_appPath, customer.Id, env.Name, "ssl", site.Name);
if (!Directory.Exists(sslPath)) {
Directory.CreateDirectory(sslPath);
}
var cacheFile = Path.Combine(cachePath, $"{site.Name}.lets-encrypt.cache.json");
#region LetsEncrypt client configuration and local registration cache initialization
_logger.LogInformation("1. Client Initialization...");
await _letsEncryptService.ConfigureClient(env.Url);
var registrationCache = (File.Exists(cacheFile)
? File.ReadAllText(cacheFile)
: null)
.ToObject<RegistrationCache>();
var initResult = await _letsEncryptService.Init(customer.Contacts, registrationCache);
if (!initResult.IsSuccess) {
continue;
}
#endregion
#region LetsEncrypt terms of service
_logger.LogInformation($"Terms of service: {_letsEncryptService.GetTermsOfServiceUri()}");
#endregion
// get cached certificate and check if it's valid
// if valid check if cert and key exists otherwise recreate
// else continue with new certificate request
var certRes = new CachedCertificateResult();
if (registrationCache != null && registrationCache.TryGetCachedCertificate(site.Name, out certRes)) {
File.WriteAllText(Path.Combine(sslPath, $"{site.Name}.crt"), certRes.Certificate);
if (certRes.PrivateKey != null)
File.WriteAllText(Path.Combine(sslPath, $"{site.Name}.key"), certRes.PrivateKey.ExportRSAPrivateKeyPem());
_logger.LogInformation("Certificate and Key exists and valid. Restored from cache.");
}
else {
//create new orders
#region LetsEncrypt new order
_logger.LogInformation("2. Client New Order...");
var (orders, newOrderResult) = await _letsEncryptService.NewOrder(site.Hosts, site.Challenge);
if (!newOrderResult.IsSuccess || orders == null) {
continue;
}
#endregion
if (orders.Count > 0) {
switch (site.Challenge) {
case "http-01": {
//ensure to enable static file discovery on server in .well-known/acme-challenge
//and listen on 80 port
foreach (FileInfo file in new DirectoryInfo(acmePath).GetFiles())
file.Delete();
foreach (var result in orders) {
Console.WriteLine($"Key: {result.Key}, Value: {result.Value}");
string[] splitToken = result.Value.Split('.');
File.WriteAllText(Path.Combine(acmePath, splitToken[0]), result.Value);
}
foreach (FileInfo file in new DirectoryInfo(acmePath).GetFiles()) {
if (env?.SSH?.Active ?? false) {
UploadFiles(_logger, env.SSH, env.ACME.Linux.Path, file.Name, File.ReadAllBytes(file.FullName), env.ACME.Linux.Owner, env.ACME.Linux.ChangeMode);
}
else {
throw new NotImplementedException();
}
}
break;
}
case "dns-01": {
//Manage DNS server MX record, depends from provider
throw new NotImplementedException();
}
default: {
throw new NotImplementedException();
}
}
#region LetsEncrypt complete challenges
_logger.LogInformation("3. Client Complete Challange...");
var completeChallengesResult = await _letsEncryptService.CompleteChallenges();
if (!completeChallengesResult.IsSuccess) {
continue;
}
_logger.LogInformation("Challanges comleted.");
#endregion
await Task.Delay(1000);
#region Download new certificate
_logger.LogInformation("4. Download certificate...");
var (certData, getCertResult) = await _letsEncryptService.GetCertificate(site.Name);
if (!getCertResult.IsSuccess || certData == null) {
continue;
}
// not used in this scenario
// var (cert, key) = certData.Value;
#endregion
#region Persist cache
registrationCache = _letsEncryptService.GetRegistrationCache();
File.WriteAllText(cacheFile, registrationCache.ToJson());
#endregion
}
#region Save cert and key to filesystem
certRes = new CachedCertificateResult();
if (registrationCache.TryGetCachedCertificate(site.Name, out certRes)) {
File.WriteAllText(Path.Combine(sslPath, $"{site.Name}.crt"), certRes.Certificate);
if (certRes.PrivateKey != null)
File.WriteAllText(Path.Combine(sslPath, $"{site.Name}.key"), certRes.PrivateKey.ExportRSAPrivateKeyPem());
_logger.LogInformation("Certificate saved.");
foreach (FileInfo file in new DirectoryInfo(sslPath).GetFiles()) {
if (env?.SSH?.Active ?? false) {
UploadFiles(_logger, env.SSH, $"{env.SSL.Linux.Path}/{site.Name}", file.Name, File.ReadAllBytes(file.FullName), env.SSL.Linux.Owner, env.SSL.Linux.ChangeMode);
}
else {
throw new NotImplementedException();
}
}
}
else {
_logger.LogError("Unable to get new cached certificate.");
}
#endregion
}
}
}
}
_logger.LogInformation($"Let's Encrypt client. Execution complete.");
}
catch (Exception ex) {
_logger.LogError(ex, $"Let's Encrypt client. Unhandled exception.");
}
}
private void UploadFiles(
ILogger logger,
SSHClientSettings sshSettings,
string workDir,
string fileName,
byte[] bytes,
string owner,
string changeMode
) {
using var sshService = new SSHService(logger, sshSettings.Host, sshSettings.Port, sshSettings.Username, sshSettings.Password);
sshService.Connect();
sshService.RunSudoCommand(sshSettings.Password, $"mkdir {workDir}");
sshService.RunSudoCommand(sshSettings.Password, $"chown {owner} {workDir} -R");
sshService.RunSudoCommand(sshSettings.Password, $"chmod 777 {workDir} -R");
sshService.Upload($"{workDir}", fileName, bytes);
sshService.RunSudoCommand(sshSettings.Password, $"chown {owner} {workDir} -R");
sshService.RunSudoCommand(sshSettings.Password, $"chmod {changeMode} {workDir} -R");
//sshService.RunSudoCommand(sshSettings.Password, $"systemctl restart nginx");
}
}

View File

@ -1,71 +0,0 @@
namespace MaksIT.LetsEncryptConsole;
public class Configuration {
public LetsEncryptEnvironment[]? Environments { get; set; }
public Customer[]? Customers { get; set; }
}
public class OsWindows {
public string? Path { get; set; }
}
public class OsLinux {
public string? Path { get; set; }
public string? Owner { get; set; }
public string? ChangeMode { get; set; }
}
public class OsDependant {
public OsWindows? Windows { get; set; }
public OsLinux? Linux { get; set; }
}
public class SSHClientSettings {
public bool Active { get; set; }
public string? Host { get; set; }
public int Port { get; set; }
public string? Username { get; set; }
public string? Password { get; set; }
}
public class LetsEncryptEnvironment {
public bool Active { get; set; }
public string? Name { get; set; }
public string? Url { get; set; }
public OsDependant? ACME { get; set; }
public OsDependant? SSL { get; set; }
public SSHClientSettings? SSH { 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 Site {
public bool Active { get; set; }
public string? Name { get; set; }
public string[]? Hosts { get; set; }
public string? Challenge { get; set; }
}

View File

@ -1,44 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>MaksIT.$(MSBuildProjectName.Replace(" ", "_"))</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="7.0.1" />
<PackageReference Include="Microsoft.Extensions.Http" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="7.0.0" />
<PackageReference Include="Serilog.Enrichers.Span" Version="3.1.0" />
<PackageReference Include="Serilog.Expressions" Version="3.4.1" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="7.0.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="7.0.0" />
<PackageReference Include="Serilog.Formatting.Compact" Version="1.1.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="7.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="4.1.0" />
<PackageReference Include="Mono.Posix.NETStandard" Version="1.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Core\Core.csproj" />
<ProjectReference Include="..\LetsEncrypt\LetsEncrypt.csproj" />
<ProjectReference Include="..\SSHProvider\SSHProvider.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@ -1,69 +0,0 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Serilog;
using MaksIT.LetsEncryptConsole.Services;
using MaksIT.LetsEncrypt.Extensions;
namespace MaksIT.LetsEncryptConsole;
class Program {
private static readonly IConfiguration _configuration = InitConfig();
static void Main(string[] args) {
// create service collection
var services = new ServiceCollection();
ConfigureServices(services);
// create service provider
var serviceProvider = services.BuildServiceProvider();
// entry to run app
#pragma warning disable CS8602 // Dereference of a possibly null reference.
var app = serviceProvider.GetService<App>();
app.Run(args).Wait();
#pragma warning restore CS8602 // Dereference of a possibly null reference.
}
public static void ConfigureServices(IServiceCollection services) {
var configurationSection = _configuration.GetSection("Configuration");
services.Configure<Configuration>(configurationSection);
var appSettings = configurationSection.Get<Configuration>();
#region Configure logging
services.AddLogging(configure => {
configure.AddSerilog(new LoggerConfiguration()
.ReadFrom.Configuration(_configuration)
.CreateLogger());
});
#endregion
#region Services
services.RegisterLetsEncrypt();
services.AddSingleton<ITerminalService, TerminalService>();
#endregion
// add app
services.AddSingleton<App>();
}
private static IConfiguration InitConfig() {
var aspNetCoreEnvironment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
var configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddEnvironmentVariables();
if (!string.IsNullOrWhiteSpace(aspNetCoreEnvironment)
&& new FileInfo(Path.Combine(Directory.GetCurrentDirectory(), $"appsettings.{aspNetCoreEnvironment}.json")).Exists
)
configuration.AddJsonFile($"appsettings.{aspNetCoreEnvironment}.json", true);
else
configuration.AddJsonFile($"appsettings.json", true, true);
return configuration.Build();
}
}

View File

@ -1,140 +0,0 @@
#ACMEv2 Client library
https://tools.ietf.org/html/draft-ietf-acme-acme-18
The following diagram illustrates the relations between resources on
an ACME server. For the most part, these relations are expressed by
URLs provided as strings in the resources' JSON representations.
Lines with labels in quotes indicate HTTP link relations.
directory
|
+--> new-nonce
|
+----------+----------+-----+-----+------------+
| | | | |
| | | | |
V V V V V
newAccount newAuthz newOrder revokeCert keyChange
| | |
| | |
V | V
account | order -----> cert
| |
| |
| V
+------> authz
| ^
| | "up"
V |
challenge
+-------------------+--------------------------------+--------------+
| Action | Request | Response |
+-------------------+--------------------------------+--------------+
| Get directory | GET directory | 200 |
| | | |
| Get nonce | HEAD newNonce | 200 |
| | | |
| Create account | POST newAccount | 201 -> |
| | | account |
| | | |
| Submit order | POST newOrder | 201 -> order |
| | | |
| Fetch challenges | POST-as-GET order's | 200 |
| | authorization urls | |
| | | |
| Respond to | POST authorization challenge | 200 |
| challenges | urls | |
| | | |
| Poll for status | POST-as-GET order | 200 |
| | | |
| Finalize order | POST order's finalize url | 200 |
| | | |
| Poll for status | POST-as-GET order | 200 |
| | | |
| Download | POST-as-GET order's | 200 |
| certificate | certificate url | |
+-------------------+--------------------------------+--------------+
pending
|
| Receive
| response
V
processing <-+
| | | Server retry or
| | | client retry request
| +----+
|
|
Successful | Failed
validation | validation
+---------+---------+
| |
V V
valid invalid
https://community.letsencrypt.org/t/acme-client-finalized-order-stuck-on-ready-state/165196
The following table illustrates a typical sequence of requests
required to establish a new account with the server, prove control of
an identifier, issue a certificate, and fetch an updated certificate
some time after issuance. The "->" is a mnemonic for a Location
header field pointing to a created resource.
+-------------------+--------------------------------+--------------+
| Action | Request | Response |
+-------------------+--------------------------------+--------------+
| Get directory | GET directory | 200 |
| | | |
| Get nonce | HEAD newNonce | 200 |
| | | |
| Create account | POST newAccount | 201 -> |
| | | account |
| | | |
| Submit order | POST newOrder | 201 -> order |
| | | |
| Fetch challenges | POST-as-GET order's | 200 |
| | authorization urls | |
| | | |
| Respond to | POST authorization challenge | 200 |
| challenges | urls | |
| | | |
| Poll for status | POST-as-GET order | 200 |
| | | |
| Finalize order | POST order's finalize url | 200 |
| | | |
| Poll for status | POST-as-GET order | 200 |
| | | |
| Download | POST-as-GET order's | 200 |
| certificate | certificate url | |
+-------------------+--------------------------------+--------------+
|Level|Usage|
|-----|-----|
|Verbose|Verbose is the noisiest level, rarely (if ever) enabled for a production app.|
|Debug|Debug is used for internal system events that are not necessarily observable from the outside, but useful when determining how something happened.|
|Information|Information events describe things happening in the system that correspond to its responsibilities and functions. Generally these are the observable actions the system can perform.|
|Warning|When service is degraded, endangered, or may be behaving outside of its expected parameters, Warning level events are used.|
|Error|When functionality is unavailable or expectations broken, an Error event is used.|
|Fatal|The most critical level, Fatal events demand immediate attention.|

View File

@ -1,28 +0,0 @@
using System.Diagnostics;
namespace MaksIT.LetsEncryptConsole.Services;
public interface ITerminalService {
void Exec(string cmd);
}
public class TerminalService : ITerminalService {
public void Exec(string cmd) {
var escapedArgs = cmd.Replace("\"", "\\\"");
var pc = new Process {
StartInfo = new ProcessStartInfo {
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden,
FileName = "/bin/bash",
Arguments = $"-c \"{escapedArgs}\""
}
};
pc.Start();
pc.WaitForExit();
}
}

View File

@ -1,150 +0,0 @@
{
"Serilog": {
"Using": [ "Serilog.Settings.Configuration", "Serilog.Expressions", "Serilog.Sinks.Console" ],
"MinimumLevel": "Information",
"Enrich": [ "FromLogContext", "WithMachineName", "WithProcessId", "WithThreadId" ],
"WriteTo": [
{
"Name": "Console",
"Args": {
"restrictedToMinimumLevel": "Information",
//"formatter": "Serilog.Formatting.Compact.RenderedCompactJsonFormatter, Serilog.Formatting.Compact"
"theme": "Serilog.Sinks.SystemConsole.Themes.AnsiConsoleTheme::Code, Serilog.Sinks.Console",
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} <s:{SourceContext}>{NewLine}{Exception}"
}
}
]
},
"Configuration": {
"Environments": [
{
"Active": false,
"Name": "StagingV2",
"Url": "https://acme-staging-v02.api.letsencrypt.org/directory",
"Cache": "staging_cache",
"ACME": {
"Linux": {
"Path": "/var/www/html/acme-challenge",
"Ower": "nginx:nginx",
"ChangeMode": "775"
},
"Windows": {
"Path": "C:\\inetpub\\www\\acme-challenge"
}
},
"SSL": {
"Linux": {
"Path": "/var/www/ssl/staging",
"Owner": "nginx:nginx",
"ChangeMode": "775"
},
"Windows": {
"Path": "C:\\inetpub\\www\\ssl\\staging"
}
},
"SSH": {
"Active": true,
"Host": "192.168.2.10",
"Port": 22,
"Username": "",
"Password": ""
}
},
{
"Active": true,
"Name": "ProductionV2",
"Url": "https://acme-v02.api.letsencrypt.org/directory",
"Cache": "production_cache",
"ACME": {
"Linux": {
"Path": "/var/www/html/acme-challenge",
"Owner": "nginx:nginx",
"ChangeMode": "775"
},
"Windows": {
"Path": "C:\\inetpub\\www\\acme-challenge"
}
},
"SSL": {
"Linux": {
"Path": "/var/www/ssl",
"Owner": "nginx:nginx",
"ChangeMode": "775"
},
"Windows": {
"Path": "C:\\inetpub\\www\\ssl"
}
},
"SSH": {
"Active": true,
"Host": "192.168.2.10",
"Port": 22,
"Username": "",
"Password": ""
}
}
],
"Customers": [
{
"Id": "9b4c8584-dc83-4388-b45f-2942e34dca9d",
"Active": true,
"Contacts": [ "maksym.sadovnychyy@gmail.com" ],
"Name": "Maksym",
"LastName": "Sadovnychyy",
"Sites": [
{
"Active": true,
"Name": "maks-it.com",
"Hosts": [
"maks-it.com",
"www.maks-it.com",
"git.maks-it.com",
"www.git.maks-it.com",
"hcr.maks-it.com",
"www.hcr.maks-it.com",
"vlt.maks-it.com",
"www.vlt.maks-it.com",
"obj.maks-it.com",
"www.obj.maks-it.com",
"s3.maks-it.com",
"www.s3.maks-it.com"
],
"Challenge": "http-01"
}
]
},
{
"Id": "46337ef5-d69b-4332-b6ef-67959dfb3c2c",
"Active": false,
"Contacts": [
"maksym.sadovnychyy@gmail.com",
"anastasiia.pavlovskaia@gmail.com"
],
"Name": "Anastasiia",
"LastName": "Pavlovskaia",
"Sites": [
{
"Active": true,
"Name": "nastyarey.com",
"Hosts": [
"nastyarey.com",
"www.nastyarey.com"
],
"Challenge": "http-01"
}
]
}
]
}
}