Compare commits

...

6 Commits

Author SHA1 Message Date
taylanbakircioglu 60f4fa71ed ci: read releaseName from backend/version.json in the release step (v1.8.7 follow-up)
The version.json single-source move (v1.8.7) updated the version READ step but
missed the create-release step, which still ran jq against the deleted repo-root
version.json and failed the workflow. Point it at backend/version.json. This
release step is public-only (GitHub Releases), so it has no corporate counterpart.
2026-07-09 16:35:10 +03:00
taylanbakircioglu 97b2452bd2 fix(version): single-source version reporting to stop UI version drift (v1.8.7)
The version shown in the UI (backend-sourced via /api/version) could lag the
real release. The canonical version lived at repo-root version.json, but the
backend image is built with context ./backend, so that file did not reach the
container unless a pipeline staged it; the backend then fell back to a hardcoded
constant in main.py that had to be bumped by hand and had drifted (it reported
1.8.4 after 1.8.5 and 1.8.6 shipped).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Addresses #31.
2026-06-25 14:42:34 +03:00
19 changed files with 501 additions and 86 deletions
+9
View File
@@ -59,6 +59,15 @@ AGENT_HEARTBEAT_TIMEOUT_SECONDS=15
# Config sync interval in seconds
AGENT_CONFIG_SYNC_INTERVAL_SECONDS=30
# ============================================================================
# BACKEND PERFORMANCE
# ============================================================================
# Number of uvicorn worker processes for the backend API (default: 1).
# On multi-core hosts, setting this to the core count (e.g. 2) lets the API
# use all cores. Safe to increase: background tasks are multi-replica safe
# (the k8s deployment already runs 2+ replicas via HPA).
UVICORN_WORKERS=1
# ============================================================================
# CORS CONFIGURATION
# ============================================================================
+7 -17
View File
@@ -21,27 +21,17 @@ jobs:
- name: read product version
id: prodversion
run: |
VERSION=$(jq -r .version version.json)
VERSION=$(jq -r .version backend/version.json)
if [ -z "$VERSION" ] || [ "$VERSION" = "null" ]; then
echo "Failed to read product version from version.json" >&2
echo "Failed to read product version from backend/version.json" >&2
exit 1
fi
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
# The backend image is built with `context: ./backend`, so the
# repo-root version.json is OUTSIDE the build context and never
# reaches the container. Backend `main.py` falls back to a
# compile-time constant when /app/version.json is missing, which
# caused a real production drift: a redeploy of the v1.5.2 tree
# silently still reported "v1.5.0" in `/api/version` because the
# constant in main.py had been bumped but the file was not
# available to read. Stage version.json into the backend
# context here so the canonical file IS shipped and the
# constant only serves as a defensive fallback. The staged file
# is gitignored to keep `git status` clean for developers.
- name: stage version.json into backend build context
run: cp version.json backend/version.json
# version.json now lives at backend/version.json (inside the ./backend build
# context), so `COPY . .` bakes it into the image directly — no staging step
# is needed and the backend reports the correct version in every deployment,
# not just this workflow's builds.
- name: set up qemu
uses: docker/setup-qemu-action@v3
@@ -91,7 +81,7 @@ jobs:
run: |
VERSION="${{ steps.prodversion.outputs.VERSION }}"
TAG="v${VERSION}"
RELEASE_NAME=$(jq -r '.releaseName // empty' version.json)
RELEASE_NAME=$(jq -r '.releaseName // empty' backend/version.json)
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "Release $TAG already exists, skipping."
else
-5
View File
@@ -39,11 +39,6 @@ venv.bak/
*.sqlite
*.sqlite3
# Build-time staged version.json (CI `cp version.json backend/`).
# The canonical file lives at repo root; this path is a transient
# copy for the backend Docker build context.
backend/version.json
# IDE
.vscode/
.idea/
+19 -1
View File
@@ -1738,6 +1738,19 @@ Add new HAProxy clusters through the web interface or directly via API:
}
```
### Performance Tuning *(v1.8.6)*
The backend API defaults to a **single uvicorn worker process**, which uses one CPU core. Agents poll the API every 30 seconds (heartbeat, config, pending-requests, upgrade checks), so larger fleets add a constant baseline load. Two ways to scale:
- **Docker Compose — worker processes**: set `UVICORN_WORKERS` in your `.env` (default `1`). On a multi-core host, matching the core count (e.g. `UVICORN_WORKERS=2` on a 2-core machine) lets the API use all cores:
```bash
echo "UVICORN_WORKERS=2" >> .env && docker-compose up -d backend
```
- **Kubernetes/OpenShift — replicas**: the shipped manifests already include an HPA for the backend (2→10 replicas, `k8s/manifests/13-hpa.yaml`); raise `minReplicas`/`maxReplicas` as needed.
Both are safe: all background tasks (ACME completion, renewals, agent monitoring) are multi-replica safe by design (atomic claims via `FOR UPDATE SKIP LOCKED`, PostgreSQL advisory locks).
**Diagnosing slow requests**: every API response carries an `X-Response-Time` header, and the backend logs `Slow request detected` (WARNING) for any request taking longer than 1 second — check those log lines to pinpoint slow endpoints before tuning anything else.
## API Reference
@@ -2069,7 +2082,7 @@ haproxy-openmanager/
├── docker-compose.yml # Docker Compose configuration
├── docker-compose.localtest.yml # Local development/testing overrides
├── docker-compose.test.yml # Test environment
├── version.json # Application version metadata
├── backend/version.json # Application version metadata (single source of truth)
├── build-images.sh # Build Docker images
├── pytest.ini # Pytest configuration
├── README.md # This file
@@ -2415,6 +2428,11 @@ Developed with ❤️ for the HAProxy community
## Release Notes
- **v1.8.7** (2026-07-09) — **Version reporting single-source fix**: the version shown in the UI (backend-sourced via `/api/version`) could lag behind the real release. The canonical version lived in the repo-root `version.json`, but the backend image is built from the `./backend` context, so that file did not reach the container in every pipeline; the backend then fell back to a hardcoded constant in `main.py` that had to be bumped by hand and had drifted (it reported 1.8.4 after 1.8.5/1.8.6 shipped). The version now lives in a single file, `backend/version.json`, baked into every image automatically, and `main.py` no longer carries a real version literal (its fallback is a neutral "unknown"). A new test enforces that the version stays single-source and cannot drift. No functional or API change.
- **v1.8.6** (2026-07-06) — **Performance: opt-in API workers + heartbeat micro-optimization** (Issue #35 follow-up): the backend container can now run multiple uvicorn worker processes via the new `UVICORN_WORKERS` environment variable (default **1** — behavior unchanged unless you opt in), letting the API use all cores on multi-core hosts; background tasks were already multi-replica safe, as exercised by the Kubernetes HPA deployment. The agent heartbeat handler now reads the agent's `status`/`version`/`upgrade_status` in one query instead of three (one round-trip per heartbeat, per agent, every 30s). Added a *Performance Tuning* section to the README (worker/replica scaling and how to use the `X-Response-Time` header and `Slow request detected` logs to pinpoint slow endpoints). Zero-risk release: no schema, API, or agent changes; defaults preserve existing behavior exactly.
- **v1.8.5** (2026-07-03) — **ACME completion-task SQL fix** (Issue #35 follow-up): the background order-completion task (`complete_pending_acme_orders`, runs every 60s) died on **every cycle** with `syntax error at or near ")"` — an extra closing parenthesis introduced in v1.8.0's bounded DNS-01 retry claim query. Because that query is the task's first database call, **no background ACME work ran at all from v1.8.0 through v1.8.4**: orders were never claimed for finalize/download, the DNS-01 TXT record was never published (so DNS-01 with an automated provider such as Cloudflare could never validate), Site Wizard staged orders never left `wizard_staged`, and DNS-01 retry/TXT-cleanup never executed. The stray parenthesis is removed and a regression test now scans all ACME modules' SQL for unbalanced parentheses (the unit suite mocks the database, which is why a raw-SQL syntax error could slip through). One-line backend query fix; no schema, API, or agent changes — fully backward compatible.
- **v1.8.4** (2026-06-27) — **Agent installer self-kill fix** (Issue #31): the Linux/macOS agent installer could abort during "pre-installation cleanup" (terminal showed `Killing processes matching: haproxy-agent` then `Killed`) when the install script's own filename contained "haproxy-agent". The cleanup killed processes by matching the bare string "haproxy-agent" against full command lines, which also matched the running installer (and a `sudo`/PAM ancestor the self-exclusion did not cover), so the installer terminated itself. Cleanup now targets only the installed agent (the `$INSTALL_DIR/haproxy-agent` binary and the agent service), never the bare string, and the UI now names the downloaded scripts `install-agent-<platform>.sh` / `uninstall-agent-<platform>.sh`. Installer-only change; the running agent and its privilege model (it runs as root for HAProxy reload, config writes, keepalived, and self-upgrade) are unchanged.
- **v1.8.3** (2026-06-25) — **Agent heartbeat JSON fix** (Issue #31): a self-hosted agent could fail every heartbeat with `HTTP 400 Invalid JSON: Expecting property name enclosed in double quotes` when the system-info block it collects came back empty on an unusual host, leaving a stray comma in the hand-built heartbeat JSON. The agent script now substitutes a valid placeholder when that block is empty so it can no longer emit a stray comma, and the backend heartbeat endpoint now parses valid payloads as-is and, only when a body fails to parse, tolerates that specific malformed pattern (a leading or doubled comma) so an already-deployed agent recovers on its next heartbeat after this build is deployed. Backend + agent-script only; healthy agents of every version are byte-for-byte unaffected.
- **v1.8.2** (2026-06-25) — **ACME nonce fix** (Issue #35 follow-up): the ACME client now scopes the anti-replay nonce **per certificate authority** so a nonce issued by one CA is never sent to another. This fixes ZeroSSL/Google account registration failing with `malformed: The Replay Nonce could not be base64url-decoded` (the client previously shared one nonce across CAs and only auto-retried on `badNonce`). Account registration now always uses a fresh nonce from the target CA, and the retry covers this case too. Backend-only; HTTP-01 and Let's Encrypt are unaffected.
- **v1.8.1** (2026-06-24) — **ACME DNS-01 fixes** (Issue #35 follow-up): Cloudflare API tokens are now sanitized so a pasted token with quotes/spaces no longer fails with "Invalid request headers"; ZeroSSL/Google **External Account Binding (EAB)** can be entered per-account in the register dialog and EAB-required failures show a clear message; and **Apply Management** now categorizes cluster ACME enable/disable changes under their own "ACME Challenge Routing" section and **Apply/Reject All** correctly process them (previously "Rejected 0 HA/VIP change(s)"), consistent with every other entity. Fully backward compatible.
- **v1.8.0** (2026-06-23) — **ACME DNS-01 challenge support** (Issue #35): Auto SSL can now validate via a **DNS TXT record** (`_acme-challenge.<domain>`) instead of HTTP-01 on port 80, enabling certificates for **internal/isolated clusters with no public ingress** and **wildcard** certificates (`*.example.com`). Pluggable **per-account DNS provider** (Manual + Cloudflare to start; credentials verified on save and **encrypted at rest**, never returned by the API or logged), the same **PENDING → APPLIED** pipeline, a **bounded automatic retry** on propagation lag, and a **DNS-01 event timeline** in the order detail. **Opt-in** via Settings → ACME (global switch, default off); **HTTP-01 is byte-for-byte unchanged**, with **zero agent or rendered-config changes**. Manual DNS-01 certificates cannot auto-renew unattended; the UI states this and disables auto-renew for them.
+10 -2
View File
@@ -37,5 +37,13 @@ USER appuser
# Expose port
EXPOSE 8000
# Run the application in production mode (without --reload)
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
# Run the application in production mode (without --reload).
# UVICORN_WORKERS (default 1) opts into multiple worker processes on multi-core
# hosts; with 1 worker uvicorn runs in-process, identical to the flagless CMD
# this replaces. Falls back to WEB_CONCURRENCY when UVICORN_WORKERS is unset
# because flagless uvicorn honored WEB_CONCURRENCY (uvicorn config.py) — this
# keeps any deployment that relied on it byte-for-byte compatible. Background
# tasks are multi-replica safe (FOR UPDATE SKIP LOCKED / advisory locks), as
# already exercised by the k8s HPA deployment. `exec` keeps uvicorn as PID 1
# so signal handling is unchanged.
CMD ["sh", "-c", "exec uvicorn main:app --host 0.0.0.0 --port 8000 --workers ${UVICORN_WORKERS:-${WEB_CONCURRENCY:-1}}"]
+7 -4
View File
@@ -8,9 +8,13 @@ import redis
import asyncio
from datetime import datetime, timedelta
# Build/deploy marker for the v1.8.x (Issue #35, DNS-01) rollout — ensures the pipeline ships this commit's image.
_version_info = {"version": "1.8.2", "releaseName": "ACME nonce fix (ZeroSSL registration)", "releaseDate": "2026-06-25"}
for _vpath in ["/app/version.json", os.path.join(os.path.dirname(__file__), "..", "version.json")]:
# Single source of truth: backend/version.json, which sits next to this module and is baked into
# every image by `COPY . .` (build context ./backend) — no pipeline staging needed. The literal
# below is only a last-resort "file missing" marker; it is deliberately NOT a real version so it can
# never silently drift out of sync (this exact drift showed a stale version after v1.8.5/v1.8.6).
# Keep the canonical version ONLY in backend/version.json — test_version_consistency.py enforces it.
_version_info = {"version": "unknown", "releaseName": "unknown", "releaseDate": ""}
for _vpath in [os.path.join(os.path.dirname(__file__), "version.json"), "/app/version.json"]:
try:
with open(_vpath) as _vf:
_version_info = json.load(_vf)
@@ -318,7 +322,6 @@ async def complete_pending_acme_orders():
OR dns01_last_attempt_at < NOW() - (
(CASE COALESCE(dns01_attempts, 0) WHEN 0 THEN 15 WHEN 1 THEN 30 ELSE 60 END)
|| ' minutes')::INTERVAL
)
)
)
)
+65 -41
View File
@@ -29,6 +29,40 @@ AGENT_VERSIONS = {
"linux": "2.0.0"
}
def _sanitize_agent_json(body_str: str):
"""Repair the common malformed-JSON patterns a hand-built agent heartbeat can emit.
Agents assemble their heartbeat JSON as text in bash, so an empty interpolated value can leave
a structurally-invalid comma (issue #31). Returns (possibly_repaired_str, was_changed). The
repairs are conservative and target only structural artifacts an agent produces; they never
alter this endpoint's legitimate string values (the agent emits no string containing ',,' —
haproxy_stats_csv is base64/comma-free and the rest are constrained os/kernel/ip/version text).
"""
import re
sanitized = False
# Fix 1: empty value before a comma ("server_statuses": ,)
if re.search(r':\s*,', body_str):
body_str = re.sub(r':\s*,', ': null,', body_str); sanitized = True
# Fix 2: empty value before a closing brace ("field":})
if re.search(r':\s*}', body_str):
body_str = re.sub(r':\s*}', ': null}', body_str); sanitized = True
# Fix 3: trailing comma before } or ]
if re.search(r',(\s*[}\]])', body_str):
body_str = re.sub(r',(\s*[}\]])', r'\1', body_str); sanitized = True
# Fix 4: leading comma run right after an opening brace/bracket (issue #31): an empty
# $system_info as the first member collapses to '{ , "name": ...'. The ': ,' fix above cannot
# catch this because there is no key/colon before the comma.
if re.search(r'([{\[])(\s*,)+', body_str):
body_str = re.sub(r'([{\[])(\s*,)+', r'\1', body_str); sanitized = True
# Fix 5: a run of commas between members (issue #31): an empty $system_info between two fields
# produces '"version": "x",\n ,\n "haproxy_status": ...'. Runs after Fix 1/3 so only
# structural commas remain; collapse any comma run to a single comma.
if re.search(r',(\s*,)+', body_str):
body_str = re.sub(r',(\s*,)+', ',', body_str); sanitized = True
return body_str, sanitized
def get_platform_key(agent_platform: str) -> str:
"""Convert agent platform to standardized platform key - fixed empty platform fallback"""
platform = agent_platform.lower() if agent_platform else 'unknown'
@@ -1455,47 +1489,33 @@ async def agent_heartbeat_by_name(
import json
from pydantic import ValidationError
# Read raw body and sanitize common JSON errors from agents
# Read raw body. Parse VALID JSON as-is (the normal case for every agent version) and only
# fall back to the malformed-JSON repair when the body does not parse. This guarantees a healthy
# heartbeat from any agent version is byte-for-byte untouched — the repair regexes can never run
# against a well-formed payload (issue #31; strictly safer than repairing unconditionally).
try:
raw_body = await request.body()
body_str = raw_body.decode('utf-8')
# Sanitize common malformed JSON patterns from agents
original_body = body_str
sanitized = False
# Fix 1: Empty values before comma (most common: "server_statuses": ,)
if re.search(r':\s*,', body_str):
body_str = re.sub(r':\s*,', ': null,', body_str)
sanitized = True
# Fix 2: Empty values before closing brace
if re.search(r':\s*}', body_str):
body_str = re.sub(r':\s*}', ': null}', body_str)
sanitized = True
# Fix 3: Trailing commas
if re.search(r',(\s*[}\]])', body_str):
body_str = re.sub(r',(\s*[}\]])', r'\1', body_str)
sanitized = True
if sanitized:
# Extract agent name for logging
agent_name = "unknown"
try:
name_match = re.search(r'"name"\s*:\s*"([^"]+)"', body_str)
if name_match:
agent_name = name_match.group(1)
except:
pass
logger.info(f"Sanitized malformed JSON from agent '{agent_name}' - fixed empty values and trailing commas")
logger.debug(f"Original JSON (preview): {original_body[:300]}")
logger.debug(f"Sanitized JSON (preview): {body_str[:300]}")
# Parse sanitized JSON into Pydantic model
heartbeat_dict = json.loads(body_str)
try:
heartbeat_dict = json.loads(body_str)
except json.JSONDecodeError:
# Malformed body (would otherwise be a hard 400). Attempt a conservative repair of the
# comma artifacts a hand-built agent heartbeat can emit, then re-parse.
repaired, changed = _sanitize_agent_json(body_str)
if changed:
agent_name = "unknown"
try:
name_match = re.search(r'"name"\s*:\s*"([^"]+)"', repaired)
if name_match:
agent_name = name_match.group(1)
except Exception:
pass
logger.info(f"Repaired malformed JSON from agent '{agent_name}' before parsing")
logger.debug(f"Original JSON (preview): {body_str[:300]}")
logger.debug(f"Repaired JSON (preview): {repaired[:300]}")
heartbeat_dict = json.loads(repaired) # may still raise -> handled as 400 below
# DEBUG: Log cluster_id for auto-register troubleshooting
if heartbeat_dict.get('name'):
logger.info(f"HEARTBEAT DEBUG: agent={heartbeat_dict.get('name')}, cluster_id={heartbeat_dict.get('cluster_id')}, has_cluster_id={bool(heartbeat_dict.get('cluster_id'))}")
@@ -1675,9 +1695,13 @@ async def agent_heartbeat_by_name(
""", x_api_key)
# Check if agent was in upgrading status and version has changed
current_agent_status = await conn.fetchval("SELECT status FROM agents WHERE id = $1", agent_id)
current_agent_version = await conn.fetchval("SELECT version FROM agents WHERE id = $1", agent_id)
current_upgrade_status = await conn.fetchval("SELECT upgrade_status FROM agents WHERE id = $1", agent_id)
# (v1.8.6: one round-trip instead of three; row is None exactly when the
# per-column fetchvals would each have returned None)
current_agent_row = await conn.fetchrow(
"SELECT status, version, upgrade_status FROM agents WHERE id = $1", agent_id)
current_agent_status = current_agent_row['status'] if current_agent_row else None
current_agent_version = current_agent_row['version'] if current_agent_row else None
current_upgrade_status = current_agent_row['upgrade_status'] if current_agent_row else None
# Determine new status - preserve upgrading status unless version actually changed
new_status = current_agent_status or 'online'
@@ -0,0 +1,97 @@
"""Issue #31 — agent heartbeat JSON sanitizer.
A self-hosted agent builds its heartbeat JSON as text in bash. When a collected value is empty,
the payload can contain a structurally-invalid comma that broke the heartbeat with
`HTTP 400 Invalid JSON: Expecting property name enclosed in double quotes`. The backend now
repairs that pattern in `_sanitize_agent_json` so an already-deployed agent recovers without a
re-install. These tests pin that behaviour and prove the repair never corrupts a healthy payload.
"""
import json
from routers.agent import _sanitize_agent_json
def _assert_parses(raw: str) -> dict:
out, _ = _sanitize_agent_json(raw)
return json.loads(out) # raises if the repair did not produce valid JSON
def test_reporter_empty_system_info_bare_comma():
# The exact shape the reporter hit: an empty $system_info collapses ' $system_info,' to a
# bare comma between two members -> '"version": "x",\n ,\n "haproxy_status": ...'.
raw = (
'{\n'
' "name": "test",\n'
' "hostname": "h",\n'
' "status": "online",\n'
' "version": "2.0.0",\n'
' ,\n'
' "haproxy_status": "running",\n'
' "cluster_id": 1\n'
'}'
)
parsed = _assert_parses(raw)
assert parsed["name"] == "test"
assert parsed["status"] == "online"
assert parsed["haproxy_status"] == "running"
def test_empty_numeric_subfield_before_comma():
# An empty unquoted numeric ("memory_total": ,) — covered by the pre-existing Fix 1.
raw = '{ "name": "t", "cpu_count": , "memory_total": , "status": "online" }'
parsed = _assert_parses(raw)
assert parsed["cpu_count"] is None and parsed["memory_total"] is None
assert parsed["status"] == "online"
def test_empty_value_before_closing_brace():
raw = '{ "name": "t", "status": "online", "applied_config_version": }'
parsed = _assert_parses(raw)
assert parsed["applied_config_version"] is None
def test_leading_comma_first_member():
# Empty $system_info as the FIRST member -> '{ , "name": ... }'.
raw = '{\n ,\n "name": "t",\n "status": "online"\n}'
parsed = _assert_parses(raw)
assert parsed["name"] == "t"
def test_comma_run_two_empty_fields():
# Two empties in a row (odd-length comma run) must still collapse to valid JSON.
raw = '{ "a": 1,\n ,\n ,\n "b": 2 }'
parsed = _assert_parses(raw)
assert parsed["a"] == 1 and parsed["b"] == 2
def test_trailing_comma_regression():
# Pre-existing Fix 3 must still hold after the new fixes were added.
raw = '{ "name": "t", "status": "online", }'
parsed = _assert_parses(raw)
assert parsed["name"] == "t"
def test_healthy_payload_is_untouched():
# A well-formed agent payload must pass through unchanged (sanitized=False) and its values —
# including the base64 stats CSV and the nested server_statuses — must be byte-identical.
payload = {
"name": "agent-1",
"status": "online",
"cluster_id": 1,
"server_statuses": {"be_app": {"s1": "UP", "s2": "DOWN"}},
"network_interfaces": ["eth0", "eth1"],
"haproxy_stats_csv": "IyBwdmJjLGJhY2tlbmQsZnJvbnRlbmQs", # base64: contains commas only inside a quoted string is impossible (base64 has none)
"applied_config_version": "cluster-1-v42",
}
raw = json.dumps(payload)
out, changed = _sanitize_agent_json(raw)
assert changed is False
assert out == raw # byte-identical
assert json.loads(out) == payload
def test_idempotent_on_already_clean_minimal():
raw = '{"name": "t", "status": "online"}'
out, changed = _sanitize_agent_json(raw)
assert changed is False
assert out == raw
@@ -0,0 +1,61 @@
"""Issue #31 — agent-script hardening guard (static).
The agent install scripts hand-build the heartbeat JSON, so if `collect_system_info` ever yields
nothing the `$system_info,` line collapses to a bare comma and the whole heartbeat is invalid JSON
(HTTP 400). The fix adds a guard at every fragment-form call site that substitutes a single valid
key when system_info is empty. This static check enforces that the guard is present AND kept in
sync across BOTH platform scripts — the project requires the two agent-script copies to stay in
lockstep. (Empty numeric subfields like "memory_total": , are a separate, milder case already
repaired by the backend sanitizer, so they are intentionally NOT guarded in the script — guarding
them with a strict integer test would wrongly reject the scientific-notation that mawk emits for
multi-GB sizes on Debian/Ubuntu.)
"""
import os
_SCRIPT_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), # backend/
"utils", "agent_scripts",
)
def _read(name: str) -> str:
with open(os.path.join(_SCRIPT_DIR, name), "r") as f:
return f.read()
LINUX = _read("linux_install.sh")
MACOS = _read("macos_install.sh")
# The empty-system_info guard — present at BOTH fragment call sites (register_agent + send_heartbeat).
_B2_GUARD = '[[ "$system_info" != *\'"\'* ]] && system_info=\'"operating_system": "unknown"\''
def test_b2_guard_present_and_in_sync():
# Two fragment-form call sites per script (register_agent + send_heartbeat), identical wording.
assert LINUX.count(_B2_GUARD) == 2, "linux_install.sh missing/duplicated empty-system_info guard"
assert MACOS.count(_B2_GUARD) == 2, "macos_install.sh missing/duplicated empty-system_info guard"
def test_b2_guard_precedes_every_fragment_system_info_use():
# Every ' $system_info,' fragment line (the one that breaks on an empty value) must be in a
# function whose system_info was guarded. We assert the count of guards matches the count of
# fragment-form interpolations' call sites: each script has exactly one register + one
# send_heartbeat fragment builder feeding those lines, both guarded above.
for name, script in (("linux", LINUX), ("macos", MACOS)):
assert script.count(" $system_info,") >= 1, f"{name}: fragment heartbeat form unexpectedly gone"
assert script.count(_B2_GUARD) == 2, f"{name}: each fragment call site must carry the guard"
def test_cleanup_does_not_self_kill_via_bare_haproxy_agent_pattern():
# Issue #31 (v1.8.4): the pre-installation cleanup kills processes by pgrep -f "$pattern". A bare
# "haproxy-agent" pattern also matches the installer's OWN path (install-haproxy-agent-*.sh) and a
# sudo/PAM ancestor, so the installer killed itself. The kill loop must target ONLY the installed
# agent (binary path + service/label), never the bare string.
for name, script in (("linux", LINUX), ("macos", MACOS)):
assert 'for pattern in "haproxy-agent"' not in script, (
f"{name}: pre-install cleanup uses the bare 'haproxy-agent' kill pattern -> self-kill (issue #31)"
)
# The narrowed, installer-safe pattern must be present (binary path via $INSTALL_DIR).
assert 'for pattern in "$INSTALL_DIR/haproxy-agent"' in script, (
f"{name}: cleanup must match the installed binary path, not a bare substring"
)
+108
View File
@@ -110,3 +110,111 @@ def test_nonce_scoped_per_directory():
assert got == "NONCE_A" # returns THIS CA's nonce
assert svc._nonce_by_dir.get("https://a.example/dir") is None # consumed (single-use)
assert svc._nonce_by_dir.get("https://b.example/dir") == "NONCE_B" # the other CA is untouched
def _sql_paren_depth(sql: str):
"""Parenthesis depth of a SQL string, counting only OUTSIDE '...' literals (with ''
escapes), `--` line comments and /* */ block comments. Single-pass state machine so a
`--` inside a literal or a `'` inside a comment cannot corrupt the count. Dollar-quoted
strings are out of scope (not used in this codebase). Returns (final_depth, min_depth).
"""
depth = 0
min_depth = 0
state = "normal"
i, n = 0, len(sql)
while i < n:
ch = sql[i]
nxt = sql[i + 1] if i + 1 < n else ""
if state == "normal":
if ch == "'":
state = "string"
elif ch == "-" and nxt == "-":
state = "line_comment"
i += 1
elif ch == "/" and nxt == "*":
state = "block_comment"
i += 1
elif ch == "(":
depth += 1
elif ch == ")":
depth -= 1
min_depth = min(min_depth, depth)
elif state == "string":
if ch == "'":
if nxt == "'":
i += 1 # escaped '' stays inside the literal
else:
state = "normal"
elif state == "line_comment":
if ch == "\n":
state = "normal"
else: # block_comment
if ch == "*" and nxt == "/":
state = "normal"
i += 1
i += 1
return depth, min_depth
def test_acme_sql_parentheses_balanced():
"""Issue #35 v1.8.5: the completion task's order-claim query shipped (v1.8.0-v1.8.4) with an
extra closing parenthesis, so EVERY 60s cycle died with `syntax error at or near ")"` and no
background ACME work (claim/finalize/download, DNS-01 publish, wizard-staged promotion,
retry, TXT cleanup) ever ran. The suite never caught it because the DB layer is mocked and
raw SQL never reaches a real parser. This guard scans the ACME modules' SQL string literals
for unbalanced parentheses.
Guard scope is deliberately conservative to avoid false positives on production changes:
keyword matching is case-sensitive (SQL is uppercase in this codebase; prose in docstrings
is not) and f-string fragments are excluded (they split at `{`, so a fragment may be
legitimately unbalanced).
"""
import ast
import re
backend_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
modules = [
"main.py",
os.path.join("services", "dns01_orchestrator.py"),
os.path.join("services", "acme_service.py"),
os.path.join("services", "letsencrypt_service.py"),
os.path.join("routers", "letsencrypt.py"),
os.path.join("routers", "acme_diagnostics.py"),
]
problems = []
for rel in modules:
with open(os.path.join(backend_dir, rel), encoding="utf-8") as fh:
tree = ast.parse(fh.read())
fstring_parts = {
id(const)
for joined in ast.walk(tree) if isinstance(joined, ast.JoinedStr)
for const in ast.walk(joined) if isinstance(const, ast.Constant)
}
for node in ast.walk(tree):
if not (isinstance(node, ast.Constant) and isinstance(node.value, str)):
continue
if id(node) in fstring_parts:
continue
sql = node.value
if not re.search(r"\b(SELECT|INSERT|UPDATE|DELETE)\b", sql):
continue
if not re.search(r"\b(FROM|INTO|SET|WHERE)\b", sql):
continue
depth, min_depth = _sql_paren_depth(sql)
if depth != 0 or min_depth < 0:
problems.append(f"{rel}:{node.lineno} (paren depth {depth:+d}, min {min_depth})")
assert not problems, f"Unbalanced parentheses in SQL literal(s): {problems}"
def test_sql_paren_depth_scanner():
# The guard's scanner itself: parens in literals/comments must not count; '' escapes and
# block comments handled; an extra ')' is reported via min_depth even if a later '(' would
# re-balance the total.
assert _sql_paren_depth("SELECT (1)") == (0, 0)
assert _sql_paren_depth("SELECT (1))") == (-1, -1) # the v1.8.0 bug shape
assert _sql_paren_depth("SELECT ')' , '((' FROM t") == (0, 0) # literals ignored
assert _sql_paren_depth("SELECT 'it''s ))' FROM t") == (0, 0) # '' escape stays inside
assert _sql_paren_depth("SELECT 1 -- comment ) (\nFROM t") == (0, 0) # line comment ignored
assert _sql_paren_depth("SELECT 1 /* ) */ FROM t") == (0, 0) # block comment ignored
assert _sql_paren_depth("SELECT 'a--b' AND (x=1\n)") == (0, 0) # -- inside literal is data
assert _sql_paren_depth("WHERE x) AND (y") == (0, -1) # net 0 but went negative
+71
View File
@@ -0,0 +1,71 @@
"""v1.8.7: app version is single-source and cannot silently drift.
The UI shows the version via GET /api/version, which returns main.py's `_version_info`. That MUST be
sourced from the one canonical file backend/version.json (co-located with main.py so `COPY . .`
bakes it into every image, regardless of pipeline). main.py's in-code fallback must NOT be a real
version, otherwise it drifts when version.json is bumped but the constant is forgotten — exactly
what left the UI reporting 1.8.4 after 1.8.5/1.8.6 shipped. These checks fail loudly on regression.
"""
import ast
import json
import os
import re
import pytest
_BACKEND = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # backend/
_VERSION_JSON = os.path.join(_BACKEND, "version.json")
_MAIN = os.path.join(_BACKEND, "main.py")
_SEMVER = re.compile(r"^\d+\.\d+\.\d+$")
def test_canonical_version_file_exists_and_valid():
assert os.path.exists(_VERSION_JSON), "backend/version.json (single source of truth) is missing"
with open(_VERSION_JSON) as f:
data = json.load(f)
assert _SEMVER.match(data.get("version", "")), \
f"backend/version.json version is not semver: {data.get('version')!r}"
assert data.get("releaseName"), "backend/version.json must have a releaseName"
def _main_fallback_version_info():
"""The literal dict assigned to _version_info in main.py (the in-code fallback)."""
with open(_MAIN) as f:
tree = ast.parse(f.read())
for node in ast.walk(tree):
if isinstance(node, ast.Assign) and isinstance(node.value, ast.Dict):
for t in node.targets:
if isinstance(t, ast.Name) and t.id == "_version_info":
return ast.literal_eval(node.value)
return None
def test_main_has_no_hardcoded_real_version():
fb = _main_fallback_version_info()
assert fb is not None, "could not find the _version_info fallback literal in main.py"
# Must be a neutral marker, never a real version that can drift out of sync.
assert not _SEMVER.match(str(fb.get("version", ""))), (
f"main.py hardcodes a real version {fb.get('version')!r}; it must be a neutral marker "
f"(e.g. 'unknown') so the version stays single-source in backend/version.json"
)
def test_main_loads_the_canonical_file_first():
# The first candidate path main.py reads must resolve to the co-located backend/version.json,
# so the correct version is available in every image (not dependent on CI staging).
with open(_MAIN) as f:
src = f.read()
assert 'os.path.dirname(__file__), "version.json"' in src, \
"main.py must read version.json co-located with the module (backend/version.json)"
def test_frontend_package_json_matches_when_present():
# Only meaningful in a full-repo checkout; the backend image build context (./backend) has no frontend/.
pkg = os.path.join(os.path.dirname(_BACKEND), "frontend", "package.json")
if not os.path.exists(pkg):
pytest.skip("frontend/package.json not in this context (e.g. backend-only image build)")
with open(_VERSION_JSON) as f:
canonical = json.load(f)["version"]
with open(pkg) as f:
fe = json.load(f)["version"]
assert fe == canonical, f"frontend/package.json {fe!r} != backend/version.json {canonical!r}"
+18 -3
View File
@@ -470,7 +470,12 @@ safe_remove() {
[[ "$QUIET_MODE" != "true" ]] && echo "Terminating existing HAProxy Agent processes..."
KILLED_COUNT=0
INSTALLER_PID=$$
for pattern in "haproxy-agent" "/usr/local/bin/haproxy-agent" "haproxy-agent.service"; do
# issue #31: match ONLY the installed agent (binary path + service), never the bare string
# "haproxy-agent". With pgrep -f, that bare string can also match the installer's OWN command line
# or a sudo/PAM ancestor (which the $$/$PPID guard does not fully cover), making the cleanup kill
# the installer itself ("Killed", install aborts). The systemd service is also stopped below; the
# "$INSTALL_DIR/haproxy-agent" path still catches any running daemon.
for pattern in "$INSTALL_DIR/haproxy-agent" "haproxy-agent.service"; do
PIDS=$(pgrep -f "$pattern" 2>/dev/null || true)
if [[ -n "$PIDS" ]]; then
FILTERED=""
@@ -1055,7 +1060,12 @@ register_agent() {
local arch=$(uname -m)
platform=$(uname -s | tr '[:upper:]' '[:lower:]') # Remove local to make it global
local system_info=$(collect_system_info)
# issue #31: if collect_system_info produced no JSON content (empty on an unusual host), the
# '$system_info,' line below would collapse to a bare comma and break the heartbeat JSON. A
# valid fragment always contains a quoted key; if none is present, fall back to one. The glob
# '*"*' is the most portable bash test (no POSIX class / pattern-substitution), safe on bash 3.x+.
[[ "$system_info" != *'"'* ]] && system_info='"operating_system": "unknown"'
local json_payload=$(cat <<SIMPLE_EOF
{
"name": "$AGENT_NAME",
@@ -1357,7 +1367,12 @@ send_heartbeat() {
local server_statuses=$(get_server_statuses)
local haproxy_stats_csv=$(get_haproxy_stats_csv)
local system_info=$(collect_system_info)
# issue #31: if collect_system_info produced no JSON content (empty on an unusual host), the
# '$system_info,' line below would collapse to a bare comma and break the heartbeat JSON. A
# valid fragment always contains a quoted key; if none is present, fall back to one. The glob
# '*"*' is the most portable bash test (no POSIX class / pattern-substitution), safe on bash 3.x+.
[[ "$system_info" != *'"'* ]] && system_info='"operating_system": "unknown"'
# Get HAProxy version for heartbeat (safe extraction, fallback to "unknown")
local haproxy_version="unknown"
if command -v haproxy &> /dev/null; then
+17 -3
View File
@@ -318,7 +318,11 @@ safe_remove() {
[[ "$QUIET_MODE" != "true" ]] && echo "Terminating existing HAProxy Agent processes..."
KILLED_COUNT=0
INSTALLER_PID=$$
for pattern in "haproxy-agent" "/usr/local/bin/haproxy-agent" "com.haproxy.agent"; do
# issue #31: match ONLY the installed agent (binary path + LaunchDaemon label), never the bare
# string "haproxy-agent". With pgrep -f, that bare string can also match the installer's OWN command
# line or a sudo ancestor (which the $$/$PPID guard does not fully cover), making the cleanup kill
# the installer itself. The "$INSTALL_DIR/haproxy-agent" path still catches any running daemon.
for pattern in "$INSTALL_DIR/haproxy-agent" "com.haproxy.agent"; do
PIDS=$(pgrep -f "$pattern" 2>/dev/null || true)
if [[ -n "$PIDS" ]]; then
FILTERED=""
@@ -920,7 +924,12 @@ register_agent() {
local arch=$(uname -m)
platform=$(uname -s | tr '[:upper:]' '[:lower:]') # Remove local to make it global
local system_info=$(collect_system_info)
# issue #31: if collect_system_info produced no JSON content (empty on an unusual host), the
# '$system_info,' line below would collapse to a bare comma and break the heartbeat JSON. A
# valid fragment always contains a quoted key; if none is present, fall back to one. The glob
# '*"*' is the most portable bash test (no POSIX class / pattern-substitution), safe on bash 3.x+.
[[ "$system_info" != *'"'* ]] && system_info='"operating_system": "unknown"'
local json_payload=$(cat <<SIMPLE_EOF
{
"name": "$AGENT_NAME",
@@ -1191,7 +1200,12 @@ send_heartbeat() {
local server_statuses=$(get_server_statuses)
local haproxy_stats_csv=$(get_haproxy_stats_csv)
local system_info=$(collect_system_info)
# issue #31: if collect_system_info produced no JSON content (empty on an unusual host), the
# '$system_info,' line below would collapse to a bare comma and break the heartbeat JSON. A
# valid fragment always contains a quoted key; if none is present, fall back to one. The glob
# '*"*' is the most portable bash test (no POSIX class / pattern-substitution), safe on bash 3.x+.
[[ "$system_info" != *'"'* ]] && system_info='"operating_system": "unknown"'
# Get HAProxy version for heartbeat (safe extraction, fallback to "unknown")
local haproxy_version="unknown"
if command -v haproxy &> /dev/null; then
+5
View File
@@ -0,0 +1,5 @@
{
"version": "1.8.7",
"releaseName": "Version reporting single-source fix",
"releaseDate": "2026-07-09"
}
-1
View File
@@ -10,7 +10,6 @@ services:
dockerfile: Dockerfile
volumes:
- haproxy_configs:/etc/haproxy
- ./version.json:/app/version.json:ro
frontend:
image: haproxy-openmanager-frontend:localtest
+3
View File
@@ -51,6 +51,9 @@ services:
- LOG_LEVEL=INFO
- PUBLIC_URL=http://localhost:8080
- MANAGEMENT_BASE_URL=http://localhost:8080
# Empty when unset on the host: the image CMD then falls back to
# WEB_CONCURRENCY (uvicorn's native env) and finally to 1.
- UVICORN_WORKERS=${UVICORN_WORKERS:-}
volumes:
- haproxy_configs:/etc/haproxy
expose:
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "haproxy-openmanager-frontend",
"version": "1.8.2",
"version": "1.8.7",
"description": "HAProxy Load Balancer Management UI",
"license": "AGPL-3.0-or-later",
"dependencies": {
+3 -3
View File
@@ -2378,7 +2378,7 @@ const AgentManagement = () => {
const element = document.createElement('a');
const file = new Blob([installScript], { type: 'text/plain' });
element.href = URL.createObjectURL(file);
element.download = `install-haproxy-agent-${selectedPlatform}.sh`;
element.download = `install-agent-${selectedPlatform}.sh`;
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
@@ -2516,7 +2516,7 @@ const AgentManagement = () => {
const element = document.createElement('a');
const file = new Blob([uninstallScript], { type: 'text/plain' });
element.href = URL.createObjectURL(file);
element.download = `uninstall-haproxy-agent-${selectedPlatform}.sh`;
element.download = `uninstall-agent-${selectedPlatform}.sh`;
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
@@ -3147,7 +3147,7 @@ const AgentManagement = () => {
const element = document.createElement('a');
const file = new Blob([deleteUninstallScript], { type: 'text/plain' });
element.href = URL.createObjectURL(file);
element.download = `uninstall-haproxy-agent-${agentToDelete.platform || 'linux'}.sh`;
element.download = `uninstall-agent-${agentToDelete.platform || 'linux'}.sh`;
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
-5
View File
@@ -1,5 +0,0 @@
{
"version": "1.8.2",
"releaseName": "ACME nonce fix (ZeroSSL registration)",
"releaseDate": "2026-06-25"
}