Compare commits

...

3 Commits

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

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

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

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

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

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

Addresses #31.
2026-06-25 14:42:34 +03:00
taylanbakircioglu e86e86a53c fix(acme): scope ACME nonce per CA - fixes ZeroSSL registration (v1.8.2)
ZeroSSL/Google account registration failed with
`malformed: The Replay Nonce could not be base64url-decoded`: the ACME client
(a process-wide singleton) kept a single anti-replay nonce shared across
certificate authorities, so a nonce issued by one CA could be sent to another,
and the auto-retry only covered `badNonce`.

- Scope the nonce per CA (self._nonce_by_dir keyed by directory_url): a nonce
  from one CA is never sent to another; account registration always uses a fresh
  nonce from the target CA.
- Broaden the 400 retry to also recover from the nonce-malformed rejection.
- Fix _b64url_decode padding (used for the EAB HMAC key).

Backend-only; HTTP-01 and Let's Encrypt are unaffected.

Addresses #35.
2026-06-25 01:11:15 +03:00
12 changed files with 307 additions and 62 deletions
+3
View File
@@ -2415,6 +2415,9 @@ Developed with ❤️ for the HAProxy community
## Release Notes
- **v1.8.4** (2026-06-27) — **Agent installer self-kill fix** (Issue #31): the Linux/macOS agent installer could abort during "pre-installation cleanup" (terminal showed `Killing processes matching: haproxy-agent` then `Killed`) when the install script's own filename contained "haproxy-agent". The cleanup killed processes by matching the bare string "haproxy-agent" against full command lines, which also matched the running installer (and a `sudo`/PAM ancestor the self-exclusion did not cover), so the installer terminated itself. Cleanup now targets only the installed agent (the `$INSTALL_DIR/haproxy-agent` binary and the agent service), never the bare string, and the UI now names the downloaded scripts `install-agent-<platform>.sh` / `uninstall-agent-<platform>.sh`. Installer-only change; the running agent and its privilege model (it runs as root for HAProxy reload, config writes, keepalived, and self-upgrade) are unchanged.
- **v1.8.3** (2026-06-25) — **Agent heartbeat JSON fix** (Issue #31): a self-hosted agent could fail every heartbeat with `HTTP 400 Invalid JSON: Expecting property name enclosed in double quotes` when the system-info block it collects came back empty on an unusual host, leaving a stray comma in the hand-built heartbeat JSON. The agent script now substitutes a valid placeholder when that block is empty so it can no longer emit a stray comma, and the backend heartbeat endpoint now parses valid payloads as-is and, only when a body fails to parse, tolerates that specific malformed pattern (a leading or doubled comma) so an already-deployed agent recovers on its next heartbeat after this build is deployed. Backend + agent-script only; healthy agents of every version are byte-for-byte unaffected.
- **v1.8.2** (2026-06-25) — **ACME nonce fix** (Issue #35 follow-up): the ACME client now scopes the anti-replay nonce **per certificate authority** so a nonce issued by one CA is never sent to another. This fixes ZeroSSL/Google account registration failing with `malformed: The Replay Nonce could not be base64url-decoded` (the client previously shared one nonce across CAs and only auto-retried on `badNonce`). Account registration now always uses a fresh nonce from the target CA, and the retry covers this case too. Backend-only; HTTP-01 and Let's Encrypt are unaffected.
- **v1.8.1** (2026-06-24) — **ACME DNS-01 fixes** (Issue #35 follow-up): Cloudflare API tokens are now sanitized so a pasted token with quotes/spaces no longer fails with "Invalid request headers"; ZeroSSL/Google **External Account Binding (EAB)** can be entered per-account in the register dialog and EAB-required failures show a clear message; and **Apply Management** now categorizes cluster ACME enable/disable changes under their own "ACME Challenge Routing" section and **Apply/Reject All** correctly process them (previously "Rejected 0 HA/VIP change(s)"), consistent with every other entity. Fully backward compatible.
- **v1.8.0** (2026-06-23) — **ACME DNS-01 challenge support** (Issue #35): Auto SSL can now validate via a **DNS TXT record** (`_acme-challenge.<domain>`) instead of HTTP-01 on port 80, enabling certificates for **internal/isolated clusters with no public ingress** and **wildcard** certificates (`*.example.com`). Pluggable **per-account DNS provider** (Manual + Cloudflare to start; credentials verified on save and **encrypted at rest**, never returned by the API or logged), the same **PENDING → APPLIED** pipeline, a **bounded automatic retry** on propagation lag, and a **DNS-01 event timeline** in the order detail. **Opt-in** via Settings → ACME (global switch, default off); **HTTP-01 is byte-for-byte unchanged**, with **zero agent or rendered-config changes**. Manual DNS-01 certificates cannot auto-renew unattended; the UI states this and disables auto-renew for them.
- **v1.7.8** (2026-06-07) — HA / VIP apply progress now shows **per-node** convergence: a multi-node VIP's apply popup reads "Syncing HA/VIP… 1/2 node(s) converged" (matching the HA/VIP table) instead of a coarse per-change count. Frontend-only.
+1 -1
View File
@@ -9,7 +9,7 @@ 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.1", "releaseName": "ACME DNS-01 fixes (Cloudflare token, EAB, Apply Management)", "releaseDate": "2026-06-24"}
_version_info = {"version": "1.8.4", "releaseName": "Agent installer self-kill fix", "releaseDate": "2026-06-27"}
for _vpath in ["/app/version.json", os.path.join(os.path.dirname(__file__), "..", "version.json")]:
try:
with open(_vpath) as _vf:
+58 -38
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'))}")
+24 -10
View File
@@ -28,7 +28,7 @@ def _b64url(data: bytes) -> str:
def _b64url_decode(s: str) -> bytes:
s += '=' * (4 - len(s) % 4)
s += '=' * (-len(s) % 4) # pad to a multiple of 4 (0 pad when already aligned)
return base64.urlsafe_b64decode(s)
@@ -37,7 +37,11 @@ class ACMEService:
def __init__(self):
self._directory_cache: Dict[str, dict] = {}
self._nonce: Optional[str] = None
# Anti-replay nonces are scoped PER CA (directory_url). A Replay-Nonce issued by one ACME
# server must never be sent in a JWS to another, or the second server rejects it (e.g. ZeroSSL
# "malformed: The Replay Nonce could not be base64url-decoded"). This client is a process-wide
# singleton shared across CAs, so a single shared nonce was leaking across them.
self._nonce_by_dir: Dict[str, str] = {}
async def _get_settings(self) -> dict:
conn = await get_database_connection()
@@ -71,17 +75,21 @@ class ACMEService:
raise Exception(f"Failed to fetch ACME directory: HTTP {resp.status}")
data = await resp.json()
if 'Replay-Nonce' in resp.headers:
self._nonce = resp.headers['Replay-Nonce']
self._nonce_by_dir[directory_url] = resp.headers['Replay-Nonce']
data['_fetched_at'] = time.time()
self._directory_cache[directory_url] = data
return data
async def _get_nonce(self, directory_url: str) -> str:
if self._nonce:
nonce = self._nonce
self._nonce = None
return nonce
# Use a cached nonce for THIS CA only; otherwise fetch a fresh one from THIS CA's newNonce.
cached = self._nonce_by_dir.pop(directory_url, None)
if cached:
return cached
directory = await self.get_directory(directory_url)
# get_directory may have just captured a nonce for this CA from the directory response.
cached = self._nonce_by_dir.pop(directory_url, None)
if cached:
return cached
async with aiohttp.ClientSession() as session:
async with session.head(directory['newNonce']) as resp:
return resp.headers['Replay-Nonce']
@@ -188,11 +196,17 @@ class ACMEService:
timeout=aiohttp.ClientTimeout(total=30),
) as resp:
if 'Replay-Nonce' in resp.headers:
self._nonce = resp.headers['Replay-Nonce']
self._nonce_by_dir[directory_url] = resp.headers['Replay-Nonce']
if resp.status == 400:
if resp.status == 400 and attempt < 2:
err = await resp.json()
if err.get('type') == 'urn:ietf:params:acme:error:badNonce' and attempt < 2:
etype = (err.get('type') or '')
edetail = (err.get('detail') or '').lower()
# Retry on badNonce, and on any nonce-related malformed rejection (e.g.
# "The Replay Nonce could not be base64url-decoded") — refetch a FRESH nonce
# from the target CA and resign. With per-CA scoping the cross-CA cause is gone;
# this is defense-in-depth so a stale/rejected nonce always self-heals.
if etype.endswith('badNonce') or 'nonce' in edetail:
nonce = resp.headers.get('Replay-Nonce') or await self._get_nonce(directory_url)
protected['nonce'] = nonce
body = self._sign_jws(private_key, protected, payload)
@@ -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"
)
+21
View File
@@ -89,3 +89,24 @@ def test_cloudflare_token_sanitize():
p = CloudflareDNSProvider({"api_token": '"my-token_123"'})
assert p._token == 'my-token_123'
assert p._raw_token == '"my-token_123"'
def test_b64url_decode_padding_roundtrip():
# Issue #35 v1.8.2: _b64url_decode must round-trip for EVERY length, including base64url strings
# whose length is a multiple of 4 (the case the old padding formula '=' * (4 - len%4) over-padded).
from services.acme_service import _b64url as enc_fn, _b64url_decode as dec_fn
for n in range(0, 20):
data = bytes(range(n))
assert dec_fn(enc_fn(data)) == data, f"round-trip failed at byte length {n}"
def test_nonce_scoped_per_directory():
# Issue #35 v1.8.2: a nonce cached for one CA (directory_url) must never be returned for another,
# and must be single-use. Both directories are pre-cached so _get_nonce returns without network.
import asyncio
svc = ACMEService()
svc._nonce_by_dir = {"https://a.example/dir": "NONCE_A", "https://b.example/dir": "NONCE_B"}
got = asyncio.run(svc._get_nonce("https://a.example/dir"))
assert got == "NONCE_A" # returns THIS CA's nonce
assert svc._nonce_by_dir.get("https://a.example/dir") is None # consumed (single-use)
assert svc._nonce_by_dir.get("https://b.example/dir") == "NONCE_B" # the other CA is untouched
+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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "haproxy-openmanager-frontend",
"version": "1.8.1",
"version": "1.8.4",
"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);
+3 -3
View File
@@ -1,5 +1,5 @@
{
"version": "1.8.1",
"releaseName": "ACME DNS-01 fixes (Cloudflare token, EAB, Apply Management)",
"releaseDate": "2026-06-24"
"version": "1.8.4",
"releaseName": "Agent installer self-kill fix",
"releaseDate": "2026-06-27"
}