mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-10-02 15:08:13 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eee0a4716a | |||
| 3c8832330a | |||
| 81ab674072 | |||
| 6bf6d016f5 | |||
| 0a0226c758 | |||
| 8e534ef170 | |||
| 71786200dd | |||
| 33e3e8ef9d | |||
| 69e12f7459 | |||
| af07d72514 | |||
| a6166d11b9 |
+26
-3
@@ -17,11 +17,34 @@ REDIS_URL=redis://redis:6379
|
||||
# Change this to a strong random string in production
|
||||
SECRET_KEY=your-secret-key-change-this-in-production
|
||||
|
||||
# Optional: dedicated Fernet key for encrypting VRRP secrets of HA/VIP (Issue #27).
|
||||
# If unset, it is derived from SECRET_KEY (HKDF), exactly like MFA. Set an explicit
|
||||
# key (urlsafe-base64, 32 bytes) in production if you want independent key rotation.
|
||||
# ----------------------------------------------------------------------------
|
||||
# Optional per-purpose encryption keys.
|
||||
#
|
||||
# Every secret the application stores is encrypted at rest with Fernet. Each class
|
||||
# derives its own key, so rotating one never affects another. If a variable below is
|
||||
# unset, that class's key is derived from SECRET_KEY via HKDF — which works, but means
|
||||
# rotating SECRET_KEY makes the existing values of that class UNDECRYPTABLE. Set an
|
||||
# explicit key (urlsafe-base64, 32 bytes) in production if you want independent
|
||||
# rotation. Generate one with:
|
||||
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
# VRRP secrets for HA/VIP (Issue #27).
|
||||
# VIP_ENCRYPTION_KEY=
|
||||
|
||||
# TOTP secrets for multi-factor authentication (Issue #18).
|
||||
# MFA_ENCRYPTION_KEY=
|
||||
|
||||
# Per-account DNS provider credentials for ACME DNS-01 (Issue #35).
|
||||
# Rotating this without re-entering credentials makes DNS-01 renewals fail until the
|
||||
# affected accounts' credentials are re-saved in ACME Automation.
|
||||
# DNS_PROVIDER_ENCRYPTION_KEY=
|
||||
|
||||
# Private keys of PENDING CSRs, held only until the signed certificate is imported
|
||||
# (Issue #53). Rotating this while CSRs are out for signature makes those CSRs
|
||||
# unusable — they must be deleted and re-created.
|
||||
# CSR_ENCRYPTION_KEY=
|
||||
|
||||
# ============================================================================
|
||||
# PUBLIC URL CONFIGURATION
|
||||
# ============================================================================
|
||||
|
||||
@@ -105,8 +105,9 @@ This architecture provides better security (no inbound connections to HAProxy se
|
||||
✅ **Version Control & Rollback** - Every change versioned with one-click restore capability
|
||||
✅ **Real-Time Monitoring** - Live stats, health checks, and performance dashboards
|
||||
✅ **SSL Certificate Management** - Centralized SSL with expiration tracking
|
||||
✅ **CSR Creation** *(v1.9.0)* - Generate a private key + CSR in-app (RSA 2048/4096, ECDSA P-256/P-384, full subject + SANs), have it signed by any external CA, then import the signed certificate — the key never leaves the server
|
||||
✅ **ACME Auto SSL (Let's Encrypt)** - Automated certificate issuance, renewal, and deployment via ACME protocol
|
||||
✅ **ACME DNS-01 Challenge** *(v1.8.0)* - TXT-record validation for internal/isolated clusters (no public port 80) and wildcard certificates; pluggable DNS providers (Manual + Cloudflare), opt-in, HTTP-01 unchanged
|
||||
✅ **ACME DNS-01 Challenge** *(v1.8.0)* - TXT-record validation for internal/isolated clusters (no public port 80) and wildcard certificates; pluggable DNS providers (Manual + Cloudflare + GoDaddy *(v1.10.0)*), opt-in, HTTP-01 unchanged
|
||||
✅ **ACME Certificate Diagnostic Panel** - Automated preflight that checks agent readiness, DNS resolution, port 80 reachability, and ACME challenge ACL before issuing certificates
|
||||
✅ **WAF Rules** - Web Application Firewall management and deployment
|
||||
✅ **Agent Script Versioning** - Update agents via UI (Monaco editor) with auto-upgrade
|
||||
@@ -255,7 +256,7 @@ This architecture provides better security (no inbound connections to HAProxy se
|
||||
- **Stuck Order Detection** *(v1.4.0)*: Setup wizard surfaces orders that the CA has validated but not yet downloaded, with one-click `Complete` action and automatic 60-second retry
|
||||
- **Multi-Provider Support**: Configurable ACME directory URL supports Let's Encrypt, ZeroSSL, Google Trust Services, Buypass, and custom CAs
|
||||
- **HTTP-01 Challenge**: Built-in challenge responder with automatic HAProxy routing injection; reserved backend name `_acme_challenge_backend` is auto-managed and protected from manual edits / agent sync collisions
|
||||
- **DNS-01 Challenge** *(v1.8.0 — Issue #35)*: Validate via a DNS TXT record instead of HTTP on port 80, for **internal/isolated clusters with no public ingress** and for **wildcard** certificates (`*.example.com`). Pluggable per-account DNS provider (Manual + Cloudflare to start; credentials encrypted at rest and verified on save), same PENDING → APPLIED pipeline, bounded automatic retry on propagation lag, and a DNS-01 event timeline. Opt-in via a global setting; HTTP-01 behaviour is unchanged. (See the *DNS-01 Challenge* subsection under ACME Auto SSL below.)
|
||||
- **DNS-01 Challenge** *(v1.8.0 — Issue #35)*: Validate via a DNS TXT record instead of HTTP on port 80, for **internal/isolated clusters with no public ingress** and for **wildcard** certificates (`*.example.com`). Pluggable per-account DNS provider (Manual + Cloudflare + GoDaddy *(v1.10.0)*; credentials encrypted at rest and verified on save), same PENDING → APPLIED pipeline, bounded automatic retry on propagation lag, and a DNS-01 event timeline. Opt-in via a global setting; HTTP-01 behaviour is unchanged. (See the *DNS-01 Challenge* subsection under ACME Auto SSL below.)
|
||||
- **ACME Account Management**: Register, view, and deactivate ACME accounts from the UI
|
||||
- **Staging Mode**: Test certificate issuance with Let's Encrypt staging environment before production
|
||||
- **Custom Staging Endpoint** *(v1.4.0)*: Optional `staging_url_override` setting lets you point staging mode at a private ACME test CA (e.g. Pebble) without touching the production directory URL
|
||||
@@ -778,6 +779,15 @@ User Updates SSL in UI → All Agents Poll Backend (30s)
|
||||
→ Validate Config → Reload HAProxy (zero downtime)
|
||||
```
|
||||
|
||||
#### CSR Workflow (external / corporate CAs) — v1.9.0
|
||||
|
||||
For certificates signed by an external or corporate CA, the **CSR tab** on the SSL Certificates page covers the whole flow without the private key ever leaving the server:
|
||||
|
||||
1. **Create CSR**: pick a name (becomes the certificate name / on-agent file path), Common Name, optional SANs and subject fields (O/OU/L/ST/C/email), and a key algorithm (RSA 2048/4096 or ECDSA P-256/P-384). The backend generates the key + CSR; only the CSR PEM is shown (copy or download as `.csr`).
|
||||
2. **Get it signed**: submit the CSR to your Certificate Authority.
|
||||
3. **Import**: paste the signed certificate (+ optional chain), choose Global or cluster-specific scope and usage type. The backend verifies the certificate matches the stored key, rejects expired certs, warns on SAN drift, and creates a normal SSL certificate entry (source: `CSR`).
|
||||
4. **Deploy**: the imported certificate goes through the standard **PENDING → Apply Management → agent pull** pipeline like any other certificate.
|
||||
|
||||
#### Key Features
|
||||
- **Certificate Upload**: PEM format certificate and private key upload
|
||||
- **ACME Automation**: Automatic certificate issuance and renewal via Let's Encrypt / ACME protocol (see [ACME Auto SSL](#acme-auto-ssl---automated-certificate-management))
|
||||
@@ -979,9 +989,9 @@ DNS-01 is **opt-in** and fully backward compatible: it is disabled until an admi
|
||||
|
||||
- **Enable it**: Settings → ACME / SSL Automation → **DNS-01 Challenge (advanced)** → turn on *Enable DNS-01 Challenge* and Save. While off, DNS-01 options are hidden and no DNS-01 orders can be created.
|
||||
- **Per-account provider**: in ACME Automation, create (or reconfigure) an ACME account with **Challenge Method = DNS-01** and a **DNS Provider**. Provider credentials are **verified before saving** and **encrypted at rest** (Fernet, mirroring the VRRP/MFA secret pattern); they are never returned by the API or written to logs.
|
||||
- **Supported providers**: **Manual** (publish the TXT record yourself in any DNS — including fully internal DNS — then click *Verify*; works everywhere but cannot auto-renew unattended) and **Cloudflare** (API token with `Zone:DNS:Edit` + `Zone:Read`; the TXT record is created and cleaned up automatically and renews unattended). The provider interface is pluggable — more providers can be added without changing the issuance flow.
|
||||
- **Supported providers**: **Manual** (publish the TXT record yourself in any DNS — including fully internal DNS — then click *Verify*; works everywhere but cannot auto-renew unattended), **Cloudflare** (API token with `Zone:DNS:Edit` + `Zone:Read`; the TXT record is created and cleaned up automatically and renews unattended), and **GoDaddy** *(v1.10.0)* (a **Production** API Key + Secret pair from `developer.godaddy.com/keys` — the first key that dashboard issues is an OTE/test key and is rejected; the zone must be in the same GoDaddy account, which needs at least one registered domain before GoDaddy allows DNS API access at all. A **Personal Access Token** works too: paste it as the API Key and leave the Secret blank — that is the forward path as GoDaddy retires the `sso-key` scheme. TXT records are created and cleaned up automatically and renew unattended). The provider interface is pluggable — more providers can be added without changing the issuance flow.
|
||||
- **Same pipeline**: after validation the certificate follows the normal PENDING → APPLIED flow (assign to clusters / Apply Management) and the agent serves it — identical to HTTP-01 from finalize onward, with **zero agent or rendered-config changes** for DNS-01.
|
||||
- **Manual flow**: the order detail shows the exact `_acme-challenge.<domain>` record name + TXT value (copyable); publish it and click *I've added the records — Verify*. For Cloudflare it is automatic.
|
||||
- **Manual flow**: the order detail shows the exact `_acme-challenge.<domain>` record name + TXT value (copyable); publish it and click *I've added the records — Verify*. For Cloudflare and GoDaddy it is automatic.
|
||||
- **Resilience**: a propagation-lag failure is recovered by a **bounded fresh-order retry chain** (1 original + 3 retries with increasing backoff, kept under Let's Encrypt's rate limits); any orphaned TXT record is cleaned up by a reconcile sweep. The order detail shows a DNS-01 event timeline (publish → validation → cleanup).
|
||||
- **Wildcards**: `*.example.com` is validated at `_acme-challenge.example.com`; it does **not** cover the apex — add `example.com` as a separate name if you need both (the providers handle the two coexisting TXT values automatically).
|
||||
- **Scope (this release)**: the Site Wizard remains HTTP-01-only; issue DNS-01 / wildcard certificates from **ACME Automation**.
|
||||
@@ -1856,6 +1866,42 @@ GET /api/backends?cluster_id=1
|
||||
GET /api/frontends?cluster_id=1
|
||||
```
|
||||
|
||||
### SSL CSR API (v1.9.0)
|
||||
```bash
|
||||
# Create a CSR (generates the private key server-side; response contains the
|
||||
# CSR PEM — the private key is never returned by any endpoint)
|
||||
POST /api/ssl/csrs
|
||||
Authorization: Bearer <token>
|
||||
{
|
||||
"name": "www-example-com",
|
||||
"common_name": "www.example.com",
|
||||
"sans": ["api.example.com"],
|
||||
"key_algorithm": "rsa-2048", # rsa-2048 | rsa-4096 | ecdsa-p256 | ecdsa-p384
|
||||
"organization": "Example Corp",
|
||||
"country": "TR"
|
||||
}
|
||||
|
||||
# List CSRs (metadata only, no PEM)
|
||||
GET /api/ssl/csrs
|
||||
|
||||
# CSR detail (includes the CSR PEM)
|
||||
GET /api/ssl/csrs/{csr_id}
|
||||
|
||||
# Import the CA-signed certificate for a pending CSR
|
||||
POST /api/ssl/csrs/{csr_id}/import
|
||||
{
|
||||
"certificate_content": "-----BEGIN CERTIFICATE-----...",
|
||||
"chain_content": "-----BEGIN CERTIFICATE-----...", # optional
|
||||
"usage_type": "frontend", # frontend | server
|
||||
"is_global": false,
|
||||
"cluster_ids": [1, 2]
|
||||
}
|
||||
|
||||
# Delete a CSR (pending: permanently destroys the private key;
|
||||
# completed: removes history only — the imported certificate is unaffected)
|
||||
DELETE /api/ssl/csrs/{csr_id}
|
||||
```
|
||||
|
||||
### ACME / Let's Encrypt API
|
||||
```bash
|
||||
# List ACME accounts
|
||||
@@ -2428,6 +2474,12 @@ Developed with ❤️ for the HAProxy community
|
||||
|
||||
## Release Notes
|
||||
|
||||
- **v1.10.1** (2026-08-08) — **CSR private key encrypted at rest** (Issue #53): the private key of a **pending** CSR is now Fernet-encrypted in the database instead of stored as PEM. It is the one key in the system worth protecting this way — it sits idle for the entire signing window (days to weeks), is never transmitted to an agent, and is destroyed the moment the signed certificate is imported; `ssl_certificates.private_key_content` and the ACME order keys are unchanged, because agents must receive those in plaintext on every poll. The token replaces the PEM in the **same column**, so there is **no schema change and no `SCHEMA_VERSION` bump** (and therefore no re-seed of the built-in roles). CSRs created before this release keep a raw PEM and are still read transparently, so anything already out for signature imports normally with no data migration. The key derives from `SECRET_KEY` via HKDF with its own info string, independent of the VIP/MFA/DNS keys, and an optional `CSR_ENCRYPTION_KEY` enables independent rotation — rotating `SECRET_KEY` without it makes pending CSR keys unrecoverable, which now fails with an explicit "delete and re-create this CSR" error rather than a misleading key-mismatch. `.env.template` now documents all four per-purpose encryption keys. No API, UI or agent change.
|
||||
- **v1.10.0** (2026-08-07) — **GoDaddy DNS provider for DNS-01** (Issue #35 follow-up): DNS-01 challenges can now be published and cleaned up automatically through **GoDaddy**, alongside the existing Manual and Cloudflare providers, so wildcard and internal-cluster certificates on GoDaddy-hosted zones **renew unattended**. Credentials are a **Production API Key + Secret** pair from `developer.godaddy.com/keys` (a **Personal Access Token** also works — paste it as the Key and leave the Secret blank, which is the forward path as GoDaddy retires `sso-key`); they are **verified against the GoDaddy API before being saved** and **encrypted at rest** (Fernet, the same path as Cloudflare), and are never returned by the API, logged, or written to an order event. GoDaddy's v1 API has **no per-value TXT write** — `PUT` replaces an entire RRset — so add/remove are read-modify-write with sibling values merged back, empty-`data` tombstones filtered out, and `DELETE` used for the last value (`PUT []` is rejected); this is what keeps the **apex + wildcard** case (two TXT values at one `_acme-challenge` name) working, and the record path is hard-gated so it can never collapse onto the zone-wide endpoint that would wipe SPF/DKIM/DMARC. Zone lookup probes the records API rather than the domain listing, so **delegated sub-zones** resolve and small accounts are not falsely rejected. Registry-only addition: one new provider module plus one registry line — no frontend change (the credential form is schema-driven). No schema, API-shape, agent, or rendered-config changes; Manual, Cloudflare and HTTP-01 are unaffected.
|
||||
- **v1.9.0** (2026-08-04) — **CSR creation** (in-app key + CSR generation and signed-certificate import): a new **CSR tab** on the SSL Certificates page generates a private key and Certificate Signing Request server-side (RSA 2048/4096 or ECDSA P-256/P-384; full subject — O/OU/L/ST/C/email — plus DNS SANs with wildcard support), for certificates signed by an **external or corporate CA**. The operator downloads/copies the CSR PEM, has it signed, then imports the signed certificate (+ optional chain): the backend verifies the certificate against the stored key (hard gate), rejects expired certs, warns on SAN drift, and creates a normal SSL certificate entry (source `CSR`) that flows through the standard **PENDING → Apply Management → agent pull** pipeline. The private key **never leaves the server** — no CSR endpoint returns it, and after import the CSR row's key copy is destroyed (the key then lives only on the certificate, like every other key). Additive schema change: one new table `ssl_csrs` (SCHEMA_VERSION 9 → 10, auto-migrated, no existing table altered); key generation runs off the event loop and is rate-limited per user; existing `ssl.*` permissions govern all new endpoints. No agent or rendered-config changes.
|
||||
- **v1.8.10** (2026-07-20) — **Security hardening** (GHSA-7rhv-c5pc-69r8, GHSA-3p5c-m5m4-mjpx, GHSA-3vh4): three advisory classes remediated, backend-only, no agent changes. (1) **RCE**: the agent script-template read/write endpoints now require the `agents.version` permission on top of authentication — a poisoned template is executed as root on every HAProxy node, so authentication alone was insufficient. (2) **Missing authentication**: operator/UI endpoints that were served without a JWT (dashboard stats, pool/cluster listings, agent inventory, WAF rules, config validate/optimize, SSL config-versions, health deep/agents/clusters) are now gated by a `require_authenticated_user` dependency, and agent data-plane endpoints that treated the `X-API-Key` header as *optional* (heartbeat, config, ssl-certificates, upgrade-status, pending-requests) now hard-reject a missing key. In every case the auth check was moved **ahead of** the handler's `try:` block so a 401 can no longer be rewritten into a 500 by the generic exception handler. (3) **SSRF**: a new `utils/ssrf_guard.py` (https-only, IPv4-pinned connector, all resolved addresses must be public, no redirects) protects the ACME directory fetch, the signed-request target and the ACME connection test, which accept operator- or DB-supplied URLs; the connection test also stopped reflecting arbitrary upstream JSON. Frontend dependency advisories patched in the same release. No schema, API-shape or rendered-config changes.
|
||||
- **v1.8.9** (2026-07-13) — **ACL `-f` pattern-file support** (Issue #38 follow-up): ACL definitions that reference a host-side pattern file (`acl … -f /etc/haproxy/lists/blocked.lst`) are accepted on import and edit instead of being rejected. The referenced file lives on the HAProxy node and cannot be validated from the manager, so the manager emits an **advisory warning** rather than a hard rejection and lets the agent's `haproxy -c` check be the fail-safe gate (a broken reference fails validation on the node and the previous config is restored). Consistent with the SPOE handling introduced in v1.8.8.
|
||||
- **v1.8.8** (2026-07-10) — **SPOE filter and frontend `log-format` preserved on import/edit** (Issue #38): importing an existing `haproxy.cfg` or editing a frontend silently dropped `filter spoe …` directives and custom `log-format` lines, so the next Apply pushed a config that had lost them. Both are now round-tripped through import and edit. As with `-f` pattern files, the SPOE engine config is a host-side file the manager cannot read, so it is preserved verbatim and reported as an advisory rather than validated centrally.
|
||||
- **v1.8.7** (2026-07-09) — **Version reporting single-source fix**: the version shown in the UI (backend-sourced via `/api/version`) could lag behind the real release. The canonical version lived in the repo-root `version.json`, but the backend image is built from the `./backend` context, so that file did not reach the container in every pipeline; the backend then fell back to a hardcoded constant in `main.py` that had to be bumped by hand and had drifted (it reported 1.8.4 after 1.8.5/1.8.6 shipped). The version now lives in a single file, `backend/version.json`, baked into every image automatically, and `main.py` no longer carries a real version literal (its fallback is a neutral "unknown"). A new test enforces that the version stays single-source and cannot drift. No functional or API change.
|
||||
- **v1.8.6** (2026-07-06) — **Performance: opt-in API workers + heartbeat micro-optimization** (Issue #35 follow-up): the backend container can now run multiple uvicorn worker processes via the new `UVICORN_WORKERS` environment variable (default **1** — behavior unchanged unless you opt in), letting the API use all cores on multi-core hosts; background tasks were already multi-replica safe, as exercised by the Kubernetes HPA deployment. The agent heartbeat handler now reads the agent's `status`/`version`/`upgrade_status` in one query instead of three (one round-trip per heartbeat, per agent, every 30s). Added a *Performance Tuning* section to the README (worker/replica scaling and how to use the `X-Response-Time` header and `Slow request detected` logs to pinpoint slow endpoints). Zero-risk release: no schema, API, or agent changes; defaults preserve existing behavior exactly.
|
||||
- **v1.8.5** (2026-07-03) — **ACME completion-task SQL fix** (Issue #35 follow-up): the background order-completion task (`complete_pending_acme_orders`, runs every 60s) died on **every cycle** with `syntax error at or near ")"` — an extra closing parenthesis introduced in v1.8.0's bounded DNS-01 retry claim query. Because that query is the task's first database call, **no background ACME work ran at all from v1.8.0 through v1.8.4**: orders were never claimed for finalize/download, the DNS-01 TXT record was never published (so DNS-01 with an automated provider such as Cloudflare could never validate), Site Wizard staged orders never left `wizard_staged`, and DNS-01 retry/TXT-cleanup never executed. The stray parenthesis is removed and a regression test now scans all ACME modules' SQL for unbalanced parentheses (the unit suite mocks the database, which is why a raw-SQL syntax error could slip through). One-line backend query fix; no schema, API, or agent changes — fully backward compatible.
|
||||
|
||||
@@ -1,3 +1,123 @@
|
||||
# Upgrade Notes — v1.10.1 (CSR private key encrypted at rest)
|
||||
|
||||
**Backward compatible.** Nothing to do on upgrade, and nothing changes for existing clusters,
|
||||
agents or certificates:
|
||||
|
||||
- **Schema:** **no `SCHEMA_VERSION` bump and no migration.** The Fernet token replaces the PEM
|
||||
inside the *existing* `ssl_csrs.private_key_pem` TEXT column. As in v1.10.0, this means the
|
||||
four built-in roles are **not** re-seeded, so any customization of `super_admin` / `operator` /
|
||||
`security_admin` / `viewer` survives.
|
||||
- **Existing pending CSRs keep working.** Rows written before this release hold a raw PEM and are
|
||||
still read transparently, so a CSR that is already out for signature can be imported normally
|
||||
after the upgrade. There is no data migration and no downtime step. Those rows stay plaintext
|
||||
until they are imported (which NULLs the key) — if you want everything encrypted immediately,
|
||||
delete and re-create any long-pending CSRs.
|
||||
- **Scope:** this covers the PENDING CSR key only. It is the one key in the system that sits idle
|
||||
for the whole signing window and is never transmitted. `ssl_certificates.private_key_content`
|
||||
and the ACME order keys are unchanged, because agents must receive those in plaintext on every
|
||||
poll.
|
||||
- **Optional env:** `CSR_ENCRYPTION_KEY` (see `.env.template`). If unset, the key is derived from
|
||||
`SECRET_KEY` via HKDF with its own info string, so it is independent of the VIP, MFA and DNS
|
||||
provider keys.
|
||||
- **⚠️ Rotating `SECRET_KEY` while `CSR_ENCRYPTION_KEY` is unset makes pending CSR keys
|
||||
unrecoverable.** Import then fails with an explicit "delete this CSR and create a new one"
|
||||
error rather than a misleading key-mismatch. Set an explicit `CSR_ENCRYPTION_KEY` if you
|
||||
rotate `SECRET_KEY`. Certificates already imported are unaffected — their key lives on the
|
||||
certificate row.
|
||||
- **API / UI / agents:** unchanged. No CSR endpoint ever returned the private key before or now,
|
||||
and nothing about the CSR tab changes.
|
||||
- **Rollback:** the application downgrades cleanly — 1.10.0 starts normally against the same
|
||||
database and every other feature is unaffected. The one casualty is a CSR **created on 1.10.1
|
||||
and still pending**: 1.10.0 has no decrypt step, so it hands the Fernet token straight to the
|
||||
key-pairing check. Measured on a real downgrade, the import then fails with
|
||||
`HTTP 500 — Could not verify the certificate/key pair: key parse failed (encrypted?)`; it does
|
||||
**not** silently pair the wrong key, and it does not corrupt anything. Import or delete CSRs
|
||||
created on 1.10.1 before downgrading. Certificates already imported are unaffected, since their
|
||||
key lives on the certificate row, and CSRs created before 1.10.1 are plaintext and still work.
|
||||
|
||||
---
|
||||
|
||||
# Upgrade Notes — v1.10.0 (GoDaddy DNS-01 provider)
|
||||
|
||||
**Backward compatible & additive.** Nothing changes unless you select **GoDaddy** as an ACME
|
||||
account's DNS provider:
|
||||
|
||||
- **Schema:** **no `SCHEMA_VERSION` bump.** The GoDaddy credentials (API Key + Secret) are stored
|
||||
as two keys inside the *existing* encrypted
|
||||
`letsencrypt_account_dns_credentials.credentials_encrypted` blob — no new table, no new column,
|
||||
no migration.
|
||||
- **✅ Built-in roles are NOT re-seeded.** The re-seed warning in the v1.9.0 notes below is
|
||||
triggered by a `SCHEMA_VERSION` bump. This release does not bump it, so any customization you
|
||||
made to `super_admin` / `operator` / `security_admin` / `viewer` survives untouched.
|
||||
- **Permissions / API shape:** unchanged. `GET /api/letsencrypt/dns-providers` simply returns one
|
||||
extra entry in its `providers` array; every request and response shape is identical, and the
|
||||
credential form is rendered from that schema, so there is no frontend behaviour change either.
|
||||
- **Environment:** no new variable. GoDaddy credentials use the same Fernet-at-rest path as
|
||||
Cloudflare (`DNS_PROVIDER_ENCRYPTION_KEY`, falling back to a key derived from `SECRET_KEY`).
|
||||
- **Agents:** zero agent changes. DNS-01 is invisible to agents; an issued certificate follows the
|
||||
normal PENDING → Apply Management → agent pull pipeline exactly as before.
|
||||
- **Using it:** the API Key must be a **Production** key from `developer.godaddy.com/keys` (the
|
||||
first key that dashboard issues is an OTE/test key and is rejected), the zone must be in the same
|
||||
GoDaddy account, and that account needs at least one registered domain before GoDaddy permits DNS
|
||||
API access. A Personal Access Token also works — paste it as the Key and leave the Secret blank.
|
||||
Credentials are checked against the GoDaddy API before they are stored, so an invalid, OTE or
|
||||
ineligible key fails at save time. Note the check is a **read**: a Personal Access Token that has
|
||||
`domains.domain:read` but not `domains.dns:update` saves successfully and only fails at the first
|
||||
publish, with a 403 in the order timeline.
|
||||
- **Rollback:** don't select GoDaddy. Existing Manual and Cloudflare accounts and all HTTP-01
|
||||
issuance are untouched. **Downgrading after adopting GoDaddy is not a no-op**: on 1.9.0
|
||||
`godaddy` is not a known provider, so any account still set to it degrades to the manual-confirm
|
||||
path (in-flight DNS-01 orders wait for a confirmation nobody can give and expire after 48h, and
|
||||
renewals stop), and the cleanup sweep marks published TXT records cleaned without removing them.
|
||||
Before downgrading, switch affected accounts back to Manual or Cloudflare and let the reconcile
|
||||
sweep remove outstanding `_acme-challenge` records first. The stored credential row itself is
|
||||
inert — an encrypted blob for an unknown provider.
|
||||
|
||||
---
|
||||
|
||||
# Upgrade Notes — v1.9.0 (CSR creation)
|
||||
|
||||
**Backward compatible & additive.** Upgrading to v1.9.0 changes nothing for existing
|
||||
clusters/agents until you create a CSR:
|
||||
|
||||
- **Schema:** `SCHEMA_VERSION` bumps to `10`, so on first start the (idempotent)
|
||||
migration sequence re-runs once and adds **one new table** (`ssl_csrs`) plus its
|
||||
indexes. **No existing table is altered**, existing rows are untouched, and the
|
||||
**admin password is not reset** (the default-user seeding is guarded by an
|
||||
existence check, not an upsert). No new permission strings are introduced — all
|
||||
CSR endpoints are governed by the existing `ssl.create` / `ssl.read` /
|
||||
`ssl.delete` permissions.
|
||||
- **⚠️ Built-in roles are re-seeded to their defaults (pre-existing behaviour of
|
||||
every `SCHEMA_VERSION` bump — verified in a v1.8.10 → v1.9.0 upgrade drill).**
|
||||
Because the version gate re-runs the whole sequence, `update_system_roles_to_enterprise_rbac()`
|
||||
issues an unconditional `UPDATE roles SET … permissions = <defaults> WHERE name = …`
|
||||
for the four **built-in** roles (`super_admin`, `operator`, `security_admin`,
|
||||
`viewer`). **Any customization you made to a built-in role is reverted.** In the
|
||||
drill, an `operator` role that had been narrowed by removing `apply.execute` and
|
||||
`config.bulk_import` came back with both restored (57 → 59 permissions).
|
||||
- **Roles you created yourself are NOT affected** — the re-seed matches on the four
|
||||
built-in names only.
|
||||
- This is not new in v1.9.0: it happens on every release that bumps
|
||||
`SCHEMA_VERSION` (v1.7.0, v1.8.0, v1.8.8 …). It is documented as intentional at
|
||||
`backend/database/migrations.py` (the "BUMP THIS … OR seeded/role data" note) —
|
||||
the migration is treated as the authority on built-in-role contents.
|
||||
- **If you have hardened a built-in role, do this:** export it before upgrading
|
||||
(`GET /api/roles`), then re-apply your changes after the first start
|
||||
(`PUT /api/roles/{id}`) — or, preferably, move your customization into a
|
||||
purpose-made custom role, which survives every upgrade.
|
||||
- **Key storage:** CSR private keys are stored in the database like every other key
|
||||
in the system (`ssl_certificates.private_key_content` and the ACME order keys).
|
||||
The key is never returned by any CSR API endpoint, and after a successful import
|
||||
the CSR row's key copy is set to NULL (the key then lives only on the certificate
|
||||
row).
|
||||
- **Agents:** zero agent changes. Agents never read the new table; a CSR becomes
|
||||
visible to agents only after its signed certificate is imported **and** applied via
|
||||
Apply Management (the standard PENDING pipeline).
|
||||
- **Rollback:** simply don't use the CSR tab. The `ssl_csrs` table is inert when
|
||||
empty; downgrading the application leaves it as an ignored extra table.
|
||||
|
||||
---
|
||||
|
||||
# Upgrade Notes — v1.7.0 (HA / VIP Keepalived management, Issue #27)
|
||||
|
||||
**Backward compatible & opt-in.** Upgrading to v1.7.0 changes nothing for existing
|
||||
|
||||
@@ -1753,7 +1753,12 @@ async def ensure_agent_activity_logs_table():
|
||||
# bump, already-deployed databases (version >= 8) skip the whole migration run and never gain
|
||||
# the columns, so the frontends SELECT/INSERT would fail. Additive + idempotent + nullable;
|
||||
# existing rows stay NULL and render byte-identical.
|
||||
SCHEMA_VERSION = 9
|
||||
# v1.9.0 (CSR creation): bumped 9 -> 10 for the brand-new `ssl_csrs` table
|
||||
# (ensure_ssl_csrs_table step). Holds a locally generated private key + CSR PEM
|
||||
# until the operator imports the CA-signed certificate; the import creates a
|
||||
# normal ssl_certificates row and NULLs the key copy here. Additive + idempotent;
|
||||
# no existing table is altered, agents never read this table.
|
||||
SCHEMA_VERSION = 10
|
||||
|
||||
|
||||
async def run_all_migrations():
|
||||
@@ -1890,12 +1895,84 @@ async def _run_all_migrations_inner():
|
||||
await ensure_mfa_columns()
|
||||
|
||||
# Issue #27 — HA/VIP Keepalived management (v1.7.0): two brand-new tables.
|
||||
# MUST stay last: FK-references haproxy_cluster_pools/agents/users, all created above.
|
||||
# MUST run after its FK targets (haproxy_cluster_pools/agents/users), all created above.
|
||||
await ensure_vip_tables()
|
||||
|
||||
# v1.9.0 — CSR creation: brand-new ssl_csrs table. FK-references
|
||||
# ssl_certificates/users, both created above.
|
||||
await ensure_ssl_csrs_table()
|
||||
|
||||
logger.info("Database migrations completed successfully.")
|
||||
|
||||
|
||||
async def ensure_ssl_csrs_table():
|
||||
"""v1.9.0 — CSR (Certificate Signing Request) creation. Additive only:
|
||||
one brand-new table (ssl_csrs) + indexes. No ALTER of any existing table,
|
||||
so the entire current fleet is byte-identical. Fully idempotent
|
||||
(CREATE TABLE/INDEX IF NOT EXISTS). FK targets (ssl_certificates, users)
|
||||
are created earlier in the sequence.
|
||||
|
||||
A CSR row holds a locally generated private key + CSR PEM until the
|
||||
operator imports the CA-signed certificate. The import creates a normal
|
||||
ssl_certificates row (source='csr', last_config_status='PENDING') and
|
||||
NULLs the private_key_pem copy here — the key then lives only on the
|
||||
certificate row, like every other key in the system. Agents never read
|
||||
this table: the agent SSL delivery endpoint selects from
|
||||
ssl_certificates only, so a pending CSR can never leak to an agent.
|
||||
"""
|
||||
conn = None
|
||||
try:
|
||||
conn = await get_database_connection()
|
||||
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS ssl_csrs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
common_name VARCHAR(253) NOT NULL,
|
||||
subject JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
sans JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
key_algorithm VARCHAR(20) NOT NULL DEFAULT 'rsa-2048',
|
||||
csr_pem TEXT NOT NULL,
|
||||
private_key_pem TEXT,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
ssl_certificate_id INTEGER REFERENCES ssl_certificates(id) ON DELETE SET NULL,
|
||||
completed_at TIMESTAMP,
|
||||
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT ssl_csrs_status_check CHECK (status IN ('pending', 'completed'))
|
||||
);
|
||||
""")
|
||||
|
||||
# Only PENDING CSRs reserve their name: the name becomes the
|
||||
# ssl_certificates.name (and thus /etc/ssl/haproxy/{name}.pem on every
|
||||
# agent) at import time, so two open CSRs must not target the same
|
||||
# cert name. Completed CSRs are history and may share a name across
|
||||
# reissues — mirrors the uq_vip_name_active partial-index rationale.
|
||||
await conn.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_ssl_csrs_name_pending ON ssl_csrs(name) WHERE status = 'pending';"
|
||||
)
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_ssl_csrs_status ON ssl_csrs(status);"
|
||||
)
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_ssl_csrs_cert ON ssl_csrs(ssl_certificate_id);"
|
||||
)
|
||||
|
||||
logger.info("ssl_csrs table ensured (v1.9.0 CSR creation)")
|
||||
except Exception as e:
|
||||
logger.error(f"Error ensuring ssl_csrs table: {e}")
|
||||
# Re-raise (ensure_ssl_cluster_junction_table precedent): this step is
|
||||
# part of the SCHEMA_VERSION=10 bump, and run_all_migrations() records
|
||||
# the marker only after the inner sequence completes cleanly. Swallowing
|
||||
# a failure here would stamp version 10 with no ssl_csrs table, and the
|
||||
# version gate would then skip every future retry — permanently.
|
||||
raise
|
||||
finally:
|
||||
if conn:
|
||||
await close_database_connection(conn)
|
||||
|
||||
|
||||
async def ensure_mfa_columns():
|
||||
"""Issue #18 — TOTP MFA (v1.6.0): additive columns on users + 3 new tables.
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ from routers.acme_diagnostics import router as acme_diagnostics_router
|
||||
from routers.site_wizard import router as site_wizard_router
|
||||
from routers.mfa import router as mfa_router
|
||||
from routers.vip import router as vip_router # Issue #27 — HA/VIP (Keepalived) management
|
||||
from routers.csr import router as csr_router # v1.9.0 — CSR creation (in-app key+CSR generation, signed-cert import)
|
||||
|
||||
# Production logging configuration
|
||||
from utils.logging_config import setup_production_logging
|
||||
@@ -892,6 +893,7 @@ app.include_router(dashboard_stats_router) # HAProxy stats dashboard
|
||||
app.include_router(agent_router)
|
||||
app.include_router(waf_router)
|
||||
app.include_router(ssl_router)
|
||||
app.include_router(csr_router) # v1.9.0: CSR creation (in-app key+CSR generation, signed-cert import)
|
||||
app.include_router(security_router)
|
||||
app.include_router(configuration_router)
|
||||
app.include_router(settings_router)
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
"""
|
||||
Pydantic models for the CSR (Certificate Signing Request) feature (v1.9.0).
|
||||
|
||||
A CSR row is the precursor of an ssl_certificates row: the backend generates
|
||||
the private key + CSR locally, the operator has the CSR signed by an external
|
||||
CA and then imports the signed certificate. The CSR `name` therefore obeys the
|
||||
exact same path-traversal contract as the SSL certificate name (Bulgu #21) —
|
||||
at import time it becomes /etc/ssl/haproxy/{name}.pem on every agent and is
|
||||
shell-processed by the agent script as root.
|
||||
|
||||
The import model deliberately has NO private key field: the key never leaves
|
||||
the server. It is stored on the ssl_csrs row at generation time and paired
|
||||
with the signed certificate server-side.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, field_validator, model_validator
|
||||
|
||||
KEY_ALGORITHMS = ('rsa-2048', 'rsa-4096', 'ecdsa-p256', 'ecdsa-p384')
|
||||
|
||||
# RFC 1035 LDH hostname, lowercase, optional single leftmost wildcard label.
|
||||
# Single-label names are allowed (internal CAs routinely sign bare hostnames).
|
||||
_DNS_NAME_PATTERN = re.compile(
|
||||
r'^(\*\.)?[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?'
|
||||
r'(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$'
|
||||
)
|
||||
|
||||
# Reject control characters in free-text subject fields: they would be
|
||||
# persisted, echoed into the UI / issuer column, and printed into agent logs
|
||||
# via `openssl -subject` output.
|
||||
_CONTROL_CHARS_PATTERN = re.compile(r'[\x00-\x1f\x7f]')
|
||||
|
||||
_MAX_SANS = 100
|
||||
_MAX_CERT_PEM_BYTES = 64 * 1024 # a leaf certificate is ~2 KB; 64 KB is generous
|
||||
_MAX_CHAIN_PEM_BYTES = 256 * 1024 # agents re-download all cert content every poll
|
||||
|
||||
|
||||
def _validate_dns_name(value: str, field_label: str) -> str:
|
||||
v = (value or '').strip().lower()
|
||||
if not v:
|
||||
raise ValueError(f'{field_label} must not be empty')
|
||||
if len(v) > 253:
|
||||
raise ValueError(f'{field_label} must be 253 characters or fewer')
|
||||
if not _DNS_NAME_PATTERN.match(v):
|
||||
raise ValueError(
|
||||
f'{field_label} {value!r} is not a valid DNS name — lowercase '
|
||||
'letters, digits, hyphens and dots only; a wildcard is allowed '
|
||||
'only as the leftmost label (e.g. *.example.com).'
|
||||
)
|
||||
return v
|
||||
|
||||
|
||||
def _validate_subject_text(value: Optional[str], field_label: str, max_len: int = 64) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
v = value.strip()
|
||||
if not v:
|
||||
return None
|
||||
if len(v) > max_len:
|
||||
raise ValueError(f'{field_label} must be {max_len} characters or fewer')
|
||||
if _CONTROL_CHARS_PATTERN.search(v):
|
||||
raise ValueError(f'{field_label} must not contain control characters')
|
||||
return v
|
||||
|
||||
|
||||
def _validate_csr_name(v: str) -> str:
|
||||
"""Mirror of SSLCertificateCreate.validate_name_no_path_traversal (Bulgu #21)
|
||||
with one deliberate tightening: max length 100, matching the
|
||||
ssl_certificates.name VARCHAR(100) column (the historical 200-char limit
|
||||
overflows the column and 500s — not replicated here)."""
|
||||
if v is None:
|
||||
raise ValueError('CSR name is required')
|
||||
stripped = v.strip()
|
||||
if not stripped:
|
||||
raise ValueError('CSR name must not be empty')
|
||||
if stripped != v:
|
||||
raise ValueError('CSR name must not contain leading/trailing whitespace')
|
||||
if len(stripped) > 100:
|
||||
raise ValueError('CSR name must be 100 characters or fewer')
|
||||
if not re.match(r'^[A-Za-z0-9_.-]+$', stripped):
|
||||
raise ValueError(
|
||||
f'CSR name={v!r} contains forbidden characters — only letters, '
|
||||
'digits, underscore, hyphen, and dot are allowed (the name becomes '
|
||||
'a filename component under /etc/ssl/haproxy/ at import).'
|
||||
)
|
||||
if '..' in stripped:
|
||||
raise ValueError(f'CSR name={v!r} must not contain ".." (path traversal)')
|
||||
if stripped.startswith('.'):
|
||||
raise ValueError(f'CSR name={v!r} must not start with "." (hidden filename)')
|
||||
if stripped.startswith('-'):
|
||||
raise ValueError(f'CSR name={v!r} must not start with "-" (CLI flag confusion)')
|
||||
return stripped
|
||||
|
||||
|
||||
class SSLCSRCreate(BaseModel):
|
||||
name: str # becomes the certificate name at import
|
||||
common_name: str
|
||||
organization: Optional[str] = None # O
|
||||
organizational_unit: Optional[str] = None # OU
|
||||
locality: Optional[str] = None # L
|
||||
state: Optional[str] = None # ST
|
||||
country: Optional[str] = None # C — exactly 2 letters
|
||||
email: Optional[str] = None # emailAddress
|
||||
sans: List[str] = [] # DNS names; CN is auto-added server-side
|
||||
key_algorithm: str = 'rsa-2048'
|
||||
|
||||
@field_validator('name')
|
||||
@classmethod
|
||||
def validate_name(cls, v):
|
||||
return _validate_csr_name(v)
|
||||
|
||||
@field_validator('common_name')
|
||||
@classmethod
|
||||
def validate_common_name(cls, v):
|
||||
v = _validate_dns_name(v, 'Common Name')
|
||||
# RFC 5280 ub-common-name — many CAs reject CNs longer than 64 chars.
|
||||
if len(v) > 64:
|
||||
raise ValueError(
|
||||
'Common Name must be 64 characters or fewer (RFC 5280 upper '
|
||||
'bound) — put longer names in the SAN list instead.'
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator('sans')
|
||||
@classmethod
|
||||
def validate_sans(cls, v):
|
||||
if not v:
|
||||
return []
|
||||
if len(v) > _MAX_SANS:
|
||||
raise ValueError(f'At most {_MAX_SANS} SAN entries are allowed')
|
||||
seen = set()
|
||||
result = []
|
||||
for entry in v:
|
||||
normalised = _validate_dns_name(entry, 'SAN entry')
|
||||
if normalised not in seen:
|
||||
seen.add(normalised)
|
||||
result.append(normalised)
|
||||
return result
|
||||
|
||||
@field_validator('organization')
|
||||
@classmethod
|
||||
def validate_organization(cls, v):
|
||||
return _validate_subject_text(v, 'Organization (O)')
|
||||
|
||||
@field_validator('organizational_unit')
|
||||
@classmethod
|
||||
def validate_organizational_unit(cls, v):
|
||||
return _validate_subject_text(v, 'Organizational Unit (OU)')
|
||||
|
||||
@field_validator('locality')
|
||||
@classmethod
|
||||
def validate_locality(cls, v):
|
||||
return _validate_subject_text(v, 'Locality (L)')
|
||||
|
||||
@field_validator('state')
|
||||
@classmethod
|
||||
def validate_state(cls, v):
|
||||
return _validate_subject_text(v, 'State/Province (ST)')
|
||||
|
||||
@field_validator('country')
|
||||
@classmethod
|
||||
def validate_country(cls, v):
|
||||
# cryptography raises a bare ValueError for a non-2-char COUNTRY_NAME;
|
||||
# pre-validate so the operator gets a friendly 422 instead of a 500.
|
||||
if v is None:
|
||||
return None
|
||||
v = v.strip()
|
||||
if not v:
|
||||
return None
|
||||
if not re.match(r'^[A-Za-z]{2}$', v):
|
||||
raise ValueError('Country (C) must be exactly 2 letters (ISO 3166-1 alpha-2, e.g. TR, US)')
|
||||
return v.upper()
|
||||
|
||||
@field_validator('email')
|
||||
@classmethod
|
||||
def validate_email(cls, v):
|
||||
v = _validate_subject_text(v, 'Email', max_len=254)
|
||||
if v is not None and ('@' not in v or v.startswith('@') or v.endswith('@')):
|
||||
raise ValueError('Email must be a valid address (missing or misplaced "@")')
|
||||
return v
|
||||
|
||||
@field_validator('key_algorithm')
|
||||
@classmethod
|
||||
def validate_key_algorithm(cls, v):
|
||||
if v not in KEY_ALGORITHMS:
|
||||
raise ValueError(
|
||||
f'key_algorithm must be one of: {", ".join(KEY_ALGORITHMS)}'
|
||||
)
|
||||
return v
|
||||
|
||||
|
||||
class SSLCSRImport(BaseModel):
|
||||
"""Import the CA-signed certificate for a pending CSR. The private key is
|
||||
NOT part of the request — it is already stored on the CSR row."""
|
||||
certificate_content: str # PEM
|
||||
chain_content: Optional[str] = None # PEM, optional
|
||||
usage_type: str = 'frontend' # "frontend" or "server"
|
||||
is_global: bool = False
|
||||
cluster_ids: Optional[List[int]] = None
|
||||
# Escape hatch for name collisions that appeared AFTER the CSR was
|
||||
# created: overrides the CSR's reserved name for the certificate row.
|
||||
name: Optional[str] = None
|
||||
|
||||
@field_validator('certificate_content')
|
||||
@classmethod
|
||||
def validate_certificate(cls, v):
|
||||
if not v or not v.strip():
|
||||
raise ValueError('Certificate content is required')
|
||||
v = v.strip()
|
||||
if len(v.encode('utf-8', errors='ignore')) > _MAX_CERT_PEM_BYTES:
|
||||
raise ValueError('Certificate content exceeds the 64 KB limit')
|
||||
if '-----BEGIN CERTIFICATE-----' not in v or '-----END CERTIFICATE-----' not in v:
|
||||
raise ValueError('Certificate must be in PEM format')
|
||||
return v
|
||||
|
||||
@field_validator('chain_content')
|
||||
@classmethod
|
||||
def validate_chain(cls, v):
|
||||
if v and v.strip():
|
||||
v = v.strip()
|
||||
if len(v.encode('utf-8', errors='ignore')) > _MAX_CHAIN_PEM_BYTES:
|
||||
raise ValueError('Certificate chain exceeds the 256 KB limit')
|
||||
if '-----BEGIN CERTIFICATE-----' not in v or '-----END CERTIFICATE-----' not in v:
|
||||
raise ValueError('Certificate chain must be in PEM format')
|
||||
return v
|
||||
return None
|
||||
|
||||
@field_validator('usage_type')
|
||||
@classmethod
|
||||
def validate_usage_type(cls, v):
|
||||
if v not in ['frontend', 'server']:
|
||||
raise ValueError('usage_type must be either "frontend" or "server"')
|
||||
return v
|
||||
|
||||
@field_validator('name')
|
||||
@classmethod
|
||||
def validate_name(cls, v):
|
||||
if v is None or not str(v).strip():
|
||||
return None
|
||||
return _validate_csr_name(v)
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_cluster_selection(self):
|
||||
if not self.is_global and not self.cluster_ids:
|
||||
raise ValueError(
|
||||
'cluster_ids is required when is_global is false — pick at '
|
||||
'least one cluster or import the certificate as global.'
|
||||
)
|
||||
return self
|
||||
@@ -0,0 +1,375 @@
|
||||
"""
|
||||
CSR (Certificate Signing Request) endpoints (v1.9.0).
|
||||
|
||||
Generate a private key + CSR in-app, download the CSR PEM, have it signed by
|
||||
an external CA, then import the signed certificate — which creates a normal
|
||||
ssl_certificates row that flows through the existing pipeline
|
||||
(config version → Apply Management → agent pull).
|
||||
|
||||
Security posture:
|
||||
- All endpoints enforce ssl.* permissions explicitly (including the read
|
||||
endpoints — deliberately stricter than the legacy cert detail route).
|
||||
- The private key is NEVER returned by any endpoint here; after import it is
|
||||
reachable only via the existing certificate detail route.
|
||||
- Key generation is offloaded to a thread (RSA-4096 takes seconds; the
|
||||
backend runs a single-worker event loop by default) and rate-limited
|
||||
per user via the user_activity_logs COUNT pattern (acme_diagnostics
|
||||
precedent — slowapi is not registered on the app).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Header
|
||||
|
||||
from database.connection import get_database_connection, close_database_connection
|
||||
from auth_middleware import get_current_user_from_token, check_user_permission
|
||||
from models.csr import SSLCSRCreate, SSLCSRImport
|
||||
from services import csr_service, ssl_service
|
||||
from routers.ssl import _assert_safe_cert_name, validate_user_cluster_access
|
||||
from utils.activity_log import log_user_activity
|
||||
|
||||
router = APIRouter(prefix="/api/ssl/csrs", tags=["SSL CSRs"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_RATE_LIMIT_CREATE_PER_MIN = 10
|
||||
|
||||
# Columns exposed to the API — private_key_pem is deliberately absent so a
|
||||
# future `SELECT *` refactor cannot silently start leaking it.
|
||||
_CSR_LIST_COLUMNS = """
|
||||
c.id, c.name, c.common_name, c.subject, c.sans, c.key_algorithm,
|
||||
c.status, c.ssl_certificate_id, c.completed_at, c.created_at, c.updated_at,
|
||||
s.name AS certificate_name, u.username AS created_by_username
|
||||
"""
|
||||
|
||||
_INT32_MAX = 2_147_483_647
|
||||
|
||||
|
||||
def _client_ip(request: Optional[Request]) -> Optional[str]:
|
||||
try:
|
||||
return str(request.client.host) if request and request.client else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _user_agent(request: Optional[Request]) -> Optional[str]:
|
||||
try:
|
||||
return request.headers.get("user-agent") if request else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def _require(authorization: Optional[str], action: str):
|
||||
"""Authenticate + enforce ssl.<action>; returns current_user or raises 401/403."""
|
||||
current_user = await get_current_user_from_token(authorization)
|
||||
ok = await check_user_permission(current_user["id"], "ssl", action, current_user=current_user)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=403, detail=f"Insufficient permissions: ssl.{action} required")
|
||||
return current_user
|
||||
|
||||
|
||||
def _assert_int32_id(csr_id: int) -> None:
|
||||
"""ssl_csrs.id is int4 — an out-of-range path param would surface as an
|
||||
asyncpg DataError 500 (Bulgu #96 precedent); return a clean 404 instead."""
|
||||
if csr_id < 1 or csr_id > _INT32_MAX:
|
||||
raise HTTPException(status_code=404, detail="CSR not found")
|
||||
|
||||
|
||||
def _assert_valid_cluster_id(cluster_id: int) -> None:
|
||||
"""Same int4 guard for body-supplied cluster ids: haproxy_clusters.id is
|
||||
SERIAL/int4, so an out-of-range value would raise asyncpg DataError inside
|
||||
validate_user_cluster_access and surface as a 500 with the raw driver
|
||||
error. Fail with the same clean 404 the cluster lookup itself produces."""
|
||||
if not isinstance(cluster_id, int) or cluster_id < 1 or cluster_id > _INT32_MAX:
|
||||
raise HTTPException(status_code=404, detail="Cluster not found")
|
||||
|
||||
|
||||
async def _enforce_create_rate_limit(conn, user_id: int) -> None:
|
||||
"""Per-user per-minute limit on key generation, counted against the
|
||||
csr_create audit-log action (acme_diagnostics _enforce_rate_limit pattern,
|
||||
backed by the (user_id, action, created_at DESC) composite index)."""
|
||||
cnt = await conn.fetchval(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM user_activity_logs
|
||||
WHERE user_id = $1
|
||||
AND action = 'csr_create'
|
||||
AND created_at >= NOW() - INTERVAL '60 seconds'
|
||||
""",
|
||||
user_id,
|
||||
)
|
||||
if cnt is not None and cnt >= _RATE_LIMIT_CREATE_PER_MIN:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=(
|
||||
f"Rate limit exceeded: at most {_RATE_LIMIT_CREATE_PER_MIN} "
|
||||
"CSRs may be created per minute"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_csr(payload: SSLCSRCreate, request: Request, authorization: Optional[str] = Header(None)):
|
||||
"""Generate a private key + CSR. Returns the CSR PEM immediately (so the
|
||||
UI can show copy/download in one round trip) — never the private key."""
|
||||
current_user = await _require(authorization, "create")
|
||||
conn = None
|
||||
try:
|
||||
# Belt and braces on top of the model validator — same duplication
|
||||
# convention as the certificate create route.
|
||||
_assert_safe_cert_name(payload.name)
|
||||
|
||||
conn = await get_database_connection()
|
||||
await _enforce_create_rate_limit(conn, current_user["id"])
|
||||
|
||||
# Fail fast on a taken name BEFORE burning CPU on key generation;
|
||||
# insert_csr_row re-checks and the partial unique index closes the race.
|
||||
await csr_service.assert_csr_name_available(conn, payload.name)
|
||||
|
||||
bundle = await asyncio.to_thread(csr_service.generate_csr_bundle, payload)
|
||||
csr_id = await csr_service.insert_csr_row(conn, payload, bundle, current_user["id"])
|
||||
|
||||
row = await conn.fetchrow(
|
||||
f"""
|
||||
SELECT {_CSR_LIST_COLUMNS}, c.csr_pem
|
||||
FROM ssl_csrs c
|
||||
LEFT JOIN ssl_certificates s ON c.ssl_certificate_id = s.id
|
||||
LEFT JOIN users u ON c.created_by = u.id
|
||||
WHERE c.id = $1
|
||||
""",
|
||||
csr_id,
|
||||
)
|
||||
|
||||
await log_user_activity(
|
||||
user_id=current_user["id"],
|
||||
action='csr_create',
|
||||
resource_type='ssl_csr',
|
||||
resource_id=str(csr_id),
|
||||
details={
|
||||
'csr_name': payload.name,
|
||||
'common_name': payload.common_name,
|
||||
'sans': bundle['sans'],
|
||||
'key_algorithm': payload.key_algorithm,
|
||||
},
|
||||
ip_address=_client_ip(request),
|
||||
user_agent=_user_agent(request),
|
||||
)
|
||||
|
||||
return {
|
||||
"message": f"CSR '{payload.name}' created successfully",
|
||||
"csr": csr_service.csr_row_to_dict(row, include_pem=True),
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating CSR: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
if conn:
|
||||
await close_database_connection(conn)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_csrs(authorization: Optional[str] = Header(None)):
|
||||
"""List CSRs (no PEM payloads — fetch the detail route for the CSR PEM).
|
||||
Cluster-agnostic: a CSR binds to clusters only at import time."""
|
||||
await _require(authorization, "read")
|
||||
conn = None
|
||||
try:
|
||||
conn = await get_database_connection()
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT {_CSR_LIST_COLUMNS}
|
||||
FROM ssl_csrs c
|
||||
LEFT JOIN ssl_certificates s ON c.ssl_certificate_id = s.id
|
||||
LEFT JOIN users u ON c.created_by = u.id
|
||||
ORDER BY c.created_at DESC
|
||||
"""
|
||||
)
|
||||
return [csr_service.csr_row_to_dict(r) for r in rows]
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing CSRs: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
if conn:
|
||||
await close_database_connection(conn)
|
||||
|
||||
|
||||
@router.get("/{csr_id}")
|
||||
async def get_csr(csr_id: int, authorization: Optional[str] = Header(None)):
|
||||
"""CSR detail including the CSR PEM. The private key is never included."""
|
||||
await _require(authorization, "read")
|
||||
_assert_int32_id(csr_id)
|
||||
conn = None
|
||||
try:
|
||||
conn = await get_database_connection()
|
||||
row = await conn.fetchrow(
|
||||
f"""
|
||||
SELECT {_CSR_LIST_COLUMNS}, c.csr_pem
|
||||
FROM ssl_csrs c
|
||||
LEFT JOIN ssl_certificates s ON c.ssl_certificate_id = s.id
|
||||
LEFT JOIN users u ON c.created_by = u.id
|
||||
WHERE c.id = $1
|
||||
""",
|
||||
csr_id,
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="CSR not found")
|
||||
return csr_service.csr_row_to_dict(row, include_pem=True)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching CSR {csr_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
if conn:
|
||||
await close_database_connection(conn)
|
||||
|
||||
|
||||
@router.post("/{csr_id}/import")
|
||||
async def import_csr_certificate(
|
||||
csr_id: int,
|
||||
payload: SSLCSRImport,
|
||||
request: Request,
|
||||
authorization: Optional[str] = Header(None),
|
||||
):
|
||||
"""Import the CA-signed certificate for a pending CSR. Creates an
|
||||
ssl_certificates row (source='csr', PENDING) and stages one config
|
||||
version per affected cluster — the operator applies manually."""
|
||||
current_user = await _require(authorization, "create")
|
||||
_assert_int32_id(csr_id)
|
||||
conn = None
|
||||
try:
|
||||
if payload.name:
|
||||
_assert_safe_cert_name(payload.name)
|
||||
|
||||
conn = await get_database_connection()
|
||||
|
||||
if not payload.is_global:
|
||||
for cluster_id in payload.cluster_ids or []:
|
||||
_assert_valid_cluster_id(cluster_id)
|
||||
await validate_user_cluster_access(current_user["id"], cluster_id, conn)
|
||||
|
||||
result = await csr_service.import_signed_certificate(
|
||||
conn, csr_id, payload, current_user["id"]
|
||||
)
|
||||
cert_id = result["certificate_id"]
|
||||
|
||||
if payload.is_global:
|
||||
cluster_rows = await conn.fetch(
|
||||
"SELECT id FROM haproxy_clusters WHERE is_active = TRUE"
|
||||
)
|
||||
affected_clusters = [r['id'] for r in cluster_rows]
|
||||
else:
|
||||
affected_clusters = payload.cluster_ids or []
|
||||
|
||||
# Post-commit staging — a config-generation failure never rolls back
|
||||
# the certificate (same semantics as the manual create flow).
|
||||
sync_results = await ssl_service.stage_ssl_config_versions(
|
||||
conn, cert_id, affected_clusters, action='create',
|
||||
created_by=current_user["id"],
|
||||
)
|
||||
|
||||
await log_user_activity(
|
||||
user_id=current_user["id"],
|
||||
action='create',
|
||||
resource_type='ssl_certificate',
|
||||
resource_id=str(cert_id),
|
||||
details={
|
||||
'certificate_name': result['certificate_name'],
|
||||
'domain': result.get('primary_domain', 'unknown'),
|
||||
'via': 'csr',
|
||||
'csr_id': csr_id,
|
||||
'usage_type': payload.usage_type,
|
||||
'is_global': payload.is_global,
|
||||
'cluster_ids': payload.cluster_ids,
|
||||
'warnings': result['warnings'],
|
||||
},
|
||||
ip_address=_client_ip(request),
|
||||
user_agent=_user_agent(request),
|
||||
)
|
||||
await log_user_activity(
|
||||
user_id=current_user["id"],
|
||||
action='csr_import',
|
||||
resource_type='ssl_csr',
|
||||
resource_id=str(csr_id),
|
||||
details={
|
||||
'certificate_id': cert_id,
|
||||
'certificate_name': result['certificate_name'],
|
||||
},
|
||||
ip_address=_client_ip(request),
|
||||
user_agent=_user_agent(request),
|
||||
)
|
||||
|
||||
return {
|
||||
"message": (
|
||||
f"Certificate '{result['certificate_name']}' imported "
|
||||
"successfully. Go to Apply Management to deploy."
|
||||
),
|
||||
"certificate_id": cert_id,
|
||||
"warnings": result["warnings"],
|
||||
"sync_results": sync_results,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error importing signed certificate for CSR {csr_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
if conn:
|
||||
await close_database_connection(conn)
|
||||
|
||||
|
||||
@router.delete("/{csr_id}")
|
||||
async def delete_csr(csr_id: int, request: Request, authorization: Optional[str] = Header(None)):
|
||||
"""Hard delete. For a pending CSR this permanently destroys the private
|
||||
key (any certificate later signed from that CSR becomes unusable); for a
|
||||
completed CSR it only removes history — the imported certificate is not
|
||||
affected (the FK points csr → cert)."""
|
||||
current_user = await _require(authorization, "delete")
|
||||
_assert_int32_id(csr_id)
|
||||
conn = None
|
||||
try:
|
||||
conn = await get_database_connection()
|
||||
async with conn.transaction():
|
||||
# FOR UPDATE serialises against an in-flight import of the same CSR.
|
||||
row = await conn.fetchrow(
|
||||
"SELECT id, name, status FROM ssl_csrs WHERE id = $1 FOR UPDATE",
|
||||
csr_id,
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="CSR not found")
|
||||
await conn.execute("DELETE FROM ssl_csrs WHERE id = $1", csr_id)
|
||||
|
||||
await log_user_activity(
|
||||
user_id=current_user["id"],
|
||||
action='delete',
|
||||
resource_type='ssl_csr',
|
||||
resource_id=str(csr_id),
|
||||
details={'csr_name': row['name'], 'status': row['status']},
|
||||
ip_address=_client_ip(request),
|
||||
user_agent=_user_agent(request),
|
||||
)
|
||||
|
||||
if row['status'] == 'pending':
|
||||
message = (
|
||||
f"CSR '{row['name']}' deleted — its private key has been "
|
||||
"permanently destroyed."
|
||||
)
|
||||
else:
|
||||
message = (
|
||||
f"CSR '{row['name']}' deleted (history only) — the imported "
|
||||
"certificate is not affected."
|
||||
)
|
||||
return {"message": message}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting CSR {csr_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
if conn:
|
||||
await close_database_connection(conn)
|
||||
@@ -0,0 +1,461 @@
|
||||
"""
|
||||
csr_service: CSR (Certificate Signing Request) generation + signed-certificate
|
||||
import (v1.9.0).
|
||||
|
||||
Flow:
|
||||
1. `generate_csr_bundle` builds a private key + CSR locally (pure crypto,
|
||||
no DB/IO — callers MUST run it via `asyncio.to_thread`: RSA-4096
|
||||
generation takes seconds and would stall the single-worker event loop).
|
||||
2. The bundle is persisted to `ssl_csrs` (`insert_csr_row`); the operator
|
||||
downloads the CSR PEM and has it signed by an external CA.
|
||||
3. `import_signed_certificate` pairs the CA response with the stored key,
|
||||
creates a normal `ssl_certificates` row (source='csr',
|
||||
last_config_status='PENDING' — agents never see it before Apply) and
|
||||
NULLs the key copy on the CSR row.
|
||||
|
||||
The CSR builder generalises the in-repo ACME reference
|
||||
(services/acme_service.py finalize_order): PEM output instead of DER, full
|
||||
subject instead of CN-only, ECDSA support, same PKCS8/NoEncryption key
|
||||
serialisation (the agent concatenates cert+key+chain into one PEM and HAProxy
|
||||
cannot read passphrase-protected keys).
|
||||
|
||||
Private keys are ENCRYPTED AT REST from v1.10.1 (Issue #53): the Fernet token
|
||||
replaces the PEM in the same `ssl_csrs.private_key_pem` column, so there is no
|
||||
schema change and no SCHEMA_VERSION bump. Rows written earlier hold a raw PEM
|
||||
and are still read transparently — see utils/csr_key_crypto.py for the format
|
||||
discriminator and the key-rotation caveat. The pending CSR key is the one key
|
||||
in the system worth encrypting: it sits idle for the whole signing window and
|
||||
is never transmitted, unlike ssl_certificates.private_key_content and the ACME
|
||||
order keys, which agents must receive in plaintext on every poll.
|
||||
The key is NEVER returned by any CSR API response — `csr_row_to_dict` strips
|
||||
it unconditionally.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
from types import SimpleNamespace
|
||||
|
||||
import asyncpg
|
||||
from fastapi import HTTPException
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec, rsa
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
from services import ssl_service
|
||||
from utils.csr_key_crypto import decrypt_csr_private_key, encrypt_csr_private_key
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_KEY_FACTORIES = {
|
||||
'rsa-2048': lambda: rsa.generate_private_key(public_exponent=65537, key_size=2048),
|
||||
'rsa-4096': lambda: rsa.generate_private_key(public_exponent=65537, key_size=4096),
|
||||
'ecdsa-p256': lambda: ec.generate_private_key(ec.SECP256R1()),
|
||||
'ecdsa-p384': lambda: ec.generate_private_key(ec.SECP384R1()),
|
||||
}
|
||||
|
||||
# (payload attribute, x509 OID, subject-JSON key)
|
||||
_SUBJECT_OID_MAP = [
|
||||
('organization', NameOID.ORGANIZATION_NAME, 'O'),
|
||||
('organizational_unit', NameOID.ORGANIZATIONAL_UNIT_NAME, 'OU'),
|
||||
('locality', NameOID.LOCALITY_NAME, 'L'),
|
||||
('state', NameOID.STATE_OR_PROVINCE_NAME, 'ST'),
|
||||
('country', NameOID.COUNTRY_NAME, 'C'),
|
||||
('email', NameOID.EMAIL_ADDRESS, 'emailAddress'),
|
||||
]
|
||||
|
||||
|
||||
def generate_csr_bundle(payload: Any) -> Dict[str, Any]:
|
||||
"""Generate a private key + CSR for a validated SSLCSRCreate payload.
|
||||
|
||||
Pure CPU-bound crypto — no DB, no network. Callers must offload via
|
||||
`asyncio.to_thread` (see module docstring).
|
||||
|
||||
Returns {'csr_pem', 'private_key_pem', 'sans', 'subject'}.
|
||||
"""
|
||||
key = _KEY_FACTORIES[payload.key_algorithm]()
|
||||
|
||||
attrs = [x509.NameAttribute(NameOID.COMMON_NAME, payload.common_name)]
|
||||
subject_json: Dict[str, str] = {}
|
||||
for attr_name, oid, json_key in _SUBJECT_OID_MAP:
|
||||
value = getattr(payload, attr_name, None)
|
||||
if value and str(value).strip():
|
||||
cleaned = str(value).strip()
|
||||
attrs.append(x509.NameAttribute(oid, cleaned))
|
||||
subject_json[json_key] = cleaned
|
||||
|
||||
# CN always first in the SAN list, then the extra names, deduped with
|
||||
# order preserved (mirrors the ACME flow where domains[0] is the CN).
|
||||
sans = list(dict.fromkeys([payload.common_name, *(payload.sans or [])]))
|
||||
|
||||
builder = (
|
||||
x509.CertificateSigningRequestBuilder()
|
||||
.subject_name(x509.Name(attrs))
|
||||
.add_extension(
|
||||
x509.SubjectAlternativeName([x509.DNSName(d) for d in sans]),
|
||||
critical=False,
|
||||
)
|
||||
)
|
||||
csr = builder.sign(key, hashes.SHA256())
|
||||
|
||||
return {
|
||||
'csr_pem': csr.public_bytes(serialization.Encoding.PEM).decode('utf-8'),
|
||||
'private_key_pem': key.private_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.PKCS8,
|
||||
serialization.NoEncryption(),
|
||||
).decode('utf-8'),
|
||||
'sans': sans,
|
||||
'subject': subject_json,
|
||||
}
|
||||
|
||||
|
||||
def diff_domains(csr_sans: Optional[List[str]], cert_domains: Optional[List[str]]) -> List[str]:
|
||||
"""Human-readable warnings for SAN drift between the CSR and the signed
|
||||
certificate (case-insensitive set diff). CAs legitimately add/normalise
|
||||
SANs, so drift is WARN-only — the hard gate is the key match."""
|
||||
csr_set = {d.lower() for d in (csr_sans or []) if d}
|
||||
cert_set = {d.lower() for d in (cert_domains or []) if d}
|
||||
warnings: List[str] = []
|
||||
added = sorted(cert_set - csr_set)
|
||||
dropped = sorted(csr_set - cert_set)
|
||||
if added:
|
||||
warnings.append(
|
||||
f"The CA added domains that were not in the CSR: {', '.join(added)}"
|
||||
)
|
||||
if dropped:
|
||||
warnings.append(
|
||||
f"The CA dropped domains that were requested in the CSR: {', '.join(dropped)}"
|
||||
)
|
||||
return warnings
|
||||
|
||||
|
||||
def _maybe_json_list(value: Any) -> List[str]:
|
||||
"""asyncpg returns JSONB columns as str unless a codec is registered."""
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
return parsed if isinstance(parsed, list) else []
|
||||
except Exception:
|
||||
return []
|
||||
return list(value) if value else []
|
||||
|
||||
|
||||
def csr_row_to_dict(row: Any, include_pem: bool = False) -> Dict[str, Any]:
|
||||
"""Row → API dict. ALWAYS strips private_key_pem — the key never leaves
|
||||
the server via a CSR endpoint. csr_pem included only on demand
|
||||
(detail/create responses, not lists)."""
|
||||
d = dict(row)
|
||||
d.pop('private_key_pem', None)
|
||||
if not include_pem:
|
||||
d.pop('csr_pem', None)
|
||||
for key in ('subject', 'sans'):
|
||||
if key in d and isinstance(d[key], str):
|
||||
try:
|
||||
d[key] = json.loads(d[key])
|
||||
except Exception:
|
||||
pass
|
||||
return d
|
||||
|
||||
|
||||
async def assert_csr_name_available(conn, name: str) -> None:
|
||||
"""Reject a CSR name that is already taken by an ACTIVE certificate or
|
||||
another PENDING CSR. Called BEFORE key generation (cheap fail-fast) and
|
||||
re-run inside `insert_csr_row` (the unique index closes the race)."""
|
||||
existing_cert = await conn.fetchval(
|
||||
"SELECT id FROM ssl_certificates WHERE name = $1 AND is_active = TRUE",
|
||||
name,
|
||||
)
|
||||
if existing_cert:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"An active SSL certificate named '{name}' already exists. "
|
||||
"The CSR name becomes the certificate name at import — choose "
|
||||
"a different name or remove the existing certificate first."
|
||||
),
|
||||
)
|
||||
existing_csr = await conn.fetchval(
|
||||
"SELECT id FROM ssl_csrs WHERE name = $1 AND status = 'pending'",
|
||||
name,
|
||||
)
|
||||
if existing_csr:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"A pending CSR named '{name}' already exists (id={existing_csr}). "
|
||||
"Import or delete it first, or choose a different name."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def insert_csr_row(conn, payload: Any, bundle: Dict[str, Any], user_id: Optional[int]) -> int:
|
||||
"""Persist a freshly generated CSR bundle. Returns the new csr id.
|
||||
|
||||
Issue #53 (v1.10.1): the private key is Fernet-encrypted before it is stored. The token goes
|
||||
into the SAME private_key_pem TEXT column — no schema change — and is only ever decrypted
|
||||
in-process by import_signed_certificate. No CSR endpoint returns the column either way.
|
||||
"""
|
||||
await assert_csr_name_available(conn, payload.name)
|
||||
stored_key = encrypt_csr_private_key(bundle['private_key_pem'])
|
||||
try:
|
||||
csr_id = await conn.fetchval(
|
||||
"""
|
||||
INSERT INTO ssl_csrs
|
||||
(name, common_name, subject, sans, key_algorithm, csr_pem,
|
||||
private_key_pem, status, created_by)
|
||||
VALUES ($1, $2, $3::jsonb, $4::jsonb, $5, $6, $7, 'pending', $8)
|
||||
RETURNING id
|
||||
""",
|
||||
payload.name,
|
||||
payload.common_name,
|
||||
json.dumps(bundle['subject']),
|
||||
json.dumps(bundle['sans']),
|
||||
payload.key_algorithm,
|
||||
bundle['csr_pem'],
|
||||
stored_key,
|
||||
user_id,
|
||||
)
|
||||
except asyncpg.exceptions.UniqueViolationError:
|
||||
# uq_ssl_csrs_name_pending — a concurrent request won the name.
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"A pending CSR named '{payload.name}' was just created by a "
|
||||
"concurrent request — choose a different name."
|
||||
),
|
||||
)
|
||||
return csr_id
|
||||
|
||||
|
||||
async def import_signed_certificate(conn, csr_id: int, imp: Any, user_id: Optional[int]) -> Dict[str, Any]:
|
||||
"""Pair the CA-signed certificate with the stored CSR key and create the
|
||||
ssl_certificates row. Atomic: cert row + CSR state change commit together.
|
||||
|
||||
Returns {'certificate_id', 'certificate_name', 'primary_domain',
|
||||
'warnings', 'reactivated'}. Raises HTTPException on every failure
|
||||
(404 missing, 409 already completed, 400 validation).
|
||||
"""
|
||||
async with conn.transaction():
|
||||
# Row lock serialises concurrent imports AND a concurrent DELETE of
|
||||
# the same CSR; works across multiple uvicorn workers (DB-level lock).
|
||||
row = await conn.fetchrow(
|
||||
"SELECT * FROM ssl_csrs WHERE id = $1 FOR UPDATE", csr_id
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="CSR not found")
|
||||
if row['status'] == 'completed':
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
f"CSR '{row['name']}' is already completed — certificate "
|
||||
f"id {row['ssl_certificate_id']} was imported from it. "
|
||||
"Create a new CSR to reissue."
|
||||
),
|
||||
)
|
||||
if not row['private_key_pem']:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=(
|
||||
"Stored CSR private key is missing — the CSR row is "
|
||||
"corrupt. Delete it and create a new CSR."
|
||||
),
|
||||
)
|
||||
# Issue #53: the column holds a Fernet token from v1.10.1 on, and a raw PEM for rows
|
||||
# written before it. decrypt_csr_private_key accepts both, so no data migration is
|
||||
# needed. A None here means the token cannot be decrypted — SECRET_KEY was rotated
|
||||
# without CSR_ENCRYPTION_KEY set. Fail loudly: the key is gone, so the CA's certificate
|
||||
# can never be paired with it, and silently falling through would surface as the far
|
||||
# more confusing "certificate does not match this CSR's private key".
|
||||
stored_key = decrypt_csr_private_key(row['private_key_pem'])
|
||||
if not stored_key:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=(
|
||||
f"The stored private key for CSR '{row['name']}' cannot be decrypted. This "
|
||||
"happens when SECRET_KEY was rotated while CSR_ENCRYPTION_KEY was not set. "
|
||||
"The key is unrecoverable, so this CSR can no longer be completed — delete "
|
||||
"it and create a new one (then have the new CSR signed)."
|
||||
),
|
||||
)
|
||||
|
||||
effective_name = getattr(imp, 'name', None) or row['name']
|
||||
|
||||
# Parse the pasted certificate FIRST so a malformed/truncated CA
|
||||
# response gets the manual flow's 400, not a 500 from the key-match
|
||||
# step below (verify_certificate_key_match reports an unparseable
|
||||
# cert as match=None, which we treat as an integrity failure).
|
||||
from utils.ssl_parser import parse_ssl_certificate, verify_certificate_key_match
|
||||
precheck = parse_ssl_certificate(imp.certificate_content)
|
||||
if precheck.get('error'):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid SSL certificate: {precheck['error']}",
|
||||
)
|
||||
|
||||
# THE defining check of this feature: the CA response must match the
|
||||
# key we generated. Deliberately stricter than create_cert_row's
|
||||
# lenient fallback — we generated this key ourselves, so an
|
||||
# unverifiable pair is an integrity failure, not operator input.
|
||||
match_result = verify_certificate_key_match(imp.certificate_content, stored_key)
|
||||
if match_result.get('match') is False:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"The signed certificate does not match this CSR's private "
|
||||
"key — the CA response likely belongs to a different "
|
||||
"CSR/key. Verify you pasted the certificate that was "
|
||||
"issued for this exact CSR."
|
||||
),
|
||||
)
|
||||
if match_result.get('match') is not True:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=(
|
||||
"Could not verify the certificate/key pair: "
|
||||
f"{match_result.get('reason', 'unknown')}"
|
||||
),
|
||||
)
|
||||
|
||||
# Full parse/validation pipeline shared with the manual + wizard
|
||||
# flows: invalid PEM, bad chain and already-expired certs all 400.
|
||||
payload = SimpleNamespace(
|
||||
name=effective_name,
|
||||
certificate_content=imp.certificate_content,
|
||||
private_key_content=stored_key,
|
||||
chain_content=getattr(imp, 'chain_content', None),
|
||||
usage_type=getattr(imp, 'usage_type', 'frontend') or 'frontend',
|
||||
)
|
||||
fields = ssl_service._prepare_cert_fields(payload)
|
||||
|
||||
# Global name uniqueness (ssl_certificates.cluster_id is always NULL
|
||||
# under the R38 schema, so name is effectively a global namespace).
|
||||
existing = await conn.fetchrow(
|
||||
"SELECT id, is_active FROM ssl_certificates WHERE name = $1 LIMIT 1",
|
||||
effective_name,
|
||||
)
|
||||
if existing and existing['is_active']:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"An active SSL certificate named '{effective_name}' "
|
||||
"already exists (created after this CSR). Delete or "
|
||||
"rename it, or pass a different `name` in the import "
|
||||
"request — the CSR stays pending and can be re-imported."
|
||||
),
|
||||
)
|
||||
|
||||
reactivated = False
|
||||
if existing and not existing['is_active']:
|
||||
# Reactivate the soft-deleted row (mirrors create_cert_row):
|
||||
# preserves the row id so historical references keep working.
|
||||
await conn.execute(
|
||||
"DELETE FROM ssl_certificate_clusters WHERE ssl_certificate_id = $1",
|
||||
existing['id'],
|
||||
)
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE ssl_certificates
|
||||
SET is_active = TRUE,
|
||||
last_config_status = 'PENDING',
|
||||
certificate_content = $2,
|
||||
private_key_content = $3,
|
||||
chain_content = $4,
|
||||
primary_domain = $5,
|
||||
all_domains = $6::jsonb,
|
||||
expiry_date = $7,
|
||||
usage_type = $8,
|
||||
issuer = $9,
|
||||
fingerprint = $10,
|
||||
status = $11,
|
||||
days_until_expiry = $12,
|
||||
source = 'csr',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
""",
|
||||
existing['id'],
|
||||
fields['cert_content'],
|
||||
fields['private_key_content'],
|
||||
fields['chain_content'],
|
||||
fields['primary_domain'],
|
||||
json.dumps(fields['all_domains']),
|
||||
fields['expiry_date'],
|
||||
fields['usage_type'],
|
||||
fields['issuer'],
|
||||
fields['fingerprint'],
|
||||
fields['status'],
|
||||
fields['days_until_expiry'],
|
||||
)
|
||||
cert_id = existing['id']
|
||||
reactivated = True
|
||||
logger.info(
|
||||
f"csr_service.import_signed_certificate: reactivated "
|
||||
f"soft-deleted cert '{effective_name}' (id={cert_id}) for CSR {csr_id}"
|
||||
)
|
||||
else:
|
||||
cert_id = await conn.fetchval(
|
||||
"""
|
||||
INSERT INTO ssl_certificates (
|
||||
name, primary_domain, certificate_content, private_key_content,
|
||||
chain_content, expiry_date, issuer, fingerprint, status,
|
||||
days_until_expiry, all_domains, is_active, cluster_id,
|
||||
last_config_status, usage_type, source
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb,
|
||||
TRUE, NULL, 'PENDING', $12, 'csr'
|
||||
)
|
||||
RETURNING id
|
||||
""",
|
||||
effective_name,
|
||||
fields['primary_domain'],
|
||||
fields['cert_content'],
|
||||
fields['private_key_content'],
|
||||
fields['chain_content'],
|
||||
fields['expiry_date'],
|
||||
fields['issuer'],
|
||||
fields['fingerprint'],
|
||||
fields['status'],
|
||||
fields['days_until_expiry'],
|
||||
json.dumps(fields['all_domains']),
|
||||
fields['usage_type'],
|
||||
)
|
||||
|
||||
# Cluster bindings: global = zero junction rows (existing convention).
|
||||
if not getattr(imp, 'is_global', False):
|
||||
for cluster_id in (getattr(imp, 'cluster_ids', None) or []):
|
||||
await ssl_service.ensure_cluster_junction(conn, cert_id, cluster_id)
|
||||
|
||||
# Complete the CSR and destroy the key copy — the key now lives on
|
||||
# the certificate row only, like every other key in the system.
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE ssl_csrs
|
||||
SET status = 'completed',
|
||||
ssl_certificate_id = $2,
|
||||
private_key_pem = NULL,
|
||||
completed_at = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
""",
|
||||
csr_id,
|
||||
cert_id,
|
||||
)
|
||||
|
||||
warnings = diff_domains(_maybe_json_list(row['sans']), fields['all_domains'])
|
||||
if reactivated:
|
||||
warnings.append(
|
||||
f"A soft-deleted certificate named '{effective_name}' was "
|
||||
f"reactivated (row id {cert_id}) — existing entities that still "
|
||||
"reference that id now serve the newly imported certificate."
|
||||
)
|
||||
|
||||
return {
|
||||
'certificate_id': cert_id,
|
||||
'certificate_name': effective_name,
|
||||
'primary_domain': fields['primary_domain'],
|
||||
'warnings': warnings,
|
||||
'reactivated': reactivated,
|
||||
}
|
||||
@@ -5,8 +5,8 @@ A small adapter layer so DNS-01 challenges can publish/clean up the
|
||||
additive at the RRset level (add/remove a single value by name+content, never
|
||||
overwrite-by-name) so multiple coexisting values at one name (wildcard + apex) work.
|
||||
|
||||
MVP providers: manual (user publishes the TXT themselves) and Cloudflare. New providers
|
||||
plug in via the registry without touching the orchestration.
|
||||
Providers: manual (user publishes the TXT themselves), Cloudflare, and GoDaddy (v1.10.0). New
|
||||
providers plug in via the registry without touching the orchestration.
|
||||
"""
|
||||
from .base import DnsProvider, DnsProviderError
|
||||
from .registry import get_provider, list_providers, is_supported
|
||||
|
||||
@@ -0,0 +1,512 @@
|
||||
"""GoDaddy DNS provider for ACME DNS-01 (Issue #35 follow-up, v1.10.0).
|
||||
|
||||
Uses the GoDaddy Domains API v1 over aiohttp (no new dependency). The base URL is a hardcoded
|
||||
constant and redirects are not followed (no user-controlled URL — only the already-validated
|
||||
domain name selects which zone is touched), which is the same reason cloudflare.py is exempt from
|
||||
utils/ssrf_guard.py. Every failure is wrapped in DnsProviderError with a SANITIZED message: the
|
||||
API Key and Secret are scrubbed out of any text that could reach a log, an order event, or
|
||||
letsencrypt_orders.error_detail.
|
||||
|
||||
Two GoDaddy-specific hazards drive the shape of this module — neither exists on Cloudflare:
|
||||
|
||||
1. NO PER-VALUE WRITE. `PUT /v1/domains/{d}/records/TXT/{name}` REPLACES the entire RRset at that
|
||||
type+name; it does not merge. A certificate for `example.com` + `*.example.com` publishes two
|
||||
DIFFERENT TXT values at the SAME name `_acme-challenge.example.com` (base.py's additive
|
||||
contract), so a naive single-value PUT would silently destroy the sibling and fail the wildcard
|
||||
authorization. Every mutation here is therefore read-modify-write: GET the current RRset, merge,
|
||||
PUT the whole list back. An EMPTY array is rejected (422 INVALID_BODY, "Records must be
|
||||
specified"), so removing the LAST value must use DELETE — never `PUT []`.
|
||||
|
||||
2. ZONE-DESTRUCTIVE SIBLING PATHS. `PUT /v1/domains/{d}/records/TXT` (three segments, no name)
|
||||
wipes EVERY TXT in the zone — SPF, DKIM, DMARC, Microsoft/Google verification — and
|
||||
`PUT /v1/domains/{d}/records` wipes the whole zone (this is dehydrated issue #430 verbatim).
|
||||
The record path is built only by _rrset_path(), which refuses an empty zone or relative name so
|
||||
a URL can never collapse onto one of those endpoints.
|
||||
|
||||
Concurrency: v1 has no ETag, no If-Match and no per-record id, so read-modify-write can lose an
|
||||
update if two mutations at one name overlap. Today they cannot: orders are advanced sequentially
|
||||
(`for oid in claimed_ids: await advance_dns01_order(oid)` in main.py) and an order's challenges are
|
||||
published sequentially (`for ch in challenges: await provider.add_txt_record(...)` in
|
||||
dns01_orchestrator.py), so the apex+wildcard pair is strictly ordered and the second publish sees
|
||||
the first. _rrset_lock() makes that safety structural rather than incidental. Across REPLICAS the
|
||||
window is real but narrow (two orders publishing at the same record name in overlapping cycles) and
|
||||
self-healing: a lost publish ends `invalid` and the bounded retry chain mints a fresh order, a lost
|
||||
cleanup is retried by the reconcile sweep, and an orphaned `_acme-challenge` TXT is inert. The real
|
||||
fix is the v3 API (POST + DELETE by recordId, natively per-value), which is PAT-only and a
|
||||
follow-up; it is deliberately not used here because v1 + sso-key is what operators can use today.
|
||||
|
||||
Credentials: an API Key + Secret pair from https://developer.godaddy.com/keys. It must be a
|
||||
PRODUCTION key — the first key the dashboard issues is an OTE (test) key and an OTE credential
|
||||
against api.godaddy.com returns 401. A Personal Access Token also works: paste it as the API Key
|
||||
and leave the Secret blank, and the Authorization header becomes `Bearer <token>`. That path is not
|
||||
cosmetic — GoDaddy marks sso-key "deprecated, supported through 2026" and the current v1 OpenAPI
|
||||
advertises only bearer auth, so the PAT is the migration target, not an alternative.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import quote
|
||||
|
||||
import aiohttp
|
||||
|
||||
from .base import DnsProvider, DnsProviderError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
GODADDY_API_BASE = "https://api.godaddy.com/v1"
|
||||
_TIMEOUT = aiohttp.ClientTimeout(total=20)
|
||||
|
||||
# GoDaddy enforces a 600s (10 min) TTL floor at request time. The published v1 OpenAPI declares no
|
||||
# minimum, so a smaller value is not caught by the schema — it fails with
|
||||
# 422 {"code":"INVALID_BODY","fields":[{"message":"must have a minimum value of 600", ...}]}.
|
||||
# Pin the floor; DNS-01 has no reason to want anything longer.
|
||||
_TXT_TTL = 600
|
||||
|
||||
# Read-modify-write serialization, keyed by the RRset (record name), not the zone — the RRset is the
|
||||
# actual unit of contention, and keying on it avoids serializing unrelated subdomains of one zone.
|
||||
# The orchestrator is sequential today (see the module docstring), so this is defence in depth: it
|
||||
# is what stops a future `asyncio.gather()` over the publish loop from silently breaking every
|
||||
# wildcard+apex certificate. Bounded in practice by the certificate inventory of one process, so
|
||||
# there is no eviction; the entries are empty Lock objects.
|
||||
_RRSET_LOCKS: Dict[str, asyncio.Lock] = {}
|
||||
|
||||
|
||||
def _rrset_lock(record_name: str) -> asyncio.Lock:
|
||||
key = (record_name or "").rstrip(".").lower()
|
||||
lock = _RRSET_LOCKS.get(key)
|
||||
if lock is None:
|
||||
# Safe without a guard: a single event loop never preempts between the get and the assign.
|
||||
lock = _RRSET_LOCKS[key] = asyncio.Lock()
|
||||
return lock
|
||||
|
||||
|
||||
def _scrub(text: str, *secrets: str) -> str:
|
||||
"""Remove credential substrings from a message before it can reach a log or an order event.
|
||||
|
||||
GoDaddy error bodies do not echo the Authorization header, so this is belt-and-braces — but it
|
||||
makes base.py's "never leak a secret" invariant structural instead of a matter of care. Short
|
||||
strings are skipped so a 1-2 char credential fragment cannot blank out ordinary prose.
|
||||
"""
|
||||
out = text or ""
|
||||
for secret in secrets:
|
||||
if secret and len(secret) >= 4:
|
||||
out = out.replace(secret, "***")
|
||||
return out[:300]
|
||||
|
||||
|
||||
def _relative_name(fqdn: str, zone: str) -> str:
|
||||
"""Convert an absolute record name to the zone-relative form GoDaddy's API requires.
|
||||
|
||||
GoDaddy record names are RELATIVE to the zone with NO trailing dot, and the zone apex is the
|
||||
literal "@" — never an empty string (which would collapse the URL onto the zone-wide TXT
|
||||
endpoint) and never the domain name itself.
|
||||
|
||||
("_acme-challenge.example.com", "example.com") -> "_acme-challenge"
|
||||
("_acme-challenge.foo.bar.example.com", "example.com") -> "_acme-challenge.foo.bar"
|
||||
("example.com", "example.com") -> "@"
|
||||
"""
|
||||
f = (fqdn or "").rstrip(".").lower()
|
||||
z = (zone or "").rstrip(".").lower()
|
||||
if z and f == z:
|
||||
return "@"
|
||||
if z and f.endswith("." + z):
|
||||
return f[: -(len(z) + 1)]
|
||||
# Defensive: callers always pass a zone that _resolve_domain derived from this very name.
|
||||
return f or "@"
|
||||
|
||||
|
||||
def _rrset_path(zone: str, rel_name: str) -> str:
|
||||
"""Build the 4-segment record path `/domains/{zone}/records/TXT/{name}`.
|
||||
|
||||
SAFETY GATE: an empty rel_name would collapse the URL to `/domains/{zone}/records/TXT` — the
|
||||
endpoint that replaces EVERY TXT record in the zone (SPF, DKIM, DMARC, domain verifications).
|
||||
A "." or ".." segment does the same thing one step later: `quote()` leaves both untouched
|
||||
(they are unreserved) and yarl normalizes dot segments away when it builds the URL, so
|
||||
".../records/TXT/.." would resolve to ".../records" — the whole-zone endpoint. Refuse both
|
||||
rather than build them. `safe=''` percent-encodes the apex "@" as "%40" (accepted bare too,
|
||||
but safer through proxies); "_", "-" and "." are unreserved and pass through unchanged, so a
|
||||
multi-label relative name stays one readable path segment.
|
||||
"""
|
||||
if not zone or not rel_name:
|
||||
raise DnsProviderError("Internal error: refusing to build a zone-wide GoDaddy TXT record path.")
|
||||
if rel_name.strip(".") == "" or any(part in (".", "..") for part in rel_name.split("/")):
|
||||
raise DnsProviderError("Internal error: refusing to build a GoDaddy TXT path from a dot segment.")
|
||||
return f"/domains/{quote(zone, safe='')}/records/TXT/{quote(rel_name, safe='')}"
|
||||
|
||||
|
||||
def _live_values(records: List[Dict]) -> List[str]:
|
||||
"""The non-empty `data` values in an RRset read.
|
||||
|
||||
GoDaddy leaves tombstone rows with `"data": ""` behind at a name after some removals. Echoing
|
||||
one back in a PUT body is rejected with 422 INVALID_BODY, so every field implementation
|
||||
(lego, acme.sh, Posh-ACME) filters them independently — so do we.
|
||||
"""
|
||||
out: List[str] = []
|
||||
for rec in records or []:
|
||||
data = (rec or {}).get("data") or ""
|
||||
if data:
|
||||
out.append(data)
|
||||
return out
|
||||
|
||||
|
||||
def _merge_add(existing: List[Dict], value: str) -> Optional[List[Dict]]:
|
||||
"""PUT body that adds `value` while preserving every coexisting sibling value.
|
||||
|
||||
Returns None when `value` is already present — an idempotent no-op, which is where an ACME
|
||||
retry cycle lands.
|
||||
"""
|
||||
live = _live_values(existing)
|
||||
if value in live:
|
||||
return None
|
||||
return [{"data": d, "ttl": _TXT_TTL} for d in live] + [{"data": value, "ttl": _TXT_TTL}]
|
||||
|
||||
|
||||
def _merge_remove(existing: List[Dict], value: str) -> Optional[List[Dict]]:
|
||||
"""PUT body that removes ONLY `value`, keeping every sibling.
|
||||
|
||||
Three-state result, because GoDaddy needs three different calls:
|
||||
None -> `value` is not there; already gone, tolerate (base.py's remove contract).
|
||||
[] -> it was the last value; the caller must DELETE, since `PUT []` is rejected.
|
||||
list -> PUT this body.
|
||||
"""
|
||||
live = _live_values(existing)
|
||||
if value not in live:
|
||||
return None
|
||||
return [{"data": d, "ttl": _TXT_TTL} for d in live if d != value]
|
||||
|
||||
|
||||
def _require_rrset(body: Any) -> List[Dict]:
|
||||
"""The RRset read, or a refusal.
|
||||
|
||||
FAIL CLOSED. A read that did not come back as a JSON array must never be treated as "the RRset
|
||||
is empty" — the very next call is a full-RRset PUT, so coercing an unreadable read to [] would
|
||||
replace every coexisting sibling value with just ours. Failing instead is free: the orchestrator
|
||||
reverts the publish flag and retries next cycle, while a destructive PUT is unrecoverable.
|
||||
"""
|
||||
if not isinstance(body, list):
|
||||
raise DnsProviderError(
|
||||
"GoDaddy returned an unreadable TXT record list; refusing to replace the record set."
|
||||
)
|
||||
return body
|
||||
|
||||
|
||||
def _error_fields(body: Any) -> Tuple[str, str]:
|
||||
"""The whitelisted (code, message) pair from a GoDaddy error body.
|
||||
|
||||
Only these two string fields are ever read; the raw body is never interpolated into a
|
||||
user-facing message.
|
||||
"""
|
||||
if not isinstance(body, dict):
|
||||
return "", ""
|
||||
code = body.get("code")
|
||||
message = body.get("message")
|
||||
return (code if isinstance(code, str) else ""), (message if isinstance(message, str) else "")
|
||||
|
||||
|
||||
def _retry_after_seconds(headers, body: Any) -> int:
|
||||
"""Seconds to wait after a 429.
|
||||
|
||||
The current platform sends `Retry-After` and `ratelimit-reset` headers with no body, while the
|
||||
legacy v1 OpenAPI documents an `ErrorLimit` body carrying `retryAfterSec`. All three shapes are
|
||||
live in the wild — and so is none of them, hence the 60s default.
|
||||
"""
|
||||
for key in ("Retry-After", "ratelimit-reset"):
|
||||
raw = (headers or {}).get(key)
|
||||
if raw:
|
||||
try:
|
||||
return max(1, int(str(raw).strip()))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if isinstance(body, dict):
|
||||
raw = body.get("retryAfterSec")
|
||||
if isinstance(raw, int) and raw > 0:
|
||||
return raw
|
||||
return 60
|
||||
|
||||
|
||||
class _GoDaddyHTTPError(DnsProviderError):
|
||||
"""A DnsProviderError that also carries the HTTP status and GoDaddy `code`.
|
||||
|
||||
Callers INSIDE this module branch on the status (tolerate a 404 read-back, fall through a
|
||||
zone probe), while everything outside — dns01_orchestrator, letsencrypt.py — still sees a
|
||||
plain sanitized DnsProviderError and needs no change.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, status: int, code: str = ""):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
self.code = code
|
||||
|
||||
|
||||
class GoDaddyDNSProvider(DnsProvider):
|
||||
name = "godaddy"
|
||||
label = "GoDaddy"
|
||||
automated = True
|
||||
credential_fields: List[Dict] = [
|
||||
{
|
||||
"key": "api_key",
|
||||
"label": "API Key",
|
||||
"type": "password",
|
||||
"required": True,
|
||||
"max_length": 200,
|
||||
"help": ("Production API Key from developer.godaddy.com/keys — the first key the dashboard "
|
||||
"issues is an OTE (test) key and will be rejected. A Personal Access Token also "
|
||||
"works: paste it here and leave the Secret blank."),
|
||||
},
|
||||
{
|
||||
"key": "api_secret",
|
||||
"label": "API Secret",
|
||||
"type": "password",
|
||||
"required": False,
|
||||
"max_length": 200,
|
||||
"help": ("The Secret half of the same API Key pair. Leave blank ONLY if the field above "
|
||||
"holds a Personal Access Token. The account also needs at least one registered "
|
||||
"domain for GoDaddy to allow DNS API access at all."),
|
||||
},
|
||||
]
|
||||
|
||||
def __init__(self, credentials: Dict[str, str] | None = None):
|
||||
super().__init__(credentials)
|
||||
# Normalize, never validate: dns01_orchestrator.py calls get_provider() OUTSIDE any
|
||||
# DnsProviderError guard, so a constructor that raised on malformed credentials would escape
|
||||
# as an unhandled exception in the 60s background cycle. The UI drops blank fields before
|
||||
# submitting, so a left-blank field arrives as a MISSING key rather than "" — `.get() or ""`
|
||||
# covers both.
|
||||
self._api_key = (self.credentials.get("api_key") or "").strip()
|
||||
self._api_secret = (self.credentials.get("api_secret") or "").strip()
|
||||
# Per-INSTANCE zone cache. A module-level cache would leak one ACME account's zone visibility
|
||||
# into another's; an instance lives for exactly one orchestrator step, which is precisely the
|
||||
# scope where caching pays off (apex + wildcard resolve the same zone from the same name).
|
||||
self._zone_cache: Dict[str, str] = {}
|
||||
|
||||
def _auth_header(self) -> str:
|
||||
"""`sso-key <key>:<secret>` when a Secret is present, else `Bearer <token>` for a PAT.
|
||||
|
||||
Literal prefix, one space, a single colon — no base64, no URL-encoding, no quotes. Keeping
|
||||
this as one swappable string is what makes GoDaddy's sso-key sunset a credential change
|
||||
rather than a code change.
|
||||
"""
|
||||
if self._api_secret:
|
||||
return f"sso-key {self._api_key}:{self._api_secret}"
|
||||
return f"Bearer {self._api_key}"
|
||||
|
||||
def _headers(self) -> Dict[str, str]:
|
||||
# Accept is not optional: these endpoints content-negotiate application/xml and
|
||||
# text/javascript. Content-Type is required on every write or GoDaddy answers 400/415.
|
||||
return {
|
||||
"Authorization": self._auth_header(),
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
def _http_error(self, status: int, code: str, message: str, retry_after: Optional[int]) -> _GoDaddyHTTPError:
|
||||
"""Map an HTTP status to a sanitized, operator-actionable DnsProviderError.
|
||||
|
||||
These strings land in acme_order_events and letsencrypt_orders.error_detail and are shown
|
||||
in the order timeline, so each one names what to fix. GoDaddy's own `code`/`message` is
|
||||
appended when present because the two 403 causes — account not eligible for the DNS API vs.
|
||||
a PAT missing `domains.dns:update` — are indistinguishable by status alone. Scrubbing
|
||||
happens HERE, at the single point where provider-supplied text enters a message, so a new
|
||||
caller cannot forget it.
|
||||
"""
|
||||
code = _scrub(code, self._api_key, self._api_secret)
|
||||
message = _scrub(message, self._api_key, self._api_secret)
|
||||
if status == 401:
|
||||
detail = ("GoDaddy rejected the API credentials. Check they are a PRODUCTION Key/Secret pair "
|
||||
"from developer.godaddy.com/keys — the first key the dashboard issues is an OTE "
|
||||
"(test) key and is not valid here.")
|
||||
elif status == 403:
|
||||
detail = ("GoDaddy denied access to the DNS API. The account needs at least one registered "
|
||||
"domain, and a Personal Access Token needs the domains.domain:read and "
|
||||
"domains.dns:update scopes.")
|
||||
elif status == 404:
|
||||
detail = ("GoDaddy has no zone for this domain (check it is registered in this account and "
|
||||
"uses GoDaddy nameservers).")
|
||||
elif status == 409:
|
||||
detail = "GoDaddy reports this domain is not eligible to have its DNS records changed."
|
||||
elif status == 422:
|
||||
detail = "GoDaddy rejected the record change as invalid (HTTP 422)."
|
||||
elif status == 429:
|
||||
detail = f"GoDaddy rate limit reached; retry in ~{retry_after or 60}s."
|
||||
else:
|
||||
detail = f"GoDaddy API error (HTTP {status})."
|
||||
if code or message:
|
||||
detail += f" (GoDaddy: {code}{': ' + message if message else ''})"
|
||||
return _GoDaddyHTTPError(detail, status=status, code=code)
|
||||
|
||||
async def _request(self, session: aiohttp.ClientSession, method: str, path: str, **kwargs) -> Any:
|
||||
"""One GoDaddy API call. Returns the parsed JSON body, or None for the empty-bodied writes.
|
||||
|
||||
Raises a SANITIZED _GoDaddyHTTPError / DnsProviderError — never the credentials, never the
|
||||
request, never a response body verbatim.
|
||||
"""
|
||||
url = f"{GODADDY_API_BASE}{path}"
|
||||
try:
|
||||
async with session.request(
|
||||
method, url, headers=self._headers(), allow_redirects=False, **kwargs
|
||||
) as resp:
|
||||
try:
|
||||
# content_type=None: every GoDaddy write answers 200/204 with an EMPTY body, and
|
||||
# aiohttp would otherwise raise on the missing/other content type before parsing.
|
||||
body = await resp.json(content_type=None)
|
||||
except ValueError:
|
||||
# ONLY a decode failure (JSONDecodeError subclasses ValueError) is swallowed —
|
||||
# an empty write body, or an HTML error page on a >=400. A transport failure
|
||||
# mid-read (ClientPayloadError, TimeoutError) must NOT land here: it would look
|
||||
# identical to "empty body", and a caller that reads an RRset would then see
|
||||
# None and could mistake it for an empty RRset. Those propagate to the handlers
|
||||
# below and become a real DnsProviderError.
|
||||
body = None
|
||||
# 2xx only. Redirects are deliberately not followed (aiohttp would forward the
|
||||
# Authorization header), so a 3xx is a failed call — treating `< 400` as success
|
||||
# would report a redirected write as a silent no-op.
|
||||
if 200 <= resp.status < 300:
|
||||
return body
|
||||
code, message = _error_fields(body)
|
||||
retry_after = _retry_after_seconds(resp.headers, body) if resp.status == 429 else None
|
||||
raise self._http_error(resp.status, code, message, retry_after)
|
||||
except DnsProviderError:
|
||||
raise
|
||||
except aiohttp.ClientError as exc:
|
||||
# Only the exception TYPE is interpolated: an aiohttp client error's str() can carry the
|
||||
# request URL, and the message is persisted to the order timeline.
|
||||
raise DnsProviderError(f"Could not reach the GoDaddy API ({type(exc).__name__}).")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise DnsProviderError(f"Unexpected GoDaddy API failure ({type(exc).__name__}).")
|
||||
|
||||
async def verify_credentials(self) -> Dict:
|
||||
if not self._api_key:
|
||||
return {"ok": False, "detail": "No GoDaddy API Key provided."}
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=_TIMEOUT) as session:
|
||||
# Cheapest read-only check: one request, no zone needed. Deliberately NOT
|
||||
# GET /v1/domains/{domain} — GoDaddy has rejected that details call for small
|
||||
# accounts since 2024-05 while record-level calls keep working, so verifying with it
|
||||
# produces false negatives on accounts where DNS-01 would succeed.
|
||||
body = await self._request(session, "GET", "/domains?limit=1")
|
||||
if not isinstance(body, list):
|
||||
return {"ok": False, "detail": "GoDaddy returned an unexpected response to the credential check."}
|
||||
if not body:
|
||||
# An empty list is NOT a failure: sub-zones delegated to GoDaddy nameservers are
|
||||
# manageable via the records API but never appear in the domain listing.
|
||||
return {"ok": True, "detail": ("GoDaddy credentials valid, but no domains are visible in this "
|
||||
"account — the domain you validate must be registered here, or "
|
||||
"be a zone delegated to GoDaddy nameservers.")}
|
||||
return {"ok": True, "detail": "GoDaddy credentials valid."}
|
||||
except DnsProviderError as exc:
|
||||
detail = str(exc)
|
||||
if not self._api_secret:
|
||||
# The Bearer path is silent otherwise, and a half-filled form is the likeliest cause.
|
||||
detail += (" Note: no API Secret was entered, so the API Key was sent as a Personal Access "
|
||||
"Token (Bearer). If you have a Key + Secret pair, enter both halves.")
|
||||
return {"ok": False, "detail": detail}
|
||||
except Exception: # noqa: BLE001 — never leak an internal/transport error verbatim
|
||||
return {"ok": False, "detail": "Could not verify the GoDaddy credentials."}
|
||||
|
||||
async def _resolve_domain(self, session: aiohttp.ClientSession, record_name: str) -> str:
|
||||
"""Find the most-specific (longest-suffix) GoDaddy-managed zone for an absolute record name.
|
||||
|
||||
GoDaddy has no `/zones?name=` equivalent, so this walks suffixes longest-to-shortest and
|
||||
probes `GET /v1/domains/{candidate}/records/NS`. That probe (rather than the domain listing
|
||||
or the domain-details call) is deliberate: it finds sub-zones delegated to GoDaddy
|
||||
nameservers, which never appear in `GET /v1/domains` at all, and it does not depend on the
|
||||
details endpoint that small accounts are rejected from.
|
||||
"""
|
||||
cached = self._zone_cache.get(record_name)
|
||||
if cached:
|
||||
return cached
|
||||
labels = record_name.rstrip(".").lower().split(".")
|
||||
for i in range(len(labels) - 1):
|
||||
candidate = ".".join(labels[i:])
|
||||
if candidate.count(".") < 1:
|
||||
break # a zone needs at least two labels
|
||||
try:
|
||||
body = await self._request(
|
||||
session, "GET", f"/domains/{quote(candidate, safe='')}/records/NS"
|
||||
)
|
||||
except _GoDaddyHTTPError as exc:
|
||||
if exc.status in (404, 422):
|
||||
continue # not a zone in this account — keep walking
|
||||
# 401/403/409/429/5xx are credential, eligibility or platform failures, not
|
||||
# "wrong zone". Continuing would burn the rate-limit budget re-failing on every
|
||||
# remaining suffix and would bury the real cause under "no managed domain".
|
||||
raise
|
||||
if isinstance(body, list) and body:
|
||||
self._zone_cache[record_name] = candidate
|
||||
return candidate
|
||||
raise DnsProviderError(f"No managed GoDaddy domain found for {record_name}.")
|
||||
|
||||
async def add_txt_record(self, name: str, value: str) -> None:
|
||||
async with _rrset_lock(name):
|
||||
async with aiohttp.ClientSession(timeout=_TIMEOUT) as session:
|
||||
zone = await self._resolve_domain(session, name)
|
||||
path = _rrset_path(zone, _relative_name(name, zone))
|
||||
try:
|
||||
existing = await self._request(session, "GET", path)
|
||||
except _GoDaddyHTTPError as exc:
|
||||
if exc.status != 404:
|
||||
raise
|
||||
# Some accounts 404 reading back a record set in a zone whose WRITES succeed
|
||||
# (acme.sh #6517). Reachable only when the NS probe resolved the zone but the
|
||||
# TXT read 404s — if the NS probe itself 404s we never get here and the caller
|
||||
# sees "No managed GoDaddy domain found", which is the honest answer. We cannot
|
||||
# merge what we cannot read, and a single-value PUT would destroy any coexisting
|
||||
# sibling, so PATCH is the only correct recovery: it is the one genuinely
|
||||
# ADDITIVE primitive in v1 ("Appends DNS records ... Existing records with the
|
||||
# same type and name are preserved"). It cannot dedupe, but a duplicate
|
||||
# identical TXT is harmless for validation and cleanup removes the whole RRset.
|
||||
await self._request(
|
||||
session, "PATCH", f"/domains/{quote(zone, safe='')}/records",
|
||||
json=[{"type": "TXT", "name": _relative_name(name, zone),
|
||||
"data": value, "ttl": _TXT_TTL}],
|
||||
)
|
||||
return
|
||||
body = _merge_add(_require_rrset(existing), value)
|
||||
if body is None:
|
||||
return # already published — idempotent, this is where ACME retries land
|
||||
await self._request(session, "PUT", path, json=body)
|
||||
|
||||
async def remove_txt_record(self, name: str, value: str) -> None:
|
||||
async with _rrset_lock(name):
|
||||
async with aiohttp.ClientSession(timeout=_TIMEOUT) as session:
|
||||
try:
|
||||
zone = await self._resolve_domain(session, name)
|
||||
except _GoDaddyHTTPError as exc:
|
||||
# Raise only what a later sweep could plausibly succeed at. reconcile_dns01_cleanup
|
||||
# swallows the error and leaves dns_record_cleaned FALSE, so the row is re-selected
|
||||
# every cycle — and its query takes a bare LIMIT 50, so rows that can NEVER succeed
|
||||
# (revoked key, account lost DNS-API eligibility) would monopolise the whole
|
||||
# cleanup budget and starve every other account. For those terminal statuses we
|
||||
# give up quietly: the orphaned `_acme-challenge` TXT is inert, and the same
|
||||
# credential failure is already loud on the publish path, where it is actionable.
|
||||
if exc.status == 429 or exc.status >= 500:
|
||||
raise
|
||||
return
|
||||
except DnsProviderError:
|
||||
return # zone genuinely not resolvable — nothing we could clean up
|
||||
path = _rrset_path(zone, _relative_name(name, zone))
|
||||
try:
|
||||
existing = await self._request(session, "GET", path)
|
||||
except _GoDaddyHTTPError as exc:
|
||||
if exc.status == 404:
|
||||
return # RRset (or the read) is gone — tolerate
|
||||
raise
|
||||
body = _merge_remove(_require_rrset(existing), value)
|
||||
if body is None:
|
||||
return # our value is not there — already gone, tolerate
|
||||
if not body:
|
||||
# The LAST value at this name. `PUT []` is rejected (422 INVALID_BODY, "Records
|
||||
# must be specified"), so emptying an RRset REQUIRES DELETE. This removes only
|
||||
# TXT at this exact name; other names and other record types are preserved.
|
||||
# Do NOT fall back to the "write an empty string to delete" folklore — that hack
|
||||
# is what creates the tombstone rows _live_values has to filter.
|
||||
try:
|
||||
await self._request(session, "DELETE", path)
|
||||
except _GoDaddyHTTPError as exc:
|
||||
if exc.status == 404:
|
||||
return # raced with another cleanup — tolerate
|
||||
raise
|
||||
return
|
||||
await self._request(session, "PUT", path, json=body)
|
||||
@@ -10,11 +10,13 @@ from typing import Dict, List, Type
|
||||
|
||||
from .base import DnsProvider
|
||||
from .cloudflare import CloudflareDNSProvider
|
||||
from .godaddy import GoDaddyDNSProvider
|
||||
from .manual import ManualDNSProvider
|
||||
|
||||
_PROVIDERS: Dict[str, Type[DnsProvider]] = {
|
||||
ManualDNSProvider.name: ManualDNSProvider,
|
||||
CloudflareDNSProvider.name: CloudflareDNSProvider,
|
||||
GoDaddyDNSProvider.name: GoDaddyDNSProvider,
|
||||
}
|
||||
|
||||
|
||||
|
||||
+139
-13
@@ -40,10 +40,12 @@ flow.
|
||||
(callers translate to wizard step-jumpback toasts).
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
@@ -106,23 +108,22 @@ def _recompute_status_from_expiry(
|
||||
return cert_info_status or "valid", cert_info_days or 0
|
||||
|
||||
|
||||
async def create_cert_row(
|
||||
conn,
|
||||
payload: Any,
|
||||
cluster_id: int,
|
||||
) -> int:
|
||||
"""Insert a row into ssl_certificates (always cluster_id=NULL) + junction
|
||||
binding to the given cluster_id. Returns new ssl_certificate_id.
|
||||
def _prepare_cert_fields(payload: Any) -> dict:
|
||||
"""Parse + validate the PEM material on `payload` and derive every
|
||||
ssl_certificates column value from it (v1.9.0 extraction — shared by
|
||||
`create_cert_row` and the CSR import flow in services/csr_service.py,
|
||||
byte-identical to the former inline body of `create_cert_row`).
|
||||
|
||||
payload is expected to expose:
|
||||
name, certificate_content, private_key_content, chain_content,
|
||||
usage_type (optional, default 'frontend').
|
||||
|
||||
All cert metadata (primary_domain, all_domains, expiry_date,
|
||||
issuer, fingerprint, status, days_until_expiry) is now parsed
|
||||
FROM the PEM content via `parse_ssl_certificate` — operator-
|
||||
supplied values on the payload are accepted as a graceful
|
||||
fallback only when parsing fails (which itself raises 400).
|
||||
Raises HTTPException(400) on any parse/validation failure (invalid PEM,
|
||||
bad private key, cert/key mismatch, bad chain, already-expired cert).
|
||||
|
||||
Returns a dict with keys: cert_content, private_key_content,
|
||||
chain_content, cert_info, primary_domain, all_domains, expiry_date,
|
||||
issuer, fingerprint, status, days_until_expiry, usage_type.
|
||||
"""
|
||||
cert_content = getattr(payload, "certificate_content", None) or ""
|
||||
if not cert_content.strip():
|
||||
@@ -213,6 +214,53 @@ async def create_cert_row(
|
||||
)
|
||||
usage_type = getattr(payload, "usage_type", "frontend") or "frontend"
|
||||
|
||||
return {
|
||||
"cert_content": cert_content,
|
||||
"private_key_content": private_key_content,
|
||||
"chain_content": chain_content,
|
||||
"cert_info": cert_info,
|
||||
"primary_domain": primary_domain,
|
||||
"all_domains": all_domains,
|
||||
"expiry_date": expiry_date,
|
||||
"issuer": issuer,
|
||||
"fingerprint": fingerprint,
|
||||
"status": status,
|
||||
"days_until_expiry": days_until_expiry,
|
||||
"usage_type": usage_type,
|
||||
}
|
||||
|
||||
|
||||
async def create_cert_row(
|
||||
conn,
|
||||
payload: Any,
|
||||
cluster_id: int,
|
||||
) -> int:
|
||||
"""Insert a row into ssl_certificates (always cluster_id=NULL) + junction
|
||||
binding to the given cluster_id. Returns new ssl_certificate_id.
|
||||
|
||||
payload is expected to expose:
|
||||
name, certificate_content, private_key_content, chain_content,
|
||||
usage_type (optional, default 'frontend').
|
||||
|
||||
All cert metadata (primary_domain, all_domains, expiry_date,
|
||||
issuer, fingerprint, status, days_until_expiry) is now parsed
|
||||
FROM the PEM content via `parse_ssl_certificate` — operator-
|
||||
supplied values on the payload are accepted as a graceful
|
||||
fallback only when parsing fails (which itself raises 400).
|
||||
"""
|
||||
fields = _prepare_cert_fields(payload)
|
||||
cert_content = fields["cert_content"]
|
||||
private_key_content = fields["private_key_content"]
|
||||
chain_content = fields["chain_content"]
|
||||
expiry_date = fields["expiry_date"]
|
||||
primary_domain = fields["primary_domain"]
|
||||
all_domains = fields["all_domains"]
|
||||
issuer = fields["issuer"]
|
||||
fingerprint = fields["fingerprint"]
|
||||
status = fields["status"]
|
||||
days_until_expiry = fields["days_until_expiry"]
|
||||
usage_type = fields["usage_type"]
|
||||
|
||||
existing = await conn.fetchrow(
|
||||
"""
|
||||
SELECT s.id, s.is_active
|
||||
@@ -408,3 +456,81 @@ async def validate_server_ca_bundle_eligibility(
|
||||
cluster_id,
|
||||
)
|
||||
return row is not None
|
||||
|
||||
|
||||
async def stage_ssl_config_versions(
|
||||
conn,
|
||||
cert_id: int,
|
||||
cluster_ids: List[int],
|
||||
action: str = "create",
|
||||
created_by: Optional[int] = None,
|
||||
) -> List[dict]:
|
||||
"""Stage one PENDING config version per affected cluster after an SSL
|
||||
certificate mutation (v1.9.0 — distilled from the routers/ssl.py POST
|
||||
/certificates staging loop; used by the CSR import flow).
|
||||
|
||||
Uses the EXACT `ssl-{cert_id}-{action}-{timestamp}` version-name scheme of
|
||||
the manual SSL flow so Apply Management, the `has_pending_config`
|
||||
LIKE-filter ('ssl-' || id || '-%'), and the agent delivery predicates
|
||||
treat CSR-imported certificates identically to manually uploaded ones.
|
||||
Agents are NOT notified here — the operator applies manually.
|
||||
|
||||
Per-cluster failures are caught and reported in the returned
|
||||
sync_results list (the DB save has already succeeded — same semantics as
|
||||
the manual flow, where a config-generation failure never rolls back the
|
||||
certificate row).
|
||||
"""
|
||||
# Local import: keeps services/haproxy_config free to import ssl helpers
|
||||
# without a module-level cycle.
|
||||
from services.haproxy_config import generate_haproxy_config_for_cluster
|
||||
|
||||
sync_results: List[dict] = []
|
||||
for cluster_id in cluster_ids:
|
||||
try:
|
||||
config_content = await generate_haproxy_config_for_cluster(cluster_id)
|
||||
config_hash = hashlib.sha256(config_content.encode()).hexdigest()
|
||||
version_name = f"ssl-{cert_id}-{action}-{int(time.time())}"
|
||||
|
||||
version_created_by = created_by
|
||||
if version_created_by is None:
|
||||
version_created_by = await conn.fetchval(
|
||||
"SELECT id FROM users WHERE username = 'admin' LIMIT 1"
|
||||
) or 1
|
||||
|
||||
await conn.fetchval(
|
||||
"""
|
||||
INSERT INTO config_versions
|
||||
(cluster_id, version_name, config_content, checksum, created_by, is_active, status)
|
||||
VALUES ($1, $2, $3, $4, $5, FALSE, 'PENDING')
|
||||
RETURNING id
|
||||
""",
|
||||
cluster_id,
|
||||
version_name,
|
||||
config_content,
|
||||
config_hash,
|
||||
version_created_by,
|
||||
)
|
||||
logger.info(
|
||||
f"APPLY WORKFLOW: Created PENDING config version {version_name} "
|
||||
f"for cluster {cluster_id} (ssl_service.stage_ssl_config_versions)"
|
||||
)
|
||||
sync_results.append({
|
||||
'node': 'pending',
|
||||
'success': True,
|
||||
'cluster_id': cluster_id,
|
||||
'version': version_name,
|
||||
'status': 'PENDING',
|
||||
'message': 'SSL certificate staged. Click Apply to activate.',
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Cluster config staging failed for SSL certificate {cert_id} "
|
||||
f"on cluster {cluster_id}: {e}"
|
||||
)
|
||||
sync_results.append({
|
||||
'node': 'cluster',
|
||||
'success': False,
|
||||
'cluster_id': cluster_id,
|
||||
'error': str(e),
|
||||
})
|
||||
return sync_results
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
"""
|
||||
v1.9.0 CSR creation — unit tests for the signed-certificate import flow and
|
||||
config-version staging (pattern: test_ssl_service_extraction.py, AsyncMock conn).
|
||||
|
||||
Pins the security-relevant invariants:
|
||||
- key match is a HARD gate: match=False → 400 before any INSERT, and
|
||||
match=None (unverifiable) → 500, never a lenient pass (we generated the
|
||||
key ourselves — deliberate divergence from create_cert_row's fallback).
|
||||
- the new cert row is cluster_id=NULL / last_config_status='PENDING' /
|
||||
source='csr' (PENDING keeps it invisible to agents until Apply).
|
||||
- completing the CSR NULLs the private key copy.
|
||||
- staging reuses the exact `ssl-{id}-create-{ts}` version-name scheme.
|
||||
"""
|
||||
import json
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from models.csr import SSLCSRImport
|
||||
from services.csr_service import (
|
||||
assert_csr_name_available,
|
||||
import_signed_certificate,
|
||||
insert_csr_row,
|
||||
)
|
||||
from services.ssl_service import stage_ssl_config_versions
|
||||
|
||||
|
||||
_VALID_PARSE = {
|
||||
"primary_domain": "www.example.com",
|
||||
"all_domains": ["www.example.com"],
|
||||
"expiry_date": datetime(2099, 1, 1, tzinfo=timezone.utc),
|
||||
"issuer": "CN=Test CA",
|
||||
"fingerprint": "AA:BB:CC",
|
||||
"status": "valid",
|
||||
"days_until_expiry": 365,
|
||||
}
|
||||
|
||||
_FAKE_CERT = "-----BEGIN CERTIFICATE-----\nX\n-----END CERTIFICATE-----"
|
||||
_FAKE_KEY = "-----BEGIN PRIVATE KEY-----\nY\n-----END PRIVATE KEY-----"
|
||||
|
||||
|
||||
def _csr_row(**overrides):
|
||||
row = {
|
||||
"id": 5,
|
||||
"name": "csr-www",
|
||||
"common_name": "www.example.com",
|
||||
"subject": "{}",
|
||||
"sans": json.dumps(["www.example.com"]),
|
||||
"key_algorithm": "rsa-2048",
|
||||
"csr_pem": "-----BEGIN CERTIFICATE REQUEST-----\nZ\n-----END CERTIFICATE REQUEST-----",
|
||||
"private_key_pem": _FAKE_KEY,
|
||||
"status": "pending",
|
||||
"ssl_certificate_id": None,
|
||||
}
|
||||
row.update(overrides)
|
||||
return row
|
||||
|
||||
|
||||
def _mk_conn():
|
||||
conn = AsyncMock()
|
||||
# asyncpg's conn.transaction() is a SYNC call returning an async CM.
|
||||
conn.transaction = MagicMock()
|
||||
return conn
|
||||
|
||||
|
||||
def _import_payload(**overrides):
|
||||
base = dict(
|
||||
certificate_content=_FAKE_CERT,
|
||||
chain_content=None,
|
||||
usage_type="frontend",
|
||||
is_global=False,
|
||||
cluster_ids=[1, 2],
|
||||
name=None,
|
||||
)
|
||||
base.update(overrides)
|
||||
return SSLCSRImport(**base)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _patched(match=None, parse=None):
|
||||
"""Patch every parser touchpoint of the import path: the function-local
|
||||
imports in csr_service (utils.ssl_parser.*) and the module-level imports
|
||||
in ssl_service._prepare_cert_fields (services.ssl_service.*)."""
|
||||
match_result = match if match is not None else {"match": True}
|
||||
parse_result = dict(parse or _VALID_PARSE)
|
||||
with patch("utils.ssl_parser.verify_certificate_key_match", return_value=match_result), \
|
||||
patch("utils.ssl_parser.parse_ssl_certificate", return_value=dict(parse_result)), \
|
||||
patch("services.ssl_service.parse_ssl_certificate", return_value=dict(parse_result)), \
|
||||
patch("services.ssl_service.validate_private_key", return_value=True), \
|
||||
patch("services.ssl_service.validate_certificate_chain", return_value=True):
|
||||
yield
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# import_signed_certificate
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_happy_path_inserts_pending_csr_sourced_cert():
|
||||
conn = _mk_conn()
|
||||
conn.fetchrow.side_effect = [_csr_row(), None] # FOR UPDATE row, no name clash
|
||||
conn.fetchval.return_value = 42 # INSERT ... RETURNING id
|
||||
|
||||
with _patched():
|
||||
result = await import_signed_certificate(conn, 5, _import_payload(), user_id=7)
|
||||
|
||||
assert result["certificate_id"] == 42
|
||||
assert result["reactivated"] is False
|
||||
|
||||
# Concurrency invariants: everything runs inside a transaction and the
|
||||
# CSR row is locked FOR UPDATE (serialises double-import and delete-races).
|
||||
assert conn.transaction.call_count == 1
|
||||
lock_sql = conn.fetchrow.call_args_list[0].args[0]
|
||||
assert "FOR UPDATE" in lock_sql
|
||||
|
||||
insert_sql, *insert_args = conn.fetchval.call_args.args
|
||||
assert "INSERT INTO ssl_certificates" in insert_sql
|
||||
assert "NULL, 'PENDING'" in insert_sql, "cert must stay invisible to agents until Apply"
|
||||
assert "'csr'" in insert_sql, "source column must record the CSR origin"
|
||||
# The stored CSR key — not any request-supplied key — must be persisted.
|
||||
assert _FAKE_KEY in insert_args
|
||||
|
||||
# One junction row per requested cluster.
|
||||
junction_calls = [
|
||||
c for c in conn.execute.call_args_list
|
||||
if c.args and "ssl_certificate_clusters" in c.args[0] and "INSERT" in c.args[0]
|
||||
]
|
||||
assert len(junction_calls) == 2
|
||||
assert {c.args[2] for c in junction_calls} == {1, 2}
|
||||
|
||||
# CSR completion must destroy the key copy.
|
||||
completion_calls = [
|
||||
c for c in conn.execute.call_args_list
|
||||
if c.args and "UPDATE ssl_csrs" in c.args[0]
|
||||
]
|
||||
assert len(completion_calls) == 1
|
||||
assert "private_key_pem = NULL" in completion_calls[0].args[0]
|
||||
assert "status = 'completed'" in completion_calls[0].args[0]
|
||||
assert completion_calls[0].args[1] == 5 # csr_id
|
||||
assert completion_calls[0].args[2] == 42 # cert_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_global_creates_zero_junction_rows():
|
||||
conn = _mk_conn()
|
||||
conn.fetchrow.side_effect = [_csr_row(), None]
|
||||
conn.fetchval.return_value = 42
|
||||
|
||||
with _patched():
|
||||
await import_signed_certificate(
|
||||
conn, 5, _import_payload(is_global=True, cluster_ids=None), user_id=7
|
||||
)
|
||||
|
||||
junction_calls = [
|
||||
c for c in conn.execute.call_args_list
|
||||
if c.args and "ssl_certificate_clusters" in c.args[0] and "INSERT" in c.args[0]
|
||||
]
|
||||
assert junction_calls == [], "global cert = zero junction rows (existing convention)"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_key_mismatch_rejected_400_before_any_write():
|
||||
conn = _mk_conn()
|
||||
conn.fetchrow.side_effect = [_csr_row()]
|
||||
|
||||
with _patched(match={"match": False, "reason": "public key mismatch"}):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await import_signed_certificate(conn, 5, _import_payload(), user_id=7)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "does not match" in exc_info.value.detail
|
||||
assert not conn.fetchval.await_count, "nothing must be inserted on mismatch"
|
||||
assert not conn.execute.await_count
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_unverifiable_key_match_is_hard_error_not_lenient():
|
||||
"""match=None means OUR stored key is unreadable — integrity failure,
|
||||
never the lenient pass create_cert_row historically allows."""
|
||||
conn = _mk_conn()
|
||||
conn.fetchrow.side_effect = [_csr_row()]
|
||||
|
||||
with _patched(match={"match": None, "reason": "key could not be parsed"}):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await import_signed_certificate(conn, 5, _import_payload(), user_id=7)
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert not conn.fetchval.await_count
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_expired_certificate_rejected_400():
|
||||
conn = _mk_conn()
|
||||
conn.fetchrow.side_effect = [_csr_row()]
|
||||
|
||||
expired = dict(_VALID_PARSE)
|
||||
expired["status"] = "expired"
|
||||
expired["days_until_expiry"] = -10
|
||||
with _patched(parse=expired):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await import_signed_certificate(conn, 5, _import_payload(), user_id=7)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "expired" in exc_info.value.detail.lower()
|
||||
assert not conn.fetchval.await_count
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_malformed_certificate_rejected_400_not_500():
|
||||
"""A cert with PEM markers but unparseable content (truncated CA response)
|
||||
is OPERATOR INPUT — it must get the manual flow's 400, not the 500 that
|
||||
the strict key-match branch reserves for a corrupt STORED key."""
|
||||
conn = _mk_conn()
|
||||
conn.fetchrow.side_effect = [_csr_row()]
|
||||
|
||||
with _patched(parse={"error": "Could not parse certificate"}):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await import_signed_certificate(conn, 5, _import_payload(), user_id=7)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "Invalid SSL certificate" in exc_info.value.detail
|
||||
assert not conn.fetchval.await_count
|
||||
assert not conn.execute.await_count
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_completed_csr_conflicts_409():
|
||||
conn = _mk_conn()
|
||||
conn.fetchrow.side_effect = [_csr_row(status="completed", ssl_certificate_id=42)]
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await import_signed_certificate(conn, 5, _import_payload(), user_id=7)
|
||||
|
||||
assert exc_info.value.status_code == 409
|
||||
assert "already completed" in exc_info.value.detail
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_missing_csr_404():
|
||||
conn = _mk_conn()
|
||||
conn.fetchrow.side_effect = [None]
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await import_signed_certificate(conn, 999, _import_payload(), user_id=7)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_active_name_collision_rejected_with_hint():
|
||||
conn = _mk_conn()
|
||||
conn.fetchrow.side_effect = [_csr_row(), {"id": 9, "is_active": True}]
|
||||
|
||||
with _patched():
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await import_signed_certificate(conn, 5, _import_payload(), user_id=7)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "already exists" in exc_info.value.detail
|
||||
assert "name" in exc_info.value.detail # points at the override escape hatch
|
||||
assert not conn.fetchval.await_count
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_name_override_is_used_for_the_cert_row():
|
||||
conn = _mk_conn()
|
||||
conn.fetchrow.side_effect = [_csr_row(), None]
|
||||
conn.fetchval.return_value = 42
|
||||
|
||||
with _patched():
|
||||
result = await import_signed_certificate(
|
||||
conn, 5, _import_payload(name="renamed-cert"), user_id=7
|
||||
)
|
||||
|
||||
assert result["certificate_name"] == "renamed-cert"
|
||||
_, *insert_args = conn.fetchval.call_args.args
|
||||
assert "renamed-cert" in insert_args
|
||||
# And the collision check must have run against the override, not csr.name.
|
||||
name_lookup = conn.fetchrow.call_args_list[1]
|
||||
assert name_lookup.args[1] == "renamed-cert"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_reactivates_soft_deleted_name_and_warns():
|
||||
conn = _mk_conn()
|
||||
conn.fetchrow.side_effect = [_csr_row(), {"id": 77, "is_active": False}]
|
||||
|
||||
with _patched():
|
||||
result = await import_signed_certificate(conn, 5, _import_payload(), user_id=7)
|
||||
|
||||
assert result["certificate_id"] == 77
|
||||
assert result["reactivated"] is True
|
||||
assert any("reactivated" in w for w in result["warnings"])
|
||||
assert not conn.fetchval.await_count, "reactivation must UPDATE, not INSERT"
|
||||
update_calls = [
|
||||
c for c in conn.execute.call_args_list
|
||||
if c.args and "UPDATE ssl_certificates" in c.args[0]
|
||||
]
|
||||
assert len(update_calls) == 1
|
||||
update_sql = update_calls[0].args[0]
|
||||
assert "source = 'csr'" in update_sql
|
||||
# The reactivated row must come back to life invisible to agents until
|
||||
# Apply, with the row itself active again.
|
||||
assert "last_config_status = 'PENDING'" in update_sql
|
||||
assert "is_active = TRUE" in update_sql
|
||||
# Old cluster bindings must be wiped before re-binding to the new scope.
|
||||
junction_deletes = [
|
||||
c for c in conn.execute.call_args_list
|
||||
if c.args and "DELETE FROM ssl_certificate_clusters" in c.args[0]
|
||||
]
|
||||
assert len(junction_deletes) == 1
|
||||
assert junction_deletes[0].args[1] == 77
|
||||
# …and the importer's requested clusters re-bound via the junction.
|
||||
junction_inserts = [
|
||||
c for c in conn.execute.call_args_list
|
||||
if c.args and "INSERT INTO ssl_certificate_clusters" in c.args[0]
|
||||
]
|
||||
assert {c.args[2] for c in junction_inserts} == {1, 2}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_san_drift_warns_but_succeeds():
|
||||
conn = _mk_conn()
|
||||
conn.fetchrow.side_effect = [
|
||||
_csr_row(sans=json.dumps(["www.example.com", "api.example.com"])),
|
||||
None,
|
||||
]
|
||||
conn.fetchval.return_value = 42
|
||||
|
||||
drifted = dict(_VALID_PARSE)
|
||||
drifted["all_domains"] = ["www.example.com", "cdn.example.com"]
|
||||
with _patched(parse=drifted):
|
||||
result = await import_signed_certificate(conn, 5, _import_payload(), user_id=7)
|
||||
|
||||
assert result["certificate_id"] == 42
|
||||
assert any("added" in w and "cdn.example.com" in w for w in result["warnings"])
|
||||
assert any("dropped" in w and "api.example.com" in w for w in result["warnings"])
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# insert_csr_row / assert_csr_name_available
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_csr_name_taken_by_active_cert_rejected():
|
||||
conn = _mk_conn()
|
||||
conn.fetchval.side_effect = [11] # active cert with the name exists
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await assert_csr_name_available(conn, "taken")
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "certificate" in exc_info.value.detail.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_csr_name_taken_by_pending_csr_rejected():
|
||||
conn = _mk_conn()
|
||||
conn.fetchval.side_effect = [None, 12] # no cert, but a pending CSR
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await assert_csr_name_available(conn, "taken")
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "pending CSR" in exc_info.value.detail
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_insert_csr_row_translates_unique_violation_to_400():
|
||||
"""The uq_ssl_csrs_name_pending partial index closes the create/create
|
||||
race — the loser must get a clean 400, not a 500."""
|
||||
import asyncpg as _asyncpg
|
||||
|
||||
conn = _mk_conn()
|
||||
# availability checks pass, INSERT hits the unique index
|
||||
conn.fetchval.side_effect = [
|
||||
None, None, _asyncpg.exceptions.UniqueViolationError("dup"),
|
||||
]
|
||||
payload = SimpleNamespace(
|
||||
name="raced", common_name="www.example.com", key_algorithm="rsa-2048"
|
||||
)
|
||||
bundle = {"subject": {}, "sans": ["www.example.com"], "csr_pem": "PEM", "private_key_pem": "KEY"}
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await insert_csr_row(conn, payload, bundle, user_id=1)
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "concurrent" in exc_info.value.detail
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# router-level guards
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cluster_id_int32_guard_rejects_out_of_range_with_404():
|
||||
"""Body-supplied cluster ids must never reach asyncpg out of int4 range
|
||||
(DataError → raw 500) — same Bulgu #96 hygiene as the csr_id path param."""
|
||||
from routers.csr import _assert_valid_cluster_id
|
||||
|
||||
_assert_valid_cluster_id(1)
|
||||
_assert_valid_cluster_id(2_147_483_647)
|
||||
for bad in (0, -1, 2_147_483_648, 99_999_999_999):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_assert_valid_cluster_id(bad)
|
||||
assert exc_info.value.status_code == 404
|
||||
assert "Cluster not found" in exc_info.value.detail
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# stage_ssl_config_versions
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stage_creates_one_pending_version_per_cluster_with_ssl_naming():
|
||||
import re
|
||||
|
||||
conn = _mk_conn()
|
||||
conn.fetchval.return_value = 1001 # config_versions INSERT RETURNING id
|
||||
|
||||
with patch(
|
||||
"services.haproxy_config.generate_haproxy_config_for_cluster",
|
||||
new=AsyncMock(return_value="# cfg"),
|
||||
):
|
||||
results = await stage_ssl_config_versions(conn, 42, [1, 2], created_by=7)
|
||||
|
||||
assert len(results) == 2
|
||||
assert all(r["success"] for r in results)
|
||||
assert [r["cluster_id"] for r in results] == [1, 2]
|
||||
|
||||
insert_calls = [
|
||||
c for c in conn.fetchval.call_args_list
|
||||
if c.args and "INSERT INTO config_versions" in c.args[0]
|
||||
]
|
||||
assert len(insert_calls) == 2
|
||||
for call in insert_calls:
|
||||
sql = call.args[0]
|
||||
assert "FALSE, 'PENDING'" in sql, "staged versions must be inactive + PENDING"
|
||||
version_name = call.args[2]
|
||||
# EXACT manual-flow scheme: Apply Management + has_pending_config
|
||||
# LIKE-filters key off 'ssl-{id}-...'.
|
||||
assert re.match(r"^ssl-42-create-\d+$", version_name), version_name
|
||||
assert call.args[5] == 7 # created_by honours the importing user
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stage_reports_per_cluster_failure_without_raising():
|
||||
conn = _mk_conn()
|
||||
conn.fetchval.return_value = 1001
|
||||
|
||||
async def _gen(cluster_id):
|
||||
if cluster_id == 2:
|
||||
raise RuntimeError("config generation exploded")
|
||||
return "# cfg"
|
||||
|
||||
with patch(
|
||||
"services.haproxy_config.generate_haproxy_config_for_cluster",
|
||||
new=AsyncMock(side_effect=_gen),
|
||||
):
|
||||
results = await stage_ssl_config_versions(conn, 42, [1, 2], created_by=7)
|
||||
|
||||
assert len(results) == 2
|
||||
assert results[0]["success"] is True
|
||||
assert results[1]["success"] is False
|
||||
assert "exploded" in results[1]["error"]
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Issue #53 (v1.10.1) — at-rest encryption for the pending CSR private key.
|
||||
|
||||
Pure logic: no DB, no network. Covers the round-trip, the backward-compatible read of rows
|
||||
written before this release, the unrecoverable-key path after a key rotation, and a static
|
||||
assertion that the write path can no longer store a raw PEM.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-csr-encryption-unit-tests")
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from utils.csr_key_crypto import (
|
||||
decrypt_csr_private_key,
|
||||
encrypt_csr_private_key,
|
||||
is_encrypted,
|
||||
reset_fernet_for_tests,
|
||||
)
|
||||
|
||||
_SAMPLE_PEM = (
|
||||
"-----BEGIN PRIVATE KEY-----\n"
|
||||
"MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7VJTUt9Us8cKj\n"
|
||||
"-----END PRIVATE KEY-----\n"
|
||||
)
|
||||
|
||||
|
||||
def test_roundtrip_and_ciphertext_does_not_contain_the_key():
|
||||
reset_fernet_for_tests()
|
||||
token = encrypt_csr_private_key(_SAMPLE_PEM)
|
||||
# The stored form must not be the PEM, and must not leak any recognisable fragment of it.
|
||||
assert token != _SAMPLE_PEM
|
||||
assert "-----BEGIN" not in token
|
||||
assert "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7VJTUt9Us8cKj" not in token
|
||||
assert decrypt_csr_private_key(token) == _SAMPLE_PEM
|
||||
|
||||
|
||||
def test_is_encrypted_discriminates_token_from_legacy_pem():
|
||||
reset_fernet_for_tests()
|
||||
assert is_encrypted(encrypt_csr_private_key(_SAMPLE_PEM)) is True
|
||||
assert is_encrypted(_SAMPLE_PEM) is False
|
||||
assert is_encrypted("") is False
|
||||
assert is_encrypted(None) is False
|
||||
|
||||
|
||||
def test_legacy_plaintext_row_is_read_unchanged():
|
||||
# Rows written before v1.10.1 hold a raw PEM. They must keep working with NO data migration,
|
||||
# otherwise upgrading would strand every CSR that is out for signature.
|
||||
reset_fernet_for_tests()
|
||||
assert decrypt_csr_private_key(_SAMPLE_PEM) == _SAMPLE_PEM
|
||||
|
||||
|
||||
def test_empty_or_missing_value_returns_none():
|
||||
reset_fernet_for_tests()
|
||||
assert decrypt_csr_private_key(None) is None
|
||||
assert decrypt_csr_private_key("") is None
|
||||
|
||||
|
||||
def test_key_rotation_makes_the_stored_key_unrecoverable_rather_than_wrong():
|
||||
"""After a rotation the caller must get None, never a silently wrong key."""
|
||||
reset_fernet_for_tests()
|
||||
token = encrypt_csr_private_key(_SAMPLE_PEM)
|
||||
|
||||
# Rotate: an explicit, different CSR_ENCRYPTION_KEY takes precedence over the derived one.
|
||||
previous = os.environ.get("CSR_ENCRYPTION_KEY")
|
||||
os.environ["CSR_ENCRYPTION_KEY"] = Fernet.generate_key().decode()
|
||||
try:
|
||||
reset_fernet_for_tests()
|
||||
assert decrypt_csr_private_key(token) is None
|
||||
finally:
|
||||
if previous is None:
|
||||
os.environ.pop("CSR_ENCRYPTION_KEY", None)
|
||||
else:
|
||||
os.environ["CSR_ENCRYPTION_KEY"] = previous
|
||||
reset_fernet_for_tests()
|
||||
|
||||
|
||||
def test_explicit_env_key_is_used_and_survives_reset():
|
||||
previous = os.environ.get("CSR_ENCRYPTION_KEY")
|
||||
key = Fernet.generate_key().decode()
|
||||
os.environ["CSR_ENCRYPTION_KEY"] = key
|
||||
try:
|
||||
reset_fernet_for_tests()
|
||||
token = encrypt_csr_private_key(_SAMPLE_PEM)
|
||||
# Decryptable with the same explicit key from a fresh instance...
|
||||
reset_fernet_for_tests()
|
||||
assert decrypt_csr_private_key(token) == _SAMPLE_PEM
|
||||
# ...and independently verifiable with the raw Fernet key.
|
||||
assert Fernet(key.encode()).decrypt(token.encode()).decode() == _SAMPLE_PEM
|
||||
finally:
|
||||
if previous is None:
|
||||
os.environ.pop("CSR_ENCRYPTION_KEY", None)
|
||||
else:
|
||||
os.environ["CSR_ENCRYPTION_KEY"] = previous
|
||||
reset_fernet_for_tests()
|
||||
|
||||
|
||||
def test_derivation_uses_its_own_hkdf_info_string():
|
||||
"""Each secret class derives an independent key, so rotating one never affects another."""
|
||||
src = (Path(__file__).resolve().parent.parent / "utils" / "csr_key_crypto.py").read_text()
|
||||
assert b"csr-private-key-v1".decode() in src
|
||||
# Must NOT reuse another class's info string.
|
||||
for foreign in ("dns-provider-creds-v1", "vip-vrrp-secret-v1", "mfa-totp-secret-v1"):
|
||||
assert foreign not in src, f"CSR key derivation must not reuse the {foreign} info string"
|
||||
|
||||
|
||||
def test_write_path_stores_the_encrypted_form_not_the_pem():
|
||||
"""Static pin: insert_csr_row must encrypt before the INSERT.
|
||||
|
||||
A future refactor that passed bundle['private_key_pem'] straight through would silently
|
||||
reintroduce plaintext storage, and no unit test with a mocked connection would notice.
|
||||
"""
|
||||
src = (Path(__file__).resolve().parent.parent / "services" / "csr_service.py").read_text()
|
||||
insert_fn = src[src.index("async def insert_csr_row("):]
|
||||
insert_fn = insert_fn[: insert_fn.index("\nasync def ")]
|
||||
assert "encrypt_csr_private_key(bundle['private_key_pem'])" in insert_fn
|
||||
# The raw PEM must not be a bind parameter of the INSERT itself.
|
||||
assert not re.search(r"^\s*bundle\['private_key_pem'\],\s*$", insert_fn, re.M)
|
||||
|
||||
|
||||
def test_import_path_decrypts_and_fails_closed_on_unrecoverable_key():
|
||||
src = (Path(__file__).resolve().parent.parent / "services" / "csr_service.py").read_text()
|
||||
fn = src[src.index("async def import_signed_certificate("):]
|
||||
assert "decrypt_csr_private_key(row['private_key_pem'])" in fn
|
||||
# A None decrypt must raise rather than fall through to the key-match comparison.
|
||||
assert "cannot be decrypted" in fn
|
||||
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
v1.9.0 CSR creation — static source assertions (pattern: test_vip_purge.py).
|
||||
|
||||
Guards the migration wiring that a unit test cannot exercise without a real
|
||||
database: the SCHEMA_VERSION bump (without it, deployed installs skip the
|
||||
whole migration run and the ssl_csrs table never appears), the migration
|
||||
registration, the security-relevant DDL, and the router registration.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
|
||||
_BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def _read(rel_path: str) -> str:
|
||||
with open(os.path.join(_BACKEND_DIR, rel_path), encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def test_schema_version_bumped_to_10():
|
||||
src = _read(os.path.join("database", "migrations.py"))
|
||||
m = re.search(r"^SCHEMA_VERSION\s*=\s*(\d+)", src, re.MULTILINE)
|
||||
assert m, "SCHEMA_VERSION constant not found in migrations.py"
|
||||
assert int(m.group(1)) >= 10, (
|
||||
"SCHEMA_VERSION must be >= 10 for the v1.9.0 ssl_csrs table — "
|
||||
"without the bump, existing installs (version >= 9) skip the whole "
|
||||
"migration run and never gain the table."
|
||||
)
|
||||
|
||||
|
||||
def test_ssl_csrs_migration_defined_and_registered():
|
||||
src = _read(os.path.join("database", "migrations.py"))
|
||||
assert "async def ensure_ssl_csrs_table" in src
|
||||
|
||||
inner = src.split("async def _run_all_migrations_inner", 1)[1]
|
||||
inner = inner.split("\nasync def ", 1)[0] # body of the runner only
|
||||
assert "await ensure_ssl_csrs_table()" in inner, (
|
||||
"ensure_ssl_csrs_table must be invoked from _run_all_migrations_inner"
|
||||
)
|
||||
|
||||
|
||||
def test_ssl_csrs_ddl_essentials():
|
||||
src = _read(os.path.join("database", "migrations.py"))
|
||||
ddl_start = src.index("CREATE TABLE IF NOT EXISTS ssl_csrs")
|
||||
ddl = src[ddl_start:ddl_start + 2500]
|
||||
|
||||
assert "private_key_pem TEXT" in ddl
|
||||
assert "name VARCHAR(100) NOT NULL" in ddl, (
|
||||
"ssl_csrs.name must align with ssl_certificates.name VARCHAR(100)"
|
||||
)
|
||||
assert "ssl_certificate_id INTEGER REFERENCES ssl_certificates(id) ON DELETE SET NULL" in ddl, (
|
||||
"deleting the imported cert must not cascade into CSR history"
|
||||
)
|
||||
# Partial unique index: only PENDING CSRs reserve their target cert name.
|
||||
assert "uq_ssl_csrs_name_pending" in src
|
||||
assert re.search(
|
||||
r"uq_ssl_csrs_name_pending\s+ON\s+ssl_csrs\(name\)\s+WHERE\s+status\s*=\s*'pending'",
|
||||
src,
|
||||
), "name uniqueness must be scoped to pending CSRs (partial index)"
|
||||
|
||||
|
||||
def test_csr_router_registered_in_main():
|
||||
src = _read("main.py")
|
||||
assert "from routers.csr import router as csr_router" in src
|
||||
assert "app.include_router(csr_router)" in src
|
||||
|
||||
|
||||
def test_csr_endpoint_permission_mapping():
|
||||
"""Pin which ssl.<action> permission each endpoint enforces: a regression
|
||||
that dropped or weakened a _require() call would otherwise pass the
|
||||
auth-rejection tests (they only assert 401/403 for unauthenticated calls)."""
|
||||
src = _read(os.path.join("routers", "csr.py"))
|
||||
|
||||
def _handler_body(decorator):
|
||||
start = src.index(decorator)
|
||||
nxt = src.find("@router.", start + 1)
|
||||
return src[start:nxt if nxt != -1 else len(src)]
|
||||
|
||||
expectations = [
|
||||
('@router.post("")', '"create"'),
|
||||
('@router.get("")', '"read"'),
|
||||
('@router.get("/{csr_id}")', '"read"'),
|
||||
('@router.post("/{csr_id}/import")', '"create"'),
|
||||
('@router.delete("/{csr_id}")', '"delete"'),
|
||||
]
|
||||
for decorator, action in expectations:
|
||||
body = _handler_body(decorator)
|
||||
assert f"_require(authorization, {action})" in body, (
|
||||
f"endpoint {decorator} must enforce ssl.{action.strip(chr(34))}"
|
||||
)
|
||||
|
||||
|
||||
def test_csr_router_never_selects_private_key():
|
||||
"""The CSR endpoints must use the explicit column list — a bare
|
||||
`SELECT *` into an API response is how the key would leak. The one place
|
||||
SELECT * is allowed is the service-layer FOR UPDATE row (it needs the key
|
||||
to pair with the cert); the router itself must not touch the column."""
|
||||
src = _read(os.path.join("routers", "csr.py"))
|
||||
code_only = re.sub(r"#.*", "", src) # strip comments; the column name may
|
||||
# legitimately appear there as documentation
|
||||
assert "private_key_pem" not in code_only, (
|
||||
"routers/csr.py must never reference private_key_pem in code"
|
||||
)
|
||||
assert "SELECT *" not in code_only, "routers/csr.py must use explicit column lists"
|
||||
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
v1.9.0 CSR creation — Pydantic model validation tests (models/csr.py).
|
||||
|
||||
The CSR name shares the SSL certificate name's path-traversal contract
|
||||
(Bulgu #21) with one deliberate tightening: max 100 chars, matching the
|
||||
ssl_certificates.name VARCHAR(100) column.
|
||||
"""
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from models.csr import SSLCSRCreate, SSLCSRImport
|
||||
|
||||
_CERT_PEM = "-----BEGIN CERTIFICATE-----\nX\n-----END CERTIFICATE-----"
|
||||
|
||||
|
||||
def _create(**overrides):
|
||||
base = dict(name="my-csr", common_name="www.example.com")
|
||||
base.update(overrides)
|
||||
return SSLCSRCreate(**base)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# SSLCSRCreate
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_minimal_valid_create():
|
||||
m = _create()
|
||||
assert m.name == "my-csr"
|
||||
assert m.common_name == "www.example.com"
|
||||
assert m.key_algorithm == "rsa-2048"
|
||||
assert m.sans == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_name", [
|
||||
"../../etc/cron.d/evil", # path traversal
|
||||
"a..b", # embedded ..
|
||||
".hidden", # hidden filename
|
||||
"-flag", # CLI flag confusion
|
||||
"has space",
|
||||
"wild*card",
|
||||
"",
|
||||
"x" * 101, # VARCHAR(100) alignment — 200 is NOT allowed here
|
||||
])
|
||||
def test_name_rejects_unsafe_values(bad_name):
|
||||
with pytest.raises(ValidationError):
|
||||
_create(name=bad_name)
|
||||
|
||||
|
||||
def test_name_accepts_100_chars():
|
||||
assert _create(name="x" * 100).name == "x" * 100
|
||||
|
||||
|
||||
def test_common_name_wildcard_accepted_and_lowercased():
|
||||
m = _create(common_name="*.Example.COM")
|
||||
assert m.common_name == "*.example.com"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_cn", [
|
||||
"",
|
||||
"under_score.example.com", # _ is not LDH
|
||||
"*.*.example.com", # wildcard only as leftmost single label
|
||||
"-leading.example.com",
|
||||
"a" * 70 + ".example.com", # label > 63
|
||||
"cn-longer-than-64-chars-" + "x" * 45 + ".example.com", # CN > 64 total
|
||||
])
|
||||
def test_common_name_rejects_invalid(bad_cn):
|
||||
with pytest.raises(ValidationError):
|
||||
_create(common_name=bad_cn)
|
||||
|
||||
|
||||
def test_sans_normalised_deduped_and_capped():
|
||||
m = _create(sans=["API.example.com", "api.example.com", "cdn.example.com"])
|
||||
assert m.sans == ["api.example.com", "cdn.example.com"]
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
_create(sans=[f"h{i}.example.com" for i in range(101)])
|
||||
|
||||
|
||||
def test_country_normalised_or_rejected():
|
||||
assert _create(country="tr").country == "TR"
|
||||
assert _create(country=None).country is None
|
||||
for bad in ("TUR", "T", "1A"):
|
||||
with pytest.raises(ValidationError):
|
||||
_create(country=bad)
|
||||
|
||||
|
||||
def test_subject_fields_reject_control_characters():
|
||||
with pytest.raises(ValidationError):
|
||||
_create(organization="Evil\x00Corp")
|
||||
with pytest.raises(ValidationError):
|
||||
_create(locality="line\nbreak")
|
||||
|
||||
|
||||
def test_subject_fields_reject_overlength():
|
||||
with pytest.raises(ValidationError):
|
||||
_create(organization="x" * 65)
|
||||
|
||||
|
||||
def test_key_algorithm_strict_enum():
|
||||
for good in ("rsa-2048", "rsa-4096", "ecdsa-p256", "ecdsa-p384"):
|
||||
assert _create(key_algorithm=good).key_algorithm == good
|
||||
for bad in ("rsa-1024", "rsa-8192", "ed25519", "2048", ""):
|
||||
with pytest.raises(ValidationError):
|
||||
_create(key_algorithm=bad)
|
||||
|
||||
|
||||
def test_email_basic_validation():
|
||||
assert _create(email="ops@example.com").email == "ops@example.com"
|
||||
with pytest.raises(ValidationError):
|
||||
_create(email="not-an-email")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# SSLCSRImport
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_import_minimal_global():
|
||||
m = SSLCSRImport(certificate_content=_CERT_PEM, is_global=True)
|
||||
assert m.usage_type == "frontend"
|
||||
assert m.name is None
|
||||
|
||||
|
||||
def test_import_requires_clusters_when_not_global():
|
||||
with pytest.raises(ValidationError):
|
||||
SSLCSRImport(certificate_content=_CERT_PEM, is_global=False)
|
||||
with pytest.raises(ValidationError):
|
||||
SSLCSRImport(certificate_content=_CERT_PEM, is_global=False, cluster_ids=[])
|
||||
m = SSLCSRImport(certificate_content=_CERT_PEM, is_global=False, cluster_ids=[1])
|
||||
assert m.cluster_ids == [1]
|
||||
|
||||
|
||||
def test_import_certificate_must_be_pem():
|
||||
with pytest.raises(ValidationError):
|
||||
SSLCSRImport(certificate_content="not a pem", is_global=True)
|
||||
with pytest.raises(ValidationError):
|
||||
SSLCSRImport(certificate_content="", is_global=True)
|
||||
|
||||
|
||||
def test_import_certificate_size_capped():
|
||||
huge = _CERT_PEM + "A" * (64 * 1024 + 1)
|
||||
with pytest.raises(ValidationError):
|
||||
SSLCSRImport(certificate_content=huge, is_global=True)
|
||||
|
||||
|
||||
def test_import_chain_optional_but_validated():
|
||||
m = SSLCSRImport(certificate_content=_CERT_PEM, is_global=True, chain_content=" ")
|
||||
assert m.chain_content is None
|
||||
with pytest.raises(ValidationError):
|
||||
SSLCSRImport(
|
||||
certificate_content=_CERT_PEM, is_global=True, chain_content="garbage"
|
||||
)
|
||||
|
||||
|
||||
def test_import_name_override_shares_the_name_contract():
|
||||
m = SSLCSRImport(certificate_content=_CERT_PEM, is_global=True, name="renamed")
|
||||
assert m.name == "renamed"
|
||||
with pytest.raises(ValidationError):
|
||||
SSLCSRImport(certificate_content=_CERT_PEM, is_global=True, name="../evil")
|
||||
# Empty override collapses to None (falls back to the CSR's own name).
|
||||
m2 = SSLCSRImport(certificate_content=_CERT_PEM, is_global=True, name=" ")
|
||||
assert m2.name is None
|
||||
|
||||
|
||||
def test_import_usage_type_enum():
|
||||
for good in ("frontend", "server"):
|
||||
assert SSLCSRImport(
|
||||
certificate_content=_CERT_PEM, is_global=True, usage_type=good
|
||||
).usage_type == good
|
||||
with pytest.raises(ValidationError):
|
||||
SSLCSRImport(certificate_content=_CERT_PEM, is_global=True, usage_type="both")
|
||||
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
v1.9.0 CSR creation — behavioral auth tests for /api/ssl/csrs endpoints
|
||||
(pattern: test_ssl_list_endpoint_auth.py).
|
||||
|
||||
Every CSR endpoint must refuse unauthenticated / garbage-token requests.
|
||||
The CSR detail route additionally must never 200 without auth because it
|
||||
returns the CSR PEM; no endpoint ever returns the private key, but auth is
|
||||
the first line regardless.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
_VALID_CREATE_BODY = {
|
||||
"name": "auth-test-csr",
|
||||
"common_name": "www.example.com",
|
||||
}
|
||||
|
||||
_VALID_IMPORT_BODY = {
|
||||
"certificate_content": (
|
||||
"-----BEGIN CERTIFICATE-----\nX\n-----END CERTIFICATE-----"
|
||||
),
|
||||
"is_global": True,
|
||||
}
|
||||
|
||||
_ENDPOINTS = [
|
||||
("get", "/api/ssl/csrs", None),
|
||||
("get", "/api/ssl/csrs/1", None),
|
||||
("post", "/api/ssl/csrs", _VALID_CREATE_BODY),
|
||||
("post", "/api/ssl/csrs/1/import", _VALID_IMPORT_BODY),
|
||||
("delete", "/api/ssl/csrs/1", None),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method,path,body", _ENDPOINTS)
|
||||
def test_csr_endpoint_unauthenticated_rejected(client, method, path, body):
|
||||
"""No Authorization header → endpoint must refuse the request."""
|
||||
res = getattr(client, method)(path, json=body) if body is not None else getattr(client, method)(path)
|
||||
assert res.status_code in (401, 403, 422), (
|
||||
f"{method.upper()} {path} without Authorization returned "
|
||||
f"{res.status_code} — anonymous access to CSR data must not be "
|
||||
f"possible. Body: {res.text[:200]}"
|
||||
)
|
||||
if res.status_code == 200: # defensive, mirrors the R18 test style
|
||||
data = res.json()
|
||||
assert not data, "CSR endpoint returned data without auth"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method,path,body", _ENDPOINTS)
|
||||
def test_csr_endpoint_invalid_token_rejected(client, method, path, body):
|
||||
"""Garbage token → endpoint must refuse the request."""
|
||||
headers = {"Authorization": "Bearer not-a-valid-jwt"}
|
||||
if body is not None:
|
||||
res = getattr(client, method)(path, json=body, headers=headers)
|
||||
else:
|
||||
res = getattr(client, method)(path, headers=headers)
|
||||
assert res.status_code in (401, 403, 422), (
|
||||
f"{method.upper()} {path} with an invalid token returned {res.status_code}"
|
||||
)
|
||||
|
||||
|
||||
def test_csr_routes_are_registered(client):
|
||||
"""The router must actually be mounted — a 404 would make the auth tests
|
||||
above pass vacuously."""
|
||||
res = client.get("/api/ssl/csrs")
|
||||
assert res.status_code != 404, (
|
||||
"GET /api/ssl/csrs returned 404 — csr_router is not registered in main.py"
|
||||
)
|
||||
@@ -0,0 +1,171 @@
|
||||
"""
|
||||
v1.9.0 CSR creation — pure-crypto tests for services/csr_service.py.
|
||||
|
||||
No mocks: every algorithm's output must parse with `cryptography` and the
|
||||
CSR's public key must match the generated private key (the property the
|
||||
whole import flow depends on).
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec, rsa
|
||||
from cryptography.x509.oid import ExtensionOID, NameOID
|
||||
|
||||
from services.csr_service import csr_row_to_dict, diff_domains, generate_csr_bundle
|
||||
|
||||
|
||||
def _payload(**overrides):
|
||||
base = dict(
|
||||
name="test-csr",
|
||||
common_name="www.example.com",
|
||||
organization=None,
|
||||
organizational_unit=None,
|
||||
locality=None,
|
||||
state=None,
|
||||
country=None,
|
||||
email=None,
|
||||
sans=[],
|
||||
key_algorithm="rsa-2048",
|
||||
)
|
||||
base.update(overrides)
|
||||
return SimpleNamespace(**base)
|
||||
|
||||
|
||||
def _spki(key):
|
||||
return key.public_key().public_bytes(
|
||||
serialization.Encoding.DER,
|
||||
serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"algo,key_cls,key_check",
|
||||
[
|
||||
("rsa-2048", rsa.RSAPrivateKey, lambda k: k.key_size == 2048),
|
||||
("rsa-4096", rsa.RSAPrivateKey, lambda k: k.key_size == 4096),
|
||||
("ecdsa-p256", ec.EllipticCurvePrivateKey, lambda k: k.curve.name == "secp256r1"),
|
||||
("ecdsa-p384", ec.EllipticCurvePrivateKey, lambda k: k.curve.name == "secp384r1"),
|
||||
],
|
||||
)
|
||||
def test_generate_bundle_all_algorithms(algo, key_cls, key_check):
|
||||
bundle = generate_csr_bundle(_payload(key_algorithm=algo))
|
||||
|
||||
csr = x509.load_pem_x509_csr(bundle["csr_pem"].encode())
|
||||
key = serialization.load_pem_private_key(
|
||||
bundle["private_key_pem"].encode(), password=None
|
||||
)
|
||||
|
||||
assert isinstance(key, key_cls)
|
||||
assert key_check(key)
|
||||
# The CSR must be signed by exactly this key.
|
||||
csr_spki = csr.public_key().public_bytes(
|
||||
serialization.Encoding.DER,
|
||||
serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
assert csr_spki == _spki(key)
|
||||
assert csr.is_signature_valid
|
||||
# PKCS8, unencrypted — the agent concatenates cert+key into one PEM and
|
||||
# HAProxy cannot read passphrase-protected keys.
|
||||
assert bundle["private_key_pem"].startswith("-----BEGIN PRIVATE KEY-----")
|
||||
|
||||
|
||||
def test_subject_contains_all_provided_fields():
|
||||
bundle = generate_csr_bundle(_payload(
|
||||
organization="Example Corp",
|
||||
organizational_unit="IT",
|
||||
locality="Istanbul",
|
||||
state="Marmara",
|
||||
country="TR",
|
||||
email="ops@example.com",
|
||||
))
|
||||
csr = x509.load_pem_x509_csr(bundle["csr_pem"].encode())
|
||||
|
||||
def _one(oid):
|
||||
attrs = csr.subject.get_attributes_for_oid(oid)
|
||||
return attrs[0].value if attrs else None
|
||||
|
||||
assert _one(NameOID.COMMON_NAME) == "www.example.com"
|
||||
assert _one(NameOID.ORGANIZATION_NAME) == "Example Corp"
|
||||
assert _one(NameOID.ORGANIZATIONAL_UNIT_NAME) == "IT"
|
||||
assert _one(NameOID.LOCALITY_NAME) == "Istanbul"
|
||||
assert _one(NameOID.STATE_OR_PROVINCE_NAME) == "Marmara"
|
||||
assert _one(NameOID.COUNTRY_NAME) == "TR"
|
||||
assert _one(NameOID.EMAIL_ADDRESS) == "ops@example.com"
|
||||
assert bundle["subject"] == {
|
||||
"O": "Example Corp", "OU": "IT", "L": "Istanbul",
|
||||
"ST": "Marmara", "C": "TR", "emailAddress": "ops@example.com",
|
||||
}
|
||||
|
||||
|
||||
def test_subject_omits_empty_fields():
|
||||
bundle = generate_csr_bundle(_payload())
|
||||
csr = x509.load_pem_x509_csr(bundle["csr_pem"].encode())
|
||||
assert not csr.subject.get_attributes_for_oid(NameOID.ORGANIZATION_NAME)
|
||||
assert bundle["subject"] == {}
|
||||
|
||||
|
||||
def test_sans_cn_first_and_deduped():
|
||||
bundle = generate_csr_bundle(_payload(
|
||||
common_name="www.example.com",
|
||||
sans=["api.example.com", "www.example.com", "api.example.com", "cdn.example.com"],
|
||||
))
|
||||
assert bundle["sans"] == ["www.example.com", "api.example.com", "cdn.example.com"]
|
||||
|
||||
csr = x509.load_pem_x509_csr(bundle["csr_pem"].encode())
|
||||
san_ext = csr.extensions.get_extension_for_oid(
|
||||
ExtensionOID.SUBJECT_ALTERNATIVE_NAME
|
||||
)
|
||||
dns_names = san_ext.value.get_values_for_type(x509.DNSName)
|
||||
assert dns_names == ["www.example.com", "api.example.com", "cdn.example.com"]
|
||||
|
||||
|
||||
def test_wildcard_common_name_flows_into_san():
|
||||
bundle = generate_csr_bundle(_payload(common_name="*.example.com"))
|
||||
csr = x509.load_pem_x509_csr(bundle["csr_pem"].encode())
|
||||
san_ext = csr.extensions.get_extension_for_oid(
|
||||
ExtensionOID.SUBJECT_ALTERNATIVE_NAME
|
||||
)
|
||||
assert san_ext.value.get_values_for_type(x509.DNSName) == ["*.example.com"]
|
||||
|
||||
|
||||
def test_diff_domains_reports_added_and_dropped():
|
||||
warnings = diff_domains(
|
||||
["www.example.com", "api.example.com"],
|
||||
["WWW.example.com", "cdn.example.com"],
|
||||
)
|
||||
assert len(warnings) == 2
|
||||
added = next(w for w in warnings if "added" in w)
|
||||
dropped = next(w for w in warnings if "dropped" in w)
|
||||
assert "cdn.example.com" in added
|
||||
assert "api.example.com" in dropped
|
||||
# Case-insensitive: www must NOT be reported in either direction.
|
||||
assert "www.example.com" not in added
|
||||
assert "www.example.com" not in dropped
|
||||
|
||||
|
||||
def test_diff_domains_identical_sets_yield_no_warnings():
|
||||
assert diff_domains(["a.example.com"], ["A.EXAMPLE.COM"]) == []
|
||||
assert diff_domains([], []) == []
|
||||
|
||||
|
||||
def test_csr_row_to_dict_never_exposes_private_key():
|
||||
row = {
|
||||
"id": 1,
|
||||
"name": "x",
|
||||
"private_key_pem": "-----BEGIN PRIVATE KEY-----\nSECRET\n-----END PRIVATE KEY-----",
|
||||
"csr_pem": "-----BEGIN CERTIFICATE REQUEST-----\nX\n-----END CERTIFICATE REQUEST-----",
|
||||
"subject": '{"O": "Example"}',
|
||||
"sans": '["a.example.com"]',
|
||||
}
|
||||
out = csr_row_to_dict(row)
|
||||
assert "private_key_pem" not in out
|
||||
assert "csr_pem" not in out # lists exclude the PEM
|
||||
assert out["subject"] == {"O": "Example"}
|
||||
assert out["sans"] == ["a.example.com"]
|
||||
|
||||
detail = csr_row_to_dict(row, include_pem=True)
|
||||
assert "private_key_pem" not in detail # NEVER, even on detail
|
||||
assert detail["csr_pem"].startswith("-----BEGIN CERTIFICATE REQUEST-----")
|
||||
+399
-4
@@ -2,7 +2,8 @@
|
||||
|
||||
Covers the TXT-value math (RFC 8555 §8.4 — raw SHA-256 digest, base64url, NOT hex),
|
||||
the _acme-challenge record-name derivation (wildcard stripping), credential encryption
|
||||
round-trip + tamper handling, and the DNS provider registry/allow-list.
|
||||
round-trip + tamper handling, the DNS provider registry/allow-list, and (v1.10.0) the
|
||||
GoDaddy provider's zone-relative name derivation and additive RRset merge math.
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
@@ -53,9 +54,9 @@ def test_decrypt_invalid_token_returns_none():
|
||||
|
||||
def test_provider_registry_and_allow_list():
|
||||
names = {p["name"] for p in list_providers()}
|
||||
assert {"manual", "cloudflare"} <= names
|
||||
assert is_supported("manual") and is_supported("cloudflare")
|
||||
assert not is_supported("route53") # not in MVP allow-list
|
||||
assert {"manual", "cloudflare", "godaddy"} <= names
|
||||
assert is_supported("manual") and is_supported("cloudflare") and is_supported("godaddy")
|
||||
assert not is_supported("route53") # not in the allow-list
|
||||
|
||||
assert get_provider("manual").automated is False
|
||||
cf = get_provider("cloudflare", {"api_token": "x"})
|
||||
@@ -91,6 +92,400 @@ def test_cloudflare_token_sanitize():
|
||||
assert p._raw_token == '"my-token_123"'
|
||||
|
||||
|
||||
# --- v1.10.0: GoDaddy provider (pure logic only — no network, no DB) ---
|
||||
|
||||
|
||||
def test_godaddy_credential_fields_schema():
|
||||
# Re-assert DnsCredentialsUpsert's validator rules directly against the declared schema, so the
|
||||
# UI can never render a field whose submission the API would reject with a 422.
|
||||
import re
|
||||
from services.dns_providers.godaddy import GoDaddyDNSProvider
|
||||
|
||||
fields = GoDaddyDNSProvider.credential_fields
|
||||
assert [f["key"] for f in fields] == ["api_key", "api_secret"]
|
||||
for f in fields:
|
||||
assert re.match(r"^[a-zA-Z0-9_]{1,50}$", f["key"]) # DnsCredentialsUpsert key regex
|
||||
assert f["type"] == "password" # renders Input.Password, not Input
|
||||
assert isinstance(f["max_length"], int) and 0 < f["max_length"] <= 4000 # validator value cap
|
||||
assert f["help"] and isinstance(f["help"], str) # shown in the Form.Item `extra` slot
|
||||
# api_secret is optional on purpose: leaving it blank is how a Personal Access Token is used
|
||||
# (Bearer), which is the migration path off the sso-key scheme GoDaddy is retiring.
|
||||
assert fields[0]["required"] is True and fields[1]["required"] is False
|
||||
# Must not reuse Cloudflare's field name: the register modal's credential Form.Items are named
|
||||
# cred_<key> in a SHARED form and are not cleared when the provider dropdown changes.
|
||||
assert "api_token" not in {f["key"] for f in fields}
|
||||
|
||||
|
||||
def test_godaddy_provider_is_automated():
|
||||
p = get_provider("godaddy", {"api_key": "k", "api_secret": "s"})
|
||||
assert p.automated is True # else the orchestrator takes the manual-confirm branch
|
||||
assert p.name == "godaddy" and 1 <= len(p.name) <= 50 # dns_provider Field(min_length=1, max_length=50)
|
||||
assert p.label == "GoDaddy"
|
||||
|
||||
|
||||
def test_godaddy_missing_credentials_returns_not_ok():
|
||||
# verify_credentials must RETURN {"ok": False}, never raise: the router turns any non-
|
||||
# DnsProviderError into the information-free generic 422 and the user never sees the reason.
|
||||
import asyncio
|
||||
|
||||
for creds in ({}, {"api_secret": "s"}): # blank UI fields arrive as MISSING keys, not ""
|
||||
r = asyncio.run(get_provider("godaddy", creds).verify_credentials())
|
||||
assert r["ok"] is False and r["detail"]
|
||||
# Short-circuits before any request, so this touches no network.
|
||||
|
||||
|
||||
def test_godaddy_auth_header_formats_and_secret_never_leaks():
|
||||
from services.dns_providers.godaddy import GoDaddyDNSProvider, _scrub
|
||||
|
||||
sentinel = "SENTINEL-SECRET-DO-NOT-LEAK"
|
||||
p = GoDaddyDNSProvider({"api_key": "KEY123", "api_secret": sentinel})
|
||||
# Literal prefix, one space, a single colon — no base64, no quoting.
|
||||
assert p._auth_header() == f"sso-key KEY123:{sentinel}"
|
||||
# No secret -> Personal Access Token. This one branch is the whole sso-key-sunset migration.
|
||||
assert GoDaddyDNSProvider({"api_key": "PAT"})._auth_header() == "Bearer PAT"
|
||||
# _scrub removes credential substrings from anything bound for a log or an order event.
|
||||
assert sentinel not in _scrub(f"boom {sentinel} boom", "KEY123", sentinel)
|
||||
assert "KEY123" not in _scrub("boom KEY123", "KEY123", sentinel)
|
||||
assert _scrub("x" * 500, "KEY123") == "x" * 300 # bounded, so a huge body can't flood an event
|
||||
|
||||
# The channel that actually persists text: _http_error composes the message an order event and
|
||||
# letsencrypt_orders.error_detail will carry, so it must scrub its own inputs — a caller that
|
||||
# forgets to pre-scrub must not be able to leak. (Regression guard: scrubbing used to live at
|
||||
# the single call site in _request instead of here.)
|
||||
exc = p._http_error(403, f"DENIED_{sentinel}", f"token {sentinel} rejected", None)
|
||||
assert sentinel not in str(exc) and "***" in str(exc)
|
||||
|
||||
# This module must not log at all — logging is the one channel _scrub cannot reach, since the
|
||||
# arguments would be formatted by the logging framework rather than passed through it.
|
||||
import inspect
|
||||
import re as _re
|
||||
from services.dns_providers import godaddy as gd_mod
|
||||
|
||||
assert not _re.search(r"\blogger\.\w+\(", inspect.getsource(gd_mod)), \
|
||||
"godaddy.py must not log; surface everything through DnsProviderError so it is scrubbed"
|
||||
|
||||
|
||||
def test_godaddy_relative_record_name():
|
||||
# GoDaddy names are RELATIVE to the zone with no trailing dot; the apex is the literal "@".
|
||||
from services.dns_providers.godaddy import _relative_name
|
||||
|
||||
assert _relative_name("_acme-challenge.example.com", "example.com") == "_acme-challenge"
|
||||
assert _relative_name("_acme-challenge.foo.bar.example.com", "example.com") == "_acme-challenge.foo.bar"
|
||||
assert _relative_name("example.com", "example.com") == "@" # never "" — see _rrset_path
|
||||
assert _relative_name("_acme-challenge.example.com.", "example.com") == "_acme-challenge"
|
||||
assert _relative_name("_ACME-Challenge.Example.COM", "example.com") == "_acme-challenge"
|
||||
# Apex and wildcard produce the SAME relative name — which is exactly why the merge below
|
||||
# has to be additive.
|
||||
apex = ACMEService._challenge_dns_name("example.com")
|
||||
wild = ACMEService._challenge_dns_name("*.example.com")
|
||||
assert _relative_name(apex, "example.com") == _relative_name(wild, "example.com") == "_acme-challenge"
|
||||
|
||||
|
||||
def test_godaddy_rrset_merge_is_additive():
|
||||
# THE critical test: GoDaddy's PUT REPLACES an entire RRset, so the merge math is the only thing
|
||||
# keeping a wildcard+apex certificate's two coexisting TXT values alive.
|
||||
from services.dns_providers.godaddy import _live_values, _merge_add, _merge_remove
|
||||
|
||||
def vals(body):
|
||||
return sorted(r["data"] for r in body)
|
||||
|
||||
assert vals(_merge_add([{"data": "valueA", "ttl": 600}], "valueB")) == ["valueA", "valueB"]
|
||||
assert _merge_add([{"data": "valueA"}], "valueA") is None # idempotent; ACME retries land here
|
||||
# Total, not an all()-over-a-computed-list (which passes vacuously on an empty result): the
|
||||
# first publish at a fresh name must emit exactly one element, carrying the 600s TTL floor.
|
||||
assert _merge_add([], "v") == [{"data": "v", "ttl": 600}] # below 600 GoDaddy answers 422
|
||||
# Tombstone rows ({"data": ""}) must never be echoed back — GoDaddy answers 422 INVALID_BODY.
|
||||
assert _live_values([{"data": ""}, {"data": "x"}, {}]) == ["x"]
|
||||
assert vals(_merge_add([{"data": ""}, {"data": "valueA"}], "valueB")) == ["valueA", "valueB"]
|
||||
|
||||
assert vals(_merge_remove([{"data": "valueA"}, {"data": "valueB"}], "valueB")) == ["valueA"]
|
||||
assert _merge_remove([{"data": "valueA"}], "valueZ") is None # already gone — tolerate
|
||||
assert _merge_remove([], "valueZ") is None
|
||||
# [] means "use DELETE": PUT with an empty array is rejected (422, "Records must be specified").
|
||||
assert _merge_remove([{"data": "valueA"}], "valueA") == []
|
||||
assert _merge_remove([{"data": ""}, {"data": "valueA"}], "valueA") == []
|
||||
|
||||
|
||||
def test_godaddy_never_builds_a_zone_wide_txt_path():
|
||||
# A 3-segment path (.../records/TXT) is the endpoint that wipes EVERY TXT in the zone — SPF,
|
||||
# DKIM, DMARC, domain verifications. An empty relative name must never be able to produce it.
|
||||
from services.dns_providers.godaddy import _rrset_path
|
||||
|
||||
p = _rrset_path("example.com", "_acme-challenge")
|
||||
assert p == "/domains/example.com/records/TXT/_acme-challenge"
|
||||
assert p.count("/") == 5 and not p.endswith("/TXT")
|
||||
assert _rrset_path("example.com", "@").endswith("/%40") # apex percent-encoded for proxy safety
|
||||
# "." and ".." survive quote() and are then normalized away by yarl when the URL is built, so
|
||||
# ".../records/TXT/.." would resolve to the whole-zone endpoint. They must be refused too.
|
||||
for bad in [("example.com", ""), ("", "_acme-challenge"), ("example.com", "."),
|
||||
("example.com", ".."), ("example.com", "...")]:
|
||||
raised = False
|
||||
try:
|
||||
_rrset_path(*bad)
|
||||
except DnsProviderError:
|
||||
raised = True
|
||||
assert raised, f"_rrset_path{bad} must refuse to build a zone-wide TXT path"
|
||||
# And the only way to reach those inputs — a malformed domain — really does produce them.
|
||||
from services.dns_providers.godaddy import _relative_name as _rel
|
||||
assert _rel("..example.com", "example.com") == "."
|
||||
|
||||
|
||||
def test_godaddy_credential_encryption_roundtrip():
|
||||
# The two-field credential dict rides the same Fernet blob as Cloudflare's single token.
|
||||
reset_fernet_for_tests()
|
||||
creds = {"api_key": "gd-key-plaintext", "api_secret": "gd-secret-plaintext"}
|
||||
token = encrypt_dns_credentials(creds)
|
||||
assert "gd-key-plaintext" not in token and "gd-secret-plaintext" not in token # ciphertext
|
||||
assert decrypt_dns_credentials(token) == creds
|
||||
# This sorted key list is exactly what GET /dns-credentials exposes as credential_fields_present
|
||||
# — names only, never values.
|
||||
assert sorted(decrypt_dns_credentials(token).keys()) == ["api_key", "api_secret"]
|
||||
|
||||
|
||||
_GD_NS = "/domains/example.com/records/NS"
|
||||
_GD_TXT = "/domains/example.com/records/TXT/_acme-challenge"
|
||||
|
||||
|
||||
def _gd_provider(responses):
|
||||
"""A GoDaddy provider whose _request is replaced by a recorder.
|
||||
|
||||
The pure-merge tests above prove the MATH; this proves the WRITE PATH actually uses it. Without
|
||||
it, replacing the merge with a single-value PUT — the mutation that silently destroys the
|
||||
sibling value of every wildcard+apex certificate — leaves the whole suite green.
|
||||
|
||||
`responses` maps (method, path) -> value to return, or an Exception to raise. Unmapped calls
|
||||
return None, which is how the zone suffix-walk's failed probes are modelled.
|
||||
"""
|
||||
import types
|
||||
from services.dns_providers.godaddy import GoDaddyDNSProvider
|
||||
|
||||
calls = []
|
||||
|
||||
async def _fake_request(self, session, method, path, **kwargs):
|
||||
calls.append((method, path, kwargs.get("json")))
|
||||
result = responses.get((method, path))
|
||||
if isinstance(result, Exception):
|
||||
raise result
|
||||
return result
|
||||
|
||||
p = GoDaddyDNSProvider({"api_key": "k", "api_secret": "s"})
|
||||
p._request = types.MethodType(_fake_request, p)
|
||||
return p, calls
|
||||
|
||||
|
||||
def _assert_never_zone_wide(calls):
|
||||
# A write to .../records or .../records/TXT replaces every TXT (or every record) in the zone.
|
||||
for method, path, _json in calls:
|
||||
if method in ("PUT", "DELETE"):
|
||||
assert not path.endswith("/records"), f"zone-wide write: {method} {path}"
|
||||
assert not path.endswith("/records/TXT"), f"type-wide write: {method} {path}"
|
||||
|
||||
|
||||
def test_godaddy_add_write_path_merges_siblings():
|
||||
import asyncio
|
||||
|
||||
# An existing sibling value at the same name — the apex half of an apex+wildcard certificate.
|
||||
p, calls = _gd_provider({
|
||||
("GET", _GD_NS): [{"data": "ns1.domaincontrol.com"}],
|
||||
("GET", _GD_TXT): [{"data": "valueA", "ttl": 600}],
|
||||
})
|
||||
asyncio.run(p.add_txt_record("_acme-challenge.example.com", "valueB"))
|
||||
|
||||
writes = [c for c in calls if c[0] in ("PUT", "PATCH", "DELETE")]
|
||||
assert len(writes) == 1 and writes[0][0] == "PUT" and writes[0][1] == _GD_TXT
|
||||
# BOTH values must be in the body: GoDaddy's PUT replaces the whole RRset.
|
||||
assert sorted(r["data"] for r in writes[0][2]) == ["valueA", "valueB"]
|
||||
_assert_never_zone_wide(calls)
|
||||
|
||||
|
||||
def test_godaddy_add_write_path_is_idempotent_and_fails_closed():
|
||||
import asyncio
|
||||
|
||||
# Already published -> no write at all (this is where an ACME retry cycle lands).
|
||||
p, calls = _gd_provider({
|
||||
("GET", _GD_NS): [{"data": "ns1.domaincontrol.com"}],
|
||||
("GET", _GD_TXT): [{"data": "valueB", "ttl": 600}],
|
||||
})
|
||||
asyncio.run(p.add_txt_record("_acme-challenge.example.com", "valueB"))
|
||||
assert [c for c in calls if c[0] != "GET"] == []
|
||||
|
||||
# Unreadable RRset read (2xx whose body did not parse as a list) must FAIL, never be treated as
|
||||
# an empty RRset — the PUT that follows would replace the sibling values with only ours.
|
||||
p, calls = _gd_provider({
|
||||
("GET", _GD_NS): [{"data": "ns1.domaincontrol.com"}],
|
||||
("GET", _GD_TXT): None,
|
||||
})
|
||||
raised = False
|
||||
try:
|
||||
asyncio.run(p.add_txt_record("_acme-challenge.example.com", "valueB"))
|
||||
except DnsProviderError:
|
||||
raised = True
|
||||
assert raised, "an unreadable RRset read must not be coerced into an empty RRset"
|
||||
assert [c for c in calls if c[0] != "GET"] == []
|
||||
|
||||
|
||||
def test_godaddy_remove_write_path_uses_delete_for_the_last_value():
|
||||
import asyncio
|
||||
|
||||
# Two values -> PUT back the survivor only.
|
||||
p, calls = _gd_provider({
|
||||
("GET", _GD_NS): [{"data": "ns1.domaincontrol.com"}],
|
||||
("GET", _GD_TXT): [{"data": "valueA"}, {"data": "valueB"}],
|
||||
})
|
||||
asyncio.run(p.remove_txt_record("_acme-challenge.example.com", "valueB"))
|
||||
writes = [c for c in calls if c[0] != "GET"]
|
||||
assert len(writes) == 1 and writes[0][0] == "PUT"
|
||||
assert [r["data"] for r in writes[0][2]] == ["valueA"]
|
||||
|
||||
# Last value -> DELETE. `PUT []` is rejected by GoDaddy (422 INVALID_BODY), so an empty PUT
|
||||
# body would make every cleanup fail forever.
|
||||
p, calls = _gd_provider({
|
||||
("GET", _GD_NS): [{"data": "ns1.domaincontrol.com"}],
|
||||
("GET", _GD_TXT): [{"data": "valueA"}],
|
||||
})
|
||||
asyncio.run(p.remove_txt_record("_acme-challenge.example.com", "valueA"))
|
||||
writes = [c for c in calls if c[0] != "GET"]
|
||||
assert len(writes) == 1 and writes[0] == ("DELETE", _GD_TXT, None)
|
||||
assert not any(c[0] == "PUT" and c[2] == [] for c in calls)
|
||||
|
||||
# Value already gone -> no write, no error.
|
||||
p, calls = _gd_provider({
|
||||
("GET", _GD_NS): [{"data": "ns1.domaincontrol.com"}],
|
||||
("GET", _GD_TXT): [{"data": "valueA"}],
|
||||
})
|
||||
asyncio.run(p.remove_txt_record("_acme-challenge.example.com", "valueZ"))
|
||||
assert [c for c in calls if c[0] != "GET"] == []
|
||||
_assert_never_zone_wide(calls)
|
||||
|
||||
|
||||
class _FakeGDResponse:
|
||||
"""Minimal stand-in for aiohttp's ClientResponse: status, headers, and json()."""
|
||||
|
||||
_NO_BODY = object()
|
||||
|
||||
def __init__(self, status, body=_NO_BODY, headers=None):
|
||||
self.status = status
|
||||
self._body = body
|
||||
self.headers = headers or {}
|
||||
|
||||
async def json(self, content_type=None):
|
||||
if self._body is _FakeGDResponse._NO_BODY:
|
||||
raise ValueError("no body to decode") # what an empty 204 does
|
||||
return self._body
|
||||
|
||||
|
||||
class _FakeGDSession:
|
||||
def __init__(self, response):
|
||||
self._response = response
|
||||
self.calls = []
|
||||
|
||||
def request(self, method, url, **kwargs):
|
||||
self.calls.append((method, url, kwargs))
|
||||
response = self._response
|
||||
|
||||
class _Ctx:
|
||||
async def __aenter__(self_inner):
|
||||
return response
|
||||
|
||||
async def __aexit__(self_inner, *exc):
|
||||
return False
|
||||
|
||||
return _Ctx()
|
||||
|
||||
|
||||
def test_godaddy_request_status_handling():
|
||||
import asyncio
|
||||
from services.dns_providers.godaddy import GoDaddyDNSProvider
|
||||
|
||||
p = GoDaddyDNSProvider({"api_key": "KEY123", "api_secret": "SEC456"})
|
||||
|
||||
def call(response):
|
||||
session = _FakeGDSession(response)
|
||||
try:
|
||||
return asyncio.run(p._request(session, "PUT", "/domains/example.com/records/TXT/x",
|
||||
json=[{"data": "v", "ttl": 600}])), None, session
|
||||
except DnsProviderError as exc:
|
||||
return None, str(exc), session
|
||||
|
||||
# 204 with an EMPTY body is the normal answer to every GoDaddy write — it must not raise.
|
||||
body, err, session = call(_FakeGDResponse(204))
|
||||
assert body is None and err is None
|
||||
# Redirects are deliberately not followed (aiohttp would forward the Authorization header), so
|
||||
# a 3xx is a FAILED call. Treating it as success would report a redirected write as a no-op.
|
||||
_kw = session.calls[0][2]
|
||||
assert _kw["allow_redirects"] is False
|
||||
assert _kw["headers"]["Authorization"] == "sso-key KEY123:SEC456"
|
||||
assert _kw["headers"]["Accept"] == "application/json"
|
||||
for status in (301, 302, 307):
|
||||
body, err, _ = call(_FakeGDResponse(status))
|
||||
assert body is None and err and str(status) in err, f"HTTP {status} must not read as success"
|
||||
|
||||
# 200 with a list is passed through verbatim.
|
||||
body, err, _ = call(_FakeGDResponse(200, [{"data": "v"}]))
|
||||
assert err is None and body == [{"data": "v"}]
|
||||
|
||||
# Error mapping: each message must name what the operator has to fix.
|
||||
_, err, _ = call(_FakeGDResponse(401, {"code": "UNABLE_TO_AUTHENTICATE", "message": "nope"}))
|
||||
assert "PRODUCTION" in err and "UNABLE_TO_AUTHENTICATE" in err
|
||||
_, err, _ = call(_FakeGDResponse(403, {"code": "ACCESS_DENIED", "message": "not allowed"}))
|
||||
assert "domains.dns:update" in err
|
||||
# 429: Retry-After wins; the legacy body field is the fallback; absent both -> 60s default.
|
||||
_, err, _ = call(_FakeGDResponse(429, None, {"Retry-After": "17"}))
|
||||
assert "~17s" in err
|
||||
_, err, _ = call(_FakeGDResponse(429, {"retryAfterSec": 42}))
|
||||
assert "~42s" in err
|
||||
_, err, _ = call(_FakeGDResponse(429, {"Retry-After": "not-a-number"}))
|
||||
assert "~60s" in err
|
||||
# A non-dict error body must not crash the error mapper.
|
||||
_, err, _ = call(_FakeGDResponse(500, "<html>gateway</html>"))
|
||||
assert "500" in err
|
||||
|
||||
# A transport failure MID-READ must not be mistaken for "empty body". Only a decode error may
|
||||
# be swallowed: a caller reading an RRset would otherwise see None and could take it for an
|
||||
# empty record set, and the full-RRset PUT that follows would destroy the sibling values.
|
||||
import aiohttp
|
||||
|
||||
class _TruncatedResponse(_FakeGDResponse):
|
||||
async def json(self, content_type=None):
|
||||
raise aiohttp.ClientPayloadError("connection closed mid-body")
|
||||
|
||||
body, err, _ = call(_TruncatedResponse(200))
|
||||
assert body is None and err and "GoDaddy" in err
|
||||
|
||||
|
||||
def test_godaddy_zone_resolution_walks_suffixes_and_caches():
|
||||
import asyncio
|
||||
from services.dns_providers.godaddy import _GoDaddyHTTPError
|
||||
|
||||
# The deepest candidate is not a zone (404 = "not this zone"); the walk must continue to the
|
||||
# registrable domain and then reuse it, so the second challenge at the same name costs no probe.
|
||||
p, calls = _gd_provider({
|
||||
("GET", "/domains/_acme-challenge.example.com/records/NS"):
|
||||
_GoDaddyHTTPError("nope", status=404, code="UNKNOWN_DOMAIN"),
|
||||
("GET", _GD_NS): [{"data": "ns1.domaincontrol.com"}],
|
||||
("GET", _GD_TXT): [],
|
||||
})
|
||||
asyncio.run(p.add_txt_record("_acme-challenge.example.com", "valueA"))
|
||||
asyncio.run(p.add_txt_record("_acme-challenge.example.com", "valueB"))
|
||||
probes = [c for c in calls if c[1].endswith("/records/NS")]
|
||||
assert len(probes) == 2, "the resolved zone must be cached for the life of the provider"
|
||||
# Relative name derived from the RESOLVED zone, never from the deepest candidate.
|
||||
assert all(c[1] == _GD_TXT for c in calls if "/records/TXT/" in c[1])
|
||||
|
||||
# A credential/eligibility failure during the walk must surface, not be swallowed as
|
||||
# "no managed domain" — otherwise the operator chases a DNS problem that is really a bad key.
|
||||
p, calls = _gd_provider({
|
||||
("GET", "/domains/_acme-challenge.example.com/records/NS"):
|
||||
_GoDaddyHTTPError("denied", status=403, code="ACCESS_DENIED"),
|
||||
})
|
||||
raised = ""
|
||||
try:
|
||||
asyncio.run(p.add_txt_record("_acme-challenge.example.com", "v"))
|
||||
except DnsProviderError as exc:
|
||||
raised = str(exc)
|
||||
assert "denied" in raised and "No managed GoDaddy domain" not in raised
|
||||
|
||||
|
||||
def test_b64url_decode_padding_roundtrip():
|
||||
# Issue #35 v1.8.2: _b64url_decode must round-trip for EVERY length, including base64url strings
|
||||
# whose length is a multiple of 4 (the case the old padding formula '=' * (4 - len%4) over-padded).
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Issue #53 — at-rest encryption for the pending CSR private key (v1.10.1).
|
||||
|
||||
Mirrors the established Fernet + HKDF(SECRET_KEY) pattern already used for the VRRP secret
|
||||
(services/keepalived_config.py), TOTP secrets (services/mfa_service.py) and DNS provider
|
||||
credentials (utils/dns_credentials.py): prefer an explicit CSR_ENCRYPTION_KEY env var (enables
|
||||
key rotation), else derive a stable key from SECRET_KEY via HKDF with its own versioned info
|
||||
string, so a rotation of one secret class never affects another.
|
||||
|
||||
WHY this key and not every key in the system: the CSR private key is the one key that sits IDLE.
|
||||
It is generated at CSR creation, waits for an external CA to sign the request (days to weeks),
|
||||
and is destroyed the moment the signed certificate is imported — it is never transmitted to an
|
||||
agent and never leaves the server. `ssl_certificates.private_key_content` and the ACME order keys
|
||||
are different: agents must receive them in plaintext on every poll, so encrypting them at rest
|
||||
buys nothing without an end-to-end redesign.
|
||||
|
||||
STORAGE: the Fernet token replaces the PEM in the SAME `ssl_csrs.private_key_pem` TEXT column.
|
||||
No new column, no new table, and deliberately NO `SCHEMA_VERSION` bump — a bump would re-run the
|
||||
migration sequence and re-seed the four built-in roles to their defaults (see UPGRADE_GUIDE.md),
|
||||
which is a needless side effect for a storage-format change.
|
||||
|
||||
BACKWARD COMPATIBILITY: rows written before this release hold a raw PEM. `decrypt_csr_private_key`
|
||||
detects those by their `-----BEGIN` header and returns them unchanged. The discriminator is exact,
|
||||
not a heuristic: a Fernet token is base64url text and can never contain "-----". Legacy rows drain
|
||||
naturally, since a CSR's key copy is NULLed on import.
|
||||
|
||||
KEY ROTATION: if SECRET_KEY rotates while CSR_ENCRYPTION_KEY is unset, previously stored keys
|
||||
become undecryptable and `decrypt_csr_private_key` returns None. Callers MUST surface a clear
|
||||
"delete this CSR and create a new one" error — the CSR is unusable at that point, because the
|
||||
signed certificate can no longer be paired with its key.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
|
||||
from config import SECRET_KEY
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# A PEM private key always carries this header; a Fernet token is base64url and never can.
|
||||
_PEM_MARKER = "-----BEGIN"
|
||||
|
||||
_fernet_instance: Optional[Fernet] = None
|
||||
|
||||
|
||||
def _resolve_fernet_key() -> bytes:
|
||||
"""Prefer an explicit CSR_ENCRYPTION_KEY; else derive from SECRET_KEY via HKDF with a
|
||||
versioned info string (so stored keys survive restarts)."""
|
||||
explicit = os.getenv("CSR_ENCRYPTION_KEY", "").strip()
|
||||
if explicit:
|
||||
try:
|
||||
Fernet(explicit.encode())
|
||||
return explicit.encode()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("CSR_ENCRYPTION_KEY env var present but invalid: %s", exc)
|
||||
logger.warning(
|
||||
"CSR_ENCRYPTION_KEY not set; deriving the CSR private-key encryption key from SECRET_KEY. "
|
||||
"Set CSR_ENCRYPTION_KEY to a Fernet key to enable key rotation."
|
||||
)
|
||||
hkdf = HKDF(algorithm=hashes.SHA256(), length=32, salt=None, info=b"csr-private-key-v1")
|
||||
derived = hkdf.derive(SECRET_KEY.encode("utf-8"))
|
||||
return base64.urlsafe_b64encode(derived)
|
||||
|
||||
|
||||
def _get_fernet() -> Fernet:
|
||||
global _fernet_instance
|
||||
if _fernet_instance is None:
|
||||
_fernet_instance = Fernet(_resolve_fernet_key())
|
||||
return _fernet_instance
|
||||
|
||||
|
||||
def reset_fernet_for_tests() -> None:
|
||||
"""Test-only hook to force re-resolution after env mutation."""
|
||||
global _fernet_instance
|
||||
_fernet_instance = None
|
||||
|
||||
|
||||
def is_encrypted(stored: Optional[str]) -> bool:
|
||||
"""True when the stored value is a Fernet token rather than a legacy raw PEM.
|
||||
|
||||
Single source of the format discriminator: `decrypt_csr_private_key` branches on this, so
|
||||
the "what does a stored value look like" rule is stated exactly once.
|
||||
"""
|
||||
return bool(stored) and _PEM_MARKER not in stored
|
||||
|
||||
|
||||
def encrypt_csr_private_key(pem: str) -> str:
|
||||
"""Fernet-encrypt a PEM private key to a storable token string."""
|
||||
return _get_fernet().encrypt(pem.encode("utf-8")).decode("utf-8")
|
||||
|
||||
|
||||
def decrypt_csr_private_key(stored: Optional[str]) -> Optional[str]:
|
||||
"""Return the PEM private key for a stored value.
|
||||
|
||||
Accepts BOTH shapes so an upgrade needs no data migration:
|
||||
- a raw PEM written before v1.10.1 -> returned unchanged
|
||||
- a Fernet token -> decrypted
|
||||
|
||||
Returns None when the value is empty or cannot be decrypted (e.g. SECRET_KEY rotated without
|
||||
CSR_ENCRYPTION_KEY). Callers MUST treat None as "this CSR's key is unrecoverable" and tell the
|
||||
operator to delete it and create a new one; never fall through to a pairing attempt.
|
||||
"""
|
||||
if not stored:
|
||||
return None
|
||||
if not is_encrypted(stored):
|
||||
return stored # legacy plaintext row, pre-v1.10.1
|
||||
try:
|
||||
return _get_fernet().decrypt(stored.encode("utf-8")).decode("utf-8")
|
||||
except InvalidToken:
|
||||
logger.warning("Failed to decrypt a stored CSR private key (invalid Fernet token)")
|
||||
return None
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Unexpected error decrypting a stored CSR private key: %s", exc)
|
||||
return None
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.10",
|
||||
"releaseName": "Security hardening — RCE, missing-auth and SSRF advisories (GHSA-7rhv/3p5c/3vh4)",
|
||||
"releaseDate": "2026-07-20"
|
||||
"version": "1.10.1",
|
||||
"releaseName": "CSR private key encrypted at rest",
|
||||
"releaseDate": "2026-08-08"
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "haproxy-openmanager-frontend",
|
||||
"version": "1.8.10",
|
||||
"version": "1.9.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "haproxy-openmanager-frontend",
|
||||
"version": "1.8.10",
|
||||
"version": "1.9.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "haproxy-openmanager-frontend",
|
||||
"version": "1.8.10",
|
||||
"version": "1.10.1",
|
||||
"description": "HAProxy Load Balancer Management UI",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,886 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Card, Table, Button, Modal, Form, Input, Space, message,
|
||||
Popconfirm, Tag, Tooltip, Row, Col, Typography, Alert, Select,
|
||||
Collapse, Descriptions, Badge
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined, ReloadOutlined, DeleteOutlined, EyeOutlined,
|
||||
DownloadOutlined, CopyOutlined, FileProtectOutlined, ImportOutlined,
|
||||
KeyOutlined
|
||||
} from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import { useCluster } from '../contexts/ClusterContext';
|
||||
import { extractApiError } from '../utils/apiError';
|
||||
import { antdDomainRule, antdDomainsListRule } from '../utils/validation';
|
||||
|
||||
const { Text } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
const KEY_ALGORITHM_OPTIONS = [
|
||||
{ value: 'rsa-2048', label: 'RSA 2048 (recommended)' },
|
||||
{ value: 'rsa-4096', label: 'RSA 4096' },
|
||||
{ value: 'ecdsa-p256', label: 'ECDSA P-256' },
|
||||
{ value: 'ecdsa-p384', label: 'ECDSA P-384' },
|
||||
];
|
||||
|
||||
const KEY_ALGORITHM_LABELS = {
|
||||
'rsa-2048': 'RSA 2048',
|
||||
'rsa-4096': 'RSA 4096',
|
||||
'ecdsa-p256': 'ECDSA P-256',
|
||||
'ecdsa-p384': 'ECDSA P-384',
|
||||
};
|
||||
|
||||
// CSR creation (v1.9.0): generate the private key + CSR server-side, submit
|
||||
// the CSR to an external CA, then import the signed certificate. The private
|
||||
// key never leaves the backend — this component only ever handles the CSR
|
||||
// PEM and the CA's certificate response.
|
||||
const CSRManagement = ({ onCertificateImported }) => {
|
||||
const { clusters } = useCluster();
|
||||
const [csrs, setCsrs] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [viewCsr, setViewCsr] = useState(null);
|
||||
const [importCsr, setImportCsr] = useState(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [createForm] = Form.useForm();
|
||||
const [importForm] = Form.useForm();
|
||||
|
||||
const fetchCsrs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await axios.get('/api/ssl/csrs', {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
'Pragma': 'no-cache',
|
||||
},
|
||||
});
|
||||
setCsrs(Array.isArray(response.data) ? response.data : []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching CSRs:', error);
|
||||
message.error(extractApiError(error, 'Failed to fetch CSRs'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCsrs();
|
||||
}, [fetchCsrs]);
|
||||
|
||||
const handleCreate = async (values) => {
|
||||
setCreating(true);
|
||||
try {
|
||||
const payload = {
|
||||
name: values.name,
|
||||
common_name: values.common_name,
|
||||
sans: values.sans || [],
|
||||
key_algorithm: values.key_algorithm || 'rsa-2048',
|
||||
organization: values.organization || null,
|
||||
organizational_unit: values.organizational_unit || null,
|
||||
locality: values.locality || null,
|
||||
state: values.state || null,
|
||||
country: values.country || null,
|
||||
email: values.email || null,
|
||||
};
|
||||
const response = await axios.post('/api/ssl/csrs', payload);
|
||||
message.success(
|
||||
<div>
|
||||
<strong>CSR '{values.name}' created</strong>
|
||||
<br />
|
||||
<small>Submit the CSR to your Certificate Authority for signing.</small>
|
||||
</div>,
|
||||
5
|
||||
);
|
||||
setCreateModalOpen(false);
|
||||
createForm.resetFields();
|
||||
fetchCsrs();
|
||||
// Open the view modal immediately so the operator can copy/download
|
||||
// the CSR PEM in one round trip.
|
||||
if (response.data?.csr) {
|
||||
setViewCsr(response.data.csr);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating CSR:', error);
|
||||
message.error(extractApiError(error, 'Failed to create CSR'));
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleView = async (record) => {
|
||||
try {
|
||||
const response = await axios.get(`/api/ssl/csrs/${record.id}`);
|
||||
setViewCsr(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching CSR details:', error);
|
||||
message.error(extractApiError(error, 'Failed to fetch CSR details'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicate = (record) => {
|
||||
const subject = record.subject || {};
|
||||
createForm.setFieldsValue({
|
||||
name: `${record.name}-new`,
|
||||
common_name: record.common_name,
|
||||
sans: (record.sans || []).filter((s) => s !== record.common_name),
|
||||
key_algorithm: record.key_algorithm || 'rsa-2048',
|
||||
organization: subject.O || undefined,
|
||||
organizational_unit: subject.OU || undefined,
|
||||
locality: subject.L || undefined,
|
||||
state: subject.ST || undefined,
|
||||
country: subject.C || undefined,
|
||||
email: subject.emailAddress || undefined,
|
||||
});
|
||||
setCreateModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (record) => {
|
||||
try {
|
||||
const response = await axios.delete(`/api/ssl/csrs/${record.id}`);
|
||||
message.success(response.data?.message || `CSR '${record.name}' deleted`);
|
||||
fetchCsrs();
|
||||
} catch (error) {
|
||||
console.error('Error deleting CSR:', error);
|
||||
message.error(extractApiError(error, 'Failed to delete CSR'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async (values) => {
|
||||
if (!importCsr) return;
|
||||
setImporting(true);
|
||||
try {
|
||||
const isGlobal = values.ssl_type === 'global';
|
||||
const payload = {
|
||||
certificate_content: values.certificate_content,
|
||||
chain_content: values.chain_content || null,
|
||||
usage_type: values.usage_type || 'frontend',
|
||||
is_global: isGlobal,
|
||||
cluster_ids: isGlobal ? null : values.cluster_ids,
|
||||
name: values.name_override ? values.name_override.trim() : null,
|
||||
};
|
||||
const response = await axios.post(
|
||||
`/api/ssl/csrs/${importCsr.id}/import`,
|
||||
payload
|
||||
);
|
||||
const warnings = response.data?.warnings || [];
|
||||
if (warnings.length > 0) {
|
||||
Modal.warning({
|
||||
title: 'Certificate imported with warnings',
|
||||
width: 560,
|
||||
content: (
|
||||
<ul style={{ paddingLeft: 18, marginTop: 8 }}>
|
||||
{warnings.map((w, i) => (
|
||||
<li key={i}>{w}</li>
|
||||
))}
|
||||
</ul>
|
||||
),
|
||||
});
|
||||
}
|
||||
message.success(
|
||||
<div>
|
||||
<strong>Certificate imported successfully</strong>
|
||||
<br />
|
||||
<small>Go to Apply Management to deploy it to the cluster(s).</small>
|
||||
</div>,
|
||||
6
|
||||
);
|
||||
setImportCsr(null);
|
||||
importForm.resetFields();
|
||||
fetchCsrs();
|
||||
if (onCertificateImported) {
|
||||
onCertificateImported();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error importing signed certificate:', error);
|
||||
message.error(extractApiError(error, 'Failed to import certificate'));
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadCsrPem = (csr) => {
|
||||
if (!csr?.csr_pem) return;
|
||||
const blob = new Blob([csr.csr_pem], { type: 'application/pkcs10;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${csr.name}.csr`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const copyCsrPem = (csr) => {
|
||||
if (!csr?.csr_pem) return;
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard
|
||||
.writeText(csr.csr_pem)
|
||||
.then(() => message.success('CSR PEM copied to clipboard'))
|
||||
.catch(() => message.error('Failed to copy CSR PEM'));
|
||||
} else {
|
||||
message.warning('Clipboard is not available in this browser');
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'CSR',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (text, record) => (
|
||||
<Space>
|
||||
<FileProtectOutlined style={{ color: '#1677ff' }} />
|
||||
<div>
|
||||
<strong>{text}</strong>
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{record.common_name}
|
||||
</Text>
|
||||
</div>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'SANs',
|
||||
dataIndex: 'sans',
|
||||
key: 'sans',
|
||||
render: (sans) => {
|
||||
const list = Array.isArray(sans) ? sans : [];
|
||||
if (list.length === 0) return <Text type="secondary">-</Text>;
|
||||
const visible = list.slice(0, 2);
|
||||
const rest = list.slice(2);
|
||||
return (
|
||||
<Space size={4} wrap>
|
||||
{visible.map((d) => (
|
||||
<Tag key={d}>{d}</Tag>
|
||||
))}
|
||||
{rest.length > 0 && (
|
||||
<Tooltip title={rest.join(', ')}>
|
||||
<Tag>+{rest.length}</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Key',
|
||||
dataIndex: 'key_algorithm',
|
||||
key: 'key_algorithm',
|
||||
render: (algo) => (
|
||||
<Tag icon={<KeyOutlined />} color="geekblue">
|
||||
{KEY_ALGORITHM_LABELS[algo] || algo}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status, record) => {
|
||||
if (status === 'completed') {
|
||||
return (
|
||||
<div>
|
||||
<Badge status="success" text="Imported" />
|
||||
{record.certificate_name && (
|
||||
<>
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
→ {record.certificate_name}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <Badge status="processing" text="Awaiting certificate" />;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Created',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
render: (date, record) => (
|
||||
<div>
|
||||
{date
|
||||
? new Date(date).toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
: '-'}
|
||||
{record.created_by_username && (
|
||||
<>
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
by {record.created_by_username}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
key: 'actions',
|
||||
render: (_, record) => (
|
||||
<Space size="small">
|
||||
<Tooltip title="View / download CSR">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => handleView(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
{record.status === 'pending' && (
|
||||
<Tooltip title="Import signed certificate">
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<ImportOutlined />}
|
||||
onClick={() => {
|
||||
importForm.resetFields();
|
||||
setImportCsr(record);
|
||||
}}
|
||||
>
|
||||
Import
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title="Duplicate (pre-fill a new CSR)">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => handleDuplicate(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Popconfirm
|
||||
title="Delete this CSR?"
|
||||
description={
|
||||
record.status === 'pending'
|
||||
? 'The private key will be permanently destroyed — any certificate later signed from this CSR becomes unusable.'
|
||||
: 'Only the CSR history entry is removed — the imported certificate is not affected.'
|
||||
}
|
||||
onConfirm={() => handleDelete(record)}
|
||||
okText="Delete"
|
||||
okType="danger"
|
||||
cancelText="Cancel"
|
||||
>
|
||||
<Tooltip title="Delete CSR">
|
||||
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const pendingCount = csrs.filter((c) => c.status === 'pending').length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="Certificate Signing Requests for external CAs"
|
||||
description="Generate a private key and CSR here, submit the CSR to your Certificate Authority, then import the signed certificate. The private key never leaves the server; the imported certificate goes through the normal Apply Management deployment flow."
|
||||
/>
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col flex="auto">
|
||||
{pendingCount > 0 && (
|
||||
<Text type="secondary">
|
||||
{pendingCount} CSR{pendingCount > 1 ? 's' : ''} awaiting a signed
|
||||
certificate
|
||||
</Text>
|
||||
)}
|
||||
</Col>
|
||||
<Col>
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={fetchCsrs} loading={loading}>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
createForm.resetFields();
|
||||
setCreateModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Create CSR
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={csrs}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total) => `Total ${total} CSRs`,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Create CSR Modal */}
|
||||
<Modal
|
||||
title="Create Certificate Signing Request"
|
||||
open={createModalOpen}
|
||||
onCancel={() => {
|
||||
setCreateModalOpen(false);
|
||||
createForm.resetFields();
|
||||
}}
|
||||
footer={null}
|
||||
width={700}
|
||||
forceRender
|
||||
>
|
||||
<Form form={createForm} layout="vertical" onFinish={handleCreate}>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="Name"
|
||||
rules={[
|
||||
{ required: true, message: 'Please enter a CSR name' },
|
||||
{
|
||||
pattern: /^[a-zA-Z0-9_.-]+$/,
|
||||
message:
|
||||
'Only letters, digits, underscore, hyphen and dot are allowed',
|
||||
},
|
||||
{ max: 100, message: 'Name must be 100 characters or fewer' },
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (!value) return Promise.resolve();
|
||||
if (value.includes('..')) {
|
||||
return Promise.reject(new Error('Name must not contain ".."'));
|
||||
}
|
||||
if (value.startsWith('.') || value.startsWith('-')) {
|
||||
return Promise.reject(
|
||||
new Error('Name must not start with "." or "-"')
|
||||
);
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
]}
|
||||
extra="Becomes the certificate name and file path at import: /etc/ssl/haproxy/{name}.pem"
|
||||
>
|
||||
<Input placeholder="e.g. www-example-com" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="common_name"
|
||||
label="Common Name (CN)"
|
||||
rules={[
|
||||
{ required: true, message: 'Please enter the Common Name' },
|
||||
antdDomainRule,
|
||||
{ max: 64, message: 'Common Name must be 64 characters or fewer' },
|
||||
]}
|
||||
extra="The primary domain, e.g. www.example.com or *.example.com"
|
||||
>
|
||||
<Input placeholder="www.example.com" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="sans"
|
||||
label="Subject Alternative Names (SANs)"
|
||||
rules={[antdDomainsListRule]}
|
||||
extra="Additional DNS names — the Common Name is included automatically"
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tokenSeparators={[',', ' ']}
|
||||
placeholder="api.example.com, cdn.example.com"
|
||||
open={false}
|
||||
suffixIcon={null}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="key_algorithm"
|
||||
label="Key Algorithm"
|
||||
initialValue="rsa-2048"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Select options={KEY_ALGORITHM_OPTIONS} />
|
||||
</Form.Item>
|
||||
|
||||
<Collapse
|
||||
style={{ marginBottom: 16 }}
|
||||
items={[
|
||||
{
|
||||
key: 'subject',
|
||||
label: 'Subject details (optional)',
|
||||
children: (
|
||||
<>
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="organization"
|
||||
label="Organization (O)"
|
||||
rules={[{ max: 64 }]}
|
||||
>
|
||||
<Input placeholder="Example Corp" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="organizational_unit"
|
||||
label="Organizational Unit (OU)"
|
||||
rules={[{ max: 64 }]}
|
||||
>
|
||||
<Input placeholder="IT Department" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={12}>
|
||||
<Col span={8}>
|
||||
<Form.Item name="locality" label="Locality (L)" rules={[{ max: 64 }]}>
|
||||
<Input placeholder="Istanbul" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name="state" label="State / Province (ST)" rules={[{ max: 64 }]}>
|
||||
<Input placeholder="Marmara" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item
|
||||
name="country"
|
||||
label="Country (C)"
|
||||
rules={[
|
||||
{
|
||||
pattern: /^[A-Za-z]{2}$/,
|
||||
message: 'Exactly 2 letters (e.g. TR, US)',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input placeholder="TR" maxLength={2} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item
|
||||
name="email"
|
||||
label="Email"
|
||||
rules={[{ type: 'email', message: 'Invalid email address' }]}
|
||||
>
|
||||
<Input placeholder="ops@example.com" />
|
||||
</Form.Item>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="🔐 The private key is generated and stored server-side"
|
||||
description="You will only receive the CSR to hand to your CA. After the signed certificate is imported, the key is available on the certificate itself."
|
||||
/>
|
||||
|
||||
<Form.Item style={{ textAlign: 'right', marginBottom: 0 }}>
|
||||
<Space>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setCreateModalOpen(false);
|
||||
createForm.resetFields();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="primary" htmlType="submit" loading={creating}>
|
||||
Generate CSR
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* View CSR Modal */}
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<FileProtectOutlined />
|
||||
{viewCsr ? `CSR: ${viewCsr.name}` : 'CSR'}
|
||||
</Space>
|
||||
}
|
||||
open={!!viewCsr}
|
||||
onCancel={() => setViewCsr(null)}
|
||||
width={760}
|
||||
footer={[
|
||||
<Button key="close" onClick={() => setViewCsr(null)}>
|
||||
Close
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
{viewCsr && (
|
||||
<div>
|
||||
<Descriptions size="small" column={2} bordered style={{ marginBottom: 12 }}>
|
||||
<Descriptions.Item label="Common Name" span={2}>
|
||||
{viewCsr.common_name}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Key">
|
||||
{KEY_ALGORITHM_LABELS[viewCsr.key_algorithm] || viewCsr.key_algorithm}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Status">
|
||||
{viewCsr.status === 'completed' ? (
|
||||
<Badge status="success" text="Imported" />
|
||||
) : (
|
||||
<Badge status="processing" text="Awaiting certificate" />
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
{viewCsr.subject && Object.keys(viewCsr.subject).length > 0 && (
|
||||
<Descriptions.Item label="Subject" span={2}>
|
||||
{Object.entries(viewCsr.subject)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(', ')}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
|
||||
{Array.isArray(viewCsr.sans) && viewCsr.sans.length > 0 && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Text strong>SANs: </Text>
|
||||
<Space size={4} wrap>
|
||||
{viewCsr.sans.map((d) => (
|
||||
<Tag key={d}>{d}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Row justify="space-between" align="middle" style={{ marginBottom: 8 }}>
|
||||
<Col>
|
||||
<Text strong>CSR (PEM)</Text>
|
||||
</Col>
|
||||
<Col>
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => copyCsrPem(viewCsr)}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => downloadCsrPem(viewCsr)}
|
||||
>
|
||||
Download .csr
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
<TextArea
|
||||
value={viewCsr.csr_pem}
|
||||
rows={12}
|
||||
readOnly
|
||||
style={{ fontFamily: 'monospace', fontSize: 12 }}
|
||||
/>
|
||||
{viewCsr.status !== 'completed' && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginTop: 12 }}
|
||||
message="Next step"
|
||||
description="Submit this CSR to your Certificate Authority. When you receive the signed certificate, come back and click Import on this CSR."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Import Signed Certificate Modal */}
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<ImportOutlined />
|
||||
{importCsr ? `Import Signed Certificate — ${importCsr.name}` : 'Import'}
|
||||
</Space>
|
||||
}
|
||||
open={!!importCsr}
|
||||
onCancel={() => {
|
||||
setImportCsr(null);
|
||||
importForm.resetFields();
|
||||
}}
|
||||
footer={null}
|
||||
width={800}
|
||||
>
|
||||
{importCsr && (
|
||||
<div>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message={`CSR: ${importCsr.name} (CN: ${importCsr.common_name})`}
|
||||
description="Paste the certificate your CA issued for this CSR. It will be verified against the stored private key before anything is saved."
|
||||
/>
|
||||
<Form
|
||||
form={importForm}
|
||||
layout="vertical"
|
||||
onFinish={handleImport}
|
||||
initialValues={{ ssl_type: 'cluster', usage_type: 'frontend' }}
|
||||
>
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="usage_type"
|
||||
label="Usage Type"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'frontend', label: 'Frontend SSL (HTTPS termination)' },
|
||||
{ value: 'server', label: 'Server SSL (backend verification)' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="ssl_type" label="Scope" rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'global', label: 'Global (all clusters)' },
|
||||
{ value: 'cluster', label: 'Cluster-specific' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prev, cur) => prev.ssl_type !== cur.ssl_type}
|
||||
>
|
||||
{({ getFieldValue }) =>
|
||||
getFieldValue('ssl_type') === 'cluster' && (
|
||||
<Form.Item
|
||||
name="cluster_ids"
|
||||
label="Clusters"
|
||||
rules={[
|
||||
{ required: true, message: 'Select at least one cluster' },
|
||||
]}
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="Select cluster(s)"
|
||||
options={(clusters || []).map((c) => ({
|
||||
value: c.id,
|
||||
label: c.name,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
)
|
||||
}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="certificate_content"
|
||||
label="Signed Certificate (PEM)"
|
||||
rules={[
|
||||
{ required: true, message: 'Please paste the signed certificate' },
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (!value) return Promise.resolve();
|
||||
if (
|
||||
value.includes('-----BEGIN CERTIFICATE-----') &&
|
||||
value.includes('-----END CERTIFICATE-----')
|
||||
) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(
|
||||
new Error('Certificate must be in PEM format')
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TextArea
|
||||
rows={8}
|
||||
placeholder={'-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----'}
|
||||
style={{ fontFamily: 'monospace', fontSize: 12 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="name_override"
|
||||
label="Certificate name override (optional)"
|
||||
rules={[
|
||||
{
|
||||
pattern: /^[a-zA-Z0-9_.-]+$/,
|
||||
message:
|
||||
'Only letters, digits, underscore, hyphen and dot are allowed',
|
||||
},
|
||||
{ max: 100, message: 'Name must be 100 characters or fewer' },
|
||||
]}
|
||||
extra={`Leave empty to use the CSR name ('${importCsr.name}'). Use this only if that name is now taken by another certificate.`}
|
||||
>
|
||||
<Input placeholder={importCsr.name} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="chain_content"
|
||||
label="Certificate Chain (PEM, optional)"
|
||||
rules={[
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (!value || !value.trim()) return Promise.resolve();
|
||||
if (
|
||||
value.includes('-----BEGIN CERTIFICATE-----') &&
|
||||
value.includes('-----END CERTIFICATE-----')
|
||||
) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(
|
||||
new Error('Certificate chain must be in PEM format')
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TextArea
|
||||
rows={4}
|
||||
placeholder={'-----BEGIN CERTIFICATE-----\n(intermediate CA)\n-----END CERTIFICATE-----'}
|
||||
style={{ fontFamily: 'monospace', fontSize: 12 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item style={{ textAlign: 'right', marginBottom: 0 }}>
|
||||
<Space>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setImportCsr(null);
|
||||
importForm.resetFields();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="primary" htmlType="submit" loading={importing}>
|
||||
Import Certificate
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CSRManagement;
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
PlayCircleOutlined, EditOutlined,
|
||||
CloudServerOutlined, CheckCircleOutlined, SyncOutlined,
|
||||
ExclamationCircleOutlined, CloseCircleOutlined, ClockCircleOutlined,
|
||||
ThunderboltOutlined
|
||||
ThunderboltOutlined, FileProtectOutlined
|
||||
} from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
@@ -22,6 +22,7 @@ import { useProgress } from '../contexts/ProgressContext';
|
||||
import { formatEntityForSync } from '../utils/agentSync';
|
||||
import { extractApiError } from '../utils/apiError';
|
||||
import ACMEAutomation from './ACMEAutomation';
|
||||
import CSRManagement from './CSRManagement';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { TextArea } = Input;
|
||||
@@ -643,12 +644,21 @@ const SSLManagement = () => {
|
||||
title: 'Source',
|
||||
dataIndex: 'source',
|
||||
key: 'source',
|
||||
render: (source) => (
|
||||
<Tag color={source === 'letsencrypt' ? 'green' : 'default'}
|
||||
icon={source === 'letsencrypt' ? <SafetyCertificateOutlined /> : null}>
|
||||
{source === 'letsencrypt' ? 'Auto (ACME)' : 'Manual'}
|
||||
</Tag>
|
||||
),
|
||||
render: (source) => {
|
||||
if (source === 'csr') {
|
||||
return (
|
||||
<Tag color="blue" icon={<FileProtectOutlined />}>
|
||||
CSR
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tag color={source === 'letsencrypt' ? 'green' : 'default'}
|
||||
icon={source === 'letsencrypt' ? <SafetyCertificateOutlined /> : null}>
|
||||
{source === 'letsencrypt' ? 'Auto (ACME)' : 'Manual'}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Sync Status',
|
||||
@@ -1020,6 +1030,11 @@ const SSLManagement = () => {
|
||||
label: <span><ThunderboltOutlined /> ACME Automation</span>,
|
||||
children: <ACMEAutomation />,
|
||||
},
|
||||
{
|
||||
key: 'csr',
|
||||
label: <span><FileProtectOutlined /> CSR</span>,
|
||||
children: <CSRManagement onCertificateImported={fetchCertificates} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user