mirror of
https://github.com/MAKS-IT-COM/maksit-certs-ui.git
synced 2026-05-16 04:48:12 +02:00
(bugfix): ACME rate limits, cooldown persistence, and LetsEncrypt hardening
This commit is contained in:
parent
685a174806
commit
1b22b8688d
22
CHANGELOG.md
22
CHANGELOG.md
@ -4,6 +4,28 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [3.3.6] - 2026-04-13
|
||||
|
||||
### Added
|
||||
|
||||
- **LetsEncrypt:** Per-host ACME rate-limit cooldown on `RegistrationCache` (`AcmeRenewalNotBeforeUtcByHostname`), with HTTP `Retry-After` and problem-detail parsing (`AcmeRetryAfterParser`), structured logging, and `Result.TooManyRequests` when the CA returns `rateLimited`.
|
||||
- **LetsEncrypt:** `AcmeProblemKind` as an `Enumeration` (RFC 8555 problem `type` URIs) instead of ad hoc strings; `LetsEncrytException` exposes `ProblemKind`, `RetryAfterUtc`, and optional rate-limit hostname.
|
||||
- **LetsEncrypt:** `AcmeSessionStore` for per-session `State` in memory; `LetsEncryptService` split into partial files (`LetsEncryptService.Helpers.cs`) for HTTP/JWS/error helpers.
|
||||
- **LetsEncrypt:** `State.TryGetAccountKey` for a single place to validate account key material after `Init`.
|
||||
- **LetsEncrypt.Tests:** Unit tests for retry parsing, problem-kind resolution, and cooldown JSON round-trip.
|
||||
|
||||
### Changed
|
||||
|
||||
- **AutoRenewal:** Skips hostnames that are still in an ACME cooldown window (with debug logs for skipped hosts).
|
||||
- **Certs flow:** Persists registration cache after failed full certificate flows when a session exists so cooldown metadata is saved.
|
||||
- **LetsEncrypt:** Broader nullable reference annotations on ACME DTOs (`Problem`, `AcmeDirectory`, `AuthorizationChallengeError`, etc.) and explicit null guards in `LetsEncryptService`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **LetsEncrypt:** Certificate PEM loading uses `X509Certificate2.CreateFromPem` instead of the obsolete `X509Certificate2(byte[])` constructor (SYSLIB0057).
|
||||
- **LetsEncrypt:** `RevokeCertificate` now fails correctly on non-success responses (missing `return`), uses the same problem-document handling as other ACME calls, and disposes the HTTP response on successful revoke.
|
||||
- **LetsEncrypt:** `NewOrder` authorization error log line now logs the authorization status, not the order status.
|
||||
|
||||
## [3.3.5] - 2026-04-12
|
||||
|
||||
### Changed
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="137" height="20" role="img" aria-label="Line Coverage: 21.7%">
|
||||
<title>Line Coverage: 21.7%</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="137" height="20" role="img" aria-label="Line Coverage: 21.6%">
|
||||
<title>Line Coverage: 21.6%</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@ -15,7 +15,7 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text aria-hidden="true" x="47.25" y="15" fill="#010101" fill-opacity=".3">Line Coverage</text>
|
||||
<text x="47.25" y="14" fill="#fff">Line Coverage</text>
|
||||
<text aria-hidden="true" x="115.75" y="15" fill="#010101" fill-opacity=".3">21.7%</text>
|
||||
<text x="115.75" y="14" fill="#fff">21.7%</text>
|
||||
<text aria-hidden="true" x="115.75" y="15" fill="#010101" fill-opacity=".3">21.6%</text>
|
||||
<text x="115.75" y="14" fill="#fff">21.6%</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Method Coverage: 29.7%">
|
||||
<title>Method Coverage: 29.7%</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Method Coverage: 31.5%">
|
||||
<title>Method Coverage: 31.5%</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@ -15,7 +15,7 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text aria-hidden="true" x="53.75" y="15" fill="#010101" fill-opacity=".3">Method Coverage</text>
|
||||
<text x="53.75" y="14" fill="#fff">Method Coverage</text>
|
||||
<text aria-hidden="true" x="128.75" y="15" fill="#010101" fill-opacity=".3">29.7%</text>
|
||||
<text x="128.75" y="14" fill="#fff">29.7%</text>
|
||||
<text aria-hidden="true" x="128.75" y="15" fill="#010101" fill-opacity=".3">31.5%</text>
|
||||
<text x="128.75" y="14" fill="#fff">31.5%</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
28
src/LetsEncrypt.Tests/AcmeProblemKindTests.cs
Normal file
28
src/LetsEncrypt.Tests/AcmeProblemKindTests.cs
Normal file
@ -0,0 +1,28 @@
|
||||
using MaksIT.LetsEncrypt;
|
||||
using Xunit;
|
||||
|
||||
namespace MaksIT.LetsEncrypt.Tests;
|
||||
|
||||
public class AcmeProblemKindTests {
|
||||
[Theory]
|
||||
[InlineData("urn:ietf:params:acme:error:rateLimited")]
|
||||
[InlineData("urn:ietf:params:acme:error:badNonce")]
|
||||
[InlineData("urn:ietf:params:acme:error:malformed")]
|
||||
[InlineData("urn:ietf:params:acme:error:serverInternal")]
|
||||
public void FromTypeUri_maps_rfc8555_uris(string uri) {
|
||||
var kind = AcmeProblemKind.FromTypeUri(uri);
|
||||
Assert.Equal(uri, kind.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromTypeUri_unknown_or_empty_returns_unknown() {
|
||||
Assert.Same(AcmeProblemKind.Unknown, AcmeProblemKind.FromTypeUri(null));
|
||||
Assert.Same(AcmeProblemKind.Unknown, AcmeProblemKind.FromTypeUri(""));
|
||||
Assert.Same(AcmeProblemKind.Unknown, AcmeProblemKind.FromTypeUri("urn:ietf:params:acme:error:customVendorThing"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromTypeUri_is_case_sensitive_for_full_uri() {
|
||||
Assert.Same(AcmeProblemKind.Unknown, AcmeProblemKind.FromTypeUri("urn:ietf:params:acme:error:RATELIMITED"));
|
||||
}
|
||||
}
|
||||
72
src/LetsEncrypt.Tests/AcmeRetryAfterParserTests.cs
Normal file
72
src/LetsEncrypt.Tests/AcmeRetryAfterParserTests.cs
Normal file
@ -0,0 +1,72 @@
|
||||
using System.Net.Http.Headers;
|
||||
using MaksIT.LetsEncrypt.Models.Responses;
|
||||
using Xunit;
|
||||
|
||||
namespace MaksIT.LetsEncrypt.Tests;
|
||||
|
||||
public class AcmeRetryAfterParserTests {
|
||||
[Fact]
|
||||
public void TryParseRetryAfterHttpHeader_reads_delta_seconds() {
|
||||
using var response = new HttpResponseMessage();
|
||||
response.Headers.RetryAfter = new RetryConditionHeaderValue(TimeSpan.FromSeconds(90));
|
||||
|
||||
var parsed = AcmeRetryAfterParser.TryParseRetryAfterHttpHeader(response);
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
var skew = (parsed.Value - DateTimeOffset.UtcNow.AddSeconds(90)).TotalSeconds;
|
||||
Assert.InRange(skew, -2, 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseRetryAfterHttpHeader_reads_absolute_date() {
|
||||
using var response = new HttpResponseMessage();
|
||||
var when = new DateTimeOffset(2026, 4, 12, 17, 49, 19, TimeSpan.Zero);
|
||||
response.Headers.RetryAfter = new RetryConditionHeaderValue(when);
|
||||
|
||||
var parsed = AcmeRetryAfterParser.TryParseRetryAfterHttpHeader(response);
|
||||
|
||||
Assert.Equal(when, parsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseRetryAfterFromDetail_parses_lets_encrypt_sample() {
|
||||
const string detail =
|
||||
"urn:ietf:params:acme:error:rateLimited: too many failed authorizations (5) for \"cloud.maks-it.com\" in the last 1h0m0s, retry after 2026-04-12 17:49:19 UTC: see https://letsencrypt.org/docs/rate-limits/";
|
||||
|
||||
var parsed = AcmeRetryAfterParser.TryParseRetryAfterFromDetail(detail);
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(2026, parsed.Value.Year);
|
||||
Assert.Equal(4, parsed.Value.Month);
|
||||
Assert.Equal(12, parsed.Value.Day);
|
||||
Assert.Equal(17, parsed.Value.Hour);
|
||||
Assert.Equal(49, parsed.Value.Minute);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseRateLimitedHostname_extracts_identifier() {
|
||||
const string detail =
|
||||
"too many failed authorizations (5) for \"cloud.maks-it.com\" in the last 1h0m0s, retry after 2026-04-12 17:49:19 UTC";
|
||||
|
||||
var host = AcmeRetryAfterParser.TryParseRateLimitedHostname(detail);
|
||||
|
||||
Assert.Equal("cloud.maks-it.com", host);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCombineRetryAfterUtc_takes_later_of_header_and_detail() {
|
||||
using var response = new HttpResponseMessage();
|
||||
response.Headers.RetryAfter = new RetryConditionHeaderValue(new DateTimeOffset(2026, 4, 12, 12, 0, 0, TimeSpan.Zero));
|
||||
|
||||
var problem = new Problem {
|
||||
Type = "urn:ietf:params:acme:error:rateLimited",
|
||||
Detail = "retry after 2026-04-12 18:00:00 UTC",
|
||||
RawJson = ""
|
||||
};
|
||||
|
||||
var combined = AcmeRetryAfterParser.TryCombineRetryAfterUtc(response, problem);
|
||||
|
||||
Assert.NotNull(combined);
|
||||
Assert.Equal(18, combined.Value.Hour);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
using MaksIT.Core.Extensions;
|
||||
using MaksIT.LetsEncrypt.Entities;
|
||||
using Xunit;
|
||||
|
||||
namespace MaksIT.LetsEncrypt.Tests;
|
||||
|
||||
public class RegistrationCacheAcmeCooldownSerializationTests {
|
||||
[Fact]
|
||||
public void AcmeRenewalNotBeforeUtcByHostname_round_trips_json() {
|
||||
var until = new DateTimeOffset(2026, 4, 12, 17, 49, 19, TimeSpan.Zero);
|
||||
var cache = new RegistrationCache {
|
||||
AccountId = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"),
|
||||
Description = "test",
|
||||
Contacts = ["ops@example.com"],
|
||||
IsStaging = true,
|
||||
ChallengeType = "http-01",
|
||||
AcmeRenewalNotBeforeUtcByHostname = new Dictionary<string, DateTimeOffset>(StringComparer.OrdinalIgnoreCase) {
|
||||
["cloud.example.com"] = until
|
||||
}
|
||||
};
|
||||
|
||||
var json = cache.ToJson();
|
||||
var restored = json.ToObject<RegistrationCache>();
|
||||
|
||||
Assert.NotNull(restored);
|
||||
Assert.NotNull(restored!.AcmeRenewalNotBeforeUtcByHostname);
|
||||
Assert.True(restored.AcmeRenewalNotBeforeUtcByHostname!.TryGetValue("cloud.example.com", out var v));
|
||||
Assert.Equal(until, v);
|
||||
}
|
||||
}
|
||||
52
src/LetsEncrypt/AcmeProblemKind.cs
Normal file
52
src/LetsEncrypt/AcmeProblemKind.cs
Normal file
@ -0,0 +1,52 @@
|
||||
using MaksIT.Core.Abstractions;
|
||||
|
||||
namespace MaksIT.LetsEncrypt;
|
||||
|
||||
/// <summary>
|
||||
/// ACME problem <c>type</c> URIs (RFC 8555 §6.7). <see cref="Unknown"/> covers missing or unrecognized URIs.
|
||||
/// </summary>
|
||||
public sealed class AcmeProblemKind : Enumeration {
|
||||
|
||||
public static readonly AcmeProblemKind Unknown = new(0, "");
|
||||
|
||||
public static readonly AcmeProblemKind BadCsr = new(1, "urn:ietf:params:acme:error:badCSR");
|
||||
public static readonly AcmeProblemKind BadNonce = new(2, "urn:ietf:params:acme:error:badNonce");
|
||||
public static readonly AcmeProblemKind BadPublicKey = new(3, "urn:ietf:params:acme:error:badPublicKey");
|
||||
public static readonly AcmeProblemKind BadRevocationReason = new(4, "urn:ietf:params:acme:error:badRevocationReason");
|
||||
public static readonly AcmeProblemKind BadSignatureAlgorithm = new(5, "urn:ietf:params:acme:error:badSignatureAlgorithm");
|
||||
public static readonly AcmeProblemKind Caa = new(6, "urn:ietf:params:acme:error:caa");
|
||||
public static readonly AcmeProblemKind Compound = new(7, "urn:ietf:params:acme:error:compound");
|
||||
public static readonly AcmeProblemKind Connection = new(8, "urn:ietf:params:acme:error:connection");
|
||||
public static readonly AcmeProblemKind Dns = new(9, "urn:ietf:params:acme:error:dns");
|
||||
public static readonly AcmeProblemKind ExternalAccountRequired = new(10, "urn:ietf:params:acme:error:externalAccountRequired");
|
||||
public static readonly AcmeProblemKind IncorrectResponse = new(11, "urn:ietf:params:acme:error:incorrectResponse");
|
||||
public static readonly AcmeProblemKind InvalidContact = new(12, "urn:ietf:params:acme:error:invalidContact");
|
||||
public static readonly AcmeProblemKind Malformed = new(13, "urn:ietf:params:acme:error:malformed");
|
||||
public static readonly AcmeProblemKind OrderNotReady = new(14, "urn:ietf:params:acme:error:orderNotReady");
|
||||
public static readonly AcmeProblemKind RateLimited = new(15, "urn:ietf:params:acme:error:rateLimited");
|
||||
public static readonly AcmeProblemKind RejectedIdentifier = new(16, "urn:ietf:params:acme:error:rejectedIdentifier");
|
||||
public static readonly AcmeProblemKind ServerInternal = new(17, "urn:ietf:params:acme:error:serverInternal");
|
||||
public static readonly AcmeProblemKind Tls = new(18, "urn:ietf:params:acme:error:tls");
|
||||
public static readonly AcmeProblemKind UnsupportedContact = new(19, "urn:ietf:params:acme:error:unsupportedContact");
|
||||
public static readonly AcmeProblemKind UnsupportedIdentifier = new(20, "urn:ietf:params:acme:error:unsupportedIdentifier");
|
||||
public static readonly AcmeProblemKind UserActionRequired = new(21, "urn:ietf:params:acme:error:userActionRequired");
|
||||
|
||||
private AcmeProblemKind(int id, string name) : base(id, name) { }
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a problem <c>type</c> URI from the CA. Comparison is ordinal (RFC 3986).
|
||||
/// </summary>
|
||||
public static AcmeProblemKind FromTypeUri(string? typeUri) {
|
||||
if (string.IsNullOrEmpty(typeUri))
|
||||
return Unknown;
|
||||
|
||||
foreach (var kind in GetAll<AcmeProblemKind>()) {
|
||||
if (kind == Unknown)
|
||||
continue;
|
||||
if (kind.Name == typeUri)
|
||||
return kind;
|
||||
}
|
||||
|
||||
return Unknown;
|
||||
}
|
||||
}
|
||||
66
src/LetsEncrypt/AcmeRetryAfterParser.cs
Normal file
66
src/LetsEncrypt/AcmeRetryAfterParser.cs
Normal file
@ -0,0 +1,66 @@
|
||||
using System.Globalization;
|
||||
using System.Net.Http;
|
||||
using System.Text.RegularExpressions;
|
||||
using MaksIT.LetsEncrypt.Models.Responses;
|
||||
|
||||
namespace MaksIT.LetsEncrypt;
|
||||
|
||||
internal static class AcmeRetryAfterParser {
|
||||
private static readonly Regex RetryAfterDetailRegex = new(
|
||||
@"retry\s+after\s+(?<ts>\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+UTC",
|
||||
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly Regex RateLimitedHostRegex = new(
|
||||
@"for\s+""(?<host>[^""]+)""",
|
||||
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
|
||||
internal static DateTimeOffset? TryParseRetryAfterHttpHeader(HttpResponseMessage? response) {
|
||||
if (response?.Headers.RetryAfter is not { } ra)
|
||||
return null;
|
||||
|
||||
if (ra.Date is { } absolute)
|
||||
return new DateTimeOffset(absolute.UtcDateTime, TimeSpan.Zero);
|
||||
|
||||
if (ra.Delta is { } delta)
|
||||
return DateTimeOffset.UtcNow + delta;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
internal static DateTimeOffset? TryParseRetryAfterFromDetail(string? detail) {
|
||||
if (string.IsNullOrEmpty(detail))
|
||||
return null;
|
||||
|
||||
var m = RetryAfterDetailRegex.Match(detail);
|
||||
if (!m.Success)
|
||||
return null;
|
||||
|
||||
var ts = m.Groups["ts"].Value;
|
||||
if (DateTimeOffset.TryParseExact(ts, "yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var dto))
|
||||
return dto;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Latest of header and detail-derived times (stricter / later wins). Null if neither present.
|
||||
/// </summary>
|
||||
internal static DateTimeOffset? TryCombineRetryAfterUtc(HttpResponseMessage? response, Problem? problem) {
|
||||
var fromHeader = TryParseRetryAfterHttpHeader(response);
|
||||
var fromDetail = TryParseRetryAfterFromDetail(problem?.Detail);
|
||||
|
||||
if (fromHeader.HasValue && fromDetail.HasValue)
|
||||
return fromHeader.Value > fromDetail.Value ? fromHeader.Value : fromDetail.Value;
|
||||
|
||||
return fromHeader ?? fromDetail;
|
||||
}
|
||||
|
||||
internal static string? TryParseRateLimitedHostname(string? detail) {
|
||||
if (string.IsNullOrEmpty(detail))
|
||||
return null;
|
||||
|
||||
var m = RateLimitedHostRegex.Match(detail);
|
||||
return m.Success ? m.Groups["host"].Value : null;
|
||||
}
|
||||
}
|
||||
@ -23,6 +23,13 @@ public class RegistrationCache {
|
||||
|
||||
|
||||
public Dictionary<string, CertificateCache>? CachedCerts { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Earliest UTC instant when renewal may be attempted again for each hostname (e.g. after ACME rate limit).
|
||||
/// Keys use the same DNS names as <see cref="CachedCerts"/>.
|
||||
/// </summary>
|
||||
public Dictionary<string, DateTimeOffset>? AcmeRenewalNotBeforeUtcByHostname { get; set; }
|
||||
|
||||
public byte[]? AccountKey { get; set; }
|
||||
public string? Id { get; set; }
|
||||
public Jwk? Key { get; set; }
|
||||
@ -77,6 +84,36 @@ public class RegistrationCache {
|
||||
return hosts.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True if the hostname must not be renewed yet due to a stored ACME cooldown.
|
||||
/// </summary>
|
||||
public bool IsHostnameInAcmeCooldown(string hostname, out DateTimeOffset notBeforeUtc) {
|
||||
notBeforeUtc = default;
|
||||
if (AcmeRenewalNotBeforeUtcByHostname == null)
|
||||
return false;
|
||||
|
||||
foreach (var kvp in AcmeRenewalNotBeforeUtcByHostname) {
|
||||
if (!string.Equals(kvp.Key, hostname, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
if (kvp.Value <= DateTimeOffset.UtcNow)
|
||||
return false;
|
||||
notBeforeUtc = kvp.Value;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public void ClearAcmeCooldownForHostname(string hostname) {
|
||||
if (AcmeRenewalNotBeforeUtcByHostname == null)
|
||||
return;
|
||||
|
||||
var key = AcmeRenewalNotBeforeUtcByHostname.Keys
|
||||
.FirstOrDefault(k => string.Equals(k, hostname, StringComparison.OrdinalIgnoreCase));
|
||||
if (key != null)
|
||||
AcmeRenewalNotBeforeUtcByHostname.Remove(key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns cached certificate. Certs older than 30 days are not returned
|
||||
/// </summary>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
using MaksIT.Core.Security.JWK;
|
||||
using MaksIT.LetsEncrypt.Models.Responses;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
using MaksIT.Core.Security.JWK;
|
||||
using MaksIT.LetsEncrypt.Models.Responses;
|
||||
|
||||
namespace MaksIT.LetsEncrypt.Entities.LetsEncrypt;
|
||||
|
||||
@ -13,4 +13,11 @@ public class State {
|
||||
public RegistrationCache? Cache { get; set; }
|
||||
public Jwk? Jwk { get; set; }
|
||||
public RSA? Rsa { get; set; }
|
||||
|
||||
/// <summary>Returns the session account key pair when both RSA and JWK are present (after <c>Init</c>).</summary>
|
||||
public bool TryGetAccountKey([NotNullWhen(true)] out RSA? rsa, [NotNullWhen(true)] out Jwk? jwk) {
|
||||
rsa = Rsa;
|
||||
jwk = Jwk;
|
||||
return rsa is not null && jwk is not null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,20 +1,44 @@
|
||||
using MaksIT.LetsEncrypt.Models.Responses;
|
||||
|
||||
using MaksIT.LetsEncrypt;
|
||||
using MaksIT.LetsEncrypt.Models.Responses;
|
||||
|
||||
namespace MaksIT.LetsEncrypt.Exceptions;
|
||||
|
||||
/// <summary>Thrown when the ACME server returns a problem document or challenge error.</summary>
|
||||
/// <remarks>
|
||||
/// <see cref="Response"/> implements <see cref="IDisposable"/>. <see cref="Services.LetsEncryptService"/> disposes it
|
||||
/// when mapping this exception to a <see cref="MaksIT.Core.Results.Result"/>; any other handler must dispose it to avoid holding connections.
|
||||
/// </remarks>
|
||||
public class LetsEncrytException : Exception {
|
||||
|
||||
public Problem? Problem { get; }
|
||||
|
||||
/// <summary>HTTP response that carried the problem (must be disposed if this exception is not handled by <see cref="Services.LetsEncryptService"/>).</summary>
|
||||
public HttpResponseMessage Response { get; }
|
||||
|
||||
/// <summary>Classified <c>type</c> from the ACME problem document (RFC 8555 §6.7).</summary>
|
||||
public AcmeProblemKind ProblemKind { get; }
|
||||
|
||||
/// <summary>Combined Retry-After from HTTP header and problem detail, when present.</summary>
|
||||
public DateTimeOffset? RetryAfterUtc { get; }
|
||||
|
||||
/// <summary>Hostname from Let's Encrypt rate-limit <c>detail</c>, when parseable.</summary>
|
||||
public string? RateLimitedIdentifier { get; }
|
||||
|
||||
public bool IsRateLimited => ProblemKind == AcmeProblemKind.RateLimited;
|
||||
|
||||
public LetsEncrytException(
|
||||
Problem? problem,
|
||||
HttpResponseMessage response
|
||||
) : base(problem != null ? $"{problem.Type}: {problem.Detail}" : "") {
|
||||
) : base(problem != null
|
||||
? $"{problem.Type}: {problem.Detail}"
|
||||
: $"HTTP {(int)(response ?? throw new ArgumentNullException(nameof(response))).StatusCode}") {
|
||||
|
||||
ArgumentNullException.ThrowIfNull(response);
|
||||
Problem = problem;
|
||||
Response = response;
|
||||
ProblemKind = AcmeProblemKind.FromTypeUri(problem?.Type);
|
||||
RetryAfterUtc = AcmeRetryAfterParser.TryCombineRetryAfterUtc(response, problem);
|
||||
if (ProblemKind == AcmeProblemKind.RateLimited)
|
||||
RateLimitedIdentifier = AcmeRetryAfterParser.TryParseRateLimitedHostname(problem?.Detail);
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ public static class ServiceCollectionExtensions {
|
||||
};
|
||||
|
||||
services.AddSingleton(config);
|
||||
services.AddSingleton<AcmeSessionStore>();
|
||||
services.AddHttpClient<ILetsEncryptService, LetsEncryptService>();
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,6 +7,10 @@
|
||||
<RootNamespace>MaksIT.$(MSBuildProjectName.Replace(" ", "_"))</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="LetsEncrypt.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MaksIT.Core" Version="1.6.5" />
|
||||
<PackageReference Include="MaksIT.Results" Version="2.0.1" />
|
||||
|
||||
@ -1,17 +1,17 @@
|
||||
namespace MaksIT.LetsEncrypt.Models.Responses;
|
||||
|
||||
public class AcmeDirectory {
|
||||
public Uri KeyChange { get; set; }
|
||||
public AcmeDirectoryMeta Meta { get; set; }
|
||||
public Uri NewAccount { get; set; }
|
||||
public Uri NewNonce { get; set; }
|
||||
public Uri NewOrder { get; set; }
|
||||
public Uri RenewalInfo { get; set; }
|
||||
public Uri RevokeCert { get; set; }
|
||||
public Uri? KeyChange { get; set; }
|
||||
public AcmeDirectoryMeta? Meta { get; set; }
|
||||
public Uri? NewAccount { get; set; }
|
||||
public Uri? NewNonce { get; set; }
|
||||
public Uri? NewOrder { get; set; }
|
||||
public Uri? RenewalInfo { get; set; }
|
||||
public Uri? RevokeCert { get; set; }
|
||||
}
|
||||
|
||||
public class AcmeDirectoryMeta {
|
||||
public string[] CaaIdentities { get; set; }
|
||||
public string TermsOfService { get; set; }
|
||||
public string Website { get; set; }
|
||||
public string[]? CaaIdentities { get; set; }
|
||||
public string? TermsOfService { get; set; }
|
||||
public string? Website { get; set; }
|
||||
}
|
||||
@ -6,7 +6,7 @@ using System.Threading.Tasks;
|
||||
|
||||
namespace MaksIT.LetsEncrypt.Models.Responses;
|
||||
public class AuthorizationChallengeError {
|
||||
public string Type { get; set; }
|
||||
public string? Type { get; set; }
|
||||
|
||||
public string Detail { get; set; }
|
||||
public string? Detail { get; set; }
|
||||
}
|
||||
|
||||
@ -6,10 +6,10 @@ using System.Threading.Tasks;
|
||||
|
||||
namespace MaksIT.LetsEncrypt.Models.Responses {
|
||||
public class Problem {
|
||||
public string Type { get; set; }
|
||||
public string? Type { get; set; }
|
||||
|
||||
public string Detail { get; set; }
|
||||
public string? Detail { get; set; }
|
||||
|
||||
public string RawJson { get; set; }
|
||||
public string? RawJson { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
21
src/LetsEncrypt/Services/AcmeSessionStore.cs
Normal file
21
src/LetsEncrypt/Services/AcmeSessionStore.cs
Normal file
@ -0,0 +1,21 @@
|
||||
using MaksIT.LetsEncrypt.Entities.LetsEncrypt;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace MaksIT.LetsEncrypt.Services;
|
||||
|
||||
/// <summary>Caches per-session <see cref="State"/> for ACME flows (directory, account, current order, challenges).</summary>
|
||||
public sealed class AcmeSessionStore {
|
||||
private static readonly TimeSpan SessionTtl = TimeSpan.FromHours(1);
|
||||
|
||||
private readonly IMemoryCache _cache;
|
||||
|
||||
public AcmeSessionStore(IMemoryCache cache) => _cache = cache;
|
||||
|
||||
public State GetOrCreate(Guid sessionId) {
|
||||
if (!_cache.TryGetValue(sessionId, out State? state) || state is null) {
|
||||
state = new State();
|
||||
_cache.Set(sessionId, state, SessionTtl);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
}
|
||||
235
src/LetsEncrypt/Services/LetsEncryptService.Helpers.cs
Normal file
235
src/LetsEncrypt/Services/LetsEncryptService.Helpers.cs
Normal file
@ -0,0 +1,235 @@
|
||||
using System.Net.Http.Headers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MaksIT.Core.Extensions;
|
||||
using MaksIT.Core.Security.JWS;
|
||||
using MaksIT.LetsEncrypt.Entities;
|
||||
using MaksIT.LetsEncrypt.Entities.Jws;
|
||||
using MaksIT.LetsEncrypt.Entities.LetsEncrypt;
|
||||
using MaksIT.LetsEncrypt.Exceptions;
|
||||
using MaksIT.LetsEncrypt.Models.Interfaces;
|
||||
using MaksIT.LetsEncrypt.Models.Responses;
|
||||
using MaksIT.Results;
|
||||
|
||||
|
||||
namespace MaksIT.LetsEncrypt.Services;
|
||||
|
||||
public partial class LetsEncryptService {
|
||||
|
||||
#region Internal helpers
|
||||
|
||||
private State GetOrCreateState(Guid sessionId) => _sessions.GetOrCreate(sessionId);
|
||||
|
||||
private async Task<Result<string?>> GetNonceAsync(Guid sessionId, Uri uri) {
|
||||
if (uri == null)
|
||||
return Result<string?>.InternalServerError(null, "URI is null");
|
||||
|
||||
try {
|
||||
var state = GetOrCreateState(sessionId);
|
||||
|
||||
_logger.LogInformation($"Executing {nameof(GetNonceAsync)}...");
|
||||
|
||||
if (state.Directory is not { NewNonce: { } newNonceUri })
|
||||
return Result<string?>.InternalServerError(null, "Directory or NewNonce URL is null.");
|
||||
|
||||
var result = await _httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Head, newNonceUri));
|
||||
|
||||
var nonce = result.Headers.GetValues(ReplayNonceHeader).FirstOrDefault();
|
||||
|
||||
if (nonce == null)
|
||||
return Result<string?>.InternalServerError(null, "Nonce is null");
|
||||
|
||||
return Result<string?>.Ok(nonce);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
return HandleUnhandledException<string?>(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Result<SendResult<T>?>> SendAcmeRequest<T>(HttpRequestMessage request, State state, HttpMethod method) {
|
||||
try {
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
var responseText = await response.Content.ReadAsStringAsync();
|
||||
|
||||
HandleProblemResponseAsync(response, responseText);
|
||||
|
||||
var sendResult = ProcessResponseContent<T>(response, responseText);
|
||||
|
||||
return Result<SendResult<T>?>.Ok(sendResult);
|
||||
}
|
||||
|
||||
catch (LetsEncrytException ex) {
|
||||
return MapLetsEncryptException<SendResult<T>?>(state, ex, null);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
return HandleUnhandledException<SendResult<T>?>(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private Result<string?> EncodeMessage(Guid sessionId, bool isPostAsGet, object? requestModel, ACMEJwsHeader protectedHeader) {
|
||||
var state = GetOrCreateState(sessionId);
|
||||
|
||||
if (!state.TryGetAccountKey(out var rsa, out var jwk))
|
||||
return Result<string?>.InternalServerError(AccountKeyMissingMessage);
|
||||
|
||||
if (isPostAsGet) {
|
||||
if (!JwsGenerator.TryEncode(rsa, jwk, protectedHeader, out var jwsPostAsGet, out var errPostAsGet))
|
||||
return Result<string?>.InternalServerError(errPostAsGet);
|
||||
|
||||
return Result<string?>.Ok(jwsPostAsGet.ToJson());
|
||||
}
|
||||
|
||||
if (!JwsGenerator.TryEncode(rsa, jwk, protectedHeader, requestModel, out var jwsWithPayload, out var errWithPayload))
|
||||
return Result<string?>.InternalServerError(errWithPayload);
|
||||
|
||||
return Result<string?>.Ok(jwsWithPayload.ToJson());
|
||||
}
|
||||
|
||||
private static string GetContentType(ContentType type) => type.GetDisplayName();
|
||||
|
||||
private void PrepareRequestContent(HttpRequestMessage request, string json, HttpMethod method) {
|
||||
request.Content = new StringContent(json ?? string.Empty);
|
||||
var contentType = method == HttpMethod.Post
|
||||
? GetContentType(ContentType.JoseJson)
|
||||
: GetContentType(ContentType.Json);
|
||||
request.Content.Headers.ContentType = new MediaTypeHeaderValue(contentType);
|
||||
}
|
||||
|
||||
private async Task<Result> PollChallengeStatus(Guid sessionId, AuthorizationChallengeChallenge challenge) {
|
||||
if (challenge?.Url == null)
|
||||
return Result.InternalServerError("Challenge URL is null");
|
||||
|
||||
var start = DateTime.UtcNow;
|
||||
|
||||
while (true) {
|
||||
var pollRequest = new HttpRequestMessage(HttpMethod.Post, challenge.Url);
|
||||
|
||||
var nonceResult = await GetNonceAsync(sessionId, challenge.Url);
|
||||
if (!nonceResult.IsSuccess || nonceResult.Value == null)
|
||||
return nonceResult;
|
||||
|
||||
var nonce = nonceResult.Value;
|
||||
|
||||
var pollJsonResult = EncodeMessage(sessionId, true, null, new ACMEJwsHeader {
|
||||
Url = challenge.Url.ToString(),
|
||||
Nonce = nonce
|
||||
});
|
||||
|
||||
if (!pollJsonResult.IsSuccess || pollJsonResult.Value == null)
|
||||
return pollJsonResult;
|
||||
|
||||
var pollJson = pollJsonResult.Value;
|
||||
|
||||
PrepareRequestContent(pollRequest, pollJson, HttpMethod.Post);
|
||||
|
||||
var pollResponse = await _httpClient.SendAsync(pollRequest);
|
||||
|
||||
var pollResponseText = await pollResponse.Content.ReadAsStringAsync();
|
||||
|
||||
HandleProblemResponseAsync(pollResponse, pollResponseText);
|
||||
|
||||
var authChallenge = ProcessResponseContent<AuthorizationChallengeResponse>(pollResponse, pollResponseText);
|
||||
|
||||
if (authChallenge.Result?.Status != "pending")
|
||||
return authChallenge.Result?.Status == "valid" ? Result.Ok() : Result.InternalServerError();
|
||||
|
||||
if ((DateTime.UtcNow - start).Seconds > 120)
|
||||
return Result.InternalServerError("Timeout");
|
||||
|
||||
await Task.Delay(1000);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleProblemResponseAsync(HttpResponseMessage response, string responseText) {
|
||||
if (response.Content.Headers.ContentType?.MediaType == GetContentType(ContentType.ProblemJson)) {
|
||||
var problem = responseText.ToObject<Problem>();
|
||||
|
||||
throw new LetsEncrytException(problem, response);
|
||||
}
|
||||
|
||||
if (response.Content.Headers.ContentType?.MediaType == GetContentType(ContentType.Json)) {
|
||||
var authorizationChallengeChallenge = responseText.ToObject<AuthorizationChallengeChallenge>();
|
||||
|
||||
if (authorizationChallengeChallenge?.Status == "invalid") {
|
||||
throw new LetsEncrytException(new Problem {
|
||||
Type = authorizationChallengeChallenge.Error?.Type,
|
||||
Detail = authorizationChallengeChallenge.Error?.Detail,
|
||||
RawJson = responseText
|
||||
}, response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private SendResult<TResult> ProcessResponseContent<TResult>(HttpResponseMessage response, string responseText) {
|
||||
if (response.Content.Headers.ContentType?.MediaType == GetContentType(ContentType.PemCertificateChain) && typeof(TResult) == typeof(string)) {
|
||||
return new SendResult<TResult> {
|
||||
Result = (TResult)(object)responseText
|
||||
};
|
||||
}
|
||||
var responseContent = responseText.ToObject<TResult>();
|
||||
if (responseContent is IHasLocation ihl && response.Headers.Location != null) {
|
||||
ihl.Location = response.Headers.Location;
|
||||
}
|
||||
return new SendResult<TResult> {
|
||||
Result = responseContent,
|
||||
ResponseText = responseText
|
||||
};
|
||||
}
|
||||
|
||||
private static bool StatusEquals(string? status, OrderStatus expected) => status == expected.GetDisplayName();
|
||||
|
||||
private Result MapLetsEncryptException(State state, LetsEncrytException ex) =>
|
||||
MapLetsEncryptExceptionCore<Result>(state, ex, m => Result.TooManyRequests(m), m => Result.InternalServerError(m));
|
||||
|
||||
private Result<T?> MapLetsEncryptException<T>(State state, LetsEncrytException ex, T? defaultValue) =>
|
||||
MapLetsEncryptExceptionCore(state, ex, m => Result<T?>.TooManyRequests(defaultValue, m), m => Result<T?>.InternalServerError(defaultValue, m));
|
||||
|
||||
private TResult MapLetsEncryptExceptionCore<TResult>(
|
||||
State state,
|
||||
LetsEncrytException ex,
|
||||
Func<string, TResult> tooManyRequests,
|
||||
Func<string, TResult> internalError) {
|
||||
try {
|
||||
if (ex.ProblemKind == AcmeProblemKind.RateLimited) {
|
||||
var when = ex.RetryAfterUtc ?? DateTimeOffset.UtcNow.AddHours(1);
|
||||
var id = ex.RateLimitedIdentifier;
|
||||
if (state.Cache != null && !string.IsNullOrEmpty(id)) {
|
||||
var key = id.ToLowerInvariant();
|
||||
state.Cache.AcmeRenewalNotBeforeUtcByHostname ??= new Dictionary<string, DateTimeOffset>(StringComparer.OrdinalIgnoreCase);
|
||||
if (state.Cache.AcmeRenewalNotBeforeUtcByHostname.TryGetValue(key, out var existing))
|
||||
when = when > existing ? when : existing;
|
||||
state.Cache.AcmeRenewalNotBeforeUtcByHostname[key] = when;
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"ACME rate limited: Kind {AcmeProblemKind}, Type {AcmeProblemType}, Hostname {Hostname}, RetryNotBefore {RetryNotBeforeUtc:o}. Detail: {Detail}",
|
||||
ex.ProblemKind, ex.Problem?.Type, id, when, ex.Problem?.Detail);
|
||||
|
||||
var msg = string.IsNullOrEmpty(id)
|
||||
? $"Let's Encrypt rate limit. Do not retry certificate operations before {when:u} UTC."
|
||||
: $"Let's Encrypt rate limit for hostname '{id}'. Do not retry before {when:u} UTC.";
|
||||
return tooManyRequests(msg);
|
||||
}
|
||||
|
||||
_logger.LogWarning(ex,
|
||||
"Let's Encrypt ACME problem: Kind {AcmeProblemKind}, Type {AcmeProblemType}. {Detail}",
|
||||
ex.ProblemKind, ex.Problem?.Type, ex.Problem?.Detail);
|
||||
var fallback = string.IsNullOrEmpty(ex.Message) ? "Let's Encrypt request failed." : ex.Message;
|
||||
return internalError(fallback);
|
||||
}
|
||||
finally {
|
||||
ex.Response?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private Result HandleUnhandledException(Exception ex, string defaultMessage = "Let's Encrypt client unhandled exception") {
|
||||
_logger.LogError(ex, defaultMessage);
|
||||
return Result.InternalServerError([defaultMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
|
||||
private Result<T?> HandleUnhandledException<T>(Exception ex, T? defaultValue = default, string defaultMessage = "Let's Encrypt client unhandled exception") {
|
||||
_logger.LogError(ex, defaultMessage);
|
||||
return Result<T?>.InternalServerError(defaultValue, [.. ex.ExtractMessages()]);
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@ -15,7 +15,6 @@ using MaksIT.LetsEncrypt.Models.Interfaces;
|
||||
using MaksIT.LetsEncrypt.Models.Requests;
|
||||
using MaksIT.LetsEncrypt.Models.Responses;
|
||||
using MaksIT.Results;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
@ -28,7 +27,7 @@ namespace MaksIT.LetsEncrypt.Services;
|
||||
public interface ILetsEncryptService {
|
||||
Result<RegistrationCache?> GetRegistrationCache(Guid sessionId);
|
||||
Task<Result> ConfigureClient(Guid sessionId, bool isStaging);
|
||||
Task<Result> Init(Guid sessionId,Guid accountId, string description, string[] contacts, RegistrationCache? registrationCache);
|
||||
Task<Result> Init(Guid sessionId, Guid accountId, string description, string[] contacts, RegistrationCache? registrationCache);
|
||||
Result<string?> GetTermsOfServiceUri(Guid sessionId);
|
||||
Task<Result<Dictionary<string, string>?>> NewOrder(Guid sessionId, string[] hostnames, string challengeType);
|
||||
Task<Result> CompleteChallenges(Guid sessionId);
|
||||
@ -37,32 +36,33 @@ public interface ILetsEncryptService {
|
||||
Task<Result> RevokeCertificate(Guid sessionId, string subject, RevokeReason reason);
|
||||
}
|
||||
|
||||
public class LetsEncryptService : ILetsEncryptService {
|
||||
public partial class LetsEncryptService : ILetsEncryptService {
|
||||
private const string DnsType = "dns";
|
||||
private const string DirectoryEndpoint = "directory";
|
||||
private const string ReplayNonceHeader = "Replay-Nonce";
|
||||
private const string AccountKeyMissingMessage = "Account key is not loaded; complete Init before this operation.";
|
||||
|
||||
private readonly ILogger<LetsEncryptService> _logger;
|
||||
private readonly LetsEncryptConfiguration _appSettings;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
private readonly AcmeSessionStore _sessions;
|
||||
|
||||
public LetsEncryptService(
|
||||
ILogger<LetsEncryptService> logger,
|
||||
LetsEncryptConfiguration appSettings,
|
||||
HttpClient httpClient,
|
||||
IMemoryCache cache
|
||||
AcmeSessionStore sessions
|
||||
) {
|
||||
_logger = logger;
|
||||
_appSettings = appSettings;
|
||||
_httpClient = httpClient;
|
||||
_memoryCache = cache;
|
||||
_sessions = sessions;
|
||||
}
|
||||
|
||||
public Result<RegistrationCache?> GetRegistrationCache(Guid sessionId) {
|
||||
var state = GetOrCreateState(sessionId);
|
||||
|
||||
if (state?.Cache == null)
|
||||
if (state.Cache == null)
|
||||
return Result<RegistrationCache?>.InternalServerError(null);
|
||||
|
||||
return Result<RegistrationCache?>.Ok(state.Cache);
|
||||
@ -92,7 +92,8 @@ public class LetsEncryptService : ILetsEncryptService {
|
||||
return Result.Ok("Client configured successfully.");
|
||||
}
|
||||
catch (LetsEncrytException ex) {
|
||||
return HandleUnhandledException(ex, "Let's Encrypt client encountered a problem");
|
||||
var state = GetOrCreateState(sessionId);
|
||||
return MapLetsEncryptException(state, ex);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
return HandleUnhandledException(ex);
|
||||
@ -149,21 +150,24 @@ public class LetsEncryptService : ILetsEncryptService {
|
||||
state.Rsa = accountKey;
|
||||
state.Jwk = jwk;
|
||||
|
||||
if (state.Directory.NewAccount is not { } newAccountUri)
|
||||
return Result.InternalServerError("Directory is missing NewAccount URL.");
|
||||
|
||||
var letsEncryptOrder = new Account {
|
||||
TermsOfServiceAgreed = true,
|
||||
Contacts = [.. contacts.Select(contact => $"mailto:{contact}")]
|
||||
};
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, state.Directory.NewAccount);
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, newAccountUri);
|
||||
|
||||
var nonceResult = await GetNonceAsync(sessionId, state.Directory.NewAccount);
|
||||
var nonceResult = await GetNonceAsync(sessionId, newAccountUri);
|
||||
if (!nonceResult.IsSuccess || nonceResult.Value == null)
|
||||
return nonceResult;
|
||||
|
||||
var nonce = nonceResult.Value;
|
||||
|
||||
var jsonResult = EncodeMessage(sessionId, false, letsEncryptOrder, new ACMEJwsHeader {
|
||||
Url = state.Directory.NewAccount.ToString(),
|
||||
Url = newAccountUri.ToString(),
|
||||
Nonce = nonce
|
||||
});
|
||||
|
||||
@ -183,9 +187,9 @@ public class LetsEncryptService : ILetsEncryptService {
|
||||
state.Jwk.KeyId = result.Result?.Location?.ToString() ?? string.Empty;
|
||||
|
||||
if (result.Result?.Status != "valid") {
|
||||
errorMessage = $"Account status is not valid, was: {result.Result?.Status} \r\n {result.ResponseText}";
|
||||
_logger.LogError(errorMessage);
|
||||
return Result.InternalServerError(errorMessage);
|
||||
var accountStatusMessage = $"Account status is not valid, was: {result.Result?.Status} \r\n {result.ResponseText}";
|
||||
_logger.LogError(accountStatusMessage);
|
||||
return Result.InternalServerError(accountStatusMessage);
|
||||
}
|
||||
|
||||
state.Cache = new RegistrationCache {
|
||||
@ -204,7 +208,7 @@ public class LetsEncryptService : ILetsEncryptService {
|
||||
return Result.Ok("Initialization successful.");
|
||||
}
|
||||
catch (LetsEncrytException ex) {
|
||||
return HandleUnhandledException(ex, "Let's Encrypt client encountered a problem");
|
||||
return MapLetsEncryptException(state, ex);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
return HandleUnhandledException(ex);
|
||||
@ -248,24 +252,24 @@ public class LetsEncryptService : ILetsEncryptService {
|
||||
}).ToArray() ?? []
|
||||
};
|
||||
|
||||
if (state.Directory == null || state.Directory.NewOrder == null)
|
||||
if (state.Directory?.NewOrder is not { } newOrderUri)
|
||||
return Result<Dictionary<string, string>?>.InternalServerError(null);
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, state.Directory.NewOrder);
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, newOrderUri);
|
||||
|
||||
var nonceResult = await GetNonceAsync(sessionId, state.Directory.NewOrder);
|
||||
var nonceResult = await GetNonceAsync(sessionId, newOrderUri);
|
||||
if (!nonceResult.IsSuccess || nonceResult.Value == null)
|
||||
return nonceResult.ToResultOfType<Dictionary<string, string>?>(_ => null);
|
||||
|
||||
var nonce = nonceResult.Value;
|
||||
|
||||
var jsonResult = EncodeMessage(sessionId, false, letsEncryptOrder, new ACMEJwsHeader {
|
||||
Url = state.Directory.NewOrder.ToString(),
|
||||
Url = newOrderUri.ToString(),
|
||||
Nonce = nonce
|
||||
});
|
||||
|
||||
if (!jsonResult.IsSuccess || jsonResult.Value == null)
|
||||
return jsonResult.ToResultOfType <Dictionary<string, string>?>(_ => null);
|
||||
return jsonResult.ToResultOfType<Dictionary<string, string>?>(_ => null);
|
||||
|
||||
var json = jsonResult.Value;
|
||||
|
||||
@ -323,7 +327,7 @@ public class LetsEncryptService : ILetsEncryptService {
|
||||
continue;
|
||||
|
||||
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: {challengeResponse.Result?.Status} \r\n {challengeResponse.ResponseText}");
|
||||
return Result<Dictionary<string, string>?>.InternalServerError(null);
|
||||
}
|
||||
|
||||
@ -340,6 +344,9 @@ public class LetsEncryptService : ILetsEncryptService {
|
||||
if (state.Cache != null)
|
||||
state.Cache.ChallengeType = challengeType;
|
||||
|
||||
if (state.Jwk is null)
|
||||
return Result<Dictionary<string, string>?>.InternalServerError(null, AccountKeyMissingMessage);
|
||||
|
||||
if (!JwkThumbprintUtility.TryGetKeyAuthorization(state.Jwk, challenge.Token, out var keyToken, out var errorMessage))
|
||||
return Result<Dictionary<string, string>?>.InternalServerError(null, errorMessage);
|
||||
|
||||
@ -381,7 +388,12 @@ public class LetsEncryptService : ILetsEncryptService {
|
||||
for (var index = 0; index < state.Challenges.Count; index++) {
|
||||
var challenge = state.Challenges[index];
|
||||
|
||||
if (challenge?.Url == null) {
|
||||
if (challenge is null) {
|
||||
_logger.LogError("Challenge entry is null");
|
||||
return Result.InternalServerError("Challenge entry is null");
|
||||
}
|
||||
|
||||
if (challenge.Url is null) {
|
||||
_logger.LogError("Challenge URL is null");
|
||||
return Result.InternalServerError("Challenge URL is null");
|
||||
}
|
||||
@ -406,7 +418,7 @@ public class LetsEncryptService : ILetsEncryptService {
|
||||
|
||||
PrepareRequestContent(request, json, HttpMethod.Post);
|
||||
|
||||
var authChallenge = await SendAcmeRequest<AuthorizationChallengeResponse>(request, state, HttpMethod.Post);
|
||||
_ = await SendAcmeRequest<AuthorizationChallengeResponse>(request, state, HttpMethod.Post);
|
||||
|
||||
var result = await PollChallengeStatus(sessionId, challenge);
|
||||
|
||||
@ -415,6 +427,9 @@ public class LetsEncryptService : ILetsEncryptService {
|
||||
}
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (LetsEncrytException ex) {
|
||||
return MapLetsEncryptException(GetOrCreateState(sessionId), ex);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
return HandleUnhandledException(ex);
|
||||
}
|
||||
@ -428,24 +443,27 @@ public class LetsEncryptService : ILetsEncryptService {
|
||||
|
||||
var state = GetOrCreateState(sessionId);
|
||||
|
||||
if (state.Directory?.NewOrder is not { } newOrderUri)
|
||||
return Result.InternalServerError("Directory is not configured. Run ConfigureClient first.");
|
||||
|
||||
var letsEncryptOrder = new Order {
|
||||
Expires = DateTime.UtcNow.AddDays(2),
|
||||
Identifiers = hostnames?.Where(h => h != null).Select(hostname => new OrderIdentifier {
|
||||
Type = "dns",
|
||||
Type = DnsType,
|
||||
Value = hostname!
|
||||
}).ToArray() ?? []
|
||||
};
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, state.Directory!.NewOrder);
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, newOrderUri);
|
||||
|
||||
var nonceResult = await GetNonceAsync(sessionId, state.Directory.NewOrder);
|
||||
var nonceResult = await GetNonceAsync(sessionId, newOrderUri);
|
||||
if (!nonceResult.IsSuccess || nonceResult.Value == null)
|
||||
return nonceResult;
|
||||
|
||||
var nonce = nonceResult.Value;
|
||||
|
||||
var jsonResult = EncodeMessage(sessionId, false, letsEncryptOrder, new ACMEJwsHeader {
|
||||
Url = state.Directory.NewOrder.ToString(),
|
||||
Url = newOrderUri.ToString(),
|
||||
Nonce = nonce
|
||||
});
|
||||
|
||||
@ -479,15 +497,14 @@ public class LetsEncryptService : ILetsEncryptService {
|
||||
|
||||
_logger.LogInformation($"Executing {nameof(GetCertificate)}...");
|
||||
|
||||
if (state.CurrentOrder?.Identifiers == null) {
|
||||
if (state.CurrentOrder?.Identifiers is not { } initialIdentifiers)
|
||||
return Result.InternalServerError();
|
||||
}
|
||||
|
||||
var key = new RSACryptoServiceProvider(4096);
|
||||
var csr = new CertificateRequest("CN=" + subject, key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
var san = new SubjectAlternativeNameBuilder();
|
||||
|
||||
foreach (var host in state.CurrentOrder.Identifiers) {
|
||||
foreach (var host in initialIdentifiers) {
|
||||
if (host?.Value != null)
|
||||
san.AddDnsName(host.Value);
|
||||
}
|
||||
@ -503,23 +520,34 @@ public class LetsEncryptService : ILetsEncryptService {
|
||||
var start = DateTime.UtcNow;
|
||||
|
||||
while (certificateUrl == null) {
|
||||
var hostnames = state.CurrentOrder?.Identifiers?.Select(x => x?.Value).Where(x => x != null).Cast<string>().ToArray() ?? [];
|
||||
var activeOrder = state.CurrentOrder;
|
||||
if (activeOrder?.Identifiers is not { } idents)
|
||||
return Result.InternalServerError("Current order identifiers are not available.");
|
||||
|
||||
var hostnames = idents.Select(x => x?.Value).Where(x => x != null).Cast<string>().ToArray();
|
||||
|
||||
await GetOrder(sessionId, hostnames);
|
||||
|
||||
var status = state.CurrentOrder?.Status;
|
||||
activeOrder = state.CurrentOrder;
|
||||
if (activeOrder is null)
|
||||
return Result.InternalServerError("Current order is no longer available.");
|
||||
|
||||
var status = activeOrder.Status;
|
||||
|
||||
if (StatusEquals(status, OrderStatus.Ready)) {
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, state.CurrentOrder.Finalize);
|
||||
if (activeOrder.Finalize is not { } finalizeUri)
|
||||
return Result.InternalServerError("Order finalize URL is missing.");
|
||||
|
||||
var nonceResult = await GetNonceAsync(sessionId, state.CurrentOrder.Finalize);
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, finalizeUri);
|
||||
|
||||
var nonceResult = await GetNonceAsync(sessionId, finalizeUri);
|
||||
if (!nonceResult.IsSuccess || nonceResult.Value == null)
|
||||
return nonceResult;
|
||||
|
||||
var nonce = nonceResult.Value;
|
||||
|
||||
var jsonResult = EncodeMessage(sessionId, false, letsEncryptOrder, new ACMEJwsHeader {
|
||||
Url = state.CurrentOrder.Finalize.ToString(),
|
||||
Url = finalizeUri.ToString(),
|
||||
Nonce = nonce
|
||||
});
|
||||
|
||||
@ -537,16 +565,20 @@ public class LetsEncryptService : ILetsEncryptService {
|
||||
var order = orderResult.Value;
|
||||
|
||||
if (StatusEquals(order.Result?.Status, OrderStatus.Processing)) {
|
||||
request = new HttpRequestMessage(HttpMethod.Post, state.CurrentOrder.Location!);
|
||||
activeOrder = state.CurrentOrder;
|
||||
if (activeOrder?.Location is not { } orderLocation)
|
||||
return Result.InternalServerError("Order location URL is missing.");
|
||||
|
||||
nonceResult = await GetNonceAsync(sessionId, state.CurrentOrder.Location);
|
||||
request = new HttpRequestMessage(HttpMethod.Post, orderLocation);
|
||||
|
||||
nonceResult = await GetNonceAsync(sessionId, orderLocation);
|
||||
if (!nonceResult.IsSuccess || nonceResult.Value == null)
|
||||
return nonceResult;
|
||||
|
||||
nonce = nonceResult.Value;
|
||||
|
||||
jsonResult = EncodeMessage(sessionId, true, null, new ACMEJwsHeader {
|
||||
Url = state.CurrentOrder.Location.ToString(),
|
||||
Url = orderLocation.ToString(),
|
||||
Nonce = nonce
|
||||
});
|
||||
|
||||
@ -565,11 +597,15 @@ public class LetsEncryptService : ILetsEncryptService {
|
||||
}
|
||||
|
||||
if (StatusEquals(order.Result?.Status, OrderStatus.Valid)) {
|
||||
certificateUrl = order.Result.Certificate;
|
||||
certificateUrl = order.Result?.Certificate;
|
||||
if (certificateUrl is null)
|
||||
return Result.InternalServerError("Certificate URL was not returned by the CA.");
|
||||
}
|
||||
}
|
||||
else if (StatusEquals(status, OrderStatus.Valid)) {
|
||||
certificateUrl = state.CurrentOrder.Certificate;
|
||||
if (activeOrder.Certificate is not { } certUri)
|
||||
return Result.InternalServerError("Certificate URL is missing on the order.");
|
||||
certificateUrl = certUri;
|
||||
break;
|
||||
}
|
||||
|
||||
@ -579,7 +615,10 @@ public class LetsEncryptService : ILetsEncryptService {
|
||||
await Task.Delay(1000);
|
||||
}
|
||||
|
||||
var finalRequest = new HttpRequestMessage(HttpMethod.Post, certificateUrl!);
|
||||
if (certificateUrl is null)
|
||||
return Result.InternalServerError("Certificate URL could not be determined.");
|
||||
|
||||
var finalRequest = new HttpRequestMessage(HttpMethod.Post, certificateUrl);
|
||||
|
||||
var finalNonceResult = await GetNonceAsync(sessionId, certificateUrl);
|
||||
if (!finalNonceResult.IsSuccess || finalNonceResult.Value == null)
|
||||
@ -618,14 +657,13 @@ public class LetsEncryptService : ILetsEncryptService {
|
||||
PrivatePem = key.ExportRSAPrivateKeyPem()
|
||||
};
|
||||
|
||||
var certPem = pem.Result ?? string.Empty;
|
||||
|
||||
if (!string.IsNullOrEmpty(certPem)) {
|
||||
var cert = new X509Certificate2(Encoding.UTF8.GetBytes(certPem));
|
||||
}
|
||||
state.Cache.ClearAcmeCooldownForHostname(subject);
|
||||
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (LetsEncrytException ex) {
|
||||
return MapLetsEncryptException(GetOrCreateState(sessionId), ex);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
return HandleUnhandledException(ex);
|
||||
}
|
||||
@ -657,7 +695,7 @@ public class LetsEncryptService : ILetsEncryptService {
|
||||
return Result.InternalServerError("Certificate PEM is null or empty");
|
||||
}
|
||||
|
||||
var certificate = new X509Certificate2(Encoding.UTF8.GetBytes(certPem));
|
||||
var certificate = X509Certificate2.CreateFromPem(certPem);
|
||||
|
||||
var derEncodedCert = certificate.Export(X509ContentType.Cert);
|
||||
|
||||
@ -668,20 +706,26 @@ public class LetsEncryptService : ILetsEncryptService {
|
||||
Reason = (int)reason
|
||||
};
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, state.Directory!.RevokeCert);
|
||||
if (state.Directory?.RevokeCert is not { } revokeUri)
|
||||
return Result.InternalServerError("Directory is not configured or RevokeCert URL is missing.");
|
||||
|
||||
var nonceResult = await GetNonceAsync(sessionId, state.Directory.RevokeCert);
|
||||
if (!state.TryGetAccountKey(out var rsa, out var jwk))
|
||||
return Result.InternalServerError(AccountKeyMissingMessage);
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, revokeUri);
|
||||
|
||||
var nonceResult = await GetNonceAsync(sessionId, revokeUri);
|
||||
if (!nonceResult.IsSuccess || nonceResult.Value == null)
|
||||
return nonceResult;
|
||||
|
||||
var nonce = nonceResult.Value;
|
||||
|
||||
var jwsHeader = new ACMEJwsHeader {
|
||||
Url = state.Directory.RevokeCert.ToString(),
|
||||
Url = revokeUri.ToString(),
|
||||
Nonce = nonce
|
||||
};
|
||||
|
||||
if (!JwsGenerator.TryEncode(state.Rsa, state.Jwk, jwsHeader, revokeRequest, out var jwsMessage, out var errorMessage)) {
|
||||
if (!JwsGenerator.TryEncode(rsa, jwk, jwsHeader, revokeRequest, out var jwsMessage, out var errorMessage)) {
|
||||
return Result.InternalServerError(errorMessage);
|
||||
}
|
||||
|
||||
@ -695,203 +739,29 @@ public class LetsEncryptService : ILetsEncryptService {
|
||||
|
||||
var responseText = await response.Content.ReadAsStringAsync();
|
||||
|
||||
if (response.Content.Headers.ContentType?.MediaType == GetContentType(ContentType.ProblemJson)) {
|
||||
var erroObj = responseText.ToObject<Problem>();
|
||||
}
|
||||
HandleProblemResponseAsync(response, responseText);
|
||||
|
||||
try {
|
||||
if (!response.IsSuccessStatusCode)
|
||||
Result.InternalServerError(responseText);
|
||||
return Result.InternalServerError(responseText);
|
||||
|
||||
state.Cache.CachedCerts.Remove(subject);
|
||||
_logger.LogInformation("Certificate revoked successfully");
|
||||
|
||||
return Result.Ok();
|
||||
}
|
||||
finally {
|
||||
response.Dispose();
|
||||
}
|
||||
|
||||
}
|
||||
catch (LetsEncrytException ex) {
|
||||
var state = GetOrCreateState(sessionId);
|
||||
return MapLetsEncryptException(state, ex);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
return HandleUnhandledException(ex);
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Internal helpers
|
||||
private State GetOrCreateState(Guid sessionId) {
|
||||
if (!_memoryCache.TryGetValue(sessionId, out State? state) || state == null) {
|
||||
state = new State();
|
||||
_memoryCache.Set(sessionId, state, TimeSpan.FromHours(1));
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
private async Task<Result<string?>> GetNonceAsync(Guid sessionId, Uri uri) {
|
||||
if (uri == null)
|
||||
return Result<string?>.InternalServerError(null, "URI is null");
|
||||
|
||||
try {
|
||||
var state = GetOrCreateState(sessionId);
|
||||
|
||||
_logger.LogInformation($"Executing {nameof(GetNonceAsync)}...");
|
||||
|
||||
if (state.Directory?.NewNonce == null)
|
||||
return Result<string?>.InternalServerError(null, $"{nameof(state.Directory.NewNonce)} is null");
|
||||
|
||||
var result = await _httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Head, state.Directory.NewNonce));
|
||||
|
||||
var nonce = result.Headers.GetValues("Replay-Nonce").FirstOrDefault();
|
||||
|
||||
if (nonce == null)
|
||||
return Result<string?>.InternalServerError(null, "Nonce is null");
|
||||
|
||||
return Result<string?>.Ok(nonce);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
return HandleUnhandledException<string?>(ex);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: Send ACME request and process response
|
||||
private async Task<Result<SendResult<T>?>> SendAcmeRequest<T>(HttpRequestMessage request, State state, HttpMethod method) {
|
||||
try {
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
var responseText = await response.Content.ReadAsStringAsync();
|
||||
|
||||
HandleProblemResponseAsync(response, responseText);
|
||||
|
||||
var sendResult = ProcessResponseContent<T>(response, responseText);
|
||||
|
||||
return Result<SendResult<T>?>.Ok(sendResult);
|
||||
}
|
||||
|
||||
catch (Exception ex) {
|
||||
return HandleUnhandledException<SendResult<T>?>(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private Result<string?> EncodeMessage(Guid sessionId, bool isPostAsGet, object? requestModel, ACMEJwsHeader protectedHeader) {
|
||||
var state = GetOrCreateState(sessionId);
|
||||
|
||||
JwsMessage jwsMessage;
|
||||
string errorMessage;
|
||||
|
||||
if (isPostAsGet) {
|
||||
if (!JwsGenerator.TryEncode(state.Rsa, state.Jwk, protectedHeader, out jwsMessage, out errorMessage))
|
||||
return Result<string?>.InternalServerError(errorMessage);
|
||||
|
||||
return Result<string?>.Ok(jwsMessage.ToJson());
|
||||
}
|
||||
else {
|
||||
if (!JwsGenerator.TryEncode(state.Rsa, state.Jwk, protectedHeader, requestModel, out jwsMessage, out errorMessage))
|
||||
return Result<string?>.InternalServerError(errorMessage);
|
||||
|
||||
return Result<string?>.Ok(jwsMessage.ToJson());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private static string GetContentType(ContentType type) => type.GetDisplayName();
|
||||
|
||||
private void PrepareRequestContent(HttpRequestMessage request, string json, HttpMethod method) {
|
||||
request.Content = new StringContent(json ?? string.Empty);
|
||||
var contentType = method == HttpMethod.Post
|
||||
? GetContentType(ContentType.JoseJson)
|
||||
: GetContentType(ContentType.Json);
|
||||
request.Content.Headers.ContentType = new MediaTypeHeaderValue(contentType);
|
||||
}
|
||||
|
||||
// Helper: Poll challenge status until valid or timeout
|
||||
private async Task<Result> PollChallengeStatus(Guid sessionId, AuthorizationChallengeChallenge challenge) {
|
||||
if (challenge?.Url == null)
|
||||
return Result.InternalServerError("Challenge URL is null");
|
||||
|
||||
var start = DateTime.UtcNow;
|
||||
|
||||
while (true) {
|
||||
var pollRequest = new HttpRequestMessage(HttpMethod.Post, challenge.Url);
|
||||
|
||||
var nonceResult = await GetNonceAsync(sessionId, challenge.Url);
|
||||
if (!nonceResult.IsSuccess || nonceResult.Value == null)
|
||||
return nonceResult;
|
||||
|
||||
var nonce = nonceResult.Value;
|
||||
|
||||
var pollJsonResult = EncodeMessage(sessionId, true, null, new ACMEJwsHeader {
|
||||
Url = challenge.Url.ToString(),
|
||||
Nonce = nonce
|
||||
});
|
||||
|
||||
if (!pollJsonResult.IsSuccess || pollJsonResult.Value == null)
|
||||
return pollJsonResult;
|
||||
|
||||
var pollJson = pollJsonResult.Value;
|
||||
|
||||
PrepareRequestContent(pollRequest, pollJson, HttpMethod.Post);
|
||||
|
||||
var pollResponse = await _httpClient.SendAsync(pollRequest);
|
||||
|
||||
var pollResponseText = await pollResponse.Content.ReadAsStringAsync();
|
||||
|
||||
HandleProblemResponseAsync(pollResponse, pollResponseText);
|
||||
|
||||
var authChallenge = ProcessResponseContent<AuthorizationChallengeResponse>(pollResponse, pollResponseText);
|
||||
|
||||
if (authChallenge.Result?.Status != "pending")
|
||||
return authChallenge.Result?.Status == "valid" ? Result.Ok() : Result.InternalServerError();
|
||||
|
||||
if ((DateTime.UtcNow - start).Seconds > 120)
|
||||
return Result.InternalServerError("Timeout");
|
||||
|
||||
await Task.Delay(1000);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleProblemResponseAsync(HttpResponseMessage response, string responseText) {
|
||||
if (response.Content.Headers.ContentType?.MediaType == GetContentType(ContentType.ProblemJson)) {
|
||||
var problem = responseText.ToObject<Problem>();
|
||||
|
||||
throw new LetsEncrytException(problem, response);
|
||||
}
|
||||
|
||||
if (response.Content.Headers.ContentType?.MediaType == GetContentType(ContentType.Json)) {
|
||||
var authorizationChallengeChallenge = responseText.ToObject<AuthorizationChallengeChallenge>();
|
||||
|
||||
if (authorizationChallengeChallenge?.Status == "invalid") {
|
||||
throw new LetsEncrytException(new Problem {
|
||||
Type = authorizationChallengeChallenge.Error.Type,
|
||||
Detail = authorizationChallengeChallenge.Error.Detail,
|
||||
RawJson = responseText
|
||||
}, response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private SendResult<TResult> ProcessResponseContent<TResult>(HttpResponseMessage response, string responseText) {
|
||||
if (response.Content.Headers.ContentType?.MediaType == GetContentType(ContentType.PemCertificateChain) && typeof(TResult) == typeof(string)) {
|
||||
return new SendResult<TResult> {
|
||||
Result = (TResult)(object)responseText
|
||||
};
|
||||
}
|
||||
var responseContent = responseText.ToObject<TResult>();
|
||||
if (responseContent is IHasLocation ihl && response.Headers.Location != null) {
|
||||
ihl.Location = response.Headers.Location;
|
||||
}
|
||||
return new SendResult<TResult> {
|
||||
Result = responseContent,
|
||||
ResponseText = responseText
|
||||
};
|
||||
}
|
||||
|
||||
// Helper for status comparison
|
||||
private static bool StatusEquals(string? status, OrderStatus expected) => status == expected.GetDisplayName();
|
||||
|
||||
private Result HandleUnhandledException(Exception ex, string defaultMessage = "Let's Encrypt client unhandled exception") {
|
||||
_logger.LogError(ex, defaultMessage);
|
||||
return Result.InternalServerError([defaultMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
|
||||
private Result<T?> HandleUnhandledException<T>(Exception ex, T? defaultValue = default, string defaultMessage = "Let's Encrypt client unhandled exception") {
|
||||
_logger.LogError(ex, defaultMessage);
|
||||
return Result<T?>.InternalServerError(defaultValue, [.. ex.ExtractMessages()]);
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
@ -71,14 +71,36 @@ namespace MaksIT.Webapi.BackgroundServices {
|
||||
return Result.Ok();
|
||||
}
|
||||
|
||||
var cooldownSkipped = new List<(string Hostname, DateTimeOffset NotBeforeUtc)>();
|
||||
var eligible = new List<string>();
|
||||
foreach (var hostname in toRenew) {
|
||||
if (cache.IsHostnameInAcmeCooldown(hostname, out var notBefore)) {
|
||||
cooldownSkipped.Add((hostname, notBefore));
|
||||
continue;
|
||||
}
|
||||
eligible.Add(hostname);
|
||||
}
|
||||
|
||||
if (cooldownSkipped.Count > 0) {
|
||||
var sample = cooldownSkipped[0];
|
||||
_logger.LogInformation(
|
||||
"Skipping {SkippedCount} hostname(s) in ACME cooldown for account {AccountId} (e.g. {ExampleHost} until {NotBeforeUtc:u} UTC).",
|
||||
cooldownSkipped.Count, cache.AccountId, sample.Hostname, sample.NotBeforeUtc);
|
||||
}
|
||||
|
||||
if (!eligible.Any()) {
|
||||
_logger.LogInformation("All due certificates for account {AccountId} are in ACME cooldown; no renewal attempted.", cache.AccountId);
|
||||
return Result.Ok();
|
||||
}
|
||||
|
||||
var fullFlowResult = await _certsFlowService.FullFlow(
|
||||
cache.IsStaging, cache.AccountId, cache.Description, cache.Contacts, cache.ChallengeType, toRenew.ToArray()
|
||||
cache.IsStaging, cache.AccountId, cache.Description, cache.Contacts, cache.ChallengeType, eligible.ToArray()
|
||||
);
|
||||
|
||||
if (!fullFlowResult.IsSuccess)
|
||||
return fullFlowResult;
|
||||
|
||||
_logger.LogInformation($"Certificates renewed for account {cache.AccountId}: {string.Join(", ", toRenew)}");
|
||||
_logger.LogInformation("Certificates renewed for account {AccountId}: {Hostnames}", cache.AccountId, string.Join(", ", eligible));
|
||||
|
||||
return Result.Ok();
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<Version>3.3.5</Version>
|
||||
<Version>3.3.6</Version>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
@ -187,28 +187,38 @@ public class CertsFlowService(
|
||||
|
||||
var challengesResult = await NewOrderAsync(sessionId, hostnames, challengeType);
|
||||
|
||||
if (!challengesResult.IsSuccess)
|
||||
if (!challengesResult.IsSuccess) {
|
||||
await TryPersistRegistrationCacheFromSessionAsync(sessionId);
|
||||
return challengesResult.ToResultOfType<Guid?>(_ => null);
|
||||
}
|
||||
|
||||
if (challengesResult.Value?.Count > 0) {
|
||||
var challengeResult = await CompleteChallengesAsync(sessionId);
|
||||
if (!challengeResult.IsSuccess)
|
||||
if (!challengeResult.IsSuccess) {
|
||||
await TryPersistRegistrationCacheFromSessionAsync(sessionId);
|
||||
return challengeResult.ToResultOfType<Guid?>(default);
|
||||
}
|
||||
}
|
||||
|
||||
var getOrderResult = await GetOrderAsync(sessionId, hostnames);
|
||||
if (!getOrderResult.IsSuccess)
|
||||
if (!getOrderResult.IsSuccess) {
|
||||
await TryPersistRegistrationCacheFromSessionAsync(sessionId);
|
||||
return getOrderResult.ToResultOfType<Guid?>(default);
|
||||
}
|
||||
|
||||
var certsResult = await GetCertificatesAsync(sessionId, hostnames);
|
||||
if (!certsResult.IsSuccess)
|
||||
if (!certsResult.IsSuccess) {
|
||||
await TryPersistRegistrationCacheFromSessionAsync(sessionId);
|
||||
return certsResult.ToResultOfType<Guid?>(default);
|
||||
}
|
||||
|
||||
if (!isStaging) {
|
||||
var applyCertsResult = await ApplyCertificatesAsync(accountId.Value);
|
||||
if (!applyCertsResult.IsSuccess)
|
||||
if (!applyCertsResult.IsSuccess) {
|
||||
await TryPersistRegistrationCacheFromSessionAsync(sessionId);
|
||||
return applyCertsResult.ToResultOfType<Guid?>(_ => null);
|
||||
}
|
||||
}
|
||||
|
||||
return Result<Guid?>.Ok(initResult.Value);
|
||||
}
|
||||
@ -236,6 +246,16 @@ public class CertsFlowService(
|
||||
return Result<string?>.Ok(fileContent);
|
||||
}
|
||||
|
||||
private async Task TryPersistRegistrationCacheFromSessionAsync(Guid sessionId) {
|
||||
var cacheResult = letsEncryptService.GetRegistrationCache(sessionId);
|
||||
if (!cacheResult.IsSuccess || cacheResult.Value == null)
|
||||
return;
|
||||
|
||||
var saveResult = await cacheService.SaveToCacheAsync(cacheResult.Value.AccountId, cacheResult.Value);
|
||||
if (!saveResult.IsSuccess)
|
||||
logger.LogWarning("Could not persist registration cache after ACME flow step for account {AccountId}.", cacheResult.Value.AccountId);
|
||||
}
|
||||
|
||||
private void DeleteExporedChallenges() {
|
||||
var currentDate = DateTime.Now;
|
||||
foreach (var file in Directory.GetFiles(_acmePath)) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user