mirror of
https://github.com/MAKS-IT-COM/maksit-certs-ui.git
synced 2026-06-10 00:28:11 +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).
|
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
|
## [3.3.22] - 2026-04-27
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
24
README.md
24
README.md
@ -282,7 +282,7 @@ sudo tee /opt/Compose/MaksIT.CertsUI/secrets/appsecrets.json > /dev/null <<EOF
|
|||||||
{
|
{
|
||||||
"Configuration": {
|
"Configuration": {
|
||||||
"CertsEngineConfiguration": {
|
"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": {
|
"Auth": {
|
||||||
"Secret": "<your-auth-secret>",
|
"Secret": "<your-auth-secret>",
|
||||||
@ -297,7 +297,7 @@ EOF
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Note:**
|
**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.
|
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:**
|
**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",
|
"Production": "https://acme-v02.api.letsencrypt.org/directory",
|
||||||
"Staging": "https://acme-staging-v02.api.letsencrypt.org/directory",
|
"Staging": "https://acme-staging-v02.api.letsencrypt.org/directory",
|
||||||
"AcmeFolder": "/acme",
|
|
||||||
"DataFolder": "/data"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note:**
|
**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:**
|
**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": {
|
"Configuration": {
|
||||||
"CertsEngineConfiguration": {
|
"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": {
|
"Auth": {
|
||||||
"Secret": "<your-auth-secret>",
|
"Secret": "<your-auth-secret>",
|
||||||
@ -530,7 +528,7 @@ Set-Content -Path 'C:\Compose\MaksIT.CertsUI\secrets\appsecrets.json' -Value @'
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Note:**
|
**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.
|
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:**
|
**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",
|
"Production": "https://acme-v02.api.letsencrypt.org/directory",
|
||||||
"Staging": "https://acme-staging-v02.api.letsencrypt.org/directory",
|
"Staging": "https://acme-staging-v02.api.letsencrypt.org/directory",
|
||||||
"AcmeFolder": "/acme",
|
|
||||||
"DataFolder": "/data"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
'@
|
'@
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note:**
|
**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:**
|
**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": {
|
"Configuration": {
|
||||||
"CertsEngineConfiguration": {
|
"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": {
|
"Auth": {
|
||||||
"Secret": "<your-auth-secret>",
|
"Secret": "<your-auth-secret>",
|
||||||
@ -698,7 +694,7 @@ kubectl create secret generic certs-ui-server-secrets \
|
|||||||
--from-literal=appsecrets.json='{
|
--from-literal=appsecrets.json='{
|
||||||
"Configuration": {
|
"Configuration": {
|
||||||
"CertsEngineConfiguration": {
|
"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": {
|
"Auth": {
|
||||||
"Secret": "<your-auth-secret>",
|
"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",
|
"Production": "https://acme-v02.api.letsencrypt.org/directory",
|
||||||
"Staging": "https://acme-staging-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",
|
"Production": "https://acme-v02.api.letsencrypt.org/directory",
|
||||||
"Staging": "https://acme-staging-v02.api.letsencrypt.org/directory",
|
"Staging": "https://acme-staging-v02.api.letsencrypt.org/directory",
|
||||||
"AcmeFolder": "/acme",
|
|
||||||
"DataFolder": "/data"
|
|
||||||
}
|
}
|
||||||
}' \
|
}' \
|
||||||
-n certs-ui
|
-n certs-ui
|
||||||
|
|||||||
@ -11,10 +11,10 @@ This document explains how HA works in `MaksIT.CertsUI` after moving mutable ACM
|
|||||||
|
|
||||||
## Runtime model
|
## 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).
|
- **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.
|
- **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}` reads token value from PostgreSQL and materializes a short-lived file in `/acme` for compatibility.
|
- **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.
|
- **Background coordination:** bootstrap and renewal hosted services use named leases to avoid duplicate work.
|
||||||
|
|
||||||
## Lease design
|
## Lease design
|
||||||
@ -33,8 +33,7 @@ This is implemented as an optimistic single-statement `INSERT ... ON CONFLICT ..
|
|||||||
## HTTP-01 coherence design
|
## HTTP-01 coherence design
|
||||||
|
|
||||||
- `NewOrderAsync` stores challenge tokens in `acme_http_challenges` via `UpsertAsync`.
|
- `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.
|
- Challenge handler (`AcmeChallengeAsync`) reads the token value from the database and returns it as plain text.
|
||||||
- Fallback: if DB row is missing, legacy on-disk token read remains available for migration compatibility.
|
|
||||||
- Cleanup: auto-renewal loop calls `DeleteOlderThanAsync(TimeSpan.FromDays(10))`.
|
- Cleanup: auto-renewal loop calls `DeleteOlderThanAsync(TimeSpan.FromDays(10))`.
|
||||||
|
|
||||||
## Kubernetes behavior
|
## Kubernetes behavior
|
||||||
|
|||||||
@ -30,7 +30,7 @@ Controllers use the usual **`/api/...`** prefix (e.g. `api/identity`, account an
|
|||||||
|
|
||||||
### HTTP-01 (Let’s Encrypt)
|
### 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)
|
### 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>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
<RootNamespace>MaksIT.CertsUI.Engine.Tests</RootNamespace>
|
<RootNamespace>MaksIT.CertsUI.Engine.Tests</RootNamespace>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<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>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</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" Version="2.9.3" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<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.FetchedAtUtc).HasColumnName("fetched_at_utc")
|
||||||
.Property(x => x.ExpiresAtUtc).HasColumnName("expires_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();
|
builder.Build();
|
||||||
return schema;
|
return schema;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -63,8 +63,6 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
|
|||||||
private readonly IAcmeHttpChallengePersistenceService _httpChallenges;
|
private readonly IAcmeHttpChallengePersistenceService _httpChallenges;
|
||||||
private readonly IRuntimeLeaseService _runtimeLease;
|
private readonly IRuntimeLeaseService _runtimeLease;
|
||||||
private readonly IRuntimeInstanceId _runtimeInstance;
|
private readonly IRuntimeInstanceId _runtimeInstance;
|
||||||
private readonly IPrimaryReplicaWorkload _primaryReplica;
|
|
||||||
private readonly string _acmePath;
|
|
||||||
|
|
||||||
public CertsFlowDomainService(
|
public CertsFlowDomainService(
|
||||||
ILogger<CertsFlowDomainService> logger,
|
ILogger<CertsFlowDomainService> logger,
|
||||||
@ -76,8 +74,7 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
|
|||||||
ITermsOfServiceCachePersistenceService termsOfServiceCache,
|
ITermsOfServiceCachePersistenceService termsOfServiceCache,
|
||||||
IAcmeHttpChallengePersistenceService httpChallenges,
|
IAcmeHttpChallengePersistenceService httpChallenges,
|
||||||
IRuntimeLeaseService runtimeLease,
|
IRuntimeLeaseService runtimeLease,
|
||||||
IRuntimeInstanceId runtimeInstance,
|
IRuntimeInstanceId runtimeInstance) {
|
||||||
IPrimaryReplicaWorkload primaryReplica) {
|
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_httpClient = httpClient;
|
_httpClient = httpClient;
|
||||||
_letsEncryptService = letsEncryptService;
|
_letsEncryptService = letsEncryptService;
|
||||||
@ -88,14 +85,12 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
|
|||||||
_httpChallenges = httpChallenges;
|
_httpChallenges = httpChallenges;
|
||||||
_runtimeLease = runtimeLease;
|
_runtimeLease = runtimeLease;
|
||||||
_runtimeInstance = runtimeInstance;
|
_runtimeInstance = runtimeInstance;
|
||||||
_primaryReplica = primaryReplica;
|
|
||||||
_acmePath = config.AcmeFolder;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#region Terms of service
|
#region Terms of service
|
||||||
|
|
||||||
public async Task<Result<string?>> GetTermsOfServiceAsync(Guid sessionId) {
|
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)
|
if (!termsUriResult.IsSuccess || termsUriResult.Value == null)
|
||||||
return termsUriResult;
|
return termsUriResult;
|
||||||
|
|
||||||
@ -178,24 +173,18 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
|
|||||||
#region Session, orders, and certificates
|
#region Session, orders, and certificates
|
||||||
|
|
||||||
public async Task<Result> CompleteChallengesAsync(Guid sessionId) {
|
public async Task<Result> CompleteChallengesAsync(Guid sessionId) {
|
||||||
if (!_primaryReplica.IsPrimary)
|
return await _letsEncryptService.CompleteChallenges(sessionId, CancellationToken.None).ConfigureAwait(false);
|
||||||
return Result.ServiceUnavailable(CertsFlowPrimaryReplica.ServiceUnavailableMessages);
|
|
||||||
return await _letsEncryptService.CompleteChallenges(sessionId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<Guid?>> ConfigureClientAsync(bool isStaging) {
|
public async Task<Result<Guid?>> ConfigureClientAsync(bool isStaging) {
|
||||||
if (!_primaryReplica.IsPrimary)
|
|
||||||
return Result<Guid?>.ServiceUnavailable(null, CertsFlowPrimaryReplica.ServiceUnavailableMessages);
|
|
||||||
var sessionId = Guid.NewGuid();
|
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)
|
if (!result.IsSuccess)
|
||||||
return result.ToResultOfType<Guid?>(default);
|
return result.ToResultOfType<Guid?>(default);
|
||||||
return Result<Guid?>.Ok(sessionId);
|
return Result<Guid?>.Ok(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<Guid?>> InitAsync(Guid sessionId, Guid? accountId, string description, string[] contacts) {
|
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;
|
RegistrationCache? cache = null;
|
||||||
if (accountId == null) {
|
if (accountId == null) {
|
||||||
accountId = Guid.NewGuid();
|
accountId = Guid.NewGuid();
|
||||||
@ -209,15 +198,13 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
|
|||||||
cache = cacheResult.Value;
|
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)
|
if (!result.IsSuccess)
|
||||||
return result.ToResultOfType<Guid?>(default);
|
return result.ToResultOfType<Guid?>(default);
|
||||||
return Result<Guid?>.Ok(accountId.Value);
|
return Result<Guid?>.Ok(accountId.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<List<string>?>> NewOrderAsync(Guid sessionId, string[] hostnames, string challengeType) {
|
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 holder = _runtimeInstance.InstanceId;
|
||||||
var acquired = await _runtimeLease.TryAcquireAsync(RuntimeLeaseNames.AcmeWriter, holder, AcmeWriterLeaseTtl, CancellationToken.None);
|
var acquired = await _runtimeLease.TryAcquireAsync(RuntimeLeaseNames.AcmeWriter, holder, AcmeWriterLeaseTtl, CancellationToken.None);
|
||||||
if (!acquired.IsSuccess)
|
if (!acquired.IsSuccess)
|
||||||
@ -228,7 +215,7 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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)
|
if (!orderResult.IsSuccess || orderResult.Value == null)
|
||||||
return orderResult.ToResultOfType<List<string>?>(_ => null);
|
return orderResult.ToResultOfType<List<string>?>(_ => null);
|
||||||
var challenges = new List<string>();
|
var challenges = new List<string>();
|
||||||
@ -253,15 +240,13 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result> GetCertificatesAsync(Guid sessionId, string[] hostnames) {
|
public async Task<Result> GetCertificatesAsync(Guid sessionId, string[] hostnames) {
|
||||||
if (!_primaryReplica.IsPrimary)
|
|
||||||
return Result.ServiceUnavailable(CertsFlowPrimaryReplica.ServiceUnavailableMessages);
|
|
||||||
foreach (var subject in hostnames) {
|
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)
|
if (!result.IsSuccess)
|
||||||
return result;
|
return result;
|
||||||
Thread.Sleep(1000);
|
Thread.Sleep(1000);
|
||||||
}
|
}
|
||||||
var cacheResult = _letsEncryptService.GetRegistrationCache(sessionId);
|
var cacheResult = await _letsEncryptService.GetRegistrationCacheAsync(sessionId, CancellationToken.None).ConfigureAwait(false);
|
||||||
if (!cacheResult.IsSuccess || cacheResult.Value == null)
|
if (!cacheResult.IsSuccess || cacheResult.Value == null)
|
||||||
return cacheResult;
|
return cacheResult;
|
||||||
var saveResult = await _registrationCache.SaveAsync(cacheResult.Value.AccountId, cacheResult.Value);
|
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) {
|
public async Task<Result> GetOrderAsync(Guid sessionId, string[] hostnames) {
|
||||||
if (!_primaryReplica.IsPrimary)
|
return await _letsEncryptService.GetOrder(sessionId, hostnames, CancellationToken.None).ConfigureAwait(false);
|
||||||
return Result.ServiceUnavailable(CertsFlowPrimaryReplica.ServiceUnavailableMessages);
|
|
||||||
return await _letsEncryptService.GetOrder(sessionId, hostnames);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
@ -281,8 +264,6 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
|
|||||||
#region Deploy and revoke
|
#region Deploy and revoke
|
||||||
|
|
||||||
public async Task<Result<Dictionary<string, string>?>> ApplyCertificatesAsync(Guid accountId) {
|
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);
|
var cacheResult = await _registrationCache.LoadAsync(accountId);
|
||||||
if (!cacheResult.IsSuccess || cacheResult.Value?.CachedCerts == null)
|
if (!cacheResult.IsSuccess || cacheResult.Value?.CachedCerts == null)
|
||||||
return cacheResult.ToResultOfType<Dictionary<string, string>?>(_ => 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) {
|
public async Task<Result> RevokeCertificatesAsync(Guid sessionId, string[] hostnames) {
|
||||||
if (!_primaryReplica.IsPrimary)
|
|
||||||
return Result.ServiceUnavailable(CertsFlowPrimaryReplica.ServiceUnavailableMessages);
|
|
||||||
foreach (var hostname in hostnames) {
|
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)
|
if (!result.IsSuccess)
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
var cacheResult = _letsEncryptService.GetRegistrationCache(sessionId);
|
var cacheResult = await _letsEncryptService.GetRegistrationCacheAsync(sessionId, CancellationToken.None).ConfigureAwait(false);
|
||||||
if (!cacheResult.IsSuccess || cacheResult.Value == null)
|
if (!cacheResult.IsSuccess || cacheResult.Value == null)
|
||||||
return cacheResult;
|
return cacheResult;
|
||||||
var saveResult = await _registrationCache.SaveAsync(cacheResult.Value.AccountId, cacheResult.Value);
|
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.");
|
return Result<string?>.BadRequest(null, "fileName is required.");
|
||||||
|
|
||||||
var fromDb = await _httpChallenges.GetTokenValueAsync(fileName, cancellationToken).ConfigureAwait(false);
|
var fromDb = await _httpChallenges.GetTokenValueAsync(fileName, cancellationToken).ConfigureAwait(false);
|
||||||
if (fromDb.IsSuccess && !string.IsNullOrEmpty(fromDb.Value)) {
|
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);
|
|
||||||
return Result<string?>.Ok(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}");
|
return Result<string?>.NotFound(null, $"Challenge token not found: {fileName}");
|
||||||
}
|
}
|
||||||
@ -416,7 +385,7 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
|
|||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
private async Task TryPersistRegistrationCacheFromSessionAsync(Guid sessionId) {
|
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)
|
if (!cacheResult.IsSuccess || cacheResult.Value == null)
|
||||||
return;
|
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;
|
namespace MaksIT.CertsUI.Engine.DomainServices;
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
public interface ICertsFlowEngineConfiguration {
|
public interface ICertsFlowEngineConfiguration {
|
||||||
string AcmeFolder { get; }
|
|
||||||
string DataFolder { get; }
|
|
||||||
string AgentServiceToReload { 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
|
#endregion
|
||||||
|
|
||||||
#region ACME / Let's Encrypt
|
#region ACME / Let's Encrypt
|
||||||
services.AddSingleton<AcmeSessionStore>();
|
services.AddSingleton<IAcmeSessionStore, AcmePostgresSessionStore>();
|
||||||
services.AddHttpClient<ILetsEncryptService, LetsEncryptService>();
|
services.AddHttpClient<ILetsEncryptService, LetsEncryptService>();
|
||||||
#endregion
|
#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>
|
/// </summary>
|
||||||
public static class CoordinationTableProvisioner {
|
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) {
|
public static async Task EnsureAsync(string? connectionString, CancellationToken cancellationToken = default) {
|
||||||
if (string.IsNullOrWhiteSpace(connectionString))
|
if (string.IsNullOrWhiteSpace(connectionString))
|
||||||
return;
|
return;
|
||||||
@ -31,6 +31,13 @@ public static class CoordinationTableProvisioner {
|
|||||||
acquired_at_utc timestamp with time zone NOT NULL,
|
acquired_at_utc timestamp with time zone NOT NULL,
|
||||||
expires_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);
|
conn);
|
||||||
await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|||||||
@ -69,6 +69,12 @@ public class SchemaSyncService(ICertsEngineConfiguration config, ILogger<SchemaS
|
|||||||
("acquired_at_utc", "timestamp with time zone"),
|
("acquired_at_utc", "timestamp with time zone"),
|
||||||
("expires_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"] = [
|
["api_keys"] = [
|
||||||
("Id", "uuid"),
|
("Id", "uuid"),
|
||||||
("Description", "text"),
|
("Description", "text"),
|
||||||
|
|||||||
@ -2,10 +2,7 @@
|
|||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<NoWarn>CA2254;NU1903;NU1904</NoWarn>
|
<NoWarn>CA2254;NU1903;NU1904</NoWarn>
|
||||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@ -14,20 +11,18 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="FluentMigrator" Version="8.0.1" />
|
<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="FluentMigrator.Runner.Postgres" Version="8.0.1" />
|
||||||
<PackageReference Include="linq2db" Version="6.2.1" />
|
<PackageReference Include="linq2db" Version="6.2.1" />
|
||||||
<PackageReference Include="linq2db.PostgreSQL" Version="6.2.1" />
|
<PackageReference Include="linq2db.PostgreSQL" Version="6.2.1" />
|
||||||
<PackageReference Include="MaksIT.Core" Version="1.6.5" />
|
<PackageReference Include="MaksIT.Core" Version="1.6.5" />
|
||||||
<PackageReference Include="MaksIT.Dapr" Version="2.0.0" />
|
<PackageReference Include="MaksIT.Dapr" Version="2.0.0" />
|
||||||
<PackageReference Include="MaksIT.Results" Version="2.0.1" />
|
<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.7" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.5" />
|
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.7" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.5" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5" />
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.7" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.5" />
|
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.7" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.5" />
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
|
||||||
<PackageReference Include="Npgsql" Version="10.0.2" />
|
<PackageReference Include="Npgsql" Version="10.0.2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@ -39,22 +39,27 @@ public sealed class TermsOfServiceCachePersistenceServiceLinq2Db(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
using var db = connectionFactory.Create();
|
using var db = connectionFactory.Create();
|
||||||
var existing = db.GetTable<TermsOfServiceCacheDto>().FirstOrDefault(x => x.Url == cacheEntry.Url);
|
db.GetTable<TermsOfServiceCacheDto>().InsertOrUpdate(
|
||||||
if (existing == null) {
|
() => new TermsOfServiceCacheDto {
|
||||||
db.Insert(cacheEntry);
|
Url = cacheEntry.Url,
|
||||||
}
|
UrlHashHex = cacheEntry.UrlHashHex,
|
||||||
else {
|
ETag = cacheEntry.ETag,
|
||||||
db.GetTable<TermsOfServiceCacheDto>()
|
LastModifiedUtc = cacheEntry.LastModifiedUtc,
|
||||||
.Where(x => x.Url == cacheEntry.Url)
|
ContentType = cacheEntry.ContentType,
|
||||||
.Set(x => x.UrlHashHex, cacheEntry.UrlHashHex)
|
ContentBytes = cacheEntry.ContentBytes,
|
||||||
.Set(x => x.ETag, cacheEntry.ETag)
|
FetchedAtUtc = cacheEntry.FetchedAtUtc,
|
||||||
.Set(x => x.LastModifiedUtc, cacheEntry.LastModifiedUtc)
|
ExpiresAtUtc = cacheEntry.ExpiresAtUtc
|
||||||
.Set(x => x.ContentType, cacheEntry.ContentType)
|
},
|
||||||
.Set(x => x.ContentBytes, cacheEntry.ContentBytes)
|
old => new TermsOfServiceCacheDto {
|
||||||
.Set(x => x.FetchedAtUtc, cacheEntry.FetchedAtUtc)
|
Url = old.Url,
|
||||||
.Set(x => x.ExpiresAtUtc, cacheEntry.ExpiresAtUtc)
|
UrlHashHex = cacheEntry.UrlHashHex,
|
||||||
.Update();
|
ETag = cacheEntry.ETag,
|
||||||
}
|
LastModifiedUtc = cacheEntry.LastModifiedUtc,
|
||||||
|
ContentType = cacheEntry.ContentType,
|
||||||
|
ContentBytes = cacheEntry.ContentBytes,
|
||||||
|
FetchedAtUtc = cacheEntry.FetchedAtUtc,
|
||||||
|
ExpiresAtUtc = cacheEntry.ExpiresAtUtc
|
||||||
|
});
|
||||||
|
|
||||||
return Task.FromResult(Result.Ok());
|
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 static class RuntimeLeaseNames {
|
||||||
public const string AcmeWriter = "certs-ui-acme-writer";
|
public const string AcmeWriter = "certs-ui-acme-writer";
|
||||||
|
|
||||||
/// <summary>Single elected instance: identity bootstrap, ACME orchestration, and background renewal.</summary>
|
/// <summary>Held only for coordination DDL + optional default-admin bootstrap; released when done (no renewal loop).</summary>
|
||||||
public const string PrimaryReplica = "certs-ui-primary";
|
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
|
#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)
|
if (uri == null)
|
||||||
return Result<string?>.InternalServerError(null, "URI is null");
|
return Result<string?>.InternalServerError(null, "URI is null");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var state = GetOrCreateState(sessionId);
|
|
||||||
|
|
||||||
_logger.LogInformation($"Executing {nameof(GetNonceAsync)}...");
|
_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) {
|
private Result<string?> EncodeMessage(State state, bool isPostAsGet, object? requestModel, ACMEJwsHeader protectedHeader) {
|
||||||
var state = GetOrCreateState(sessionId);
|
|
||||||
|
|
||||||
if (!state.TryGetAccountKey(out var rsa, out var jwk))
|
if (!state.TryGetAccountKey(out var rsa, out var jwk))
|
||||||
return Result<string?>.InternalServerError(AccountKeyMissingMessage);
|
return Result<string?>.InternalServerError(AccountKeyMissingMessage);
|
||||||
|
|
||||||
@ -94,7 +115,7 @@ public partial class LetsEncryptService {
|
|||||||
request.Content.Headers.ContentType = new MediaTypeHeaderValue(contentType);
|
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)
|
if (challenge?.Url == null)
|
||||||
return Result.InternalServerError("Challenge URL is null");
|
return Result.InternalServerError("Challenge URL is null");
|
||||||
|
|
||||||
@ -103,13 +124,13 @@ public partial class LetsEncryptService {
|
|||||||
while (true) {
|
while (true) {
|
||||||
var pollRequest = new HttpRequestMessage(HttpMethod.Post, challenge.Url);
|
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)
|
if (!nonceResult.IsSuccess || nonceResult.Value == null)
|
||||||
return nonceResult;
|
return nonceResult;
|
||||||
|
|
||||||
var nonce = nonceResult.Value;
|
var nonce = nonceResult.Value;
|
||||||
|
|
||||||
var pollJsonResult = EncodeMessage(sessionId, true, null, new ACMEJwsHeader {
|
var pollJsonResult = EncodeMessage(state, true, null, new ACMEJwsHeader {
|
||||||
Url = challenge.Url.ToString(),
|
Url = challenge.Url.ToString(),
|
||||||
Nonce = nonce
|
Nonce = nonce
|
||||||
});
|
});
|
||||||
|
|||||||
@ -16,6 +16,7 @@ using MaksIT.CertsUI.Engine.Dto.LetsEncrypt.Responses;
|
|||||||
using MaksIT.Results;
|
using MaksIT.Results;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
|
using System.Threading;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Security.Cryptography.X509Certificates;
|
using System.Security.Cryptography.X509Certificates;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
@ -24,15 +25,15 @@ using System.Text;
|
|||||||
namespace MaksIT.CertsUI.Engine.Services;
|
namespace MaksIT.CertsUI.Engine.Services;
|
||||||
|
|
||||||
public interface ILetsEncryptService {
|
public interface ILetsEncryptService {
|
||||||
Result<RegistrationCache?> GetRegistrationCache(Guid sessionId);
|
Task<Result<RegistrationCache?>> GetRegistrationCacheAsync(Guid sessionId, CancellationToken cancellationToken = default);
|
||||||
Task<Result> ConfigureClient(Guid sessionId, bool isStaging);
|
Task<Result> ConfigureClient(Guid sessionId, bool isStaging, CancellationToken cancellationToken = default);
|
||||||
Task<Result> Init(Guid sessionId, Guid accountId, string description, string[] contacts, RegistrationCache? registrationCache);
|
Task<Result> Init(Guid sessionId, Guid accountId, string description, string[] contacts, RegistrationCache? registrationCache, CancellationToken cancellationToken = default);
|
||||||
Result<string?> GetTermsOfServiceUri(Guid sessionId);
|
Task<Result<string?>> GetTermsOfServiceUriAsync(Guid sessionId, CancellationToken cancellationToken = default);
|
||||||
Task<Result<Dictionary<string, string>?>> NewOrder(Guid sessionId, string[] hostnames, string challengeType);
|
Task<Result<Dictionary<string, string>?>> NewOrder(Guid sessionId, string[] hostnames, string challengeType, CancellationToken cancellationToken = default);
|
||||||
Task<Result> CompleteChallenges(Guid sessionId);
|
Task<Result> CompleteChallenges(Guid sessionId, CancellationToken cancellationToken = default);
|
||||||
Task<Result> GetOrder(Guid sessionId, string[] hostnames);
|
Task<Result> GetOrder(Guid sessionId, string[] hostnames, CancellationToken cancellationToken = default);
|
||||||
Task<Result> GetCertificate(Guid sessionId, string subject);
|
Task<Result> GetCertificate(Guid sessionId, string subject, CancellationToken cancellationToken = default);
|
||||||
Task<Result> RevokeCertificate(Guid sessionId, string subject, RevokeReason reason);
|
Task<Result> RevokeCertificate(Guid sessionId, string subject, RevokeReason reason, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial class LetsEncryptService : ILetsEncryptService {
|
public partial class LetsEncryptService : ILetsEncryptService {
|
||||||
@ -44,40 +45,36 @@ public partial class LetsEncryptService : ILetsEncryptService {
|
|||||||
private readonly ILogger<LetsEncryptService> _logger;
|
private readonly ILogger<LetsEncryptService> _logger;
|
||||||
private readonly ICertsEngineConfiguration _engineConfiguration;
|
private readonly ICertsEngineConfiguration _engineConfiguration;
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly AcmeSessionStore _sessions;
|
private readonly IAcmeSessionStore _sessionStore;
|
||||||
|
|
||||||
public LetsEncryptService(
|
public LetsEncryptService(
|
||||||
ILogger<LetsEncryptService> logger,
|
ILogger<LetsEncryptService> logger,
|
||||||
ICertsEngineConfiguration engineConfiguration,
|
ICertsEngineConfiguration engineConfiguration,
|
||||||
HttpClient httpClient,
|
HttpClient httpClient,
|
||||||
AcmeSessionStore sessions
|
IAcmeSessionStore sessionStore
|
||||||
) {
|
) {
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_engineConfiguration = engineConfiguration;
|
_engineConfiguration = engineConfiguration;
|
||||||
_httpClient = httpClient;
|
_httpClient = httpClient;
|
||||||
_sessions = sessions;
|
_sessionStore = sessionStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Result<RegistrationCache?> GetRegistrationCache(Guid sessionId) {
|
public Task<Result<RegistrationCache?>> GetRegistrationCacheAsync(Guid sessionId, CancellationToken cancellationToken = default) =>
|
||||||
var state = GetOrCreateState(sessionId);
|
WithPersistedSessionAsync<RegistrationCache?>(sessionId, cancellationToken, async state => {
|
||||||
|
|
||||||
if (state.Cache == null)
|
if (state.Cache == null)
|
||||||
return Result<RegistrationCache?>.InternalServerError(null);
|
return Result<RegistrationCache?>.InternalServerError(null);
|
||||||
|
|
||||||
return Result<RegistrationCache?>.Ok(state.Cache);
|
return Result<RegistrationCache?>.Ok(state.Cache);
|
||||||
}
|
});
|
||||||
|
|
||||||
#region ConfigureClient
|
#region ConfigureClient
|
||||||
public async Task<Result> ConfigureClient(Guid sessionId, bool isStaging) {
|
public Task<Result> ConfigureClient(Guid sessionId, bool isStaging, CancellationToken cancellationToken = default) =>
|
||||||
|
WithPersistedSessionAsync(sessionId, cancellationToken, async state => {
|
||||||
try {
|
try {
|
||||||
var state = GetOrCreateState(sessionId);
|
|
||||||
|
|
||||||
state.IsStaging = isStaging;
|
state.IsStaging = isStaging;
|
||||||
|
|
||||||
_httpClient.BaseAddress ??= new Uri(isStaging ? _engineConfiguration.LetsEncryptStaging : _engineConfiguration.LetsEncryptProduction);
|
|
||||||
|
|
||||||
if (state.Directory == null) {
|
if (state.Directory == null) {
|
||||||
var request = new HttpRequestMessage(HttpMethod.Get, new Uri(DirectoryEndpoint, UriKind.Relative));
|
var directoryUri = AcmeDirectoryAbsoluteUri(isStaging);
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Get, directoryUri);
|
||||||
|
|
||||||
var requestResult = await SendAcmeRequest<AcmeDirectory>(request, state, HttpMethod.Get);
|
var requestResult = await SendAcmeRequest<AcmeDirectory>(request, state, HttpMethod.Get);
|
||||||
if (!requestResult.IsSuccess || requestResult.Value == null)
|
if (!requestResult.IsSuccess || requestResult.Value == null)
|
||||||
@ -91,31 +88,29 @@ public partial class LetsEncryptService : ILetsEncryptService {
|
|||||||
return Result.Ok("Client configured successfully.");
|
return Result.Ok("Client configured successfully.");
|
||||||
}
|
}
|
||||||
catch (LetsEncrytException ex) {
|
catch (LetsEncrytException ex) {
|
||||||
var state = GetOrCreateState(sessionId);
|
|
||||||
return MapLetsEncryptException(state, ex);
|
return MapLetsEncryptException(state, ex);
|
||||||
}
|
}
|
||||||
catch (Exception ex) {
|
catch (Exception ex) {
|
||||||
return HandleUnhandledException(ex);
|
return HandleUnhandledException(ex);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Init
|
#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) {
|
if (sessionId == Guid.Empty) {
|
||||||
const string message = "Invalid sessionId";
|
const string message = "Invalid sessionId";
|
||||||
_logger.LogError(message);
|
_logger.LogError(message);
|
||||||
return Result.InternalServerError(message);
|
return Task.FromResult(Result.InternalServerError(message));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contacts == null || contacts.Length == 0) {
|
if (contacts == null || contacts.Length == 0) {
|
||||||
const string message = "Contacts are null or empty";
|
const string message = "Contacts are null or empty";
|
||||||
_logger.LogError(message);
|
_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) {
|
if (state.Directory == null) {
|
||||||
const string message = "State directory is null";
|
const string message = "State directory is null";
|
||||||
_logger.LogError(message);
|
_logger.LogError(message);
|
||||||
@ -159,13 +154,13 @@ public partial class LetsEncryptService : ILetsEncryptService {
|
|||||||
|
|
||||||
var request = new HttpRequestMessage(HttpMethod.Post, newAccountUri);
|
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)
|
if (!nonceResult.IsSuccess || nonceResult.Value == null)
|
||||||
return nonceResult;
|
return nonceResult;
|
||||||
|
|
||||||
var nonce = nonceResult.Value;
|
var nonce = nonceResult.Value;
|
||||||
|
|
||||||
var jsonResult = EncodeMessage(sessionId, false, letsEncryptOrder, new ACMEJwsHeader {
|
var jsonResult = EncodeMessage(state, false, letsEncryptOrder, new ACMEJwsHeader {
|
||||||
Url = newAccountUri.ToString(),
|
Url = newAccountUri.ToString(),
|
||||||
Nonce = nonce
|
Nonce = nonce
|
||||||
});
|
});
|
||||||
@ -212,15 +207,15 @@ public partial class LetsEncryptService : ILetsEncryptService {
|
|||||||
catch (Exception ex) {
|
catch (Exception ex) {
|
||||||
return HandleUnhandledException(ex);
|
return HandleUnhandledException(ex);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region GetTermsOfService
|
#region GetTermsOfService
|
||||||
public Result<string?> GetTermsOfServiceUri(Guid sessionId) {
|
public Task<Result<string?>> GetTermsOfServiceUriAsync(Guid sessionId, CancellationToken cancellationToken = default) =>
|
||||||
|
WithPersistedSessionAsync<string?>(sessionId, cancellationToken, async state => {
|
||||||
try {
|
try {
|
||||||
var state = GetOrCreateState(sessionId);
|
_logger.LogInformation($"Executing {nameof(GetTermsOfServiceUriAsync)}...");
|
||||||
|
|
||||||
_logger.LogInformation($"Executing {nameof(GetTermsOfServiceUri)}...");
|
|
||||||
|
|
||||||
if (state.Directory?.Meta?.TermsOfService == null) {
|
if (state.Directory?.Meta?.TermsOfService == null) {
|
||||||
return Result<string?>.Ok(null);
|
return Result<string?>.Ok(null);
|
||||||
@ -231,14 +226,13 @@ public partial class LetsEncryptService : ILetsEncryptService {
|
|||||||
catch (Exception ex) {
|
catch (Exception ex) {
|
||||||
return HandleUnhandledException<string?>(ex);
|
return HandleUnhandledException<string?>(ex);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region NewOrder
|
#region NewOrder
|
||||||
public async Task<Result<Dictionary<string, string>?>> NewOrder(Guid sessionId, string[] hostnames, string challengeType) {
|
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 {
|
try {
|
||||||
var state = GetOrCreateState(sessionId);
|
|
||||||
|
|
||||||
_logger.LogInformation($"Executing {nameof(NewOrder)}...");
|
_logger.LogInformation($"Executing {nameof(NewOrder)}...");
|
||||||
|
|
||||||
state.Challenges.Clear();
|
state.Challenges.Clear();
|
||||||
@ -256,13 +250,13 @@ public partial class LetsEncryptService : ILetsEncryptService {
|
|||||||
|
|
||||||
var request = new HttpRequestMessage(HttpMethod.Post, newOrderUri);
|
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)
|
if (!nonceResult.IsSuccess || nonceResult.Value == null)
|
||||||
return nonceResult.ToResultOfType<Dictionary<string, string>?>(_ => null);
|
return nonceResult.ToResultOfType<Dictionary<string, string>?>(_ => null);
|
||||||
|
|
||||||
var nonce = nonceResult.Value;
|
var nonce = nonceResult.Value;
|
||||||
|
|
||||||
var jsonResult = EncodeMessage(sessionId, false, letsEncryptOrder, new ACMEJwsHeader {
|
var jsonResult = EncodeMessage(state, false, letsEncryptOrder, new ACMEJwsHeader {
|
||||||
Url = newOrderUri.ToString(),
|
Url = newOrderUri.ToString(),
|
||||||
Nonce = nonce
|
Nonce = nonce
|
||||||
});
|
});
|
||||||
@ -298,13 +292,13 @@ public partial class LetsEncryptService : ILetsEncryptService {
|
|||||||
|
|
||||||
request = new HttpRequestMessage(HttpMethod.Post, item);
|
request = new HttpRequestMessage(HttpMethod.Post, item);
|
||||||
|
|
||||||
nonceResult = await GetNonceAsync(sessionId, item);
|
nonceResult = await GetNonceAsync(state, item);
|
||||||
if (!nonceResult.IsSuccess || nonceResult.Value == null)
|
if (!nonceResult.IsSuccess || nonceResult.Value == null)
|
||||||
return nonceResult.ToResultOfType<Dictionary<string, string>?>(_ => null);
|
return nonceResult.ToResultOfType<Dictionary<string, string>?>(_ => null);
|
||||||
|
|
||||||
nonce = nonceResult.Value;
|
nonce = nonceResult.Value;
|
||||||
|
|
||||||
jsonResult = EncodeMessage(sessionId, true, null, new ACMEJwsHeader {
|
jsonResult = EncodeMessage(state, true, null, new ACMEJwsHeader {
|
||||||
Url = item.ToString(),
|
Url = item.ToString(),
|
||||||
Nonce = nonce
|
Nonce = nonce
|
||||||
});
|
});
|
||||||
@ -370,14 +364,13 @@ public partial class LetsEncryptService : ILetsEncryptService {
|
|||||||
catch (Exception ex) {
|
catch (Exception ex) {
|
||||||
return HandleUnhandledException<Dictionary<string, string>?>(ex);
|
return HandleUnhandledException<Dictionary<string, string>?>(ex);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region CompleteChallenges
|
#region CompleteChallenges
|
||||||
public async Task<Result> CompleteChallenges(Guid sessionId) {
|
public Task<Result> CompleteChallenges(Guid sessionId, CancellationToken cancellationToken = default) =>
|
||||||
|
WithPersistedSessionAsync(sessionId, cancellationToken, async state => {
|
||||||
try {
|
try {
|
||||||
var state = GetOrCreateState(sessionId);
|
|
||||||
|
|
||||||
_logger.LogInformation($"Executing {nameof(CompleteChallenges)}...");
|
_logger.LogInformation($"Executing {nameof(CompleteChallenges)}...");
|
||||||
|
|
||||||
if (state.CurrentOrder?.Identifiers == null) {
|
if (state.CurrentOrder?.Identifiers == null) {
|
||||||
@ -399,13 +392,13 @@ public partial class LetsEncryptService : ILetsEncryptService {
|
|||||||
|
|
||||||
var request = new HttpRequestMessage(HttpMethod.Post, challenge.Url);
|
var request = 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)
|
if (!nonceResult.IsSuccess || nonceResult.Value == null)
|
||||||
return nonceResult;
|
return nonceResult;
|
||||||
|
|
||||||
var nonce = nonceResult.Value;
|
var nonce = nonceResult.Value;
|
||||||
|
|
||||||
var jsonResult = EncodeMessage(sessionId, false, "{}", new ACMEJwsHeader {
|
var jsonResult = EncodeMessage(state, false, "{}", new ACMEJwsHeader {
|
||||||
Url = challenge.Url.ToString(),
|
Url = challenge.Url.ToString(),
|
||||||
Nonce = nonce
|
Nonce = nonce
|
||||||
});
|
});
|
||||||
@ -419,7 +412,7 @@ public partial class LetsEncryptService : ILetsEncryptService {
|
|||||||
|
|
||||||
_ = await SendAcmeRequest<AuthorizationChallengeResponse>(request, state, HttpMethod.Post);
|
_ = await SendAcmeRequest<AuthorizationChallengeResponse>(request, state, HttpMethod.Post);
|
||||||
|
|
||||||
var result = await PollChallengeStatus(sessionId, challenge);
|
var result = await PollChallengeStatus(state, challenge);
|
||||||
|
|
||||||
if (!result.IsSuccess)
|
if (!result.IsSuccess)
|
||||||
return result;
|
return result;
|
||||||
@ -427,21 +420,22 @@ public partial class LetsEncryptService : ILetsEncryptService {
|
|||||||
return Result.Ok();
|
return Result.Ok();
|
||||||
}
|
}
|
||||||
catch (LetsEncrytException ex) {
|
catch (LetsEncrytException ex) {
|
||||||
return MapLetsEncryptException(GetOrCreateState(sessionId), ex);
|
return MapLetsEncryptException(state, ex);
|
||||||
}
|
}
|
||||||
catch (Exception ex) {
|
catch (Exception ex) {
|
||||||
return HandleUnhandledException(ex);
|
return HandleUnhandledException(ex);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region GetOrder
|
#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 {
|
try {
|
||||||
_logger.LogInformation($"Executing {nameof(GetOrder)}");
|
_logger.LogInformation($"Executing {nameof(GetOrder)}");
|
||||||
|
|
||||||
var state = GetOrCreateState(sessionId);
|
|
||||||
|
|
||||||
if (state.Directory?.NewOrder is not { } newOrderUri)
|
if (state.Directory?.NewOrder is not { } newOrderUri)
|
||||||
return Result.InternalServerError("Directory is not configured. Run ConfigureClient first.");
|
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 request = new HttpRequestMessage(HttpMethod.Post, newOrderUri);
|
||||||
|
|
||||||
var nonceResult = await GetNonceAsync(sessionId, newOrderUri);
|
var nonceResult = await GetNonceAsync(state, newOrderUri);
|
||||||
if (!nonceResult.IsSuccess || nonceResult.Value == null)
|
if (!nonceResult.IsSuccess || nonceResult.Value == null)
|
||||||
return nonceResult;
|
return nonceResult;
|
||||||
|
|
||||||
var nonce = nonceResult.Value;
|
var nonce = nonceResult.Value;
|
||||||
|
|
||||||
var jsonResult = EncodeMessage(sessionId, false, letsEncryptOrder, new ACMEJwsHeader {
|
var jsonResult = EncodeMessage(state, false, letsEncryptOrder, new ACMEJwsHeader {
|
||||||
Url = newOrderUri.ToString(),
|
Url = newOrderUri.ToString(),
|
||||||
Nonce = nonce
|
Nonce = nonce
|
||||||
});
|
});
|
||||||
@ -490,10 +484,9 @@ public partial class LetsEncryptService : ILetsEncryptService {
|
|||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region GetCertificates
|
#region GetCertificates
|
||||||
public async Task<Result> GetCertificate(Guid sessionId, string subject) {
|
public Task<Result> GetCertificate(Guid sessionId, string subject, CancellationToken cancellationToken = default) =>
|
||||||
|
WithPersistedSessionAsync(sessionId, cancellationToken, async state => {
|
||||||
try {
|
try {
|
||||||
var state = GetOrCreateState(sessionId);
|
|
||||||
|
|
||||||
_logger.LogInformation($"Executing {nameof(GetCertificate)}...");
|
_logger.LogInformation($"Executing {nameof(GetCertificate)}...");
|
||||||
|
|
||||||
if (state.CurrentOrder?.Identifiers is not { } initialIdentifiers)
|
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();
|
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;
|
activeOrder = state.CurrentOrder;
|
||||||
if (activeOrder is null)
|
if (activeOrder is null)
|
||||||
@ -539,13 +532,13 @@ public partial class LetsEncryptService : ILetsEncryptService {
|
|||||||
|
|
||||||
var request = new HttpRequestMessage(HttpMethod.Post, finalizeUri);
|
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)
|
if (!nonceResult.IsSuccess || nonceResult.Value == null)
|
||||||
return nonceResult;
|
return nonceResult;
|
||||||
|
|
||||||
var nonce = nonceResult.Value;
|
var nonce = nonceResult.Value;
|
||||||
|
|
||||||
var jsonResult = EncodeMessage(sessionId, false, letsEncryptOrder, new ACMEJwsHeader {
|
var jsonResult = EncodeMessage(state, false, letsEncryptOrder, new ACMEJwsHeader {
|
||||||
Url = finalizeUri.ToString(),
|
Url = finalizeUri.ToString(),
|
||||||
Nonce = nonce
|
Nonce = nonce
|
||||||
});
|
});
|
||||||
@ -570,13 +563,13 @@ public partial class LetsEncryptService : ILetsEncryptService {
|
|||||||
|
|
||||||
request = new HttpRequestMessage(HttpMethod.Post, orderLocation);
|
request = new HttpRequestMessage(HttpMethod.Post, orderLocation);
|
||||||
|
|
||||||
nonceResult = await GetNonceAsync(sessionId, orderLocation);
|
nonceResult = await GetNonceAsync(state, orderLocation);
|
||||||
if (!nonceResult.IsSuccess || nonceResult.Value == null)
|
if (!nonceResult.IsSuccess || nonceResult.Value == null)
|
||||||
return nonceResult;
|
return nonceResult;
|
||||||
|
|
||||||
nonce = nonceResult.Value;
|
nonce = nonceResult.Value;
|
||||||
|
|
||||||
jsonResult = EncodeMessage(sessionId, true, null, new ACMEJwsHeader {
|
jsonResult = EncodeMessage(state, true, null, new ACMEJwsHeader {
|
||||||
Url = orderLocation.ToString(),
|
Url = orderLocation.ToString(),
|
||||||
Nonce = nonce
|
Nonce = nonce
|
||||||
});
|
});
|
||||||
@ -619,13 +612,13 @@ public partial class LetsEncryptService : ILetsEncryptService {
|
|||||||
|
|
||||||
var finalRequest = new HttpRequestMessage(HttpMethod.Post, certificateUrl);
|
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)
|
if (!finalNonceResult.IsSuccess || finalNonceResult.Value == null)
|
||||||
return finalNonceResult;
|
return finalNonceResult;
|
||||||
|
|
||||||
var finalNonce = finalNonceResult.Value;
|
var finalNonce = finalNonceResult.Value;
|
||||||
|
|
||||||
var finalJsonResult = EncodeMessage(sessionId, true, null, new ACMEJwsHeader {
|
var finalJsonResult = EncodeMessage(state, true, null, new ACMEJwsHeader {
|
||||||
Url = certificateUrl.ToString(),
|
Url = certificateUrl.ToString(),
|
||||||
Nonce = finalNonce
|
Nonce = finalNonce
|
||||||
});
|
});
|
||||||
@ -661,12 +654,12 @@ public partial class LetsEncryptService : ILetsEncryptService {
|
|||||||
return Result.Ok();
|
return Result.Ok();
|
||||||
}
|
}
|
||||||
catch (LetsEncrytException ex) {
|
catch (LetsEncrytException ex) {
|
||||||
return MapLetsEncryptException(GetOrCreateState(sessionId), ex);
|
return MapLetsEncryptException(state, ex);
|
||||||
}
|
}
|
||||||
catch (Exception ex) {
|
catch (Exception ex) {
|
||||||
return HandleUnhandledException(ex);
|
return HandleUnhandledException(ex);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Key change
|
#region Key change
|
||||||
@ -676,10 +669,9 @@ public partial class LetsEncryptService : ILetsEncryptService {
|
|||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region RevokeCertificate
|
#region RevokeCertificate
|
||||||
public async Task<Result> RevokeCertificate(Guid sessionId, string subject, RevokeReason reason) {
|
public Task<Result> RevokeCertificate(Guid sessionId, string subject, RevokeReason reason, CancellationToken cancellationToken = default) =>
|
||||||
|
WithPersistedSessionAsync(sessionId, cancellationToken, async state => {
|
||||||
try {
|
try {
|
||||||
var state = GetOrCreateState(sessionId);
|
|
||||||
|
|
||||||
_logger.LogInformation($"Executing {nameof(RevokeCertificate)}...");
|
_logger.LogInformation($"Executing {nameof(RevokeCertificate)}...");
|
||||||
|
|
||||||
if (state.Cache?.CachedCerts == null || !state.Cache.CachedCerts.TryGetValue(subject, out var certificateCache) || certificateCache == null) {
|
if (state.Cache?.CachedCerts == null || !state.Cache.CachedCerts.TryGetValue(subject, out var certificateCache) || certificateCache == null) {
|
||||||
@ -713,7 +705,7 @@ public partial class LetsEncryptService : ILetsEncryptService {
|
|||||||
|
|
||||||
var request = new HttpRequestMessage(HttpMethod.Post, revokeUri);
|
var request = new HttpRequestMessage(HttpMethod.Post, revokeUri);
|
||||||
|
|
||||||
var nonceResult = await GetNonceAsync(sessionId, revokeUri);
|
var nonceResult = await GetNonceAsync(state, revokeUri);
|
||||||
if (!nonceResult.IsSuccess || nonceResult.Value == null)
|
if (!nonceResult.IsSuccess || nonceResult.Value == null)
|
||||||
return nonceResult;
|
return nonceResult;
|
||||||
|
|
||||||
@ -752,15 +744,30 @@ public partial class LetsEncryptService : ILetsEncryptService {
|
|||||||
finally {
|
finally {
|
||||||
response.Dispose();
|
response.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
catch (LetsEncrytException ex) {
|
catch (LetsEncrytException ex) {
|
||||||
var state = GetOrCreateState(sessionId);
|
|
||||||
return MapLetsEncryptException(state, ex);
|
return MapLetsEncryptException(state, ex);
|
||||||
}
|
}
|
||||||
catch (Exception ex) {
|
catch (Exception ex) {
|
||||||
return HandleUnhandledException(ex);
|
return HandleUnhandledException(ex);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
#endregion
|
#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
|
#region Certs
|
||||||
public static readonly Table RegistrationCaches = new(2, "registration_caches");
|
public static readonly Table RegistrationCaches = new(2, "registration_caches");
|
||||||
public static readonly Table TermsOfServiceCache = new(5, "terms_of_service_cache");
|
public static readonly Table TermsOfServiceCache = new(5, "terms_of_service_cache");
|
||||||
|
public static readonly Table AcmeSessions = new(6, "acme_sessions");
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,8 +21,7 @@ public class PostgresCacheFixture : IAsyncLifetime, IDisposable {
|
|||||||
public WebApiTestFixture Config { get; private set; } = null!;
|
public WebApiTestFixture Config { get; private set; } = null!;
|
||||||
|
|
||||||
public async Task InitializeAsync() {
|
public async Task InitializeAsync() {
|
||||||
_container = new PostgreSqlBuilder()
|
_container = new PostgreSqlBuilder("postgres:16-alpine")
|
||||||
.WithImage("postgres:16-alpine")
|
|
||||||
.Build();
|
.Build();
|
||||||
await _container.StartAsync();
|
await _container.StartAsync();
|
||||||
|
|
||||||
|
|||||||
@ -4,23 +4,14 @@ using Microsoft.Extensions.Options;
|
|||||||
namespace MaksIT.CertsUI.Tests.Infrastructure;
|
namespace MaksIT.CertsUI.Tests.Infrastructure;
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
public sealed class WebApiTestFixture : IDisposable
|
public sealed class WebApiTestFixture : IDisposable
|
||||||
{
|
{
|
||||||
public string Root { get; }
|
|
||||||
public IOptions<Configuration> AppOptions { get; }
|
public IOptions<Configuration> AppOptions { get; }
|
||||||
|
|
||||||
public WebApiTestFixture()
|
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
|
var configuration = new Configuration
|
||||||
{
|
{
|
||||||
CertsUIEngineConfiguration = new CertsUIEngineConfiguration
|
CertsUIEngineConfiguration = new CertsUIEngineConfiguration
|
||||||
@ -47,8 +38,6 @@ public sealed class WebApiTestFixture : IDisposable
|
|||||||
},
|
},
|
||||||
Production = "https://acme-v02.api.letsencrypt.org/directory",
|
Production = "https://acme-v02.api.letsencrypt.org/directory",
|
||||||
Staging = "https://acme-staging-v02.api.letsencrypt.org/directory",
|
Staging = "https://acme-staging-v02.api.letsencrypt.org/directory",
|
||||||
AcmeFolder = acmeFolder,
|
|
||||||
DataFolder = dataFolder,
|
|
||||||
Agent = new Agent
|
Agent = new Agent
|
||||||
{
|
{
|
||||||
AgentHostname = "http://127.0.0.1",
|
AgentHostname = "http://127.0.0.1",
|
||||||
@ -62,16 +51,5 @@ public sealed class WebApiTestFixture : IDisposable
|
|||||||
AppOptions = Microsoft.Extensions.Options.Options.Create(configuration);
|
AppOptions = Microsoft.Extensions.Options.Options.Create(configuration);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose() { }
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (Directory.Exists(Root))
|
|
||||||
Directory.Delete(Root, recursive: true);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// best-effort cleanup of temp dir
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,21 +2,19 @@
|
|||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
<IsTestProject>true</IsTestProject>
|
<IsTestProject>true</IsTestProject>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.5" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.7" />
|
||||||
<PackageReference Include="coverlet.collector" Version="8.0.1">
|
<PackageReference Include="coverlet.collector" Version="10.0.0">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</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="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" Version="2.9.3" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
|||||||
@ -17,8 +17,6 @@ namespace MaksIT.CertsUI.Tests.Services;
|
|||||||
public sealed class CertsFlowServiceTests
|
public sealed class CertsFlowServiceTests
|
||||||
{
|
{
|
||||||
private sealed class TestCertsFlowEngineConfiguration(WebApiTestFixture fx) : ICertsFlowEngineConfiguration {
|
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;
|
public string AgentServiceToReload => fx.AppOptions.Value.CertsUIEngineConfiguration.Agent.ServiceToReload;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -31,8 +29,7 @@ public sealed class CertsFlowServiceTests
|
|||||||
Mock<IAcmeHttpChallengePersistenceService>? httpChallenges = null,
|
Mock<IAcmeHttpChallengePersistenceService>? httpChallenges = null,
|
||||||
Mock<IRuntimeLeaseService>? runtimeLease = null,
|
Mock<IRuntimeLeaseService>? runtimeLease = null,
|
||||||
Mock<IRuntimeInstanceId>? runtimeInstance = null,
|
Mock<IRuntimeInstanceId>? runtimeInstance = null,
|
||||||
HttpMessageHandler? httpHandler = null,
|
HttpMessageHandler? httpHandler = null)
|
||||||
Mock<IPrimaryReplicaWorkload>? primaryReplica = null)
|
|
||||||
{
|
{
|
||||||
registrationCache ??= new Mock<IRegistrationCachePersistanceService>();
|
registrationCache ??= new Mock<IRegistrationCachePersistanceService>();
|
||||||
agent ??= new Mock<IAgentDeploymentService>();
|
agent ??= new Mock<IAgentDeploymentService>();
|
||||||
@ -66,9 +63,6 @@ public sealed class CertsFlowServiceTests
|
|||||||
runtimeInstance ??= new Mock<IRuntimeInstanceId>();
|
runtimeInstance ??= new Mock<IRuntimeInstanceId>();
|
||||||
if (!runtimeInstanceProvided)
|
if (!runtimeInstanceProvided)
|
||||||
runtimeInstance.Setup(i => i.InstanceId).Returns("test-instance");
|
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 handler = httpHandler ?? new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent([0x25, 0x50, 0x44, 0x46]) });
|
||||||
var httpClient = new HttpClient(handler, disposeHandler: true);
|
var httpClient = new HttpClient(handler, disposeHandler: true);
|
||||||
return new CertsFlowDomainService(
|
return new CertsFlowDomainService(
|
||||||
@ -81,8 +75,7 @@ public sealed class CertsFlowServiceTests
|
|||||||
termsOfServiceCache.Object,
|
termsOfServiceCache.Object,
|
||||||
httpChallenges.Object,
|
httpChallenges.Object,
|
||||||
runtimeLease.Object,
|
runtimeLease.Object,
|
||||||
runtimeInstance.Object,
|
runtimeInstance.Object);
|
||||||
primaryWorkload.Object);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@ -90,7 +83,7 @@ public sealed class CertsFlowServiceTests
|
|||||||
{
|
{
|
||||||
using var fx = new WebApiTestFixture();
|
using var fx = new WebApiTestFixture();
|
||||||
var le = new Mock<ILetsEncryptService>();
|
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());
|
.ReturnsAsync(Result.Ok());
|
||||||
|
|
||||||
var sut = CreateSut(fx, le);
|
var sut = CreateSut(fx, le);
|
||||||
@ -101,51 +94,12 @@ public sealed class CertsFlowServiceTests
|
|||||||
Assert.NotNull(result.Value);
|
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]
|
[Fact]
|
||||||
public async Task ConfigureClientAsync_WhenConfigureFails_PropagatesFailure()
|
public async Task ConfigureClientAsync_WhenConfigureFails_PropagatesFailure()
|
||||||
{
|
{
|
||||||
using var fx = new WebApiTestFixture();
|
using var fx = new WebApiTestFixture();
|
||||||
var le = new Mock<ILetsEncryptService>();
|
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"]));
|
.ReturnsAsync(Result.InternalServerError(["configure failed"]));
|
||||||
|
|
||||||
var sut = CreateSut(fx, le);
|
var sut = CreateSut(fx, le);
|
||||||
@ -161,7 +115,7 @@ public sealed class CertsFlowServiceTests
|
|||||||
using var fx = new WebApiTestFixture();
|
using var fx = new WebApiTestFixture();
|
||||||
var sessionId = Guid.NewGuid();
|
var sessionId = Guid.NewGuid();
|
||||||
var le = new Mock<ILetsEncryptService>();
|
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());
|
.ReturnsAsync(Result.Ok());
|
||||||
|
|
||||||
var sut = CreateSut(fx, le);
|
var sut = CreateSut(fx, le);
|
||||||
@ -170,7 +124,7 @@ public sealed class CertsFlowServiceTests
|
|||||||
|
|
||||||
Assert.True(result.IsSuccess);
|
Assert.True(result.IsSuccess);
|
||||||
Assert.NotNull(result.Value);
|
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]
|
[Fact]
|
||||||
@ -184,7 +138,7 @@ public sealed class CertsFlowServiceTests
|
|||||||
.ReturnsAsync(Result<RegistrationCache?>.InternalServerError(null, "missing"));
|
.ReturnsAsync(Result<RegistrationCache?>.InternalServerError(null, "missing"));
|
||||||
|
|
||||||
var le = new Mock<ILetsEncryptService>();
|
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());
|
.ReturnsAsync(Result.Ok());
|
||||||
|
|
||||||
var sut = CreateSut(fx, le, cache);
|
var sut = CreateSut(fx, le, cache);
|
||||||
@ -214,7 +168,7 @@ public sealed class CertsFlowServiceTests
|
|||||||
.ReturnsAsync(Result<RegistrationCache?>.Ok(reg));
|
.ReturnsAsync(Result<RegistrationCache?>.Ok(reg));
|
||||||
|
|
||||||
var le = new Mock<ILetsEncryptService>();
|
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());
|
.ReturnsAsync(Result.Ok());
|
||||||
|
|
||||||
var sut = CreateSut(fx, le, cache);
|
var sut = CreateSut(fx, le, cache);
|
||||||
@ -231,7 +185,7 @@ public sealed class CertsFlowServiceTests
|
|||||||
using var fx = new WebApiTestFixture();
|
using var fx = new WebApiTestFixture();
|
||||||
var sessionId = Guid.NewGuid();
|
var sessionId = Guid.NewGuid();
|
||||||
var le = new Mock<ILetsEncryptService>();
|
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>
|
.ReturnsAsync(Result<Dictionary<string, string>?>.Ok(new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
["example.com"] = "tokenPart.rest.of.token"
|
["example.com"] = "tokenPart.rest.of.token"
|
||||||
@ -271,7 +225,7 @@ public sealed class CertsFlowServiceTests
|
|||||||
var result = await sut.NewOrderAsync(sessionId, ["example.com"], "http-01");
|
var result = await sut.NewOrderAsync(sessionId, ["example.com"], "http-01");
|
||||||
|
|
||||||
Assert.False(result.IsSuccess);
|
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);
|
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");
|
var result = await sut.NewOrderAsync(sessionId, ["example.com"], "http-01");
|
||||||
|
|
||||||
Assert.False(result.IsSuccess);
|
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);
|
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();
|
using var fx = new WebApiTestFixture();
|
||||||
var sessionId = Guid.NewGuid();
|
var sessionId = Guid.NewGuid();
|
||||||
var le = new Mock<ILetsEncryptService>();
|
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"));
|
.ReturnsAsync(Result<Dictionary<string, string>?>.InternalServerError(null, "acme failed"));
|
||||||
var runtimeLease = new Mock<IRuntimeLeaseService>();
|
var runtimeLease = new Mock<IRuntimeLeaseService>();
|
||||||
runtimeLease.Setup(l => l.TryAcquireAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
|
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();
|
using var fx = new WebApiTestFixture();
|
||||||
var sessionId = Guid.NewGuid();
|
var sessionId = Guid.NewGuid();
|
||||||
var le = new Mock<ILetsEncryptService>();
|
var le = new Mock<ILetsEncryptService>();
|
||||||
le.Setup(x => x.GetTermsOfServiceUri(sessionId))
|
le.Setup(x => x.GetTermsOfServiceUriAsync(sessionId, It.IsAny<CancellationToken>()))
|
||||||
.Returns(Result<string?>.InternalServerError(null, "no uri"));
|
.ReturnsAsync(Result<string?>.InternalServerError(null, "no uri"));
|
||||||
|
|
||||||
var sut = CreateSut(fx, le);
|
var sut = CreateSut(fx, le);
|
||||||
|
|
||||||
@ -341,8 +295,8 @@ public sealed class CertsFlowServiceTests
|
|||||||
var sessionId = Guid.NewGuid();
|
var sessionId = Guid.NewGuid();
|
||||||
var url = "https://acme.test/sub/cached-tos.pdf";
|
var url = "https://acme.test/sub/cached-tos.pdf";
|
||||||
var le = new Mock<ILetsEncryptService>();
|
var le = new Mock<ILetsEncryptService>();
|
||||||
le.Setup(x => x.GetTermsOfServiceUri(sessionId))
|
le.Setup(x => x.GetTermsOfServiceUriAsync(sessionId, It.IsAny<CancellationToken>()))
|
||||||
.Returns(Result<string?>.Ok(url));
|
.ReturnsAsync(Result<string?>.Ok(url));
|
||||||
|
|
||||||
var tosCache = new Mock<ITermsOfServiceCachePersistenceService>();
|
var tosCache = new Mock<ITermsOfServiceCachePersistenceService>();
|
||||||
tosCache.Setup(c => c.GetByUrlAsync(url, It.IsAny<CancellationToken>()))
|
tosCache.Setup(c => c.GetByUrlAsync(url, It.IsAny<CancellationToken>()))
|
||||||
@ -384,7 +338,7 @@ public sealed class CertsFlowServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AcmeChallenge_WhenDbRowExists_MaterializesFileAndReturnsContent()
|
public async Task AcmeChallenge_WhenDbRowExists_ReturnsContent()
|
||||||
{
|
{
|
||||||
using var fx = new WebApiTestFixture();
|
using var fx = new WebApiTestFixture();
|
||||||
var name = "challenge-token";
|
var name = "challenge-token";
|
||||||
@ -402,9 +356,6 @@ public sealed class CertsFlowServiceTests
|
|||||||
|
|
||||||
Assert.True(result.IsSuccess);
|
Assert.True(result.IsSuccess);
|
||||||
Assert.Equal("challenge-body", result.Value);
|
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]
|
[Fact]
|
||||||
@ -508,14 +459,14 @@ public sealed class CertsFlowServiceTests
|
|||||||
using var fx = new WebApiTestFixture();
|
using var fx = new WebApiTestFixture();
|
||||||
var sessionId = Guid.NewGuid();
|
var sessionId = Guid.NewGuid();
|
||||||
var le = new Mock<ILetsEncryptService>();
|
var le = new Mock<ILetsEncryptService>();
|
||||||
le.Setup(x => x.CompleteChallenges(sessionId))
|
le.Setup(x => x.CompleteChallenges(sessionId, It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(Result.Ok());
|
.ReturnsAsync(Result.Ok());
|
||||||
var sut = CreateSut(fx, le);
|
var sut = CreateSut(fx, le);
|
||||||
|
|
||||||
var result = await sut.CompleteChallengesAsync(sessionId);
|
var result = await sut.CompleteChallengesAsync(sessionId);
|
||||||
|
|
||||||
Assert.True(result.IsSuccess);
|
Assert.True(result.IsSuccess);
|
||||||
le.Verify(x => x.CompleteChallenges(sessionId), Times.Once);
|
le.Verify(x => x.CompleteChallenges(sessionId, It.IsAny<CancellationToken>()), Times.Once);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@ -524,7 +475,7 @@ public sealed class CertsFlowServiceTests
|
|||||||
using var fx = new WebApiTestFixture();
|
using var fx = new WebApiTestFixture();
|
||||||
var sessionId = Guid.NewGuid();
|
var sessionId = Guid.NewGuid();
|
||||||
var le = new Mock<ILetsEncryptService>();
|
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());
|
.ReturnsAsync(Result.Ok());
|
||||||
var sut = CreateSut(fx, le);
|
var sut = CreateSut(fx, le);
|
||||||
|
|
||||||
|
|||||||
@ -86,14 +86,5 @@ public class CertsUIEngineConfiguration : ICertsFlowEngineConfiguration {
|
|||||||
|
|
||||||
public required string Staging { get; set; }
|
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;
|
string ICertsFlowEngineConfiguration.AgentServiceToReload => Agent.ServiceToReload;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
using MaksIT.Models.LetsEncryptServer.CertsFlow.Requests;
|
using MaksIT.Models.LetsEncryptServer.CertsFlow.Requests;
|
||||||
using MaksIT.CertsUI.Authorization.Filters;
|
using MaksIT.CertsUI.Authorization.Filters;
|
||||||
using MaksIT.CertsUI.Mvc;
|
|
||||||
using MaksIT.CertsUI.Services;
|
using MaksIT.CertsUI.Services;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
@ -21,55 +20,55 @@ namespace MaksIT.CertsUI.Controllers {
|
|||||||
[HttpPost("configure-client")]
|
[HttpPost("configure-client")]
|
||||||
public async Task<IActionResult> ConfigureClient([FromBody] ConfigureClientRequest requestData) {
|
public async Task<IActionResult> ConfigureClient([FromBody] ConfigureClientRequest requestData) {
|
||||||
var result = await _certsFlowService.ConfigureClientAsync(requestData.IsStaging);
|
var result = await _certsFlowService.ConfigureClientAsync(requestData.IsStaging);
|
||||||
return result.ToCertsFlowActionResult();
|
return result.ToActionResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{sessionId}/terms-of-service")]
|
[HttpGet("{sessionId}/terms-of-service")]
|
||||||
public async Task<IActionResult> TermsOfService(Guid sessionId) {
|
public async Task<IActionResult> TermsOfService(Guid sessionId) {
|
||||||
var result = await _certsFlowService.GetTermsOfServiceAsync(sessionId);
|
var result = await _certsFlowService.GetTermsOfServiceAsync(sessionId);
|
||||||
return result.ToCertsFlowActionResult();
|
return result.ToActionResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{sessionId}/init/{accountId?}")]
|
[HttpPost("{sessionId}/init/{accountId?}")]
|
||||||
public async Task<IActionResult> Init(Guid sessionId, Guid? accountId, [FromBody] InitRequest requestData) {
|
public async Task<IActionResult> Init(Guid sessionId, Guid? accountId, [FromBody] InitRequest requestData) {
|
||||||
var result = await _certsFlowService.InitAsync(sessionId, accountId, requestData.Description, requestData.Contacts);
|
var result = await _certsFlowService.InitAsync(sessionId, accountId, requestData.Description, requestData.Contacts);
|
||||||
return result.ToCertsFlowActionResult();
|
return result.ToActionResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{sessionId}/order")]
|
[HttpPost("{sessionId}/order")]
|
||||||
public async Task<IActionResult> NewOrder(Guid sessionId, [FromBody] NewOrderRequest requestData) {
|
public async Task<IActionResult> NewOrder(Guid sessionId, [FromBody] NewOrderRequest requestData) {
|
||||||
var result = await _certsFlowService.NewOrderAsync(sessionId, requestData.Hostnames, requestData.ChallengeType);
|
var result = await _certsFlowService.NewOrderAsync(sessionId, requestData.Hostnames, requestData.ChallengeType);
|
||||||
return result.ToCertsFlowActionResult();
|
return result.ToActionResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{sessionId}/complete-challenges")]
|
[HttpPost("{sessionId}/complete-challenges")]
|
||||||
public async Task<IActionResult> CompleteChallenges(Guid sessionId) {
|
public async Task<IActionResult> CompleteChallenges(Guid sessionId) {
|
||||||
var result = await _certsFlowService.CompleteChallengesAsync(sessionId);
|
var result = await _certsFlowService.CompleteChallengesAsync(sessionId);
|
||||||
return result.ToCertsFlowActionResult();
|
return result.ToActionResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{sessionId}/order-status")]
|
[HttpGet("{sessionId}/order-status")]
|
||||||
public async Task<IActionResult> GetOrder(Guid sessionId, [FromBody] GetOrderRequest requestData) {
|
public async Task<IActionResult> GetOrder(Guid sessionId, [FromBody] GetOrderRequest requestData) {
|
||||||
var result = await _certsFlowService.GetOrderAsync(sessionId, requestData.Hostnames);
|
var result = await _certsFlowService.GetOrderAsync(sessionId, requestData.Hostnames);
|
||||||
return result.ToCertsFlowActionResult();
|
return result.ToActionResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{sessionId}/certificates/download")]
|
[HttpPost("{sessionId}/certificates/download")]
|
||||||
public async Task<IActionResult> GetCertificates(Guid sessionId, [FromBody] GetCertificatesRequest requestData) {
|
public async Task<IActionResult> GetCertificates(Guid sessionId, [FromBody] GetCertificatesRequest requestData) {
|
||||||
var result = await _certsFlowService.GetCertificatesAsync(sessionId, requestData.Hostnames);
|
var result = await _certsFlowService.GetCertificatesAsync(sessionId, requestData.Hostnames);
|
||||||
return result.ToCertsFlowActionResult();
|
return result.ToActionResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{accountId}/certificates/apply")]
|
[HttpPost("{accountId}/certificates/apply")]
|
||||||
public async Task<IActionResult> ApplyCertificates(Guid accountId) {
|
public async Task<IActionResult> ApplyCertificates(Guid accountId) {
|
||||||
var result = await _certsFlowService.ApplyCertificatesAsync(accountId);
|
var result = await _certsFlowService.ApplyCertificatesAsync(accountId);
|
||||||
return result.ToCertsFlowActionResult();
|
return result.ToActionResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{sessionId}/certificates/revoke")]
|
[HttpPost("{sessionId}/certificates/revoke")]
|
||||||
public async Task<IActionResult> RevokeCertificates(Guid sessionId, [FromBody] RevokeCertificatesRequest requestData) {
|
public async Task<IActionResult> RevokeCertificates(Guid sessionId, [FromBody] RevokeCertificatesRequest requestData) {
|
||||||
var result = await _certsFlowService.RevokeCertificatesAsync(sessionId, requestData.Hostnames);
|
var result = await _certsFlowService.RevokeCertificatesAsync(sessionId, requestData.Hostnames);
|
||||||
return result.ToCertsFlowActionResult();
|
return result.ToActionResult();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,70 +1,73 @@
|
|||||||
using MaksIT.CertsUI.Engine.Domain.Certs;
|
using MaksIT.CertsUI.Engine.Domain.Certs;
|
||||||
|
using MaksIT.CertsUI.Engine.Infrastructure;
|
||||||
using MaksIT.CertsUI.Engine.Persistance.Services;
|
using MaksIT.CertsUI.Engine.Persistance.Services;
|
||||||
using MaksIT.CertsUI.Engine.RuntimeCoordination;
|
using MaksIT.CertsUI.Engine.RuntimeCoordination;
|
||||||
using MaksIT.Results;
|
|
||||||
using MaksIT.CertsUI.Services;
|
using MaksIT.CertsUI.Services;
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using System;
|
|
||||||
|
|
||||||
namespace MaksIT.CertsUI.HostedServices {
|
namespace MaksIT.CertsUI.HostedServices;
|
||||||
public class AutoRenewal : BackgroundService {
|
|
||||||
|
|
||||||
private readonly IOptions<Configuration> _appSettings;
|
/// <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>
|
||||||
private readonly ILogger<AutoRenewal> _logger;
|
public sealed class AutoRenewal(
|
||||||
private readonly IServiceScopeFactory _scopeFactory;
|
|
||||||
private readonly IPrimaryReplicaWorkload _primaryReplica;
|
|
||||||
|
|
||||||
private static readonly Random _random = new();
|
|
||||||
|
|
||||||
public AutoRenewal(
|
|
||||||
IOptions<Configuration> appSettings,
|
|
||||||
ILogger<AutoRenewal> logger,
|
ILogger<AutoRenewal> logger,
|
||||||
IServiceScopeFactory scopeFactory,
|
IServiceScopeFactory scopeFactory,
|
||||||
IPrimaryReplicaWorkload primaryReplica
|
IRuntimeLeaseService leaseService,
|
||||||
) {
|
IRuntimeInstanceId runtimeInstance
|
||||||
_appSettings = appSettings;
|
) : BackgroundService {
|
||||||
_logger = logger;
|
|
||||||
_scopeFactory = scopeFactory;
|
private static readonly TimeSpan RenewalLeaseTtl = TimeSpan.FromMinutes(12);
|
||||||
_primaryReplica = primaryReplica;
|
private static readonly Random Random = new();
|
||||||
}
|
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
|
||||||
while (!stoppingToken.IsCancellationRequested) {
|
while (!stoppingToken.IsCancellationRequested) {
|
||||||
if (!_primaryReplica.IsPrimary) {
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!acquired.Value) {
|
||||||
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken).ConfigureAwait(false);
|
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken).ConfigureAwait(false);
|
||||||
continue;
|
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 cacheService = scope.ServiceProvider.GetRequiredService<ICacheService>();
|
||||||
var certsFlowService = scope.ServiceProvider.GetRequiredService<ICertsFlowService>();
|
var certsFlowService = scope.ServiceProvider.GetRequiredService<ICertsFlowService>();
|
||||||
var httpChallenges = scope.ServiceProvider.GetRequiredService<IAcmeHttpChallengePersistenceService>();
|
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)
|
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) {
|
if (!loadAccountsFromCacheResult.IsSuccess || loadAccountsFromCacheResult.Value == null) {
|
||||||
LogErrors(loadAccountsFromCacheResult.Messages);
|
LogErrorMessages(loadAccountsFromCacheResult.Messages);
|
||||||
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken).ConfigureAwait(false);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var accountsResponse = loadAccountsFromCacheResult.Value;
|
var accountsResponse = loadAccountsFromCacheResult.Value;
|
||||||
|
foreach (var account in accountsResponse.Where(x => !x.IsDisabled))
|
||||||
foreach (var account in accountsResponse.Where(x => !x.IsDisabled)) {
|
await ProcessAccountAsync(certsFlowService, account).ConfigureAwait(false);
|
||||||
await ProcessAccountAsync(certsFlowService, account);
|
}
|
||||||
|
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 ?? []));
|
||||||
}
|
}
|
||||||
|
|
||||||
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<Result> ProcessAccountAsync(ICertsFlowService certsFlowService, RegistrationCache cache) {
|
private async Task ProcessAccountAsync(ICertsFlowService certsFlowService, RegistrationCache cache) {
|
||||||
|
|
||||||
var hosts = cache.GetHosts();
|
var hosts = cache.GetHosts();
|
||||||
var toRenew = new List<string>();
|
var toRenew = new List<string>();
|
||||||
|
|
||||||
@ -72,20 +75,17 @@ namespace MaksIT.CertsUI.HostedServices {
|
|||||||
if (host.IsDisabled)
|
if (host.IsDisabled)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
// Only consider certs expiring within 30 days
|
|
||||||
if ((host.Expires - DateTime.UtcNow).TotalDays < 30) {
|
if ((host.Expires - DateTime.UtcNow).TotalDays < 30) {
|
||||||
// Randomize renewal between 1 and 5 days before expiry
|
int randomDays = Random.Next(1, 6);
|
||||||
int randomDays = _random.Next(1, 6);
|
|
||||||
var renewalTime = host.Expires.AddDays(-randomDays);
|
var renewalTime = host.Expires.AddDays(-randomDays);
|
||||||
if (DateTime.UtcNow >= renewalTime) {
|
if (DateTime.UtcNow >= renewalTime)
|
||||||
toRenew.Add(host.Hostname);
|
toRenew.Add(host.Hostname);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!toRenew.Any()) {
|
if (!toRenew.Any()) {
|
||||||
_logger.LogInformation("No certificates are due for randomized renewal at this time.");
|
logger.LogInformation("No certificates are due for randomized renewal at this time for account {AccountId}.", cache.AccountId);
|
||||||
return Result.Ok();
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var cooldownSkipped = new List<(string Hostname, DateTimeOffset NotBeforeUtc)>();
|
var cooldownSkipped = new List<(string Hostname, DateTimeOffset NotBeforeUtc)>();
|
||||||
@ -100,39 +100,35 @@ namespace MaksIT.CertsUI.HostedServices {
|
|||||||
|
|
||||||
if (cooldownSkipped.Count > 0) {
|
if (cooldownSkipped.Count > 0) {
|
||||||
var sample = cooldownSkipped[0];
|
var sample = cooldownSkipped[0];
|
||||||
_logger.LogInformation(
|
logger.LogInformation(
|
||||||
"Skipping {SkippedCount} hostname(s) in ACME cooldown for account {AccountId} (e.g. {ExampleHost} until {NotBeforeUtc:u} UTC).",
|
"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);
|
cooldownSkipped.Count, cache.AccountId, sample.Hostname, sample.NotBeforeUtc);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!eligible.Any()) {
|
if (!eligible.Any()) {
|
||||||
_logger.LogInformation("All due certificates for account {AccountId} are in ACME cooldown; no renewal attempted.", cache.AccountId);
|
logger.LogInformation("All due certificates for account {AccountId} are in ACME cooldown; no renewal attempted.", cache.AccountId);
|
||||||
return Result.Ok();
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var fullFlowResult = await certsFlowService.FullFlow(
|
var fullFlowResult = await certsFlowService.FullFlow(
|
||||||
cache.IsStaging, cache.AccountId, cache.Description, cache.Contacts, cache.ChallengeType, eligible.ToArray()
|
cache.IsStaging, cache.AccountId, cache.Description, cache.Contacts, cache.ChallengeType, eligible.ToArray()
|
||||||
);
|
).ConfigureAwait(false);
|
||||||
|
|
||||||
if (!fullFlowResult.IsSuccess)
|
if (!fullFlowResult.IsSuccess)
|
||||||
return fullFlowResult;
|
LogErrorMessages(fullFlowResult.Messages);
|
||||||
|
else
|
||||||
_logger.LogInformation("Certificates renewed for account {AccountId}: {Hostnames}", cache.AccountId, string.Join(", ", eligible));
|
logger.LogInformation("Certificates renewed for account {AccountId}: {Hostnames}", cache.AccountId, string.Join(", ", eligible));
|
||||||
|
|
||||||
return Result.Ok();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void LogErrorMessages(IEnumerable<string>? errors) {
|
||||||
|
if (errors == null)
|
||||||
private void LogErrors(IEnumerable<string> errors) {
|
return;
|
||||||
foreach (var error in errors) {
|
foreach (var error in errors)
|
||||||
_logger.LogError(error);
|
logger.LogError("{Error}", error);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Task StopAsync(CancellationToken stoppingToken) {
|
public override Task StopAsync(CancellationToken stoppingToken) {
|
||||||
_logger.LogInformation("Background service is stopping.");
|
logger.LogInformation("Background service is stopping.");
|
||||||
return base.StopAsync(stoppingToken);
|
return base.StopAsync(stoppingToken);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,62 +1,63 @@
|
|||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using MaksIT.CertsUI.Engine;
|
using MaksIT.CertsUI.Engine;
|
||||||
using MaksIT.CertsUI.Engine.DomainServices;
|
using MaksIT.CertsUI.Engine.DomainServices;
|
||||||
using MaksIT.CertsUI.Engine.Infrastructure;
|
using MaksIT.CertsUI.Engine.Infrastructure;
|
||||||
using MaksIT.CertsUI.Engine.RuntimeCoordination;
|
using MaksIT.CertsUI.Engine.RuntimeCoordination;
|
||||||
using MaksIT.CertsUI.Infrastructure;
|
|
||||||
|
|
||||||
namespace MaksIT.CertsUI.HostedServices;
|
namespace MaksIT.CertsUI.HostedServices;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Exactly one instance holds <see cref="RuntimeLeaseNames.PrimaryReplica"/> and runs coordination DDL plus identity bootstrap.
|
/// Uses a short-lived Postgres lease (<see cref="RuntimeLeaseNames.BootstrapCoordinator"/>) so exactly one pod runs
|
||||||
/// 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.
|
/// coordination DDL + default admin creation; other pods wait until <c>users</c> exist. No long-lived leader role.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class InitializationHostedService(
|
public sealed class InitializationHostedService(
|
||||||
ILogger<InitializationHostedService> logger,
|
ILogger<InitializationHostedService> logger,
|
||||||
IServiceProvider serviceProvider,
|
IServiceProvider serviceProvider,
|
||||||
IOptions<Configuration> appSettings,
|
IRuntimeLeaseService leaseService,
|
||||||
PrimaryReplicaGate primaryGate
|
IRuntimeInstanceId runtimeInstance
|
||||||
) : IHostedService {
|
) : IHostedService {
|
||||||
|
|
||||||
|
private static readonly TimeSpan BootstrapLeaseTtl = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
public async Task StartAsync(CancellationToken cancellationToken) {
|
public async Task StartAsync(CancellationToken cancellationToken) {
|
||||||
const int delayMilliseconds = 2000;
|
const int delayMilliseconds = 2000;
|
||||||
var appLifetime = serviceProvider.GetRequiredService<IHostApplicationLifetime>();
|
|
||||||
|
|
||||||
while (!cancellationToken.IsCancellationRequested) {
|
while (!cancellationToken.IsCancellationRequested) {
|
||||||
try {
|
try {
|
||||||
logger.LogInformation("Running startup initialization (primary replica election)...");
|
logger.LogInformation("Running startup coordination (Postgres bootstrap lease)...");
|
||||||
|
|
||||||
if (await primaryGate.TryAcquirePrimaryLeaseAsync(cancellationToken).ConfigureAwait(false)) {
|
var holder = runtimeInstance.InstanceId;
|
||||||
primaryGate.StartLeaseRenewal(appLifetime);
|
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 {
|
try {
|
||||||
var engineConfig = serviceProvider.GetRequiredService<ICertsEngineConfiguration>();
|
var engineConfig = serviceProvider.GetRequiredService<ICertsEngineConfiguration>();
|
||||||
await CoordinationTableProvisioner.EnsureAsync(engineConfig.ConnectionString, cancellationToken).ConfigureAwait(false);
|
await CoordinationTableProvisioner.EnsureAsync(engineConfig.ConnectionString, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
await using var scope = serviceProvider.CreateAsyncScope();
|
await using var scope = serviceProvider.CreateAsyncScope();
|
||||||
var identityDomainService = scope.ServiceProvider.GetRequiredService<IIdentityDomainService>();
|
var identityDomainService = scope.ServiceProvider.GetRequiredService<IIdentityDomainService>();
|
||||||
await EnsureIdentityAsLeaderAsync(appSettings.Value, identityDomainService, cancellationToken).ConfigureAwait(false);
|
await EnsureIdentityAsLeaderAsync(identityDomainService, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch {
|
finally {
|
||||||
await primaryGate.AbandonPrimaryAsync().ConfigureAwait(false);
|
var released = await leaseService.ReleaseAsync(RuntimeLeaseNames.BootstrapCoordinator, holder, CancellationToken.None).ConfigureAwait(false);
|
||||||
throw;
|
if (!released.IsSuccess && logger.IsEnabled(LogLevel.Warning))
|
||||||
|
logger.LogWarning("Bootstrap lease release: {Messages}", string.Join("; ", released.Messages ?? []));
|
||||||
}
|
}
|
||||||
|
|
||||||
primaryGate.EnablePrimaryWorkload();
|
logger.LogInformation("Startup coordination completed (this instance held the bootstrap lease).");
|
||||||
logger.LogInformation("Startup initialization completed; this instance is the primary replica.");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await using (var followerScope = serviceProvider.CreateAsyncScope()) {
|
await using (var followerScope = serviceProvider.CreateAsyncScope()) {
|
||||||
var identityFollower = followerScope.ServiceProvider.GetRequiredService<IIdentityDomainService>();
|
var identityFollower = followerScope.ServiceProvider.GetRequiredService<IIdentityDomainService>();
|
||||||
var cfg = appSettings.Value;
|
|
||||||
while (!cancellationToken.IsCancellationRequested) {
|
while (!cancellationToken.IsCancellationRequested) {
|
||||||
if (await IsClusterIdentityReadyAsync(cfg, identityFollower, cancellationToken).ConfigureAwait(false)) {
|
if (await IsClusterIdentityReadyAsync(identityFollower, cancellationToken).ConfigureAwait(false)) {
|
||||||
logger.LogInformation("Startup initialization completed; this instance is a secondary replica.");
|
logger.LogInformation("Startup coordination completed (another instance bootstrapped identity).");
|
||||||
return;
|
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);
|
await Task.Delay(delayMilliseconds, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -64,20 +65,20 @@ public sealed class InitializationHostedService(
|
|||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) {
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) {
|
||||||
logger.LogInformation("Startup initialization canceled (host is stopping).");
|
logger.LogInformation("Startup coordination canceled (host is stopping).");
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
catch (Exception ex) {
|
catch (Exception ex) {
|
||||||
if (cancellationToken.IsCancellationRequested) {
|
if (cancellationToken.IsCancellationRequested) {
|
||||||
logger.LogInformation(ex, "Startup initialization aborted while stopping host.");
|
logger.LogInformation(ex, "Startup coordination aborted while stopping host.");
|
||||||
throw new OperationCanceledException("Host stopped during startup initialization.", ex, cancellationToken);
|
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 {
|
try {
|
||||||
await Task.Delay(delayMilliseconds, cancellationToken).ConfigureAwait(false);
|
await Task.Delay(delayMilliseconds, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) {
|
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;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -87,53 +88,29 @@ public sealed class InitializationHostedService(
|
|||||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
|
||||||
private static async Task EnsureIdentityAsLeaderAsync(
|
private static async Task EnsureIdentityAsLeaderAsync(
|
||||||
Configuration appSettings,
|
|
||||||
IIdentityDomainService identityDomainService,
|
IIdentityDomainService identityDomainService,
|
||||||
CancellationToken cancellationToken
|
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);
|
var count = await identityDomainService.CountUsersAsync(cancellationToken).ConfigureAwait(false);
|
||||||
if (!count.IsSuccess)
|
if (!count.IsSuccess)
|
||||||
throw new InvalidOperationException(string.Join(", ", count.Messages));
|
throw new InvalidOperationException(string.Join(", ", count.Messages));
|
||||||
|
|
||||||
if (count.Value == 0) {
|
if (count.Value != 0)
|
||||||
|
return;
|
||||||
|
|
||||||
var bootstrap = await identityDomainService.EnsureDefaultAdminAsync(cancellationToken).ConfigureAwait(false);
|
var bootstrap = await identityDomainService.EnsureDefaultAdminAsync(cancellationToken).ConfigureAwait(false);
|
||||||
if (!bootstrap.IsSuccess)
|
if (!bootstrap.IsSuccess)
|
||||||
throw new InvalidOperationException(string.Join(", ", bootstrap.Messages));
|
throw new InvalidOperationException(string.Join(", ", bootstrap.Messages));
|
||||||
}
|
}
|
||||||
|
|
||||||
await File.WriteAllTextAsync(initPath, string.Empty, cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<bool> IsClusterIdentityReadyAsync(
|
private static async Task<bool> IsClusterIdentityReadyAsync(
|
||||||
Configuration appSettings,
|
|
||||||
IIdentityDomainService identityDomainService,
|
IIdentityDomainService identityDomainService,
|
||||||
CancellationToken cancellationToken
|
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);
|
var count = await identityDomainService.CountUsersAsync(cancellationToken).ConfigureAwait(false);
|
||||||
if (!count.IsSuccess)
|
if (!count.IsSuccess)
|
||||||
throw new InvalidOperationException(string.Join(", ", count.Messages));
|
throw new InvalidOperationException(string.Join(", ", count.Messages));
|
||||||
|
|
||||||
if (count.Value > 0) {
|
return count.Value > 0;
|
||||||
await File.WriteAllTextAsync(initPath, string.Empty, cancellationToken).ConfigureAwait(false);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>3.3.22</Version>
|
<Version>3.4.0</Version>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||||
<DockerComposeProjectPath>..\docker-compose.dcproj</DockerComposeProjectPath>
|
<DockerComposeProjectPath>..\docker-compose.dcproj</DockerComposeProjectPath>
|
||||||
<NoWarn>CA2254</NoWarn>
|
<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<JwtAuthorizationFilter>();
|
||||||
builder.Services.AddScoped<JwtOrApiKeyAuthorizationFilter>();
|
builder.Services.AddScoped<JwtOrApiKeyAuthorizationFilter>();
|
||||||
|
|
||||||
// Primary replica: one elected instance (Postgres lease) runs ACME + renewal; register shutdown last so StopAsync releases the lease first.
|
// Hosted services: coordination/bootstrap lease, then renewal sweeps (each uses short-lived Postgres leases — symmetric pods).
|
||||||
builder.Services.AddSingleton<PrimaryReplicaGate>();
|
|
||||||
builder.Services.AddSingleton<IPrimaryReplicaWorkload>(sp => sp.GetRequiredService<PrimaryReplicaGate>());
|
|
||||||
|
|
||||||
// Hosted services: initialization first, then autorenewal loop.
|
|
||||||
builder.Services.AddHostedService<InitializationHostedService>();
|
builder.Services.AddHostedService<InitializationHostedService>();
|
||||||
builder.Services.AddHostedService<AutoRenewal>();
|
builder.Services.AddHostedService<AutoRenewal>();
|
||||||
builder.Services.AddHostedService<PrimaryReplicaShutdownHostedService>();
|
|
||||||
|
|
||||||
// PostgreSQL: prefer Configuration:CertsUIEngineConfiguration:ConnectionString in appsecrets.json; fallback ConnectionStrings:Certs for older files.
|
// PostgreSQL: prefer Configuration:CertsUIEngineConfiguration:ConnectionString in appsecrets.json; fallback ConnectionStrings:Certs for older files.
|
||||||
var certsConnectionString = appSettings.CertsUIEngineConfiguration.ConnectionString
|
var certsConnectionString = appSettings.CertsUIEngineConfiguration.ConnectionString
|
||||||
@ -85,7 +80,7 @@ if (string.IsNullOrWhiteSpace(certsConnectionString))
|
|||||||
|
|
||||||
var engineSection = appSettings.CertsUIEngineConfiguration;
|
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 =>
|
builder.Services.AddSingleton<IIdentityDomainConfiguration>(sp =>
|
||||||
sp.GetRequiredService<IOptions<Configuration>>().Value.CertsUIEngineConfiguration.JwtSettingsConfiguration);
|
sp.GetRequiredService<IOptions<Configuration>>().Value.CertsUIEngineConfiguration.JwtSettingsConfiguration);
|
||||||
builder.Services.AddSingleton<ITwoFactorSettingsConfiguration>(sp =>
|
builder.Services.AddSingleton<ITwoFactorSettingsConfiguration>(sp =>
|
||||||
@ -105,7 +100,6 @@ builder.Services.AddCertsEngine(new MaksIT.CertsUI.Engine.CertsEngineConfigurati
|
|||||||
LetsEncryptStaging = engineSection.Staging,
|
LetsEncryptStaging = engineSection.Staging,
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.AddMemoryCache();
|
|
||||||
builder.Services.AddScoped<ICacheService, CacheService>();
|
builder.Services.AddScoped<ICacheService, CacheService>();
|
||||||
|
|
||||||
// Controller services
|
// Controller services
|
||||||
@ -140,7 +134,7 @@ builder.Services.AddHealthChecks()
|
|||||||
|
|
||||||
var app = builder.Build();
|
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();
|
await app.Services.EnsureCertsEngineMigratedAsync();
|
||||||
|
|
||||||
app.UseMiddleware<ErrorHandlingMiddleware>();
|
app.UseMiddleware<ErrorHandlingMiddleware>();
|
||||||
|
|||||||
@ -44,9 +44,7 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
"Production": "https://acme-v02.api.letsencrypt.org/directory",
|
"Production": "https://acme-v02.api.letsencrypt.org/directory",
|
||||||
"Staging": "https://acme-staging-v02.api.letsencrypt.org/directory",
|
"Staging": "https://acme-staging-v02.api.letsencrypt.org/directory"
|
||||||
"AcmeFolder": "/acme",
|
|
||||||
"DataFolder": "/data"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,5 +4,5 @@
|
|||||||
namespace MaksIT.Models.Agent.Requests;
|
namespace MaksIT.Models.Agent.Requests;
|
||||||
|
|
||||||
public class CertsUploadRequest : RequestModelBase {
|
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;
|
namespace MaksIT.Models.Agent.Requests;
|
||||||
|
|
||||||
public class ServiceReloadRequest : RequestModelBase {
|
public class ServiceReloadRequest : RequestModelBase {
|
||||||
public string ServiceName { get; set; }
|
public required string ServiceName { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,5 +4,5 @@
|
|||||||
namespace MaksIT.Models.Agent.Responses;
|
namespace MaksIT.Models.Agent.Responses;
|
||||||
|
|
||||||
public class HelloWorldResponse : ResponseModelBase {
|
public class HelloWorldResponse : ResponseModelBase {
|
||||||
public string Message { get; set; }
|
public required string Message { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<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 axios from 'axios'
|
||||||
import { readIdentity } from './localStorage/identity'
|
import { readIdentity } from './localStorage/identity'
|
||||||
import { ApiRoutes, GetApiRoute } from './AppMap'
|
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 {
|
export interface ProblemDetails {
|
||||||
status?: number;
|
type?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
status?: number;
|
||||||
detail?: string;
|
detail?: string;
|
||||||
instance?: 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[]>;
|
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();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Configure the HTTP request pipeline.
|
app.MapReverseProxy();
|
||||||
app.UseRouting();
|
|
||||||
|
|
||||||
// Use YARP reverse proxy
|
|
||||||
app.UseEndpoints(endpoints => {
|
|
||||||
endpoints.MapReverseProxy();
|
|
||||||
});
|
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|||||||
@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
|||||||
@ -33,8 +33,6 @@ services:
|
|||||||
ASPNETCORE_ENVIRONMENT: Development
|
ASPNETCORE_ENVIRONMENT: Development
|
||||||
ASPNETCORE_HTTP_PORTS: "5000"
|
ASPNETCORE_HTTP_PORTS: "5000"
|
||||||
volumes:
|
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/configMap/appsettings.json:/configMap/appsettings.json:ro
|
||||||
- D:/Compose/MaksIT.CertsUI/secrets/appsecrets.json:/secrets/appsecrets.json:ro
|
- D:/Compose/MaksIT.CertsUI/secrets/appsecrets.json:/secrets/appsecrets.json:ro
|
||||||
networks:
|
networks:
|
||||||
@ -44,10 +42,11 @@ services:
|
|||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
# Aligns with Helm-style local defaults: user/db/password certsui (set the same in secrets appsecrets.json ConnectionString).
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: maksit
|
POSTGRES_USER: certsui
|
||||||
POSTGRES_PASSWORD: maksit
|
POSTGRES_PASSWORD: certsui
|
||||||
POSTGRES_DB: maksit_certs
|
POSTGRES_DB: certsui
|
||||||
networks:
|
networks:
|
||||||
- maksit-certs-ui-network
|
- maksit-certs-ui-network
|
||||||
volumes:
|
volumes:
|
||||||
@ -55,6 +54,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
|
|
||||||
|
# pgAdmin: mount servers.json (see repo src/postgresql/servers.json.example). Store password for user certsui in pgAdmin or use PassFile.
|
||||||
pgadmin:
|
pgadmin:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
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.
|
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
|
## Config
|
||||||
|
|||||||
@ -42,8 +42,6 @@ certsServerConfig:
|
|||||||
serviceToReload: haproxy
|
serviceToReload: haproxy
|
||||||
production: "https://acme-v02.api.letsencrypt.org/directory"
|
production: "https://acme-v02.api.letsencrypt.org/directory"
|
||||||
staging: "https://acme-staging-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
|
# Server Secret (appsecrets.json); referenced from components.server.secretsFile when tpl: true
|
||||||
# Configuration:CertsUIEngineConfiguration:ConnectionString — same structural role as MaksIT.Vault VaultEngineConfiguration:ConnectionString.
|
# Configuration:CertsUIEngineConfiguration:ConnectionString — same structural role as MaksIT.Vault VaultEngineConfiguration:ConnectionString.
|
||||||
@ -55,13 +53,12 @@ certsServerSecrets:
|
|||||||
certsUIEngineConfiguration:
|
certsUIEngineConfiguration:
|
||||||
connectionString: ""
|
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:
|
certsClientRuntime:
|
||||||
apiUrl: "http://certs-ui.example.com/api"
|
apiUrl: "/api"
|
||||||
|
|
||||||
components:
|
components:
|
||||||
# Per-component replica count (minimum 1). Server uses RWO PVCs by default — use 1 unless
|
# Per-component replica count (minimum 1). Server is stateless for app data (PostgreSQL); scale freely.
|
||||||
# your StorageClass supports ReadWriteMany and the app can share the volume (see NOTES.txt).
|
|
||||||
server:
|
server:
|
||||||
replicaCount: 1
|
replicaCount: 1
|
||||||
image:
|
image:
|
||||||
@ -83,9 +80,9 @@ components:
|
|||||||
type: ClusterIP
|
type: ClusterIP
|
||||||
port: 5000
|
port: 5000
|
||||||
targetPort: 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:
|
sessionAffinity:
|
||||||
enabled: true
|
enabled: false
|
||||||
clientIPTimeoutSeconds: 10800
|
clientIPTimeoutSeconds: 10800
|
||||||
# Give kube-proxy / ingress time to stop sending new connections before SIGKILL (pairs with preStop).
|
# Give kube-proxy / ingress time to stop sending new connections before SIGKILL (pairs with preStop).
|
||||||
terminationGracePeriodSeconds: 90
|
terminationGracePeriodSeconds: 90
|
||||||
@ -95,23 +92,8 @@ components:
|
|||||||
command: ["/bin/sh", "-c", "sleep 5"]
|
command: ["/bin/sh", "-c", "sleep 5"]
|
||||||
persistence:
|
persistence:
|
||||||
storageClass: local-path
|
storageClass: local-path
|
||||||
volumes:
|
# Optional extra mounts (e.g. emptyDir scratch). ACME sessions and HTTP-01 tokens use PostgreSQL, not /acme.
|
||||||
- name: acme
|
volumes: []
|
||||||
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]
|
|
||||||
secretsFile:
|
secretsFile:
|
||||||
key: appsecrets.json
|
key: appsecrets.json
|
||||||
mountPath: /secrets/appsecrets.json
|
mountPath: /secrets/appsecrets.json
|
||||||
@ -181,9 +163,7 @@ components:
|
|||||||
"ServiceToReload": {{ .Values.certsServerConfig.configuration.certsUIEngineConfiguration.agent.serviceToReload | toJson }}
|
"ServiceToReload": {{ .Values.certsServerConfig.configuration.certsUIEngineConfiguration.agent.serviceToReload | toJson }}
|
||||||
},
|
},
|
||||||
"Production": {{ .Values.certsServerConfig.configuration.certsUIEngineConfiguration.production | toJson }},
|
"Production": {{ .Values.certsServerConfig.configuration.certsUIEngineConfiguration.production | toJson }},
|
||||||
"Staging": {{ .Values.certsServerConfig.configuration.certsUIEngineConfiguration.staging | toJson }},
|
"Staging": {{ .Values.certsServerConfig.configuration.certsUIEngineConfiguration.staging | toJson }}
|
||||||
"AcmeFolder": {{ .Values.certsServerConfig.configuration.certsUIEngineConfiguration.acmeFolder | toJson }},
|
|
||||||
"DataFolder": {{ .Values.certsServerConfig.configuration.certsUIEngineConfiguration.dataFolder | 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