mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-09-12 05:48:58 +00:00
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.
This commit is contained in:
@@ -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.<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.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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {<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))
|
||||
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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."}
|
||||
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"'
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
</a>).
|
||||
</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 && (
|
||||
<>
|
||||
<Divider style={{ margin: '16px 0 12px' }} />
|
||||
|
||||
@@ -368,7 +368,7 @@ const ApplyManagement = () => {
|
||||
title: 'Apply All Configuration Changes',
|
||||
content: (
|
||||
<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 }}>
|
||||
{pendingChanges.frontends.length > 0 && (
|
||||
<li><strong>{pendingChanges.frontends.length}</strong> Frontend changes</li>
|
||||
@@ -385,6 +385,12 @@ const ApplyManagement = () => {
|
||||
{(pendingChanges.vips || []).length > 0 && (
|
||||
<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>
|
||||
<Alert
|
||||
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".
|
||||
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: (
|
||||
<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 }}>
|
||||
{pendingChanges.frontends.length > 0 && (
|
||||
<li><strong>{pendingChanges.frontends.length}</strong> Frontend changes</li>
|
||||
@@ -822,6 +840,12 @@ const ApplyManagement = () => {
|
||||
{(pendingChanges.vips || []).length > 0 && (
|
||||
<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>
|
||||
<Alert
|
||||
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
|
||||
+ 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-<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) => {
|
||||
// For PENDING items, don't show sync status since they haven't been applied yet
|
||||
@@ -1314,12 +1361,46 @@ const ApplyManagement = () => {
|
||||
</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 && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
{(() => {
|
||||
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 (
|
||||
<>
|
||||
|
||||
+3
-3
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user