diff --git a/README.md b/README.md index 08f5039..449ac3e 100644 --- a/README.md +++ b/README.md @@ -2474,6 +2474,7 @@ 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. diff --git a/UPGRADE_GUIDE.md b/UPGRADE_GUIDE.md index c7dd6ef..c3a1a95 100644 --- a/UPGRADE_GUIDE.md +++ b/UPGRADE_GUIDE.md @@ -1,3 +1,29 @@ +# 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, diff --git a/backend/version.json b/backend/version.json index 28f95c8..ef98c45 100644 --- a/backend/version.json +++ b/backend/version.json @@ -1,5 +1,5 @@ { - "version": "1.10.2", - "releaseName": "Dark mode fixes on Apply Management", + "version": "1.10.3", + "releaseName": "Multi-account ACME — the wizard honours the selected account", "releaseDate": "2026-08-08" } diff --git a/frontend/package.json b/frontend/package.json index 71e9378..2c1eec2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "haproxy-openmanager-frontend", - "version": "1.10.2", + "version": "1.10.3", "description": "HAProxy Load Balancer Management UI", "license": "AGPL-3.0-or-later", "dependencies": { diff --git a/frontend/src/components/ACMEAutomation.js b/frontend/src/components/ACMEAutomation.js index aa4877b..a07fa1d 100644 --- a/frontend/src/components/ACMEAutomation.js +++ b/frontend/src/components/ACMEAutomation.js @@ -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={