Compare commits

..

2 Commits

Author SHA1 Message Date
taylanbakircioglu e86e86a53c fix(acme): scope ACME nonce per CA - fixes ZeroSSL registration (v1.8.2)
ZeroSSL/Google account registration failed with
`malformed: The Replay Nonce could not be base64url-decoded`: the ACME client
(a process-wide singleton) kept a single anti-replay nonce shared across
certificate authorities, so a nonce issued by one CA could be sent to another,
and the auto-retry only covered `badNonce`.

- Scope the nonce per CA (self._nonce_by_dir keyed by directory_url): a nonce
  from one CA is never sent to another; account registration always uses a fresh
  nonce from the target CA.
- Broaden the 400 retry to also recover from the nonce-malformed rejection.
- Fix _b64url_decode padding (used for the EAB HMAC key).

Backend-only; HTTP-01 and Let's Encrypt are unaffected.

Addresses #35.
2026-06-25 01:11:15 +03:00
taylanbakircioglu 70ebc02e09 fix(acme): Cloudflare token, ZeroSSL EAB, Apply Management (v1.8.1)
Follow-up fixes for the DNS-01 feature reported on #35:

- Cloudflare: sanitize the API token (strip surrounding quotes + any non
  token68 chars) so a pasted token with quotes/spaces no longer fails with
  "Invalid request headers"; verify-on-save shows a precise hint when it
  cleaned the input. Covers the automated orchestrator path too.
- ZeroSSL/Google EAB: enter the EAB Key ID and HMAC Key per-account in the
  Register Account dialog (falls back to the global Settings value when blank);
  base64-validate the HMAC key; humanize the externalAccountRequired failure;
  and preserve the deliberate 409/422 instead of downgrading them to 400.
- Apply Management: cluster ACME enable/disable changes now show under a
  dedicated "ACME Challenge Routing" section, are counted in the Apply/Reject
  dialogs, and Apply/Reject All process them (previously "Rejected 0 HA/VIP
  change(s)") - consistent with every other entity. Reject rolls acme_enabled
  back to the original via ORDER BY created_at ASC over the snapshot chain.
- getErrorMsg surfaces field-level validation messages.

Backward compatible (additive / strict superset; HTTP-01 unchanged).

Addresses #35.
2026-06-24 20:37:05 +03:00
14 changed files with 317 additions and 31 deletions
+3 -1
View File
@@ -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 - **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 - **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 - **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 - **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 - **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 - **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,8 @@ Developed with ❤️ for the HAProxy community
## Release Notes ## Release Notes
- **v1.8.2** (2026-06-25) — **ACME nonce fix** (Issue #35 follow-up): the ACME client now scopes the anti-replay nonce **per certificate authority** so a nonce issued by one CA is never sent to another. This fixes ZeroSSL/Google account registration failing with `malformed: The Replay Nonce could not be base64url-decoded` (the client previously shared one nonce across CAs and only auto-retried on `badNonce`). Account registration now always uses a fresh nonce from the target CA, and the retry covers this case too. Backend-only; HTTP-01 and Let's Encrypt are unaffected.
- **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.<domain>`) 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.8.0** (2026-06-23) — **ACME DNS-01 challenge support** (Issue #35): Auto SSL can now validate via a **DNS TXT record** (`_acme-challenge.<domain>`) 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.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. - **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.
+2 -1
View File
@@ -8,7 +8,8 @@ import redis
import asyncio import asyncio
from datetime import datetime, timedelta 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.2", "releaseName": "ACME nonce fix (ZeroSSL registration)", "releaseDate": "2026-06-25"}
for _vpath in ["/app/version.json", os.path.join(os.path.dirname(__file__), "..", "version.json")]: for _vpath in ["/app/version.json", os.path.join(os.path.dirname(__file__), "..", "version.json")]:
try: try:
with open(_vpath) as _vf: with open(_vpath) as _vf:
+6
View File
@@ -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!) # 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 # HA/VIP (Issue #27): exclude vip-* versions — they are rejected/reverted by the
# VIP reject endpoint (which restores keepalived state), not the generic rollback. # 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(""" pending_versions = await conn.fetch("""
SELECT id, version_name, metadata FROM config_versions SELECT id, version_name, metadata FROM config_versions
WHERE cluster_id = $1 AND status = 'PENDING' AND version_name NOT LIKE 'vip-%' WHERE cluster_id = $1 AND status = 'PENDING' AND version_name NOT LIKE 'vip-%'
ORDER BY created_at ASC
""", cluster_id) """, cluster_id)
# CRITICAL FIX: Detect and clean orphan config versions # CRITICAL FIX: Detect and clean orphan config versions
+28 -2
View File
@@ -1,6 +1,7 @@
from fastapi import APIRouter, HTTPException, Header from fastapi import APIRouter, HTTPException, Header
from pydantic import BaseModel, Field, field_validator, model_validator from pydantic import BaseModel, Field, field_validator, model_validator
from typing import Optional, List, Dict from typing import Optional, List, Dict
import base64
import json import json
import logging import logging
import re import re
@@ -59,8 +60,11 @@ class AccountCreate(BaseModel):
email: str email: str
directory_url: Optional[str] = None directory_url: Optional[str] = None
tos_agreed: bool = True tos_agreed: bool = True
eab_kid: Optional[str] = None # EAB (External Account Binding) for CAs that require it (ZeroSSL, Google). The KID is opaque
eab_hmac_key: Optional[str] = None # (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). # Issue #35: per-account default challenge method + DNS provider (for dns-01).
challenge_type: str = "http-01" challenge_type: str = "http-01"
dns_provider: Optional[str] = None dns_provider: Optional[str] = None
@@ -72,6 +76,17 @@ class AccountCreate(BaseModel):
raise ValueError(f"challenge_type must be one of {_CHALLENGE_TYPES}") raise ValueError(f"challenge_type must be one of {_CHALLENGE_TYPES}")
return v 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') @model_validator(mode='after')
def _require_provider_for_dns01(self): def _require_provider_for_dns01(self):
if self.challenge_type == 'dns-01' and not (self.dns_provider or '').strip(): 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), dns_provider=(body.dns_provider or None),
) )
return result 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: except Exception as e:
logger.error(f"ACME account registration failed: {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: {<dict>}"), 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)) raise HTTPException(status_code=400, detail=str(e))
+4
View File
@@ -116,6 +116,10 @@ _PROBLEM_HUMANIZED: Dict[str, Dict[str, str]] = {
"title": "HTTP-01 challenge response mismatch", "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.", "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": { "urn:ietf:params:acme:error:invalidContact": {
"title": "Invalid contact email", "title": "Invalid contact email",
"hint": "The ACME account email is malformed. Update the LE account email.", "hint": "The ACME account email is malformed. Update the LE account email.",
+24 -10
View File
@@ -28,7 +28,7 @@ def _b64url(data: bytes) -> str:
def _b64url_decode(s: str) -> bytes: def _b64url_decode(s: str) -> bytes:
s += '=' * (4 - len(s) % 4) s += '=' * (-len(s) % 4) # pad to a multiple of 4 (0 pad when already aligned)
return base64.urlsafe_b64decode(s) return base64.urlsafe_b64decode(s)
@@ -37,7 +37,11 @@ class ACMEService:
def __init__(self): def __init__(self):
self._directory_cache: Dict[str, dict] = {} self._directory_cache: Dict[str, dict] = {}
self._nonce: Optional[str] = None # Anti-replay nonces are scoped PER CA (directory_url). A Replay-Nonce issued by one ACME
# server must never be sent in a JWS to another, or the second server rejects it (e.g. ZeroSSL
# "malformed: The Replay Nonce could not be base64url-decoded"). This client is a process-wide
# singleton shared across CAs, so a single shared nonce was leaking across them.
self._nonce_by_dir: Dict[str, str] = {}
async def _get_settings(self) -> dict: async def _get_settings(self) -> dict:
conn = await get_database_connection() conn = await get_database_connection()
@@ -71,17 +75,21 @@ class ACMEService:
raise Exception(f"Failed to fetch ACME directory: HTTP {resp.status}") raise Exception(f"Failed to fetch ACME directory: HTTP {resp.status}")
data = await resp.json() data = await resp.json()
if 'Replay-Nonce' in resp.headers: if 'Replay-Nonce' in resp.headers:
self._nonce = resp.headers['Replay-Nonce'] self._nonce_by_dir[directory_url] = resp.headers['Replay-Nonce']
data['_fetched_at'] = time.time() data['_fetched_at'] = time.time()
self._directory_cache[directory_url] = data self._directory_cache[directory_url] = data
return data return data
async def _get_nonce(self, directory_url: str) -> str: async def _get_nonce(self, directory_url: str) -> str:
if self._nonce: # Use a cached nonce for THIS CA only; otherwise fetch a fresh one from THIS CA's newNonce.
nonce = self._nonce cached = self._nonce_by_dir.pop(directory_url, None)
self._nonce = None if cached:
return nonce return cached
directory = await self.get_directory(directory_url) directory = await self.get_directory(directory_url)
# get_directory may have just captured a nonce for this CA from the directory response.
cached = self._nonce_by_dir.pop(directory_url, None)
if cached:
return cached
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.head(directory['newNonce']) as resp: async with session.head(directory['newNonce']) as resp:
return resp.headers['Replay-Nonce'] return resp.headers['Replay-Nonce']
@@ -188,11 +196,17 @@ class ACMEService:
timeout=aiohttp.ClientTimeout(total=30), timeout=aiohttp.ClientTimeout(total=30),
) as resp: ) as resp:
if 'Replay-Nonce' in resp.headers: if 'Replay-Nonce' in resp.headers:
self._nonce = resp.headers['Replay-Nonce'] self._nonce_by_dir[directory_url] = resp.headers['Replay-Nonce']
if resp.status == 400: if resp.status == 400 and attempt < 2:
err = await resp.json() err = await resp.json()
if err.get('type') == 'urn:ietf:params:acme:error:badNonce' and attempt < 2: etype = (err.get('type') or '')
edetail = (err.get('detail') or '').lower()
# Retry on badNonce, and on any nonce-related malformed rejection (e.g.
# "The Replay Nonce could not be base64url-decoded") — refetch a FRESH nonce
# from the target CA and resign. With per-CA scoping the cross-CA cause is gone;
# this is defense-in-depth so a stale/rejected nonce always self-heals.
if etype.endswith('badNonce') or 'nonce' in edetail:
nonce = resp.headers.get('Replay-Nonce') or await self._get_nonce(directory_url) nonce = resp.headers.get('Replay-Nonce') or await self._get_nonce(directory_url)
protected['nonce'] = nonce protected['nonce'] = nonce
body = self._sign_jws(private_key, protected, payload) body = self._sign_jws(private_key, protected, payload)
+25 -2
View File
@@ -10,6 +10,7 @@ Token scope required: Zone:DNS:Edit + Zone:Read.
from __future__ import annotations from __future__ import annotations
import logging import logging
import re
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional, Tuple
from urllib.parse import quote from urllib.parse import quote
@@ -22,6 +23,14 @@ logger = logging.getLogger(__name__)
CLOUDFLARE_API_BASE = "https://api.cloudflare.com/client/v4" CLOUDFLARE_API_BASE = "https://api.cloudflare.com/client/v4"
_TIMEOUT = aiohttp.ClientTimeout(total=20) _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: def _strip_quotes(s: str) -> str:
s = (s or "").strip() s = (s or "").strip()
@@ -30,6 +39,11 @@ def _strip_quotes(s: str) -> str:
return s 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): class CloudflareDNSProvider(DnsProvider):
name = "cloudflare" name = "cloudflare"
label = "Cloudflare" label = "Cloudflare"
@@ -47,7 +61,10 @@ class CloudflareDNSProvider(DnsProvider):
def __init__(self, credentials: Dict[str, str] | None = None): def __init__(self, credentials: Dict[str, str] | None = None):
super().__init__(credentials) 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]: def _headers(self) -> Dict[str, str]:
return {"Authorization": f"Bearer {self._token}", "Content-Type": "application/json"} 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." detail = f"Cloudflare token valid; {total} zone(s) visible."
return {"ok": True, "detail": detail} return {"ok": True, "detail": detail}
except DnsProviderError as exc: 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 except Exception: # noqa: BLE001 — never leak an internal/transport error verbatim
return {"ok": False, "detail": "Could not verify the Cloudflare token."} return {"ok": False, "detail": "Could not verify the Cloudflare token."}
+1
View File
@@ -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:rejectedIdentifier", "blacklisted", "rejected"),
("urn:ietf:params:acme:error:serverInternal", "internal err", "ACME server"), ("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: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): 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}) payload = json.dumps({"type": problem_type, "detail": detail_text, "status": 400})
+25 -1
View File
@@ -8,7 +8,31 @@ from pydantic import ValidationError
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 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: class TestCertificateRequestDomains:
+42
View File
@@ -68,3 +68,45 @@ def test_provider_registry_and_allow_list():
except ValueError: except ValueError:
raised = True raised = True
assert raised 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"'
def test_b64url_decode_padding_roundtrip():
# Issue #35 v1.8.2: _b64url_decode must round-trip for EVERY length, including base64url strings
# whose length is a multiple of 4 (the case the old padding formula '=' * (4 - len%4) over-padded).
from services.acme_service import _b64url as enc_fn, _b64url_decode as dec_fn
for n in range(0, 20):
data = bytes(range(n))
assert dec_fn(enc_fn(data)) == data, f"round-trip failed at byte length {n}"
def test_nonce_scoped_per_directory():
# Issue #35 v1.8.2: a nonce cached for one CA (directory_url) must never be returned for another,
# and must be single-use. Both directories are pre-cached so _get_nonce returns without network.
import asyncio
svc = ACMEService()
svc._nonce_by_dir = {"https://a.example/dir": "NONCE_A", "https://b.example/dir": "NONCE_B"}
got = asyncio.run(svc._get_nonce("https://a.example/dir"))
assert got == "NONCE_A" # returns THIS CA's nonce
assert svc._nonce_by_dir.get("https://a.example/dir") is None # consumed (single-use)
assert svc._nonce_by_dir.get("https://b.example/dir") == "NONCE_B" # the other CA is untouched
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "haproxy-openmanager-frontend", "name": "haproxy-openmanager-frontend",
"version": "1.8.0", "version": "1.8.2",
"description": "HAProxy Load Balancer Management UI", "description": "HAProxy Load Balancer Management UI",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
+65 -3
View File
@@ -1,7 +1,7 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { import {
Card, Table, Button, Tag, Space, Modal, Form, Input, Select, Steps, 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 Tabs, Timeline, Spin, Empty, Typography, Divider
} from 'antd'; } from 'antd';
import { import {
@@ -18,8 +18,14 @@ import axios from 'axios';
const { Option } = Select; const { Option } = Select;
const getErrorMsg = (err, fallback) => const getErrorMsg = (err, fallback) => {
err?.response?.data?.error?.message || err?.response?.data?.detail || 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 // 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 // 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, tos_agreed: values.tos_agreed,
challenge_type: challengeType, challenge_type: challengeType,
dns_provider: dnsProvider, 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; const accountId = res.data?.id;
// For an automated DNS-01 provider, store the entered credentials (verified server-side). // For an automated DNS-01 provider, store the entered credentials (verified server-side).
@@ -1613,6 +1622,59 @@ const ACMEAutomation = () => {
Let's Encrypt Subscriber Agreement Let's Encrypt Subscriber Agreement
</a>). </a>).
</div> </div>
{/* Issue #35: External Account Binding — required by ZeroSSL / Google Trust Services. */}
<Collapse
ghost
style={{ marginTop: 12 }}
items={[{
key: 'eab',
label: 'External Account Binding (EAB)',
children: (
<>
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
message="Required by some CAs (ZeroSSL, Google Trust Services). Copy the Key ID and HMAC Key from your CA account. Re-enter them on each registration; the HMAC key is not stored."
/>
<Form.Item
name="eab_kid"
label="EAB Key ID"
dependencies={['eab_hmac_key']}
rules={[({ getFieldValue }) => ({
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();
},
})]}
>
<Input placeholder="EAB Key Identifier" autoComplete="off" />
</Form.Item>
<Form.Item
name="eab_hmac_key"
label="EAB HMAC Key"
dependencies={['eab_kid']}
rules={[({ getFieldValue }) => ({
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();
},
})]}
>
<Input.Password placeholder="EAB HMAC Key (base64url)" autoComplete="new-password" />
</Form.Item>
</>
),
}]}
/>
{dns01Enabled && ( {dns01Enabled && (
<> <>
<Divider style={{ margin: '16px 0 12px' }} /> <Divider style={{ margin: '16px 0 12px' }} />
+88 -7
View File
@@ -368,7 +368,7 @@ const ApplyManagement = () => {
title: 'Apply All Configuration Changes', title: 'Apply All Configuration Changes',
content: ( content: (
<div> <div>
<p>You are about to apply <strong>{effectiveTotal}</strong> pending changes:</p> <p>You are about to apply <strong>{modalChangeCount}</strong> pending changes:</p>
<ul style={{ marginTop: 10, marginBottom: 10 }}> <ul style={{ marginTop: 10, marginBottom: 10 }}>
{pendingChanges.frontends.length > 0 && ( {pendingChanges.frontends.length > 0 && (
<li><strong>{pendingChanges.frontends.length}</strong> Frontend changes</li> <li><strong>{pendingChanges.frontends.length}</strong> Frontend changes</li>
@@ -385,6 +385,12 @@ const ApplyManagement = () => {
{(pendingChanges.vips || []).length > 0 && ( {(pendingChanges.vips || []).length > 0 && (
<li><strong>{pendingChanges.vips.length}</strong> HA/VIP changes</li> <li><strong>{pendingChanges.vips.length}</strong> HA/VIP changes</li>
)} )}
{acmeVersions.length > 0 && (
<li><strong>{acmeVersions.length}</strong> ACME Challenge Routing changes</li>
)}
{otherConfigVersions.length > 0 && (
<li><strong>{otherConfigVersions.length}</strong> Other configuration changes</li>
)}
</ul> </ul>
<Alert <Alert
message="All changes will be applied together and sent to agents" message="All changes will be applied together and sent to agents"
@@ -523,6 +529,10 @@ const ApplyManagement = () => {
// async on their next agent poll) instead of looping on "Entities: 0/0". // async on their next agent poll) instead of looping on "Entities: 0/0".
const vipCount = (pendingChanges.vips || []).length; const vipCount = (pendingChanges.vips || []).length;
const isVipOnly = totalEntities === 0 && !isRestoreOperation && nonVipPendingVersions.length === 0 && vipCount > 0; 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) { if (isRestoreOperation) {
// Restore operation: Show "Configuration" instead of "Entities" // 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 }); setSyncProgress({ visible: true, step: `Applying ${vipCount} HA/VIP change(s)...`, progress: 20 });
startProgress('apply', `Applying ${vipCount} HA/VIP change(s)...`); startProgress('apply', `Applying ${vipCount} HA/VIP change(s)...`);
updateEntityCounts(0, vipCount, 0, 0, 0); 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 { } else {
// Normal operation: Show "Entities" as usual // Normal operation: Show "Entities" as usual
setSyncProgress({ visible: true, step: `Applying configuration changes... Entities: 0/${totalEntities}, Agents: ⏳`, progress: 20 }); 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 const haproxyPending = pendingChanges.frontends.length + pendingChanges.backends.length
+ pendingChanges.waf_rules.length + pendingChanges.ssl_certificates.length; + pendingChanges.waf_rules.length + pendingChanges.ssl_certificates.length;
const response = haproxyPending > 0 const response = (haproxyPending > 0 || nonVipPendingVersions.length > 0)
? await axios.post( ? await axios.post(
`/api/clusters/${selectedCluster.id}/apply-changes`, `/api/clusters/${selectedCluster.id}/apply-changes`,
{}, {},
@@ -805,7 +823,7 @@ const ApplyManagement = () => {
title: 'Reject All Configuration Changes', title: 'Reject All Configuration Changes',
content: ( content: (
<div> <div>
<p>You are about to reject <strong>{effectiveTotal}</strong> pending changes:</p> <p>You are about to reject <strong>{modalChangeCount}</strong> pending changes:</p>
<ul style={{ marginTop: 10, marginBottom: 10 }}> <ul style={{ marginTop: 10, marginBottom: 10 }}>
{pendingChanges.frontends.length > 0 && ( {pendingChanges.frontends.length > 0 && (
<li><strong>{pendingChanges.frontends.length}</strong> Frontend changes</li> <li><strong>{pendingChanges.frontends.length}</strong> Frontend changes</li>
@@ -822,6 +840,12 @@ const ApplyManagement = () => {
{(pendingChanges.vips || []).length > 0 && ( {(pendingChanges.vips || []).length > 0 && (
<li><strong>{pendingChanges.vips.length}</strong> HA/VIP changes</li> <li><strong>{pendingChanges.vips.length}</strong> HA/VIP changes</li>
)} )}
{acmeVersions.length > 0 && (
<li><strong>{acmeVersions.length}</strong> ACME Challenge Routing changes</li>
)}
{otherConfigVersions.length > 0 && (
<li><strong>{otherConfigVersions.length}</strong> Other configuration changes</li>
)}
</ul> </ul>
<Alert <Alert
message="All pending changes will be permanently discarded" message="All pending changes will be permanently discarded"
@@ -874,10 +898,16 @@ const ApplyManagement = () => {
} }
} }
// 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 const haproxyPending = pendingChanges.frontends.length + pendingChanges.backends.length
+ pendingChanges.waf_rules.length + pendingChanges.ssl_certificates.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( ? await axios.delete(
`/api/clusters/${selectedCluster.id}/pending-changes`, `/api/clusters/${selectedCluster.id}/pending-changes`,
{ headers: { Authorization: `Bearer ${token}` } } { headers: { Authorization: `Bearer ${token}` } }
@@ -957,6 +987,23 @@ const ApplyManagement = () => {
const appliedVersions = configVersions.filter(v => v.status === 'APPLIED'); const appliedVersions = configVersions.filter(v => v.status === 'APPLIED');
const rejectedVersions = configVersions.filter(v => v.status === 'REJECTED'); const rejectedVersions = configVersions.filter(v => v.status === 'REJECTED');
const effectiveTotal = pendingChanges.total_count > 0 ? pendingChanges.total_count : pendingVersions.length; const effectiveTotal = pendingChanges.total_count > 0 ? pendingChanges.total_count : pendingVersions.length;
// Issue #35: cluster ACME enable/disable produce `cluster-<id>-acme-<enable|disable>-<ts>` 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 <li> 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) => { const renderPendingItem = (item, type, icon) => {
// For PENDING items, don't show sync status since they haven't been applied yet // For PENDING items, don't show sync status since they haven't been applied yet
@@ -1314,12 +1361,46 @@ const ApplyManagement = () => {
</div> </div>
)} )}
{/* Issue #35: ACME Challenge Routing (cluster-<id>-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 && (
<div style={{ marginTop: 8, marginBottom: 8 }}>
<Title level={5}>
<SafetyCertificateOutlined style={{ marginRight: 8, color: '#1890ff' }} />
ACME Challenge Routing ({acmeVersions.length})
</Title>
{acmeVersions.map(v => {
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'
}}>
<span style={{ fontFamily: 'monospace' }}>{v.version_name}</span>
<span>
<Tag color={isEnable ? 'green' : 'default'}>{isEnable ? 'ENABLE' : 'DISABLE'}</Tag>
<Tag color="orange">PENDING</Tag>
</span>
</div>
);
})}
<div style={{ fontSize: 12, color: token.colorTextSecondary, marginTop: 4 }}>
ACME challenge routing change. Apply to push the updated HAProxy config to the agents.
</div>
</div>
)}
{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 && ( {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 && (
<div style={{ marginTop: 8 }}> <div style={{ marginTop: 8 }}>
{(() => { {(() => {
const restoreVersions = pendingVersions.filter(v => v.version_name.startsWith('restore-')); const restoreVersions = pendingVersions.filter(v => v.version_name.startsWith('restore-'));
const bulkImportVersions = pendingVersions.filter(v => v.version_name.startsWith('bulk-import-')); 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 ( return (
<> <>
+3 -3
View File
@@ -1,5 +1,5 @@
{ {
"version": "1.8.0", "version": "1.8.2",
"releaseName": "ACME DNS-01 challenge support", "releaseName": "ACME nonce fix (ZeroSSL registration)",
"releaseDate": "2026-06-23" "releaseDate": "2026-06-25"
} }