diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ca3baa..9a368d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`** 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 diff --git a/README.md b/README.md index c62161f..6579e72 100644 --- a/README.md +++ b/README.md @@ -282,7 +282,7 @@ sudo tee /opt/Compose/MaksIT.CertsUI/secrets/appsecrets.json > /dev/null <", @@ -297,7 +297,7 @@ EOF ``` **Note:** -PostgreSQL is configured as **`Configuration:CertsEngineConfiguration:ConnectionString`** — same structural pattern as MaksIT.Vault’s **`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 ``, ``, ``, with secure, your environment-specific values. +PostgreSQL is configured as **`Configuration:CertsUIEngineConfiguration:ConnectionString`** — same structural pattern as MaksIT.Vault’s **`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 ``, ``, ``, with secure, your environment-specific values. Make sure `` 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 <`, `` and `` 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 ``, `` and `` 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": "", @@ -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 ``, ``, ``, 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 ``, ``, ``, with secure, your environment-specific values. Make sure `` 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 ``, `` and `` 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 ``, `` and `` 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=;Port=5432;Database=maksit_certs;Username=;Password=;SslMode=Prefer" + "ConnectionString": "Host=;Port=5432;Database=certsui;Username=certsui;Password=certsui;SslMode=Prefer" }, "Auth": { "Secret": "", @@ -698,7 +694,7 @@ kubectl create secret generic certs-ui-server-secrets \ --from-literal=appsecrets.json='{ "Configuration": { "CertsEngineConfiguration": { - "ConnectionString": "Host=;Port=5432;Database=maksit_certs;Username=;Password=;SslMode=Prefer" + "ConnectionString": "Host=;Port=5432;Database=certsui;Username=certsui;Password=certsui;SslMode=Prefer" }, "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 diff --git a/assets/docs/HA_ARCHITECTURE.md b/assets/docs/HA_ARCHITECTURE.md index 5a6eacc..df8c157 100644 --- a/assets/docs/HA_ARCHITECTURE.md +++ b/assets/docs/HA_ARCHITECTURE.md @@ -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 diff --git a/assets/docs/REVERSE_PROXY_ROUTING.md b/assets/docs/REVERSE_PROXY_ROUTING.md index 1105edd..2fadcc0 100644 --- a/assets/docs/REVERSE_PROXY_ROUTING.md +++ b/assets/docs/REVERSE_PROXY_ROUTING.md @@ -30,7 +30,7 @@ Controllers use the usual **`/api/...`** prefix (e.g. `api/identity`, account an ### HTTP-01 (Let’s 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) diff --git a/src/Directory.Build.props b/src/Directory.Build.props new file mode 100644 index 0000000..7b1030f --- /dev/null +++ b/src/Directory.Build.props @@ -0,0 +1,10 @@ + + + + enable + enable + true + latest + true + + diff --git a/src/MaksIT.CertsUI.Engine.Tests/MaksIT.CertsUI.Engine.Tests.csproj b/src/MaksIT.CertsUI.Engine.Tests/MaksIT.CertsUI.Engine.Tests.csproj index 4f87865..d32f7fa 100644 --- a/src/MaksIT.CertsUI.Engine.Tests/MaksIT.CertsUI.Engine.Tests.csproj +++ b/src/MaksIT.CertsUI.Engine.Tests/MaksIT.CertsUI.Engine.Tests.csproj @@ -2,18 +2,16 @@ net10.0 - enable - enable false MaksIT.CertsUI.Engine.Tests - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/MaksIT.CertsUI.Engine/Data/CertsLinq2DbMapping.cs b/src/MaksIT.CertsUI.Engine/Data/CertsLinq2DbMapping.cs index cafe341..7c9b47a 100644 --- a/src/MaksIT.CertsUI.Engine/Data/CertsLinq2DbMapping.cs +++ b/src/MaksIT.CertsUI.Engine/Data/CertsLinq2DbMapping.cs @@ -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() + .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; } diff --git a/src/MaksIT.CertsUI.Engine/DomainServices/CertsFlowDomainService.cs b/src/MaksIT.CertsUI.Engine/DomainServices/CertsFlowDomainService.cs index 03d9f1d..887769f 100644 --- a/src/MaksIT.CertsUI.Engine/DomainServices/CertsFlowDomainService.cs +++ b/src/MaksIT.CertsUI.Engine/DomainServices/CertsFlowDomainService.cs @@ -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 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> 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 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> ConfigureClientAsync(bool isStaging) { - if (!_primaryReplica.IsPrimary) - return Result.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(default); return Result.Ok(sessionId); } public async Task> InitAsync(Guid sessionId, Guid? accountId, string description, string[] contacts) { - if (!_primaryReplica.IsPrimary) - return Result.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(default); return Result.Ok(accountId.Value); } public async Task?>> NewOrderAsync(Guid sessionId, string[] hostnames, string challengeType) { - if (!_primaryReplica.IsPrimary) - return Result?>.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?>(_ => null); var challenges = new List(); @@ -253,15 +240,13 @@ public class CertsFlowDomainService : ICertsFlowDomainService { } public async Task 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 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?>> ApplyCertificatesAsync(Guid accountId) { - if (!_primaryReplica.IsPrimary) - return Result?>.ServiceUnavailable(null, CertsFlowPrimaryReplica.ServiceUnavailableMessages); var cacheResult = await _registrationCache.LoadAsync(accountId); if (!cacheResult.IsSuccess || cacheResult.Value?.CachedCerts == null) return cacheResult.ToResultOfType?>(_ => null); @@ -302,14 +283,12 @@ public class CertsFlowDomainService : ICertsFlowDomainService { } public async Task 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.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.Ok(fromDb.Value); - } - - var legacyPath = Path.Combine(_acmePath, fileName); - if (File.Exists(legacyPath)) { - var legacy = await File.ReadAllTextAsync(legacyPath, cancellationToken).ConfigureAwait(false); - return Result.Ok(legacy); - } return Result.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; diff --git a/src/MaksIT.CertsUI.Engine/DomainServices/CertsFlowPrimaryReplica.cs b/src/MaksIT.CertsUI.Engine/DomainServices/CertsFlowPrimaryReplica.cs deleted file mode 100644 index 3196c98..0000000 --- a/src/MaksIT.CertsUI.Engine/DomainServices/CertsFlowPrimaryReplica.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace MaksIT.CertsUI.Engine.DomainServices; - -/// -/// Stable markers for Result.ServiceUnavailable when ACME is invoked on a non-primary replica. -/// The host maps these to HTTP 503 + Retry-After + RFC 7807 ProblemDetails. -/// -public static class CertsFlowPrimaryReplica { - - /// Machine-readable first line in result messages for detection in MVC. - 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." - ]; -} diff --git a/src/MaksIT.CertsUI.Engine/DomainServices/ICertsFlowEngineConfiguration.cs b/src/MaksIT.CertsUI.Engine/DomainServices/ICertsFlowEngineConfiguration.cs index 7d54146..e16686a 100644 --- a/src/MaksIT.CertsUI.Engine/DomainServices/ICertsFlowEngineConfiguration.cs +++ b/src/MaksIT.CertsUI.Engine/DomainServices/ICertsFlowEngineConfiguration.cs @@ -1,10 +1,8 @@ namespace MaksIT.CertsUI.Engine.DomainServices; /// -/// 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. /// public interface ICertsFlowEngineConfiguration { - string AcmeFolder { get; } - string DataFolder { get; } string AgentServiceToReload { get; } } diff --git a/src/MaksIT.CertsUI.Engine/Dto/Certs/AcmeSessionDto.cs b/src/MaksIT.CertsUI.Engine/Dto/Certs/AcmeSessionDto.cs new file mode 100644 index 0000000..10dac17 --- /dev/null +++ b/src/MaksIT.CertsUI.Engine/Dto/Certs/AcmeSessionDto.cs @@ -0,0 +1,9 @@ +namespace MaksIT.CertsUI.Engine.Dto.Certs; + +/// PostgreSQL acme_sessions: shared ACME flow state keyed by browser session id (survives HA / any replica). +public sealed class AcmeSessionDto { + public Guid SessionId { get; set; } + public string PayloadJson { get; set; } = "{}"; + public DateTimeOffset UpdatedAtUtc { get; set; } + public DateTimeOffset ExpiresAtUtc { get; set; } +} diff --git a/src/MaksIT.CertsUI.Engine/Extensions/ServiceCollectionExtensions.cs b/src/MaksIT.CertsUI.Engine/Extensions/ServiceCollectionExtensions.cs index cfbcd6a..faae9f6 100644 --- a/src/MaksIT.CertsUI.Engine/Extensions/ServiceCollectionExtensions.cs +++ b/src/MaksIT.CertsUI.Engine/Extensions/ServiceCollectionExtensions.cs @@ -64,7 +64,7 @@ public static class ServiceCollectionExtensions { #endregion #region ACME / Let's Encrypt - services.AddSingleton(); + services.AddSingleton(); services.AddHttpClient(); #endregion } diff --git a/src/MaksIT.CertsUI.Engine/FluentMigrations/20260427203000_AcmeSessions.cs b/src/MaksIT.CertsUI.Engine/FluentMigrations/20260427203000_AcmeSessions.cs new file mode 100644 index 0000000..66848df --- /dev/null +++ b/src/MaksIT.CertsUI.Engine/FluentMigrations/20260427203000_AcmeSessions.cs @@ -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"); + } +} diff --git a/src/MaksIT.CertsUI.Engine/Infrastructure/CoordinationTableProvisioner.cs b/src/MaksIT.CertsUI.Engine/Infrastructure/CoordinationTableProvisioner.cs index 4cccf64..69b42f1 100644 --- a/src/MaksIT.CertsUI.Engine/Infrastructure/CoordinationTableProvisioner.cs +++ b/src/MaksIT.CertsUI.Engine/Infrastructure/CoordinationTableProvisioner.cs @@ -8,7 +8,7 @@ namespace MaksIT.CertsUI.Engine.Infrastructure; /// public static class CoordinationTableProvisioner { - /// Creates public.acme_http_challenges and public.app_runtime_leases if missing. + /// Creates public.acme_http_challenges, public.app_runtime_leases, and public.acme_sessions if missing. 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); diff --git a/src/MaksIT.CertsUI.Engine/Infrastructure/SchemaSyncService.cs b/src/MaksIT.CertsUI.Engine/Infrastructure/SchemaSyncService.cs index 88e9a7e..ae9ea14 100644 --- a/src/MaksIT.CertsUI.Engine/Infrastructure/SchemaSyncService.cs +++ b/src/MaksIT.CertsUI.Engine/Infrastructure/SchemaSyncService.cs @@ -69,6 +69,12 @@ public class SchemaSyncService(ICertsEngineConfiguration config, ILogger net10.0 - enable - enable CA2254;NU1903;NU1904 - true @@ -14,20 +11,18 @@ - - - - - - - - + + + + + + diff --git a/src/MaksIT.CertsUI.Engine/Persistance/Services/Linq2Db/TermsOfServiceCachePersistenceServiceLinq2Db.cs b/src/MaksIT.CertsUI.Engine/Persistance/Services/Linq2Db/TermsOfServiceCachePersistenceServiceLinq2Db.cs index 2fc1e08..f5a3c92 100644 --- a/src/MaksIT.CertsUI.Engine/Persistance/Services/Linq2Db/TermsOfServiceCachePersistenceServiceLinq2Db.cs +++ b/src/MaksIT.CertsUI.Engine/Persistance/Services/Linq2Db/TermsOfServiceCachePersistenceServiceLinq2Db.cs @@ -39,22 +39,27 @@ public sealed class TermsOfServiceCachePersistenceServiceLinq2Db( try { using var db = connectionFactory.Create(); - var existing = db.GetTable().FirstOrDefault(x => x.Url == cacheEntry.Url); - if (existing == null) { - db.Insert(cacheEntry); - } - else { - db.GetTable() - .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().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()); } diff --git a/src/MaksIT.CertsUI.Engine/RuntimeCoordination/IPrimaryReplicaWorkload.cs b/src/MaksIT.CertsUI.Engine/RuntimeCoordination/IPrimaryReplicaWorkload.cs deleted file mode 100644 index 30f8761..0000000 --- a/src/MaksIT.CertsUI.Engine/RuntimeCoordination/IPrimaryReplicaWorkload.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace MaksIT.CertsUI.Engine.RuntimeCoordination; - -/// -/// True when this process is the elected primary replica (Postgres lease) and may run ACME orchestration and background renewal. -/// -public interface IPrimaryReplicaWorkload { - bool IsPrimary { get; } -} diff --git a/src/MaksIT.CertsUI.Engine/RuntimeCoordination/RuntimeLeaseNames.cs b/src/MaksIT.CertsUI.Engine/RuntimeCoordination/RuntimeLeaseNames.cs index b1cbba4..1e7176b 100644 --- a/src/MaksIT.CertsUI.Engine/RuntimeCoordination/RuntimeLeaseNames.cs +++ b/src/MaksIT.CertsUI.Engine/RuntimeCoordination/RuntimeLeaseNames.cs @@ -4,6 +4,9 @@ namespace MaksIT.CertsUI.Engine.RuntimeCoordination; public static class RuntimeLeaseNames { public const string AcmeWriter = "certs-ui-acme-writer"; - /// Single elected instance: identity bootstrap, ACME orchestration, and background renewal. - public const string PrimaryReplica = "certs-ui-primary"; + /// Held only for coordination DDL + optional default-admin bootstrap; released when done (no renewal loop). + public const string BootstrapCoordinator = "certs-ui-bootstrap"; + + /// Held for one renewal sweep (purge + account passes); released after each sweep so any pod may run the next. + public const string RenewalSweep = "certs-ui-renewal-sweep"; } diff --git a/src/MaksIT.CertsUI.Engine/Services/AcmePostgresSessionStore.cs b/src/MaksIT.CertsUI.Engine/Services/AcmePostgresSessionStore.cs new file mode 100644 index 0000000..e509c89 --- /dev/null +++ b/src/MaksIT.CertsUI.Engine/Services/AcmePostgresSessionStore.cs @@ -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; + +/// PostgreSQL-backed ACME session state (replaces in-process IMemoryCache). +public sealed class AcmePostgresSessionStore( + ICertsEngineConfiguration config, + ILogger 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 LoadOrCreateAsync(Guid sessionId, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); + using var db = CreateConnection(); + var now = DateTimeOffset.UtcNow; + var row = db.GetTable() + .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() + .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; + } +} diff --git a/src/MaksIT.CertsUI.Engine/Services/AcmeSessionJsonSerializer.cs b/src/MaksIT.CertsUI.Engine/Services/AcmeSessionJsonSerializer.cs new file mode 100644 index 0000000..e6ecca7 --- /dev/null +++ b/src/MaksIT.CertsUI.Engine/Services/AcmeSessionJsonSerializer.cs @@ -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(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; + } +} diff --git a/src/MaksIT.CertsUI.Engine/Services/AcmeSessionSnapshot.cs b/src/MaksIT.CertsUI.Engine/Services/AcmeSessionSnapshot.cs new file mode 100644 index 0000000..73b220c --- /dev/null +++ b/src/MaksIT.CertsUI.Engine/Services/AcmeSessionSnapshot.cs @@ -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; + +/// JSON-serializable projection of for acme_sessions.payload_json. +internal sealed class AcmeSessionSnapshot { + public bool IsStaging { get; set; } + public AcmeDirectory? Directory { get; set; } + public Order? CurrentOrder { get; set; } + public List Challenges { get; set; } = []; + public RegistrationCache? Cache { get; set; } + public Jwk? Jwk { get; set; } + /// RSA account key as CSP blob when present (same encoding as ). + public byte[]? AccountKeyCspBlob { get; set; } +} diff --git a/src/MaksIT.CertsUI.Engine/Services/AcmeSessionStore.cs b/src/MaksIT.CertsUI.Engine/Services/AcmeSessionStore.cs deleted file mode 100644 index 1273652..0000000 --- a/src/MaksIT.CertsUI.Engine/Services/AcmeSessionStore.cs +++ /dev/null @@ -1,23 +0,0 @@ -using MaksIT.CertsUI.Engine.Domain.LetsEncrypt; -using Microsoft.Extensions.Caching.Memory; - -namespace MaksIT.CertsUI.Engine.Services; - -/// -/// In-memory cache of per-session for ACME flows (directory, account, current order, challenges). -/// -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; - } -} diff --git a/src/MaksIT.CertsUI.Engine/Services/IAcmeSessionStore.cs b/src/MaksIT.CertsUI.Engine/Services/IAcmeSessionStore.cs new file mode 100644 index 0000000..67db18d --- /dev/null +++ b/src/MaksIT.CertsUI.Engine/Services/IAcmeSessionStore.cs @@ -0,0 +1,9 @@ +using MaksIT.CertsUI.Engine.Domain.LetsEncrypt; + +namespace MaksIT.CertsUI.Engine.Services; + +/// Loads and persists per-browser ACME so any replica can continue the flow. +public interface IAcmeSessionStore { + Task LoadOrCreateAsync(Guid sessionId, CancellationToken cancellationToken = default); + Task PersistAsync(Guid sessionId, State state, CancellationToken cancellationToken = default); +} diff --git a/src/MaksIT.CertsUI.Engine/Services/LetsEncryptService.Helpers.cs b/src/MaksIT.CertsUI.Engine/Services/LetsEncryptService.Helpers.cs index 304fe05..29f779c 100644 --- a/src/MaksIT.CertsUI.Engine/Services/LetsEncryptService.Helpers.cs +++ b/src/MaksIT.CertsUI.Engine/Services/LetsEncryptService.Helpers.cs @@ -16,14 +16,37 @@ public partial class LetsEncryptService { #region Internal helpers - private State GetOrCreateState(Guid sessionId) => _sessions.GetOrCreate(sessionId); + private async Task WithPersistedSessionAsync( + Guid sessionId, + CancellationToken cancellationToken, + Func> 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> GetNonceAsync(Guid sessionId, Uri uri) { + private async Task> WithPersistedSessionAsync( + Guid sessionId, + CancellationToken cancellationToken, + Func>> 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> GetNonceAsync(State state, Uri uri) { if (uri == null) return Result.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 EncodeMessage(Guid sessionId, bool isPostAsGet, object? requestModel, ACMEJwsHeader protectedHeader) { - var state = GetOrCreateState(sessionId); - + private Result EncodeMessage(State state, bool isPostAsGet, object? requestModel, ACMEJwsHeader protectedHeader) { if (!state.TryGetAccountKey(out var rsa, out var jwk)) return Result.InternalServerError(AccountKeyMissingMessage); @@ -94,7 +115,7 @@ public partial class LetsEncryptService { request.Content.Headers.ContentType = new MediaTypeHeaderValue(contentType); } - private async Task PollChallengeStatus(Guid sessionId, AuthorizationChallengeChallenge challenge) { + private async Task 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 }); diff --git a/src/MaksIT.CertsUI.Engine/Services/LetsEncryptService.cs b/src/MaksIT.CertsUI.Engine/Services/LetsEncryptService.cs index 5621fe7..bdd28cc 100644 --- a/src/MaksIT.CertsUI.Engine/Services/LetsEncryptService.cs +++ b/src/MaksIT.CertsUI.Engine/Services/LetsEncryptService.cs @@ -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 GetRegistrationCache(Guid sessionId); - Task ConfigureClient(Guid sessionId, bool isStaging); - Task Init(Guid sessionId, Guid accountId, string description, string[] contacts, RegistrationCache? registrationCache); - Result GetTermsOfServiceUri(Guid sessionId); - Task?>> NewOrder(Guid sessionId, string[] hostnames, string challengeType); - Task CompleteChallenges(Guid sessionId); - Task GetOrder(Guid sessionId, string[] hostnames); - Task GetCertificate(Guid sessionId, string subject); - Task RevokeCertificate(Guid sessionId, string subject, RevokeReason reason); + Task> GetRegistrationCacheAsync(Guid sessionId, CancellationToken cancellationToken = default); + Task ConfigureClient(Guid sessionId, bool isStaging, CancellationToken cancellationToken = default); + Task Init(Guid sessionId, Guid accountId, string description, string[] contacts, RegistrationCache? registrationCache, CancellationToken cancellationToken = default); + Task> GetTermsOfServiceUriAsync(Guid sessionId, CancellationToken cancellationToken = default); + Task?>> NewOrder(Guid sessionId, string[] hostnames, string challengeType, CancellationToken cancellationToken = default); + Task CompleteChallenges(Guid sessionId, CancellationToken cancellationToken = default); + Task GetOrder(Guid sessionId, string[] hostnames, CancellationToken cancellationToken = default); + Task GetCertificate(Guid sessionId, string subject, CancellationToken cancellationToken = default); + Task 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 _logger; private readonly ICertsEngineConfiguration _engineConfiguration; private readonly HttpClient _httpClient; - private readonly AcmeSessionStore _sessions; + private readonly IAcmeSessionStore _sessionStore; public LetsEncryptService( ILogger logger, ICertsEngineConfiguration engineConfiguration, HttpClient httpClient, - AcmeSessionStore sessions + IAcmeSessionStore sessionStore ) { _logger = logger; _engineConfiguration = engineConfiguration; _httpClient = httpClient; - _sessions = sessions; + _sessionStore = sessionStore; } - public Result GetRegistrationCache(Guid sessionId) { - var state = GetOrCreateState(sessionId); - - if (state.Cache == null) - return Result.InternalServerError(null); - - return Result.Ok(state.Cache); - } + public Task> GetRegistrationCacheAsync(Guid sessionId, CancellationToken cancellationToken = default) => + WithPersistedSessionAsync(sessionId, cancellationToken, async state => { + if (state.Cache == null) + return Result.InternalServerError(null); + return Result.Ok(state.Cache); + }); #region ConfigureClient - public async Task ConfigureClient(Guid sessionId, bool isStaging) { - try { - var state = GetOrCreateState(sessionId); + public Task 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(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(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 Init(Guid sessionId, Guid accountId, string description, string[] contacts, RegistrationCache? cache) { + public Task 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 GetTermsOfServiceUri(Guid sessionId) { - try { - var state = GetOrCreateState(sessionId); + public Task> GetTermsOfServiceUriAsync(Guid sessionId, CancellationToken cancellationToken = default) => + WithPersistedSessionAsync(sessionId, cancellationToken, async state => { + try { + _logger.LogInformation($"Executing {nameof(GetTermsOfServiceUriAsync)}..."); - _logger.LogInformation($"Executing {nameof(GetTermsOfServiceUri)}..."); + if (state.Directory?.Meta?.TermsOfService == null) { + return Result.Ok(null); + } - if (state.Directory?.Meta?.TermsOfService == null) { - return Result.Ok(null); + return Result.Ok(state.Directory.Meta.TermsOfService); } - - return Result.Ok(state.Directory.Meta.TermsOfService); - } - catch (Exception ex) { - return HandleUnhandledException(ex); - } - } + catch (Exception ex) { + return HandleUnhandledException(ex); + } + }); #endregion #region NewOrder - public async Task?>> NewOrder(Guid sessionId, string[] hostnames, string challengeType) { - try { - var state = GetOrCreateState(sessionId); + public Task?>> NewOrder(Guid sessionId, string[] hostnames, string challengeType, CancellationToken cancellationToken = default) => + WithPersistedSessionAsync?>(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?>.InternalServerError(null); - if (state.Directory?.NewOrder is not { } newOrderUri) - return Result?>.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?>(_ => 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?>(_ => null); - - var json = jsonResult.Value; - - PrepareRequestContent(request, json, HttpMethod.Post); - - var requestResult = await SendAcmeRequest(request, state, HttpMethod.Post); - if (!requestResult.IsSuccess || requestResult.Value == null) - return requestResult.ToResultOfType?>(_ => null); - - var order = requestResult.Value; - - if (StatusEquals(order.Result?.Status, OrderStatus.Ready)) - return Result?>.Ok(new Dictionary()); - - 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?>.InternalServerError(null); - } - - state.CurrentOrder = order.Result; - - var results = new Dictionary(); - - foreach (var item in state.CurrentOrder?.Authorizations ?? Array.Empty()) { - 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?>(_ => 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?>(_ => null); - json = jsonResult.Value; - - PrepareRequestContent(request, json, HttpMethod.Post); - - var challengeResult = await SendAcmeRequest(request, state, HttpMethod.Post); - if (!challengeResult.IsSuccess || challengeResult.Value == null) - return challengeResult.ToResultOfType?>(_ => 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?>.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?>.InternalServerError(null); - } - - state.Challenges.Add(challenge); - - if (state.Cache != null) - state.Cache.ChallengeType = challengeType; - - if (state.Jwk is null) - return Result?>.InternalServerError(null, AccountKeyMissingMessage); - - if (!JwkThumbprintUtility.TryGetKeyAuthorization(state.Jwk, challenge.Token, out var keyToken, out var errorMessage)) - return Result?>.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?>.Ok(results); - } - catch (Exception ex) { - return HandleUnhandledException?>(ex); - } - } - #endregion - - #region CompleteChallenges - public async Task 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(request, state, HttpMethod.Post); + var requestResult = await SendAcmeRequest(request, state, HttpMethod.Post); + if (!requestResult.IsSuccess || requestResult.Value == null) + return requestResult.ToResultOfType?>(_ => 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?>.Ok(new Dictionary()); + + 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?>.InternalServerError(null); + } + + state.CurrentOrder = order.Result; + + var results = new Dictionary(); + + foreach (var item in state.CurrentOrder?.Authorizations ?? Array.Empty()) { + if (item == null) + continue; + + request = new HttpRequestMessage(HttpMethod.Post, item); + + nonceResult = await GetNonceAsync(state, item); + if (!nonceResult.IsSuccess || nonceResult.Value == null) + return nonceResult.ToResultOfType?>(_ => 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?>(_ => null); + + json = jsonResult.Value; + + PrepareRequestContent(request, json, HttpMethod.Post); + + var challengeResult = await SendAcmeRequest(request, state, HttpMethod.Post); + if (!challengeResult.IsSuccess || challengeResult.Value == null) + return challengeResult.ToResultOfType?>(_ => 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?>.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?>.InternalServerError(null); + } + + state.Challenges.Add(challenge); + + if (state.Cache != null) + state.Cache.ChallengeType = challengeType; + + if (state.Jwk is null) + return Result?>.InternalServerError(null, AccountKeyMissingMessage); + + if (!JwkThumbprintUtility.TryGetKeyAuthorization(state.Jwk, challenge.Token, out var keyToken, out var errorMessage)) + return Result?>.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?>.Ok(results); } - return Result.Ok(); - } - catch (LetsEncrytException ex) { - return MapLetsEncryptException(GetOrCreateState(sessionId), ex); - } - catch (Exception ex) { - return HandleUnhandledException(ex); - } - } + catch (Exception ex) { + return HandleUnhandledException?>(ex); + } + }); + #endregion + + #region CompleteChallenges + public Task 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(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 GetOrder(Guid sessionId, string[] hostnames) { + public Task GetOrder(Guid sessionId, string[] hostnames, CancellationToken cancellationToken = default) => + WithPersistedSessionAsync(sessionId, cancellationToken, state => GetOrderCoreAsync(state, hostnames)); + + private async Task 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 GetCertificate(Guid sessionId, string subject) { - try { - var state = GetOrCreateState(sessionId); - + public Task 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().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 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 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}'."); + } } diff --git a/src/MaksIT.CertsUI.Engine/Table.cs b/src/MaksIT.CertsUI.Engine/Table.cs index 6cecccc..d9aef44 100644 --- a/src/MaksIT.CertsUI.Engine/Table.cs +++ b/src/MaksIT.CertsUI.Engine/Table.cs @@ -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 } diff --git a/src/MaksIT.CertsUI.Tests/Infrastructure/PostgresCacheFixture.cs b/src/MaksIT.CertsUI.Tests/Infrastructure/PostgresCacheFixture.cs index ffec2b6..1ecc1d4 100644 --- a/src/MaksIT.CertsUI.Tests/Infrastructure/PostgresCacheFixture.cs +++ b/src/MaksIT.CertsUI.Tests/Infrastructure/PostgresCacheFixture.cs @@ -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(); diff --git a/src/MaksIT.CertsUI.Tests/Infrastructure/WebApiTestFixture.cs b/src/MaksIT.CertsUI.Tests/Infrastructure/WebApiTestFixture.cs index 309c555..e6d6e06 100644 --- a/src/MaksIT.CertsUI.Tests/Infrastructure/WebApiTestFixture.cs +++ b/src/MaksIT.CertsUI.Tests/Infrastructure/WebApiTestFixture.cs @@ -4,23 +4,14 @@ using Microsoft.Extensions.Options; namespace MaksIT.CertsUI.Tests.Infrastructure; /// -/// Creates a disposable temp workspace and with valid auth and paths. +/// Creates with valid auth and agent settings for API/domain tests. /// public sealed class WebApiTestFixture : IDisposable { - public string Root { get; } public IOptions 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() { } } diff --git a/src/MaksIT.CertsUI.Tests/MaksIT.CertsUI.Tests.csproj b/src/MaksIT.CertsUI.Tests/MaksIT.CertsUI.Tests.csproj index 31eeebb..a4af208 100644 --- a/src/MaksIT.CertsUI.Tests/MaksIT.CertsUI.Tests.csproj +++ b/src/MaksIT.CertsUI.Tests/MaksIT.CertsUI.Tests.csproj @@ -2,21 +2,19 @@ net10.0 - enable - enable false true - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + - + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/MaksIT.CertsUI.Tests/Services/CertsFlowServiceTests.cs b/src/MaksIT.CertsUI.Tests/Services/CertsFlowServiceTests.cs index 066f0a2..15aacf4 100644 --- a/src/MaksIT.CertsUI.Tests/Services/CertsFlowServiceTests.cs +++ b/src/MaksIT.CertsUI.Tests/Services/CertsFlowServiceTests.cs @@ -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? httpChallenges = null, Mock? runtimeLease = null, Mock? runtimeInstance = null, - HttpMessageHandler? httpHandler = null, - Mock? primaryReplica = null) + HttpMessageHandler? httpHandler = null) { registrationCache ??= new Mock(); agent ??= new Mock(); @@ -66,9 +63,6 @@ public sealed class CertsFlowServiceTests runtimeInstance ??= new Mock(); if (!runtimeInstanceProvided) runtimeInstance.Setup(i => i.InstanceId).Returns("test-instance"); - var primaryWorkload = primaryReplica ?? new Mock(); - 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(); - le.Setup(x => x.ConfigureClient(It.IsAny(), false)) + le.Setup(x => x.ConfigureClient(It.IsAny(), false, It.IsAny())) .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(); - var primary = new Mock(); - 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(), It.IsAny()), Times.Never); - } - - [Fact] - public async Task AcmeChallenge_WhenNotPrimary_StillSucceedsFromDatabase() - { - using var fx = new WebApiTestFixture(); - var name = "challenge-token"; - var le = new Mock(); - var primary = new Mock(); - primary.Setup(p => p.IsPrimary).Returns(false); - var challenges = new Mock(); - challenges.Setup(c => c.GetTokenValueAsync(name, It.IsAny())) - .ReturnsAsync(Result.Ok("body")); - challenges.Setup(c => c.UpsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(Result.Ok()); - challenges.Setup(c => c.DeleteOlderThanAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(Result.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(); - le.Setup(x => x.ConfigureClient(It.IsAny(), It.IsAny())) + le.Setup(x => x.ConfigureClient(It.IsAny(), It.IsAny(), It.IsAny())) .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(); - le.Setup(x => x.Init(sessionId, It.IsAny(), "d", It.Is(c => c.Length == 1 && c[0] == "mailto:a@b"), null)) + le.Setup(x => x.Init(sessionId, It.IsAny(), "d", It.Is(c => c.Length == 1 && c[0] == "mailto:a@b"), null, It.IsAny())) .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(), "d", It.IsAny(), null), Times.Once); + le.Verify(x => x.Init(sessionId, It.IsAny(), "d", It.IsAny(), null, It.IsAny()), Times.Once); } [Fact] @@ -184,7 +138,7 @@ public sealed class CertsFlowServiceTests .ReturnsAsync(Result.InternalServerError(null, "missing")); var le = new Mock(); - le.Setup(x => x.Init(sessionId, It.IsAny(), "d", It.IsAny(), null)) + le.Setup(x => x.Init(sessionId, It.IsAny(), "d", It.IsAny(), null, It.IsAny())) .ReturnsAsync(Result.Ok()); var sut = CreateSut(fx, le, cache); @@ -214,7 +168,7 @@ public sealed class CertsFlowServiceTests .ReturnsAsync(Result.Ok(reg)); var le = new Mock(); - le.Setup(x => x.Init(sessionId, accountId, "d", It.IsAny(), reg)) + le.Setup(x => x.Init(sessionId, accountId, "d", It.IsAny(), reg, It.IsAny())) .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(); - le.Setup(x => x.NewOrder(sessionId, It.IsAny(), "http-01")) + le.Setup(x => x.NewOrder(sessionId, It.IsAny(), "http-01", It.IsAny())) .ReturnsAsync(Result?>.Ok(new Dictionary { ["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(), It.IsAny(), It.IsAny()), Times.Never); + le.Verify(x => x.NewOrder(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); runtimeLease.Verify(l => l.ReleaseAsync(It.IsAny(), It.IsAny(), It.IsAny()), 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(), It.IsAny(), It.IsAny()), Times.Never); + le.Verify(x => x.NewOrder(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); runtimeLease.Verify(l => l.ReleaseAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } @@ -302,7 +256,7 @@ public sealed class CertsFlowServiceTests using var fx = new WebApiTestFixture(); var sessionId = Guid.NewGuid(); var le = new Mock(); - le.Setup(x => x.NewOrder(sessionId, It.IsAny(), "http-01")) + le.Setup(x => x.NewOrder(sessionId, It.IsAny(), "http-01", It.IsAny())) .ReturnsAsync(Result?>.InternalServerError(null, "acme failed")); var runtimeLease = new Mock(); runtimeLease.Setup(l => l.TryAcquireAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) @@ -324,8 +278,8 @@ public sealed class CertsFlowServiceTests using var fx = new WebApiTestFixture(); var sessionId = Guid.NewGuid(); var le = new Mock(); - le.Setup(x => x.GetTermsOfServiceUri(sessionId)) - .Returns(Result.InternalServerError(null, "no uri")); + le.Setup(x => x.GetTermsOfServiceUriAsync(sessionId, It.IsAny())) + .ReturnsAsync(Result.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(); - le.Setup(x => x.GetTermsOfServiceUri(sessionId)) - .Returns(Result.Ok(url)); + le.Setup(x => x.GetTermsOfServiceUriAsync(sessionId, It.IsAny())) + .ReturnsAsync(Result.Ok(url)); var tosCache = new Mock(); tosCache.Setup(c => c.GetByUrlAsync(url, It.IsAny())) @@ -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(); - le.Setup(x => x.CompleteChallenges(sessionId)) + le.Setup(x => x.CompleteChallenges(sessionId, It.IsAny())) .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()), Times.Once); } [Fact] @@ -524,7 +475,7 @@ public sealed class CertsFlowServiceTests using var fx = new WebApiTestFixture(); var sessionId = Guid.NewGuid(); var le = new Mock(); - le.Setup(x => x.GetOrder(sessionId, It.IsAny())) + le.Setup(x => x.GetOrder(sessionId, It.IsAny(), It.IsAny())) .ReturnsAsync(Result.Ok()); var sut = CreateSut(fx, le); diff --git a/src/MaksIT.CertsUI/Configuration.cs b/src/MaksIT.CertsUI/Configuration.cs index 2372957..cd872a0 100644 --- a/src/MaksIT.CertsUI/Configuration.cs +++ b/src/MaksIT.CertsUI/Configuration.cs @@ -86,14 +86,5 @@ public class CertsUIEngineConfiguration : ICertsFlowEngineConfiguration { public required string Staging { get; set; } - public required string AcmeFolder { get; set; } - - /// Writable directory for ACME subscriber agreement PDFs and init marker. - public required string DataFolder { get; set; } - - string ICertsFlowEngineConfiguration.AcmeFolder => AcmeFolder; - - string ICertsFlowEngineConfiguration.DataFolder => DataFolder; - string ICertsFlowEngineConfiguration.AgentServiceToReload => Agent.ServiceToReload; } diff --git a/src/MaksIT.CertsUI/Controllers/CertsFlowController.cs b/src/MaksIT.CertsUI/Controllers/CertsFlowController.cs index 030c9d0..26ec87d 100644 --- a/src/MaksIT.CertsUI/Controllers/CertsFlowController.cs +++ b/src/MaksIT.CertsUI/Controllers/CertsFlowController.cs @@ -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 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 TermsOfService(Guid sessionId) { var result = await _certsFlowService.GetTermsOfServiceAsync(sessionId); - return result.ToCertsFlowActionResult(); + return result.ToActionResult(); } [HttpPost("{sessionId}/init/{accountId?}")] public async Task 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 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 CompleteChallenges(Guid sessionId) { var result = await _certsFlowService.CompleteChallengesAsync(sessionId); - return result.ToCertsFlowActionResult(); + return result.ToActionResult(); } [HttpGet("{sessionId}/order-status")] public async Task 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 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 ApplyCertificates(Guid accountId) { var result = await _certsFlowService.ApplyCertificatesAsync(accountId); - return result.ToCertsFlowActionResult(); + return result.ToActionResult(); } [HttpPost("{sessionId}/certificates/revoke")] public async Task RevokeCertificates(Guid sessionId, [FromBody] RevokeCertificatesRequest requestData) { var result = await _certsFlowService.RevokeCertificatesAsync(sessionId, requestData.Hostnames); - return result.ToCertsFlowActionResult(); + return result.ToActionResult(); } } } diff --git a/src/MaksIT.CertsUI/HostedServices/AutoRenewal.cs b/src/MaksIT.CertsUI/HostedServices/AutoRenewal.cs index 1217929..ddde2e2 100644 --- a/src/MaksIT.CertsUI/HostedServices/AutoRenewal.cs +++ b/src/MaksIT.CertsUI/HostedServices/AutoRenewal.cs @@ -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 _appSettings; - private readonly ILogger _logger; - private readonly IServiceScopeFactory _scopeFactory; - private readonly IPrimaryReplicaWorkload _primaryReplica; +/// Certificate renewal: each sweep acquires so only one pod runs ACME renewal at a time (symmetric replicas, no elected primary). +public sealed class AutoRenewal( + ILogger 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 appSettings, - ILogger 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(); var certsFlowService = scope.ServiceProvider.GetRequiredService(); var httpChallenges = scope.ServiceProvider.GetRequiredService(); - 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 ProcessAccountAsync(ICertsFlowService certsFlowService, RegistrationCache cache) { - - var hosts = cache.GetHosts(); - var toRenew = new List(); - - 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(); - 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 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(); + + 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(); + 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? 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); + } } diff --git a/src/MaksIT.CertsUI/HostedServices/InitializationHostedService.cs b/src/MaksIT.CertsUI/HostedServices/InitializationHostedService.cs index 6096c56..2a46866 100644 --- a/src/MaksIT.CertsUI/HostedServices/InitializationHostedService.cs +++ b/src/MaksIT.CertsUI/HostedServices/InitializationHostedService.cs @@ -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; /// -/// Exactly one instance holds and runs coordination DDL plus identity bootstrap. -/// Other instances wait until the database (and optional shared init marker under ) shows bootstrap complete, then start without ACME privileges. +/// Uses a short-lived Postgres lease () so exactly one pod runs +/// coordination DDL + default admin creation; other pods wait until users exist. No long-lived leader role. /// public sealed class InitializationHostedService( ILogger logger, IServiceProvider serviceProvider, - IOptions 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(); - 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(); await CoordinationTableProvisioner.EnsureAsync(engineConfig.ConnectionString, cancellationToken).ConfigureAwait(false); await using var scope = serviceProvider.CreateAsyncScope(); var identityDomainService = scope.ServiceProvider.GetRequiredService(); - 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(); - 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 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; } } diff --git a/src/MaksIT.CertsUI/HostedServices/PrimaryReplicaShutdownHostedService.cs b/src/MaksIT.CertsUI/HostedServices/PrimaryReplicaShutdownHostedService.cs deleted file mode 100644 index e9ae4f6..0000000 --- a/src/MaksIT.CertsUI/HostedServices/PrimaryReplicaShutdownHostedService.cs +++ /dev/null @@ -1,14 +0,0 @@ -using MaksIT.CertsUI.Infrastructure; - -namespace MaksIT.CertsUI.HostedServices; - -/// -/// Registered last so runs first on shutdown: releases the primary Postgres lease and stops renewal. -/// -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); -} diff --git a/src/MaksIT.CertsUI/Infrastructure/PrimaryReplicaGate.cs b/src/MaksIT.CertsUI/Infrastructure/PrimaryReplicaGate.cs deleted file mode 100644 index 757832e..0000000 --- a/src/MaksIT.CertsUI/Infrastructure/PrimaryReplicaGate.cs +++ /dev/null @@ -1,121 +0,0 @@ -using Microsoft.Extensions.Hosting; -using MaksIT.CertsUI.Engine.Infrastructure; -using MaksIT.CertsUI.Engine.RuntimeCoordination; - -namespace MaksIT.CertsUI.Infrastructure; - -/// -/// Holds and renews it while this instance is leader. -/// stays false until runs after successful startup bootstrap. -/// -public sealed class PrimaryReplicaGate( - IRuntimeLeaseService leaseService, - IRuntimeInstanceId runtimeInstance, - ILogger 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; - - /// Single attempt to insert/update the primary lease row for this holder. - public async Task 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; - } - - /// After returned true, start renewal (call before long init). - 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; - } - } - - /// Release lease and stop renewal after failed leader bootstrap (instance stays usable for retry). - 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); -} diff --git a/src/MaksIT.CertsUI/MaksIT.CertsUI.csproj b/src/MaksIT.CertsUI/MaksIT.CertsUI.csproj index 1a90ea4..9bd8d90 100644 --- a/src/MaksIT.CertsUI/MaksIT.CertsUI.csproj +++ b/src/MaksIT.CertsUI/MaksIT.CertsUI.csproj @@ -1,10 +1,8 @@ - 3.3.22 + 3.4.0 net10.0 - enable - enable Linux ..\docker-compose.dcproj CA2254 diff --git a/src/MaksIT.CertsUI/Mvc/CertsFlowResultExtensions.cs b/src/MaksIT.CertsUI/Mvc/CertsFlowResultExtensions.cs deleted file mode 100644 index 7d4a0c4..0000000 --- a/src/MaksIT.CertsUI/Mvc/CertsFlowResultExtensions.cs +++ /dev/null @@ -1,26 +0,0 @@ -using MaksIT.Results; -using MaksIT.Results.Mvc; -using Microsoft.AspNetCore.Mvc; - -namespace MaksIT.CertsUI.Mvc; - -/// -/// Maps ACME domain results to HTTP: primary-replica required becomes 503 + Retry-After + ProblemDetails. -/// -public static class CertsFlowResultExtensions { - - /// Default retry hint for clients and caches (seconds). - 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(this Result result) { - if (!result.IsSuccess && PrimaryReplicaRequiredObjectResult.IsPrimaryReplicaResult(result.Messages)) - return PrimaryReplicaRequiredObjectResult.FromMessages(result.Messages, DefaultPrimaryReplicaRetryAfterSeconds); - return result.ToActionResult(); - } -} diff --git a/src/MaksIT.CertsUI/Mvc/PrimaryReplicaRequiredObjectResult.cs b/src/MaksIT.CertsUI/Mvc/PrimaryReplicaRequiredObjectResult.cs deleted file mode 100644 index a16ce16..0000000 --- a/src/MaksIT.CertsUI/Mvc/PrimaryReplicaRequiredObjectResult.cs +++ /dev/null @@ -1,39 +0,0 @@ -using MaksIT.CertsUI.Engine.DomainServices; -using Microsoft.AspNetCore.Mvc; - -namespace MaksIT.CertsUI.Mvc; - -/// -/// HTTP 503 with Retry-After (delay-seconds) and RFC 7807 for primary-replica routing. -/// -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? messages) => - messages is { Count: > 0 } && string.Equals(messages[0], CertsFlowPrimaryReplica.DiagnosticMarker, StringComparison.Ordinal); - - internal static IActionResult FromMessages(IReadOnlyList? 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); - } -} diff --git a/src/MaksIT.CertsUI/Program.cs b/src/MaksIT.CertsUI/Program.cs index 9b66059..d7d1078 100644 --- a/src/MaksIT.CertsUI/Program.cs +++ b/src/MaksIT.CertsUI/Program.cs @@ -67,14 +67,9 @@ builder.Services.AddOptions().Configure(o => builder.Services.AddScoped(); builder.Services.AddScoped(); -// Primary replica: one elected instance (Postgres lease) runs ACME + renewal; register shutdown last so StopAsync releases the lease first. -builder.Services.AddSingleton(); -builder.Services.AddSingleton(sp => sp.GetRequiredService()); - -// 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(); builder.Services.AddHostedService(); -builder.Services.AddHostedService(); // 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(sp => sp.GetRequiredService>().Value.CertsUIEngineConfiguration.JwtSettingsConfiguration); builder.Services.AddSingleton(sp => @@ -105,7 +100,6 @@ builder.Services.AddCertsEngine(new MaksIT.CertsUI.Engine.CertsEngineConfigurati LetsEncryptStaging = engineSection.Staging, }); -builder.Services.AddMemoryCache(); builder.Services.AddScoped(); // 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(); diff --git a/src/MaksIT.CertsUI/appsettings.json b/src/MaksIT.CertsUI/appsettings.json index 698b846..17fa51b 100644 --- a/src/MaksIT.CertsUI/appsettings.json +++ b/src/MaksIT.CertsUI/appsettings.json @@ -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" } } } diff --git a/src/MaksIT.Models/Agent/Requests/CertsUploadRequest.cs b/src/MaksIT.Models/Agent/Requests/CertsUploadRequest.cs index c732b40..d9c498f 100644 --- a/src/MaksIT.Models/Agent/Requests/CertsUploadRequest.cs +++ b/src/MaksIT.Models/Agent/Requests/CertsUploadRequest.cs @@ -4,5 +4,5 @@ namespace MaksIT.Models.Agent.Requests; public class CertsUploadRequest : RequestModelBase { - public Dictionary Certs { get; set; } + public required Dictionary Certs { get; set; } } diff --git a/src/MaksIT.Models/Agent/Requests/ServiceReloadRequest.cs b/src/MaksIT.Models/Agent/Requests/ServiceReloadRequest.cs index bbff92c..acc8122 100644 --- a/src/MaksIT.Models/Agent/Requests/ServiceReloadRequest.cs +++ b/src/MaksIT.Models/Agent/Requests/ServiceReloadRequest.cs @@ -4,5 +4,5 @@ namespace MaksIT.Models.Agent.Requests; public class ServiceReloadRequest : RequestModelBase { - public string ServiceName { get; set; } + public required string ServiceName { get; set; } } diff --git a/src/MaksIT.Models/Agent/Responses/HelloWorldResponse.cs b/src/MaksIT.Models/Agent/Responses/HelloWorldResponse.cs index 3f46d89..fddf8bb 100644 --- a/src/MaksIT.Models/Agent/Responses/HelloWorldResponse.cs +++ b/src/MaksIT.Models/Agent/Responses/HelloWorldResponse.cs @@ -4,5 +4,5 @@ namespace MaksIT.Models.Agent.Responses; public class HelloWorldResponse : ResponseModelBase { - public string Message { get; set; } + public required string Message { get; set; } } diff --git a/src/MaksIT.Models/MaksIT.Models.csproj b/src/MaksIT.Models/MaksIT.Models.csproj index 3801b3a..67371b2 100644 --- a/src/MaksIT.Models/MaksIT.Models.csproj +++ b/src/MaksIT.Models/MaksIT.Models.csproj @@ -2,8 +2,6 @@ net10.0 - enable - enable diff --git a/src/MaksIT.WebUI/src/axiosConfig.ts b/src/MaksIT.WebUI/src/axiosConfig.ts index 7855f83..2082e95 100644 --- a/src/MaksIT.WebUI/src/axiosConfig.ts +++ b/src/MaksIT.WebUI/src/axiosConfig.ts @@ -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' diff --git a/src/MaksIT.WebUI/src/models/ProblemDetails.ts b/src/MaksIT.WebUI/src/models/ProblemDetails.ts index c57f5fa..b68aa5e 100644 --- a/src/MaksIT.WebUI/src/models/ProblemDetails.ts +++ b/src/MaksIT.WebUI/src/models/ProblemDetails.ts @@ -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; - 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: {} -}) diff --git a/src/ReverseProxy/Program.cs b/src/ReverseProxy/Program.cs index 5a0f792..c9e7438 100644 --- a/src/ReverseProxy/Program.cs +++ b/src/ReverseProxy/Program.cs @@ -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(); diff --git a/src/ReverseProxy/ReverseProxy.csproj b/src/ReverseProxy/ReverseProxy.csproj index 596fb7f..69eb385 100644 --- a/src/ReverseProxy/ReverseProxy.csproj +++ b/src/ReverseProxy/ReverseProxy.csproj @@ -2,8 +2,6 @@ net10.0 - enable - enable Linux diff --git a/src/docker-compose.override.yml b/src/docker-compose.override.yml index dc77a5f..4f11867 100644 --- a/src/docker-compose.override.yml +++ b/src/docker-compose.override.yml @@ -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: diff --git a/src/helm/templates/NOTES.txt b/src/helm/templates/NOTES.txt index a1b16ab..c68e0ee 100644 --- a/src/helm/templates/NOTES.txt +++ b/src/helm/templates/NOTES.txt @@ -32,9 +32,9 @@ Optional per workload under **`components.`**: **`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 diff --git a/src/helm/values.yaml b/src/helm/values.yaml index 7af0576..6641584 100644 --- a/src/helm/values.yaml +++ b/src/helm/values.yaml @@ -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 }} } } } diff --git a/src/postgresql/servers.json.example b/src/postgresql/servers.json.example new file mode 100644 index 0000000..79cbb16 --- /dev/null +++ b/src/postgresql/servers.json.example @@ -0,0 +1,14 @@ +{ + "Servers": { + "1": { + "Name": "CertsUI (Compose)", + "Group": "Servers", + "Host": "postgres", + "Port": 5432, + "MaintenanceDB": "certsui", + "Username": "certsui", + "SSLMode": "prefer", + "PassFile": "" + } + } +}