Compare commits

...

11 Commits

Author SHA1 Message Date
taylanbakircioglu eee0a4716a feat(ssl): encrypt the pending CSR private key at rest (v1.10.1, closes #53)
Closes the follow-up filed during the v1.9.0 CSR review. The private key of a
PENDING CSR is now Fernet-encrypted in the database instead of being stored as
a raw PEM.

Why this key specifically: it is the one key in the system 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 deliberately
NOT covered, because agents must receive those in plaintext on every poll, so
encrypting them at rest buys nothing without an end-to-end redesign.

Implementation follows the pattern already used for the VRRP secret, TOTP
secrets and DNS provider credentials: a new utils/csr_key_crypto.py with its
own CSR_ENCRYPTION_KEY env var and its own HKDF info string
("csr-private-key-v1"), so rotating one secret class never affects another.

No schema change and deliberately NO SCHEMA_VERSION bump: the Fernet token
replaces the PEM inside the existing ssl_csrs.private_key_pem TEXT column. A
bump would re-run the migration sequence and re-seed the four built-in roles to
their defaults, which is a needless side effect for a storage-format change.

Backward compatible with no data migration. Rows written before this release
hold a raw PEM and are still read unchanged; the discriminator is exact rather
than a heuristic, since a Fernet token is base64url and can never contain the
"-----BEGIN" marker. Legacy rows drain naturally because a CSR's key copy is
NULLed on import.

A key that cannot be decrypted (SECRET_KEY rotated while CSR_ENCRYPTION_KEY was
unset) now fails with an explicit "delete this CSR and create a new one" error.
Previously that situation would have surfaced as the far more confusing
"certificate does not match this CSR's private key".

Also documents all four per-purpose encryption keys in .env.template. Only
VIP_ENCRYPTION_KEY was listed; MFA_ENCRYPTION_KEY and
DNS_PROVIDER_ENCRYPTION_KEY had been missing since v1.6.0 and v1.8.0.

Verified before release, on a corporate pre-production environment and locally:
- Full backend suite 1234 -> 1243 passed (+9 new tests), 0 failed.
- Against a real Postgres: a CSR created through the API stores a Fernet token
  with no PEM header in the column, and imports successfully.
- Full 1.10.0 -> 1.10.1 -> 1.10.0 drill on one database volume. The upgrade
  logs "Schema already at version 10 (>= 10); skipping migration run", so no
  migration executes and the built-in roles are not re-seeded. A CSR created on
  1.10.0 with a plaintext key imports successfully after the upgrade, which is
  the backward-compatibility guarantee proven against a real row rather than a
  mock.
- rsa-2048, rsa-4096 and ecdsa-p384 all round-trip through create, encrypt,
  decrypt and import.
- Key derivation is stable across processes: two independent containers sharing
  SECRET_KEY decrypt each other's tokens (required for UVICORN_WORKERS > 1 and
  multi-replica deployments), while a different SECRET_KEY yields None rather
  than a wrong key or an exception.
- Downgrade behaviour was measured, not assumed: 1.10.0 cannot parse the token
  and fails with HTTP 500 "key parse failed (encrypted?)" rather than pairing a
  wrong key. The rollback note states the measured behaviour.
- No CSR endpoint returns the key in any form: list and detail responses
  contain neither a PEM nor a Fernet token.

Not changed here, from the issue's "worth folding in" list: the create rate
limit is not a concurrency guard, create_csr holds a pooled connection across
RSA key generation, detail=str(e) echoes internal error text (a repo-wide
convention), and is_global skips cluster validation in both routers/ssl.py and
routers/csr.py. None are storage concerns and each is a separate change.
2026-08-08 01:44:32 +03:00
Taylan Bakırcıoğlu 3c8832330a Merge pull request #54 from appouse/feature/godaddy-dns-provider
GoDaddy DNS-01 provider for ACME (v1.10.0).

Validated on a corporate pre-production environment before merge: backend suite 1221 to 1234 passed (+13, exactly the new GoDaddy tests) with 0 failures; a wire-level harness against a fake GoDaddy API confirmed the apex+wildcard pair coexists, removing the last value uses DELETE rather than PUT [], an unreadable read fails closed with no write attempted, and across every scenario not one request reached a zone-wide endpoint (SPF, DKIM and DMARC survived untouched). The path-guard premise was measured directly: with yarl 1.24.5 a '.' segment normalizes onto the zone-wide TXT endpoint and '..' onto the whole-zone endpoint, so the guard in _rrset_path is load-bearing. No schema, environment, frontend or agent change.

Closes #55
2026-08-07 23:30:10 +03:00
mustafa.ulukaya 81ab674072 docs(v1.10.0): document the GoDaddy DNS provider and upgrade notes
README: add GoDaddy to the two feature bullets and to the DNS-01 provider
catalog, spelling out that the API Key must be a Production key (the first key
the developer dashboard issues is an OTE/test key and is rejected), that the
zone must be in the same account, that the account needs a registered domain
before GoDaddy permits DNS API access, and that a Personal Access Token works
with the Secret left blank. Note that publishing is automatic for GoDaddy as
well as Cloudflare, and add the release-notes entry.

UPGRADE_GUIDE: new section stating there is no SCHEMA_VERSION bump, so the
built-in-role re-seed warning from v1.9.0 does not apply, and no new
environment variable, API-shape or agent change. Two limits are stated
explicitly rather than glossed: the credential check is a read, so a token
with read but not write scope saves successfully and only fails at the first
publish; and downgrading after adopting GoDaddy is not a no-op, because an
unknown provider name degrades DNS-01 orders to the manual-confirm path and
leaves published TXT records marked cleaned without being removed.
2026-08-07 09:01:35 +03:00
mustafa.ulukaya 6bf6d016f5 chore(version): bump to 1.10.0 - GoDaddy DNS-01 provider 2026-08-07 09:01:35 +03:00
mustafa.ulukaya 0a0226c758 test(dns): cover GoDaddy relative-name derivation, RRset merge and request handling
Twelve tests, no network and no database, in the existing pure-logic style.

The merge helpers are covered directly (additive add, idempotent republish,
tombstone filtering, remove-one-of-many, remove-the-last-value signalling
DELETE), but helper math alone would stay green if the write path stopped
using it, so add_txt_record and remove_txt_record are also driven against a
recording stub: the assertions pin that a sibling value survives a publish,
that an already-published value issues no write, that an unreadable read
raises instead of replacing the set, that removing the last value emits DELETE
and never an empty PUT, and that no call is ever aimed at a zone-wide path.

_request is exercised through a fake response for the cases that only appear
against the real API: an empty 204 body must not raise, a 3xx must not read as
success (redirects are not followed), a transport failure mid-read must not be
mistaken for an empty body, and each error status must produce a message
naming what the operator has to fix.

Also covers the auth header in both forms, that the sanitizer strips
credentials from composed error text, that the module does not log at all, the
credential-field schema against the upsert validator's own key and length
rules, and the two-key encryption round trip.

Verified by mutation: nine deliberate breakages of the provider - single-value
PUT, empty PUT instead of DELETE, coercing an unreadable read to empty,
treating 3xx as success, following redirects, swallowing transport errors,
dropping the dot-segment guard, lowering the TTL below the API floor, and
removing sanitization - are each caught by at least one test.
2026-08-07 09:01:23 +03:00
mustafa.ulukaya 8e534ef170 feat(dns): add GoDaddy DNS-01 provider (API Key+Secret / PAT, additive RRset writes)
Registers a third pluggable DNS provider for ACME DNS-01 alongside Manual and
Cloudflare. Credentials are an API Key + Secret pair; leaving the Secret blank
sends the Key as a Personal Access Token (Bearer), which is the migration path
as GoDaddy retires the sso-key scheme.

GoDaddy's Domains API v1 has no per-value TXT write: PUT on a record set
replaces every value at that name. A certificate covering example.com and
*.example.com publishes two different TXT values at the same
_acme-challenge.example.com, so add/remove are read-modify-write - read the
current set, merge, put the whole list back - with empty-data tombstone rows
filtered out (they are rejected on echo) and DELETE used for the last value,
since PUT with an empty array is rejected.

The zone-wide sibling endpoints (.../records/TXT and .../records) would wipe
SPF/DKIM/DMARC and the whole zone respectively, so the record path is built in
one place that refuses an empty or dot segment. An unreadable record-set read
fails closed rather than being treated as an empty set, because the PUT that
follows would otherwise destroy the coexisting values.

Zone lookup walks name suffixes probing the records API rather than the domain
listing, so zones delegated to GoDaddy nameservers resolve and accounts that
are rejected from the domain-details endpoint still work. Credential and
eligibility failures during the walk surface instead of being reported as
"no managed domain".

Provider errors are sanitized at the single point where GoDaddy-supplied text
enters a message, since those strings are persisted to order events and shown
in the UI. No new dependency, no schema change, no frontend change - the
credential form is rendered from the provider schema.
2026-08-07 09:01:12 +03:00
taylanbakircioglu 71786200dd docs(v1.9.0): correct upgrade notes on built-in role re-seed + backfill 1.8.8-1.8.10 release notes
Found during a v1.8.10 to v1.9.0 upgrade drill on a populated database
(schema v9 to v10) before releasing. Documentation only, no code change.

1. The v1.9.0 upgrade notes said "custom roles need no changes", which reads
   as "role data is untouched". It is not: because the SCHEMA_VERSION bump
   re-runs the whole idempotent sequence, update_system_roles_to_enterprise_rbac()
   issues an unconditional UPDATE roles SET ... permissions = <defaults> for
   the four BUILT-IN roles. In the drill an `operator` role that had been
   narrowed by removing apply.execute and config.bulk_import came back with
   both restored (57 to 59 permissions). Operator-created roles are NOT
   affected; the re-seed matches the four built-in names only.

   This is pre-existing behaviour of every SCHEMA_VERSION bump and is
   documented as intentional in migrations.py, so it is not introduced by the
   CSR feature. The v1.7.0 upgrade notes carried this caveat and it was not
   carried forward. Restored, with an export and re-apply procedure.

   Also clarified why the admin password is safe: the default-user seeding is
   guarded by an existence check ("safer than ON CONFLICT"), not an upsert,
   so an operator-changed password survives.

2. README release notes jumped from v1.8.7 straight to v1.9.0 because
   v1.8.8, v1.8.9 and v1.8.10 were never backfilled. Added all three.
2026-08-05 23:43:14 +03:00
Taylan Bakırcıoğlu 33e3e8ef9d Merge pull request #50 from mustafaulukaya/feature/csr-creation
CSR creation (v1.9.0): in-app key + CSR generation and signed-certificate import.

Validated on a corporate pre-production environment before merge: backend suite 1145 to 1221 passed (+76), config generator output byte-identical to 1.8.10 against the same database, real haproxy 2.8 -c accepts a CSR-issued certificate, API surface additive only (+5 endpoints), populated v9 to v10 upgrade drill preserved all data, rollback to 1.8.10 starts cleanly, zero diff in the agent scripts.

Closes #49
2026-08-05 23:42:02 +03:00
mustafa.ulukaya 69e12f7459 chore(version): bump to 1.9.0 - CSR creation
- backend/version.json + frontend package version to 1.9.0
- README: feature list entry, CSR workflow section, SSL CSR API reference,
  v1.9.0 release notes
- UPGRADE_GUIDE: v1.9.0 section (additive ssl_csrs table, SCHEMA_VERSION
  9 -> 10, no RBAC changes, zero agent impact, rollback note)
2026-08-04 21:24:34 +03:00
mustafa.ulukaya af07d72514 feat(ui): add CSR tab to the SSL Certificates page
New CSRManagement component as a third tab (deep-linkable via ?tab=csr):

- Create modal: name (path-traversal-safe client rules mirroring the
  server), CN with wildcard support, SAN tag input, key algorithm select,
  optional subject fields in a collapse panel. On success the view modal
  opens immediately with the CSR PEM.
- View modal: subject/SAN summary, read-only CSR PEM with copy and a
  Download .csr button (Blob download).
- Import modal: paste signed certificate + optional chain, usage type,
  global/cluster scope with cluster multi-select, and an optional
  certificate-name override for collisions that appeared after CSR
  creation; SAN-drift warnings surface in a warning dialog.
- Duplicate action pre-fills the create modal (forceRender so the form
  accepts values before first open); delete confirm spells out that a
  pending CSR key is destroyed permanently.
- SSL certificate table now renders a distinct CSR source tag next to
  the existing Manual / Auto (ACME) tags.
2026-08-04 21:24:34 +03:00
mustafa.ulukaya a6166d11b9 feat(ssl): add CSR generation and signed-certificate import (backend)
New /api/ssl/csrs endpoint group: generate a private key + CSR server-side
(RSA 2048/4096, ECDSA P-256/P-384; full subject + DNS SANs with wildcard
support), list/detail/delete CSRs, and import the CA-signed certificate.

- New ssl_csrs table (SCHEMA_VERSION 9 -> 10, additive + idempotent); the
  migration re-raises on failure so a failed run is retried instead of being
  stamped as applied.
- Import verifies the certificate against the stored key as a hard gate
  (match=None is treated as an integrity error, not a lenient pass), rejects
  malformed and expired certificates with 400, warns on SAN drift, and
  creates a normal ssl_certificates row (source=csr, cluster_id=NULL,
  last_config_status=PENDING) so it flows through the standard
  Apply Management -> agent pull pipeline.
- Concurrency: FOR UPDATE row lock serialises double-import and
  delete-during-import; a partial unique index reserves pending CSR names;
  soft-deleted same-name certs are reactivated preserving the row id.
- Security: no CSR endpoint ever returns the private key (explicit column
  lists, enforced by a static test); the key copy on the CSR row is NULLed
  after import; ssl.create/read/delete permissions enforced on every
  endpoint incl. reads; per-user rate limit on key generation, which runs
  in a worker thread; csr_id and cluster_ids are int32-guarded.
- ssl_service: extract _prepare_cert_fields from create_cert_row (behaviour
  unchanged, extraction tests untouched) and add stage_ssl_config_versions
  reusing the exact ssl-{id}-create-{ts} version-name scheme.
- Tests: crypto round-trip for all four algorithms, model validation,
  import-flow unit tests, endpoint auth/permission pinning, migration and
  key-non-exposure static assertions.
2026-08-04 21:23:57 +03:00
25 changed files with 4569 additions and 41 deletions
+26 -3
View File
@@ -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
# ============================================================================
+56 -4
View File
@@ -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.
+120
View File
@@ -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
+79 -2
View File
@@ -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.
+2
View File
@@ -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)
+251
View File
@@ -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
+375
View File
@@ -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)
+461
View File
@@ -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,
}
+2 -2
View File
@@ -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
+512
View File
@@ -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
View File
@@ -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
+469
View File
@@ -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"]
+129
View File
@@ -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
+104
View File
@@ -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"
+172
View File
@@ -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")
+66
View File
@@ -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"
)
+171
View File
@@ -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
View File
@@ -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).
+120
View File
@@ -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
+3 -3
View File
@@ -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"
}
+2 -2
View File
@@ -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 -1
View File
@@ -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": {
+886
View File
@@ -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;
+22 -7
View File
@@ -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} />,
},
]}
/>