Compare commits

...

13 Commits

Author SHA1 Message Date
taylanbakircioglu 9e2ea04777 feat(haproxy): preserve SPOE filter + frontend log-format on import/edit (v1.8.8, Issue #38)
Bulk import / manual edit silently dropped `filter spoe engine ...` (Coraza WAF)
and frontend `log-format` because the parser recognised only a fixed directive
set. The regenerated config then missed the SPOE engine, so HAProxy failed with
"unable to find SPOE engine 'coraza' used by the send-spoe-group".

- parser: capture `filter` + `log-format`/`log-format-sd` into new ParsedFrontend fields
- db: additive nullable `log_format` + `filters` TEXT columns on frontends (SCHEMA_VERSION 8->9)
- generator: new `filter` bucket flushed before http-request rules so `filter` precedes
  `send-spoe-group`; `log-format` kept in prelude
- bulk import: preview dict, change-detection, persist (create + merge-update); cluster-aware
  SPOE pre-flight advisories (missing-filter + host-prerequisite) surfaced in the UI
- manual CRUD: full round-trip (get/create/update) incl. React form fields (no null-wipe)
- reject/rollback: restore the new columns; restore path + wizard helper kept in parity
- backend `option spop-check` recognised (suppresses spurious warning for coraza-spoa)
- tests: test_spoe_filter_import.py; full suite green (1079 passed)
2026-07-10 18:34:36 +03:00
taylanbakircioglu 60f4fa71ed ci: read releaseName from backend/version.json in the release step (v1.8.7 follow-up)
The version.json single-source move (v1.8.7) updated the version READ step but
missed the create-release step, which still ran jq against the deleted repo-root
version.json and failed the workflow. Point it at backend/version.json. This
release step is public-only (GitHub Releases), so it has no corporate counterpart.
2026-07-09 16:35:10 +03:00
taylanbakircioglu 97b2452bd2 fix(version): single-source version reporting to stop UI version drift (v1.8.7)
The version shown in the UI (backend-sourced via /api/version) could lag the
real release. The canonical version lived at repo-root version.json, but the
backend image is built with context ./backend, so that file did not reach the
container unless a pipeline staged it; the backend then fell back to a hardcoded
constant in main.py that had to be bumped by hand and had drifted (it reported
1.8.4 after 1.8.5 and 1.8.6 shipped).

- version.json moves to backend/version.json (single source of truth), now
  committed and co-located with main.py, so COPY . . bakes it into every image
  directly - correct version in every deployment, no pipeline staging required.
- backend/main.py reads the co-located file; its in-code fallback is no longer a
  real version ("unknown") so it can never silently drift again.
- docker-build.yml reads backend/version.json and drops the staging step;
  docker-compose.localtest drops the stale root mount.
- backend/tests/test_version_consistency.py fails the build if main.py hardcodes
  a real version, if the canonical file is missing/invalid, or if
  frontend/package.json drifts from it.

Bumped to 1.8.7. No functional, schema, API, or agent change. Full suite green.
2026-07-09 16:06:17 +03:00
taylanbakircioglu 23257b02cf perf(api): opt-in uvicorn workers + heartbeat query consolidation (v1.8.6, Issue #35)
A user running the API on a 2-core/4GB host reported slow-feeling API
responses (Issue #35 follow-up). Review of the hot paths found no
pathological defect; the dominant factors are the single uvicorn worker
(one core serves all requests) and the constant agent-poll baseline
(4 requests per agent every 30s). Two zero-risk improvements:

- backend/Dockerfile: CMD now honors UVICORN_WORKERS, falling back to
  WEB_CONCURRENCY and then 1. Flagless uvicorn natively honors
  WEB_CONCURRENCY, so the fallback keeps any deployment that relied on it
  byte-for-byte compatible; with the final default of 1 worker uvicorn runs
  in-process exactly as before. >1 enables the multiprocess supervisor so
  multi-core hosts can use all cores. Background tasks are already
  multi-replica safe (FOR UPDATE SKIP LOCKED / advisory locks), as exercised
  by the k8s HPA deployment (2-10 replicas). `exec` keeps uvicorn as PID 1
  (clean SIGTERM, verified ~1s docker stop with 2 workers).
  docker-compose.yml passes UVICORN_WORKERS through as empty-when-unset so a
  user-set WEB_CONCURRENCY is never overridden; .env.template documents it.

- routers/agent.py heartbeat (by-name endpoint): the agent's
  status/version/upgrade_status were read with three separate single-column
  SELECTs against the same row; now one SELECT. Identical values and None
  semantics (single consistent snapshot instead of three reads); saves two
  round-trips per heartbeat per agent every 30s. The legacy by-id heartbeat
  endpoint is untouched; the heartbeat API contract is unchanged for agents
  of every version.

- README: new "Performance Tuning" section (worker/replica scaling, and how
  to use the X-Response-Time header plus "Slow request detected" logs to
  pinpoint slow endpoints).

Verification: full backend suite in docker green (1063 passed, 151 skipped;
also re-run by the runtime image build); worker-count expansion matrix
(unset->1, UVICORN_WORKERS=2->2, WEB_CONCURRENCY=3->3, both->UVICORN_WORKERS,
empty->fallback) all correct; default run confirmed single-process with
uvicorn as PID 1 and healthy API; UVICORN_WORKERS=2 confirmed parent + 2
workers, healthy API, clean shutdown; live heartbeats verified for
register + existing-agent paths AND degraded agents (no stats socket /
haproxy stopped / garbage stats CSV / unknown backend in server_statuses):
all return 200, agent row updates correctly, zero backend errors.
No schema, API, or agent changes.
2026-07-06 02:27:39 +03:00
taylanbakircioglu c8d144ca9d fix(acme): remove stray paren breaking ACME completion task (v1.8.5, Issue #35)
The background order-completion task (complete_pending_acme_orders, 60s
cycle) failed on EVERY cycle since v1.8.0 with:

    [ACME-COMPLETE] Error in completion task: syntax error at or near ")"

Root cause: the bounded DNS-01 retry OR-arm added to the atomic order-claim
query in v1.8.0 (13b65d9) carried one extra closing parenthesis, making the
whole SELECT invalid PostgreSQL. The claim is the task's first statement, so
the generic except swallowed it each minute and NO background ACME work ever
ran on v1.8.0-v1.8.4:

- orders were never claimed for finalize -> download -> save (http-01 too);
- advance_dns01_order never ran, so the DNS-01 TXT record was never
  published - DNS-01 with an automated provider (e.g. Cloudflare) could
  never validate (exactly the report in Issue #35);
- wizard-staged orders never left wizard_staged (same try block);
- retry_invalid_dns01 / reconcile_dns01_cleanup never executed;
- hourly-created renewal orders could never complete in the background.

Fix: drop the stray ')' (one line). Query semantics are unchanged.

Why the suite missed it: the unit tests mock asyncpg, so raw SQL never
reaches a real parser. Added a regression test that AST-scans the ACME
modules' SQL string literals (comments/quoted literals stripped) and fails
on unbalanced parentheses - it is red on the pre-fix tree and would have
caught the v1.8.0 regression at commit time. Scanned all six ACME modules:
this query was the only unbalanced SQL.

Verification: full backend suite in docker green (1062 passed, 151
skipped); the fixed query EXPLAINs cleanly on postgres:15; live localtest
run shows zero completion-task errors and a seeded pending order is claimed
("[ACME-COMPLETE] Claimed 1 order(s)"). Backend-only, no schema/API/agent
changes; fully backward compatible.

Reported-by: @tkkost (GitHub Issue #35)
2026-07-03 02:07:43 +03:00
taylanbakircioglu 64d42663cd fix(agent): stop installer self-kill in pre-installation cleanup (v1.8.4)
The Linux/macOS agent installer could abort during "pre-installation cleanup"
(terminal showed `Killing processes matching: haproxy-agent` then `Killed`,
returning to the prompt) when the install script's own command line contained
"haproxy-agent". The cleanup killed processes via `pgrep -f "$pattern"` starting
with the bare string "haproxy-agent", which also matched the running installer's
own command line and a sudo/PAM ancestor that the $$/$PPID self-exclusion did not
cover, so the installer terminated itself before installing.

- linux_install.sh / macos_install.sh: the cleanup kill loop now targets ONLY
  the installed agent - "$INSTALL_DIR/haproxy-agent" (the daemon binary path) and
  the agent service/label ("haproxy-agent.service" / "com.haproxy.agent") - never
  the bare "haproxy-agent" substring. Neither pattern can match the installer's
  own command line. The redundant bare pattern is dropped (the service is stopped
  separately, and the binary-path pattern still catches a running daemon).
- frontend (AgentManagement.js): name the downloaded scripts
  install-agent-<platform>.sh / uninstall-agent-<platform>.sh (matching the
  backend's suggested filename) - defense in depth so this cannot resurface.

Installer-only change. The running agent and its privilege model are unchanged
(it runs as root for HAProxy reload, config writes, keepalived, and self-upgrade).
The cleanup runs only on a full interactive install (gated by SKIP_TO_DAEMON), so
daemon mode, self-upgrade, and config/version apply are unaffected. Both agent
scripts kept in sync. Scripts parse on bash 4.2-5.2; full backend suite green.

Addresses #31.
2026-06-27 13:49:59 +03:00
taylanbakircioglu 27fbe48c4b fix(agent): tolerate empty system_info in heartbeat JSON (v1.8.3)
A self-hosted agent could fail every heartbeat with HTTP 400
`Invalid JSON: Expecting property name enclosed in double quotes` when the
system-info block it collects came back empty on an unusual host. The agent
builds the heartbeat JSON as text, so an empty `$system_info` collapsed the
`$system_info,` line to a bare comma and broke the payload.

- Agent script (linux + macos, kept in sync): guard the fragment-form
  register_agent and send_heartbeat builders so an empty system_info falls back
  to a valid key and can never emit a bare comma. Uses the most portable bash
  glob test (no POSIX class / pattern substitution; verified on bash 3.2-5.2 and
  on Ubuntu/Debian/Rocky/Alpine/Amazon Linux). True no-op for healthy agents.
- Backend heartbeat endpoint: parse the body as-is first and only run the
  malformed-JSON repair when parsing fails, so a valid heartbeat from any agent
  version is byte-for-byte untouched. The repair (now a testable helper) recovers
  a leading or doubled comma (the empty-system_info artifact) in addition to the
  existing empty-value / trailing-comma fixes.

No agent version bump; self-upgrade and daemon mode are unaffected. Healthy
agents of every version behave identically. Full backend suite green.

Addresses #31.
2026-06-25 14:42:34 +03:00
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
taylanbakircioglu c492b26bb1 feat(acme): DNS-01 challenge support with pluggable DNS providers (v1.8.0)
Add ACME DNS-01 (TXT-record) validation alongside the existing HTTP-01,
for internal/isolated clusters with no public port 80 and for wildcard
certificates. Opt-in via a global kill-switch (default off); HTTP-01 is
byte-for-byte unchanged, with zero agent or rendered-config changes.

- Pluggable DNS provider interface (Manual + Cloudflare). Per-account
  credentials are Fernet-encrypted at rest, verified on save, and never
  returned by the API or written to logs/events/error_detail.
- Non-blocking per-cycle orchestrator: publish (CAS) -> propagation grace
  (across cycles, no in-loop sleep) -> respond -> finalize/download, with a
  bounded fresh-order retry chain (1 original + 3 retries) on propagation lag.
- Manual flow: user publishes the TXT record and confirms; manual DNS-01
  cannot auto-renew unattended (auto-renew forced off and surfaced in the UI).
- Migration v8: additive, idempotent columns on letsencrypt_accounts/orders
  and acme_challenges, plus a new letsencrypt_account_dns_credentials table.
- Challenge-type-aware diagnostics (port80/routing/DNS checks skipped for
  DNS-01) and a DNS-01 event timeline in the order detail.
- Frontend: DNS-01 account + credentials management, cert wizard adaptation,
  order-detail TXT records + verify, orders/renewal Method columns, and a
  Settings kill-switch. README, release notes, and API docs updated.

Implements #35.
2026-06-24 02:24:33 +03:00
taylanbakircioglu 8b07d7a6a3 docs(standalone): clarify :8080 as the entry point and fix default login
Two friction points surfaced in issue #31: (1) the default-login note said
admin/admin but the seeded password is admin123; (2) a reporter logged in at
:3000 (the raw static frontend, no /api behind it) instead of :8080 (nginx,
which serves the UI and proxies /api). Fix the credentials note and add a
clear pointer that :8080 is the single entry point, with the Quick Reference
table annotated accordingly.
2026-06-17 20:43:24 +03:00
taylanbakircioglu 428915998b fix(deps): bump shell-quote to 1.8.4 (GHSA-w7jw-789q-3m8p)
Patches the critical shell-quote advisory (quote() does not escape
newlines in object .op values, CVSS 8.1). shell-quote is a dev/build-only
transitive dependency (react-dev-utils / launch-editor) and is not present
in the production image or the browser bundle, so there is no runtime
exposure; this clears the alert and the dev-time risk. Lockfile-only change
verified to install with shell-quote resolving to 1.8.4.
2026-06-15 14:24:14 +03:00
taylanbakircioglu 1bc99c5fe7 ci: cut a GitHub release/tag from version.json on push to main
The build workflow tagged the Docker images with the product version but
never created the matching git tag, so the repo Tags/Releases drifted
behind (stuck at the last manual tag, v1.6.0) while Docker Hub had 1.7.8.
Add a step that, after the images are pushed, creates a Release (and its
tag) for the current version.json when one does not already exist, and
grant the job contents:write so it can do so.
2026-06-15 12:53:51 +03:00
52 changed files with 3376 additions and 275 deletions
+9
View File
@@ -59,6 +59,15 @@ AGENT_HEARTBEAT_TIMEOUT_SECONDS=15
# Config sync interval in seconds
AGENT_CONFIG_SYNC_INTERVAL_SECONDS=30
# ============================================================================
# BACKEND PERFORMANCE
# ============================================================================
# Number of uvicorn worker processes for the backend API (default: 1).
# On multi-core hosts, setting this to the core count (e.g. 2) lets the API
# use all cores. Safe to increase: background tasks are multi-replica safe
# (the k8s deployment already runs 2+ replicas via HPA).
UVICORN_WORKERS=1
# ============================================================================
# CORS CONFIGURATION
# ============================================================================
+35 -16
View File
@@ -7,6 +7,8 @@ on:
jobs:
build_and_push:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: checkout
@@ -19,27 +21,17 @@ jobs:
- name: read product version
id: prodversion
run: |
VERSION=$(jq -r .version version.json)
VERSION=$(jq -r .version backend/version.json)
if [ -z "$VERSION" ] || [ "$VERSION" = "null" ]; then
echo "Failed to read product version from version.json" >&2
echo "Failed to read product version from backend/version.json" >&2
exit 1
fi
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
# The backend image is built with `context: ./backend`, so the
# repo-root version.json is OUTSIDE the build context and never
# reaches the container. Backend `main.py` falls back to a
# compile-time constant when /app/version.json is missing, which
# caused a real production drift: a redeploy of the v1.5.2 tree
# silently still reported "v1.5.0" in `/api/version` because the
# constant in main.py had been bumped but the file was not
# available to read. Stage version.json into the backend
# context here so the canonical file IS shipped and the
# constant only serves as a defensive fallback. The staged file
# is gitignored to keep `git status` clean for developers.
- name: stage version.json into backend build context
run: cp version.json backend/version.json
# version.json now lives at backend/version.json (inside the ./backend build
# context), so `COPY . .` bakes it into the image directly — no staging step
# is needed and the backend reports the correct version in every deployment,
# not just this workflow's builds.
- name: set up qemu
uses: docker/setup-qemu-action@v3
@@ -76,3 +68,30 @@ jobs:
taylanbakircioglu/haproxy-openmanager-frontend:${{ steps.version.outputs.TAG }}
taylanbakircioglu/haproxy-openmanager-frontend:${{ steps.prodversion.outputs.VERSION }}
# Keep the GitHub Releases/Tags in sync with version.json. The docker
# images above are tagged with the product version, but nothing here
# created the matching git tag, so the repo's Tags/Releases drifted
# behind (stuck at the last manually-created tag). After the images are
# pushed, cut a Release (which also creates the tag) for the current
# version.json, but only if one does not already exist, so re-runs
# without a version bump are a no-op.
- name: create github release from version.json
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION="${{ steps.prodversion.outputs.VERSION }}"
TAG="v${VERSION}"
RELEASE_NAME=$(jq -r '.releaseName // empty' backend/version.json)
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "Release $TAG already exists, skipping."
else
TITLE="$TAG"
[ -n "$RELEASE_NAME" ] && TITLE="$TAG — $RELEASE_NAME"
gh release create "$TAG" \
--repo "$GITHUB_REPOSITORY" \
--target "$GITHUB_SHA" \
--title "$TITLE" \
--notes "Automated release for $TAG (from version.json)."
echo "Created release $TAG"
fi
-5
View File
@@ -39,11 +39,6 @@ venv.bak/
*.sqlite
*.sqlite3
# Build-time staged version.json (CI `cp version.json backend/`).
# The canonical file lives at repo root; this path is a transient
# copy for the backend Docker build context.
backend/version.json
# IDE
.vscode/
.idea/
+43 -2
View File
@@ -106,6 +106,7 @@ This architecture provides better security (no inbound connections to HAProxy se
✅ **Real-Time Monitoring** - Live stats, health checks, and performance dashboards
✅ **SSL Certificate Management** - Centralized SSL with expiration tracking
✅ **ACME Auto SSL (Let's Encrypt)** - Automated certificate issuance, renewal, and deployment via ACME protocol
✅ **ACME DNS-01 Challenge** *(v1.8.0)* - TXT-record validation for internal/isolated clusters (no public port 80) and wildcard certificates; pluggable DNS providers (Manual + Cloudflare), opt-in, HTTP-01 unchanged
✅ **ACME Certificate Diagnostic Panel** - Automated preflight that checks agent readiness, DNS resolution, port 80 reachability, and ACME challenge ACL before issuing certificates
✅ **WAF Rules** - Web Application Firewall management and deployment
✅ **Agent Script Versioning** - Update agents via UI (Monaco editor) with auto-upgrade
@@ -254,10 +255,11 @@ This architecture provides better security (no inbound connections to HAProxy se
- **Stuck Order Detection** *(v1.4.0)*: Setup wizard surfaces orders that the CA has validated but not yet downloaded, with one-click `Complete` action and automatic 60-second retry
- **Multi-Provider Support**: Configurable ACME directory URL supports Let's Encrypt, ZeroSSL, Google Trust Services, Buypass, and custom CAs
- **HTTP-01 Challenge**: Built-in challenge responder with automatic HAProxy routing injection; reserved backend name `_acme_challenge_backend` is auto-managed and protected from manual edits / agent sync collisions
- **DNS-01 Challenge** *(v1.8.0 — Issue #35)*: Validate via a DNS TXT record instead of HTTP on port 80, for **internal/isolated clusters with no public ingress** and for **wildcard** certificates (`*.example.com`). Pluggable per-account DNS provider (Manual + Cloudflare to start; credentials encrypted at rest and verified on save), same PENDING → APPLIED pipeline, bounded automatic retry on propagation lag, and a DNS-01 event timeline. Opt-in via a global setting; HTTP-01 behaviour is unchanged. (See the *DNS-01 Challenge* subsection under ACME Auto SSL below.)
- **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
@@ -966,6 +968,24 @@ Understanding how HTTP-01 challenges work in a distributed HAProxy environment i
| Behind NAT/VIP | VIP: 1.2.3.4:80 | Internal:5000 | VIP address |
| Multi-cluster | Multiple HAProxy nodes | Central OpenManager | Each domain → respective HAProxy |
#### DNS-01 Challenge — Internal/Isolated Clusters & Wildcards *(v1.8.0 — Issue #35)*
The default **HTTP-01** challenge validates over **port 80**, so the domain must resolve publicly to an HAProxy node with ACME Challenge Routing enabled. **DNS-01** validates via a **DNS TXT record** (`_acme-challenge.<domain>`) instead, so it needs **no inbound port 80 and no public ingress** to the HAProxy node. Use it for:
- **Internal / isolated clusters** (behind a VPN/firewall, no public port 80) where you still control the domain's DNS.
- **Wildcard certificates** (`*.example.com`) — which can *only* be issued via DNS-01.
DNS-01 is **opt-in** and fully backward compatible: it is disabled until an administrator enables it, and existing HTTP-01 certificates are completely unaffected.
- **Enable it**: Settings → ACME / SSL Automation → **DNS-01 Challenge (advanced)** → turn on *Enable DNS-01 Challenge* and Save. While off, DNS-01 options are hidden and no DNS-01 orders can be created.
- **Per-account provider**: in ACME Automation, create (or reconfigure) an ACME account with **Challenge Method = DNS-01** and a **DNS Provider**. Provider credentials are **verified before saving** and **encrypted at rest** (Fernet, mirroring the VRRP/MFA secret pattern); they are never returned by the API or written to logs.
- **Supported providers**: **Manual** (publish the TXT record yourself in any DNS — including fully internal DNS — then click *Verify*; works everywhere but cannot auto-renew unattended) and **Cloudflare** (API token with `Zone:DNS:Edit` + `Zone:Read`; the TXT record is created and cleaned up automatically and renews unattended). The provider interface is pluggable — more providers can be added without changing the issuance flow.
- **Same pipeline**: after validation the certificate follows the normal PENDING → APPLIED flow (assign to clusters / Apply Management) and the agent serves it — identical to HTTP-01 from finalize onward, with **zero agent or rendered-config changes** for DNS-01.
- **Manual flow**: the order detail shows the exact `_acme-challenge.<domain>` record name + TXT value (copyable); publish it and click *I've added the records — Verify*. For Cloudflare it is automatic.
- **Resilience**: a propagation-lag failure is recovered by a **bounded fresh-order retry chain** (1 original + 3 retries with increasing backoff, kept under Let's Encrypt's rate limits); any orphaned TXT record is cleaned up by a reconcile sweep. The order detail shows a DNS-01 event timeline (publish → validation → cleanup).
- **Wildcards**: `*.example.com` is validated at `_acme-challenge.example.com`; it does **not** cover the apex — add `example.com` as a separate name if you need both (the providers handle the two coexisting TXT values automatically).
- **Scope (this release)**: the Site Wizard remains HTTP-01-only; issue DNS-01 / wildcard certificates from **ACME Automation**.
#### ACME Quick Start Guide
Follow these steps to obtain your first Let's Encrypt certificate:
@@ -1718,6 +1738,19 @@ Add new HAProxy clusters through the web interface or directly via API:
}
```
### Performance Tuning *(v1.8.6)*
The backend API defaults to a **single uvicorn worker process**, which uses one CPU core. Agents poll the API every 30 seconds (heartbeat, config, pending-requests, upgrade checks), so larger fleets add a constant baseline load. Two ways to scale:
- **Docker Compose — worker processes**: set `UVICORN_WORKERS` in your `.env` (default `1`). On a multi-core host, matching the core count (e.g. `UVICORN_WORKERS=2` on a 2-core machine) lets the API use all cores:
```bash
echo "UVICORN_WORKERS=2" >> .env && docker-compose up -d backend
```
- **Kubernetes/OpenShift — replicas**: the shipped manifests already include an HPA for the backend (2→10 replicas, `k8s/manifests/13-hpa.yaml`); raise `minReplicas`/`maxReplicas` as needed.
Both are safe: all background tasks (ACME completion, renewals, agent monitoring) are multi-replica safe by design (atomic claims via `FOR UPDATE SKIP LOCKED`, PostgreSQL advisory locks).
**Diagnosing slow requests**: every API response carries an `X-Response-Time` header, and the backend logs `Slow request detected` (WARNING) for any request taking longer than 1 second — check those log lines to pinpoint slow endpoints before tuning anything else.
## API Reference
@@ -2049,7 +2082,7 @@ haproxy-openmanager/
├── docker-compose.yml # Docker Compose configuration
├── docker-compose.localtest.yml # Local development/testing overrides
├── docker-compose.test.yml # Test environment
├── version.json # Application version metadata
├── backend/version.json # Application version metadata (single source of truth)
├── build-images.sh # Build Docker images
├── pytest.ini # Pytest configuration
├── README.md # This file
@@ -2395,6 +2428,14 @@ Developed with ❤️ for the HAProxy community
## Release Notes
- **v1.8.7** (2026-07-09) — **Version reporting single-source fix**: the version shown in the UI (backend-sourced via `/api/version`) could lag behind the real release. The canonical version lived in the repo-root `version.json`, but the backend image is built from the `./backend` context, so that file did not reach the container in every pipeline; the backend then fell back to a hardcoded constant in `main.py` that had to be bumped by hand and had drifted (it reported 1.8.4 after 1.8.5/1.8.6 shipped). The version now lives in a single file, `backend/version.json`, baked into every image automatically, and `main.py` no longer carries a real version literal (its fallback is a neutral "unknown"). A new test enforces that the version stays single-source and cannot drift. No functional or API change.
- **v1.8.6** (2026-07-06) — **Performance: opt-in API workers + heartbeat micro-optimization** (Issue #35 follow-up): the backend container can now run multiple uvicorn worker processes via the new `UVICORN_WORKERS` environment variable (default **1** — behavior unchanged unless you opt in), letting the API use all cores on multi-core hosts; background tasks were already multi-replica safe, as exercised by the Kubernetes HPA deployment. The agent heartbeat handler now reads the agent's `status`/`version`/`upgrade_status` in one query instead of three (one round-trip per heartbeat, per agent, every 30s). Added a *Performance Tuning* section to the README (worker/replica scaling and how to use the `X-Response-Time` header and `Slow request detected` logs to pinpoint slow endpoints). Zero-risk release: no schema, API, or agent changes; defaults preserve existing behavior exactly.
- **v1.8.5** (2026-07-03) — **ACME completion-task SQL fix** (Issue #35 follow-up): the background order-completion task (`complete_pending_acme_orders`, runs every 60s) died on **every cycle** with `syntax error at or near ")"` — an extra closing parenthesis introduced in v1.8.0's bounded DNS-01 retry claim query. Because that query is the task's first database call, **no background ACME work ran at all from v1.8.0 through v1.8.4**: orders were never claimed for finalize/download, the DNS-01 TXT record was never published (so DNS-01 with an automated provider such as Cloudflare could never validate), Site Wizard staged orders never left `wizard_staged`, and DNS-01 retry/TXT-cleanup never executed. The stray parenthesis is removed and a regression test now scans all ACME modules' SQL for unbalanced parentheses (the unit suite mocks the database, which is why a raw-SQL syntax error could slip through). One-line backend query fix; no schema, API, or agent changes — fully backward compatible.
- **v1.8.4** (2026-06-27) — **Agent installer self-kill fix** (Issue #31): the Linux/macOS agent installer could abort during "pre-installation cleanup" (terminal showed `Killing processes matching: haproxy-agent` then `Killed`) when the install script's own filename contained "haproxy-agent". The cleanup killed processes by matching the bare string "haproxy-agent" against full command lines, which also matched the running installer (and a `sudo`/PAM ancestor the self-exclusion did not cover), so the installer terminated itself. Cleanup now targets only the installed agent (the `$INSTALL_DIR/haproxy-agent` binary and the agent service), never the bare string, and the UI now names the downloaded scripts `install-agent-<platform>.sh` / `uninstall-agent-<platform>.sh`. Installer-only change; the running agent and its privilege model (it runs as root for HAProxy reload, config writes, keepalived, and self-upgrade) are unchanged.
- **v1.8.3** (2026-06-25) — **Agent heartbeat JSON fix** (Issue #31): a self-hosted agent could fail every heartbeat with `HTTP 400 Invalid JSON: Expecting property name enclosed in double quotes` when the system-info block it collects came back empty on an unusual host, leaving a stray comma in the hand-built heartbeat JSON. The agent script now substitutes a valid placeholder when that block is empty so it can no longer emit a stray comma, and the backend heartbeat endpoint now parses valid payloads as-is and, only when a body fails to parse, tolerates that specific malformed pattern (a leading or doubled comma) so an already-deployed agent recovers on its next heartbeat after this build is deployed. Backend + agent-script only; healthy agents of every version are byte-for-byte unaffected.
- **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.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.6** (2026-06-07) — HA / VIP UX + accuracy polish: (1) the on-prem/L2 cloud caveat is now a **subtle, collapsed-by-default "Network requirements" info link** instead of a prominent yellow warning. (2) The delete dialog is simplified — deletion is **always a graceful teardown** (stop & disable keepalived, remove our config, release the VIP, keep the package); the confusing "also uninstall the package" checkbox was removed (it was a no-op on any node whose keepalived predates the install marker, and package removal is better handled as a deliberate node-decommission step — the `purge_package` API remains for that). (3) The agent now reports keepalived **FAULT** state (e.g. when the chosen interface has no usable IPv4) instead of misreporting it as BACKUP, so a misconfigured VIP shows red/FAULT in the UI. (1)+(2) are frontend-only; (3) is an additive agent-script change — push it via **Agent Script Management → Reset to Defaults**, then **Upgrade**.
+10 -2
View File
@@ -37,5 +37,13 @@ USER appuser
# Expose port
EXPOSE 8000
# Run the application in production mode (without --reload)
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
# Run the application in production mode (without --reload).
# UVICORN_WORKERS (default 1) opts into multiple worker processes on multi-core
# hosts; with 1 worker uvicorn runs in-process, identical to the flagless CMD
# this replaces. Falls back to WEB_CONCURRENCY when UVICORN_WORKERS is unset
# because flagless uvicorn honored WEB_CONCURRENCY (uvicorn config.py) — this
# keeps any deployment that relied on it byte-for-byte compatible. Background
# tasks are multi-replica safe (FOR UPDATE SKIP LOCKED / advisory locks), as
# already exercised by the k8s HPA deployment. `exec` keeps uvicorn as PID 1
# so signal handling is unchanged.
CMD ["sh", "-c", "exec uvicorn main:app --host 0.0.0.0 --port 8000 --workers ${UVICORN_WORKERS:-${WEB_CONCURRENCY:-1}}"]
+66 -2
View File
@@ -194,7 +194,14 @@ async def ensure_agents_table():
'use_backend_rules': "ALTER TABLE frontends ADD COLUMN use_backend_rules JSONB DEFAULT '[]'::jsonb;",
'request_headers': "ALTER TABLE frontends ADD COLUMN request_headers TEXT;",
'response_headers': "ALTER TABLE frontends ADD COLUMN response_headers TEXT;",
'maxconn': "ALTER TABLE frontends ADD COLUMN maxconn INTEGER;"
'maxconn': "ALTER TABLE frontends ADD COLUMN maxconn INTEGER;",
# Issue #38: SPOE filter directives (e.g. Coraza WAF) and frontend
# log-format were silently dropped on bulk-import / manual edit
# because the parser recognised only a fixed set of directives.
# These nullable TEXT columns persist them verbatim (multi-line for
# `filters`), mirroring the request_headers/options passthrough.
'log_format': "ALTER TABLE frontends ADD COLUMN log_format TEXT;",
'filters': "ALTER TABLE frontends ADD COLUMN filters TEXT;"
}
for col, query in frontend_columns.items():
@@ -1737,7 +1744,16 @@ async def ensure_agent_activity_logs_table():
# v1.7.2: bumped 6 -> 7 for the additive `pending_delete` column on vip_instances
# (approval-gated VIP deletion: a delete is staged for Apply Management and the VIP keeps
# running until APPROVED, so an agent never tears down without explicit human approval).
SCHEMA_VERSION = 7
# v1.8.0 (Issue #35 — ACME DNS-01 challenge support): bumped 7 -> 8 for additive DNS-01
# columns on letsencrypt_accounts/letsencrypt_orders/acme_challenges and the brand-new
# letsencrypt_account_dns_credentials table (ensure_letsencrypt_dns_credentials step).
# All additive + idempotent; default challenge_type 'http-01' keeps existing flows byte-identical.
# v1.8.8 (Issue #38 — SPOE filter + frontend log-format): bumped 8 -> 9 for the additive
# `log_format` + `filters` TEXT columns on `frontends` (frontend_columns loop). Without this
# bump, already-deployed databases (version >= 8) skip the whole migration run and never gain
# the columns, so the frontends SELECT/INSERT would fail. Additive + idempotent + nullable;
# existing rows stay NULL and render byte-identical.
SCHEMA_VERSION = 9
async def run_all_migrations():
@@ -1851,6 +1867,9 @@ async def _run_all_migrations_inner():
await ensure_system_settings_table()
await ensure_acme_tables()
await ensure_acme_columns_on_existing_tables()
# Issue #35 (v1.8.0 — ACME DNS-01): per-account encrypted DNS provider credentials.
# MUST run after ensure_acme_tables() (FK references letsencrypt_accounts).
await ensure_letsencrypt_dns_credentials()
# Issue #11 cleanup: must run AFTER acme_tables/columns to ensure FK refs exist
await cleanup_orphan_acme_challenge_backend()
# v1.5.0 Feature A (ACME diagnostics) + Feature B (site wizard)
@@ -3649,6 +3668,23 @@ async def ensure_acme_columns_on_existing_tables():
('last_attempt_at', "ALTER TABLE acme_challenges ADD COLUMN IF NOT EXISTS last_attempt_at TIMESTAMPTZ"),
# Commit 3a: track auto-completion task lock/poll timestamps for atomic claim across replicas
('orders_updated_at_idx', "CREATE INDEX IF NOT EXISTS idx_letsencrypt_orders_status_updated ON letsencrypt_orders(status, updated_at) WHERE status = 'valid' AND ssl_certificate_id IS NULL"),
# Issue #35 (v1.8.0 — ACME DNS-01): per-account challenge method + DNS provider selection
('acct_challenge_type', "ALTER TABLE letsencrypt_accounts ADD COLUMN IF NOT EXISTS challenge_type VARCHAR(20) DEFAULT 'http-01'"),
('acct_dns_provider', "ALTER TABLE letsencrypt_accounts ADD COLUMN IF NOT EXISTS dns_provider VARCHAR(50)"),
# per-order challenge method + bounded DNS-01 retry chain (dns01_parent_order_id is a PLAIN INTEGER, not a FK,
# to avoid a self-referential cascade interacting with account/order bulk DELETEs)
('order_challenge_type', "ALTER TABLE letsencrypt_orders ADD COLUMN IF NOT EXISTS challenge_type VARCHAR(20) DEFAULT 'http-01'"),
('order_dns01_attempts', "ALTER TABLE letsencrypt_orders ADD COLUMN IF NOT EXISTS dns01_attempts INTEGER DEFAULT 0"),
('order_dns01_last_attempt_at', "ALTER TABLE letsencrypt_orders ADD COLUMN IF NOT EXISTS dns01_last_attempt_at TIMESTAMPTZ"),
('order_dns01_parent_order_id', "ALTER TABLE letsencrypt_orders ADD COLUMN IF NOT EXISTS dns01_parent_order_id INTEGER"),
('order_dns01_retry_claimed', "ALTER TABLE letsencrypt_orders ADD COLUMN IF NOT EXISTS dns01_retry_claimed BOOLEAN DEFAULT FALSE"),
# per-challenge DNS-01 lifecycle state
('chal_challenge_type', "ALTER TABLE acme_challenges ADD COLUMN IF NOT EXISTS challenge_type VARCHAR(20) DEFAULT 'http-01'"),
('chal_dns_txt_value', "ALTER TABLE acme_challenges ADD COLUMN IF NOT EXISTS dns_txt_value TEXT"),
('chal_dns_record_published', "ALTER TABLE acme_challenges ADD COLUMN IF NOT EXISTS dns_record_published BOOLEAN DEFAULT FALSE"),
('chal_dns_record_cleaned', "ALTER TABLE acme_challenges ADD COLUMN IF NOT EXISTS dns_record_cleaned BOOLEAN DEFAULT FALSE"),
('chal_dns_published_at', "ALTER TABLE acme_challenges ADD COLUMN IF NOT EXISTS dns_published_at TIMESTAMPTZ"),
('chal_manual_confirm_deadline', "ALTER TABLE acme_challenges ADD COLUMN IF NOT EXISTS manual_confirm_deadline TIMESTAMPTZ"),
]:
try:
await conn.execute(sql)
@@ -3664,6 +3700,34 @@ async def ensure_acme_columns_on_existing_tables():
logger.error(f"Error adding ACME columns: {e}")
async def ensure_letsencrypt_dns_credentials():
"""Issue #35 (v1.8.0 — ACME DNS-01): per-account encrypted DNS provider credentials.
Idempotent (CREATE TABLE IF NOT EXISTS). FK to letsencrypt_accounts (created earlier by
ensure_acme_tables). Credentials are Fernet-encrypted at rest (backend/utils/dns_credentials.py);
only the provider name + timestamps are ever surfaced to the API.
"""
conn = None
try:
conn = await get_database_connection()
await conn.execute("""
CREATE TABLE IF NOT EXISTS letsencrypt_account_dns_credentials (
id SERIAL PRIMARY KEY,
account_id INTEGER NOT NULL UNIQUE REFERENCES letsencrypt_accounts(id) ON DELETE CASCADE,
dns_provider VARCHAR(50) NOT NULL,
credentials_encrypted TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
)
""")
logger.info("Ensured letsencrypt_account_dns_credentials table")
await close_database_connection(conn)
except Exception as e:
if conn:
await close_database_connection(conn)
logger.error(f"Error ensuring letsencrypt_account_dns_credentials: {e}")
async def cleanup_orphan_acme_challenge_backend():
"""
Issue #11: One-time cleanup of orphan `_acme_challenge_backend` rows that may
+70 -9
View File
@@ -8,8 +8,13 @@ import redis
import asyncio
from datetime import datetime, timedelta
_version_info = {"version": "1.7.8", "releaseName": "HA/VIP — per-node apply progress", "releaseDate": "2026-06-07"}
for _vpath in ["/app/version.json", os.path.join(os.path.dirname(__file__), "..", "version.json")]:
# Single source of truth: backend/version.json, which sits next to this module and is baked into
# every image by `COPY . .` (build context ./backend) — no pipeline staging needed. The literal
# below is only a last-resort "file missing" marker; it is deliberately NOT a real version so it can
# never silently drift out of sync (this exact drift showed a stale version after v1.8.5/v1.8.6).
# Keep the canonical version ONLY in backend/version.json — test_version_consistency.py enforces it.
_version_info = {"version": "unknown", "releaseName": "unknown", "releaseDate": ""}
for _vpath in [os.path.join(os.path.dirname(__file__), "version.json"), "/app/version.json"]:
try:
with open(_vpath) as _vf:
_version_info = json.load(_vf)
@@ -304,6 +309,21 @@ async def complete_pending_acme_orders():
WHERE (
status IN ('pending', 'processing', 'ready')
OR (status = 'valid' AND ssl_certificate_id IS NULL)
-- Issue #35: bounded DNS-01 retry. ONLY dns-01 invalids with remaining
-- budget + elapsed backoff are claimed; http-01 invalids are NEVER matched
-- (their existing skip-and-log is preserved).
OR (
status = 'invalid' AND challenge_type = 'dns-01'
AND ssl_certificate_id IS NULL
AND COALESCE(dns01_retry_claimed, FALSE) = FALSE
AND COALESCE(dns01_attempts, 0) < 3
AND (
dns01_last_attempt_at IS NULL
OR dns01_last_attempt_at < NOW() - (
(CASE COALESCE(dns01_attempts, 0) WHEN 0 THEN 15 WHEN 1 THEN 30 ELSE 60 END)
|| ' minutes')::INTERVAL
)
)
)
AND created_at > NOW() - INTERVAL '7 days'
AND (updated_at IS NULL OR updated_at < NOW() - INTERVAL '30 seconds')
@@ -337,9 +357,17 @@ async def complete_pending_acme_orders():
continue
logger.info(f"[ACME-COMPLETE] Claimed {len(claimed_ids)} order(s) for completion: {claimed_ids}")
from services.dns01_orchestrator import (
advance_dns01_order, retry_invalid_dns01, reconcile_dns01_cleanup,
)
for oid in claimed_ids:
try:
# Issue #35: advance the DNS-01 publish->confirm->respond state machine for
# pending dns-01 orders (no-op for http-01 or non-pending orders).
await advance_dns01_order(oid)
status_info = await acme_svc.check_order_status(oid)
current_status = status_info.get('status')
@@ -352,12 +380,21 @@ async def complete_pending_acme_orders():
result = await _complete_certificate(oid)
logger.info(f"[ACME-COMPLETE] Order {oid} completed - {result.get('message', '')}")
elif current_status == 'invalid':
logger.warning(f"[ACME-COMPLETE] Order {oid} is invalid, skipping")
# Issue #35: bounded DNS-01 fresh-order retry (no-op for http-01).
await retry_invalid_dns01(oid)
logger.warning(f"[ACME-COMPLETE] Order {oid} is invalid")
elif current_status in ('pending', 'processing'):
logger.info(f"[ACME-COMPLETE] Order {oid} still {current_status}, will retry next cycle")
except Exception as poll_err:
logger.error(f"[ACME-COMPLETE] Failed to complete order {oid}: {poll_err}")
# Issue #35: best-effort cleanup of TXT records left published on terminal orders
# (covers a failed cleanup or the kill-switch being flipped off). NOT gated by the switch.
try:
await reconcile_dns01_cleanup()
except Exception as rec_err:
logger.debug(f"[ACME-COMPLETE] DNS-01 reconcile skipped: {rec_err}")
# NOTE: v1.5.0 wizard-staged processing now runs BEFORE the
# claimed_ids early-continue above (Bulgu #2 fix), so it executes
# every cycle regardless of pending/processing volume.
@@ -673,7 +710,9 @@ async def check_letsencrypt_renewals():
skip = False
try:
order = await conn2.fetchrow(
"SELECT account_id, domains, cluster_ids FROM letsencrypt_orders WHERE id = $1",
"SELECT o.account_id, o.domains, o.cluster_ids, o.challenge_type, a.dns_provider "
"FROM letsencrypt_orders o JOIN letsencrypt_accounts a ON o.account_id = a.id "
"WHERE o.id = $1",
order_id
)
if order:
@@ -689,15 +728,37 @@ async def check_letsencrypt_renewals():
if existing:
logger.info(f"[ACME-RENEWAL] Skipping cert {cert['id']} - order {existing['id']} already in progress")
skip = True
elif (order['challenge_type'] == 'dns-01'):
# Issue #35: manual DNS-01 cannot auto-renew unattended; and for an
# automated provider, don't re-mint hourly if a recent retry chain already
# exhausted its budget (avoids tripping the CA new-order rate limit).
if (order['dns_provider'] or 'manual') == 'manual':
logger.warning(f"[ACME-RENEWAL] cert {cert['id']} uses manual DNS-01; cannot auto-renew unattended (publish the TXT and renew manually)")
skip = True
else:
exhausted = await conn2.fetchrow("""
SELECT id FROM letsencrypt_orders
WHERE domains::text = $1::text AND challenge_type = 'dns-01'
AND status = 'invalid' AND COALESCE(dns01_attempts, 0) >= 3
AND created_at > NOW() - INTERVAL '24 hours'
LIMIT 1
""", json.dumps(domains))
if exhausted:
logger.warning(f"[ACME-RENEWAL] cert {cert['id']} DNS-01 renewal recently failed (check DNS); skipping re-mint for 24h")
skip = True
finally:
await close_database_connection(conn2)
if not order or skip:
continue
new_order = await acme_svc.create_order(order['account_id'], domains, cluster_ids)
await acme_svc.respond_to_challenges(new_order['order_id'])
logger.info(f"[ACME-RENEWAL] Initiated renewal order {new_order['order_id']} for cert {cert['id']} ({cert['name']})")
challenge_type = order['challenge_type'] or 'http-01'
new_order = await acme_svc.create_order(order['account_id'], domains, cluster_ids, challenge_type=challenge_type)
# http-01 responds immediately (token served continuously); dns-01 is driven by the
# orchestrator AFTER the TXT is published (never respond before publish).
if challenge_type != 'dns-01':
await acme_svc.respond_to_challenges(new_order['order_id'])
logger.info(f"[ACME-RENEWAL] Initiated renewal order {new_order['order_id']} ({challenge_type}) for cert {cert['id']} ({cert['name']})")
except Exception as cert_err:
logger.error(f"[ACME-RENEWAL] Failed to initiate renewal for cert {cert['id']}: {cert_err}")
@@ -1055,7 +1116,7 @@ async def serve_acme_challenge(token: str):
try:
conn = await get_database_connection()
row = await conn.fetchrow(
"SELECT key_authorization FROM acme_challenges WHERE token = $1 AND (status IN ('pending', 'processing') OR status IS NULL) LIMIT 1",
"SELECT key_authorization FROM acme_challenges WHERE token = $1 AND (status IN ('pending', 'processing') OR status IS NULL) AND (challenge_type = 'http-01' OR challenge_type IS NULL) LIMIT 1",
token
)
if row:
+5
View File
@@ -85,6 +85,11 @@ class FrontendConfig(BaseModel):
response_headers: Optional[str] = None
options: Optional[str] = None
tcp_request_rules: Optional[str] = None
# Issue #38: SPOE filter directives (Coraza WAF etc.) + frontend log-format.
# Passthrough TEXT (no validator) — SPOE `filter ... config <path>` legitimately
# references an operator-managed file, so the ACL `-f` guard must NOT apply here.
log_format: Optional[str] = None
filters: Optional[str] = None
timeout_client: Optional[int] = None
timeout_http_request: Optional[int] = None
rate_limit: Optional[int] = None
+3 -1
View File
@@ -79,7 +79,7 @@ async def _load_order(conn, order_id: int) -> dict:
"""
SELECT id, account_id, status, domains, cluster_ids, error_detail,
post_completion_actions, pending_apply_version_name,
wizard_staged_until, created_by
wizard_staged_until, created_by, challenge_type
FROM letsencrypt_orders
WHERE id = $1
""",
@@ -220,6 +220,7 @@ async def run_diagnostics(order_id: int, authorization: str = Header(None)):
domains=domains,
cluster_ids=cluster_ids,
account_id=order["account_id"],
challenge_type=(order.get("challenge_type") or "http-01"),
)
except Exception as exc: # noqa: BLE001 — diagnostic boundary
# run_checks now wraps individual checks, but a top-level
@@ -335,6 +336,7 @@ async def rerun_diagnostic_check(
cluster_ids=cluster_ids,
account_id=order["account_id"],
only=[check_id],
challenge_type=(order.get("challenge_type") or "http-01"),
)
except Exception as exc: # noqa: BLE001 — diagnostic boundary
logger.exception(
+65 -41
View File
@@ -29,6 +29,40 @@ AGENT_VERSIONS = {
"linux": "2.0.0"
}
def _sanitize_agent_json(body_str: str):
"""Repair the common malformed-JSON patterns a hand-built agent heartbeat can emit.
Agents assemble their heartbeat JSON as text in bash, so an empty interpolated value can leave
a structurally-invalid comma (issue #31). Returns (possibly_repaired_str, was_changed). The
repairs are conservative and target only structural artifacts an agent produces; they never
alter this endpoint's legitimate string values (the agent emits no string containing ',,' —
haproxy_stats_csv is base64/comma-free and the rest are constrained os/kernel/ip/version text).
"""
import re
sanitized = False
# Fix 1: empty value before a comma ("server_statuses": ,)
if re.search(r':\s*,', body_str):
body_str = re.sub(r':\s*,', ': null,', body_str); sanitized = True
# Fix 2: empty value before a closing brace ("field":})
if re.search(r':\s*}', body_str):
body_str = re.sub(r':\s*}', ': null}', body_str); sanitized = True
# Fix 3: trailing comma before } or ]
if re.search(r',(\s*[}\]])', body_str):
body_str = re.sub(r',(\s*[}\]])', r'\1', body_str); sanitized = True
# Fix 4: leading comma run right after an opening brace/bracket (issue #31): an empty
# $system_info as the first member collapses to '{ , "name": ...'. The ': ,' fix above cannot
# catch this because there is no key/colon before the comma.
if re.search(r'([{\[])(\s*,)+', body_str):
body_str = re.sub(r'([{\[])(\s*,)+', r'\1', body_str); sanitized = True
# Fix 5: a run of commas between members (issue #31): an empty $system_info between two fields
# produces '"version": "x",\n ,\n "haproxy_status": ...'. Runs after Fix 1/3 so only
# structural commas remain; collapse any comma run to a single comma.
if re.search(r',(\s*,)+', body_str):
body_str = re.sub(r',(\s*,)+', ',', body_str); sanitized = True
return body_str, sanitized
def get_platform_key(agent_platform: str) -> str:
"""Convert agent platform to standardized platform key - fixed empty platform fallback"""
platform = agent_platform.lower() if agent_platform else 'unknown'
@@ -1455,47 +1489,33 @@ async def agent_heartbeat_by_name(
import json
from pydantic import ValidationError
# Read raw body and sanitize common JSON errors from agents
# Read raw body. Parse VALID JSON as-is (the normal case for every agent version) and only
# fall back to the malformed-JSON repair when the body does not parse. This guarantees a healthy
# heartbeat from any agent version is byte-for-byte untouched — the repair regexes can never run
# against a well-formed payload (issue #31; strictly safer than repairing unconditionally).
try:
raw_body = await request.body()
body_str = raw_body.decode('utf-8')
# Sanitize common malformed JSON patterns from agents
original_body = body_str
sanitized = False
# Fix 1: Empty values before comma (most common: "server_statuses": ,)
if re.search(r':\s*,', body_str):
body_str = re.sub(r':\s*,', ': null,', body_str)
sanitized = True
# Fix 2: Empty values before closing brace
if re.search(r':\s*}', body_str):
body_str = re.sub(r':\s*}', ': null}', body_str)
sanitized = True
# Fix 3: Trailing commas
if re.search(r',(\s*[}\]])', body_str):
body_str = re.sub(r',(\s*[}\]])', r'\1', body_str)
sanitized = True
if sanitized:
# Extract agent name for logging
agent_name = "unknown"
try:
name_match = re.search(r'"name"\s*:\s*"([^"]+)"', body_str)
if name_match:
agent_name = name_match.group(1)
except:
pass
logger.info(f"Sanitized malformed JSON from agent '{agent_name}' - fixed empty values and trailing commas")
logger.debug(f"Original JSON (preview): {original_body[:300]}")
logger.debug(f"Sanitized JSON (preview): {body_str[:300]}")
# Parse sanitized JSON into Pydantic model
heartbeat_dict = json.loads(body_str)
try:
heartbeat_dict = json.loads(body_str)
except json.JSONDecodeError:
# Malformed body (would otherwise be a hard 400). Attempt a conservative repair of the
# comma artifacts a hand-built agent heartbeat can emit, then re-parse.
repaired, changed = _sanitize_agent_json(body_str)
if changed:
agent_name = "unknown"
try:
name_match = re.search(r'"name"\s*:\s*"([^"]+)"', repaired)
if name_match:
agent_name = name_match.group(1)
except Exception:
pass
logger.info(f"Repaired malformed JSON from agent '{agent_name}' before parsing")
logger.debug(f"Original JSON (preview): {body_str[:300]}")
logger.debug(f"Repaired JSON (preview): {repaired[:300]}")
heartbeat_dict = json.loads(repaired) # may still raise -> handled as 400 below
# DEBUG: Log cluster_id for auto-register troubleshooting
if heartbeat_dict.get('name'):
logger.info(f"HEARTBEAT DEBUG: agent={heartbeat_dict.get('name')}, cluster_id={heartbeat_dict.get('cluster_id')}, has_cluster_id={bool(heartbeat_dict.get('cluster_id'))}")
@@ -1675,9 +1695,13 @@ async def agent_heartbeat_by_name(
""", x_api_key)
# Check if agent was in upgrading status and version has changed
current_agent_status = await conn.fetchval("SELECT status FROM agents WHERE id = $1", agent_id)
current_agent_version = await conn.fetchval("SELECT version FROM agents WHERE id = $1", agent_id)
current_upgrade_status = await conn.fetchval("SELECT upgrade_status FROM agents WHERE id = $1", agent_id)
# (v1.8.6: one round-trip instead of three; row is None exactly when the
# per-column fetchvals would each have returned None)
current_agent_row = await conn.fetchrow(
"SELECT status, version, upgrade_status FROM agents WHERE id = $1", agent_id)
current_agent_status = current_agent_row['status'] if current_agent_row else None
current_agent_version = current_agent_row['version'] if current_agent_row else None
current_upgrade_status = current_agent_row['upgrade_status'] if current_agent_row else None
# Determine new status - preserve upgrading status unless version actually changed
new_status = current_agent_status or 'online'
+18 -9
View File
@@ -3696,17 +3696,19 @@ async def confirm_restore_config_version(
# UPDATE existing frontend (ALL 8 parsed fields)
# CRITICAL FIX: Include maxconn and timeout_client so UI shows restored values
await conn.execute("""
UPDATE frontends
SET bind_address = $1, bind_port = $2, default_backend = $3,
UPDATE frontends
SET bind_address = $1, bind_port = $2, default_backend = $3,
mode = $4, ssl_enabled = $5, ssl_port = $6,
maxconn = $7, timeout_client = $8,
log_format = $11, filters = $12,
updated_at = CURRENT_TIMESTAMP, last_config_status = 'PENDING'
WHERE id = $9 AND cluster_id = $10
""",
""",
parsed_fe.bind_address, parsed_fe.bind_port, parsed_fe.default_backend,
parsed_fe.mode, parsed_fe.ssl_enabled, parsed_fe.ssl_port,
parsed_fe.maxconn, parsed_fe.timeout_client,
fe_id, cluster_id
fe_id, cluster_id,
parsed_fe.log_format, parsed_fe.filters # Issue #38
)
changes_summary["frontends_updated"] += 1
logger.info(f"RESTORE: Updated frontend '{parsed_fe.name}' (SSL: {parsed_fe.ssl_enabled}, maxconn: {parsed_fe.maxconn})")
@@ -3714,16 +3716,17 @@ async def confirm_restore_config_version(
# CREATE new frontend (ALL 8 parsed fields)
# CRITICAL FIX: Include maxconn and timeout_client so UI shows restored values
await conn.execute("""
INSERT INTO frontends
INSERT INTO frontends
(name, bind_address, bind_port, default_backend, mode, ssl_enabled, ssl_port,
maxconn, timeout_client,
cluster_id, is_active, last_config_status, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, TRUE, 'PENDING', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""",
cluster_id, log_format, filters, is_active, last_config_status, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, TRUE, 'PENDING', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""",
parsed_fe.name, parsed_fe.bind_address, parsed_fe.bind_port,
parsed_fe.default_backend, parsed_fe.mode, parsed_fe.ssl_enabled, parsed_fe.ssl_port,
parsed_fe.maxconn, parsed_fe.timeout_client,
cluster_id
cluster_id,
parsed_fe.log_format, parsed_fe.filters # Issue #38
)
changes_summary["frontends_created"] += 1
logger.info(f"RESTORE: Created frontend '{parsed_fe.name}' (SSL: {parsed_fe.ssl_enabled}, maxconn: {parsed_fe.maxconn})")
@@ -5048,9 +5051,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
+77 -7
View File
@@ -855,6 +855,9 @@ async def parse_bulk_config(
"response_headers": frontend.response_headers,
"options": frontend.options,
"tcp_request_rules": frontend.tcp_request_rules,
# Issue #38: SPOE filters + frontend log-format
"log_format": frontend.log_format,
"filters": frontend.filters,
# CRITICAL: SSL Advanced Options (parsed from bind directive)
"ssl_alpn": frontend.ssl_alpn,
"ssl_npn": frontend.ssl_npn,
@@ -1089,7 +1092,50 @@ async def parse_bulk_config(
# Add auto-assignment info at the beginning
enhanced_warnings = ssl_auto_assign_info + enhanced_warnings
# ─────────────────────────────────────────────────────────────────
# Issue #38: SPOE pre-flight advisories. Surface, at preview time, the
# SPOE configurations that would FAIL HAProxy's `haproxy -c` at apply so
# the operator sees them BEFORE importing. Cluster-aware: the referenced
# SPOE engine config (e.g. coraza.cfg) is a sibling of the cluster's
# haproxy_config_path, which HAProxy OpenManager does not provision.
# ─────────────────────────────────────────────────────────────────
try:
_cfg_path = await conn.fetchval(
"SELECT haproxy_config_path FROM haproxy_clusters WHERE id = $1",
request.cluster_id,
) or "/etc/haproxy/haproxy.cfg"
_cfg_dir = _cfg_path.rsplit("/", 1)[0] or "/etc/haproxy"
for _fe in frontends_data:
_rh = _fe.get("request_headers") or ""
_filters = _fe.get("filters") or ""
# engines declared by `filter spoe engine <name> config <path>`
_declared_engines = set(re.findall(
r"filter\s+spoe\s+engine\s+(\S+)", _filters, re.IGNORECASE))
# engines referenced by `... send-spoe-group <name> <group>`
_used_engines = set(re.findall(
r"send-spoe-group\s+(\S+)", _rh, re.IGNORECASE))
_missing = _used_engines - _declared_engines
if _missing:
enhanced_warnings.append(
f"⚠️ Frontend '{_fe['name']}': 'send-spoe-group' references SPOE "
f"engine(s) {', '.join(sorted(_missing))} but no matching "
f"'filter spoe engine <name> ...' line was found. HAProxy will "
f"reject this at apply with \"unable to find SPOE engine\". Add the "
f"filter line to this frontend."
)
for _path in re.findall(
r"filter\s+spoe\s+engine\s+\S+\s+config\s+(\S+)",
_filters, re.IGNORECASE):
enhanced_warnings.append(
f"ℹ️ Frontend '{_fe['name']}': SPOE engine config '{_path}' and its "
f"SPOA backend must exist on the HAProxy host (cluster config dir: "
f"{_cfg_dir}). HAProxy OpenManager preserves the filter directive but "
f"does not provision these files; otherwise 'haproxy -c' fails at apply."
)
except Exception as _spoe_adv_err:
logger.warning(f"SPOE advisory generation skipped: {_spoe_adv_err}")
# BULK IMPORT MVP: Check existing entities for UPSERT detection
# Mark each entity as new or update for UI display
# CRITICAL: Only mark as UPDATE if there are actual field changes
@@ -1150,7 +1196,17 @@ async def parse_bulk_config(
if frontend.get("tcp_request_rules") and frontend["tcp_request_rules"] != existing["tcp_request_rules"]:
has_changes = True
changes["tcp_request_rules"] = {"old": existing["tcp_request_rules"], "new": frontend["tcp_request_rules"]}
# Issue #38: SPOE filters + log-format change detection. REQUIRED for
# persistence (not just display): without it, an import that only adds
# a `filter`/`log-format` to an existing frontend would be flagged
# "no change" and the directive would never be written to the DB.
if frontend.get("log_format") and frontend["log_format"] != existing.get("log_format"):
has_changes = True
changes["log_format"] = {"old": existing.get("log_format"), "new": frontend["log_format"]}
if frontend.get("filters") and frontend["filters"] != existing.get("filters"):
has_changes = True
changes["filters"] = {"old": existing.get("filters"), "new": frontend["filters"]}
# CRITICAL: SSL Advanced Options change detection
if frontend.get("ssl_alpn") is not None and frontend.get("ssl_alpn") != existing.get("ssl_alpn"):
has_changes = True
@@ -2094,7 +2150,18 @@ async def bulk_create_entities(
update_fields.append(f"options = ${param_index}")
update_values.append(frontend_data["options"])
param_index += 1
# Issue #38: SPOE filters + frontend log-format (merge strategy)
if frontend_data.get("log_format") and frontend_data["log_format"] != existing_full.get("log_format"):
update_fields.append(f"log_format = ${param_index}")
update_values.append(frontend_data["log_format"])
param_index += 1
if frontend_data.get("filters") and frontend_data["filters"] != existing_full.get("filters"):
update_fields.append(f"filters = ${param_index}")
update_values.append(frontend_data["filters"])
param_index += 1
# CRITICAL FIX: Update SSL advanced options (alpn, npn, ciphers, etc.)
# These are parsed from bind directive and should be preserved in database
if "ssl_alpn" in frontend_data and frontend_data.get("ssl_alpn") != existing_full.get("ssl_alpn"):
@@ -2214,9 +2281,10 @@ async def bulk_create_entities(
timeout_client, timeout_http_request, maxconn,
request_headers, response_headers, tcp_request_rules, options,
rate_limit, compression, log_separate, monitor_uri,
cluster_id, acl_rules, use_backend_rules, redirect_rules, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, CURRENT_TIMESTAMP)
cluster_id, acl_rules, use_backend_rules, redirect_rules,
log_format, filters, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, CURRENT_TIMESTAMP)
RETURNING id
""",
frontend_data["name"],
@@ -2257,7 +2325,9 @@ async def bulk_create_entities(
request.cluster_id,
json.dumps(frontend_data.get("acl_rules", [])), # acl_rules
json.dumps(frontend_data.get("use_backend_rules", [])), # use_backend_rules
json.dumps([]) # redirect_rules
json.dumps([]), # redirect_rules
frontend_data.get("log_format"), # Issue #38
frontend_data.get("filters") # Issue #38
)
created_entities["frontends"].append({
+20 -9
View File
@@ -408,6 +408,7 @@ async def get_frontends(
ssl_alpn, ssl_npn, ssl_ciphers, ssl_ciphersuites, ssl_min_ver, ssl_max_ver, ssl_strict_sni,
acl_rules, redirect_rules, use_backend_rules,
request_headers, response_headers, options, tcp_request_rules,
log_format, filters,
timeout_client, timeout_http_request,
rate_limit, compression, log_separate, monitor_uri,
maxconn, is_active, created_at, updated_at, cluster_id, last_config_status
@@ -424,6 +425,7 @@ async def get_frontends(
ssl_alpn, ssl_npn, ssl_ciphers, ssl_ciphersuites, ssl_min_ver, ssl_max_ver, ssl_strict_sni,
acl_rules, redirect_rules, use_backend_rules,
request_headers, response_headers, options, tcp_request_rules,
log_format, filters,
timeout_client, timeout_http_request,
rate_limit, compression, log_separate, monitor_uri,
maxconn, is_active, created_at, updated_at, cluster_id, last_config_status
@@ -453,6 +455,7 @@ async def get_frontends(
ssl_alpn, ssl_npn, ssl_ciphers, ssl_ciphersuites, ssl_min_ver, ssl_max_ver, ssl_strict_sni,
acl_rules, redirect_rules, use_backend_rules,
request_headers, response_headers, options, tcp_request_rules,
log_format, filters,
timeout_client, timeout_http_request,
rate_limit, compression, log_separate, monitor_uri,
maxconn, is_active, created_at, updated_at, cluster_id, last_config_status
@@ -465,6 +468,7 @@ async def get_frontends(
ssl_alpn, ssl_npn, ssl_ciphers, ssl_ciphersuites, ssl_min_ver, ssl_max_ver, ssl_strict_sni,
acl_rules, redirect_rules, use_backend_rules,
request_headers, response_headers, options, tcp_request_rules,
log_format, filters,
timeout_client, timeout_http_request,
rate_limit, compression, log_separate, monitor_uri,
maxconn, is_active, created_at, updated_at, cluster_id, last_config_status
@@ -480,6 +484,7 @@ async def get_frontends(
ssl_alpn, ssl_npn, ssl_ciphers, ssl_ciphersuites, ssl_min_ver, ssl_max_ver, ssl_strict_sni,
acl_rules, redirect_rules, use_backend_rules,
request_headers, response_headers, options, tcp_request_rules,
log_format, filters,
timeout_client, timeout_http_request,
rate_limit, compression, log_separate, monitor_uri,
maxconn, is_active, created_at, updated_at, cluster_id, last_config_status
@@ -492,6 +497,7 @@ async def get_frontends(
ssl_alpn, ssl_npn, ssl_ciphers, ssl_ciphersuites, ssl_min_ver, ssl_max_ver, ssl_strict_sni,
acl_rules, redirect_rules, use_backend_rules,
request_headers, response_headers, options, tcp_request_rules,
log_format, filters,
timeout_client, timeout_http_request,
rate_limit, compression, log_separate, monitor_uri,
maxconn, is_active, created_at, updated_at, cluster_id, last_config_status
@@ -601,6 +607,8 @@ async def get_frontends(
"response_headers": f.get("response_headers"),
"options": f.get("options"),
"tcp_request_rules": f.get("tcp_request_rules"),
"log_format": f.get("log_format"), # Issue #38
"filters": f.get("filters"), # Issue #38
"timeout_client": f.get("timeout_client"),
"timeout_http_request": f.get("timeout_http_request"),
"rate_limit": f.get("rate_limit"),
@@ -735,18 +743,18 @@ async def create_frontend(frontend: FrontendConfig, request: Request, authorizat
acl_rules, redirect_rules, use_backend_rules,
request_headers, response_headers, options, tcp_request_rules, timeout_client, timeout_http_request,
rate_limit, compression, log_separate, monitor_uri,
cluster_id, maxconn, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, CURRENT_TIMESTAMP)
cluster_id, maxconn, log_format, filters, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, CURRENT_TIMESTAMP)
RETURNING id
""", frontend.name, frontend.bind_address, frontend.bind_port,
""", frontend.name, frontend.bind_address, frontend.bind_port,
frontend.default_backend, frontend.mode, frontend.ssl_enabled,
frontend.ssl_certificate_id, ssl_cert_ids_json, frontend.ssl_port, frontend.ssl_cert_path, frontend.ssl_cert, frontend.ssl_verify,
frontend.ssl_alpn, frontend.ssl_npn, frontend.ssl_ciphers, frontend.ssl_ciphersuites,
frontend.ssl_alpn, frontend.ssl_npn, frontend.ssl_ciphers, frontend.ssl_ciphersuites,
frontend.ssl_min_ver, frontend.ssl_max_ver, frontend.ssl_strict_sni,
json.dumps(frontend.acl_rules or []), json.dumps(frontend.redirect_rules or []), json.dumps(frontend.use_backend_rules or []),
frontend.request_headers, frontend.response_headers, filtered_options, frontend.tcp_request_rules, frontend.timeout_client, frontend.timeout_http_request,
frontend.rate_limit, frontend.compression, frontend.log_separate, frontend.monitor_uri,
frontend.cluster_id, frontend.maxconn)
frontend.cluster_id, frontend.maxconn, frontend.log_format, frontend.filters)
# If cluster_id provided, create new config version for agents
sync_results = []
@@ -1060,9 +1068,10 @@ async def update_frontend(frontend_id: int, frontend: FrontendConfig, request: R
acl_rules = $20, redirect_rules = $21, use_backend_rules = $22,
request_headers = $23, response_headers = $24, options = $25, tcp_request_rules = $26, timeout_client = $27, timeout_http_request = $28,
rate_limit = $29, compression = $30, log_separate = $31, monitor_uri = $32,
cluster_id = $33, maxconn = $34, updated_at = CURRENT_TIMESTAMP
WHERE id = $35
""", frontend.name, frontend.bind_address, frontend.bind_port,
cluster_id = $33, maxconn = $34, log_format = $35, filters = $36,
updated_at = CURRENT_TIMESTAMP
WHERE id = $37
""", frontend.name, frontend.bind_address, frontend.bind_port,
frontend.default_backend, frontend.mode, ssl_enabled,
ssl_certificate_id, ssl_cert_ids_json, ssl_port, ssl_cert_path, ssl_cert, ssl_verify,
frontend.ssl_alpn, frontend.ssl_npn, frontend.ssl_ciphers, frontend.ssl_ciphersuites,
@@ -1070,7 +1079,7 @@ async def update_frontend(frontend_id: int, frontend: FrontendConfig, request: R
json.dumps(frontend.acl_rules or []), json.dumps(frontend.redirect_rules or []), json.dumps(frontend.use_backend_rules or []),
frontend.request_headers, frontend.response_headers, filtered_options, frontend.tcp_request_rules, frontend.timeout_client, frontend.timeout_http_request,
frontend.rate_limit, frontend.compression, frontend.log_separate, frontend.monitor_uri,
frontend.cluster_id, frontend.maxconn, frontend_id)
frontend.cluster_id, frontend.maxconn, frontend.log_format, frontend.filters, frontend_id)
# Debug: Check what was actually saved
updated_frontend = await conn.fetchrow("""
@@ -1124,6 +1133,8 @@ async def update_frontend(frontend_id: int, frontend: FrontendConfig, request: R
"response_headers": frontend.response_headers,
"options": filtered_options,
"tcp_request_rules": frontend.tcp_request_rules,
"log_format": frontend.log_format, # Issue #38
"filters": frontend.filters, # Issue #38
"timeout_client": frontend.timeout_client,
"timeout_http_request": frontend.timeout_http_request,
"rate_limit": frontend.rate_limit,
+412 -48
View File
@@ -1,6 +1,7 @@
from fastapi import APIRouter, HTTPException, Header
from pydantic import BaseModel, Field, field_validator
from typing import Optional, List
from pydantic import BaseModel, Field, field_validator, model_validator
from typing import Optional, List, Dict
import base64
import json
import logging
import re
@@ -10,6 +11,40 @@ from datetime import datetime
from database.connection import get_database_connection, close_database_connection
from services.acme_service import acme_service
from services.haproxy_config import generate_haproxy_config_for_cluster
from services.dns_providers import list_providers, is_supported, get_provider, DnsProviderError
from utils.dns_credentials import encrypt_dns_credentials, decrypt_dns_credentials
# Issue #35: DNS-01 challenge methods.
_CHALLENGE_TYPES = ("http-01", "dns-01")
async def _dns01_enabled() -> bool:
"""Global kill-switch (system_settings acme.dns01_enabled, default False). Read via the ACME
settings dict so non-admins never need the admin-only /api/settings/acme endpoint."""
try:
settings = await acme_service._get_settings()
val = settings.get('dns01_enabled')
if isinstance(val, str):
return val.strip().lower() in ('1', 'true', 'yes', 'on')
return bool(val)
except Exception:
return False
# Per-user sliding-window rate limit for the manual dns-confirm action (soft anti-abuse so a user
# can't spam the CA via the confirm button). Per-process; sufficient for a manual UI action.
_DNS_CONFIRM_RL: Dict[int, list] = {}
_DNS_CONFIRM_LIMIT = 5
_DNS_CONFIRM_WINDOW = 60.0
async def _enforce_dns_confirm_rate_limit(user_id: int) -> None:
now = time.time()
bucket = [t for t in _DNS_CONFIRM_RL.get(user_id, []) if now - t < _DNS_CONFIRM_WINDOW]
if len(bucket) >= _DNS_CONFIRM_LIMIT:
raise HTTPException(status_code=429, detail="Rate limit exceeded: dns-confirm allowed 5 requests per minute")
bucket.append(now)
_DNS_CONFIRM_RL[user_id] = bucket
logger = logging.getLogger(__name__)
@@ -25,8 +60,55 @@ 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
@field_validator('challenge_type')
@classmethod
def _validate_challenge_type(cls, v):
if v not in _CHALLENGE_TYPES:
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():
raise ValueError("dns_provider is required when challenge_type is 'dns-01'")
return self
class DnsCredentialsUpsert(BaseModel):
dns_provider: str = Field(..., min_length=1, max_length=50)
credentials: Dict[str, str] = Field(default_factory=dict)
@field_validator('credentials')
@classmethod
def _validate_credentials(cls, v):
if len(v) > 20:
raise ValueError("Too many credential fields")
for key, val in v.items():
if not isinstance(key, str) or not re.match(r'^[a-zA-Z0-9_]{1,50}$', key):
raise ValueError(f"Invalid credential field name: {key!r}")
if not isinstance(val, str) or len(val) > 4000:
raise ValueError(f"Credential value for {key!r} is missing or too long")
return v
class CertificateRequest(BaseModel):
@@ -38,6 +120,8 @@ class CertificateRequest(BaseModel):
account_id: Optional[int] = None
cluster_ids: List[int] = Field(default_factory=list)
auto_renew: bool = True
# Issue #35: optional override; when None the account's default method is used.
challenge_type: Optional[str] = None
@field_validator('domains')
@classmethod
@@ -54,7 +138,30 @@ class CertificateRequest(BaseModel):
if not _DOMAIN_REGEX.match(d_norm):
raise ValueError(f"Invalid domain format: '{d}'")
normalized.append(d_norm)
return normalized
# De-duplicate (case/whitespace variants normalize to the same value) while preserving order,
# so we don't submit a redundant SAN to the CA or render duplicate-keyed tags in the UI.
return list(dict.fromkeys(normalized))
@field_validator('challenge_type')
@classmethod
def _validate_challenge_type(cls, v):
if v is not None and v not in _CHALLENGE_TYPES:
raise ValueError(f"challenge_type must be one of {_CHALLENGE_TYPES}")
return v
@model_validator(mode='after')
def _wildcard_requires_dns01(self):
# Static cross-field guard: a wildcard SAN can ONLY be issued via dns-01 (the CA rejects
# wildcard over http-01). The runtime dns01_enabled gate + provider resolution happen in the
# endpoint (validators can't do async/DB). When challenge_type is None here, the effective
# method is resolved from the account in the endpoint, which re-checks this.
if any((d or '').startswith('*.') for d in (self.domains or [])):
# Only reject when the caller EXPLICITLY chose a non-dns-01 method. When challenge_type is
# None, the effective method is resolved from the account in the endpoint, which re-checks
# wildcard-requires-dns-01 — so account-default dns-01 inheritance still works for wildcards.
if self.challenge_type is not None and self.challenge_type != 'dns-01':
raise ValueError("Wildcard certificates require challenge_type 'dns-01'")
return self
# --- Account management ---
@@ -77,7 +184,7 @@ async def list_accounts(authorization: str = Header(None)):
conn = await get_database_connection()
try:
rows = await conn.fetch(
"SELECT id, email, directory_url, account_url, status, tos_agreed, eab_kid, created_at, updated_at FROM letsencrypt_accounts ORDER BY id"
"SELECT id, email, directory_url, account_url, status, tos_agreed, eab_kid, created_at, updated_at, challenge_type, dns_provider FROM letsencrypt_accounts ORDER BY id"
)
return [dict(r) for r in rows]
finally:
@@ -107,16 +214,36 @@ async def create_account(body: AccountCreate, authorization: str = Header(None))
eab_kid = body.eab_kid or settings.get('eab_kid', '') or None
eab_hmac_key = body.eab_hmac_key or settings.get('eab_hmac_key', '') or None
# Issue #35: a dns-01 account must name a supported DNS provider.
if body.challenge_type == 'dns-01':
if not await _dns01_enabled():
raise HTTPException(status_code=409, detail="DNS-01 is disabled by an administrator (enable it in Settings).")
if not is_supported((body.dns_provider or '').strip()):
raise HTTPException(status_code=422, detail=f"Unsupported DNS provider: {body.dns_provider}")
result = await acme_service.register_account(
email=body.email,
directory_url=directory_url,
tos_agreed=body.tos_agreed,
eab_kid=eab_kid,
eab_hmac_key=eab_hmac_key,
challenge_type=body.challenge_type,
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))
@@ -186,6 +313,147 @@ async def remove_account(account_id: int, authorization: str = Header(None)):
await close_database_connection(conn)
# --- Issue #35: DNS-01 providers + per-account DNS credentials ---
@router.get("/dns-providers")
async def get_dns_providers(authorization: str = Header(None)):
"""List supported DNS providers + their credential-field schema (for the UI). Also returns the
global dns01_enabled gate so a non-admin cert UI can read it without the admin-only settings API.
Authenticated (any user); not admin-only."""
from auth_middleware import get_current_user_from_token
await get_current_user_from_token(authorization)
return {"dns01_enabled": await _dns01_enabled(), "providers": list_providers()}
@router.get("/accounts/{account_id}/dns-credentials")
async def get_dns_credentials(account_id: int, authorization: str = Header(None)):
"""Masked metadata only — provider + which credential fields are set + updated_at. NEVER returns
the ciphertext or any plaintext token. Read-only for any authenticated user (matches list_accounts)."""
from auth_middleware import get_current_user_from_token
await get_current_user_from_token(authorization)
conn = await get_database_connection()
try:
row = await conn.fetchrow(
"SELECT dns_provider, credentials_encrypted, updated_at FROM letsencrypt_account_dns_credentials WHERE account_id = $1",
account_id,
)
if not row:
return {"configured": False, "dns_provider": None, "credential_fields_present": [], "updated_at": None}
present = []
decrypted = decrypt_dns_credentials(row["credentials_encrypted"])
if isinstance(decrypted, dict):
present = sorted(decrypted.keys())
return {
"configured": True,
"dns_provider": row["dns_provider"],
"credential_fields_present": present,
"updated_at": row["updated_at"],
}
finally:
await close_database_connection(conn)
@router.put("/accounts/{account_id}/dns-credentials")
async def upsert_dns_credentials(account_id: int, body: DnsCredentialsUpsert, authorization: str = Header(None)):
"""Store (encrypted) DNS provider credentials for an account. Admin-only. Verifies the
credentials against the provider BEFORE persisting; returns a sanitized result (never the token)."""
from auth_middleware import get_current_user_from_token
current_user = await get_current_user_from_token(authorization)
if not current_user.get('is_admin', False):
raise HTTPException(status_code=403, detail="Admin access required")
provider_name = body.dns_provider.strip()
if not is_supported(provider_name):
raise HTTPException(status_code=422, detail=f"Unsupported DNS provider: {provider_name}")
conn = await get_database_connection()
try:
exists = await conn.fetchval("SELECT 1 FROM letsencrypt_accounts WHERE id = $1", account_id)
if not exists:
raise HTTPException(status_code=404, detail="ACME account not found")
# Verify credentials synchronously; only persist on success. The detail is user-safe.
try:
provider = get_provider(provider_name, dict(body.credentials))
verify = await provider.verify_credentials()
except DnsProviderError as exc:
raise HTTPException(status_code=422, detail=str(exc))
except HTTPException:
raise
except Exception:
# Defensive: never let a provider-internal exception string (which could echo creds in a
# future provider) reach the client. Always a sanitized 422.
raise HTTPException(status_code=422, detail="DNS provider credential verification failed")
if not verify.get("ok"):
raise HTTPException(status_code=422, detail=verify.get("detail") or "DNS provider credential verification failed")
token = encrypt_dns_credentials(dict(body.credentials))
await conn.execute(
"""INSERT INTO letsencrypt_account_dns_credentials (account_id, dns_provider, credentials_encrypted, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (account_id) DO UPDATE SET
dns_provider = EXCLUDED.dns_provider,
credentials_encrypted = EXCLUDED.credentials_encrypted,
updated_at = NOW()""",
account_id, provider_name, token,
)
# Keep the account's provider selection in sync.
await conn.execute(
"UPDATE letsencrypt_accounts SET dns_provider = $1, updated_at = NOW() WHERE id = $2",
provider_name, account_id,
)
return {"ok": True, "dns_provider": provider_name, "detail": verify.get("detail", "Credentials stored.")}
finally:
await close_database_connection(conn)
@router.delete("/accounts/{account_id}/dns-credentials")
async def delete_dns_credentials(account_id: int, authorization: str = Header(None)):
"""Remove an account's stored DNS credentials. Admin-only."""
from auth_middleware import get_current_user_from_token
current_user = await get_current_user_from_token(authorization)
if not current_user.get('is_admin', False):
raise HTTPException(status_code=403, detail="Admin access required")
conn = await get_database_connection()
try:
await conn.execute("DELETE FROM letsencrypt_account_dns_credentials WHERE account_id = $1", account_id)
return {"ok": True}
finally:
await close_database_connection(conn)
@router.post("/orders/{order_id}/dns-confirm")
async def confirm_dns_order(order_id: int, authorization: str = Header(None)):
"""Manual DNS-01 only: the user asserts the TXT record is published; tell the CA to validate.
Requires ssl.create + a per-user rate limit; acts only on a dns-01 + manual + pending order."""
from auth_middleware import get_current_user_from_token, check_user_permission
current_user = await get_current_user_from_token(authorization)
has_perm = await check_user_permission(current_user['id'], 'ssl', 'create')
if not has_perm:
raise HTTPException(status_code=403, detail="Insufficient permissions: ssl.create required")
await _enforce_dns_confirm_rate_limit(current_user['id'])
conn = await get_database_connection()
try:
order = await conn.fetchrow(
"""SELECT o.id, o.status, o.challenge_type, a.dns_provider
FROM letsencrypt_orders o JOIN letsencrypt_accounts a ON o.account_id = a.id
WHERE o.id = $1""",
order_id,
)
finally:
await close_database_connection(conn)
if not order:
raise HTTPException(status_code=404, detail="Order not found")
if order['challenge_type'] != 'dns-01' or (order['dns_provider'] or 'manual') != 'manual':
raise HTTPException(status_code=409, detail="This order is not a manual DNS-01 order")
if order['status'] not in ('pending', 'processing'):
raise HTTPException(status_code=409, detail=f"Order is '{order['status']}' and cannot be confirmed")
from services.dns01_orchestrator import confirm_manual_dns01
await confirm_manual_dns01(order_id)
return {"ok": True, "message": "DNS-01 confirmation submitted; the CA will validate shortly."}
# --- Certificate operations ---
@router.post("/certificates")
@@ -222,32 +490,61 @@ async def request_certificate(body: CertificateRequest, authorization: str = Hea
logger.info(f"ACME: Using account_id={account_id} for certificate request")
warnings = []
# Audit Tur 5 / Commit 8c: empty cluster_ids in UI means "global certificate".
# Resolve to all ACME-enabled active clusters; only fail if NONE exist.
# Issue #35: resolve the effective challenge method (request override, else account default).
conn_acct = await get_database_connection()
try:
acct = await conn_acct.fetchrow(
"SELECT challenge_type, dns_provider FROM letsencrypt_accounts WHERE id = $1", account_id
)
finally:
await close_database_connection(conn_acct)
effective_challenge = (body.challenge_type or (acct['challenge_type'] if acct else None) or 'http-01')
dns_provider = (acct['dns_provider'] if acct else None)
is_dns01 = (effective_challenge == 'dns-01')
has_wildcard = any(d.startswith('*.') for d in body.domains)
if is_dns01:
if not await _dns01_enabled():
raise HTTPException(status_code=409, detail="DNS-01 is disabled by an administrator (enable it in Settings).")
if not is_supported((dns_provider or '').strip()):
raise HTTPException(status_code=422, detail="The selected ACME account has no DNS provider configured for DNS-01.")
if (dns_provider or 'manual') == 'manual':
# Manual DNS-01 cannot be renewed unattended; auto-renew is forced off on the issued
# certificate (see _complete_certificate). Tell the requester so it isn't a surprise.
warnings.append(
"Manual DNS-01 certificates cannot auto-renew unattended. Auto-renew will be disabled; "
"re-publish the TXT record and request renewal before expiry."
)
elif has_wildcard:
raise HTTPException(status_code=422, detail="Wildcard certificates require a DNS-01 account.")
# Empty cluster_ids = "global certificate". For DNS-01 no ACME Challenge Routing / port 80 is
# needed, so resolve to ALL active clusters; http-01 still requires acme_enabled clusters.
if not body.cluster_ids:
conn_resolve = await get_database_connection()
try:
acme_clusters_resolved = await conn_resolve.fetch(
"SELECT id FROM haproxy_clusters WHERE acme_enabled = TRUE AND is_active = TRUE"
)
if is_dns01:
resolved = await conn_resolve.fetch("SELECT id FROM haproxy_clusters WHERE is_active = TRUE")
else:
resolved = await conn_resolve.fetch("SELECT id FROM haproxy_clusters WHERE acme_enabled = TRUE AND is_active = TRUE")
finally:
await close_database_connection(conn_resolve)
if not acme_clusters_resolved:
if not resolved:
if is_dns01:
raise HTTPException(status_code=422, detail="Cannot issue certificate: no active clusters configured.")
raise HTTPException(
status_code=422,
detail="Cannot issue certificate: no ACME-enabled clusters configured. "
"Enable ACME Challenge Routing on at least one cluster in Cluster Management, "
"Apply the configuration change, then retry."
)
body.cluster_ids = [c['id'] for c in acme_clusters_resolved]
body.cluster_ids = [c['id'] for c in resolved]
warnings.append(
f"No clusters specified — applied to all ACME-enabled cluster(s) ({len(body.cluster_ids)})"
f"No clusters specified — applied to all {'active' if is_dns01 else 'ACME-enabled'} cluster(s) ({len(body.cluster_ids)})"
)
logger.warning(
f"ACME: Empty cluster_ids → global cert fallback to {len(body.cluster_ids)} ACME-enabled cluster(s)"
)
else:
# Validate that referenced clusters exist + are ACME-enabled (warn-only).
elif not is_dns01:
# http-01 only: warn if no cluster has ACME Challenge Routing enabled.
try:
conn_warn = await get_database_connection()
try:
@@ -255,7 +552,6 @@ async def request_certificate(body: CertificateRequest, authorization: str = Hea
"SELECT COUNT(*) FROM haproxy_clusters WHERE acme_enabled = TRUE AND is_active = TRUE"
)
if acme_clusters == 0:
logger.warning("ACME: No clusters with ACME Challenge Routing enabled - certificate validation will likely fail")
warnings.append(
"No clusters have ACME Challenge Routing enabled. "
"Certificate validation will fail. Enable it in Cluster Management and Apply Changes first."
@@ -269,14 +565,30 @@ async def request_certificate(body: CertificateRequest, authorization: str = Hea
account_id=account_id,
domains=body.domains,
cluster_ids=body.cluster_ids,
challenge_type=effective_challenge,
created_by=current_user['id'],
)
challenges = await acme_service.respond_to_challenges(order['order_id'])
# Audit trail: record who requested the certificate + the method (esp. for DNS-01/wildcard,
# which has a wider blast radius than http-01). Never raises into the request path.
try:
from utils.activity_log import record_event
await record_event(
order['order_id'], "acme.order.requested",
message=f"Certificate requested ({effective_challenge}) by user {current_user['id']}",
details={"user_id": current_user['id'], "challenge_type": effective_challenge,
"dns_provider": dns_provider, "domains": body.domains},
)
except Exception:
pass
# http-01 posts the challenge response immediately (token served continuously). dns-01 is
# driven by the orchestrator AFTER the TXT is published (manual waits for dns-confirm), so we
# must NOT respond here.
challenges = []
if not is_dns01:
challenges = await acme_service.respond_to_challenges(order['order_id'])
# Commit 5b: re-fetch the order's *current* status from DB. The status
# returned by create_order() reflects the moment of creation; after
# respond_to_challenges() the CA may have already advanced it (e.g. to
# 'processing'). Surfacing stale status leads UI to under-poll.
conn_status = await get_database_connection()
try:
fresh_status = await conn_status.fetchval(
@@ -287,14 +599,23 @@ async def request_certificate(body: CertificateRequest, authorization: str = Hea
await close_database_connection(conn_status)
effective_status = fresh_status or order['status']
logger.info(f"ACME: Order {order['order_id']} created, {len(challenges)} challenge(s) posted, status={effective_status}")
if is_dns01:
msg = ("Order created. Publish the DNS TXT record shown for each domain, then confirm."
if dns_provider == 'manual'
else "Order created. The DNS TXT record(s) will be published automatically; waiting for CA validation.")
else:
msg = "Order created. ACME challenges have been posted. Waiting for CA validation."
logger.info(f"ACME: Order {order['order_id']} created ({effective_challenge}), status={effective_status}")
return {
"order_id": order['order_id'],
"status": effective_status,
"domains": body.domains,
"challenge_type": effective_challenge,
"dns_provider": dns_provider,
"challenges": challenges,
"message": "Order created. ACME challenges have been posted. Waiting for CA validation.",
"message": msg,
"warnings": warnings,
}
except HTTPException:
@@ -316,7 +637,8 @@ async def list_orders(authorization: str = Header(None)):
rows = await conn.fetch("""
SELECT o.id, o.account_id, o.order_url, o.status, o.domains,
o.ssl_certificate_id, o.cluster_ids, o.error_detail,
o.created_at, o.updated_at, a.email as account_email
o.created_at, o.updated_at, o.challenge_type, a.email as account_email,
a.dns_provider
FROM letsencrypt_orders o
JOIN letsencrypt_accounts a ON o.account_id = a.id
ORDER BY o.created_at DESC
@@ -345,7 +667,8 @@ async def get_order(order_id: int, authorization: str = Header(None)):
SELECT o.id, o.account_id, o.order_url, o.status, o.domains,
o.certificate_url, o.finalize_url, o.expires_at,
o.error_detail, o.ssl_certificate_id, o.cluster_ids,
o.created_at, o.updated_at, a.email as account_email
o.created_at, o.updated_at, o.challenge_type, a.email as account_email,
a.dns_provider
FROM letsencrypt_orders o
JOIN letsencrypt_accounts a ON o.account_id = a.id
WHERE o.id = $1
@@ -353,13 +676,23 @@ async def get_order(order_id: int, authorization: str = Header(None)):
if not order:
raise HTTPException(status_code=404, detail="Order not found")
# Issue #35: include challenge_type + dns_txt_value (PUBLIC DNS data — NOT key_authorization,
# NOT the API token) so the UI can render manual DNS-01 instructions. Explicit column list.
challenges = await conn.fetch(
"SELECT id, order_id, domain, token, challenge_url, status, validated_at, created_at FROM acme_challenges WHERE order_id = $1 ORDER BY domain", order_id
"SELECT id, order_id, domain, token, challenge_url, status, validated_at, created_at, "
"challenge_type, dns_txt_value FROM acme_challenges WHERE order_id = $1 ORDER BY domain", order_id
)
result = dict(order)
result['domains'] = json.loads(result['domains']) if isinstance(result['domains'], str) else result['domains']
result['cluster_ids'] = json.loads(result['cluster_ids']) if isinstance(result['cluster_ids'], str) else result['cluster_ids']
result['challenges'] = [dict(c) for c in challenges]
ch_list = []
for c in challenges:
cd = dict(c)
if cd.get('challenge_type') == 'dns-01':
# Server computes the record name (wildcard *.-stripping lives server-side).
cd['dns_record_name'] = acme_service._challenge_dns_name(cd['domain'])
ch_list.append(cd)
result['challenges'] = ch_list
return result
finally:
await close_database_connection(conn)
@@ -470,18 +803,23 @@ async def renew_order(order_id: int, authorization: str = Header(None)):
conn = await get_database_connection()
try:
order = await conn.fetchrow(
"SELECT account_id, domains, cluster_ids FROM letsencrypt_orders WHERE id = $1", order_id
"SELECT account_id, domains, cluster_ids, challenge_type FROM letsencrypt_orders WHERE id = $1", order_id
)
if not order:
raise HTTPException(status_code=404, detail="Order not found")
domains = json.loads(order['domains']) if isinstance(order['domains'], str) else order['domains']
cluster_ids = json.loads(order['cluster_ids']) if isinstance(order['cluster_ids'], str) else order['cluster_ids']
challenge_type = order['challenge_type'] or 'http-01'
new_order = await acme_service.create_order(
account_id=order['account_id'], domains=domains, cluster_ids=cluster_ids
account_id=order['account_id'], domains=domains, cluster_ids=cluster_ids,
challenge_type=challenge_type, created_by=current_user['id'],
)
challenges = await acme_service.respond_to_challenges(new_order['order_id'])
return {"message": "Renewal order created", "new_order_id": new_order['order_id'], "challenges": challenges}
# dns-01 is driven by the orchestrator after the TXT is published; only http-01 responds here.
challenges = []
if challenge_type != 'dns-01':
challenges = await acme_service.respond_to_challenges(new_order['order_id'])
return {"message": "Renewal order created", "new_order_id": new_order['order_id'], "challenge_type": challenge_type, "challenges": challenges}
finally:
await close_database_connection(conn)
@@ -611,11 +949,17 @@ async def get_renewal_schedule(authorization: str = Header(None)):
raise HTTPException(status_code=403, detail="Insufficient permissions: ssl.read required")
conn = await get_database_connection()
try:
# Issue #35: expose the challenge method/provider (via the originating order/account) so the
# UI can distinguish manual DNS-01 certs, which cannot auto-renew unattended. LEFT JOINs keep
# legacy certs (no order link / pre-DNS-01 columns) rendering as http-01.
certs = await conn.fetch("""
SELECT id, name, primary_domain, expiry_date, auto_renew, days_until_expiry
FROM ssl_certificates
WHERE source = 'letsencrypt' AND is_active = TRUE
ORDER BY expiry_date ASC NULLS LAST
SELECT c.id, c.name, c.primary_domain, c.expiry_date, c.auto_renew, c.days_until_expiry,
o.challenge_type, a.dns_provider
FROM ssl_certificates c
LEFT JOIN letsencrypt_orders o ON o.id = c.letsencrypt_order_id
LEFT JOIN letsencrypt_accounts a ON a.id = o.account_id
WHERE c.source = 'letsencrypt' AND c.is_active = TRUE
ORDER BY c.expiry_date ASC NULLS LAST
""")
return [dict(c) for c in certs]
finally:
@@ -663,6 +1007,18 @@ async def _complete_certificate(order_id: int) -> dict:
cluster_ids = json.loads(order['cluster_ids']) if isinstance(order['cluster_ids'], str) else order['cluster_ids']
primary_domain = domains[0] if domains else 'unknown'
# Issue #35: a manual DNS-01 certificate cannot be auto-renewed unattended (the renewal task
# skips it — see main.py), so persist auto_renew=FALSE rather than storing a misleading
# "Enabled" that the user trusts while the cert silently expires. http-01 and automated
# DNS-01 (e.g. Cloudflare) keep auto_renew=TRUE, preserving existing behaviour.
auto_renew_value = True
if order.get('challenge_type') == 'dns-01':
acct_provider = await conn.fetchval(
"SELECT dns_provider FROM letsencrypt_accounts WHERE id = $1", order['account_id']
)
if (acct_provider or 'manual') == 'manual':
auto_renew_value = False
# Commit 5g: guard against empty cert_private_key.
# Inserting an SSL certificate row with an empty private_key would silently
# produce an unusable certificate (HAProxy would fail to load on Apply, or
@@ -749,12 +1105,13 @@ async def _complete_certificate(order_id: int) -> dict:
certificate_content = $1, private_key_content = $2, chain_content = $3,
all_domains = $4::jsonb, expiry_date = $5, days_until_expiry = $6,
issuer = $7, fingerprint = $8, letsencrypt_order_id = $9,
auto_renew = TRUE, is_active = TRUE, last_config_status = 'PENDING',
auto_renew = $11, is_active = TRUE, last_config_status = 'PENDING',
updated_at = NOW()
WHERE id = $10
""", cert_data['certificate_pem'], private_key_pem,
cert_data.get('chain_pem', ''), all_domains,
expiry_date, days_until_expiry, issuer, fingerprint, order_id, cert_id)
expiry_date, days_until_expiry, issuer, fingerprint, order_id, cert_id,
auto_renew_value)
logger.info(f"ACME RENEWAL: Updated certificate {cert_id} for {primary_domain}, status=PENDING")
else:
cert_row = await conn.fetchrow("""
@@ -764,11 +1121,12 @@ async def _complete_certificate(order_id: int) -> dict:
usage_type, source, letsencrypt_order_id, auto_renew, is_active,
last_config_status)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, $10,
'frontend', 'letsencrypt', $11, TRUE, TRUE, 'PENDING')
'frontend', 'letsencrypt', $11, $12, TRUE, 'PENDING')
RETURNING id
""", f"le-{primary_domain}", cert_data['certificate_pem'], private_key_pem,
cert_data.get('chain_pem', ''), primary_domain, all_domains,
expiry_date, days_until_expiry, issuer, fingerprint, order_id)
expiry_date, days_until_expiry, issuer, fingerprint, order_id,
auto_renew_value)
cert_id = cert_row['id']
await conn.execute(
@@ -815,12 +1173,18 @@ async def _complete_certificate(order_id: int) -> dict:
if mapped:
effective_cluster_ids = [r['cluster_id'] for r in mapped]
else:
# Audit Tur 6 / Commit 5j: only fall back to ACME-enabled clusters.
# Applying renewal to ACME-disabled clusters could re-introduce Issue #11
# patterns and risks deploying certs to clusters where they can't be renewed.
all_clusters = await conn.fetch(
"SELECT id FROM haproxy_clusters WHERE is_active = TRUE AND acme_enabled = TRUE"
# Audit Tur 6 / Commit 5j: http-01 only falls back to ACME-enabled clusters.
# Issue #35: a dns-01 cert needs NO challenge routing / port 80, so it can renew on
# any active cluster — resolve to all active clusters for dns-01.
ch_type = await conn.fetchval(
"SELECT challenge_type FROM letsencrypt_orders WHERE id = $1", order_id
)
if ch_type == 'dns-01':
all_clusters = await conn.fetch("SELECT id FROM haproxy_clusters WHERE is_active = TRUE")
else:
all_clusters = await conn.fetch(
"SELECT id FROM haproxy_clusters WHERE is_active = TRUE AND acme_enabled = TRUE"
)
effective_cluster_ids = [r['id'] for r in all_clusters]
if effective_cluster_ids:
logger.info(f"ACME RENEWAL: Resolved {len(effective_cluster_ids)} cluster(s) for global cert {cert_id}")
+42 -3
View File
@@ -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.",
@@ -199,6 +203,23 @@ def humanize_error_detail(error_detail: Any) -> Dict[str, Any]:
status = parsed.get("status")
subproblems = parsed.get("subproblems") or []
# Issue #35: DNS-01 failures are recorded as {stage, reason, timestamp} (no RFC8555 "type"),
# so without this fallback the humanized alert would show a bare "ACME error" with no message.
# Surface the reason and a targeted hint so the operator knows exactly what to fix.
if not problem_type and parsed.get("reason"):
reason = str(parsed.get("reason"))
message = message or reason
title = "DNS-01 validation failed"
rlow = reason.lower()
if "decrypt" in rlow or "credential" in rlow:
hint = hint or "Re-enter the DNS provider credentials for this account in ACME Automation."
elif "zone" in rlow:
hint = hint or "Confirm the domain's DNS zone is managed by the configured provider and the token has access to it."
elif "deadline" in rlow or "expired" in rlow or "confirm" in rlow:
hint = hint or "The manual confirmation window passed. Create a new certificate request and publish the TXT record promptly."
else:
hint = hint or "Check the DNS TXT record and provider credentials, then retry."
out = {
"title": title,
"message": message,
@@ -694,6 +715,7 @@ async def run_checks(
cluster_ids: List[int],
account_id: Optional[int],
only: Optional[List[str]] = None,
challenge_type: str = "http-01",
) -> List[Dict[str, Any]]:
"""Execute the full pre-flight check suite. `only` lets callers re-run a
subset (per-check rerun in the UI).
@@ -714,12 +736,29 @@ async def run_checks(
except (TypeError, ValueError):
safe_account_id = None
# Issue #35: DNS-01 validates via a TXT record, so the HTTP-01 reachability checks
# (public A record, inbound port 80, ACME Challenge Routing) do not apply — report them
# as `skipped` rather than failing an internal/isolated host that is actually fine.
is_dns01 = (challenge_type == "dns-01")
if "dns" in selected:
results.append(await _safe_check("dns", "DNS resolution", check_dns(safe_domains)))
if is_dns01:
results.append(_check_result("dns", "DNS resolution", "skipped",
"DNS-01: a public A record is not required (validation is via a TXT record).",
severity="info"))
else:
results.append(await _safe_check("dns", "DNS resolution", check_dns(safe_domains)))
if "port80" in selected:
results.append(await _safe_check("port80", "Port 80 reachability", check_port80(safe_domains)))
if is_dns01:
results.append(_check_result("port80", "Port 80 reachability", "skipped",
"DNS-01: inbound port 80 is not required.", severity="info"))
else:
results.append(await _safe_check("port80", "Port 80 reachability", check_port80(safe_domains)))
if "routing" in selected:
results.append(await _safe_check("routing", "HAProxy routing", check_routing(conn, safe_domains, safe_cluster_ids)))
if is_dns01:
results.append(_check_result("routing", "HAProxy routing", "skipped",
"DNS-01: ACME Challenge Routing is not required.", severity="info"))
else:
results.append(await _safe_check("routing", "HAProxy routing", check_routing(conn, safe_domains, safe_cluster_ids)))
if "account" in selected:
results.append(await _safe_check("account", "ACME account", check_account(conn, safe_account_id)))
if "agents" in selected:
+70 -26
View File
@@ -28,7 +28,7 @@ def _b64url(data: bytes) -> str:
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)
@@ -37,7 +37,11 @@ class ACMEService:
def __init__(self):
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:
conn = await get_database_connection()
@@ -71,17 +75,21 @@ class ACMEService:
raise Exception(f"Failed to fetch ACME directory: HTTP {resp.status}")
data = await resp.json()
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()
self._directory_cache[directory_url] = data
return data
async def _get_nonce(self, directory_url: str) -> str:
if self._nonce:
nonce = self._nonce
self._nonce = None
return nonce
# Use a cached nonce for THIS CA only; otherwise fetch a fresh one from THIS CA's newNonce.
cached = self._nonce_by_dir.pop(directory_url, None)
if cached:
return cached
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 session.head(directory['newNonce']) as resp:
return resp.headers['Replay-Nonce']
@@ -128,6 +136,20 @@ class ACMEService:
digest = hashlib.sha256(ordered.encode('utf-8')).digest()
return _b64url(digest)
@staticmethod
def _dns_txt_value(key_authorization: str) -> str:
"""RFC 8555 §8.4: the DNS-01 TXT value is base64url(SHA256(key_authorization)) over the
RAW 32-byte digest (NOT the hexdigest)."""
return _b64url(hashlib.sha256(key_authorization.encode('utf-8')).digest())
@staticmethod
def _challenge_dns_name(identifier: str) -> str:
"""The `_acme-challenge.<base>` record name for an ACME identifier. A leading wildcard
`*.` is stripped, so both `*.example.com` and bare `example.com` map to the SAME name
`_acme-challenge.example.com` (which is why apex+wildcard need two coexisting TXT values)."""
base = identifier[2:] if identifier.startswith('*.') else identifier
return f"_acme-challenge.{base}"
def _sign_jws(self, private_key, protected: dict, payload: Any) -> dict:
protected_b64 = _b64url(json.dumps(protected).encode('utf-8'))
if payload == "":
@@ -174,11 +196,17 @@ class ACMEService:
timeout=aiohttp.ClientTimeout(total=30),
) as resp:
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()
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)
protected['nonce'] = nonce
body = self._sign_jws(private_key, protected, payload)
@@ -208,6 +236,8 @@ class ACMEService:
tos_agreed: bool = True,
eab_kid: Optional[str] = None,
eab_hmac_key: Optional[str] = None,
challenge_type: str = 'http-01',
dns_provider: Optional[str] = None,
) -> dict:
directory = await self.get_directory(directory_url)
pem, jwk = self._generate_account_key()
@@ -250,13 +280,14 @@ class ACMEService:
conn = await get_database_connection()
try:
row = await conn.fetchrow("""
INSERT INTO letsencrypt_accounts (email, directory_url, account_url, jwk_private_key, status, tos_agreed, eab_kid)
VALUES ($1, $2, $3, $4, $5, $6, $7)
INSERT INTO letsencrypt_accounts (email, directory_url, account_url, jwk_private_key, status, tos_agreed, eab_kid, challenge_type, dns_provider)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (email, directory_url) DO UPDATE SET
account_url = $3, jwk_private_key = $4, status = $5, tos_agreed = $6, updated_at = NOW()
RETURNING id, email, directory_url, account_url, status, tos_agreed, created_at
account_url = $3, jwk_private_key = $4, status = $5, tos_agreed = $6,
challenge_type = $8, dns_provider = $9, updated_at = NOW()
RETURNING id, email, directory_url, account_url, status, tos_agreed, created_at, challenge_type, dns_provider
""", email, directory_url, account_url, pem,
data.get('status') or 'valid', tos_agreed, eab_kid)
data.get('status') or 'valid', tos_agreed, eab_kid, challenge_type, dns_provider)
return dict(row)
finally:
await close_database_connection(conn)
@@ -302,8 +333,10 @@ class ACMEService:
account_id: int,
domains: List[str],
cluster_ids: Optional[List[int]] = None,
challenge_type: str = 'http-01',
created_by: Optional[int] = None,
) -> dict:
logger.info(f"ACME: Creating order for domains={domains}, account_id={account_id}")
logger.info(f"ACME: Creating order for domains={domains}, account_id={account_id}, challenge_type={challenge_type}")
conn = await get_database_connection()
try:
account = await conn.fetchrow(
@@ -341,12 +374,12 @@ class ACMEService:
order_row = await conn.fetchrow("""
INSERT INTO letsencrypt_orders
(account_id, order_url, status, domains, finalize_url, expires_at, cluster_ids)
VALUES ($1, $2, $3, $4, $5, $6, $7)
(account_id, order_url, status, domains, finalize_url, expires_at, cluster_ids, challenge_type, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id
""", account_id, order_url, data.get('status') or 'pending',
json.dumps(domains), data.get('finalize') or '', expires_at,
json.dumps(cluster_ids or []))
json.dumps(cluster_ids or []), challenge_type, created_by)
order_id = order_row['id']
@@ -383,18 +416,24 @@ class ACMEService:
domain = (auth_data.get('identifier') or {}).get('value', '')
http01_for_domain = False
for challenge in (auth_data.get('challenges') or []):
if challenge.get('type') == 'http-01':
# Store only the challenge of the CHOSEN method (default 'http-01' keeps the
# existing behaviour byte-identical; 'dns-01' selects the TXT challenge instead).
if challenge.get('type') == challenge_type:
token = challenge['token']
jwk = self._get_jwk(private_key)
thumbprint = self._jwk_thumbprint(jwk)
key_auth = f"{token}.{thumbprint}"
dns_txt = self._dns_txt_value(key_auth) if challenge_type == 'dns-01' else None
await conn.execute("""
INSERT INTO acme_challenges (order_id, domain, token, key_authorization, challenge_url, status)
VALUES ($1, $2, $3, $4, $5, $6)
INSERT INTO acme_challenges
(order_id, domain, token, key_authorization, challenge_url, status,
challenge_type, dns_txt_value)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
""", order_id, domain, token, key_auth,
challenge.get('url') or '', challenge.get('status') or 'pending')
logger.info(f"ACME: Challenge stored for domain={domain}, token={token[:20]}..., challenge_url={(challenge.get('url') or '')[:60]}")
challenge.get('url') or '', challenge.get('status') or 'pending',
challenge_type, dns_txt)
logger.info(f"ACME: {challenge_type} challenge stored for domain={domain}, token={token[:20]}..., challenge_url={(challenge.get('url') or '')[:60]}")
http01_for_domain = True
if http01_for_domain and domain:
domains_with_http01.add(domain)
@@ -413,7 +452,7 @@ class ACMEService:
error_payload, order_id
)
raise Exception(
f"ACME order {order_id} created but no http-01 challenges available "
f"ACME order {order_id} created but no {challenge_type} challenges available "
f"(auth fetch failures: {len(auth_fetch_failures)}). See order.error_detail for diagnostics."
)
elif auth_fetch_failures:
@@ -449,10 +488,15 @@ class ACMEService:
try:
# Issue #12 / Commit 5a: include 'failed' challenges so they can be retried,
# but rate-limit per challenge: max 5 attempts in last 5 minutes.
# DNS-01 skip-gate (the single safe choke point): never POST a challenge response for a
# dns-01 row whose TXT record has not been published yet — that would make the CA validate
# against a missing record and burn the order. http-01 rows (challenge_type 'http-01'/NULL)
# are never excluded, so the existing flow is byte-identical.
challenges = await conn.fetch(
"""SELECT * FROM acme_challenges
WHERE order_id = $1
AND (status IN ('pending', 'failed') OR status IS NULL)""",
AND (status IN ('pending', 'failed') OR status IS NULL)
AND NOT (COALESCE(challenge_type, 'http-01') = 'dns-01' AND COALESCE(dns_record_published, FALSE) = FALSE)""",
order_id
)
order = await conn.fetchrow(
+321
View File
@@ -0,0 +1,321 @@
"""Issue #35 — ACME DNS-01 (v1.8.0): non-blocking per-cycle orchestration.
Driven by the existing `complete_pending_acme_orders` background task (which already claims
in-progress orders with `FOR UPDATE SKIP LOCKED`). For a dns-01 order this module advances AT MOST
ONE step per 60s cycle — publish TXT, then (after a short min-age) respond — so the serial claim
loop is never blocked by a multi-minute wait, and NO DNS library is needed (the CA is the source of
truth; a propagation-lag `invalid` is recovered by a bounded fresh-order chain).
Design invariants (from the hardening review):
- Additive at the RRset level: publish/cleanup operate on a single (name, value), so wildcard+apex
(two values at one name) coexist.
- No new order-status value: a failed order stays `invalid`; a boolean `dns01_retry_claimed` does the
winner-only CAS + claim exclusion, so existing `status` consumers are untouched.
- Secrets (provider API tokens) are NEVER logged or written to error_detail/events.
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from typing import Dict, List, Optional, Tuple
from database.connection import get_database_connection, close_database_connection
from services.acme_service import acme_service as acme_svc, ACMEService
from services.dns_providers import get_provider, is_supported, DnsProviderError
from utils.dns_credentials import decrypt_dns_credentials
from utils.activity_log import record_event
logger = logging.getLogger(__name__)
# Tunables (kept conservative vs Let's Encrypt rate limits: 5 failed-validations/host/hour,
# 300 new-orders/account/3h).
PROPAGATION_GRACE_SECONDS = 25 # min age before we tell the CA to validate
MANUAL_CONFIRM_TTL = timedelta(hours=48)
MAX_RETRIES = 3 # bounded fresh-order chain (1 original + 3 retries = 4 orders)
# Retry backoff floor (minutes) indexed by the order's current dns01_attempts: [15, 30, 60].
# The AUTHORITATIVE implementation is the SQL CASE in main.py's claim query
# (complete_pending_acme_orders), so the backoff is evaluated atomically with the
# FOR UPDATE SKIP LOCKED claim. Documented here only — do not reintroduce a second copy.
def _now() -> datetime:
return datetime.now(timezone.utc)
def _aware(dt) -> Optional[datetime]:
if dt is None:
return None
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
async def _load_credentials(conn, account_id: int) -> Tuple[Optional[Dict[str, str]], bool]:
"""Return (credentials_dict_or_None, row_exists). credentials None + row_exists True means the
stored token could not be decrypted (e.g. SECRET_KEY rotated)."""
row = await conn.fetchrow(
"SELECT credentials_encrypted FROM letsencrypt_account_dns_credentials WHERE account_id = $1",
account_id,
)
if not row:
return None, False
return decrypt_dns_credentials(row["credentials_encrypted"]), True
async def _fail_order(conn, order_id: int, reason: str) -> None:
"""Mark an order invalid with a sanitized reason (no secrets) + event."""
import json
payload = json.dumps({"stage": "dns01", "reason": reason, "timestamp": _now().isoformat()})
await conn.execute(
"UPDATE letsencrypt_orders SET status = 'invalid', error_detail = $1, updated_at = NOW() WHERE id = $2",
payload, order_id,
)
await record_event(order_id, "acme.dns01.validation", severity="ERROR", message=reason, conn=conn)
async def advance_dns01_order(order_id: int) -> None:
"""One non-blocking step for a claimed pending/processing dns-01 order. No-op for http-01 or
orders not in a publishable state. Safe to call every cycle (idempotent via CAS flags)."""
conn = await get_database_connection()
try:
order = await conn.fetchrow(
"""SELECT o.id, o.status, o.challenge_type, o.account_id, a.dns_provider
FROM letsencrypt_orders o JOIN letsencrypt_accounts a ON o.account_id = a.id
WHERE o.id = $1""",
order_id,
)
if not order or order["challenge_type"] != "dns-01":
return
if order["status"] not in ("pending", "processing"):
return
challenges = await conn.fetch(
"SELECT * FROM acme_challenges WHERE order_id = $1 AND challenge_type = 'dns-01'", order_id
)
if not challenges:
return
provider_name = (order["dns_provider"] or "manual").strip()
# --- Manual provider: the user publishes + confirms; we only enforce the deadline. ---
if provider_name == "manual" or not is_supported(provider_name):
deadline = _aware(challenges[0]["manual_confirm_deadline"])
if deadline is None:
new_deadline = _now() + MANUAL_CONFIRM_TTL
await conn.execute(
"UPDATE acme_challenges SET manual_confirm_deadline = $1 WHERE order_id = $2 AND manual_confirm_deadline IS NULL",
new_deadline, order_id,
)
elif _now() > deadline:
await _fail_order(conn, order_id,
"Manual DNS-01 confirmation deadline passed without confirmation.")
return # respond happens via the dns-confirm endpoint
# --- Automated provider (e.g. Cloudflare). ---
creds, row_exists = await _load_credentials(conn, order["account_id"])
if not row_exists:
await _fail_order(conn, order_id,
f"No DNS provider credentials configured for provider '{provider_name}'.")
return
if creds is None:
await _fail_order(conn, order_id,
"DNS provider credentials could not be decrypted; re-enter them in Settings.")
return
provider = get_provider(provider_name, creds)
# Publish any not-yet-published challenge (CAS so two replicas can't double-publish).
for ch in challenges:
if ch["dns_record_published"]:
continue
flipped = await conn.fetchval(
"""UPDATE acme_challenges SET dns_record_published = TRUE, dns_published_at = NOW()
WHERE id = $1 AND dns_record_published = FALSE RETURNING id""",
ch["id"],
)
if not flipped:
continue
name = ACMEService._challenge_dns_name(ch["domain"])
try:
await provider.add_txt_record(name, ch["dns_txt_value"])
await record_event(order_id, "acme.dns01.publish",
message=f"Published TXT {name}; waiting for DNS propagation before asking the CA to validate.",
details={"name": name, "provider": provider_name}, conn=conn)
except DnsProviderError as exc:
# Revert so the next cycle retries the publish; keep the order pending. exc is sanitized.
await conn.execute(
"UPDATE acme_challenges SET dns_record_published = FALSE, dns_published_at = NULL WHERE id = $1",
ch["id"],
)
await record_event(order_id, "acme.dns01.publish", severity="WARNING",
message=f"Publish failed for {name}: {exc}",
details={"name": name, "provider": provider_name}, conn=conn)
return
# All published? Then respond once the min-age gate has elapsed (across cycles, no sleep).
rows = await conn.fetch(
"SELECT dns_record_published, dns_published_at FROM acme_challenges WHERE order_id = $1 AND challenge_type = 'dns-01'",
order_id,
)
if any(not r["dns_record_published"] for r in rows):
return
published_ats = [_aware(r["dns_published_at"]) for r in rows if r["dns_published_at"]]
if not published_ats:
return
if (_now() - min(published_ats)).total_seconds() < PROPAGATION_GRACE_SECONDS:
return # wait one more cycle
# Only POST the challenge response if something still needs validating — avoids re-POSTing
# every cycle (and bumping dns01_last_attempt_at) once the CA already has them processing.
still_pending = await conn.fetchval(
"""SELECT 1 FROM acme_challenges WHERE order_id = $1 AND challenge_type = 'dns-01'
AND (status IN ('pending', 'failed') OR status IS NULL) LIMIT 1""",
order_id,
)
if not still_pending:
return
await acme_svc.respond_to_challenges(order_id)
await conn.execute("UPDATE letsencrypt_orders SET dns01_last_attempt_at = NOW() WHERE id = $1", order_id)
await record_event(order_id, "acme.dns01.responded",
message="Told the CA to validate the DNS-01 challenge(s).", conn=conn)
finally:
await close_database_connection(conn)
async def confirm_manual_dns01(order_id: int) -> Dict:
"""Called by POST /orders/{id}/dns-confirm for the manual provider: mark the TXT published and
tell the CA to validate. Returns a small status dict."""
conn = await get_database_connection()
try:
await conn.execute(
"""UPDATE acme_challenges SET dns_record_published = TRUE, dns_published_at = COALESCE(dns_published_at, NOW())
WHERE order_id = $1 AND challenge_type = 'dns-01'""",
order_id,
)
await acme_svc.respond_to_challenges(order_id)
await conn.execute("UPDATE letsencrypt_orders SET dns01_last_attempt_at = NOW() WHERE id = $1", order_id)
await record_event(order_id, "acme.dns01.responded",
message="Manual DNS-01 confirmed; told the CA to validate.", conn=conn)
return {"ok": True}
finally:
await close_database_connection(conn)
async def retry_invalid_dns01(order_id: int) -> None:
"""Bounded fresh-order recovery for a dns-01 order that went `invalid` (e.g. propagation lag).
Winner-only CAS on `dns01_retry_claimed`; cleans the old TXT, mints a child order. No-op for
http-01 or when the budget is exhausted."""
conn = await get_database_connection()
child_created = False
try:
order = await conn.fetchrow(
"""SELECT o.*, a.dns_provider FROM letsencrypt_orders o
JOIN letsencrypt_accounts a ON o.account_id = a.id WHERE o.id = $1""",
order_id,
)
if not order or order["challenge_type"] != "dns-01":
return
if (order["dns01_attempts"] or 0) >= MAX_RETRIES:
return # budget exhausted; stays terminal invalid
# Winner-only claim (closes the cross-replica double-mint race).
claimed = await conn.fetchval(
"""UPDATE letsencrypt_orders SET dns01_retry_claimed = TRUE, updated_at = NOW()
WHERE id = $1 AND status = 'invalid' AND dns01_retry_claimed = FALSE RETURNING id""",
order_id,
)
if not claimed:
return
provider_name = (order["dns_provider"] or "manual").strip()
# Best-effort cleanup of this order's TXT before minting the replacement.
if provider_name != "manual" and is_supported(provider_name):
creds, _exists = await _load_credentials(conn, order["account_id"])
if creds:
provider = get_provider(provider_name, creds)
chs = await conn.fetch(
"SELECT domain, dns_txt_value FROM acme_challenges WHERE order_id = $1 AND challenge_type = 'dns-01'",
order_id,
)
for ch in chs:
try:
await provider.remove_txt_record(ACMEService._challenge_dns_name(ch["domain"]), ch["dns_txt_value"])
except DnsProviderError:
pass # tolerate; the reconcile sweep will retry
await conn.execute(
"UPDATE acme_challenges SET dns_record_cleaned = TRUE WHERE order_id = $1 AND dns_record_published = TRUE",
order_id,
)
import json
domains = json.loads(order["domains"]) if isinstance(order["domains"], str) else (order["domains"] or [])
cluster_ids = json.loads(order["cluster_ids"]) if isinstance(order["cluster_ids"], str) else (order["cluster_ids"] or [])
next_attempts = (order["dns01_attempts"] or 0) + 1
child = await acme_svc.create_order(
order["account_id"], domains, cluster_ids, challenge_type="dns-01",
created_by=order["created_by"],
)
child_created = True
await conn.execute(
"""UPDATE letsencrypt_orders
SET dns01_attempts = $1, dns01_parent_order_id = $2, dns01_last_attempt_at = NOW()
WHERE id = $3""",
next_attempts, order_id, child["order_id"],
)
await record_event(order_id, "acme.dns01.validation", severity="WARNING",
message=f"DNS-01 order invalid; minted retry #{next_attempts} (order {child['order_id']}).",
details={"child_order_id": child["order_id"], "attempt": next_attempts}, conn=conn)
except Exception as exc: # noqa: BLE001
logger.error(f"[DNS01-RETRY] order {order_id}: {exc}")
# A transient failure (e.g. CA rate limit) BEFORE the child was minted must NOT permanently
# burn the retry slot — reset the claim so the next cycle can retry. If the child was already
# created, leave the claim set (resetting would double-mint).
if not child_created:
try:
await conn.execute(
"UPDATE letsencrypt_orders SET dns01_retry_claimed = FALSE WHERE id = $1 AND status = 'invalid'",
order_id,
)
except Exception:
pass
finally:
await close_database_connection(conn)
async def reconcile_dns01_cleanup() -> None:
"""Best-effort sweep that removes any TXT records left published for terminal orders (covers a
cleanup that failed, or the kill-switch being flipped off mid-flight). NOT gated by the
kill-switch. Runs once per completion cycle."""
conn = await get_database_connection()
try:
rows = await conn.fetch(
"""SELECT c.id AS chal_id, c.order_id, c.domain, c.dns_txt_value, o.account_id, a.dns_provider
FROM acme_challenges c
JOIN letsencrypt_orders o ON c.order_id = o.id
JOIN letsencrypt_accounts a ON o.account_id = a.id
WHERE c.challenge_type = 'dns-01'
AND c.dns_record_published = TRUE
AND COALESCE(c.dns_record_cleaned, FALSE) = FALSE
AND o.status IN ('valid', 'invalid', 'cancelled')
LIMIT 50""",
)
for r in rows:
provider_name = (r["dns_provider"] or "manual").strip()
if provider_name == "manual" or not is_supported(provider_name):
# Manual: nothing to call; mark cleaned so we stop revisiting.
await conn.execute("UPDATE acme_challenges SET dns_record_cleaned = TRUE WHERE id = $1", r["chal_id"])
continue
creds, _exists = await _load_credentials(conn, r["account_id"])
if creds is None:
continue # can't clean without creds; leave for a later pass
provider = get_provider(provider_name, creds)
try:
await provider.remove_txt_record(ACMEService._challenge_dns_name(r["domain"]), r["dns_txt_value"])
await conn.execute("UPDATE acme_challenges SET dns_record_cleaned = TRUE WHERE id = $1", r["chal_id"])
await record_event(r["order_id"], "acme.dns01.cleanup",
message=f"Cleaned up TXT for {r['domain']}", conn=conn)
except DnsProviderError:
pass # retry next sweep
except Exception as exc: # noqa: BLE001
logger.debug(f"[DNS01-RECONCILE] skipped: {exc}")
finally:
await close_database_connection(conn)
@@ -0,0 +1,14 @@
"""Issue #35 — ACME DNS-01 (v1.8.0): pluggable DNS provider package.
A small adapter layer so DNS-01 challenges can publish/clean up the
`_acme-challenge.<domain>` TXT record via different DNS providers. The interface is
additive at the RRset level (add/remove a single value by name+content, never
overwrite-by-name) so multiple coexisting values at one name (wildcard + apex) work.
MVP providers: manual (user publishes the TXT themselves) and Cloudflare. New providers
plug in via the registry without touching the orchestration.
"""
from .base import DnsProvider, DnsProviderError
from .registry import get_provider, list_providers, is_supported
__all__ = ["DnsProvider", "DnsProviderError", "get_provider", "list_providers", "is_supported"]
+53
View File
@@ -0,0 +1,53 @@
"""Abstract DNS provider interface for ACME DNS-01 (Issue #35)."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Dict, List
class DnsProviderError(Exception):
"""A DNS provider failure with a SANITIZED, user-safe message.
The message must NEVER contain API tokens, request headers, or other secrets — it is
persisted to acme_order_events / order error_detail and shown in the UI. Raise this (not a
raw aiohttp/json error) so credentials can't leak into logs or the order timeline.
"""
class DnsProvider(ABC):
"""Base class for a pluggable DNS provider.
RRset semantics are ADDITIVE: ``add_txt_record`` ensures a (name, value) TXT exists WITHOUT
removing other values at the same name, and ``remove_txt_record`` deletes ONLY the record
matching (name, value). This is required because a cert for ``example.com`` + ``*.example.com``
publishes two distinct values at the SAME name ``_acme-challenge.example.com``.
"""
# Stable machine name (used in DB + API); human label; whether the provider automates publishing.
name: str = "base"
label: str = "Base"
automated: bool = True
# Declarative schema the UI renders to collect credentials. Each field:
# {"key", "label", "type" ("text"|"password"), "required" (bool), "max_length" (int), "help" (str)}
credential_fields: List[Dict] = []
def __init__(self, credentials: Dict[str, str] | None = None):
self.credentials = credentials or {}
@abstractmethod
async def verify_credentials(self) -> Dict:
"""Validate the stored credentials against the provider. Returns
``{"ok": bool, "detail": str}`` (detail is user-safe). Must not raise on auth failure —
return ``ok=False`` with a sanitized detail; may raise DnsProviderError on transport errors.
"""
@abstractmethod
async def add_txt_record(self, name: str, value: str) -> None:
"""Ensure a TXT record (name, value) exists. Idempotent; must not remove other values
at the same name. Raise DnsProviderError (sanitized) on failure."""
@abstractmethod
async def remove_txt_record(self, name: str, value: str) -> None:
"""Remove ONLY the TXT record matching (name, value). Tolerate 'already gone'.
Raise DnsProviderError (sanitized) on a real failure."""
@@ -0,0 +1,179 @@
"""Cloudflare DNS provider for ACME DNS-01 (Issue #35).
Uses the Cloudflare API v4 over aiohttp (no new dependency). The base URL is a hardcoded
constant and redirects are not followed (no user-controlled URL — only the already-validated
domain name influences which zone is used). Errors are wrapped in DnsProviderError with a
sanitized message so the API token never reaches logs / order events.
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
import aiohttp
from .base import DnsProvider, DnsProviderError
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()
if len(s) >= 2 and s[0] == '"' and s[-1] == '"':
return s[1:-1]
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"
automated = True
credential_fields: List[Dict] = [
{
"key": "api_token",
"label": "API Token",
"type": "password",
"required": True,
"max_length": 200,
"help": "Scoped API token with Zone:DNS:Edit and Zone:Read permissions.",
}
]
def __init__(self, credentials: Dict[str, str] | None = None):
super().__init__(credentials)
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"}
async def _request(self, session: aiohttp.ClientSession, method: str, path: str, **kwargs) -> dict:
"""One Cloudflare API call. Returns the parsed JSON body. Raises a SANITIZED
DnsProviderError on transport/HTTP/API error (never echoes the token or raw headers)."""
url = f"{CLOUDFLARE_API_BASE}{path}"
try:
async with session.request(
method, url, headers=self._headers(), allow_redirects=False, **kwargs
) as resp:
try:
body = await resp.json()
except Exception: # noqa: BLE001
body = {}
if resp.status in (401, 403):
raise DnsProviderError("Cloudflare rejected the API token (check it has Zone:DNS:Edit + Zone:Read).")
if resp.status >= 400 or not body.get("success", False):
# Cloudflare returns {"errors":[{"code":..,"message":..}]} — surface only the
# human message text, never the request (which carries the token header).
msgs = "; ".join(
str(e.get("message")) for e in (body.get("errors") or []) if e.get("message")
)
raise DnsProviderError(
f"Cloudflare API error (HTTP {resp.status}){': ' + msgs if msgs else ''}"
)
return body
except DnsProviderError:
raise
except aiohttp.ClientError as exc:
# Do NOT include exc verbatim everywhere; aiohttp client errors are URL/transport only
# (no token), but keep the message generic and stable.
raise DnsProviderError(f"Could not reach the Cloudflare API ({type(exc).__name__}).")
except Exception as exc: # noqa: BLE001
raise DnsProviderError(f"Unexpected Cloudflare API failure ({type(exc).__name__}).")
async def verify_credentials(self) -> Dict:
if not self._token:
return {"ok": False, "detail": "No Cloudflare API token provided."}
try:
async with aiohttp.ClientSession(timeout=_TIMEOUT) as session:
body = await self._request(session, "GET", "/zones?per_page=1")
total = ((body.get("result_info") or {}).get("total_count"))
detail = "Cloudflare token valid."
if isinstance(total, int):
detail = f"Cloudflare token valid; {total} zone(s) visible."
return {"ok": True, "detail": detail}
except DnsProviderError as 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."}
async def _resolve_zone(self, session: aiohttp.ClientSession, record_name: str) -> Tuple[str, str]:
"""Find the most-specific (longest-suffix) managed zone for a record name.
Returns (zone_id, zone_name). Raises DnsProviderError if no zone matches."""
labels = record_name.split(".")
# Walk suffixes from longest to shortest; a zone needs at least 2 labels.
for i in range(len(labels) - 1):
candidate = ".".join(labels[i:])
if candidate.count(".") < 1:
break
body = await self._request(
session, "GET", f"/zones?name={quote(candidate)}&status=active&per_page=50"
)
results = body.get("result") or []
if results:
return results[0]["id"], candidate
raise DnsProviderError(f"No managed Cloudflare zone found for {record_name}.")
async def _find_record_id(
self, session: aiohttp.ClientSession, zone_id: str, name: str, value: str
) -> Optional[str]:
body = await self._request(
session, "GET", f"/zones/{zone_id}/dns_records?type=TXT&name={quote(name)}&per_page=100"
)
for rec in body.get("result") or []:
if _strip_quotes(rec.get("content", "")) == value:
return rec.get("id")
return None
async def add_txt_record(self, name: str, value: str) -> None:
async with aiohttp.ClientSession(timeout=_TIMEOUT) as session:
zone_id, _zone_name = await self._resolve_zone(session, name)
# Idempotent: only create if (name, value) is not already present (preserves coexisting values).
existing = await self._find_record_id(session, zone_id, name, value)
if existing:
return
await self._request(
session,
"POST",
f"/zones/{zone_id}/dns_records",
json={"type": "TXT", "name": name, "content": value, "ttl": 120},
)
async def remove_txt_record(self, name: str, value: str) -> None:
async with aiohttp.ClientSession(timeout=_TIMEOUT) as session:
try:
zone_id, _zone_name = await self._resolve_zone(session, name)
except DnsProviderError:
# Zone gone / not resolvable — nothing we can clean up.
return
record_id = await self._find_record_id(session, zone_id, name, value)
if not record_id:
return # already gone — tolerate
await self._request(session, "DELETE", f"/zones/{zone_id}/dns_records/{record_id}")
+31
View File
@@ -0,0 +1,31 @@
"""Manual DNS provider for ACME DNS-01 (Issue #35).
The user publishes the `_acme-challenge` TXT record in their own DNS (any provider, including
fully internal/isolated DNS that no API can reach) and then confirms via the UI. There is no API
to call, so add/remove are no-ops and the orchestration waits for an explicit `dns-confirm`.
CNAME delegation works implicitly here: the CA follows a CNAME, so a user who delegates
`_acme-challenge` elsewhere just publishes the value there and confirms.
"""
from __future__ import annotations
from typing import Dict, List
from .base import DnsProvider
class ManualDNSProvider(DnsProvider):
name = "manual"
label = "Manual (publish the TXT record yourself)"
automated = False
credential_fields: List[Dict] = [] # no credentials needed
async def verify_credentials(self) -> Dict:
return {"ok": True, "detail": "Manual mode needs no credentials. You will publish the TXT record yourself."}
async def add_txt_record(self, name: str, value: str) -> None:
# No-op: the user publishes the record and confirms via the UI.
return None
async def remove_txt_record(self, name: str, value: str) -> None:
# No-op: the user may remove the record manually after issuance.
return None
@@ -0,0 +1,44 @@
"""DNS provider registry for ACME DNS-01 (Issue #35).
Single source of truth mapping a provider name -> class. The API serves the credential-field
schema from here (so the UI has no hardcoded provider fields) and validates inbound provider
names against this allow-list. Adding a provider = add it here; nothing else changes.
"""
from __future__ import annotations
from typing import Dict, List, Type
from .base import DnsProvider
from .cloudflare import CloudflareDNSProvider
from .manual import ManualDNSProvider
_PROVIDERS: Dict[str, Type[DnsProvider]] = {
ManualDNSProvider.name: ManualDNSProvider,
CloudflareDNSProvider.name: CloudflareDNSProvider,
}
def is_supported(name: str) -> bool:
return name in _PROVIDERS
def get_provider(name: str, credentials: Dict[str, str] | None = None) -> DnsProvider:
cls = _PROVIDERS.get(name)
if cls is None:
raise ValueError(f"Unsupported DNS provider: {name}")
return cls(credentials or {})
def list_providers() -> List[Dict]:
"""Return the UI-facing provider catalog: name, label, automated flag, and credential schema."""
out: List[Dict] = []
for name, cls in _PROVIDERS.items():
out.append(
{
"name": cls.name,
"label": cls.label,
"automated": cls.automated,
"credential_fields": cls.credential_fields,
}
)
return out
+4 -2
View File
@@ -56,14 +56,14 @@ async def create_frontend_row(
acl_rules, redirect_rules, use_backend_rules,
request_headers, response_headers, options, tcp_request_rules, timeout_client, timeout_http_request,
rate_limit, compression, log_separate, monitor_uri,
cluster_id, maxconn, updated_at
cluster_id, maxconn, log_format, filters, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12,
$13, $14, $15, $16, $17, $18, $19,
$20, $21, $22,
$23, $24, $25, $26, $27, $28,
$29, $30, $31, $32,
$33, $34, CURRENT_TIMESTAMP
$33, $34, $35, $36, CURRENT_TIMESTAMP
)
RETURNING id
""",
@@ -101,6 +101,8 @@ async def create_frontend_row(
getattr(payload, "monitor_uri", None),
cluster_id,
getattr(payload, "maxconn", None),
getattr(payload, "log_format", None), # Issue #38
getattr(payload, "filters", None), # Issue #38
)
if mark_pending:
+20 -1
View File
@@ -393,6 +393,12 @@ def _categorize_haproxy_directive(line: str) -> str:
return "prelude"
if s.startswith("acl "):
return "acl"
# Issue #38: SPOE (and other) `filter` directives must be declared BEFORE
# the `http-request send-spoe-group` rules that use them, otherwise HAProxy
# fails with "unable to find SPOE engine". Own bucket, flushed right after
# `prelude` and before tcp_req/acl/http_req (see flush order below).
if s.startswith("filter "):
return "filter"
if s.startswith("stick-table") or s.startswith("stick "):
return "stick"
if s.startswith("tcp-request"):
@@ -414,6 +420,7 @@ def _categorize_haproxy_directive(line: str) -> str:
or s.startswith("compression ")
or s.startswith("monitor-uri")
or s.startswith("log ")
or s.startswith("log-format") # Issue #38: log-format / log-format-sd
or s.startswith("description ")
or s.startswith("disabled")
or s.startswith("enabled")
@@ -903,7 +910,7 @@ async def generate_haproxy_config_for_cluster(cluster_id: int, conn: Optional[An
# "stick-table already declared").
# ─────────────────────────────────────────────────────────────────
_fe_buckets: Dict[str, List[str]] = {
"prelude": [], "stick": [], "tcp_req": [],
"prelude": [], "filter": [], "stick": [], "tcp_req": [],
"acl": [], "http_req": [], "http_resp": [],
"redirect": [], "use_be": [], "default_be": [],
}
@@ -996,6 +1003,17 @@ async def generate_haproxy_config_for_cluster(cluster_id: int, conn: Optional[An
if line_stripped and line_stripped not in ('[]', '{}', 'null', 'None'):
_emit_fe(f" {line_stripped}")
# Issue #38: emit frontend log-format and SPOE (etc.) filter directives.
# `log_format` routes to the `prelude` bucket, `filters` to the `filter`
# bucket (both via _emit_fe → _categorize_haproxy_directive), guaranteeing
# `filter ...` is rendered before the `http-request send-spoe-group` rules.
for _fld in ('log_format', 'filters'):
if frontend.get(_fld):
for line in frontend[_fld].split('\n'):
line_stripped = line.strip()
if line_stripped and line_stripped not in ('[]', '{}', 'null', 'None'):
_emit_fe(f" {line_stripped}")
# CRITICAL: Validate frontend-backend mode compatibility
if frontend.get('default_backend'):
default_backend_name = frontend['default_backend'].strip() if frontend['default_backend'] else ''
@@ -1223,6 +1241,7 @@ async def generate_haproxy_config_for_cluster(cluster_id: int, conn: Optional[An
# ─────────────────────────────────────────────────────────────
for _bucket_key in (
"prelude",
"filter",
"stick",
"tcp_req",
"acl",
+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: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})
+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__))))
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:
@@ -0,0 +1,97 @@
"""Issue #31 — agent heartbeat JSON sanitizer.
A self-hosted agent builds its heartbeat JSON as text in bash. When a collected value is empty,
the payload can contain a structurally-invalid comma that broke the heartbeat with
`HTTP 400 Invalid JSON: Expecting property name enclosed in double quotes`. The backend now
repairs that pattern in `_sanitize_agent_json` so an already-deployed agent recovers without a
re-install. These tests pin that behaviour and prove the repair never corrupts a healthy payload.
"""
import json
from routers.agent import _sanitize_agent_json
def _assert_parses(raw: str) -> dict:
out, _ = _sanitize_agent_json(raw)
return json.loads(out) # raises if the repair did not produce valid JSON
def test_reporter_empty_system_info_bare_comma():
# The exact shape the reporter hit: an empty $system_info collapses ' $system_info,' to a
# bare comma between two members -> '"version": "x",\n ,\n "haproxy_status": ...'.
raw = (
'{\n'
' "name": "test",\n'
' "hostname": "h",\n'
' "status": "online",\n'
' "version": "2.0.0",\n'
' ,\n'
' "haproxy_status": "running",\n'
' "cluster_id": 1\n'
'}'
)
parsed = _assert_parses(raw)
assert parsed["name"] == "test"
assert parsed["status"] == "online"
assert parsed["haproxy_status"] == "running"
def test_empty_numeric_subfield_before_comma():
# An empty unquoted numeric ("memory_total": ,) — covered by the pre-existing Fix 1.
raw = '{ "name": "t", "cpu_count": , "memory_total": , "status": "online" }'
parsed = _assert_parses(raw)
assert parsed["cpu_count"] is None and parsed["memory_total"] is None
assert parsed["status"] == "online"
def test_empty_value_before_closing_brace():
raw = '{ "name": "t", "status": "online", "applied_config_version": }'
parsed = _assert_parses(raw)
assert parsed["applied_config_version"] is None
def test_leading_comma_first_member():
# Empty $system_info as the FIRST member -> '{ , "name": ... }'.
raw = '{\n ,\n "name": "t",\n "status": "online"\n}'
parsed = _assert_parses(raw)
assert parsed["name"] == "t"
def test_comma_run_two_empty_fields():
# Two empties in a row (odd-length comma run) must still collapse to valid JSON.
raw = '{ "a": 1,\n ,\n ,\n "b": 2 }'
parsed = _assert_parses(raw)
assert parsed["a"] == 1 and parsed["b"] == 2
def test_trailing_comma_regression():
# Pre-existing Fix 3 must still hold after the new fixes were added.
raw = '{ "name": "t", "status": "online", }'
parsed = _assert_parses(raw)
assert parsed["name"] == "t"
def test_healthy_payload_is_untouched():
# A well-formed agent payload must pass through unchanged (sanitized=False) and its values —
# including the base64 stats CSV and the nested server_statuses — must be byte-identical.
payload = {
"name": "agent-1",
"status": "online",
"cluster_id": 1,
"server_statuses": {"be_app": {"s1": "UP", "s2": "DOWN"}},
"network_interfaces": ["eth0", "eth1"],
"haproxy_stats_csv": "IyBwdmJjLGJhY2tlbmQsZnJvbnRlbmQs", # base64: contains commas only inside a quoted string is impossible (base64 has none)
"applied_config_version": "cluster-1-v42",
}
raw = json.dumps(payload)
out, changed = _sanitize_agent_json(raw)
assert changed is False
assert out == raw # byte-identical
assert json.loads(out) == payload
def test_idempotent_on_already_clean_minimal():
raw = '{"name": "t", "status": "online"}'
out, changed = _sanitize_agent_json(raw)
assert changed is False
assert out == raw
@@ -0,0 +1,61 @@
"""Issue #31 — agent-script hardening guard (static).
The agent install scripts hand-build the heartbeat JSON, so if `collect_system_info` ever yields
nothing the `$system_info,` line collapses to a bare comma and the whole heartbeat is invalid JSON
(HTTP 400). The fix adds a guard at every fragment-form call site that substitutes a single valid
key when system_info is empty. This static check enforces that the guard is present AND kept in
sync across BOTH platform scripts — the project requires the two agent-script copies to stay in
lockstep. (Empty numeric subfields like "memory_total": , are a separate, milder case already
repaired by the backend sanitizer, so they are intentionally NOT guarded in the script — guarding
them with a strict integer test would wrongly reject the scientific-notation that mawk emits for
multi-GB sizes on Debian/Ubuntu.)
"""
import os
_SCRIPT_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), # backend/
"utils", "agent_scripts",
)
def _read(name: str) -> str:
with open(os.path.join(_SCRIPT_DIR, name), "r") as f:
return f.read()
LINUX = _read("linux_install.sh")
MACOS = _read("macos_install.sh")
# The empty-system_info guard — present at BOTH fragment call sites (register_agent + send_heartbeat).
_B2_GUARD = '[[ "$system_info" != *\'"\'* ]] && system_info=\'"operating_system": "unknown"\''
def test_b2_guard_present_and_in_sync():
# Two fragment-form call sites per script (register_agent + send_heartbeat), identical wording.
assert LINUX.count(_B2_GUARD) == 2, "linux_install.sh missing/duplicated empty-system_info guard"
assert MACOS.count(_B2_GUARD) == 2, "macos_install.sh missing/duplicated empty-system_info guard"
def test_b2_guard_precedes_every_fragment_system_info_use():
# Every ' $system_info,' fragment line (the one that breaks on an empty value) must be in a
# function whose system_info was guarded. We assert the count of guards matches the count of
# fragment-form interpolations' call sites: each script has exactly one register + one
# send_heartbeat fragment builder feeding those lines, both guarded above.
for name, script in (("linux", LINUX), ("macos", MACOS)):
assert script.count(" $system_info,") >= 1, f"{name}: fragment heartbeat form unexpectedly gone"
assert script.count(_B2_GUARD) == 2, f"{name}: each fragment call site must carry the guard"
def test_cleanup_does_not_self_kill_via_bare_haproxy_agent_pattern():
# Issue #31 (v1.8.4): the pre-installation cleanup kills processes by pgrep -f "$pattern". A bare
# "haproxy-agent" pattern also matches the installer's OWN path (install-haproxy-agent-*.sh) and a
# sudo/PAM ancestor, so the installer killed itself. The kill loop must target ONLY the installed
# agent (binary path + service/label), never the bare string.
for name, script in (("linux", LINUX), ("macos", MACOS)):
assert 'for pattern in "haproxy-agent"' not in script, (
f"{name}: pre-install cleanup uses the bare 'haproxy-agent' kill pattern -> self-kill (issue #31)"
)
# The narrowed, installer-safe pattern must be present (binary path via $INSTALL_DIR).
assert 'for pattern in "$INSTALL_DIR/haproxy-agent"' in script, (
f"{name}: cleanup must match the installed binary path, not a bare substring"
)
+220
View File
@@ -0,0 +1,220 @@
"""Issue #35 — ACME DNS-01: focused unit tests for the pure logic (no DB/network).
Covers the TXT-value math (RFC 8555 §8.4 — raw SHA-256 digest, base64url, NOT hex),
the _acme-challenge record-name derivation (wildcard stripping), credential encryption
round-trip + tamper handling, and the DNS provider registry/allow-list.
"""
import base64
import hashlib
import os
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-dns01-unit-tests")
from services.acme_service import ACMEService
from services.dns_providers import list_providers, is_supported, get_provider, DnsProviderError
from utils.dns_credentials import (
encrypt_dns_credentials, decrypt_dns_credentials, reset_fernet_for_tests,
)
def _b64url(b: bytes) -> str:
return base64.urlsafe_b64encode(b).rstrip(b"=").decode("ascii")
def test_dns_txt_value_is_raw_sha256_base64url():
key_auth = "token123.thumbprintABC"
expected = _b64url(hashlib.sha256(key_auth.encode("utf-8")).digest())
assert ACMEService._dns_txt_value(key_auth) == expected
# Must NOT be the (classic-mistake) base64url of the HEX digest.
hex_based = _b64url(hashlib.sha256(key_auth.encode("utf-8")).hexdigest().encode("utf-8"))
assert ACMEService._dns_txt_value(key_auth) != hex_based
def test_challenge_dns_name_derivation():
assert ACMEService._challenge_dns_name("example.com") == "_acme-challenge.example.com"
# Wildcard: the '*.' is stripped, so apex + wildcard share the SAME record name.
assert ACMEService._challenge_dns_name("*.example.com") == "_acme-challenge.example.com"
assert ACMEService._challenge_dns_name("foo.bar.example.com") == "_acme-challenge.foo.bar.example.com"
def test_credential_encryption_roundtrip():
reset_fernet_for_tests()
creds = {"api_token": "super-secret-token-value"}
token = encrypt_dns_credentials(creds)
assert token != "super-secret-token-value"
assert "super-secret-token-value" not in token # ciphertext, not plaintext
assert decrypt_dns_credentials(token) == creds
def test_decrypt_invalid_token_returns_none():
reset_fernet_for_tests()
assert decrypt_dns_credentials("not-a-valid-fernet-token") is None
def test_provider_registry_and_allow_list():
names = {p["name"] for p in list_providers()}
assert {"manual", "cloudflare"} <= names
assert is_supported("manual") and is_supported("cloudflare")
assert not is_supported("route53") # not in MVP allow-list
assert get_provider("manual").automated is False
cf = get_provider("cloudflare", {"api_token": "x"})
assert cf.automated is True
assert any(f["key"] == "api_token" for f in cf.credential_fields)
raised = False
try:
get_provider("definitely-not-a-provider")
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"'
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
def _sql_paren_depth(sql: str):
"""Parenthesis depth of a SQL string, counting only OUTSIDE '...' literals (with ''
escapes), `--` line comments and /* */ block comments. Single-pass state machine so a
`--` inside a literal or a `'` inside a comment cannot corrupt the count. Dollar-quoted
strings are out of scope (not used in this codebase). Returns (final_depth, min_depth).
"""
depth = 0
min_depth = 0
state = "normal"
i, n = 0, len(sql)
while i < n:
ch = sql[i]
nxt = sql[i + 1] if i + 1 < n else ""
if state == "normal":
if ch == "'":
state = "string"
elif ch == "-" and nxt == "-":
state = "line_comment"
i += 1
elif ch == "/" and nxt == "*":
state = "block_comment"
i += 1
elif ch == "(":
depth += 1
elif ch == ")":
depth -= 1
min_depth = min(min_depth, depth)
elif state == "string":
if ch == "'":
if nxt == "'":
i += 1 # escaped '' stays inside the literal
else:
state = "normal"
elif state == "line_comment":
if ch == "\n":
state = "normal"
else: # block_comment
if ch == "*" and nxt == "/":
state = "normal"
i += 1
i += 1
return depth, min_depth
def test_acme_sql_parentheses_balanced():
"""Issue #35 v1.8.5: the completion task's order-claim query shipped (v1.8.0-v1.8.4) with an
extra closing parenthesis, so EVERY 60s cycle died with `syntax error at or near ")"` and no
background ACME work (claim/finalize/download, DNS-01 publish, wizard-staged promotion,
retry, TXT cleanup) ever ran. The suite never caught it because the DB layer is mocked and
raw SQL never reaches a real parser. This guard scans the ACME modules' SQL string literals
for unbalanced parentheses.
Guard scope is deliberately conservative to avoid false positives on production changes:
keyword matching is case-sensitive (SQL is uppercase in this codebase; prose in docstrings
is not) and f-string fragments are excluded (they split at `{`, so a fragment may be
legitimately unbalanced).
"""
import ast
import re
backend_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
modules = [
"main.py",
os.path.join("services", "dns01_orchestrator.py"),
os.path.join("services", "acme_service.py"),
os.path.join("services", "letsencrypt_service.py"),
os.path.join("routers", "letsencrypt.py"),
os.path.join("routers", "acme_diagnostics.py"),
]
problems = []
for rel in modules:
with open(os.path.join(backend_dir, rel), encoding="utf-8") as fh:
tree = ast.parse(fh.read())
fstring_parts = {
id(const)
for joined in ast.walk(tree) if isinstance(joined, ast.JoinedStr)
for const in ast.walk(joined) if isinstance(const, ast.Constant)
}
for node in ast.walk(tree):
if not (isinstance(node, ast.Constant) and isinstance(node.value, str)):
continue
if id(node) in fstring_parts:
continue
sql = node.value
if not re.search(r"\b(SELECT|INSERT|UPDATE|DELETE)\b", sql):
continue
if not re.search(r"\b(FROM|INTO|SET|WHERE)\b", sql):
continue
depth, min_depth = _sql_paren_depth(sql)
if depth != 0 or min_depth < 0:
problems.append(f"{rel}:{node.lineno} (paren depth {depth:+d}, min {min_depth})")
assert not problems, f"Unbalanced parentheses in SQL literal(s): {problems}"
def test_sql_paren_depth_scanner():
# The guard's scanner itself: parens in literals/comments must not count; '' escapes and
# block comments handled; an extra ')' is reported via min_depth even if a later '(' would
# re-balance the total.
assert _sql_paren_depth("SELECT (1)") == (0, 0)
assert _sql_paren_depth("SELECT (1))") == (-1, -1) # the v1.8.0 bug shape
assert _sql_paren_depth("SELECT ')' , '((' FROM t") == (0, 0) # literals ignored
assert _sql_paren_depth("SELECT 'it''s ))' FROM t") == (0, 0) # '' escape stays inside
assert _sql_paren_depth("SELECT 1 -- comment ) (\nFROM t") == (0, 0) # line comment ignored
assert _sql_paren_depth("SELECT 1 /* ) */ FROM t") == (0, 0) # block comment ignored
assert _sql_paren_depth("SELECT 'a--b' AND (x=1\n)") == (0, 0) # -- inside literal is data
assert _sql_paren_depth("WHERE x) AND (y") == (0, -1) # net 0 but went negative
+4 -2
View File
@@ -276,14 +276,16 @@ def test_categorize_routes_directives_correctly():
def test_emit_buckets_flushed_in_canonical_order():
"""The flush block at end of frontend processing must list buckets
in: prelude → stick → tcp_req → acl → http_req → http_resp →
in: prelude → filter → stick → tcp_req → acl → http_req → http_resp →
redirect → use_be → default_be. Pre-fix `http-request` rules
interleaved with `use_backend` rules in source order, producing
HAProxy parser warnings."""
HAProxy parser warnings. (Issue #38 added the `filter` bucket, flushed
right after `prelude` so SPOE `filter` lines precede `send-spoe-group`.)"""
src = _gen_src()
flush_match = re.search(
r'for\s+_bucket_key\s+in\s+\(\s*'
r'"prelude"\s*,\s*'
r'"filter"\s*,\s*'
r'"stick"\s*,\s*'
r'"tcp_req"\s*,\s*'
r'"acl"\s*,\s*'
+203
View File
@@ -0,0 +1,203 @@
"""
Issue #38 regression tests: HAProxy SPOE `filter` + frontend `log-format` support.
Bug: the bulk-config parser recognised only a fixed set of frontend directives,
so `filter spoe engine coraza config ...` and `log-format ...` were silently
dropped on import / manual edit. This regenerated a config missing the SPOE
engine definition, so HAProxy failed with
"unable to find SPOE engine 'coraza' used by the send-spoe-group 'coraza-req'".
These tests verify the end-to-end fix without requiring a database:
1. parser captures `filter` + `log-format` into the new ParsedFrontend fields;
2. `http-request send-spoe-group` is still preserved (regression guard);
3. the generator's directive categoriser + bucket flush order emit `filter`
BEFORE the `http-request send-spoe-group` rules and keep `log-format`;
4. reject/rollback restores the new columns;
5. a non-SPOE frontend is completely unaffected (zero-impact).
"""
import os
import re
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from utils.haproxy_config_parser import parse_haproxy_config, ParsedFrontend
from services.haproxy_config import _categorize_haproxy_directive
from models.frontend import FrontendConfig
# The exact frontend/backend config reported in Issue #38 (Coraza-SPOA).
ISSUE_38_CONFIG = r"""
frontend web-frontend
bind *:8073
mode http
log-format "%ci:%cp\ [%t]\ %ft\ %b/%s\ %ST\ %B\ %{+Q}r\ %[var(txn.coraza.id)]\ waf-hit:\ %[var(txn.coraza.fail)]"
filter spoe engine coraza config /etc/haproxy/coraza.cfg
http-request set-var(txn.coraza.app) str(haproxy_waf)
http-request send-spoe-group coraza coraza-req
http-request deny if { var(txn.coraza.fail) -m int eq 1 }
default_backend web-backend
backend web-backend
balance roundrobin
mode http
server server1 192.168.1.10:443 weight 100 ssl verify none
backend coraza-spoa
mode tcp
option spop-check
server coraza_spoa 192.168.12.21:9000
"""
def _get_frontend(parse_result, name):
for fe in parse_result.frontends:
if fe.name == name:
return fe
return None
class TestParserCapturesSpoe:
def test_filter_and_log_format_captured(self):
result = parse_haproxy_config(ISSUE_38_CONFIG)
fe = _get_frontend(result, "web-frontend")
assert fe is not None, "web-frontend should be parsed and kept"
assert fe.filters is not None
assert "filter spoe engine coraza config /etc/haproxy/coraza.cfg" in fe.filters
assert fe.log_format is not None
assert fe.log_format.startswith("log-format")
# the escaped/quoted format string must be preserved verbatim
assert "%[var(txn.coraza.fail)]" in fe.log_format
def test_send_spoe_group_still_preserved(self):
# Regression guard: http-request rules (incl. send-spoe-group) must
# still be collected into request_headers as before.
result = parse_haproxy_config(ISSUE_38_CONFIG)
fe = _get_frontend(result, "web-frontend")
assert fe.request_headers is not None
assert "send-spoe-group coraza coraza-req" in fe.request_headers
def test_multiple_filters_preserved_in_order(self):
cfg = """
frontend f1
bind *:80
mode http
filter compression
filter spoe engine coraza config /etc/haproxy/coraza.cfg
default_backend b1
backend b1
mode http
server s1 10.0.0.1:80
"""
fe = _get_frontend(parse_haproxy_config(cfg), "f1")
lines = fe.filters.split("\n")
assert lines == [
"filter compression",
"filter spoe engine coraza config /etc/haproxy/coraza.cfg",
]
def test_log_format_sd_variant_captured(self):
cfg = """
frontend f1
bind *:80
mode http
log-format-sd "[exampleSDID@1234 field=value]"
default_backend b1
backend b1
mode http
server s1 10.0.0.1:80
"""
fe = _get_frontend(parse_haproxy_config(cfg), "f1")
assert fe.log_format is not None
assert fe.log_format.startswith("log-format-sd")
class TestGeneratorOrderingContract:
"""The generator routes directives into ordered buckets. Verify SPOE
correctness at the (pure) categoriser + documented flush-order level."""
def test_filter_routes_to_filter_bucket(self):
assert _categorize_haproxy_directive(" filter spoe engine coraza config /x.cfg") == "filter"
def test_send_spoe_group_routes_to_http_req(self):
assert _categorize_haproxy_directive(" http-request send-spoe-group coraza coraza-req") == "http_req"
def test_log_format_routes_to_prelude(self):
assert _categorize_haproxy_directive(' log-format "%ci:%cp"') == "prelude"
assert _categorize_haproxy_directive(' log-format-sd "[x]"') == "prelude"
def test_flush_order_places_filter_before_http_req(self):
# The bucket flush order is the single source of truth for emission
# ordering. Assert `filter` is flushed before `http_req` (and after
# `prelude`), guaranteeing `filter ...` renders before
# `http-request send-spoe-group ...`.
src = _read_source("services/haproxy_config.py")
m = re.search(r"for _bucket_key in \((.*?)\):", src, re.DOTALL)
assert m, "bucket flush loop not found"
order = re.findall(r'"(\w+)"', m.group(1))
assert "filter" in order, "new 'filter' bucket missing from flush order"
assert order.index("prelude") < order.index("filter") < order.index("http_req")
class TestModelAndRollback:
def test_model_has_passthrough_fields(self):
fc = FrontendConfig(
name="f", bind_port=80,
filters="filter spoe engine coraza config /etc/haproxy/coraza.cfg",
log_format='log-format "%ci"',
)
assert fc.filters.startswith("filter spoe")
assert fc.log_format.startswith("log-format")
def test_dataclass_defaults_none(self):
fe = ParsedFrontend(name="f")
assert fe.filters is None
assert fe.log_format is None
def test_rollback_restores_new_columns(self):
# Reject/rollback of a frontend UPDATE must restore the new columns,
# otherwise the rejected (new) filters/log_format would persist.
src = _read_source("utils/entity_snapshot.py")
assert "log_format = $" in src
assert "filters = $" in src
assert "old_values.get('log_format')" in src
assert "old_values.get('filters')" in src
class TestZeroImpact:
def test_non_spoe_frontend_unaffected(self):
cfg = """
frontend plain
bind *:80
mode http
option httplog
default_backend b1
backend b1
mode http
server s1 10.0.0.1:80
"""
fe = _get_frontend(parse_haproxy_config(cfg), "plain")
# No filter / log-format present → new fields stay None (no behaviour change)
assert fe.filters is None
assert fe.log_format is None
def test_spop_check_backend_roundtrips_without_warning(self):
result = parse_haproxy_config(ISSUE_38_CONFIG)
be = next((b for b in result.backends if b.name == "coraza-spoa"), None)
assert be is not None, "coraza-spoa backend should import"
assert be.mode == "tcp"
assert be.options and "option spop-check" in be.options
# spop-check is now a known option → no spurious 'unknown option' warning
assert not any(
"coraza-spoa" in w and "spop-check" in w and "Unknown" in w
for w in result.warnings
)
def _read_source(relpath):
base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
with open(os.path.join(base, relpath), "r", encoding="utf-8") as fh:
return fh.read()
+71
View File
@@ -0,0 +1,71 @@
"""v1.8.7: app version is single-source and cannot silently drift.
The UI shows the version via GET /api/version, which returns main.py's `_version_info`. That MUST be
sourced from the one canonical file backend/version.json (co-located with main.py so `COPY . .`
bakes it into every image, regardless of pipeline). main.py's in-code fallback must NOT be a real
version, otherwise it drifts when version.json is bumped but the constant is forgotten — exactly
what left the UI reporting 1.8.4 after 1.8.5/1.8.6 shipped. These checks fail loudly on regression.
"""
import ast
import json
import os
import re
import pytest
_BACKEND = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # backend/
_VERSION_JSON = os.path.join(_BACKEND, "version.json")
_MAIN = os.path.join(_BACKEND, "main.py")
_SEMVER = re.compile(r"^\d+\.\d+\.\d+$")
def test_canonical_version_file_exists_and_valid():
assert os.path.exists(_VERSION_JSON), "backend/version.json (single source of truth) is missing"
with open(_VERSION_JSON) as f:
data = json.load(f)
assert _SEMVER.match(data.get("version", "")), \
f"backend/version.json version is not semver: {data.get('version')!r}"
assert data.get("releaseName"), "backend/version.json must have a releaseName"
def _main_fallback_version_info():
"""The literal dict assigned to _version_info in main.py (the in-code fallback)."""
with open(_MAIN) as f:
tree = ast.parse(f.read())
for node in ast.walk(tree):
if isinstance(node, ast.Assign) and isinstance(node.value, ast.Dict):
for t in node.targets:
if isinstance(t, ast.Name) and t.id == "_version_info":
return ast.literal_eval(node.value)
return None
def test_main_has_no_hardcoded_real_version():
fb = _main_fallback_version_info()
assert fb is not None, "could not find the _version_info fallback literal in main.py"
# Must be a neutral marker, never a real version that can drift out of sync.
assert not _SEMVER.match(str(fb.get("version", ""))), (
f"main.py hardcodes a real version {fb.get('version')!r}; it must be a neutral marker "
f"(e.g. 'unknown') so the version stays single-source in backend/version.json"
)
def test_main_loads_the_canonical_file_first():
# The first candidate path main.py reads must resolve to the co-located backend/version.json,
# so the correct version is available in every image (not dependent on CI staging).
with open(_MAIN) as f:
src = f.read()
assert 'os.path.dirname(__file__), "version.json"' in src, \
"main.py must read version.json co-located with the module (backend/version.json)"
def test_frontend_package_json_matches_when_present():
# Only meaningful in a full-repo checkout; the backend image build context (./backend) has no frontend/.
pkg = os.path.join(os.path.dirname(_BACKEND), "frontend", "package.json")
if not os.path.exists(pkg):
pytest.skip("frontend/package.json not in this context (e.g. backend-only image build)")
with open(_VERSION_JSON) as f:
canonical = json.load(f)["version"]
with open(pkg) as f:
fe = json.load(f)["version"]
assert fe == canonical, f"frontend/package.json {fe!r} != backend/version.json {canonical!r}"
+5 -1
View File
@@ -51,9 +51,13 @@ def test_agent_delivery_teardown_only_on_inactive():
def test_migration_adds_pending_delete_and_bumps_schema():
import re
s = _read("database/migrations.py")
assert "pending_delete BOOLEAN NOT NULL DEFAULT FALSE" in s
assert "SCHEMA_VERSION = 7" in s
# Schema was bumped to accommodate this column. Assert >= 7 (the version it landed in)
# rather than pinning an exact value, so later schema bumps don't re-break this test.
m = re.search(r"^SCHEMA_VERSION\s*=\s*(\d+)", s, re.MULTILINE)
assert m is not None and int(m.group(1)) >= 7
def test_list_visibility_backward_compat_gates_on_applied():
+5 -1
View File
@@ -42,6 +42,10 @@ def test_delete_endpoint_accepts_purge_package_default_off():
def test_migration_adds_purge_column_and_bumps_schema():
import re
s = _read("database/migrations.py")
assert "purge_on_teardown BOOLEAN NOT NULL DEFAULT FALSE" in s
assert "SCHEMA_VERSION = 7" in s
# Schema was bumped to accommodate this column. Assert >= 7 (the version it landed in)
# rather than pinning an exact value, so later schema bumps don't re-break this test.
m = re.search(r"^SCHEMA_VERSION\s*=\s*(\d+)", s, re.MULTILINE)
assert m is not None and int(m.group(1)) >= 7
+18 -3
View File
@@ -470,7 +470,12 @@ safe_remove() {
[[ "$QUIET_MODE" != "true" ]] && echo "Terminating existing HAProxy Agent processes..."
KILLED_COUNT=0
INSTALLER_PID=$$
for pattern in "haproxy-agent" "/usr/local/bin/haproxy-agent" "haproxy-agent.service"; do
# issue #31: match ONLY the installed agent (binary path + service), never the bare string
# "haproxy-agent". With pgrep -f, that bare string can also match the installer's OWN command line
# or a sudo/PAM ancestor (which the $$/$PPID guard does not fully cover), making the cleanup kill
# the installer itself ("Killed", install aborts). The systemd service is also stopped below; the
# "$INSTALL_DIR/haproxy-agent" path still catches any running daemon.
for pattern in "$INSTALL_DIR/haproxy-agent" "haproxy-agent.service"; do
PIDS=$(pgrep -f "$pattern" 2>/dev/null || true)
if [[ -n "$PIDS" ]]; then
FILTERED=""
@@ -1055,7 +1060,12 @@ register_agent() {
local arch=$(uname -m)
platform=$(uname -s | tr '[:upper:]' '[:lower:]') # Remove local to make it global
local system_info=$(collect_system_info)
# issue #31: if collect_system_info produced no JSON content (empty on an unusual host), the
# '$system_info,' line below would collapse to a bare comma and break the heartbeat JSON. A
# valid fragment always contains a quoted key; if none is present, fall back to one. The glob
# '*"*' is the most portable bash test (no POSIX class / pattern-substitution), safe on bash 3.x+.
[[ "$system_info" != *'"'* ]] && system_info='"operating_system": "unknown"'
local json_payload=$(cat <<SIMPLE_EOF
{
"name": "$AGENT_NAME",
@@ -1357,7 +1367,12 @@ send_heartbeat() {
local server_statuses=$(get_server_statuses)
local haproxy_stats_csv=$(get_haproxy_stats_csv)
local system_info=$(collect_system_info)
# issue #31: if collect_system_info produced no JSON content (empty on an unusual host), the
# '$system_info,' line below would collapse to a bare comma and break the heartbeat JSON. A
# valid fragment always contains a quoted key; if none is present, fall back to one. The glob
# '*"*' is the most portable bash test (no POSIX class / pattern-substitution), safe on bash 3.x+.
[[ "$system_info" != *'"'* ]] && system_info='"operating_system": "unknown"'
# Get HAProxy version for heartbeat (safe extraction, fallback to "unknown")
local haproxy_version="unknown"
if command -v haproxy &> /dev/null; then
+17 -3
View File
@@ -318,7 +318,11 @@ safe_remove() {
[[ "$QUIET_MODE" != "true" ]] && echo "Terminating existing HAProxy Agent processes..."
KILLED_COUNT=0
INSTALLER_PID=$$
for pattern in "haproxy-agent" "/usr/local/bin/haproxy-agent" "com.haproxy.agent"; do
# issue #31: match ONLY the installed agent (binary path + LaunchDaemon label), never the bare
# string "haproxy-agent". With pgrep -f, that bare string can also match the installer's OWN command
# line or a sudo ancestor (which the $$/$PPID guard does not fully cover), making the cleanup kill
# the installer itself. The "$INSTALL_DIR/haproxy-agent" path still catches any running daemon.
for pattern in "$INSTALL_DIR/haproxy-agent" "com.haproxy.agent"; do
PIDS=$(pgrep -f "$pattern" 2>/dev/null || true)
if [[ -n "$PIDS" ]]; then
FILTERED=""
@@ -920,7 +924,12 @@ register_agent() {
local arch=$(uname -m)
platform=$(uname -s | tr '[:upper:]' '[:lower:]') # Remove local to make it global
local system_info=$(collect_system_info)
# issue #31: if collect_system_info produced no JSON content (empty on an unusual host), the
# '$system_info,' line below would collapse to a bare comma and break the heartbeat JSON. A
# valid fragment always contains a quoted key; if none is present, fall back to one. The glob
# '*"*' is the most portable bash test (no POSIX class / pattern-substitution), safe on bash 3.x+.
[[ "$system_info" != *'"'* ]] && system_info='"operating_system": "unknown"'
local json_payload=$(cat <<SIMPLE_EOF
{
"name": "$AGENT_NAME",
@@ -1191,7 +1200,12 @@ send_heartbeat() {
local server_statuses=$(get_server_statuses)
local haproxy_stats_csv=$(get_haproxy_stats_csv)
local system_info=$(collect_system_info)
# issue #31: if collect_system_info produced no JSON content (empty on an unusual host), the
# '$system_info,' line below would collapse to a bare comma and break the heartbeat JSON. A
# valid fragment always contains a quoted key; if none is present, fall back to one. The glob
# '*"*' is the most portable bash test (no POSIX class / pattern-substitution), safe on bash 3.x+.
[[ "$system_info" != *'"'* ]] && system_info='"operating_system": "unknown"'
# Get HAProxy version for heartbeat (safe extraction, fallback to "unknown")
local haproxy_version="unknown"
if command -v haproxy &> /dev/null; then
+88
View File
@@ -0,0 +1,88 @@
"""Issue #35 — ACME DNS-01 (v1.8.0): at-rest encryption for per-account DNS provider credentials.
Mirrors the established Fernet + HKDF(SECRET_KEY) pattern used for the VRRP secret
(backend/services/keepalived_config.py) and TOTP secrets (backend/services/mfa_service.py):
prefer an explicit DNS_PROVIDER_ENCRYPTION_KEY env var (enables key rotation), else derive a
stable key from SECRET_KEY via HKDF with a versioned info string.
DNS provider credentials are a small dict (e.g. {"api_token": "..."}). They are JSON-serialized,
encrypted to a Fernet token string for storage, and only ever decrypted in-process when a DNS-01
order needs to talk to the provider. Plaintext credentials are NEVER logged or returned by the API.
NOTE on key rotation: if SECRET_KEY rotates and DNS_PROVIDER_ENCRYPTION_KEY is not set, previously
stored credentials become undecryptable (decrypt returns None). Callers MUST treat a None result as
"credentials unavailable — re-enter in Settings" and surface a clear error, never a silent hang.
"""
from __future__ import annotations
import base64
import json
import logging
import os
from typing import Dict, Optional
from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from config import SECRET_KEY
logger = logging.getLogger(__name__)
_fernet_instance: Optional[Fernet] = None
def _resolve_fernet_key() -> bytes:
"""Prefer an explicit DNS_PROVIDER_ENCRYPTION_KEY; else derive from SECRET_KEY via HKDF
with a versioned info string (so credentials survive restarts)."""
explicit = os.getenv("DNS_PROVIDER_ENCRYPTION_KEY", "").strip()
if explicit:
try:
Fernet(explicit.encode())
return explicit.encode()
except Exception as exc: # noqa: BLE001
logger.error("DNS_PROVIDER_ENCRYPTION_KEY env var present but invalid: %s", exc)
logger.warning(
"DNS_PROVIDER_ENCRYPTION_KEY not set; deriving the DNS-credentials encryption key from "
"SECRET_KEY. Set DNS_PROVIDER_ENCRYPTION_KEY to a Fernet key to enable key rotation."
)
hkdf = HKDF(algorithm=hashes.SHA256(), length=32, salt=None, info=b"dns-provider-creds-v1")
derived = hkdf.derive(SECRET_KEY.encode("utf-8"))
return base64.urlsafe_b64encode(derived)
def _get_fernet() -> Fernet:
global _fernet_instance
if _fernet_instance is None:
_fernet_instance = Fernet(_resolve_fernet_key())
return _fernet_instance
def reset_fernet_for_tests() -> None:
"""Test-only hook to force re-resolution after env mutation."""
global _fernet_instance
_fernet_instance = None
def encrypt_dns_credentials(credentials: Dict[str, str]) -> str:
"""JSON-serialize and Fernet-encrypt a credentials dict to a storable token string."""
payload = json.dumps(credentials, separators=(",", ":")).encode("utf-8")
return _get_fernet().encrypt(payload).decode("utf-8")
def decrypt_dns_credentials(token: str) -> Optional[Dict[str, str]]:
"""Decrypt a stored token back to the credentials dict. Returns None if the token can't be
decrypted (e.g. key rotated) — callers must surface a clear 're-enter credentials' error."""
try:
plain = _get_fernet().decrypt(token.encode("utf-8")).decode("utf-8")
data = json.loads(plain)
if not isinstance(data, dict):
logger.error("Decrypted DNS credentials are not a JSON object")
return None
return data
except InvalidToken:
logger.warning("Failed to decrypt DNS provider credentials (invalid Fernet token)")
return None
except Exception as exc: # noqa: BLE001
logger.error("Unexpected error decrypting DNS provider credentials: %s", exc)
return None
+5 -2
View File
@@ -329,9 +329,10 @@ async def _rollback_update(
tcp_request_rules = $26, timeout_client = $27, timeout_http_request = $28,
rate_limit = $29, compression = $30, log_separate = $31,
monitor_uri = $32, maxconn = $33,
cluster_id = $34, is_active = $35, last_config_status = $36,
cluster_id = $34, is_active = $35, last_config_status = $36,
log_format = $37, filters = $38,
updated_at = CURRENT_TIMESTAMP
WHERE id = $37
WHERE id = $39
""",
old_values.get('name'),
old_values.get('bind_address'),
@@ -369,6 +370,8 @@ async def _rollback_update(
old_values.get('cluster_id'),
old_values.get('is_active'),
old_values.get('last_config_status'),
old_values.get('log_format'), # Issue #38
old_values.get('filters'), # Issue #38
entity_id
)
+34 -2
View File
@@ -105,6 +105,11 @@ class ParsedFrontend:
response_headers: Optional[str] = None
options: Optional[str] = None # HAProxy frontend options (option httplog, option forwardfor, etc.)
tcp_request_rules: Optional[str] = None # TCP request directives (for TCP mode)
# Issue #38: SPOE (and other) filter directives + frontend log-format.
# Stored as full directive lines; `filters` is newline-joined to preserve
# ordering when multiple `filter ...` lines exist.
log_format: Optional[str] = None # `log-format` / `log-format-sd` line(s)
filters: Optional[str] = None # `filter ...` line(s), e.g. `filter spoe engine coraza config ...`
@dataclass
@@ -256,8 +261,23 @@ class HAProxyConfigParser:
acl_rules_list = []
use_backend_rules_list = []
tcp_request_rules_list = []
filters_list = []
log_format_list = []
for line in lines:
# Issue #38: capture `filter ...` (SPOE/Coraza etc.) and
# `log-format`/`log-format-sd` directives. Pre-fix these matched
# no branch below and were silently dropped, so an imported SPOE
# config lost `filter spoe engine coraza ...` (→ HAProxy fatal
# "unable to find SPOE engine") and the frontend log-format.
# `continue` isolates them from the header/option handling below.
if line.startswith('filter '):
filters_list.append(line.strip())
continue
if re.match(r'^log-format(-sd)?\s', line, re.IGNORECASE):
log_format_list.append(line.strip())
continue
# Parse bind directive
# IMPORTANT: Handle multiple bind lines correctly
# Example: bind *:1002 (HTTP) and bind *:443 ssl (HTTPS)
@@ -540,6 +560,13 @@ class HAProxyConfigParser:
if tcp_request_rules_list:
frontend.tcp_request_rules = '\n'.join(tcp_request_rules_list)
# Issue #38: assign captured SPOE filters + log-format
if filters_list:
frontend.filters = '\n'.join(filters_list)
if log_format_list:
frontend.log_format = '\n'.join(log_format_list)
self.frontends.append(frontend)
logger.info(f"Parsed frontend: {name} -> {frontend.default_backend}")
@@ -666,9 +693,14 @@ class HAProxyConfigParser:
'transparent', 'abortonclose', 'allbackups', 'checkcache', 'clitcpka',
'srvtcpka', 'http-no-delay', 'socket-stats', 'tcp-smart-accept',
'tcp-smart-connect', 'independant-streams', 'log-separate-errors',
'log-health-checks', 'accept-invalid-http-request', 'accept-invalid-http-response'
'log-health-checks', 'accept-invalid-http-request', 'accept-invalid-http-response',
# Issue #38: SPOP health check for SPOE agent backends
# (e.g. coraza-spoa). Already collected below regardless, but
# listing it suppresses the spurious "unknown option" warning
# for the exact SPOE use-case.
'spop-check'
]
if option_name not in valid_options:
# Unknown/invalid option - add warning but still collect it
self.warnings.append(
+5
View File
@@ -0,0 +1,5 @@
{
"version": "1.8.8",
"releaseName": "SPOE filter + frontend log-format support (Issue #38)",
"releaseDate": "2026-07-10"
}
-1
View File
@@ -10,7 +10,6 @@ services:
dockerfile: Dockerfile
volumes:
- haproxy_configs:/etc/haproxy
- ./version.json:/app/version.json:ro
frontend:
image: haproxy-openmanager-frontend:localtest
+3
View File
@@ -51,6 +51,9 @@ services:
- LOG_LEVEL=INFO
- PUBLIC_URL=http://localhost:8080
- MANAGEMENT_BASE_URL=http://localhost:8080
# Empty when unset on the host: the image CMD then falls back to
# WEB_CONCURRENCY (uvicorn's native env) and finally to 1.
- UVICORN_WORKERS=${UVICORN_WORKERS:-}
volumes:
- haproxy_configs:/etc/haproxy
expose:
+9 -4
View File
@@ -163,6 +163,11 @@ serve -s build -l 3000
Option B — copy to nginx (recommended, see next section).
> **Important: use the nginx URL on port 8080 as your entry point.** The `serve` option above (port 3000) hosts
> only the static UI; there is no `/api` backend behind it, so the login page renders but cannot actually log you
> in. nginx (next section) serves the UI *and* proxies `/api` to the backend on a single port, so do your login and
> everyday use at `http://<server-ip>:8080`.
### Create a systemd service for the frontend (if using `serve`)
```bash
@@ -268,10 +273,10 @@ curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8080/
# Login
curl -s -X POST http://127.0.0.1:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "admin", "password": "admin"}' | python3 -m json.tool
-d '{"username": "admin", "password": "admin123"}' | python3 -m json.tool
```
> **Default credentials**: `admin` / `admin` — change the password immediately after first login.
> **Default credentials**: `admin` / `admin123` — change the password immediately after first login.
## 9. Firewall
@@ -290,8 +295,8 @@ sudo ufw allow 8080/tcp
| Service | Port | URL |
|---------|------|-----|
| Backend API | 8000 | `http://127.0.0.1:8000/api/health` |
| Frontend | 3000 | `http://127.0.0.1:3000` |
| Nginx (unified) | 8080 | `http://your-server-ip:8080` |
| Frontend (static only) | 3000 | `http://127.0.0.1:3000` (UI only, no API; not the login URL) |
| Nginx (unified, entry point) | 8080 | `http://your-server-ip:8080` (use this) |
| PostgreSQL | 5432 | local |
| Redis | 6379 | local |
+3 -3
View File
@@ -20347,9 +20347,9 @@
}
},
"node_modules/shell-quote": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
"version": "1.8.4",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz",
"integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==",
"dev": true,
"license": "MIT",
"engines": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "haproxy-openmanager-frontend",
"version": "1.7.8",
"version": "1.8.8",
"description": "HAProxy Load Balancer Management UI",
"license": "AGPL-3.0-or-later",
"dependencies": {
+683 -49
View File
@@ -1,8 +1,8 @@
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,
Tabs, Timeline, Spin, Empty
message, Row, Col, Statistic, Alert, Tooltip, Switch, theme, Segmented, Collapse,
Tabs, Timeline, Spin, Empty, Typography, Divider
} from 'antd';
import {
SafetyCertificateOutlined, PlusOutlined, ReloadOutlined,
@@ -10,7 +10,7 @@ import {
SyncOutlined, CloseCircleOutlined,
DeleteOutlined, EyeOutlined,
CloudDownloadOutlined, UserOutlined, InfoCircleOutlined,
RocketOutlined, ExperimentOutlined
RocketOutlined, ExperimentOutlined, KeyOutlined
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useCluster } from '../contexts/ClusterContext';
@@ -18,8 +18,26 @@ 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
// fall back to the raw token.
const EVENT_LABELS = {
'acme.dns01.publish': 'Published DNS TXT record',
'acme.dns01.responded': 'Asked the CA to validate',
'acme.dns01.validation': 'DNS-01 validation result',
'acme.dns01.cleanup': 'Removed DNS TXT record',
'acme.order.requested': 'Certificate requested',
};
const humanizeEventType = (t) => EVENT_LABELS[t] || t;
// Render letsencrypt_orders.error_detail (TEXT column). Backend now writes
// structured JSON-strings (stage / http_status / ca_response / timestamp) for
@@ -83,6 +101,25 @@ const ACMEAutomation = () => {
const [orderFilter, setOrderFilter] = useState('active');
const { token } = theme.useToken();
// Issue #35: DNS-01 provider catalog + global enable flag (from GET /dns-providers).
const [dnsProviders, setDnsProviders] = useState([]);
const [dns01Enabled, setDns01Enabled] = useState(false);
const [confirming, setConfirming] = useState(false);
const regChallengeType = Form.useWatch('challenge_type', registerForm);
const regDnsProvider = Form.useWatch('dns_provider', registerForm);
const wizardAccountId = Form.useWatch('account_id', wizardForm);
const wizardDomains = Form.useWatch('domains', wizardForm);
const selectedDnsProvider = dnsProviders.find(p => p.name === regDnsProvider) || null;
// Issue #35: per-account DNS credential management (view/replace/clear after creation).
const [credModalVisible, setCredModalVisible] = useState(false);
const [credAccount, setCredAccount] = useState(null);
const [credMeta, setCredMeta] = useState(null);
const [credLoading, setCredLoading] = useState(false);
const [credSaving, setCredSaving] = useState(false);
const [credForm] = Form.useForm();
const credProvider = credAccount ? (dnsProviders.find(p => p.name === credAccount.dns_provider) || null) : null;
// v1.5.0 Issue #13: ACME Diagnostic Panel state
const [diagVisible, setDiagVisible] = useState(false);
const [diagOrderId, setDiagOrderId] = useState(null);
@@ -105,18 +142,23 @@ const ACMEAutomation = () => {
const fetchData = useCallback(async () => {
setLoading(true);
try {
const [ordersRes, accountsRes, renewalRes, clustersRes, prereqRes] = await Promise.allSettled([
const [ordersRes, accountsRes, renewalRes, clustersRes, prereqRes, dnsRes] = await Promise.allSettled([
axios.get('/api/letsencrypt/orders'),
axios.get('/api/letsencrypt/accounts'),
axios.get('/api/letsencrypt/renewal-schedule'),
axios.get('/api/clusters'),
axios.get('/api/letsencrypt/prerequisites'),
axios.get('/api/letsencrypt/dns-providers'),
]);
if (ordersRes.status === 'fulfilled') setOrders(ordersRes.value.data || []);
if (accountsRes.status === 'fulfilled') setAccounts(accountsRes.value.data || []);
if (prereqRes.status === 'fulfilled') setPrerequisites(prereqRes.value.data);
if (renewalRes.status === 'fulfilled') setRenewalSchedule(renewalRes.value.data || []);
if (clustersRes.status === 'fulfilled') setClusters(clustersRes.value.data?.clusters || []);
if (dnsRes.status === 'fulfilled') {
setDnsProviders(dnsRes.value.data?.providers || []);
setDns01Enabled(!!dnsRes.value.data?.dns01_enabled);
}
} catch (err) {
console.error('Error loading ACME data:', err);
} finally {
@@ -133,9 +175,29 @@ const ACMEAutomation = () => {
o.status === 'pending' || o.status === 'processing' || o.status === 'ready' ||
(o.status === 'valid' && !o.ssl_certificate_id)
);
// Track the order whose detail modal is open so the poll can refresh it WITHOUT
// making the interval depend on orderDetail (which would recreate it every poll).
const openDetailIdRef = useRef(null);
const detailRefetchInFlightRef = useRef(false);
useEffect(() => {
openDetailIdRef.current = (detailVisible && orderDetail?.id) ? orderDetail.id : null;
}, [detailVisible, orderDetail]);
useEffect(() => {
if (!hasInProgress) return undefined;
const interval = setInterval(() => { fetchData(); }, 30000);
const interval = setInterval(() => {
fetchData();
// Keep an open order-detail modal (e.g. a manual DNS-01 order awaiting validation)
// in sync so its status / TXT block / Verify button cannot go stale. Skip if a prior
// refetch is still in flight so slow backends don't pile up overlapping requests.
const oid = openDetailIdRef.current;
if (oid && !detailRefetchInFlightRef.current) {
detailRefetchInFlightRef.current = true;
axios.get(`/api/letsencrypt/orders/${oid}`)
.then((r) => setOrderDetail((prev) => (prev && prev.id === oid ? r.data : prev)))
.catch(() => { /* transient; the next poll retries */ })
.finally(() => { detailRefetchInFlightRef.current = false; });
}
}, 30000);
return () => clearInterval(interval);
}, [hasInProgress, fetchData]);
@@ -144,7 +206,10 @@ const ACMEAutomation = () => {
if (!expiryDate) return null;
return Math.ceil((new Date(expiryDate) - new Date()) / (1000 * 60 * 60 * 24));
};
const nextRenewal = renewalSchedule.find(c => c.auto_renew && calcDaysLeft(c.expiry_date) > 0);
// Manual DNS-01 certs are not auto-renewed (even a legacy row left at auto_renew=TRUE), so they
// must not drive the "Next Renewal" countdown, which implies an automated event.
const isManualDnsCert = (c) => c.challenge_type === 'dns-01' && (c.dns_provider || 'manual') === 'manual';
const nextRenewal = renewalSchedule.find(c => c.auto_renew && !isManualDnsCert(c) && calcDaysLeft(c.expiry_date) > 0);
const nextRenewalDays = nextRenewal ? calcDaysLeft(nextRenewal.expiry_date) : null;
// Issue #11/#12: an order is "in progress" if it's pre-valid OR valid-but-not-downloaded (stuck).
// Including 'ready' here ensures the dashboard counter & UI auto-refresh react to all in-flight states.
@@ -155,6 +220,21 @@ const ACMEAutomation = () => {
const activeAccount = accounts.find(a => a.status === 'valid') || null;
const acmeAccount = activeAccount || (accounts.length > 0 ? accounts[accounts.length - 1] : null);
const acmeEnabledClusters = clusters.filter(c => c.acme_enabled && c.is_active);
// Issue #35: the cert wizard adapts to the selected account's challenge method.
const wizardAccount = accounts.find(a => a.id === wizardAccountId) || activeAccount || acmeAccount;
const wizardIsDns01 = (wizardAccount?.challenge_type === 'dns-01');
const wizardDnsManual = wizardIsDns01 && (wizardAccount?.dns_provider === 'manual');
// Wildcard certificates can only be issued over DNS-01. Catch this client-side so the user is
// told their mistake up front instead of waiting for a CA-side rejection.
const wizardHasWildcard = (wizardDomains || []).some(d => typeof d === 'string' && d.trim().startsWith('*.'));
const wizardWildcardBlocked = wizardHasWildcard && !wizardIsDns01;
// A DNS-01 account can exist while the global kill-switch is off (e.g. an admin disabled it later).
// Issuing would be rejected by the backend, so block it in the wizard with a clear reason.
const wizardDns01Disabled = wizardIsDns01 && !dns01Enabled;
// Surface a direct credentials shortcut on the dashboard card when the primary account uses an
// automated DNS provider (manual providers need no credentials).
const acmeAccountProvider = acmeAccount ? dnsProviders.find(p => p.name === acmeAccount.dns_provider) : null;
const acmeAccountNeedsCreds = acmeAccount?.challenge_type === 'dns-01' && (acmeAccountProvider?.credential_fields || []).length > 0;
const filteredOrders = orders.filter(o => {
if (orderFilter === 'active') return !['cancelled', 'invalid', 'valid'].includes(o.status);
@@ -170,12 +250,29 @@ const ACMEAutomation = () => {
message.error('At least one domain is required');
return;
}
// Defense-in-depth: the Submit button is already disabled for these, but guard here too.
if (wizardWildcardBlocked) {
message.error('Wildcard certificates require a DNS-01 account. Select a DNS-01 account or remove the wildcard domain.');
return;
}
if (wizardDns01Disabled) {
message.error('DNS-01 is disabled by an administrator. Enable it in Settings > ACME to issue this certificate.');
return;
}
setSubmitting(true);
// Resolve the chosen account's challenge method so DNS-01/wildcard requests are explicit.
// Use the same resolution as the wizard description (wizardAccount) so what the user reviewed
// matches what is sent.
const challengeType = wizardAccount?.challenge_type; // 'http-01' | 'dns-01' | undefined
// Manual DNS-01 can't auto-renew (the wizard shows the switch off+disabled). Send false to
// match the displayed state rather than relying only on the backend to override it.
const autoRenew = wizardDnsManual ? false : (values.auto_renew !== false);
const res = await axios.post('/api/letsencrypt/certificates', {
domains: values.domains,
cluster_ids: values.cluster_ids || [],
auto_renew: values.auto_renew !== false,
auto_renew: autoRenew,
account_id: values.account_id || null,
challenge_type: challengeType || undefined,
});
message.success(res.data?.message || 'Certificate request submitted');
if (res.data?.warnings?.length > 0) {
@@ -185,6 +282,16 @@ const ACMEAutomation = () => {
setWizardStep(0);
wizardForm.resetFields();
fetchData();
// For a manual DNS-01 order the user must publish the TXT record(s) next, so open the
// order detail straight away instead of leaving them to hunt for it.
if (res.data?.challenge_type === 'dns-01'
&& (res.data?.dns_provider || 'manual') === 'manual'
&& res.data?.order_id) {
handleViewOrder(res.data.order_id);
} else if (res.data?.challenge_type === 'dns-01') {
// Automated DNS-01 (e.g. Cloudflare): reassure the user it is hands-off.
message.info('Automated DNS-01: the TXT records will be published and validated automatically. No action needed.', 6);
}
} catch (err) {
message.error(getErrorMsg(err, 'Failed to request certificate'));
} finally {
@@ -234,7 +341,7 @@ const ACMEAutomation = () => {
setOrderDetail(res.data);
setDetailVisible(true);
} catch (err) {
message.error('Failed to load order details');
message.error(getErrorMsg(err, 'Failed to load order details'));
}
};
@@ -453,21 +560,144 @@ const ACMEAutomation = () => {
try {
const values = await registerForm.validateFields();
setRegistering(true);
const challengeType = dns01Enabled ? (values.challenge_type || 'http-01') : 'http-01';
const dnsProvider = challengeType === 'dns-01' ? (values.dns_provider || null) : null;
const res = await axios.post('/api/letsencrypt/accounts', {
email: values.email,
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,
});
message.success(`ACME account registered: ${res.data?.email || values.email}`);
setRegisterVisible(false);
registerForm.resetFields();
const accountId = res.data?.id;
// For an automated DNS-01 provider, store the entered credentials (verified server-side).
const provider = dnsProviders.find(p => p.name === dnsProvider);
let credFailed = false;
if (challengeType === 'dns-01' && accountId && provider && (provider.credential_fields || []).length > 0) {
const creds = {};
(provider.credential_fields || []).forEach(f => {
const v = values[`cred_${f.key}`];
if (v != null && v !== '') creds[f.key] = v;
});
try {
const r2 = await axios.put(`/api/letsencrypt/accounts/${accountId}/dns-credentials`, {
dns_provider: dnsProvider,
credentials: creds,
});
message.success(r2.data?.detail || 'DNS provider credentials saved');
} catch (credErr) {
credFailed = true;
message.warning(getErrorMsg(credErr, `Account "${res.data?.email || values.email}" registered, but the DNS credentials could not be saved. You can fix them from the account's DNS credentials action.`), 8);
}
}
// Only close + reset on full success. On a credential-save failure, keep the modal open with
// the entered values so the user can correct the token and re-submit (the account already
// exists and the PUT re-verifies) — avoids discarding input, a dead-end, and a misleading
// success toast (Finding 7). The warning toast above explains what to fix.
if (!credFailed) {
message.success(`ACME account registered: ${res.data?.email || values.email}`);
setRegisterVisible(false);
registerForm.resetFields();
}
fetchData();
} catch (err) {
// Inline field-validation rejections already render under each field; don't also
// fire a generic error toast (mirrors handleSaveDnsCreds).
if (err?.errorFields) return;
message.error(getErrorMsg(err, 'Account registration failed'));
} finally {
setRegistering(false);
}
};
// Issue #35: manual DNS-01 — user asserts the TXT records are published; tell the CA to validate.
const handleDnsConfirm = async (orderId) => {
if (confirming) return; // guard the leading-edge double-click (loading alone does not block a synchronous re-fire)
try {
setConfirming(true);
const res = await axios.post(`/api/letsencrypt/orders/${orderId}/dns-confirm`);
message.success(res.data?.message || 'DNS-01 confirmation submitted; the CA will validate shortly.');
// Refresh the orders list AND the open detail modal so the user sees the new state
// (otherwise the modal shows a stale TXT block + an active Verify button).
fetchData();
try {
const fresh = await axios.get(`/api/letsencrypt/orders/${orderId}`);
// Only repopulate if the same order's detail is still open (the user may have
// closed it or navigated to another order while the request was in flight).
setOrderDetail((prev) => (prev && prev.id === orderId ? fresh.data : prev));
} catch (_e) { /* list refresh already happened; modal stays as-is */ }
} catch (err) {
message.error(getErrorMsg(err, 'Failed to confirm DNS-01'));
} finally {
setConfirming(false);
}
};
// Issue #35: view / replace / clear an account's DNS provider credentials after creation.
const openDnsCredsModal = async (account) => {
setCredAccount(account);
setCredMeta(null);
credForm.resetFields();
setCredModalVisible(true);
setCredLoading(true);
try {
const res = await axios.get(`/api/letsencrypt/accounts/${account.id}/dns-credentials`);
setCredMeta(res.data);
} catch (err) {
message.error(getErrorMsg(err, 'Failed to load DNS credentials'));
} finally {
setCredLoading(false);
}
};
const handleSaveDnsCreds = async () => {
if (!credAccount || !credProvider) return;
try {
const values = await credForm.validateFields();
setCredSaving(true);
const creds = {};
(credProvider.credential_fields || []).forEach(f => {
const v = values[`cred_${f.key}`];
if (v != null && v !== '') creds[f.key] = v;
});
const res = await axios.put(`/api/letsencrypt/accounts/${credAccount.id}/dns-credentials`, {
dns_provider: credAccount.dns_provider,
credentials: creds,
});
message.success(res.data?.detail || 'DNS provider credentials saved and verified');
setCredModalVisible(false);
credForm.resetFields();
fetchData();
} catch (err) {
if (err?.errorFields) return; // antd validation errors shown inline
message.error(getErrorMsg(err, 'Failed to save DNS credentials'));
} finally {
setCredSaving(false);
}
};
const handleClearDnsCreds = () => {
if (!credAccount) return;
Modal.confirm({
title: 'Clear DNS credentials',
content: `Remove the stored DNS provider credentials for ${credAccount.email}? Automated DNS-01 issuance/renewal will stop working until you re-enter them.`,
okText: 'Clear',
okButtonProps: { danger: true },
onOk: async () => {
try {
await axios.delete(`/api/letsencrypt/accounts/${credAccount.id}/dns-credentials`);
message.success('DNS credentials cleared');
setCredModalVisible(false);
fetchData();
} catch (err) {
message.error(getErrorMsg(err, 'Failed to clear DNS credentials'));
}
},
});
};
const handleDeactivateAccount = (accountId, email) => {
Modal.confirm({
title: 'Deactivate ACME Account',
@@ -564,7 +794,19 @@ const ACMEAutomation = () => {
render: (status, record) => statusTag(status, record),
},
{
title: 'Account', dataIndex: 'account_email', key: 'account_email',
title: 'Method', key: 'method', width: 170,
render: (_, record) => {
const ct = record.challenge_type || 'http-01';
if (ct !== 'dns-01') return <Tag>HTTP-01</Tag>;
const prov = record.dns_provider || 'manual';
const needsAction = prov === 'manual' && (record.status === 'pending' || record.status === 'processing');
return needsAction
? <Tag color="warning" icon={<ExclamationCircleOutlined />}>DNS-01 (manual): action needed</Tag>
: <Tag>DNS-01 ({prov})</Tag>;
},
},
{
title: 'Account', dataIndex: 'account_email', key: 'account_email', ellipsis: true,
render: (e) => e || '-',
},
{
@@ -630,9 +872,27 @@ const ACMEAutomation = () => {
return <Tag color={color}>{d} days</Tag>;
},
},
{
title: 'Method', key: 'method', width: 130,
render: (_, record) => {
const ct = record.challenge_type || 'http-01';
if (ct !== 'dns-01') return <Tag>HTTP-01</Tag>;
return <Tag>DNS-01 ({record.dns_provider || 'manual'})</Tag>;
},
},
{
title: 'Auto-Renew', dataIndex: 'auto_renew', key: 'auto_renew',
render: (v) => v ? <Tag color="green">Enabled</Tag> : <Tag>Disabled</Tag>,
render: (v, record) => {
const isManualDns = record.challenge_type === 'dns-01' && (record.dns_provider || 'manual') === 'manual';
if (isManualDns) {
return (
<Tooltip title="Manual DNS-01 cannot auto-renew unattended. Re-publish the TXT record and request renewal before expiry.">
<Tag color="warning" icon={<ExclamationCircleOutlined />}>Manual (re-publish TXT)</Tag>
</Tooltip>
);
}
return v ? <Tag color="green">Enabled</Tag> : <Tag>Disabled</Tag>;
},
},
];
@@ -655,8 +915,19 @@ const ACMEAutomation = () => {
<Alert
type="info"
showIcon
message="Each domain must resolve to an HAProxy node with ACME challenge routing enabled."
message={wizardIsDns01
? "DNS-01: validated via a DNS TXT record, so no public port 80 is needed. Wildcards (*.example.com) are supported. Note that a wildcard does not cover the bare apex (example.com); add it as a separate domain if you need both."
: "Each domain must resolve to an HAProxy node with ACME challenge routing enabled (HTTP-01)."}
/>
{wizardWildcardBlocked && (
<Alert
type="warning"
showIcon
style={{ marginTop: 12 }}
message="Wildcard requires a DNS-01 account"
description="A wildcard domain (*.example.com) can only be validated over DNS-01. The currently selected account uses HTTP-01. Choose a DNS-01 account in the next step, or remove the wildcard domain."
/>
)}
</>
),
},
@@ -668,20 +939,63 @@ const ACMEAutomation = () => {
<Select mode="multiple" placeholder="Leave empty for global certificate" allowClear>
{clusters.map(c => (
<Option key={c.id} value={c.id}>
{c.name} {c.acme_enabled ? '' : '(ACME not enabled)'}
{c.name} {wizardIsDns01 ? '' : (c.acme_enabled ? '' : '(ACME not enabled)')}
</Option>
))}
</Select>
</Form.Item>
<Form.Item name="auto_renew" label="Auto-Renew" valuePropName="checked" initialValue={true}>
<Switch defaultChecked />
</Form.Item>
{wizardIsDns01 && (
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="DNS-01 needs no ACME Challenge Routing. Any active cluster works."
description={wizardDnsManual
? "This account uses a manual DNS provider: after submitting, open the order and publish the shown TXT record, then confirm."
: "The DNS TXT record(s) will be published automatically."}
/>
)}
{wizardDnsManual ? (
// Manual DNS-01 cannot auto-renew (the backend forces it off); show the control off and
// disabled so it matches the outcome rather than implying an automated renewal.
<Form.Item label="Auto-Renew">
<Switch checked={false} disabled />
</Form.Item>
) : (
<Form.Item name="auto_renew" label="Auto-Renew" valuePropName="checked" initialValue={true}>
<Switch />
</Form.Item>
)}
{wizardDnsManual && (
<Alert
type="warning"
showIcon
style={{ marginTop: -8, marginBottom: 16 }}
message="Manual DNS-01 cannot auto-renew unattended. You will need to re-publish the TXT record at renewal time."
/>
)}
{accounts.length > 1 && (
<Form.Item name="account_id" label="ACME Account">
<Select placeholder="Use default account">
{accounts.map(a => (
<Option key={a.id} value={a.id}>{a.email} ({a.directory_url})</Option>
))}
<Form.Item
name="account_id"
label="ACME Account"
extra={<span style={{ fontSize: 12, color: token.colorTextSecondary }}>The validation method (HTTP-01 or DNS-01) is set by the chosen account. To use DNS-01, pick a DNS-01 account.</span>}
>
<Select placeholder="Use default account" optionLabelProp="label">
{accounts.map(a => {
// Match the parenthesized "DNS-01 (provider)" form used in the tables and order detail.
const methodLabel = a.challenge_type === 'dns-01'
? `DNS-01 (${a.dns_provider || 'manual'})`
: 'HTTP-01';
return (
<Option key={a.id} value={a.id} label={`${a.email} · ${methodLabel}`}>
<span>{a.email}{' '}
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{methodLabel} · {a.directory_url}
</Typography.Text>
</span>
</Option>
);
})}
</Select>
</Form.Item>
)}
@@ -707,7 +1021,31 @@ const ACMEAutomation = () => {
style={{ marginBottom: 16 }}
/>
)}
{acmeEnabledClusters.length === 0 && (
{wizardDns01Disabled && (
<Alert
type="error"
showIcon
message="DNS-01 is disabled"
description={
<span>
This account uses DNS-01, but DNS-01 is currently disabled by an administrator.{' '}
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => navigate('/settings?tab=acme')}>Enable it in Settings &gt; ACME</Button>
{' '}to issue this certificate.
</span>
}
style={{ marginBottom: 16 }}
/>
)}
{wizardWildcardBlocked && (
<Alert
type="error"
showIcon
message="Wildcard requires a DNS-01 account"
description="Remove the wildcard domain or select a DNS-01 account in the Configuration step."
style={{ marginBottom: 16 }}
/>
)}
{!wizardIsDns01 && acmeEnabledClusters.length === 0 && (
<Alert
type="warning"
showIcon
@@ -723,7 +1061,7 @@ const ACMEAutomation = () => {
style={{ marginBottom: 16 }}
/>
)}
{prerequisites?.steps?.find(s => s.key === 'config_applied' && s.ok === false) && (() => {
{!wizardIsDns01 && prerequisites?.steps?.find(s => s.key === 'config_applied' && s.ok === false) && (() => {
const configStep = prerequisites.steps.find(s => s.key === 'config_applied');
const pendingNames = (configStep?.pending_clusters || []).map(c => c.name).join(', ');
return (
@@ -755,8 +1093,17 @@ const ACMEAutomation = () => {
description={
<ul style={{ margin: 0, paddingLeft: 20 }}>
<li>ACME Account: {activeAccount ? <Tag color="success">Active ({activeAccount.email})</Tag> : <Tag color="error">No active account</Tag>}</li>
<li>ACME-enabled Clusters: {acmeEnabledClusters.length > 0 ? <Tag color="success">{acmeEnabledClusters.map(c => c.name).join(', ')}</Tag> : <Tag color="warning">None</Tag>}</li>
<li>Domains must resolve to HAProxy node IPs for HTTP-01 validation</li>
{wizardIsDns01 ? (
<>
<li>Challenge Method: <Tag>DNS-01</Tag> (TXT record; no port 80 / ACME routing needed)</li>
<li>DNS provider: <Tag>{wizardAccount?.dns_provider || 'manual'}</Tag></li>
</>
) : (
<>
<li>ACME-enabled Clusters: {acmeEnabledClusters.length > 0 ? <Tag color="success">{acmeEnabledClusters.map(c => c.name).join(', ')}</Tag> : <Tag color="warning">None</Tag>}</li>
<li>Domains must resolve to HAProxy node IPs for HTTP-01 validation</li>
</>
)}
</ul>
}
/>
@@ -807,9 +1154,15 @@ const ACMEAutomation = () => {
{pendingOrders.length > 0 && (() => {
const stuckCount = pendingOrders.filter(isOrderStuck).length;
const inFlightCount = pendingOrders.length - stuckCount;
// Manual DNS-01 orders are waiting on the USER to publish a TXT record, not on the CA —
// call them out separately so the action item is visible without scanning the table.
const manualDnsAwaiting = pendingOrders.filter(o =>
o.challenge_type === 'dns-01' && (o.dns_provider || 'manual') === 'manual'
&& (o.status === 'pending' || o.status === 'processing')).length;
const inFlightCount = pendingOrders.length - stuckCount - manualDnsAwaiting;
const parts = [];
if (inFlightCount > 0) parts.push(`${inFlightCount} awaiting validation`);
if (manualDnsAwaiting > 0) parts.push(`${manualDnsAwaiting} manual DNS-01 awaiting your TXT record${manualDnsAwaiting > 1 ? 's' : ''}`);
if (stuckCount > 0) parts.push(`${stuckCount} pending download (auto-retrying every 60s)`);
return (
<Alert
@@ -817,9 +1170,11 @@ const ACMEAutomation = () => {
showIcon
icon={<ExclamationCircleOutlined />}
message={`${pendingOrders.length} certificate order(s) in progress: ${parts.join(', ')}`}
description={stuckCount > 0
? "Stuck orders will auto-complete via the background task. You can also click \"Complete\" to retry immediately."
: undefined}
description={manualDnsAwaiting > 0
? "Open a manual DNS-01 order to see the TXT record to publish, then confirm it."
: stuckCount > 0
? "Stuck orders will auto-complete via the background task. You can also click \"Complete\" to retry immediately."
: undefined}
style={{ marginBottom: 16 }}
/>
);
@@ -866,6 +1221,11 @@ const ACMEAutomation = () => {
<InfoCircleOutlined /> Manage
</Button>
)}
{acmeAccountNeedsCreds && (
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => openDnsCredsModal(acmeAccount)}>
<KeyOutlined /> DNS credentials
</Button>
)}
</div>
</Card>
</Col>
@@ -965,7 +1325,7 @@ const ACMEAutomation = () => {
</Button>
)}
{wizardStep === wizardSteps.length - 1 && (
<Button type="primary" onClick={handleRequestCert} loading={submitting} disabled={!activeAccount || acmeEnabledClusters.length === 0}>
<Button type="primary" onClick={handleRequestCert} loading={submitting} disabled={!activeAccount || wizardWildcardBlocked || wizardDns01Disabled || (!wizardIsDns01 && acmeEnabledClusters.length === 0)}>
Submit Request
</Button>
)}
@@ -994,7 +1354,7 @@ const ACMEAutomation = () => {
</Tag>
) : orderDetail.status === 'valid' ? (
<Tag color="warning" icon={<ExclamationCircleOutlined />}>
Pending download — auto-completion task will retry every 60s
Pending download. Auto-completion task will retry every 60s
</Tag>
) : orderDetail.status === 'invalid' || orderDetail.status === 'cancelled' ? (
<Tag color="default">Not issued</Tag>
@@ -1010,6 +1370,71 @@ const ACMEAutomation = () => {
style={{ marginBottom: 16 }}
/>
)}
{orderDetail.challenge_type === 'dns-01'
&& !(orderDetail.challenges || []).some(c => c.challenge_type === 'dns-01')
&& (orderDetail.status === 'pending' || orderDetail.status === 'processing') && (
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="Preparing DNS-01 challenge"
description="The TXT record(s) for this order are being provisioned. They will appear here shortly; this view refreshes automatically."
/>
)}
{orderDetail.challenge_type === 'dns-01' && (orderDetail.challenges || []).some(c => c.challenge_type === 'dns-01') && (() => {
const dnsChallenges = (orderDetail.challenges || []).filter(c => c.challenge_type === 'dns-01');
const isManual = (orderDetail.dns_provider || 'manual') === 'manual';
const canConfirm = isManual && (orderDetail.status === 'pending' || orderDetail.status === 'processing');
return (
<Alert
type={isManual ? 'warning' : 'info'}
showIcon
style={{ marginBottom: 16 }}
message={isManual
? 'DNS-01 (manual): publish these TXT record(s), then verify'
: 'DNS-01 (automated): the TXT record(s) are published for you'}
description={
<div>
<div style={{ marginBottom: 8 }}>
Add the following DNS TXT record{dnsChallenges.length > 1 ? 's' : ''} at your DNS provider:
</div>
<Table
size="small"
pagination={false}
dataSource={dnsChallenges}
rowKey="id"
scroll={{ x: 'max-content' }}
columns={[
{ title: 'Record name', dataIndex: 'dns_record_name', key: 'name',
render: (v) => v ? <Typography.Text code copyable style={{ wordBreak: 'break-all' }}>{v}</Typography.Text> : <Typography.Text type="secondary">-</Typography.Text> },
{ title: 'Type', key: 'type', width: 60, render: () => 'TXT' },
{ title: 'Value', dataIndex: 'dns_txt_value', key: 'val',
render: (v) => v ? <Typography.Text code copyable style={{ wordBreak: 'break-all' }}>{v}</Typography.Text> : <Typography.Text type="secondary">-</Typography.Text> },
]}
/>
{canConfirm && (
<>
<div style={{ marginTop: 12, fontSize: 12, color: token.colorTextSecondary }}>
DNS changes can take a few minutes to propagate. If verification fails,
wait a short while and try again. The order keeps retrying in the background.
</div>
<Button
type="primary"
size="small"
loading={confirming}
disabled={confirming}
style={{ marginTop: 8 }}
onClick={() => handleDnsConfirm(orderDetail.id)}
>
I've added the record(s), verify now
</Button>
</>
)}
</div>
}
/>
);
})()}
{orderDetail.challenges?.length > 0 && (
<>
<h4>Challenges</h4>
@@ -1020,7 +1445,10 @@ const ACMEAutomation = () => {
rowKey="id"
columns={[
{ title: 'Domain', dataIndex: 'domain', key: 'domain' },
{ title: 'Token', dataIndex: 'token', key: 'token', ellipsis: true },
{ title: 'Method', dataIndex: 'challenge_type', key: 'method', width: 90,
render: (ct) => <Tag>{(ct || 'http-01') === 'dns-01' ? 'DNS-01' : 'HTTP-01'}</Tag> },
{ title: 'Token', dataIndex: 'token', key: 'token', ellipsis: true,
render: (t, r) => (r.challenge_type === 'dns-01') ? <Typography.Text type="secondary">TXT-based (see above)</Typography.Text> : t },
{ title: 'Status', dataIndex: 'status', key: 'status', render: (s) => statusTag(s) },
]}
/>
@@ -1047,7 +1475,7 @@ const ACMEAutomation = () => {
rowKey="id"
columns={[
{
title: 'Email', dataIndex: 'email', key: 'email',
title: 'Email', dataIndex: 'email', key: 'email', ellipsis: true,
render: (email) => <strong>{email}</strong>,
},
{
@@ -1098,6 +1526,10 @@ const ACMEAutomation = () => {
</p>
)}
{record.eab_kid && <p><strong>EAB Key ID:</strong> {record.eab_kid}</p>}
<p><strong>Challenge Method:</strong> {(record.challenge_type || 'http-01') === 'dns-01' ? 'DNS-01' : 'HTTP-01'}</p>
{(record.challenge_type === 'dns-01') && (
<p><strong>DNS Provider:</strong> {record.dns_provider || 'manual'}</p>
)}
<p><strong>ToS Accepted:</strong> {record.tos_agreed ? 'Yes' : 'No'}</p>
<p><strong>Registered:</strong> {record.created_at ? new Date(record.created_at).toLocaleString() : '-'}</p>
</div>
@@ -1106,6 +1538,16 @@ const ACMEAutomation = () => {
}}
/>
</Tooltip>
{record.challenge_type === 'dns-01'
&& (dnsProviders.find(p => p.name === record.dns_provider)?.credential_fields || []).length > 0 && (
<Tooltip title="DNS Provider Credentials">
<Button
icon={<KeyOutlined />}
size="small"
onClick={() => openDnsCredsModal(record)}
/>
</Tooltip>
)}
{record.status !== 'deactivated' ? (
<Tooltip title="Deactivate">
<Button
@@ -1154,7 +1596,7 @@ const ACMEAutomation = () => {
message="ACME account will be registered with the directory URL configured in Settings > ACME."
style={{ marginBottom: 16 }}
/>
<Form form={registerForm} layout="vertical">
<Form form={registerForm} layout="vertical" preserve={false}>
<Form.Item
name="email"
label="Contact Email"
@@ -1167,11 +1609,12 @@ const ACMEAutomation = () => {
</Form.Item>
<Form.Item
name="tos_agreed"
label="Terms of Service"
valuePropName="checked"
initialValue={false}
rules={[{ validator: (_, v) => v ? Promise.resolve() : Promise.reject('You must accept the Terms of Service') }]}
>
<Switch checkedChildren="Accepted" unCheckedChildren="Not Accepted" />
<Switch checkedChildren="Accepted" unCheckedChildren="Not Accepted" aria-label="Accept Terms of Service" />
</Form.Item>
<div style={{ fontSize: 12, color: token.colorTextSecondary }}>
By accepting, you agree to the ACME CA's Terms of Service (e.g.{' '}
@@ -1179,12 +1622,194 @@ 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' }} />
<Form.Item
name="challenge_type"
label="Challenge Method"
initialValue="http-01"
tooltip="HTTP-01 validates over port 80. DNS-01 validates via a DNS TXT record. It works for internal/isolated clusters (no public port 80) and supports wildcards."
>
<Select>
<Option value="http-01">HTTP-01 (default)</Option>
<Option value="dns-01">DNS-01 (TXT record)</Option>
</Select>
</Form.Item>
{regChallengeType === 'dns-01' && (
<>
{dnsProviders.length === 0 ? (
<Alert
type="warning"
showIcon
message="No DNS providers are available"
description="DNS-01 appears enabled, but no provider catalog was returned. Confirm DNS-01 is enabled in Settings and that the backend is reachable, then reopen this dialog."
/>
) : (
<Form.Item
name="dns_provider"
label="DNS Provider"
rules={[{ required: true, message: 'Select a DNS provider' }]}
>
<Select placeholder="Select a DNS provider">
{dnsProviders.map(p => (
<Option key={p.name} value={p.name}>{p.label}</Option>
))}
</Select>
</Form.Item>
)}
{selectedDnsProvider && (selectedDnsProvider.credential_fields || []).length > 0 && (
<>
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
message="Provider credentials are encrypted at rest and verified before they are saved. The token needs permission to create and delete TXT records in your domain's DNS zone."
/>
{(selectedDnsProvider.credential_fields || []).map(f => (
<Form.Item
key={f.key}
name={`cred_${f.key}`}
label={f.label}
extra={f.help ? <span style={{ fontSize: 12, color: token.colorTextSecondary }}>{f.help}</span> : null}
rules={f.required ? [{ required: true, message: `${f.label} is required` }] : []}
>
{f.type === 'password'
? <Input.Password placeholder={f.label} maxLength={f.max_length || undefined} />
: <Input placeholder={f.label} maxLength={f.max_length || undefined} />}
</Form.Item>
))}
</>
)}
{selectedDnsProvider && !selectedDnsProvider.automated && (
<Alert
type="warning"
showIcon
message="Manual DNS provider"
description="You will publish the DNS TXT record yourself and confirm it. Manual DNS-01 certificates cannot auto-renew unattended."
/>
)}
</>
)}
</>
)}
</Form>
</Modal>
{/* Issue #35: DNS provider credentials for an existing DNS-01 account (view / replace / clear) */}
<Modal
title={<span><KeyOutlined /> DNS Provider Credentials{credAccount ? `: ${credAccount.email}` : ''}</span>}
open={credModalVisible}
onCancel={() => { setCredModalVisible(false); credForm.resetFields(); }}
width={560}
footer={[
<Button key="clear" danger onClick={handleClearDnsCreds} disabled={!credMeta?.configured || credSaving}>
Clear credentials
</Button>,
<Button key="cancel" onClick={() => { setCredModalVisible(false); credForm.resetFields(); }}>
Cancel
</Button>,
<Button key="save" type="primary" loading={credSaving} onClick={handleSaveDnsCreds}>
Save & verify
</Button>,
]}
>
{credLoading ? (
<div style={{ textAlign: 'center', padding: 24 }}><Spin /></div>
) : (
<>
<p style={{ marginBottom: 4 }}>
<strong>Provider:</strong> {credAccount?.dns_provider || '-'}
</p>
<p style={{ marginTop: 0, fontSize: 12, color: token.colorTextSecondary }}>
{credMeta?.configured
? `Configured: ${(credMeta.credential_fields_present || []).join(', ') || '(none)'}${credMeta.updated_at ? ` · updated ${new Date(credMeta.updated_at).toLocaleString()}` : ''}`
: 'No credentials are stored yet for this account.'}
</p>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="Existing values are never shown. Enter the values to (re)store them; they are verified against the provider and encrypted at rest. The token needs permission to create and delete TXT records in your domain's DNS zone."
/>
<Form form={credForm} layout="vertical">
{(credProvider?.credential_fields || []).map(f => (
<Form.Item
key={f.key}
name={`cred_${f.key}`}
label={f.label}
rules={f.required ? [{ required: true, message: `${f.label} is required` }] : []}
extra={f.help ? <span style={{ fontSize: 12, color: token.colorTextSecondary }}>{f.help}</span> : null}
>
{f.type === 'password'
? <Input.Password placeholder={f.label} maxLength={f.max_length || undefined} />
: <Input placeholder={f.label} maxLength={f.max_length || undefined} />}
</Form.Item>
))}
{(credProvider?.credential_fields || []).length === 0 && (
<Alert type="warning" showIcon message="This provider needs no credentials (manual mode)." />
)}
</Form>
</>
)}
</Modal>
{/* v1.5.0 Issue #13: ACME Diagnostic Panel modal */}
<Modal
title={diagOrderId ? `Diagnostics — Order #${diagOrderId}` : 'Diagnostics'}
title={diagOrderId ? `Diagnostics: Order #${diagOrderId}` : 'Diagnostics'}
open={diagVisible}
onCancel={closeDiagModal}
width={920}
@@ -1266,12 +1891,21 @@ const ACMEAutomation = () => {
{
title: 'Re-run', key: 'rerun', width: 90,
render: (_, record) => (
<Button
size="small"
icon={<ReloadOutlined />}
loading={diagRunningCheckId === record.id}
onClick={() => handleRerunCheck(record.id)}
/>
// A skipped check (e.g. port 80 / routing for a DNS-01 order) has no
// transient cause to re-test, so re-running just reproduces "skipped".
record.status === 'skipped'
? <Typography.Text type="secondary">-</Typography.Text>
: (
<Tooltip title="Re-run this check">
<Button
size="small"
aria-label="Re-run this check"
icon={<ReloadOutlined />}
loading={diagRunningCheckId === record.id}
onClick={() => handleRerunCheck(record.id)}
/>
</Tooltip>
)
),
},
]}
@@ -1327,13 +1961,13 @@ const ACMEAutomation = () => {
type="warning"
showIcon
style={{ marginBottom: 12 }}
message="Event log partial — one or more sources failed"
message="Event log partial: one or more sources failed"
description={
<div>
<ul style={{ margin: '4px 0 4px 16px' }}>
{diagEventsError.errors.map((err, i) => (
<li key={i}>
<strong>{err.section}</strong>: {err.exception_type} — {err.message}
<strong>{err.section}</strong>: {err.exception_type}: {err.message}
</li>
))}
</ul>
@@ -1359,7 +1993,7 @@ const ACMEAutomation = () => {
children: (
<div>
<div style={{ fontSize: 12, color: '#888' }}>{ev.created_at} · {ev.source}</div>
<div><strong>{ev.event_type}</strong></div>
<div><strong>{humanizeEventType(ev.event_type)}</strong></div>
{ev.message && <div>{ev.message}</div>}
</div>
),
+3 -3
View File
@@ -2378,7 +2378,7 @@ const AgentManagement = () => {
const element = document.createElement('a');
const file = new Blob([installScript], { type: 'text/plain' });
element.href = URL.createObjectURL(file);
element.download = `install-haproxy-agent-${selectedPlatform}.sh`;
element.download = `install-agent-${selectedPlatform}.sh`;
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
@@ -2516,7 +2516,7 @@ const AgentManagement = () => {
const element = document.createElement('a');
const file = new Blob([uninstallScript], { type: 'text/plain' });
element.href = URL.createObjectURL(file);
element.download = `uninstall-haproxy-agent-${selectedPlatform}.sh`;
element.download = `uninstall-agent-${selectedPlatform}.sh`;
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
@@ -3147,7 +3147,7 @@ const AgentManagement = () => {
const element = document.createElement('a');
const file = new Blob([deleteUninstallScript], { type: 'text/plain' });
element.href = URL.createObjectURL(file);
element.download = `uninstall-haproxy-agent-${agentToDelete.platform || 'linux'}.sh`;
element.download = `uninstall-agent-${agentToDelete.platform || 'linux'}.sh`;
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
+88 -7
View File
@@ -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 (
<>
+46 -3
View File
@@ -458,6 +458,8 @@ backend web-backend
{record.request_headers && <Tag color="blue">Req Headers</Tag>}
{record.response_headers && <Tag color="green">Resp Headers</Tag>}
{record.tcp_request_rules && <Tag color="purple">TCP Rules</Tag>}
{record.filters && <Tag color="magenta">Filters</Tag>}
{record.log_format && <Tag color="geekblue">Log Format</Tag>}
{record.acl_rules && record.acl_rules.length > 0 && <Tag color="orange">{record.acl_rules.length} ACLs</Tag>}
{record.use_backend_rules && record.use_backend_rules.length > 0 && <Tag color="cyan">{record.use_backend_rules.length} Routes</Tag>}
</Space>
@@ -1076,8 +1078,9 @@ backend web-backend
size="small"
expandable={{
expandedRowRender: (frontend) => {
const hasDetails = frontend.request_headers || frontend.response_headers ||
frontend.options || frontend.tcp_request_rules ||
const hasDetails = frontend.request_headers || frontend.response_headers ||
frontend.options || frontend.tcp_request_rules ||
frontend.filters || frontend.log_format ||
(frontend.acl_rules && frontend.acl_rules.length > 0) ||
(frontend.use_backend_rules && frontend.use_backend_rules.length > 0);
@@ -1157,12 +1160,52 @@ backend web-backend
</span>
}
>
<MultiLineDiffRenderer
<MultiLineDiffRenderer
value={frontend.tcp_request_rules}
changeInfo={frontend._changes?.tcp_request_rules}
/>
</Descriptions.Item>
)}
{/* Issue #38: SPOE filters */}
{frontend.filters && (
<Descriptions.Item
label={
<span>
Filters (SPOE/WAF)
{frontend._changes?.filters && (
<Tag color="green" style={{ marginLeft: 8, fontSize: '10px' }}>
{frontend._changes.filters.old ? 'CHANGED' : 'NEW'}
</Tag>
)}
</span>
}
>
<MultiLineDiffRenderer
value={frontend.filters}
changeInfo={frontend._changes?.filters}
/>
</Descriptions.Item>
)}
{/* Issue #38: frontend log-format */}
{frontend.log_format && (
<Descriptions.Item
label={
<span>
Log Format
{frontend._changes?.log_format && (
<Tag color="green" style={{ marginLeft: 8, fontSize: '10px' }}>
{frontend._changes.log_format.old ? 'CHANGED' : 'NEW'}
</Tag>
)}
</span>
}
>
<MultiLineDiffRenderer
value={frontend.log_format}
changeInfo={frontend._changes?.log_format}
/>
</Descriptions.Item>
)}
{frontend.acl_rules && frontend.acl_rules.length > 0 && (
<Descriptions.Item label={`ACL Rules (${frontend.acl_rules.length})`}>
{frontend.acl_rules.map((acl, idx) => (
+39 -1
View File
@@ -642,7 +642,11 @@ const FrontendManagement = () => {
// Explicitly set options field to handle null/undefined case (NEW field)
options: frontend.options || '',
// BUGFIX: Explicitly set tcp_request_rules field to handle null/undefined case
tcp_request_rules: frontend.tcp_request_rules || ''
tcp_request_rules: frontend.tcp_request_rules || '',
// Issue #38: SPOE filters + frontend log-format (null → '' so the
// TextAreas populate on edit and round-trip on save, preventing null-wipe)
log_format: frontend.log_format || '',
filters: frontend.filters || ''
});
// Update SSL field visibility after setting values
@@ -2250,6 +2254,40 @@ tcp-request connection reject if { src -f /etc/haproxy/blacklist.lst }`}
</Form.Item>
</Col>
</Row>
{/* Issue #38: SPOE filters + frontend log-format */}
<Row gutter={16}>
<Col span={24}>
<Form.Item
name="filters"
label="Filters (SPOE / WAF)"
extra="HAProxy filter directives (one per line). Emitted before send-spoe-group rules."
tooltip="e.g. Coraza WAF via SPOE. The referenced engine config file and its SPOA backend must exist on the HAProxy host."
>
<TextArea
rows={3}
placeholder={`Examples:
filter spoe engine coraza config /etc/haproxy/coraza.cfg`}
/>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={24}>
<Form.Item
name="log_format"
label="Custom Log Format"
extra="HAProxy log-format / log-format-sd directive (kept verbatim)"
tooltip="Overrides option httplog/tcplog. Use the full directive including the quoted format string."
>
<TextArea
rows={3}
placeholder={'log-format "%ci:%cp [%t] %ft %b/%s %ST %B %{+Q}r"'}
/>
</Form.Item>
</Col>
</Row>
</Panel>
</Collapse>
+31
View File
@@ -212,6 +212,7 @@ const Settings = () => {
eab_kid: '',
eab_hmac_key: '',
challenge_backend_url: '',
dns01_enabled: false,
}}
>
<Form.Item name="provider" label="ACME Provider">
@@ -308,6 +309,36 @@ const Settings = () => {
}]}
/>
{/* Issue #35: DNS-01 challenge support (global kill-switch). Per-account DNS provider
credentials are configured on each ACME account in ACME Automation. */}
<Collapse
ghost
style={{ marginTop: 16 }}
items={[{
key: 'dns01',
label: 'DNS-01 Challenge (Advanced)',
children: (
<>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="DNS-01 validates certificates via a DNS TXT record instead of HTTP on port 80."
description="Use it for internal/isolated clusters with no public inbound port 80, or for wildcard certificates. When enabled, choose DNS-01 and a DNS provider per ACME account in ACME Automation. Leaving this off keeps the default HTTP-01 behavior unchanged."
/>
<Form.Item
name="dns01_enabled"
label="Enable DNS-01 Challenge"
valuePropName="checked"
tooltip="Master switch. While off, DNS-01 options are hidden and no DNS-01 orders can be created."
>
<Switch />
</Form.Item>
</>
),
}]}
/>
<div style={{ marginTop: 24, display: 'flex', gap: 12 }}>
<Button type="primary" htmlType="submit" loading={acmeSaving}>
Save ACME Settings
-5
View File
@@ -1,5 +0,0 @@
{
"version": "1.7.8",
"releaseName": "HA/VIP \u2014 per-node apply progress",
"releaseDate": "2026-06-07"
}