mirror of
https://github.com/MAKS-IT-COM/maksit-certs-ui.git
synced 2025-12-31 04:00:03 +01:00
(feature): auto renewal service implementation
This commit is contained in:
parent
7378996d19
commit
d046bcecd9
@ -36,4 +36,20 @@ public static class StringExtensions {
|
||||
? JsonSerializer.Deserialize<T>(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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
/// </summary>
|
||||
public Guid AccountId { get; set; }
|
||||
public string[]? Contacts { get; set; }
|
||||
|
||||
|
||||
public Dictionary<string, CertificateCache>? CachedCerts { get; set; }
|
||||
@ -28,10 +29,7 @@ public class RegistrationCache {
|
||||
/// <summary>
|
||||
/// Returns a list of hosts with upcoming SSL expiry
|
||||
/// </summary>
|
||||
public string[] HostsWithUpcomingSslExpiry {
|
||||
|
||||
get {
|
||||
|
||||
public string[] GetHostsWithUpcomingSslExpiry(int days = 30) {
|
||||
var hostsWithUpcomingSslExpiry = new List<string>();
|
||||
|
||||
if (CachedCerts == null)
|
||||
@ -44,14 +42,13 @@ public class RegistrationCache {
|
||||
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)
|
||||
if ((cert.NotAfter - DateTime.UtcNow).TotalDays < days)
|
||||
hostsWithUpcomingSslExpiry.Add(subject);
|
||||
}
|
||||
}
|
||||
|
||||
return hostsWithUpcomingSslExpiry.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns cached certificate. Certs older than 30 days are not returned
|
||||
|
||||
@ -25,6 +25,7 @@ public interface ILetsEncryptService {
|
||||
Task<IDomainResult> CompleteChallenges(Guid sessionId);
|
||||
Task<IDomainResult> GetOrder(Guid sessionId, string[] hostnames);
|
||||
Task<IDomainResult> 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<string[]?>();
|
||||
|
||||
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<TResult>(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<Problem>(), response);
|
||||
}
|
||||
|
||||
@ -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<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) {
|
||||
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<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) {
|
||||
Console.WriteLine("Background service is stopping.");
|
||||
_logger.LogInformation("Background service is stopping.");
|
||||
return base.StopAsync(stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
@ -108,7 +108,18 @@ public class CertsFlowController : ControllerBase {
|
||||
/// <returns></returns>
|
||||
[HttpPost("[action]/{sessionId}")]
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<ILetsEncryptService, LetsEncryptService>();
|
||||
builder.Services.AddScoped<ICertsFlowService, CertsFlowService>();
|
||||
builder.Services.AddSingleton<ICertsFlowService, CertsFlowService>();
|
||||
builder.Services.AddSingleton<ICacheService, CacheService>();
|
||||
builder.Services.AddHttpClient<IAgentService, AgentService>();
|
||||
builder.Services.AddHostedService<AutoRenewal>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
|
||||
@ -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<IDomainResult> SaveToCacheAsync(Guid accountId, RegistrationCache cache);
|
||||
Task<IDomainResult> 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<Guid[]?> (message);
|
||||
}
|
||||
finally {
|
||||
_cacheLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
_cacheLock?.Dispose();
|
||||
}
|
||||
|
||||
@ -23,7 +23,8 @@ public interface ICertsFlowService : ICertsFlowServiceBase {
|
||||
Task<IDomainResult> CompleteChallengesAsync(Guid sessionId);
|
||||
Task<IDomainResult> GetOrderAsync(Guid sessionId, GetOrderRequest 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 {
|
||||
@ -140,7 +141,7 @@ public class CertsFlowService : ICertsFlowService {
|
||||
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>();
|
||||
|
||||
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();
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user