Compare commits

...

8 Commits

Author SHA1 Message Date
taylanbakircioglu 02667bbda4 test(acme): make the wizard regression suite pass under the default jest timeout
Follow-up to #57. The contributed tests render the whole ACMEAutomation tree
(antd Steps + Form + Select) and drive it through all three wizard steps, which
takes 4-9 seconds per test. Under jest's default 5s per-test limit two of them
failed, so `npm test` did not pass as shipped:

  ✕ an explicitly picked HTTP-01 account survives the step change and is what
    gets submitted        -> Exceeded timeout of 5000 ms
  ✕ the wildcard guard still applies on the Review step, where Submit lives
                          -> Exceeded timeout of 5000 ms

The PR's reported 5/5 holds only when the runner is invoked with an explicit
--testTimeout. Setting it in the file instead means the suite passes however it
is invoked, which matters because the frontend image build runs `npm run build`
and never the tests, so nothing else would have caught this.

Verified with the default runner (no flags) after the change: 5/5 pass.

Test-only. No production code touched.
2026-08-09 03:17:15 +03:00
Taylan Bakırcıoğlu c97df53da8 Merge pull request #57 from appouse/feature/multiple-account-acme-automation
Multi-account ACME: the certificate wizard honours the selected account (v1.10.3).

Verified before merge. The contributed regression tests were run against the PRE-FIX component to confirm they actually catch the bug: 4 failed / 1 passed, reproducing the reported symptom exactly (Expected "http-01" / Received "dns-01", Review rendering the default account's email instead of the picked one, and the wildcard warning absent). With the fix: 5/5 pass. The three root causes were each confirmed against the tree — rc-field-form's useWatch honours options.preserve (es/useWatch.js:66), the backend default is ORDER BY created_at DESC (routers/letsencrypt.py:505) while the list is served ORDER BY id (:213), and the null-dns_provider normalization matches the backend's own at :473. Frontend production build succeeds; backend suite unchanged at 1243 passed.
2026-08-09 03:11:10 +03:00
mustafa.ulukaya be01ddd119 test(acme): drive the certificate wizard to pin multi-account account resolution
Renders the real component and walks it through all three wizard steps, because
the bug these cover was invisible to any unit test: it only appeared once the
wizard advanced PAST the step that owns the account Select, since Form.useWatch
reports only currently-rendered fields.

Assertions describe behaviour rather than markup - the primary evidence is the
POST body (account_id paired with challenge_type), compared against a fixture
whose DNS-01 account is deliberately both the lower id and the older account,
which is the exact shape that made the UI default and the backend default
disagree.

Verified by running the suite against the pre-fix component: the picked-account
test reports challenge_type "dns-01" where "http-01" is expected, the Review
test shows "Active (dns@example.com) / Challenge Method: DNS-01 / DNS provider:
godaddy" for a chosen HTTP-01 account - the reported symptom reproduced - and
the wildcard-guard test finds Submit enabled. Four fail, one passes: the DNS-01
selection path, kept as a positive control because it worked before (the
default the wizard fell back to happened to be the DNS-01 account) and must
keep working after.

Adds src/setupTests.js with the ResizeObserver and matchMedia polyfills jsdom
lacks and Ant Design 5 needs before any Select can open.
2026-08-08 12:41:52 +03:00
mustafa.ulukaya bbd8359f50 docs(v1.10.3): document the multi-account ACME wizard fix
Release note covering the three compounding faults, and upgrade notes stating
that this is frontend-only with nothing to do on upgrade. Two points are called
out for operators rather than glossed: installations that never picked an
account explicitly were already using the newest valid account, so only the
preview was wrong; and the wildcard guard that stopped applying on Review was a
lost warning, not a correctness hole, since the backend still rejected those
requests.
2026-08-08 12:21:02 +03:00
mustafa.ulukaya dd7b7822bf chore(version): bump to 1.10.3 - multi-account ACME wizard fix 2026-08-08 12:21:02 +03:00
mustafa.ulukaya c4139bb11a fix(acme): honour the selected ACME account in the certificate wizard
With more than one account registered, picking an HTTP-01 account in Request
ACME Certificate still submitted a DNS-01 request, which the API rejected with
"The selected ACME account has no DNS provider configured for DNS-01."

Three faults compounded:

1. Form.useWatch reports only fields that are currently rendered. The account
   Select lives on the Configuration step, so the moment the wizard advanced to
   Review the watch read undefined and the wizard fell back to the default
   account - even though the value was still in the form store. Both wizard
   watches now pass preserve: true. The same fault silently disabled the
   wildcard guard on Review, the one step where Submit lives.

2. The UI and the backend disagreed on which account is the default. The
   backend takes the newest valid account (ORDER BY created_at DESC); the UI
   took the first valid entry of a list ordered by id, i.e. the oldest - the
   opposite account whenever the two differ. The wizard now resolves the same
   account and sends account_id explicitly, so there is no guess left to
   disagree about.

3. account_id was read from the form store while challenge_type came from the
   reverted account object. Both are now derived from one resolved account, so
   the pair can no longer describe two different accounts.

Also: the Review step showed the default account's address instead of the
chosen one, and Submit stayed enabled when the resolved account was
deactivated (the fallback can land on a non-valid account, and the Select
lists deactivated accounts).

Single-account installations are unaffected.
2026-08-08 12:21:02 +03:00
taylanbakircioglu dbb9189f16 fix(ui): make Apply Management readable in dark mode (v1.10.2)
Three dark-mode defects reported on the Apply Management page, all the same
class of bug: light-mode colour literals hardcoded where theme tokens belong.

1. The "Pending Changes" box was painted background #fffbe6 with border
   #ffe58f. In dark mode the text on top is light, so the version name,
   timestamp and "View Change" link sat on a cream panel and were unreadable.
   Measured contrast was 1.03:1; it is now 11.50:1 (secondary text 1.03:1 ->
   7.02:1).

2. The added/removed rows in the View Change diff used #f6ffed/#52c41a and
   #fff2f0/#ff4d4f, which stayed near-white inside the otherwise dark diff
   panel. Now 5.49:1 (added) and 4.01:1 (removed), from 2.21:1 and 2.99:1.

3. The "Apply All Configuration Changes" confirm dialog came up white. This one
   is not a colour literal: in Ant Design 5 the STATIC Modal.confirm / message /
   notification APIs render into their own detached root and never see the app's
   ConfigProvider, so they always fall back to the light algorithm. Registering
   ConfigProvider.config({ holderRender }) once at the app root wraps that
   detached root in the same ConfigProvider. Verified against the installed antd
   5.29.3 source rather than assumed: config-provider/index.js sets
   globalHolderRender, and modal/confirm.js wraps the dialog with it. This fixes
   EVERY static dialog in the application — 12 components call Modal.confirm —
   not just this page.

While in the file, six more instances of the same bug were fixed: the error
Alert border, the VIP pending-delete row, two ACME/pending version panels, the
applied-version panel and the agent-error recommendation box.

Light mode is byte-identical. Each token resolves under the default algorithm
to exactly the literal it replaced (colorWarningBg -> #fffbe6, colorSuccessBg ->
#f6ffed, colorErrorBg -> #fff2f0, colorInfoBg, colorErrorBorder, ...), so this
release can only change dark mode. Contrast was measured by resolving the real
design tokens under both algorithms and computing WCAG ratios, not by eye.

Note the diff rows were already low-contrast in LIGHT mode (2.21:1 and 2.99:1)
and remain so; that is the design system's own success/error pair and changing
it would alter the established light-mode appearance, so it is left alone.

holderRender is registered in an effect rather than during render, since
ConfigProvider.config() mutates antd module state; effects still run long
before a user can click anything that opens a static dialog.

Frontend only: no schema, no SCHEMA_VERSION bump, no API change, no environment
variable, zero agent impact. Backend suite unchanged at 1243 passed.
2026-08-08 02:39:23 +03:00
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
13 changed files with 718 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
# ============================================================================
+3
View File
@@ -2474,6 +2474,9 @@ Developed with ❤️ for the HAProxy community
## Release Notes
- **v1.10.3** (2026-08-08) — **Multi-account ACME: the certificate wizard honours the account you pick**: with more than one ACME account registered, picking an **HTTP-01** account in *Request ACME Certificate* still produced a **DNS-01** request. Three faults compounded. (1) `Form.useWatch` reports only fields that are currently **rendered**, and the account `Select` lives on the *Configuration* step — so as soon as the wizard advanced to *Review* the watch read `undefined` and the wizard silently reverted to the default account, even though the value was still in the form store; the watches now pass `preserve: true`. The same fault disabled the **wildcard guard** on *Review*, the one step where Submit lives. (2) The UI and the backend disagreed on which account is the *default*: the backend takes the **newest** valid account (`ORDER BY created_at DESC`), the UI took the **oldest** entry of a list ordered by id — the opposite account whenever the two differ. The wizard now resolves the same one, and sends `account_id` **explicitly** so there is no guess left to disagree about. (3) `account_id` was read from the form store while `challenge_type` came from the reverted account object, so the request asked for DNS-01 validation on an HTTP-01 account and the API answered `The selected ACME account has no DNS provider configured for DNS-01.` — both are now derived from one resolved account. The *Review* step also showed the default account's address instead of the chosen one, and Submit stayed enabled for a deactivated account; both fixed. Frontend only — no schema, API-shape, agent or rendered-config changes, and single-account installations behave exactly as before.
- **v1.10.2** (2026-08-08) — **Dark mode fixes on Apply Management**: several panels on the Apply Management page were painted with light-mode colour literals, so in dark mode the **Pending Changes** box rendered as a cream panel with light text on it — measured contrast **1.03:1**, effectively unreadable, now **11.50:1**. The same bug affected the added/removed rows in the *View Change* diff (2.21:1 and 2.99:1, now 5.49:1 and 4.01:1), the ACME and pending-version panels, the VIP pending-delete row, and the agent-error recommendation box; all now derive from theme tokens. Separately, **static confirm dialogs came up white in dark mode**: in Ant Design 5 the static `Modal.confirm` / `message` / `notification` APIs render into their own detached root and never see the app's `ConfigProvider`, so they always used the light algorithm. Registering `ConfigProvider.config({ holderRender })` once at the app root fixes **every** static dialog in the application (12 components use them), not only this page. Light mode is byte-identical — each token resolves under the default algorithm to exactly the literal it replaced. Frontend only: no schema, API, environment or agent change.
- **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.
+87
View File
@@ -1,3 +1,90 @@
# Upgrade Notes — v1.10.3 (Multi-account ACME wizard fix)
**Frontend only. Nothing to do on upgrade.** No schema, no `SCHEMA_VERSION` bump, no API change, no
environment variable, zero agent impact. Installations with a single ACME account behave exactly as
before.
- **What was broken:** with **more than one** ACME account registered, the *Request ACME
Certificate* wizard did not honour the account you selected. Choosing an HTTP-01 account still
submitted a DNS-01 request, which the API rejected with
`The selected ACME account has no DNS provider configured for DNS-01.` The *Review* step also
named the default account rather than the chosen one, so the mismatch was invisible before
submitting.
- **Default account:** the wizard previously previewed the **oldest** valid account while the
backend uses the **newest** (`ORDER BY created_at DESC`). If you never picked an account
explicitly and have several, requests were already going to the newest one — only the preview was
wrong. The wizard now previews that same account, marks it `(default)`, and sends `account_id`
explicitly so the two can no longer diverge.
- **Wildcard guard:** the client-side "wildcard requires a DNS-01 account" block silently stopped
applying on the *Review* step. Requests were still rejected by the backend, so nothing incorrect
was ever issued — you now get the warning before submitting instead of an error after.
- **No action needed on existing certificates or orders.** Nothing about issuance, renewal or the
stored accounts changes; only how the wizard resolves which account a new request uses.
- **Rollback:** downgrade freely. This release changes frontend behaviour only.
---
# Upgrade Notes — v1.10.2 (Dark mode fixes on Apply Management)
**Frontend only. Nothing to do on upgrade.** No schema, no `SCHEMA_VERSION` bump, no API change,
no environment variable, zero agent impact. Light mode is byte-identical: every colour swapped in
this release resolves, under the default algorithm, to exactly the literal it replaced
(`colorWarningBg` → `#fffbe6`, `colorSuccessBg` → `#f6ffed`, `colorErrorBg` → `#fff2f0`, …), so
only dark mode changes.
- **Apply Management panels** were painted with light-mode colour literals, so in dark mode the
"Pending Changes" box rendered as a cream panel with light text on it. Measured contrast was
**1.03:1** — effectively invisible. It is now **11.50:1**. The same class of bug affected the
diff rows in *View Change* (2.21:1 and 2.99:1, now 5.49:1 and 4.01:1), the ACME/pending version
panels, the VIP pending-delete row and the agent-error recommendation box.
- **Static confirm dialogs came up white in dark mode.** In Ant Design 5 the static
`Modal.confirm` / `message` / `notification` APIs render into their own detached root and never
see the app's `ConfigProvider`, so they always used the light algorithm. This release registers
`ConfigProvider.config({ holderRender })` once at the app root, which fixes **every** static
dialog in the app (12 components use them), not just Apply Management.
- **Rollback:** downgrade freely. This release changes rendering only.
---
# 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
+35 -6
View File
@@ -19,8 +19,14 @@ 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 stored PLAINTEXT, consistent with every other key in the
system (ssl_certificates.private_key_content, letsencrypt_orders.cert_private_key).
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.
"""
@@ -39,6 +45,7 @@ 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__)
@@ -186,8 +193,14 @@ async def assert_csr_name_available(conn, name: str) -> None:
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."""
"""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(
"""
@@ -203,7 +216,7 @@ async def insert_csr_row(conn, payload: Any, bundle: Dict[str, Any], user_id: Op
json.dumps(bundle['sans']),
payload.key_algorithm,
bundle['csr_pem'],
bundle['private_key_pem'],
stored_key,
user_id,
)
except asyncpg.exceptions.UniqueViolationError:
@@ -243,8 +256,7 @@ async def import_signed_certificate(conn, csr_id: int, imp: Any, user_id: Option
"Create a new CSR to reissue."
),
)
stored_key = row['private_key_pem']
if not stored_key:
if not row['private_key_pem']:
raise HTTPException(
status_code=500,
detail=(
@@ -252,6 +264,23 @@ async def import_signed_certificate(conn, csr_id: int, imp: Any, user_id: Option
"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']
+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
+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.10.0",
"releaseName": "GoDaddy DNS provider for ACME DNS-01",
"releaseDate": "2026-08-07"
"version": "1.10.3",
"releaseName": "Multi-account ACME — the wizard honours the selected account",
"releaseDate": "2026-08-08"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "haproxy-openmanager-frontend",
"version": "1.10.0",
"version": "1.10.3",
"description": "HAProxy Load Balancer Management UI",
"license": "AGPL-3.0-or-later",
"dependencies": {
+23 -5
View File
@@ -484,12 +484,30 @@ function AppContent() {
function ThemedApp() {
const { isDarkMode } = useTheme();
const antdTheme = {
algorithm: isDarkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
};
// Ant Design 5: the STATIC message/notification/Modal.confirm APIs render into their own
// detached root, so they do not see this ConfigProvider and always fall back to the light
// algorithm — a confirm dialog came up white while the app was in dark mode. `holderRender`
// wraps that detached root in the same ConfigProvider, which fixes every static call in the
// app at once (12 components use Modal.confirm) instead of migrating each one to
// App.useApp(). In an effect rather than during render: ConfigProvider.config() mutates
// antd module state, and effects still run long before a user can click anything that opens
// a static modal. Re-registered on theme change so the toggle takes effect immediately.
React.useEffect(() => {
ConfigProvider.config({
holderRender: (children) => (
<ConfigProvider theme={{ algorithm: isDarkMode ? theme.darkAlgorithm : theme.defaultAlgorithm }}>
{children}
</ConfigProvider>
),
});
}, [isDarkMode]);
return (
<ConfigProvider
theme={{
algorithm: isDarkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
}}
>
<ConfigProvider theme={antdTheme}>
<AuthProvider>
<ClusterProvider>
<ProgressProvider>
+47 -11
View File
@@ -107,8 +107,14 @@ const ACMEAutomation = () => {
const [confirming, setConfirming] = useState(false);
const regChallengeType = Form.useWatch('challenge_type', registerForm);
const regDnsProvider = Form.useWatch('dns_provider', registerForm);
const wizardAccountId = Form.useWatch('account_id', wizardForm);
const wizardDomains = Form.useWatch('domains', wizardForm);
// `preserve: true` is load-bearing, not a nicety. Each wizard step renders only its own fields —
// the domain list on Domains, the account Select on Configuration — and a plain useWatch reports
// only fields that are currently REGISTERED, so every one of these read `undefined` from the
// Review step onward even though the values were still in the form store. That is what made
// Review (and the request it submits) silently fall back to the default ACME account, and it
// disabled the wildcard guard at exactly the step where Submit lives.
const wizardAccountId = Form.useWatch('account_id', { form: wizardForm, preserve: true });
const wizardDomains = Form.useWatch('domains', { form: wizardForm, preserve: true });
const selectedDnsProvider = dnsProviders.find(p => p.name === regDnsProvider) || null;
// Issue #35: per-account DNS credential management (view/replace/clear after creation).
@@ -217,7 +223,16 @@ const ACMEAutomation = () => {
const pendingOrders = orders.filter(o =>
o.status === 'pending' || o.status === 'processing' || o.status === 'ready' || isOrderStuck(o)
);
const activeAccount = accounts.find(a => a.status === 'valid') || null;
// The account a request lands on when it carries no explicit account_id. This MUST match the
// backend, which takes `ORDER BY created_at DESC LIMIT 1` (routers/letsencrypt.py). The list
// arrives ORDER BY id, so picking the first valid entry would preview the OLDEST account — the
// opposite one. With two accounts of different challenge methods that made the wizard describe
// DNS-01 while the request would actually have gone to an HTTP-01 account.
const activeAccount = accounts.filter(a => a.status === 'valid').reduce((best, a) => {
if (!best) return a;
const delta = new Date(a.created_at) - new Date(best.created_at);
return delta > 0 || (delta === 0 && a.id > best.id) ? a : best;
}, null);
const acmeAccount = activeAccount || (accounts.length > 0 ? accounts[accounts.length - 1] : null);
const acmeEnabledClusters = clusters.filter(c => c.acme_enabled && c.is_active);
// Issue #35: the cert wizard adapts to the selected account's challenge method.
@@ -260,18 +275,26 @@ const ACMEAutomation = () => {
return;
}
setSubmitting(true);
// Resolve the chosen account's challenge method so DNS-01/wildcard requests are explicit.
// Use the same resolution as the wizard description (wizardAccount) so what the user reviewed
// matches what is sent.
const challengeType = wizardAccount?.challenge_type; // 'http-01' | 'dns-01' | undefined
// Resolve the account ONCE and derive everything else from that single object. The id and the
// challenge method used to come from different places — account_id from the form store,
// challenge_type from wizardAccount — so whenever those two disagreed the request asked for
// DNS-01 validation on an HTTP-01 account and the backend answered "The selected ACME account
// has no DNS provider configured for DNS-01."
const selectedAccountId = values.account_id ?? wizardAccount?.id ?? null;
const account = accounts.find(a => a.id === selectedAccountId) || wizardAccount || null;
const challengeType = account?.challenge_type; // 'http-01' | 'dns-01' | undefined
// Manual DNS-01 can't auto-renew (the wizard shows the switch off+disabled). Send false to
// match the displayed state rather than relying only on the backend to override it.
const autoRenew = wizardDnsManual ? false : (values.auto_renew !== false);
const isManualDns01 = challengeType === 'dns-01' && (account?.dns_provider || 'manual') === 'manual';
const autoRenew = isManualDns01 ? false : (values.auto_renew !== false);
const res = await axios.post('/api/letsencrypt/certificates', {
domains: values.domains,
cluster_ids: values.cluster_ids || [],
auto_renew: autoRenew,
account_id: values.account_id || null,
// Always explicit: sending the resolved id removes the frontend/backend "default account"
// guess, which disagreed (the UI previewed the oldest valid account, the backend used the
// newest) and made the Review step describe an account the request never went to.
account_id: account?.id ?? null,
challenge_type: challengeType || undefined,
});
message.success(res.data?.message || 'Certificate request submitted');
@@ -1092,7 +1115,17 @@ const ACMEAutomation = () => {
message="Prerequisite Check"
description={
<ul style={{ margin: 0, paddingLeft: 20 }}>
<li>ACME Account: {activeAccount ? <Tag color="success">Active ({activeAccount.email})</Tag> : <Tag color="error">No active account</Tag>}</li>
{/* The account the request will actually use — NOT `activeAccount`, which is only
the default and would name a different account whenever the user picked one. */}
<li>ACME Account: {wizardAccount
? <Tag color={wizardAccount.status === 'valid' ? 'success' : 'error'}>
{wizardAccount.email}{wizardAccount.status !== 'valid' ? ` (${wizardAccount.status})` : ''}
</Tag>
: <Tag color="error">No active account</Tag>}
{wizardAccount && accounts.length > 1 && !wizardAccountId && (
<Typography.Text type="secondary" style={{ fontSize: 12 }}> (default)</Typography.Text>
)}
</li>
{wizardIsDns01 ? (
<>
<li>Challenge Method: <Tag>DNS-01</Tag> (TXT record; no port 80 / ACME routing needed)</li>
@@ -1324,8 +1357,11 @@ const ACMEAutomation = () => {
Next
</Button>
)}
{/* Gate on the account the request will actually use, and on its status: with no valid
account wizardAccount falls back to the newest (deactivated) one, and the Select lists
deactivated accounts too, so a bare null-check would leave Submit enabled. */}
{wizardStep === wizardSteps.length - 1 && (
<Button type="primary" onClick={handleRequestCert} loading={submitting} disabled={!activeAccount || wizardWildcardBlocked || wizardDns01Disabled || (!wizardIsDns01 && acmeEnabledClusters.length === 0)}>
<Button type="primary" onClick={handleRequestCert} loading={submitting} disabled={wizardAccount?.status !== 'valid' || wizardWildcardBlocked || wizardDns01Disabled || (!wizardIsDns01 && acmeEnabledClusters.length === 0)}>
Submit Request
</Button>
)}
+20 -12
View File
@@ -1092,7 +1092,7 @@ const ApplyManagement = () => {
style={{
marginBottom: 24,
borderRadius: 8,
border: '1px solid #ffccc7',
border: `1px solid ${token.colorErrorBorder}`,
boxShadow: '0 2px 8px rgba(255, 77, 79, 0.15)'
}}
message={
@@ -1348,7 +1348,7 @@ const ApplyManagement = () => {
HA / VIP Changes ({pendingChanges.vips.length})
</Title>
{pendingChanges.vips.map(item => (
<div key={`vip-${item.id}`} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', marginBottom: 6, border: item.pending_delete ? '1px solid #ffccc7' : '1px solid #f0f0f0', borderRadius: 6, background: item.pending_delete ? '#fff1f0' : undefined }}>
<div key={`vip-${item.id}`} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', marginBottom: 6, border: `1px solid ${item.pending_delete ? token.colorErrorBorder : token.colorBorderSecondary}`, borderRadius: 6, background: item.pending_delete ? token.colorErrorBg : undefined }}>
<CloudServerOutlined style={{ color: item.pending_delete ? '#cf1322' : '#13c2c2' }} />
<span style={{ fontWeight: 500 }}>{item.name}</span>
<Tag>{item.virtual_ip}/{item.prefix_length}</Tag>
@@ -1374,8 +1374,8 @@ const ApplyManagement = () => {
const isEnable = /^cluster-\d+-acme-enable-/.test(v.version_name);
return (
<div key={v.id} style={{
padding: 10, border: '1px dashed #1890ff', borderRadius: 6, marginBottom: 8,
display: 'flex', alignItems: 'center', justifyContent: 'space-between', backgroundColor: '#f0f8ff'
padding: 10, border: `1px dashed ${token.colorPrimary}`, borderRadius: 6, marginBottom: 8,
display: 'flex', alignItems: 'center', justifyContent: 'space-between', backgroundColor: token.colorInfoBg
}}>
<span style={{ fontFamily: 'monospace' }}>{v.version_name}</span>
<span>
@@ -1419,7 +1419,7 @@ const ApplyManagement = () => {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: '#f0f8ff'
backgroundColor: token.colorInfoBg
}}>
<span style={{ fontFamily: 'monospace' }}>{v.version_name}</span>
<Tag color="orange">PENDING</Tag>
@@ -1443,7 +1443,7 @@ const ApplyManagement = () => {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: '#f6ffed'
backgroundColor: token.colorSuccessBg
}}>
<span style={{ fontFamily: 'monospace' }}>{v.version_name}</span>
<Tag color="orange">PENDING</Tag>
@@ -1518,9 +1518,13 @@ const ApplyManagement = () => {
</Descriptions>
</div>
{/* Pending Versions Section */}
{/* Pending Versions Section.
Theme tokens, not the light-mode literals #fffbe6/#ffe58f: in dark mode those
produced a cream panel with light text on it, so the version name, timestamp
and "View Change" link were unreadable. colorWarningBg/Border track the
algorithm, so the "pending" tint survives in both themes. */}
{pendingVersions.length > 0 && (
<div style={{ marginBottom: 24, background: '#fffbe6', borderRadius: 8, border: '1px solid #ffe58f', padding: '16px 16px 8px' }}>
<div style={{ marginBottom: 24, background: token.colorWarningBg, borderRadius: 8, border: `1px solid ${token.colorWarningBorder}`, padding: '16px 16px 8px' }}>
<Title level={5} style={{ marginTop: 0 }}>
<ClockCircleOutlined style={{ marginRight: 8, color: '#faad14' }} />
Pending Changes ({pendingVersions.length})
@@ -1765,8 +1769,8 @@ const ApplyManagement = () => {
<div>
{/* Parsed suggestion */}
{agentSync.parsed_error?.suggestion && (
<div style={{ marginBottom: 12, padding: '8px 12px', background: '#fff7e6', borderRadius: 4, border: '1px solid #ffd591' }}>
<Text strong style={{ color: '#ad4e00' }}>Recommendation: </Text>
<div style={{ marginBottom: 12, padding: '8px 12px', background: token.colorWarningBg, borderRadius: 4, border: `1px solid ${token.colorWarningBorder}` }}>
<Text strong style={{ color: token.colorWarningText }}>Recommendation: </Text>
<Text>{agentSync.parsed_error.suggestion}</Text>
</div>
)}
@@ -1951,13 +1955,17 @@ const ApplyManagement = () => {
{diffData.changes && diffData.changes.length > 0 ? (
diffData.changes.map((change, index) => (
<div key={index} style={{ marginBottom: '4px' }}>
{/* Token-based, not the light literals #f6ffed/#fff2f0: those stayed
near-white in dark mode, so the added/removed rows glared against
the dark diff panel around them. colorSuccessBg/colorErrorBg darken
with the algorithm while keeping the green/red semantics. */}
{change.type === 'added' && (
<div style={{ backgroundColor: '#f6ffed', color: '#52c41a', padding: '2px 8px', borderLeft: '3px solid #52c41a' }}>
<div style={{ backgroundColor: token.colorSuccessBg, color: token.colorSuccessText, padding: '2px 8px', borderLeft: `3px solid ${token.colorSuccess}` }}>
+ {change.line}
</div>
)}
{change.type === 'removed' && (
<div style={{ backgroundColor: '#fff2f0', color: '#ff4d4f', padding: '2px 8px', borderLeft: '3px solid #ff4d4f' }}>
<div style={{ backgroundColor: token.colorErrorBg, color: token.colorErrorText, padding: '2px 8px', borderLeft: `3px solid ${token.colorError}` }}>
- {change.line}
</div>
)}
@@ -0,0 +1,198 @@
/**
* v1.10.3 regression tests: with more than one ACME account, the certificate wizard must honour the
* account the operator picked — on the Review step AND in the request it submits.
*
* These drive the real component through all three wizard steps rather than testing a helper,
* because the bug was invisible until the wizard ADVANCED PAST the step that owns the account
* Select: Form.useWatch reports only fields that are currently rendered, so on Review the selection
* read `undefined` and the wizard silently reverted to the default account. A unit test of any
* single function would have passed the whole time.
*/
import React from 'react';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import axios from 'axios';
import ACMEAutomation from '../ACMEAutomation';
// These render the whole ACMEAutomation tree (antd Steps + Form + Select) three times per test and
// take 4-7s each, so jest's default 5s per-test limit fails two of them. Raise it here rather than
// relying on the runner being invoked with --testTimeout, so `npm test` passes as shipped.
jest.setTimeout(30000);
jest.mock('axios');
jest.mock('react-router-dom', () => ({ useNavigate: () => jest.fn() }));
jest.mock('../../contexts/ClusterContext', () => ({
useCluster: () => ({ clusters: [], selectCluster: jest.fn() }),
}));
// The account list arrives ORDER BY id while the backend's default is ORDER BY created_at DESC, so
// this fixture is deliberately the shape that made the two disagree: the DNS-01 account is BOTH the
// lower id and the older account, the HTTP-01 account is the newest. Picking the first valid entry
// (as the UI used to) yields the DNS-01 account; the backend would have used the HTTP-01 one.
const DNS_ACCOUNT = {
id: 1, email: 'dns@example.com', status: 'valid',
challenge_type: 'dns-01', dns_provider: 'godaddy',
created_at: '2026-05-06T00:00:00Z', directory_url: 'https://acme.zerossl.com/v2/DV90',
};
const HTTP_ACCOUNT = {
id: 2, email: 'http@example.com', status: 'valid',
challenge_type: 'http-01', dns_provider: null,
created_at: '2026-08-08T00:00:00Z', directory_url: 'https://acme.zerossl.com/v2/DV90',
};
const GET_ROUTES = {
'/api/letsencrypt/orders': [],
'/api/letsencrypt/accounts': [DNS_ACCOUNT, HTTP_ACCOUNT],
'/api/letsencrypt/renewal-schedule': [],
'/api/clusters': { clusters: [{ id: 10, name: 'cluster-a', acme_enabled: true, is_active: true }] },
'/api/letsencrypt/prerequisites': { steps: [] },
'/api/letsencrypt/dns-providers': {
dns01_enabled: true,
providers: [
{ name: 'manual', label: 'Manual', automated: false, credential_fields: [] },
{
name: 'godaddy', label: 'GoDaddy', automated: true,
credential_fields: [
{ key: 'api_key', label: 'API Key', type: 'password', required: true, max_length: 200, help: 'Production key' },
{ key: 'api_secret', label: 'API Secret', type: 'password', required: false, max_length: 200, help: 'Blank for a PAT' },
],
},
],
},
};
beforeEach(() => {
jest.clearAllMocks();
axios.get.mockImplementation((url) =>
Promise.resolve({ data: Object.prototype.hasOwnProperty.call(GET_ROUTES, url) ? GET_ROUTES[url] : {} })
);
axios.post.mockResolvedValue({ data: { message: 'ok', order_id: 99 } });
});
const modal = () => document.querySelector('.ant-modal-content');
/** Open the wizard and wait for the Domains step. */
async function openWizard() {
render(<ACMEAutomation />);
// Settle the initial fetch on a signal that does NOT depend on which account the component picks
// as its default — that choice is one of the things under test, so waiting on an account address
// here would make every test fail at the same early point instead of at its own assertion.
await waitFor(() => expect(axios.get).toHaveBeenCalledWith('/api/letsencrypt/accounts'));
await act(async () => {});
fireEvent.click(screen.getByRole('button', { name: /Request Certificate/i }));
await screen.findByText('Domain Names');
}
/** antd wires the Form.Item name onto the inner input's id, which is the only stable handle. */
function typeDomain(domain) {
const input = document.getElementById('domains');
fireEvent.change(input, { target: { value: domain } });
fireEvent.keyDown(input, { key: 'Enter', keyCode: 13 });
}
async function selectAccount(email) {
await act(async () => {
fireEvent.mouseDown(document.getElementById('account_id'));
});
const option = [...document.querySelectorAll('.ant-select-item-option')]
.find((o) => o.textContent.includes(email));
if (!option) throw new Error(`account option not found: ${email}`);
await act(async () => {
fireEvent.click(option);
});
}
const next = async () => {
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /^Next$/ }));
});
};
const submitButton = () => screen.getByRole('button', { name: /Submit Request/i });
async function gotoReview({ domain = 'example.com', account } = {}) {
await openWizard();
typeDomain(domain);
await next();
// Wait for the Configuration step to actually MOUNT. The transition is async (validateFields
// returns a promise) and the text "ACME Account" also appears on the dashboard card behind the
// modal, so waiting on that text can resolve while the wizard is still on step 1.
await waitFor(() => expect(document.getElementById('account_id')).toBeTruthy());
if (account) await selectAccount(account);
await next();
await screen.findByText('Prerequisite Check');
}
/** The Review step's rendered text. Compared as a whole string on purpose: the assertions must
* describe BEHAVIOUR, not the markup this change happens to use, so that a failure means the
* wizard resolved the wrong account rather than that a wrapper element moved. */
const reviewText = () => modal().textContent;
async function submitAndGetBody() {
fireEvent.click(submitButton());
await waitFor(() => expect(axios.post).toHaveBeenCalled());
const [url, body] = axios.post.mock.calls[0];
expect(url).toBe('/api/letsencrypt/certificates');
return body;
}
describe('certificate wizard with multiple ACME accounts', () => {
test('an explicitly picked HTTP-01 account survives the step change and is what gets submitted', async () => {
await gotoReview({ account: HTTP_ACCOUNT.email });
// The payload is the real evidence. account_id used to come from the form store while
// challenge_type came from an account object that had reverted to the default, so the API got
// "HTTP-01 account + dns-01" and answered 422 "no DNS provider configured for DNS-01".
const body = await submitAndGetBody();
expect(body.account_id).toBe(HTTP_ACCOUNT.id);
expect(body.challenge_type).toBe('http-01');
});
test('the Review step describes the picked HTTP-01 account, not the default', async () => {
await gotoReview({ account: HTTP_ACCOUNT.email });
expect(reviewText()).toContain(HTTP_ACCOUNT.email);
expect(reviewText()).not.toContain(DNS_ACCOUNT.email);
// "Challenge Method" and the provider name only render on the DNS-01 branch.
expect(reviewText()).not.toContain('Challenge Method');
expect(reviewText()).not.toContain('godaddy');
});
test('an explicitly picked DNS-01 account is described and submitted as DNS-01', async () => {
// Positive control: the DNS-01 path must keep working. This one passed before the fix too,
// because the default the wizard fell back to happened to be the DNS-01 account.
await gotoReview({ account: DNS_ACCOUNT.email });
expect(reviewText()).toContain(DNS_ACCOUNT.email);
expect(reviewText()).toContain('Challenge Method');
expect(reviewText()).toContain('godaddy');
const body = await submitAndGetBody();
expect(body.account_id).toBe(DNS_ACCOUNT.id);
expect(body.challenge_type).toBe('dns-01');
});
test('with no explicit pick the wizard previews and sends the same default the backend would use', async () => {
// The backend takes the NEWEST valid account; the UI used to preview the oldest entry of a
// list ordered by id, so Review described an account the request never went to.
await gotoReview();
expect(reviewText()).toContain(HTTP_ACCOUNT.email);
expect(reviewText()).not.toContain(DNS_ACCOUNT.email);
const body = await submitAndGetBody();
// Sent explicitly rather than left to the backend to guess a second time.
expect(body.account_id).toBe(HTTP_ACCOUNT.id);
expect(body.challenge_type).toBe('http-01');
});
test('the wildcard guard still applies on the Review step, where Submit lives', async () => {
// Same root cause as the account bug: `domains` is entered on the first step, so a
// non-preserving useWatch read undefined from Review onward and the guard evaporated at exactly
// the point it had to hold.
await gotoReview({ domain: '*.example.com', account: HTTP_ACCOUNT.email });
expect(reviewText()).toContain('Wildcard requires a DNS-01 account');
expect(submitButton()).toBeDisabled();
fireEvent.click(submitButton());
expect(axios.post).not.toHaveBeenCalled();
});
});
+26
View File
@@ -0,0 +1,26 @@
// Loaded automatically by react-scripts test (CRA convention).
import '@testing-library/jest-dom';
// jsdom implements neither of these, and Ant Design 5 needs both: rc-select renders its dropdown
// through rc-virtual-list (ResizeObserver) and the responsive Grid reads matchMedia. Without the
// polyfills any test that opens a Select throws before it can assert anything.
if (!global.ResizeObserver) {
global.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
};
}
if (!window.matchMedia) {
window.matchMedia = (query) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
});
}