mirror of
https://github.com/MAKS-IT-COM/maksit-certs-ui.git
synced 2026-05-16 04:48:12 +02:00
(bugfix): HA mode incomple migration fix
This commit is contained in:
parent
1c68cc63b8
commit
098fa91515
32
CHANGELOG.md
32
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<Configuration>`** from the constructor (DI callers unchanged except the removed parameter).
|
||||
- **Bootstrap:** **`InitializationHostedService`** acquires **`certs-ui-bootstrap`** (`RuntimeLeaseNames.BootstrapCoordinator`), runs **`CoordinationTableProvisioner`** + optional default admin, **releases** the lease, and exits. Other pods wait until **`users`** exist.
|
||||
- **Renewal:** **`AutoRenewal`** acquires **`certs-ui-renewal-sweep`** (`RuntimeLeaseNames.RenewalSweep`) for each sweep, runs work, **releases**, then sleeps. Any pod may win the next sweep.
|
||||
- **Lease names:** Replaced **`certs-ui-primary`** with **`BootstrapCoordinator`** and **`RenewalSweep`** constants (see **`RuntimeLeaseNames`**).
|
||||
- **Helm (cloud-native defaults):** **`components.server.service.sessionAffinity.enabled`** defaults to **`false`** so the server `Service` uses stateless load balancing (no **`ClientIP`** stickiness). Enable explicitly only when needed.
|
||||
- **Helm:** **`certsClientRuntime.apiUrl`** default is **`/api`** so the Web UI calls the API on the same browser origin (typical single-ingress / reverse-proxy setup). Override with a full URL when UI and API are on different hosts.
|
||||
|
||||
### Removed
|
||||
|
||||
- **`CertsFlowPrimaryReplica`**, **`PrimaryReplicaRequiredObjectResult`**, and **`CertsFlowResultExtensions`** / **`ToCertsFlowActionResult`**; **`CertsFlowController`** uses **`ToActionResult()`** like other API controllers.
|
||||
- **Web UI:** Primary-replica **503** auto-retry logic in **`axiosConfig.ts`**.
|
||||
- **Configuration / Helm:** **`AcmeFolder`** and **`DataFolder`** settings and the default **server** **acme**/**data** PVC mounts (cloud-native: no app-local disk for ACME or bootstrap markers). **`AddMemoryCache()`** host registration removed (unused).
|
||||
|
||||
### Upgrade notes
|
||||
|
||||
- **Migrations:** Apply FluentMigrator through **`3.4.0`** (includes **`acme_sessions`** and related coordination entries) before relying on cross-replica ACME sessions.
|
||||
- **Compose / secrets:** Remove **`acme`** and **`data`** bind mounts from **`docker-compose.override.yml`** if you still have them; they are no longer read by the application.
|
||||
- **Operations:** If you alert or filter on lease name **`certs-ui-primary`**, retarget to **`certs-ui-bootstrap`** and **`certs-ui-renewal-sweep`**.
|
||||
|
||||
## [3.3.22] - 2026-04-27
|
||||
|
||||
### Changed
|
||||
|
||||
24
README.md
24
README.md
@ -282,7 +282,7 @@ sudo tee /opt/Compose/MaksIT.CertsUI/secrets/appsecrets.json > /dev/null <<EOF
|
||||
{
|
||||
"Configuration": {
|
||||
"CertsEngineConfiguration": {
|
||||
"ConnectionString": "Host=postgres;Port=5432;Database=maksit_certs;Username=maksit;Password=maksit;SslMode=Prefer"
|
||||
"ConnectionString": "Host=postgres;Port=5432;Database=certsui;Username=certsui;Password=certsui;SslMode=Prefer"
|
||||
},
|
||||
"Auth": {
|
||||
"Secret": "<your-auth-secret>",
|
||||
@ -297,7 +297,7 @@ EOF
|
||||
```
|
||||
|
||||
**Note:**
|
||||
PostgreSQL is configured as **`Configuration:CertsEngineConfiguration:ConnectionString`** — same structural pattern as MaksIT.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 `<your-auth-secret>`, `<your-pepper>`, `<your-agent-key>`, with secure, your environment-specific values.
|
||||
PostgreSQL is configured as **`Configuration:CertsUIEngineConfiguration:ConnectionString`** — same structural pattern as MaksIT.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 `<your-auth-secret>`, `<your-pepper>`, `<your-agent-key>`, with secure, your environment-specific values.
|
||||
Make sure `<your-agent-key>` matches the key configured in your agent deployment.
|
||||
|
||||
**2. Create the file `/opt/Compose/MaksIT.CertsUI/configMap/appsettings.json` with this command:**
|
||||
@ -325,15 +325,13 @@ sudo tee /opt/Compose/MaksIT.CertsUI/configMap/appsettings.json <<EOF
|
||||
},
|
||||
"Production": "https://acme-v02.api.letsencrypt.org/directory",
|
||||
"Staging": "https://acme-staging-v02.api.letsencrypt.org/directory",
|
||||
"AcmeFolder": "/acme",
|
||||
"DataFolder": "/data"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
**Note:**
|
||||
`DataFolder` holds ACME subscriber agreement PDFs and an empty `init` bootstrap marker (users and registration data live in PostgreSQL). Replace all JWT-related placeholder values `<your-issuer>`, `<your-audience>` and `<your-agent-hostname>` with your environment-specific values.
|
||||
ACME sessions, HTTP-01 challenges, Terms of Service caching, and registration data live in PostgreSQL. Replace all JWT-related placeholder values `<your-issuer>`, `<your-audience>` and `<your-agent-hostname>` with your environment-specific values.
|
||||
|
||||
**3. Create the file `/opt/Compose/MaksIT.CertsUI/client/config.js` with this command:**
|
||||
|
||||
@ -515,7 +513,7 @@ Set-Content -Path 'C:\Compose\MaksIT.CertsUI\secrets\appsecrets.json' -Value @'
|
||||
{
|
||||
"Configuration": {
|
||||
"CertsEngineConfiguration": {
|
||||
"ConnectionString": "Host=postgres;Port=5432;Database=maksit_certs;Username=maksit;Password=maksit;SslMode=Prefer"
|
||||
"ConnectionString": "Host=postgres;Port=5432;Database=certsui;Username=certsui;Password=certsui;SslMode=Prefer"
|
||||
},
|
||||
"Auth": {
|
||||
"Secret": "<your-auth-secret>",
|
||||
@ -530,7 +528,7 @@ Set-Content -Path 'C:\Compose\MaksIT.CertsUI\secrets\appsecrets.json' -Value @'
|
||||
```
|
||||
|
||||
**Note:**
|
||||
PostgreSQL is **`Configuration:CertsEngineConfiguration:ConnectionString`** (same pattern as MaksIT.Vault **`VaultEngineConfiguration:ConnectionString`**). For Docker Compose, use the Postgres service hostname (here `postgres`) and credentials that match the `postgres` service. Legacy **`ConnectionStrings:Certs`** is still supported. Replace placeholder values `<your-auth-secret>`, `<your-pepper>`, `<your-agent-key>`, with secure, your environment-specific values.
|
||||
PostgreSQL is **`Configuration:CertsUIEngineConfiguration:ConnectionString`** (same pattern as MaksIT.Vault **`VaultEngineConfiguration:ConnectionString`**). For Docker Compose, use the Postgres service hostname (here **`postgres`**) and credentials that match **`docker-compose.override.yml`** (**`certsui`** defaults). Legacy **`ConnectionStrings:Certs`** is still supported. Replace placeholder values `<your-auth-secret>`, `<your-pepper>`, `<your-agent-key>`, with secure, your environment-specific values.
|
||||
Make sure `<your-agent-key>` matches the key configured in your agent deployment.
|
||||
|
||||
**2. Create the file `C:\Compose\MaksIT.CertsUI\configMap\appsettings.json` with this command:**
|
||||
@ -558,15 +556,13 @@ Set-Content -Path 'C:\Compose\MaksIT.CertsUI\configMap\appsettings.json' -Value
|
||||
},
|
||||
"Production": "https://acme-v02.api.letsencrypt.org/directory",
|
||||
"Staging": "https://acme-staging-v02.api.letsencrypt.org/directory",
|
||||
"AcmeFolder": "/acme",
|
||||
"DataFolder": "/data"
|
||||
}
|
||||
}
|
||||
'@
|
||||
```
|
||||
|
||||
**Note:**
|
||||
`DataFolder` holds ACME subscriber agreement PDFs and an empty `init` bootstrap marker (users and registration data live in PostgreSQL). Replace all JWT-related placeholder values `<your-issuer>`, `<your-audience>` and `<your-agent-hostname>` with your environment-specific values.
|
||||
ACME sessions, HTTP-01 challenges, Terms of Service caching, and registration data live in PostgreSQL. Replace all JWT-related placeholder values `<your-issuer>`, `<your-audience>` and `<your-agent-hostname>` with your environment-specific values.
|
||||
|
||||
**3. Create the file `C:\Compose\MaksIT.CertsUI\client\config.js` with this command:**
|
||||
|
||||
@ -680,7 +676,7 @@ Replace the placeholder values with your actual secrets. This secret contains th
|
||||
{
|
||||
"Configuration": {
|
||||
"CertsEngineConfiguration": {
|
||||
"ConnectionString": "Host=<postgres-host>;Port=5432;Database=maksit_certs;Username=<user>;Password=<password>;SslMode=Prefer"
|
||||
"ConnectionString": "Host=<postgres-host>;Port=5432;Database=certsui;Username=certsui;Password=certsui;SslMode=Prefer"
|
||||
},
|
||||
"Auth": {
|
||||
"Secret": "<your-auth-secret>",
|
||||
@ -698,7 +694,7 @@ kubectl create secret generic certs-ui-server-secrets \
|
||||
--from-literal=appsecrets.json='{
|
||||
"Configuration": {
|
||||
"CertsEngineConfiguration": {
|
||||
"ConnectionString": "Host=<postgres-host>;Port=5432;Database=maksit_certs;Username=<user>;Password=<password>;SslMode=Prefer"
|
||||
"ConnectionString": "Host=<postgres-host>;Port=5432;Database=certsui;Username=certsui;Password=certsui;SslMode=Prefer"
|
||||
},
|
||||
"Auth": {
|
||||
"Secret": "<your-auth-secret>",
|
||||
@ -743,8 +739,6 @@ Edit the values as needed for your environment. This configmap contains applicat
|
||||
},
|
||||
"Production": "https://acme-v02.api.letsencrypt.org/directory",
|
||||
"Staging": "https://acme-staging-v02.api.letsencrypt.org/directory",
|
||||
"AcmeFolder": "/acme",
|
||||
"DataFolder": "/data"
|
||||
}
|
||||
}
|
||||
```
|
||||
@ -773,8 +767,6 @@ kubectl create configmap certs-ui-server-configmap \
|
||||
},
|
||||
"Production": "https://acme-v02.api.letsencrypt.org/directory",
|
||||
"Staging": "https://acme-staging-v02.api.letsencrypt.org/directory",
|
||||
"AcmeFolder": "/acme",
|
||||
"DataFolder": "/data"
|
||||
}
|
||||
}' \
|
||||
-n certs-ui
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
10
src/Directory.Build.props
Normal file
10
src/Directory.Build.props
Normal file
@ -0,0 +1,10 @@
|
||||
<Project>
|
||||
<!-- Applies to SDK-style .csproj under src/ (excludes Microsoft.Docker.Sdk compose projects). -->
|
||||
<PropertyGroup Condition="'$(UsingMicrosoftNETSdk)' == 'true'">
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<AnalysisLevel>latest</AnalysisLevel>
|
||||
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@ -2,18 +2,16 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>MaksIT.CertsUI.Engine.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="8.0.1">
|
||||
<PackageReference Include="coverlet.collector" Version="10.0.0">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.5.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
||||
@ -84,6 +84,13 @@ public static class CertsLinq2DbMapping {
|
||||
.Property(x => x.FetchedAtUtc).HasColumnName("fetched_at_utc")
|
||||
.Property(x => x.ExpiresAtUtc).HasColumnName("expires_at_utc");
|
||||
|
||||
builder.Entity<AcmeSessionDto>()
|
||||
.HasTableName(Table.AcmeSessions.Name)
|
||||
.Property(x => x.SessionId).HasColumnName("session_id").IsPrimaryKey()
|
||||
.Property(x => x.PayloadJson).HasColumnName("payload_json")
|
||||
.Property(x => x.UpdatedAtUtc).HasColumnName("updated_at_utc")
|
||||
.Property(x => x.ExpiresAtUtc).HasColumnName("expires_at_utc");
|
||||
|
||||
builder.Build();
|
||||
return schema;
|
||||
}
|
||||
|
||||
@ -63,8 +63,6 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
|
||||
private readonly IAcmeHttpChallengePersistenceService _httpChallenges;
|
||||
private readonly IRuntimeLeaseService _runtimeLease;
|
||||
private readonly IRuntimeInstanceId _runtimeInstance;
|
||||
private readonly IPrimaryReplicaWorkload _primaryReplica;
|
||||
private readonly string _acmePath;
|
||||
|
||||
public CertsFlowDomainService(
|
||||
ILogger<CertsFlowDomainService> logger,
|
||||
@ -76,8 +74,7 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
|
||||
ITermsOfServiceCachePersistenceService termsOfServiceCache,
|
||||
IAcmeHttpChallengePersistenceService httpChallenges,
|
||||
IRuntimeLeaseService runtimeLease,
|
||||
IRuntimeInstanceId runtimeInstance,
|
||||
IPrimaryReplicaWorkload primaryReplica) {
|
||||
IRuntimeInstanceId runtimeInstance) {
|
||||
_logger = logger;
|
||||
_httpClient = httpClient;
|
||||
_letsEncryptService = letsEncryptService;
|
||||
@ -88,14 +85,12 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
|
||||
_httpChallenges = httpChallenges;
|
||||
_runtimeLease = runtimeLease;
|
||||
_runtimeInstance = runtimeInstance;
|
||||
_primaryReplica = primaryReplica;
|
||||
_acmePath = config.AcmeFolder;
|
||||
}
|
||||
|
||||
#region Terms of service
|
||||
|
||||
public async Task<Result<string?>> GetTermsOfServiceAsync(Guid sessionId) {
|
||||
var termsUriResult = _letsEncryptService.GetTermsOfServiceUri(sessionId);
|
||||
var termsUriResult = await _letsEncryptService.GetTermsOfServiceUriAsync(sessionId, CancellationToken.None).ConfigureAwait(false);
|
||||
if (!termsUriResult.IsSuccess || termsUriResult.Value == null)
|
||||
return termsUriResult;
|
||||
|
||||
@ -178,24 +173,18 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
|
||||
#region Session, orders, and certificates
|
||||
|
||||
public async Task<Result> CompleteChallengesAsync(Guid sessionId) {
|
||||
if (!_primaryReplica.IsPrimary)
|
||||
return Result.ServiceUnavailable(CertsFlowPrimaryReplica.ServiceUnavailableMessages);
|
||||
return await _letsEncryptService.CompleteChallenges(sessionId);
|
||||
return await _letsEncryptService.CompleteChallenges(sessionId, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<Result<Guid?>> ConfigureClientAsync(bool isStaging) {
|
||||
if (!_primaryReplica.IsPrimary)
|
||||
return Result<Guid?>.ServiceUnavailable(null, CertsFlowPrimaryReplica.ServiceUnavailableMessages);
|
||||
var sessionId = Guid.NewGuid();
|
||||
var result = await _letsEncryptService.ConfigureClient(sessionId, isStaging);
|
||||
var result = await _letsEncryptService.ConfigureClient(sessionId, isStaging, CancellationToken.None).ConfigureAwait(false);
|
||||
if (!result.IsSuccess)
|
||||
return result.ToResultOfType<Guid?>(default);
|
||||
return Result<Guid?>.Ok(sessionId);
|
||||
}
|
||||
|
||||
public async Task<Result<Guid?>> InitAsync(Guid sessionId, Guid? accountId, string description, string[] contacts) {
|
||||
if (!_primaryReplica.IsPrimary)
|
||||
return Result<Guid?>.ServiceUnavailable(null, CertsFlowPrimaryReplica.ServiceUnavailableMessages);
|
||||
RegistrationCache? cache = null;
|
||||
if (accountId == null) {
|
||||
accountId = Guid.NewGuid();
|
||||
@ -209,15 +198,13 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
|
||||
cache = cacheResult.Value;
|
||||
}
|
||||
}
|
||||
var result = await _letsEncryptService.Init(sessionId, accountId.Value, description, contacts, cache);
|
||||
var result = await _letsEncryptService.Init(sessionId, accountId.Value, description, contacts, cache, CancellationToken.None).ConfigureAwait(false);
|
||||
if (!result.IsSuccess)
|
||||
return result.ToResultOfType<Guid?>(default);
|
||||
return Result<Guid?>.Ok(accountId.Value);
|
||||
}
|
||||
|
||||
public async Task<Result<List<string>?>> NewOrderAsync(Guid sessionId, string[] hostnames, string challengeType) {
|
||||
if (!_primaryReplica.IsPrimary)
|
||||
return Result<List<string>?>.ServiceUnavailable(null, CertsFlowPrimaryReplica.ServiceUnavailableMessages);
|
||||
var holder = _runtimeInstance.InstanceId;
|
||||
var acquired = await _runtimeLease.TryAcquireAsync(RuntimeLeaseNames.AcmeWriter, holder, AcmeWriterLeaseTtl, CancellationToken.None);
|
||||
if (!acquired.IsSuccess)
|
||||
@ -228,7 +215,7 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
|
||||
}
|
||||
|
||||
try {
|
||||
var orderResult = await _letsEncryptService.NewOrder(sessionId, hostnames, challengeType);
|
||||
var orderResult = await _letsEncryptService.NewOrder(sessionId, hostnames, challengeType, CancellationToken.None).ConfigureAwait(false);
|
||||
if (!orderResult.IsSuccess || orderResult.Value == null)
|
||||
return orderResult.ToResultOfType<List<string>?>(_ => null);
|
||||
var challenges = new List<string>();
|
||||
@ -253,15 +240,13 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
|
||||
}
|
||||
|
||||
public async Task<Result> GetCertificatesAsync(Guid sessionId, string[] hostnames) {
|
||||
if (!_primaryReplica.IsPrimary)
|
||||
return Result.ServiceUnavailable(CertsFlowPrimaryReplica.ServiceUnavailableMessages);
|
||||
foreach (var subject in hostnames) {
|
||||
var result = await _letsEncryptService.GetCertificate(sessionId, subject);
|
||||
var result = await _letsEncryptService.GetCertificate(sessionId, subject, CancellationToken.None).ConfigureAwait(false);
|
||||
if (!result.IsSuccess)
|
||||
return result;
|
||||
Thread.Sleep(1000);
|
||||
}
|
||||
var cacheResult = _letsEncryptService.GetRegistrationCache(sessionId);
|
||||
var cacheResult = await _letsEncryptService.GetRegistrationCacheAsync(sessionId, CancellationToken.None).ConfigureAwait(false);
|
||||
if (!cacheResult.IsSuccess || cacheResult.Value == null)
|
||||
return cacheResult;
|
||||
var saveResult = await _registrationCache.SaveAsync(cacheResult.Value.AccountId, cacheResult.Value);
|
||||
@ -271,9 +256,7 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
|
||||
}
|
||||
|
||||
public async Task<Result> GetOrderAsync(Guid sessionId, string[] hostnames) {
|
||||
if (!_primaryReplica.IsPrimary)
|
||||
return Result.ServiceUnavailable(CertsFlowPrimaryReplica.ServiceUnavailableMessages);
|
||||
return await _letsEncryptService.GetOrder(sessionId, hostnames);
|
||||
return await _letsEncryptService.GetOrder(sessionId, hostnames, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
#endregion
|
||||
@ -281,8 +264,6 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
|
||||
#region Deploy and revoke
|
||||
|
||||
public async Task<Result<Dictionary<string, string>?>> ApplyCertificatesAsync(Guid accountId) {
|
||||
if (!_primaryReplica.IsPrimary)
|
||||
return Result<Dictionary<string, string>?>.ServiceUnavailable(null, CertsFlowPrimaryReplica.ServiceUnavailableMessages);
|
||||
var cacheResult = await _registrationCache.LoadAsync(accountId);
|
||||
if (!cacheResult.IsSuccess || cacheResult.Value?.CachedCerts == null)
|
||||
return cacheResult.ToResultOfType<Dictionary<string, string>?>(_ => null);
|
||||
@ -302,14 +283,12 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
|
||||
}
|
||||
|
||||
public async Task<Result> RevokeCertificatesAsync(Guid sessionId, string[] hostnames) {
|
||||
if (!_primaryReplica.IsPrimary)
|
||||
return Result.ServiceUnavailable(CertsFlowPrimaryReplica.ServiceUnavailableMessages);
|
||||
foreach (var hostname in hostnames) {
|
||||
var result = await _letsEncryptService.RevokeCertificate(sessionId, hostname, RevokeReason.Unspecified);
|
||||
var result = await _letsEncryptService.RevokeCertificate(sessionId, hostname, RevokeReason.Unspecified, CancellationToken.None).ConfigureAwait(false);
|
||||
if (!result.IsSuccess)
|
||||
return result;
|
||||
}
|
||||
var cacheResult = _letsEncryptService.GetRegistrationCache(sessionId);
|
||||
var cacheResult = await _letsEncryptService.GetRegistrationCacheAsync(sessionId, CancellationToken.None).ConfigureAwait(false);
|
||||
if (!cacheResult.IsSuccess || cacheResult.Value == null)
|
||||
return cacheResult;
|
||||
var saveResult = await _registrationCache.SaveAsync(cacheResult.Value.AccountId, cacheResult.Value);
|
||||
@ -397,18 +376,8 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
|
||||
return Result<string?>.BadRequest(null, "fileName is required.");
|
||||
|
||||
var fromDb = await _httpChallenges.GetTokenValueAsync(fileName, cancellationToken).ConfigureAwait(false);
|
||||
if (fromDb.IsSuccess && !string.IsNullOrEmpty(fromDb.Value)) {
|
||||
Directory.CreateDirectory(_acmePath);
|
||||
var path = Path.Combine(_acmePath, fileName);
|
||||
await File.WriteAllTextAsync(path, fromDb.Value!, cancellationToken).ConfigureAwait(false);
|
||||
if (fromDb.IsSuccess && !string.IsNullOrEmpty(fromDb.Value))
|
||||
return Result<string?>.Ok(fromDb.Value);
|
||||
}
|
||||
|
||||
var legacyPath = Path.Combine(_acmePath, fileName);
|
||||
if (File.Exists(legacyPath)) {
|
||||
var legacy = await File.ReadAllTextAsync(legacyPath, cancellationToken).ConfigureAwait(false);
|
||||
return Result<string?>.Ok(legacy);
|
||||
}
|
||||
|
||||
return Result<string?>.NotFound(null, $"Challenge token not found: {fileName}");
|
||||
}
|
||||
@ -416,7 +385,7 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
|
||||
#endregion
|
||||
|
||||
private async Task TryPersistRegistrationCacheFromSessionAsync(Guid sessionId) {
|
||||
var cacheResult = _letsEncryptService.GetRegistrationCache(sessionId);
|
||||
var cacheResult = await _letsEncryptService.GetRegistrationCacheAsync(sessionId, CancellationToken.None).ConfigureAwait(false);
|
||||
if (!cacheResult.IsSuccess || cacheResult.Value == null)
|
||||
return;
|
||||
|
||||
|
||||
@ -1,16 +0,0 @@
|
||||
namespace MaksIT.CertsUI.Engine.DomainServices;
|
||||
|
||||
/// <summary>
|
||||
/// Stable markers for <c>Result.ServiceUnavailable</c> when ACME is invoked on a non-primary replica.
|
||||
/// The host maps these to HTTP 503 + <c>Retry-After</c> + RFC 7807 <c>ProblemDetails</c>.
|
||||
/// </summary>
|
||||
public static class CertsFlowPrimaryReplica {
|
||||
|
||||
/// <summary>Machine-readable first line in result messages for detection in MVC.</summary>
|
||||
public const string DiagnosticMarker = "urn:maksit:certs-ui:primary-replica-required";
|
||||
|
||||
public static readonly string[] ServiceUnavailableMessages = [
|
||||
DiagnosticMarker,
|
||||
"Only the elected primary Certs UI replica runs ACME orchestration. Retry after a short delay; use service session affinity (ClientIP) so interactive flows stay on the primary."
|
||||
];
|
||||
}
|
||||
@ -1,10 +1,8 @@
|
||||
namespace MaksIT.CertsUI.Engine.DomainServices;
|
||||
|
||||
/// <summary>
|
||||
/// Paths and agent wiring for applying certificates after ACME issuance. The host maps these from configuration (e.g. appsettings).
|
||||
/// Agent wiring after ACME issuance. Interactive ACME and HTTP-01 state live in PostgreSQL, not on local paths.
|
||||
/// </summary>
|
||||
public interface ICertsFlowEngineConfiguration {
|
||||
string AcmeFolder { get; }
|
||||
string DataFolder { get; }
|
||||
string AgentServiceToReload { get; }
|
||||
}
|
||||
|
||||
9
src/MaksIT.CertsUI.Engine/Dto/Certs/AcmeSessionDto.cs
Normal file
9
src/MaksIT.CertsUI.Engine/Dto/Certs/AcmeSessionDto.cs
Normal file
@ -0,0 +1,9 @@
|
||||
namespace MaksIT.CertsUI.Engine.Dto.Certs;
|
||||
|
||||
/// <summary>PostgreSQL <c>acme_sessions</c>: shared ACME flow state keyed by browser session id (survives HA / any replica).</summary>
|
||||
public sealed class AcmeSessionDto {
|
||||
public Guid SessionId { get; set; }
|
||||
public string PayloadJson { get; set; } = "{}";
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; }
|
||||
public DateTimeOffset ExpiresAtUtc { get; set; }
|
||||
}
|
||||
@ -64,7 +64,7 @@ public static class ServiceCollectionExtensions {
|
||||
#endregion
|
||||
|
||||
#region ACME / Let's Encrypt
|
||||
services.AddSingleton<AcmeSessionStore>();
|
||||
services.AddSingleton<IAcmeSessionStore, AcmePostgresSessionStore>();
|
||||
services.AddHttpClient<ILetsEncryptService, LetsEncryptService>();
|
||||
#endregion
|
||||
}
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -8,7 +8,7 @@ namespace MaksIT.CertsUI.Engine.Infrastructure;
|
||||
/// </summary>
|
||||
public static class CoordinationTableProvisioner {
|
||||
|
||||
/// <summary>Creates <c>public.acme_http_challenges</c> and <c>public.app_runtime_leases</c> if missing.</summary>
|
||||
/// <summary>Creates <c>public.acme_http_challenges</c>, <c>public.app_runtime_leases</c>, and <c>public.acme_sessions</c> if missing.</summary>
|
||||
public static async Task EnsureAsync(string? connectionString, CancellationToken cancellationToken = default) {
|
||||
if (string.IsNullOrWhiteSpace(connectionString))
|
||||
return;
|
||||
@ -31,6 +31,13 @@ public static class CoordinationTableProvisioner {
|
||||
acquired_at_utc timestamp with time zone NOT NULL,
|
||||
expires_at_utc timestamp with time zone NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS public.acme_sessions (
|
||||
session_id uuid NOT NULL PRIMARY KEY,
|
||||
payload_json text NOT NULL,
|
||||
updated_at_utc timestamp with time zone NOT NULL,
|
||||
expires_at_utc timestamp with time zone NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "IX_acme_sessions_expires_at_utc" ON public.acme_sessions (expires_at_utc);
|
||||
""",
|
||||
conn);
|
||||
await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@ -69,6 +69,12 @@ public class SchemaSyncService(ICertsEngineConfiguration config, ILogger<SchemaS
|
||||
("acquired_at_utc", "timestamp with time zone"),
|
||||
("expires_at_utc", "timestamp with time zone"),
|
||||
],
|
||||
["acme_sessions"] = [
|
||||
("session_id", "uuid"),
|
||||
("payload_json", "text"),
|
||||
("updated_at_utc", "timestamp with time zone"),
|
||||
("expires_at_utc", "timestamp with time zone"),
|
||||
],
|
||||
["api_keys"] = [
|
||||
("Id", "uuid"),
|
||||
("Description", "text"),
|
||||
|
||||
@ -2,10 +2,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<NoWarn>CA2254;NU1903;NU1904</NoWarn>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@ -14,20 +11,18 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentMigrator" Version="8.0.1" />
|
||||
<PackageReference Include="FluentMigrator.Runner" Version="8.0.1" />
|
||||
<PackageReference Include="FluentMigrator.Runner.Postgres" Version="8.0.1" />
|
||||
<PackageReference Include="linq2db" Version="6.2.1" />
|
||||
<PackageReference Include="linq2db.PostgreSQL" Version="6.2.1" />
|
||||
<PackageReference Include="MaksIT.Core" Version="1.6.5" />
|
||||
<PackageReference Include="MaksIT.Dapr" Version="2.0.0" />
|
||||
<PackageReference Include="MaksIT.Results" Version="2.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.5" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.7" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageReference Include="Npgsql" Version="10.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@ -39,22 +39,27 @@ public sealed class TermsOfServiceCachePersistenceServiceLinq2Db(
|
||||
|
||||
try {
|
||||
using var db = connectionFactory.Create();
|
||||
var existing = db.GetTable<TermsOfServiceCacheDto>().FirstOrDefault(x => x.Url == cacheEntry.Url);
|
||||
if (existing == null) {
|
||||
db.Insert(cacheEntry);
|
||||
}
|
||||
else {
|
||||
db.GetTable<TermsOfServiceCacheDto>()
|
||||
.Where(x => x.Url == cacheEntry.Url)
|
||||
.Set(x => x.UrlHashHex, cacheEntry.UrlHashHex)
|
||||
.Set(x => x.ETag, cacheEntry.ETag)
|
||||
.Set(x => x.LastModifiedUtc, cacheEntry.LastModifiedUtc)
|
||||
.Set(x => x.ContentType, cacheEntry.ContentType)
|
||||
.Set(x => x.ContentBytes, cacheEntry.ContentBytes)
|
||||
.Set(x => x.FetchedAtUtc, cacheEntry.FetchedAtUtc)
|
||||
.Set(x => x.ExpiresAtUtc, cacheEntry.ExpiresAtUtc)
|
||||
.Update();
|
||||
}
|
||||
db.GetTable<TermsOfServiceCacheDto>().InsertOrUpdate(
|
||||
() => new TermsOfServiceCacheDto {
|
||||
Url = cacheEntry.Url,
|
||||
UrlHashHex = cacheEntry.UrlHashHex,
|
||||
ETag = cacheEntry.ETag,
|
||||
LastModifiedUtc = cacheEntry.LastModifiedUtc,
|
||||
ContentType = cacheEntry.ContentType,
|
||||
ContentBytes = cacheEntry.ContentBytes,
|
||||
FetchedAtUtc = cacheEntry.FetchedAtUtc,
|
||||
ExpiresAtUtc = cacheEntry.ExpiresAtUtc
|
||||
},
|
||||
old => new TermsOfServiceCacheDto {
|
||||
Url = old.Url,
|
||||
UrlHashHex = cacheEntry.UrlHashHex,
|
||||
ETag = cacheEntry.ETag,
|
||||
LastModifiedUtc = cacheEntry.LastModifiedUtc,
|
||||
ContentType = cacheEntry.ContentType,
|
||||
ContentBytes = cacheEntry.ContentBytes,
|
||||
FetchedAtUtc = cacheEntry.FetchedAtUtc,
|
||||
ExpiresAtUtc = cacheEntry.ExpiresAtUtc
|
||||
});
|
||||
|
||||
return Task.FromResult(Result.Ok());
|
||||
}
|
||||
|
||||
@ -1,8 +0,0 @@
|
||||
namespace MaksIT.CertsUI.Engine.RuntimeCoordination;
|
||||
|
||||
/// <summary>
|
||||
/// True when this process is the elected primary replica (Postgres lease) and may run ACME orchestration and background renewal.
|
||||
/// </summary>
|
||||
public interface IPrimaryReplicaWorkload {
|
||||
bool IsPrimary { get; }
|
||||
}
|
||||
@ -4,6 +4,9 @@ namespace MaksIT.CertsUI.Engine.RuntimeCoordination;
|
||||
public static class RuntimeLeaseNames {
|
||||
public const string AcmeWriter = "certs-ui-acme-writer";
|
||||
|
||||
/// <summary>Single elected instance: identity bootstrap, ACME orchestration, and background renewal.</summary>
|
||||
public const string PrimaryReplica = "certs-ui-primary";
|
||||
/// <summary>Held only for coordination DDL + optional default-admin bootstrap; released when done (no renewal loop).</summary>
|
||||
public const string BootstrapCoordinator = "certs-ui-bootstrap";
|
||||
|
||||
/// <summary>Held for one renewal sweep (purge + account passes); released after each sweep so any pod may run the next.</summary>
|
||||
public const string RenewalSweep = "certs-ui-renewal-sweep";
|
||||
}
|
||||
|
||||
@ -0,0 +1,70 @@
|
||||
using LinqToDB;
|
||||
using LinqToDB.Data;
|
||||
using MaksIT.CertsUI.Engine;
|
||||
using MaksIT.CertsUI.Engine.Data;
|
||||
using MaksIT.CertsUI.Engine.Domain.LetsEncrypt;
|
||||
using MaksIT.CertsUI.Engine.Dto.Certs;
|
||||
using MaksIT.CertsUI.Engine.Infrastructure;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MaksIT.CertsUI.Engine.Services;
|
||||
|
||||
/// <summary>PostgreSQL-backed ACME session state (replaces in-process <c>IMemoryCache</c>).</summary>
|
||||
public sealed class AcmePostgresSessionStore(
|
||||
ICertsEngineConfiguration config,
|
||||
ILogger<AcmePostgresSessionStore> logger
|
||||
) : IAcmeSessionStore {
|
||||
|
||||
private static readonly TimeSpan SessionTtl = TimeSpan.FromHours(1);
|
||||
|
||||
private DataConnection CreateConnection() {
|
||||
var options = new DataOptions()
|
||||
.UseConnectionString(ProviderName.PostgreSQL, config.ConnectionString)
|
||||
.UseMappingSchema(CertsLinq2DbMapping.Schema);
|
||||
return new DataConnection(options);
|
||||
}
|
||||
|
||||
public Task<State> LoadOrCreateAsync(Guid sessionId, CancellationToken cancellationToken = default) {
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
using var db = CreateConnection();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var row = db.GetTable<AcmeSessionDto>()
|
||||
.Where(x => x.SessionId == sessionId && x.ExpiresAtUtc > now)
|
||||
.FirstOrDefault();
|
||||
if (row == null)
|
||||
return Task.FromResult(new State());
|
||||
try {
|
||||
return Task.FromResult(AcmeSessionJsonSerializer.FromJson(row.PayloadJson));
|
||||
}
|
||||
catch (Exception ex) {
|
||||
logger.LogWarning(ex, "Failed to deserialize ACME session {SessionId}; starting empty state.", sessionId);
|
||||
return Task.FromResult(new State());
|
||||
}
|
||||
}
|
||||
|
||||
public Task PersistAsync(Guid sessionId, State state, CancellationToken cancellationToken = default) {
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var json = AcmeSessionJsonSerializer.ToJson(state);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var expires = now.Add(SessionTtl);
|
||||
using var db = CreateConnection();
|
||||
var existing = db.GetTable<AcmeSessionDto>()
|
||||
.Where(x => x.SessionId == sessionId)
|
||||
.FirstOrDefault();
|
||||
if (existing == null) {
|
||||
db.Insert(new AcmeSessionDto {
|
||||
SessionId = sessionId,
|
||||
PayloadJson = json,
|
||||
UpdatedAtUtc = now,
|
||||
ExpiresAtUtc = expires
|
||||
});
|
||||
}
|
||||
else {
|
||||
existing.PayloadJson = json;
|
||||
existing.UpdatedAtUtc = now;
|
||||
existing.ExpiresAtUtc = expires;
|
||||
db.Update(existing);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
using System.Security.Cryptography;
|
||||
using MaksIT.CertsUI.Engine.Domain.LetsEncrypt;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace MaksIT.CertsUI.Engine.Services;
|
||||
|
||||
internal static class AcmeSessionJsonSerializer {
|
||||
private static readonly JsonSerializerSettings Settings = new() {
|
||||
NullValueHandling = NullValueHandling.Ignore,
|
||||
Formatting = Formatting.None
|
||||
};
|
||||
|
||||
public static string ToJson(State state) {
|
||||
var snap = new AcmeSessionSnapshot {
|
||||
IsStaging = state.IsStaging,
|
||||
Directory = state.Directory,
|
||||
CurrentOrder = state.CurrentOrder,
|
||||
Challenges = [.. state.Challenges],
|
||||
Cache = state.Cache,
|
||||
Jwk = state.Jwk,
|
||||
AccountKeyCspBlob = state.Rsa is RSACryptoServiceProvider csp ? csp.ExportCspBlob(true) : null
|
||||
};
|
||||
return JsonConvert.SerializeObject(snap, Settings);
|
||||
}
|
||||
|
||||
public static State FromJson(string json) {
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return new State();
|
||||
var snap = JsonConvert.DeserializeObject<AcmeSessionSnapshot>(json, Settings);
|
||||
if (snap == null)
|
||||
return new State();
|
||||
var state = new State {
|
||||
IsStaging = snap.IsStaging,
|
||||
Directory = snap.Directory,
|
||||
CurrentOrder = snap.CurrentOrder,
|
||||
Cache = snap.Cache,
|
||||
Jwk = snap.Jwk
|
||||
};
|
||||
foreach (var c in snap.Challenges) {
|
||||
if (c != null)
|
||||
state.Challenges.Add(c);
|
||||
}
|
||||
if (snap.AccountKeyCspBlob is { Length: > 0 }) {
|
||||
var rsa = new RSACryptoServiceProvider();
|
||||
rsa.ImportCspBlob(snap.AccountKeyCspBlob);
|
||||
state.Rsa = rsa;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
}
|
||||
18
src/MaksIT.CertsUI.Engine/Services/AcmeSessionSnapshot.cs
Normal file
18
src/MaksIT.CertsUI.Engine/Services/AcmeSessionSnapshot.cs
Normal file
@ -0,0 +1,18 @@
|
||||
using MaksIT.Core.Security.JWK;
|
||||
using MaksIT.CertsUI.Engine.Domain.Certs;
|
||||
using MaksIT.CertsUI.Engine.Dto.LetsEncrypt.Responses;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace MaksIT.CertsUI.Engine.Services;
|
||||
|
||||
/// <summary>JSON-serializable projection of <see cref="MaksIT.CertsUI.Engine.Domain.LetsEncrypt.State"/> for <c>acme_sessions.payload_json</c>.</summary>
|
||||
internal sealed class AcmeSessionSnapshot {
|
||||
public bool IsStaging { get; set; }
|
||||
public AcmeDirectory? Directory { get; set; }
|
||||
public Order? CurrentOrder { get; set; }
|
||||
public List<AuthorizationChallengeChallenge> Challenges { get; set; } = [];
|
||||
public RegistrationCache? Cache { get; set; }
|
||||
public Jwk? Jwk { get; set; }
|
||||
/// <summary>RSA account key as CSP blob when present (same encoding as <see cref="RegistrationCache.AccountKey"/>).</summary>
|
||||
public byte[]? AccountKeyCspBlob { get; set; }
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
using MaksIT.CertsUI.Engine.Domain.LetsEncrypt;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace MaksIT.CertsUI.Engine.Services;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory cache of per-session <see cref="State"/> for ACME flows (directory, account, current order, challenges).
|
||||
/// </summary>
|
||||
public sealed class AcmeSessionStore {
|
||||
private static readonly TimeSpan SessionTtl = TimeSpan.FromHours(1);
|
||||
|
||||
private readonly IMemoryCache _cache;
|
||||
|
||||
public AcmeSessionStore(IMemoryCache cache) => _cache = cache;
|
||||
|
||||
public State GetOrCreate(Guid sessionId) {
|
||||
if (!_cache.TryGetValue(sessionId, out State? state) || state is null) {
|
||||
state = new State();
|
||||
_cache.Set(sessionId, state, SessionTtl);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
}
|
||||
9
src/MaksIT.CertsUI.Engine/Services/IAcmeSessionStore.cs
Normal file
9
src/MaksIT.CertsUI.Engine/Services/IAcmeSessionStore.cs
Normal file
@ -0,0 +1,9 @@
|
||||
using MaksIT.CertsUI.Engine.Domain.LetsEncrypt;
|
||||
|
||||
namespace MaksIT.CertsUI.Engine.Services;
|
||||
|
||||
/// <summary>Loads and persists per-browser ACME <see cref="State"/> so any replica can continue the flow.</summary>
|
||||
public interface IAcmeSessionStore {
|
||||
Task<State> LoadOrCreateAsync(Guid sessionId, CancellationToken cancellationToken = default);
|
||||
Task PersistAsync(Guid sessionId, State state, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@ -16,14 +16,37 @@ public partial class LetsEncryptService {
|
||||
|
||||
#region Internal helpers
|
||||
|
||||
private State GetOrCreateState(Guid sessionId) => _sessions.GetOrCreate(sessionId);
|
||||
private async Task<Result> WithPersistedSessionAsync(
|
||||
Guid sessionId,
|
||||
CancellationToken cancellationToken,
|
||||
Func<State, Task<Result>> body) {
|
||||
var state = await _sessionStore.LoadOrCreateAsync(sessionId, cancellationToken).ConfigureAwait(false);
|
||||
try {
|
||||
return await body(state).ConfigureAwait(false);
|
||||
}
|
||||
finally {
|
||||
await _sessionStore.PersistAsync(sessionId, state, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Result<string?>> GetNonceAsync(Guid sessionId, Uri uri) {
|
||||
private async Task<Result<T?>> WithPersistedSessionAsync<T>(
|
||||
Guid sessionId,
|
||||
CancellationToken cancellationToken,
|
||||
Func<State, Task<Result<T?>>> body) {
|
||||
var state = await _sessionStore.LoadOrCreateAsync(sessionId, cancellationToken).ConfigureAwait(false);
|
||||
try {
|
||||
return await body(state).ConfigureAwait(false);
|
||||
}
|
||||
finally {
|
||||
await _sessionStore.PersistAsync(sessionId, state, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Result<string?>> GetNonceAsync(State state, Uri uri) {
|
||||
if (uri == null)
|
||||
return Result<string?>.InternalServerError(null, "URI is null");
|
||||
|
||||
try {
|
||||
var state = GetOrCreateState(sessionId);
|
||||
|
||||
_logger.LogInformation($"Executing {nameof(GetNonceAsync)}...");
|
||||
|
||||
@ -65,9 +88,7 @@ public partial class LetsEncryptService {
|
||||
}
|
||||
}
|
||||
|
||||
private Result<string?> EncodeMessage(Guid sessionId, bool isPostAsGet, object? requestModel, ACMEJwsHeader protectedHeader) {
|
||||
var state = GetOrCreateState(sessionId);
|
||||
|
||||
private Result<string?> EncodeMessage(State state, bool isPostAsGet, object? requestModel, ACMEJwsHeader protectedHeader) {
|
||||
if (!state.TryGetAccountKey(out var rsa, out var jwk))
|
||||
return Result<string?>.InternalServerError(AccountKeyMissingMessage);
|
||||
|
||||
@ -94,7 +115,7 @@ public partial class LetsEncryptService {
|
||||
request.Content.Headers.ContentType = new MediaTypeHeaderValue(contentType);
|
||||
}
|
||||
|
||||
private async Task<Result> PollChallengeStatus(Guid sessionId, AuthorizationChallengeChallenge challenge) {
|
||||
private async Task<Result> PollChallengeStatus(State state, AuthorizationChallengeChallenge challenge) {
|
||||
if (challenge?.Url == null)
|
||||
return Result.InternalServerError("Challenge URL is null");
|
||||
|
||||
@ -103,13 +124,13 @@ public partial class LetsEncryptService {
|
||||
while (true) {
|
||||
var pollRequest = new HttpRequestMessage(HttpMethod.Post, challenge.Url);
|
||||
|
||||
var nonceResult = await GetNonceAsync(sessionId, challenge.Url);
|
||||
var nonceResult = await GetNonceAsync(state, challenge.Url);
|
||||
if (!nonceResult.IsSuccess || nonceResult.Value == null)
|
||||
return nonceResult;
|
||||
|
||||
var nonce = nonceResult.Value;
|
||||
|
||||
var pollJsonResult = EncodeMessage(sessionId, true, null, new ACMEJwsHeader {
|
||||
var pollJsonResult = EncodeMessage(state, true, null, new ACMEJwsHeader {
|
||||
Url = challenge.Url.ToString(),
|
||||
Nonce = nonce
|
||||
});
|
||||
|
||||
@ -16,6 +16,7 @@ using MaksIT.CertsUI.Engine.Dto.LetsEncrypt.Responses;
|
||||
using MaksIT.Results;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
@ -24,15 +25,15 @@ using System.Text;
|
||||
namespace MaksIT.CertsUI.Engine.Services;
|
||||
|
||||
public interface ILetsEncryptService {
|
||||
Result<RegistrationCache?> GetRegistrationCache(Guid sessionId);
|
||||
Task<Result> ConfigureClient(Guid sessionId, bool isStaging);
|
||||
Task<Result> Init(Guid sessionId, Guid accountId, string description, string[] contacts, RegistrationCache? registrationCache);
|
||||
Result<string?> GetTermsOfServiceUri(Guid sessionId);
|
||||
Task<Result<Dictionary<string, string>?>> NewOrder(Guid sessionId, string[] hostnames, string challengeType);
|
||||
Task<Result> CompleteChallenges(Guid sessionId);
|
||||
Task<Result> GetOrder(Guid sessionId, string[] hostnames);
|
||||
Task<Result> GetCertificate(Guid sessionId, string subject);
|
||||
Task<Result> RevokeCertificate(Guid sessionId, string subject, RevokeReason reason);
|
||||
Task<Result<RegistrationCache?>> GetRegistrationCacheAsync(Guid sessionId, CancellationToken cancellationToken = default);
|
||||
Task<Result> ConfigureClient(Guid sessionId, bool isStaging, CancellationToken cancellationToken = default);
|
||||
Task<Result> Init(Guid sessionId, Guid accountId, string description, string[] contacts, RegistrationCache? registrationCache, CancellationToken cancellationToken = default);
|
||||
Task<Result<string?>> GetTermsOfServiceUriAsync(Guid sessionId, CancellationToken cancellationToken = default);
|
||||
Task<Result<Dictionary<string, string>?>> NewOrder(Guid sessionId, string[] hostnames, string challengeType, CancellationToken cancellationToken = default);
|
||||
Task<Result> CompleteChallenges(Guid sessionId, CancellationToken cancellationToken = default);
|
||||
Task<Result> GetOrder(Guid sessionId, string[] hostnames, CancellationToken cancellationToken = default);
|
||||
Task<Result> GetCertificate(Guid sessionId, string subject, CancellationToken cancellationToken = default);
|
||||
Task<Result> RevokeCertificate(Guid sessionId, string subject, RevokeReason reason, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public partial class LetsEncryptService : ILetsEncryptService {
|
||||
@ -44,78 +45,72 @@ public partial class LetsEncryptService : ILetsEncryptService {
|
||||
private readonly ILogger<LetsEncryptService> _logger;
|
||||
private readonly ICertsEngineConfiguration _engineConfiguration;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly AcmeSessionStore _sessions;
|
||||
private readonly IAcmeSessionStore _sessionStore;
|
||||
|
||||
public LetsEncryptService(
|
||||
ILogger<LetsEncryptService> logger,
|
||||
ICertsEngineConfiguration engineConfiguration,
|
||||
HttpClient httpClient,
|
||||
AcmeSessionStore sessions
|
||||
IAcmeSessionStore sessionStore
|
||||
) {
|
||||
_logger = logger;
|
||||
_engineConfiguration = engineConfiguration;
|
||||
_httpClient = httpClient;
|
||||
_sessions = sessions;
|
||||
_sessionStore = sessionStore;
|
||||
}
|
||||
|
||||
public Result<RegistrationCache?> GetRegistrationCache(Guid sessionId) {
|
||||
var state = GetOrCreateState(sessionId);
|
||||
|
||||
if (state.Cache == null)
|
||||
return Result<RegistrationCache?>.InternalServerError(null);
|
||||
|
||||
return Result<RegistrationCache?>.Ok(state.Cache);
|
||||
}
|
||||
public Task<Result<RegistrationCache?>> GetRegistrationCacheAsync(Guid sessionId, CancellationToken cancellationToken = default) =>
|
||||
WithPersistedSessionAsync<RegistrationCache?>(sessionId, cancellationToken, async state => {
|
||||
if (state.Cache == null)
|
||||
return Result<RegistrationCache?>.InternalServerError(null);
|
||||
return Result<RegistrationCache?>.Ok(state.Cache);
|
||||
});
|
||||
|
||||
#region ConfigureClient
|
||||
public async Task<Result> ConfigureClient(Guid sessionId, bool isStaging) {
|
||||
try {
|
||||
var state = GetOrCreateState(sessionId);
|
||||
public Task<Result> ConfigureClient(Guid sessionId, bool isStaging, CancellationToken cancellationToken = default) =>
|
||||
WithPersistedSessionAsync(sessionId, cancellationToken, async state => {
|
||||
try {
|
||||
state.IsStaging = isStaging;
|
||||
|
||||
state.IsStaging = isStaging;
|
||||
if (state.Directory == null) {
|
||||
var directoryUri = AcmeDirectoryAbsoluteUri(isStaging);
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, directoryUri);
|
||||
|
||||
_httpClient.BaseAddress ??= new Uri(isStaging ? _engineConfiguration.LetsEncryptStaging : _engineConfiguration.LetsEncryptProduction);
|
||||
var requestResult = await SendAcmeRequest<AcmeDirectory>(request, state, HttpMethod.Get);
|
||||
if (!requestResult.IsSuccess || requestResult.Value == null)
|
||||
return requestResult;
|
||||
|
||||
if (state.Directory == null) {
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, new Uri(DirectoryEndpoint, UriKind.Relative));
|
||||
var directory = requestResult.Value;
|
||||
|
||||
var requestResult = await SendAcmeRequest<AcmeDirectory>(request, state, HttpMethod.Get);
|
||||
if (!requestResult.IsSuccess || requestResult.Value == null)
|
||||
return requestResult;
|
||||
state.Directory = directory.Result ?? throw new InvalidOperationException("Directory response is null");
|
||||
}
|
||||
|
||||
var directory = requestResult.Value;
|
||||
|
||||
state.Directory = directory.Result ?? throw new InvalidOperationException("Directory response is null");
|
||||
return Result.Ok("Client configured successfully.");
|
||||
}
|
||||
|
||||
return Result.Ok("Client configured successfully.");
|
||||
}
|
||||
catch (LetsEncrytException ex) {
|
||||
var state = GetOrCreateState(sessionId);
|
||||
return MapLetsEncryptException(state, ex);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
return HandleUnhandledException(ex);
|
||||
}
|
||||
}
|
||||
catch (LetsEncrytException ex) {
|
||||
return MapLetsEncryptException(state, ex);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
return HandleUnhandledException(ex);
|
||||
}
|
||||
});
|
||||
#endregion
|
||||
|
||||
#region Init
|
||||
public async Task<Result> Init(Guid sessionId, Guid accountId, string description, string[] contacts, RegistrationCache? cache) {
|
||||
public Task<Result> Init(Guid sessionId, Guid accountId, string description, string[] contacts, RegistrationCache? cache, CancellationToken cancellationToken = default) {
|
||||
if (sessionId == Guid.Empty) {
|
||||
const string message = "Invalid sessionId";
|
||||
_logger.LogError(message);
|
||||
return Result.InternalServerError(message);
|
||||
return Task.FromResult(Result.InternalServerError(message));
|
||||
}
|
||||
|
||||
if (contacts == null || contacts.Length == 0) {
|
||||
const string message = "Contacts are null or empty";
|
||||
_logger.LogError(message);
|
||||
return Result.InternalServerError(message);
|
||||
return Task.FromResult(Result.InternalServerError(message));
|
||||
}
|
||||
|
||||
var state = GetOrCreateState(sessionId);
|
||||
|
||||
return WithPersistedSessionAsync(sessionId, cancellationToken, async state => {
|
||||
if (state.Directory == null) {
|
||||
const string message = "State directory is null";
|
||||
_logger.LogError(message);
|
||||
@ -159,13 +154,13 @@ public partial class LetsEncryptService : ILetsEncryptService {
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, newAccountUri);
|
||||
|
||||
var nonceResult = await GetNonceAsync(sessionId, newAccountUri);
|
||||
var nonceResult = await GetNonceAsync(state, newAccountUri);
|
||||
if (!nonceResult.IsSuccess || nonceResult.Value == null)
|
||||
return nonceResult;
|
||||
|
||||
var nonce = nonceResult.Value;
|
||||
|
||||
var jsonResult = EncodeMessage(sessionId, false, letsEncryptOrder, new ACMEJwsHeader {
|
||||
var jsonResult = EncodeMessage(state, false, letsEncryptOrder, new ACMEJwsHeader {
|
||||
Url = newAccountUri.ToString(),
|
||||
Nonce = nonce
|
||||
});
|
||||
@ -212,236 +207,235 @@ public partial class LetsEncryptService : ILetsEncryptService {
|
||||
catch (Exception ex) {
|
||||
return HandleUnhandledException(ex);
|
||||
}
|
||||
});
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region GetTermsOfService
|
||||
public Result<string?> GetTermsOfServiceUri(Guid sessionId) {
|
||||
try {
|
||||
var state = GetOrCreateState(sessionId);
|
||||
public Task<Result<string?>> GetTermsOfServiceUriAsync(Guid sessionId, CancellationToken cancellationToken = default) =>
|
||||
WithPersistedSessionAsync<string?>(sessionId, cancellationToken, async state => {
|
||||
try {
|
||||
_logger.LogInformation($"Executing {nameof(GetTermsOfServiceUriAsync)}...");
|
||||
|
||||
_logger.LogInformation($"Executing {nameof(GetTermsOfServiceUri)}...");
|
||||
if (state.Directory?.Meta?.TermsOfService == null) {
|
||||
return Result<string?>.Ok(null);
|
||||
}
|
||||
|
||||
if (state.Directory?.Meta?.TermsOfService == null) {
|
||||
return Result<string?>.Ok(null);
|
||||
return Result<string?>.Ok(state.Directory.Meta.TermsOfService);
|
||||
}
|
||||
|
||||
return Result<string?>.Ok(state.Directory.Meta.TermsOfService);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
return HandleUnhandledException<string?>(ex);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) {
|
||||
return HandleUnhandledException<string?>(ex);
|
||||
}
|
||||
});
|
||||
#endregion
|
||||
|
||||
#region NewOrder
|
||||
public async Task<Result<Dictionary<string, string>?>> NewOrder(Guid sessionId, string[] hostnames, string challengeType) {
|
||||
try {
|
||||
var state = GetOrCreateState(sessionId);
|
||||
public Task<Result<Dictionary<string, string>?>> NewOrder(Guid sessionId, string[] hostnames, string challengeType, CancellationToken cancellationToken = default) =>
|
||||
WithPersistedSessionAsync<Dictionary<string, string>?>(sessionId, cancellationToken, async state => {
|
||||
try {
|
||||
_logger.LogInformation($"Executing {nameof(NewOrder)}...");
|
||||
|
||||
_logger.LogInformation($"Executing {nameof(NewOrder)}...");
|
||||
state.Challenges.Clear();
|
||||
|
||||
state.Challenges.Clear();
|
||||
var letsEncryptOrder = new Order {
|
||||
Expires = DateTime.UtcNow.AddDays(2),
|
||||
Identifiers = hostnames?.Where(h => h != null).Select(hostname => new OrderIdentifier {
|
||||
Type = DnsType,
|
||||
Value = hostname ?? string.Empty
|
||||
}).ToArray() ?? []
|
||||
};
|
||||
|
||||
var letsEncryptOrder = new Order {
|
||||
Expires = DateTime.UtcNow.AddDays(2),
|
||||
Identifiers = hostnames?.Where(h => h != null).Select(hostname => new OrderIdentifier {
|
||||
Type = DnsType,
|
||||
Value = hostname ?? string.Empty
|
||||
}).ToArray() ?? []
|
||||
};
|
||||
if (state.Directory?.NewOrder is not { } newOrderUri)
|
||||
return Result<Dictionary<string, string>?>.InternalServerError(null);
|
||||
|
||||
if (state.Directory?.NewOrder is not { } newOrderUri)
|
||||
return Result<Dictionary<string, string>?>.InternalServerError(null);
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, newOrderUri);
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, newOrderUri);
|
||||
|
||||
var nonceResult = await GetNonceAsync(sessionId, newOrderUri);
|
||||
if (!nonceResult.IsSuccess || nonceResult.Value == null)
|
||||
return nonceResult.ToResultOfType<Dictionary<string, string>?>(_ => null);
|
||||
|
||||
var nonce = nonceResult.Value;
|
||||
|
||||
var jsonResult = EncodeMessage(sessionId, false, letsEncryptOrder, new ACMEJwsHeader {
|
||||
Url = newOrderUri.ToString(),
|
||||
Nonce = nonce
|
||||
});
|
||||
|
||||
if (!jsonResult.IsSuccess || jsonResult.Value == null)
|
||||
return jsonResult.ToResultOfType<Dictionary<string, string>?>(_ => null);
|
||||
|
||||
var json = jsonResult.Value;
|
||||
|
||||
PrepareRequestContent(request, json, HttpMethod.Post);
|
||||
|
||||
var requestResult = await SendAcmeRequest<Order>(request, state, HttpMethod.Post);
|
||||
if (!requestResult.IsSuccess || requestResult.Value == null)
|
||||
return requestResult.ToResultOfType<Dictionary<string, string>?>(_ => null);
|
||||
|
||||
var order = requestResult.Value;
|
||||
|
||||
if (StatusEquals(order.Result?.Status, OrderStatus.Ready))
|
||||
return Result<Dictionary<string, string>?>.Ok(new Dictionary<string, string>());
|
||||
|
||||
if (!StatusEquals(order.Result?.Status, OrderStatus.Pending)) {
|
||||
_logger.LogError($"Created new order and expected status '{OrderStatus.Pending.GetDisplayName()}', but got: {order.Result?.Status} \r\n {order.Result}");
|
||||
return Result<Dictionary<string, string>?>.InternalServerError(null);
|
||||
}
|
||||
|
||||
state.CurrentOrder = order.Result;
|
||||
|
||||
var results = new Dictionary<string, string>();
|
||||
|
||||
foreach (var item in state.CurrentOrder?.Authorizations ?? Array.Empty<Uri>()) {
|
||||
if (item == null)
|
||||
continue;
|
||||
|
||||
request = new HttpRequestMessage(HttpMethod.Post, item);
|
||||
|
||||
nonceResult = await GetNonceAsync(sessionId, item);
|
||||
var nonceResult = await GetNonceAsync(state, newOrderUri);
|
||||
if (!nonceResult.IsSuccess || nonceResult.Value == null)
|
||||
return nonceResult.ToResultOfType<Dictionary<string, string>?>(_ => null);
|
||||
|
||||
nonce = nonceResult.Value;
|
||||
var nonce = nonceResult.Value;
|
||||
|
||||
jsonResult = EncodeMessage(sessionId, true, null, new ACMEJwsHeader {
|
||||
Url = item.ToString(),
|
||||
var jsonResult = EncodeMessage(state, false, letsEncryptOrder, new ACMEJwsHeader {
|
||||
Url = newOrderUri.ToString(),
|
||||
Nonce = nonce
|
||||
});
|
||||
|
||||
if (!jsonResult.IsSuccess || jsonResult.Value == null)
|
||||
return jsonResult.ToResultOfType<Dictionary<string, string>?>(_ => null);
|
||||
|
||||
json = jsonResult.Value;
|
||||
|
||||
PrepareRequestContent(request, json, HttpMethod.Post);
|
||||
|
||||
var challengeResult = await SendAcmeRequest<AuthorizationChallengeResponse>(request, state, HttpMethod.Post);
|
||||
if (!challengeResult.IsSuccess || challengeResult.Value == null)
|
||||
return challengeResult.ToResultOfType<Dictionary<string, string>?>(_ => null);
|
||||
|
||||
var challengeResponse = challengeResult.Value;
|
||||
|
||||
if (StatusEquals(challengeResponse.Result?.Status, OrderStatus.Valid))
|
||||
continue;
|
||||
|
||||
if (!StatusEquals(challengeResponse.Result?.Status, OrderStatus.Pending)) {
|
||||
_logger.LogError($"Expected authorization status '{OrderStatus.Pending.GetDisplayName()}', but got: {challengeResponse.Result?.Status} \r\n {challengeResponse.ResponseText}");
|
||||
return Result<Dictionary<string, string>?>.InternalServerError(null);
|
||||
}
|
||||
|
||||
var challenge = challengeResponse.Result?.Challenges?
|
||||
.FirstOrDefault(x => x?.Type == challengeType);
|
||||
|
||||
if (challenge == null || challenge.Token == null) {
|
||||
_logger.LogError("Challenge or token is null");
|
||||
return Result<Dictionary<string, string>?>.InternalServerError(null);
|
||||
}
|
||||
|
||||
state.Challenges.Add(challenge);
|
||||
|
||||
if (state.Cache != null)
|
||||
state.Cache.ChallengeType = challengeType;
|
||||
|
||||
if (state.Jwk is null)
|
||||
return Result<Dictionary<string, string>?>.InternalServerError(null, AccountKeyMissingMessage);
|
||||
|
||||
if (!JwkThumbprintUtility.TryGetKeyAuthorization(state.Jwk, challenge.Token, out var keyToken, out var errorMessage))
|
||||
return Result<Dictionary<string, string>?>.InternalServerError(null, errorMessage);
|
||||
|
||||
switch (challengeType) {
|
||||
case "dns-01":
|
||||
using (var sha256 = SHA256.Create()) {
|
||||
var dnsToken = Base64UrlUtility.Encode(sha256.ComputeHash(Encoding.UTF8.GetBytes(keyToken ?? string.Empty)));
|
||||
|
||||
results[challengeResponse.Result?.Identifier?.Value ?? string.Empty] = dnsToken;
|
||||
}
|
||||
break;
|
||||
case "http-01":
|
||||
results[challengeResponse.Result?.Identifier?.Value ?? string.Empty] = keyToken ?? string.Empty;
|
||||
break;
|
||||
default:
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
|
||||
return Result<Dictionary<string, string>?>.Ok(results);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
return HandleUnhandledException<Dictionary<string, string>?>(ex);
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region CompleteChallenges
|
||||
public async Task<Result> CompleteChallenges(Guid sessionId) {
|
||||
try {
|
||||
var state = GetOrCreateState(sessionId);
|
||||
|
||||
_logger.LogInformation($"Executing {nameof(CompleteChallenges)}...");
|
||||
|
||||
if (state.CurrentOrder?.Identifiers == null) {
|
||||
return Result.InternalServerError("Current order identifiers are null");
|
||||
}
|
||||
|
||||
for (var index = 0; index < state.Challenges.Count; index++) {
|
||||
var challenge = state.Challenges[index];
|
||||
|
||||
if (challenge is null) {
|
||||
_logger.LogError("Challenge entry is null");
|
||||
return Result.InternalServerError("Challenge entry is null");
|
||||
}
|
||||
|
||||
if (challenge.Url is null) {
|
||||
_logger.LogError("Challenge URL is null");
|
||||
return Result.InternalServerError("Challenge URL is null");
|
||||
}
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, challenge.Url);
|
||||
|
||||
var nonceResult = await GetNonceAsync(sessionId, challenge.Url);
|
||||
if (!nonceResult.IsSuccess || nonceResult.Value == null)
|
||||
return nonceResult;
|
||||
|
||||
var nonce = nonceResult.Value;
|
||||
|
||||
var jsonResult = EncodeMessage(sessionId, false, "{}", new ACMEJwsHeader {
|
||||
Url = challenge.Url.ToString(),
|
||||
Nonce = nonce
|
||||
});
|
||||
|
||||
if (!jsonResult.IsSuccess || jsonResult.Value == null)
|
||||
return jsonResult;
|
||||
|
||||
var json = jsonResult.Value;
|
||||
|
||||
PrepareRequestContent(request, json, HttpMethod.Post);
|
||||
|
||||
_ = await SendAcmeRequest<AuthorizationChallengeResponse>(request, state, HttpMethod.Post);
|
||||
var requestResult = await SendAcmeRequest<Order>(request, state, HttpMethod.Post);
|
||||
if (!requestResult.IsSuccess || requestResult.Value == null)
|
||||
return requestResult.ToResultOfType<Dictionary<string, string>?>(_ => null);
|
||||
|
||||
var result = await PollChallengeStatus(sessionId, challenge);
|
||||
var order = requestResult.Value;
|
||||
|
||||
if (!result.IsSuccess)
|
||||
return result;
|
||||
if (StatusEquals(order.Result?.Status, OrderStatus.Ready))
|
||||
return Result<Dictionary<string, string>?>.Ok(new Dictionary<string, string>());
|
||||
|
||||
if (!StatusEquals(order.Result?.Status, OrderStatus.Pending)) {
|
||||
_logger.LogError($"Created new order and expected status '{OrderStatus.Pending.GetDisplayName()}', but got: {order.Result?.Status} \r\n {order.Result}");
|
||||
return Result<Dictionary<string, string>?>.InternalServerError(null);
|
||||
}
|
||||
|
||||
state.CurrentOrder = order.Result;
|
||||
|
||||
var results = new Dictionary<string, string>();
|
||||
|
||||
foreach (var item in state.CurrentOrder?.Authorizations ?? Array.Empty<Uri>()) {
|
||||
if (item == null)
|
||||
continue;
|
||||
|
||||
request = new HttpRequestMessage(HttpMethod.Post, item);
|
||||
|
||||
nonceResult = await GetNonceAsync(state, item);
|
||||
if (!nonceResult.IsSuccess || nonceResult.Value == null)
|
||||
return nonceResult.ToResultOfType<Dictionary<string, string>?>(_ => null);
|
||||
|
||||
nonce = nonceResult.Value;
|
||||
|
||||
jsonResult = EncodeMessage(state, true, null, new ACMEJwsHeader {
|
||||
Url = item.ToString(),
|
||||
Nonce = nonce
|
||||
});
|
||||
|
||||
if (!jsonResult.IsSuccess || jsonResult.Value == null)
|
||||
return jsonResult.ToResultOfType<Dictionary<string, string>?>(_ => null);
|
||||
|
||||
json = jsonResult.Value;
|
||||
|
||||
PrepareRequestContent(request, json, HttpMethod.Post);
|
||||
|
||||
var challengeResult = await SendAcmeRequest<AuthorizationChallengeResponse>(request, state, HttpMethod.Post);
|
||||
if (!challengeResult.IsSuccess || challengeResult.Value == null)
|
||||
return challengeResult.ToResultOfType<Dictionary<string, string>?>(_ => null);
|
||||
|
||||
var challengeResponse = challengeResult.Value;
|
||||
|
||||
if (StatusEquals(challengeResponse.Result?.Status, OrderStatus.Valid))
|
||||
continue;
|
||||
|
||||
if (!StatusEquals(challengeResponse.Result?.Status, OrderStatus.Pending)) {
|
||||
_logger.LogError($"Expected authorization status '{OrderStatus.Pending.GetDisplayName()}', but got: {challengeResponse.Result?.Status} \r\n {challengeResponse.ResponseText}");
|
||||
return Result<Dictionary<string, string>?>.InternalServerError(null);
|
||||
}
|
||||
|
||||
var challenge = challengeResponse.Result?.Challenges?
|
||||
.FirstOrDefault(x => x?.Type == challengeType);
|
||||
|
||||
if (challenge == null || challenge.Token == null) {
|
||||
_logger.LogError("Challenge or token is null");
|
||||
return Result<Dictionary<string, string>?>.InternalServerError(null);
|
||||
}
|
||||
|
||||
state.Challenges.Add(challenge);
|
||||
|
||||
if (state.Cache != null)
|
||||
state.Cache.ChallengeType = challengeType;
|
||||
|
||||
if (state.Jwk is null)
|
||||
return Result<Dictionary<string, string>?>.InternalServerError(null, AccountKeyMissingMessage);
|
||||
|
||||
if (!JwkThumbprintUtility.TryGetKeyAuthorization(state.Jwk, challenge.Token, out var keyToken, out var errorMessage))
|
||||
return Result<Dictionary<string, string>?>.InternalServerError(null, errorMessage);
|
||||
|
||||
switch (challengeType) {
|
||||
case "dns-01":
|
||||
using (var sha256 = SHA256.Create()) {
|
||||
var dnsToken = Base64UrlUtility.Encode(sha256.ComputeHash(Encoding.UTF8.GetBytes(keyToken ?? string.Empty)));
|
||||
|
||||
results[challengeResponse.Result?.Identifier?.Value ?? string.Empty] = dnsToken;
|
||||
}
|
||||
break;
|
||||
case "http-01":
|
||||
results[challengeResponse.Result?.Identifier?.Value ?? string.Empty] = keyToken ?? string.Empty;
|
||||
break;
|
||||
default:
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
|
||||
return Result<Dictionary<string, string>?>.Ok(results);
|
||||
}
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (LetsEncrytException ex) {
|
||||
return MapLetsEncryptException(GetOrCreateState(sessionId), ex);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
return HandleUnhandledException(ex);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) {
|
||||
return HandleUnhandledException<Dictionary<string, string>?>(ex);
|
||||
}
|
||||
});
|
||||
#endregion
|
||||
|
||||
#region CompleteChallenges
|
||||
public Task<Result> CompleteChallenges(Guid sessionId, CancellationToken cancellationToken = default) =>
|
||||
WithPersistedSessionAsync(sessionId, cancellationToken, async state => {
|
||||
try {
|
||||
_logger.LogInformation($"Executing {nameof(CompleteChallenges)}...");
|
||||
|
||||
if (state.CurrentOrder?.Identifiers == null) {
|
||||
return Result.InternalServerError("Current order identifiers are null");
|
||||
}
|
||||
|
||||
for (var index = 0; index < state.Challenges.Count; index++) {
|
||||
var challenge = state.Challenges[index];
|
||||
|
||||
if (challenge is null) {
|
||||
_logger.LogError("Challenge entry is null");
|
||||
return Result.InternalServerError("Challenge entry is null");
|
||||
}
|
||||
|
||||
if (challenge.Url is null) {
|
||||
_logger.LogError("Challenge URL is null");
|
||||
return Result.InternalServerError("Challenge URL is null");
|
||||
}
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, challenge.Url);
|
||||
|
||||
var nonceResult = await GetNonceAsync(state, challenge.Url);
|
||||
if (!nonceResult.IsSuccess || nonceResult.Value == null)
|
||||
return nonceResult;
|
||||
|
||||
var nonce = nonceResult.Value;
|
||||
|
||||
var jsonResult = EncodeMessage(state, false, "{}", new ACMEJwsHeader {
|
||||
Url = challenge.Url.ToString(),
|
||||
Nonce = nonce
|
||||
});
|
||||
|
||||
if (!jsonResult.IsSuccess || jsonResult.Value == null)
|
||||
return jsonResult;
|
||||
|
||||
var json = jsonResult.Value;
|
||||
|
||||
PrepareRequestContent(request, json, HttpMethod.Post);
|
||||
|
||||
_ = await SendAcmeRequest<AuthorizationChallengeResponse>(request, state, HttpMethod.Post);
|
||||
|
||||
var result = await PollChallengeStatus(state, challenge);
|
||||
|
||||
if (!result.IsSuccess)
|
||||
return result;
|
||||
}
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (LetsEncrytException ex) {
|
||||
return MapLetsEncryptException(state, ex);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
return HandleUnhandledException(ex);
|
||||
}
|
||||
});
|
||||
#endregion
|
||||
|
||||
#region GetOrder
|
||||
public async Task<Result> GetOrder(Guid sessionId, string[] hostnames) {
|
||||
public Task<Result> GetOrder(Guid sessionId, string[] hostnames, CancellationToken cancellationToken = default) =>
|
||||
WithPersistedSessionAsync(sessionId, cancellationToken, state => GetOrderCoreAsync(state, hostnames));
|
||||
|
||||
private async Task<Result> GetOrderCoreAsync(State state, string[] hostnames) {
|
||||
try {
|
||||
_logger.LogInformation($"Executing {nameof(GetOrder)}");
|
||||
|
||||
var state = GetOrCreateState(sessionId);
|
||||
|
||||
if (state.Directory?.NewOrder is not { } newOrderUri)
|
||||
return Result.InternalServerError("Directory is not configured. Run ConfigureClient first.");
|
||||
|
||||
@ -455,13 +449,13 @@ public partial class LetsEncryptService : ILetsEncryptService {
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, newOrderUri);
|
||||
|
||||
var nonceResult = await GetNonceAsync(sessionId, newOrderUri);
|
||||
var nonceResult = await GetNonceAsync(state, newOrderUri);
|
||||
if (!nonceResult.IsSuccess || nonceResult.Value == null)
|
||||
return nonceResult;
|
||||
|
||||
var nonce = nonceResult.Value;
|
||||
|
||||
var jsonResult = EncodeMessage(sessionId, false, letsEncryptOrder, new ACMEJwsHeader {
|
||||
var jsonResult = EncodeMessage(state, false, letsEncryptOrder, new ACMEJwsHeader {
|
||||
Url = newOrderUri.ToString(),
|
||||
Nonce = nonce
|
||||
});
|
||||
@ -490,10 +484,9 @@ public partial class LetsEncryptService : ILetsEncryptService {
|
||||
#endregion
|
||||
|
||||
#region GetCertificates
|
||||
public async Task<Result> GetCertificate(Guid sessionId, string subject) {
|
||||
try {
|
||||
var state = GetOrCreateState(sessionId);
|
||||
|
||||
public Task<Result> GetCertificate(Guid sessionId, string subject, CancellationToken cancellationToken = default) =>
|
||||
WithPersistedSessionAsync(sessionId, cancellationToken, async state => {
|
||||
try {
|
||||
_logger.LogInformation($"Executing {nameof(GetCertificate)}...");
|
||||
|
||||
if (state.CurrentOrder?.Identifiers is not { } initialIdentifiers)
|
||||
@ -525,7 +518,7 @@ public partial class LetsEncryptService : ILetsEncryptService {
|
||||
|
||||
var hostnames = idents.Select(x => x?.Value).Where(x => x != null).Cast<string>().ToArray();
|
||||
|
||||
await GetOrder(sessionId, hostnames);
|
||||
await GetOrderCoreAsync(state, hostnames);
|
||||
|
||||
activeOrder = state.CurrentOrder;
|
||||
if (activeOrder is null)
|
||||
@ -539,13 +532,13 @@ public partial class LetsEncryptService : ILetsEncryptService {
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, finalizeUri);
|
||||
|
||||
var nonceResult = await GetNonceAsync(sessionId, finalizeUri);
|
||||
var nonceResult = await GetNonceAsync(state, finalizeUri);
|
||||
if (!nonceResult.IsSuccess || nonceResult.Value == null)
|
||||
return nonceResult;
|
||||
|
||||
var nonce = nonceResult.Value;
|
||||
|
||||
var jsonResult = EncodeMessage(sessionId, false, letsEncryptOrder, new ACMEJwsHeader {
|
||||
var jsonResult = EncodeMessage(state, false, letsEncryptOrder, new ACMEJwsHeader {
|
||||
Url = finalizeUri.ToString(),
|
||||
Nonce = nonce
|
||||
});
|
||||
@ -570,13 +563,13 @@ public partial class LetsEncryptService : ILetsEncryptService {
|
||||
|
||||
request = new HttpRequestMessage(HttpMethod.Post, orderLocation);
|
||||
|
||||
nonceResult = await GetNonceAsync(sessionId, orderLocation);
|
||||
nonceResult = await GetNonceAsync(state, orderLocation);
|
||||
if (!nonceResult.IsSuccess || nonceResult.Value == null)
|
||||
return nonceResult;
|
||||
|
||||
nonce = nonceResult.Value;
|
||||
|
||||
jsonResult = EncodeMessage(sessionId, true, null, new ACMEJwsHeader {
|
||||
jsonResult = EncodeMessage(state, true, null, new ACMEJwsHeader {
|
||||
Url = orderLocation.ToString(),
|
||||
Nonce = nonce
|
||||
});
|
||||
@ -619,13 +612,13 @@ public partial class LetsEncryptService : ILetsEncryptService {
|
||||
|
||||
var finalRequest = new HttpRequestMessage(HttpMethod.Post, certificateUrl);
|
||||
|
||||
var finalNonceResult = await GetNonceAsync(sessionId, certificateUrl);
|
||||
var finalNonceResult = await GetNonceAsync(state, certificateUrl);
|
||||
if (!finalNonceResult.IsSuccess || finalNonceResult.Value == null)
|
||||
return finalNonceResult;
|
||||
|
||||
var finalNonce = finalNonceResult.Value;
|
||||
|
||||
var finalJsonResult = EncodeMessage(sessionId, true, null, new ACMEJwsHeader {
|
||||
var finalJsonResult = EncodeMessage(state, true, null, new ACMEJwsHeader {
|
||||
Url = certificateUrl.ToString(),
|
||||
Nonce = finalNonce
|
||||
});
|
||||
@ -661,12 +654,12 @@ public partial class LetsEncryptService : ILetsEncryptService {
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (LetsEncrytException ex) {
|
||||
return MapLetsEncryptException(GetOrCreateState(sessionId), ex);
|
||||
return MapLetsEncryptException(state, ex);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
return HandleUnhandledException(ex);
|
||||
}
|
||||
}
|
||||
});
|
||||
#endregion
|
||||
|
||||
#region Key change
|
||||
@ -676,91 +669,105 @@ public partial class LetsEncryptService : ILetsEncryptService {
|
||||
#endregion
|
||||
|
||||
#region RevokeCertificate
|
||||
public async Task<Result> RevokeCertificate(Guid sessionId, string subject, RevokeReason reason) {
|
||||
try {
|
||||
var state = GetOrCreateState(sessionId);
|
||||
|
||||
_logger.LogInformation($"Executing {nameof(RevokeCertificate)}...");
|
||||
|
||||
if (state.Cache?.CachedCerts == null || !state.Cache.CachedCerts.TryGetValue(subject, out var certificateCache) || certificateCache == null) {
|
||||
_logger.LogError("Certificate not found in cache");
|
||||
return Result.InternalServerError("Certificate not found");
|
||||
}
|
||||
|
||||
var certPem = certificateCache.Cert ?? string.Empty;
|
||||
|
||||
if (string.IsNullOrEmpty(certPem)) {
|
||||
_logger.LogError("Certificate PEM is null or empty");
|
||||
return Result.InternalServerError("Certificate PEM is null or empty");
|
||||
}
|
||||
|
||||
var certificate = X509Certificate2.CreateFromPem(certPem);
|
||||
|
||||
var derEncodedCert = certificate.Export(X509ContentType.Cert);
|
||||
|
||||
var base64UrlEncodedCert = Base64UrlUtility.Encode(derEncodedCert);
|
||||
|
||||
var revokeRequest = new RevokeRequest {
|
||||
Certificate = base64UrlEncodedCert,
|
||||
Reason = (int)reason
|
||||
};
|
||||
|
||||
if (state.Directory?.RevokeCert is not { } revokeUri)
|
||||
return Result.InternalServerError("Directory is not configured or RevokeCert URL is missing.");
|
||||
|
||||
if (!state.TryGetAccountKey(out var rsa, out var jwk))
|
||||
return Result.InternalServerError(AccountKeyMissingMessage);
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, revokeUri);
|
||||
|
||||
var nonceResult = await GetNonceAsync(sessionId, revokeUri);
|
||||
if (!nonceResult.IsSuccess || nonceResult.Value == null)
|
||||
return nonceResult;
|
||||
|
||||
var nonce = nonceResult.Value;
|
||||
|
||||
var jwsHeader = new ACMEJwsHeader {
|
||||
Url = revokeUri.ToString(),
|
||||
Nonce = nonce
|
||||
};
|
||||
|
||||
if (!JwsGenerator.TryEncode(rsa, jwk, jwsHeader, revokeRequest, out var jwsMessage, out var errorMessage)) {
|
||||
return Result.InternalServerError(errorMessage);
|
||||
}
|
||||
|
||||
var json = jwsMessage.ToJson();
|
||||
|
||||
request.Content = new StringContent(json);
|
||||
|
||||
request.Content.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(ContentType.JoseJson));
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
var responseText = await response.Content.ReadAsStringAsync();
|
||||
|
||||
HandleProblemResponseAsync(response, responseText);
|
||||
|
||||
public Task<Result> RevokeCertificate(Guid sessionId, string subject, RevokeReason reason, CancellationToken cancellationToken = default) =>
|
||||
WithPersistedSessionAsync(sessionId, cancellationToken, async state => {
|
||||
try {
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return Result.InternalServerError(responseText);
|
||||
_logger.LogInformation($"Executing {nameof(RevokeCertificate)}...");
|
||||
|
||||
state.Cache.CachedCerts.Remove(subject);
|
||||
_logger.LogInformation("Certificate revoked successfully");
|
||||
if (state.Cache?.CachedCerts == null || !state.Cache.CachedCerts.TryGetValue(subject, out var certificateCache) || certificateCache == null) {
|
||||
_logger.LogError("Certificate not found in cache");
|
||||
return Result.InternalServerError("Certificate not found");
|
||||
}
|
||||
|
||||
return Result.Ok();
|
||||
var certPem = certificateCache.Cert ?? string.Empty;
|
||||
|
||||
if (string.IsNullOrEmpty(certPem)) {
|
||||
_logger.LogError("Certificate PEM is null or empty");
|
||||
return Result.InternalServerError("Certificate PEM is null or empty");
|
||||
}
|
||||
|
||||
var certificate = X509Certificate2.CreateFromPem(certPem);
|
||||
|
||||
var derEncodedCert = certificate.Export(X509ContentType.Cert);
|
||||
|
||||
var base64UrlEncodedCert = Base64UrlUtility.Encode(derEncodedCert);
|
||||
|
||||
var revokeRequest = new RevokeRequest {
|
||||
Certificate = base64UrlEncodedCert,
|
||||
Reason = (int)reason
|
||||
};
|
||||
|
||||
if (state.Directory?.RevokeCert is not { } revokeUri)
|
||||
return Result.InternalServerError("Directory is not configured or RevokeCert URL is missing.");
|
||||
|
||||
if (!state.TryGetAccountKey(out var rsa, out var jwk))
|
||||
return Result.InternalServerError(AccountKeyMissingMessage);
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, revokeUri);
|
||||
|
||||
var nonceResult = await GetNonceAsync(state, revokeUri);
|
||||
if (!nonceResult.IsSuccess || nonceResult.Value == null)
|
||||
return nonceResult;
|
||||
|
||||
var nonce = nonceResult.Value;
|
||||
|
||||
var jwsHeader = new ACMEJwsHeader {
|
||||
Url = revokeUri.ToString(),
|
||||
Nonce = nonce
|
||||
};
|
||||
|
||||
if (!JwsGenerator.TryEncode(rsa, jwk, jwsHeader, revokeRequest, out var jwsMessage, out var errorMessage)) {
|
||||
return Result.InternalServerError(errorMessage);
|
||||
}
|
||||
|
||||
var json = jwsMessage.ToJson();
|
||||
|
||||
request.Content = new StringContent(json);
|
||||
|
||||
request.Content.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(ContentType.JoseJson));
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
var responseText = await response.Content.ReadAsStringAsync();
|
||||
|
||||
HandleProblemResponseAsync(response, responseText);
|
||||
|
||||
try {
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return Result.InternalServerError(responseText);
|
||||
|
||||
state.Cache.CachedCerts.Remove(subject);
|
||||
_logger.LogInformation("Certificate revoked successfully");
|
||||
|
||||
return Result.Ok();
|
||||
}
|
||||
finally {
|
||||
response.Dispose();
|
||||
}
|
||||
}
|
||||
finally {
|
||||
response.Dispose();
|
||||
catch (LetsEncrytException ex) {
|
||||
return MapLetsEncryptException(state, ex);
|
||||
}
|
||||
|
||||
}
|
||||
catch (LetsEncrytException ex) {
|
||||
var state = GetOrCreateState(sessionId);
|
||||
return MapLetsEncryptException(state, ex);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
return HandleUnhandledException(ex);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) {
|
||||
return HandleUnhandledException(ex);
|
||||
}
|
||||
});
|
||||
#endregion
|
||||
|
||||
private Uri AcmeDirectoryAbsoluteUri(bool isStaging) {
|
||||
var configured = (isStaging ? _engineConfiguration.LetsEncryptStaging : _engineConfiguration.LetsEncryptProduction).Trim();
|
||||
if (string.IsNullOrWhiteSpace(configured))
|
||||
throw new InvalidOperationException("Let's Encrypt directory URL is empty.");
|
||||
|
||||
if (Uri.TryCreate(configured, UriKind.Absolute, out var absolute)) {
|
||||
// Config already points to the ACME directory endpoint.
|
||||
if (absolute.AbsolutePath.TrimEnd('/').EndsWith($"/{DirectoryEndpoint}", StringComparison.OrdinalIgnoreCase))
|
||||
return absolute;
|
||||
|
||||
// Backward compatibility: treat configured value as ACME base URL.
|
||||
return new Uri(absolute, $"{DirectoryEndpoint}");
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Invalid Let's Encrypt URL: '{configured}'.");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -4,23 +4,14 @@ using Microsoft.Extensions.Options;
|
||||
namespace MaksIT.CertsUI.Tests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a disposable temp workspace and <see cref="IOptions{Configuration}"/> with valid auth and paths.
|
||||
/// Creates <see cref="IOptions{Configuration}"/> with valid auth and agent settings for API/domain tests.
|
||||
/// </summary>
|
||||
public sealed class WebApiTestFixture : IDisposable
|
||||
{
|
||||
public string Root { get; }
|
||||
public IOptions<Configuration> AppOptions { get; }
|
||||
|
||||
public WebApiTestFixture()
|
||||
{
|
||||
Root = Path.Combine(Path.GetTempPath(), "maksit-webapi-tests-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(Root);
|
||||
|
||||
var dataFolder = Path.Combine(Root, "data");
|
||||
Directory.CreateDirectory(dataFolder);
|
||||
var acmeFolder = Path.Combine(Root, "acme");
|
||||
Directory.CreateDirectory(acmeFolder);
|
||||
|
||||
var configuration = new Configuration
|
||||
{
|
||||
CertsUIEngineConfiguration = new CertsUIEngineConfiguration
|
||||
@ -47,8 +38,6 @@ public sealed class WebApiTestFixture : IDisposable
|
||||
},
|
||||
Production = "https://acme-v02.api.letsencrypt.org/directory",
|
||||
Staging = "https://acme-staging-v02.api.letsencrypt.org/directory",
|
||||
AcmeFolder = acmeFolder,
|
||||
DataFolder = dataFolder,
|
||||
Agent = new Agent
|
||||
{
|
||||
AgentHostname = "http://127.0.0.1",
|
||||
@ -62,16 +51,5 @@ public sealed class WebApiTestFixture : IDisposable
|
||||
AppOptions = Microsoft.Extensions.Options.Options.Create(configuration);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(Root))
|
||||
Directory.Delete(Root, recursive: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// best-effort cleanup of temp dir
|
||||
}
|
||||
}
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
@ -2,21 +2,19 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.5" />
|
||||
<PackageReference Include="coverlet.collector" Version="8.0.1">
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.7" />
|
||||
<PackageReference Include="coverlet.collector" Version="10.0.0">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.5.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="Testcontainers.PostgreSql" Version="4.9.0" />
|
||||
<PackageReference Include="Testcontainers.PostgreSql" Version="4.11.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
||||
@ -17,8 +17,6 @@ namespace MaksIT.CertsUI.Tests.Services;
|
||||
public sealed class CertsFlowServiceTests
|
||||
{
|
||||
private sealed class TestCertsFlowEngineConfiguration(WebApiTestFixture fx) : ICertsFlowEngineConfiguration {
|
||||
public string AcmeFolder => fx.AppOptions.Value.CertsUIEngineConfiguration.AcmeFolder;
|
||||
public string DataFolder => fx.AppOptions.Value.CertsUIEngineConfiguration.DataFolder;
|
||||
public string AgentServiceToReload => fx.AppOptions.Value.CertsUIEngineConfiguration.Agent.ServiceToReload;
|
||||
}
|
||||
|
||||
@ -31,8 +29,7 @@ public sealed class CertsFlowServiceTests
|
||||
Mock<IAcmeHttpChallengePersistenceService>? httpChallenges = null,
|
||||
Mock<IRuntimeLeaseService>? runtimeLease = null,
|
||||
Mock<IRuntimeInstanceId>? runtimeInstance = null,
|
||||
HttpMessageHandler? httpHandler = null,
|
||||
Mock<IPrimaryReplicaWorkload>? primaryReplica = null)
|
||||
HttpMessageHandler? httpHandler = null)
|
||||
{
|
||||
registrationCache ??= new Mock<IRegistrationCachePersistanceService>();
|
||||
agent ??= new Mock<IAgentDeploymentService>();
|
||||
@ -66,9 +63,6 @@ public sealed class CertsFlowServiceTests
|
||||
runtimeInstance ??= new Mock<IRuntimeInstanceId>();
|
||||
if (!runtimeInstanceProvided)
|
||||
runtimeInstance.Setup(i => i.InstanceId).Returns("test-instance");
|
||||
var primaryWorkload = primaryReplica ?? new Mock<IPrimaryReplicaWorkload>();
|
||||
if (primaryReplica is null)
|
||||
primaryWorkload.Setup(p => p.IsPrimary).Returns(true);
|
||||
var handler = httpHandler ?? new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent([0x25, 0x50, 0x44, 0x46]) });
|
||||
var httpClient = new HttpClient(handler, disposeHandler: true);
|
||||
return new CertsFlowDomainService(
|
||||
@ -81,8 +75,7 @@ public sealed class CertsFlowServiceTests
|
||||
termsOfServiceCache.Object,
|
||||
httpChallenges.Object,
|
||||
runtimeLease.Object,
|
||||
runtimeInstance.Object,
|
||||
primaryWorkload.Object);
|
||||
runtimeInstance.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@ -90,7 +83,7 @@ public sealed class CertsFlowServiceTests
|
||||
{
|
||||
using var fx = new WebApiTestFixture();
|
||||
var le = new Mock<ILetsEncryptService>();
|
||||
le.Setup(x => x.ConfigureClient(It.IsAny<Guid>(), false))
|
||||
le.Setup(x => x.ConfigureClient(It.IsAny<Guid>(), false, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Result.Ok());
|
||||
|
||||
var sut = CreateSut(fx, le);
|
||||
@ -101,51 +94,12 @@ public sealed class CertsFlowServiceTests
|
||||
Assert.NotNull(result.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigureClientAsync_WhenNotPrimary_ReturnsServiceUnavailableWithMarker()
|
||||
{
|
||||
using var fx = new WebApiTestFixture();
|
||||
var le = new Mock<ILetsEncryptService>();
|
||||
var primary = new Mock<IPrimaryReplicaWorkload>();
|
||||
primary.Setup(p => p.IsPrimary).Returns(false);
|
||||
var sut = CreateSut(fx, le, primaryReplica: primary);
|
||||
|
||||
var result = await sut.ConfigureClientAsync(isStaging: false);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Contains(CertsFlowPrimaryReplica.DiagnosticMarker, result.Messages ?? []);
|
||||
le.Verify(x => x.ConfigureClient(It.IsAny<Guid>(), It.IsAny<bool>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcmeChallenge_WhenNotPrimary_StillSucceedsFromDatabase()
|
||||
{
|
||||
using var fx = new WebApiTestFixture();
|
||||
var name = "challenge-token";
|
||||
var le = new Mock<ILetsEncryptService>();
|
||||
var primary = new Mock<IPrimaryReplicaWorkload>();
|
||||
primary.Setup(p => p.IsPrimary).Returns(false);
|
||||
var challenges = new Mock<IAcmeHttpChallengePersistenceService>();
|
||||
challenges.Setup(c => c.GetTokenValueAsync(name, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Result<string?>.Ok("body"));
|
||||
challenges.Setup(c => c.UpsertAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Result.Ok());
|
||||
challenges.Setup(c => c.DeleteOlderThanAsync(It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Result<int>.Ok(0));
|
||||
var sut = CreateSut(fx, le, httpChallenges: challenges, primaryReplica: primary);
|
||||
|
||||
var result = await sut.AcmeChallengeAsync(name, CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("body", result.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigureClientAsync_WhenConfigureFails_PropagatesFailure()
|
||||
{
|
||||
using var fx = new WebApiTestFixture();
|
||||
var le = new Mock<ILetsEncryptService>();
|
||||
le.Setup(x => x.ConfigureClient(It.IsAny<Guid>(), It.IsAny<bool>()))
|
||||
le.Setup(x => x.ConfigureClient(It.IsAny<Guid>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Result.InternalServerError(["configure failed"]));
|
||||
|
||||
var sut = CreateSut(fx, le);
|
||||
@ -161,7 +115,7 @@ public sealed class CertsFlowServiceTests
|
||||
using var fx = new WebApiTestFixture();
|
||||
var sessionId = Guid.NewGuid();
|
||||
var le = new Mock<ILetsEncryptService>();
|
||||
le.Setup(x => x.Init(sessionId, It.IsAny<Guid>(), "d", It.Is<string[]>(c => c.Length == 1 && c[0] == "mailto:a@b"), null))
|
||||
le.Setup(x => x.Init(sessionId, It.IsAny<Guid>(), "d", It.Is<string[]>(c => c.Length == 1 && c[0] == "mailto:a@b"), null, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Result.Ok());
|
||||
|
||||
var sut = CreateSut(fx, le);
|
||||
@ -170,7 +124,7 @@ public sealed class CertsFlowServiceTests
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.NotNull(result.Value);
|
||||
le.Verify(x => x.Init(sessionId, It.IsAny<Guid>(), "d", It.IsAny<string[]>(), null), Times.Once);
|
||||
le.Verify(x => x.Init(sessionId, It.IsAny<Guid>(), "d", It.IsAny<string[]>(), null, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@ -184,7 +138,7 @@ public sealed class CertsFlowServiceTests
|
||||
.ReturnsAsync(Result<RegistrationCache?>.InternalServerError(null, "missing"));
|
||||
|
||||
var le = new Mock<ILetsEncryptService>();
|
||||
le.Setup(x => x.Init(sessionId, It.IsAny<Guid>(), "d", It.IsAny<string[]>(), null))
|
||||
le.Setup(x => x.Init(sessionId, It.IsAny<Guid>(), "d", It.IsAny<string[]>(), null, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Result.Ok());
|
||||
|
||||
var sut = CreateSut(fx, le, cache);
|
||||
@ -214,7 +168,7 @@ public sealed class CertsFlowServiceTests
|
||||
.ReturnsAsync(Result<RegistrationCache?>.Ok(reg));
|
||||
|
||||
var le = new Mock<ILetsEncryptService>();
|
||||
le.Setup(x => x.Init(sessionId, accountId, "d", It.IsAny<string[]>(), reg))
|
||||
le.Setup(x => x.Init(sessionId, accountId, "d", It.IsAny<string[]>(), reg, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Result.Ok());
|
||||
|
||||
var sut = CreateSut(fx, le, cache);
|
||||
@ -231,7 +185,7 @@ public sealed class CertsFlowServiceTests
|
||||
using var fx = new WebApiTestFixture();
|
||||
var sessionId = Guid.NewGuid();
|
||||
var le = new Mock<ILetsEncryptService>();
|
||||
le.Setup(x => x.NewOrder(sessionId, It.IsAny<string[]>(), "http-01"))
|
||||
le.Setup(x => x.NewOrder(sessionId, It.IsAny<string[]>(), "http-01", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Result<Dictionary<string, string>?>.Ok(new Dictionary<string, string>
|
||||
{
|
||||
["example.com"] = "tokenPart.rest.of.token"
|
||||
@ -271,7 +225,7 @@ public sealed class CertsFlowServiceTests
|
||||
var result = await sut.NewOrderAsync(sessionId, ["example.com"], "http-01");
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
le.Verify(x => x.NewOrder(It.IsAny<Guid>(), It.IsAny<string[]>(), It.IsAny<string>()), Times.Never);
|
||||
le.Verify(x => x.NewOrder(It.IsAny<Guid>(), It.IsAny<string[]>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
runtimeLease.Verify(l => l.ReleaseAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
@ -292,7 +246,7 @@ public sealed class CertsFlowServiceTests
|
||||
var result = await sut.NewOrderAsync(sessionId, ["example.com"], "http-01");
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
le.Verify(x => x.NewOrder(It.IsAny<Guid>(), It.IsAny<string[]>(), It.IsAny<string>()), Times.Never);
|
||||
le.Verify(x => x.NewOrder(It.IsAny<Guid>(), It.IsAny<string[]>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
runtimeLease.Verify(l => l.ReleaseAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
@ -302,7 +256,7 @@ public sealed class CertsFlowServiceTests
|
||||
using var fx = new WebApiTestFixture();
|
||||
var sessionId = Guid.NewGuid();
|
||||
var le = new Mock<ILetsEncryptService>();
|
||||
le.Setup(x => x.NewOrder(sessionId, It.IsAny<string[]>(), "http-01"))
|
||||
le.Setup(x => x.NewOrder(sessionId, It.IsAny<string[]>(), "http-01", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Result<Dictionary<string, string>?>.InternalServerError(null, "acme failed"));
|
||||
var runtimeLease = new Mock<IRuntimeLeaseService>();
|
||||
runtimeLease.Setup(l => l.TryAcquireAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
|
||||
@ -324,8 +278,8 @@ public sealed class CertsFlowServiceTests
|
||||
using var fx = new WebApiTestFixture();
|
||||
var sessionId = Guid.NewGuid();
|
||||
var le = new Mock<ILetsEncryptService>();
|
||||
le.Setup(x => x.GetTermsOfServiceUri(sessionId))
|
||||
.Returns(Result<string?>.InternalServerError(null, "no uri"));
|
||||
le.Setup(x => x.GetTermsOfServiceUriAsync(sessionId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Result<string?>.InternalServerError(null, "no uri"));
|
||||
|
||||
var sut = CreateSut(fx, le);
|
||||
|
||||
@ -341,8 +295,8 @@ public sealed class CertsFlowServiceTests
|
||||
var sessionId = Guid.NewGuid();
|
||||
var url = "https://acme.test/sub/cached-tos.pdf";
|
||||
var le = new Mock<ILetsEncryptService>();
|
||||
le.Setup(x => x.GetTermsOfServiceUri(sessionId))
|
||||
.Returns(Result<string?>.Ok(url));
|
||||
le.Setup(x => x.GetTermsOfServiceUriAsync(sessionId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Result<string?>.Ok(url));
|
||||
|
||||
var tosCache = new Mock<ITermsOfServiceCachePersistenceService>();
|
||||
tosCache.Setup(c => c.GetByUrlAsync(url, It.IsAny<CancellationToken>()))
|
||||
@ -384,7 +338,7 @@ public sealed class CertsFlowServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcmeChallenge_WhenDbRowExists_MaterializesFileAndReturnsContent()
|
||||
public async Task AcmeChallenge_WhenDbRowExists_ReturnsContent()
|
||||
{
|
||||
using var fx = new WebApiTestFixture();
|
||||
var name = "challenge-token";
|
||||
@ -402,9 +356,6 @@ public sealed class CertsFlowServiceTests
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("challenge-body", result.Value);
|
||||
var path = Path.Combine(fx.AppOptions.Value.CertsUIEngineConfiguration.AcmeFolder, name);
|
||||
Assert.True(File.Exists(path));
|
||||
Assert.Equal("challenge-body", await File.ReadAllTextAsync(path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@ -508,14 +459,14 @@ public sealed class CertsFlowServiceTests
|
||||
using var fx = new WebApiTestFixture();
|
||||
var sessionId = Guid.NewGuid();
|
||||
var le = new Mock<ILetsEncryptService>();
|
||||
le.Setup(x => x.CompleteChallenges(sessionId))
|
||||
le.Setup(x => x.CompleteChallenges(sessionId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Result.Ok());
|
||||
var sut = CreateSut(fx, le);
|
||||
|
||||
var result = await sut.CompleteChallengesAsync(sessionId);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
le.Verify(x => x.CompleteChallenges(sessionId), Times.Once);
|
||||
le.Verify(x => x.CompleteChallenges(sessionId, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@ -524,7 +475,7 @@ public sealed class CertsFlowServiceTests
|
||||
using var fx = new WebApiTestFixture();
|
||||
var sessionId = Guid.NewGuid();
|
||||
var le = new Mock<ILetsEncryptService>();
|
||||
le.Setup(x => x.GetOrder(sessionId, It.IsAny<string[]>()))
|
||||
le.Setup(x => x.GetOrder(sessionId, It.IsAny<string[]>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Result.Ok());
|
||||
var sut = CreateSut(fx, le);
|
||||
|
||||
|
||||
@ -86,14 +86,5 @@ public class CertsUIEngineConfiguration : ICertsFlowEngineConfiguration {
|
||||
|
||||
public required string Staging { get; set; }
|
||||
|
||||
public required string AcmeFolder { get; set; }
|
||||
|
||||
/// <summary>Writable directory for ACME subscriber agreement PDFs and <c>init</c> marker.</summary>
|
||||
public required string DataFolder { get; set; }
|
||||
|
||||
string ICertsFlowEngineConfiguration.AcmeFolder => AcmeFolder;
|
||||
|
||||
string ICertsFlowEngineConfiguration.DataFolder => DataFolder;
|
||||
|
||||
string ICertsFlowEngineConfiguration.AgentServiceToReload => Agent.ServiceToReload;
|
||||
}
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
using MaksIT.Models.LetsEncryptServer.CertsFlow.Requests;
|
||||
using MaksIT.CertsUI.Authorization.Filters;
|
||||
using MaksIT.CertsUI.Mvc;
|
||||
using MaksIT.CertsUI.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@ -21,55 +20,55 @@ namespace MaksIT.CertsUI.Controllers {
|
||||
[HttpPost("configure-client")]
|
||||
public async Task<IActionResult> ConfigureClient([FromBody] ConfigureClientRequest requestData) {
|
||||
var result = await _certsFlowService.ConfigureClientAsync(requestData.IsStaging);
|
||||
return result.ToCertsFlowActionResult();
|
||||
return result.ToActionResult();
|
||||
}
|
||||
|
||||
[HttpGet("{sessionId}/terms-of-service")]
|
||||
public async Task<IActionResult> TermsOfService(Guid sessionId) {
|
||||
var result = await _certsFlowService.GetTermsOfServiceAsync(sessionId);
|
||||
return result.ToCertsFlowActionResult();
|
||||
return result.ToActionResult();
|
||||
}
|
||||
|
||||
[HttpPost("{sessionId}/init/{accountId?}")]
|
||||
public async Task<IActionResult> Init(Guid sessionId, Guid? accountId, [FromBody] InitRequest requestData) {
|
||||
var result = await _certsFlowService.InitAsync(sessionId, accountId, requestData.Description, requestData.Contacts);
|
||||
return result.ToCertsFlowActionResult();
|
||||
return result.ToActionResult();
|
||||
}
|
||||
|
||||
[HttpPost("{sessionId}/order")]
|
||||
public async Task<IActionResult> NewOrder(Guid sessionId, [FromBody] NewOrderRequest requestData) {
|
||||
var result = await _certsFlowService.NewOrderAsync(sessionId, requestData.Hostnames, requestData.ChallengeType);
|
||||
return result.ToCertsFlowActionResult();
|
||||
return result.ToActionResult();
|
||||
}
|
||||
|
||||
[HttpPost("{sessionId}/complete-challenges")]
|
||||
public async Task<IActionResult> CompleteChallenges(Guid sessionId) {
|
||||
var result = await _certsFlowService.CompleteChallengesAsync(sessionId);
|
||||
return result.ToCertsFlowActionResult();
|
||||
return result.ToActionResult();
|
||||
}
|
||||
|
||||
[HttpGet("{sessionId}/order-status")]
|
||||
public async Task<IActionResult> GetOrder(Guid sessionId, [FromBody] GetOrderRequest requestData) {
|
||||
var result = await _certsFlowService.GetOrderAsync(sessionId, requestData.Hostnames);
|
||||
return result.ToCertsFlowActionResult();
|
||||
return result.ToActionResult();
|
||||
}
|
||||
|
||||
[HttpPost("{sessionId}/certificates/download")]
|
||||
public async Task<IActionResult> GetCertificates(Guid sessionId, [FromBody] GetCertificatesRequest requestData) {
|
||||
var result = await _certsFlowService.GetCertificatesAsync(sessionId, requestData.Hostnames);
|
||||
return result.ToCertsFlowActionResult();
|
||||
return result.ToActionResult();
|
||||
}
|
||||
|
||||
[HttpPost("{accountId}/certificates/apply")]
|
||||
public async Task<IActionResult> ApplyCertificates(Guid accountId) {
|
||||
var result = await _certsFlowService.ApplyCertificatesAsync(accountId);
|
||||
return result.ToCertsFlowActionResult();
|
||||
return result.ToActionResult();
|
||||
}
|
||||
|
||||
[HttpPost("{sessionId}/certificates/revoke")]
|
||||
public async Task<IActionResult> RevokeCertificates(Guid sessionId, [FromBody] RevokeCertificatesRequest requestData) {
|
||||
var result = await _certsFlowService.RevokeCertificatesAsync(sessionId, requestData.Hostnames);
|
||||
return result.ToCertsFlowActionResult();
|
||||
return result.ToActionResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,138 +1,134 @@
|
||||
using MaksIT.CertsUI.Engine.Domain.Certs;
|
||||
using MaksIT.CertsUI.Engine.Infrastructure;
|
||||
using MaksIT.CertsUI.Engine.Persistance.Services;
|
||||
using MaksIT.CertsUI.Engine.RuntimeCoordination;
|
||||
using MaksIT.Results;
|
||||
using MaksIT.CertsUI.Services;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System;
|
||||
|
||||
namespace MaksIT.CertsUI.HostedServices {
|
||||
public class AutoRenewal : BackgroundService {
|
||||
namespace MaksIT.CertsUI.HostedServices;
|
||||
|
||||
private readonly IOptions<Configuration> _appSettings;
|
||||
private readonly ILogger<AutoRenewal> _logger;
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IPrimaryReplicaWorkload _primaryReplica;
|
||||
/// <summary>Certificate renewal: each sweep acquires <see cref="RuntimeLeaseNames.RenewalSweep"/> so only one pod runs ACME renewal at a time (symmetric replicas, no elected primary).</summary>
|
||||
public sealed class AutoRenewal(
|
||||
ILogger<AutoRenewal> logger,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IRuntimeLeaseService leaseService,
|
||||
IRuntimeInstanceId runtimeInstance
|
||||
) : BackgroundService {
|
||||
|
||||
private static readonly Random _random = new();
|
||||
private static readonly TimeSpan RenewalLeaseTtl = TimeSpan.FromMinutes(12);
|
||||
private static readonly Random Random = new();
|
||||
|
||||
public AutoRenewal(
|
||||
IOptions<Configuration> appSettings,
|
||||
ILogger<AutoRenewal> logger,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IPrimaryReplicaWorkload primaryReplica
|
||||
) {
|
||||
_appSettings = appSettings;
|
||||
_logger = logger;
|
||||
_scopeFactory = scopeFactory;
|
||||
_primaryReplica = primaryReplica;
|
||||
}
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
|
||||
while (!stoppingToken.IsCancellationRequested) {
|
||||
var holder = runtimeInstance.InstanceId;
|
||||
var acquired = await leaseService.TryAcquireAsync(RuntimeLeaseNames.RenewalSweep, holder, RenewalLeaseTtl, stoppingToken).ConfigureAwait(false);
|
||||
if (!acquired.IsSuccess) {
|
||||
if (logger.IsEnabled(LogLevel.Warning))
|
||||
logger.LogWarning("Renewal sweep lease check failed: {Messages}", string.Join("; ", acquired.Messages ?? []));
|
||||
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
|
||||
while (!stoppingToken.IsCancellationRequested) {
|
||||
if (!_primaryReplica.IsPrimary) {
|
||||
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
if (!acquired.Value) {
|
||||
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Background service is running (primary replica).");
|
||||
try {
|
||||
if (logger.IsEnabled(LogLevel.Information))
|
||||
logger.LogInformation("Running certificate renewal sweep (lease holder {Holder}).", holder);
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var cacheService = scope.ServiceProvider.GetRequiredService<ICacheService>();
|
||||
var certsFlowService = scope.ServiceProvider.GetRequiredService<ICertsFlowService>();
|
||||
var httpChallenges = scope.ServiceProvider.GetRequiredService<IAcmeHttpChallengePersistenceService>();
|
||||
|
||||
var purge = await httpChallenges.DeleteOlderThanAsync(TimeSpan.FromDays(10), stoppingToken);
|
||||
var purge = await httpChallenges.DeleteOlderThanAsync(TimeSpan.FromDays(10), stoppingToken).ConfigureAwait(false);
|
||||
if (purge.IsSuccess && purge.Value > 0)
|
||||
_logger.LogInformation("Purged {Count} HTTP-01 challenge row(s) older than 10 days.", purge.Value);
|
||||
logger.LogInformation("Purged {Count} HTTP-01 challenge row(s) older than 10 days.", purge.Value);
|
||||
|
||||
var loadAccountsFromCacheResult = await cacheService.LoadAccountsFromCacheAsync();
|
||||
var loadAccountsFromCacheResult = await cacheService.LoadAccountsFromCacheAsync().ConfigureAwait(false);
|
||||
if (!loadAccountsFromCacheResult.IsSuccess || loadAccountsFromCacheResult.Value == null) {
|
||||
LogErrors(loadAccountsFromCacheResult.Messages);
|
||||
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
||||
LogErrorMessages(loadAccountsFromCacheResult.Messages);
|
||||
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
var accountsResponse = loadAccountsFromCacheResult.Value;
|
||||
|
||||
foreach (var account in accountsResponse.Where(x => !x.IsDisabled)) {
|
||||
await ProcessAccountAsync(certsFlowService, account);
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
||||
foreach (var account in accountsResponse.Where(x => !x.IsDisabled))
|
||||
await ProcessAccountAsync(certsFlowService, account).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Result> ProcessAccountAsync(ICertsFlowService certsFlowService, RegistrationCache cache) {
|
||||
|
||||
var hosts = cache.GetHosts();
|
||||
var toRenew = new List<string>();
|
||||
|
||||
foreach (var host in hosts) {
|
||||
if (host.IsDisabled)
|
||||
continue;
|
||||
|
||||
// Only consider certs expiring within 30 days
|
||||
if ((host.Expires - DateTime.UtcNow).TotalDays < 30) {
|
||||
// Randomize renewal between 1 and 5 days before expiry
|
||||
int randomDays = _random.Next(1, 6);
|
||||
var renewalTime = host.Expires.AddDays(-randomDays);
|
||||
if (DateTime.UtcNow >= renewalTime) {
|
||||
toRenew.Add(host.Hostname);
|
||||
}
|
||||
}
|
||||
finally {
|
||||
var released = await leaseService.ReleaseAsync(RuntimeLeaseNames.RenewalSweep, holder, CancellationToken.None).ConfigureAwait(false);
|
||||
if (!released.IsSuccess && logger.IsEnabled(LogLevel.Warning))
|
||||
logger.LogWarning("Renewal sweep lease release: {Messages}", string.Join("; ", released.Messages ?? []));
|
||||
}
|
||||
|
||||
if (!toRenew.Any()) {
|
||||
_logger.LogInformation("No certificates are due for randomized renewal at this time.");
|
||||
return Result.Ok();
|
||||
}
|
||||
|
||||
var cooldownSkipped = new List<(string Hostname, DateTimeOffset NotBeforeUtc)>();
|
||||
var eligible = new List<string>();
|
||||
foreach (var hostname in toRenew) {
|
||||
if (cache.IsHostnameInAcmeCooldown(hostname, out var notBefore)) {
|
||||
cooldownSkipped.Add((hostname, notBefore));
|
||||
continue;
|
||||
}
|
||||
eligible.Add(hostname);
|
||||
}
|
||||
|
||||
if (cooldownSkipped.Count > 0) {
|
||||
var sample = cooldownSkipped[0];
|
||||
_logger.LogInformation(
|
||||
"Skipping {SkippedCount} hostname(s) in ACME cooldown for account {AccountId} (e.g. {ExampleHost} until {NotBeforeUtc:u} UTC).",
|
||||
cooldownSkipped.Count, cache.AccountId, sample.Hostname, sample.NotBeforeUtc);
|
||||
}
|
||||
|
||||
if (!eligible.Any()) {
|
||||
_logger.LogInformation("All due certificates for account {AccountId} are in ACME cooldown; no renewal attempted.", cache.AccountId);
|
||||
return Result.Ok();
|
||||
}
|
||||
|
||||
var fullFlowResult = await certsFlowService.FullFlow(
|
||||
cache.IsStaging, cache.AccountId, cache.Description, cache.Contacts, cache.ChallengeType, eligible.ToArray()
|
||||
);
|
||||
|
||||
if (!fullFlowResult.IsSuccess)
|
||||
return fullFlowResult;
|
||||
|
||||
_logger.LogInformation("Certificates renewed for account {AccountId}: {Hostnames}", cache.AccountId, string.Join(", ", eligible));
|
||||
|
||||
return Result.Ok();
|
||||
}
|
||||
|
||||
|
||||
|
||||
private void LogErrors(IEnumerable<string> errors) {
|
||||
foreach (var error in errors) {
|
||||
_logger.LogError(error);
|
||||
}
|
||||
}
|
||||
|
||||
public override Task StopAsync(CancellationToken stoppingToken) {
|
||||
_logger.LogInformation("Background service is stopping.");
|
||||
return base.StopAsync(stoppingToken);
|
||||
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessAccountAsync(ICertsFlowService certsFlowService, RegistrationCache cache) {
|
||||
var hosts = cache.GetHosts();
|
||||
var toRenew = new List<string>();
|
||||
|
||||
foreach (var host in hosts) {
|
||||
if (host.IsDisabled)
|
||||
continue;
|
||||
|
||||
if ((host.Expires - DateTime.UtcNow).TotalDays < 30) {
|
||||
int randomDays = Random.Next(1, 6);
|
||||
var renewalTime = host.Expires.AddDays(-randomDays);
|
||||
if (DateTime.UtcNow >= renewalTime)
|
||||
toRenew.Add(host.Hostname);
|
||||
}
|
||||
}
|
||||
|
||||
if (!toRenew.Any()) {
|
||||
logger.LogInformation("No certificates are due for randomized renewal at this time for account {AccountId}.", cache.AccountId);
|
||||
return;
|
||||
}
|
||||
|
||||
var cooldownSkipped = new List<(string Hostname, DateTimeOffset NotBeforeUtc)>();
|
||||
var eligible = new List<string>();
|
||||
foreach (var hostname in toRenew) {
|
||||
if (cache.IsHostnameInAcmeCooldown(hostname, out var notBefore)) {
|
||||
cooldownSkipped.Add((hostname, notBefore));
|
||||
continue;
|
||||
}
|
||||
eligible.Add(hostname);
|
||||
}
|
||||
|
||||
if (cooldownSkipped.Count > 0) {
|
||||
var sample = cooldownSkipped[0];
|
||||
logger.LogInformation(
|
||||
"Skipping {SkippedCount} hostname(s) in ACME cooldown for account {AccountId} (e.g. {ExampleHost} until {NotBeforeUtc:u} UTC).",
|
||||
cooldownSkipped.Count, cache.AccountId, sample.Hostname, sample.NotBeforeUtc);
|
||||
}
|
||||
|
||||
if (!eligible.Any()) {
|
||||
logger.LogInformation("All due certificates for account {AccountId} are in ACME cooldown; no renewal attempted.", cache.AccountId);
|
||||
return;
|
||||
}
|
||||
|
||||
var fullFlowResult = await certsFlowService.FullFlow(
|
||||
cache.IsStaging, cache.AccountId, cache.Description, cache.Contacts, cache.ChallengeType, eligible.ToArray()
|
||||
).ConfigureAwait(false);
|
||||
|
||||
if (!fullFlowResult.IsSuccess)
|
||||
LogErrorMessages(fullFlowResult.Messages);
|
||||
else
|
||||
logger.LogInformation("Certificates renewed for account {AccountId}: {Hostnames}", cache.AccountId, string.Join(", ", eligible));
|
||||
}
|
||||
|
||||
private void LogErrorMessages(IEnumerable<string>? errors) {
|
||||
if (errors == null)
|
||||
return;
|
||||
foreach (var error in errors)
|
||||
logger.LogError("{Error}", error);
|
||||
}
|
||||
|
||||
public override Task StopAsync(CancellationToken stoppingToken) {
|
||||
logger.LogInformation("Background service is stopping.");
|
||||
return base.StopAsync(stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,62 +1,63 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MaksIT.CertsUI.Engine;
|
||||
using MaksIT.CertsUI.Engine.DomainServices;
|
||||
using MaksIT.CertsUI.Engine.Infrastructure;
|
||||
using MaksIT.CertsUI.Engine.RuntimeCoordination;
|
||||
using MaksIT.CertsUI.Infrastructure;
|
||||
|
||||
namespace MaksIT.CertsUI.HostedServices;
|
||||
|
||||
/// <summary>
|
||||
/// Exactly one instance holds <see cref="RuntimeLeaseNames.PrimaryReplica"/> and runs coordination DDL plus identity bootstrap.
|
||||
/// Other instances wait until the database (and optional shared <c>init</c> marker under <see cref="Configuration.CertsUIEngineConfiguration.DataFolder"/>) shows bootstrap complete, then start without ACME privileges.
|
||||
/// Uses a short-lived Postgres lease (<see cref="RuntimeLeaseNames.BootstrapCoordinator"/>) so exactly one pod runs
|
||||
/// coordination DDL + default admin creation; other pods wait until <c>users</c> exist. No long-lived leader role.
|
||||
/// </summary>
|
||||
public sealed class InitializationHostedService(
|
||||
ILogger<InitializationHostedService> logger,
|
||||
IServiceProvider serviceProvider,
|
||||
IOptions<Configuration> appSettings,
|
||||
PrimaryReplicaGate primaryGate
|
||||
IRuntimeLeaseService leaseService,
|
||||
IRuntimeInstanceId runtimeInstance
|
||||
) : IHostedService {
|
||||
|
||||
private static readonly TimeSpan BootstrapLeaseTtl = TimeSpan.FromMinutes(5);
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken) {
|
||||
const int delayMilliseconds = 2000;
|
||||
var appLifetime = serviceProvider.GetRequiredService<IHostApplicationLifetime>();
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested) {
|
||||
try {
|
||||
logger.LogInformation("Running startup initialization (primary replica election)...");
|
||||
logger.LogInformation("Running startup coordination (Postgres bootstrap lease)...");
|
||||
|
||||
if (await primaryGate.TryAcquirePrimaryLeaseAsync(cancellationToken).ConfigureAwait(false)) {
|
||||
primaryGate.StartLeaseRenewal(appLifetime);
|
||||
var holder = runtimeInstance.InstanceId;
|
||||
var acquired = await leaseService.TryAcquireAsync(RuntimeLeaseNames.BootstrapCoordinator, holder, BootstrapLeaseTtl, cancellationToken).ConfigureAwait(false);
|
||||
if (!acquired.IsSuccess)
|
||||
throw new InvalidOperationException(string.Join(", ", acquired.Messages ?? ["Bootstrap lease acquire failed."]));
|
||||
|
||||
if (acquired.Value) {
|
||||
try {
|
||||
var engineConfig = serviceProvider.GetRequiredService<ICertsEngineConfiguration>();
|
||||
await CoordinationTableProvisioner.EnsureAsync(engineConfig.ConnectionString, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var scope = serviceProvider.CreateAsyncScope();
|
||||
var identityDomainService = scope.ServiceProvider.GetRequiredService<IIdentityDomainService>();
|
||||
await EnsureIdentityAsLeaderAsync(appSettings.Value, identityDomainService, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureIdentityAsLeaderAsync(identityDomainService, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch {
|
||||
await primaryGate.AbandonPrimaryAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
finally {
|
||||
var released = await leaseService.ReleaseAsync(RuntimeLeaseNames.BootstrapCoordinator, holder, CancellationToken.None).ConfigureAwait(false);
|
||||
if (!released.IsSuccess && logger.IsEnabled(LogLevel.Warning))
|
||||
logger.LogWarning("Bootstrap lease release: {Messages}", string.Join("; ", released.Messages ?? []));
|
||||
}
|
||||
|
||||
primaryGate.EnablePrimaryWorkload();
|
||||
logger.LogInformation("Startup initialization completed; this instance is the primary replica.");
|
||||
logger.LogInformation("Startup coordination completed (this instance held the bootstrap lease).");
|
||||
return;
|
||||
}
|
||||
|
||||
await using (var followerScope = serviceProvider.CreateAsyncScope()) {
|
||||
var identityFollower = followerScope.ServiceProvider.GetRequiredService<IIdentityDomainService>();
|
||||
var cfg = appSettings.Value;
|
||||
while (!cancellationToken.IsCancellationRequested) {
|
||||
if (await IsClusterIdentityReadyAsync(cfg, identityFollower, cancellationToken).ConfigureAwait(false)) {
|
||||
logger.LogInformation("Startup initialization completed; this instance is a secondary replica.");
|
||||
if (await IsClusterIdentityReadyAsync(identityFollower, cancellationToken).ConfigureAwait(false)) {
|
||||
logger.LogInformation("Startup coordination completed (another instance bootstrapped identity).");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation("Waiting for primary replica to finish database bootstrap...");
|
||||
logger.LogInformation("Waiting for bootstrap to finish (checking database)...");
|
||||
await Task.Delay(delayMilliseconds, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@ -64,20 +65,20 @@ public sealed class InitializationHostedService(
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) {
|
||||
logger.LogInformation("Startup initialization canceled (host is stopping).");
|
||||
logger.LogInformation("Startup coordination canceled (host is stopping).");
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
if (cancellationToken.IsCancellationRequested) {
|
||||
logger.LogInformation(ex, "Startup initialization aborted while stopping host.");
|
||||
throw new OperationCanceledException("Host stopped during startup initialization.", ex, cancellationToken);
|
||||
logger.LogInformation(ex, "Startup coordination aborted while stopping host.");
|
||||
throw new OperationCanceledException("Host stopped during startup coordination.", ex, cancellationToken);
|
||||
}
|
||||
logger.LogError(ex, "Startup initialization failed. Retrying...");
|
||||
logger.LogError(ex, "Startup coordination failed. Retrying...");
|
||||
try {
|
||||
await Task.Delay(delayMilliseconds, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) {
|
||||
logger.LogInformation("Startup initialization retry wait canceled (host is stopping).");
|
||||
logger.LogInformation("Startup coordination retry wait canceled (host is stopping).");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
@ -87,53 +88,29 @@ public sealed class InitializationHostedService(
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
private static async Task EnsureIdentityAsLeaderAsync(
|
||||
Configuration appSettings,
|
||||
IIdentityDomainService identityDomainService,
|
||||
CancellationToken cancellationToken
|
||||
) {
|
||||
var dataDir = appSettings.CertsUIEngineConfiguration.DataFolder;
|
||||
if (!Directory.Exists(dataDir))
|
||||
Directory.CreateDirectory(dataDir);
|
||||
|
||||
var initPath = Path.Combine(dataDir, "init");
|
||||
if (File.Exists(initPath))
|
||||
return;
|
||||
|
||||
var count = await identityDomainService.CountUsersAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!count.IsSuccess)
|
||||
throw new InvalidOperationException(string.Join(", ", count.Messages));
|
||||
|
||||
if (count.Value == 0) {
|
||||
var bootstrap = await identityDomainService.EnsureDefaultAdminAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!bootstrap.IsSuccess)
|
||||
throw new InvalidOperationException(string.Join(", ", bootstrap.Messages));
|
||||
}
|
||||
if (count.Value != 0)
|
||||
return;
|
||||
|
||||
await File.WriteAllTextAsync(initPath, string.Empty, cancellationToken).ConfigureAwait(false);
|
||||
var bootstrap = await identityDomainService.EnsureDefaultAdminAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!bootstrap.IsSuccess)
|
||||
throw new InvalidOperationException(string.Join(", ", bootstrap.Messages));
|
||||
}
|
||||
|
||||
private static async Task<bool> IsClusterIdentityReadyAsync(
|
||||
Configuration appSettings,
|
||||
IIdentityDomainService identityDomainService,
|
||||
CancellationToken cancellationToken
|
||||
) {
|
||||
var dataDir = appSettings.CertsUIEngineConfiguration.DataFolder;
|
||||
if (!Directory.Exists(dataDir))
|
||||
Directory.CreateDirectory(dataDir);
|
||||
|
||||
var initPath = Path.Combine(dataDir, "init");
|
||||
if (File.Exists(initPath))
|
||||
return true;
|
||||
|
||||
var count = await identityDomainService.CountUsersAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!count.IsSuccess)
|
||||
throw new InvalidOperationException(string.Join(", ", count.Messages));
|
||||
|
||||
if (count.Value > 0) {
|
||||
await File.WriteAllTextAsync(initPath, string.Empty, cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
return count.Value > 0;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,14 +0,0 @@
|
||||
using MaksIT.CertsUI.Infrastructure;
|
||||
|
||||
namespace MaksIT.CertsUI.HostedServices;
|
||||
|
||||
/// <summary>
|
||||
/// Registered last so <see cref="IHostedService.StopAsync"/> runs first on shutdown: releases the primary Postgres lease and stops renewal.
|
||||
/// </summary>
|
||||
public sealed class PrimaryReplicaShutdownHostedService(PrimaryReplicaGate primaryGate) : IHostedService {
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
public async Task StopAsync(CancellationToken cancellationToken) =>
|
||||
await primaryGate.AbandonPrimaryAsync().ConfigureAwait(false);
|
||||
}
|
||||
@ -1,121 +0,0 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using MaksIT.CertsUI.Engine.Infrastructure;
|
||||
using MaksIT.CertsUI.Engine.RuntimeCoordination;
|
||||
|
||||
namespace MaksIT.CertsUI.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Holds <see cref="RuntimeLeaseNames.PrimaryReplica"/> and renews it while this instance is leader.
|
||||
/// <see cref="IPrimaryReplicaWorkload.IsPrimary"/> stays false until <see cref="EnablePrimaryWorkload"/> runs after successful startup bootstrap.
|
||||
/// </summary>
|
||||
public sealed class PrimaryReplicaGate(
|
||||
IRuntimeLeaseService leaseService,
|
||||
IRuntimeInstanceId runtimeInstance,
|
||||
ILogger<PrimaryReplicaGate> logger
|
||||
) : IPrimaryReplicaWorkload, IAsyncDisposable {
|
||||
|
||||
private static readonly TimeSpan PrimaryLeaseTtl = TimeSpan.FromSeconds(90);
|
||||
private static readonly TimeSpan RenewInterval = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly object _sync = new();
|
||||
private CancellationTokenSource? _renewCts;
|
||||
private Task? _renewalTask;
|
||||
private string? _holderId;
|
||||
private volatile bool _mayRunPrimaryWorkload;
|
||||
|
||||
public bool IsPrimary => _mayRunPrimaryWorkload;
|
||||
|
||||
/// <summary>Single attempt to insert/update the primary lease row for this holder.</summary>
|
||||
public async Task<bool> TryAcquirePrimaryLeaseAsync(CancellationToken cancellationToken) {
|
||||
var holder = runtimeInstance.InstanceId;
|
||||
var acquired = await leaseService.TryAcquireAsync(RuntimeLeaseNames.PrimaryReplica, holder, PrimaryLeaseTtl, cancellationToken).ConfigureAwait(false);
|
||||
if (!acquired.IsSuccess)
|
||||
throw new InvalidOperationException(string.Join(", ", acquired.Messages ?? ["Primary lease acquire failed."]));
|
||||
if (!acquired.Value)
|
||||
return false;
|
||||
|
||||
lock (_sync) {
|
||||
_holderId = holder;
|
||||
_mayRunPrimaryWorkload = false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>After <see cref="TryAcquirePrimaryLeaseAsync"/> returned true, start renewal (call before long init).</summary>
|
||||
public void StartLeaseRenewal(IHostApplicationLifetime applicationLifetime) {
|
||||
lock (_sync) {
|
||||
if (_holderId == null)
|
||||
throw new InvalidOperationException("Cannot start renewal without an acquired primary lease.");
|
||||
_renewCts?.Cancel();
|
||||
_renewCts?.Dispose();
|
||||
_renewCts = CancellationTokenSource.CreateLinkedTokenSource(applicationLifetime.ApplicationStopping);
|
||||
var holder = _holderId;
|
||||
var ct = _renewCts.Token;
|
||||
_renewalTask = RenewalLoopAsync(holder, ct);
|
||||
}
|
||||
}
|
||||
|
||||
public void EnablePrimaryWorkload() => _mayRunPrimaryWorkload = true;
|
||||
|
||||
private async Task RenewalLoopAsync(string holderId, CancellationToken cancellationToken) {
|
||||
try {
|
||||
while (!cancellationToken.IsCancellationRequested) {
|
||||
var renewed = await leaseService.TryAcquireAsync(RuntimeLeaseNames.PrimaryReplica, holderId, PrimaryLeaseTtl, cancellationToken).ConfigureAwait(false);
|
||||
if (!renewed.IsSuccess || !renewed.Value) {
|
||||
if (logger.IsEnabled(LogLevel.Warning))
|
||||
logger.LogWarning("Primary replica lease was not renewed (success={Success}, acquired={Acquired}).", renewed.IsSuccess, renewed.Value);
|
||||
_mayRunPrimaryWorkload = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.Delay(RenewInterval, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) {
|
||||
// normal shutdown
|
||||
}
|
||||
catch (Exception ex) {
|
||||
if (logger.IsEnabled(LogLevel.Error))
|
||||
logger.LogError(ex, "Primary replica lease renewal loop failed.");
|
||||
_mayRunPrimaryWorkload = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Release lease and stop renewal after failed leader bootstrap (instance stays usable for retry).</summary>
|
||||
public async Task AbandonPrimaryAsync() {
|
||||
_mayRunPrimaryWorkload = false;
|
||||
Task? renewalToAwait;
|
||||
CancellationTokenSource? cts;
|
||||
string? holder;
|
||||
lock (_sync) {
|
||||
holder = _holderId;
|
||||
_holderId = null;
|
||||
cts = _renewCts;
|
||||
_renewCts = null;
|
||||
renewalToAwait = _renewalTask;
|
||||
_renewalTask = null;
|
||||
}
|
||||
|
||||
try {
|
||||
cts?.Cancel();
|
||||
if (renewalToAwait != null)
|
||||
await renewalToAwait.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
if (logger.IsEnabled(LogLevel.Debug))
|
||||
logger.LogDebug(ex, "Primary renewal task did not end cleanly during abandon.");
|
||||
}
|
||||
finally {
|
||||
cts?.Dispose();
|
||||
}
|
||||
|
||||
if (holder != null) {
|
||||
var released = await leaseService.ReleaseAsync(RuntimeLeaseNames.PrimaryReplica, holder, CancellationToken.None).ConfigureAwait(false);
|
||||
if (!released.IsSuccess && logger.IsEnabled(LogLevel.Warning))
|
||||
logger.LogWarning("Primary lease release (abandon): {Messages}", string.Join("; ", released.Messages ?? []));
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync() => await AbandonPrimaryAsync().ConfigureAwait(false);
|
||||
}
|
||||
@ -1,10 +1,8 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<Version>3.3.22</Version>
|
||||
<Version>3.4.0</Version>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
<DockerComposeProjectPath>..\docker-compose.dcproj</DockerComposeProjectPath>
|
||||
<NoWarn>CA2254</NoWarn>
|
||||
|
||||
@ -1,26 +0,0 @@
|
||||
using MaksIT.Results;
|
||||
using MaksIT.Results.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace MaksIT.CertsUI.Mvc;
|
||||
|
||||
/// <summary>
|
||||
/// Maps ACME domain results to HTTP: primary-replica required becomes 503 + <c>Retry-After</c> + ProblemDetails.
|
||||
/// </summary>
|
||||
public static class CertsFlowResultExtensions {
|
||||
|
||||
/// <summary>Default retry hint for clients and caches (seconds).</summary>
|
||||
public const int DefaultPrimaryReplicaRetryAfterSeconds = 2;
|
||||
|
||||
public static IActionResult ToCertsFlowActionResult(this Result result) {
|
||||
if (!result.IsSuccess && PrimaryReplicaRequiredObjectResult.IsPrimaryReplicaResult(result.Messages))
|
||||
return PrimaryReplicaRequiredObjectResult.FromMessages(result.Messages, DefaultPrimaryReplicaRetryAfterSeconds);
|
||||
return result.ToActionResult();
|
||||
}
|
||||
|
||||
public static IActionResult ToCertsFlowActionResult<T>(this Result<T?> result) {
|
||||
if (!result.IsSuccess && PrimaryReplicaRequiredObjectResult.IsPrimaryReplicaResult(result.Messages))
|
||||
return PrimaryReplicaRequiredObjectResult.FromMessages(result.Messages, DefaultPrimaryReplicaRetryAfterSeconds);
|
||||
return result.ToActionResult();
|
||||
}
|
||||
}
|
||||
@ -1,39 +0,0 @@
|
||||
using MaksIT.CertsUI.Engine.DomainServices;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace MaksIT.CertsUI.Mvc;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP 503 with <c>Retry-After</c> (delay-seconds) and RFC 7807 <see cref="ProblemDetails"/> for primary-replica routing.
|
||||
/// </summary>
|
||||
internal sealed class PrimaryReplicaRequiredObjectResult : ObjectResult {
|
||||
|
||||
public PrimaryReplicaRequiredObjectResult(ProblemDetails problemDetails, int retryAfterSeconds) : base(problemDetails) {
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(retryAfterSeconds, 1);
|
||||
StatusCode = StatusCodes.Status503ServiceUnavailable;
|
||||
DeclaredType = typeof(ProblemDetails);
|
||||
RetryAfterSeconds = retryAfterSeconds;
|
||||
}
|
||||
|
||||
public int RetryAfterSeconds { get; }
|
||||
|
||||
public override Task ExecuteResultAsync(ActionContext context) {
|
||||
context.HttpContext.Response.Headers.RetryAfter = RetryAfterSeconds.ToString(System.Globalization.NumberFormatInfo.InvariantInfo);
|
||||
return base.ExecuteResultAsync(context);
|
||||
}
|
||||
|
||||
internal static bool IsPrimaryReplicaResult(IReadOnlyList<string>? messages) =>
|
||||
messages is { Count: > 0 } && string.Equals(messages[0], CertsFlowPrimaryReplica.DiagnosticMarker, StringComparison.Ordinal);
|
||||
|
||||
internal static IActionResult FromMessages(IReadOnlyList<string>? messages, int retryAfterSeconds) {
|
||||
var detail = (messages is { Count: > 1 } ? messages[1] : null) ?? "Only the primary replica runs this operation.";
|
||||
var pd = new ProblemDetails {
|
||||
Status = StatusCodes.Status503ServiceUnavailable,
|
||||
Title = "Primary replica required",
|
||||
Detail = detail,
|
||||
Type = CertsFlowPrimaryReplica.DiagnosticMarker,
|
||||
};
|
||||
pd.Extensions["retryAfterSeconds"] = retryAfterSeconds;
|
||||
return new PrimaryReplicaRequiredObjectResult(pd, retryAfterSeconds);
|
||||
}
|
||||
}
|
||||
@ -67,14 +67,9 @@ builder.Services.AddOptions<JsonOptions>().Configure(o =>
|
||||
builder.Services.AddScoped<JwtAuthorizationFilter>();
|
||||
builder.Services.AddScoped<JwtOrApiKeyAuthorizationFilter>();
|
||||
|
||||
// Primary replica: one elected instance (Postgres lease) runs ACME + renewal; register shutdown last so StopAsync releases the lease first.
|
||||
builder.Services.AddSingleton<PrimaryReplicaGate>();
|
||||
builder.Services.AddSingleton<IPrimaryReplicaWorkload>(sp => sp.GetRequiredService<PrimaryReplicaGate>());
|
||||
|
||||
// Hosted services: initialization first, then autorenewal loop.
|
||||
// Hosted services: coordination/bootstrap lease, then renewal sweeps (each uses short-lived Postgres leases — symmetric pods).
|
||||
builder.Services.AddHostedService<InitializationHostedService>();
|
||||
builder.Services.AddHostedService<AutoRenewal>();
|
||||
builder.Services.AddHostedService<PrimaryReplicaShutdownHostedService>();
|
||||
|
||||
// PostgreSQL: prefer Configuration:CertsUIEngineConfiguration:ConnectionString in appsecrets.json; fallback ConnectionStrings:Certs for older files.
|
||||
var certsConnectionString = appSettings.CertsUIEngineConfiguration.ConnectionString
|
||||
@ -85,7 +80,7 @@ if (string.IsNullOrWhiteSpace(certsConnectionString))
|
||||
|
||||
var engineSection = appSettings.CertsUIEngineConfiguration;
|
||||
|
||||
// Identity / flow configuration must be registered before AddCertsEngine (engine domain services depend on pepper and paths).
|
||||
// Identity / flow configuration must be registered before AddCertsEngine (engine domain services depend on pepper, etc.).
|
||||
builder.Services.AddSingleton<IIdentityDomainConfiguration>(sp =>
|
||||
sp.GetRequiredService<IOptions<Configuration>>().Value.CertsUIEngineConfiguration.JwtSettingsConfiguration);
|
||||
builder.Services.AddSingleton<ITwoFactorSettingsConfiguration>(sp =>
|
||||
@ -105,7 +100,6 @@ builder.Services.AddCertsEngine(new MaksIT.CertsUI.Engine.CertsEngineConfigurati
|
||||
LetsEncryptStaging = engineSection.Staging,
|
||||
});
|
||||
|
||||
builder.Services.AddMemoryCache();
|
||||
builder.Services.AddScoped<ICacheService, CacheService>();
|
||||
|
||||
// Controller services
|
||||
@ -140,7 +134,7 @@ builder.Services.AddHealthChecks()
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// FluentMigrator must complete before any IHostedService starts; bootstrap lease uses app_runtime_leases.
|
||||
// FluentMigrator must complete before any IHostedService starts; bootstrap uses app_runtime_leases.
|
||||
await app.Services.EnsureCertsEngineMigratedAsync();
|
||||
|
||||
app.UseMiddleware<ErrorHandlingMiddleware>();
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,5 +4,5 @@
|
||||
namespace MaksIT.Models.Agent.Requests;
|
||||
|
||||
public class CertsUploadRequest : RequestModelBase {
|
||||
public Dictionary<string, string> Certs { get; set; }
|
||||
public required Dictionary<string, string> Certs { get; set; }
|
||||
}
|
||||
|
||||
@ -4,5 +4,5 @@
|
||||
namespace MaksIT.Models.Agent.Requests;
|
||||
|
||||
public class ServiceReloadRequest : RequestModelBase {
|
||||
public string ServiceName { get; set; }
|
||||
public required string ServiceName { get; set; }
|
||||
}
|
||||
|
||||
@ -4,5 +4,5 @@
|
||||
namespace MaksIT.Models.Agent.Responses;
|
||||
|
||||
public class HelloWorldResponse : ResponseModelBase {
|
||||
public string Message { get; set; }
|
||||
public required string Message { get; set; }
|
||||
}
|
||||
|
||||
@ -2,8 +2,6 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -1,17 +1,21 @@
|
||||
/**
|
||||
* JSON shape for `MaksIT.Results.Mvc.ProblemDetails` (RFC 7807).
|
||||
*
|
||||
* `Extensions` is `[JsonExtensionData]` in the library: extra members serialize as **sibling**
|
||||
* properties on the same object (`traceId`, custom `id`, etc.), not under a nested `extensions` key.
|
||||
*
|
||||
* @see `MaksIT.Results.Mvc.ProblemDetails` in the **maksit-results** repository (same contract as the **MaksIT.Results** NuGet package).
|
||||
*/
|
||||
export interface ProblemDetails {
|
||||
status?: number;
|
||||
type?: string;
|
||||
title?: string;
|
||||
status?: number;
|
||||
detail?: string;
|
||||
instance?: string;
|
||||
/** Validation errors: property name -> list of messages (ASP.NET ValidationProblemDetails) */
|
||||
/** Validation failures when the API puts `errors` in extension data (ValidationProblemDetails-style). */
|
||||
errors?: Record<string, string[]>;
|
||||
extensions: { [key: string]: never };
|
||||
/** Often emitted by ASP.NET (`traceId` in extension data). */
|
||||
traceId?: string;
|
||||
/** Any other extension member the server attaches (correlation id, etc.). */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export const ProblemDetailsProto = (): ProblemDetails => ({
|
||||
status: undefined,
|
||||
title: undefined,
|
||||
detail: undefined,
|
||||
instance: undefined,
|
||||
extensions: {}
|
||||
})
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -2,8 +2,6 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -32,9 +32,9 @@ Optional per workload under **`components.<name>`**: **`replicaCount`** (default
|
||||
|
||||
When **`replicaCount` > 1**, the chart creates a **PodDisruptionBudget** (`minAvailable: 1`) for that component.
|
||||
|
||||
**Primary replica + ACME:** With multiple **server** pods, exactly one holds the Postgres lease `certs-ui-primary` and runs ACME orchestration (`CertsFlowDomainService`, renewal). Others answer **`AcmeChallengeAsync`** from the database for HTTP-01. Interactive UI flows should hit the primary: the chart defaults **`ClientIP`** session affinity on the **server** Service, and clients should retry on **503** (see `Retry-After` / `ProblemDetails`). After unclean failover, the old lease row can linger until its TTL (~90s with defaults); renewals and clean shutdown avoid stuck primaries.
|
||||
**Postgres leases (short-lived):** **`certs-ui-bootstrap`** — one pod runs coordination DDL + default admin, then releases the lease. **`certs-ui-renewal-sweep`** — one pod runs each renewal sweep, then releases. All **server** pods are **symmetric** (no elected primary in DI). Interactive ACME and **HTTP-01** use **PostgreSQL**. The **server** `Service` defaults to **no session affinity**. Set **`components.server.service.sessionAffinity.enabled: true`** only if you want **`ClientIP`** stickiness. Stale lease rows expire by TTL if a pod dies mid-section.
|
||||
|
||||
**Server + RWO PVCs:** the default **acme** / **data** volumes use **ReadWriteOnce**. Kubernetes will not schedule a second server pod on the same volume; for multiple server replicas you need **ReadWriteMany** (or equivalent) and an application design that tolerates shared disk (see product HA roadmap).
|
||||
**Persistence:** the chart does **not** mount application data PVCs by default (ACME and identity state are in **PostgreSQL**). Add entries under **`components.server.persistence.volumes`** only if you need extra local scratch or sidecar files.
|
||||
|
||||
------------------------------------------------------------
|
||||
## Config
|
||||
|
||||
@ -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 }}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
14
src/postgresql/servers.json.example
Normal file
14
src/postgresql/servers.json.example
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"Servers": {
|
||||
"1": {
|
||||
"Name": "CertsUI (Compose)",
|
||||
"Group": "Servers",
|
||||
"Host": "postgres",
|
||||
"Port": 5432,
|
||||
"MaintenanceDB": "certsui",
|
||||
"Username": "certsui",
|
||||
"SSLMode": "prefer",
|
||||
"PassFile": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user