diff --git a/README.md b/README.md index eebd1cb..096fe5c 100644 --- a/README.md +++ b/README.md @@ -259,7 +259,7 @@ This architecture provides better security (no inbound connections to HAProxy se - **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 -- **External Account Binding (EAB)**: Support for CAs that require EAB (ZeroSSL, Google Trust Services) +- **External Account Binding (EAB)**: Support for CAs that require EAB (ZeroSSL, Google Trust Services). Enter the EAB Key ID and HMAC Key globally in Settings, or per-account in the Register Account dialog (a per-account value overrides the global setting; leave it blank to use the global one) - **Structured Error Diagnostics** *(v1.4.0)*: All ACME failures (challenge, finalize, download) persist structured JSON to `letsencrypt_orders.error_detail` for clear post-mortem analysis - **Audit Logging** *(v1.4.0)*: Every ACME operation (request, revoke, CA-chain import, account ops) is captured in `user_activity_logs` for compliance review - **ACME Diagnostic Panel** *(v1.5.0 — Issue #13)*: Live pre-flight + post-failure diagnostics (DNS / port-80 / routing / account / agents) and merged event timeline (`acme_order_events` + correlated `user_activity_logs`) accessible from the ACME Automation page; humanized error rendering for 11+ RFC8555 problem types with backwards-compatible fallback for legacy plain-string `error_detail`; per-user 5/min rate-limit @@ -2415,6 +2415,7 @@ Developed with ❤️ for the HAProxy community ## Release Notes +- **v1.8.1** (2026-06-24) — **ACME DNS-01 fixes** (Issue #35 follow-up): Cloudflare API tokens are now sanitized so a pasted token with quotes/spaces no longer fails with "Invalid request headers"; ZeroSSL/Google **External Account Binding (EAB)** can be entered per-account in the register dialog and EAB-required failures show a clear message; and **Apply Management** now categorizes cluster ACME enable/disable changes under their own "ACME Challenge Routing" section and **Apply/Reject All** correctly process them (previously "Rejected 0 HA/VIP change(s)"), consistent with every other entity. Fully backward compatible. - **v1.8.0** (2026-06-23) — **ACME DNS-01 challenge support** (Issue #35): Auto SSL can now validate via a **DNS TXT record** (`_acme-challenge.`) instead of HTTP-01 on port 80, enabling certificates for **internal/isolated clusters with no public ingress** and **wildcard** certificates (`*.example.com`). Pluggable **per-account DNS provider** (Manual + Cloudflare to start; credentials verified on save and **encrypted at rest**, never returned by the API or logged), the same **PENDING → APPLIED** pipeline, a **bounded automatic retry** on propagation lag, and a **DNS-01 event timeline** in the order detail. **Opt-in** via Settings → ACME (global switch, default off); **HTTP-01 is byte-for-byte unchanged**, with **zero agent or rendered-config changes**. Manual DNS-01 certificates cannot auto-renew unattended; the UI states this and disables auto-renew for them. - **v1.7.8** (2026-06-07) — HA / VIP apply progress now shows **per-node** convergence: a multi-node VIP's apply popup reads "Syncing HA/VIP… 1/2 node(s) converged" (matching the HA/VIP table) instead of a coarse per-change count. Frontend-only. - **v1.7.7** (2026-06-07) — HA / VIP apply-progress consistency: applying a VIP change (or approving a delete) used to flash the progress popup green instantly while the HA/VIP page still showed `SYNCING (0/1)` for a couple of minutes. The popup now **keeps showing "Syncing HA/VIP… X/Y node(s) converged"** until each member node reports the VIP `ACTIVE` (create/edit) or fully torn down (delete) — exactly like the HAProxy agent-sync widget — then completes green. It's a fire-and-forget background poll (the Apply button is released immediately), bounded at ~5 min so an offline node can't spin forever (then it completes with an informational "still converging — track on the HA/VIP page"). Frontend-only; no backend/agent/schema change. diff --git a/backend/main.py b/backend/main.py index 38d15d0..03777a7 100644 --- a/backend/main.py +++ b/backend/main.py @@ -8,7 +8,8 @@ import redis import asyncio from datetime import datetime, timedelta -_version_info = {"version": "1.8.0", "releaseName": "ACME DNS-01 challenge support", "releaseDate": "2026-06-23"} +# Build/deploy marker for the v1.8.x (Issue #35, DNS-01) rollout — ensures the pipeline ships this commit's image. +_version_info = {"version": "1.8.1", "releaseName": "ACME DNS-01 fixes (Cloudflare token, EAB, Apply Management)", "releaseDate": "2026-06-24"} for _vpath in ["/app/version.json", os.path.join(os.path.dirname(__file__), "..", "version.json")]: try: with open(_vpath) as _vf: diff --git a/backend/routers/cluster.py b/backend/routers/cluster.py index e156541..e36157a 100644 --- a/backend/routers/cluster.py +++ b/backend/routers/cluster.py @@ -5048,9 +5048,15 @@ async def reject_all_pending_changes(cluster_id: int, authorization: str = Heade # Get all pending config versions for this cluster (CRITICAL: Include metadata for rollback!) # HA/VIP (Issue #27): exclude vip-* versions — they are rejected/reverted by the # VIP reject endpoint (which restores keepalived state), not the generic rollback. + # ORDER BY created_at ASC: the rollback loop dedups per entity and keeps the FIRST-processed + # snapshot, so the OLDEST snapshot must win — its old_values hold the true pre-change state. + # Critical when one entity has multiple pending versions (e.g. cluster ACME enable->disable->enable): + # rolling back to the oldest restores the original acme_enabled. (Matches the apply SELECT, which + # already orders created_at ASC.) pending_versions = await conn.fetch(""" SELECT id, version_name, metadata FROM config_versions WHERE cluster_id = $1 AND status = 'PENDING' AND version_name NOT LIKE 'vip-%' + ORDER BY created_at ASC """, cluster_id) # CRITICAL FIX: Detect and clean orphan config versions diff --git a/backend/routers/letsencrypt.py b/backend/routers/letsencrypt.py index 20b92b2..f1dfb42 100644 --- a/backend/routers/letsencrypt.py +++ b/backend/routers/letsencrypt.py @@ -1,6 +1,7 @@ from fastapi import APIRouter, HTTPException, Header from pydantic import BaseModel, Field, field_validator, model_validator from typing import Optional, List, Dict +import base64 import json import logging import re @@ -59,8 +60,11 @@ class AccountCreate(BaseModel): email: str directory_url: Optional[str] = None tos_agreed: bool = True - eab_kid: Optional[str] = None - eab_hmac_key: Optional[str] = None + # EAB (External Account Binding) for CAs that require it (ZeroSSL, Google). The KID is opaque + # (bound only); the HMAC key must be base64url so newAccount's _b64url_decode won't raise a + # cryptic binascii error (a common copy mistake is standard-base64 '+'/'/' vs urlsafe '-'/'_'). + eab_kid: Optional[str] = Field(default=None, max_length=256) + eab_hmac_key: Optional[str] = Field(default=None, max_length=512) # Issue #35: per-account default challenge method + DNS provider (for dns-01). challenge_type: str = "http-01" dns_provider: Optional[str] = None @@ -72,6 +76,17 @@ class AccountCreate(BaseModel): raise ValueError(f"challenge_type must be one of {_CHALLENGE_TYPES}") return v + @field_validator('eab_hmac_key') + @classmethod + def _validate_eab_hmac_key(cls, v): + if not v: + return v + try: + base64.urlsafe_b64decode(v + '=' * (-len(v) % 4)) + except Exception: + raise ValueError("eab_hmac_key is not valid base64; copy it exactly from your CA account.") + return v + @model_validator(mode='after') def _require_provider_for_dns01(self): if self.challenge_type == 'dns-01' and not (self.dns_provider or '').strip(): @@ -216,8 +231,19 @@ async def create_account(body: AccountCreate, authorization: str = Header(None)) dns_provider=(body.dns_provider or None), ) return result + except HTTPException: + # Preserve deliberate status codes (e.g. 409 DNS-01 disabled, 422 unsupported provider) — + # the broad except below would otherwise downgrade them all to 400. + raise except Exception as e: logger.error(f"ACME account registration failed: {e}") + # Humanize the common EAB-required failure (ZeroSSL/Google). The ACME error propagates as a + # string ("Account registration failed: {}"), so match the URN substring in str(e). + if 'externalaccountrequired' in str(e).lower(): + raise HTTPException(status_code=400, detail=( + "This CA requires External Account Binding (EAB). Enter the EAB Key ID and HMAC Key " + "from your ZeroSSL/Google account and retry." + )) raise HTTPException(status_code=400, detail=str(e)) diff --git a/backend/services/acme_diagnostics.py b/backend/services/acme_diagnostics.py index a0859c5..8154a83 100644 --- a/backend/services/acme_diagnostics.py +++ b/backend/services/acme_diagnostics.py @@ -116,6 +116,10 @@ _PROBLEM_HUMANIZED: Dict[str, Dict[str, str]] = { "title": "HTTP-01 challenge response mismatch", "hint": "The CA fetched the challenge URL but received the wrong key authorization. Confirm the challenge was served from the right backend.", }, + "urn:ietf:params:acme:error:externalAccountRequired": { + "title": "External Account Binding (EAB) required", + "hint": "This CA (e.g. ZeroSSL, Google) requires EAB. Enter the EAB Key ID and HMAC Key from your CA account when registering.", + }, "urn:ietf:params:acme:error:invalidContact": { "title": "Invalid contact email", "hint": "The ACME account email is malformed. Update the LE account email.", diff --git a/backend/services/dns_providers/cloudflare.py b/backend/services/dns_providers/cloudflare.py index c61c0b5..775122c 100644 --- a/backend/services/dns_providers/cloudflare.py +++ b/backend/services/dns_providers/cloudflare.py @@ -10,6 +10,7 @@ Token scope required: Zone:DNS:Edit + Zone:Read. from __future__ import annotations import logging +import re from typing import Dict, List, Optional, Tuple from urllib.parse import quote @@ -22,6 +23,14 @@ logger = logging.getLogger(__name__) CLOUDFLARE_API_BASE = "https://api.cloudflare.com/client/v4" _TIMEOUT = aiohttp.ClientTimeout(total=20) +# Characters NOT valid in an HTTP bearer credential (RFC 6750 token68: A-Za-z0-9-._~+/=). +# Cloudflare API tokens are a strict subset of this set, so removing anything outside it can +# never corrupt a valid token, but it does strip the paste artifacts that make Cloudflare +# reject the Authorization header with HTTP 400 "Invalid request headers" (CF code 6003): +# surrounding/embedded quotes, interior spaces/tabs, zero-width/unicode chars, and CR/LF +# (the latter would otherwise make aiohttp raise client-side before the request is even sent). +_NON_TOKEN68 = re.compile(r"[^A-Za-z0-9._~+/=-]") + def _strip_quotes(s: str) -> str: s = (s or "").strip() @@ -30,6 +39,11 @@ def _strip_quotes(s: str) -> str: return s +def _sanitize_token(s: str) -> str: + """Strip surrounding quotes/whitespace, then drop every character outside the token68 set.""" + return _NON_TOKEN68.sub("", _strip_quotes(s)) + + class CloudflareDNSProvider(DnsProvider): name = "cloudflare" label = "Cloudflare" @@ -47,7 +61,10 @@ class CloudflareDNSProvider(DnsProvider): def __init__(self, credentials: Dict[str, str] | None = None): super().__init__(credentials) - self._token = (self.credentials.get("api_token") or "").strip() + self._raw_token = (self.credentials.get("api_token") or "").strip() + # Sanitize to the token68 set so a pasted token with quotes/spaces/control/unicode chars + # cannot produce an invalid Authorization header (CF 6003 "Invalid request headers"). + self._token = _sanitize_token(self._raw_token) def _headers(self) -> Dict[str, str]: return {"Authorization": f"Bearer {self._token}", "Content-Type": "application/json"} @@ -97,7 +114,13 @@ class CloudflareDNSProvider(DnsProvider): detail = f"Cloudflare token valid; {total} zone(s) visible." return {"ok": True, "detail": detail} except DnsProviderError as exc: - return {"ok": False, "detail": str(exc)} + # Always surface the real Cloudflare reason (e.g. token scope). If sanitizing also changed + # the token, append a hint that stray characters were stripped (never echo the token). + detail = str(exc) + if self._raw_token != self._token: + detail += (" Note: the token contained characters that were stripped; if it still " + "fails, re-copy it from Cloudflare without quotes or spaces.") + return {"ok": False, "detail": detail} except Exception: # noqa: BLE001 — never leak an internal/transport error verbatim return {"ok": False, "detail": "Could not verify the Cloudflare token."} diff --git a/backend/tests/test_acme_humanizer.py b/backend/tests/test_acme_humanizer.py index 7f4d06e..abdce4c 100644 --- a/backend/tests/test_acme_humanizer.py +++ b/backend/tests/test_acme_humanizer.py @@ -81,6 +81,7 @@ def test_legacy_plain_string_with_brace_but_invalid_json_falls_back(): ("urn:ietf:params:acme:error:rejectedIdentifier", "blacklisted", "rejected"), ("urn:ietf:params:acme:error:serverInternal", "internal err", "ACME server"), ("urn:ietf:params:acme:error:userActionRequired", "agree to ToS", "User action"), + ("urn:ietf:params:acme:error:externalAccountRequired", "EAB required", "External Account Binding"), ]) def test_known_problem_types_are_humanized(problem_type, detail_text, expected_title_contains): payload = json.dumps({"type": problem_type, "detail": detail_text, "status": 400}) diff --git a/backend/tests/test_acme_pydantic_validation.py b/backend/tests/test_acme_pydantic_validation.py index 47a8504..033dc36 100644 --- a/backend/tests/test_acme_pydantic_validation.py +++ b/backend/tests/test_acme_pydantic_validation.py @@ -8,7 +8,31 @@ from pydantic import ValidationError sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from routers.letsencrypt import CertificateRequest +from routers.letsencrypt import CertificateRequest, AccountCreate + + +class TestAccountCreateEAB: + """Issue #35 follow-up: EAB HMAC key must be valid base64url; empty/None passes through + (falls back to global Settings) so non-EAB accounts (HTTP-01 / LE / Cloudflare) are unaffected.""" + + def test_no_eab_is_allowed(self): + acc = AccountCreate(email="a@b.com") + assert acc.eab_hmac_key is None and acc.eab_kid is None + + def test_valid_base64url_hmac_accepted(self): + # urlsafe base64, unpadded and padded — both accepted. + AccountCreate(email="a@b.com", eab_kid="kid-1", eab_hmac_key="YWJjZGVmZ2g") + AccountCreate(email="a@b.com", eab_kid="kid-1", eab_hmac_key="YWJjZA==") + + def test_invalid_base64_hmac_rejected(self): + # 5 base64 chars (count ≡ 1 mod 4) is undecodable — the exact shape that would otherwise + # make register_account's _b64url_decode raise a cryptic binascii error. + with pytest.raises(ValidationError): + AccountCreate(email="a@b.com", eab_kid="kid-1", eab_hmac_key="AAAAA") + + def test_oversized_hmac_rejected(self): + with pytest.raises(ValidationError): + AccountCreate(email="a@b.com", eab_kid="kid-1", eab_hmac_key="A" * 600) class TestCertificateRequestDomains: diff --git a/backend/tests/test_dns01.py b/backend/tests/test_dns01.py index 10706e4..c0c1148 100644 --- a/backend/tests/test_dns01.py +++ b/backend/tests/test_dns01.py @@ -68,3 +68,24 @@ def test_provider_registry_and_allow_list(): except ValueError: raised = True assert raised + + +def test_cloudflare_token_sanitize(): + # Issue #35 follow-up: a pasted token with quotes/spaces/control/unicode chars produced an + # invalid Authorization header (CF 6003 "Invalid request headers"). The sanitizer strips them. + from services.dns_providers.cloudflare import _sanitize_token, CloudflareDNSProvider + + # Surrounding double quotes stripped. + assert _sanitize_token('"abc123-_def"') == 'abc123-_def' + # Interior spaces / tabs / newlines removed. + assert _sanitize_token('abc 123\tdef\n') == 'abc123def' + # A clean token68 string is unchanged (cannot corrupt a valid Cloudflare token). + clean = 'A1b2-_C3.d4~e5+f6/g7==' + assert _sanitize_token(clean) == clean + # Single quotes and a zero-width char removed. + assert _sanitize_token("'tok" + chr(0x200b) + "en'") == 'token' + + # The provider constructor sanitizes into _token and keeps the raw input for diagnostics. + p = CloudflareDNSProvider({"api_token": '"my-token_123"'}) + assert p._token == 'my-token_123' + assert p._raw_token == '"my-token_123"' diff --git a/frontend/package.json b/frontend/package.json index 69fb07d..bee03a9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "haproxy-openmanager-frontend", - "version": "1.8.0", + "version": "1.8.1", "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 18b99ce..aa4877b 100644 --- a/frontend/src/components/ACMEAutomation.js +++ b/frontend/src/components/ACMEAutomation.js @@ -1,7 +1,7 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; import { Card, Table, Button, Tag, Space, Modal, Form, Input, Select, Steps, - message, Row, Col, Statistic, Alert, Tooltip, Switch, theme, Segmented, + message, Row, Col, Statistic, Alert, Tooltip, Switch, theme, Segmented, Collapse, Tabs, Timeline, Spin, Empty, Typography, Divider } from 'antd'; import { @@ -18,8 +18,14 @@ import axios from 'axios'; const { Option } = Select; -const getErrorMsg = (err, fallback) => - err?.response?.data?.error?.message || err?.response?.data?.detail || fallback; +const getErrorMsg = (err, fallback) => { + const data = err?.response?.data; + // FastAPI/Pydantic 422s wrap the specific field message in error.details.validation_errors[]; + // the top-level error.message is generic ("Validation error in request data"), so prefer the + // field-level message (e.g. the EAB base64 hint) when present. + const fieldMsg = data?.error?.details?.validation_errors?.[0]?.message; + return fieldMsg || data?.error?.message || data?.detail || fallback; +}; // Issue #35: humanize the dotted event_type tokens emitted for DNS-01 orders so the diagnostics // timeline reads as a step-by-step progress log rather than raw machine strings. Unknown types @@ -561,6 +567,9 @@ const ACMEAutomation = () => { tos_agreed: values.tos_agreed, challenge_type: challengeType, dns_provider: dnsProvider, + // EAB for CAs that require it (ZeroSSL/Google). Empty → backend falls back to global Settings. + eab_kid: (values.eab_kid || '').trim() || undefined, + eab_hmac_key: (values.eab_hmac_key || '').trim() || undefined, }); const accountId = res.data?.id; // For an automated DNS-01 provider, store the entered credentials (verified server-side). @@ -1613,6 +1622,59 @@ const ACMEAutomation = () => { Let's Encrypt Subscriber Agreement ). + {/* Issue #35: External Account Binding — required by ZeroSSL / Google Trust Services. */} + + + ({ + validator(_, value) { + const kid = (value || '').trim(); + const hmac = (getFieldValue('eab_hmac_key') || '').trim(); + if (!kid && hmac) { + return Promise.reject(new Error('EAB Key ID is required when an HMAC Key is entered.')); + } + return Promise.resolve(); + }, + })]} + > + + + ({ + validator(_, value) { + const kid = (getFieldValue('eab_kid') || '').trim(); + const hmac = (value || '').trim(); + if (kid && !hmac) { + return Promise.reject(new Error('EAB HMAC Key is required when a Key ID is entered.')); + } + return Promise.resolve(); + }, + })]} + > + + + + ), + }]} + /> {dns01Enabled && ( <> diff --git a/frontend/src/components/ApplyManagement.js b/frontend/src/components/ApplyManagement.js index cec9bf7..7dd256f 100644 --- a/frontend/src/components/ApplyManagement.js +++ b/frontend/src/components/ApplyManagement.js @@ -368,7 +368,7 @@ const ApplyManagement = () => { title: 'Apply All Configuration Changes', content: (
-

You are about to apply {effectiveTotal} pending changes:

+

You are about to apply {modalChangeCount} pending changes:

    {pendingChanges.frontends.length > 0 && (
  • {pendingChanges.frontends.length} Frontend changes
  • @@ -385,6 +385,12 @@ const ApplyManagement = () => { {(pendingChanges.vips || []).length > 0 && (
  • {pendingChanges.vips.length} HA/VIP changes
  • )} + {acmeVersions.length > 0 && ( +
  • {acmeVersions.length} ACME Challenge Routing changes
  • + )} + {otherConfigVersions.length > 0 && ( +
  • {otherConfigVersions.length} Other configuration changes
  • + )}
{ // async on their next agent poll) instead of looping on "Entities: 0/0". const vipCount = (pendingChanges.vips || []).length; const isVipOnly = totalEntities === 0 && !isRestoreOperation && nonVipPendingVersions.length === 0 && vipCount > 0; + // Config-version-only apply (e.g. cluster ACME enable/disable): no entity rows, not a restore, + // but there ARE non-vip config versions to push. Without this branch it falls to the "else" and + // shows a misleading "Entities: 0/0" while still syncing agents. + const isConfigVersionOnly = totalEntities === 0 && !isRestoreOperation && vipCount === 0 && nonVipPendingVersions.length > 0; if (isRestoreOperation) { // Restore operation: Show "Configuration" instead of "Entities" @@ -534,6 +544,12 @@ const ApplyManagement = () => { setSyncProgress({ visible: true, step: `Applying ${vipCount} HA/VIP change(s)...`, progress: 20 }); startProgress('apply', `Applying ${vipCount} HA/VIP change(s)...`); updateEntityCounts(0, vipCount, 0, 0, 0); + } else if (isConfigVersionOnly) { + // Config-version-only (ACME toggle, etc.): show "Configuration" instead of "Entities: 0/0". + const cfgCount = nonVipPendingVersions.length; + setSyncProgress({ visible: true, step: `Applying configuration change... Configuration: 0/${cfgCount}, Agents: ⏳`, progress: 20 }); + startProgress('apply', `Applying configuration change... Configuration: 0/${cfgCount}, Agents: ⏳`); + updateEntityCounts(0, cfgCount, 0, totalAgents, disabledAgents); } else { // Normal operation: Show "Entities" as usual setSyncProgress({ visible: true, step: `Applying configuration changes... Entities: 0/${totalEntities}, Agents: ⏳`, progress: 20 }); @@ -556,10 +572,12 @@ const ApplyManagement = () => { } } - // Apply HAProxy changes only if there are any (avoids a no-op call when only VIPs are pending). + // Apply HAProxy changes if there are entity-level changes OR any non-vip config version + // (cluster ACME enable/disable, restore, bulk-import). Gating only on haproxyPending used to + // skip the call for config-version-only states, leaving those versions stuck PENDING. const haproxyPending = pendingChanges.frontends.length + pendingChanges.backends.length + pendingChanges.waf_rules.length + pendingChanges.ssl_certificates.length; - const response = haproxyPending > 0 + const response = (haproxyPending > 0 || nonVipPendingVersions.length > 0) ? await axios.post( `/api/clusters/${selectedCluster.id}/apply-changes`, {}, @@ -805,7 +823,7 @@ const ApplyManagement = () => { title: 'Reject All Configuration Changes', content: (
-

You are about to reject {effectiveTotal} pending changes:

+

You are about to reject {modalChangeCount} pending changes:

    {pendingChanges.frontends.length > 0 && (
  • {pendingChanges.frontends.length} Frontend changes
  • @@ -822,6 +840,12 @@ const ApplyManagement = () => { {(pendingChanges.vips || []).length > 0 && (
  • {pendingChanges.vips.length} HA/VIP changes
  • )} + {acmeVersions.length > 0 && ( +
  • {acmeVersions.length} ACME Challenge Routing changes
  • + )} + {otherConfigVersions.length > 0 && ( +
  • {otherConfigVersions.length} Other configuration changes
  • + )}
{ } } - // Reject HAProxy changes only if there are any. + // Reject HAProxy changes if there are entity-level changes OR any non-vip config version + // (e.g. cluster ACME enable/disable, restore, bulk-import) — the backend DELETE rejects all + // non-vip PENDING versions and rolls back their snapshots. Gating only on haproxyPending used + // to skip the call for config-version-only states, returning the misleading "Rejected 0 HA/VIP". const haproxyPending = pendingChanges.frontends.length + pendingChanges.backends.length + pendingChanges.waf_rules.length + pendingChanges.ssl_certificates.length; - const response = haproxyPending > 0 + const nonVipPendingVersions = configVersions.filter( + v => v.status === 'PENDING' && !(v.version_name || '').startsWith('vip-') + ); + const response = (haproxyPending > 0 || nonVipPendingVersions.length > 0) ? await axios.delete( `/api/clusters/${selectedCluster.id}/pending-changes`, { headers: { Authorization: `Bearer ${token}` } } @@ -957,6 +987,23 @@ const ApplyManagement = () => { const appliedVersions = configVersions.filter(v => v.status === 'APPLIED'); const rejectedVersions = configVersions.filter(v => v.status === 'REJECTED'); const effectiveTotal = pendingChanges.total_count > 0 ? pendingChanges.total_count : pendingVersions.length; + // Issue #35: cluster ACME enable/disable produce `cluster--acme--` config + // versions that have NO entity-level pending flag, so they were neither categorized nor counted. + const ACME_VERSION_RE = /^cluster-\d+-acme-(enable|disable)-/; + const ENTITY_VERSION_PREFIXES = ['frontend-', 'backend-', 'server-', 'ssl-', 'waf-']; + const acmeVersions = pendingVersions.filter(v => ACME_VERSION_RE.test(v.version_name || '')); + // "Other" config versions for the confirm modal = non-vip, non-acme, non-entity-backed (i.e. + // restore-*/bulk-import-*/other cluster-level) — entity-backed versions are already counted via + // total_count, and vips are listed separately, so excluding them avoids double-counting. + const otherConfigVersions = pendingVersions.filter(v => { + const n = v.version_name || ''; + if (n.startsWith('vip-') || ACME_VERSION_RE.test(n)) return false; + return !ENTITY_VERSION_PREFIXES.some(p => n.startsWith(p)); + }); + // Confirm-modal header count: entities+VIPs (total_count) + ACME + other config versions, so the + // header equals the sum of the listed
  • items in every state. The button-enable gate keeps + // using effectiveTotal (unchanged) so entity-only button/Alert behavior is byte-identical. + const modalChangeCount = (pendingChanges.total_count || 0) + acmeVersions.length + otherConfigVersions.length; const renderPendingItem = (item, type, icon) => { // For PENDING items, don't show sync status since they haven't been applied yet @@ -1314,12 +1361,46 @@ const ApplyManagement = () => {
  • )} + {/* Issue #35: ACME Challenge Routing (cluster--acme-*) versions have no entity + flag. Render them in their own section REGARDLESS of whether entity sections are + present, so a co-pending ACME toggle is never hidden in the left panel. */} + {acmeVersions.length > 0 && ( +
    + + <SafetyCertificateOutlined style={{ marginRight: 8, color: '#1890ff' }} /> + ACME Challenge Routing ({acmeVersions.length}) + + {acmeVersions.map(v => { + const isEnable = /^cluster-\d+-acme-enable-/.test(v.version_name); + return ( +
    + {v.version_name} + + {isEnable ? 'ENABLE' : 'DISABLE'} + PENDING + +
    + ); + })} +
    + ACME challenge routing change. Apply to push the updated HAProxy config to the agents. +
    +
    + )} + {pendingChanges.frontends.length === 0 && pendingChanges.backends.length === 0 && pendingChanges.waf_rules.length === 0 && pendingChanges.ssl_certificates.length === 0 && (pendingChanges.vips || []).length === 0 && pendingVersions.length > 0 && (
    {(() => { const restoreVersions = pendingVersions.filter(v => v.version_name.startsWith('restore-')); const bulkImportVersions = pendingVersions.filter(v => v.version_name.startsWith('bulk-import-')); - const otherVersions = pendingVersions.filter(v => !v.version_name.startsWith('restore-') && !v.version_name.startsWith('bulk-import-')); + // Exclude restore-/bulk-import- (own sections), vip-* (VIP section), and acme-* + // (the dedicated ACME section above) so they aren't duplicated in "Other". + const otherVersions = pendingVersions.filter(v => + !v.version_name.startsWith('restore-') && !v.version_name.startsWith('bulk-import-') + && !v.version_name.startsWith('vip-') && !ACME_VERSION_RE.test(v.version_name)); return ( <> diff --git a/version.json b/version.json index d4d4ce3..ef54cf4 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { - "version": "1.8.0", - "releaseName": "ACME DNS-01 challenge support", - "releaseDate": "2026-06-23" + "version": "1.8.1", + "releaseName": "ACME DNS-01 fixes (Cloudflare token, EAB, Apply Management)", + "releaseDate": "2026-06-24" }