(bugfix): HA mode incomple migration fix

This commit is contained in:
Maksym Sadovnychyy 2026-04-28 18:30:11 +02:00
parent 1c68cc63b8
commit 098fa91515
54 changed files with 908 additions and 1061 deletions

View File

@ -4,6 +4,38 @@ 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.4.0] - 2026-04-27
### Breaking
- **HA / interactive ACME:** `CertsFlowDomainService` no longer checks `IPrimaryReplicaWorkload.IsPrimary`. All replicas may run configure-client, init, orders, challenge completion, certificate download, apply, and revoke. The API no longer returns **HTTP 503** with `ProblemDetails.type` **`urn:maksit:certs-ui:primary-replica-required`** for those flows. Clients that retried on that signal (for example the SPA) should treat normal error semantics only.
- **HTTP-01 challenge:** `AcmeChallengeAsync` no longer writes tokens under **`AcmeFolder`** or reads a legacy on-disk file. Challenge text is served from PostgreSQL only; ingress must reach **`GET /.well-known/acme-challenge/{token}`** on this app (or equivalent) rather than a shared volume of token files.
- **Startup:** Removed the shared **`init`** marker file under **`DataFolder`**. Followers wait until the database reports at least one user (same readiness signal, without filesystem coupling).
- **HA / process model:** Removed **`IPrimaryReplicaWorkload`**, **`PrimaryReplicaGate`**, and **`PrimaryReplicaShutdownHostedService`**. There is no long-lived “primary replica” or lease renewal loop in the API process.
### Changed
- **ACME sessions:** Let's Encrypt client **`State`** is persisted in PostgreSQL table **`acme_sessions`** (`session_id`, payload JSON, timestamps) so any replica can continue the same ACME session after load balancing.
- **LetsEncrypt / `HttpClient`:** `ConfigureClient` fetches the ACME directory using an absolute URL derived from staging/production configuration instead of assigning **`BaseAddress`** on the shared **`HttpClient`**.
- **`InitializationHostedService`:** Dropped unused **`IOptions<Configuration>`** from the constructor (DI callers unchanged except the removed parameter).
- **Bootstrap:** **`InitializationHostedService`** acquires **`certs-ui-bootstrap`** (`RuntimeLeaseNames.BootstrapCoordinator`), runs **`CoordinationTableProvisioner`** + optional default admin, **releases** the lease, and exits. Other pods wait until **`users`** exist.
- **Renewal:** **`AutoRenewal`** acquires **`certs-ui-renewal-sweep`** (`RuntimeLeaseNames.RenewalSweep`) for each sweep, runs work, **releases**, then sleeps. Any pod may win the next sweep.
- **Lease names:** Replaced **`certs-ui-primary`** with **`BootstrapCoordinator`** and **`RenewalSweep`** constants (see **`RuntimeLeaseNames`**).
- **Helm (cloud-native defaults):** **`components.server.service.sessionAffinity.enabled`** defaults to **`false`** so the server `Service` uses stateless load balancing (no **`ClientIP`** stickiness). Enable explicitly only when needed.
- **Helm:** **`certsClientRuntime.apiUrl`** default is **`/api`** so the Web UI calls the API on the same browser origin (typical single-ingress / reverse-proxy setup). Override with a full URL when UI and API are on different hosts.
### Removed
- **`CertsFlowPrimaryReplica`**, **`PrimaryReplicaRequiredObjectResult`**, and **`CertsFlowResultExtensions`** / **`ToCertsFlowActionResult`**; **`CertsFlowController`** uses **`ToActionResult()`** like other API controllers.
- **Web UI:** Primary-replica **503** auto-retry logic in **`axiosConfig.ts`**.
- **Configuration / Helm:** **`AcmeFolder`** and **`DataFolder`** settings and the default **server** **acme**/**data** PVC mounts (cloud-native: no app-local disk for ACME or bootstrap markers). **`AddMemoryCache()`** host registration removed (unused).
### Upgrade notes
- **Migrations:** Apply FluentMigrator through **`3.4.0`** (includes **`acme_sessions`** and related coordination entries) before relying on cross-replica ACME sessions.
- **Compose / secrets:** Remove **`acme`** and **`data`** bind mounts from **`docker-compose.override.yml`** if you still have them; they are no longer read by the application.
- **Operations:** If you alert or filter on lease name **`certs-ui-primary`**, retarget to **`certs-ui-bootstrap`** and **`certs-ui-renewal-sweep`**.
## [3.3.22] - 2026-04-27
### Changed

View File

@ -282,7 +282,7 @@ sudo tee /opt/Compose/MaksIT.CertsUI/secrets/appsecrets.json > /dev/null <<EOF
{
"Configuration": {
"CertsEngineConfiguration": {
"ConnectionString": "Host=postgres;Port=5432;Database=maksit_certs;Username=maksit;Password=maksit;SslMode=Prefer"
"ConnectionString": "Host=postgres;Port=5432;Database=certsui;Username=certsui;Password=certsui;SslMode=Prefer"
},
"Auth": {
"Secret": "<your-auth-secret>",
@ -297,7 +297,7 @@ EOF
```
**Note:**
PostgreSQL is configured as **`Configuration:CertsEngineConfiguration:ConnectionString`** — same structural pattern as MaksIT.Vaults **`Configuration:VaultEngineConfiguration:ConnectionString`**. For Docker Compose, use the Postgres service hostname (here `postgres`) and credentials that match the `postgres` service. The host also accepts legacy **`ConnectionStrings:Certs`** if needed. Replace placeholder values `<your-auth-secret>`, `<your-pepper>`, `<your-agent-key>`, with secure, your environment-specific values.
PostgreSQL is configured as **`Configuration:CertsUIEngineConfiguration:ConnectionString`** — same structural pattern as MaksIT.Vaults **`Configuration:VaultEngineConfiguration:ConnectionString`**. For Docker Compose, use the Postgres service hostname (here **`postgres`**) and credentials that match **`docker-compose.override.yml`** (**`certsui`** / **`certsui`** / database **`certsui`** by default). The host also accepts legacy **`ConnectionStrings:Certs`** if needed. Replace placeholder values `<your-auth-secret>`, `<your-pepper>`, `<your-agent-key>`, with secure, your environment-specific values.
Make sure `<your-agent-key>` matches the key configured in your agent deployment.
**2. Create the file `/opt/Compose/MaksIT.CertsUI/configMap/appsettings.json` with this command:**
@ -325,15 +325,13 @@ sudo tee /opt/Compose/MaksIT.CertsUI/configMap/appsettings.json <<EOF
},
"Production": "https://acme-v02.api.letsencrypt.org/directory",
"Staging": "https://acme-staging-v02.api.letsencrypt.org/directory",
"AcmeFolder": "/acme",
"DataFolder": "/data"
}
}
EOF
```
**Note:**
`DataFolder` holds ACME subscriber agreement PDFs and an empty `init` bootstrap marker (users and registration data live in PostgreSQL). Replace all JWT-related placeholder values `<your-issuer>`, `<your-audience>` and `<your-agent-hostname>` with your environment-specific values.
ACME sessions, HTTP-01 challenges, Terms of Service caching, and registration data live in PostgreSQL. Replace all JWT-related placeholder values `<your-issuer>`, `<your-audience>` and `<your-agent-hostname>` with your environment-specific values.
**3. Create the file `/opt/Compose/MaksIT.CertsUI/client/config.js` with this command:**
@ -515,7 +513,7 @@ Set-Content -Path 'C:\Compose\MaksIT.CertsUI\secrets\appsecrets.json' -Value @'
{
"Configuration": {
"CertsEngineConfiguration": {
"ConnectionString": "Host=postgres;Port=5432;Database=maksit_certs;Username=maksit;Password=maksit;SslMode=Prefer"
"ConnectionString": "Host=postgres;Port=5432;Database=certsui;Username=certsui;Password=certsui;SslMode=Prefer"
},
"Auth": {
"Secret": "<your-auth-secret>",
@ -530,7 +528,7 @@ Set-Content -Path 'C:\Compose\MaksIT.CertsUI\secrets\appsecrets.json' -Value @'
```
**Note:**
PostgreSQL is **`Configuration:CertsEngineConfiguration:ConnectionString`** (same pattern as MaksIT.Vault **`VaultEngineConfiguration:ConnectionString`**). For Docker Compose, use the Postgres service hostname (here `postgres`) and credentials that match the `postgres` service. Legacy **`ConnectionStrings:Certs`** is still supported. Replace placeholder values `<your-auth-secret>`, `<your-pepper>`, `<your-agent-key>`, with secure, your environment-specific values.
PostgreSQL is **`Configuration:CertsUIEngineConfiguration:ConnectionString`** (same pattern as MaksIT.Vault **`VaultEngineConfiguration:ConnectionString`**). For Docker Compose, use the Postgres service hostname (here **`postgres`**) and credentials that match **`docker-compose.override.yml`** (**`certsui`** defaults). Legacy **`ConnectionStrings:Certs`** is still supported. Replace placeholder values `<your-auth-secret>`, `<your-pepper>`, `<your-agent-key>`, with secure, your environment-specific values.
Make sure `<your-agent-key>` matches the key configured in your agent deployment.
**2. Create the file `C:\Compose\MaksIT.CertsUI\configMap\appsettings.json` with this command:**
@ -558,15 +556,13 @@ Set-Content -Path 'C:\Compose\MaksIT.CertsUI\configMap\appsettings.json' -Value
},
"Production": "https://acme-v02.api.letsencrypt.org/directory",
"Staging": "https://acme-staging-v02.api.letsencrypt.org/directory",
"AcmeFolder": "/acme",
"DataFolder": "/data"
}
}
'@
```
**Note:**
`DataFolder` holds ACME subscriber agreement PDFs and an empty `init` bootstrap marker (users and registration data live in PostgreSQL). Replace all JWT-related placeholder values `<your-issuer>`, `<your-audience>` and `<your-agent-hostname>` with your environment-specific values.
ACME sessions, HTTP-01 challenges, Terms of Service caching, and registration data live in PostgreSQL. Replace all JWT-related placeholder values `<your-issuer>`, `<your-audience>` and `<your-agent-hostname>` with your environment-specific values.
**3. Create the file `C:\Compose\MaksIT.CertsUI\client\config.js` with this command:**
@ -680,7 +676,7 @@ Replace the placeholder values with your actual secrets. This secret contains th
{
"Configuration": {
"CertsEngineConfiguration": {
"ConnectionString": "Host=<postgres-host>;Port=5432;Database=maksit_certs;Username=<user>;Password=<password>;SslMode=Prefer"
"ConnectionString": "Host=<postgres-host>;Port=5432;Database=certsui;Username=certsui;Password=certsui;SslMode=Prefer"
},
"Auth": {
"Secret": "<your-auth-secret>",
@ -698,7 +694,7 @@ kubectl create secret generic certs-ui-server-secrets \
--from-literal=appsecrets.json='{
"Configuration": {
"CertsEngineConfiguration": {
"ConnectionString": "Host=<postgres-host>;Port=5432;Database=maksit_certs;Username=<user>;Password=<password>;SslMode=Prefer"
"ConnectionString": "Host=<postgres-host>;Port=5432;Database=certsui;Username=certsui;Password=certsui;SslMode=Prefer"
},
"Auth": {
"Secret": "<your-auth-secret>",
@ -743,8 +739,6 @@ Edit the values as needed for your environment. This configmap contains applicat
},
"Production": "https://acme-v02.api.letsencrypt.org/directory",
"Staging": "https://acme-staging-v02.api.letsencrypt.org/directory",
"AcmeFolder": "/acme",
"DataFolder": "/data"
}
}
```
@ -773,8 +767,6 @@ kubectl create configmap certs-ui-server-configmap \
},
"Production": "https://acme-v02.api.letsencrypt.org/directory",
"Staging": "https://acme-staging-v02.api.letsencrypt.org/directory",
"AcmeFolder": "/acme",
"DataFolder": "/data"
}
}' \
-n certs-ui

View File

@ -11,10 +11,10 @@ This document explains how HA works in `MaksIT.CertsUI` after moving mutable ACM
## Runtime model
- **Shared source of truth:** PostgreSQL stores ACME challenge rows and runtime leases.
- **Shared source of truth:** PostgreSQL stores ACME sessions, challenge rows, ToS cache, registration caches, and runtime leases.
- **Per-instance identity:** each running server process gets one canonical `InstanceId` (`IRuntimeInstanceId` singleton).
- **Lease holder:** mutating ACME paths acquire a PostgreSQL lease row (`app_runtime_leases`) with TTL.
- **Challenge reads:** `/.well-known/acme-challenge/{token}` reads token value from PostgreSQL and materializes a short-lived file in `/acme` for compatibility.
- **Lease holder:** `NewOrderAsync` acquires **AcmeWriter**; startup uses **BootstrapCoordinator**; each renewal sweep uses **RenewalSweep** (see `RuntimeLeaseNames`). All leases are rows in **`app_runtime_leases`** with TTL semantics—no long-lived leader object in the app.
- **Challenge reads:** `/.well-known/acme-challenge/{token}` returns the token value from PostgreSQL (no local ACME directory).
- **Background coordination:** bootstrap and renewal hosted services use named leases to avoid duplicate work.
## Lease design
@ -33,8 +33,7 @@ This is implemented as an optimistic single-statement `INSERT ... ON CONFLICT ..
## HTTP-01 coherence design
- `NewOrderAsync` stores challenge tokens in `acme_http_challenges` via `UpsertAsync`.
- Challenge handler (`AcmeChallengeAsync`) reads token value from DB, writes `/acme/{token}`, and returns the value.
- Fallback: if DB row is missing, legacy on-disk token read remains available for migration compatibility.
- Challenge handler (`AcmeChallengeAsync`) reads the token value from the database and returns it as plain text.
- Cleanup: auto-renewal loop calls `DeleteOlderThanAsync(TimeSpan.FromDays(10))`.
## Kubernetes behavior

View File

@ -30,7 +30,7 @@ Controllers use the usual **`/api/...`** prefix (e.g. `api/identity`, account an
### HTTP-01 (Lets Encrypt)
Traffic for **`/.well-known/acme-challenge/*`** must reach **MaksIT.CertsUI** so the HTTP-01 validator can fetch the token file. The dedicated route sends that path to the **`server`** service (same `webapiCluster` as `/api`).
Traffic for **`/.well-known/acme-challenge/*`** must reach **MaksIT.CertsUI** so the HTTP-01 validator can fetch the token body from the API (backed by PostgreSQL). The dedicated route sends that path to the **`server`** service (same `webapiCluster` as `/api`).
### Kubernetes (Helm)

10
src/Directory.Build.props Normal file
View File

@ -0,0 +1,10 @@
<Project>
<!-- Applies to SDK-style .csproj under src/ (excludes Microsoft.Docker.Sdk compose projects). -->
<PropertyGroup Condition="'$(UsingMicrosoftNETSdk)' == 'true'">
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AnalysisLevel>latest</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
</PropertyGroup>
</Project>

View File

@ -2,18 +2,16 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>MaksIT.CertsUI.Engine.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="8.0.1">
<PackageReference Include="coverlet.collector" Version="10.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.5.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View File

@ -84,6 +84,13 @@ public static class CertsLinq2DbMapping {
.Property(x => x.FetchedAtUtc).HasColumnName("fetched_at_utc")
.Property(x => x.ExpiresAtUtc).HasColumnName("expires_at_utc");
builder.Entity<AcmeSessionDto>()
.HasTableName(Table.AcmeSessions.Name)
.Property(x => x.SessionId).HasColumnName("session_id").IsPrimaryKey()
.Property(x => x.PayloadJson).HasColumnName("payload_json")
.Property(x => x.UpdatedAtUtc).HasColumnName("updated_at_utc")
.Property(x => x.ExpiresAtUtc).HasColumnName("expires_at_utc");
builder.Build();
return schema;
}

View File

@ -63,8 +63,6 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
private readonly IAcmeHttpChallengePersistenceService _httpChallenges;
private readonly IRuntimeLeaseService _runtimeLease;
private readonly IRuntimeInstanceId _runtimeInstance;
private readonly IPrimaryReplicaWorkload _primaryReplica;
private readonly string _acmePath;
public CertsFlowDomainService(
ILogger<CertsFlowDomainService> logger,
@ -76,8 +74,7 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
ITermsOfServiceCachePersistenceService termsOfServiceCache,
IAcmeHttpChallengePersistenceService httpChallenges,
IRuntimeLeaseService runtimeLease,
IRuntimeInstanceId runtimeInstance,
IPrimaryReplicaWorkload primaryReplica) {
IRuntimeInstanceId runtimeInstance) {
_logger = logger;
_httpClient = httpClient;
_letsEncryptService = letsEncryptService;
@ -88,14 +85,12 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
_httpChallenges = httpChallenges;
_runtimeLease = runtimeLease;
_runtimeInstance = runtimeInstance;
_primaryReplica = primaryReplica;
_acmePath = config.AcmeFolder;
}
#region Terms of service
public async Task<Result<string?>> GetTermsOfServiceAsync(Guid sessionId) {
var termsUriResult = _letsEncryptService.GetTermsOfServiceUri(sessionId);
var termsUriResult = await _letsEncryptService.GetTermsOfServiceUriAsync(sessionId, CancellationToken.None).ConfigureAwait(false);
if (!termsUriResult.IsSuccess || termsUriResult.Value == null)
return termsUriResult;
@ -178,24 +173,18 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
#region Session, orders, and certificates
public async Task<Result> CompleteChallengesAsync(Guid sessionId) {
if (!_primaryReplica.IsPrimary)
return Result.ServiceUnavailable(CertsFlowPrimaryReplica.ServiceUnavailableMessages);
return await _letsEncryptService.CompleteChallenges(sessionId);
return await _letsEncryptService.CompleteChallenges(sessionId, CancellationToken.None).ConfigureAwait(false);
}
public async Task<Result<Guid?>> ConfigureClientAsync(bool isStaging) {
if (!_primaryReplica.IsPrimary)
return Result<Guid?>.ServiceUnavailable(null, CertsFlowPrimaryReplica.ServiceUnavailableMessages);
var sessionId = Guid.NewGuid();
var result = await _letsEncryptService.ConfigureClient(sessionId, isStaging);
var result = await _letsEncryptService.ConfigureClient(sessionId, isStaging, CancellationToken.None).ConfigureAwait(false);
if (!result.IsSuccess)
return result.ToResultOfType<Guid?>(default);
return Result<Guid?>.Ok(sessionId);
}
public async Task<Result<Guid?>> InitAsync(Guid sessionId, Guid? accountId, string description, string[] contacts) {
if (!_primaryReplica.IsPrimary)
return Result<Guid?>.ServiceUnavailable(null, CertsFlowPrimaryReplica.ServiceUnavailableMessages);
RegistrationCache? cache = null;
if (accountId == null) {
accountId = Guid.NewGuid();
@ -209,15 +198,13 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
cache = cacheResult.Value;
}
}
var result = await _letsEncryptService.Init(sessionId, accountId.Value, description, contacts, cache);
var result = await _letsEncryptService.Init(sessionId, accountId.Value, description, contacts, cache, CancellationToken.None).ConfigureAwait(false);
if (!result.IsSuccess)
return result.ToResultOfType<Guid?>(default);
return Result<Guid?>.Ok(accountId.Value);
}
public async Task<Result<List<string>?>> NewOrderAsync(Guid sessionId, string[] hostnames, string challengeType) {
if (!_primaryReplica.IsPrimary)
return Result<List<string>?>.ServiceUnavailable(null, CertsFlowPrimaryReplica.ServiceUnavailableMessages);
var holder = _runtimeInstance.InstanceId;
var acquired = await _runtimeLease.TryAcquireAsync(RuntimeLeaseNames.AcmeWriter, holder, AcmeWriterLeaseTtl, CancellationToken.None);
if (!acquired.IsSuccess)
@ -228,7 +215,7 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
}
try {
var orderResult = await _letsEncryptService.NewOrder(sessionId, hostnames, challengeType);
var orderResult = await _letsEncryptService.NewOrder(sessionId, hostnames, challengeType, CancellationToken.None).ConfigureAwait(false);
if (!orderResult.IsSuccess || orderResult.Value == null)
return orderResult.ToResultOfType<List<string>?>(_ => null);
var challenges = new List<string>();
@ -253,15 +240,13 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
}
public async Task<Result> GetCertificatesAsync(Guid sessionId, string[] hostnames) {
if (!_primaryReplica.IsPrimary)
return Result.ServiceUnavailable(CertsFlowPrimaryReplica.ServiceUnavailableMessages);
foreach (var subject in hostnames) {
var result = await _letsEncryptService.GetCertificate(sessionId, subject);
var result = await _letsEncryptService.GetCertificate(sessionId, subject, CancellationToken.None).ConfigureAwait(false);
if (!result.IsSuccess)
return result;
Thread.Sleep(1000);
}
var cacheResult = _letsEncryptService.GetRegistrationCache(sessionId);
var cacheResult = await _letsEncryptService.GetRegistrationCacheAsync(sessionId, CancellationToken.None).ConfigureAwait(false);
if (!cacheResult.IsSuccess || cacheResult.Value == null)
return cacheResult;
var saveResult = await _registrationCache.SaveAsync(cacheResult.Value.AccountId, cacheResult.Value);
@ -271,9 +256,7 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
}
public async Task<Result> GetOrderAsync(Guid sessionId, string[] hostnames) {
if (!_primaryReplica.IsPrimary)
return Result.ServiceUnavailable(CertsFlowPrimaryReplica.ServiceUnavailableMessages);
return await _letsEncryptService.GetOrder(sessionId, hostnames);
return await _letsEncryptService.GetOrder(sessionId, hostnames, CancellationToken.None).ConfigureAwait(false);
}
#endregion
@ -281,8 +264,6 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
#region Deploy and revoke
public async Task<Result<Dictionary<string, string>?>> ApplyCertificatesAsync(Guid accountId) {
if (!_primaryReplica.IsPrimary)
return Result<Dictionary<string, string>?>.ServiceUnavailable(null, CertsFlowPrimaryReplica.ServiceUnavailableMessages);
var cacheResult = await _registrationCache.LoadAsync(accountId);
if (!cacheResult.IsSuccess || cacheResult.Value?.CachedCerts == null)
return cacheResult.ToResultOfType<Dictionary<string, string>?>(_ => null);
@ -302,14 +283,12 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
}
public async Task<Result> RevokeCertificatesAsync(Guid sessionId, string[] hostnames) {
if (!_primaryReplica.IsPrimary)
return Result.ServiceUnavailable(CertsFlowPrimaryReplica.ServiceUnavailableMessages);
foreach (var hostname in hostnames) {
var result = await _letsEncryptService.RevokeCertificate(sessionId, hostname, RevokeReason.Unspecified);
var result = await _letsEncryptService.RevokeCertificate(sessionId, hostname, RevokeReason.Unspecified, CancellationToken.None).ConfigureAwait(false);
if (!result.IsSuccess)
return result;
}
var cacheResult = _letsEncryptService.GetRegistrationCache(sessionId);
var cacheResult = await _letsEncryptService.GetRegistrationCacheAsync(sessionId, CancellationToken.None).ConfigureAwait(false);
if (!cacheResult.IsSuccess || cacheResult.Value == null)
return cacheResult;
var saveResult = await _registrationCache.SaveAsync(cacheResult.Value.AccountId, cacheResult.Value);
@ -397,18 +376,8 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
return Result<string?>.BadRequest(null, "fileName is required.");
var fromDb = await _httpChallenges.GetTokenValueAsync(fileName, cancellationToken).ConfigureAwait(false);
if (fromDb.IsSuccess && !string.IsNullOrEmpty(fromDb.Value)) {
Directory.CreateDirectory(_acmePath);
var path = Path.Combine(_acmePath, fileName);
await File.WriteAllTextAsync(path, fromDb.Value!, cancellationToken).ConfigureAwait(false);
if (fromDb.IsSuccess && !string.IsNullOrEmpty(fromDb.Value))
return Result<string?>.Ok(fromDb.Value);
}
var legacyPath = Path.Combine(_acmePath, fileName);
if (File.Exists(legacyPath)) {
var legacy = await File.ReadAllTextAsync(legacyPath, cancellationToken).ConfigureAwait(false);
return Result<string?>.Ok(legacy);
}
return Result<string?>.NotFound(null, $"Challenge token not found: {fileName}");
}
@ -416,7 +385,7 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
#endregion
private async Task TryPersistRegistrationCacheFromSessionAsync(Guid sessionId) {
var cacheResult = _letsEncryptService.GetRegistrationCache(sessionId);
var cacheResult = await _letsEncryptService.GetRegistrationCacheAsync(sessionId, CancellationToken.None).ConfigureAwait(false);
if (!cacheResult.IsSuccess || cacheResult.Value == null)
return;

View File

@ -1,16 +0,0 @@
namespace MaksIT.CertsUI.Engine.DomainServices;
/// <summary>
/// Stable markers for <c>Result.ServiceUnavailable</c> when ACME is invoked on a non-primary replica.
/// The host maps these to HTTP 503 + <c>Retry-After</c> + RFC 7807 <c>ProblemDetails</c>.
/// </summary>
public static class CertsFlowPrimaryReplica {
/// <summary>Machine-readable first line in result messages for detection in MVC.</summary>
public const string DiagnosticMarker = "urn:maksit:certs-ui:primary-replica-required";
public static readonly string[] ServiceUnavailableMessages = [
DiagnosticMarker,
"Only the elected primary Certs UI replica runs ACME orchestration. Retry after a short delay; use service session affinity (ClientIP) so interactive flows stay on the primary."
];
}

View File

@ -1,10 +1,8 @@
namespace MaksIT.CertsUI.Engine.DomainServices;
/// <summary>
/// Paths and agent wiring for applying certificates after ACME issuance. The host maps these from configuration (e.g. appsettings).
/// Agent wiring after ACME issuance. Interactive ACME and HTTP-01 state live in PostgreSQL, not on local paths.
/// </summary>
public interface ICertsFlowEngineConfiguration {
string AcmeFolder { get; }
string DataFolder { get; }
string AgentServiceToReload { get; }
}

View File

@ -0,0 +1,9 @@
namespace MaksIT.CertsUI.Engine.Dto.Certs;
/// <summary>PostgreSQL <c>acme_sessions</c>: shared ACME flow state keyed by browser session id (survives HA / any replica).</summary>
public sealed class AcmeSessionDto {
public Guid SessionId { get; set; }
public string PayloadJson { get; set; } = "{}";
public DateTimeOffset UpdatedAtUtc { get; set; }
public DateTimeOffset ExpiresAtUtc { get; set; }
}

View File

@ -64,7 +64,7 @@ public static class ServiceCollectionExtensions {
#endregion
#region ACME / Let's Encrypt
services.AddSingleton<AcmeSessionStore>();
services.AddSingleton<IAcmeSessionStore, AcmePostgresSessionStore>();
services.AddHttpClient<ILetsEncryptService, LetsEncryptService>();
#endregion
}

View File

@ -0,0 +1,21 @@
using FluentMigrator;
namespace MaksIT.CertsUI.Engine.FluentMigrations;
[Migration(20260427203000)]
public class AcmeSessions : Migration {
public override void Up() {
Create.Table("acme_sessions")
.WithColumn("session_id").AsGuid().PrimaryKey()
.WithColumn("payload_json").AsCustom("text").NotNullable()
.WithColumn("updated_at_utc").AsDateTimeOffset().NotNullable()
.WithColumn("expires_at_utc").AsDateTimeOffset().NotNullable();
Create.Index("IX_acme_sessions_expires_at_utc").OnTable("acme_sessions").OnColumn("expires_at_utc");
}
public override void Down() {
Delete.Index("IX_acme_sessions_expires_at_utc").OnTable("acme_sessions");
Delete.Table("acme_sessions");
}
}

View File

@ -8,7 +8,7 @@ namespace MaksIT.CertsUI.Engine.Infrastructure;
/// </summary>
public static class CoordinationTableProvisioner {
/// <summary>Creates <c>public.acme_http_challenges</c> and <c>public.app_runtime_leases</c> if missing.</summary>
/// <summary>Creates <c>public.acme_http_challenges</c>, <c>public.app_runtime_leases</c>, and <c>public.acme_sessions</c> if missing.</summary>
public static async Task EnsureAsync(string? connectionString, CancellationToken cancellationToken = default) {
if (string.IsNullOrWhiteSpace(connectionString))
return;
@ -31,6 +31,13 @@ public static class CoordinationTableProvisioner {
acquired_at_utc timestamp with time zone NOT NULL,
expires_at_utc timestamp with time zone NOT NULL
);
CREATE TABLE IF NOT EXISTS public.acme_sessions (
session_id uuid NOT NULL PRIMARY KEY,
payload_json text NOT NULL,
updated_at_utc timestamp with time zone NOT NULL,
expires_at_utc timestamp with time zone NOT NULL
);
CREATE INDEX IF NOT EXISTS "IX_acme_sessions_expires_at_utc" ON public.acme_sessions (expires_at_utc);
""",
conn);
await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);

View File

@ -69,6 +69,12 @@ public class SchemaSyncService(ICertsEngineConfiguration config, ILogger<SchemaS
("acquired_at_utc", "timestamp with time zone"),
("expires_at_utc", "timestamp with time zone"),
],
["acme_sessions"] = [
("session_id", "uuid"),
("payload_json", "text"),
("updated_at_utc", "timestamp with time zone"),
("expires_at_utc", "timestamp with time zone"),
],
["api_keys"] = [
("Id", "uuid"),
("Description", "text"),

View File

@ -2,10 +2,7 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<NoWarn>CA2254;NU1903;NU1904</NoWarn>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
@ -14,20 +11,18 @@
<ItemGroup>
<PackageReference Include="FluentMigrator" Version="8.0.1" />
<PackageReference Include="FluentMigrator.Runner" Version="8.0.1" />
<PackageReference Include="FluentMigrator.Runner.Postgres" Version="8.0.1" />
<PackageReference Include="linq2db" Version="6.2.1" />
<PackageReference Include="linq2db.PostgreSQL" Version="6.2.1" />
<PackageReference Include="MaksIT.Core" Version="1.6.5" />
<PackageReference Include="MaksIT.Dapr" Version="2.0.0" />
<PackageReference Include="MaksIT.Results" Version="2.0.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.5" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.7" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="Npgsql" Version="10.0.2" />
</ItemGroup>

View File

@ -39,22 +39,27 @@ public sealed class TermsOfServiceCachePersistenceServiceLinq2Db(
try {
using var db = connectionFactory.Create();
var existing = db.GetTable<TermsOfServiceCacheDto>().FirstOrDefault(x => x.Url == cacheEntry.Url);
if (existing == null) {
db.Insert(cacheEntry);
}
else {
db.GetTable<TermsOfServiceCacheDto>()
.Where(x => x.Url == cacheEntry.Url)
.Set(x => x.UrlHashHex, cacheEntry.UrlHashHex)
.Set(x => x.ETag, cacheEntry.ETag)
.Set(x => x.LastModifiedUtc, cacheEntry.LastModifiedUtc)
.Set(x => x.ContentType, cacheEntry.ContentType)
.Set(x => x.ContentBytes, cacheEntry.ContentBytes)
.Set(x => x.FetchedAtUtc, cacheEntry.FetchedAtUtc)
.Set(x => x.ExpiresAtUtc, cacheEntry.ExpiresAtUtc)
.Update();
}
db.GetTable<TermsOfServiceCacheDto>().InsertOrUpdate(
() => new TermsOfServiceCacheDto {
Url = cacheEntry.Url,
UrlHashHex = cacheEntry.UrlHashHex,
ETag = cacheEntry.ETag,
LastModifiedUtc = cacheEntry.LastModifiedUtc,
ContentType = cacheEntry.ContentType,
ContentBytes = cacheEntry.ContentBytes,
FetchedAtUtc = cacheEntry.FetchedAtUtc,
ExpiresAtUtc = cacheEntry.ExpiresAtUtc
},
old => new TermsOfServiceCacheDto {
Url = old.Url,
UrlHashHex = cacheEntry.UrlHashHex,
ETag = cacheEntry.ETag,
LastModifiedUtc = cacheEntry.LastModifiedUtc,
ContentType = cacheEntry.ContentType,
ContentBytes = cacheEntry.ContentBytes,
FetchedAtUtc = cacheEntry.FetchedAtUtc,
ExpiresAtUtc = cacheEntry.ExpiresAtUtc
});
return Task.FromResult(Result.Ok());
}

View File

@ -1,8 +0,0 @@
namespace MaksIT.CertsUI.Engine.RuntimeCoordination;
/// <summary>
/// True when this process is the elected primary replica (Postgres lease) and may run ACME orchestration and background renewal.
/// </summary>
public interface IPrimaryReplicaWorkload {
bool IsPrimary { get; }
}

View File

@ -4,6 +4,9 @@ namespace MaksIT.CertsUI.Engine.RuntimeCoordination;
public static class RuntimeLeaseNames {
public const string AcmeWriter = "certs-ui-acme-writer";
/// <summary>Single elected instance: identity bootstrap, ACME orchestration, and background renewal.</summary>
public const string PrimaryReplica = "certs-ui-primary";
/// <summary>Held only for coordination DDL + optional default-admin bootstrap; released when done (no renewal loop).</summary>
public const string BootstrapCoordinator = "certs-ui-bootstrap";
/// <summary>Held for one renewal sweep (purge + account passes); released after each sweep so any pod may run the next.</summary>
public const string RenewalSweep = "certs-ui-renewal-sweep";
}

View File

@ -0,0 +1,70 @@
using LinqToDB;
using LinqToDB.Data;
using MaksIT.CertsUI.Engine;
using MaksIT.CertsUI.Engine.Data;
using MaksIT.CertsUI.Engine.Domain.LetsEncrypt;
using MaksIT.CertsUI.Engine.Dto.Certs;
using MaksIT.CertsUI.Engine.Infrastructure;
using Microsoft.Extensions.Logging;
namespace MaksIT.CertsUI.Engine.Services;
/// <summary>PostgreSQL-backed ACME session state (replaces in-process <c>IMemoryCache</c>).</summary>
public sealed class AcmePostgresSessionStore(
ICertsEngineConfiguration config,
ILogger<AcmePostgresSessionStore> logger
) : IAcmeSessionStore {
private static readonly TimeSpan SessionTtl = TimeSpan.FromHours(1);
private DataConnection CreateConnection() {
var options = new DataOptions()
.UseConnectionString(ProviderName.PostgreSQL, config.ConnectionString)
.UseMappingSchema(CertsLinq2DbMapping.Schema);
return new DataConnection(options);
}
public Task<State> LoadOrCreateAsync(Guid sessionId, CancellationToken cancellationToken = default) {
cancellationToken.ThrowIfCancellationRequested();
using var db = CreateConnection();
var now = DateTimeOffset.UtcNow;
var row = db.GetTable<AcmeSessionDto>()
.Where(x => x.SessionId == sessionId && x.ExpiresAtUtc > now)
.FirstOrDefault();
if (row == null)
return Task.FromResult(new State());
try {
return Task.FromResult(AcmeSessionJsonSerializer.FromJson(row.PayloadJson));
}
catch (Exception ex) {
logger.LogWarning(ex, "Failed to deserialize ACME session {SessionId}; starting empty state.", sessionId);
return Task.FromResult(new State());
}
}
public Task PersistAsync(Guid sessionId, State state, CancellationToken cancellationToken = default) {
cancellationToken.ThrowIfCancellationRequested();
var json = AcmeSessionJsonSerializer.ToJson(state);
var now = DateTimeOffset.UtcNow;
var expires = now.Add(SessionTtl);
using var db = CreateConnection();
var existing = db.GetTable<AcmeSessionDto>()
.Where(x => x.SessionId == sessionId)
.FirstOrDefault();
if (existing == null) {
db.Insert(new AcmeSessionDto {
SessionId = sessionId,
PayloadJson = json,
UpdatedAtUtc = now,
ExpiresAtUtc = expires
});
}
else {
existing.PayloadJson = json;
existing.UpdatedAtUtc = now;
existing.ExpiresAtUtc = expires;
db.Update(existing);
}
return Task.CompletedTask;
}
}

View File

@ -0,0 +1,50 @@
using System.Security.Cryptography;
using MaksIT.CertsUI.Engine.Domain.LetsEncrypt;
using Newtonsoft.Json;
namespace MaksIT.CertsUI.Engine.Services;
internal static class AcmeSessionJsonSerializer {
private static readonly JsonSerializerSettings Settings = new() {
NullValueHandling = NullValueHandling.Ignore,
Formatting = Formatting.None
};
public static string ToJson(State state) {
var snap = new AcmeSessionSnapshot {
IsStaging = state.IsStaging,
Directory = state.Directory,
CurrentOrder = state.CurrentOrder,
Challenges = [.. state.Challenges],
Cache = state.Cache,
Jwk = state.Jwk,
AccountKeyCspBlob = state.Rsa is RSACryptoServiceProvider csp ? csp.ExportCspBlob(true) : null
};
return JsonConvert.SerializeObject(snap, Settings);
}
public static State FromJson(string json) {
if (string.IsNullOrWhiteSpace(json))
return new State();
var snap = JsonConvert.DeserializeObject<AcmeSessionSnapshot>(json, Settings);
if (snap == null)
return new State();
var state = new State {
IsStaging = snap.IsStaging,
Directory = snap.Directory,
CurrentOrder = snap.CurrentOrder,
Cache = snap.Cache,
Jwk = snap.Jwk
};
foreach (var c in snap.Challenges) {
if (c != null)
state.Challenges.Add(c);
}
if (snap.AccountKeyCspBlob is { Length: > 0 }) {
var rsa = new RSACryptoServiceProvider();
rsa.ImportCspBlob(snap.AccountKeyCspBlob);
state.Rsa = rsa;
}
return state;
}
}

View File

@ -0,0 +1,18 @@
using MaksIT.Core.Security.JWK;
using MaksIT.CertsUI.Engine.Domain.Certs;
using MaksIT.CertsUI.Engine.Dto.LetsEncrypt.Responses;
using Newtonsoft.Json;
namespace MaksIT.CertsUI.Engine.Services;
/// <summary>JSON-serializable projection of <see cref="MaksIT.CertsUI.Engine.Domain.LetsEncrypt.State"/> for <c>acme_sessions.payload_json</c>.</summary>
internal sealed class AcmeSessionSnapshot {
public bool IsStaging { get; set; }
public AcmeDirectory? Directory { get; set; }
public Order? CurrentOrder { get; set; }
public List<AuthorizationChallengeChallenge> Challenges { get; set; } = [];
public RegistrationCache? Cache { get; set; }
public Jwk? Jwk { get; set; }
/// <summary>RSA account key as CSP blob when present (same encoding as <see cref="RegistrationCache.AccountKey"/>).</summary>
public byte[]? AccountKeyCspBlob { get; set; }
}

View File

@ -1,23 +0,0 @@
using MaksIT.CertsUI.Engine.Domain.LetsEncrypt;
using Microsoft.Extensions.Caching.Memory;
namespace MaksIT.CertsUI.Engine.Services;
/// <summary>
/// In-memory cache of 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;
}
}

View File

@ -0,0 +1,9 @@
using MaksIT.CertsUI.Engine.Domain.LetsEncrypt;
namespace MaksIT.CertsUI.Engine.Services;
/// <summary>Loads and persists per-browser ACME <see cref="State"/> so any replica can continue the flow.</summary>
public interface IAcmeSessionStore {
Task<State> LoadOrCreateAsync(Guid sessionId, CancellationToken cancellationToken = default);
Task PersistAsync(Guid sessionId, State state, CancellationToken cancellationToken = default);
}

View File

@ -16,14 +16,37 @@ public partial class LetsEncryptService {
#region Internal helpers
private State GetOrCreateState(Guid sessionId) => _sessions.GetOrCreate(sessionId);
private async Task<Result> WithPersistedSessionAsync(
Guid sessionId,
CancellationToken cancellationToken,
Func<State, Task<Result>> body) {
var state = await _sessionStore.LoadOrCreateAsync(sessionId, cancellationToken).ConfigureAwait(false);
try {
return await body(state).ConfigureAwait(false);
}
finally {
await _sessionStore.PersistAsync(sessionId, state, cancellationToken).ConfigureAwait(false);
}
}
private async Task<Result<string?>> GetNonceAsync(Guid sessionId, Uri uri) {
private async Task<Result<T?>> WithPersistedSessionAsync<T>(
Guid sessionId,
CancellationToken cancellationToken,
Func<State, Task<Result<T?>>> body) {
var state = await _sessionStore.LoadOrCreateAsync(sessionId, cancellationToken).ConfigureAwait(false);
try {
return await body(state).ConfigureAwait(false);
}
finally {
await _sessionStore.PersistAsync(sessionId, state, cancellationToken).ConfigureAwait(false);
}
}
private async Task<Result<string?>> GetNonceAsync(State state, Uri uri) {
if (uri == null)
return Result<string?>.InternalServerError(null, "URI is null");
try {
var state = GetOrCreateState(sessionId);
_logger.LogInformation($"Executing {nameof(GetNonceAsync)}...");
@ -65,9 +88,7 @@ public partial class LetsEncryptService {
}
}
private Result<string?> EncodeMessage(Guid sessionId, bool isPostAsGet, object? requestModel, ACMEJwsHeader protectedHeader) {
var state = GetOrCreateState(sessionId);
private Result<string?> EncodeMessage(State state, bool isPostAsGet, object? requestModel, ACMEJwsHeader protectedHeader) {
if (!state.TryGetAccountKey(out var rsa, out var jwk))
return Result<string?>.InternalServerError(AccountKeyMissingMessage);
@ -94,7 +115,7 @@ public partial class LetsEncryptService {
request.Content.Headers.ContentType = new MediaTypeHeaderValue(contentType);
}
private async Task<Result> PollChallengeStatus(Guid sessionId, AuthorizationChallengeChallenge challenge) {
private async Task<Result> PollChallengeStatus(State state, AuthorizationChallengeChallenge challenge) {
if (challenge?.Url == null)
return Result.InternalServerError("Challenge URL is null");
@ -103,13 +124,13 @@ public partial class LetsEncryptService {
while (true) {
var pollRequest = new HttpRequestMessage(HttpMethod.Post, challenge.Url);
var nonceResult = await GetNonceAsync(sessionId, challenge.Url);
var nonceResult = await GetNonceAsync(state, challenge.Url);
if (!nonceResult.IsSuccess || nonceResult.Value == null)
return nonceResult;
var nonce = nonceResult.Value;
var pollJsonResult = EncodeMessage(sessionId, true, null, new ACMEJwsHeader {
var pollJsonResult = EncodeMessage(state, true, null, new ACMEJwsHeader {
Url = challenge.Url.ToString(),
Nonce = nonce
});

View File

@ -16,6 +16,7 @@ using MaksIT.CertsUI.Engine.Dto.LetsEncrypt.Responses;
using MaksIT.Results;
using Microsoft.Extensions.Logging;
using System.Net.Http.Headers;
using System.Threading;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
@ -24,15 +25,15 @@ using System.Text;
namespace MaksIT.CertsUI.Engine.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);
Result<string?> GetTermsOfServiceUri(Guid sessionId);
Task<Result<Dictionary<string, string>?>> NewOrder(Guid sessionId, string[] hostnames, string challengeType);
Task<Result> CompleteChallenges(Guid sessionId);
Task<Result> GetOrder(Guid sessionId, string[] hostnames);
Task<Result> GetCertificate(Guid sessionId, string subject);
Task<Result> RevokeCertificate(Guid sessionId, string subject, RevokeReason reason);
Task<Result<RegistrationCache?>> GetRegistrationCacheAsync(Guid sessionId, CancellationToken cancellationToken = default);
Task<Result> ConfigureClient(Guid sessionId, bool isStaging, CancellationToken cancellationToken = default);
Task<Result> Init(Guid sessionId, Guid accountId, string description, string[] contacts, RegistrationCache? registrationCache, CancellationToken cancellationToken = default);
Task<Result<string?>> GetTermsOfServiceUriAsync(Guid sessionId, CancellationToken cancellationToken = default);
Task<Result<Dictionary<string, string>?>> NewOrder(Guid sessionId, string[] hostnames, string challengeType, CancellationToken cancellationToken = default);
Task<Result> CompleteChallenges(Guid sessionId, CancellationToken cancellationToken = default);
Task<Result> GetOrder(Guid sessionId, string[] hostnames, CancellationToken cancellationToken = default);
Task<Result> GetCertificate(Guid sessionId, string subject, CancellationToken cancellationToken = default);
Task<Result> RevokeCertificate(Guid sessionId, string subject, RevokeReason reason, CancellationToken cancellationToken = default);
}
public partial class LetsEncryptService : ILetsEncryptService {
@ -44,78 +45,72 @@ public partial class LetsEncryptService : ILetsEncryptService {
private readonly ILogger<LetsEncryptService> _logger;
private readonly ICertsEngineConfiguration _engineConfiguration;
private readonly HttpClient _httpClient;
private readonly AcmeSessionStore _sessions;
private readonly IAcmeSessionStore _sessionStore;
public LetsEncryptService(
ILogger<LetsEncryptService> logger,
ICertsEngineConfiguration engineConfiguration,
HttpClient httpClient,
AcmeSessionStore sessions
IAcmeSessionStore sessionStore
) {
_logger = logger;
_engineConfiguration = engineConfiguration;
_httpClient = httpClient;
_sessions = sessions;
_sessionStore = sessionStore;
}
public Result<RegistrationCache?> GetRegistrationCache(Guid sessionId) {
var state = GetOrCreateState(sessionId);
if (state.Cache == null)
return Result<RegistrationCache?>.InternalServerError(null);
return Result<RegistrationCache?>.Ok(state.Cache);
}
public Task<Result<RegistrationCache?>> GetRegistrationCacheAsync(Guid sessionId, CancellationToken cancellationToken = default) =>
WithPersistedSessionAsync<RegistrationCache?>(sessionId, cancellationToken, async state => {
if (state.Cache == null)
return Result<RegistrationCache?>.InternalServerError(null);
return Result<RegistrationCache?>.Ok(state.Cache);
});
#region ConfigureClient
public async Task<Result> ConfigureClient(Guid sessionId, bool isStaging) {
try {
var state = GetOrCreateState(sessionId);
public Task<Result> ConfigureClient(Guid sessionId, bool isStaging, CancellationToken cancellationToken = default) =>
WithPersistedSessionAsync(sessionId, cancellationToken, async state => {
try {
state.IsStaging = isStaging;
state.IsStaging = isStaging;
if (state.Directory == null) {
var directoryUri = AcmeDirectoryAbsoluteUri(isStaging);
var request = new HttpRequestMessage(HttpMethod.Get, directoryUri);
_httpClient.BaseAddress ??= new Uri(isStaging ? _engineConfiguration.LetsEncryptStaging : _engineConfiguration.LetsEncryptProduction);
var requestResult = await SendAcmeRequest<AcmeDirectory>(request, state, HttpMethod.Get);
if (!requestResult.IsSuccess || requestResult.Value == null)
return requestResult;
if (state.Directory == null) {
var request = new HttpRequestMessage(HttpMethod.Get, new Uri(DirectoryEndpoint, UriKind.Relative));
var directory = requestResult.Value;
var requestResult = await SendAcmeRequest<AcmeDirectory>(request, state, HttpMethod.Get);
if (!requestResult.IsSuccess || requestResult.Value == null)
return requestResult;
state.Directory = directory.Result ?? throw new InvalidOperationException("Directory response is null");
}
var directory = requestResult.Value;
state.Directory = directory.Result ?? throw new InvalidOperationException("Directory response is null");
return Result.Ok("Client configured successfully.");
}
return Result.Ok("Client configured successfully.");
}
catch (LetsEncrytException ex) {
var state = GetOrCreateState(sessionId);
return MapLetsEncryptException(state, ex);
}
catch (Exception ex) {
return HandleUnhandledException(ex);
}
}
catch (LetsEncrytException ex) {
return MapLetsEncryptException(state, ex);
}
catch (Exception ex) {
return HandleUnhandledException(ex);
}
});
#endregion
#region Init
public async Task<Result> Init(Guid sessionId, Guid accountId, string description, string[] contacts, RegistrationCache? cache) {
public Task<Result> Init(Guid sessionId, Guid accountId, string description, string[] contacts, RegistrationCache? cache, CancellationToken cancellationToken = default) {
if (sessionId == Guid.Empty) {
const string message = "Invalid sessionId";
_logger.LogError(message);
return Result.InternalServerError(message);
return Task.FromResult(Result.InternalServerError(message));
}
if (contacts == null || contacts.Length == 0) {
const string message = "Contacts are null or empty";
_logger.LogError(message);
return Result.InternalServerError(message);
return Task.FromResult(Result.InternalServerError(message));
}
var state = GetOrCreateState(sessionId);
return WithPersistedSessionAsync(sessionId, cancellationToken, async state => {
if (state.Directory == null) {
const string message = "State directory is null";
_logger.LogError(message);
@ -159,13 +154,13 @@ public partial class LetsEncryptService : ILetsEncryptService {
var request = new HttpRequestMessage(HttpMethod.Post, newAccountUri);
var nonceResult = await GetNonceAsync(sessionId, newAccountUri);
var nonceResult = await GetNonceAsync(state, newAccountUri);
if (!nonceResult.IsSuccess || nonceResult.Value == null)
return nonceResult;
var nonce = nonceResult.Value;
var jsonResult = EncodeMessage(sessionId, false, letsEncryptOrder, new ACMEJwsHeader {
var jsonResult = EncodeMessage(state, false, letsEncryptOrder, new ACMEJwsHeader {
Url = newAccountUri.ToString(),
Nonce = nonce
});
@ -212,236 +207,235 @@ public partial class LetsEncryptService : ILetsEncryptService {
catch (Exception ex) {
return HandleUnhandledException(ex);
}
});
}
#endregion
#region GetTermsOfService
public Result<string?> GetTermsOfServiceUri(Guid sessionId) {
try {
var state = GetOrCreateState(sessionId);
public Task<Result<string?>> GetTermsOfServiceUriAsync(Guid sessionId, CancellationToken cancellationToken = default) =>
WithPersistedSessionAsync<string?>(sessionId, cancellationToken, async state => {
try {
_logger.LogInformation($"Executing {nameof(GetTermsOfServiceUriAsync)}...");
_logger.LogInformation($"Executing {nameof(GetTermsOfServiceUri)}...");
if (state.Directory?.Meta?.TermsOfService == null) {
return Result<string?>.Ok(null);
}
if (state.Directory?.Meta?.TermsOfService == null) {
return Result<string?>.Ok(null);
return Result<string?>.Ok(state.Directory.Meta.TermsOfService);
}
return Result<string?>.Ok(state.Directory.Meta.TermsOfService);
}
catch (Exception ex) {
return HandleUnhandledException<string?>(ex);
}
}
catch (Exception ex) {
return HandleUnhandledException<string?>(ex);
}
});
#endregion
#region NewOrder
public async Task<Result<Dictionary<string, string>?>> NewOrder(Guid sessionId, string[] hostnames, string challengeType) {
try {
var state = GetOrCreateState(sessionId);
public Task<Result<Dictionary<string, string>?>> NewOrder(Guid sessionId, string[] hostnames, string challengeType, CancellationToken cancellationToken = default) =>
WithPersistedSessionAsync<Dictionary<string, string>?>(sessionId, cancellationToken, async state => {
try {
_logger.LogInformation($"Executing {nameof(NewOrder)}...");
_logger.LogInformation($"Executing {nameof(NewOrder)}...");
state.Challenges.Clear();
state.Challenges.Clear();
var letsEncryptOrder = new Order {
Expires = DateTime.UtcNow.AddDays(2),
Identifiers = hostnames?.Where(h => h != null).Select(hostname => new OrderIdentifier {
Type = DnsType,
Value = hostname ?? string.Empty
}).ToArray() ?? []
};
var letsEncryptOrder = new Order {
Expires = DateTime.UtcNow.AddDays(2),
Identifiers = hostnames?.Where(h => h != null).Select(hostname => new OrderIdentifier {
Type = DnsType,
Value = hostname ?? string.Empty
}).ToArray() ?? []
};
if (state.Directory?.NewOrder is not { } newOrderUri)
return Result<Dictionary<string, string>?>.InternalServerError(null);
if (state.Directory?.NewOrder is not { } newOrderUri)
return Result<Dictionary<string, string>?>.InternalServerError(null);
var request = new HttpRequestMessage(HttpMethod.Post, newOrderUri);
var request = new HttpRequestMessage(HttpMethod.Post, newOrderUri);
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 = newOrderUri.ToString(),
Nonce = nonce
});
if (!jsonResult.IsSuccess || jsonResult.Value == null)
return jsonResult.ToResultOfType<Dictionary<string, string>?>(_ => null);
var json = jsonResult.Value;
PrepareRequestContent(request, json, HttpMethod.Post);
var requestResult = await SendAcmeRequest<Order>(request, state, HttpMethod.Post);
if (!requestResult.IsSuccess || requestResult.Value == null)
return requestResult.ToResultOfType<Dictionary<string, string>?>(_ => null);
var order = requestResult.Value;
if (StatusEquals(order.Result?.Status, OrderStatus.Ready))
return Result<Dictionary<string, string>?>.Ok(new Dictionary<string, string>());
if (!StatusEquals(order.Result?.Status, OrderStatus.Pending)) {
_logger.LogError($"Created new order and expected status '{OrderStatus.Pending.GetDisplayName()}', but got: {order.Result?.Status} \r\n {order.Result}");
return Result<Dictionary<string, string>?>.InternalServerError(null);
}
state.CurrentOrder = order.Result;
var results = new Dictionary<string, string>();
foreach (var item in state.CurrentOrder?.Authorizations ?? Array.Empty<Uri>()) {
if (item == null)
continue;
request = new HttpRequestMessage(HttpMethod.Post, item);
nonceResult = await GetNonceAsync(sessionId, item);
var nonceResult = await GetNonceAsync(state, newOrderUri);
if (!nonceResult.IsSuccess || nonceResult.Value == null)
return nonceResult.ToResultOfType<Dictionary<string, string>?>(_ => null);
nonce = nonceResult.Value;
var nonce = nonceResult.Value;
jsonResult = EncodeMessage(sessionId, true, null, new ACMEJwsHeader {
Url = item.ToString(),
var jsonResult = EncodeMessage(state, false, letsEncryptOrder, new ACMEJwsHeader {
Url = newOrderUri.ToString(),
Nonce = nonce
});
if (!jsonResult.IsSuccess || jsonResult.Value == null)
return jsonResult.ToResultOfType<Dictionary<string, string>?>(_ => null);
json = jsonResult.Value;
PrepareRequestContent(request, json, HttpMethod.Post);
var challengeResult = await SendAcmeRequest<AuthorizationChallengeResponse>(request, state, HttpMethod.Post);
if (!challengeResult.IsSuccess || challengeResult.Value == null)
return challengeResult.ToResultOfType<Dictionary<string, string>?>(_ => null);
var challengeResponse = challengeResult.Value;
if (StatusEquals(challengeResponse.Result?.Status, OrderStatus.Valid))
continue;
if (!StatusEquals(challengeResponse.Result?.Status, OrderStatus.Pending)) {
_logger.LogError($"Expected authorization status '{OrderStatus.Pending.GetDisplayName()}', but got: {challengeResponse.Result?.Status} \r\n {challengeResponse.ResponseText}");
return Result<Dictionary<string, string>?>.InternalServerError(null);
}
var challenge = challengeResponse.Result?.Challenges?
.FirstOrDefault(x => x?.Type == challengeType);
if (challenge == null || challenge.Token == null) {
_logger.LogError("Challenge or token is null");
return Result<Dictionary<string, string>?>.InternalServerError(null);
}
state.Challenges.Add(challenge);
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);
switch (challengeType) {
case "dns-01":
using (var sha256 = SHA256.Create()) {
var dnsToken = Base64UrlUtility.Encode(sha256.ComputeHash(Encoding.UTF8.GetBytes(keyToken ?? string.Empty)));
results[challengeResponse.Result?.Identifier?.Value ?? string.Empty] = dnsToken;
}
break;
case "http-01":
results[challengeResponse.Result?.Identifier?.Value ?? string.Empty] = keyToken ?? string.Empty;
break;
default:
throw new NotImplementedException();
}
}
return Result<Dictionary<string, string>?>.Ok(results);
}
catch (Exception ex) {
return HandleUnhandledException<Dictionary<string, string>?>(ex);
}
}
#endregion
#region CompleteChallenges
public async Task<Result> CompleteChallenges(Guid sessionId) {
try {
var state = GetOrCreateState(sessionId);
_logger.LogInformation($"Executing {nameof(CompleteChallenges)}...");
if (state.CurrentOrder?.Identifiers == null) {
return Result.InternalServerError("Current order identifiers are null");
}
for (var index = 0; index < state.Challenges.Count; index++) {
var challenge = state.Challenges[index];
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");
}
var request = 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 jsonResult = EncodeMessage(sessionId, false, "{}", new ACMEJwsHeader {
Url = challenge.Url.ToString(),
Nonce = nonce
});
if (!jsonResult.IsSuccess || jsonResult.Value == null)
return jsonResult;
var json = jsonResult.Value;
PrepareRequestContent(request, json, HttpMethod.Post);
_ = await SendAcmeRequest<AuthorizationChallengeResponse>(request, state, HttpMethod.Post);
var requestResult = await SendAcmeRequest<Order>(request, state, HttpMethod.Post);
if (!requestResult.IsSuccess || requestResult.Value == null)
return requestResult.ToResultOfType<Dictionary<string, string>?>(_ => null);
var result = await PollChallengeStatus(sessionId, challenge);
var order = requestResult.Value;
if (!result.IsSuccess)
return result;
if (StatusEquals(order.Result?.Status, OrderStatus.Ready))
return Result<Dictionary<string, string>?>.Ok(new Dictionary<string, string>());
if (!StatusEquals(order.Result?.Status, OrderStatus.Pending)) {
_logger.LogError($"Created new order and expected status '{OrderStatus.Pending.GetDisplayName()}', but got: {order.Result?.Status} \r\n {order.Result}");
return Result<Dictionary<string, string>?>.InternalServerError(null);
}
state.CurrentOrder = order.Result;
var results = new Dictionary<string, string>();
foreach (var item in state.CurrentOrder?.Authorizations ?? Array.Empty<Uri>()) {
if (item == null)
continue;
request = new HttpRequestMessage(HttpMethod.Post, item);
nonceResult = await GetNonceAsync(state, item);
if (!nonceResult.IsSuccess || nonceResult.Value == null)
return nonceResult.ToResultOfType<Dictionary<string, string>?>(_ => null);
nonce = nonceResult.Value;
jsonResult = EncodeMessage(state, true, null, new ACMEJwsHeader {
Url = item.ToString(),
Nonce = nonce
});
if (!jsonResult.IsSuccess || jsonResult.Value == null)
return jsonResult.ToResultOfType<Dictionary<string, string>?>(_ => null);
json = jsonResult.Value;
PrepareRequestContent(request, json, HttpMethod.Post);
var challengeResult = await SendAcmeRequest<AuthorizationChallengeResponse>(request, state, HttpMethod.Post);
if (!challengeResult.IsSuccess || challengeResult.Value == null)
return challengeResult.ToResultOfType<Dictionary<string, string>?>(_ => null);
var challengeResponse = challengeResult.Value;
if (StatusEquals(challengeResponse.Result?.Status, OrderStatus.Valid))
continue;
if (!StatusEquals(challengeResponse.Result?.Status, OrderStatus.Pending)) {
_logger.LogError($"Expected authorization status '{OrderStatus.Pending.GetDisplayName()}', but got: {challengeResponse.Result?.Status} \r\n {challengeResponse.ResponseText}");
return Result<Dictionary<string, string>?>.InternalServerError(null);
}
var challenge = challengeResponse.Result?.Challenges?
.FirstOrDefault(x => x?.Type == challengeType);
if (challenge == null || challenge.Token == null) {
_logger.LogError("Challenge or token is null");
return Result<Dictionary<string, string>?>.InternalServerError(null);
}
state.Challenges.Add(challenge);
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);
switch (challengeType) {
case "dns-01":
using (var sha256 = SHA256.Create()) {
var dnsToken = Base64UrlUtility.Encode(sha256.ComputeHash(Encoding.UTF8.GetBytes(keyToken ?? string.Empty)));
results[challengeResponse.Result?.Identifier?.Value ?? string.Empty] = dnsToken;
}
break;
case "http-01":
results[challengeResponse.Result?.Identifier?.Value ?? string.Empty] = keyToken ?? string.Empty;
break;
default:
throw new NotImplementedException();
}
}
return Result<Dictionary<string, string>?>.Ok(results);
}
return Result.Ok();
}
catch (LetsEncrytException ex) {
return MapLetsEncryptException(GetOrCreateState(sessionId), ex);
}
catch (Exception ex) {
return HandleUnhandledException(ex);
}
}
catch (Exception ex) {
return HandleUnhandledException<Dictionary<string, string>?>(ex);
}
});
#endregion
#region CompleteChallenges
public Task<Result> CompleteChallenges(Guid sessionId, CancellationToken cancellationToken = default) =>
WithPersistedSessionAsync(sessionId, cancellationToken, async state => {
try {
_logger.LogInformation($"Executing {nameof(CompleteChallenges)}...");
if (state.CurrentOrder?.Identifiers == null) {
return Result.InternalServerError("Current order identifiers are null");
}
for (var index = 0; index < state.Challenges.Count; index++) {
var challenge = state.Challenges[index];
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");
}
var request = new HttpRequestMessage(HttpMethod.Post, challenge.Url);
var nonceResult = await GetNonceAsync(state, challenge.Url);
if (!nonceResult.IsSuccess || nonceResult.Value == null)
return nonceResult;
var nonce = nonceResult.Value;
var jsonResult = EncodeMessage(state, false, "{}", new ACMEJwsHeader {
Url = challenge.Url.ToString(),
Nonce = nonce
});
if (!jsonResult.IsSuccess || jsonResult.Value == null)
return jsonResult;
var json = jsonResult.Value;
PrepareRequestContent(request, json, HttpMethod.Post);
_ = await SendAcmeRequest<AuthorizationChallengeResponse>(request, state, HttpMethod.Post);
var result = await PollChallengeStatus(state, challenge);
if (!result.IsSuccess)
return result;
}
return Result.Ok();
}
catch (LetsEncrytException ex) {
return MapLetsEncryptException(state, ex);
}
catch (Exception ex) {
return HandleUnhandledException(ex);
}
});
#endregion
#region GetOrder
public async Task<Result> GetOrder(Guid sessionId, string[] hostnames) {
public Task<Result> GetOrder(Guid sessionId, string[] hostnames, CancellationToken cancellationToken = default) =>
WithPersistedSessionAsync(sessionId, cancellationToken, state => GetOrderCoreAsync(state, hostnames));
private async Task<Result> GetOrderCoreAsync(State state, string[] hostnames) {
try {
_logger.LogInformation($"Executing {nameof(GetOrder)}");
var state = GetOrCreateState(sessionId);
if (state.Directory?.NewOrder is not { } newOrderUri)
return Result.InternalServerError("Directory is not configured. Run ConfigureClient first.");
@ -455,13 +449,13 @@ public partial class LetsEncryptService : ILetsEncryptService {
var request = new HttpRequestMessage(HttpMethod.Post, newOrderUri);
var nonceResult = await GetNonceAsync(sessionId, newOrderUri);
var nonceResult = await GetNonceAsync(state, newOrderUri);
if (!nonceResult.IsSuccess || nonceResult.Value == null)
return nonceResult;
var nonce = nonceResult.Value;
var jsonResult = EncodeMessage(sessionId, false, letsEncryptOrder, new ACMEJwsHeader {
var jsonResult = EncodeMessage(state, false, letsEncryptOrder, new ACMEJwsHeader {
Url = newOrderUri.ToString(),
Nonce = nonce
});
@ -490,10 +484,9 @@ public partial class LetsEncryptService : ILetsEncryptService {
#endregion
#region GetCertificates
public async Task<Result> GetCertificate(Guid sessionId, string subject) {
try {
var state = GetOrCreateState(sessionId);
public Task<Result> GetCertificate(Guid sessionId, string subject, CancellationToken cancellationToken = default) =>
WithPersistedSessionAsync(sessionId, cancellationToken, async state => {
try {
_logger.LogInformation($"Executing {nameof(GetCertificate)}...");
if (state.CurrentOrder?.Identifiers is not { } initialIdentifiers)
@ -525,7 +518,7 @@ public partial class LetsEncryptService : ILetsEncryptService {
var hostnames = idents.Select(x => x?.Value).Where(x => x != null).Cast<string>().ToArray();
await GetOrder(sessionId, hostnames);
await GetOrderCoreAsync(state, hostnames);
activeOrder = state.CurrentOrder;
if (activeOrder is null)
@ -539,13 +532,13 @@ public partial class LetsEncryptService : ILetsEncryptService {
var request = new HttpRequestMessage(HttpMethod.Post, finalizeUri);
var nonceResult = await GetNonceAsync(sessionId, finalizeUri);
var nonceResult = await GetNonceAsync(state, finalizeUri);
if (!nonceResult.IsSuccess || nonceResult.Value == null)
return nonceResult;
var nonce = nonceResult.Value;
var jsonResult = EncodeMessage(sessionId, false, letsEncryptOrder, new ACMEJwsHeader {
var jsonResult = EncodeMessage(state, false, letsEncryptOrder, new ACMEJwsHeader {
Url = finalizeUri.ToString(),
Nonce = nonce
});
@ -570,13 +563,13 @@ public partial class LetsEncryptService : ILetsEncryptService {
request = new HttpRequestMessage(HttpMethod.Post, orderLocation);
nonceResult = await GetNonceAsync(sessionId, orderLocation);
nonceResult = await GetNonceAsync(state, orderLocation);
if (!nonceResult.IsSuccess || nonceResult.Value == null)
return nonceResult;
nonce = nonceResult.Value;
jsonResult = EncodeMessage(sessionId, true, null, new ACMEJwsHeader {
jsonResult = EncodeMessage(state, true, null, new ACMEJwsHeader {
Url = orderLocation.ToString(),
Nonce = nonce
});
@ -619,13 +612,13 @@ public partial class LetsEncryptService : ILetsEncryptService {
var finalRequest = new HttpRequestMessage(HttpMethod.Post, certificateUrl);
var finalNonceResult = await GetNonceAsync(sessionId, certificateUrl);
var finalNonceResult = await GetNonceAsync(state, certificateUrl);
if (!finalNonceResult.IsSuccess || finalNonceResult.Value == null)
return finalNonceResult;
var finalNonce = finalNonceResult.Value;
var finalJsonResult = EncodeMessage(sessionId, true, null, new ACMEJwsHeader {
var finalJsonResult = EncodeMessage(state, true, null, new ACMEJwsHeader {
Url = certificateUrl.ToString(),
Nonce = finalNonce
});
@ -661,12 +654,12 @@ public partial class LetsEncryptService : ILetsEncryptService {
return Result.Ok();
}
catch (LetsEncrytException ex) {
return MapLetsEncryptException(GetOrCreateState(sessionId), ex);
return MapLetsEncryptException(state, ex);
}
catch (Exception ex) {
return HandleUnhandledException(ex);
}
}
});
#endregion
#region Key change
@ -676,91 +669,105 @@ public partial class LetsEncryptService : ILetsEncryptService {
#endregion
#region RevokeCertificate
public async Task<Result> RevokeCertificate(Guid sessionId, string subject, RevokeReason reason) {
try {
var state = GetOrCreateState(sessionId);
_logger.LogInformation($"Executing {nameof(RevokeCertificate)}...");
if (state.Cache?.CachedCerts == null || !state.Cache.CachedCerts.TryGetValue(subject, out var certificateCache) || certificateCache == null) {
_logger.LogError("Certificate not found in cache");
return Result.InternalServerError("Certificate not found");
}
var certPem = certificateCache.Cert ?? string.Empty;
if (string.IsNullOrEmpty(certPem)) {
_logger.LogError("Certificate PEM is null or empty");
return Result.InternalServerError("Certificate PEM is null or empty");
}
var certificate = X509Certificate2.CreateFromPem(certPem);
var derEncodedCert = certificate.Export(X509ContentType.Cert);
var base64UrlEncodedCert = Base64UrlUtility.Encode(derEncodedCert);
var revokeRequest = new RevokeRequest {
Certificate = base64UrlEncodedCert,
Reason = (int)reason
};
if (state.Directory?.RevokeCert is not { } revokeUri)
return Result.InternalServerError("Directory is not configured or RevokeCert URL is missing.");
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 = revokeUri.ToString(),
Nonce = nonce
};
if (!JwsGenerator.TryEncode(rsa, jwk, jwsHeader, revokeRequest, out var jwsMessage, out var errorMessage)) {
return Result.InternalServerError(errorMessage);
}
var json = jwsMessage.ToJson();
request.Content = new StringContent(json);
request.Content.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(ContentType.JoseJson));
var response = await _httpClient.SendAsync(request);
var responseText = await response.Content.ReadAsStringAsync();
HandleProblemResponseAsync(response, responseText);
public Task<Result> RevokeCertificate(Guid sessionId, string subject, RevokeReason reason, CancellationToken cancellationToken = default) =>
WithPersistedSessionAsync(sessionId, cancellationToken, async state => {
try {
if (!response.IsSuccessStatusCode)
return Result.InternalServerError(responseText);
_logger.LogInformation($"Executing {nameof(RevokeCertificate)}...");
state.Cache.CachedCerts.Remove(subject);
_logger.LogInformation("Certificate revoked successfully");
if (state.Cache?.CachedCerts == null || !state.Cache.CachedCerts.TryGetValue(subject, out var certificateCache) || certificateCache == null) {
_logger.LogError("Certificate not found in cache");
return Result.InternalServerError("Certificate not found");
}
return Result.Ok();
var certPem = certificateCache.Cert ?? string.Empty;
if (string.IsNullOrEmpty(certPem)) {
_logger.LogError("Certificate PEM is null or empty");
return Result.InternalServerError("Certificate PEM is null or empty");
}
var certificate = X509Certificate2.CreateFromPem(certPem);
var derEncodedCert = certificate.Export(X509ContentType.Cert);
var base64UrlEncodedCert = Base64UrlUtility.Encode(derEncodedCert);
var revokeRequest = new RevokeRequest {
Certificate = base64UrlEncodedCert,
Reason = (int)reason
};
if (state.Directory?.RevokeCert is not { } revokeUri)
return Result.InternalServerError("Directory is not configured or RevokeCert URL is missing.");
if (!state.TryGetAccountKey(out var rsa, out var jwk))
return Result.InternalServerError(AccountKeyMissingMessage);
var request = new HttpRequestMessage(HttpMethod.Post, revokeUri);
var nonceResult = await GetNonceAsync(state, revokeUri);
if (!nonceResult.IsSuccess || nonceResult.Value == null)
return nonceResult;
var nonce = nonceResult.Value;
var jwsHeader = new ACMEJwsHeader {
Url = revokeUri.ToString(),
Nonce = nonce
};
if (!JwsGenerator.TryEncode(rsa, jwk, jwsHeader, revokeRequest, out var jwsMessage, out var errorMessage)) {
return Result.InternalServerError(errorMessage);
}
var json = jwsMessage.ToJson();
request.Content = new StringContent(json);
request.Content.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(ContentType.JoseJson));
var response = await _httpClient.SendAsync(request);
var responseText = await response.Content.ReadAsStringAsync();
HandleProblemResponseAsync(response, responseText);
try {
if (!response.IsSuccessStatusCode)
return Result.InternalServerError(responseText);
state.Cache.CachedCerts.Remove(subject);
_logger.LogInformation("Certificate revoked successfully");
return Result.Ok();
}
finally {
response.Dispose();
}
}
finally {
response.Dispose();
catch (LetsEncrytException ex) {
return MapLetsEncryptException(state, ex);
}
}
catch (LetsEncrytException ex) {
var state = GetOrCreateState(sessionId);
return MapLetsEncryptException(state, ex);
}
catch (Exception ex) {
return HandleUnhandledException(ex);
}
}
catch (Exception ex) {
return HandleUnhandledException(ex);
}
});
#endregion
private Uri AcmeDirectoryAbsoluteUri(bool isStaging) {
var configured = (isStaging ? _engineConfiguration.LetsEncryptStaging : _engineConfiguration.LetsEncryptProduction).Trim();
if (string.IsNullOrWhiteSpace(configured))
throw new InvalidOperationException("Let's Encrypt directory URL is empty.");
if (Uri.TryCreate(configured, UriKind.Absolute, out var absolute)) {
// Config already points to the ACME directory endpoint.
if (absolute.AbsolutePath.TrimEnd('/').EndsWith($"/{DirectoryEndpoint}", StringComparison.OrdinalIgnoreCase))
return absolute;
// Backward compatibility: treat configured value as ACME base URL.
return new Uri(absolute, $"{DirectoryEndpoint}");
}
throw new InvalidOperationException($"Invalid Let's Encrypt URL: '{configured}'.");
}
}

View File

@ -17,5 +17,6 @@ public class Table(int id, string name) : Enumeration(id, name) {
#region Certs
public static readonly Table RegistrationCaches = new(2, "registration_caches");
public static readonly Table TermsOfServiceCache = new(5, "terms_of_service_cache");
public static readonly Table AcmeSessions = new(6, "acme_sessions");
#endregion
}

View File

@ -21,8 +21,7 @@ public class PostgresCacheFixture : IAsyncLifetime, IDisposable {
public WebApiTestFixture Config { get; private set; } = null!;
public async Task InitializeAsync() {
_container = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
_container = new PostgreSqlBuilder("postgres:16-alpine")
.Build();
await _container.StartAsync();

View File

@ -4,23 +4,14 @@ using Microsoft.Extensions.Options;
namespace MaksIT.CertsUI.Tests.Infrastructure;
/// <summary>
/// Creates a disposable temp workspace and <see cref="IOptions{Configuration}"/> with valid auth and paths.
/// Creates <see cref="IOptions{Configuration}"/> with valid auth and agent settings for API/domain tests.
/// </summary>
public sealed class WebApiTestFixture : IDisposable
{
public string Root { get; }
public IOptions<Configuration> AppOptions { get; }
public WebApiTestFixture()
{
Root = Path.Combine(Path.GetTempPath(), "maksit-webapi-tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(Root);
var dataFolder = Path.Combine(Root, "data");
Directory.CreateDirectory(dataFolder);
var acmeFolder = Path.Combine(Root, "acme");
Directory.CreateDirectory(acmeFolder);
var configuration = new Configuration
{
CertsUIEngineConfiguration = new CertsUIEngineConfiguration
@ -47,8 +38,6 @@ public sealed class WebApiTestFixture : IDisposable
},
Production = "https://acme-v02.api.letsencrypt.org/directory",
Staging = "https://acme-staging-v02.api.letsencrypt.org/directory",
AcmeFolder = acmeFolder,
DataFolder = dataFolder,
Agent = new Agent
{
AgentHostname = "http://127.0.0.1",
@ -62,16 +51,5 @@ public sealed class WebApiTestFixture : IDisposable
AppOptions = Microsoft.Extensions.Options.Options.Create(configuration);
}
public void Dispose()
{
try
{
if (Directory.Exists(Root))
Directory.Delete(Root, recursive: true);
}
catch
{
// best-effort cleanup of temp dir
}
}
public void Dispose() { }
}

View File

@ -2,21 +2,19 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.5" />
<PackageReference Include="coverlet.collector" Version="8.0.1">
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.7" />
<PackageReference Include="coverlet.collector" Version="10.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.5.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="Testcontainers.PostgreSql" Version="4.9.0" />
<PackageReference Include="Testcontainers.PostgreSql" Version="4.11.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View File

@ -17,8 +17,6 @@ namespace MaksIT.CertsUI.Tests.Services;
public sealed class CertsFlowServiceTests
{
private sealed class TestCertsFlowEngineConfiguration(WebApiTestFixture fx) : ICertsFlowEngineConfiguration {
public string AcmeFolder => fx.AppOptions.Value.CertsUIEngineConfiguration.AcmeFolder;
public string DataFolder => fx.AppOptions.Value.CertsUIEngineConfiguration.DataFolder;
public string AgentServiceToReload => fx.AppOptions.Value.CertsUIEngineConfiguration.Agent.ServiceToReload;
}
@ -31,8 +29,7 @@ public sealed class CertsFlowServiceTests
Mock<IAcmeHttpChallengePersistenceService>? httpChallenges = null,
Mock<IRuntimeLeaseService>? runtimeLease = null,
Mock<IRuntimeInstanceId>? runtimeInstance = null,
HttpMessageHandler? httpHandler = null,
Mock<IPrimaryReplicaWorkload>? primaryReplica = null)
HttpMessageHandler? httpHandler = null)
{
registrationCache ??= new Mock<IRegistrationCachePersistanceService>();
agent ??= new Mock<IAgentDeploymentService>();
@ -66,9 +63,6 @@ public sealed class CertsFlowServiceTests
runtimeInstance ??= new Mock<IRuntimeInstanceId>();
if (!runtimeInstanceProvided)
runtimeInstance.Setup(i => i.InstanceId).Returns("test-instance");
var primaryWorkload = primaryReplica ?? new Mock<IPrimaryReplicaWorkload>();
if (primaryReplica is null)
primaryWorkload.Setup(p => p.IsPrimary).Returns(true);
var handler = httpHandler ?? new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent([0x25, 0x50, 0x44, 0x46]) });
var httpClient = new HttpClient(handler, disposeHandler: true);
return new CertsFlowDomainService(
@ -81,8 +75,7 @@ public sealed class CertsFlowServiceTests
termsOfServiceCache.Object,
httpChallenges.Object,
runtimeLease.Object,
runtimeInstance.Object,
primaryWorkload.Object);
runtimeInstance.Object);
}
[Fact]
@ -90,7 +83,7 @@ public sealed class CertsFlowServiceTests
{
using var fx = new WebApiTestFixture();
var le = new Mock<ILetsEncryptService>();
le.Setup(x => x.ConfigureClient(It.IsAny<Guid>(), false))
le.Setup(x => x.ConfigureClient(It.IsAny<Guid>(), false, It.IsAny<CancellationToken>()))
.ReturnsAsync(Result.Ok());
var sut = CreateSut(fx, le);
@ -101,51 +94,12 @@ public sealed class CertsFlowServiceTests
Assert.NotNull(result.Value);
}
[Fact]
public async Task ConfigureClientAsync_WhenNotPrimary_ReturnsServiceUnavailableWithMarker()
{
using var fx = new WebApiTestFixture();
var le = new Mock<ILetsEncryptService>();
var primary = new Mock<IPrimaryReplicaWorkload>();
primary.Setup(p => p.IsPrimary).Returns(false);
var sut = CreateSut(fx, le, primaryReplica: primary);
var result = await sut.ConfigureClientAsync(isStaging: false);
Assert.False(result.IsSuccess);
Assert.Contains(CertsFlowPrimaryReplica.DiagnosticMarker, result.Messages ?? []);
le.Verify(x => x.ConfigureClient(It.IsAny<Guid>(), It.IsAny<bool>()), Times.Never);
}
[Fact]
public async Task AcmeChallenge_WhenNotPrimary_StillSucceedsFromDatabase()
{
using var fx = new WebApiTestFixture();
var name = "challenge-token";
var le = new Mock<ILetsEncryptService>();
var primary = new Mock<IPrimaryReplicaWorkload>();
primary.Setup(p => p.IsPrimary).Returns(false);
var challenges = new Mock<IAcmeHttpChallengePersistenceService>();
challenges.Setup(c => c.GetTokenValueAsync(name, It.IsAny<CancellationToken>()))
.ReturnsAsync(Result<string?>.Ok("body"));
challenges.Setup(c => c.UpsertAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Result.Ok());
challenges.Setup(c => c.DeleteOlderThanAsync(It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Result<int>.Ok(0));
var sut = CreateSut(fx, le, httpChallenges: challenges, primaryReplica: primary);
var result = await sut.AcmeChallengeAsync(name, CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal("body", result.Value);
}
[Fact]
public async Task ConfigureClientAsync_WhenConfigureFails_PropagatesFailure()
{
using var fx = new WebApiTestFixture();
var le = new Mock<ILetsEncryptService>();
le.Setup(x => x.ConfigureClient(It.IsAny<Guid>(), It.IsAny<bool>()))
le.Setup(x => x.ConfigureClient(It.IsAny<Guid>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Result.InternalServerError(["configure failed"]));
var sut = CreateSut(fx, le);
@ -161,7 +115,7 @@ public sealed class CertsFlowServiceTests
using var fx = new WebApiTestFixture();
var sessionId = Guid.NewGuid();
var le = new Mock<ILetsEncryptService>();
le.Setup(x => x.Init(sessionId, It.IsAny<Guid>(), "d", It.Is<string[]>(c => c.Length == 1 && c[0] == "mailto:a@b"), null))
le.Setup(x => x.Init(sessionId, It.IsAny<Guid>(), "d", It.Is<string[]>(c => c.Length == 1 && c[0] == "mailto:a@b"), null, It.IsAny<CancellationToken>()))
.ReturnsAsync(Result.Ok());
var sut = CreateSut(fx, le);
@ -170,7 +124,7 @@ public sealed class CertsFlowServiceTests
Assert.True(result.IsSuccess);
Assert.NotNull(result.Value);
le.Verify(x => x.Init(sessionId, It.IsAny<Guid>(), "d", It.IsAny<string[]>(), null), Times.Once);
le.Verify(x => x.Init(sessionId, It.IsAny<Guid>(), "d", It.IsAny<string[]>(), null, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
@ -184,7 +138,7 @@ public sealed class CertsFlowServiceTests
.ReturnsAsync(Result<RegistrationCache?>.InternalServerError(null, "missing"));
var le = new Mock<ILetsEncryptService>();
le.Setup(x => x.Init(sessionId, It.IsAny<Guid>(), "d", It.IsAny<string[]>(), null))
le.Setup(x => x.Init(sessionId, It.IsAny<Guid>(), "d", It.IsAny<string[]>(), null, It.IsAny<CancellationToken>()))
.ReturnsAsync(Result.Ok());
var sut = CreateSut(fx, le, cache);
@ -214,7 +168,7 @@ public sealed class CertsFlowServiceTests
.ReturnsAsync(Result<RegistrationCache?>.Ok(reg));
var le = new Mock<ILetsEncryptService>();
le.Setup(x => x.Init(sessionId, accountId, "d", It.IsAny<string[]>(), reg))
le.Setup(x => x.Init(sessionId, accountId, "d", It.IsAny<string[]>(), reg, It.IsAny<CancellationToken>()))
.ReturnsAsync(Result.Ok());
var sut = CreateSut(fx, le, cache);
@ -231,7 +185,7 @@ public sealed class CertsFlowServiceTests
using var fx = new WebApiTestFixture();
var sessionId = Guid.NewGuid();
var le = new Mock<ILetsEncryptService>();
le.Setup(x => x.NewOrder(sessionId, It.IsAny<string[]>(), "http-01"))
le.Setup(x => x.NewOrder(sessionId, It.IsAny<string[]>(), "http-01", It.IsAny<CancellationToken>()))
.ReturnsAsync(Result<Dictionary<string, string>?>.Ok(new Dictionary<string, string>
{
["example.com"] = "tokenPart.rest.of.token"
@ -271,7 +225,7 @@ public sealed class CertsFlowServiceTests
var result = await sut.NewOrderAsync(sessionId, ["example.com"], "http-01");
Assert.False(result.IsSuccess);
le.Verify(x => x.NewOrder(It.IsAny<Guid>(), It.IsAny<string[]>(), It.IsAny<string>()), Times.Never);
le.Verify(x => x.NewOrder(It.IsAny<Guid>(), It.IsAny<string[]>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
runtimeLease.Verify(l => l.ReleaseAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
@ -292,7 +246,7 @@ public sealed class CertsFlowServiceTests
var result = await sut.NewOrderAsync(sessionId, ["example.com"], "http-01");
Assert.False(result.IsSuccess);
le.Verify(x => x.NewOrder(It.IsAny<Guid>(), It.IsAny<string[]>(), It.IsAny<string>()), Times.Never);
le.Verify(x => x.NewOrder(It.IsAny<Guid>(), It.IsAny<string[]>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
runtimeLease.Verify(l => l.ReleaseAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
@ -302,7 +256,7 @@ public sealed class CertsFlowServiceTests
using var fx = new WebApiTestFixture();
var sessionId = Guid.NewGuid();
var le = new Mock<ILetsEncryptService>();
le.Setup(x => x.NewOrder(sessionId, It.IsAny<string[]>(), "http-01"))
le.Setup(x => x.NewOrder(sessionId, It.IsAny<string[]>(), "http-01", It.IsAny<CancellationToken>()))
.ReturnsAsync(Result<Dictionary<string, string>?>.InternalServerError(null, "acme failed"));
var runtimeLease = new Mock<IRuntimeLeaseService>();
runtimeLease.Setup(l => l.TryAcquireAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
@ -324,8 +278,8 @@ public sealed class CertsFlowServiceTests
using var fx = new WebApiTestFixture();
var sessionId = Guid.NewGuid();
var le = new Mock<ILetsEncryptService>();
le.Setup(x => x.GetTermsOfServiceUri(sessionId))
.Returns(Result<string?>.InternalServerError(null, "no uri"));
le.Setup(x => x.GetTermsOfServiceUriAsync(sessionId, It.IsAny<CancellationToken>()))
.ReturnsAsync(Result<string?>.InternalServerError(null, "no uri"));
var sut = CreateSut(fx, le);
@ -341,8 +295,8 @@ public sealed class CertsFlowServiceTests
var sessionId = Guid.NewGuid();
var url = "https://acme.test/sub/cached-tos.pdf";
var le = new Mock<ILetsEncryptService>();
le.Setup(x => x.GetTermsOfServiceUri(sessionId))
.Returns(Result<string?>.Ok(url));
le.Setup(x => x.GetTermsOfServiceUriAsync(sessionId, It.IsAny<CancellationToken>()))
.ReturnsAsync(Result<string?>.Ok(url));
var tosCache = new Mock<ITermsOfServiceCachePersistenceService>();
tosCache.Setup(c => c.GetByUrlAsync(url, It.IsAny<CancellationToken>()))
@ -384,7 +338,7 @@ public sealed class CertsFlowServiceTests
}
[Fact]
public async Task AcmeChallenge_WhenDbRowExists_MaterializesFileAndReturnsContent()
public async Task AcmeChallenge_WhenDbRowExists_ReturnsContent()
{
using var fx = new WebApiTestFixture();
var name = "challenge-token";
@ -402,9 +356,6 @@ public sealed class CertsFlowServiceTests
Assert.True(result.IsSuccess);
Assert.Equal("challenge-body", result.Value);
var path = Path.Combine(fx.AppOptions.Value.CertsUIEngineConfiguration.AcmeFolder, name);
Assert.True(File.Exists(path));
Assert.Equal("challenge-body", await File.ReadAllTextAsync(path));
}
[Fact]
@ -508,14 +459,14 @@ public sealed class CertsFlowServiceTests
using var fx = new WebApiTestFixture();
var sessionId = Guid.NewGuid();
var le = new Mock<ILetsEncryptService>();
le.Setup(x => x.CompleteChallenges(sessionId))
le.Setup(x => x.CompleteChallenges(sessionId, It.IsAny<CancellationToken>()))
.ReturnsAsync(Result.Ok());
var sut = CreateSut(fx, le);
var result = await sut.CompleteChallengesAsync(sessionId);
Assert.True(result.IsSuccess);
le.Verify(x => x.CompleteChallenges(sessionId), Times.Once);
le.Verify(x => x.CompleteChallenges(sessionId, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
@ -524,7 +475,7 @@ public sealed class CertsFlowServiceTests
using var fx = new WebApiTestFixture();
var sessionId = Guid.NewGuid();
var le = new Mock<ILetsEncryptService>();
le.Setup(x => x.GetOrder(sessionId, It.IsAny<string[]>()))
le.Setup(x => x.GetOrder(sessionId, It.IsAny<string[]>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Result.Ok());
var sut = CreateSut(fx, le);

View File

@ -86,14 +86,5 @@ public class CertsUIEngineConfiguration : ICertsFlowEngineConfiguration {
public required string Staging { get; set; }
public required string AcmeFolder { get; set; }
/// <summary>Writable directory for ACME subscriber agreement PDFs and <c>init</c> marker.</summary>
public required string DataFolder { get; set; }
string ICertsFlowEngineConfiguration.AcmeFolder => AcmeFolder;
string ICertsFlowEngineConfiguration.DataFolder => DataFolder;
string ICertsFlowEngineConfiguration.AgentServiceToReload => Agent.ServiceToReload;
}

View File

@ -1,6 +1,5 @@
using MaksIT.Models.LetsEncryptServer.CertsFlow.Requests;
using MaksIT.CertsUI.Authorization.Filters;
using MaksIT.CertsUI.Mvc;
using MaksIT.CertsUI.Services;
using Microsoft.AspNetCore.Mvc;
@ -21,55 +20,55 @@ namespace MaksIT.CertsUI.Controllers {
[HttpPost("configure-client")]
public async Task<IActionResult> ConfigureClient([FromBody] ConfigureClientRequest requestData) {
var result = await _certsFlowService.ConfigureClientAsync(requestData.IsStaging);
return result.ToCertsFlowActionResult();
return result.ToActionResult();
}
[HttpGet("{sessionId}/terms-of-service")]
public async Task<IActionResult> TermsOfService(Guid sessionId) {
var result = await _certsFlowService.GetTermsOfServiceAsync(sessionId);
return result.ToCertsFlowActionResult();
return result.ToActionResult();
}
[HttpPost("{sessionId}/init/{accountId?}")]
public async Task<IActionResult> Init(Guid sessionId, Guid? accountId, [FromBody] InitRequest requestData) {
var result = await _certsFlowService.InitAsync(sessionId, accountId, requestData.Description, requestData.Contacts);
return result.ToCertsFlowActionResult();
return result.ToActionResult();
}
[HttpPost("{sessionId}/order")]
public async Task<IActionResult> NewOrder(Guid sessionId, [FromBody] NewOrderRequest requestData) {
var result = await _certsFlowService.NewOrderAsync(sessionId, requestData.Hostnames, requestData.ChallengeType);
return result.ToCertsFlowActionResult();
return result.ToActionResult();
}
[HttpPost("{sessionId}/complete-challenges")]
public async Task<IActionResult> CompleteChallenges(Guid sessionId) {
var result = await _certsFlowService.CompleteChallengesAsync(sessionId);
return result.ToCertsFlowActionResult();
return result.ToActionResult();
}
[HttpGet("{sessionId}/order-status")]
public async Task<IActionResult> GetOrder(Guid sessionId, [FromBody] GetOrderRequest requestData) {
var result = await _certsFlowService.GetOrderAsync(sessionId, requestData.Hostnames);
return result.ToCertsFlowActionResult();
return result.ToActionResult();
}
[HttpPost("{sessionId}/certificates/download")]
public async Task<IActionResult> GetCertificates(Guid sessionId, [FromBody] GetCertificatesRequest requestData) {
var result = await _certsFlowService.GetCertificatesAsync(sessionId, requestData.Hostnames);
return result.ToCertsFlowActionResult();
return result.ToActionResult();
}
[HttpPost("{accountId}/certificates/apply")]
public async Task<IActionResult> ApplyCertificates(Guid accountId) {
var result = await _certsFlowService.ApplyCertificatesAsync(accountId);
return result.ToCertsFlowActionResult();
return result.ToActionResult();
}
[HttpPost("{sessionId}/certificates/revoke")]
public async Task<IActionResult> RevokeCertificates(Guid sessionId, [FromBody] RevokeCertificatesRequest requestData) {
var result = await _certsFlowService.RevokeCertificatesAsync(sessionId, requestData.Hostnames);
return result.ToCertsFlowActionResult();
return result.ToActionResult();
}
}
}

View File

@ -1,138 +1,134 @@
using MaksIT.CertsUI.Engine.Domain.Certs;
using MaksIT.CertsUI.Engine.Infrastructure;
using MaksIT.CertsUI.Engine.Persistance.Services;
using MaksIT.CertsUI.Engine.RuntimeCoordination;
using MaksIT.Results;
using MaksIT.CertsUI.Services;
using Microsoft.Extensions.Options;
using System;
namespace MaksIT.CertsUI.HostedServices {
public class AutoRenewal : BackgroundService {
namespace MaksIT.CertsUI.HostedServices;
private readonly IOptions<Configuration> _appSettings;
private readonly ILogger<AutoRenewal> _logger;
private readonly IServiceScopeFactory _scopeFactory;
private readonly IPrimaryReplicaWorkload _primaryReplica;
/// <summary>Certificate renewal: each sweep acquires <see cref="RuntimeLeaseNames.RenewalSweep"/> so only one pod runs ACME renewal at a time (symmetric replicas, no elected primary).</summary>
public sealed class AutoRenewal(
ILogger<AutoRenewal> logger,
IServiceScopeFactory scopeFactory,
IRuntimeLeaseService leaseService,
IRuntimeInstanceId runtimeInstance
) : BackgroundService {
private static readonly Random _random = new();
private static readonly TimeSpan RenewalLeaseTtl = TimeSpan.FromMinutes(12);
private static readonly Random Random = new();
public AutoRenewal(
IOptions<Configuration> appSettings,
ILogger<AutoRenewal> logger,
IServiceScopeFactory scopeFactory,
IPrimaryReplicaWorkload primaryReplica
) {
_appSettings = appSettings;
_logger = logger;
_scopeFactory = scopeFactory;
_primaryReplica = primaryReplica;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
while (!stoppingToken.IsCancellationRequested) {
var holder = runtimeInstance.InstanceId;
var acquired = await leaseService.TryAcquireAsync(RuntimeLeaseNames.RenewalSweep, holder, RenewalLeaseTtl, stoppingToken).ConfigureAwait(false);
if (!acquired.IsSuccess) {
if (logger.IsEnabled(LogLevel.Warning))
logger.LogWarning("Renewal sweep lease check failed: {Messages}", string.Join("; ", acquired.Messages ?? []));
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken).ConfigureAwait(false);
continue;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
while (!stoppingToken.IsCancellationRequested) {
if (!_primaryReplica.IsPrimary) {
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken).ConfigureAwait(false);
continue;
}
if (!acquired.Value) {
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken).ConfigureAwait(false);
continue;
}
_logger.LogInformation("Background service is running (primary replica).");
try {
if (logger.IsEnabled(LogLevel.Information))
logger.LogInformation("Running certificate renewal sweep (lease holder {Holder}).", holder);
using var scope = _scopeFactory.CreateScope();
using var scope = scopeFactory.CreateScope();
var cacheService = scope.ServiceProvider.GetRequiredService<ICacheService>();
var certsFlowService = scope.ServiceProvider.GetRequiredService<ICertsFlowService>();
var httpChallenges = scope.ServiceProvider.GetRequiredService<IAcmeHttpChallengePersistenceService>();
var purge = await httpChallenges.DeleteOlderThanAsync(TimeSpan.FromDays(10), stoppingToken);
var purge = await httpChallenges.DeleteOlderThanAsync(TimeSpan.FromDays(10), stoppingToken).ConfigureAwait(false);
if (purge.IsSuccess && purge.Value > 0)
_logger.LogInformation("Purged {Count} HTTP-01 challenge row(s) older than 10 days.", purge.Value);
logger.LogInformation("Purged {Count} HTTP-01 challenge row(s) older than 10 days.", purge.Value);
var loadAccountsFromCacheResult = await cacheService.LoadAccountsFromCacheAsync();
var loadAccountsFromCacheResult = await cacheService.LoadAccountsFromCacheAsync().ConfigureAwait(false);
if (!loadAccountsFromCacheResult.IsSuccess || loadAccountsFromCacheResult.Value == null) {
LogErrors(loadAccountsFromCacheResult.Messages);
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
LogErrorMessages(loadAccountsFromCacheResult.Messages);
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken).ConfigureAwait(false);
continue;
}
var accountsResponse = loadAccountsFromCacheResult.Value;
foreach (var account in accountsResponse.Where(x => !x.IsDisabled)) {
await ProcessAccountAsync(certsFlowService, account);
}
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
foreach (var account in accountsResponse.Where(x => !x.IsDisabled))
await ProcessAccountAsync(certsFlowService, account).ConfigureAwait(false);
}
}
private async Task<Result> ProcessAccountAsync(ICertsFlowService certsFlowService, RegistrationCache cache) {
var hosts = cache.GetHosts();
var toRenew = new List<string>();
foreach (var host in hosts) {
if (host.IsDisabled)
continue;
// Only consider certs expiring within 30 days
if ((host.Expires - DateTime.UtcNow).TotalDays < 30) {
// Randomize renewal between 1 and 5 days before expiry
int randomDays = _random.Next(1, 6);
var renewalTime = host.Expires.AddDays(-randomDays);
if (DateTime.UtcNow >= renewalTime) {
toRenew.Add(host.Hostname);
}
}
finally {
var released = await leaseService.ReleaseAsync(RuntimeLeaseNames.RenewalSweep, holder, CancellationToken.None).ConfigureAwait(false);
if (!released.IsSuccess && logger.IsEnabled(LogLevel.Warning))
logger.LogWarning("Renewal sweep lease release: {Messages}", string.Join("; ", released.Messages ?? []));
}
if (!toRenew.Any()) {
_logger.LogInformation("No certificates are due for randomized renewal at this time.");
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, eligible.ToArray()
);
if (!fullFlowResult.IsSuccess)
return fullFlowResult;
_logger.LogInformation("Certificates renewed for account {AccountId}: {Hostnames}", cache.AccountId, string.Join(", ", eligible));
return Result.Ok();
}
private void LogErrors(IEnumerable<string> errors) {
foreach (var error in errors) {
_logger.LogError(error);
}
}
public override Task StopAsync(CancellationToken stoppingToken) {
_logger.LogInformation("Background service is stopping.");
return base.StopAsync(stoppingToken);
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken).ConfigureAwait(false);
}
}
private async Task ProcessAccountAsync(ICertsFlowService certsFlowService, RegistrationCache cache) {
var hosts = cache.GetHosts();
var toRenew = new List<string>();
foreach (var host in hosts) {
if (host.IsDisabled)
continue;
if ((host.Expires - DateTime.UtcNow).TotalDays < 30) {
int randomDays = Random.Next(1, 6);
var renewalTime = host.Expires.AddDays(-randomDays);
if (DateTime.UtcNow >= renewalTime)
toRenew.Add(host.Hostname);
}
}
if (!toRenew.Any()) {
logger.LogInformation("No certificates are due for randomized renewal at this time for account {AccountId}.", cache.AccountId);
return;
}
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;
}
var fullFlowResult = await certsFlowService.FullFlow(
cache.IsStaging, cache.AccountId, cache.Description, cache.Contacts, cache.ChallengeType, eligible.ToArray()
).ConfigureAwait(false);
if (!fullFlowResult.IsSuccess)
LogErrorMessages(fullFlowResult.Messages);
else
logger.LogInformation("Certificates renewed for account {AccountId}: {Hostnames}", cache.AccountId, string.Join(", ", eligible));
}
private void LogErrorMessages(IEnumerable<string>? errors) {
if (errors == null)
return;
foreach (var error in errors)
logger.LogError("{Error}", error);
}
public override Task StopAsync(CancellationToken stoppingToken) {
logger.LogInformation("Background service is stopping.");
return base.StopAsync(stoppingToken);
}
}

View File

@ -1,62 +1,63 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using MaksIT.CertsUI.Engine;
using MaksIT.CertsUI.Engine.DomainServices;
using MaksIT.CertsUI.Engine.Infrastructure;
using MaksIT.CertsUI.Engine.RuntimeCoordination;
using MaksIT.CertsUI.Infrastructure;
namespace MaksIT.CertsUI.HostedServices;
/// <summary>
/// Exactly one instance holds <see cref="RuntimeLeaseNames.PrimaryReplica"/> and runs coordination DDL plus identity bootstrap.
/// Other instances wait until the database (and optional shared <c>init</c> marker under <see cref="Configuration.CertsUIEngineConfiguration.DataFolder"/>) shows bootstrap complete, then start without ACME privileges.
/// Uses a short-lived Postgres lease (<see cref="RuntimeLeaseNames.BootstrapCoordinator"/>) so exactly one pod runs
/// coordination DDL + default admin creation; other pods wait until <c>users</c> exist. No long-lived leader role.
/// </summary>
public sealed class InitializationHostedService(
ILogger<InitializationHostedService> logger,
IServiceProvider serviceProvider,
IOptions<Configuration> appSettings,
PrimaryReplicaGate primaryGate
IRuntimeLeaseService leaseService,
IRuntimeInstanceId runtimeInstance
) : IHostedService {
private static readonly TimeSpan BootstrapLeaseTtl = TimeSpan.FromMinutes(5);
public async Task StartAsync(CancellationToken cancellationToken) {
const int delayMilliseconds = 2000;
var appLifetime = serviceProvider.GetRequiredService<IHostApplicationLifetime>();
while (!cancellationToken.IsCancellationRequested) {
try {
logger.LogInformation("Running startup initialization (primary replica election)...");
logger.LogInformation("Running startup coordination (Postgres bootstrap lease)...");
if (await primaryGate.TryAcquirePrimaryLeaseAsync(cancellationToken).ConfigureAwait(false)) {
primaryGate.StartLeaseRenewal(appLifetime);
var holder = runtimeInstance.InstanceId;
var acquired = await leaseService.TryAcquireAsync(RuntimeLeaseNames.BootstrapCoordinator, holder, BootstrapLeaseTtl, cancellationToken).ConfigureAwait(false);
if (!acquired.IsSuccess)
throw new InvalidOperationException(string.Join(", ", acquired.Messages ?? ["Bootstrap lease acquire failed."]));
if (acquired.Value) {
try {
var engineConfig = serviceProvider.GetRequiredService<ICertsEngineConfiguration>();
await CoordinationTableProvisioner.EnsureAsync(engineConfig.ConnectionString, cancellationToken).ConfigureAwait(false);
await using var scope = serviceProvider.CreateAsyncScope();
var identityDomainService = scope.ServiceProvider.GetRequiredService<IIdentityDomainService>();
await EnsureIdentityAsLeaderAsync(appSettings.Value, identityDomainService, cancellationToken).ConfigureAwait(false);
await EnsureIdentityAsLeaderAsync(identityDomainService, cancellationToken).ConfigureAwait(false);
}
catch {
await primaryGate.AbandonPrimaryAsync().ConfigureAwait(false);
throw;
finally {
var released = await leaseService.ReleaseAsync(RuntimeLeaseNames.BootstrapCoordinator, holder, CancellationToken.None).ConfigureAwait(false);
if (!released.IsSuccess && logger.IsEnabled(LogLevel.Warning))
logger.LogWarning("Bootstrap lease release: {Messages}", string.Join("; ", released.Messages ?? []));
}
primaryGate.EnablePrimaryWorkload();
logger.LogInformation("Startup initialization completed; this instance is the primary replica.");
logger.LogInformation("Startup coordination completed (this instance held the bootstrap lease).");
return;
}
await using (var followerScope = serviceProvider.CreateAsyncScope()) {
var identityFollower = followerScope.ServiceProvider.GetRequiredService<IIdentityDomainService>();
var cfg = appSettings.Value;
while (!cancellationToken.IsCancellationRequested) {
if (await IsClusterIdentityReadyAsync(cfg, identityFollower, cancellationToken).ConfigureAwait(false)) {
logger.LogInformation("Startup initialization completed; this instance is a secondary replica.");
if (await IsClusterIdentityReadyAsync(identityFollower, cancellationToken).ConfigureAwait(false)) {
logger.LogInformation("Startup coordination completed (another instance bootstrapped identity).");
return;
}
logger.LogInformation("Waiting for primary replica to finish database bootstrap...");
logger.LogInformation("Waiting for bootstrap to finish (checking database)...");
await Task.Delay(delayMilliseconds, cancellationToken).ConfigureAwait(false);
}
}
@ -64,20 +65,20 @@ public sealed class InitializationHostedService(
cancellationToken.ThrowIfCancellationRequested();
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) {
logger.LogInformation("Startup initialization canceled (host is stopping).");
logger.LogInformation("Startup coordination canceled (host is stopping).");
throw;
}
catch (Exception ex) {
if (cancellationToken.IsCancellationRequested) {
logger.LogInformation(ex, "Startup initialization aborted while stopping host.");
throw new OperationCanceledException("Host stopped during startup initialization.", ex, cancellationToken);
logger.LogInformation(ex, "Startup coordination aborted while stopping host.");
throw new OperationCanceledException("Host stopped during startup coordination.", ex, cancellationToken);
}
logger.LogError(ex, "Startup initialization failed. Retrying...");
logger.LogError(ex, "Startup coordination failed. Retrying...");
try {
await Task.Delay(delayMilliseconds, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) {
logger.LogInformation("Startup initialization retry wait canceled (host is stopping).");
logger.LogInformation("Startup coordination retry wait canceled (host is stopping).");
throw;
}
}
@ -87,53 +88,29 @@ public sealed class InitializationHostedService(
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
private static async Task EnsureIdentityAsLeaderAsync(
Configuration appSettings,
IIdentityDomainService identityDomainService,
CancellationToken cancellationToken
) {
var dataDir = appSettings.CertsUIEngineConfiguration.DataFolder;
if (!Directory.Exists(dataDir))
Directory.CreateDirectory(dataDir);
var initPath = Path.Combine(dataDir, "init");
if (File.Exists(initPath))
return;
var count = await identityDomainService.CountUsersAsync(cancellationToken).ConfigureAwait(false);
if (!count.IsSuccess)
throw new InvalidOperationException(string.Join(", ", count.Messages));
if (count.Value == 0) {
var bootstrap = await identityDomainService.EnsureDefaultAdminAsync(cancellationToken).ConfigureAwait(false);
if (!bootstrap.IsSuccess)
throw new InvalidOperationException(string.Join(", ", bootstrap.Messages));
}
if (count.Value != 0)
return;
await File.WriteAllTextAsync(initPath, string.Empty, cancellationToken).ConfigureAwait(false);
var bootstrap = await identityDomainService.EnsureDefaultAdminAsync(cancellationToken).ConfigureAwait(false);
if (!bootstrap.IsSuccess)
throw new InvalidOperationException(string.Join(", ", bootstrap.Messages));
}
private static async Task<bool> IsClusterIdentityReadyAsync(
Configuration appSettings,
IIdentityDomainService identityDomainService,
CancellationToken cancellationToken
) {
var dataDir = appSettings.CertsUIEngineConfiguration.DataFolder;
if (!Directory.Exists(dataDir))
Directory.CreateDirectory(dataDir);
var initPath = Path.Combine(dataDir, "init");
if (File.Exists(initPath))
return true;
var count = await identityDomainService.CountUsersAsync(cancellationToken).ConfigureAwait(false);
if (!count.IsSuccess)
throw new InvalidOperationException(string.Join(", ", count.Messages));
if (count.Value > 0) {
await File.WriteAllTextAsync(initPath, string.Empty, cancellationToken).ConfigureAwait(false);
return true;
}
return false;
return count.Value > 0;
}
}

View File

@ -1,14 +0,0 @@
using MaksIT.CertsUI.Infrastructure;
namespace MaksIT.CertsUI.HostedServices;
/// <summary>
/// Registered last so <see cref="IHostedService.StopAsync"/> runs first on shutdown: releases the primary Postgres lease and stops renewal.
/// </summary>
public sealed class PrimaryReplicaShutdownHostedService(PrimaryReplicaGate primaryGate) : IHostedService {
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public async Task StopAsync(CancellationToken cancellationToken) =>
await primaryGate.AbandonPrimaryAsync().ConfigureAwait(false);
}

View File

@ -1,121 +0,0 @@
using Microsoft.Extensions.Hosting;
using MaksIT.CertsUI.Engine.Infrastructure;
using MaksIT.CertsUI.Engine.RuntimeCoordination;
namespace MaksIT.CertsUI.Infrastructure;
/// <summary>
/// Holds <see cref="RuntimeLeaseNames.PrimaryReplica"/> and renews it while this instance is leader.
/// <see cref="IPrimaryReplicaWorkload.IsPrimary"/> stays false until <see cref="EnablePrimaryWorkload"/> runs after successful startup bootstrap.
/// </summary>
public sealed class PrimaryReplicaGate(
IRuntimeLeaseService leaseService,
IRuntimeInstanceId runtimeInstance,
ILogger<PrimaryReplicaGate> logger
) : IPrimaryReplicaWorkload, IAsyncDisposable {
private static readonly TimeSpan PrimaryLeaseTtl = TimeSpan.FromSeconds(90);
private static readonly TimeSpan RenewInterval = TimeSpan.FromSeconds(30);
private readonly object _sync = new();
private CancellationTokenSource? _renewCts;
private Task? _renewalTask;
private string? _holderId;
private volatile bool _mayRunPrimaryWorkload;
public bool IsPrimary => _mayRunPrimaryWorkload;
/// <summary>Single attempt to insert/update the primary lease row for this holder.</summary>
public async Task<bool> TryAcquirePrimaryLeaseAsync(CancellationToken cancellationToken) {
var holder = runtimeInstance.InstanceId;
var acquired = await leaseService.TryAcquireAsync(RuntimeLeaseNames.PrimaryReplica, holder, PrimaryLeaseTtl, cancellationToken).ConfigureAwait(false);
if (!acquired.IsSuccess)
throw new InvalidOperationException(string.Join(", ", acquired.Messages ?? ["Primary lease acquire failed."]));
if (!acquired.Value)
return false;
lock (_sync) {
_holderId = holder;
_mayRunPrimaryWorkload = false;
}
return true;
}
/// <summary>After <see cref="TryAcquirePrimaryLeaseAsync"/> returned true, start renewal (call before long init).</summary>
public void StartLeaseRenewal(IHostApplicationLifetime applicationLifetime) {
lock (_sync) {
if (_holderId == null)
throw new InvalidOperationException("Cannot start renewal without an acquired primary lease.");
_renewCts?.Cancel();
_renewCts?.Dispose();
_renewCts = CancellationTokenSource.CreateLinkedTokenSource(applicationLifetime.ApplicationStopping);
var holder = _holderId;
var ct = _renewCts.Token;
_renewalTask = RenewalLoopAsync(holder, ct);
}
}
public void EnablePrimaryWorkload() => _mayRunPrimaryWorkload = true;
private async Task RenewalLoopAsync(string holderId, CancellationToken cancellationToken) {
try {
while (!cancellationToken.IsCancellationRequested) {
var renewed = await leaseService.TryAcquireAsync(RuntimeLeaseNames.PrimaryReplica, holderId, PrimaryLeaseTtl, cancellationToken).ConfigureAwait(false);
if (!renewed.IsSuccess || !renewed.Value) {
if (logger.IsEnabled(LogLevel.Warning))
logger.LogWarning("Primary replica lease was not renewed (success={Success}, acquired={Acquired}).", renewed.IsSuccess, renewed.Value);
_mayRunPrimaryWorkload = false;
return;
}
await Task.Delay(RenewInterval, cancellationToken).ConfigureAwait(false);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) {
// normal shutdown
}
catch (Exception ex) {
if (logger.IsEnabled(LogLevel.Error))
logger.LogError(ex, "Primary replica lease renewal loop failed.");
_mayRunPrimaryWorkload = false;
}
}
/// <summary>Release lease and stop renewal after failed leader bootstrap (instance stays usable for retry).</summary>
public async Task AbandonPrimaryAsync() {
_mayRunPrimaryWorkload = false;
Task? renewalToAwait;
CancellationTokenSource? cts;
string? holder;
lock (_sync) {
holder = _holderId;
_holderId = null;
cts = _renewCts;
_renewCts = null;
renewalToAwait = _renewalTask;
_renewalTask = null;
}
try {
cts?.Cancel();
if (renewalToAwait != null)
await renewalToAwait.ConfigureAwait(false);
}
catch (Exception ex) {
if (logger.IsEnabled(LogLevel.Debug))
logger.LogDebug(ex, "Primary renewal task did not end cleanly during abandon.");
}
finally {
cts?.Dispose();
}
if (holder != null) {
var released = await leaseService.ReleaseAsync(RuntimeLeaseNames.PrimaryReplica, holder, CancellationToken.None).ConfigureAwait(false);
if (!released.IsSuccess && logger.IsEnabled(LogLevel.Warning))
logger.LogWarning("Primary lease release (abandon): {Messages}", string.Join("; ", released.Messages ?? []));
}
}
public async ValueTask DisposeAsync() => await AbandonPrimaryAsync().ConfigureAwait(false);
}

View File

@ -1,10 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<Version>3.3.22</Version>
<Version>3.4.0</Version>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<DockerComposeProjectPath>..\docker-compose.dcproj</DockerComposeProjectPath>
<NoWarn>CA2254</NoWarn>

View File

@ -1,26 +0,0 @@
using MaksIT.Results;
using MaksIT.Results.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace MaksIT.CertsUI.Mvc;
/// <summary>
/// Maps ACME domain results to HTTP: primary-replica required becomes 503 + <c>Retry-After</c> + ProblemDetails.
/// </summary>
public static class CertsFlowResultExtensions {
/// <summary>Default retry hint for clients and caches (seconds).</summary>
public const int DefaultPrimaryReplicaRetryAfterSeconds = 2;
public static IActionResult ToCertsFlowActionResult(this Result result) {
if (!result.IsSuccess && PrimaryReplicaRequiredObjectResult.IsPrimaryReplicaResult(result.Messages))
return PrimaryReplicaRequiredObjectResult.FromMessages(result.Messages, DefaultPrimaryReplicaRetryAfterSeconds);
return result.ToActionResult();
}
public static IActionResult ToCertsFlowActionResult<T>(this Result<T?> result) {
if (!result.IsSuccess && PrimaryReplicaRequiredObjectResult.IsPrimaryReplicaResult(result.Messages))
return PrimaryReplicaRequiredObjectResult.FromMessages(result.Messages, DefaultPrimaryReplicaRetryAfterSeconds);
return result.ToActionResult();
}
}

View File

@ -1,39 +0,0 @@
using MaksIT.CertsUI.Engine.DomainServices;
using Microsoft.AspNetCore.Mvc;
namespace MaksIT.CertsUI.Mvc;
/// <summary>
/// HTTP 503 with <c>Retry-After</c> (delay-seconds) and RFC 7807 <see cref="ProblemDetails"/> for primary-replica routing.
/// </summary>
internal sealed class PrimaryReplicaRequiredObjectResult : ObjectResult {
public PrimaryReplicaRequiredObjectResult(ProblemDetails problemDetails, int retryAfterSeconds) : base(problemDetails) {
ArgumentOutOfRangeException.ThrowIfLessThan(retryAfterSeconds, 1);
StatusCode = StatusCodes.Status503ServiceUnavailable;
DeclaredType = typeof(ProblemDetails);
RetryAfterSeconds = retryAfterSeconds;
}
public int RetryAfterSeconds { get; }
public override Task ExecuteResultAsync(ActionContext context) {
context.HttpContext.Response.Headers.RetryAfter = RetryAfterSeconds.ToString(System.Globalization.NumberFormatInfo.InvariantInfo);
return base.ExecuteResultAsync(context);
}
internal static bool IsPrimaryReplicaResult(IReadOnlyList<string>? messages) =>
messages is { Count: > 0 } && string.Equals(messages[0], CertsFlowPrimaryReplica.DiagnosticMarker, StringComparison.Ordinal);
internal static IActionResult FromMessages(IReadOnlyList<string>? messages, int retryAfterSeconds) {
var detail = (messages is { Count: > 1 } ? messages[1] : null) ?? "Only the primary replica runs this operation.";
var pd = new ProblemDetails {
Status = StatusCodes.Status503ServiceUnavailable,
Title = "Primary replica required",
Detail = detail,
Type = CertsFlowPrimaryReplica.DiagnosticMarker,
};
pd.Extensions["retryAfterSeconds"] = retryAfterSeconds;
return new PrimaryReplicaRequiredObjectResult(pd, retryAfterSeconds);
}
}

View File

@ -67,14 +67,9 @@ builder.Services.AddOptions<JsonOptions>().Configure(o =>
builder.Services.AddScoped<JwtAuthorizationFilter>();
builder.Services.AddScoped<JwtOrApiKeyAuthorizationFilter>();
// Primary replica: one elected instance (Postgres lease) runs ACME + renewal; register shutdown last so StopAsync releases the lease first.
builder.Services.AddSingleton<PrimaryReplicaGate>();
builder.Services.AddSingleton<IPrimaryReplicaWorkload>(sp => sp.GetRequiredService<PrimaryReplicaGate>());
// Hosted services: initialization first, then autorenewal loop.
// Hosted services: coordination/bootstrap lease, then renewal sweeps (each uses short-lived Postgres leases — symmetric pods).
builder.Services.AddHostedService<InitializationHostedService>();
builder.Services.AddHostedService<AutoRenewal>();
builder.Services.AddHostedService<PrimaryReplicaShutdownHostedService>();
// PostgreSQL: prefer Configuration:CertsUIEngineConfiguration:ConnectionString in appsecrets.json; fallback ConnectionStrings:Certs for older files.
var certsConnectionString = appSettings.CertsUIEngineConfiguration.ConnectionString
@ -85,7 +80,7 @@ if (string.IsNullOrWhiteSpace(certsConnectionString))
var engineSection = appSettings.CertsUIEngineConfiguration;
// Identity / flow configuration must be registered before AddCertsEngine (engine domain services depend on pepper and paths).
// Identity / flow configuration must be registered before AddCertsEngine (engine domain services depend on pepper, etc.).
builder.Services.AddSingleton<IIdentityDomainConfiguration>(sp =>
sp.GetRequiredService<IOptions<Configuration>>().Value.CertsUIEngineConfiguration.JwtSettingsConfiguration);
builder.Services.AddSingleton<ITwoFactorSettingsConfiguration>(sp =>
@ -105,7 +100,6 @@ builder.Services.AddCertsEngine(new MaksIT.CertsUI.Engine.CertsEngineConfigurati
LetsEncryptStaging = engineSection.Staging,
});
builder.Services.AddMemoryCache();
builder.Services.AddScoped<ICacheService, CacheService>();
// Controller services
@ -140,7 +134,7 @@ builder.Services.AddHealthChecks()
var app = builder.Build();
// FluentMigrator must complete before any IHostedService starts; bootstrap lease uses app_runtime_leases.
// FluentMigrator must complete before any IHostedService starts; bootstrap uses app_runtime_leases.
await app.Services.EnsureCertsEngineMigratedAsync();
app.UseMiddleware<ErrorHandlingMiddleware>();

View File

@ -44,9 +44,7 @@
},
"Production": "https://acme-v02.api.letsencrypt.org/directory",
"Staging": "https://acme-staging-v02.api.letsencrypt.org/directory",
"AcmeFolder": "/acme",
"DataFolder": "/data"
"Staging": "https://acme-staging-v02.api.letsencrypt.org/directory"
}
}
}

View File

@ -4,5 +4,5 @@
namespace MaksIT.Models.Agent.Requests;
public class CertsUploadRequest : RequestModelBase {
public Dictionary<string, string> Certs { get; set; }
public required Dictionary<string, string> Certs { get; set; }
}

View File

@ -4,5 +4,5 @@
namespace MaksIT.Models.Agent.Requests;
public class ServiceReloadRequest : RequestModelBase {
public string ServiceName { get; set; }
public required string ServiceName { get; set; }
}

View File

@ -4,5 +4,5 @@
namespace MaksIT.Models.Agent.Responses;
public class HelloWorldResponse : ResponseModelBase {
public string Message { get; set; }
public required string Message { get; set; }
}

View File

@ -2,8 +2,6 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>

View File

@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any -- axios config bags use dynamic fields (skipLoader) */
/* eslint-disable @typescript-eslint/no-explicit-any -- axios config bags use dynamic fields (e.g. skipLoader) */
import axios from 'axios'
import { readIdentity } from './localStorage/identity'
import { ApiRoutes, GetApiRoute } from './AppMap'

View File

@ -1,17 +1,21 @@
/**
* JSON shape for `MaksIT.Results.Mvc.ProblemDetails` (RFC 7807).
*
* `Extensions` is `[JsonExtensionData]` in the library: extra members serialize as **sibling**
* properties on the same object (`traceId`, custom `id`, etc.), not under a nested `extensions` key.
*
* @see `MaksIT.Results.Mvc.ProblemDetails` in the **maksit-results** repository (same contract as the **MaksIT.Results** NuGet package).
*/
export interface ProblemDetails {
status?: number;
type?: string;
title?: string;
status?: number;
detail?: string;
instance?: string;
/** Validation errors: property name -> list of messages (ASP.NET ValidationProblemDetails) */
/** Validation failures when the API puts `errors` in extension data (ValidationProblemDetails-style). */
errors?: Record<string, string[]>;
extensions: { [key: string]: never };
/** Often emitted by ASP.NET (`traceId` in extension data). */
traceId?: string;
/** Any other extension member the server attaches (correlation id, etc.). */
[key: string]: unknown;
}
export const ProblemDetailsProto = (): ProblemDetails => ({
status: undefined,
title: undefined,
detail: undefined,
instance: undefined,
extensions: {}
})

View File

@ -11,12 +11,6 @@ builder.Services.AddReverseProxy()
var app = builder.Build();
// Configure the HTTP request pipeline.
app.UseRouting();
// Use YARP reverse proxy
app.UseEndpoints(endpoints => {
endpoints.MapReverseProxy();
});
app.MapReverseProxy();
app.Run();

View File

@ -2,8 +2,6 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>

View File

@ -33,8 +33,6 @@ services:
ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_HTTP_PORTS: "5000"
volumes:
- D:/Compose/MaksIT.CertsUI/acme:/acme
- D:/Compose/MaksIT.CertsUI/data:/data
- D:/Compose/MaksIT.CertsUI/configMap/appsettings.json:/configMap/appsettings.json:ro
- D:/Compose/MaksIT.CertsUI/secrets/appsecrets.json:/secrets/appsecrets.json:ro
networks:
@ -44,10 +42,11 @@ services:
postgres:
restart: unless-stopped
# Aligns with Helm-style local defaults: user/db/password certsui (set the same in secrets appsecrets.json ConnectionString).
environment:
POSTGRES_USER: maksit
POSTGRES_PASSWORD: maksit
POSTGRES_DB: maksit_certs
POSTGRES_USER: certsui
POSTGRES_PASSWORD: certsui
POSTGRES_DB: certsui
networks:
- maksit-certs-ui-network
volumes:
@ -55,6 +54,7 @@ services:
ports:
- "5432:5432"
# pgAdmin: mount servers.json (see repo src/postgresql/servers.json.example). Store password for user certsui in pgAdmin or use PassFile.
pgadmin:
restart: unless-stopped
environment:

View File

@ -32,9 +32,9 @@ Optional per workload under **`components.<name>`**: **`replicaCount`** (default
When **`replicaCount` > 1**, the chart creates a **PodDisruptionBudget** (`minAvailable: 1`) for that component.
**Primary replica + ACME:** With multiple **server** pods, exactly one holds the Postgres lease `certs-ui-primary` and runs ACME orchestration (`CertsFlowDomainService`, renewal). Others answer **`AcmeChallengeAsync`** from the database for HTTP-01. Interactive UI flows should hit the primary: the chart defaults **`ClientIP`** session affinity on the **server** Service, and clients should retry on **503** (see `Retry-After` / `ProblemDetails`). After unclean failover, the old lease row can linger until its TTL (~90s with defaults); renewals and clean shutdown avoid stuck primaries.
**Postgres leases (short-lived):** **`certs-ui-bootstrap`** — one pod runs coordination DDL + default admin, then releases the lease. **`certs-ui-renewal-sweep`** — one pod runs each renewal sweep, then releases. All **server** pods are **symmetric** (no elected primary in DI). Interactive ACME and **HTTP-01** use **PostgreSQL**. The **server** `Service` defaults to **no session affinity**. Set **`components.server.service.sessionAffinity.enabled: true`** only if you want **`ClientIP`** stickiness. Stale lease rows expire by TTL if a pod dies mid-section.
**Server + RWO PVCs:** the default **acme** / **data** volumes use **ReadWriteOnce**. Kubernetes will not schedule a second server pod on the same volume; for multiple server replicas you need **ReadWriteMany** (or equivalent) and an application design that tolerates shared disk (see product HA roadmap).
**Persistence:** the chart does **not** mount application data PVCs by default (ACME and identity state are in **PostgreSQL**). Add entries under **`components.server.persistence.volumes`** only if you need extra local scratch or sidecar files.
------------------------------------------------------------
## Config

View File

@ -42,8 +42,6 @@ certsServerConfig:
serviceToReload: haproxy
production: "https://acme-v02.api.letsencrypt.org/directory"
staging: "https://acme-staging-v02.api.letsencrypt.org/directory"
acmeFolder: /acme
dataFolder: /data
# Server Secret (appsecrets.json); referenced from components.server.secretsFile when tpl: true
# Configuration:CertsUIEngineConfiguration:ConnectionString — same structural role as MaksIT.Vault VaultEngineConfiguration:ConnectionString.
@ -55,13 +53,12 @@ certsServerSecrets:
certsUIEngineConfiguration:
connectionString: ""
# Client ConfigMap (config.js); referenced when tpl: true
# Client ConfigMap (config.js); referenced when tpl: true. Prefer a relative URL (/api) when UI and API share one ingress origin.
certsClientRuntime:
apiUrl: "http://certs-ui.example.com/api"
apiUrl: "/api"
components:
# Per-component replica count (minimum 1). Server uses RWO PVCs by default — use 1 unless
# your StorageClass supports ReadWriteMany and the app can share the volume (see NOTES.txt).
# Per-component replica count (minimum 1). Server is stateless for app data (PostgreSQL); scale freely.
server:
replicaCount: 1
image:
@ -83,9 +80,9 @@ components:
type: ClusterIP
port: 5000
targetPort: 5000
# ClientIP affinity helps browsers hit the same server pod for multi-step ACME (primary holds orchestration).
# Stateless default (no ClientIP). Set enabled: true only if you want sticky sessions at the Service layer.
sessionAffinity:
enabled: true
enabled: false
clientIPTimeoutSeconds: 10800
# Give kube-proxy / ingress time to stop sending new connections before SIGKILL (pairs with preStop).
terminationGracePeriodSeconds: 90
@ -95,23 +92,8 @@ components:
command: ["/bin/sh", "-c", "sleep 5"]
persistence:
storageClass: local-path
volumes:
- name: acme
mountPath: /acme
type: pvc
pvc:
create: true
keep: true
size: 50Mi
accessModes: [ReadWriteOnce]
- name: data
mountPath: /data
type: pvc
pvc:
create: true
keep: true
size: 50Mi
accessModes: [ReadWriteOnce]
# Optional extra mounts (e.g. emptyDir scratch). ACME sessions and HTTP-01 tokens use PostgreSQL, not /acme.
volumes: []
secretsFile:
key: appsecrets.json
mountPath: /secrets/appsecrets.json
@ -181,9 +163,7 @@ components:
"ServiceToReload": {{ .Values.certsServerConfig.configuration.certsUIEngineConfiguration.agent.serviceToReload | toJson }}
},
"Production": {{ .Values.certsServerConfig.configuration.certsUIEngineConfiguration.production | toJson }},
"Staging": {{ .Values.certsServerConfig.configuration.certsUIEngineConfiguration.staging | toJson }},
"AcmeFolder": {{ .Values.certsServerConfig.configuration.certsUIEngineConfiguration.acmeFolder | toJson }},
"DataFolder": {{ .Values.certsServerConfig.configuration.certsUIEngineConfiguration.dataFolder | toJson }}
"Staging": {{ .Values.certsServerConfig.configuration.certsUIEngineConfiguration.staging | toJson }}
}
}
}

View File

@ -0,0 +1,14 @@
{
"Servers": {
"1": {
"Name": "CertsUI (Compose)",
"Group": "Servers",
"Host": "postgres",
"Port": 5432,
"MaintenanceDB": "certsui",
"Username": "certsui",
"SSLMode": "prefer",
"PassFile": ""
}
}
}