Skip to content

Commit 70ebc02

Browse files
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.
1 parent c492b26 commit 70ebc02

13 files changed

Lines changed: 271 additions & 21 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ This architecture provides better security (no inbound connections to HAProxy se
259259
- **ACME Account Management**: Register, view, and deactivate ACME accounts from the UI
260260
- **Staging Mode**: Test certificate issuance with Let's Encrypt staging environment before production
261261
- **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
262-
- **External Account Binding (EAB)**: Support for CAs that require EAB (ZeroSSL, Google Trust Services)
262+
- **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)
263263
- **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
264264
- **Audit Logging** *(v1.4.0)*: Every ACME operation (request, revoke, CA-chain import, account ops) is captured in `user_activity_logs` for compliance review
265265
- **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
24152415

24162416
## Release Notes
24172417

2418+
- **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.
24182419
- **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.
24192420
- **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.
24202421
- **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.

backend/main.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
import asyncio
99
from datetime import datetime, timedelta
1010

11-
_version_info = {"version": "1.8.0", "releaseName": "ACME DNS-01 challenge support", "releaseDate": "2026-06-23"}
11+
# Build/deploy marker for the v1.8.x (Issue #35, DNS-01) rollout — ensures the pipeline ships this commit's image.
12+
_version_info = {"version": "1.8.1", "releaseName": "ACME DNS-01 fixes (Cloudflare token, EAB, Apply Management)", "releaseDate": "2026-06-24"}
1213
for _vpath in ["/app/version.json", os.path.join(os.path.dirname(__file__), "..", "version.json")]:
1314
try:
1415
with open(_vpath) as _vf:

backend/routers/cluster.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5048,9 +5048,15 @@ async def reject_all_pending_changes(cluster_id: int, authorization: str = Heade
50485048
# Get all pending config versions for this cluster (CRITICAL: Include metadata for rollback!)
50495049
# HA/VIP (Issue #27): exclude vip-* versions — they are rejected/reverted by the
50505050
# VIP reject endpoint (which restores keepalived state), not the generic rollback.
5051+
# ORDER BY created_at ASC: the rollback loop dedups per entity and keeps the FIRST-processed
5052+
# snapshot, so the OLDEST snapshot must win — its old_values hold the true pre-change state.
5053+
# Critical when one entity has multiple pending versions (e.g. cluster ACME enable->disable->enable):
5054+
# rolling back to the oldest restores the original acme_enabled. (Matches the apply SELECT, which
5055+
# already orders created_at ASC.)
50515056
pending_versions = await conn.fetch("""
50525057
SELECT id, version_name, metadata FROM config_versions
50535058
WHERE cluster_id = $1 AND status = 'PENDING' AND version_name NOT LIKE 'vip-%'
5059+
ORDER BY created_at ASC
50545060
""", cluster_id)
50555061

50565062
# CRITICAL FIX: Detect and clean orphan config versions

backend/routers/letsencrypt.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from fastapi import APIRouter, HTTPException, Header
22
from pydantic import BaseModel, Field, field_validator, model_validator
33
from typing import Optional, List, Dict
4+
import base64
45
import json
56
import logging
67
import re
@@ -59,8 +60,11 @@ class AccountCreate(BaseModel):
5960
email: str
6061
directory_url: Optional[str] = None
6162
tos_agreed: bool = True
62-
eab_kid: Optional[str] = None
63-
eab_hmac_key: Optional[str] = None
63+
# EAB (External Account Binding) for CAs that require it (ZeroSSL, Google). The KID is opaque
64+
# (bound only); the HMAC key must be base64url so newAccount's _b64url_decode won't raise a
65+
# cryptic binascii error (a common copy mistake is standard-base64 '+'/'/' vs urlsafe '-'/'_').
66+
eab_kid: Optional[str] = Field(default=None, max_length=256)
67+
eab_hmac_key: Optional[str] = Field(default=None, max_length=512)
6468
# Issue #35: per-account default challenge method + DNS provider (for dns-01).
6569
challenge_type: str = "http-01"
6670
dns_provider: Optional[str] = None
@@ -72,6 +76,17 @@ def _validate_challenge_type(cls, v):
7276
raise ValueError(f"challenge_type must be one of {_CHALLENGE_TYPES}")
7377
return v
7478

79+
@field_validator('eab_hmac_key')
80+
@classmethod
81+
def _validate_eab_hmac_key(cls, v):
82+
if not v:
83+
return v
84+
try:
85+
base64.urlsafe_b64decode(v + '=' * (-len(v) % 4))
86+
except Exception:
87+
raise ValueError("eab_hmac_key is not valid base64; copy it exactly from your CA account.")
88+
return v
89+
7590
@model_validator(mode='after')
7691
def _require_provider_for_dns01(self):
7792
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))
216231
dns_provider=(body.dns_provider or None),
217232
)
218233
return result
234+
except HTTPException:
235+
# Preserve deliberate status codes (e.g. 409 DNS-01 disabled, 422 unsupported provider) —
236+
# the broad except below would otherwise downgrade them all to 400.
237+
raise
219238
except Exception as e:
220239
logger.error(f"ACME account registration failed: {e}")
240+
# Humanize the common EAB-required failure (ZeroSSL/Google). The ACME error propagates as a
241+
# string ("Account registration failed: {<dict>}"), so match the URN substring in str(e).
242+
if 'externalaccountrequired' in str(e).lower():
243+
raise HTTPException(status_code=400, detail=(
244+
"This CA requires External Account Binding (EAB). Enter the EAB Key ID and HMAC Key "
245+
"from your ZeroSSL/Google account and retry."
246+
))
221247
raise HTTPException(status_code=400, detail=str(e))
222248

223249

backend/services/acme_diagnostics.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,10 @@ async def _all_ips_public(domain: str, *, timeout: float = 5.0) -> tuple[bool, l
116116
"title": "HTTP-01 challenge response mismatch",
117117
"hint": "The CA fetched the challenge URL but received the wrong key authorization. Confirm the challenge was served from the right backend.",
118118
},
119+
"urn:ietf:params:acme:error:externalAccountRequired": {
120+
"title": "External Account Binding (EAB) required",
121+
"hint": "This CA (e.g. ZeroSSL, Google) requires EAB. Enter the EAB Key ID and HMAC Key from your CA account when registering.",
122+
},
119123
"urn:ietf:params:acme:error:invalidContact": {
120124
"title": "Invalid contact email",
121125
"hint": "The ACME account email is malformed. Update the LE account email.",

backend/services/dns_providers/cloudflare.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from __future__ import annotations
1111

1212
import logging
13+
import re
1314
from typing import Dict, List, Optional, Tuple
1415
from urllib.parse import quote
1516

@@ -22,6 +23,14 @@
2223
CLOUDFLARE_API_BASE = "https://api.cloudflare.com/client/v4"
2324
_TIMEOUT = aiohttp.ClientTimeout(total=20)
2425

26+
# Characters NOT valid in an HTTP bearer credential (RFC 6750 token68: A-Za-z0-9-._~+/=).
27+
# Cloudflare API tokens are a strict subset of this set, so removing anything outside it can
28+
# never corrupt a valid token, but it does strip the paste artifacts that make Cloudflare
29+
# reject the Authorization header with HTTP 400 "Invalid request headers" (CF code 6003):
30+
# surrounding/embedded quotes, interior spaces/tabs, zero-width/unicode chars, and CR/LF
31+
# (the latter would otherwise make aiohttp raise client-side before the request is even sent).
32+
_NON_TOKEN68 = re.compile(r"[^A-Za-z0-9._~+/=-]")
33+
2534

2635
def _strip_quotes(s: str) -> str:
2736
s = (s or "").strip()
@@ -30,6 +39,11 @@ def _strip_quotes(s: str) -> str:
3039
return s
3140

3241

42+
def _sanitize_token(s: str) -> str:
43+
"""Strip surrounding quotes/whitespace, then drop every character outside the token68 set."""
44+
return _NON_TOKEN68.sub("", _strip_quotes(s))
45+
46+
3347
class CloudflareDNSProvider(DnsProvider):
3448
name = "cloudflare"
3549
label = "Cloudflare"
@@ -47,7 +61,10 @@ class CloudflareDNSProvider(DnsProvider):
4761

4862
def __init__(self, credentials: Dict[str, str] | None = None):
4963
super().__init__(credentials)
50-
self._token = (self.credentials.get("api_token") or "").strip()
64+
self._raw_token = (self.credentials.get("api_token") or "").strip()
65+
# Sanitize to the token68 set so a pasted token with quotes/spaces/control/unicode chars
66+
# cannot produce an invalid Authorization header (CF 6003 "Invalid request headers").
67+
self._token = _sanitize_token(self._raw_token)
5168

5269
def _headers(self) -> Dict[str, str]:
5370
return {"Authorization": f"Bearer {self._token}", "Content-Type": "application/json"}
@@ -97,7 +114,13 @@ async def verify_credentials(self) -> Dict:
97114
detail = f"Cloudflare token valid; {total} zone(s) visible."
98115
return {"ok": True, "detail": detail}
99116
except DnsProviderError as exc:
100-
return {"ok": False, "detail": str(exc)}
117+
# Always surface the real Cloudflare reason (e.g. token scope). If sanitizing also changed
118+
# the token, append a hint that stray characters were stripped (never echo the token).
119+
detail = str(exc)
120+
if self._raw_token != self._token:
121+
detail += (" Note: the token contained characters that were stripped; if it still "
122+
"fails, re-copy it from Cloudflare without quotes or spaces.")
123+
return {"ok": False, "detail": detail}
101124
except Exception: # noqa: BLE001 — never leak an internal/transport error verbatim
102125
return {"ok": False, "detail": "Could not verify the Cloudflare token."}
103126

backend/tests/test_acme_humanizer.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ def test_legacy_plain_string_with_brace_but_invalid_json_falls_back():
8181
("urn:ietf:params:acme:error:rejectedIdentifier", "blacklisted", "rejected"),
8282
("urn:ietf:params:acme:error:serverInternal", "internal err", "ACME server"),
8383
("urn:ietf:params:acme:error:userActionRequired", "agree to ToS", "User action"),
84+
("urn:ietf:params:acme:error:externalAccountRequired", "EAB required", "External Account Binding"),
8485
])
8586
def test_known_problem_types_are_humanized(problem_type, detail_text, expected_title_contains):
8687
payload = json.dumps({"type": problem_type, "detail": detail_text, "status": 400})

backend/tests/test_acme_pydantic_validation.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,31 @@
88

99
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
1010

11-
from routers.letsencrypt import CertificateRequest
11+
from routers.letsencrypt import CertificateRequest, AccountCreate
12+
13+
14+
class TestAccountCreateEAB:
15+
"""Issue #35 follow-up: EAB HMAC key must be valid base64url; empty/None passes through
16+
(falls back to global Settings) so non-EAB accounts (HTTP-01 / LE / Cloudflare) are unaffected."""
17+
18+
def test_no_eab_is_allowed(self):
19+
acc = AccountCreate(email="a@b.com")
20+
assert acc.eab_hmac_key is None and acc.eab_kid is None
21+
22+
def test_valid_base64url_hmac_accepted(self):
23+
# urlsafe base64, unpadded and padded — both accepted.
24+
AccountCreate(email="a@b.com", eab_kid="kid-1", eab_hmac_key="YWJjZGVmZ2g")
25+
AccountCreate(email="a@b.com", eab_kid="kid-1", eab_hmac_key="YWJjZA==")
26+
27+
def test_invalid_base64_hmac_rejected(self):
28+
# 5 base64 chars (count ≡ 1 mod 4) is undecodable — the exact shape that would otherwise
29+
# make register_account's _b64url_decode raise a cryptic binascii error.
30+
with pytest.raises(ValidationError):
31+
AccountCreate(email="a@b.com", eab_kid="kid-1", eab_hmac_key="AAAAA")
32+
33+
def test_oversized_hmac_rejected(self):
34+
with pytest.raises(ValidationError):
35+
AccountCreate(email="a@b.com", eab_kid="kid-1", eab_hmac_key="A" * 600)
1236

1337

1438
class TestCertificateRequestDomains:

backend/tests/test_dns01.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,24 @@ def test_provider_registry_and_allow_list():
6868
except ValueError:
6969
raised = True
7070
assert raised
71+
72+
73+
def test_cloudflare_token_sanitize():
74+
# Issue #35 follow-up: a pasted token with quotes/spaces/control/unicode chars produced an
75+
# invalid Authorization header (CF 6003 "Invalid request headers"). The sanitizer strips them.
76+
from services.dns_providers.cloudflare import _sanitize_token, CloudflareDNSProvider
77+
78+
# Surrounding double quotes stripped.
79+
assert _sanitize_token('"abc123-_def"') == 'abc123-_def'
80+
# Interior spaces / tabs / newlines removed.
81+
assert _sanitize_token('abc 123\tdef\n') == 'abc123def'
82+
# A clean token68 string is unchanged (cannot corrupt a valid Cloudflare token).
83+
clean = 'A1b2-_C3.d4~e5+f6/g7=='
84+
assert _sanitize_token(clean) == clean
85+
# Single quotes and a zero-width char removed.
86+
assert _sanitize_token("'tok" + chr(0x200b) + "en'") == 'token'
87+
88+
# The provider constructor sanitizes into _token and keeps the raw input for diagnostics.
89+
p = CloudflareDNSProvider({"api_token": '"my-token_123"'})
90+
assert p._token == 'my-token_123'
91+
assert p._raw_token == '"my-token_123"'

frontend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "haproxy-openmanager-frontend",
3-
"version": "1.8.0",
3+
"version": "1.8.1",
44
"description": "HAProxy Load Balancer Management UI",
55
"license": "AGPL-3.0-or-later",
66
"dependencies": {

0 commit comments

Comments
 (0)