(feature): auto renewal service implementation

This commit is contained in:
Maksym Sadovnychyy 2024-06-07 18:41:11 +02:00
parent 7378996d19
commit d046bcecd9
11 changed files with 307 additions and 41 deletions

View File

@ -36,4 +36,20 @@ public static class StringExtensions {
? JsonSerializer.Deserialize<T>(s, options) ? JsonSerializer.Deserialize<T>(s, options)
: default; : 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;
}
} }

View File

@ -1,8 +1,8 @@
using System; 
using System.Collections.Generic;
using System.Security.Cryptography.X509Certificates;
using System.Security.Cryptography;
using System.Text; using System.Text;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using MaksIT.LetsEncrypt.Entities.Jws; using MaksIT.LetsEncrypt.Entities.Jws;
namespace MaksIT.LetsEncrypt.Entities; namespace MaksIT.LetsEncrypt.Entities;
@ -17,6 +17,7 @@ public class RegistrationCache {
/// Field used to identify cache by account id /// Field used to identify cache by account id
/// </summary> /// </summary>
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
public string[]? Contacts { get; set; }
public Dictionary<string, CertificateCache>? CachedCerts { get; set; } public Dictionary<string, CertificateCache>? CachedCerts { get; set; }
@ -28,29 +29,25 @@ public class RegistrationCache {
/// <summary> /// <summary>
/// Returns a list of hosts with upcoming SSL expiry /// Returns a list of hosts with upcoming SSL expiry
/// </summary> /// </summary>
public string[] HostsWithUpcomingSslExpiry { public string[] GetHostsWithUpcomingSslExpiry(int days = 30) {
var hostsWithUpcomingSslExpiry = new List<string>();
get {
var hostsWithUpcomingSslExpiry = new List<string>();
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);
}
}
if (CachedCerts == null)
return hostsWithUpcomingSslExpiry.ToArray(); 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();
} }
/// <summary> /// <summary>

View File

@ -25,6 +25,7 @@ public interface ILetsEncryptService {
Task<IDomainResult> CompleteChallenges(Guid sessionId); Task<IDomainResult> CompleteChallenges(Guid sessionId);
Task<IDomainResult> GetOrder(Guid sessionId, string[] hostnames); Task<IDomainResult> GetOrder(Guid sessionId, string[] hostnames);
Task<IDomainResult> GetCertificate(Guid sessionId, string subject); Task<IDomainResult> GetCertificate(Guid sessionId, string subject);
(string[]?, IDomainResult) HostsWithUpcomingSslExpiry(Guid sessionId);
(CachedCertificateResult?, IDomainResult) TryGetCachedCertificate(Guid sessionId, string subject); (CachedCertificateResult?, IDomainResult) TryGetCachedCertificate(Guid sessionId, string subject);
} }
@ -128,6 +129,7 @@ public class LetsEncryptService : ILetsEncryptService {
state.Cache = new RegistrationCache { state.Cache = new RegistrationCache {
AccountId = accountId, AccountId = accountId,
Contacts = contacts,
Location = account.Result.Location, Location = account.Result.Location,
AccountKey = accountKey.ExportCspBlob(true), AccountKey = accountKey.ExportCspBlob(true),
@ -432,6 +434,15 @@ public class LetsEncryptService : ILetsEncryptService {
#endregion #endregion
#region TryGetCachedCertificate #region TryGetCachedCertificate
public (string[]?, IDomainResult) HostsWithUpcomingSslExpiry(Guid sessionId) {
var state = GetOrCreateState(sessionId);
if (state.Cache == null)
return IDomainResult.Failed<string[]?>();
return IDomainResult.Success(state.Cache.GetHostsWithUpcomingSslExpiry());
}
public (CachedCertificateResult?, IDomainResult) TryGetCachedCertificate(Guid sessionId, string subject) { public (CachedCertificateResult?, IDomainResult) TryGetCachedCertificate(Guid sessionId, string subject) {
var state = GetOrCreateState(sessionId); var state = GetOrCreateState(sessionId);
@ -567,10 +578,10 @@ public class LetsEncryptService : ILetsEncryptService {
} }
var response = await _httpClient.SendAsync(request); var response = await _httpClient.SendAsync(request);
await UpdateStateNonceIfNeededAsync(response, state, method); UpdateStateNonceIfNeededAsync(response, state, method);
var responseText = await response.Content.ReadAsStringAsync(); var responseText = await response.Content.ReadAsStringAsync();
await HandleProblemResponseAsync(response, responseText); HandleProblemResponseAsync(response, responseText);
var result = ProcessResponseContent<TResult>(response, responseText); var result = ProcessResponseContent<TResult>(response, responseText);
return IDomainResult.Success(result); return IDomainResult.Success(result);
@ -634,13 +645,13 @@ public class LetsEncryptService : ILetsEncryptService {
request.Content.Headers.ContentType = new MediaTypeHeaderValue(contentType); 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")) { if (method == HttpMethod.Post && response.Headers.Contains("Replay-Nonce")) {
state.Nonce = response.Headers.GetValues("Replay-Nonce").First(); 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") { if (response.Content.Headers.ContentType?.MediaType == "application/problem+json") {
throw new LetsEncrytException(responseText.ToObject<Problem>(), response); throw new LetsEncrytException(responseText.ToObject<Problem>(), response);
} }

View File

@ -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 { public class AutoRenewal : BackgroundService {
private readonly IOptions<Configuration> _appSettings;
private readonly ILogger<AutoRenewal> _logger;
private readonly ICacheService _cacheService;
private readonly ICertsFlowService _certsFlowService;
public AutoRenewal(
IOptions<Configuration> appSettings,
ILogger<AutoRenewal> logger,
ICacheService cacheService,
ICertsFlowService certsFlowService
) {
_appSettings = appSettings;
_logger = logger;
_cacheService = cacheService;
_certsFlowService = certsFlowService;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken) { protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
while (!stoppingToken.IsCancellationRequested) { while (!stoppingToken.IsCancellationRequested) {
// Your background task logic here _logger.LogInformation("Background service is running.");
Console.WriteLine("Background service is running.");
// Simulate some work by delaying for 5 seconds var (accountIds, getAccountIdsResult) = await _cacheService.ListCachedAccountsAsync();
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); 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<IDomainResult> 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<IDomainResult> 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<string> errors) {
foreach (var error in errors) {
_logger.LogError(error);
} }
} }
public override Task StopAsync(CancellationToken stoppingToken) { public override Task StopAsync(CancellationToken stoppingToken) {
Console.WriteLine("Background service is stopping."); _logger.LogInformation("Background service is stopping.");
return base.StopAsync(stoppingToken); return base.StopAsync(stoppingToken);
} }
} }

View File

@ -108,7 +108,18 @@ public class CertsFlowController : ControllerBase {
/// <returns></returns> /// <returns></returns>
[HttpPost("[action]/{sessionId}")] [HttpPost("[action]/{sessionId}")]
public async Task<IActionResult> ApplyCertificates(Guid sessionId, [FromBody] GetCertificatesRequest requestData) { public async Task<IActionResult> ApplyCertificates(Guid sessionId, [FromBody] GetCertificatesRequest requestData) {
var result = await _certsFlowService.ApplyCertificates(sessionId, requestData); var result = await _certsFlowService.ApplyCertificatesAsync(sessionId, requestData);
return result.ToActionResult();
}
/// <summary>
/// Returns a list of hosts with upcoming SSL expiry
/// </summary>
/// <param name="sessionId"></param>
/// <returns></returns>
[HttpGet("[action]/{sessionId}")]
public IActionResult HostsWithUpcomingSslExpiry(Guid sessionId) {
var result = _certsFlowService.HostsWithUpcomingSslExpiry(sessionId);
return result.ToActionResult(); return result.ToActionResult();
} }
} }

View File

@ -1,7 +1,7 @@
using MaksIT.LetsEncryptServer; using MaksIT.LetsEncryptServer;
using MaksIT.LetsEncrypt.Services; using MaksIT.LetsEncrypt.Services;
using Microsoft.Extensions.DependencyInjection;
using MaksIT.LetsEncryptServer.Services; using MaksIT.LetsEncryptServer.Services;
using MaksIT.LetsEncryptServer.BackgroundServices;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@ -26,9 +26,10 @@ builder.Services.AddSwaggerGen();
builder.Services.AddMemoryCache(); builder.Services.AddMemoryCache();
builder.Services.AddHttpClient<ILetsEncryptService, LetsEncryptService>(); builder.Services.AddHttpClient<ILetsEncryptService, LetsEncryptService>();
builder.Services.AddScoped<ICertsFlowService, CertsFlowService>(); builder.Services.AddSingleton<ICertsFlowService, CertsFlowService>();
builder.Services.AddSingleton<ICacheService, CacheService>(); builder.Services.AddSingleton<ICacheService, CacheService>();
builder.Services.AddHttpClient<IAgentService, AgentService>(); builder.Services.AddHttpClient<IAgentService, AgentService>();
builder.Services.AddHostedService<AutoRenewal>();
var app = builder.Build(); var app = builder.Build();

View File

@ -1,7 +1,7 @@
using System.Text.Json; using System.Text.Json;
using DomainResults.Common; using DomainResults.Common;
using MaksIT.Core.Extensions;
using MaksIT.LetsEncrypt.Entities; using MaksIT.LetsEncrypt.Entities;
namespace MaksIT.LetsEncryptServer.Services; namespace MaksIT.LetsEncryptServer.Services;
@ -10,6 +10,7 @@ public interface ICacheService {
Task<(RegistrationCache?, IDomainResult)> LoadFromCacheAsync(Guid accountId); Task<(RegistrationCache?, IDomainResult)> LoadFromCacheAsync(Guid accountId);
Task<IDomainResult> SaveToCacheAsync(Guid accountId, RegistrationCache cache); Task<IDomainResult> SaveToCacheAsync(Guid accountId, RegistrationCache cache);
Task<IDomainResult> DeleteFromCacheAsync(Guid accountId); Task<IDomainResult> DeleteFromCacheAsync(Guid accountId);
Task<(Guid[]?, IDomainResult)> ListCachedAccountsAsync();
} }
public class CacheService : ICacheService, IDisposable { 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<Guid[]?> (message);
}
finally {
_cacheLock.Release();
}
}
public void Dispose() { public void Dispose() {
_cacheLock?.Dispose(); _cacheLock?.Dispose();
} }

View File

@ -23,7 +23,8 @@ public interface ICertsFlowService : ICertsFlowServiceBase {
Task<IDomainResult> CompleteChallengesAsync(Guid sessionId); Task<IDomainResult> CompleteChallengesAsync(Guid sessionId);
Task<IDomainResult> GetOrderAsync(Guid sessionId, GetOrderRequest requestData); Task<IDomainResult> GetOrderAsync(Guid sessionId, GetOrderRequest requestData);
Task<IDomainResult> GetCertificatesAsync(Guid sessionId, GetCertificatesRequest requestData); Task<IDomainResult> GetCertificatesAsync(Guid sessionId, GetCertificatesRequest requestData);
Task<(Dictionary<string, string>?, IDomainResult)> ApplyCertificates(Guid sessionId, GetCertificatesRequest requestData); Task<(Dictionary<string, string>?, IDomainResult)> ApplyCertificatesAsync(Guid sessionId, GetCertificatesRequest requestData);
(string[]?, IDomainResult) HostsWithUpcomingSslExpiry(Guid sessionId);
} }
public class CertsFlowService : ICertsFlowService { public class CertsFlowService : ICertsFlowService {
@ -140,7 +141,7 @@ public class CertsFlowService : ICertsFlowService {
return IDomainResult.Success(); return IDomainResult.Success();
} }
public async Task<(Dictionary<string, string>?, IDomainResult)> ApplyCertificates(Guid sessionId, GetCertificatesRequest requestData) { public async Task<(Dictionary<string, string>?, IDomainResult)> ApplyCertificatesAsync(Guid sessionId, GetCertificatesRequest requestData) {
var results = new Dictionary<string, string>(); var results = new Dictionary<string, string>();
foreach (var subject in requestData.Hostnames) { foreach (var subject in requestData.Hostnames) {
@ -164,6 +165,28 @@ public class CertsFlowService : ICertsFlowService {
return IDomainResult.Success(results); 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) { public (string?, IDomainResult) AcmeChallenge(string fileName) {
DeleteExporedChallenges(); DeleteExporedChallenges();

View File

@ -11,7 +11,7 @@
"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",
"DevMode": true, "DevMode": false,
"Agent": { "Agent": {
"AgentHostname": "http://lblsrv0001.corp.maks-it.com", "AgentHostname": "http://lblsrv0001.corp.maks-it.com",

View File

@ -474,6 +474,38 @@
} }
}, },
"response": [] "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": []
} }
] ]
} }

View File

@ -476,6 +476,38 @@
} }
}, },
"response": [] "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": []
} }
] ]
} }