(feature): improve logging and result messages

This commit is contained in:
Maksym Sadovnychyy 2025-10-26 01:34:42 +02:00
parent b413f2bf3a
commit 950b858af7

View File

@ -13,7 +13,6 @@ using MaksIT.LetsEncrypt.Models.Interfaces;
using MaksIT.LetsEncrypt.Models.Requests; using MaksIT.LetsEncrypt.Models.Requests;
using MaksIT.LetsEncrypt.Models.Responses; using MaksIT.LetsEncrypt.Models.Responses;
using MaksIT.Results; using MaksIT.Results;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System.Net.Http.Headers; using System.Net.Http.Headers;
@ -68,33 +67,51 @@ public class LetsEncryptService : ILetsEncryptService {
// Helper: Send ACME request and process response // Helper: Send ACME request and process response
private async Task<SendResult<T>> SendAcmeRequest<T>(HttpRequestMessage request, State state, HttpMethod method) { private async Task<SendResult<T>> SendAcmeRequest<T>(HttpRequestMessage request, State state, HttpMethod method) {
var response = await _httpClient.SendAsync(request); var response = await _httpClient.SendAsync(request);
UpdateStateNonceIfNeeded(response, state, method); UpdateStateNonceIfNeeded(response, state, method);
var responseText = await response.Content.ReadAsStringAsync(); var responseText = await response.Content.ReadAsStringAsync();
HandleProblemResponseAsync(response, responseText); HandleProblemResponseAsync(response, responseText);
return ProcessResponseContent<T>(response, responseText); return ProcessResponseContent<T>(response, responseText);
} }
// Helper: Poll challenge status until valid or timeout // Helper: Poll challenge status until valid or timeout
private async Task<Result> PollChallengeStatus(Guid sessionId, AuthorizationChallengeChallenge challenge, State state) { private async Task<Result> PollChallengeStatus(Guid sessionId, AuthorizationChallengeChallenge challenge, State state) {
if (challenge?.Url == null) return Result.InternalServerError("Challenge URL is null"); if (challenge?.Url == null)
return Result.InternalServerError("Challenge URL is null");
var start = DateTime.UtcNow; var start = DateTime.UtcNow;
while (true) { while (true) {
var pollRequest = new HttpRequestMessage(HttpMethod.Post, challenge.Url); var pollRequest = new HttpRequestMessage(HttpMethod.Post, challenge.Url);
await HandleNonceAsync(sessionId, challenge.Url, state); await HandleNonceAsync(sessionId, challenge.Url, state);
var pollJson = EncodeMessage(true, null, state, new JwsHeader { var pollJson = EncodeMessage(true, null, state, new JwsHeader {
Url = challenge.Url, Url = challenge.Url,
Nonce = state.Nonce Nonce = state.Nonce
}); });
PrepareRequestContent(pollRequest, pollJson, HttpMethod.Post); PrepareRequestContent(pollRequest, pollJson, HttpMethod.Post);
var pollResponse = await _httpClient.SendAsync(pollRequest); var pollResponse = await _httpClient.SendAsync(pollRequest);
UpdateStateNonceIfNeeded(pollResponse, state, HttpMethod.Post); UpdateStateNonceIfNeeded(pollResponse, state, HttpMethod.Post);
var pollResponseText = await pollResponse.Content.ReadAsStringAsync(); var pollResponseText = await pollResponse.Content.ReadAsStringAsync();
HandleProblemResponseAsync(pollResponse, pollResponseText); HandleProblemResponseAsync(pollResponse, pollResponseText);
var authChallenge = ProcessResponseContent<AuthorizationChallengeResponse>(pollResponse, pollResponseText); var authChallenge = ProcessResponseContent<AuthorizationChallengeResponse>(pollResponse, pollResponseText);
if (authChallenge.Result?.Status != "pending") if (authChallenge.Result?.Status != "pending")
return authChallenge.Result?.Status == "valid" ? Result.Ok() : Result.InternalServerError(); return authChallenge.Result?.Status == "valid" ? Result.Ok() : Result.InternalServerError();
if ((DateTime.UtcNow - start).Seconds > 120) if ((DateTime.UtcNow - start).Seconds > 120)
return Result.InternalServerError("Timeout"); return Result.InternalServerError("Timeout");
await Task.Delay(1000); await Task.Delay(1000);
} }
} }
@ -103,16 +120,30 @@ public class LetsEncryptService : ILetsEncryptService {
public async Task<Result> ConfigureClient(Guid sessionId, bool isStaging) { public async Task<Result> ConfigureClient(Guid sessionId, bool isStaging) {
try { try {
var state = GetOrCreateState(sessionId); var state = GetOrCreateState(sessionId);
state.IsStaging = isStaging; state.IsStaging = isStaging;
_httpClient.BaseAddress ??= new Uri(isStaging ? _appSettings.Staging : _appSettings.Production); _httpClient.BaseAddress ??= new Uri(isStaging ? _appSettings.Staging : _appSettings.Production);
if (state.Directory == null) { if (state.Directory == null) {
var request = new HttpRequestMessage(HttpMethod.Get, new Uri(DirectoryEndpoint, UriKind.Relative)); var request = new HttpRequestMessage(HttpMethod.Get, new Uri(DirectoryEndpoint, UriKind.Relative));
await HandleNonceAsync(sessionId, new Uri(DirectoryEndpoint, UriKind.Relative), state); await HandleNonceAsync(sessionId, new Uri(DirectoryEndpoint, UriKind.Relative), state);
var directory = await SendAcmeRequest<AcmeDirectory>(request, state, HttpMethod.Get); var directory = await SendAcmeRequest<AcmeDirectory>(request, state, HttpMethod.Get);
state.Directory = directory.Result ?? throw new InvalidOperationException("Directory response is null"); state.Directory = directory.Result ?? throw new InvalidOperationException("Directory response is null");
} }
return Result.Ok();
} catch (Exception ex) { return Result.Ok("Client configured successfully.");
}
catch (LetsEncrytException ex) {
List<string> messages = ["Let's Encrypt client encountered a problem"];
_logger.LogError(ex, messages.FirstOrDefault());
messages.Add(ex.Message);
return Result.InternalServerError([.. messages]);
}
catch (Exception ex) {
const string message = "Let's Encrypt client unhandled exception"; const string message = "Let's Encrypt client unhandled exception";
_logger.LogError(ex, message); _logger.LogError(ex, message);
return Result.InternalServerError(message); return Result.InternalServerError(message);
@ -123,45 +154,68 @@ public class LetsEncryptService : ILetsEncryptService {
#region Init #region Init
public async Task<Result> Init(Guid sessionId, Guid accountId, string description, string[] contacts, RegistrationCache? cache) { public async Task<Result> Init(Guid sessionId, Guid accountId, string description, string[] contacts, RegistrationCache? cache) {
if (sessionId == Guid.Empty) { if (sessionId == Guid.Empty) {
_logger.LogError("Invalid sessionId"); const string message = "Invalid sessionId";
return Result.InternalServerError(); _logger.LogError(message);
return Result.InternalServerError(message);
} }
if (contacts == null || contacts.Length == 0) { if (contacts == null || contacts.Length == 0) {
_logger.LogError("Contacts are null or empty"); const string message = "Contacts are null or empty";
return Result.InternalServerError(); _logger.LogError(message);
return Result.InternalServerError(message);
} }
var state = GetOrCreateState(sessionId); var state = GetOrCreateState(sessionId);
if (state.Directory == null) { if (state.Directory == null) {
_logger.LogError("State directory is null"); const string message = "State directory is null";
return Result.InternalServerError(); _logger.LogError(message);
return Result.InternalServerError(message);
} }
_logger.LogInformation($"Executing {nameof(Init)}..."); _logger.LogInformation($"Executing {nameof(Init)}...");
try { try {
var accountKey = new RSACryptoServiceProvider(4096); var accountKey = new RSACryptoServiceProvider(4096);
if (cache != null && cache.AccountKey != null) { if (cache != null && cache.AccountKey != null) {
state.Cache = cache; state.Cache = cache;
accountKey.ImportCspBlob(cache.AccountKey); accountKey.ImportCspBlob(cache.AccountKey);
state.JwsService = new JwsService(accountKey); state.JwsService = new JwsService(accountKey);
state.JwsService.SetKeyId(cache.Location?.ToString() ?? string.Empty); state.JwsService.SetKeyId(cache.Location?.ToString() ?? string.Empty);
} else { }
else {
state.JwsService = new JwsService(accountKey); 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}")]
}; };
var request = new HttpRequestMessage(HttpMethod.Post, state.Directory.NewAccount); var request = new HttpRequestMessage(HttpMethod.Post, state.Directory.NewAccount);
await HandleNonceAsync(sessionId, state.Directory.NewAccount, state); await HandleNonceAsync(sessionId, state.Directory.NewAccount, state);
var json = EncodeMessage(false, letsEncryptOrder, state, new JwsHeader { var json = EncodeMessage(false, letsEncryptOrder, state, new JwsHeader {
Url = state.Directory.NewAccount, Url = state.Directory.NewAccount,
Nonce = state.Nonce Nonce = state.Nonce
}); });
PrepareRequestContent(request, json, HttpMethod.Post); PrepareRequestContent(request, json, HttpMethod.Post);
var result = await SendAcmeRequest<Account>(request, state, HttpMethod.Post); var result = await SendAcmeRequest<Account>(request, state, HttpMethod.Post);
state.JwsService.SetKeyId(result.Result?.Location?.ToString() ?? string.Empty); state.JwsService.SetKeyId(result.Result?.Location?.ToString() ?? string.Empty);
if (result.Result?.Status != "valid") { if (result.Result?.Status != "valid") {
_logger.LogError($"Account status is not valid, was: {result.Result?.Status} \r\n {result.ResponseText}"); var errorMessage = $"Account status is not valid, was: {result.Result?.Status} \r\n {result.ResponseText}";
return Result.InternalServerError(); _logger.LogError(errorMessage);
return Result.InternalServerError(errorMessage);
} }
state.Cache = new RegistrationCache { state.Cache = new RegistrationCache {
AccountId = accountId, AccountId = accountId,
Description = description, Description = description,
@ -173,8 +227,16 @@ public class LetsEncryptService : ILetsEncryptService {
Key = result.Result.Key Key = result.Result.Key
}; };
} }
return Result.Ok();
} catch (Exception ex) { return Result.Ok("Initialization successful.");
}
catch (LetsEncrytException ex) {
List<string> messages = ["Let's Encrypt client encountered a problem"];
_logger.LogError(ex, messages.FirstOrDefault());
messages.Add(ex.Message);
return Result.InternalServerError([.. messages]);
}
catch (Exception ex) {
const string message = "Let's Encrypt client unhandled exception"; const string message = "Let's Encrypt client unhandled exception";
_logger.LogError(ex, message); _logger.LogError(ex, message);
return Result.InternalServerError(message); return Result.InternalServerError(message);
@ -184,8 +246,10 @@ public class LetsEncryptService : ILetsEncryptService {
public Result<RegistrationCache?> GetRegistrationCache(Guid sessionId) { public Result<RegistrationCache?> GetRegistrationCache(Guid sessionId) {
var state = GetOrCreateState(sessionId); var state = GetOrCreateState(sessionId);
if(state?.Cache == null) if(state?.Cache == null)
return Result<RegistrationCache?>.InternalServerError(null); return Result<RegistrationCache?>.InternalServerError(null);
return Result<RegistrationCache?>.Ok(state.Cache); return Result<RegistrationCache?>.Ok(state.Cache);
} }
@ -193,15 +257,20 @@ public class LetsEncryptService : ILetsEncryptService {
public Result<string?> GetTermsOfServiceUri(Guid sessionId) { public Result<string?> GetTermsOfServiceUri(Guid sessionId) {
try { try {
var state = GetOrCreateState(sessionId); var state = GetOrCreateState(sessionId);
_logger.LogInformation($"Executing {nameof(GetTermsOfServiceUri)}..."); _logger.LogInformation($"Executing {nameof(GetTermsOfServiceUri)}...");
if (state.Directory?.Meta?.TermsOfService == null) { if (state.Directory?.Meta?.TermsOfService == null) {
return Result<string?>.Ok(null); return Result<string?>.Ok(null);
} }
return Result<string?>.Ok(state.Directory.Meta.TermsOfService); return Result<string?>.Ok(state.Directory.Meta.TermsOfService);
} catch (Exception ex) { }
var message = "Let's Encrypt client unhandled exception"; catch (Exception ex) {
_logger.LogError(ex, message); List<string> messages = new List<string> { "Let's Encrypt client unhandled exception" };
return Result<string?>.InternalServerError(message); _logger.LogError(ex, messages.FirstOrDefault());
messages.Add(ex.Message);
return Result<string?>.InternalServerError(null, [.. messages]);
} }
} }
#endregion #endregion
@ -210,61 +279,96 @@ public class LetsEncryptService : ILetsEncryptService {
public async Task<Result<Dictionary<string, string>?>> NewOrder(Guid sessionId, string[] hostnames, string challengeType) { public async Task<Result<Dictionary<string, string>?>> NewOrder(Guid sessionId, string[] hostnames, string challengeType) {
try { try {
var state = GetOrCreateState(sessionId); var state = GetOrCreateState(sessionId);
_logger.LogInformation($"Executing {nameof(NewOrder)}..."); _logger.LogInformation($"Executing {nameof(NewOrder)}...");
state.Challenges.Clear(); state.Challenges.Clear();
var letsEncryptOrder = new Order { var letsEncryptOrder = new Order {
Expires = DateTime.UtcNow.AddDays(2), Expires = DateTime.UtcNow.AddDays(2),
Identifiers = hostnames?.Where(h => h != null).Select(hostname => new OrderIdentifier { Identifiers = hostnames?.Where(h => h != null).Select(hostname => new OrderIdentifier {
Type = DnsType, Type = DnsType,
Value = hostname ?? string.Empty Value = hostname ?? string.Empty
}).ToArray() ?? Array.Empty<OrderIdentifier>() }).ToArray() ?? []
}; };
if (state.Directory == null || state.Directory.NewOrder == null) if (state.Directory == null || state.Directory.NewOrder == null)
return Result<Dictionary<string, string>?>.InternalServerError(null); return Result<Dictionary<string, string>?>.InternalServerError(null);
var request = new HttpRequestMessage(HttpMethod.Post, state.Directory.NewOrder); var request = new HttpRequestMessage(HttpMethod.Post, state.Directory.NewOrder);
await HandleNonceAsync(sessionId, state.Directory.NewOrder, state); await HandleNonceAsync(sessionId, state.Directory.NewOrder, state);
var json = EncodeMessage(false, letsEncryptOrder, state, new JwsHeader { var json = EncodeMessage(false, letsEncryptOrder, state, new JwsHeader {
Url = state.Directory.NewOrder, Url = state.Directory.NewOrder,
Nonce = state.Nonce Nonce = state.Nonce
}); });
PrepareRequestContent(request, json, HttpMethod.Post); PrepareRequestContent(request, json, HttpMethod.Post);
var order = await SendAcmeRequest<Order>(request, state, HttpMethod.Post); var order = await SendAcmeRequest<Order>(request, state, HttpMethod.Post);
if (StatusEquals(order.Result?.Status, OrderStatus.Ready)) if (StatusEquals(order.Result?.Status, OrderStatus.Ready))
return Result<Dictionary<string, string>?>.Ok(new Dictionary<string, string>()); return Result<Dictionary<string, string>?>.Ok(new Dictionary<string, string>());
if (!StatusEquals(order.Result?.Status, OrderStatus.Pending)) { if (!StatusEquals(order.Result?.Status, OrderStatus.Pending)) {
_logger.LogError($"Created new order and expected status '{OrderStatus.Pending.GetDisplayName()}', but got: {order.Result?.Status} \r\n {order.Result}"); _logger.LogError($"Created new order and expected status '{OrderStatus.Pending.GetDisplayName()}', but got: {order.Result?.Status} \r\n {order.Result}");
return Result<Dictionary<string, string>?>.InternalServerError(null); return Result<Dictionary<string, string>?>.InternalServerError(null);
} }
state.CurrentOrder = order.Result; state.CurrentOrder = order.Result;
var results = new Dictionary<string, string>(); var results = new Dictionary<string, string>();
foreach (var item in state.CurrentOrder?.Authorizations ?? Array.Empty<Uri>()) { foreach (var item in state.CurrentOrder?.Authorizations ?? Array.Empty<Uri>()) {
if (item == null) continue; if (item == null)
continue;
request = new HttpRequestMessage(HttpMethod.Post, item); request = new HttpRequestMessage(HttpMethod.Post, item);
await HandleNonceAsync(sessionId, item, state); await HandleNonceAsync(sessionId, item, state);
json = EncodeMessage(true, null, state, new JwsHeader { json = EncodeMessage(true, null, state, new JwsHeader {
Url = item, Url = item,
Nonce = state.Nonce Nonce = state.Nonce
}); });
PrepareRequestContent(request, json, HttpMethod.Post); PrepareRequestContent(request, json, HttpMethod.Post);
var challengeResponse = await SendAcmeRequest<AuthorizationChallengeResponse>(request, state, HttpMethod.Post); var challengeResponse = await SendAcmeRequest<AuthorizationChallengeResponse>(request, state, HttpMethod.Post);
if (StatusEquals(challengeResponse.Result?.Status, OrderStatus.Valid)) if (StatusEquals(challengeResponse.Result?.Status, OrderStatus.Valid))
continue; continue;
if (!StatusEquals(challengeResponse.Result?.Status, OrderStatus.Pending)) { if (!StatusEquals(challengeResponse.Result?.Status, OrderStatus.Pending)) {
_logger.LogError($"Expected authorization status '{OrderStatus.Pending.GetDisplayName()}', but got: {state.CurrentOrder?.Status} \r\n {challengeResponse.ResponseText}"); _logger.LogError($"Expected authorization status '{OrderStatus.Pending.GetDisplayName()}', but got: {state.CurrentOrder?.Status} \r\n {challengeResponse.ResponseText}");
return Result<Dictionary<string, string>?>.InternalServerError(null); return Result<Dictionary<string, string>?>.InternalServerError(null);
} }
var challenge = challengeResponse.Result?.Challenges?.FirstOrDefault(x => x?.Type == challengeType);
var challenge = challengeResponse.Result?.Challenges?
.FirstOrDefault(x => x?.Type == challengeType);
if (challenge == null || challenge.Token == null) { if (challenge == null || challenge.Token == null) {
_logger.LogError("Challenge or token is null"); _logger.LogError("Challenge or token is null");
return Result<Dictionary<string, string>?>.InternalServerError(null); return Result<Dictionary<string, string>?>.InternalServerError(null);
} }
state.Challenges.Add(challenge); state.Challenges.Add(challenge);
if (state.Cache != null) state.Cache.ChallengeType = challengeType;
var keyToken = state.JwsService != null ? state.JwsService.GetKeyAuthorization(challenge.Token) : string.Empty; if (state.Cache != null)
state.Cache.ChallengeType = challengeType;
var keyToken = state.JwsService != null
? state.JwsService.GetKeyAuthorization(challenge.Token)
: string.Empty;
switch (challengeType) { switch (challengeType) {
case "dns-01": case "dns-01":
using (var sha256 = SHA256.Create()) { using (var sha256 = SHA256.Create()) {
var dnsToken = state.JwsService != null ? state.JwsService.Base64UrlEncoded(sha256.ComputeHash(Encoding.UTF8.GetBytes(keyToken ?? string.Empty))) : string.Empty; var dnsToken = state.JwsService != null
? state.JwsService.Base64UrlEncoded(sha256.ComputeHash(Encoding.UTF8.GetBytes(keyToken ?? string.Empty)))
: string.Empty;
results[challengeResponse.Result?.Identifier?.Value ?? string.Empty] = dnsToken; results[challengeResponse.Result?.Identifier?.Value ?? string.Empty] = dnsToken;
} }
break; break;
@ -277,10 +381,12 @@ public class LetsEncryptService : ILetsEncryptService {
} }
return Result<Dictionary<string, string>?>.Ok(results); return Result<Dictionary<string, string>?>.Ok(results);
} catch (Exception ex) { }
var message = "Let's Encrypt client unhandled exception"; catch (Exception ex) {
_logger.LogError(ex, message); List<string> messages = new List<string> { "Let's Encrypt client unhandled exception" };
return Result<Dictionary<string, string>?>.InternalServerError(null, message); _logger.LogError(ex, messages.FirstOrDefault());
messages.Add(ex.Message);
return Result<Dictionary<string, string>?>.InternalServerError(null, [.. messages]);
} }
} }
#endregion #endregion
@ -289,33 +395,46 @@ public class LetsEncryptService : ILetsEncryptService {
public async Task<Result> CompleteChallenges(Guid sessionId) { public async Task<Result> CompleteChallenges(Guid sessionId) {
try { try {
var state = GetOrCreateState(sessionId); var state = GetOrCreateState(sessionId);
_logger.LogInformation($"Executing {nameof(CompleteChallenges)}..."); _logger.LogInformation($"Executing {nameof(CompleteChallenges)}...");
if (state.CurrentOrder?.Identifiers == null) { if (state.CurrentOrder?.Identifiers == null) {
return Result.InternalServerError("Current order identifiers are null"); return Result.InternalServerError("Current order identifiers are null");
} }
for (var index = 0; index < state.Challenges.Count; index++) { for (var index = 0; index < state.Challenges.Count; index++) {
var challenge = state.Challenges[index]; var challenge = state.Challenges[index];
if (challenge?.Url == null) { if (challenge?.Url == null) {
_logger.LogError("Challenge URL is null"); _logger.LogError("Challenge URL is null");
return Result.InternalServerError(); return Result.InternalServerError();
} }
var request = new HttpRequestMessage(HttpMethod.Post, challenge.Url); var request = new HttpRequestMessage(HttpMethod.Post, challenge.Url);
await HandleNonceAsync(sessionId, challenge.Url, state); await HandleNonceAsync(sessionId, challenge.Url, state);
var json = EncodeMessage(false, "{}", state, new JwsHeader { var json = EncodeMessage(false, "{}", state, new JwsHeader {
Url = challenge.Url, Url = challenge.Url,
Nonce = state.Nonce Nonce = state.Nonce
}); });
PrepareRequestContent(request, json, HttpMethod.Post); PrepareRequestContent(request, json, HttpMethod.Post);
var authChallenge = await SendAcmeRequest<AuthorizationChallengeResponse>(request, state, HttpMethod.Post); var authChallenge = await SendAcmeRequest<AuthorizationChallengeResponse>(request, state, HttpMethod.Post);
var result = await PollChallengeStatus(sessionId, challenge, state); var result = await PollChallengeStatus(sessionId, challenge, state);
if (!result.IsSuccess) if (!result.IsSuccess)
return result; return result;
} }
return Result.Ok(); return Result.Ok();
} catch (Exception ex) { }
var message = "Let's Encrypt client unhandled exception"; catch (Exception ex) {
_logger.LogError(ex, message); List<string> messages = new List<string> { "Let's Encrypt client unhandled exception" };
return Result.InternalServerError(message); _logger.LogError(ex, messages.FirstOrDefault());
messages.Add(ex.Message);
return Result.InternalServerError([.. messages]);
} }
} }
#endregion #endregion
@ -324,28 +443,39 @@ public class LetsEncryptService : ILetsEncryptService {
public async Task<Result> GetOrder(Guid sessionId, string[] hostnames) { public async Task<Result> GetOrder(Guid sessionId, string[] hostnames) {
try { try {
_logger.LogInformation($"Executing {nameof(GetOrder)}"); _logger.LogInformation($"Executing {nameof(GetOrder)}");
var state = GetOrCreateState(sessionId); var state = GetOrCreateState(sessionId);
var letsEncryptOrder = new Order { var letsEncryptOrder = new Order {
Expires = DateTime.UtcNow.AddDays(2), Expires = DateTime.UtcNow.AddDays(2),
Identifiers = hostnames?.Where(h => h != null).Select(hostname => new OrderIdentifier { Identifiers = hostnames?.Where(h => h != null).Select(hostname => new OrderIdentifier {
Type = "dns", Type = "dns",
Value = hostname! Value = hostname!
}).ToArray() ?? Array.Empty<OrderIdentifier>() }).ToArray() ?? []
}; };
var request = new HttpRequestMessage(HttpMethod.Post, state.Directory!.NewOrder); var request = new HttpRequestMessage(HttpMethod.Post, state.Directory!.NewOrder);
await HandleNonceAsync(sessionId, state.Directory.NewOrder, state); await HandleNonceAsync(sessionId, state.Directory.NewOrder, state);
var json = EncodeMessage(false, letsEncryptOrder, state, new JwsHeader { var json = EncodeMessage(false, letsEncryptOrder, state, new JwsHeader {
Url = state.Directory.NewOrder, Url = state.Directory.NewOrder,
Nonce = state.Nonce Nonce = state.Nonce
}); });
PrepareRequestContent(request, json, HttpMethod.Post); PrepareRequestContent(request, json, HttpMethod.Post);
var order = await SendAcmeRequest<Order>(request, state, HttpMethod.Post); var order = await SendAcmeRequest<Order>(request, state, HttpMethod.Post);
state.CurrentOrder = order.Result; state.CurrentOrder = order.Result;
return Result.Ok(); return Result.Ok();
} catch (Exception ex) { }
var message = "Let's Encrypt client unhandled exception"; catch (Exception ex) {
_logger.LogError(ex, message); List<string> messages = new List<string> { "Let's Encrypt client unhandled exception" };
return Result.InternalServerError(message); _logger.LogError(ex, messages.FirstOrDefault());
messages.Add(ex.Message);
return Result.InternalServerError([.. messages]);
} }
} }
#endregion #endregion
@ -354,85 +484,122 @@ public class LetsEncryptService : ILetsEncryptService {
public async Task<Result> GetCertificate(Guid sessionId, string subject) { public async Task<Result> GetCertificate(Guid sessionId, string subject) {
try { try {
var state = GetOrCreateState(sessionId); var state = GetOrCreateState(sessionId);
_logger.LogInformation($"Executing {nameof(GetCertificate)}..."); _logger.LogInformation($"Executing {nameof(GetCertificate)}...");
if (state.CurrentOrder?.Identifiers == null) { if (state.CurrentOrder?.Identifiers == null) {
return Result.InternalServerError(); return Result.InternalServerError();
} }
var key = new RSACryptoServiceProvider(4096); var key = new RSACryptoServiceProvider(4096);
var csr = new CertificateRequest("CN=" + subject, var csr = new CertificateRequest("CN=" + subject, key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
var san = new SubjectAlternativeNameBuilder(); var san = new SubjectAlternativeNameBuilder();
foreach (var host in state.CurrentOrder.Identifiers) { foreach (var host in state.CurrentOrder.Identifiers) {
if (host?.Value != null) if (host?.Value != null)
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 = state.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) {
var hostnames = state.CurrentOrder.Identifiers?.Select(x => x?.Value).Where(x => x != null).Cast<string>().ToArray() ?? Array.Empty<string>(); var hostnames = state.CurrentOrder?.Identifiers?.Select(x => x?.Value).Where(x => x != null).Cast<string>().ToArray() ?? [];
await GetOrder(sessionId, hostnames); await GetOrder(sessionId, hostnames);
var status = state.CurrentOrder?.Status; var status = state.CurrentOrder?.Status;
if (StatusEquals(status, OrderStatus.Ready)) { if (StatusEquals(status, OrderStatus.Ready)) {
var request = new HttpRequestMessage(HttpMethod.Post, state.CurrentOrder.Finalize!); var request = new HttpRequestMessage(HttpMethod.Post, state.CurrentOrder.Finalize);
await HandleNonceAsync(sessionId, state.CurrentOrder.Finalize!, state); await HandleNonceAsync(sessionId, state.CurrentOrder.Finalize!, state);
var json = EncodeMessage(false, letsEncryptOrder, state, new JwsHeader { var json = EncodeMessage(false, letsEncryptOrder, state, new JwsHeader {
Url = state.CurrentOrder.Finalize, Url = state.CurrentOrder.Finalize,
Nonce = state.Nonce Nonce = state.Nonce
}); });
PrepareRequestContent(request, json, HttpMethod.Post); PrepareRequestContent(request, json, HttpMethod.Post);
var order = await SendAcmeRequest<Order>(request, state, HttpMethod.Post); var order = await SendAcmeRequest<Order>(request, state, HttpMethod.Post);
if (StatusEquals(order.Result?.Status, OrderStatus.Processing)) { if (StatusEquals(order.Result?.Status, OrderStatus.Processing)) {
request = new HttpRequestMessage(HttpMethod.Post, state.CurrentOrder.Location!); request = new HttpRequestMessage(HttpMethod.Post, state.CurrentOrder.Location!);
await HandleNonceAsync(sessionId, state.CurrentOrder.Location!, state); await HandleNonceAsync(sessionId, state.CurrentOrder.Location!, state);
json = EncodeMessage(true, null, state, new JwsHeader { json = EncodeMessage(true, null, state, new JwsHeader {
Url = state.CurrentOrder.Location, Url = state.CurrentOrder.Location,
Nonce = state.Nonce Nonce = state.Nonce
}); });
PrepareRequestContent(request, json, HttpMethod.Post); PrepareRequestContent(request, json, HttpMethod.Post);
order = await SendAcmeRequest<Order>(request, state, HttpMethod.Post); order = await SendAcmeRequest<Order>(request, state, HttpMethod.Post);
} }
if (StatusEquals(order.Result?.Status, OrderStatus.Valid)) { if (StatusEquals(order.Result?.Status, OrderStatus.Valid)) {
certificateUrl = order.Result.Certificate; certificateUrl = order.Result.Certificate;
} }
} else if (StatusEquals(status, OrderStatus.Valid)) { }
else if (StatusEquals(status, OrderStatus.Valid)) {
certificateUrl = state.CurrentOrder.Certificate; certificateUrl = state.CurrentOrder.Certificate;
break; break;
} }
if ((DateTime.UtcNow - start).Seconds > 120) if ((DateTime.UtcNow - start).Seconds > 120)
throw new TimeoutException(); throw new TimeoutException();
await Task.Delay(1000); await Task.Delay(1000);
} }
var finalRequest = new HttpRequestMessage(HttpMethod.Post, certificateUrl!); var finalRequest = new HttpRequestMessage(HttpMethod.Post, certificateUrl!);
await HandleNonceAsync(sessionId, certificateUrl!, state); await HandleNonceAsync(sessionId, certificateUrl!, state);
var finalJson = EncodeMessage(true, null, state, new JwsHeader { var finalJson = EncodeMessage(true, null, state, new JwsHeader {
Url = certificateUrl, Url = certificateUrl,
Nonce = state.Nonce Nonce = state.Nonce
}); });
PrepareRequestContent(finalRequest, finalJson, HttpMethod.Post); PrepareRequestContent(finalRequest, finalJson, HttpMethod.Post);
var pem = await SendAcmeRequest<string>(finalRequest, state, HttpMethod.Post); var pem = await SendAcmeRequest<string>(finalRequest, state, HttpMethod.Post);
if (state.Cache == null) { if (state.Cache == null) {
_logger.LogError($"{nameof(state.Cache)} is null"); _logger.LogError($"{nameof(state.Cache)} is null");
return Result.InternalServerError(); return Result.InternalServerError();
} }
state.Cache.CachedCerts ??= new Dictionary<string, CertificateCache>(); state.Cache.CachedCerts ??= new Dictionary<string, CertificateCache>();
state.Cache.CachedCerts[subject] = new CertificateCache { state.Cache.CachedCerts[subject] = new CertificateCache {
Cert = pem.Result ?? string.Empty, Cert = pem.Result ?? string.Empty,
Private = key.ExportCspBlob(true), Private = key.ExportCspBlob(true),
PrivatePem = key.ExportRSAPrivateKeyPem() PrivatePem = key.ExportRSAPrivateKeyPem()
}; };
var certPem = pem.Result ?? string.Empty; var certPem = pem.Result ?? string.Empty;
if (!string.IsNullOrEmpty(certPem)) { if (!string.IsNullOrEmpty(certPem)) {
var cert = new X509Certificate2(Encoding.UTF8.GetBytes(certPem)); var cert = new X509Certificate2(Encoding.UTF8.GetBytes(certPem));
} }
return Result.Ok(); return Result.Ok();
} catch (Exception ex) { }
var message = "Let's Encrypt client unhandled exception"; catch (Exception ex) {
_logger.LogError(ex, message); List<string> messages = new List<string> { "Let's Encrypt client unhandled exception" };
return Result.InternalServerError(message); _logger.LogError(ex, messages.FirstOrDefault());
messages.Add(ex.Message);
return Result.InternalServerError([.. messages]);
} }
} }
#endregion #endregion
@ -444,58 +611,86 @@ public class LetsEncryptService : ILetsEncryptService {
public async Task<Result> RevokeCertificate(Guid sessionId, string subject, RevokeReason reason) { public async Task<Result> RevokeCertificate(Guid sessionId, string subject, RevokeReason reason) {
try { try {
var state = GetOrCreateState(sessionId); var state = GetOrCreateState(sessionId);
_logger.LogInformation($"Executing {nameof(RevokeCertificate)}..."); _logger.LogInformation($"Executing {nameof(RevokeCertificate)}...");
if (state.Cache?.CachedCerts == null || !state.Cache.CachedCerts.TryGetValue(subject, out var certificateCache) || certificateCache == null) { if (state.Cache?.CachedCerts == null || !state.Cache.CachedCerts.TryGetValue(subject, out var certificateCache) || certificateCache == null) {
_logger.LogError("Certificate not found in cache"); _logger.LogError("Certificate not found in cache");
return Result.InternalServerError("Certificate not found"); return Result.InternalServerError("Certificate not found");
} }
var certPem = certificateCache.Cert ?? string.Empty; var certPem = certificateCache.Cert ?? string.Empty;
if (string.IsNullOrEmpty(certPem)) { if (string.IsNullOrEmpty(certPem)) {
_logger.LogError("Certificate PEM is null or empty"); _logger.LogError("Certificate PEM is null or empty");
return Result.InternalServerError("Certificate PEM is null or empty"); return Result.InternalServerError("Certificate PEM is null or empty");
} }
var certificate = new X509Certificate2(Encoding.UTF8.GetBytes(certPem)); var certificate = new X509Certificate2(Encoding.UTF8.GetBytes(certPem));
var derEncodedCert = certificate.Export(X509ContentType.Cert); var derEncodedCert = certificate.Export(X509ContentType.Cert);
var base64UrlEncodedCert = state.JwsService!.Base64UrlEncoded(derEncodedCert); var base64UrlEncodedCert = state.JwsService!.Base64UrlEncoded(derEncodedCert);
var revokeRequest = new RevokeRequest { var revokeRequest = new RevokeRequest {
Certificate = base64UrlEncodedCert, Certificate = base64UrlEncodedCert,
Reason = (int)reason Reason = (int)reason
}; };
var request = new HttpRequestMessage(HttpMethod.Post, state.Directory!.RevokeCert); var request = new HttpRequestMessage(HttpMethod.Post, state.Directory!.RevokeCert);
await HandleNonceAsync(sessionId, state.Directory.RevokeCert, state); await HandleNonceAsync(sessionId, state.Directory.RevokeCert, state);
var jwsHeader = new JwsHeader { var jwsHeader = new JwsHeader {
Url = state.Directory.RevokeCert, Url = state.Directory.RevokeCert,
Nonce = state.Nonce Nonce = state.Nonce
}; };
var json = state.JwsService.Encode(revokeRequest, jwsHeader).ToJson(); var json = state.JwsService.Encode(revokeRequest, jwsHeader).ToJson();
request.Content = new StringContent(json); request.Content = new StringContent(json);
request.Content.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(ContentType.JoseJson)); request.Content.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(ContentType.JoseJson));
var response = await _httpClient.SendAsync(request); var response = await _httpClient.SendAsync(request);
UpdateStateNonceIfNeeded(response, state, HttpMethod.Post); UpdateStateNonceIfNeeded(response, state, HttpMethod.Post);
var responseText = await response.Content.ReadAsStringAsync(); var responseText = await response.Content.ReadAsStringAsync();
if (response.Content.Headers.ContentType?.MediaType == GetContentType(ContentType.ProblemJson)) { if (response.Content.Headers.ContentType?.MediaType == GetContentType(ContentType.ProblemJson)) {
var erroObj = responseText.ToObject<Problem>(); var erroObj = responseText.ToObject<Problem>();
} }
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
Result.InternalServerError(responseText); Result.InternalServerError(responseText);
state.Cache.CachedCerts.Remove(subject); state.Cache.CachedCerts.Remove(subject);
_logger.LogInformation("Certificate revoked successfully"); _logger.LogInformation("Certificate revoked successfully");
return Result.Ok(); return Result.Ok();
} catch (Exception ex) {
var message = "Let's Encrypt client unhandled exception"; }
_logger.LogError(ex, message); catch (Exception ex) {
return Result.InternalServerError($"{message}: {ex.Message}"); List<string> messages = new List<string> { "Let's Encrypt client unhandled exception" };
_logger.LogError(ex, messages.FirstOrDefault());
messages.Add(ex.Message);
return Result.InternalServerError([.. messages]);
} }
} }
#region SendAsync #region SendAsync
private async Task HandleNonceAsync(Guid sessionId, Uri uri, State state) { private async Task HandleNonceAsync(Guid sessionId, Uri uri, State state) {
if (uri == null) throw new ArgumentNullException(nameof(uri)); if (uri == null)
throw new ArgumentNullException(nameof(uri));
if (uri.OriginalString != "directory") { if (uri.OriginalString != "directory") {
var newNonceResult = await NewNonce(sessionId); var newNonceResult = await NewNonce(sessionId);
if (!newNonceResult.IsSuccess || newNonceResult.Value == null) { if (!newNonceResult.IsSuccess || newNonceResult.Value == null) {
throw new InvalidOperationException("Failed to retrieve nonce."); throw new InvalidOperationException("Failed to retrieve nonce.");
} }
state.Nonce = newNonceResult.Value; state.Nonce = newNonceResult.Value;
} }
else { else {
@ -506,17 +701,23 @@ public class LetsEncryptService : ILetsEncryptService {
private async Task<Result<string?>> NewNonce(Guid sessionId) { private async Task<Result<string?>> NewNonce(Guid sessionId) {
try { try {
var state = GetOrCreateState(sessionId); var state = GetOrCreateState(sessionId);
_logger.LogInformation($"Executing {nameof(NewNonce)}..."); _logger.LogInformation($"Executing {nameof(NewNonce)}...");
if (state.Directory?.NewNonce == null) if (state.Directory?.NewNonce == null)
return Result<string?>.InternalServerError(null); return Result<string?>.InternalServerError(null);
var result = await _httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Head, state.Directory.NewNonce)); var result = await _httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Head, state.Directory.NewNonce));
var nonce = result.Headers.GetValues("Replay-Nonce").FirstOrDefault(); var nonce = result.Headers.GetValues("Replay-Nonce").FirstOrDefault();
return Result<string?>.Ok(nonce); return Result<string?>.Ok(nonce);
} }
catch (Exception ex) { catch (Exception ex) {
var message = "Let's Encrypt client unhandled exception"; List<string> messages = new List<string> { "Let's Encrypt client unhandled exception" };
_logger.LogError(ex, message); _logger.LogError(ex, messages.FirstOrDefault());
return Result<string?>.InternalServerError(null, message); messages.Add(ex.Message);
return Result<string?>.InternalServerError(null, [.. messages]);
} }
} }
@ -530,13 +731,17 @@ public class LetsEncryptService : ILetsEncryptService {
private void PrepareRequestContent(HttpRequestMessage request, string json, HttpMethod method) { private void PrepareRequestContent(HttpRequestMessage request, string json, HttpMethod method) {
request.Content = new StringContent(json ?? string.Empty); request.Content = new StringContent(json ?? string.Empty);
var contentType = method == HttpMethod.Post ? GetContentType(ContentType.JoseJson) : GetContentType(ContentType.Json); var contentType = method == HttpMethod.Post
? GetContentType(ContentType.JoseJson)
: GetContentType(ContentType.Json);
request.Content.Headers.ContentType = new MediaTypeHeaderValue(contentType); request.Content.Headers.ContentType = new MediaTypeHeaderValue(contentType);
} }
private void HandleProblemResponseAsync(HttpResponseMessage response, string responseText) { private void HandleProblemResponseAsync(HttpResponseMessage response, string responseText) {
if (response.Content.Headers.ContentType?.MediaType == GetContentType(ContentType.ProblemJson)) { if (response.Content.Headers.ContentType?.MediaType == GetContentType(ContentType.ProblemJson)) {
throw new LetsEncrytException(responseText.ToObject<Problem>(), response); var problem = responseText.ToObject<Problem>();
throw new LetsEncrytException(problem, response);
} }
} }