From d046bcecd93ce75fa0a78970de17c8b29439edf0 Mon Sep 17 00:00:00 2001 From: Maksym Sadovnychyy Date: Fri, 7 Jun 2024 18:41:11 +0200 Subject: [PATCH] (feature): auto renewal service implementation --- src/Core/Extensions/StringExtensions.cs | 16 +++ .../Entities/LetsEncrypt/RegistrationCache.cs | 45 +++--- .../Services/LetsEncryptService.cs | 19 ++- .../BackgroundServices/AutoRenewal.cs | 131 +++++++++++++++++- .../Controllers/CertsFlowController.cs | 13 +- src/LetsEncryptServer/Program.cs | 5 +- .../Services/CacheService.cs | 26 +++- .../Services/CertsFlowService.cs | 27 +++- src/LetsEncryptServer/appsettings.json | 2 +- ...Encrypt Production.postman_collection.json | 32 +++++ ...etsEncrypt Staging.postman_collection.json | 32 +++++ 11 files changed, 307 insertions(+), 41 deletions(-) diff --git a/src/Core/Extensions/StringExtensions.cs b/src/Core/Extensions/StringExtensions.cs index fb178e3..bdd5fb8 100644 --- a/src/Core/Extensions/StringExtensions.cs +++ b/src/Core/Extensions/StringExtensions.cs @@ -36,4 +36,20 @@ public static class StringExtensions { ? JsonSerializer.Deserialize(s, options) : default; } + + public static Guid? ToNullabeGuid(this string? s) { + if (Guid.TryParse(s, out var result)) { + return result; + } + + return null; + } + + public static Guid ToGuid(this string s) { + if (Guid.TryParse(s, out var result)) { + return result; + } + + return Guid.Empty; + } } diff --git a/src/LetsEncrypt/Entities/LetsEncrypt/RegistrationCache.cs b/src/LetsEncrypt/Entities/LetsEncrypt/RegistrationCache.cs index 1228735..5c11d7e 100644 --- a/src/LetsEncrypt/Entities/LetsEncrypt/RegistrationCache.cs +++ b/src/LetsEncrypt/Entities/LetsEncrypt/RegistrationCache.cs @@ -1,8 +1,8 @@ -using System; -using System.Collections.Generic; -using System.Security.Cryptography.X509Certificates; -using System.Security.Cryptography; + using System.Text; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + using MaksIT.LetsEncrypt.Entities.Jws; namespace MaksIT.LetsEncrypt.Entities; @@ -17,6 +17,7 @@ public class RegistrationCache { /// Field used to identify cache by account id /// public Guid AccountId { get; set; } + public string[]? Contacts { get; set; } public Dictionary? CachedCerts { get; set; } @@ -28,29 +29,25 @@ public class RegistrationCache { /// /// Returns a list of hosts with upcoming SSL expiry /// - public string[] HostsWithUpcomingSslExpiry { - - get { - - var hostsWithUpcomingSslExpiry = new List(); - - if (CachedCerts == null) - return hostsWithUpcomingSslExpiry.ToArray(); - - foreach (var result in CachedCerts) { - var (subject, cachedChert) = result; - - if (cachedChert.Cert != null) { - var cert = new X509Certificate2(Encoding.ASCII.GetBytes(cachedChert.Cert)); - - // if it is about to expire, we need to refresh - if ((cert.NotAfter - DateTime.UtcNow).TotalDays < 30) - hostsWithUpcomingSslExpiry.Add(subject); - } - } + public string[] GetHostsWithUpcomingSslExpiry(int days = 30) { + var hostsWithUpcomingSslExpiry = new List(); + if (CachedCerts == null) return hostsWithUpcomingSslExpiry.ToArray(); + + foreach (var result in CachedCerts) { + var (subject, cachedChert) = result; + + if (cachedChert.Cert != null) { + var cert = new X509Certificate2(Encoding.ASCII.GetBytes(cachedChert.Cert)); + + // if it is about to expire, we need to refresh + if ((cert.NotAfter - DateTime.UtcNow).TotalDays < days) + hostsWithUpcomingSslExpiry.Add(subject); + } } + + return hostsWithUpcomingSslExpiry.ToArray(); } /// diff --git a/src/LetsEncrypt/Services/LetsEncryptService.cs b/src/LetsEncrypt/Services/LetsEncryptService.cs index f45a593..72f0cd4 100644 --- a/src/LetsEncrypt/Services/LetsEncryptService.cs +++ b/src/LetsEncrypt/Services/LetsEncryptService.cs @@ -25,6 +25,7 @@ public interface ILetsEncryptService { Task CompleteChallenges(Guid sessionId); Task GetOrder(Guid sessionId, string[] hostnames); Task GetCertificate(Guid sessionId, string subject); + (string[]?, IDomainResult) HostsWithUpcomingSslExpiry(Guid sessionId); (CachedCertificateResult?, IDomainResult) TryGetCachedCertificate(Guid sessionId, string subject); } @@ -128,6 +129,7 @@ public class LetsEncryptService : ILetsEncryptService { state.Cache = new RegistrationCache { AccountId = accountId, + Contacts = contacts, Location = account.Result.Location, AccountKey = accountKey.ExportCspBlob(true), @@ -432,6 +434,15 @@ public class LetsEncryptService : ILetsEncryptService { #endregion #region TryGetCachedCertificate + public (string[]?, IDomainResult) HostsWithUpcomingSslExpiry(Guid sessionId) { + + var state = GetOrCreateState(sessionId); + if (state.Cache == null) + return IDomainResult.Failed(); + + return IDomainResult.Success(state.Cache.GetHostsWithUpcomingSslExpiry()); + } + public (CachedCertificateResult?, IDomainResult) TryGetCachedCertificate(Guid sessionId, string subject) { var state = GetOrCreateState(sessionId); @@ -567,10 +578,10 @@ public class LetsEncryptService : ILetsEncryptService { } var response = await _httpClient.SendAsync(request); - await UpdateStateNonceIfNeededAsync(response, state, method); + UpdateStateNonceIfNeededAsync(response, state, method); var responseText = await response.Content.ReadAsStringAsync(); - await HandleProblemResponseAsync(response, responseText); + HandleProblemResponseAsync(response, responseText); var result = ProcessResponseContent(response, responseText); return IDomainResult.Success(result); @@ -634,13 +645,13 @@ public class LetsEncryptService : ILetsEncryptService { request.Content.Headers.ContentType = new MediaTypeHeaderValue(contentType); } - private async Task UpdateStateNonceIfNeededAsync(HttpResponseMessage response, State state, HttpMethod method) { + private void UpdateStateNonceIfNeededAsync(HttpResponseMessage response, State state, HttpMethod method) { if (method == HttpMethod.Post && response.Headers.Contains("Replay-Nonce")) { state.Nonce = response.Headers.GetValues("Replay-Nonce").First(); } } - private async Task HandleProblemResponseAsync(HttpResponseMessage response, string responseText) { + private void HandleProblemResponseAsync(HttpResponseMessage response, string responseText) { if (response.Content.Headers.ContentType?.MediaType == "application/problem+json") { throw new LetsEncrytException(responseText.ToObject(), response); } diff --git a/src/LetsEncryptServer/BackgroundServices/AutoRenewal.cs b/src/LetsEncryptServer/BackgroundServices/AutoRenewal.cs index 5a14e10..657fe6a 100644 --- a/src/LetsEncryptServer/BackgroundServices/AutoRenewal.cs +++ b/src/LetsEncryptServer/BackgroundServices/AutoRenewal.cs @@ -1,17 +1,136 @@ -namespace LetsEncryptServer.BackgroundServices { +using DomainResults.Common; +using MaksIT.LetsEncryptServer.Services; +using MaksIT.Models.LetsEncryptServer.Requests; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace MaksIT.LetsEncryptServer.BackgroundServices { public class AutoRenewal : BackgroundService { + + private readonly IOptions _appSettings; + private readonly ILogger _logger; + private readonly ICacheService _cacheService; + private readonly ICertsFlowService _certsFlowService; + + public AutoRenewal( + IOptions appSettings, + ILogger logger, + ICacheService cacheService, + ICertsFlowService certsFlowService + ) { + _appSettings = appSettings; + _logger = logger; + _cacheService = cacheService; + _certsFlowService = certsFlowService; + } + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { - // Your background task logic here - Console.WriteLine("Background service is running."); + _logger.LogInformation("Background service is running."); - // Simulate some work by delaying for 5 seconds - await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); + var (accountIds, getAccountIdsResult) = await _cacheService.ListCachedAccountsAsync(); + if (!getAccountIdsResult.IsSuccess || accountIds == null) { + LogErrors(getAccountIdsResult.Errors); + continue; + } + + foreach (var accountId in accountIds) { + await ProcessAccountAsync(accountId); + } + + await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken); + } + } + + private async Task ProcessAccountAsync(Guid accountId) { + var (cache, loadResult) = await _cacheService.LoadFromCacheAsync(accountId); + if (!loadResult.IsSuccess || cache == null) { + LogErrors(loadResult.Errors); + return loadResult; + } + + var hostnames = cache.GetHostsWithUpcomingSslExpiry(); + if (hostnames == null || !hostnames.Any()) { + _logger.LogError("No hosts found with upcoming SSL expiry"); + return IDomainResult.Success(); + } + + var renewResult = await RenewCertificatesForHostnames(accountId, cache.Contacts, hostnames); + if (!renewResult.IsSuccess) + return renewResult; + + _logger.LogInformation($"Certificates renewed for account {accountId}"); + + return IDomainResult.Success(); + } + + private async Task RenewCertificatesForHostnames(Guid accountId, string[] contacts, string[] hostnames) { + var (sessionId, configureClientResult) = await _certsFlowService.ConfigureClientAsync(); + if (!configureClientResult.IsSuccess || sessionId == null) { + LogErrors(configureClientResult.Errors); + return configureClientResult; + } + + var sessionIdValue = sessionId.Value; + + var (_, initResult) = await _certsFlowService.InitAsync(sessionIdValue, accountId, new InitRequest { + Contacts = contacts + }); + if (!initResult.IsSuccess) { + LogErrors(initResult.Errors); + return initResult; + } + + var (_, newOrderResult) = await _certsFlowService.NewOrderAsync(sessionIdValue, new NewOrderRequest { + Hostnames = hostnames, + ChallengeType = "http-01" + }); + if (!newOrderResult.IsSuccess) { + LogErrors(newOrderResult.Errors); + return newOrderResult; + } + + var challengeResult = await _certsFlowService.CompleteChallengesAsync(sessionIdValue); + if (!challengeResult.IsSuccess) { + LogErrors(challengeResult.Errors); + return challengeResult; + } + + var getOrderResult = await _certsFlowService.GetOrderAsync(sessionIdValue, new GetOrderRequest { + Hostnames = hostnames + }); + if (!getOrderResult.IsSuccess) { + LogErrors(getOrderResult.Errors); + return getOrderResult; + } + + var certs = await _certsFlowService.GetCertificatesAsync(sessionIdValue, new GetCertificatesRequest { + Hostnames = hostnames + }); + if (!certs.IsSuccess) { + LogErrors(certs.Errors); + return certs; + } + + var (_, applyCertsResult) = await _certsFlowService.ApplyCertificatesAsync(sessionIdValue, new GetCertificatesRequest { + Hostnames = hostnames + }); + if (!applyCertsResult.IsSuccess) { + LogErrors(applyCertsResult.Errors); + return applyCertsResult; + } + + return IDomainResult.Success(); + } + + private void LogErrors(IEnumerable errors) { + foreach (var error in errors) { + _logger.LogError(error); } } public override Task StopAsync(CancellationToken stoppingToken) { - Console.WriteLine("Background service is stopping."); + _logger.LogInformation("Background service is stopping."); return base.StopAsync(stoppingToken); } } diff --git a/src/LetsEncryptServer/Controllers/CertsFlowController.cs b/src/LetsEncryptServer/Controllers/CertsFlowController.cs index d6efd49..7066997 100644 --- a/src/LetsEncryptServer/Controllers/CertsFlowController.cs +++ b/src/LetsEncryptServer/Controllers/CertsFlowController.cs @@ -108,7 +108,18 @@ public class CertsFlowController : ControllerBase { /// [HttpPost("[action]/{sessionId}")] public async Task ApplyCertificates(Guid sessionId, [FromBody] GetCertificatesRequest requestData) { - var result = await _certsFlowService.ApplyCertificates(sessionId, requestData); + var result = await _certsFlowService.ApplyCertificatesAsync(sessionId, requestData); + return result.ToActionResult(); + } + + /// + /// Returns a list of hosts with upcoming SSL expiry + /// + /// + /// + [HttpGet("[action]/{sessionId}")] + public IActionResult HostsWithUpcomingSslExpiry(Guid sessionId) { + var result = _certsFlowService.HostsWithUpcomingSslExpiry(sessionId); return result.ToActionResult(); } } diff --git a/src/LetsEncryptServer/Program.cs b/src/LetsEncryptServer/Program.cs index 0bb7cf3..3e6fb43 100644 --- a/src/LetsEncryptServer/Program.cs +++ b/src/LetsEncryptServer/Program.cs @@ -1,7 +1,7 @@ using MaksIT.LetsEncryptServer; using MaksIT.LetsEncrypt.Services; -using Microsoft.Extensions.DependencyInjection; using MaksIT.LetsEncryptServer.Services; +using MaksIT.LetsEncryptServer.BackgroundServices; var builder = WebApplication.CreateBuilder(args); @@ -26,9 +26,10 @@ builder.Services.AddSwaggerGen(); builder.Services.AddMemoryCache(); builder.Services.AddHttpClient(); -builder.Services.AddScoped(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHttpClient(); +builder.Services.AddHostedService(); var app = builder.Build(); diff --git a/src/LetsEncryptServer/Services/CacheService.cs b/src/LetsEncryptServer/Services/CacheService.cs index e9f3de8..df1a3b1 100644 --- a/src/LetsEncryptServer/Services/CacheService.cs +++ b/src/LetsEncryptServer/Services/CacheService.cs @@ -1,7 +1,7 @@ using System.Text.Json; using DomainResults.Common; - +using MaksIT.Core.Extensions; using MaksIT.LetsEncrypt.Entities; namespace MaksIT.LetsEncryptServer.Services; @@ -10,6 +10,7 @@ public interface ICacheService { Task<(RegistrationCache?, IDomainResult)> LoadFromCacheAsync(Guid accountId); Task SaveToCacheAsync(Guid accountId, RegistrationCache cache); Task DeleteFromCacheAsync(Guid accountId); + Task<(Guid[]?, IDomainResult)> ListCachedAccountsAsync(); } public class CacheService : ICacheService, IDisposable { @@ -117,6 +118,29 @@ public class CacheService : ICacheService, IDisposable { } } + public async Task<(Guid[]?, IDomainResult)> ListCachedAccountsAsync() { + await _cacheLock.WaitAsync(); + + try { + var cacheFiles = Directory.GetFiles(_cacheDirectory); + if (cacheFiles == null) + return IDomainResult.Success(new Guid[0]); + + var accountIds = cacheFiles.Select(x => Path.GetFileNameWithoutExtension(x).ToGuid()).ToArray(); + + return IDomainResult.Success(accountIds); + } + catch (Exception ex) { + var message = "Error listing cache files"; + _logger.LogError(ex, message); + + return IDomainResult.Failed (message); + } + finally { + _cacheLock.Release(); + } + } + public void Dispose() { _cacheLock?.Dispose(); } diff --git a/src/LetsEncryptServer/Services/CertsFlowService.cs b/src/LetsEncryptServer/Services/CertsFlowService.cs index ddb3be9..09bc360 100644 --- a/src/LetsEncryptServer/Services/CertsFlowService.cs +++ b/src/LetsEncryptServer/Services/CertsFlowService.cs @@ -23,7 +23,8 @@ public interface ICertsFlowService : ICertsFlowServiceBase { Task CompleteChallengesAsync(Guid sessionId); Task GetOrderAsync(Guid sessionId, GetOrderRequest requestData); Task GetCertificatesAsync(Guid sessionId, GetCertificatesRequest requestData); - Task<(Dictionary?, IDomainResult)> ApplyCertificates(Guid sessionId, GetCertificatesRequest requestData); + Task<(Dictionary?, IDomainResult)> ApplyCertificatesAsync(Guid sessionId, GetCertificatesRequest requestData); + (string[]?, IDomainResult) HostsWithUpcomingSslExpiry(Guid sessionId); } public class CertsFlowService : ICertsFlowService { @@ -140,7 +141,7 @@ public class CertsFlowService : ICertsFlowService { return IDomainResult.Success(); } - public async Task<(Dictionary?, IDomainResult)> ApplyCertificates(Guid sessionId, GetCertificatesRequest requestData) { + public async Task<(Dictionary?, IDomainResult)> ApplyCertificatesAsync(Guid sessionId, GetCertificatesRequest requestData) { var results = new Dictionary(); foreach (var subject in requestData.Hostnames) { @@ -164,6 +165,28 @@ public class CertsFlowService : ICertsFlowService { return IDomainResult.Success(results); } + + public (string[]?, IDomainResult) HostsWithUpcomingSslExpiry(Guid sessionId) { + + var (hostnames, hostnamesResult) = _letsEncryptService.HostsWithUpcomingSslExpiry(sessionId); + if(!hostnamesResult.IsSuccess) + return (null, hostnamesResult); + + return IDomainResult.Success(hostnames); + } + + + + + + + + + + + + + public (string?, IDomainResult) AcmeChallenge(string fileName) { DeleteExporedChallenges(); diff --git a/src/LetsEncryptServer/appsettings.json b/src/LetsEncryptServer/appsettings.json index cff203a..8726755 100644 --- a/src/LetsEncryptServer/appsettings.json +++ b/src/LetsEncryptServer/appsettings.json @@ -11,7 +11,7 @@ "Production": "https://acme-v02.api.letsencrypt.org/directory", "Staging": "https://acme-staging-v02.api.letsencrypt.org/directory", - "DevMode": true, + "DevMode": false, "Agent": { "AgentHostname": "http://lblsrv0001.corp.maks-it.com", diff --git a/src/Postman/LetsEncrypt Production.postman_collection.json b/src/Postman/LetsEncrypt Production.postman_collection.json index 609661c..d88c4cf 100644 --- a/src/Postman/LetsEncrypt Production.postman_collection.json +++ b/src/Postman/LetsEncrypt Production.postman_collection.json @@ -474,6 +474,38 @@ } }, "response": [] + }, + { + "name": "host with upcoming ssl expire", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "disabled": true + }, + { + "key": "Accept", + "value": "application/json", + "disabled": true + } + ], + "url": { + "raw": "http://localhost:8080/CertsFlow/HostsWithUpcomingSslExpiry/{{sessionId}}", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "CertsFlow", + "HostsWithUpcomingSslExpiry", + "{{sessionId}}" + ] + } + }, + "response": [] } ] } \ No newline at end of file diff --git a/src/Postman/LetsEncrypt Staging.postman_collection.json b/src/Postman/LetsEncrypt Staging.postman_collection.json index 51962ce..d3eb036 100644 --- a/src/Postman/LetsEncrypt Staging.postman_collection.json +++ b/src/Postman/LetsEncrypt Staging.postman_collection.json @@ -476,6 +476,38 @@ } }, "response": [] + }, + { + "name": "host with upcoming ssl expire Copy", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "disabled": true + }, + { + "key": "Accept", + "value": "application/json", + "disabled": true + } + ], + "url": { + "raw": "http://localhost:8080/CertsFlow/HostsWithUpcomingSslExpiry/{{sessionId}}", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "CertsFlow", + "HostsWithUpcomingSslExpiry", + "{{sessionId}}" + ] + } + }, + "response": [] } ] } \ No newline at end of file