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).
|
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
|
## [3.3.5] - 2026-04-12
|
||||||
|
|
||||||
### Changed
|
### 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%">
|
<svg xmlns="http://www.w3.org/2000/svg" width="137" height="20" role="img" aria-label="Line Coverage: 21.6%">
|
||||||
<title>Line Coverage: 21.7%</title>
|
<title>Line Coverage: 21.6%</title>
|
||||||
<linearGradient id="s" x2="0" y2="100%">
|
<linearGradient id="s" x2="0" y2="100%">
|
||||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||||
<stop offset="1" 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">
|
<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 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 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 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.7%</text>
|
<text x="115.75" y="14" fill="#fff">21.6%</text>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</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%">
|
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Method Coverage: 31.5%">
|
||||||
<title>Method Coverage: 29.7%</title>
|
<title>Method Coverage: 31.5%</title>
|
||||||
<linearGradient id="s" x2="0" y2="100%">
|
<linearGradient id="s" x2="0" y2="100%">
|
||||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||||
<stop offset="1" 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">
|
<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 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 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 aria-hidden="true" x="128.75" y="15" fill="#010101" fill-opacity=".3">31.5%</text>
|
||||||
<text x="128.75" y="14" fill="#fff">29.7%</text>
|
<text x="128.75" y="14" fill="#fff">31.5%</text>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</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; }
|
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 byte[]? AccountKey { get; set; }
|
||||||
public string? Id { get; set; }
|
public string? Id { get; set; }
|
||||||
public Jwk? Key { get; set; }
|
public Jwk? Key { get; set; }
|
||||||
@ -77,6 +84,36 @@ public class RegistrationCache {
|
|||||||
return hosts.ToArray();
|
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>
|
/// <summary>
|
||||||
/// Returns cached certificate. Certs older than 30 days are not returned
|
/// Returns cached certificate. Certs older than 30 days are not returned
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
using MaksIT.Core.Security.JWK;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using MaksIT.LetsEncrypt.Models.Responses;
|
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
|
using MaksIT.Core.Security.JWK;
|
||||||
|
using MaksIT.LetsEncrypt.Models.Responses;
|
||||||
|
|
||||||
namespace MaksIT.LetsEncrypt.Entities.LetsEncrypt;
|
namespace MaksIT.LetsEncrypt.Entities.LetsEncrypt;
|
||||||
|
|
||||||
@ -13,4 +13,11 @@ public class State {
|
|||||||
public RegistrationCache? Cache { get; set; }
|
public RegistrationCache? Cache { get; set; }
|
||||||
public Jwk? Jwk { get; set; }
|
public Jwk? Jwk { get; set; }
|
||||||
public RSA? Rsa { 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;
|
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 class LetsEncrytException : Exception {
|
||||||
|
|
||||||
public Problem? Problem { get; }
|
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; }
|
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(
|
public LetsEncrytException(
|
||||||
Problem? problem,
|
Problem? problem,
|
||||||
HttpResponseMessage response
|
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;
|
Problem = problem;
|
||||||
Response = response;
|
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(config);
|
||||||
|
services.AddSingleton<AcmeSessionStore>();
|
||||||
services.AddHttpClient<ILetsEncryptService, LetsEncryptService>();
|
services.AddHttpClient<ILetsEncryptService, LetsEncryptService>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,10 @@
|
|||||||
<RootNamespace>MaksIT.$(MSBuildProjectName.Replace(" ", "_"))</RootNamespace>
|
<RootNamespace>MaksIT.$(MSBuildProjectName.Replace(" ", "_"))</RootNamespace>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="LetsEncrypt.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="MaksIT.Core" Version="1.6.5" />
|
<PackageReference Include="MaksIT.Core" Version="1.6.5" />
|
||||||
<PackageReference Include="MaksIT.Results" Version="2.0.1" />
|
<PackageReference Include="MaksIT.Results" Version="2.0.1" />
|
||||||
|
|||||||
@ -1,17 +1,17 @@
|
|||||||
namespace MaksIT.LetsEncrypt.Models.Responses;
|
namespace MaksIT.LetsEncrypt.Models.Responses;
|
||||||
|
|
||||||
public class AcmeDirectory {
|
public class AcmeDirectory {
|
||||||
public Uri KeyChange { get; set; }
|
public Uri? KeyChange { get; set; }
|
||||||
public AcmeDirectoryMeta Meta { get; set; }
|
public AcmeDirectoryMeta? Meta { get; set; }
|
||||||
public Uri NewAccount { get; set; }
|
public Uri? NewAccount { get; set; }
|
||||||
public Uri NewNonce { get; set; }
|
public Uri? NewNonce { get; set; }
|
||||||
public Uri NewOrder { get; set; }
|
public Uri? NewOrder { get; set; }
|
||||||
public Uri RenewalInfo { get; set; }
|
public Uri? RenewalInfo { get; set; }
|
||||||
public Uri RevokeCert { get; set; }
|
public Uri? RevokeCert { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class AcmeDirectoryMeta {
|
public class AcmeDirectoryMeta {
|
||||||
public string[] CaaIdentities { get; set; }
|
public string[]? CaaIdentities { get; set; }
|
||||||
public string TermsOfService { get; set; }
|
public string? TermsOfService { get; set; }
|
||||||
public string Website { get; set; }
|
public string? Website { get; set; }
|
||||||
}
|
}
|
||||||
@ -6,7 +6,7 @@ using System.Threading.Tasks;
|
|||||||
|
|
||||||
namespace MaksIT.LetsEncrypt.Models.Responses;
|
namespace MaksIT.LetsEncrypt.Models.Responses;
|
||||||
public class AuthorizationChallengeError {
|
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 {
|
namespace MaksIT.LetsEncrypt.Models.Responses {
|
||||||
public class Problem {
|
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.Requests;
|
||||||
using MaksIT.LetsEncrypt.Models.Responses;
|
using MaksIT.LetsEncrypt.Models.Responses;
|
||||||
using MaksIT.Results;
|
using MaksIT.Results;
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
@ -37,32 +36,33 @@ public interface ILetsEncryptService {
|
|||||||
Task<Result> RevokeCertificate(Guid sessionId, string subject, RevokeReason reason);
|
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 DnsType = "dns";
|
||||||
private const string DirectoryEndpoint = "directory";
|
private const string DirectoryEndpoint = "directory";
|
||||||
private const string ReplayNonceHeader = "Replay-Nonce";
|
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 ILogger<LetsEncryptService> _logger;
|
||||||
private readonly LetsEncryptConfiguration _appSettings;
|
private readonly LetsEncryptConfiguration _appSettings;
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly IMemoryCache _memoryCache;
|
private readonly AcmeSessionStore _sessions;
|
||||||
|
|
||||||
public LetsEncryptService(
|
public LetsEncryptService(
|
||||||
ILogger<LetsEncryptService> logger,
|
ILogger<LetsEncryptService> logger,
|
||||||
LetsEncryptConfiguration appSettings,
|
LetsEncryptConfiguration appSettings,
|
||||||
HttpClient httpClient,
|
HttpClient httpClient,
|
||||||
IMemoryCache cache
|
AcmeSessionStore sessions
|
||||||
) {
|
) {
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_appSettings = appSettings;
|
_appSettings = appSettings;
|
||||||
_httpClient = httpClient;
|
_httpClient = httpClient;
|
||||||
_memoryCache = cache;
|
_sessions = sessions;
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
@ -92,7 +92,8 @@ public class LetsEncryptService : ILetsEncryptService {
|
|||||||
return Result.Ok("Client configured successfully.");
|
return Result.Ok("Client configured successfully.");
|
||||||
}
|
}
|
||||||
catch (LetsEncrytException ex) {
|
catch (LetsEncrytException ex) {
|
||||||
return HandleUnhandledException(ex, "Let's Encrypt client encountered a problem");
|
var state = GetOrCreateState(sessionId);
|
||||||
|
return MapLetsEncryptException(state, ex);
|
||||||
}
|
}
|
||||||
catch (Exception ex) {
|
catch (Exception ex) {
|
||||||
return HandleUnhandledException(ex);
|
return HandleUnhandledException(ex);
|
||||||
@ -149,21 +150,24 @@ public class LetsEncryptService : ILetsEncryptService {
|
|||||||
state.Rsa = accountKey;
|
state.Rsa = accountKey;
|
||||||
state.Jwk = jwk;
|
state.Jwk = jwk;
|
||||||
|
|
||||||
|
if (state.Directory.NewAccount is not { } newAccountUri)
|
||||||
|
return Result.InternalServerError("Directory is missing NewAccount URL.");
|
||||||
|
|
||||||
var letsEncryptOrder = new Account {
|
var letsEncryptOrder = new Account {
|
||||||
TermsOfServiceAgreed = true,
|
TermsOfServiceAgreed = true,
|
||||||
Contacts = [.. contacts.Select(contact => $"mailto:{contact}")]
|
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)
|
if (!nonceResult.IsSuccess || nonceResult.Value == null)
|
||||||
return nonceResult;
|
return nonceResult;
|
||||||
|
|
||||||
var nonce = nonceResult.Value;
|
var nonce = nonceResult.Value;
|
||||||
|
|
||||||
var jsonResult = EncodeMessage(sessionId, false, letsEncryptOrder, new ACMEJwsHeader {
|
var jsonResult = EncodeMessage(sessionId, false, letsEncryptOrder, new ACMEJwsHeader {
|
||||||
Url = state.Directory.NewAccount.ToString(),
|
Url = newAccountUri.ToString(),
|
||||||
Nonce = nonce
|
Nonce = nonce
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -183,9 +187,9 @@ public class LetsEncryptService : ILetsEncryptService {
|
|||||||
state.Jwk.KeyId = result.Result?.Location?.ToString() ?? string.Empty;
|
state.Jwk.KeyId = result.Result?.Location?.ToString() ?? string.Empty;
|
||||||
|
|
||||||
if (result.Result?.Status != "valid") {
|
if (result.Result?.Status != "valid") {
|
||||||
errorMessage = $"Account status is not valid, was: {result.Result?.Status} \r\n {result.ResponseText}";
|
var accountStatusMessage = $"Account status is not valid, was: {result.Result?.Status} \r\n {result.ResponseText}";
|
||||||
_logger.LogError(errorMessage);
|
_logger.LogError(accountStatusMessage);
|
||||||
return Result.InternalServerError(errorMessage);
|
return Result.InternalServerError(accountStatusMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
state.Cache = new RegistrationCache {
|
state.Cache = new RegistrationCache {
|
||||||
@ -204,7 +208,7 @@ public class LetsEncryptService : ILetsEncryptService {
|
|||||||
return Result.Ok("Initialization successful.");
|
return Result.Ok("Initialization successful.");
|
||||||
}
|
}
|
||||||
catch (LetsEncrytException ex) {
|
catch (LetsEncrytException ex) {
|
||||||
return HandleUnhandledException(ex, "Let's Encrypt client encountered a problem");
|
return MapLetsEncryptException(state, ex);
|
||||||
}
|
}
|
||||||
catch (Exception ex) {
|
catch (Exception ex) {
|
||||||
return HandleUnhandledException(ex);
|
return HandleUnhandledException(ex);
|
||||||
@ -248,19 +252,19 @@ public class LetsEncryptService : ILetsEncryptService {
|
|||||||
}).ToArray() ?? []
|
}).ToArray() ?? []
|
||||||
};
|
};
|
||||||
|
|
||||||
if (state.Directory == null || state.Directory.NewOrder == null)
|
if (state.Directory?.NewOrder is not { } newOrderUri)
|
||||||
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, newOrderUri);
|
||||||
|
|
||||||
var nonceResult = await GetNonceAsync(sessionId, state.Directory.NewOrder);
|
var nonceResult = await GetNonceAsync(sessionId, newOrderUri);
|
||||||
if (!nonceResult.IsSuccess || nonceResult.Value == null)
|
if (!nonceResult.IsSuccess || nonceResult.Value == null)
|
||||||
return nonceResult.ToResultOfType<Dictionary<string, string>?>(_ => null);
|
return nonceResult.ToResultOfType<Dictionary<string, string>?>(_ => null);
|
||||||
|
|
||||||
var nonce = nonceResult.Value;
|
var nonce = nonceResult.Value;
|
||||||
|
|
||||||
var jsonResult = EncodeMessage(sessionId, false, letsEncryptOrder, new ACMEJwsHeader {
|
var jsonResult = EncodeMessage(sessionId, false, letsEncryptOrder, new ACMEJwsHeader {
|
||||||
Url = state.Directory.NewOrder.ToString(),
|
Url = newOrderUri.ToString(),
|
||||||
Nonce = nonce
|
Nonce = nonce
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -323,7 +327,7 @@ public class LetsEncryptService : ILetsEncryptService {
|
|||||||
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: {challengeResponse.Result?.Status} \r\n {challengeResponse.ResponseText}");
|
||||||
return Result<Dictionary<string, string>?>.InternalServerError(null);
|
return Result<Dictionary<string, string>?>.InternalServerError(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -340,6 +344,9 @@ public class LetsEncryptService : ILetsEncryptService {
|
|||||||
if (state.Cache != null)
|
if (state.Cache != null)
|
||||||
state.Cache.ChallengeType = challengeType;
|
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))
|
if (!JwkThumbprintUtility.TryGetKeyAuthorization(state.Jwk, challenge.Token, out var keyToken, out var errorMessage))
|
||||||
return Result<Dictionary<string, string>?>.InternalServerError(null, 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++) {
|
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 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");
|
_logger.LogError("Challenge URL is null");
|
||||||
return Result.InternalServerError("Challenge URL is null");
|
return Result.InternalServerError("Challenge URL is null");
|
||||||
}
|
}
|
||||||
@ -406,7 +418,7 @@ public class LetsEncryptService : ILetsEncryptService {
|
|||||||
|
|
||||||
PrepareRequestContent(request, json, HttpMethod.Post);
|
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);
|
var result = await PollChallengeStatus(sessionId, challenge);
|
||||||
|
|
||||||
@ -415,6 +427,9 @@ public class LetsEncryptService : ILetsEncryptService {
|
|||||||
}
|
}
|
||||||
return Result.Ok();
|
return Result.Ok();
|
||||||
}
|
}
|
||||||
|
catch (LetsEncrytException ex) {
|
||||||
|
return MapLetsEncryptException(GetOrCreateState(sessionId), ex);
|
||||||
|
}
|
||||||
catch (Exception ex) {
|
catch (Exception ex) {
|
||||||
return HandleUnhandledException(ex);
|
return HandleUnhandledException(ex);
|
||||||
}
|
}
|
||||||
@ -428,24 +443,27 @@ public class LetsEncryptService : ILetsEncryptService {
|
|||||||
|
|
||||||
var state = GetOrCreateState(sessionId);
|
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 {
|
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 = DnsType,
|
||||||
Value = hostname!
|
Value = hostname!
|
||||||
}).ToArray() ?? []
|
}).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)
|
if (!nonceResult.IsSuccess || nonceResult.Value == null)
|
||||||
return nonceResult;
|
return nonceResult;
|
||||||
|
|
||||||
var nonce = nonceResult.Value;
|
var nonce = nonceResult.Value;
|
||||||
|
|
||||||
var jsonResult = EncodeMessage(sessionId, false, letsEncryptOrder, new ACMEJwsHeader {
|
var jsonResult = EncodeMessage(sessionId, false, letsEncryptOrder, new ACMEJwsHeader {
|
||||||
Url = state.Directory.NewOrder.ToString(),
|
Url = newOrderUri.ToString(),
|
||||||
Nonce = nonce
|
Nonce = nonce
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -479,15 +497,14 @@ public class LetsEncryptService : ILetsEncryptService {
|
|||||||
|
|
||||||
_logger.LogInformation($"Executing {nameof(GetCertificate)}...");
|
_logger.LogInformation($"Executing {nameof(GetCertificate)}...");
|
||||||
|
|
||||||
if (state.CurrentOrder?.Identifiers == null) {
|
if (state.CurrentOrder?.Identifiers is not { } initialIdentifiers)
|
||||||
return Result.InternalServerError();
|
return Result.InternalServerError();
|
||||||
}
|
|
||||||
|
|
||||||
var key = new RSACryptoServiceProvider(4096);
|
var key = new RSACryptoServiceProvider(4096);
|
||||||
var csr = new CertificateRequest("CN=" + subject, key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
var csr = new CertificateRequest("CN=" + subject, key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||||
var san = new SubjectAlternativeNameBuilder();
|
var san = new SubjectAlternativeNameBuilder();
|
||||||
|
|
||||||
foreach (var host in state.CurrentOrder.Identifiers) {
|
foreach (var host in initialIdentifiers) {
|
||||||
if (host?.Value != null)
|
if (host?.Value != null)
|
||||||
san.AddDnsName(host.Value);
|
san.AddDnsName(host.Value);
|
||||||
}
|
}
|
||||||
@ -503,23 +520,34 @@ public class LetsEncryptService : ILetsEncryptService {
|
|||||||
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() ?? [];
|
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);
|
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)) {
|
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)
|
if (!nonceResult.IsSuccess || nonceResult.Value == null)
|
||||||
return nonceResult;
|
return nonceResult;
|
||||||
|
|
||||||
var nonce = nonceResult.Value;
|
var nonce = nonceResult.Value;
|
||||||
|
|
||||||
var jsonResult = EncodeMessage(sessionId, false, letsEncryptOrder, new ACMEJwsHeader {
|
var jsonResult = EncodeMessage(sessionId, false, letsEncryptOrder, new ACMEJwsHeader {
|
||||||
Url = state.CurrentOrder.Finalize.ToString(),
|
Url = finalizeUri.ToString(),
|
||||||
Nonce = nonce
|
Nonce = nonce
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -537,16 +565,20 @@ public class LetsEncryptService : ILetsEncryptService {
|
|||||||
var order = orderResult.Value;
|
var order = orderResult.Value;
|
||||||
|
|
||||||
if (StatusEquals(order.Result?.Status, OrderStatus.Processing)) {
|
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)
|
if (!nonceResult.IsSuccess || nonceResult.Value == null)
|
||||||
return nonceResult;
|
return nonceResult;
|
||||||
|
|
||||||
nonce = nonceResult.Value;
|
nonce = nonceResult.Value;
|
||||||
|
|
||||||
jsonResult = EncodeMessage(sessionId, true, null, new ACMEJwsHeader {
|
jsonResult = EncodeMessage(sessionId, true, null, new ACMEJwsHeader {
|
||||||
Url = state.CurrentOrder.Location.ToString(),
|
Url = orderLocation.ToString(),
|
||||||
Nonce = nonce
|
Nonce = nonce
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -565,11 +597,15 @@ public class LetsEncryptService : ILetsEncryptService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (StatusEquals(order.Result?.Status, OrderStatus.Valid)) {
|
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)) {
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -579,7 +615,10 @@ public class LetsEncryptService : ILetsEncryptService {
|
|||||||
await Task.Delay(1000);
|
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);
|
var finalNonceResult = await GetNonceAsync(sessionId, certificateUrl);
|
||||||
if (!finalNonceResult.IsSuccess || finalNonceResult.Value == null)
|
if (!finalNonceResult.IsSuccess || finalNonceResult.Value == null)
|
||||||
@ -618,14 +657,13 @@ public class LetsEncryptService : ILetsEncryptService {
|
|||||||
PrivatePem = key.ExportRSAPrivateKeyPem()
|
PrivatePem = key.ExportRSAPrivateKeyPem()
|
||||||
};
|
};
|
||||||
|
|
||||||
var certPem = pem.Result ?? string.Empty;
|
state.Cache.ClearAcmeCooldownForHostname(subject);
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(certPem)) {
|
|
||||||
var cert = new X509Certificate2(Encoding.UTF8.GetBytes(certPem));
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result.Ok();
|
return Result.Ok();
|
||||||
}
|
}
|
||||||
|
catch (LetsEncrytException ex) {
|
||||||
|
return MapLetsEncryptException(GetOrCreateState(sessionId), ex);
|
||||||
|
}
|
||||||
catch (Exception ex) {
|
catch (Exception ex) {
|
||||||
return HandleUnhandledException(ex);
|
return HandleUnhandledException(ex);
|
||||||
}
|
}
|
||||||
@ -657,7 +695,7 @@ public class LetsEncryptService : ILetsEncryptService {
|
|||||||
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 = X509Certificate2.CreateFromPem(certPem);
|
||||||
|
|
||||||
var derEncodedCert = certificate.Export(X509ContentType.Cert);
|
var derEncodedCert = certificate.Export(X509ContentType.Cert);
|
||||||
|
|
||||||
@ -668,20 +706,26 @@ public class LetsEncryptService : ILetsEncryptService {
|
|||||||
Reason = (int)reason
|
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)
|
if (!nonceResult.IsSuccess || nonceResult.Value == null)
|
||||||
return nonceResult;
|
return nonceResult;
|
||||||
|
|
||||||
var nonce = nonceResult.Value;
|
var nonce = nonceResult.Value;
|
||||||
|
|
||||||
var jwsHeader = new ACMEJwsHeader {
|
var jwsHeader = new ACMEJwsHeader {
|
||||||
Url = state.Directory.RevokeCert.ToString(),
|
Url = revokeUri.ToString(),
|
||||||
Nonce = nonce
|
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);
|
return Result.InternalServerError(errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -695,203 +739,29 @@ public class LetsEncryptService : ILetsEncryptService {
|
|||||||
|
|
||||||
var responseText = await response.Content.ReadAsStringAsync();
|
var responseText = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
if (response.Content.Headers.ContentType?.MediaType == GetContentType(ContentType.ProblemJson)) {
|
HandleProblemResponseAsync(response, responseText);
|
||||||
var erroObj = responseText.ToObject<Problem>();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
try {
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
Result.InternalServerError(responseText);
|
return 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();
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
response.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
catch (LetsEncrytException ex) {
|
||||||
|
var state = GetOrCreateState(sessionId);
|
||||||
|
return MapLetsEncryptException(state, ex);
|
||||||
|
}
|
||||||
catch (Exception ex) {
|
catch (Exception ex) {
|
||||||
return HandleUnhandledException(ex);
|
return HandleUnhandledException(ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endregion
|
#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();
|
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(
|
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)
|
if (!fullFlowResult.IsSuccess)
|
||||||
return fullFlowResult;
|
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();
|
return Result.Ok();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>3.3.5</Version>
|
<Version>3.3.6</Version>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
|||||||
@ -187,28 +187,38 @@ public class CertsFlowService(
|
|||||||
|
|
||||||
var challengesResult = await NewOrderAsync(sessionId, hostnames, challengeType);
|
var challengesResult = await NewOrderAsync(sessionId, hostnames, challengeType);
|
||||||
|
|
||||||
if (!challengesResult.IsSuccess)
|
if (!challengesResult.IsSuccess) {
|
||||||
|
await TryPersistRegistrationCacheFromSessionAsync(sessionId);
|
||||||
return challengesResult.ToResultOfType<Guid?>(_ => null);
|
return challengesResult.ToResultOfType<Guid?>(_ => null);
|
||||||
|
}
|
||||||
|
|
||||||
if (challengesResult.Value?.Count > 0) {
|
if (challengesResult.Value?.Count > 0) {
|
||||||
var challengeResult = await CompleteChallengesAsync(sessionId);
|
var challengeResult = await CompleteChallengesAsync(sessionId);
|
||||||
if (!challengeResult.IsSuccess)
|
if (!challengeResult.IsSuccess) {
|
||||||
|
await TryPersistRegistrationCacheFromSessionAsync(sessionId);
|
||||||
return challengeResult.ToResultOfType<Guid?>(default);
|
return challengeResult.ToResultOfType<Guid?>(default);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var getOrderResult = await GetOrderAsync(sessionId, hostnames);
|
var getOrderResult = await GetOrderAsync(sessionId, hostnames);
|
||||||
if (!getOrderResult.IsSuccess)
|
if (!getOrderResult.IsSuccess) {
|
||||||
|
await TryPersistRegistrationCacheFromSessionAsync(sessionId);
|
||||||
return getOrderResult.ToResultOfType<Guid?>(default);
|
return getOrderResult.ToResultOfType<Guid?>(default);
|
||||||
|
}
|
||||||
|
|
||||||
var certsResult = await GetCertificatesAsync(sessionId, hostnames);
|
var certsResult = await GetCertificatesAsync(sessionId, hostnames);
|
||||||
if (!certsResult.IsSuccess)
|
if (!certsResult.IsSuccess) {
|
||||||
|
await TryPersistRegistrationCacheFromSessionAsync(sessionId);
|
||||||
return certsResult.ToResultOfType<Guid?>(default);
|
return certsResult.ToResultOfType<Guid?>(default);
|
||||||
|
}
|
||||||
|
|
||||||
if (!isStaging) {
|
if (!isStaging) {
|
||||||
var applyCertsResult = await ApplyCertificatesAsync(accountId.Value);
|
var applyCertsResult = await ApplyCertificatesAsync(accountId.Value);
|
||||||
if (!applyCertsResult.IsSuccess)
|
if (!applyCertsResult.IsSuccess) {
|
||||||
|
await TryPersistRegistrationCacheFromSessionAsync(sessionId);
|
||||||
return applyCertsResult.ToResultOfType<Guid?>(_ => null);
|
return applyCertsResult.ToResultOfType<Guid?>(_ => null);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Result<Guid?>.Ok(initResult.Value);
|
return Result<Guid?>.Ok(initResult.Value);
|
||||||
}
|
}
|
||||||
@ -236,6 +246,16 @@ public class CertsFlowService(
|
|||||||
return Result<string?>.Ok(fileContent);
|
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() {
|
private void DeleteExporedChallenges() {
|
||||||
var currentDate = DateTime.Now;
|
var currentDate = DateTime.Now;
|
||||||
foreach (var file in Directory.GetFiles(_acmePath)) {
|
foreach (var file in Directory.GetFiles(_acmePath)) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user