mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-10-02 15:08:13 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c492b26bb1 | |||
| 8b07d7a6a3 | |||
| 428915998b | |||
| 1bc99c5fe7 | |||
| a1192e602d | |||
| 9d7a718671 | |||
| 62b1599354 | |||
| b34d7cf811 | |||
| c2ea424d70 | |||
| d9ef86f548 | |||
| f3d4fb11bb | |||
| 0692f26ebb | |||
| b0bb6a55c0 | |||
| f2b3df517f |
@@ -17,6 +17,11 @@ REDIS_URL=redis://redis:6379
|
||||
# Change this to a strong random string in production
|
||||
SECRET_KEY=your-secret-key-change-this-in-production
|
||||
|
||||
# Optional: dedicated Fernet key for encrypting VRRP secrets of HA/VIP (Issue #27).
|
||||
# If unset, it is derived from SECRET_KEY (HKDF), exactly like MFA. Set an explicit
|
||||
# key (urlsafe-base64, 32 bytes) in production if you want independent key rotation.
|
||||
# VIP_ENCRYPTION_KEY=
|
||||
|
||||
# ============================================================================
|
||||
# PUBLIC URL CONFIGURATION
|
||||
# ============================================================================
|
||||
|
||||
@@ -7,6 +7,8 @@ on:
|
||||
jobs:
|
||||
build_and_push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: checkout
|
||||
@@ -76,3 +78,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' 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
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ Modern, web-based management interface for HAProxy load balancers with multi-clu
|
||||
- [ACME Auto SSL](#acme-auto-ssl---automated-certificate-management)
|
||||
- [WAF Management](#waf-management---web-application-firewall)
|
||||
- [IP Inventory](#ip-inventory---cross-cluster-ip-search--discovery)
|
||||
- [HA / VIP Management](#ha--vip-management---keepalived-virtual-ip-failover)
|
||||
- [User Management](#user-management---access-control--authentication)
|
||||
- [Settings](#settings---system-configuration)
|
||||
6. [Getting Started - First-Time Usage](#getting-started---first-time-usage)
|
||||
@@ -105,12 +106,13 @@ 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
|
||||
✅ **Token-Based Agent Auth** - Secure token management with revoke/renew
|
||||
✅ **IP Inventory** - Cross-cluster IP search to identify agents, VIPs, and backend servers by IP
|
||||
✅ **Keepalived VRRP Detection** - Automatic MASTER/BACKUP state and VIP detection from keepalived
|
||||
✅ **HA / VIP (Keepalived) Management** - Create virtual IPs from the UI; the agent installs & configures Keepalived (unicast VRRP) with a HAProxy health-check so the VIP fails over automatically; live MASTER/BACKUP detection per node
|
||||
✅ **Role-Based User Management** - Admin and user roles with granular permissions and access control
|
||||
✅ **User Activity Audit Logs** - Complete audit trail of all system events
|
||||
✅ **REST API** - Full programmatic access for automation and CI/CD integration
|
||||
@@ -229,6 +231,7 @@ This architecture provides better security (no inbound connections to HAProxy se
|
||||
- **Agent Token Management**: Secure token-based agent installation with token revoke and renew capabilities
|
||||
- **Agent Script Editor**: Monaco code editor for updating agent scripts via UI (not binary) - create new versions, rollback, and auto-upgrade all agents
|
||||
- **Platform-Specific Scripts**: Auto-generated installation scripts for Linux and macOS (x86_64/ARM64)
|
||||
- **HA / VIP (Keepalived) Management**: Create a virtual IP from the UI and the agents install & configure Keepalived (VRRP) with a HAProxy health-check so the VIP fails over automatically when HAProxy drops — opt-in, with live MASTER/BACKUP per node, multi-distro install, cluster-driven config path, and Apply/Reject staging
|
||||
|
||||
#### Version Control & Change Management
|
||||
- **Apply Management**: Centralized change tracking and deployment status monitoring across all agents
|
||||
@@ -252,6 +255,7 @@ 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
|
||||
@@ -964,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:
|
||||
@@ -1055,6 +1077,32 @@ The IP Inventory page provides a unified view of all IP addresses across every c
|
||||
- Locate a backend server IP across multiple clusters
|
||||
- Audit all IP addresses managed by the platform
|
||||
|
||||
### HA / VIP Management - Keepalived Virtual IP Failover
|
||||
|
||||
The HA / VIP page manages **highly-available virtual IPs** backed by Keepalived (VRRP) directly from the UI — no SSHing into nodes to install/configure Keepalived by hand. It builds on the agent pull-architecture: you define the VIP centrally, click Apply, and the agents converge.
|
||||
|
||||
**What it does:**
|
||||
- **VIP dashboard**: lists each virtual IP with its pool, VRID, per-node interface, and a **live MASTER / BACKUP / FAULT** column per node (refreshed every 30s from the existing keepalive-state heartbeat pipeline).
|
||||
- **Creation form**: enter the virtual IP (+ prefix), pick the pool, and select which of the pool's HAProxy nodes participate — each with a network interface (from the node's reported interfaces), a VRRP role (MASTER/BACKUP) and a priority. VRID auto-allocates per pool; an optional VRRP secret can be set.
|
||||
- **Hands-off automation**: on **Apply**, each member's `keepalived.conf` is rendered and delivered; the agent installs Keepalived if missing, writes the config + a HAProxy health-check (`vrrp_script` + `track_script`), validates with `keepalived -t`, and starts the service. When HAProxy drops on the active node, the health-check lowers VRRP priority and the **VIP fails over automatically** to a backup.
|
||||
|
||||
**How it works (pull-based, isolated):**
|
||||
- New tables `vip_instances` + `vip_members`; the agent polls `GET /api/agents/{name}/keepalived-config` (key-authenticated, agent-bound) and converges. It is fully **isolated** from the global HAProxy apply flow — VIP changes never regenerate `haproxy.cfg`.
|
||||
- **Cluster-driven path**: the `keepalived.conf` location is a cluster setting (`keepalived_config_path`, default `/etc/keepalived/keepalived.conf`) delivered to the agent dynamically — just like the HAProxy paths — so non-standard installs are supported with zero per-node effort. The default is what the keepalived service loads on every distro; if you set a non-default path, ensure the keepalived unit is configured to load it.
|
||||
|
||||
**Apply / Reject staging:**
|
||||
- VIP edits are staged as **PENDING** and only go live on **Apply**, which records an applied snapshot. **Reject** discards pending changes and **restores the previous applied state** (a never-applied VIP is discarded). Editing a live VIP never disrupts it until you re-Apply.
|
||||
|
||||
**Enterprise safety:**
|
||||
- **Opt-in & backward compatible** — nodes/clusters without a VIP do nothing new.
|
||||
- **Never clobbers a hand-managed Keepalived** — if an unmanaged `keepalived.conf` exists, the agent reports "externally managed" and leaves it untouched.
|
||||
- **Multi-distro install** — Debian/Ubuntu, RHEL/CentOS/Alma/Rocky, Fedora, SUSE/openSUSE, Alpine (apt/dnf/yum/zypper/apk).
|
||||
- **Reliable detection across platforms** — MASTER/BACKUP is detected via systemd journal, syslog, and a portable interface-based check (exact-match, all VIPs).
|
||||
- **Secure** — the VRRP secret is encrypted at rest and never returned by the API; the delivery endpoint requires the node's own API key.
|
||||
- **Scope** — VRRP targets bare-metal / VMware / on-prem L2 networks; the UI clearly notes that AWS/Azure/GCP don't honor VRRP/gratuitous-ARP. macOS agents can't run Keepalived and are excluded (the UI warns if one is selected).
|
||||
|
||||
**RBAC:** gated by the `vip` permission group (`vip.read/create/update/delete/apply`); admins bypass.
|
||||
|
||||
### User Management - Access Control & Authentication
|
||||
- **User Accounts**: Create, edit, and manage user accounts
|
||||
- **Role-based Access**: Admin, user, and custom role definitions
|
||||
@@ -2367,6 +2415,26 @@ Developed with ❤️ for the HAProxy community
|
||||
|
||||
## Release Notes
|
||||
|
||||
- **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**.
|
||||
- **v1.7.5** (2026-06-07) — HA / VIP "View Change" fix: editing a VIP (e.g. priority + virtual IP) now shows a **real line diff — only the lines that actually changed** — in Apply Management's View Change, instead of rendering the whole `keepalived.conf` as "added". Also fixes a doubled `+ +` prefix (the VIP diff now stores lines without a +/- prefix, matching the standard config diff). View-only; no schema, agent, apply, or render change.
|
||||
- **v1.7.4** (2026-06-06) — HAProxy config-generator robustness fix: a frontend that uses a stick counter (`track-sc<N>` or an `sc_*_rate(...)` fetch, e.g. a rate-limit `http-request deny if { sc_http_req_rate(0) gt N }`) but declares **no `stick-table`** caused HAProxy to fatally reject the whole cluster config with *"table '<frontend>' used but not configured"*. This happened where rate-limit directives had been baked into a frontend's stored `request_headers`/`options` (by an older version or a config import). The generator now **auto-injects a default `stick-table`** in that case. Purely additive — it only fires when a counter is used and no table exists (a config that was already invalid), so frontends that already declare a stick-table or don't rate-limit are byte-unchanged.
|
||||
- **v1.7.3** (2026-06-06) — HA / VIP backward-compat fix: the v1.7.2 deletion-tracking list now only resurfaces VIPs deleted through the **new approval flow** (gated on `last_config_status='APPLIED'`), so a VIP soft-deleted under an earlier version's immediate-delete is no longer shown as `DELETING`. Display-only; no schema or agent change.
|
||||
- **v1.7.2** (2026-06-06) — HA / VIP safety & visibility follow-up:
|
||||
- **Approval-gated deletion (safety).** Deleting a *running* VIP from the UI no longer takes effect immediately — it is **staged for Apply Management** and the VIP **keeps running, untouched**, until you **Approve** it (Reject keeps it). The agent is told to tear keepalived down **only after approval**, so a misclick can never tear down a production VIP — an agent never deletes without an explicit human approval. The deletion is trackable through the standard Apply popup and a **DELETING** status on the HA/VIP tab. (A VIP that was never applied is removed at once — nothing is running to tear down.)
|
||||
- **Diagnostics view** — a per-VIP search-icon modal showing each node's keepalived deploy state, the message it reported, last-ack time and live VRRP state (handy while a fresh install is SYNCING, ~30s), plus the exact node-side log commands.
|
||||
- **Opt-in package uninstall** — the safe default keeps the keepalived package (stop & disable, remove our config, release the VIP); a default-off checkbox additionally uninstalls the package **only on nodes where OpenManager installed it** (an admin's pre-existing keepalived is never removed, tracked via an install marker).
|
||||
- Adds additive `purge_on_teardown` + `pending_delete` columns; `SCHEMA_VERSION` 5 → 7 (idempotent; existing data/passwords unaffected). Agent script updated — push it via **Agent Script Management → Reset to Defaults**, then **Upgrade** agents.
|
||||
- **v1.7.1** (2026-06-06) — HA / VIP follow-up: a VIP can now be created on a **single node** — a Keepalived-managed floating IP **without** failover (e.g. a one-box HAProxy that wants a stable address, or before a second node is added). Add a second node anytime for real VRRP failover. A single-node VIP renders a clean **multicast** config (no bare `unicast_src_ip`, which `keepalived -t` rejects); multi-node behaviour is unchanged. The Create-VIP form now auto-selects the first chosen node as **MASTER** and shows an in-UI notice that, on Apply, Keepalived is **installed automatically from the node's OS package repositories** (apt/dnf/yum/zypper/apk — the node must reach its repos / an internal mirror), while a hand-managed Keepalived is still left untouched. No schema change.
|
||||
- **v1.7.0** (2026-06-05) — Feature (Issue #27): **HA / VIP (Keepalived) management** from the UI. A new "HA / VIP" tab lets you create a virtual IP, pick a per-node interface, and select which pool nodes participate (with MASTER/BACKUP roles + priorities); on Apply, the agent installs & configures Keepalived (unicast VRRP, cloud-safe default) with a HAProxy health-check so the VIP fails over automatically when HAProxy drops, and the tab shows live MASTER/BACKUP per node. Fully **opt-in and backward compatible** — nodes/clusters without a VIP are untouched, and a node already running a hand-managed Keepalived is detected and never overwritten (reported as "externally managed"). Pending VIP changes can be **Rejected** to fully restore the last applied state. Keepalived is installed across the major distros (Debian/Ubuntu, RHEL/CentOS/Alma/Rocky, Fedora, SUSE/openSUSE, Alpine), and live MASTER/BACKUP detection works across distros/init systems (journald + log files + portable interface-based detection). On-prem/L2 scope (VRRP); a clear in-UI notice covers the cloud caveat. Adds two new tables (`vip_instances`, `vip_members`) — `SCHEMA_VERSION` bumps to 3 (idempotent re-run; existing data and passwords unaffected). A new "HA / VIP" tab lets you create a virtual IP, pick a per-node interface, and select which pool nodes participate (with MASTER/BACKUP roles + priorities); on Apply, the agent installs & configures Keepalived (unicast VRRP, cloud-safe default) with a HAProxy health-check so the VIP fails over automatically when HAProxy drops, and the tab shows live MASTER/BACKUP per node. Fully **opt-in and backward compatible** — nodes/clusters without a VIP are untouched, and a node already running a hand-managed Keepalived is detected and never overwritten (reported as "externally managed"). Pending VIP changes can be **Rejected** to fully restore the last applied state. Keepalived is installed across the major distros (Debian/Ubuntu, RHEL/CentOS/Alma/Rocky, Fedora, SUSE/openSUSE, Alpine), and live MASTER/BACKUP detection works across distros/init systems (journald + log files + portable interface-based detection). On-prem/L2 scope (VRRP); a clear in-UI notice covers the cloud caveat. Adds two new tables (`vip_instances`, `vip_members`) — `SCHEMA_VERSION` bumps to 3 (idempotent re-run; existing data and passwords unaffected).
|
||||
- **v1.6.5** (2026-06-02) — Security: re-pinned the bundled nginx reverse-proxy image to `nginx:1.31.1-alpine` (mainline patched release) for the nginx "poolslip" advisory (fixed in mainline 1.31.1+ / stable 1.30.2+). Supersedes the v1.6.4 stable pin. No config, schema, or behavior changes.
|
||||
- **v1.6.4** (2026-06-02) — Security: pinned the bundled nginx reverse-proxy image to a patched stable release (`nginx:1.30.2-alpine`) for the nginx "poolslip" advisory (mainline ≤ 1.31.0 affected; fixed in stable 1.30.2+). The product's nginx config uses no `rewrite` capture groups, so the config-level mitigation did not apply — the fix is the version pin. No config, schema, or behavior changes.
|
||||
- **v1.6.3** (2026-06-01) — Bugfix: a backend **server** toggled OFF (`is_active=false`) disappeared from the UI with no way to reactivate it. `GET /api/backends` now honors `include_inactive` for servers (previously only backends), so disabled servers stay visible with an OFF switch + "Inactive" tag and can be re-enabled; soft-deleted (pending-delete) servers stay hidden. A Reject of a server toggle now correctly rolls back `is_active` (entity snapshot). Startup migrations are hardened for multiple replicas / rolling deploys (advisory lock + schema-version gate, so an already-current schema isn't re-migrated under a serving peer's load). Version is reported consistently across all layers. No schema changes; config generation unchanged (disabled servers stay commented out).
|
||||
- **v1.6.2** (2026-05-30) — Bugfixes: (1) agents (which authenticate with their `X-API-Key` token) could not reach `GET /api/clusters` / `/api/clusters/{id}` after the v1.5.x cluster-read hardening, breaking agent assignment ("401: Authorization header missing"); these endpoints now accept either a user JWT or an agent token (anonymous access is still rejected). (2) The uninstall-script generator returned 400 for macOS agents (which report platform `darwin`); it now normalizes the platform the same way the install generator does. No UI or schema changes.
|
||||
- **v1.6.1** (2026-05-21) — Security patch: bump `axios` to 1.16.x (prototype-pollution hardening, header-injection fix, keep-alive memory leak fix) and `fast-uri` to 3.1.2 (GHSA-v39h-62p7-jpjc). No functional changes.
|
||||
|
||||
For full release notes and the list of features delivered in each version (v1.5.x Site Wizard + ACME Diagnostic Panel, v1.4.0 ACME stability + enterprise audit, v1.3.0, ...) see the [GitHub Releases](https://github.com/taylanbakircioglu/haproxy-openmanager/releases) page.
|
||||
|
||||
---
|
||||
|
||||
@@ -1,3 +1,31 @@
|
||||
# Upgrade Notes — v1.7.0 (HA / VIP Keepalived management, Issue #27)
|
||||
|
||||
**Backward compatible & opt-in.** Upgrading to v1.7.0 changes nothing for existing
|
||||
clusters/agents until you create a VIP:
|
||||
|
||||
- **Schema:** `SCHEMA_VERSION` bumps to `3`, so on first start the (idempotent)
|
||||
migration sequence re-runs once and adds two **new** tables (`vip_instances`,
|
||||
`vip_members`) plus an additive `vip_instances.applied_snapshot` column (enables
|
||||
rejecting a pending VIP change and restoring the previous applied state). No existing
|
||||
table is altered. Existing rows and the **admin password
|
||||
are not reset** (default users are create-if-missing). The only data effect is that
|
||||
the **four built-in system roles** (`super_admin`/`operator`/`security_admin`/`viewer`)
|
||||
are re-seeded to their canonical permission sets **plus** the new `vip.*` permissions —
|
||||
this is the long-standing behavior of the role seeder; **custom roles are untouched**.
|
||||
- **Agents:** the agent script gains an opt-in keepalived deploy that is a **no-op** on
|
||||
any node without an applied VIP, and it **never overwrites a hand-managed
|
||||
`/etc/keepalived/keepalived.conf`** (it reports "externally managed" instead).
|
||||
- **Scope:** VRRP VIPs target bare-metal / VMware / on-prem L2 networks. On AWS/Azure/GCP
|
||||
the cloud fabric doesn't honor VRRP/gratuitous-ARP; the UI surfaces this. Ensure host
|
||||
firewalls permit VRRP (IP protocol 112).
|
||||
- **Optional env:** `VIP_ENCRYPTION_KEY` (see `.env.template`) — if unset, the VRRP secret
|
||||
encryption key is derived from `SECRET_KEY` (like MFA).
|
||||
|
||||
No rollback steps are required to *disable* the feature: simply don't create VIPs (or
|
||||
delete them — agents tear down their managed keepalived on the next poll).
|
||||
|
||||
---
|
||||
|
||||
# Agent Upgrade Guide - Dashboard Stats Fix
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -50,9 +50,62 @@ async def ensure_agents_table():
|
||||
await conn.execute("ALTER TYPE config_status ADD VALUE IF NOT EXISTS 'DELETION';")
|
||||
logger.info("Ensured REJECTED and DELETION values exist in config_status enum.")
|
||||
|
||||
# First, create essential tables if they don't exist
|
||||
await create_essential_tables(conn)
|
||||
|
||||
# First, create essential tables if they don't exist.
|
||||
#
|
||||
# Rolling-restart resilience: create_essential_tables() runs idempotent
|
||||
# CREATE ... IF NOT EXISTS statements on every startup. Its CREATE INDEX
|
||||
# block needs a SHARE lock that conflicts with concurrent writes (e.g.
|
||||
# agent heartbeats updating backend_servers/agents). During a redeploy a
|
||||
# writer can hold that lock, so the DDL blocked for the full 60s
|
||||
# command_timeout -> TimeoutError -> startup crash -> crash-loop.
|
||||
#
|
||||
# Fix: fail fast on locks (lock_timeout), retry briefly, and on
|
||||
# persistent contention SKIP the idempotent bootstrap and continue — on
|
||||
# an established DB the objects already exist; a fresh DB has no writers
|
||||
# so the first attempt always succeeds. lock_timeout is scoped to this
|
||||
# call and RESET afterwards, so every other migration below keeps its
|
||||
# original (wait-indefinitely) behavior. Non-lock errors still propagate
|
||||
# (genuine schema problems must NOT be masked).
|
||||
import asyncio as _asyncio
|
||||
_lock_excs = (_asyncio.TimeoutError,)
|
||||
try:
|
||||
import asyncpg as _asyncpg
|
||||
_lock_excs = _lock_excs + (
|
||||
_asyncpg.exceptions.LockNotAvailableError,
|
||||
_asyncpg.exceptions.QueryCanceledError,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await conn.execute("SET lock_timeout = '10s'")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
for _attempt in range(1, 4):
|
||||
try:
|
||||
await create_essential_tables(conn)
|
||||
break
|
||||
except _lock_excs as _lock_err:
|
||||
if _attempt < 3:
|
||||
logger.warning(
|
||||
f"create_essential_tables: lock contention "
|
||||
f"(attempt {_attempt}/3), retrying in 3s "
|
||||
f"({type(_lock_err).__name__})"
|
||||
)
|
||||
await _asyncio.sleep(3)
|
||||
else:
|
||||
logger.warning(
|
||||
"create_essential_tables: persistent lock contention; "
|
||||
"skipping idempotent schema bootstrap and continuing "
|
||||
"startup (objects already exist on an established DB) "
|
||||
f"({type(_lock_err).__name__})"
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
await conn.execute("RESET lock_timeout")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Ensure status column exists in config_versions table
|
||||
status_column_exists = await conn.fetchval("""
|
||||
SELECT 1 FROM information_schema.columns
|
||||
@@ -431,6 +484,7 @@ async def ensure_agents_table():
|
||||
connection_type VARCHAR(50) DEFAULT 'agent',
|
||||
stats_socket_path VARCHAR(500) DEFAULT '/run/haproxy/admin.sock',
|
||||
haproxy_config_path VARCHAR(500) DEFAULT '/etc/haproxy/haproxy.cfg',
|
||||
keepalived_config_path VARCHAR(500) DEFAULT '/etc/keepalived/keepalived.conf',
|
||||
pool_id INTEGER REFERENCES haproxy_cluster_pools(id) ON DELETE SET NULL,
|
||||
haproxy_user VARCHAR(255) DEFAULT 'haproxy',
|
||||
haproxy_group VARCHAR(255) DEFAULT 'haproxy',
|
||||
@@ -1298,6 +1352,7 @@ async def update_system_roles_to_enterprise_rbac():
|
||||
'apply.read', 'apply.execute', 'apply.reject', 'apply.history', 'apply.bulk', 'apply.emergency',
|
||||
'agents.read', 'agents.create', 'agents.update', 'agents.delete', 'agents.script', 'agents.toggle', 'agents.upgrade', 'agents.version', 'agents.logs',
|
||||
'clusters.read', 'clusters.create', 'clusters.update', 'clusters.delete', 'clusters.switch', 'clusters.config',
|
||||
'vip.read', 'vip.create', 'vip.update', 'vip.delete', 'vip.apply',
|
||||
'config.read', 'config.update', 'config.download', 'config.upload', 'config.backup', 'config.restore', 'config.history', 'config.bulk_import', 'config.view_request', 'config.download_request',
|
||||
'users.read', 'users.create', 'users.update', 'users.delete', 'users.password', 'users.roles',
|
||||
'roles.read', 'roles.create', 'roles.update', 'roles.delete', 'roles.permissions',
|
||||
@@ -1319,6 +1374,7 @@ async def update_system_roles_to_enterprise_rbac():
|
||||
'apply.read', 'apply.execute', 'apply.reject', 'apply.history', 'apply.bulk',
|
||||
'agents.read', 'agents.update', 'agents.toggle', 'agents.upgrade', 'agents.version', 'agents.logs',
|
||||
'clusters.read', 'clusters.switch', 'clusters.config',
|
||||
'vip.read', 'vip.create', 'vip.update', 'vip.delete', 'vip.apply',
|
||||
'config.read', 'config.update', 'config.download', 'config.history', 'config.bulk_import', 'config.view_request', 'config.download_request',
|
||||
'statistics.read', 'statistics.performance', 'statistics.agents', 'statistics.health',
|
||||
'activity.read'
|
||||
@@ -1336,6 +1392,7 @@ async def update_system_roles_to_enterprise_rbac():
|
||||
'apply.read', 'apply.execute', 'apply.reject', 'apply.history',
|
||||
'agents.read', 'agents.version', 'agents.logs',
|
||||
'clusters.read', 'clusters.switch',
|
||||
'vip.read',
|
||||
'config.read', 'config.history', 'config.view_request', 'config.download_request',
|
||||
'statistics.read', 'statistics.performance', 'statistics.agents', 'statistics.health',
|
||||
'activity.read', 'activity.all', 'activity.export',
|
||||
@@ -1354,6 +1411,7 @@ async def update_system_roles_to_enterprise_rbac():
|
||||
'apply.read', 'apply.history',
|
||||
'agents.read',
|
||||
'clusters.read', 'clusters.switch',
|
||||
'vip.read',
|
||||
'config.read', 'config.history', 'config.view_request',
|
||||
'statistics.read', 'statistics.performance', 'statistics.agents', 'statistics.health',
|
||||
'activity.read',
|
||||
@@ -1639,13 +1697,126 @@ async def ensure_agent_activity_logs_table():
|
||||
await close_database_connection(conn)
|
||||
# Don't raise - this is not critical for system operation
|
||||
|
||||
# Schema-version gate for the migration runner.
|
||||
#
|
||||
# >>> BUMP THIS whenever you add/modify ANY step in _run_all_migrations_inner()
|
||||
# >>> that changes the schema (table/column/index/constraint) OR seeded/role data
|
||||
# >>> (e.g. update_system_roles_to_enterprise_rbac). Otherwise the new step will
|
||||
# >>> NOT run on databases already marked at the current version.
|
||||
#
|
||||
# When the DB already records >= this version, run_all_migrations() skips the
|
||||
# whole (lock-heavy) idempotent sequence, so redeploys/scale-ups issue NO DDL and
|
||||
# a concurrently-serving replica's traffic cannot block ALTER / CREATE INDEX (the
|
||||
# rolling-deploy startup crash that motivated this gate).
|
||||
#
|
||||
# Backward compatibility (the product runs at many versions across companies):
|
||||
# - First start on this code: no marker -> applied_version is NULL -> the FULL
|
||||
# sequence runs (upgrades any prior version), THEN the marker is written. So
|
||||
# upgrading from any older version is unaffected.
|
||||
# - The marker is written ONLY after _run_all_migrations_inner() completes with
|
||||
# no exception, so an interrupted/failed migration never marks an incomplete
|
||||
# schema as done — the next start retries.
|
||||
# - Behavior change vs the historical "re-run every idempotent ensure_* on every
|
||||
# start": once marked, same-version restarts no longer re-run (and therefore no
|
||||
# longer auto-repair manual drift). To force a re-run, bump SCHEMA_VERSION or
|
||||
# delete the schema_migrations row.
|
||||
#
|
||||
# v1.7.0 (Issue #27 — HA/VIP Keepalived management): bumped 1 -> 2 so the new
|
||||
# additive ensure_vip_tables() step (two brand-new tables) actually runs on
|
||||
# databases already marked at version 1. The whole re-run is idempotent.
|
||||
# v1.7.0 self-review: bumped 2 -> 3 so the additive `applied_snapshot` column on
|
||||
# vip_instances (enables VIP reject/restore-to-previous) lands on DBs marked at 2.
|
||||
# v1.7.0 self-review: bumped 3 -> 4 for the additive `keepalived_config_path` column on
|
||||
# haproxy_clusters (cluster-driven keepalived.conf path, like haproxy_config_path).
|
||||
# v1.7.0 self-review: bumped 4 -> 5 to drop the table-level UNIQUE on vip_instances.name
|
||||
# and replace it with a partial unique index (active rows only), so a soft-deleted VIP's
|
||||
# name is reusable — consistent with the address/VRID partial indexes. Idempotent re-run.
|
||||
# v1.7.2: bumped 5 -> 6 for the additive `purge_on_teardown` column on vip_instances
|
||||
# (opt-in "also uninstall the keepalived package on delete"; default FALSE keeps the safe
|
||||
# graceful-teardown behaviour). Additive + idempotent.
|
||||
# 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).
|
||||
# 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.
|
||||
SCHEMA_VERSION = 8
|
||||
|
||||
|
||||
async def run_all_migrations():
|
||||
"""Run all database migrations"""
|
||||
"""Run all database migrations.
|
||||
|
||||
Hardened for multiple backend replicas / rolling deploys:
|
||||
- A session-level advisory lock serializes the run so only one pod migrates
|
||||
at a time (others wait, then hit the version gate and skip). It is
|
||||
session-scoped, so it auto-releases if a pod dies mid-migration.
|
||||
- A schema-version marker (schema_migrations) gates the run: when the DB is
|
||||
already at SCHEMA_VERSION the whole idempotent sequence is skipped, so no
|
||||
DDL is issued and a serving replica's traffic can't block it.
|
||||
If the advisory lock or marker can't be used, we fall back to running the
|
||||
(idempotent) migrations rather than crashing startup.
|
||||
"""
|
||||
logger.info("Starting database migrations...")
|
||||
|
||||
MIGRATION_ADVISORY_LOCK_KEY = 1836016242 # single-key advisory space ("migr"); distinct from the (ns,id) locks used elsewhere
|
||||
lock_conn = None
|
||||
lock_acquired = False
|
||||
try:
|
||||
lock_conn = await get_database_connection()
|
||||
try:
|
||||
await lock_conn.execute("SELECT pg_advisory_lock($1)", MIGRATION_ADVISORY_LOCK_KEY)
|
||||
lock_acquired = True
|
||||
logger.info("Acquired migration advisory lock (migrations serialized across pods)")
|
||||
except Exception as _lock_e:
|
||||
logger.warning(f"Could not acquire migration advisory lock; proceeding (migrations are idempotent): {_lock_e}")
|
||||
|
||||
# Schema-version gate: skip the lock-heavy sequence if the DB is current.
|
||||
applied_version = None
|
||||
try:
|
||||
await lock_conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||
version INTEGER NOT NULL,
|
||||
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT schema_migrations_singleton CHECK (id = 1)
|
||||
)
|
||||
""")
|
||||
applied_version = await lock_conn.fetchval("SELECT version FROM schema_migrations WHERE id = 1")
|
||||
except Exception as _mk_e:
|
||||
logger.warning(f"schema_migrations marker unavailable; running full migrations: {_mk_e}")
|
||||
applied_version = None
|
||||
|
||||
if applied_version is not None and applied_version >= SCHEMA_VERSION:
|
||||
logger.info(f"Schema already at version {applied_version} (>= {SCHEMA_VERSION}); skipping migration run.")
|
||||
return
|
||||
|
||||
await _run_all_migrations_inner()
|
||||
|
||||
try:
|
||||
await lock_conn.execute("""
|
||||
INSERT INTO schema_migrations (id, version, applied_at)
|
||||
VALUES (1, $1, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (id) DO UPDATE SET version = EXCLUDED.version, applied_at = EXCLUDED.applied_at
|
||||
""", SCHEMA_VERSION)
|
||||
logger.info(f"Recorded schema version {SCHEMA_VERSION} in schema_migrations.")
|
||||
except Exception as _wr_e:
|
||||
logger.warning(f"Could not record schema version marker (migrations still applied): {_wr_e}")
|
||||
finally:
|
||||
if lock_acquired and lock_conn is not None:
|
||||
try:
|
||||
await lock_conn.execute("SELECT pg_advisory_unlock($1)", MIGRATION_ADVISORY_LOCK_KEY)
|
||||
except Exception:
|
||||
pass
|
||||
if lock_conn is not None:
|
||||
await close_database_connection(lock_conn)
|
||||
|
||||
|
||||
async def _run_all_migrations_inner():
|
||||
"""The full idempotent migration sequence. Runs under the migration advisory
|
||||
lock and is gated by the schema-version marker in run_all_migrations()."""
|
||||
# First, ensure basic database schema exists
|
||||
await run_init_sql()
|
||||
|
||||
|
||||
# Then run additional migrations
|
||||
await ensure_agents_table()
|
||||
await ensure_config_versions_metadata_column()
|
||||
@@ -1684,6 +1855,9 @@ async def run_all_migrations():
|
||||
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)
|
||||
@@ -1703,6 +1877,10 @@ async def run_all_migrations():
|
||||
# Issue #18 — TOTP MFA (v1.6.0): additive columns + 3 new tables
|
||||
await ensure_mfa_columns()
|
||||
|
||||
# Issue #27 — HA/VIP Keepalived management (v1.7.0): two brand-new tables.
|
||||
# MUST stay last: FK-references haproxy_cluster_pools/agents/users, all created above.
|
||||
await ensure_vip_tables()
|
||||
|
||||
logger.info("Database migrations completed successfully.")
|
||||
|
||||
|
||||
@@ -1781,6 +1959,127 @@ async def ensure_mfa_columns():
|
||||
if conn:
|
||||
await close_database_connection(conn)
|
||||
|
||||
async def ensure_vip_tables():
|
||||
"""Issue #27 — HA/VIP (Keepalived) management (v1.7.0). Additive only:
|
||||
two brand-new tables (vip_instances, vip_members) + indexes. No ALTER of any
|
||||
existing table, so the entire current fleet is byte-identical. Fully idempotent
|
||||
(CREATE TABLE/INDEX IF NOT EXISTS). FK targets (haproxy_cluster_pools, agents,
|
||||
users) are created earlier in the sequence — this function is registered LAST.
|
||||
|
||||
Backward-compat: a cluster/agent with no VIP row is unaffected; the agent
|
||||
delivery endpoint returns 'not_configured' for every node without a membership.
|
||||
"""
|
||||
conn = None
|
||||
try:
|
||||
conn = await get_database_connection()
|
||||
|
||||
# Cluster-driven keepalived.conf path (mirrors haproxy_config_path): additive +
|
||||
# idempotent, with a universal default so operators need set nothing. The agent
|
||||
# pulls this from its cluster, exactly like the HAProxy paths.
|
||||
await conn.execute(
|
||||
"ALTER TABLE haproxy_clusters ADD COLUMN IF NOT EXISTS keepalived_config_path "
|
||||
"VARCHAR(500) DEFAULT '/etc/keepalived/keepalived.conf';")
|
||||
|
||||
# VIP instance: one row per virtual IP (one VRRP group), anchored to a pool.
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS vip_instances (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
pool_id INTEGER NOT NULL REFERENCES haproxy_cluster_pools(id) ON DELETE CASCADE,
|
||||
virtual_ip VARCHAR(45) NOT NULL,
|
||||
prefix_length INTEGER NOT NULL DEFAULT 24,
|
||||
virtual_router_id INTEGER NOT NULL,
|
||||
advert_int INTEGER NOT NULL DEFAULT 1,
|
||||
auth_pass_encrypted TEXT,
|
||||
use_unicast BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
track_haproxy BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
last_config_status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
|
||||
applied_snapshot JSONB,
|
||||
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT vip_vrid_range CHECK (virtual_router_id BETWEEN 1 AND 255)
|
||||
);
|
||||
""")
|
||||
# Additive (idempotent) for DBs that created vip_instances before applied_snapshot
|
||||
# existed (v1.7.0 self-review): holds the field-level state as of the last Apply so
|
||||
# a pending edit can be rejected and fully reverted to the previous applied state.
|
||||
await conn.execute("ALTER TABLE vip_instances ADD COLUMN IF NOT EXISTS applied_snapshot JSONB;")
|
||||
# Opt-in package removal (v1.7.2): when an operator deletes a VIP and explicitly ticks
|
||||
# "also uninstall keepalived from the node(s)", we set this flag so the teardown
|
||||
# delivery tells the agent to purge the OS package. Default FALSE = the safe enterprise
|
||||
# default (stop+disable+remove our config, but KEEP the package). Additive + idempotent;
|
||||
# a node we never managed stays untouched regardless.
|
||||
await conn.execute("ALTER TABLE vip_instances ADD COLUMN IF NOT EXISTS purge_on_teardown BOOLEAN NOT NULL DEFAULT FALSE;")
|
||||
# Approval-gated deletion (v1.7.2): deleting a RUNNING VIP from the UI does NOT take
|
||||
# effect immediately — it sets pending_delete=TRUE and stages a vip-*-delete version
|
||||
# for Apply Management. The VIP stays is_active=TRUE (agents keep serving it, NOTHING
|
||||
# is torn down) until the operator APPROVES; only then does apply flip is_active=FALSE
|
||||
# and the agents tear down. Reject clears the flag and the VIP keeps running untouched.
|
||||
# This guarantees an agent never tears a VIP down without an explicit human approval —
|
||||
# protecting production. Additive + idempotent.
|
||||
await conn.execute("ALTER TABLE vip_instances ADD COLUMN IF NOT EXISTS pending_delete BOOLEAN NOT NULL DEFAULT FALSE;")
|
||||
# Uniqueness as PARTIAL indexes on active rows so a soft-deleted VIP frees its
|
||||
# name/address/VRID for immediate reuse (a table-level UNIQUE would keep blocking it).
|
||||
# NAME (v1.7.0 self-review): the original CREATE used a table-level UNIQUE on name,
|
||||
# which left a soft-deleted VIP's name blocked (you couldn't re-create a VIP with the
|
||||
# same name) — inconsistent with addr/VRID. Drop that constraint and use a partial
|
||||
# index instead. Idempotent: no-op on a fresh table (no inline UNIQUE) and on re-run.
|
||||
await conn.execute("ALTER TABLE vip_instances DROP CONSTRAINT IF EXISTS vip_instances_name_key;")
|
||||
await conn.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_vip_name_active ON vip_instances(name) WHERE is_active=TRUE;"
|
||||
)
|
||||
await conn.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_vip_addr_active ON vip_instances(virtual_ip) WHERE is_active=TRUE;"
|
||||
)
|
||||
await conn.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_vip_vrid_active ON vip_instances(pool_id, virtual_router_id) WHERE is_active=TRUE;"
|
||||
)
|
||||
|
||||
# Per-node membership: which agents participate + their VRRP role/priority,
|
||||
# the applied (delivered) config snapshot, and the agent's deploy ack.
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS vip_members (
|
||||
id SERIAL PRIMARY KEY,
|
||||
vip_id INTEGER NOT NULL REFERENCES vip_instances(id) ON DELETE CASCADE,
|
||||
agent_id INTEGER NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||||
network_interface VARCHAR(64) NOT NULL,
|
||||
role VARCHAR(10) NOT NULL DEFAULT 'BACKUP',
|
||||
priority INTEGER NOT NULL DEFAULT 100,
|
||||
applied_config_content TEXT,
|
||||
applied_config_hash VARCHAR(64),
|
||||
last_deploy_state VARCHAR(24),
|
||||
last_deploy_message TEXT,
|
||||
last_deploy_hash VARCHAR(64),
|
||||
last_deploy_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT vip_member_role CHECK (role IN ('MASTER','BACKUP')),
|
||||
CONSTRAINT vip_member_priority_range CHECK (priority BETWEEN 1 AND 254),
|
||||
CONSTRAINT vip_member_unique UNIQUE (vip_id, agent_id)
|
||||
);
|
||||
""")
|
||||
# Last line of defense against split-brain: at most one MASTER per VIP.
|
||||
await conn.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_vip_one_master ON vip_members(vip_id) WHERE role='MASTER';"
|
||||
)
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_vip_members_vip ON vip_members(vip_id);"
|
||||
)
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_vip_members_agent ON vip_members(agent_id);"
|
||||
)
|
||||
|
||||
logger.info("✅ VIP tables ensured (Issue #27 — HA/VIP Keepalived management)")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to ensure VIP tables: {e}")
|
||||
# Don't raise — follow the same defensive pattern as ensure_mfa_columns
|
||||
finally:
|
||||
if conn:
|
||||
await close_database_connection(conn)
|
||||
|
||||
async def add_ssl_certificate_id_to_backend_servers():
|
||||
"""Add ssl_certificate_id column to backend_servers table for SSL certificate management"""
|
||||
conn = None
|
||||
@@ -3357,6 +3656,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)
|
||||
@@ -3372,6 +3688,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
|
||||
|
||||
+67
-8
@@ -8,7 +8,7 @@ import redis
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
_version_info = {"version": "1.6.0", "releaseName": "Multi-Factor Authentication (MFA)", "releaseDate": "2026-05-18"}
|
||||
_version_info = {"version": "1.8.0", "releaseName": "ACME DNS-01 challenge support", "releaseDate": "2026-06-23"}
|
||||
for _vpath in ["/app/version.json", os.path.join(os.path.dirname(__file__), "..", "version.json")]:
|
||||
try:
|
||||
with open(_vpath) as _vf:
|
||||
@@ -41,6 +41,7 @@ from routers.letsencrypt import router as letsencrypt_router
|
||||
from routers.acme_diagnostics import router as acme_diagnostics_router
|
||||
from routers.site_wizard import router as site_wizard_router
|
||||
from routers.mfa import router as mfa_router
|
||||
from routers.vip import router as vip_router # Issue #27 — HA/VIP (Keepalived) management
|
||||
|
||||
# Production logging configuration
|
||||
from utils.logging_config import setup_production_logging
|
||||
@@ -303,6 +304,22 @@ 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')
|
||||
@@ -336,9 +353,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')
|
||||
|
||||
@@ -351,12 +376,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.
|
||||
@@ -672,7 +706,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:
|
||||
@@ -688,15 +724,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}")
|
||||
|
||||
@@ -836,6 +894,7 @@ app.include_router(settings_router)
|
||||
app.include_router(letsencrypt_router)
|
||||
app.include_router(acme_diagnostics_router) # v1.5.0 Issue #13: ACME Diagnostic Panel
|
||||
app.include_router(site_wizard_router) # v1.5.0 Issue #14: New Site Setup Wizard
|
||||
app.include_router(vip_router) # v1.7.0 Issue #27: HA/VIP (Keepalived) management
|
||||
|
||||
|
||||
# Legacy URL alias: /api/proxied-hosts/* → 308 redirect to /api/sites/*.
|
||||
@@ -1053,7 +1112,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:
|
||||
|
||||
@@ -8,6 +8,7 @@ class HAProxyClusterCreate(BaseModel):
|
||||
stats_socket_path: str = "/run/haproxy/admin.sock"
|
||||
haproxy_config_path: str = "/etc/haproxy/haproxy.cfg"
|
||||
haproxy_bin_path: str = "/usr/sbin/haproxy" # HAProxy binary path
|
||||
keepalived_config_path: str = "/etc/keepalived/keepalived.conf" # HA/VIP: keepalived.conf path (Issue #27)
|
||||
pool_id: Optional[int] = None # Which pool this cluster belongs to
|
||||
|
||||
class HAProxyClusterUpdate(BaseModel):
|
||||
@@ -17,6 +18,7 @@ class HAProxyClusterUpdate(BaseModel):
|
||||
stats_socket_path: Optional[str] = None
|
||||
haproxy_config_path: Optional[str] = None
|
||||
haproxy_bin_path: Optional[str] = None
|
||||
keepalived_config_path: Optional[str] = None
|
||||
pool_id: Optional[int] = None
|
||||
is_active: Optional[bool] = None
|
||||
acme_enabled: Optional[bool] = None
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Issue #27 — HA/VIP (Keepalived) management (v1.7.0).
|
||||
|
||||
Pydantic request/response models for the VIP management API. Field validation
|
||||
is strict because several values flow into a generated keepalived.conf and into
|
||||
root-run agent commands — we reuse the same FORBIDDEN-metacharacter discipline as
|
||||
models/agent.py (Bulgu #81) and validate the VIP as a real IPv4 address.
|
||||
|
||||
Pydantic idiom: the project runs pydantic>=2.5; this module uses the v2-native
|
||||
@field_validator/@model_validator style (matching models/ssl.py).
|
||||
"""
|
||||
import ipaddress
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, field_validator, model_validator
|
||||
|
||||
# Shell/keepalived.conf metacharacters that must never appear in a value that
|
||||
# reaches the generated config or a root-run agent command (mirrors
|
||||
# models/agent.py:202, the Bulgu #81 convention).
|
||||
_FORBIDDEN = set('$`;&|<>"\'\\\n\r\x00*?')
|
||||
|
||||
|
||||
def _validate_iface(v: str) -> str:
|
||||
if not isinstance(v, str) or not v.strip():
|
||||
raise ValueError('network_interface must be a non-empty string')
|
||||
s = v.strip()
|
||||
if len(s) > 64:
|
||||
raise ValueError('network_interface too long (max 64 chars)')
|
||||
if any(c in _FORBIDDEN for c in s):
|
||||
raise ValueError('network_interface contains a forbidden character')
|
||||
# Linux iface names: letters, digits, and . _ - : @ (vlans/aliases/altnames)
|
||||
import re as _re
|
||||
if not _re.match(r'^[A-Za-z0-9][A-Za-z0-9._:@-]{0,63}$', s):
|
||||
raise ValueError(
|
||||
'network_interface must start alphanumeric and contain only '
|
||||
'letters, digits, and . _ - : @'
|
||||
)
|
||||
return s
|
||||
|
||||
|
||||
def _validate_ipv4(v: str) -> str:
|
||||
if not isinstance(v, str) or not v.strip():
|
||||
raise ValueError('virtual_ip must be a non-empty string')
|
||||
s = v.strip()
|
||||
try:
|
||||
addr = ipaddress.ip_address(s)
|
||||
except ValueError:
|
||||
raise ValueError(f'virtual_ip={v!r} is not a valid IP address')
|
||||
if addr.version != 4:
|
||||
raise ValueError('virtual_ip must be IPv4 — IPv6 VIPs are not supported yet')
|
||||
return s
|
||||
|
||||
|
||||
class VIPMemberIn(BaseModel):
|
||||
agent_id: int
|
||||
network_interface: str
|
||||
role: str = 'BACKUP'
|
||||
priority: int = 100
|
||||
|
||||
@field_validator('network_interface')
|
||||
@classmethod
|
||||
def _iface(cls, v):
|
||||
return _validate_iface(v)
|
||||
|
||||
@field_validator('role')
|
||||
@classmethod
|
||||
def _role(cls, v):
|
||||
u = (v or '').strip().upper()
|
||||
if u not in ('MASTER', 'BACKUP'):
|
||||
raise ValueError("role must be 'MASTER' or 'BACKUP'")
|
||||
return u
|
||||
|
||||
@field_validator('priority')
|
||||
@classmethod
|
||||
def _priority(cls, v):
|
||||
if not isinstance(v, int) or not (1 <= v <= 254):
|
||||
raise ValueError('priority must be an integer between 1 and 254')
|
||||
return v
|
||||
|
||||
|
||||
def _validate_members(members: List['VIPMemberIn']) -> List['VIPMemberIn']:
|
||||
# >=1 node: a single-node VIP is a keepalived-managed floating IP without failover
|
||||
# (valid, e.g. a one-box HAProxy that wants a stable VIP, or before a 2nd node is added).
|
||||
# Two or more nodes give actual VRRP failover. The UI flags the single-node case.
|
||||
if not members:
|
||||
raise ValueError('a VIP needs at least 1 member node')
|
||||
agent_ids = [m.agent_id for m in members]
|
||||
if len(set(agent_ids)) != len(agent_ids):
|
||||
raise ValueError('each node may appear at most once in a VIP')
|
||||
masters = [m for m in members if m.role == 'MASTER']
|
||||
if len(masters) != 1:
|
||||
raise ValueError('exactly one member must be MASTER')
|
||||
master_prio = masters[0].priority
|
||||
if any(m.role == 'BACKUP' and m.priority >= master_prio for m in members):
|
||||
raise ValueError('the MASTER must have a strictly higher priority than every BACKUP')
|
||||
return members
|
||||
|
||||
|
||||
def _validate_auth_pass(v: Optional[str]) -> Optional[str]:
|
||||
if v is None or v == '':
|
||||
return None
|
||||
# keepalived PASS auth_pass is silently truncated to 8 chars (B-3) — reject longer
|
||||
# so MASTER/BACKUP never silently disagree.
|
||||
if not (1 <= len(v) <= 8):
|
||||
raise ValueError('auth_pass must be 1-8 characters (keepalived PASS limit)')
|
||||
if any(c in _FORBIDDEN for c in v):
|
||||
raise ValueError('auth_pass contains a forbidden character')
|
||||
# No whitespace: keepalived PASS auth_pass is a single token, and a whitespace-containing
|
||||
# secret would only partially redact in the masked config diff (review HIGH-2).
|
||||
if any(c.isspace() for c in v):
|
||||
raise ValueError('auth_pass must not contain whitespace')
|
||||
return v
|
||||
|
||||
|
||||
class VIPCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
pool_id: int
|
||||
virtual_ip: str
|
||||
prefix_length: int = 24
|
||||
virtual_router_id: Optional[int] = None # auto-allocated within the pool when omitted
|
||||
advert_int: int = 1
|
||||
auth_pass: Optional[str] = None # plaintext on the wire; stored Fernet-encrypted
|
||||
use_unicast: bool = True
|
||||
track_haproxy: bool = True
|
||||
members: List[VIPMemberIn]
|
||||
|
||||
@field_validator('name')
|
||||
@classmethod
|
||||
def _name(cls, v):
|
||||
if not isinstance(v, str) or not v.strip():
|
||||
raise ValueError('name must be a non-empty string')
|
||||
s = v.strip()
|
||||
if len(s) > 255:
|
||||
raise ValueError('name too long (max 255 chars)')
|
||||
if any(c in _FORBIDDEN for c in s):
|
||||
raise ValueError('name contains a forbidden character')
|
||||
return s
|
||||
|
||||
@field_validator('virtual_ip')
|
||||
@classmethod
|
||||
def _vip(cls, v):
|
||||
return _validate_ipv4(v)
|
||||
|
||||
@field_validator('prefix_length')
|
||||
@classmethod
|
||||
def _prefix(cls, v):
|
||||
if not isinstance(v, int) or not (1 <= v <= 32):
|
||||
raise ValueError('prefix_length must be an integer between 1 and 32 (IPv4)')
|
||||
return v
|
||||
|
||||
@field_validator('virtual_router_id')
|
||||
@classmethod
|
||||
def _vrid(cls, v):
|
||||
if v is None:
|
||||
return v
|
||||
if not isinstance(v, int) or not (1 <= v <= 255):
|
||||
raise ValueError('virtual_router_id must be an integer between 1 and 255')
|
||||
return v
|
||||
|
||||
@field_validator('advert_int')
|
||||
@classmethod
|
||||
def _advert(cls, v):
|
||||
if not isinstance(v, int) or not (1 <= v <= 255):
|
||||
raise ValueError('advert_int must be an integer between 1 and 255 (seconds)')
|
||||
return v
|
||||
|
||||
@field_validator('auth_pass')
|
||||
@classmethod
|
||||
def _auth(cls, v):
|
||||
return _validate_auth_pass(v)
|
||||
|
||||
@model_validator(mode='after')
|
||||
def _members_consistent(self):
|
||||
_validate_members(self.members)
|
||||
return self
|
||||
|
||||
|
||||
class VIPUpdate(BaseModel):
|
||||
"""All fields optional — only provided fields are changed. Any change sets the
|
||||
VIP back to last_config_status='PENDING' (router-side)."""
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
virtual_ip: Optional[str] = None
|
||||
prefix_length: Optional[int] = None
|
||||
virtual_router_id: Optional[int] = None
|
||||
advert_int: Optional[int] = None
|
||||
auth_pass: Optional[str] = None # provide only to rotate; omit to keep existing
|
||||
use_unicast: Optional[bool] = None
|
||||
track_haproxy: Optional[bool] = None
|
||||
members: Optional[List[VIPMemberIn]] = None
|
||||
|
||||
@field_validator('name')
|
||||
@classmethod
|
||||
def _name(cls, v):
|
||||
if v is None:
|
||||
return v
|
||||
if not v.strip():
|
||||
raise ValueError('name must be a non-empty string')
|
||||
s = v.strip()
|
||||
if len(s) > 255 or any(c in _FORBIDDEN for c in s):
|
||||
raise ValueError('name invalid (too long or forbidden character)')
|
||||
return s
|
||||
|
||||
@field_validator('virtual_ip')
|
||||
@classmethod
|
||||
def _vip(cls, v):
|
||||
return _validate_ipv4(v) if v is not None else v
|
||||
|
||||
@field_validator('prefix_length')
|
||||
@classmethod
|
||||
def _prefix(cls, v):
|
||||
if v is None:
|
||||
return v
|
||||
if not (1 <= v <= 32):
|
||||
raise ValueError('prefix_length must be 1-32 (IPv4)')
|
||||
return v
|
||||
|
||||
@field_validator('virtual_router_id')
|
||||
@classmethod
|
||||
def _vrid(cls, v):
|
||||
if v is None:
|
||||
return v
|
||||
if not (1 <= v <= 255):
|
||||
raise ValueError('virtual_router_id must be 1-255')
|
||||
return v
|
||||
|
||||
@field_validator('advert_int')
|
||||
@classmethod
|
||||
def _advert(cls, v):
|
||||
if v is None:
|
||||
return v
|
||||
if not (1 <= v <= 255):
|
||||
raise ValueError('advert_int must be 1-255 seconds')
|
||||
return v
|
||||
|
||||
@field_validator('auth_pass')
|
||||
@classmethod
|
||||
def _auth(cls, v):
|
||||
return _validate_auth_pass(v)
|
||||
|
||||
@model_validator(mode='after')
|
||||
def _members_consistent(self):
|
||||
if self.members is not None:
|
||||
_validate_members(self.members)
|
||||
return self
|
||||
@@ -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(
|
||||
|
||||
+195
-13
@@ -730,14 +730,15 @@ async def generate_uninstall_script(platform: str, authorization: str = Header(N
|
||||
```
|
||||
"""
|
||||
try:
|
||||
# Validate platform
|
||||
platform_lower = platform.lower()
|
||||
if platform_lower not in ['linux', 'macos']:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid platform: {platform}. Must be 'linux' or 'macos'"
|
||||
)
|
||||
|
||||
# Normalize platform to a canonical key (always 'linux' or 'macos').
|
||||
# macOS agents register with platform 'darwin' (from `uname -s`), so the
|
||||
# strict ['linux','macos'] check used to 400 on the UI's uninstall flow
|
||||
# (GET /generate-uninstall-script/darwin). Reuse the same get_platform_key()
|
||||
# helper the install-script generator uses, so darwin/osx/mac and the
|
||||
# linux distro variants all resolve correctly. Backward-compatible:
|
||||
# 'linux'/'macos' still map to themselves.
|
||||
platform_lower = get_platform_key(platform)
|
||||
|
||||
# Read uninstall script from agent_scripts directory (same as install scripts)
|
||||
import os
|
||||
script_filename = f"uninstall-agent-{platform_lower}.sh"
|
||||
@@ -861,7 +862,25 @@ async def delete_agent(agent_id: int, authorization: str = Header(None)):
|
||||
# Validate cluster access if agent belongs to a cluster
|
||||
if agent['cluster_id']:
|
||||
await validate_user_cluster_access(current_user['id'], agent['cluster_id'], conn)
|
||||
|
||||
|
||||
# HA/VIP (Issue #27): block deleting a node that's still a member of an active VIP.
|
||||
# Otherwise the CASCADE would silently drop it from the VIP (breaking the one-MASTER
|
||||
# topology with no signal) and the still-running node would keep advertising the VIP
|
||||
# with no way to be told to tear down (review MED-3). Make the operator remove it
|
||||
# from the VIP first — that stages a clean PENDING change they can apply.
|
||||
try:
|
||||
vip_member = await conn.fetchrow(
|
||||
"SELECT v.name FROM vip_members vm JOIN vip_instances v ON v.id = vm.vip_id "
|
||||
"WHERE vm.agent_id = $1 AND v.is_active = TRUE LIMIT 1", agent_id)
|
||||
except Exception: # noqa: BLE001 — vip_* may not exist on older schemas; don't block delete
|
||||
vip_member = None
|
||||
if vip_member:
|
||||
await close_database_connection(conn)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(f"This node is a member of VIP '{vip_member['name']}'. Remove it from the "
|
||||
f"VIP on the HA / VIP page (and apply) before deleting the agent."))
|
||||
|
||||
await conn.execute("DELETE FROM agents WHERE id = $1", agent_id)
|
||||
|
||||
await close_database_connection(conn)
|
||||
@@ -1749,9 +1768,14 @@ async def agent_heartbeat_by_name(
|
||||
heartbeat_data.operating_system, heartbeat_data.kernel_version,
|
||||
heartbeat_data.uptime, heartbeat_data.cpu_count, heartbeat_data.memory_total,
|
||||
heartbeat_data.disk_space,
|
||||
# Convert lists to JSON for JSONB columns
|
||||
heartbeat_data.network_interfaces if isinstance(heartbeat_data.network_interfaces, str) else json.dumps(heartbeat_data.network_interfaces or []),
|
||||
heartbeat_data.capabilities if isinstance(heartbeat_data.capabilities, str) else json.dumps(heartbeat_data.capabilities or []),
|
||||
# Convert lists to JSON for JSONB columns. Don't WIPE network_interfaces/capabilities
|
||||
# when a heartbeat omits them: send NULL so COALESCE keeps the existing value (a bare
|
||||
# `or []` would store "[]" and erase the reported NICs / keepalived_management on every
|
||||
# daemon heartbeat that doesn't include them — issue #27 corporate test).
|
||||
(heartbeat_data.network_interfaces if isinstance(heartbeat_data.network_interfaces, str)
|
||||
else (json.dumps(heartbeat_data.network_interfaces) if heartbeat_data.network_interfaces else None)),
|
||||
(heartbeat_data.capabilities if isinstance(heartbeat_data.capabilities, str)
|
||||
else (json.dumps(heartbeat_data.capabilities) if heartbeat_data.capabilities else None)),
|
||||
agent_ip, heartbeat_data.haproxy_status, heartbeat_data.haproxy_version,
|
||||
heartbeat_data.applied_config_version, new_status, update_applied_version,
|
||||
heartbeat_data.keepalive_state, heartbeat_data.keepalive_ip)
|
||||
@@ -2180,13 +2204,171 @@ async def get_agent_ssl_certificates(agent_name: str, since: Optional[str] = Non
|
||||
logger.info(f"SSL INCREMENTAL: No certificates updated since {since}")
|
||||
|
||||
return response_data
|
||||
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"SSL certificates retrieval failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{agent_name}/keepalived-config")
|
||||
async def get_agent_keepalived_config(agent_name: str, x_api_key: Optional[str] = Header(None)):
|
||||
"""Issue #27 (v1.7.0) — deliver this agent's APPLIED keepalived snapshot, or a
|
||||
teardown / no-op signal.
|
||||
|
||||
Snapshot-based (T-1): keys off vip_instances.is_active + the member's
|
||||
applied_config_content, NOT the live last_config_status — so a PENDING edit never
|
||||
flips a running member to not_configured (no mid-edit teardown). Auth is MANDATORY (a
|
||||
valid agent key is required), but — like the /config and /ssl-certificates endpoints —
|
||||
the agent API key is a SHARED/global install token, so the config is resolved by the
|
||||
requested agent_name and a token/name mismatch is an advisory audit log, NOT a 403
|
||||
(a hard 403 would break every VIP member whose name isn't the one row the shared token
|
||||
resolves to — review HIGH-1). Any unexpected error degrades to not_configured (B-7) so
|
||||
the agent stays inert; a node with no membership row always gets not_configured.
|
||||
"""
|
||||
conn = None
|
||||
try:
|
||||
# Auth FIRST and MANDATORY: a valid agent key is REQUIRED (the response carries the
|
||||
# VRRP secret). The token is shared/global, so resolve by agent_name and only LOG a
|
||||
# name mismatch — do not 403 (HIGH-1). Done before the agent lookup so an
|
||||
# unauthenticated caller can't probe which agent names exist.
|
||||
from auth_middleware import validate_agent_api_key
|
||||
if not x_api_key:
|
||||
raise HTTPException(status_code=401, detail="Agent API key required")
|
||||
agent_auth = await validate_agent_api_key(x_api_key)
|
||||
if not agent_auth:
|
||||
raise HTTPException(status_code=401, detail="Invalid API key")
|
||||
# The agent API key is a SHARED/global install token (many agent rows per token),
|
||||
# so validate_agent_api_key resolves it to one arbitrary agent for that token. Resolve
|
||||
# the keepalived config strictly by the requested agent_name and treat a token/name
|
||||
# mismatch as an advisory audit log — exactly like the /config and /ssl-certificates
|
||||
# endpoints. (A hard 403 here would reject every VIP member whose name isn't the one
|
||||
# row the shared token happens to return, so the VIP could never converge — review HIGH-1.)
|
||||
if agent_auth['name'] == agent_name:
|
||||
logger.info(f"Agent '{agent_name}' fetching keepalived config using its own API key")
|
||||
else:
|
||||
logger.info(f"Agent '{agent_name}' fetching keepalived config using API key from agent '{agent_auth['name']}'")
|
||||
|
||||
conn = await get_database_connection()
|
||||
# Resolve the agent + its cluster's keepalived.conf path (cluster-driven, like the
|
||||
# HAProxy paths). config_path is returned in EVERY response so the agent knows where
|
||||
# to write/own-marker-check even on not_configured/teardown.
|
||||
agent = await conn.fetchrow("""
|
||||
SELECT a.id, a.name, COALESCE(a.enabled, TRUE) AS enabled,
|
||||
hc.keepalived_config_path
|
||||
FROM agents a
|
||||
LEFT JOIN haproxy_clusters hc ON hc.pool_id = a.pool_id
|
||||
WHERE a.name = $1
|
||||
""", agent_name)
|
||||
if not agent:
|
||||
raise HTTPException(status_code=404, detail=f"Agent '{agent_name}' not found")
|
||||
config_path = agent['keepalived_config_path'] or '/etc/keepalived/keepalived.conf'
|
||||
if not agent['enabled']:
|
||||
return {"agent_name": agent_name, "status": "not_configured", "config_path": config_path, "keepalived": None}
|
||||
|
||||
row = await conn.fetchrow("""
|
||||
SELECT v.id AS vip_id, v.name AS vip_name, v.is_active, v.track_haproxy,
|
||||
v.purge_on_teardown,
|
||||
m.applied_config_content, m.applied_config_hash
|
||||
FROM vip_members m JOIN vip_instances v ON v.id = m.vip_id
|
||||
WHERE m.agent_id = $1
|
||||
-- Active VIP first (an agent has at most one). With NO active VIP, pick the most
|
||||
-- RECENTLY updated inactive membership so a teardown reflects the latest delete
|
||||
-- (incl. its purge flag) — not a stale older VIP the node was once part of.
|
||||
ORDER BY v.is_active DESC, v.updated_at DESC, v.id DESC
|
||||
LIMIT 1
|
||||
""", agent['id'])
|
||||
|
||||
if not row:
|
||||
return {"agent_name": agent_name, "status": "not_configured", "config_path": config_path, "keepalived": None}
|
||||
if not row['is_active']:
|
||||
# Soft-deleted VIP → teardown. purge carries the operator's opt-in package removal;
|
||||
# the agent still only purges on nodes where IT installed keepalived (install marker).
|
||||
return {"agent_name": agent_name, "status": "teardown", "vip_id": row['vip_id'],
|
||||
"config_path": config_path, "keepalived": None,
|
||||
"purge": bool(row['purge_on_teardown'])}
|
||||
if not row['applied_config_content']:
|
||||
return {"agent_name": agent_name, "status": "not_configured", "config_path": config_path, "keepalived": None}
|
||||
|
||||
from services.keepalived_config import build_haproxy_check_script
|
||||
check_script = build_haproxy_check_script() if row['track_haproxy'] else ""
|
||||
return {
|
||||
"agent_name": agent_name,
|
||||
"status": "available",
|
||||
"config_path": config_path,
|
||||
"keepalived": {
|
||||
"desired_state": "enabled",
|
||||
"install_if_missing": True,
|
||||
"vip_id": row['vip_id'],
|
||||
"vip_name": row['vip_name'],
|
||||
"config_content": row['applied_config_content'],
|
||||
"config_hash": row['applied_config_hash'],
|
||||
"check_script": check_script,
|
||||
},
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"keepalived-config delivery failed for '{agent_name}': {e}")
|
||||
# Degrade to no-op rather than 500 (B-7) — keeps the fleet inert on any error.
|
||||
return {"agent_name": agent_name, "status": "not_configured",
|
||||
"config_path": "/etc/keepalived/keepalived.conf", "keepalived": None}
|
||||
finally:
|
||||
if conn:
|
||||
await close_database_connection(conn)
|
||||
|
||||
|
||||
@router.post("/{agent_name}/keepalived-status")
|
||||
async def agent_keepalived_status(agent_name: str, status_data: dict, x_api_key: Optional[str] = Header(None)):
|
||||
"""Issue #27 (v1.7.0) — agent reports the outcome of a keepalived deploy/teardown.
|
||||
|
||||
Auth mirrors config-applied's post-Bulgu-#75 guard (reject a MISSING key — never the
|
||||
`and` short-circuit that accepted no-key requests). The token is a shared/global install
|
||||
token, so the status is recorded strictly for the requested agent_name and a token/name
|
||||
mismatch is an advisory audit log (like /config-applied), not a 403 — review HIGH-1.
|
||||
"""
|
||||
conn = None
|
||||
try:
|
||||
from auth_middleware import validate_agent_api_key
|
||||
agent_auth = await validate_agent_api_key(x_api_key)
|
||||
if not x_api_key or not agent_auth:
|
||||
raise HTTPException(status_code=401, detail="Invalid API key")
|
||||
if agent_auth['name'] != agent_name:
|
||||
logger.info(f"Agent '{agent_name}' reporting keepalived status using API key from agent '{agent_auth['name']}'")
|
||||
|
||||
conn = await get_database_connection()
|
||||
agent = await conn.fetchrow("SELECT id FROM agents WHERE name = $1", agent_name)
|
||||
if not agent:
|
||||
raise HTTPException(status_code=404, detail=f"Agent '{agent_name}' not found")
|
||||
|
||||
vip_id = status_data.get("vip_id")
|
||||
state = (status_data.get("state") or "").strip()[:24]
|
||||
config_hash = (status_data.get("config_hash") or "")[:64]
|
||||
message = status_data.get("message")
|
||||
if vip_id is None:
|
||||
# No specific VIP (e.g. a teardown ack) — update all this agent's memberships.
|
||||
await conn.execute("""
|
||||
UPDATE vip_members SET last_deploy_state=$2, last_deploy_message=$3,
|
||||
last_deploy_hash=$4, last_deploy_at=CURRENT_TIMESTAMP, updated_at=CURRENT_TIMESTAMP
|
||||
WHERE agent_id=$1
|
||||
""", agent['id'], state, message, config_hash)
|
||||
else:
|
||||
await conn.execute("""
|
||||
UPDATE vip_members SET last_deploy_state=$3, last_deploy_message=$4,
|
||||
last_deploy_hash=$5, last_deploy_at=CURRENT_TIMESTAMP, updated_at=CURRENT_TIMESTAMP
|
||||
WHERE agent_id=$1 AND vip_id=$2
|
||||
""", agent['id'], int(vip_id), state, message, config_hash)
|
||||
return {"status": "ok"}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"keepalived-status update failed for '{agent_name}': {e}")
|
||||
raise HTTPException(status_code=500, detail="keepalived-status update failed")
|
||||
finally:
|
||||
if conn:
|
||||
await close_database_connection(conn)
|
||||
|
||||
@router.get("/script-version")
|
||||
async def get_latest_script_version(platform: str = "macos"):
|
||||
"""Get the latest available agent script version for specified platform"""
|
||||
|
||||
+53
-22
@@ -333,31 +333,38 @@ async def get_backends(
|
||||
""")
|
||||
|
||||
result = []
|
||||
# Issue #24: servers must honor include_inactive exactly like the backend
|
||||
# queries above. Pre-fix these sub-queries hardcoded `is_active = TRUE`
|
||||
# (added in f34a6ee to hide soft-deleted entities), so a server toggled
|
||||
# OFF (is_active=false) vanished from the UI with no way to reactivate it.
|
||||
# Default callers (include_inactive=false) keep the is_active filter →
|
||||
# byte-identical behavior; include_inactive=true now also returns inactive
|
||||
# (disabled / soft-deleted) servers. last_config_status is selected so the
|
||||
# frontend can tell DISABLED (re-enableable) from DELETION (pending delete).
|
||||
server_active_filter = "" if include_inactive else "AND is_active = TRUE"
|
||||
for backend in backends:
|
||||
# Get servers for this backend with cluster_id (ONLY show active servers)
|
||||
# CRITICAL FIX: Add is_active = TRUE filter to prevent soft-deleted servers from appearing
|
||||
if cluster_id:
|
||||
servers = await conn.fetch("""
|
||||
servers = await conn.fetch(f"""
|
||||
SELECT id, server_name, server_address, server_port, weight, maxconn,
|
||||
check_enabled, check_port, backup_server, ssl_enabled, ssl_verify, ssl_certificate_id,
|
||||
ssl_sni, ssl_min_ver, ssl_max_ver, ssl_ciphers,
|
||||
cookie_value, inter, fall, rise,
|
||||
is_active, cluster_id,
|
||||
is_active, cluster_id, last_config_status,
|
||||
haproxy_status, haproxy_status_updated_at, backend_name
|
||||
FROM backend_servers
|
||||
WHERE backend_name = $1 AND cluster_id = $2 AND is_active = TRUE ORDER BY server_name
|
||||
FROM backend_servers
|
||||
WHERE backend_name = $1 AND cluster_id = $2 {server_active_filter} ORDER BY server_name
|
||||
""", backend["name"], cluster_id)
|
||||
else:
|
||||
servers = await conn.fetch("""
|
||||
servers = await conn.fetch(f"""
|
||||
SELECT id, server_name, server_address, server_port, weight, maxconn,
|
||||
check_enabled, check_port, backup_server, ssl_enabled, ssl_verify, ssl_certificate_id,
|
||||
ssl_sni, ssl_min_ver, ssl_max_ver, ssl_ciphers,
|
||||
cookie_value, inter, fall, rise,
|
||||
is_active, cluster_id,
|
||||
is_active, cluster_id, last_config_status,
|
||||
haproxy_status, haproxy_status_updated_at, backend_name
|
||||
FROM backend_servers
|
||||
WHERE backend_name = $1 AND is_active = TRUE ORDER BY server_name
|
||||
""", backend["name"])
|
||||
FROM backend_servers
|
||||
WHERE backend_name = $1 {server_active_filter} ORDER BY server_name
|
||||
""", backend["name"])
|
||||
|
||||
# Prepare server list with real-time HAProxy status from agents
|
||||
server_list = []
|
||||
@@ -413,6 +420,7 @@ async def get_backends(
|
||||
"fall": s.get("fall"),
|
||||
"rise": s.get("rise"),
|
||||
"is_active": s["is_active"],
|
||||
"last_config_status": s.get("last_config_status") or "APPLIED", # Issue #24: lets UI distinguish DISABLED (re-enableable) from DELETION
|
||||
"status": server_status,
|
||||
"status_age_minutes": status_age_minutes,
|
||||
"last_status_update": s.get("haproxy_status_updated_at").isoformat().replace('+00:00', 'Z') if s.get("haproxy_status_updated_at") else None,
|
||||
@@ -1909,12 +1917,12 @@ async def toggle_server(server_id: int, request: Request, authorization: str = H
|
||||
|
||||
conn = await get_database_connection()
|
||||
|
||||
# Get server info
|
||||
# Get server info. Issue #24: fetch the FULL row (not just 5 columns) so we
|
||||
# can snapshot the pre-toggle state for reject-rollback (see config version below).
|
||||
server = await conn.fetchrow("""
|
||||
SELECT id, server_name, backend_name, is_active, cluster_id
|
||||
FROM backend_servers WHERE id = $1
|
||||
SELECT * FROM backend_servers WHERE id = $1
|
||||
""", server_id)
|
||||
|
||||
|
||||
if not server:
|
||||
await close_database_connection(conn)
|
||||
raise HTTPException(status_code=404, detail="Server not found")
|
||||
@@ -1975,22 +1983,45 @@ async def toggle_server(server_id: int, request: Request, authorization: str = H
|
||||
|
||||
# Generate new HAProxy config (after database commit)
|
||||
config_content = await generate_haproxy_config_for_cluster(cluster_id)
|
||||
|
||||
|
||||
# Create new config version
|
||||
config_hash = hashlib.sha256(config_content.encode()).hexdigest()
|
||||
version_name = f"server-{server_id}-toggle-{int(time.time())}"
|
||||
|
||||
|
||||
# Get system admin user ID for created_by
|
||||
conn2 = await get_database_connection()
|
||||
admin_user_id = await conn2.fetchval("SELECT id FROM users WHERE username = 'admin' LIMIT 1") or 1
|
||||
|
||||
|
||||
# Issue #24: persist an entity snapshot so a Reject of this toggle
|
||||
# rolls back is_active to its pre-toggle value. Pre-fix the toggle's
|
||||
# config version carried NO metadata, so reject only reset
|
||||
# last_config_status and the server stayed disabled (out of sync with
|
||||
# the still-active live config). Mirrors the server-edit snapshot path;
|
||||
# reject's rollback_entity_from_snapshot('server') restores is_active.
|
||||
import json
|
||||
from utils.entity_snapshot import save_entity_snapshot
|
||||
entity_snapshot_metadata = await save_entity_snapshot(
|
||||
conn=conn2,
|
||||
entity_type="server",
|
||||
entity_id=server_id,
|
||||
old_values=dict(server), # full pre-toggle row
|
||||
new_values={"is_active": new_status},
|
||||
operation="UPDATE",
|
||||
)
|
||||
old_config = await conn2.fetchval("""
|
||||
SELECT config_content FROM config_versions
|
||||
WHERE cluster_id = $1 AND status = 'APPLIED' AND is_active = TRUE
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
""", cluster_id)
|
||||
metadata = {"pre_apply_snapshot": old_config or "", **entity_snapshot_metadata}
|
||||
|
||||
# Create PENDING config version
|
||||
config_version_id = await conn2.fetchval("""
|
||||
INSERT INTO config_versions
|
||||
(cluster_id, version_name, config_content, checksum, created_by, is_active, status)
|
||||
VALUES ($1, $2, $3, $4, $5, FALSE, 'PENDING')
|
||||
INSERT INTO config_versions
|
||||
(cluster_id, version_name, config_content, checksum, created_by, is_active, status, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5, FALSE, 'PENDING', $6)
|
||||
RETURNING id
|
||||
""", cluster_id, version_name, config_content, config_hash, admin_user_id)
|
||||
""", cluster_id, version_name, config_content, config_hash, admin_user_id, json.dumps(metadata))
|
||||
|
||||
logger.error(f"SERVER TOGGLE DEBUG: Created PENDING config version {version_name} for cluster {cluster_id}")
|
||||
|
||||
|
||||
+178
-31
@@ -292,12 +292,14 @@ async def create_cluster(cluster: HAProxyClusterCreate, authorization: str = Hea
|
||||
|
||||
# Create cluster
|
||||
cluster_id = await conn.fetchval("""
|
||||
INSERT INTO haproxy_clusters (name, description, connection_type, is_active,
|
||||
stats_socket_path, haproxy_config_path, haproxy_bin_path, pool_id)
|
||||
VALUES ($1, $2, $3, TRUE, $4, $5, $6, $7)
|
||||
INSERT INTO haproxy_clusters (name, description, connection_type, is_active,
|
||||
stats_socket_path, haproxy_config_path, haproxy_bin_path,
|
||||
keepalived_config_path, pool_id)
|
||||
VALUES ($1, $2, $3, TRUE, $4, $5, $6, $7, $8)
|
||||
RETURNING id
|
||||
""", cluster.name, cluster.description, cluster.connection_type,
|
||||
cluster.stats_socket_path, cluster.haproxy_config_path, cluster.haproxy_bin_path, cluster.pool_id)
|
||||
cluster.stats_socket_path, cluster.haproxy_config_path, cluster.haproxy_bin_path,
|
||||
cluster.keepalived_config_path, cluster.pool_id)
|
||||
|
||||
await close_database_connection(conn)
|
||||
|
||||
@@ -436,7 +438,12 @@ async def update_cluster(cluster_id: int, cluster: HAProxyClusterUpdate, authori
|
||||
update_fields.append(f"haproxy_bin_path = ${param_counter}")
|
||||
update_values.append(cluster.haproxy_bin_path)
|
||||
param_counter += 1
|
||||
|
||||
|
||||
if cluster.keepalived_config_path is not None:
|
||||
update_fields.append(f"keepalived_config_path = ${param_counter}")
|
||||
update_values.append(cluster.keepalived_config_path)
|
||||
param_counter += 1
|
||||
|
||||
if cluster.pool_id is not None:
|
||||
update_fields.append(f"pool_id = ${param_counter}")
|
||||
update_values.append(cluster.pool_id)
|
||||
@@ -517,7 +524,7 @@ async def update_cluster(cluster_id: int, cluster: HAProxyClusterUpdate, authori
|
||||
|
||||
|
||||
@router.get("/{cluster_id}", summary="Get Cluster by ID", response_description="Cluster details")
|
||||
async def get_cluster(cluster_id: int, authorization: str = Header(None)):
|
||||
async def get_cluster(cluster_id: int, authorization: str = Header(None), x_api_key: Optional[str] = Header(None)):
|
||||
"""
|
||||
# Get Specific HAProxy Cluster
|
||||
|
||||
@@ -528,8 +535,12 @@ async def get_cluster(cluster_id: int, authorization: str = Header(None)):
|
||||
|
||||
## Example Request
|
||||
```bash
|
||||
# User (UI) authentication:
|
||||
curl -X GET "{BASE_URL}/api/clusters/1" \\
|
||||
-H "Authorization: Bearer eyJhbGciOiJIUz..."
|
||||
# Agent authentication (agent token in X-API-Key):
|
||||
curl -X GET "{BASE_URL}/api/clusters/1" \\
|
||||
-H "X-API-Key: hap_..."
|
||||
```
|
||||
|
||||
## Example Response
|
||||
@@ -554,20 +565,30 @@ async def get_cluster(cluster_id: int, authorization: str = Header(None)):
|
||||
- **404**: Cluster not found
|
||||
- **500**: Server error
|
||||
"""
|
||||
try:
|
||||
# R18c audit fix (round 6 final convergence): authenticate
|
||||
# the caller before fetching cluster topology by ID. Pre-fix
|
||||
# this sibling of GET /api/clusters was anonymous, so an
|
||||
# attacker could iterate cluster IDs to enumerate the same
|
||||
# info (stats socket, paths, ACME flags, pool identity) the
|
||||
# list endpoint just locked down. Closes the asymmetry.
|
||||
# R18c audit fix (round 6 final convergence): authenticate the caller
|
||||
# before fetching cluster topology by ID. Pre-fix this sibling of
|
||||
# GET /api/clusters was anonymous, so an attacker could iterate cluster
|
||||
# IDs to enumerate the same info (stats socket, paths, ACME flags, pool
|
||||
# identity) the list endpoint just locked down.
|
||||
# Issue #22: agents send their token in the X-API-Key header (not a user
|
||||
# JWT), so accept either credential — mirrors the dual-auth on
|
||||
# POST /api/agents/generate-install-script. Anonymous is still rejected.
|
||||
if authorization:
|
||||
from auth_middleware import get_current_user_from_token
|
||||
await get_current_user_from_token(authorization)
|
||||
elif x_api_key:
|
||||
from auth_middleware import validate_agent_api_key
|
||||
if not await validate_agent_api_key(x_api_key):
|
||||
raise HTTPException(status_code=401, detail="Invalid agent API key")
|
||||
else:
|
||||
raise HTTPException(status_code=401, detail="Authorization header or X-API-Key required")
|
||||
try:
|
||||
conn = await get_database_connection()
|
||||
|
||||
cluster = await conn.fetchrow("""
|
||||
SELECT c.id, c.name, c.description, c.connection_type, c.is_active,
|
||||
SELECT c.id, c.name, c.description, c.connection_type, c.is_active,
|
||||
c.created_at, c.stats_socket_path, c.haproxy_config_path, c.haproxy_bin_path,
|
||||
c.keepalived_config_path,
|
||||
c.pool_id, c.is_default, c.acme_enabled, c.acme_backend_url,
|
||||
p.name as pool_name
|
||||
FROM haproxy_clusters c
|
||||
@@ -591,6 +612,7 @@ async def get_cluster(cluster_id: int, authorization: str = Header(None)):
|
||||
"stats_socket_path": cluster["stats_socket_path"],
|
||||
"haproxy_config_path": cluster["haproxy_config_path"],
|
||||
"haproxy_bin_path": cluster["haproxy_bin_path"],
|
||||
"keepalived_config_path": cluster.get("keepalived_config_path"),
|
||||
"pool_id": cluster["pool_id"],
|
||||
"pool_name": cluster["pool_name"],
|
||||
"acme_enabled": cluster.get("acme_enabled", False),
|
||||
@@ -602,7 +624,7 @@ async def get_cluster(cluster_id: int, authorization: str = Header(None)):
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("", summary="Get All Clusters", response_description="List of all clusters")
|
||||
async def get_clusters(authorization: str = Header(None)):
|
||||
async def get_clusters(authorization: str = Header(None), x_api_key: Optional[str] = Header(None)):
|
||||
"""
|
||||
# Get All HAProxy Clusters
|
||||
|
||||
@@ -610,8 +632,12 @@ async def get_clusters(authorization: str = Header(None)):
|
||||
|
||||
## Example Request
|
||||
```bash
|
||||
# User (UI) authentication:
|
||||
curl -X GET "{BASE_URL}/api/clusters" \\
|
||||
-H "Authorization: Bearer eyJhbGciOiJIUz..."
|
||||
# Agent authentication (agent token in X-API-Key):
|
||||
curl -X GET "{BASE_URL}/api/clusters" \\
|
||||
-H "X-API-Key: hap_..."
|
||||
```
|
||||
|
||||
## Example Response
|
||||
@@ -662,23 +688,34 @@ async def get_clusters(authorization: str = Header(None)):
|
||||
## Error Responses
|
||||
- **500**: Server error
|
||||
"""
|
||||
try:
|
||||
# R18c audit fix (round 6 #3 — KRITIK info leak): require an
|
||||
# authenticated caller. Pre-fix the endpoint accepted
|
||||
# anonymous GETs and returned cluster topology including
|
||||
# internal HAProxy paths (stats socket, config path, bin
|
||||
# path), pool ids, ACME flags, and agent counts. This is
|
||||
# both reconnaissance for an attacker and the spine of the
|
||||
# cluster-scoped RBAC the rest of the platform builds on,
|
||||
# so guarding it at the read layer is essential after R18c
|
||||
# round 5's roster + role guards.
|
||||
# R18c audit fix (round 6 #3 — KRITIK info leak): require an authenticated
|
||||
# caller. Pre-fix the endpoint accepted anonymous GETs and returned cluster
|
||||
# topology including internal HAProxy paths (stats socket, config path, bin
|
||||
# path), pool ids, ACME flags, and agent counts. This is both reconnaissance
|
||||
# for an attacker and the spine of the cluster-scoped RBAC the rest of the
|
||||
# platform builds on, so guarding it at the read layer is essential.
|
||||
# Issue #22: agents send their token in the X-API-Key header (not a user
|
||||
# JWT), so accept either credential — mirrors the dual-auth on
|
||||
# POST /api/agents/generate-install-script. Anonymous is still rejected.
|
||||
# (Guard kept OUTSIDE the try below: get_clusters' broad `except Exception`
|
||||
# re-wraps raised HTTPExceptions into 500, which produced the "500 - 401"
|
||||
# in the issue log; raising here yields a clean 401.)
|
||||
if authorization:
|
||||
from auth_middleware import get_current_user_from_token
|
||||
await get_current_user_from_token(authorization)
|
||||
elif x_api_key:
|
||||
from auth_middleware import validate_agent_api_key
|
||||
if not await validate_agent_api_key(x_api_key):
|
||||
raise HTTPException(status_code=401, detail="Invalid agent API key")
|
||||
else:
|
||||
raise HTTPException(status_code=401, detail="Authorization header or X-API-Key required")
|
||||
try:
|
||||
conn = await get_database_connection()
|
||||
|
||||
clusters = await conn.fetch("""
|
||||
SELECT c.id, c.name, c.description, c.connection_type, c.is_active,
|
||||
SELECT c.id, c.name, c.description, c.connection_type, c.is_active,
|
||||
c.created_at, c.stats_socket_path, c.haproxy_config_path, c.haproxy_bin_path,
|
||||
c.keepalived_config_path,
|
||||
c.pool_id, c.is_default, c.acme_enabled, c.acme_backend_url,
|
||||
p.name as pool_name,
|
||||
COALESCE(agent_counts.total_agents, 0) as total_agents,
|
||||
@@ -737,6 +774,7 @@ async def get_clusters(authorization: str = Header(None)):
|
||||
"stats_socket_path": cluster["stats_socket_path"],
|
||||
"haproxy_config_path": cluster["haproxy_config_path"],
|
||||
"haproxy_bin_path": cluster["haproxy_bin_path"],
|
||||
"keepalived_config_path": cluster.get("keepalived_config_path"),
|
||||
"pool_id": cluster["pool_id"],
|
||||
"pool_name": cluster["pool_name"],
|
||||
"acme_enabled": cluster.get("acme_enabled", False),
|
||||
@@ -1313,6 +1351,8 @@ async def list_cluster_config_versions(cluster_id: int, authorization: str = Hea
|
||||
version_type = "WAF Rule"
|
||||
elif "ssl-" in version['version_name']:
|
||||
version_type = "SSL Certificate"
|
||||
elif "vip-" in version['version_name']:
|
||||
version_type = "HA / VIP"
|
||||
|
||||
# Parse validation error if present
|
||||
validation_error = version.get("validation_error")
|
||||
@@ -1417,10 +1457,14 @@ async def apply_pending_changes(
|
||||
# Users will handle configuration completeness through the centralized Apply Management page
|
||||
|
||||
# Get all pending config versions for this cluster
|
||||
# HA/VIP (Issue #27): vip-* versions are owned by the VIP apply/reject endpoints
|
||||
# (keepalived is not part of haproxy.cfg). Exclude them so a generic cluster apply
|
||||
# from any entity page never marks a VIP version APPLIED without enacting it. This
|
||||
# is a no-op for every non-VIP cluster (no vip-* rows exist).
|
||||
pending_versions = await conn.fetch("""
|
||||
SELECT id, version_name, created_at, config_content, checksum, metadata
|
||||
FROM config_versions
|
||||
WHERE cluster_id = $1 AND status = 'PENDING'
|
||||
FROM config_versions
|
||||
WHERE cluster_id = $1 AND status = 'PENDING' AND version_name NOT LIKE 'vip-%'
|
||||
ORDER BY created_at ASC
|
||||
""", cluster_id)
|
||||
|
||||
@@ -2539,6 +2583,92 @@ async def get_config_version_diff(cluster_id: int, version_id: int, authorizatio
|
||||
}
|
||||
}
|
||||
|
||||
# HA/VIP (Issue #27): vip-{id}-{action} versions show the generated keepalived.conf
|
||||
# each member node will deploy as the change content (VRRP secret masked). Mirrors
|
||||
# the ssl-* special case above so VIP uses the STANDARD View Change diff modal.
|
||||
vip_match = re.search(r'vip-(\d+)-(create|update|delete)', current_version['version_name'])
|
||||
if vip_match:
|
||||
vip_id = int(vip_match.group(1))
|
||||
vip_action = vip_match.group(2)
|
||||
rendered, vip_meta = None, None
|
||||
old_content = ""
|
||||
try:
|
||||
from routers.vip import render_vip_config_masked
|
||||
rendered, vip_meta = await render_vip_config_masked(conn, vip_id)
|
||||
# For a create/update diff, fetch the PREVIOUS applied vip-* config for this VIP
|
||||
# (the last-deployed keepalived.conf) so an EDIT shows ONLY the changed lines
|
||||
# instead of the whole config as "added". Both sides are already secret-masked.
|
||||
if vip_action != "delete":
|
||||
prev_applied = await conn.fetchrow(
|
||||
"SELECT config_content FROM config_versions WHERE version_name LIKE $1 "
|
||||
"AND status='APPLIED' AND cluster_id=$2 AND id < $3 ORDER BY id DESC LIMIT 1",
|
||||
f"vip-{vip_id}-%", cluster_id, current_version['id'])
|
||||
if prev_applied and prev_applied['config_content']:
|
||||
old_content = prev_applied['config_content']
|
||||
except Exception as vip_err:
|
||||
logger.warning(f"VIP DIFF: render failed for vip {vip_id}: {vip_err}")
|
||||
await close_database_connection(conn)
|
||||
|
||||
changes = []
|
||||
line_number = 1
|
||||
if vip_action == "delete" or rendered is None:
|
||||
title = (vip_meta or {}).get("name") or f"VIP {vip_id}"
|
||||
for line in [f"# HA/VIP change: {title}",
|
||||
"# keepalived will be stopped and the virtual IP released on each member node."
|
||||
if vip_action == "delete" else
|
||||
"# (configuration is not available to render yet)"]:
|
||||
changes.append({"type": "context", "line": line, "line_number": line_number})
|
||||
line_number += 1
|
||||
summary = {"added": 0, "removed": 1 if vip_action == "delete" else 0,
|
||||
"total_changes": 1 if vip_action == "delete" else 0}
|
||||
else:
|
||||
# Real line diff. Match the STANDARD haproxy diff format: the line is stored
|
||||
# WITHOUT a +/- prefix (the UI adds it from `type` — the old `+ {line}` here
|
||||
# caused the doubled "+ +"). A CREATE (no previous applied config) shows
|
||||
# everything as added; an UPDATE shows ONLY the lines that actually changed.
|
||||
import difflib
|
||||
new_content = current_version['config_content'] or rendered or ""
|
||||
added_count = 0
|
||||
removed_count = 0
|
||||
line_number = 0
|
||||
if not old_content:
|
||||
for i, l in enumerate(new_content.split('\n')):
|
||||
changes.append({"type": "added", "line": l, "line_number": i + 1})
|
||||
added_count += 1
|
||||
else:
|
||||
for dl in difflib.unified_diff(old_content.split('\n'), new_content.split('\n'),
|
||||
lineterm='', n=3):
|
||||
if dl.startswith('@@'):
|
||||
mm = re.search(r'@@ -(\d+),?\d* \+(\d+),?\d* @@', dl)
|
||||
if mm:
|
||||
line_number = int(mm.group(2))
|
||||
continue
|
||||
if dl.startswith('---') or dl.startswith('+++'):
|
||||
continue
|
||||
if dl.startswith('+'):
|
||||
changes.append({"type": "added", "line": dl[1:], "line_number": line_number})
|
||||
added_count += 1
|
||||
line_number += 1
|
||||
elif dl.startswith('-'):
|
||||
changes.append({"type": "removed", "line": dl[1:], "line_number": line_number})
|
||||
removed_count += 1
|
||||
elif dl.startswith(' '):
|
||||
changes.append({"type": "context", "line": dl[1:], "line_number": line_number})
|
||||
line_number += 1
|
||||
summary = {"added": added_count, "removed": removed_count,
|
||||
"total_changes": added_count + removed_count}
|
||||
|
||||
return {
|
||||
"current_version": {
|
||||
"id": current_version['id'],
|
||||
"version_name": current_version['version_name'],
|
||||
"created_at": current_version['created_at'].isoformat().replace('+00:00', 'Z')
|
||||
},
|
||||
"previous_version": None,
|
||||
"changes": changes,
|
||||
"summary": summary,
|
||||
}
|
||||
|
||||
# Check if current version has config content
|
||||
if not current_version['config_content']:
|
||||
# Special handling for restore versions - they might not have content yet
|
||||
@@ -4226,6 +4356,20 @@ async def undo_reject_config_version(
|
||||
),
|
||||
)
|
||||
|
||||
# HA/VIP (Issue #27): vip-* versions are owned by the VIP entity, so undo is handled
|
||||
# by the VIP router — it re-stages the rejected change as PENDING from the version's
|
||||
# captured pending_state (reactivating the VIP if a rejected create soft-deleted it,
|
||||
# or re-applying a rejected edit). Returns an error string if it can't (e.g. the
|
||||
# name/address/VRID was reused since reject) -> surface a clean 409.
|
||||
if version_name.startswith('vip-'):
|
||||
from routers.vip import restore_vip_from_rejected_version
|
||||
err = await restore_vip_from_rejected_version(conn, version_id)
|
||||
await close_database_connection(conn)
|
||||
if err:
|
||||
raise HTTPException(status_code=409, detail=f"Cannot undo this VIP change — {err}.")
|
||||
return {"message": "VIP change restored to PENDING — review and Apply it from Apply Management",
|
||||
"version_name": version_name}
|
||||
|
||||
async with conn.transaction():
|
||||
# Mark the version as PENDING again
|
||||
await conn.execute("""
|
||||
@@ -4902,9 +5046,11 @@ async def reject_all_pending_changes(cluster_id: int, authorization: str = Heade
|
||||
await validate_user_cluster_access(current_user['id'], cluster_id, conn)
|
||||
|
||||
# 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.
|
||||
pending_versions = await conn.fetch("""
|
||||
SELECT id, version_name, metadata FROM config_versions
|
||||
WHERE cluster_id = $1 AND status = 'PENDING'
|
||||
WHERE cluster_id = $1 AND status = 'PENDING' AND version_name NOT LIKE 'vip-%'
|
||||
""", cluster_id)
|
||||
|
||||
# CRITICAL FIX: Detect and clean orphan config versions
|
||||
@@ -5116,11 +5262,12 @@ async def reject_all_pending_changes(cluster_id: int, authorization: str = Heade
|
||||
)
|
||||
|
||||
# Mark all pending config versions as REJECTED (don't delete them)
|
||||
# HA/VIP (Issue #27): leave vip-* versions to the VIP reject endpoint.
|
||||
rejected_count = len(pending_versions)
|
||||
await conn.execute("""
|
||||
UPDATE config_versions
|
||||
UPDATE config_versions
|
||||
SET status = 'REJECTED'
|
||||
WHERE cluster_id = $1 AND status = 'PENDING'
|
||||
WHERE cluster_id = $1 AND status = 'PENDING' AND version_name NOT LIKE 'vip-%'
|
||||
""", cluster_id)
|
||||
|
||||
# Update WAF rules status to APPLIED (rolled back)
|
||||
|
||||
+384
-46
@@ -1,6 +1,6 @@
|
||||
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 json
|
||||
import logging
|
||||
import re
|
||||
@@ -10,6 +10,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__)
|
||||
|
||||
@@ -27,6 +61,39 @@ class AccountCreate(BaseModel):
|
||||
tos_agreed: bool = True
|
||||
eab_kid: Optional[str] = None
|
||||
eab_hmac_key: Optional[str] = None
|
||||
# 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
|
||||
|
||||
@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 +105,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 +123,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 +169,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,12 +199,21 @@ 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 Exception as e:
|
||||
@@ -186,6 +287,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 +464,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 +526,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 +539,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 +573,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 +611,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 +641,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 +650,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 +777,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 +923,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 +981,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 +1079,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 +1095,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 +1147,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}")
|
||||
|
||||
@@ -0,0 +1,987 @@
|
||||
"""Issue #27 — HA/VIP (Keepalived) management API (v1.7.0).
|
||||
|
||||
Isolated router: it owns vip_instances / vip_members only. It NEVER imports or calls
|
||||
the global HAProxy apply flow (cluster.py::apply_pending_changes) or the haproxy.cfg
|
||||
generator — VIP "Apply" is a standalone verb that renders per-node keepalived.conf
|
||||
snapshots into vip_members and flips the VIP to APPLIED.
|
||||
|
||||
For Apply-Management consistency it ALSO stages a standard `config_versions` row per
|
||||
change (version_name `vip-{id}-{action}`, status PENDING, is_active=FALSE — exactly like
|
||||
ssl-* versions) so VIP changes appear in the right-panel Pending Versions list with the
|
||||
product's standard "View Change" diff. is_active stays FALSE so a VIP version can never
|
||||
be served to an agent as haproxy.cfg (the agent config query requires is_active=TRUE);
|
||||
cluster.py keeps vip-* versions out of the generic haproxy apply/reject (NOT LIKE 'vip-%').
|
||||
|
||||
Every DB access is wrapped so a (pathological) missing vip_* relation degrades to an
|
||||
empty/None result instead of a 500 (B-7) — preserving the fleet-wide no-op guarantee.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, Request
|
||||
|
||||
from auth_middleware import check_user_permission, get_current_user_from_token
|
||||
from database.connection import close_database_connection, get_database_connection
|
||||
from models.vip import VIPCreate, VIPUpdate
|
||||
from services.keepalived_config import (
|
||||
build_haproxy_check_script,
|
||||
decrypt_vrrp_secret,
|
||||
encrypt_vrrp_secret,
|
||||
render_keepalived_conf,
|
||||
)
|
||||
from utils.activity_log import log_user_activity
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/vip", tags=["HA / VIP"])
|
||||
|
||||
|
||||
def _client_ip(request: Optional[Request]) -> Optional[str]:
|
||||
try:
|
||||
return request.client.host if request and request.client else None
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
def _user_agent(request: Optional[Request]) -> Optional[str]:
|
||||
try:
|
||||
return request.headers.get("user-agent") if request else None
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
async def _require(authorization: Optional[str], action: str):
|
||||
"""Authenticate + enforce vip.<action>; returns current_user or raises 401/403."""
|
||||
current_user = await get_current_user_from_token(authorization)
|
||||
ok = await check_user_permission(current_user["id"], "vip", action, current_user=current_user)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=403, detail=f"vip.{action} permission required")
|
||||
return current_user
|
||||
|
||||
|
||||
async def _alloc_free_vrid(conn, pool_id: int, requested: Optional[int]) -> int:
|
||||
"""Use the requested VRID if free in the pool, else the lowest free 1..255."""
|
||||
used = {r["virtual_router_id"] for r in await conn.fetch(
|
||||
"SELECT virtual_router_id FROM vip_instances WHERE pool_id=$1 AND is_active=TRUE", pool_id)}
|
||||
if requested is not None:
|
||||
if requested in used:
|
||||
raise HTTPException(status_code=409, detail=f"VRID {requested} already used in this pool")
|
||||
return requested
|
||||
for cand in range(1, 256):
|
||||
if cand not in used:
|
||||
return cand
|
||||
raise HTTPException(status_code=409, detail="No free VRID (1-255) left in this pool")
|
||||
|
||||
|
||||
def _md5(text: str) -> str:
|
||||
return hashlib.md5(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
# The VRRP secret is rendered into keepalived.conf as `auth_pass <value>`. The "secret
|
||||
# never leaves the server in cleartext" rule means any config we hand back to the UI
|
||||
# (e.g. the version diff) must mask it — only the at-rest Fernet token and the
|
||||
# agent-delivery endpoint ever see the real value. Mask the WHOLE remainder of the line
|
||||
# (not just up to the first space) so a secret containing whitespace can't partially leak.
|
||||
_AUTH_PASS_RE = re.compile(r"(auth_pass\s+).*")
|
||||
|
||||
|
||||
def _redact_secret(conf: Optional[str]) -> str:
|
||||
return _AUTH_PASS_RE.sub(r"\1********", conf or "")
|
||||
|
||||
|
||||
def _derive_deploy_status(last_config_status: str, members, pending_delete: bool = False,
|
||||
is_active: bool = True) -> tuple:
|
||||
"""Display status that reflects ACTUAL agent convergence, not just the staging flag.
|
||||
|
||||
Other entities only read APPLIED once their agents acknowledge the new config; the
|
||||
VIP now mirrors that so the table never claims a VIP is live before its member nodes
|
||||
have deployed keepalived and acked the current config hash (issue #27 follow-up).
|
||||
|
||||
PENDING_DELETE — a deletion is STAGED and awaiting approval in Apply Management; the
|
||||
VIP keeps running until approved (nothing is torn down)
|
||||
DELETING — deletion APPROVED; member nodes are tearing keepalived down (not yet acked)
|
||||
PENDING — staged (created/edited); apply from Apply Management
|
||||
SYNCING — applied; an ONLINE member is still converging (deploying/acking)
|
||||
AWAITING — applied, but the un-converged members' agents are all OFFLINE, so
|
||||
nothing can deploy yet (bring the node's agent online) — not a hang
|
||||
ACTIVE — applied AND every member deployed & acked the current config hash
|
||||
ERROR — a member reported a deploy error
|
||||
ATTENTION — a member found a hand-managed keepalived (externally_managed)
|
||||
|
||||
Returns (status, synced_count, total_count).
|
||||
"""
|
||||
# Approval-gated delete: staged (still running) vs approved (tearing down).
|
||||
if pending_delete:
|
||||
return "PENDING_DELETE", 0, len(members)
|
||||
if not is_active:
|
||||
total = len(members)
|
||||
torn = sum(1 for m in members if m["last_deploy_state"] == "disabled")
|
||||
return ("DELETED" if total and torn == total else "DELETING"), torn, total
|
||||
if last_config_status == "PENDING":
|
||||
return "PENDING", 0, len(members)
|
||||
total = len(members)
|
||||
if total == 0:
|
||||
return "APPLIED", 0, 0
|
||||
|
||||
def _in_sync(m) -> bool:
|
||||
return (m["last_deploy_state"] == "enabled"
|
||||
and m["applied_config_hash"] is not None
|
||||
and m["last_deploy_hash"] == m["applied_config_hash"])
|
||||
|
||||
synced = sum(1 for m in members if _in_sync(m))
|
||||
if any(m["last_deploy_state"] == "error" for m in members):
|
||||
return "ERROR", synced, total
|
||||
if any(m["last_deploy_state"] == "externally_managed" for m in members):
|
||||
return "ATTENTION", synced, total
|
||||
if synced == total:
|
||||
return "ACTIVE", synced, total
|
||||
# Not fully converged: distinguish "actively converging" (an online agent will deploy
|
||||
# on its next poll) from "waiting on offline agents" (nothing will happen until the
|
||||
# operator brings the node's agent online) — the latter must NOT read as a live spinner.
|
||||
not_synced = [m for m in members if not _in_sync(m)]
|
||||
if not_synced and all((m["agent_status"] or "offline") != "online" for m in not_synced):
|
||||
return "AWAITING", synced, total
|
||||
return "SYNCING", synced, total
|
||||
|
||||
|
||||
async def render_vip_config_masked(conn, vip_id: int):
|
||||
"""Render the keepalived.conf each member node would deploy, as ONE masked text block
|
||||
(VRRP secret never in cleartext). Used by the standard config-version diff for vip-*
|
||||
versions (cluster.py) and to populate the staged version's config_content — so VIP
|
||||
changes show the product's standard "View Change" instead of a bespoke screen.
|
||||
|
||||
Returns (text, {"name": ...}) or (None, None) if the VIP is gone. Best-effort: a
|
||||
per-node render error becomes an inline comment rather than raising.
|
||||
"""
|
||||
v = await conn.fetchrow("SELECT * FROM vip_instances WHERE id=$1", vip_id)
|
||||
if not v:
|
||||
return None, None
|
||||
members = await conn.fetch("""
|
||||
SELECT m.agent_id, m.network_interface, m.role, m.priority,
|
||||
a.name AS agent_name, a.ip_address
|
||||
FROM vip_members m LEFT JOIN agents a ON a.id = m.agent_id
|
||||
WHERE m.vip_id=$1 ORDER BY m.priority DESC
|
||||
""", vip_id)
|
||||
auth_plain = decrypt_vrrp_secret(v["auth_pass_encrypted"]) if v["auth_pass_encrypted"] else None
|
||||
vip_dict = {"id": v["id"], "name": v["name"], "virtual_ip": v["virtual_ip"],
|
||||
"prefix_length": v["prefix_length"], "virtual_router_id": v["virtual_router_id"],
|
||||
"advert_int": v["advert_int"], "use_unicast": v["use_unicast"],
|
||||
"track_haproxy": v["track_haproxy"]}
|
||||
member_dicts = [{"role": m["role"], "priority": m["priority"],
|
||||
"network_interface": m["network_interface"], "agent_id": m["agent_id"],
|
||||
"ip_address": str(m["ip_address"]) if m["ip_address"] else ""} for m in members]
|
||||
blocks = []
|
||||
for m in members:
|
||||
this_agent = next(d for d in member_dicts if d["agent_id"] == m["agent_id"])
|
||||
peer_ips = [d["ip_address"] for d in member_dicts
|
||||
if d["agent_id"] != m["agent_id"] and d["ip_address"]]
|
||||
try:
|
||||
conf = render_keepalived_conf(vip=vip_dict, members=member_dicts,
|
||||
this_agent=this_agent, peer_ips=peer_ips,
|
||||
auth_pass_plain=auth_plain)
|
||||
except Exception as exc: # noqa: BLE001 — best-effort preview
|
||||
conf = f"# cannot render this node yet: {exc}\n"
|
||||
header = (f"# ===== node: {m['agent_name'] or m['agent_id']} "
|
||||
f"({m['ip_address'] or 'no IP'}) — {m['role']} priority {m['priority']} =====")
|
||||
blocks.append(header + "\n" + _redact_secret(conf))
|
||||
text = "\n\n".join(blocks) if blocks else "# (no participating nodes selected yet)\n"
|
||||
return text, {"name": v["name"]}
|
||||
|
||||
|
||||
async def _stage_vip_version(conn, vip_id: int, action: str, created_by: Optional[int]):
|
||||
"""Stage a standard PENDING config_versions row for a VIP change so it shows in Apply
|
||||
Management exactly like other entities. One row per cluster in the VIP's pool;
|
||||
is_active=FALSE so it is NEVER delivered as haproxy.cfg. Best-effort — a staging
|
||||
failure never fails the VIP operation (logged); the VIP still applies via its own flow.
|
||||
"""
|
||||
try:
|
||||
cluster_ids = [r["id"] for r in await conn.fetch(
|
||||
"SELECT id FROM haproxy_clusters WHERE pool_id = "
|
||||
"(SELECT pool_id FROM vip_instances WHERE id=$1)", vip_id)]
|
||||
if not cluster_ids:
|
||||
return
|
||||
content, _meta = await render_vip_config_masked(conn, vip_id)
|
||||
content = content or f"# VIP {vip_id} ({action})\n"
|
||||
checksum = _md5(content)
|
||||
# uuid suffix makes the name collision-proof even for sub-second same-action re-edits
|
||||
# (UNIQUE(cluster_id, version_name) would otherwise reject a same-second retry; review LOW-1).
|
||||
version_name = f"vip-{vip_id}-{action}-{int(time.time())}-{uuid.uuid4().hex[:6]}"
|
||||
# Capture this change's PENDING state so undo-reject can faithfully re-stage it
|
||||
# (restore the VIP — reactivating it if a rejected create soft-deleted it).
|
||||
pending_state = await _capture_vip_pending_state(conn, vip_id)
|
||||
metadata = json.dumps({"pending_state": pending_state}) if pending_state else None
|
||||
for cid in cluster_ids:
|
||||
# Collapse repeated pre-apply edits to a single PENDING row per VIP+cluster.
|
||||
await conn.execute(
|
||||
"DELETE FROM config_versions WHERE cluster_id=$1 AND status='PENDING' "
|
||||
"AND version_name LIKE $2", cid, f"vip-{vip_id}-%")
|
||||
await conn.execute("""
|
||||
INSERT INTO config_versions
|
||||
(cluster_id, version_name, config_content, checksum, created_by, is_active, status, metadata)
|
||||
VALUES ($1,$2,$3,$4,$5,FALSE,'PENDING',$6::jsonb)
|
||||
""", cid, version_name, content, checksum, created_by, metadata)
|
||||
except Exception as e: # noqa: BLE001 — versioning is a UI convenience, never block the op
|
||||
logger.warning(f"_stage_vip_version({vip_id},{action}) failed: {e}")
|
||||
|
||||
|
||||
async def _capture_vip_pending_state(conn, vip_id: int):
|
||||
"""Snapshot a VIP's current (pending) field + member state for undo-reject. The VRRP
|
||||
secret travels only as its encrypted token (never plaintext)."""
|
||||
v = await conn.fetchrow("SELECT * FROM vip_instances WHERE id=$1", vip_id)
|
||||
if not v:
|
||||
return None
|
||||
members = await conn.fetch(
|
||||
"SELECT agent_id, network_interface, role, priority FROM vip_members WHERE vip_id=$1", vip_id)
|
||||
return {
|
||||
"vip": {"name": v["name"], "description": v["description"], "virtual_ip": v["virtual_ip"],
|
||||
"prefix_length": v["prefix_length"], "virtual_router_id": v["virtual_router_id"],
|
||||
"advert_int": v["advert_int"], "use_unicast": v["use_unicast"],
|
||||
"track_haproxy": v["track_haproxy"], "auth_pass_encrypted": v["auth_pass_encrypted"]},
|
||||
"members": [{"agent_id": m["agent_id"], "network_interface": m["network_interface"],
|
||||
"role": m["role"], "priority": m["priority"]} for m in members],
|
||||
}
|
||||
|
||||
|
||||
async def restore_vip_from_rejected_version(conn, version_id: int):
|
||||
"""Undo-reject for a vip-* version: re-stage the rejected change as PENDING from the
|
||||
version's captured `pending_state`. Reactivates the VIP if a rejected create soft-deleted
|
||||
it, or re-applies a rejected edit's members — preserving each remaining member's
|
||||
last-applied snapshot so a running VIP is never torn down (T-1). Returns None on success
|
||||
or a human-readable error string (e.g. the name/address/VRID was reused since reject).
|
||||
"""
|
||||
row = await conn.fetchrow("SELECT version_name, metadata FROM config_versions WHERE id=$1", version_id)
|
||||
if not row:
|
||||
return "version not found"
|
||||
m = re.match(r"vip-(\d+)-", row["version_name"] or "")
|
||||
if not m:
|
||||
return "not a VIP version"
|
||||
vip_id = int(m.group(1))
|
||||
# Undo the reject of a DELETE request → re-arm the staged deletion (the VIP keeps running
|
||||
# until re-approved; nothing on the node changes). Requires the VIP to still be active.
|
||||
if "-delete-" in (row["version_name"] or ""):
|
||||
if not await conn.fetchrow("SELECT 1 FROM vip_instances WHERE id=$1 AND is_active=TRUE", vip_id):
|
||||
return "the VIP is no longer active — nothing to re-stage for deletion"
|
||||
await conn.execute(
|
||||
"UPDATE vip_instances SET pending_delete=TRUE, last_config_status='PENDING', "
|
||||
"updated_at=CURRENT_TIMESTAMP WHERE id=$1", vip_id)
|
||||
await conn.execute(
|
||||
"UPDATE config_versions SET status='PENDING', is_active=FALSE, updated_at=CURRENT_TIMESTAMP "
|
||||
"WHERE version_name=$1 AND status='REJECTED'", row["version_name"])
|
||||
return None
|
||||
meta = row["metadata"]
|
||||
meta = json.loads(meta) if isinstance(meta, str) else meta
|
||||
ps = (meta or {}).get("pending_state")
|
||||
if not ps:
|
||||
return ("this VIP change predates undo support — re-create or re-edit the VIP "
|
||||
"from the HA/VIP page")
|
||||
if not await conn.fetchrow("SELECT 1 FROM vip_instances WHERE id=$1", vip_id):
|
||||
return "the VIP no longer exists — re-create it from the HA/VIP page"
|
||||
sv, sm = ps["vip"], ps.get("members", [])
|
||||
# One-VIP-per-agent must STILL hold after reactivation: if a member was meanwhile added
|
||||
# to another active VIP (while this one was rejected/soft-deleted), undoing here would
|
||||
# create a double-membership — and since the delivery endpoint serves one VIP per agent,
|
||||
# reactivating this (never-applied → not_configured) VIP would tear down the other live
|
||||
# VIP on that node. Block it with a clear message (review round-3 FINDING 2).
|
||||
member_ids = [m["agent_id"] for m in sm]
|
||||
if member_ids:
|
||||
dup = await conn.fetchrow(
|
||||
"SELECT a.name AS agent_name, v.name AS vip_name FROM vip_members vm "
|
||||
"JOIN vip_instances v ON v.id = vm.vip_id JOIN agents a ON a.id = vm.agent_id "
|
||||
"WHERE vm.agent_id = ANY($1) AND v.is_active = TRUE AND v.id <> $2 LIMIT 1",
|
||||
member_ids, vip_id)
|
||||
if dup:
|
||||
return (f"node '{dup['agent_name']}' now belongs to active VIP '{dup['vip_name']}' — "
|
||||
f"remove it there first, then undo")
|
||||
# Preserve the running (applied) snapshot for members that remain, so undo of an edit
|
||||
# never momentarily flips a live node to not_configured (T-1).
|
||||
prev = {r["agent_id"]: r for r in await conn.fetch(
|
||||
"SELECT agent_id, applied_config_content, applied_config_hash, last_deploy_state, "
|
||||
"last_deploy_message, last_deploy_hash, last_deploy_at FROM vip_members WHERE vip_id=$1", vip_id)}
|
||||
try:
|
||||
async with conn.transaction():
|
||||
await conn.execute("""
|
||||
UPDATE vip_instances SET name=$2, description=$3, virtual_ip=$4, prefix_length=$5,
|
||||
virtual_router_id=$6, advert_int=$7, use_unicast=$8, track_haproxy=$9,
|
||||
auth_pass_encrypted=$10, is_active=TRUE, last_config_status='PENDING',
|
||||
updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=$1
|
||||
""", vip_id, sv["name"], sv.get("description"), sv["virtual_ip"], sv["prefix_length"],
|
||||
sv["virtual_router_id"], sv["advert_int"], sv["use_unicast"], sv["track_haproxy"],
|
||||
sv.get("auth_pass_encrypted"))
|
||||
await conn.execute("DELETE FROM vip_members WHERE vip_id=$1", vip_id)
|
||||
for mm in sm:
|
||||
o = prev.get(mm["agent_id"])
|
||||
await conn.execute("""
|
||||
INSERT INTO vip_members (vip_id, agent_id, network_interface, role, priority,
|
||||
applied_config_content, applied_config_hash,
|
||||
last_deploy_state, last_deploy_message, last_deploy_hash, last_deploy_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
|
||||
""", vip_id, mm["agent_id"], mm["network_interface"], mm["role"], mm["priority"],
|
||||
o["applied_config_content"] if o else None, o["applied_config_hash"] if o else None,
|
||||
o["last_deploy_state"] if o else None, o["last_deploy_message"] if o else None,
|
||||
o["last_deploy_hash"] if o else None, o["last_deploy_at"] if o else None)
|
||||
# A multi-cluster pool stages one row per cluster under the SAME version_name;
|
||||
# flip them all back to PENDING so every affected cluster's Apply Management shows
|
||||
# the restored change (mirrors the SSL auto-undo). is_active stays FALSE (review MED-2).
|
||||
await conn.execute(
|
||||
"UPDATE config_versions SET status='PENDING', is_active=FALSE, "
|
||||
"updated_at=CURRENT_TIMESTAMP WHERE version_name=$1 AND status='REJECTED'",
|
||||
row["version_name"])
|
||||
except Exception as e: # noqa: BLE001
|
||||
if "unique" in str(e).lower() or "duplicate" in str(e).lower():
|
||||
return "the VIP's name, address or VRID was reused after it was rejected"
|
||||
raise
|
||||
return None
|
||||
|
||||
|
||||
async def _transition_vip_versions(conn, vip_id: int, new_status: str):
|
||||
"""Move this VIP's PENDING config_versions to APPLIED/REJECTED (is_active stays FALSE).
|
||||
Called by the VIP apply/reject endpoints so the right-panel version tracks the VIP."""
|
||||
try:
|
||||
await conn.execute(
|
||||
"UPDATE config_versions SET status=$2, is_active=FALSE, updated_at=CURRENT_TIMESTAMP "
|
||||
"WHERE status='PENDING' AND version_name LIKE $1", f"vip-{vip_id}-%", new_status)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"_transition_vip_versions({vip_id},{new_status}) failed: {e}")
|
||||
|
||||
|
||||
def _jsonb_list(v):
|
||||
"""agents.capabilities/network_interfaces are JSONB; asyncpg returns them as a raw
|
||||
JSON string (no codec). Normalize to a Python list."""
|
||||
if isinstance(v, list):
|
||||
return v
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
parsed = json.loads(v)
|
||||
return parsed if isinstance(parsed, list) else []
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
return []
|
||||
|
||||
|
||||
def _capable(capabilities) -> bool:
|
||||
return "keepalived_management" in _jsonb_list(capabilities)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# List / read
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.get("")
|
||||
async def list_vips(cluster_id: Optional[int] = None, authorization: str = Header(None)):
|
||||
"""List VIPs with their members + live MASTER/BACKUP state (never the secret).
|
||||
|
||||
Optional cluster_id scopes to VIPs in that cluster's pool — used by the Apply
|
||||
Management page (which is cluster-scoped) to surface pending VIP changes.
|
||||
"""
|
||||
await _require(authorization, "read")
|
||||
conn = await get_database_connection()
|
||||
try:
|
||||
# Show active VIPs PLUS approved-but-still-tearing-down ones (is_active=FALSE with a
|
||||
# member that hasn't acked 'disabled' yet) so the operator can TRACK a deletion through
|
||||
# to completion; a VIP drops off only once every member has torn keepalived down.
|
||||
# The teardown-tracking clause is gated on last_config_status='APPLIED' so it ONLY shows
|
||||
# deletions APPROVED via the new flow — a VIP soft-deleted under the old immediate-delete
|
||||
# (pre-1.7.2: is_active=FALSE, last_config_status='PENDING') is NOT resurfaced (backward
|
||||
# compat). Rejected never-applied creates (also PENDING) are likewise excluded.
|
||||
_visible = ("(v.is_active = TRUE OR (v.last_config_status = 'APPLIED' AND EXISTS ("
|
||||
"SELECT 1 FROM vip_members mm WHERE mm.vip_id = v.id "
|
||||
"AND mm.applied_config_hash IS NOT NULL "
|
||||
"AND mm.last_deploy_state IS DISTINCT FROM 'disabled')))")
|
||||
if cluster_id is not None:
|
||||
vips = await conn.fetch(f"""
|
||||
SELECT v.id, v.name, v.description, v.pool_id, v.virtual_ip, v.prefix_length,
|
||||
v.virtual_router_id, v.advert_int, v.use_unicast, v.track_haproxy,
|
||||
v.is_active, v.last_config_status, v.pending_delete,
|
||||
(v.auth_pass_encrypted IS NOT NULL) AS auth_pass_set,
|
||||
v.created_at, v.updated_at, p.name AS pool_name
|
||||
FROM vip_instances v
|
||||
LEFT JOIN haproxy_cluster_pools p ON p.id = v.pool_id
|
||||
WHERE {_visible}
|
||||
AND v.pool_id = (SELECT pool_id FROM haproxy_clusters WHERE id = $1)
|
||||
ORDER BY v.name
|
||||
""", cluster_id)
|
||||
else:
|
||||
vips = await conn.fetch(f"""
|
||||
SELECT v.id, v.name, v.description, v.pool_id, v.virtual_ip, v.prefix_length,
|
||||
v.virtual_router_id, v.advert_int, v.use_unicast, v.track_haproxy,
|
||||
v.is_active, v.last_config_status, v.pending_delete,
|
||||
(v.auth_pass_encrypted IS NOT NULL) AS auth_pass_set,
|
||||
v.created_at, v.updated_at, p.name AS pool_name
|
||||
FROM vip_instances v
|
||||
LEFT JOIN haproxy_cluster_pools p ON p.id = v.pool_id
|
||||
WHERE {_visible}
|
||||
ORDER BY v.name
|
||||
""")
|
||||
result = []
|
||||
for v in vips:
|
||||
members = await conn.fetch("""
|
||||
SELECT m.id, m.agent_id, m.network_interface, m.role, m.priority,
|
||||
m.applied_config_hash, m.last_deploy_state, m.last_deploy_hash, m.last_deploy_at,
|
||||
a.name AS agent_name, a.status AS agent_status,
|
||||
a.keepalive_state, a.keepalive_ip, a.capabilities,
|
||||
a.ip_address
|
||||
FROM vip_members m
|
||||
LEFT JOIN agents a ON a.id = m.agent_id
|
||||
WHERE m.vip_id = $1
|
||||
ORDER BY m.priority DESC
|
||||
""", v["id"])
|
||||
deploy_status, deploy_synced, deploy_total = _derive_deploy_status(
|
||||
v["last_config_status"], members, v["pending_delete"], v["is_active"])
|
||||
result.append({
|
||||
**dict(v),
|
||||
# Convergence-aware status for the table (issue #27 follow-up); the raw
|
||||
# last_config_status is kept above for the PENDING gate / Apply button.
|
||||
"deploy_status": deploy_status,
|
||||
"deploy_synced": deploy_synced,
|
||||
"deploy_total": deploy_total,
|
||||
"members": [{
|
||||
"id": m["id"], "agent_id": m["agent_id"], "agent_name": m["agent_name"],
|
||||
"network_interface": m["network_interface"], "role": m["role"],
|
||||
"priority": m["priority"], "agent_status": m["agent_status"],
|
||||
"keepalive_state": m["keepalive_state"], "keepalive_ip": m["keepalive_ip"],
|
||||
"ip_address": str(m["ip_address"]) if m["ip_address"] else None,
|
||||
"keepalived_capable": _capable(m["capabilities"]),
|
||||
"last_deploy_state": m["last_deploy_state"],
|
||||
"last_deploy_at": m["last_deploy_at"].isoformat() if m["last_deploy_at"] else None,
|
||||
} for m in members],
|
||||
})
|
||||
return {"vips": result}
|
||||
except Exception as e: # noqa: BLE001 — degrade to empty rather than 500 (B-7)
|
||||
logger.error(f"list_vips failed: {e}")
|
||||
return {"vips": []}
|
||||
finally:
|
||||
await close_database_connection(conn)
|
||||
|
||||
|
||||
@router.get("/{vip_id}")
|
||||
async def get_vip(vip_id: int, authorization: str = Header(None)):
|
||||
await _require(authorization, "read")
|
||||
conn = await get_database_connection()
|
||||
try:
|
||||
v = await conn.fetchrow("""
|
||||
SELECT v.id, v.name, v.description, v.pool_id, v.virtual_ip, v.prefix_length,
|
||||
v.virtual_router_id, v.advert_int, v.use_unicast, v.track_haproxy,
|
||||
v.is_active, v.last_config_status,
|
||||
(v.auth_pass_encrypted IS NOT NULL) AS auth_pass_set,
|
||||
v.created_at, v.updated_at
|
||||
FROM vip_instances v WHERE v.id = $1 AND v.is_active = TRUE
|
||||
""", vip_id)
|
||||
if not v:
|
||||
raise HTTPException(status_code=404, detail="VIP not found")
|
||||
members = await conn.fetch("""
|
||||
SELECT m.agent_id, m.network_interface, m.role, m.priority,
|
||||
m.last_deploy_state, m.last_deploy_at,
|
||||
a.name AS agent_name, a.keepalive_state, a.keepalive_ip
|
||||
FROM vip_members m LEFT JOIN agents a ON a.id = m.agent_id
|
||||
WHERE m.vip_id = $1 ORDER BY m.priority DESC
|
||||
""", vip_id)
|
||||
return {**dict(v), "members": [dict(m) for m in members]}
|
||||
finally:
|
||||
await close_database_connection(conn)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Create / update / delete
|
||||
# ---------------------------------------------------------------------------
|
||||
async def _validate_members_against_pool(conn, pool_id: int, members: List, vip_virtual_ip: str,
|
||||
exclude_vip_id: Optional[int] = None):
|
||||
"""Members must belong to the VIP's pool; VIP must not collide with an agent IP
|
||||
or another live VIP (T-4/SQL-1)."""
|
||||
# pool membership
|
||||
for m in members:
|
||||
row = await conn.fetchrow("SELECT pool_id FROM agents WHERE id=$1", m.agent_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=400, detail=f"agent {m.agent_id} not found")
|
||||
if row["pool_id"] != pool_id:
|
||||
raise HTTPException(status_code=400,
|
||||
detail=f"agent {m.agent_id} is not in pool {pool_id}")
|
||||
# One active VIP per agent (v1): a node deploys a single keepalived.conf, and the
|
||||
# delivery endpoint serves one VIP per agent — a second active membership would never
|
||||
# converge (stuck SYNCING) instead of erroring. Reject it up front (review MED-2).
|
||||
agent_ids = [m.agent_id for m in members]
|
||||
if agent_ids:
|
||||
dq = ("SELECT a.name AS agent_name, v.name AS vip_name "
|
||||
"FROM vip_members vm JOIN vip_instances v ON v.id = vm.vip_id "
|
||||
"JOIN agents a ON a.id = vm.agent_id "
|
||||
"WHERE vm.agent_id = ANY($1) AND v.is_active = TRUE")
|
||||
dparams = [agent_ids]
|
||||
if exclude_vip_id is not None:
|
||||
dq += " AND v.id <> $2"
|
||||
dparams.append(exclude_vip_id)
|
||||
dup = await conn.fetchrow(dq + " LIMIT 1", *dparams)
|
||||
if dup:
|
||||
raise HTTPException(status_code=409,
|
||||
detail=f"node '{dup['agent_name']}' is already a member of VIP "
|
||||
f"'{dup['vip_name']}' — a node can belong to only one VIP")
|
||||
# VIP must not be an existing agent's primary IP (INET cast, SQL-1)
|
||||
clash = await conn.fetchval("SELECT 1 FROM agents WHERE ip_address = $1::inet LIMIT 1", vip_virtual_ip)
|
||||
if clash:
|
||||
raise HTTPException(status_code=409, detail=f"{vip_virtual_ip} is already a node's IP")
|
||||
# …or another live VIP's address
|
||||
q = "SELECT 1 FROM vip_instances WHERE virtual_ip=$1 AND is_active=TRUE"
|
||||
params = [vip_virtual_ip]
|
||||
if exclude_vip_id is not None:
|
||||
q += " AND id <> $2"
|
||||
params.append(exclude_vip_id)
|
||||
if await conn.fetchval(q, *params):
|
||||
raise HTTPException(status_code=409, detail=f"{vip_virtual_ip} is already used by another VIP")
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_vip(payload: VIPCreate, request: Request, authorization: str = Header(None)):
|
||||
current_user = await _require(authorization, "create")
|
||||
conn = await get_database_connection()
|
||||
try:
|
||||
pool = await conn.fetchrow("SELECT id FROM haproxy_cluster_pools WHERE id=$1", payload.pool_id)
|
||||
if not pool:
|
||||
raise HTTPException(status_code=400, detail=f"pool {payload.pool_id} not found")
|
||||
await _validate_members_against_pool(conn, payload.pool_id, payload.members, payload.virtual_ip)
|
||||
|
||||
enc = encrypt_vrrp_secret(payload.auth_pass) if payload.auth_pass else None
|
||||
# Allocate VRID + insert, retrying once on a unique-violation race (B-1).
|
||||
last_err = None
|
||||
for _attempt in range(2):
|
||||
vrid = await _alloc_free_vrid(conn, payload.pool_id, payload.virtual_router_id)
|
||||
try:
|
||||
async with conn.transaction():
|
||||
vip_id = await conn.fetchval("""
|
||||
INSERT INTO vip_instances
|
||||
(name, description, pool_id, virtual_ip, prefix_length, virtual_router_id,
|
||||
advert_int, auth_pass_encrypted, use_unicast, track_haproxy,
|
||||
is_active, last_config_status, created_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,TRUE,'PENDING',$11)
|
||||
RETURNING id
|
||||
""", payload.name, payload.description, payload.pool_id, payload.virtual_ip,
|
||||
payload.prefix_length, vrid, payload.advert_int, enc,
|
||||
payload.use_unicast, payload.track_haproxy, current_user["id"])
|
||||
for m in payload.members:
|
||||
await conn.execute("""
|
||||
INSERT INTO vip_members (vip_id, agent_id, network_interface, role, priority)
|
||||
VALUES ($1,$2,$3,$4,$5)
|
||||
""", vip_id, m.agent_id, m.network_interface, m.role, m.priority)
|
||||
last_err = None
|
||||
break
|
||||
except Exception as ie: # noqa: BLE001
|
||||
if "unique" in str(ie).lower() or "duplicate" in str(ie).lower():
|
||||
last_err = ie
|
||||
if payload.virtual_router_id is not None:
|
||||
raise HTTPException(status_code=409, detail="VIP name/address/VRID already in use")
|
||||
continue # auto-VRID race → retry allocation
|
||||
raise
|
||||
if last_err is not None:
|
||||
raise HTTPException(status_code=409, detail="VIP create conflict (name/address/VRID)")
|
||||
|
||||
# Stage a standard PENDING config_version so the change shows in Apply Management.
|
||||
await _stage_vip_version(conn, vip_id, "create", current_user["id"])
|
||||
await log_user_activity(
|
||||
user_id=current_user["id"], action="create", resource_type="vip",
|
||||
resource_id=str(vip_id),
|
||||
details={"name": payload.name, "virtual_ip": payload.virtual_ip,
|
||||
"pool_id": payload.pool_id, "vrid": vrid, "members": len(payload.members)},
|
||||
ip_address=_client_ip(request), user_agent=_user_agent(request))
|
||||
return {"id": vip_id, "message": "VIP created (PENDING — apply from Apply Management)"}
|
||||
finally:
|
||||
await close_database_connection(conn)
|
||||
|
||||
|
||||
@router.put("/{vip_id}")
|
||||
async def update_vip(vip_id: int, payload: VIPUpdate, request: Request, authorization: str = Header(None)):
|
||||
current_user = await _require(authorization, "update")
|
||||
conn = await get_database_connection()
|
||||
try:
|
||||
v = await conn.fetchrow("SELECT * FROM vip_instances WHERE id=$1 AND is_active=TRUE", vip_id)
|
||||
if not v:
|
||||
raise HTTPException(status_code=404, detail="VIP not found")
|
||||
new_ip = payload.virtual_ip or v["virtual_ip"]
|
||||
members = payload.members if payload.members is not None else None
|
||||
if members is not None:
|
||||
await _validate_members_against_pool(conn, v["pool_id"], members, new_ip, exclude_vip_id=vip_id)
|
||||
elif payload.virtual_ip:
|
||||
# Address-only change: re-check the VIP isn't a node IP or another live VIP.
|
||||
clash = await conn.fetchval("SELECT 1 FROM agents WHERE ip_address=$1::inet LIMIT 1", new_ip)
|
||||
if clash:
|
||||
raise HTTPException(status_code=409, detail=f"{new_ip} is already a node's IP")
|
||||
dup = await conn.fetchval(
|
||||
"SELECT 1 FROM vip_instances WHERE virtual_ip=$1 AND is_active=TRUE AND id<>$2", new_ip, vip_id)
|
||||
if dup:
|
||||
raise HTTPException(status_code=409, detail=f"{new_ip} is already used by another VIP")
|
||||
|
||||
enc_set = payload.auth_pass is not None and payload.auth_pass != ""
|
||||
try:
|
||||
async with conn.transaction():
|
||||
await conn.execute("""
|
||||
UPDATE vip_instances SET
|
||||
name = COALESCE($2, name),
|
||||
description = COALESCE($3, description),
|
||||
virtual_ip = COALESCE($4, virtual_ip),
|
||||
prefix_length = COALESCE($5, prefix_length),
|
||||
virtual_router_id = COALESCE($6, virtual_router_id),
|
||||
advert_int = COALESCE($7, advert_int),
|
||||
use_unicast = COALESCE($8, use_unicast),
|
||||
track_haproxy = COALESCE($9, track_haproxy),
|
||||
auth_pass_encrypted = CASE WHEN $10 THEN $11 ELSE auth_pass_encrypted END,
|
||||
last_config_status = 'PENDING',
|
||||
-- Editing a VIP means you are KEEPING and changing it, so it cancels any
|
||||
-- staged deletion (otherwise a later Apply would delete instead of applying
|
||||
-- the edit). The edit then becomes the pending change to approve.
|
||||
pending_delete = FALSE,
|
||||
purge_on_teardown = FALSE,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
""", vip_id, payload.name, payload.description, payload.virtual_ip,
|
||||
payload.prefix_length, payload.virtual_router_id, payload.advert_int,
|
||||
payload.use_unicast, payload.track_haproxy,
|
||||
enc_set, encrypt_vrrp_secret(payload.auth_pass) if enc_set else None)
|
||||
if members is not None:
|
||||
# T-1: preserve the APPLIED snapshot + deploy ack for members that REMAIN,
|
||||
# so a pending member edit never momentarily flips a running node to
|
||||
# not_configured (which would self-heal-teardown a live VIP). The new
|
||||
# config only goes live on the next Apply. A REMOVED member loses its row
|
||||
# → delivery returns not_configured → marker self-heal teardown (correct:
|
||||
# it's no longer part of the VIP). A NEW member starts with a NULL snapshot.
|
||||
prev = {r["agent_id"]: r for r in await conn.fetch(
|
||||
"SELECT agent_id, applied_config_content, applied_config_hash, "
|
||||
"last_deploy_state, last_deploy_message, last_deploy_hash, last_deploy_at "
|
||||
"FROM vip_members WHERE vip_id=$1", vip_id)}
|
||||
await conn.execute("DELETE FROM vip_members WHERE vip_id=$1", vip_id)
|
||||
for m in members:
|
||||
o = prev.get(m.agent_id)
|
||||
await conn.execute("""
|
||||
INSERT INTO vip_members (vip_id, agent_id, network_interface, role, priority,
|
||||
applied_config_content, applied_config_hash,
|
||||
last_deploy_state, last_deploy_message, last_deploy_hash, last_deploy_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
|
||||
""", vip_id, m.agent_id, m.network_interface, m.role, m.priority,
|
||||
o["applied_config_content"] if o else None,
|
||||
o["applied_config_hash"] if o else None,
|
||||
o["last_deploy_state"] if o else None,
|
||||
o["last_deploy_message"] if o else None,
|
||||
o["last_deploy_hash"] if o else None,
|
||||
o["last_deploy_at"] if o else None)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as ie: # noqa: BLE001
|
||||
if "unique" in str(ie).lower() or "duplicate" in str(ie).lower():
|
||||
raise HTTPException(status_code=409, detail="VIP name/address/VRID already in use")
|
||||
raise
|
||||
# Re-stage the standard PENDING config_version reflecting the edited config.
|
||||
await _stage_vip_version(conn, vip_id, "update", current_user["id"])
|
||||
await log_user_activity(
|
||||
user_id=current_user["id"], action="update", resource_type="vip",
|
||||
resource_id=str(vip_id), details={"vip_id": vip_id},
|
||||
ip_address=_client_ip(request), user_agent=_user_agent(request))
|
||||
return {"message": "VIP updated (PENDING — apply from Apply Management)"}
|
||||
finally:
|
||||
await close_database_connection(conn)
|
||||
|
||||
|
||||
@router.delete("/{vip_id}")
|
||||
async def delete_vip(vip_id: int, request: Request, purge_package: bool = False,
|
||||
authorization: str = Header(None)):
|
||||
"""Request VIP deletion — APPROVAL-GATED for safety.
|
||||
|
||||
Deleting a *running* (already-applied) VIP does NOT take effect immediately: it is STAGED
|
||||
for Apply Management (pending_delete=TRUE + a vip-*-delete version) and the VIP keeps
|
||||
running — is_active stays TRUE, agents keep serving it, NOTHING is torn down — until the
|
||||
operator APPROVES the deletion. Rejecting it leaves the VIP running, untouched. Only the
|
||||
approval flips is_active=FALSE and lets the agents tear keepalived down. So a misclick can
|
||||
never tear down a production VIP, and an agent never deletes without an explicit approval.
|
||||
|
||||
A VIP that was NEVER applied (not deployed to any node) is removed immediately — there is
|
||||
nothing running to tear down. purge_package opts into uninstalling the keepalived package
|
||||
on teardown (default keeps it) and is honoured only once the deletion is approved, and only
|
||||
on nodes where WE installed it (the agent's install marker), never an admin's package.
|
||||
"""
|
||||
current_user = await _require(authorization, "delete")
|
||||
conn = await get_database_connection()
|
||||
try:
|
||||
v = await conn.fetchrow(
|
||||
"SELECT id, name, applied_snapshot FROM vip_instances WHERE id=$1 AND is_active=TRUE", vip_id)
|
||||
if not v:
|
||||
raise HTTPException(status_code=404, detail="VIP not found")
|
||||
|
||||
if v["applied_snapshot"] is None:
|
||||
# Never deployed to any node — removing it affects nothing, so do it at once.
|
||||
await conn.execute(
|
||||
"UPDATE vip_instances SET is_active=FALSE, last_config_status='PENDING', "
|
||||
"pending_delete=FALSE, purge_on_teardown=$2, updated_at=CURRENT_TIMESTAMP WHERE id=$1",
|
||||
vip_id, bool(purge_package))
|
||||
await conn.execute(
|
||||
"DELETE FROM config_versions WHERE status='PENDING' AND version_name LIKE $1",
|
||||
f"vip-{vip_id}-%")
|
||||
await log_user_activity(
|
||||
user_id=current_user["id"], action="delete", resource_type="vip",
|
||||
resource_id=str(vip_id), details={"name": v["name"], "never_applied": True},
|
||||
ip_address=_client_ip(request), user_agent=_user_agent(request))
|
||||
return {"message": "VIP removed — it was never applied, so no node was affected.",
|
||||
"staged": False}
|
||||
|
||||
# Running VIP → STAGE the deletion for approval. is_active stays TRUE (no teardown yet);
|
||||
# the agent keeps serving the VIP until the operator approves in Apply Management.
|
||||
await conn.execute(
|
||||
"UPDATE vip_instances SET pending_delete=TRUE, purge_on_teardown=$2, "
|
||||
"last_config_status='PENDING', updated_at=CURRENT_TIMESTAMP WHERE id=$1",
|
||||
vip_id, bool(purge_package))
|
||||
await _stage_vip_version(conn, vip_id, "delete", current_user["id"])
|
||||
await log_user_activity(
|
||||
user_id=current_user["id"], action="delete-requested", resource_type="vip",
|
||||
resource_id=str(vip_id), details={"name": v["name"], "purge_package": bool(purge_package)},
|
||||
ip_address=_client_ip(request), user_agent=_user_agent(request))
|
||||
return {"message": ("Deletion staged for approval — the VIP keeps running until you APPROVE it "
|
||||
"in Apply Management; reject to keep it. Nothing changes on the node until "
|
||||
"you approve."),
|
||||
"staged": True, "purge_package": bool(purge_package)}
|
||||
finally:
|
||||
await close_database_connection(conn)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Apply (isolated) + status
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.post("/{vip_id}/apply")
|
||||
async def apply_vip(vip_id: int, request: Request, authorization: str = Header(None)):
|
||||
current_user = await _require(authorization, "apply")
|
||||
conn = await get_database_connection()
|
||||
try:
|
||||
v = await conn.fetchrow("SELECT * FROM vip_instances WHERE id=$1 AND is_active=TRUE", vip_id)
|
||||
if not v:
|
||||
raise HTTPException(status_code=404, detail="VIP not found")
|
||||
# APPROVED DELETION: if a deletion was staged for this VIP, approving it here performs the
|
||||
# actual delete — flip is_active=FALSE so the agents tear keepalived down on their next
|
||||
# poll (honouring purge_on_teardown). Until this moment the VIP kept running untouched, so
|
||||
# the teardown happens ONLY after this explicit human approval.
|
||||
if v["pending_delete"]:
|
||||
async with conn.transaction():
|
||||
await conn.execute(
|
||||
"UPDATE vip_instances SET is_active=FALSE, pending_delete=FALSE, "
|
||||
"last_config_status='APPLIED', updated_at=CURRENT_TIMESTAMP WHERE id=$1", vip_id)
|
||||
await _transition_vip_versions(conn, vip_id, "APPLIED")
|
||||
await log_user_activity(
|
||||
user_id=current_user["id"], action="delete", resource_type="vip", resource_id=str(vip_id),
|
||||
details={"name": v["name"], "approved_delete": True,
|
||||
"purge_package": bool(v["purge_on_teardown"])},
|
||||
ip_address=_client_ip(request), user_agent=_user_agent(request))
|
||||
msg = "VIP deletion approved — member nodes will stop keepalived and release the VIP on their next poll"
|
||||
if v["purge_on_teardown"]:
|
||||
msg += "; the keepalived package will be uninstalled on nodes where we installed it"
|
||||
return {"message": msg, "deleted": True}
|
||||
members = await conn.fetch("""
|
||||
SELECT m.id, m.agent_id, m.network_interface, m.role, m.priority, a.ip_address
|
||||
FROM vip_members m LEFT JOIN agents a ON a.id = m.agent_id
|
||||
WHERE m.vip_id = $1
|
||||
""", vip_id)
|
||||
if len(members) < 1:
|
||||
raise HTTPException(status_code=400, detail="VIP needs at least 1 member")
|
||||
masters = [m for m in members if m["role"] == "MASTER"]
|
||||
if len(masters) != 1:
|
||||
raise HTTPException(status_code=400, detail="exactly one member must be MASTER")
|
||||
if any(m["ip_address"] is None for m in members):
|
||||
raise HTTPException(status_code=400,
|
||||
detail="every member must have a reported IP before apply (unicast peers)")
|
||||
master_prio = masters[0]["priority"]
|
||||
if any(m["role"] == "BACKUP" and m["priority"] >= master_prio for m in members):
|
||||
raise HTTPException(status_code=400, detail="MASTER priority must exceed every BACKUP")
|
||||
|
||||
auth_plain = decrypt_vrrp_secret(v["auth_pass_encrypted"]) if v["auth_pass_encrypted"] else None
|
||||
# If a secret is set but can't be decrypted (SECRET_KEY/VIP_ENCRYPTION_KEY rotated or
|
||||
# drifted between pods), FAIL the apply rather than silently rendering a config with NO
|
||||
# VRRP authentication — that would be a silent security downgrade and a guaranteed
|
||||
# MASTER/BACKUP auth mismatch with any node still holding the old config (review MED-1).
|
||||
if v["auth_pass_encrypted"] and not auth_plain:
|
||||
raise HTTPException(status_code=409,
|
||||
detail="VRRP secret could not be decrypted (encryption key changed?) — "
|
||||
"re-enter the VRRP secret on the VIP, then apply again")
|
||||
check_script = build_haproxy_check_script() if v["track_haproxy"] else ""
|
||||
member_dicts = [{"role": m["role"], "priority": m["priority"],
|
||||
"network_interface": m["network_interface"],
|
||||
"agent_id": m["agent_id"],
|
||||
"ip_address": str(m["ip_address"])} for m in members]
|
||||
vip_dict = {"id": v["id"], "name": v["name"], "virtual_ip": v["virtual_ip"],
|
||||
"prefix_length": v["prefix_length"], "virtual_router_id": v["virtual_router_id"],
|
||||
"advert_int": v["advert_int"], "use_unicast": v["use_unicast"],
|
||||
"track_haproxy": v["track_haproxy"]}
|
||||
|
||||
snapshot_members = []
|
||||
async with conn.transaction():
|
||||
for m in members:
|
||||
this_agent = next(d for d in member_dicts if d["agent_id"] == m["agent_id"])
|
||||
peer_ips = [d["ip_address"] for d in member_dicts if d["agent_id"] != m["agent_id"]]
|
||||
conf = render_keepalived_conf(vip=vip_dict, members=member_dicts,
|
||||
this_agent=this_agent, peer_ips=peer_ips,
|
||||
auth_pass_plain=auth_plain)
|
||||
chash = _md5(conf)
|
||||
await conn.execute("""
|
||||
UPDATE vip_members
|
||||
SET applied_config_content=$2, applied_config_hash=$3, updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=$1
|
||||
""", m["id"], conf, chash)
|
||||
snapshot_members.append({
|
||||
"agent_id": m["agent_id"], "network_interface": m["network_interface"],
|
||||
"role": m["role"], "priority": m["priority"],
|
||||
"applied_config_content": conf, "applied_config_hash": chash})
|
||||
# Capture the field-level applied state so a later pending edit can be REJECTED
|
||||
# and fully reverted to exactly this state (auth secret stored encrypted, never plain).
|
||||
applied_snapshot = {
|
||||
"vip": {"name": v["name"], "description": v["description"], "virtual_ip": v["virtual_ip"],
|
||||
"prefix_length": v["prefix_length"], "virtual_router_id": v["virtual_router_id"],
|
||||
"advert_int": v["advert_int"], "use_unicast": v["use_unicast"],
|
||||
"track_haproxy": v["track_haproxy"], "auth_pass_encrypted": v["auth_pass_encrypted"]},
|
||||
"members": snapshot_members}
|
||||
await conn.execute(
|
||||
"UPDATE vip_instances SET last_config_status='APPLIED', "
|
||||
"applied_snapshot=$2::jsonb, updated_at=CURRENT_TIMESTAMP WHERE id=$1",
|
||||
vip_id, json.dumps(applied_snapshot))
|
||||
|
||||
# Move the standard config_version PENDING → APPLIED (stays is_active=FALSE so it is
|
||||
# never served as haproxy.cfg). Keeps the right-panel version in lockstep with the VIP.
|
||||
await _transition_vip_versions(conn, vip_id, "APPLIED")
|
||||
await log_user_activity(
|
||||
user_id=current_user["id"], action="apply", resource_type="vip",
|
||||
resource_id=str(vip_id),
|
||||
details={"name": v["name"], "virtual_ip": v["virtual_ip"], "members": len(members)},
|
||||
ip_address=_client_ip(request), user_agent=_user_agent(request))
|
||||
# check_script is rendered but not stored on the vip row; the agent gets it via
|
||||
# the delivery endpoint (which rebuilds it). Returned here only for visibility.
|
||||
return {"message": "VIP applied — agents will converge on next poll",
|
||||
"members_rendered": len(members), "tracks_haproxy": bool(check_script)}
|
||||
finally:
|
||||
await close_database_connection(conn)
|
||||
|
||||
|
||||
@router.post("/{vip_id}/reject")
|
||||
async def reject_vip(vip_id: int, request: Request, authorization: str = Header(None)):
|
||||
"""Discard a VIP's PENDING changes and fully restore the last-APPLIED state — the
|
||||
isolated equivalent of the product's reject -> restore-to-previous. Restores both the
|
||||
vip_instances fields and the exact member set (with their delivered config snapshots)
|
||||
from `applied_snapshot`, so the agents keep running what they already have (no churn).
|
||||
A never-applied PENDING VIP (no snapshot) is SOFT-deleted (is_active=FALSE) and its
|
||||
staged version marked REJECTED — so the change is reversible from the Rejected tab
|
||||
(undo-reject reactivates it). Never touches the global haproxy.cfg apply flow.
|
||||
"""
|
||||
current_user = await _require(authorization, "update")
|
||||
conn = await get_database_connection()
|
||||
try:
|
||||
v = await conn.fetchrow("SELECT * FROM vip_instances WHERE id=$1 AND is_active=TRUE", vip_id)
|
||||
if not v:
|
||||
raise HTTPException(status_code=404, detail="VIP not found")
|
||||
# REJECT A STAGED DELETION: cancel it — the VIP keeps running exactly as before (it was
|
||||
# never touched; is_active was never flipped). Clear the delete + purge intent and mark
|
||||
# the staged version REJECTED. This is the "nothing happened" path the operator expects.
|
||||
if v["pending_delete"]:
|
||||
await conn.execute(
|
||||
"UPDATE vip_instances SET pending_delete=FALSE, purge_on_teardown=FALSE, "
|
||||
"last_config_status='APPLIED', updated_at=CURRENT_TIMESTAMP WHERE id=$1", vip_id)
|
||||
await _transition_vip_versions(conn, vip_id, "REJECTED")
|
||||
await log_user_activity(
|
||||
user_id=current_user["id"], action="reject", resource_type="vip", resource_id=str(vip_id),
|
||||
details={"name": v["name"], "delete_cancelled": True},
|
||||
ip_address=_client_ip(request), user_agent=_user_agent(request))
|
||||
return {"message": "Deletion rejected — the VIP keeps running unchanged."}
|
||||
if v["last_config_status"] != "PENDING":
|
||||
return {"message": "Nothing to reject — no pending changes"}
|
||||
|
||||
snap_raw = v["applied_snapshot"]
|
||||
snap = json.loads(snap_raw) if isinstance(snap_raw, str) else snap_raw
|
||||
if not snap:
|
||||
# Created but never applied -> reject SOFT-deletes the VIP (is_active=FALSE) and
|
||||
# marks its staged version REJECTED. The row + members are kept so undo-reject can
|
||||
# reactivate the exact VIP (no orphan). The partial unique indexes free its
|
||||
# name/address/VRID for reuse while it's inactive.
|
||||
await _transition_vip_versions(conn, vip_id, "REJECTED")
|
||||
await conn.execute(
|
||||
"UPDATE vip_instances SET is_active=FALSE, last_config_status='PENDING', "
|
||||
"updated_at=CURRENT_TIMESTAMP WHERE id=$1", vip_id)
|
||||
await log_user_activity(
|
||||
user_id=current_user["id"], action="reject", resource_type="vip", resource_id=str(vip_id),
|
||||
details={"name": v["name"], "discarded": True},
|
||||
ip_address=_client_ip(request), user_agent=_user_agent(request))
|
||||
return {"message": "Pending VIP rejected (undo from the Rejected tab to restore it)"}
|
||||
|
||||
sv = snap["vip"]
|
||||
sm = snap.get("members", [])
|
||||
try:
|
||||
async with conn.transaction():
|
||||
await conn.execute("""
|
||||
UPDATE vip_instances SET
|
||||
name=$2, description=$3, virtual_ip=$4, prefix_length=$5, virtual_router_id=$6,
|
||||
advert_int=$7, use_unicast=$8, track_haproxy=$9, auth_pass_encrypted=$10,
|
||||
last_config_status='APPLIED', updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=$1
|
||||
""", vip_id, sv["name"], sv.get("description"), sv["virtual_ip"], sv["prefix_length"],
|
||||
sv["virtual_router_id"], sv["advert_int"], sv["use_unicast"], sv["track_haproxy"],
|
||||
sv.get("auth_pass_encrypted"))
|
||||
await conn.execute("DELETE FROM vip_members WHERE vip_id=$1", vip_id)
|
||||
for m in sm:
|
||||
await conn.execute("""
|
||||
INSERT INTO vip_members (vip_id, agent_id, network_interface, role, priority,
|
||||
applied_config_content, applied_config_hash)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7)
|
||||
""", vip_id, m["agent_id"], m["network_interface"], m["role"], m["priority"],
|
||||
m.get("applied_config_content"), m.get("applied_config_hash"))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as ie: # noqa: BLE001
|
||||
if "unique" in str(ie).lower() or "duplicate" in str(ie).lower():
|
||||
raise HTTPException(status_code=409,
|
||||
detail="Cannot restore — the previous address/VRID was taken in the meantime")
|
||||
raise
|
||||
# Mark the standard config_version REJECTED (history); the VIP is back to APPLIED.
|
||||
await _transition_vip_versions(conn, vip_id, "REJECTED")
|
||||
await log_user_activity(
|
||||
user_id=current_user["id"], action="reject", resource_type="vip", resource_id=str(vip_id),
|
||||
details={"name": v["name"], "restored": True},
|
||||
ip_address=_client_ip(request), user_agent=_user_agent(request))
|
||||
return {"message": "Pending changes rejected — VIP restored to its last applied state"}
|
||||
finally:
|
||||
await close_database_connection(conn)
|
||||
|
||||
|
||||
@router.get("/{vip_id}/status")
|
||||
async def vip_status(vip_id: int, authorization: str = Header(None)):
|
||||
await _require(authorization, "read")
|
||||
conn = await get_database_connection()
|
||||
try:
|
||||
v = await conn.fetchrow("SELECT id, name, is_active, last_config_status FROM vip_instances WHERE id=$1", vip_id)
|
||||
if not v:
|
||||
raise HTTPException(status_code=404, detail="VIP not found")
|
||||
members = await conn.fetch("""
|
||||
SELECT m.agent_id, m.role, m.priority, m.network_interface,
|
||||
m.last_deploy_state, m.last_deploy_message, m.last_deploy_at, m.applied_config_hash,
|
||||
a.name AS agent_name, a.keepalive_state, a.keepalive_ip, a.status AS agent_status,
|
||||
a.capabilities
|
||||
FROM vip_members m LEFT JOIN agents a ON a.id = m.agent_id
|
||||
WHERE m.vip_id=$1 ORDER BY m.priority DESC
|
||||
""", vip_id)
|
||||
out = []
|
||||
for m in members:
|
||||
applied = m["applied_config_hash"]
|
||||
deploy = m["last_deploy_state"]
|
||||
capable = _capable(m["capabilities"])
|
||||
if applied and not deploy:
|
||||
converge = "awaiting agent (upgrade may be required)" if not capable else "converging"
|
||||
else:
|
||||
converge = deploy or "pending"
|
||||
out.append({
|
||||
"agent_name": m["agent_name"], "role": m["role"], "priority": m["priority"],
|
||||
"network_interface": m["network_interface"],
|
||||
"agent_status": m["agent_status"], "keepalive_state": m["keepalive_state"],
|
||||
"keepalive_ip": m["keepalive_ip"], "keepalived_capable": capable,
|
||||
"deploy_state": deploy, "deploy_message": m["last_deploy_message"],
|
||||
"deploy_at": m["last_deploy_at"].isoformat() if m["last_deploy_at"] else None,
|
||||
"convergence": converge,
|
||||
})
|
||||
return {"id": v["id"], "name": v["name"], "is_active": v["is_active"],
|
||||
"last_config_status": v["last_config_status"], "members": out}
|
||||
finally:
|
||||
await close_database_connection(conn)
|
||||
|
||||
|
||||
# NOTE: the keepalived config preview/diff is served by the STANDARD config-version diff
|
||||
# endpoint (cluster.py get_config_version_diff, vip-* branch) via render_vip_config_masked
|
||||
# above — there is no bespoke VIP preview endpoint, so VIP changes use the product's
|
||||
# standard "View Change" like every other entity (issue #27 follow-up).
|
||||
@@ -199,6 +199,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 +711,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 +732,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:
|
||||
|
||||
@@ -128,6 +128,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 == "":
|
||||
@@ -208,6 +222,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 +266,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 +319,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 +360,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 +402,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 +438,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 +474,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(
|
||||
|
||||
@@ -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"]
|
||||
@@ -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,156 @@
|
||||
"""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
|
||||
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)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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._token = (self.credentials.get("api_token") or "").strip()
|
||||
|
||||
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:
|
||||
return {"ok": False, "detail": str(exc)}
|
||||
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}")
|
||||
@@ -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
|
||||
@@ -908,6 +908,13 @@ async def generate_haproxy_config_for_cluster(cluster_id: int, conn: Optional[An
|
||||
"redirect": [], "use_be": [], "default_be": [],
|
||||
}
|
||||
_stick_table_emitted = False
|
||||
# Whether this frontend emits ANY stick-counter usage (`track-sc<N>` or an
|
||||
# `sc_*_rate(...)` fetch). If it does but no `stick-table` is declared, HAProxy
|
||||
# fatally rejects the WHOLE cluster config with "table '<frontend>' used but not
|
||||
# configured". This happens with rate-limit directives baked into a frontend's
|
||||
# stored fields (request_headers/options) by an older version or a config import.
|
||||
# We track it here and inject a default stick-table before flushing if needed.
|
||||
_sc_counter_used = False
|
||||
# Phase K Phase D follow-up (Bulgu #13) — same dedup
|
||||
# contract for `http-request track-sc<N> <fetch>` lines.
|
||||
# HAProxy only NEEDS one tracking call per
|
||||
@@ -931,9 +938,13 @@ async def generate_haproxy_config_for_cluster(cluster_id: int, conn: Optional[An
|
||||
correct frontend-block bucket. Idempotent for stick-table
|
||||
lines (R3.3 dedup) AND http-request track-sc<N> lines
|
||||
(Bulgu #13 dedup)."""
|
||||
nonlocal _stick_table_emitted
|
||||
nonlocal _stick_table_emitted, _sc_counter_used
|
||||
cat = _categorize_haproxy_directive(line)
|
||||
stripped = line.strip()
|
||||
# Any stick-counter usage (track-sc<N> write, or an sc_*_rate(...) fetch like
|
||||
# sc_http_req_rate(0)) requires a stick-table in this frontend.
|
||||
if "track-sc" in stripped or ("sc_" in stripped and "_rate(" in stripped):
|
||||
_sc_counter_used = True
|
||||
if cat == "stick":
|
||||
if _stick_table_emitted and stripped.startswith("stick-table"):
|
||||
logger.debug(
|
||||
@@ -1188,6 +1199,22 @@ async def generate_haproxy_config_for_cluster(cluster_id: int, conn: Optional[An
|
||||
else:
|
||||
logger.warning(f"Config Generation: No config lines generated for WAF rule '{waf_rule['name']}' (ID: {waf_rule['id']}, Type: {waf_rule['rule_type']})")
|
||||
|
||||
# Robustness fix: if this frontend uses a stick counter (track-sc<N> or an
|
||||
# sc_*_rate(...) fetch) but declared NO stick-table, inject a default one so HAProxy
|
||||
# doesn't fatally reject the whole cluster config with "table '<frontend>' used but
|
||||
# not configured". This rescues rate-limit directives baked into a frontend's stored
|
||||
# request_headers/options by an older version or import. Purely additive — it only
|
||||
# fires when a counter is used AND no table exists (a config that is invalid today),
|
||||
# so it never changes a frontend that already has a stick-table or doesn't rate-limit.
|
||||
if _sc_counter_used and not _stick_table_emitted:
|
||||
_fe_buckets["stick"].insert(
|
||||
0, " stick-table type ip size 100k expire 30s store http_req_rate(10s)")
|
||||
_stick_table_emitted = True
|
||||
logger.info(
|
||||
f"STICK-TABLE AUTO-INJECT: frontend '{frontend['name']}' uses a stick "
|
||||
f"counter (track-sc/sc_*_rate) but declared no stick-table; injected a "
|
||||
f"default so the config stays valid.")
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Flush the per-frontend buckets in canonical HAProxy order.
|
||||
# The order below is the single source of truth for emit
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Issue #27 — HA/VIP (Keepalived) management (v1.7.0).
|
||||
|
||||
Standalone, DB-free renderer for a node's /etc/keepalived/keepalived.conf and the
|
||||
HAProxy health-check script, plus Fernet at-rest encryption for the VRRP secret.
|
||||
|
||||
The router fetches DB rows and calls these pure functions; nothing here touches the
|
||||
database or logs secrets. Trivially unit-testable (see tests/test_keepalived_config.py).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import List, 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__)
|
||||
|
||||
# Written into every file we manage so the agent can tell "ours" from a
|
||||
# hand-maintained keepalived setup (ownership guard, B-3/T-2). Must match the
|
||||
# string the agent greps for in linux_install.sh.
|
||||
OWNERSHIP_MARKER = "# Managed by HAProxy OpenManager"
|
||||
CHECK_SCRIPT_PATH = "/etc/keepalived/check_haproxy.sh"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# VRRP secret at rest (mirrors backend/services/mfa_service.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
_fernet_instance: Optional[Fernet] = None
|
||||
|
||||
|
||||
def _resolve_fernet_key() -> bytes:
|
||||
"""Prefer an explicit VIP_ENCRYPTION_KEY; else derive from SECRET_KEY via HKDF
|
||||
with a versioned info string (so the secret survives restarts, like MFA)."""
|
||||
explicit = os.getenv("VIP_ENCRYPTION_KEY", "").strip()
|
||||
if explicit:
|
||||
try:
|
||||
Fernet(explicit.encode())
|
||||
return explicit.encode()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("VIP_ENCRYPTION_KEY env var present but invalid: %s", exc)
|
||||
hkdf = HKDF(algorithm=hashes.SHA256(), length=32, salt=None, info=b"vip-vrrp-secret-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_vrrp_secret(secret_plain: str) -> str:
|
||||
return _get_fernet().encrypt(secret_plain.encode("utf-8")).decode("utf-8")
|
||||
|
||||
|
||||
def decrypt_vrrp_secret(secret_encrypted: str) -> Optional[str]:
|
||||
try:
|
||||
return _get_fernet().decrypt(secret_encrypted.encode("utf-8")).decode("utf-8")
|
||||
except InvalidToken:
|
||||
logger.warning("Failed to decrypt VRRP secret (invalid Fernet token)")
|
||||
return None
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Unexpected error decrypting VRRP secret: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Renderers
|
||||
# ---------------------------------------------------------------------------
|
||||
def build_haproxy_check_script(*, bin_path: Optional[str] = None,
|
||||
config_path: Optional[str] = None) -> str:
|
||||
"""Render the health-check the agent writes to CHECK_SCRIPT_PATH.
|
||||
|
||||
Derives the process name from the HAProxy binary basename (B-4) rather than a
|
||||
blind hardcoded 'haproxy'. Returns non-zero when HAProxy isn't running so the
|
||||
VRRP track_script lowers this node's priority and the VIP fails over.
|
||||
"""
|
||||
proc = "haproxy"
|
||||
if bin_path:
|
||||
base = os.path.basename(bin_path.strip())
|
||||
if re.match(r'^[A-Za-z0-9._-]{1,64}$', base):
|
||||
proc = base
|
||||
return (
|
||||
"#!/bin/sh\n"
|
||||
f"{OWNERSHIP_MARKER} — DO NOT EDIT\n"
|
||||
"# Exits 0 while HAProxy is up; non-zero triggers VRRP failover.\n"
|
||||
f"pidof {proc} >/dev/null 2>&1 || exit 1\n"
|
||||
"exit 0\n"
|
||||
)
|
||||
|
||||
|
||||
def _vrrp_instance_name(vip_id: int) -> str:
|
||||
return f"VI_{int(vip_id)}"
|
||||
|
||||
|
||||
def _failover_weight(members: List[dict]) -> int:
|
||||
"""Negative weight so a failed MASTER drops strictly below every healthy BACKUP
|
||||
(B-6). master_priority + weight < min(backup_priority)."""
|
||||
master = next((m for m in members if str(m.get("role", "")).upper() == "MASTER"), None)
|
||||
backups = [int(m["priority"]) for m in members if str(m.get("role", "")).upper() != "MASTER"]
|
||||
if not master or not backups:
|
||||
return -20
|
||||
return -((int(master["priority"]) - min(backups)) + 1)
|
||||
|
||||
|
||||
def render_keepalived_conf(*, vip: dict, members: List[dict], this_agent: dict,
|
||||
peer_ips: List[str], auth_pass_plain: Optional[str]) -> str:
|
||||
"""Render one node's keepalived.conf from the VIP + member rows.
|
||||
|
||||
`vip` keys: id, name, virtual_ip, prefix_length, virtual_router_id, advert_int,
|
||||
use_unicast, track_haproxy.
|
||||
`this_agent` keys: role, priority, network_interface, ip_address (str).
|
||||
`peer_ips`: the OTHER members' ip_address strings (already str()'d by the caller).
|
||||
Caller must never log the returned string (it may contain auth_pass).
|
||||
"""
|
||||
role = str(this_agent["role"]).upper()
|
||||
iface = this_agent["network_interface"]
|
||||
prio = int(this_agent["priority"])
|
||||
track = bool(vip.get("track_haproxy", True))
|
||||
use_unicast = bool(vip.get("use_unicast", True))
|
||||
vrid = int(vip["virtual_router_id"])
|
||||
advert = int(vip.get("advert_int", 1))
|
||||
name = str(vip.get("name", ""))
|
||||
inst = _vrrp_instance_name(vip["id"])
|
||||
|
||||
lines: List[str] = []
|
||||
lines.append(f"{OWNERSHIP_MARKER} — DO NOT EDIT")
|
||||
lines.append(f'# VIP "{name}" (id={vip["id"]}) — role {role}')
|
||||
lines.append("global_defs {")
|
||||
lines.append(" enable_script_security")
|
||||
lines.append(" script_user root")
|
||||
lines.append("}")
|
||||
lines.append("")
|
||||
|
||||
if track:
|
||||
weight = _failover_weight(members)
|
||||
lines.append("vrrp_script chk_haproxy {")
|
||||
lines.append(f' script "{CHECK_SCRIPT_PATH}"')
|
||||
lines.append(" interval 2")
|
||||
lines.append(" fall 2")
|
||||
lines.append(" rise 2")
|
||||
lines.append(f" weight {weight}")
|
||||
lines.append("}")
|
||||
lines.append("")
|
||||
|
||||
lines.append(f"vrrp_instance {inst} {{")
|
||||
lines.append(f" state {role}")
|
||||
lines.append(f" interface {iface}")
|
||||
lines.append(f" virtual_router_id {vrid}")
|
||||
lines.append(f" priority {prio}")
|
||||
lines.append(f" advert_int {advert}")
|
||||
if auth_pass_plain:
|
||||
lines.append(" authentication {")
|
||||
lines.append(" auth_type PASS")
|
||||
lines.append(f" auth_pass {auth_pass_plain}")
|
||||
lines.append(" }")
|
||||
# Unicast only makes sense with at least one peer. For a single-node VIP (no peers)
|
||||
# we deliberately omit the unicast block: keepalived treats a bare `unicast_src_ip`
|
||||
# with no `unicast_peer` as deprecated, warns, and silently falls back to multicast —
|
||||
# and `keepalived -t` flags it. Omitting it yields a clean multicast config that holds
|
||||
# the VIP with no peer to talk to. Multi-node behaviour (peers present) is unchanged.
|
||||
if use_unicast and peer_ips:
|
||||
src = this_agent.get("ip_address")
|
||||
if src:
|
||||
lines.append(f" unicast_src_ip {src}")
|
||||
lines.append(" unicast_peer {")
|
||||
for p in peer_ips:
|
||||
lines.append(f" {p}")
|
||||
lines.append(" }")
|
||||
lines.append(" virtual_ipaddress {")
|
||||
lines.append(f' {vip["virtual_ip"]}/{int(vip.get("prefix_length", 24))} dev {iface}')
|
||||
lines.append(" }")
|
||||
if track:
|
||||
lines.append(" track_script {")
|
||||
lines.append(" chk_haproxy")
|
||||
lines.append(" }")
|
||||
lines.append("}")
|
||||
return "\n".join(lines) + "\n"
|
||||
@@ -0,0 +1,70 @@
|
||||
"""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
|
||||
@@ -0,0 +1,21 @@
|
||||
"""v1.7.6 — guard: the agent must surface keepalived FAULT state.
|
||||
|
||||
A VIP whose interface has no usable IPv4 (or whose track-script fails) puts keepalived into
|
||||
FAULT — the virtual IP is NOT held. Previously get_keepalive_state only grepped (MASTER|BACKUP),
|
||||
so a FAULT'd VIP reported as BACKUP — misleading (looks healthy-ish). It must report FAULT so the
|
||||
UI shows it red. Both get_keepalive_state copies (installer + SKIP_TO_DAEMON daemon) must include
|
||||
FAULT. Pure source guard."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def test_keepalive_state_detects_fault_in_both_copies():
|
||||
with open(os.path.join(ROOT, "utils", "agent_scripts", "linux_install.sh"), encoding="utf-8") as f:
|
||||
s = f.read()
|
||||
# FAULT added to the state grep in both copies (installer + daemon), both detection methods.
|
||||
assert s.count("MASTER|BACKUP|FAULT") >= 2
|
||||
# and the misleading MASTER|BACKUP-only grep is gone.
|
||||
assert 'grep -oE "(MASTER|BACKUP)"' not in s
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Issue #27 (v1.7.0) — unit tests for the keepalived config generator + secret crypto.
|
||||
|
||||
Pure-function tests; no DB. Validates the rendered keepalived.conf for MASTER/BACKUP,
|
||||
unicast peers, the failover weight arithmetic, the script-security requirements, the
|
||||
ownership marker, and Fernet round-trip.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from services import keepalived_config as kc # noqa: E402
|
||||
|
||||
|
||||
VIP = {
|
||||
"id": 3, "name": "web-vip", "virtual_ip": "10.0.0.100", "prefix_length": 24,
|
||||
"virtual_router_id": 51, "advert_int": 1, "use_unicast": True, "track_haproxy": True,
|
||||
}
|
||||
MEMBERS = [
|
||||
{"role": "MASTER", "priority": 150, "network_interface": "eth0", "agent_id": 1, "ip_address": "10.0.0.11"},
|
||||
{"role": "BACKUP", "priority": 100, "network_interface": "eth0", "agent_id": 2, "ip_address": "10.0.0.12"},
|
||||
]
|
||||
|
||||
|
||||
def _render(this_idx, auth="s3cr3t"):
|
||||
this_agent = MEMBERS[this_idx]
|
||||
peers = [m["ip_address"] for m in MEMBERS if m["agent_id"] != this_agent["agent_id"]]
|
||||
return kc.render_keepalived_conf(vip=VIP, members=MEMBERS, this_agent=this_agent,
|
||||
peer_ips=peers, auth_pass_plain=auth)
|
||||
|
||||
|
||||
class TestRender:
|
||||
def test_master_state_priority_iface_vrid(self):
|
||||
conf = _render(0)
|
||||
assert "state MASTER" in conf
|
||||
assert "priority 150" in conf
|
||||
assert "interface eth0" in conf
|
||||
assert "virtual_router_id 51" in conf
|
||||
assert "10.0.0.100/24 dev eth0" in conf
|
||||
|
||||
def test_backup_state(self):
|
||||
conf = _render(1)
|
||||
assert "state BACKUP" in conf
|
||||
assert "priority 100" in conf
|
||||
|
||||
def test_unicast_peers(self):
|
||||
# MASTER's config lists the BACKUP as its unicast peer (and its own src ip).
|
||||
conf = _render(0)
|
||||
assert "unicast_src_ip 10.0.0.11" in conf
|
||||
assert "unicast_peer" in conf
|
||||
assert "10.0.0.12" in conf
|
||||
|
||||
def test_script_security_block(self):
|
||||
conf = _render(0)
|
||||
assert "enable_script_security" in conf
|
||||
assert "script_user root" in conf
|
||||
|
||||
def test_ownership_marker(self):
|
||||
assert kc.OWNERSHIP_MARKER in _render(0)
|
||||
|
||||
def test_weight_makes_failed_master_lose(self):
|
||||
# On HAProxy failure the master's effective priority must drop below the backup.
|
||||
conf = _render(0)
|
||||
weight_line = [l for l in conf.splitlines() if l.strip().startswith("weight ")][0]
|
||||
weight = int(weight_line.strip().split()[1])
|
||||
assert 150 + weight < 100, "failed master must fall below every backup"
|
||||
assert "track_script" in conf and "chk_haproxy" in conf
|
||||
|
||||
def test_track_disabled_omits_script(self):
|
||||
vip = {**VIP, "track_haproxy": False}
|
||||
conf = kc.render_keepalived_conf(vip=vip, members=MEMBERS, this_agent=MEMBERS[0],
|
||||
peer_ips=["10.0.0.12"], auth_pass_plain=None)
|
||||
assert "vrrp_script" not in conf
|
||||
assert "track_script" not in conf
|
||||
|
||||
def test_no_auth_when_secret_absent(self):
|
||||
conf = _render(0, auth=None)
|
||||
assert "auth_pass" not in conf
|
||||
|
||||
def test_multicast_omits_unicast(self):
|
||||
vip = {**VIP, "use_unicast": False}
|
||||
conf = kc.render_keepalived_conf(vip=vip, members=MEMBERS, this_agent=MEMBERS[0],
|
||||
peer_ips=["10.0.0.12"], auth_pass_plain="x")
|
||||
assert "unicast_src_ip" not in conf
|
||||
assert "unicast_peer" not in conf
|
||||
|
||||
def test_single_node_omits_unicast_block(self):
|
||||
# Single-node VIP (no peers): even with use_unicast=True we must NOT emit a bare
|
||||
# `unicast_src_ip`/`unicast_peer` — keepalived treats a unicast keyword with no peers
|
||||
# as deprecated, warns, and falls back to multicast (and `keepalived -t` flags it).
|
||||
# Omitting the block yields a clean multicast config that holds the VIP solo.
|
||||
only = [{"role": "MASTER", "priority": 150, "network_interface": "eth0",
|
||||
"agent_id": 1, "ip_address": "10.0.0.11"}]
|
||||
conf = kc.render_keepalived_conf(vip=VIP, members=only, this_agent=only[0],
|
||||
peer_ips=[], auth_pass_plain=None)
|
||||
assert "unicast_src_ip" not in conf
|
||||
assert "unicast_peer" not in conf
|
||||
assert "state MASTER" in conf
|
||||
assert "10.0.0.100/24 dev eth0" in conf
|
||||
|
||||
|
||||
class TestCheckScript:
|
||||
def test_default_process_name(self):
|
||||
s = kc.build_haproxy_check_script()
|
||||
assert "pidof haproxy" in s
|
||||
assert kc.OWNERSHIP_MARKER in s
|
||||
|
||||
def test_process_name_from_bin_path(self):
|
||||
s = kc.build_haproxy_check_script(bin_path="/opt/hap/sbin/haproxy-ent")
|
||||
assert "pidof haproxy-ent" in s
|
||||
|
||||
def test_malicious_bin_path_falls_back(self):
|
||||
s = kc.build_haproxy_check_script(bin_path="/x/haproxy; rm -rf /")
|
||||
assert "rm -rf" not in s
|
||||
assert "pidof haproxy" in s
|
||||
|
||||
|
||||
class TestSecretCrypto:
|
||||
def test_roundtrip(self):
|
||||
kc.reset_fernet_for_tests()
|
||||
token = kc.encrypt_vrrp_secret("s3cr3t")
|
||||
assert token != "s3cr3t"
|
||||
assert kc.decrypt_vrrp_secret(token) == "s3cr3t"
|
||||
|
||||
def test_decrypt_garbage_returns_none(self):
|
||||
kc.reset_fernet_for_tests()
|
||||
assert kc.decrypt_vrrp_secret("not-a-fernet-token") is None
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Issue #27 safety (v1.7.2) — source-level guards for APPROVAL-GATED VIP deletion.
|
||||
|
||||
The critical invariant: an agent must NEVER tear a VIP down without an explicit human approval.
|
||||
We enforce that by keeping the VIP is_active=TRUE (so the agent keeps serving it) when a delete
|
||||
is merely *requested*; only an APPROVE (apply) flips is_active=FALSE. These guards lock in that
|
||||
wiring so a future edit can't silently make delete immediate again. Pure (no DB)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def _read(rel: str) -> str:
|
||||
with open(os.path.join(ROOT, rel), encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def test_delete_stages_for_approval_keeps_vip_running():
|
||||
s = _read("routers/vip.py")
|
||||
# A running (applied) VIP's delete sets pending_delete=TRUE and stages a delete version —
|
||||
# it must NOT flip is_active=FALSE in delete_vip (that only happens on approval in apply_vip).
|
||||
assert "pending_delete=TRUE" in s
|
||||
assert '_stage_vip_version(conn, vip_id, "delete"' in s
|
||||
# The staged-delete branch (running VIP) must not contain an is_active=FALSE soft-delete;
|
||||
# only the "never applied" branch may remove immediately (guarded by applied_snapshot IS NULL).
|
||||
assert 'v["applied_snapshot"] is None' in s
|
||||
|
||||
|
||||
def test_apply_performs_delete_only_on_approval():
|
||||
s = _read("routers/vip.py")
|
||||
# apply_vip short-circuits on pending_delete and only THEN flips is_active=FALSE.
|
||||
assert 'if v["pending_delete"]:' in s
|
||||
assert "is_active=FALSE, pending_delete=FALSE" in s
|
||||
|
||||
|
||||
def test_reject_cancels_delete_as_noop():
|
||||
s = _read("routers/vip.py")
|
||||
# reject_vip clears the staged delete + purge intent; the VIP keeps running (is_active never
|
||||
# touched here) — the "nothing happened" path.
|
||||
assert "pending_delete=FALSE, purge_on_teardown=FALSE" in s
|
||||
assert "Deletion rejected" in s
|
||||
|
||||
|
||||
def test_agent_delivery_teardown_only_on_inactive():
|
||||
# The agent is told to tear down ONLY when is_active=FALSE; a pending-delete VIP is still
|
||||
# is_active=TRUE, so the delivery returns 'available' and the agent keeps serving it.
|
||||
s = _read("routers/agent.py")
|
||||
assert "if not row['is_active']:" in s
|
||||
assert '"status": "teardown"' in s
|
||||
|
||||
|
||||
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
|
||||
# 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():
|
||||
# A VIP soft-deleted under the OLD immediate-delete (pre-1.7.2: is_active=FALSE,
|
||||
# last_config_status='PENDING') must NOT reappear in the list as DELETING — the
|
||||
# teardown-tracking clause is gated on last_config_status='APPLIED' (new-flow approved
|
||||
# deletes only). Locks the backward-compat fix.
|
||||
s = _read("routers/vip.py")
|
||||
assert "v.last_config_status = 'APPLIED' AND EXISTS" in s
|
||||
@@ -0,0 +1,34 @@
|
||||
"""v1.7.5 — guard for the HA/VIP "View Change" diff.
|
||||
|
||||
Editing a VIP (e.g. priority + virtual IP) must show a REAL line diff (only the changed lines)
|
||||
against the previous applied vip-* config — not the whole keepalived.conf marked as "added", and
|
||||
without the doubled "+ +" prefix (the line must be stored WITHOUT a +/- prefix; the UI adds it
|
||||
from `type`, exactly like the standard haproxy diff). Pure source guard (the behavioural diff
|
||||
test needs a DB); locks the wiring so it can't regress."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def _cluster_src() -> str:
|
||||
with open(os.path.join(ROOT, "routers", "cluster.py"), encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def test_vip_diff_uses_real_difflib_against_previous_applied():
|
||||
s = _cluster_src()
|
||||
# The previous APPLIED vip-* config for this VIP is fetched as the diff baseline.
|
||||
assert "AND status='APPLIED' AND cluster_id=$2 AND id < $3 ORDER BY id DESC LIMIT 1" in s
|
||||
# And the diff is a real unified_diff of old vs new (not "everything added").
|
||||
assert "difflib.unified_diff(old_content.split('\\n'), new_content.split('\\n')" in s
|
||||
|
||||
|
||||
def test_vip_diff_stores_lines_without_prefix():
|
||||
s = _cluster_src()
|
||||
# The old vip bug prepended "+ {line}", doubling the UI prefix ("+ +"). The vip diff now
|
||||
# stores the stripped diff line (dl[1:]) — the UI adds the +/- from `type`. `dl` is unique
|
||||
# to the vip branch (the standard haproxy diff uses `line`), so this targets the vip fix
|
||||
# only and does not touch the (separate, out-of-scope) SSL diff branch.
|
||||
assert '"line": dl[1:], "line_number": line_number' in s
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Issue #27 (v1.7.0) — unit tests for VIP request-model validation (pure, no DB)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from models.vip import VIPCreate, VIPMemberIn # noqa: E402
|
||||
|
||||
|
||||
def _members(master_prio=150, backup_prio=100, n_backup=1):
|
||||
members = [{"agent_id": 1, "network_interface": "eth0", "role": "MASTER", "priority": master_prio}]
|
||||
for i in range(n_backup):
|
||||
members.append({"agent_id": 2 + i, "network_interface": "eth0", "role": "BACKUP", "priority": backup_prio})
|
||||
return members
|
||||
|
||||
|
||||
def _create(**over):
|
||||
base = dict(name="web-vip", pool_id=1, virtual_ip="10.0.0.100", members=_members())
|
||||
base.update(over)
|
||||
return VIPCreate(**base)
|
||||
|
||||
|
||||
class TestField:
|
||||
def test_ok(self):
|
||||
v = _create()
|
||||
assert v.virtual_ip == "10.0.0.100"
|
||||
assert v.use_unicast is True # cloud-safe default
|
||||
|
||||
def test_ipv6_rejected(self):
|
||||
with pytest.raises(ValueError):
|
||||
_create(virtual_ip="fd00::1")
|
||||
|
||||
def test_bad_ip_rejected(self):
|
||||
with pytest.raises(ValueError):
|
||||
_create(virtual_ip="not-an-ip")
|
||||
|
||||
def test_auth_pass_max_8(self):
|
||||
_create(auth_pass="12345678")
|
||||
with pytest.raises(ValueError):
|
||||
_create(auth_pass="123456789")
|
||||
|
||||
def test_interface_forbidden_char(self):
|
||||
with pytest.raises(ValueError):
|
||||
VIPMemberIn(agent_id=1, network_interface="eth0; rm -rf /", role="BACKUP", priority=100)
|
||||
|
||||
def test_priority_range(self):
|
||||
with pytest.raises(ValueError):
|
||||
VIPMemberIn(agent_id=1, network_interface="eth0", role="BACKUP", priority=255)
|
||||
|
||||
def test_role_normalized(self):
|
||||
m = VIPMemberIn(agent_id=1, network_interface="eth0", role="master", priority=150)
|
||||
assert m.role == "MASTER"
|
||||
|
||||
def test_vrid_range(self):
|
||||
with pytest.raises(ValueError):
|
||||
_create(virtual_router_id=300)
|
||||
|
||||
|
||||
class TestMembers:
|
||||
def test_single_node_allowed(self):
|
||||
# A single-node VIP (one MASTER, no BACKUP) is valid: a keepalived-managed floating
|
||||
# IP without failover (Issue #27 follow-up — relaxed from >=2 members to >=1, e.g. a
|
||||
# one-box cluster that wants a stable VIP, or before a 2nd node is added for real HA).
|
||||
v = VIPCreate(name="x", pool_id=1, virtual_ip="10.0.0.5",
|
||||
members=[{"agent_id": 1, "network_interface": "eth0", "role": "MASTER", "priority": 150}])
|
||||
assert len(v.members) == 1 and v.members[0].role == "MASTER"
|
||||
|
||||
def test_needs_at_least_one(self):
|
||||
# An empty membership is still rejected — a VIP must have at least one node.
|
||||
with pytest.raises(ValueError):
|
||||
VIPCreate(name="x", pool_id=1, virtual_ip="10.0.0.5", members=[])
|
||||
|
||||
def test_exactly_one_master(self):
|
||||
bad = [{"agent_id": 1, "network_interface": "eth0", "role": "MASTER", "priority": 150},
|
||||
{"agent_id": 2, "network_interface": "eth0", "role": "MASTER", "priority": 140}]
|
||||
with pytest.raises(ValueError):
|
||||
VIPCreate(name="x", pool_id=1, virtual_ip="10.0.0.5", members=bad)
|
||||
|
||||
def test_master_must_be_highest(self):
|
||||
with pytest.raises(ValueError):
|
||||
_create(members=_members(master_prio=100, backup_prio=120))
|
||||
|
||||
def test_no_duplicate_agent(self):
|
||||
dup = [{"agent_id": 1, "network_interface": "eth0", "role": "MASTER", "priority": 150},
|
||||
{"agent_id": 1, "network_interface": "eth1", "role": "BACKUP", "priority": 100}]
|
||||
with pytest.raises(ValueError):
|
||||
VIPCreate(name="x", pool_id=1, virtual_ip="10.0.0.5", members=dup)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Issue #27 follow-up (v1.7.2) — source-level guards for the opt-in keepalived package
|
||||
uninstall + the "we installed it" marker. Pure (no DB), mirroring the project's other
|
||||
source-assertion tests: they lock in the *safe defaults* so a future edit can't silently
|
||||
turn routine VIP deletion into a package purge, or purge a package we didn't install."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def _read(rel: str) -> str:
|
||||
with open(os.path.join(ROOT, rel), encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def test_agent_script_purge_is_optin_and_marker_guarded():
|
||||
s = _read("utils/agent_scripts/linux_install.sh")
|
||||
# The install marker is written only when WE install keepalived, and read by the purge
|
||||
# guard — present in BOTH function copies (installer-mode + SKIP_TO_DAEMON).
|
||||
assert s.count(".hom_installed") >= 4
|
||||
# Multi-distro purge cascade exists, but ONLY inside the opt-in branch.
|
||||
assert "apt-get purge -y -qq keepalived" in s
|
||||
assert "apk del keepalived" in s
|
||||
# Orphan self-heal (not_configured) must NEVER purge — graceful teardown only, both copies.
|
||||
assert s.count('_kp_teardown "false"') >= 2
|
||||
# Purge is honored only on an explicit teardown, parsed from the delivery response.
|
||||
assert s.count(".purge // false") >= 2
|
||||
|
||||
|
||||
def test_agent_delivery_signals_purge_on_teardown():
|
||||
s = _read("routers/agent.py")
|
||||
assert "v.purge_on_teardown" in s
|
||||
assert '"purge"' in s # teardown response carries the opt-in flag
|
||||
|
||||
|
||||
def test_delete_endpoint_accepts_purge_package_default_off():
|
||||
s = _read("routers/vip.py")
|
||||
# Query param defaults to False (safe), and it sets the persisted teardown flag.
|
||||
assert "purge_package: bool = False" in s
|
||||
assert "purge_on_teardown=$2" in s
|
||||
|
||||
|
||||
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
|
||||
# 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
|
||||
@@ -0,0 +1,34 @@
|
||||
"""v1.7.4 — guard for the stick-table auto-inject in the HAProxy config renderer.
|
||||
|
||||
A frontend that uses a stick counter (`track-sc<N>` or an `sc_*_rate(...)` fetch) but declares
|
||||
no `stick-table` makes HAProxy fatally reject the WHOLE cluster config with "table '<frontend>'
|
||||
used but not configured". This happens with rate-limit directives baked into a frontend's stored
|
||||
request_headers/options by an older version or a config import. The renderer now injects a default
|
||||
stick-table in that case. Pure source guard (the behavioural renderer test needs a DB and is
|
||||
disabled); this locks the wiring so it can't silently regress."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def _src() -> str:
|
||||
with open(os.path.join(ROOT, "services", "haproxy_config.py"), encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def test_renderer_detects_stick_counter_usage():
|
||||
s = _src()
|
||||
# Detection: any track-sc<N> write OR an sc_*_rate(...) fetch flips _sc_counter_used.
|
||||
assert "_sc_counter_used" in s
|
||||
assert '"track-sc" in stripped or ("sc_" in stripped and "_rate(" in stripped)' in s
|
||||
|
||||
|
||||
def test_renderer_injects_stick_table_when_missing():
|
||||
s = _src()
|
||||
# Inject ONLY when a counter is used AND no stick-table was emitted (purely additive).
|
||||
assert "if _sc_counter_used and not _stick_table_emitted:" in s
|
||||
assert 'stick-table type ip size 100k expire 30s store http_req_rate(10s)' in s
|
||||
# The inject must register the table so it is not added twice.
|
||||
assert "_stick_table_emitted = True" in s
|
||||
@@ -153,7 +153,7 @@ collect_system_info() {
|
||||
"disk_space": $disk_bytes,
|
||||
"ip_address": "$ip_address",
|
||||
"network_interfaces": ["${network_interfaces//,/\",\"}"],
|
||||
"capabilities": ["haproxy_management", "ssl_deployment", "config_reload", "systemd_service"]
|
||||
"capabilities": ["haproxy_management", "ssl_deployment", "config_reload", "systemd_service", "keepalived_management"]
|
||||
SYSTEM_INFO_EOF
|
||||
}
|
||||
|
||||
@@ -635,7 +635,7 @@ if [[ "$SKIP_TO_DAEMON" != "true" ]]; then
|
||||
|
||||
# Validate cluster exists by checking management API
|
||||
log "DEBUG" "Validating HAProxy Cluster..."
|
||||
CLUSTER_CHECK=$("$CURL_BIN" -k -s -f "$MANAGEMENT_URL/api/clusters" -H "User-Agent: haproxy-agent-installer" || echo "FAILED")
|
||||
CLUSTER_CHECK=$("$CURL_BIN" -k -s -f "$MANAGEMENT_URL/api/clusters" -H "X-API-Key: $AGENT_TOKEN" -H "User-Agent: haproxy-agent-installer" || echo "FAILED")
|
||||
if [[ "$CLUSTER_CHECK" == "FAILED" ]]; then
|
||||
log "ERROR" "Failed to connect to management API!"
|
||||
echo " Please check your network connection and management URL."
|
||||
@@ -1289,7 +1289,7 @@ collect_system_info() {
|
||||
"disk_space": $disk_bytes,
|
||||
"ip_address": "$ip_address",
|
||||
"network_interfaces": ["${network_interfaces//,/\",\"}"],
|
||||
"capabilities": ["haproxy_management", "ssl_deployment", "config_reload", "systemd_service"]
|
||||
"capabilities": ["haproxy_management", "ssl_deployment", "config_reload", "systemd_service", "keepalived_management"]
|
||||
SYSTEM_INFO_EOF
|
||||
}
|
||||
|
||||
@@ -1309,7 +1309,7 @@ get_keepalive_state() {
|
||||
# Method 1: journalctl (most reliable on RHEL/CentOS/Ubuntu with systemd)
|
||||
if command -v journalctl &>/dev/null; then
|
||||
state=$(journalctl -u keepalived -n 50 --no-pager 2>/dev/null \
|
||||
| grep -oE "(MASTER|BACKUP)" | tail -1)
|
||||
| grep -oE "(MASTER|BACKUP|FAULT)" | tail -1)
|
||||
fi
|
||||
|
||||
# Method 2: Fallback to log files (for non-systemd or restricted journalctl)
|
||||
@@ -1317,22 +1317,27 @@ get_keepalive_state() {
|
||||
for logfile in /var/log/messages /var/log/syslog /var/log/keepalived.log; do
|
||||
if [[ -r "$logfile" ]]; then
|
||||
state=$(tail -200 "$logfile" 2>/dev/null \
|
||||
| grep -i keepalived | grep -oE "(MASTER|BACKUP)" | tail -1)
|
||||
| grep -i keepalived | grep -oE "(MASTER|BACKUP|FAULT)" | tail -1)
|
||||
[[ -n "$state" ]] && break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Method 3: Check VIP presence on network interfaces (confirms MASTER)
|
||||
# Method 3: VIP presence on local interfaces — the most portable signal, independent
|
||||
# of logging/journald (works on any distro / init system / keepalived install type).
|
||||
# MASTER iff ANY configured VIP is actually held locally; keepalived up but holding no
|
||||
# VIP => BACKUP. Exact whole-line IP match (grep -Fxq) so 10.0.0.1 can't falsely match
|
||||
# 10.0.0.10/100, and ALL configured VIPs are checked (not just the first).
|
||||
if [[ -z "$state" ]] && [[ -r /etc/keepalived/keepalived.conf ]]; then
|
||||
local conf_vip=$(grep -A10 'virtual_ipaddress' /etc/keepalived/keepalived.conf 2>/dev/null \
|
||||
| grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | head -1)
|
||||
if [[ -n "$conf_vip" ]]; then
|
||||
if ip addr show 2>/dev/null | grep -q "$conf_vip"; then
|
||||
state="MASTER"
|
||||
else
|
||||
state="BACKUP"
|
||||
fi
|
||||
local conf_vips=$(grep -A20 'virtual_ipaddress' /etc/keepalived/keepalived.conf 2>/dev/null \
|
||||
| grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}')
|
||||
if [[ -n "$conf_vips" ]]; then
|
||||
local local_ips=$(ip -4 -o addr show 2>/dev/null | grep -oE 'inet ([0-9]{1,3}\.){3}[0-9]{1,3}' | awk '{print $2}')
|
||||
state="BACKUP"
|
||||
local _cvip
|
||||
for _cvip in $conf_vips; do
|
||||
if printf '%s\n' "$local_ips" | grep -Fxq "$_cvip"; then state="MASTER"; break; fi
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -1665,6 +1670,163 @@ check_ssl_updates() {
|
||||
}
|
||||
|
||||
# Reload HAProxy service (Linux specific)
|
||||
# Issue #27 (v1.7.0) — HA/VIP (Keepalived) convergence. OPT-IN and inert by default:
|
||||
# a node with no APPLIED VIP gets status=not_configured and (lacking our ownership
|
||||
# marker) does NOTHING. Never clobbers a hand-managed keepalived. Uses $AGENT_TOKEN
|
||||
# (kept in sync with the live token in both daemon loops).
|
||||
fetch_and_deploy_keepalived_config() {
|
||||
local marker="# Managed by HAProxy OpenManager"
|
||||
local resp status http_code we_own="false" conf chk
|
||||
|
||||
# Timeouts so a hung management server can never stall the daemon loop.
|
||||
resp=$(curl -k -s --connect-timeout 10 --max-time 30 -w '\n%{http_code}' -X GET \
|
||||
"$MANAGEMENT_URL/api/agents/$AGENT_NAME/keepalived-config" \
|
||||
-H "X-API-Key: $AGENT_TOKEN" 2>/dev/null) || return 0
|
||||
http_code="${resp##*$'\n'}" # last line = HTTP status
|
||||
resp="${resp%$'\n'*}" # everything before = JSON body
|
||||
[[ -z "$resp" ]] && return 0
|
||||
status=$(echo "$resp" | jq -r '.status // "unknown"' 2>/dev/null)
|
||||
# A 404 means this agent was deleted server-side (node decommissioned / pool removed):
|
||||
# self-heal teardown OUR keepalived so a removed node stops advertising the VIP. The
|
||||
# teardown branch below is marker-guarded, so a node we don't manage stays untouched.
|
||||
[[ "$http_code" == "404" ]] && status="teardown"
|
||||
# Cluster-driven keepalived.conf path (delivered in every response); default is universal.
|
||||
conf=$(echo "$resp" | jq -r '.config_path // empty' 2>/dev/null)
|
||||
[[ -z "$conf" || "$conf" == "null" ]] && conf="/etc/keepalived/keepalived.conf"
|
||||
chk="$(dirname "$conf")/check_haproxy.sh"
|
||||
if [[ -f "$conf" ]] && grep -q "$marker" "$conf" 2>/dev/null; then we_own="true"; fi
|
||||
|
||||
_kp_report() { # $1=state $2=vip_id(or empty) $3=hash $4=message
|
||||
local vid="${2:-null}"; [[ -z "$2" ]] && vid="null"
|
||||
curl -k -s --connect-timeout 10 --max-time 30 -X POST "$MANAGEMENT_URL/api/agents/$AGENT_NAME/keepalived-status" \
|
||||
-H "X-API-Key: $AGENT_TOKEN" -H "Content-Type: application/json" \
|
||||
-d "{\"vip_id\":${vid},\"state\":\"$1\",\"config_hash\":\"${3:-}\",\"message\":\"${4:-}\"}" \
|
||||
>/dev/null 2>&1 || true
|
||||
}
|
||||
_kp_teardown() {
|
||||
# $1=purge ("true" only on an explicit operator opt-in delete). Default: stop+disable
|
||||
# keepalived and remove OUR config (the VIP is released) but KEEP the package — the safe
|
||||
# enterprise default. Purge only when opted in AND we are the ones who installed it
|
||||
# (the .hom_installed marker); never remove a package the admin pre-installed.
|
||||
local purge="${1:-false}" marker_inst; marker_inst="$(dirname "$conf")/.hom_installed"
|
||||
log "INFO" "KEEPALIVED: tearing down our managed VIP config (purge=$purge)"
|
||||
systemctl stop keepalived >/dev/null 2>&1
|
||||
systemctl disable keepalived >/dev/null 2>&1
|
||||
rm -f "$conf" "$chk"
|
||||
if [[ "$purge" == "true" && -f "$marker_inst" ]]; then
|
||||
log "INFO" "KEEPALIVED: uninstalling keepalived package (operator opt-in)"
|
||||
if command -v apt-get >/dev/null 2>&1; then timeout 300 apt-get purge -y -qq keepalived >/dev/null 2>&1
|
||||
elif command -v dnf >/dev/null 2>&1; then timeout 300 dnf remove -y -q keepalived >/dev/null 2>&1
|
||||
elif command -v yum >/dev/null 2>&1; then timeout 300 yum remove -y -q keepalived >/dev/null 2>&1
|
||||
elif command -v zypper >/dev/null 2>&1; then timeout 300 zypper --non-interactive remove -y keepalived >/dev/null 2>&1
|
||||
elif command -v apk >/dev/null 2>&1; then timeout 300 apk del keepalived >/dev/null 2>&1
|
||||
fi
|
||||
rm -f "$marker_inst"
|
||||
if command -v keepalived >/dev/null 2>&1; then
|
||||
_kp_report "disabled" "" "" "config removed; package uninstall attempted (still present)"
|
||||
else
|
||||
_kp_report "disabled" "" "" "torn down + keepalived package uninstalled"
|
||||
fi
|
||||
elif [[ "$purge" == "true" ]]; then
|
||||
log "INFO" "KEEPALIVED: package was pre-existing (not installed by us) — left in place; our config removed"
|
||||
_kp_report "disabled" "" "" "torn down (package left: pre-existing, not installed by us)"
|
||||
else
|
||||
_kp_report "disabled" "" "" "torn down"
|
||||
fi
|
||||
}
|
||||
|
||||
case "$status" in
|
||||
available) : ;; # fall through to converge
|
||||
teardown)
|
||||
# Explicit server-side delete. Honor the operator's opt-in package purge (.purge);
|
||||
# marker-guarded inside _kp_teardown, and a node we don't own (no marker conf) is a no-op.
|
||||
local _kp_purge; _kp_purge=$(echo "$resp" | jq -r '.purge // false' 2>/dev/null)
|
||||
[[ "$we_own" == "true" ]] && _kp_teardown "$_kp_purge"
|
||||
return 0 ;;
|
||||
not_configured)
|
||||
[[ "$we_own" == "true" ]] && _kp_teardown "false" # T-2 orphan self-heal: graceful only, never purge
|
||||
return 0 ;;
|
||||
*) return 0 ;; # unknown / auth error → no-op
|
||||
esac
|
||||
|
||||
# --- status == available: converge ---
|
||||
local vip_id new_conf check_script install_if new_hash
|
||||
vip_id=$(echo "$resp" | jq -r '.keepalived.vip_id // empty' 2>/dev/null)
|
||||
new_conf=$(echo "$resp" | jq -r '.keepalived.config_content // empty' 2>/dev/null)
|
||||
new_hash=$(echo "$resp" | jq -r '.keepalived.config_hash // empty' 2>/dev/null)
|
||||
check_script=$(echo "$resp" | jq -r '.keepalived.check_script // empty' 2>/dev/null)
|
||||
install_if=$(echo "$resp" | jq -r '.keepalived.install_if_missing // false' 2>/dev/null)
|
||||
[[ -z "$new_conf" ]] && return 0
|
||||
|
||||
# Ownership guard: never overwrite a keepalived.conf we don't own.
|
||||
if [[ -f "$conf" && "$we_own" != "true" ]]; then
|
||||
log "WARN" "KEEPALIVED: $conf is externally managed — refusing to overwrite"
|
||||
_kp_report "externally_managed" "$vip_id" "" "pre-existing unmanaged keepalived.conf"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Hybrid install: install keepalived only if missing.
|
||||
if ! command -v keepalived >/dev/null 2>&1; then
|
||||
if [[ "$install_if" == "true" ]]; then
|
||||
log "INFO" "KEEPALIVED: installing package..."
|
||||
if command -v apt-get >/dev/null 2>&1; then timeout 300 apt-get install -y -qq keepalived >/dev/null 2>&1
|
||||
elif command -v dnf >/dev/null 2>&1; then timeout 300 dnf install -y -q keepalived >/dev/null 2>&1
|
||||
elif command -v yum >/dev/null 2>&1; then timeout 300 yum install -y -q keepalived >/dev/null 2>&1
|
||||
elif command -v zypper >/dev/null 2>&1; then timeout 300 zypper --non-interactive install -y keepalived >/dev/null 2>&1
|
||||
elif command -v apk >/dev/null 2>&1; then timeout 300 apk add --no-cache keepalived >/dev/null 2>&1
|
||||
fi
|
||||
fi
|
||||
if ! command -v keepalived >/dev/null 2>&1; then
|
||||
log "ERROR" "KEEPALIVED: not installed/available"
|
||||
_kp_report "error" "$vip_id" "" "keepalived not installed"
|
||||
return 0
|
||||
fi
|
||||
# We installed keepalived (it was missing) — drop a marker so an opt-in uninstall on
|
||||
# delete removes only OUR install, never an admin's pre-existing keepalived package.
|
||||
mkdir -p "$(dirname "$conf")" 2>/dev/null; : > "$(dirname "$conf")/.hom_installed" 2>/dev/null
|
||||
fi
|
||||
|
||||
# Idempotency: skip write+reload when the on-disk content already matches.
|
||||
if [[ -f "$conf" ]]; then
|
||||
local cur_hash would_hash
|
||||
cur_hash=$(md5sum "$conf" 2>/dev/null | awk '{print $1}')
|
||||
would_hash=$(printf '%s' "$new_conf" | md5sum 2>/dev/null | awk '{print $1}')
|
||||
if [[ -n "$cur_hash" && "$cur_hash" == "$would_hash" ]]; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$conf")"
|
||||
if [[ -n "$check_script" ]]; then
|
||||
printf '%s' "$check_script" > "$chk"
|
||||
chown root:root "$chk" 2>/dev/null
|
||||
chmod 0755 "$chk" # root-owned, not world-writable — required by enable_script_security
|
||||
fi
|
||||
# Validate on a TEMP file and swap in only on success: a bad render must never land on
|
||||
# $conf (it would also make the md5 idempotency guard above suppress retries forever).
|
||||
local tmp_conf="${conf}.hom.tmp"
|
||||
printf '%s' "$new_conf" > "$tmp_conf"
|
||||
chmod 0644 "$tmp_conf"
|
||||
if ! keepalived -t -f "$tmp_conf" >/dev/null 2>&1; then
|
||||
rm -f "$tmp_conf"
|
||||
log "ERROR" "KEEPALIVED: config validation failed (keepalived -t) — keeping current config, not (re)starting"
|
||||
_kp_report "error" "$vip_id" "$new_hash" "keepalived -t failed"
|
||||
return 0
|
||||
fi
|
||||
mv -f "$tmp_conf" "$conf"
|
||||
chown root:root "$conf" 2>/dev/null
|
||||
chmod 0644 "$conf"
|
||||
systemctl enable keepalived >/dev/null 2>&1
|
||||
if systemctl reload keepalived >/dev/null 2>&1 || systemctl restart keepalived >/dev/null 2>&1; then
|
||||
log "INFO" "KEEPALIVED: applied config for VIP ${vip_id}"
|
||||
_kp_report "enabled" "$vip_id" "$new_hash" "applied"
|
||||
else
|
||||
log "ERROR" "KEEPALIVED: reload/restart failed"
|
||||
_kp_report "error" "$vip_id" "$new_hash" "reload/restart failed"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
reload_haproxy_service() {
|
||||
log "INFO" "Performing zero-downtime HAProxy configuration reload..."
|
||||
|
||||
@@ -2356,8 +2518,12 @@ run_daemon() {
|
||||
_loop_count=$((_loop_count + 1))
|
||||
if (( _loop_count % 5 == 0 )); then
|
||||
check_ssl_updates
|
||||
# Issue #27 — HA/VIP convergence at the SSL cadence (inert for non-VIP nodes)
|
||||
if type fetch_and_deploy_keepalived_config &>/dev/null; then
|
||||
fetch_and_deploy_keepalived_config
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
check_agent_upgrade
|
||||
done
|
||||
|
||||
@@ -2789,28 +2955,31 @@ SYSTEM_INFO_EOF
|
||||
|
||||
if command -v journalctl &>/dev/null; then
|
||||
state=$(journalctl -u keepalived -n 50 --no-pager 2>/dev/null \
|
||||
| grep -oE "(MASTER|BACKUP)" | tail -1)
|
||||
| grep -oE "(MASTER|BACKUP|FAULT)" | tail -1)
|
||||
fi
|
||||
|
||||
if [[ -z "$state" ]]; then
|
||||
for logfile in /var/log/messages /var/log/syslog /var/log/keepalived.log; do
|
||||
if [[ -r "$logfile" ]]; then
|
||||
state=$(tail -200 "$logfile" 2>/dev/null \
|
||||
| grep -i keepalived | grep -oE "(MASTER|BACKUP)" | tail -1)
|
||||
| grep -i keepalived | grep -oE "(MASTER|BACKUP|FAULT)" | tail -1)
|
||||
[[ -n "$state" ]] && break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Method 3: VIP presence on local interfaces — portable, logging-independent.
|
||||
# MASTER iff ANY configured VIP is held locally (exact whole-line match, all VIPs).
|
||||
if [[ -z "$state" ]] && [[ -r /etc/keepalived/keepalived.conf ]]; then
|
||||
local conf_vip=$(grep -A10 'virtual_ipaddress' /etc/keepalived/keepalived.conf 2>/dev/null \
|
||||
| grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | head -1)
|
||||
if [[ -n "$conf_vip" ]]; then
|
||||
if ip addr show 2>/dev/null | grep -q "$conf_vip"; then
|
||||
state="MASTER"
|
||||
else
|
||||
state="BACKUP"
|
||||
fi
|
||||
local conf_vips=$(grep -A20 'virtual_ipaddress' /etc/keepalived/keepalived.conf 2>/dev/null \
|
||||
| grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}')
|
||||
if [[ -n "$conf_vips" ]]; then
|
||||
local local_ips=$(ip -4 -o addr show 2>/dev/null | grep -oE 'inet ([0-9]{1,3}\.){3}[0-9]{1,3}' | awk '{print $2}')
|
||||
state="BACKUP"
|
||||
local _cvip
|
||||
for _cvip in $conf_vips; do
|
||||
if printf '%s\n' "$local_ips" | grep -Fxq "$_cvip"; then state="MASTER"; break; fi
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -2849,7 +3018,15 @@ SYSTEM_INFO_EOF
|
||||
local keepalive_info=$(get_keepalive_state)
|
||||
local keepalive_state=$(echo "$keepalive_info" | cut -d'|' -f1)
|
||||
local keepalive_ip=$(echo "$keepalive_info" | cut -d'|' -f2)
|
||||
|
||||
|
||||
# Network interfaces (DAEMON) — reported so the HA/VIP create form can offer this
|
||||
# node's real NICs. /sys/class/net is universal across distros (no iproute2 needed);
|
||||
# exclude loopback here, and the UI further hides docker/veth/bridge interfaces. Empty
|
||||
# -> "[]" (never "[\"\"]") so a node with no NICs doesn't surface a blank option.
|
||||
local net_ifaces ni_json
|
||||
net_ifaces=$(ls /sys/class/net 2>/dev/null | grep -vE '^lo$' | tr '\n' ',' | sed 's/,$//')
|
||||
if [ -n "$net_ifaces" ]; then ni_json="[\"${net_ifaces//,/\",\"}\"]"; else ni_json="[]"; fi
|
||||
|
||||
# Prepare heartbeat payload with all data
|
||||
local heartbeat_payload="{
|
||||
\"name\": \"$AGENT_NAME\",
|
||||
@@ -2859,6 +3036,8 @@ SYSTEM_INFO_EOF
|
||||
\"platform\": \"$platform\",
|
||||
\"architecture\": \"$(uname -m)\",
|
||||
\"version\": \"{{AGENT_VERSION}}\",
|
||||
\"capabilities\": [\"haproxy_management\", \"ssl_deployment\", \"config_reload\", \"systemd_service\", \"keepalived_management\"],
|
||||
\"network_interfaces\": $ni_json,
|
||||
\"haproxy_status\": \"$haproxy_status\",
|
||||
\"haproxy_version\": \"$haproxy_version\",
|
||||
\"cluster_id\": $CLUSTER_ID,
|
||||
@@ -3041,9 +3220,159 @@ CONFIG_RESPONSE_EOF
|
||||
HAPROXY_BIN="${HAPROXY_BIN_PATH}"
|
||||
HAPROXY_CONFIG="${HAPROXY_CONFIG_PATH}"
|
||||
log "INFO" "DAEMON: Initialized HAProxy paths - bin: $HAPROXY_BIN, config: $HAPROXY_CONFIG"
|
||||
|
||||
|
||||
# Issue #27 (v1.7.0) — HA/VIP (Keepalived) convergence (DAEMON copy). Inert by
|
||||
# default: a node with no APPLIED VIP gets not_configured and (lacking our marker)
|
||||
# does nothing. Never clobbers a hand-managed keepalived. $AGENT_TOKEN is synced to
|
||||
# the live token at the top of each loop iteration below.
|
||||
fetch_and_deploy_keepalived_config() {
|
||||
local marker="# Managed by HAProxy OpenManager"
|
||||
local resp status http_code we_own="false" conf chk
|
||||
|
||||
# Timeouts so a hung management server can never stall the daemon loop.
|
||||
resp=$(curl -k -s --connect-timeout 10 --max-time 30 -w '\n%{http_code}' -X GET \
|
||||
"$MANAGEMENT_URL/api/agents/$AGENT_NAME/keepalived-config" \
|
||||
-H "X-API-Key: $AGENT_TOKEN" 2>/dev/null) || return 0
|
||||
http_code="${resp##*$'\n'}" # last line = HTTP status
|
||||
resp="${resp%$'\n'*}" # everything before = JSON body
|
||||
[[ -z "$resp" ]] && return 0
|
||||
status=$(echo "$resp" | jq -r '.status // "unknown"' 2>/dev/null)
|
||||
# A 404 means this agent was deleted server-side (node decommissioned / pool removed):
|
||||
# self-heal teardown OUR keepalived so a removed node stops advertising the VIP. The
|
||||
# teardown branch below is marker-guarded, so a node we don't manage stays untouched.
|
||||
[[ "$http_code" == "404" ]] && status="teardown"
|
||||
# Cluster-driven keepalived.conf path (delivered in every response); default is universal.
|
||||
conf=$(echo "$resp" | jq -r '.config_path // empty' 2>/dev/null)
|
||||
[[ -z "$conf" || "$conf" == "null" ]] && conf="/etc/keepalived/keepalived.conf"
|
||||
chk="$(dirname "$conf")/check_haproxy.sh"
|
||||
if [[ -f "$conf" ]] && grep -q "$marker" "$conf" 2>/dev/null; then we_own="true"; fi
|
||||
|
||||
_kp_report() {
|
||||
local vid="${2:-null}"; [[ -z "$2" ]] && vid="null"
|
||||
curl -k -s --connect-timeout 10 --max-time 30 -X POST "$MANAGEMENT_URL/api/agents/$AGENT_NAME/keepalived-status" \
|
||||
-H "X-API-Key: $AGENT_TOKEN" -H "Content-Type: application/json" \
|
||||
-d "{\"vip_id\":${vid},\"state\":\"$1\",\"config_hash\":\"${3:-}\",\"message\":\"${4:-}\"}" \
|
||||
>/dev/null 2>&1 || true
|
||||
}
|
||||
_kp_teardown() {
|
||||
# $1=purge ("true" only on an explicit operator opt-in delete). Default: stop+disable
|
||||
# keepalived and remove OUR config (VIP released) but KEEP the package. Purge only when
|
||||
# opted in AND we installed it (.hom_installed marker) — never an admin's package.
|
||||
local purge="${1:-false}" marker_inst; marker_inst="$(dirname "$conf")/.hom_installed"
|
||||
log "INFO" "KEEPALIVED: tearing down our managed VIP config (purge=$purge)"
|
||||
systemctl stop keepalived >/dev/null 2>&1
|
||||
systemctl disable keepalived >/dev/null 2>&1
|
||||
rm -f "$conf" "$chk"
|
||||
if [[ "$purge" == "true" && -f "$marker_inst" ]]; then
|
||||
log "INFO" "KEEPALIVED: uninstalling keepalived package (operator opt-in)"
|
||||
if command -v apt-get >/dev/null 2>&1; then timeout 300 apt-get purge -y -qq keepalived >/dev/null 2>&1
|
||||
elif command -v dnf >/dev/null 2>&1; then timeout 300 dnf remove -y -q keepalived >/dev/null 2>&1
|
||||
elif command -v yum >/dev/null 2>&1; then timeout 300 yum remove -y -q keepalived >/dev/null 2>&1
|
||||
elif command -v zypper >/dev/null 2>&1; then timeout 300 zypper --non-interactive remove -y keepalived >/dev/null 2>&1
|
||||
elif command -v apk >/dev/null 2>&1; then timeout 300 apk del keepalived >/dev/null 2>&1
|
||||
fi
|
||||
rm -f "$marker_inst"
|
||||
if command -v keepalived >/dev/null 2>&1; then
|
||||
_kp_report "disabled" "" "" "config removed; package uninstall attempted (still present)"
|
||||
else
|
||||
_kp_report "disabled" "" "" "torn down + keepalived package uninstalled"
|
||||
fi
|
||||
elif [[ "$purge" == "true" ]]; then
|
||||
log "INFO" "KEEPALIVED: package was pre-existing (not installed by us) — left in place; our config removed"
|
||||
_kp_report "disabled" "" "" "torn down (package left: pre-existing, not installed by us)"
|
||||
else
|
||||
_kp_report "disabled" "" "" "torn down"
|
||||
fi
|
||||
}
|
||||
|
||||
case "$status" in
|
||||
available) : ;;
|
||||
teardown)
|
||||
local _kp_purge; _kp_purge=$(echo "$resp" | jq -r '.purge // false' 2>/dev/null)
|
||||
[[ "$we_own" == "true" ]] && _kp_teardown "$_kp_purge" # explicit delete: honor opt-in purge
|
||||
return 0 ;;
|
||||
not_configured)
|
||||
[[ "$we_own" == "true" ]] && _kp_teardown "false" # T-2 orphan self-heal: graceful only
|
||||
return 0 ;;
|
||||
*) return 0 ;;
|
||||
esac
|
||||
|
||||
local vip_id new_conf check_script install_if new_hash
|
||||
vip_id=$(echo "$resp" | jq -r '.keepalived.vip_id // empty' 2>/dev/null)
|
||||
new_conf=$(echo "$resp" | jq -r '.keepalived.config_content // empty' 2>/dev/null)
|
||||
new_hash=$(echo "$resp" | jq -r '.keepalived.config_hash // empty' 2>/dev/null)
|
||||
check_script=$(echo "$resp" | jq -r '.keepalived.check_script // empty' 2>/dev/null)
|
||||
install_if=$(echo "$resp" | jq -r '.keepalived.install_if_missing // false' 2>/dev/null)
|
||||
[[ -z "$new_conf" ]] && return 0
|
||||
|
||||
if [[ -f "$conf" && "$we_own" != "true" ]]; then
|
||||
log "WARN" "KEEPALIVED: $conf is externally managed — refusing to overwrite"
|
||||
_kp_report "externally_managed" "$vip_id" "" "pre-existing unmanaged keepalived.conf"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! command -v keepalived >/dev/null 2>&1; then
|
||||
if [[ "$install_if" == "true" ]]; then
|
||||
log "INFO" "KEEPALIVED: installing package..."
|
||||
if command -v apt-get >/dev/null 2>&1; then timeout 300 apt-get install -y -qq keepalived >/dev/null 2>&1
|
||||
elif command -v dnf >/dev/null 2>&1; then timeout 300 dnf install -y -q keepalived >/dev/null 2>&1
|
||||
elif command -v yum >/dev/null 2>&1; then timeout 300 yum install -y -q keepalived >/dev/null 2>&1
|
||||
elif command -v zypper >/dev/null 2>&1; then timeout 300 zypper --non-interactive install -y keepalived >/dev/null 2>&1
|
||||
elif command -v apk >/dev/null 2>&1; then timeout 300 apk add --no-cache keepalived >/dev/null 2>&1
|
||||
fi
|
||||
fi
|
||||
if ! command -v keepalived >/dev/null 2>&1; then
|
||||
log "ERROR" "KEEPALIVED: not installed/available"
|
||||
_kp_report "error" "$vip_id" "" "keepalived not installed"
|
||||
return 0
|
||||
fi
|
||||
# We installed keepalived (it was missing) — drop a marker so an opt-in uninstall on
|
||||
# delete removes only OUR install, never an admin's pre-existing keepalived package.
|
||||
mkdir -p "$(dirname "$conf")" 2>/dev/null; : > "$(dirname "$conf")/.hom_installed" 2>/dev/null
|
||||
fi
|
||||
|
||||
if [[ -f "$conf" ]]; then
|
||||
local cur_hash would_hash
|
||||
cur_hash=$(md5sum "$conf" 2>/dev/null | awk '{print $1}')
|
||||
would_hash=$(printf '%s' "$new_conf" | md5sum 2>/dev/null | awk '{print $1}')
|
||||
if [[ -n "$cur_hash" && "$cur_hash" == "$would_hash" ]]; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$conf")"
|
||||
if [[ -n "$check_script" ]]; then
|
||||
printf '%s' "$check_script" > "$chk"
|
||||
chown root:root "$chk" 2>/dev/null
|
||||
chmod 0755 "$chk"
|
||||
fi
|
||||
# Validate on a TEMP file and swap in only on success: a bad render must never land on
|
||||
# $conf (it would also make the md5 idempotency guard above suppress retries forever).
|
||||
local tmp_conf="${conf}.hom.tmp"
|
||||
printf '%s' "$new_conf" > "$tmp_conf"
|
||||
chmod 0644 "$tmp_conf"
|
||||
if ! keepalived -t -f "$tmp_conf" >/dev/null 2>&1; then
|
||||
rm -f "$tmp_conf"
|
||||
log "ERROR" "KEEPALIVED: config validation failed (keepalived -t) — keeping current config, not (re)starting"
|
||||
_kp_report "error" "$vip_id" "$new_hash" "keepalived -t failed"
|
||||
return 0
|
||||
fi
|
||||
mv -f "$tmp_conf" "$conf"
|
||||
chown root:root "$conf" 2>/dev/null
|
||||
chmod 0644 "$conf"
|
||||
systemctl enable keepalived >/dev/null 2>&1
|
||||
if systemctl reload keepalived >/dev/null 2>&1 || systemctl restart keepalived >/dev/null 2>&1; then
|
||||
log "INFO" "KEEPALIVED: applied config for VIP ${vip_id}"
|
||||
_kp_report "enabled" "$vip_id" "$new_hash" "applied"
|
||||
else
|
||||
log "ERROR" "KEEPALIVED: reload/restart failed"
|
||||
_kp_report "error" "$vip_id" "$new_hash" "reload/restart failed"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
log "DEBUG" "DAEMON: Starting agent monitoring loop for $AGENT_NAME"
|
||||
|
||||
|
||||
# Enhanced daemon loop with upgrade capability - EXACT MacOS COPY
|
||||
while true; do
|
||||
sleep 30
|
||||
@@ -3208,6 +3537,13 @@ CONFIG_RESPONSE_EOF
|
||||
fi
|
||||
fi
|
||||
|
||||
# Issue #27 — HA/VIP (Keepalived) convergence at the SSL cadence (~2.5 min);
|
||||
# inert for non-VIP nodes (status=not_configured + no ownership marker).
|
||||
_kp_loop_count=$(( ${_kp_loop_count:-0} + 1 ))
|
||||
if (( _kp_loop_count % 5 == 0 )) && type fetch_and_deploy_keepalived_config &>/dev/null; then
|
||||
fetch_and_deploy_keepalived_config
|
||||
fi
|
||||
|
||||
# Check for configuration updates with proper error handling
|
||||
config_response=$(curl -k -s -X GET "$MANAGEMENT_URL/api/agents/$AGENT_NAME/config" \
|
||||
-H "X-API-Key: $CURRENT_AGENT_TOKEN" 2>/dev/null)
|
||||
|
||||
@@ -522,7 +522,7 @@ if [[ "$SKIP_TO_DAEMON" != "true" ]]; then
|
||||
|
||||
# Validate cluster exists by checking management API
|
||||
log "DEBUG" "Validating HAProxy Cluster..."
|
||||
CLUSTER_CHECK=$("$CURL_BIN" -k -s -f "$MANAGEMENT_URL/api/clusters" -H "User-Agent: haproxy-agent-installer" || echo "FAILED")
|
||||
CLUSTER_CHECK=$("$CURL_BIN" -k -s -f "$MANAGEMENT_URL/api/clusters" -H "X-API-Key: $AGENT_TOKEN" -H "User-Agent: haproxy-agent-installer" || echo "FAILED")
|
||||
if [[ "$CLUSTER_CHECK" == "FAILED" ]]; then
|
||||
log "ERROR" "Failed to connect to management API!"
|
||||
echo " Please check your network connection and management URL."
|
||||
@@ -867,7 +867,7 @@ SSL_SYNC_TIMESTAMP_FILE="/tmp/haproxy-agent-ssl-sync-${AGENT_NAME}"
|
||||
get_cluster_paths() {
|
||||
log "DEBUG" "Fetching cluster paths from management API"
|
||||
local cluster_response=$("$CURL_BIN" -k -s -X GET "$MANAGEMENT_URL/api/clusters" \
|
||||
-H "Authorization: Bearer $AGENT_TOKEN")
|
||||
-H "X-API-Key: $AGENT_TOKEN")
|
||||
|
||||
if [[ $? -eq 0 ]]; then
|
||||
local _cfg _bin _sock
|
||||
@@ -1164,6 +1164,27 @@ get_keepalive_state() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# Issue #27 — keepalived/VRRP is Linux-only. macOS agents cannot run keepalived, so they
|
||||
# can never be VIP members (the backend also withholds the keepalived_management capability,
|
||||
# so the UI warns at assign time). This is a safe no-op that, if a VIP is somehow mis-assigned
|
||||
# to a macOS node, reports a clear "unsupported on macOS" status instead of silently never
|
||||
# converging. It never installs/writes anything.
|
||||
fetch_and_deploy_keepalived_config() {
|
||||
local resp status vip_id
|
||||
resp=$(curl -k -s -X GET "$MANAGEMENT_URL/api/agents/$AGENT_NAME/keepalived-config" \
|
||||
-H "X-API-Key: $AGENT_TOKEN" 2>/dev/null) || return 0
|
||||
[[ -z "$resp" ]] && return 0
|
||||
status=$(echo "$resp" | jq -r '.status // "unknown"' 2>/dev/null)
|
||||
if [[ "$status" == "available" ]]; then
|
||||
vip_id=$(echo "$resp" | jq -r '.keepalived.vip_id // empty' 2>/dev/null)
|
||||
log "WARN" "KEEPALIVED: a VIP is assigned to this node, but keepalived/VRRP is not supported on macOS — ignoring"
|
||||
curl -k -s -X POST "$MANAGEMENT_URL/api/agents/$AGENT_NAME/keepalived-status" \
|
||||
-H "X-API-Key: $AGENT_TOKEN" -H "Content-Type: application/json" \
|
||||
-d "{\"vip_id\":${vip_id:-null},\"state\":\"error\",\"message\":\"keepalived/VRRP not supported on macOS\"}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Send heartbeat
|
||||
send_heartbeat() {
|
||||
local haproxy_status=$(get_haproxy_status)
|
||||
@@ -2197,8 +2218,12 @@ run_daemon() {
|
||||
_loop_count=$((_loop_count + 1))
|
||||
if (( _loop_count % 5 == 0 )); then
|
||||
check_ssl_updates
|
||||
# Issue #27 — HA/VIP convergence (no-op on macOS; reports unsupported if assigned)
|
||||
if type fetch_and_deploy_keepalived_config &>/dev/null; then
|
||||
fetch_and_deploy_keepalived_config
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
check_agent_upgrade
|
||||
done
|
||||
|
||||
@@ -2629,7 +2654,25 @@ SYSTEM_INFO_EOF
|
||||
echo "|"
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
# Issue #27 — keepalived/VRRP is Linux-only (DAEMON version). Safe no-op that reports a
|
||||
# clear "unsupported on macOS" status if a VIP is ever mis-assigned to a macOS node.
|
||||
fetch_and_deploy_keepalived_config() {
|
||||
local resp status vip_id
|
||||
resp=$(curl -k -s -X GET "$MANAGEMENT_URL/api/agents/$AGENT_NAME/keepalived-config" \
|
||||
-H "X-API-Key: $AGENT_TOKEN" 2>/dev/null) || return 0
|
||||
[[ -z "$resp" ]] && return 0
|
||||
status=$(echo "$resp" | jq -r '.status // "unknown"' 2>/dev/null)
|
||||
if [[ "$status" == "available" ]]; then
|
||||
vip_id=$(echo "$resp" | jq -r '.keepalived.vip_id // empty' 2>/dev/null)
|
||||
log "WARN" "KEEPALIVED: a VIP is assigned to this node, but keepalived/VRRP is not supported on macOS — ignoring"
|
||||
curl -k -s -X POST "$MANAGEMENT_URL/api/agents/$AGENT_NAME/keepalived-status" \
|
||||
-H "X-API-Key: $AGENT_TOKEN" -H "Content-Type: application/json" \
|
||||
-d "{\"vip_id\":${vip_id:-null},\"state\":\"error\",\"message\":\"keepalived/VRRP not supported on macOS\"}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Send heartbeat (DAEMON version - includes all stats)
|
||||
send_heartbeat() {
|
||||
local haproxy_status=$(get_haproxy_status)
|
||||
@@ -3021,6 +3064,13 @@ CONFIG_RESPONSE_EOF
|
||||
fi
|
||||
fi
|
||||
|
||||
# Issue #27 — HA/VIP convergence at the SSL cadence (no-op on macOS; reports
|
||||
# unsupported if a VIP is mis-assigned to this node).
|
||||
_kp_loop_count=$(( ${_kp_loop_count:-0} + 1 ))
|
||||
if (( _kp_loop_count % 5 == 0 )) && type fetch_and_deploy_keepalived_config &>/dev/null; then
|
||||
fetch_and_deploy_keepalived_config
|
||||
fi
|
||||
|
||||
# Check for configuration updates with proper error handling
|
||||
config_response=$(curl -k -s -X GET "$MANAGEMENT_URL/api/agents/$AGENT_NAME/config" \
|
||||
-H "X-API-Key: $CURRENT_AGENT_TOKEN" 2>/dev/null)
|
||||
|
||||
@@ -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
|
||||
+3
-1
@@ -96,7 +96,9 @@ services:
|
||||
|
||||
# Nginx Reverse Proxy
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
# Pinned to a patched release for the nginx "poolslip" advisory
|
||||
# (mainline <=1.31.0 affected; fixed in mainline 1.31.1+ / stable 1.30.2+).
|
||||
image: nginx:1.31.1-alpine
|
||||
container_name: haproxy-openmanager-nginx
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
Generated
+29
-50
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"name": "haproxy-openmanager-frontend",
|
||||
"version": "1.6.0",
|
||||
"version": "1.7.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "haproxy-openmanager-frontend",
|
||||
"version": "1.6.0",
|
||||
"version": "1.7.8",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.0.0",
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"ace-builds": "^1.23.4",
|
||||
"antd": "^5.2.0",
|
||||
"axios": "^1.3.0",
|
||||
"axios": "^1.16.0",
|
||||
"moment": "^2.29.0",
|
||||
"monaco-editor": "^0.36.0",
|
||||
"qrcode.react": "^4.0.0",
|
||||
@@ -4654,27 +4654,6 @@
|
||||
"url": "https://github.com/sponsors/gregberge"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/aria-query": "^5.0.1",
|
||||
"aria-query": "5.3.0",
|
||||
"dom-accessibility-api": "^0.5.9",
|
||||
"lz-string": "^1.5.0",
|
||||
"picocolors": "1.1.1",
|
||||
"pretty-format": "^27.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/jest-dom": {
|
||||
"version": "5.17.0",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.17.0.tgz",
|
||||
@@ -6545,12 +6524,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.14.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz",
|
||||
"integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
|
||||
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"follow-redirects": "^1.16.0",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
@@ -6924,9 +6903,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.4",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
||||
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
|
||||
"version": "1.20.5",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
|
||||
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -6938,7 +6917,7 @@
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"on-finished": "~2.4.1",
|
||||
"qs": "~6.14.0",
|
||||
"qs": "~6.15.1",
|
||||
"raw-body": "~2.5.3",
|
||||
"type-is": "~1.6.18",
|
||||
"unpipe": "~1.0.0"
|
||||
@@ -10023,15 +10002,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.22.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
||||
"version": "4.22.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
||||
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "~1.20.3",
|
||||
"body-parser": "~1.20.5",
|
||||
"content-disposition": "~0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "~0.7.1",
|
||||
@@ -10050,7 +10029,7 @@
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "~0.1.12",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "~6.14.0",
|
||||
"qs": "~6.15.1",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.2.1",
|
||||
"send": "~0.19.0",
|
||||
@@ -10147,9 +10126,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -10414,9 +10393,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -18249,9 +18228,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
|
||||
"version": "6.15.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
@@ -20368,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": {
|
||||
@@ -22200,7 +22179,7 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wbuf": {
|
||||
"version": "1.7.3",
|
||||
"version": "1.7.8",
|
||||
"resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz",
|
||||
"integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==",
|
||||
"dev": true,
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "haproxy-openmanager-frontend",
|
||||
"version": "1.6.0",
|
||||
"version": "1.8.0",
|
||||
"description": "HAProxy Load Balancer Management UI",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.8.0",
|
||||
"axios": "^1.3.0",
|
||||
"axios": "^1.16.0",
|
||||
"antd": "^5.2.0",
|
||||
"@ant-design/icons": "^5.0.0",
|
||||
"recharts": "^2.5.0",
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
DashboardOutlined,
|
||||
SettingOutlined,
|
||||
CloudServerOutlined,
|
||||
DeploymentUnitOutlined,
|
||||
FileTextOutlined,
|
||||
GlobalOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
@@ -42,6 +43,7 @@ import PoolManagement from './components/PoolManagement';
|
||||
import Login from './components/Login';
|
||||
import ClusterSelector from './components/ClusterSelector';
|
||||
import ClusterManagement from './components/ClusterManagement';
|
||||
import VIPManagement from './components/VIPManagement';
|
||||
import Configuration from './components/Configuration';
|
||||
import APIDocumentation from './components/APIDocumentation';
|
||||
import IPInventory from './components/IPInventory';
|
||||
@@ -156,6 +158,11 @@ const { Text } = Typography;
|
||||
icon: <CloudServerOutlined />,
|
||||
label: <Link to="/clusters">Clusters</Link>,
|
||||
},
|
||||
{
|
||||
key: '/ha-vip',
|
||||
icon: <DeploymentUnitOutlined />,
|
||||
label: <Link to="/ha-vip">HA / VIP</Link>,
|
||||
},
|
||||
{
|
||||
key: '/pools',
|
||||
icon: <GlobalOutlined />,
|
||||
@@ -466,6 +473,7 @@ function AppContent() {
|
||||
<Route path="/pools" element={<PoolManagement />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/clusters" element={<ClusterManagement />} />
|
||||
<Route path="/ha-vip" element={<VIPManagement />} />
|
||||
<Route path="/ip-inventory" element={<IPInventory />} />
|
||||
</Routes>
|
||||
</Content>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Card, Table, Button, Tag, Space, Modal, Form, Input, Select, Steps,
|
||||
message, Row, Col, Statistic, Alert, Tooltip, Switch, theme, Segmented,
|
||||
Tabs, Timeline, Spin, Empty
|
||||
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';
|
||||
@@ -21,6 +21,18 @@ const { Option } = Select;
|
||||
const getErrorMsg = (err, fallback) =>
|
||||
err?.response?.data?.error?.message || err?.response?.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
|
||||
// CA-side failures, but legacy rows may still contain plain strings.
|
||||
@@ -83,6 +95,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 +136,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 +169,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 +200,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 +214,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 +244,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 +276,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 +335,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 +554,141 @@ 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,
|
||||
});
|
||||
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 +785,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 +863,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 +906,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 +930,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 +1012,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 > 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 +1052,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 +1084,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 +1145,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 +1161,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 +1212,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 +1316,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 +1345,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 +1361,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 +1436,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 +1466,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 +1517,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 +1529,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 +1587,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 +1600,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 +1613,141 @@ const ACMEAutomation = () => {
|
||||
Let's Encrypt Subscriber Agreement
|
||||
</a>).
|
||||
</div>
|
||||
{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 +1829,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 +1899,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 +1931,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>
|
||||
),
|
||||
|
||||
@@ -37,6 +37,7 @@ const ApplyManagement = () => {
|
||||
backends: [],
|
||||
waf_rules: [],
|
||||
ssl_certificates: [],
|
||||
vips: [],
|
||||
total_count: 0
|
||||
});
|
||||
const [configVersions, setConfigVersions] = useState([]);
|
||||
@@ -53,6 +54,8 @@ const ApplyManagement = () => {
|
||||
// Validation Error Modal state
|
||||
const [validationErrorModalVisible, setValidationErrorModalVisible] = useState(false);
|
||||
const [selectedValidationError, setSelectedValidationError] = useState(null);
|
||||
// HA/VIP (Issue #27): VIP changes appear in the standard right-panel "Pending Versions"
|
||||
// (vip-* config_versions) and use the standard "View Change" diff — no bespoke VIP modal.
|
||||
|
||||
// Initial load on component mount
|
||||
useEffect(() => {
|
||||
@@ -67,7 +70,7 @@ const ApplyManagement = () => {
|
||||
useEffect(() => {
|
||||
if (selectedCluster) {
|
||||
// CRITICAL: Clear all state immediately when cluster changes to prevent cross-cluster contamination
|
||||
setPendingChanges({ frontends: [], backends: [], waf_rules: [], ssl_certificates: [], total_count: 0 });
|
||||
setPendingChanges({ frontends: [], backends: [], waf_rules: [], ssl_certificates: [], vips: [], total_count: 0 });
|
||||
setConfigVersions([]);
|
||||
setAgentSync(null);
|
||||
setEntitySyncStates({});
|
||||
@@ -77,7 +80,7 @@ const ApplyManagement = () => {
|
||||
fetchConfigVersions();
|
||||
fetchAgentSync();
|
||||
} else {
|
||||
setPendingChanges({ frontends: [], backends: [], waf_rules: [], ssl_certificates: [], total_count: 0 });
|
||||
setPendingChanges({ frontends: [], backends: [], waf_rules: [], ssl_certificates: [], vips: [], total_count: 0 });
|
||||
setConfigVersions([]);
|
||||
setAgentSync(null);
|
||||
setEntitySyncStates({});
|
||||
@@ -125,26 +128,32 @@ const ApplyManagement = () => {
|
||||
'Pragma': 'no-cache'
|
||||
};
|
||||
|
||||
const [frontendsRes, backendsRes, wafRes, sslRes] = await Promise.all([
|
||||
axios.get('/api/frontends', {
|
||||
const [frontendsRes, backendsRes, wafRes, sslRes, vipsRes] = await Promise.all([
|
||||
axios.get('/api/frontends', {
|
||||
params: { cluster_id: selectedCluster.id, include_inactive: true },
|
||||
headers: cacheHeaders
|
||||
}).catch(() => ({ data: { frontends: [] } })),
|
||||
|
||||
axios.get('/api/backends', {
|
||||
|
||||
axios.get('/api/backends', {
|
||||
params: { cluster_id: selectedCluster.id, include_inactive: true },
|
||||
headers: cacheHeaders
|
||||
}).catch(() => ({ data: { backends: [] } })),
|
||||
|
||||
axios.get('/api/waf/rules', {
|
||||
|
||||
axios.get('/api/waf/rules', {
|
||||
params: { cluster_id: selectedCluster.id },
|
||||
headers: cacheHeaders
|
||||
}).catch(() => ({ data: { rules: [] } })),
|
||||
|
||||
axios.get('/api/ssl/certificates', {
|
||||
|
||||
axios.get('/api/ssl/certificates', {
|
||||
params: { cluster_id: selectedCluster.id },
|
||||
headers: cacheHeaders
|
||||
}).catch(() => ({ data: [] }))
|
||||
}).catch(() => ({ data: [] })),
|
||||
|
||||
// HA/VIP (Issue #27): pool-scoped, fetched cluster-scoped so it lists like other entities
|
||||
axios.get('/api/vip', {
|
||||
params: { cluster_id: selectedCluster.id },
|
||||
headers: cacheHeaders
|
||||
}).catch(() => ({ data: { vips: [] } }))
|
||||
]);
|
||||
|
||||
// DEBUG: Log raw backend data
|
||||
@@ -180,14 +189,17 @@ const ApplyManagement = () => {
|
||||
|
||||
const waf_rules = (wafRes.data.rules || []).filter(w => w.has_pending_config);
|
||||
const ssl_certificates = (sslRes.data.ssl_certificates || sslRes.data || []).filter(s => s.has_pending_config);
|
||||
// VIP "pending" is its last_config_status (no has_pending_config flag).
|
||||
const vips = (vipsRes.data.vips || []).filter(v => v.last_config_status === 'PENDING');
|
||||
|
||||
const total_count = frontends.length + backends.length + waf_rules.length + ssl_certificates.length;
|
||||
const total_count = frontends.length + backends.length + waf_rules.length + ssl_certificates.length + vips.length;
|
||||
|
||||
setPendingChanges({
|
||||
frontends,
|
||||
backends,
|
||||
waf_rules,
|
||||
ssl_certificates,
|
||||
vips,
|
||||
total_count
|
||||
});
|
||||
|
||||
@@ -370,6 +382,9 @@ const ApplyManagement = () => {
|
||||
{pendingChanges.ssl_certificates.length > 0 && (
|
||||
<li><strong>{pendingChanges.ssl_certificates.length}</strong> SSL certificate changes</li>
|
||||
)}
|
||||
{(pendingChanges.vips || []).length > 0 && (
|
||||
<li><strong>{pendingChanges.vips.length}</strong> HA/VIP changes</li>
|
||||
)}
|
||||
</ul>
|
||||
<Alert
|
||||
message="All changes will be applied together and sent to agents"
|
||||
@@ -387,6 +402,74 @@ const ApplyManagement = () => {
|
||||
});
|
||||
};
|
||||
|
||||
// HA/VIP convergence tracking (Issue #27 follow-up). VIP teardown/deploy is asynchronous —
|
||||
// member agents converge keepalived on their next poll. Mirror the HAProxy agent-sync widget
|
||||
// so the progress popup keeps showing "Syncing HA/VIP... X/Y" until the nodes report the VIP
|
||||
// ACTIVE, instead of flashing green while the HA/VIP page still shows SYNCING. Fire-and-forget
|
||||
// recursive poll exactly like checkAgentSync (applyLoading is released immediately; this runs
|
||||
// in the background and updates the floating widget). Bounded so an offline node can't poll
|
||||
// forever — it then completes with an informational "still converging" note.
|
||||
const trackVipConvergence = (clusterId, vipIds, startedAt) => {
|
||||
const token = localStorage.getItem('token');
|
||||
const total = vipIds.length;
|
||||
const poll = async () => {
|
||||
let vips = [];
|
||||
try {
|
||||
const r = await axios.get(`/api/vip?cluster_id=${clusterId}`, { headers: { Authorization: `Bearer ${token}` } });
|
||||
vips = r.data.vips || [];
|
||||
} catch (e) { /* transient — keep polling */ }
|
||||
// A change has converged when the node reports the VIP ACTIVE (create/edit) OR the VIP is
|
||||
// fully torn down (delete approval): deploy_status DELETED, or it has dropped off the list
|
||||
// entirely once every member acked the teardown.
|
||||
const isConverged = (vid) => {
|
||||
const v = vips.find(x => x.id === vid);
|
||||
if (!v) return true; // gone from the list → torn down / deleted
|
||||
return v.deploy_status === 'ACTIVE' || v.deploy_status === 'DELETED';
|
||||
};
|
||||
const synced = vipIds.filter(isConverged).length; // VIP-level (drives completion)
|
||||
const errored = vips.filter(v => vipIds.includes(v.id)
|
||||
&& (v.deploy_status === 'ERROR' || v.deploy_status === 'ATTENTION')).length;
|
||||
// Per-NODE progress: sum member acks across the tracked VIPs still present, so a multi-node
|
||||
// VIP shows "1/2 node(s)" like the HA/VIP table — not just "1 change". Gone (torn-down) VIPs
|
||||
// are already counted converged via isConverged.
|
||||
let nodeTotal = 0, nodeSynced = 0;
|
||||
for (const vid of vipIds) {
|
||||
const v = vips.find(x => x.id === vid);
|
||||
if (v) { nodeTotal += (v.deploy_total || 0); nodeSynced += (v.deploy_synced || 0); }
|
||||
}
|
||||
const label = nodeTotal > 0 ? `${nodeSynced}/${nodeTotal} node(s)` : `${synced}/${total} change(s)`;
|
||||
const prog = Math.min(95, 60 + Math.round(35 * (nodeTotal > 0 ? nodeSynced / nodeTotal : synced / Math.max(1, total))));
|
||||
updateEntityCounts(nodeTotal > 0 ? nodeSynced : synced, nodeTotal > 0 ? nodeTotal : total, 0, 0, 0);
|
||||
|
||||
if (synced === total) {
|
||||
const msg = `${total} HA/VIP change(s) fully converged on all member node(s).`;
|
||||
setSyncProgress({ visible: true, step: msg, progress: 100 });
|
||||
completeProgress(msg);
|
||||
setTimeout(() => { setSyncProgress({ visible: false, step: '', progress: 0 }); message.success(msg); }, 1500);
|
||||
return;
|
||||
}
|
||||
if (errored > 0) {
|
||||
const msg = 'HA/VIP applied, but a member node reported an issue — open Diagnostics on the HA / VIP page.';
|
||||
setSyncProgress({ visible: true, step: msg, progress: 100 });
|
||||
completeProgress(msg);
|
||||
setTimeout(() => { setSyncProgress({ visible: false, step: '', progress: 0 }); message.warning(msg); }, 1800);
|
||||
return;
|
||||
}
|
||||
if (Date.now() - startedAt > 300000) { // ~5 min cap (e.g. an offline member node)
|
||||
const msg = `HA/VIP applied — ${label} converged; the rest are still converging (or a node's agent is offline). Track live status on the HA / VIP page.`;
|
||||
setSyncProgress({ visible: true, step: msg, progress: 100 });
|
||||
completeProgress(msg);
|
||||
setTimeout(() => { setSyncProgress({ visible: false, step: '', progress: 0 }); message.info(msg); }, 2000);
|
||||
return;
|
||||
}
|
||||
const step = `Syncing HA/VIP... ${label} converged (keepalived deploy can take a couple of minutes)`;
|
||||
setSyncProgress({ visible: true, step, progress: prog });
|
||||
updateProgress(step, prog, { 'Synced HA/VIP': label });
|
||||
setTimeout(poll, 5000);
|
||||
};
|
||||
setTimeout(poll, 2000);
|
||||
};
|
||||
|
||||
const executeApplyAll = async () => {
|
||||
setApplyLoading(true);
|
||||
|
||||
@@ -429,13 +512,28 @@ const ApplyManagement = () => {
|
||||
|
||||
// Detect if this is a restore operation (safe display-only check)
|
||||
const pendingVersions = configVersions.filter(v => v.status === 'PENDING');
|
||||
const isRestoreOperation = totalEntities === 0 && pendingVersions.some(v => v.version_name.startsWith('restore-'));
|
||||
|
||||
// HA/VIP (Issue #27): vip-* versions are VIP-owned and excluded from the HAProxy apply
|
||||
// (cluster.py). Ignore them when deciding whether this apply has any HAProxy/cluster
|
||||
// work to track — otherwise a VIP-only apply (which now stages a vip-* version) would
|
||||
// look like it has a pending version and the agent-sync tracker would hang on 0/0.
|
||||
const nonVipPendingVersions = pendingVersions.filter(v => !(v.version_name || '').startsWith('vip-'));
|
||||
const isRestoreOperation = totalEntities === 0 && nonVipPendingVersions.some(v => v.version_name.startsWith('restore-'));
|
||||
// HA/VIP-only apply: no HAProxy entity or config-version goes through the cluster-sync
|
||||
// pipeline, so complete promptly after the isolated VIP apply (member nodes converge
|
||||
// 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;
|
||||
|
||||
if (isRestoreOperation) {
|
||||
// Restore operation: Show "Configuration" instead of "Entities"
|
||||
setSyncProgress({ visible: true, step: `Applying configuration restore... Configuration: 0/1, Agents: ⏳`, progress: 20 });
|
||||
startProgress('apply', `Applying configuration restore... Configuration: 0/1, Agents: ⏳`);
|
||||
updateEntityCounts(0, 1, 0, totalAgents, disabledAgents); // Show 1 configuration item
|
||||
} else if (isVipOnly) {
|
||||
// HA/VIP-only: avoid the "Entities: 0/0" / agent-sync widget entirely.
|
||||
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 {
|
||||
// Normal operation: Show "Entities" as usual
|
||||
setSyncProgress({ visible: true, step: `Applying configuration changes... Entities: 0/${totalEntities}, Agents: ⏳`, progress: 20 });
|
||||
@@ -445,16 +543,59 @@ const ApplyManagement = () => {
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
const response = await axios.post(
|
||||
`/api/clusters/${selectedCluster.id}/apply-changes`,
|
||||
{},
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
|
||||
|
||||
// HA/VIP (Issue #27): apply pending VIPs first (isolated endpoint; safe no-op for
|
||||
// HAProxy config). Done here so VIPs apply even when there are no HAProxy changes.
|
||||
const pendingVips = pendingChanges.vips || [];
|
||||
for (const vip of pendingVips) {
|
||||
try {
|
||||
await axios.post(`/api/vip/${vip.id}/apply`, {}, { headers: { Authorization: `Bearer ${token}` } });
|
||||
} catch (vipErr) {
|
||||
console.error(`[APPLY VIP ${vip.id}] failed:`, vipErr);
|
||||
message.warning(`VIP "${vip.name}" apply failed: ${extractApiError(vipErr, 'error')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply HAProxy changes only if there are any (avoids a no-op call when only VIPs are pending).
|
||||
const haproxyPending = pendingChanges.frontends.length + pendingChanges.backends.length
|
||||
+ pendingChanges.waf_rules.length + pendingChanges.ssl_certificates.length;
|
||||
const response = haproxyPending > 0
|
||||
? await axios.post(
|
||||
`/api/clusters/${selectedCluster.id}/apply-changes`,
|
||||
{},
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
)
|
||||
: { data: { message: `Applied ${pendingVips.length} HA/VIP change(s)`, applied_count: pendingVips.length } };
|
||||
|
||||
// CRITICAL DEBUG: Log apply response
|
||||
console.log('[APPLY ALL RESPONSE]:', response.data);
|
||||
|
||||
|
||||
// HA/VIP-only apply (Issue #27): nothing goes through the HAProxy config-version /
|
||||
// cluster-sync pipeline, so the agent-sync tracker below would loop forever on
|
||||
// "Entities: 0/0". The isolated VIP apply already staged each node's snapshot; member
|
||||
// agents install/configure keepalived and converge on their next poll — live status
|
||||
// shows on the HA / VIP page (PENDING → SYNCING → ACTIVE). Complete now (no flash).
|
||||
if (haproxyPending === 0 && nonVipPendingVersions.length === 0) {
|
||||
await fetchPendingChanges();
|
||||
await fetchConfigVersions();
|
||||
if (pendingVips.length > 0) {
|
||||
// Keep the popup in a "Syncing HA/VIP... X/Y" state (like every other entity) until the
|
||||
// member nodes report the VIP ACTIVE — instead of flashing green while the HA/VIP page
|
||||
// still shows SYNCING. The poll runs in the background (finally{} releases applyLoading).
|
||||
const step = `Applied — syncing HA/VIP... 0/${pendingVips.length} node(s) converged`;
|
||||
updateEntityCounts(0, pendingVips.length, 0, 0, 0);
|
||||
setSyncProgress({ visible: true, step, progress: 60 });
|
||||
updateProgress(step, 60);
|
||||
trackVipConvergence(selectedCluster.id, pendingVips.map(v => v.id), Date.now());
|
||||
} else {
|
||||
const vmsg = 'Changes applied.';
|
||||
setSyncProgress({ visible: true, step: vmsg, progress: 100 });
|
||||
completeProgress(vmsg);
|
||||
setTimeout(() => { setSyncProgress({ visible: false, step: '', progress: 0 }); message.success(vmsg); }, 1500);
|
||||
}
|
||||
return; // finally{} resets applyLoading; the VIP poll runs in the background (like checkAgentSync)
|
||||
}
|
||||
|
||||
setSyncProgress({ visible: true, step: `Configuration applied, syncing agents... Entities: ${totalEntities}/${totalEntities}, Agents: 0/${totalAgents}`, progress: 60 });
|
||||
updateProgress(`Configuration applied, syncing agents... Entities: ${totalEntities}/${totalEntities}, Agents: 0/${totalAgents}`, 60);
|
||||
updateEntityCounts(totalEntities, totalEntities, 0, totalAgents, disabledAgents);
|
||||
@@ -678,6 +819,9 @@ const ApplyManagement = () => {
|
||||
{pendingChanges.ssl_certificates.length > 0 && (
|
||||
<li><strong>{pendingChanges.ssl_certificates.length}</strong> SSL certificate changes</li>
|
||||
)}
|
||||
{(pendingChanges.vips || []).length > 0 && (
|
||||
<li><strong>{pendingChanges.vips.length}</strong> HA/VIP changes</li>
|
||||
)}
|
||||
</ul>
|
||||
<Alert
|
||||
message="All pending changes will be permanently discarded"
|
||||
@@ -718,12 +862,28 @@ const ApplyManagement = () => {
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
const response = await axios.delete(
|
||||
`/api/clusters/${selectedCluster.id}/pending-changes`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
|
||||
|
||||
// HA/VIP (Issue #27): reject pending VIPs (isolated endpoint; restores last applied state).
|
||||
const pendingVips = pendingChanges.vips || [];
|
||||
for (const vip of pendingVips) {
|
||||
try {
|
||||
await axios.post(`/api/vip/${vip.id}/reject`, {}, { headers: { Authorization: `Bearer ${token}` } });
|
||||
} catch (vipErr) {
|
||||
console.error(`[REJECT VIP ${vip.id}] failed:`, vipErr);
|
||||
message.warning(`VIP "${vip.name}" reject failed: ${extractApiError(vipErr, 'error')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Reject HAProxy changes only if there are any.
|
||||
const haproxyPending = pendingChanges.frontends.length + pendingChanges.backends.length
|
||||
+ pendingChanges.waf_rules.length + pendingChanges.ssl_certificates.length;
|
||||
const response = haproxyPending > 0
|
||||
? await axios.delete(
|
||||
`/api/clusters/${selectedCluster.id}/pending-changes`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
)
|
||||
: { data: { message: `Rejected ${pendingVips.length} HA/VIP change(s)` } };
|
||||
|
||||
// CRITICAL DEBUG: Log reject response
|
||||
console.log('[REJECT ALL RESPONSE]:', response.data);
|
||||
|
||||
@@ -1133,7 +1293,28 @@ const ApplyManagement = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pendingChanges.frontends.length === 0 && pendingChanges.backends.length === 0 && pendingChanges.waf_rules.length === 0 && pendingChanges.ssl_certificates.length === 0 && pendingVersions.length > 0 && (
|
||||
{/* HA/VIP Changes (Issue #27) */}
|
||||
{(pendingChanges.vips || []).length > 0 && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Title level={5}>
|
||||
<CloudServerOutlined style={{ marginRight: 8, color: '#13c2c2' }} />
|
||||
HA / VIP Changes ({pendingChanges.vips.length})
|
||||
</Title>
|
||||
{pendingChanges.vips.map(item => (
|
||||
<div key={`vip-${item.id}`} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', marginBottom: 6, border: item.pending_delete ? '1px solid #ffccc7' : '1px solid #f0f0f0', borderRadius: 6, background: item.pending_delete ? '#fff1f0' : undefined }}>
|
||||
<CloudServerOutlined style={{ color: item.pending_delete ? '#cf1322' : '#13c2c2' }} />
|
||||
<span style={{ fontWeight: 500 }}>{item.name}</span>
|
||||
<Tag>{item.virtual_ip}/{item.prefix_length}</Tag>
|
||||
{item.pool_name && <Tag color="blue">{item.pool_name}</Tag>}
|
||||
{item.pending_delete
|
||||
? <Tag color="red">DELETION — approve to tear down, reject to keep</Tag>
|
||||
: <Tag color="orange">PENDING</Tag>}
|
||||
</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-'));
|
||||
@@ -1401,6 +1582,10 @@ const ApplyManagement = () => {
|
||||
confusing error post-click. */}
|
||||
{(() => {
|
||||
const vn = version?.version_name || '';
|
||||
// vip-* versions ARE undoable (Issue #27): the VIP router
|
||||
// re-stages the rejected change as PENDING (reactivating a
|
||||
// rejected create, or re-applying a rejected edit), so they
|
||||
// use the normal Undo button below — not this disabled case.
|
||||
const destructive = (
|
||||
vn.startsWith('bulk-site-create-')
|
||||
|| vn.startsWith('bulk-import-')
|
||||
|
||||
@@ -172,11 +172,13 @@ const BackendServers = () => {
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = { cluster_id: selectedCluster.id };
|
||||
|
||||
// Issue #24: request inactive entities so DISABLED (toggled-OFF) servers
|
||||
// are returned and can be reactivated from the UI (mirrors ApplyManagement).
|
||||
const params = { cluster_id: selectedCluster.id, include_inactive: true };
|
||||
|
||||
// CRITICAL FIX: Add cache busting to prevent stale data from appearing
|
||||
// Browser/axios may cache GET requests, causing deleted backends to reappear
|
||||
const response = await axios.get('/api/backends', {
|
||||
const response = await axios.get('/api/backends', {
|
||||
params,
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
@@ -184,7 +186,21 @@ const BackendServers = () => {
|
||||
'Expires': '0'
|
||||
}
|
||||
});
|
||||
const fetchedBackends = response.data.backends || [];
|
||||
// Issue #24: we now request include_inactive=true so DISABLED servers come
|
||||
// back. That ALSO returns soft-deleted (is_active=false) BACKENDS, which the
|
||||
// pre-change default (include_inactive=false -> WHERE is_active=TRUE) hid.
|
||||
// Restore that filter here so soft-deleted backends don't reappear in the
|
||||
// normal view (preserve commit f34a6ee), while still keeping inactive
|
||||
// SERVERS inside active backends. Then hide ONLY soft-deleted-pending
|
||||
// servers (last_config_status==='DELETION'); DISABLED servers (toggled
|
||||
// OFF — status PENDING/APPLIED) stay visible so operators can re-enable
|
||||
// them. Single chokepoint covers counts, expanded rows, and All Servers tab.
|
||||
const fetchedBackends = (response.data.backends || [])
|
||||
.filter(b => b.is_active !== false)
|
||||
.map(b => ({
|
||||
...b,
|
||||
servers: (b.servers || []).filter(s => s.last_config_status !== 'DELETION'),
|
||||
}));
|
||||
setBackends(fetchedBackends);
|
||||
// CRITICAL FIX: Apply status filters after fetching to maintain filter state
|
||||
// This prevents backends from disappearing when updated (e.g., APPLIED → PENDING)
|
||||
@@ -1289,6 +1305,7 @@ const BackendServers = () => {
|
||||
/>
|
||||
<strong>{text}</strong>
|
||||
{record.backup_server && <Tag color="orange">Backup</Tag>}
|
||||
{!record.is_active && <Tag color="red">Inactive</Tag>}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -156,6 +156,7 @@ const ClusterManagement = () => {
|
||||
stats_socket_path: cluster.stats_socket_path || '/run/haproxy/admin.sock',
|
||||
haproxy_config_path: cluster.haproxy_config_path || '/etc/haproxy/haproxy.cfg',
|
||||
haproxy_bin_path: cluster.haproxy_bin_path,
|
||||
keepalived_config_path: cluster.keepalived_config_path || '/etc/keepalived/keepalived.conf',
|
||||
agent_pool_id: cluster.pool_id || undefined,
|
||||
haproxy_user: cluster.haproxy_user || '',
|
||||
haproxy_group: cluster.haproxy_group || '',
|
||||
@@ -197,6 +198,7 @@ const ClusterManagement = () => {
|
||||
stats_socket_path: values.stats_socket_path,
|
||||
haproxy_config_path: values.haproxy_config_path,
|
||||
haproxy_bin_path: values.haproxy_bin_path,
|
||||
keepalived_config_path: values.keepalived_config_path,
|
||||
pool_id: values.agent_pool_id || null,
|
||||
haproxy_user: values.haproxy_user || null,
|
||||
haproxy_group: values.haproxy_group || null,
|
||||
@@ -748,6 +750,14 @@ const ClusterManagement = () => {
|
||||
<Input placeholder="/usr/sbin/haproxy" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Keepalived Config Path"
|
||||
name="keepalived_config_path"
|
||||
tooltip="Where the agent writes keepalived.conf for this cluster's HA/VIPs. The default (/etc/keepalived/keepalived.conf) is what the keepalived service loads on every distro — leave it unless you run a non-standard install AND have configured the keepalived service/unit to load this exact path."
|
||||
>
|
||||
<Input placeholder="/etc/keepalived/keepalived.conf" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="ACME Challenge Routing"
|
||||
name="acme_enabled"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -146,6 +146,17 @@ const PERMISSION_TREE = [
|
||||
{ title: 'View Cluster Config', key: 'clusters.config' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '🛰️ HA / VIP Management',
|
||||
key: 'vip',
|
||||
children: [
|
||||
{ title: 'View VIPs', key: 'vip.read' },
|
||||
{ title: 'Create VIP', key: 'vip.create' },
|
||||
{ title: 'Edit VIP', key: 'vip.update' },
|
||||
{ title: 'Delete VIP', key: 'vip.delete' },
|
||||
{ title: 'Apply / Deploy VIP', key: 'vip.apply' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '📝 Configuration',
|
||||
key: 'config',
|
||||
|
||||
@@ -0,0 +1,583 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Table, Button, Space, Modal, Form, Input, InputNumber, Select, Tag, message,
|
||||
Switch, Typography, Card, Alert, Tooltip, Spin
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined, WarningOutlined,
|
||||
PlayCircleOutlined, SyncOutlined, CrownOutlined, ClockCircleOutlined, FileSearchOutlined,
|
||||
InfoCircleOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { useCluster } from '../contexts/ClusterContext';
|
||||
import { extractApiError } from '../utils/apiError';
|
||||
|
||||
const { Option } = Select;
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
// Interfaces that are never sensible VIP carriers — hidden from the dropdown.
|
||||
const IFACE_HIDE = /^(lo|docker|veth|br-|cni|flannel|kube|virbr)/i;
|
||||
|
||||
const authHeaders = () => ({
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('authToken') || ''}`,
|
||||
});
|
||||
|
||||
// Convergence-aware status (issue #27 follow-up): like other entities, a VIP only reads
|
||||
// "live" once its member agents have actually deployed & acked — never the instant Apply
|
||||
// is clicked. Backend returns deploy_status; we fall back to last_config_status.
|
||||
const DEPLOY_STATUS = {
|
||||
PENDING: { color: 'orange', label: 'PENDING', tip: 'Staged change — review and Apply (or Reject) it from the Apply Management page.' },
|
||||
PENDING_DELETE: { color: 'volcano', label: 'PENDING DELETE', tip: 'Deletion staged for approval — the VIP keeps running untouched until you APPROVE it in Apply Management. Reject to keep it. The node is not changed until approval.' },
|
||||
DELETING: { color: 'processing', label: 'DELETING', tip: 'Deletion approved — member node(s) are stopping keepalived and releasing the VIP. Disappears once every node has torn down.' },
|
||||
DELETED: { color: 'default', label: 'DELETED', tip: 'All member nodes have torn keepalived down.' },
|
||||
SYNCING: { color: 'processing', label: 'SYNCING', tip: 'Applied — an online member node is installing/configuring keepalived and will acknowledge on its next poll (~2–3 min).' },
|
||||
AWAITING: { color: 'gold', label: 'AWAITING AGENT', tip: 'Applied, but the member node(s) that still need it are OFFLINE, so nothing can deploy yet. Bring the node\'s agent online — it converges on its next poll. (Not a hang.)' },
|
||||
ACTIVE: { color: 'green', label: 'ACTIVE', tip: 'Applied and every member node has deployed keepalived and acknowledged the current config.' },
|
||||
ERROR: { color: 'red', label: 'ERROR', tip: 'A member node failed to deploy keepalived — see Members / Live state for the node, then check that agent.' },
|
||||
ATTENTION: { color: 'gold', label: 'ATTENTION',tip: 'A member already runs a hand-managed keepalived; the agent left it untouched (externally managed). Resolve it on that node or remove it from the VIP.' },
|
||||
APPLIED: { color: 'green', label: 'APPLIED', tip: 'Applied.' },
|
||||
};
|
||||
|
||||
// agents.capabilities / network_interfaces come from the API as JSONB → a JSON string
|
||||
// (asyncpg has no jsonb codec). Mirror AgentManagement.js and JSON.parse when needed.
|
||||
const parseArr = (v) => {
|
||||
if (Array.isArray(v)) return v;
|
||||
if (typeof v === 'string') { try { const p = JSON.parse(v); return Array.isArray(p) ? p : []; } catch { return []; } }
|
||||
return [];
|
||||
};
|
||||
|
||||
// This component uses raw fetch(), but extractApiError expects an axios-shaped error
|
||||
// (err.response.data). Read the fetch Response body and reuse the envelope-aware extractor
|
||||
// so backend messages — e.g. the 409 "node already in VIP X" — actually reach the user.
|
||||
const fetchApiError = async (res, fallback) => {
|
||||
try { const data = await res.json(); return extractApiError({ response: { data } }, fallback); }
|
||||
catch { return fallback; }
|
||||
};
|
||||
|
||||
const VIPManagement = () => {
|
||||
const { clusters } = useCluster();
|
||||
const [vips, setVips] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [editing, setEditing] = useState(null);
|
||||
const [selectedPoolId, setSelectedPoolId] = useState(null);
|
||||
// One row per agent in the selected pool — the user toggles which participate.
|
||||
const [memberRows, setMemberRows] = useState([]);
|
||||
const [form] = Form.useForm();
|
||||
// Delete confirmation (with opt-in package uninstall) + diagnostics modal state.
|
||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||
const [showL2Note, setShowL2Note] = useState(false);
|
||||
const [diagVip, setDiagVip] = useState(null);
|
||||
const [diagData, setDiagData] = useState(null);
|
||||
const [diagLoading, setDiagLoading] = useState(false);
|
||||
|
||||
// Distinct pools derived from the cluster list (cluster -> pool_id).
|
||||
const pools = React.useMemo(() => {
|
||||
const seen = new Map();
|
||||
(clusters || []).forEach((c) => {
|
||||
if (c.pool_id && !seen.has(c.pool_id)) seen.set(c.pool_id, c.name || `pool ${c.pool_id}`);
|
||||
});
|
||||
return Array.from(seen, ([id, name]) => ({ id, name }));
|
||||
}, [clusters]);
|
||||
|
||||
const fetchVips = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/vip', { headers: authHeaders() });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setVips(data.vips || []);
|
||||
} else if (res.status === 403) {
|
||||
message.warning('You do not have permission to view VIPs (vip.read).');
|
||||
setVips([]);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('fetchVips failed', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchVips();
|
||||
const t = setInterval(fetchVips, 30000); // live MASTER/BACKUP via existing detection pipeline
|
||||
return () => clearInterval(t);
|
||||
}, [fetchVips]);
|
||||
|
||||
// Build the participating-nodes table from the pool's EXISTING agents (installed via the
|
||||
// standard Agent Management process). On edit, pre-select the VIP's current members.
|
||||
const buildMemberRows = (agents, existing) => {
|
||||
const ex = {};
|
||||
(existing || []).forEach((m) => { ex[m.agent_id] = m; });
|
||||
return (agents || [])
|
||||
.filter((a) => !String(a.name).startsWith('token_'))
|
||||
.map((a) => {
|
||||
const interfaces = parseArr(a.network_interfaces).filter((n) => !IFACE_HIDE.test(n));
|
||||
const e = ex[a.id];
|
||||
return {
|
||||
agent_id: a.id,
|
||||
agent_name: a.name,
|
||||
ip_address: a.ip_address,
|
||||
capable: parseArr(a.capabilities).includes('keepalived_management'),
|
||||
interfaces,
|
||||
participate: !!e,
|
||||
role: e ? e.role : 'BACKUP',
|
||||
priority: e ? e.priority : 100,
|
||||
network_interface: e ? e.network_interface : (interfaces[0] || ''),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const loadPoolMembers = useCallback(async (poolId, existing) => {
|
||||
if (!poolId) { setMemberRows([]); return; }
|
||||
try {
|
||||
const res = await fetch(`/api/agents?pool_id=${poolId}`, { headers: authHeaders() });
|
||||
const data = res.ok ? await res.json() : { agents: [] };
|
||||
setMemberRows(buildMemberRows(data.agents || [], existing));
|
||||
} catch (e) {
|
||||
console.error('loadPoolMembers failed', e);
|
||||
setMemberRows([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setRow = (agentId, patch) =>
|
||||
setMemberRows((rows) => rows.map((r) => (r.agent_id === agentId ? { ...r, ...patch } : r)));
|
||||
|
||||
// Toggling a node into the VIP: if no other participating node is MASTER yet, make this
|
||||
// one the MASTER. This makes the single-node case work without the operator having to flip
|
||||
// the role by hand (a one-node VIP's only node IS the master), and gives a sensible default
|
||||
// for multi-node (first picked = master, the rest backup). Editing keeps stored roles.
|
||||
const toggleParticipate = (agentId, on) =>
|
||||
setMemberRows((rows) => {
|
||||
const otherMaster = rows.some((r) => r.agent_id !== agentId && r.participate && r.role === 'MASTER');
|
||||
return rows.map((r) => {
|
||||
if (r.agent_id !== agentId) return r;
|
||||
if (on && !otherMaster) return { ...r, participate: true, role: 'MASTER', priority: 150 };
|
||||
return { ...r, participate: on };
|
||||
});
|
||||
});
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setSelectedPoolId(null);
|
||||
setMemberRows([]);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ prefix_length: 24, advert_int: 1, use_unicast: true, track_haproxy: true });
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const openEdit = (vip) => {
|
||||
setEditing(vip);
|
||||
setSelectedPoolId(vip.pool_id);
|
||||
loadPoolMembers(vip.pool_id, vip.members);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
name: vip.name, description: vip.description, pool_id: vip.pool_id,
|
||||
virtual_ip: vip.virtual_ip, prefix_length: vip.prefix_length,
|
||||
virtual_router_id: vip.virtual_router_id, advert_int: vip.advert_int,
|
||||
use_unicast: vip.use_unicast, track_haproxy: vip.track_haproxy,
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
let values;
|
||||
try { values = await form.validateFields(); }
|
||||
catch { return; }
|
||||
|
||||
const chosen = memberRows.filter((r) => r.participate);
|
||||
if (chosen.length < 1) { message.error('Select at least 1 participating node.'); return; }
|
||||
const masters = chosen.filter((r) => r.role === 'MASTER');
|
||||
if (masters.length !== 1) { message.error('Exactly one participating node must be MASTER.'); return; }
|
||||
if (chosen.some((r) => !r.network_interface)) { message.error('Pick a network interface for every participating node.'); return; }
|
||||
const maxBackup = Math.max(...chosen.filter((r) => r.role === 'BACKUP').map((r) => r.priority));
|
||||
if (masters[0].priority <= maxBackup) { message.error('The MASTER must have a higher priority than every BACKUP.'); return; }
|
||||
|
||||
const body = {
|
||||
...values,
|
||||
members: chosen.map((r) => ({
|
||||
agent_id: r.agent_id, network_interface: r.network_interface, role: r.role, priority: r.priority,
|
||||
})),
|
||||
};
|
||||
if (!body.auth_pass) delete body.auth_pass; // omit to keep existing on edit
|
||||
try {
|
||||
const url = editing ? `/api/vip/${editing.id}` : '/api/vip';
|
||||
const res = await fetch(url, { method: editing ? 'PUT' : 'POST', headers: authHeaders(), body: JSON.stringify(body) });
|
||||
if (res.ok) {
|
||||
message.success(editing
|
||||
? 'VIP updated (PENDING) — apply it from the Apply Management page'
|
||||
: 'VIP created (PENDING) — apply it from the Apply Management page');
|
||||
setModalVisible(false);
|
||||
fetchVips();
|
||||
} else {
|
||||
message.error(await fetchApiError(res, 'Failed to save VIP'));
|
||||
}
|
||||
} catch (e) {
|
||||
message.error('Failed to save VIP: ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteVip = async (vip, purge) => {
|
||||
try {
|
||||
const res = await fetch(`/api/vip/${vip.id}${purge ? '?purge_package=true' : ''}`,
|
||||
{ method: 'DELETE', headers: authHeaders() });
|
||||
if (res.ok) {
|
||||
let body = {};
|
||||
try { body = await res.json(); } catch (_) { /* ignore */ }
|
||||
// Backend returns staged=true (approval required, VIP still running) or staged=false
|
||||
// (never-applied VIP removed at once). Surface its exact message either way.
|
||||
(body.staged ? message.info : message.success)(
|
||||
body.message || 'Deletion requested.');
|
||||
setDeleteTarget(null); fetchVips();
|
||||
} else message.error(await fetchApiError(res, 'Delete failed'));
|
||||
} catch (e) { message.error('Delete failed: ' + e.message); }
|
||||
};
|
||||
|
||||
// Diagnostics: per-member deploy state/ack from GET /api/vip/{id}/status — the live view
|
||||
// of what each node reported (installing/applied/error/externally-managed), most useful
|
||||
// while a freshly-applied VIP is SYNCING (keepalived install can take ~30s).
|
||||
const openDiagnostics = async (vip) => {
|
||||
setDiagVip(vip); setDiagData(null); setDiagLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/vip/${vip.id}/status`, { headers: authHeaders() });
|
||||
if (res.ok) setDiagData(await res.json());
|
||||
else message.error(await fetchApiError(res, 'Failed to load diagnostics'));
|
||||
} catch (e) { message.error('Diagnostics failed: ' + e.message); }
|
||||
finally { setDiagLoading(false); }
|
||||
};
|
||||
|
||||
// Colorful, IP-Inventory/Agent-consistent state: live VRRP MASTER (green, crowned) /
|
||||
// BACKUP (orange) / FAULT (red); when the agent hasn't reported a live state yet, show
|
||||
// the configured role as a dashed outline tag (same color) so it's clearly "intended,
|
||||
// not yet observed". A node whose agent is too old gets an "awaiting agent" flag.
|
||||
const renderMembers = (_, vip) => (
|
||||
<Space direction="vertical" size={4}>
|
||||
{(vip.members || []).map((m) => {
|
||||
const live = m.keepalive_state && m.keepalive_state !== 'NONE' ? m.keepalive_state : null;
|
||||
const roleColor = m.role === 'MASTER' ? 'green' : 'orange';
|
||||
const awaiting = !live && !m.keepalived_capable;
|
||||
return (
|
||||
<Space key={m.agent_id} size={6}>
|
||||
<Text style={{ fontSize: 12 }}>{m.agent_name || `agent ${m.agent_id}`}</Text>
|
||||
{live ? (
|
||||
<Tooltip title={`Live VRRP state: ${live}`}>
|
||||
<Tag
|
||||
color={live === 'MASTER' ? 'green' : live === 'BACKUP' ? 'orange' : 'red'}
|
||||
icon={live === 'MASTER' ? <CrownOutlined /> : undefined}
|
||||
style={{ marginInlineEnd: 0, fontWeight: 600 }}
|
||||
>
|
||||
{live}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title="Configured role — live VRRP state not observed yet (agent offline or still converging).">
|
||||
<Tag color={roleColor} style={{ marginInlineEnd: 0, borderStyle: 'dashed', opacity: 0.85 }}>
|
||||
{m.role}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
{awaiting && (
|
||||
<Tooltip title="This node's agent does not advertise keepalived_management — upgrade the agent.">
|
||||
<Tag color="gold" icon={<WarningOutlined />} style={{ marginInlineEnd: 0 }}>awaiting agent</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
);
|
||||
|
||||
const columns = [
|
||||
{ title: 'Name', dataIndex: 'name', key: 'name' },
|
||||
{ title: 'Virtual IP', key: 'vip', render: (_, v) => <Text code>{v.virtual_ip}/{v.prefix_length}</Text> },
|
||||
{ title: 'Pool', dataIndex: 'pool_name', key: 'pool' },
|
||||
{ title: 'VRID', dataIndex: 'virtual_router_id', key: 'vrid' },
|
||||
{ title: 'Members / Live state', key: 'members', render: renderMembers },
|
||||
{
|
||||
title: 'Status', key: 'status', render: (_, v) => {
|
||||
const s = v.deploy_status || v.last_config_status;
|
||||
const d = DEPLOY_STATUS[s] || { color: 'default', label: s, tip: '' };
|
||||
const count = (s === 'SYNCING' || s === 'AWAITING' || s === 'ACTIVE' || s === 'DELETING' || s === 'DELETED') && v.deploy_total
|
||||
? ` (${v.deploy_synced}/${v.deploy_total})` : '';
|
||||
return (
|
||||
<Tooltip title={d.tip}>
|
||||
<Tag color={d.color} icon={(s === 'SYNCING' || s === 'DELETING') ? <SyncOutlined spin /> : s === 'AWAITING' ? <ClockCircleOutlined /> : undefined}>
|
||||
{(d.label || s)}{count}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Actions', key: 'actions', render: (_, v) => (
|
||||
<Space>
|
||||
{v.last_config_status === 'PENDING' && (
|
||||
<Tooltip title="Apply pending configuration changes">
|
||||
<Button type="primary" size="small" icon={<PlayCircleOutlined />}
|
||||
onClick={() => { window.location.href = '/apply-management'; }}
|
||||
style={{ backgroundColor: '#1890ff', borderColor: '#1890ff' }}>
|
||||
Apply
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title="Edit VIP (changes become PENDING; apply from Apply Management)">
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => openEdit(v)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="Diagnostics — per-node keepalived deploy status & logs">
|
||||
<Button size="small" icon={<FileSearchOutlined />} onClick={() => openDiagnostics(v)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete VIP">
|
||||
<Button size="small" danger icon={<DeleteOutlined />}
|
||||
onClick={() => setDeleteTarget(v)} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// Member-selection table inside the modal — the pool's installed agents (nodes).
|
||||
const memberColumns = [
|
||||
{
|
||||
title: 'Node (agent)', key: 'node', render: (_, r) => (
|
||||
<span>
|
||||
<Text strong>{r.agent_name}</Text>{' '}
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{r.ip_address ? `(${r.ip_address})` : '(no IP yet)'}</Text>
|
||||
{!r.capable && (
|
||||
<Tooltip title="This agent doesn't advertise keepalived_management — upgrade it or this node won't deploy.">
|
||||
{' '}<Tag color="gold" icon={<WarningOutlined />}>agent too old</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Participate', key: 'participate', width: 100, render: (_, r) => (
|
||||
<Switch checked={r.participate} onChange={(c) => toggleParticipate(r.agent_id, c)} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Role', key: 'role', width: 130, render: (_, r) => (
|
||||
<Select size="small" style={{ width: 110 }} value={r.role} disabled={!r.participate}
|
||||
onChange={(val) => setRow(r.agent_id, { role: val, priority: val === 'MASTER' ? 150 : 100 })}>
|
||||
<Option value="MASTER">MASTER</Option>
|
||||
<Option value="BACKUP">BACKUP</Option>
|
||||
</Select>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Priority', key: 'priority', width: 110, render: (_, r) => (
|
||||
<InputNumber size="small" min={1} max={254} value={r.priority} disabled={!r.participate}
|
||||
onChange={(val) => setRow(r.agent_id, { priority: val })} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Interface', key: 'iface', width: 160, render: (_, r) => (
|
||||
<Select size="small" style={{ width: 140 }} value={r.network_interface || undefined}
|
||||
placeholder="interface" disabled={!r.participate} showSearch
|
||||
onChange={(val) => setRow(r.agent_id, { network_interface: val })}
|
||||
notFoundContent="no interfaces reported"
|
||||
options={(r.interfaces || []).map((n) => ({ label: n, value: n }))}
|
||||
{...((r.interfaces || []).length === 0 ? { mode: 'tags' } : {})} />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<Title level={2} style={{ margin: 0 }}>HA / VIP (Keepalived)</Title>
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={fetchVips}>Refresh</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>Create VIP</Button>
|
||||
</Space>
|
||||
</div>
|
||||
{/* The cloud caveat is rarely relevant for the on-prem target audience, so it's a
|
||||
subtle, collapsed-by-default info note (not a prominent yellow warning). */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Button type="link" size="small" icon={<InfoCircleOutlined />} style={{ paddingLeft: 0 }}
|
||||
onClick={() => setShowL2Note((v) => !v)}>
|
||||
Network requirements (on-prem / L2)
|
||||
</Button>
|
||||
{showL2Note && (
|
||||
<Alert
|
||||
type="info" showIcon style={{ marginTop: 4 }}
|
||||
message="On-prem / L2 networks"
|
||||
description="VRRP-based VIP failover targets bare-metal / VMware / on-prem L2 segments. On AWS/Azure/GCP, cloud fabrics don't honor VRRP/gratuitous-ARP, so VIPs won't move. Ensure VRRP (IP protocol 112) is permitted by host firewalls."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Table rowKey="id" columns={columns} dataSource={vips} loading={loading} pagination={{ pageSize: 10 }} />
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editing ? `Edit VIP — ${editing.name}` : 'Create VIP'}
|
||||
open={modalVisible}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
onOk={submit}
|
||||
okText={editing ? 'Save (PENDING)' : 'Create (PENDING)'}
|
||||
width={880}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="Name" rules={[{ required: true }]}>
|
||||
<Input placeholder="web-vip" disabled={!!editing} />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="Description">
|
||||
<Input placeholder="optional" />
|
||||
</Form.Item>
|
||||
{!editing && (
|
||||
<Form.Item name="pool_id" label="Pool" rules={[{ required: true }]}
|
||||
tooltip="The VIP's nodes are the HAProxy servers (agents) already enrolled in this pool.">
|
||||
<Select placeholder="Select a pool" onChange={(pid) => { setSelectedPoolId(pid); loadPoolMembers(pid); }}>
|
||||
{pools.map((p) => <Option key={p.id} value={p.id}>{p.name}</Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Space size="large" style={{ display: 'flex' }}>
|
||||
<Form.Item name="virtual_ip" label="Virtual IP (IPv4)" rules={[{ required: true }]}>
|
||||
<Input placeholder="10.0.0.100" />
|
||||
</Form.Item>
|
||||
<Form.Item name="prefix_length" label="Prefix" rules={[{ required: true }]}>
|
||||
<InputNumber min={1} max={32} />
|
||||
</Form.Item>
|
||||
<Form.Item name="virtual_router_id" label="VRID (blank = auto)">
|
||||
<InputNumber min={1} max={255} placeholder="auto" />
|
||||
</Form.Item>
|
||||
<Form.Item name="advert_int" label="Advert int (s)">
|
||||
<InputNumber min={1} max={255} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Space size="large">
|
||||
<Form.Item name="use_unicast" label="Unicast VRRP" valuePropName="checked" tooltip="Recommended; works where multicast is blocked.">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="track_haproxy" label="Fail over when HAProxy drops" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="auth_pass" label="VRRP secret (≤8 chars)">
|
||||
<Input.Password placeholder={editing ? '•••• (unchanged)' : 'optional'} maxLength={8} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
<Text strong>Participating nodes</Text>
|
||||
<div style={{ color: '#888', fontSize: 12, marginBottom: 8 }}>
|
||||
These are the HAProxy servers (agents) already enrolled in this pool — toggle which join the VIP.
|
||||
Pick <b>exactly one MASTER</b> (highest priority); the rest are BACKUP. A <b>single node</b> is allowed
|
||||
(a keepalived-managed VIP <i>without</i> failover) — add a second node for real HA. Add new servers from
|
||||
the standard Agent Management install flow.
|
||||
</div>
|
||||
<Alert
|
||||
type="info" showIcon style={{ marginBottom: 8 }}
|
||||
message="keepalived is installed automatically on Apply"
|
||||
description={<>On Apply, any participating node that doesn’t already run keepalived will <b>install it from the node’s OS package repositories</b> (apt/dnf/yum/zypper/apk) — make sure the node can reach its repos (internet or an internal mirror). A node already running a <b>hand-managed</b> keepalived is left untouched (reported as “externally managed”).</>}
|
||||
/>
|
||||
<Table
|
||||
rowKey="agent_id"
|
||||
size="small"
|
||||
columns={memberColumns}
|
||||
dataSource={memberRows}
|
||||
pagination={false}
|
||||
locale={{ emptyText: selectedPoolId ? 'No agents in this pool — install agents from Agent Management first.' : 'Select a pool to list its nodes.' }}
|
||||
/>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* Delete confirmation with opt-in package uninstall. Enterprise-safe DEFAULT keeps the
|
||||
package (just stop/disable + remove our config + release the VIP). */}
|
||||
<Modal
|
||||
title="Delete VIP — requires approval"
|
||||
open={!!deleteTarget}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onOk={() => deleteVip(deleteTarget, false)}
|
||||
okText="Stage deletion for approval"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
{deleteTarget && (
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="This does NOT delete the VIP immediately"
|
||||
description={<>It stages the deletion for approval. The VIP <b>keeps running, untouched</b>, on its
|
||||
member node(s) until you <b>Approve</b> it on the <b>Apply Management</b> page — and you can
|
||||
<b> Reject</b> it there to keep it. The node is changed <b>only after approval</b>, so an
|
||||
accidental click can't tear down a production VIP.</>}
|
||||
/>
|
||||
<Text>
|
||||
Stage deletion of <Text strong>{deleteTarget.name}</Text> ({deleteTarget.virtual_ip}/{deleteTarget.prefix_length})?
|
||||
When approved, the member node(s) <b>stop & disable keepalived, remove the config we manage, and release the VIP</b>. The keepalived package itself is left installed, so re-adding a VIP later is instant.
|
||||
</Text>
|
||||
</Space>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Per-node keepalived deploy diagnostics + node-side log commands (esp. during SYNCING). */}
|
||||
<Modal
|
||||
title={diagVip ? `Diagnostics — ${diagVip.name}` : 'Diagnostics'}
|
||||
open={!!diagVip}
|
||||
onCancel={() => { setDiagVip(null); setDiagData(null); }}
|
||||
width={780}
|
||||
footer={[
|
||||
<Button key="refresh" icon={<ReloadOutlined />} onClick={() => diagVip && openDiagnostics(diagVip)}>Refresh</Button>,
|
||||
<Button key="close" type="primary" onClick={() => { setDiagVip(null); setDiagData(null); }}>Close</Button>,
|
||||
]}
|
||||
>
|
||||
{diagLoading && <div style={{ textAlign: 'center', padding: 24 }}><Spin /></div>}
|
||||
{!diagLoading && diagData && (
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Text type="secondary">
|
||||
Staging <Tag>{diagData.last_config_status}</Tag> — each node reports its deploy state after every poll; a fresh keepalived install can take ~30s.
|
||||
</Text>
|
||||
<Table
|
||||
size="small" rowKey={(m) => m.agent_name} pagination={false}
|
||||
dataSource={diagData.members || []}
|
||||
columns={[
|
||||
{ title: 'Node', key: 'node', render: (_, m) => (
|
||||
<Space size={4}>
|
||||
<Text style={{ fontSize: 12 }}>{m.agent_name}</Text>
|
||||
{m.role === 'MASTER'
|
||||
? <Tag color="green" icon={<CrownOutlined />} style={{ marginInlineEnd: 0 }}>MASTER</Tag>
|
||||
: <Tag color="orange" style={{ marginInlineEnd: 0 }}>BACKUP</Tag>}
|
||||
</Space>) },
|
||||
{ title: 'Agent', dataIndex: 'agent_status', key: 'agent',
|
||||
render: (s) => <Tag color={s === 'online' ? 'green' : 'red'}>{s || 'offline'}</Tag> },
|
||||
{ title: 'Live VRRP', dataIndex: 'keepalive_state', key: 'live',
|
||||
render: (s) => (s && s !== 'NONE')
|
||||
? <Tag color={s === 'MASTER' ? 'green' : s === 'BACKUP' ? 'orange' : 'red'}>{s}</Tag>
|
||||
: <Text type="secondary">—</Text> },
|
||||
{ title: 'Deploy', key: 'deploy', render: (_, m) => {
|
||||
const st = m.deploy_state;
|
||||
const color = st === 'enabled' ? 'green' : st === 'error' ? 'red'
|
||||
: st === 'externally_managed' ? 'gold' : 'blue';
|
||||
return <Tooltip title={m.deploy_message || ''}><Tag color={color}>{m.convergence || st || 'pending'}</Tag></Tooltip>;
|
||||
} },
|
||||
{ title: 'Last ack', dataIndex: 'deploy_at', key: 'ack',
|
||||
render: (t) => t ? <Text style={{ fontSize: 11 }}>{new Date(t).toLocaleString()}</Text> : <Text type="secondary">—</Text> },
|
||||
]}
|
||||
/>
|
||||
{(diagData.members || []).some((m) => m.deploy_message) && (
|
||||
<Card size="small" title="Latest node messages" bodyStyle={{ padding: 8 }}>
|
||||
{(diagData.members || []).filter((m) => m.deploy_message).map((m) => (
|
||||
<div key={m.agent_name} style={{ fontSize: 12 }}><Text strong>{m.agent_name}:</Text> {m.deploy_message}</div>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
<Alert
|
||||
type="info" showIcon
|
||||
message="See the live install / VRRP logs on the node"
|
||||
description={
|
||||
<pre style={{ margin: 0, fontSize: 11, whiteSpace: 'pre-wrap' }}>{`systemctl status keepalived --no-pager
|
||||
journalctl -u keepalived --no-pager -n 50
|
||||
tail -n 100 /var/log/haproxy-agent/agent.log | grep -i keepalived`}</pre>
|
||||
}
|
||||
/>
|
||||
</Space>
|
||||
)}
|
||||
{!diagLoading && !diagData && <Text type="secondary">No diagnostics available.</Text>}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VIPManagement;
|
||||
@@ -22,7 +22,9 @@ spec:
|
||||
serviceAccountName: haproxy-openmanager-nginx
|
||||
containers:
|
||||
- name: nginx
|
||||
image: nginx:alpine
|
||||
# Pinned to a patched release for the nginx "poolslip" advisory
|
||||
# (mainline <=1.31.0 affected; fixed in mainline 1.31.1+ / stable 1.30.2+).
|
||||
image: nginx:1.31.1-alpine
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: http
|
||||
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.6.0",
|
||||
"releaseName": "Multi-Factor Authentication (MFA)",
|
||||
"releaseDate": "2026-05-18"
|
||||
"version": "1.8.0",
|
||||
"releaseName": "ACME DNS-01 challenge support",
|
||||
"releaseDate": "2026-06-23"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user