mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-10-02 23:18:14 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e2ea04777 | |||
| 60f4fa71ed | |||
| 97b2452bd2 | |||
| 23257b02cf | |||
| c8d144ca9d | |||
| 64d42663cd | |||
| 27fbe48c4b |
@@ -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
|
||||
# ============================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
@@ -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}}"]
|
||||
@@ -194,7 +194,14 @@ async def ensure_agents_table():
|
||||
'use_backend_rules': "ALTER TABLE frontends ADD COLUMN use_backend_rules JSONB DEFAULT '[]'::jsonb;",
|
||||
'request_headers': "ALTER TABLE frontends ADD COLUMN request_headers TEXT;",
|
||||
'response_headers': "ALTER TABLE frontends ADD COLUMN response_headers TEXT;",
|
||||
'maxconn': "ALTER TABLE frontends ADD COLUMN maxconn INTEGER;"
|
||||
'maxconn': "ALTER TABLE frontends ADD COLUMN maxconn INTEGER;",
|
||||
# Issue #38: SPOE filter directives (e.g. Coraza WAF) and frontend
|
||||
# log-format were silently dropped on bulk-import / manual edit
|
||||
# because the parser recognised only a fixed set of directives.
|
||||
# These nullable TEXT columns persist them verbatim (multi-line for
|
||||
# `filters`), mirroring the request_headers/options passthrough.
|
||||
'log_format': "ALTER TABLE frontends ADD COLUMN log_format TEXT;",
|
||||
'filters': "ALTER TABLE frontends ADD COLUMN filters TEXT;"
|
||||
}
|
||||
|
||||
for col, query in frontend_columns.items():
|
||||
@@ -1741,7 +1748,12 @@ async def ensure_agent_activity_logs_table():
|
||||
# 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
|
||||
# v1.8.8 (Issue #38 — SPOE filter + frontend log-format): bumped 8 -> 9 for the additive
|
||||
# `log_format` + `filters` TEXT columns on `frontends` (frontend_columns loop). Without this
|
||||
# bump, already-deployed databases (version >= 8) skip the whole migration run and never gain
|
||||
# the columns, so the frontends SELECT/INSERT would fail. Additive + idempotent + nullable;
|
||||
# existing rows stay NULL and render byte-identical.
|
||||
SCHEMA_VERSION = 9
|
||||
|
||||
|
||||
async def run_all_migrations():
|
||||
|
||||
+7
-4
@@ -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
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -85,6 +85,11 @@ class FrontendConfig(BaseModel):
|
||||
response_headers: Optional[str] = None
|
||||
options: Optional[str] = None
|
||||
tcp_request_rules: Optional[str] = None
|
||||
# Issue #38: SPOE filter directives (Coraza WAF etc.) + frontend log-format.
|
||||
# Passthrough TEXT (no validator) — SPOE `filter ... config <path>` legitimately
|
||||
# references an operator-managed file, so the ACL `-f` guard must NOT apply here.
|
||||
log_format: Optional[str] = None
|
||||
filters: Optional[str] = None
|
||||
timeout_client: Optional[int] = None
|
||||
timeout_http_request: Optional[int] = None
|
||||
rate_limit: Optional[int] = None
|
||||
|
||||
+65
-41
@@ -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'
|
||||
|
||||
@@ -3696,17 +3696,19 @@ async def confirm_restore_config_version(
|
||||
# UPDATE existing frontend (ALL 8 parsed fields)
|
||||
# CRITICAL FIX: Include maxconn and timeout_client so UI shows restored values
|
||||
await conn.execute("""
|
||||
UPDATE frontends
|
||||
SET bind_address = $1, bind_port = $2, default_backend = $3,
|
||||
UPDATE frontends
|
||||
SET bind_address = $1, bind_port = $2, default_backend = $3,
|
||||
mode = $4, ssl_enabled = $5, ssl_port = $6,
|
||||
maxconn = $7, timeout_client = $8,
|
||||
log_format = $11, filters = $12,
|
||||
updated_at = CURRENT_TIMESTAMP, last_config_status = 'PENDING'
|
||||
WHERE id = $9 AND cluster_id = $10
|
||||
""",
|
||||
""",
|
||||
parsed_fe.bind_address, parsed_fe.bind_port, parsed_fe.default_backend,
|
||||
parsed_fe.mode, parsed_fe.ssl_enabled, parsed_fe.ssl_port,
|
||||
parsed_fe.maxconn, parsed_fe.timeout_client,
|
||||
fe_id, cluster_id
|
||||
fe_id, cluster_id,
|
||||
parsed_fe.log_format, parsed_fe.filters # Issue #38
|
||||
)
|
||||
changes_summary["frontends_updated"] += 1
|
||||
logger.info(f"RESTORE: Updated frontend '{parsed_fe.name}' (SSL: {parsed_fe.ssl_enabled}, maxconn: {parsed_fe.maxconn})")
|
||||
@@ -3714,16 +3716,17 @@ async def confirm_restore_config_version(
|
||||
# CREATE new frontend (ALL 8 parsed fields)
|
||||
# CRITICAL FIX: Include maxconn and timeout_client so UI shows restored values
|
||||
await conn.execute("""
|
||||
INSERT INTO frontends
|
||||
INSERT INTO frontends
|
||||
(name, bind_address, bind_port, default_backend, mode, ssl_enabled, ssl_port,
|
||||
maxconn, timeout_client,
|
||||
cluster_id, is_active, last_config_status, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, TRUE, 'PENDING', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
""",
|
||||
cluster_id, log_format, filters, is_active, last_config_status, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, TRUE, 'PENDING', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
""",
|
||||
parsed_fe.name, parsed_fe.bind_address, parsed_fe.bind_port,
|
||||
parsed_fe.default_backend, parsed_fe.mode, parsed_fe.ssl_enabled, parsed_fe.ssl_port,
|
||||
parsed_fe.maxconn, parsed_fe.timeout_client,
|
||||
cluster_id
|
||||
cluster_id,
|
||||
parsed_fe.log_format, parsed_fe.filters # Issue #38
|
||||
)
|
||||
changes_summary["frontends_created"] += 1
|
||||
logger.info(f"RESTORE: Created frontend '{parsed_fe.name}' (SSL: {parsed_fe.ssl_enabled}, maxconn: {parsed_fe.maxconn})")
|
||||
|
||||
@@ -855,6 +855,9 @@ async def parse_bulk_config(
|
||||
"response_headers": frontend.response_headers,
|
||||
"options": frontend.options,
|
||||
"tcp_request_rules": frontend.tcp_request_rules,
|
||||
# Issue #38: SPOE filters + frontend log-format
|
||||
"log_format": frontend.log_format,
|
||||
"filters": frontend.filters,
|
||||
# CRITICAL: SSL Advanced Options (parsed from bind directive)
|
||||
"ssl_alpn": frontend.ssl_alpn,
|
||||
"ssl_npn": frontend.ssl_npn,
|
||||
@@ -1089,7 +1092,50 @@ async def parse_bulk_config(
|
||||
|
||||
# Add auto-assignment info at the beginning
|
||||
enhanced_warnings = ssl_auto_assign_info + enhanced_warnings
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# Issue #38: SPOE pre-flight advisories. Surface, at preview time, the
|
||||
# SPOE configurations that would FAIL HAProxy's `haproxy -c` at apply so
|
||||
# the operator sees them BEFORE importing. Cluster-aware: the referenced
|
||||
# SPOE engine config (e.g. coraza.cfg) is a sibling of the cluster's
|
||||
# haproxy_config_path, which HAProxy OpenManager does not provision.
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
try:
|
||||
_cfg_path = await conn.fetchval(
|
||||
"SELECT haproxy_config_path FROM haproxy_clusters WHERE id = $1",
|
||||
request.cluster_id,
|
||||
) or "/etc/haproxy/haproxy.cfg"
|
||||
_cfg_dir = _cfg_path.rsplit("/", 1)[0] or "/etc/haproxy"
|
||||
for _fe in frontends_data:
|
||||
_rh = _fe.get("request_headers") or ""
|
||||
_filters = _fe.get("filters") or ""
|
||||
# engines declared by `filter spoe engine <name> config <path>`
|
||||
_declared_engines = set(re.findall(
|
||||
r"filter\s+spoe\s+engine\s+(\S+)", _filters, re.IGNORECASE))
|
||||
# engines referenced by `... send-spoe-group <name> <group>`
|
||||
_used_engines = set(re.findall(
|
||||
r"send-spoe-group\s+(\S+)", _rh, re.IGNORECASE))
|
||||
_missing = _used_engines - _declared_engines
|
||||
if _missing:
|
||||
enhanced_warnings.append(
|
||||
f"⚠️ Frontend '{_fe['name']}': 'send-spoe-group' references SPOE "
|
||||
f"engine(s) {', '.join(sorted(_missing))} but no matching "
|
||||
f"'filter spoe engine <name> ...' line was found. HAProxy will "
|
||||
f"reject this at apply with \"unable to find SPOE engine\". Add the "
|
||||
f"filter line to this frontend."
|
||||
)
|
||||
for _path in re.findall(
|
||||
r"filter\s+spoe\s+engine\s+\S+\s+config\s+(\S+)",
|
||||
_filters, re.IGNORECASE):
|
||||
enhanced_warnings.append(
|
||||
f"ℹ️ Frontend '{_fe['name']}': SPOE engine config '{_path}' and its "
|
||||
f"SPOA backend must exist on the HAProxy host (cluster config dir: "
|
||||
f"{_cfg_dir}). HAProxy OpenManager preserves the filter directive but "
|
||||
f"does not provision these files; otherwise 'haproxy -c' fails at apply."
|
||||
)
|
||||
except Exception as _spoe_adv_err:
|
||||
logger.warning(f"SPOE advisory generation skipped: {_spoe_adv_err}")
|
||||
|
||||
# BULK IMPORT MVP: Check existing entities for UPSERT detection
|
||||
# Mark each entity as new or update for UI display
|
||||
# CRITICAL: Only mark as UPDATE if there are actual field changes
|
||||
@@ -1150,7 +1196,17 @@ async def parse_bulk_config(
|
||||
if frontend.get("tcp_request_rules") and frontend["tcp_request_rules"] != existing["tcp_request_rules"]:
|
||||
has_changes = True
|
||||
changes["tcp_request_rules"] = {"old": existing["tcp_request_rules"], "new": frontend["tcp_request_rules"]}
|
||||
|
||||
# Issue #38: SPOE filters + log-format change detection. REQUIRED for
|
||||
# persistence (not just display): without it, an import that only adds
|
||||
# a `filter`/`log-format` to an existing frontend would be flagged
|
||||
# "no change" and the directive would never be written to the DB.
|
||||
if frontend.get("log_format") and frontend["log_format"] != existing.get("log_format"):
|
||||
has_changes = True
|
||||
changes["log_format"] = {"old": existing.get("log_format"), "new": frontend["log_format"]}
|
||||
if frontend.get("filters") and frontend["filters"] != existing.get("filters"):
|
||||
has_changes = True
|
||||
changes["filters"] = {"old": existing.get("filters"), "new": frontend["filters"]}
|
||||
|
||||
# CRITICAL: SSL Advanced Options change detection
|
||||
if frontend.get("ssl_alpn") is not None and frontend.get("ssl_alpn") != existing.get("ssl_alpn"):
|
||||
has_changes = True
|
||||
@@ -2094,7 +2150,18 @@ async def bulk_create_entities(
|
||||
update_fields.append(f"options = ${param_index}")
|
||||
update_values.append(frontend_data["options"])
|
||||
param_index += 1
|
||||
|
||||
|
||||
# Issue #38: SPOE filters + frontend log-format (merge strategy)
|
||||
if frontend_data.get("log_format") and frontend_data["log_format"] != existing_full.get("log_format"):
|
||||
update_fields.append(f"log_format = ${param_index}")
|
||||
update_values.append(frontend_data["log_format"])
|
||||
param_index += 1
|
||||
|
||||
if frontend_data.get("filters") and frontend_data["filters"] != existing_full.get("filters"):
|
||||
update_fields.append(f"filters = ${param_index}")
|
||||
update_values.append(frontend_data["filters"])
|
||||
param_index += 1
|
||||
|
||||
# CRITICAL FIX: Update SSL advanced options (alpn, npn, ciphers, etc.)
|
||||
# These are parsed from bind directive and should be preserved in database
|
||||
if "ssl_alpn" in frontend_data and frontend_data.get("ssl_alpn") != existing_full.get("ssl_alpn"):
|
||||
@@ -2214,9 +2281,10 @@ async def bulk_create_entities(
|
||||
timeout_client, timeout_http_request, maxconn,
|
||||
request_headers, response_headers, tcp_request_rules, options,
|
||||
rate_limit, compression, log_separate, monitor_uri,
|
||||
cluster_id, acl_rules, use_backend_rules, redirect_rules, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, CURRENT_TIMESTAMP)
|
||||
cluster_id, acl_rules, use_backend_rules, redirect_rules,
|
||||
log_format, filters, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, CURRENT_TIMESTAMP)
|
||||
RETURNING id
|
||||
""",
|
||||
frontend_data["name"],
|
||||
@@ -2257,7 +2325,9 @@ async def bulk_create_entities(
|
||||
request.cluster_id,
|
||||
json.dumps(frontend_data.get("acl_rules", [])), # acl_rules
|
||||
json.dumps(frontend_data.get("use_backend_rules", [])), # use_backend_rules
|
||||
json.dumps([]) # redirect_rules
|
||||
json.dumps([]), # redirect_rules
|
||||
frontend_data.get("log_format"), # Issue #38
|
||||
frontend_data.get("filters") # Issue #38
|
||||
)
|
||||
|
||||
created_entities["frontends"].append({
|
||||
|
||||
@@ -408,6 +408,7 @@ async def get_frontends(
|
||||
ssl_alpn, ssl_npn, ssl_ciphers, ssl_ciphersuites, ssl_min_ver, ssl_max_ver, ssl_strict_sni,
|
||||
acl_rules, redirect_rules, use_backend_rules,
|
||||
request_headers, response_headers, options, tcp_request_rules,
|
||||
log_format, filters,
|
||||
timeout_client, timeout_http_request,
|
||||
rate_limit, compression, log_separate, monitor_uri,
|
||||
maxconn, is_active, created_at, updated_at, cluster_id, last_config_status
|
||||
@@ -424,6 +425,7 @@ async def get_frontends(
|
||||
ssl_alpn, ssl_npn, ssl_ciphers, ssl_ciphersuites, ssl_min_ver, ssl_max_ver, ssl_strict_sni,
|
||||
acl_rules, redirect_rules, use_backend_rules,
|
||||
request_headers, response_headers, options, tcp_request_rules,
|
||||
log_format, filters,
|
||||
timeout_client, timeout_http_request,
|
||||
rate_limit, compression, log_separate, monitor_uri,
|
||||
maxconn, is_active, created_at, updated_at, cluster_id, last_config_status
|
||||
@@ -453,6 +455,7 @@ async def get_frontends(
|
||||
ssl_alpn, ssl_npn, ssl_ciphers, ssl_ciphersuites, ssl_min_ver, ssl_max_ver, ssl_strict_sni,
|
||||
acl_rules, redirect_rules, use_backend_rules,
|
||||
request_headers, response_headers, options, tcp_request_rules,
|
||||
log_format, filters,
|
||||
timeout_client, timeout_http_request,
|
||||
rate_limit, compression, log_separate, monitor_uri,
|
||||
maxconn, is_active, created_at, updated_at, cluster_id, last_config_status
|
||||
@@ -465,6 +468,7 @@ async def get_frontends(
|
||||
ssl_alpn, ssl_npn, ssl_ciphers, ssl_ciphersuites, ssl_min_ver, ssl_max_ver, ssl_strict_sni,
|
||||
acl_rules, redirect_rules, use_backend_rules,
|
||||
request_headers, response_headers, options, tcp_request_rules,
|
||||
log_format, filters,
|
||||
timeout_client, timeout_http_request,
|
||||
rate_limit, compression, log_separate, monitor_uri,
|
||||
maxconn, is_active, created_at, updated_at, cluster_id, last_config_status
|
||||
@@ -480,6 +484,7 @@ async def get_frontends(
|
||||
ssl_alpn, ssl_npn, ssl_ciphers, ssl_ciphersuites, ssl_min_ver, ssl_max_ver, ssl_strict_sni,
|
||||
acl_rules, redirect_rules, use_backend_rules,
|
||||
request_headers, response_headers, options, tcp_request_rules,
|
||||
log_format, filters,
|
||||
timeout_client, timeout_http_request,
|
||||
rate_limit, compression, log_separate, monitor_uri,
|
||||
maxconn, is_active, created_at, updated_at, cluster_id, last_config_status
|
||||
@@ -492,6 +497,7 @@ async def get_frontends(
|
||||
ssl_alpn, ssl_npn, ssl_ciphers, ssl_ciphersuites, ssl_min_ver, ssl_max_ver, ssl_strict_sni,
|
||||
acl_rules, redirect_rules, use_backend_rules,
|
||||
request_headers, response_headers, options, tcp_request_rules,
|
||||
log_format, filters,
|
||||
timeout_client, timeout_http_request,
|
||||
rate_limit, compression, log_separate, monitor_uri,
|
||||
maxconn, is_active, created_at, updated_at, cluster_id, last_config_status
|
||||
@@ -601,6 +607,8 @@ async def get_frontends(
|
||||
"response_headers": f.get("response_headers"),
|
||||
"options": f.get("options"),
|
||||
"tcp_request_rules": f.get("tcp_request_rules"),
|
||||
"log_format": f.get("log_format"), # Issue #38
|
||||
"filters": f.get("filters"), # Issue #38
|
||||
"timeout_client": f.get("timeout_client"),
|
||||
"timeout_http_request": f.get("timeout_http_request"),
|
||||
"rate_limit": f.get("rate_limit"),
|
||||
@@ -735,18 +743,18 @@ async def create_frontend(frontend: FrontendConfig, request: Request, authorizat
|
||||
acl_rules, redirect_rules, use_backend_rules,
|
||||
request_headers, response_headers, options, tcp_request_rules, timeout_client, timeout_http_request,
|
||||
rate_limit, compression, log_separate, monitor_uri,
|
||||
cluster_id, maxconn, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, CURRENT_TIMESTAMP)
|
||||
cluster_id, maxconn, log_format, filters, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, CURRENT_TIMESTAMP)
|
||||
RETURNING id
|
||||
""", frontend.name, frontend.bind_address, frontend.bind_port,
|
||||
""", frontend.name, frontend.bind_address, frontend.bind_port,
|
||||
frontend.default_backend, frontend.mode, frontend.ssl_enabled,
|
||||
frontend.ssl_certificate_id, ssl_cert_ids_json, frontend.ssl_port, frontend.ssl_cert_path, frontend.ssl_cert, frontend.ssl_verify,
|
||||
frontend.ssl_alpn, frontend.ssl_npn, frontend.ssl_ciphers, frontend.ssl_ciphersuites,
|
||||
frontend.ssl_alpn, frontend.ssl_npn, frontend.ssl_ciphers, frontend.ssl_ciphersuites,
|
||||
frontend.ssl_min_ver, frontend.ssl_max_ver, frontend.ssl_strict_sni,
|
||||
json.dumps(frontend.acl_rules or []), json.dumps(frontend.redirect_rules or []), json.dumps(frontend.use_backend_rules or []),
|
||||
frontend.request_headers, frontend.response_headers, filtered_options, frontend.tcp_request_rules, frontend.timeout_client, frontend.timeout_http_request,
|
||||
frontend.rate_limit, frontend.compression, frontend.log_separate, frontend.monitor_uri,
|
||||
frontend.cluster_id, frontend.maxconn)
|
||||
frontend.cluster_id, frontend.maxconn, frontend.log_format, frontend.filters)
|
||||
|
||||
# If cluster_id provided, create new config version for agents
|
||||
sync_results = []
|
||||
@@ -1060,9 +1068,10 @@ async def update_frontend(frontend_id: int, frontend: FrontendConfig, request: R
|
||||
acl_rules = $20, redirect_rules = $21, use_backend_rules = $22,
|
||||
request_headers = $23, response_headers = $24, options = $25, tcp_request_rules = $26, timeout_client = $27, timeout_http_request = $28,
|
||||
rate_limit = $29, compression = $30, log_separate = $31, monitor_uri = $32,
|
||||
cluster_id = $33, maxconn = $34, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $35
|
||||
""", frontend.name, frontend.bind_address, frontend.bind_port,
|
||||
cluster_id = $33, maxconn = $34, log_format = $35, filters = $36,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $37
|
||||
""", frontend.name, frontend.bind_address, frontend.bind_port,
|
||||
frontend.default_backend, frontend.mode, ssl_enabled,
|
||||
ssl_certificate_id, ssl_cert_ids_json, ssl_port, ssl_cert_path, ssl_cert, ssl_verify,
|
||||
frontend.ssl_alpn, frontend.ssl_npn, frontend.ssl_ciphers, frontend.ssl_ciphersuites,
|
||||
@@ -1070,7 +1079,7 @@ async def update_frontend(frontend_id: int, frontend: FrontendConfig, request: R
|
||||
json.dumps(frontend.acl_rules or []), json.dumps(frontend.redirect_rules or []), json.dumps(frontend.use_backend_rules or []),
|
||||
frontend.request_headers, frontend.response_headers, filtered_options, frontend.tcp_request_rules, frontend.timeout_client, frontend.timeout_http_request,
|
||||
frontend.rate_limit, frontend.compression, frontend.log_separate, frontend.monitor_uri,
|
||||
frontend.cluster_id, frontend.maxconn, frontend_id)
|
||||
frontend.cluster_id, frontend.maxconn, frontend.log_format, frontend.filters, frontend_id)
|
||||
|
||||
# Debug: Check what was actually saved
|
||||
updated_frontend = await conn.fetchrow("""
|
||||
@@ -1124,6 +1133,8 @@ async def update_frontend(frontend_id: int, frontend: FrontendConfig, request: R
|
||||
"response_headers": frontend.response_headers,
|
||||
"options": filtered_options,
|
||||
"tcp_request_rules": frontend.tcp_request_rules,
|
||||
"log_format": frontend.log_format, # Issue #38
|
||||
"filters": frontend.filters, # Issue #38
|
||||
"timeout_client": frontend.timeout_client,
|
||||
"timeout_http_request": frontend.timeout_http_request,
|
||||
"rate_limit": frontend.rate_limit,
|
||||
|
||||
@@ -56,14 +56,14 @@ async def create_frontend_row(
|
||||
acl_rules, redirect_rules, use_backend_rules,
|
||||
request_headers, response_headers, options, tcp_request_rules, timeout_client, timeout_http_request,
|
||||
rate_limit, compression, log_separate, monitor_uri,
|
||||
cluster_id, maxconn, updated_at
|
||||
cluster_id, maxconn, log_format, filters, updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12,
|
||||
$13, $14, $15, $16, $17, $18, $19,
|
||||
$20, $21, $22,
|
||||
$23, $24, $25, $26, $27, $28,
|
||||
$29, $30, $31, $32,
|
||||
$33, $34, CURRENT_TIMESTAMP
|
||||
$33, $34, $35, $36, CURRENT_TIMESTAMP
|
||||
)
|
||||
RETURNING id
|
||||
""",
|
||||
@@ -101,6 +101,8 @@ async def create_frontend_row(
|
||||
getattr(payload, "monitor_uri", None),
|
||||
cluster_id,
|
||||
getattr(payload, "maxconn", None),
|
||||
getattr(payload, "log_format", None), # Issue #38
|
||||
getattr(payload, "filters", None), # Issue #38
|
||||
)
|
||||
|
||||
if mark_pending:
|
||||
|
||||
@@ -393,6 +393,12 @@ def _categorize_haproxy_directive(line: str) -> str:
|
||||
return "prelude"
|
||||
if s.startswith("acl "):
|
||||
return "acl"
|
||||
# Issue #38: SPOE (and other) `filter` directives must be declared BEFORE
|
||||
# the `http-request send-spoe-group` rules that use them, otherwise HAProxy
|
||||
# fails with "unable to find SPOE engine". Own bucket, flushed right after
|
||||
# `prelude` and before tcp_req/acl/http_req (see flush order below).
|
||||
if s.startswith("filter "):
|
||||
return "filter"
|
||||
if s.startswith("stick-table") or s.startswith("stick "):
|
||||
return "stick"
|
||||
if s.startswith("tcp-request"):
|
||||
@@ -414,6 +420,7 @@ def _categorize_haproxy_directive(line: str) -> str:
|
||||
or s.startswith("compression ")
|
||||
or s.startswith("monitor-uri")
|
||||
or s.startswith("log ")
|
||||
or s.startswith("log-format") # Issue #38: log-format / log-format-sd
|
||||
or s.startswith("description ")
|
||||
or s.startswith("disabled")
|
||||
or s.startswith("enabled")
|
||||
@@ -903,7 +910,7 @@ async def generate_haproxy_config_for_cluster(cluster_id: int, conn: Optional[An
|
||||
# "stick-table already declared").
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
_fe_buckets: Dict[str, List[str]] = {
|
||||
"prelude": [], "stick": [], "tcp_req": [],
|
||||
"prelude": [], "filter": [], "stick": [], "tcp_req": [],
|
||||
"acl": [], "http_req": [], "http_resp": [],
|
||||
"redirect": [], "use_be": [], "default_be": [],
|
||||
}
|
||||
@@ -996,6 +1003,17 @@ async def generate_haproxy_config_for_cluster(cluster_id: int, conn: Optional[An
|
||||
if line_stripped and line_stripped not in ('[]', '{}', 'null', 'None'):
|
||||
_emit_fe(f" {line_stripped}")
|
||||
|
||||
# Issue #38: emit frontend log-format and SPOE (etc.) filter directives.
|
||||
# `log_format` routes to the `prelude` bucket, `filters` to the `filter`
|
||||
# bucket (both via _emit_fe → _categorize_haproxy_directive), guaranteeing
|
||||
# `filter ...` is rendered before the `http-request send-spoe-group` rules.
|
||||
for _fld in ('log_format', 'filters'):
|
||||
if frontend.get(_fld):
|
||||
for line in frontend[_fld].split('\n'):
|
||||
line_stripped = line.strip()
|
||||
if line_stripped and line_stripped not in ('[]', '{}', 'null', 'None'):
|
||||
_emit_fe(f" {line_stripped}")
|
||||
|
||||
# CRITICAL: Validate frontend-backend mode compatibility
|
||||
if frontend.get('default_backend'):
|
||||
default_backend_name = frontend['default_backend'].strip() if frontend['default_backend'] else ''
|
||||
@@ -1223,6 +1241,7 @@ async def generate_haproxy_config_for_cluster(cluster_id: int, conn: Optional[An
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
for _bucket_key in (
|
||||
"prelude",
|
||||
"filter",
|
||||
"stick",
|
||||
"tcp_req",
|
||||
"acl",
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -276,14 +276,16 @@ def test_categorize_routes_directives_correctly():
|
||||
|
||||
def test_emit_buckets_flushed_in_canonical_order():
|
||||
"""The flush block at end of frontend processing must list buckets
|
||||
in: prelude → stick → tcp_req → acl → http_req → http_resp →
|
||||
in: prelude → filter → stick → tcp_req → acl → http_req → http_resp →
|
||||
redirect → use_be → default_be. Pre-fix `http-request` rules
|
||||
interleaved with `use_backend` rules in source order, producing
|
||||
HAProxy parser warnings."""
|
||||
HAProxy parser warnings. (Issue #38 added the `filter` bucket, flushed
|
||||
right after `prelude` so SPOE `filter` lines precede `send-spoe-group`.)"""
|
||||
src = _gen_src()
|
||||
flush_match = re.search(
|
||||
r'for\s+_bucket_key\s+in\s+\(\s*'
|
||||
r'"prelude"\s*,\s*'
|
||||
r'"filter"\s*,\s*'
|
||||
r'"stick"\s*,\s*'
|
||||
r'"tcp_req"\s*,\s*'
|
||||
r'"acl"\s*,\s*'
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
"""
|
||||
Issue #38 regression tests: HAProxy SPOE `filter` + frontend `log-format` support.
|
||||
|
||||
Bug: the bulk-config parser recognised only a fixed set of frontend directives,
|
||||
so `filter spoe engine coraza config ...` and `log-format ...` were silently
|
||||
dropped on import / manual edit. This regenerated a config missing the SPOE
|
||||
engine definition, so HAProxy failed with
|
||||
"unable to find SPOE engine 'coraza' used by the send-spoe-group 'coraza-req'".
|
||||
|
||||
These tests verify the end-to-end fix without requiring a database:
|
||||
1. parser captures `filter` + `log-format` into the new ParsedFrontend fields;
|
||||
2. `http-request send-spoe-group` is still preserved (regression guard);
|
||||
3. the generator's directive categoriser + bucket flush order emit `filter`
|
||||
BEFORE the `http-request send-spoe-group` rules and keep `log-format`;
|
||||
4. reject/rollback restores the new columns;
|
||||
5. a non-SPOE frontend is completely unaffected (zero-impact).
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from utils.haproxy_config_parser import parse_haproxy_config, ParsedFrontend
|
||||
from services.haproxy_config import _categorize_haproxy_directive
|
||||
from models.frontend import FrontendConfig
|
||||
|
||||
|
||||
# The exact frontend/backend config reported in Issue #38 (Coraza-SPOA).
|
||||
ISSUE_38_CONFIG = r"""
|
||||
frontend web-frontend
|
||||
bind *:8073
|
||||
mode http
|
||||
log-format "%ci:%cp\ [%t]\ %ft\ %b/%s\ %ST\ %B\ %{+Q}r\ %[var(txn.coraza.id)]\ waf-hit:\ %[var(txn.coraza.fail)]"
|
||||
filter spoe engine coraza config /etc/haproxy/coraza.cfg
|
||||
http-request set-var(txn.coraza.app) str(haproxy_waf)
|
||||
http-request send-spoe-group coraza coraza-req
|
||||
http-request deny if { var(txn.coraza.fail) -m int eq 1 }
|
||||
default_backend web-backend
|
||||
|
||||
backend web-backend
|
||||
balance roundrobin
|
||||
mode http
|
||||
server server1 192.168.1.10:443 weight 100 ssl verify none
|
||||
|
||||
backend coraza-spoa
|
||||
mode tcp
|
||||
option spop-check
|
||||
server coraza_spoa 192.168.12.21:9000
|
||||
"""
|
||||
|
||||
|
||||
def _get_frontend(parse_result, name):
|
||||
for fe in parse_result.frontends:
|
||||
if fe.name == name:
|
||||
return fe
|
||||
return None
|
||||
|
||||
|
||||
class TestParserCapturesSpoe:
|
||||
def test_filter_and_log_format_captured(self):
|
||||
result = parse_haproxy_config(ISSUE_38_CONFIG)
|
||||
fe = _get_frontend(result, "web-frontend")
|
||||
assert fe is not None, "web-frontend should be parsed and kept"
|
||||
assert fe.filters is not None
|
||||
assert "filter spoe engine coraza config /etc/haproxy/coraza.cfg" in fe.filters
|
||||
assert fe.log_format is not None
|
||||
assert fe.log_format.startswith("log-format")
|
||||
# the escaped/quoted format string must be preserved verbatim
|
||||
assert "%[var(txn.coraza.fail)]" in fe.log_format
|
||||
|
||||
def test_send_spoe_group_still_preserved(self):
|
||||
# Regression guard: http-request rules (incl. send-spoe-group) must
|
||||
# still be collected into request_headers as before.
|
||||
result = parse_haproxy_config(ISSUE_38_CONFIG)
|
||||
fe = _get_frontend(result, "web-frontend")
|
||||
assert fe.request_headers is not None
|
||||
assert "send-spoe-group coraza coraza-req" in fe.request_headers
|
||||
|
||||
def test_multiple_filters_preserved_in_order(self):
|
||||
cfg = """
|
||||
frontend f1
|
||||
bind *:80
|
||||
mode http
|
||||
filter compression
|
||||
filter spoe engine coraza config /etc/haproxy/coraza.cfg
|
||||
default_backend b1
|
||||
|
||||
backend b1
|
||||
mode http
|
||||
server s1 10.0.0.1:80
|
||||
"""
|
||||
fe = _get_frontend(parse_haproxy_config(cfg), "f1")
|
||||
lines = fe.filters.split("\n")
|
||||
assert lines == [
|
||||
"filter compression",
|
||||
"filter spoe engine coraza config /etc/haproxy/coraza.cfg",
|
||||
]
|
||||
|
||||
def test_log_format_sd_variant_captured(self):
|
||||
cfg = """
|
||||
frontend f1
|
||||
bind *:80
|
||||
mode http
|
||||
log-format-sd "[exampleSDID@1234 field=value]"
|
||||
default_backend b1
|
||||
|
||||
backend b1
|
||||
mode http
|
||||
server s1 10.0.0.1:80
|
||||
"""
|
||||
fe = _get_frontend(parse_haproxy_config(cfg), "f1")
|
||||
assert fe.log_format is not None
|
||||
assert fe.log_format.startswith("log-format-sd")
|
||||
|
||||
|
||||
class TestGeneratorOrderingContract:
|
||||
"""The generator routes directives into ordered buckets. Verify SPOE
|
||||
correctness at the (pure) categoriser + documented flush-order level."""
|
||||
|
||||
def test_filter_routes_to_filter_bucket(self):
|
||||
assert _categorize_haproxy_directive(" filter spoe engine coraza config /x.cfg") == "filter"
|
||||
|
||||
def test_send_spoe_group_routes_to_http_req(self):
|
||||
assert _categorize_haproxy_directive(" http-request send-spoe-group coraza coraza-req") == "http_req"
|
||||
|
||||
def test_log_format_routes_to_prelude(self):
|
||||
assert _categorize_haproxy_directive(' log-format "%ci:%cp"') == "prelude"
|
||||
assert _categorize_haproxy_directive(' log-format-sd "[x]"') == "prelude"
|
||||
|
||||
def test_flush_order_places_filter_before_http_req(self):
|
||||
# The bucket flush order is the single source of truth for emission
|
||||
# ordering. Assert `filter` is flushed before `http_req` (and after
|
||||
# `prelude`), guaranteeing `filter ...` renders before
|
||||
# `http-request send-spoe-group ...`.
|
||||
src = _read_source("services/haproxy_config.py")
|
||||
m = re.search(r"for _bucket_key in \((.*?)\):", src, re.DOTALL)
|
||||
assert m, "bucket flush loop not found"
|
||||
order = re.findall(r'"(\w+)"', m.group(1))
|
||||
assert "filter" in order, "new 'filter' bucket missing from flush order"
|
||||
assert order.index("prelude") < order.index("filter") < order.index("http_req")
|
||||
|
||||
|
||||
class TestModelAndRollback:
|
||||
def test_model_has_passthrough_fields(self):
|
||||
fc = FrontendConfig(
|
||||
name="f", bind_port=80,
|
||||
filters="filter spoe engine coraza config /etc/haproxy/coraza.cfg",
|
||||
log_format='log-format "%ci"',
|
||||
)
|
||||
assert fc.filters.startswith("filter spoe")
|
||||
assert fc.log_format.startswith("log-format")
|
||||
|
||||
def test_dataclass_defaults_none(self):
|
||||
fe = ParsedFrontend(name="f")
|
||||
assert fe.filters is None
|
||||
assert fe.log_format is None
|
||||
|
||||
def test_rollback_restores_new_columns(self):
|
||||
# Reject/rollback of a frontend UPDATE must restore the new columns,
|
||||
# otherwise the rejected (new) filters/log_format would persist.
|
||||
src = _read_source("utils/entity_snapshot.py")
|
||||
assert "log_format = $" in src
|
||||
assert "filters = $" in src
|
||||
assert "old_values.get('log_format')" in src
|
||||
assert "old_values.get('filters')" in src
|
||||
|
||||
|
||||
class TestZeroImpact:
|
||||
def test_non_spoe_frontend_unaffected(self):
|
||||
cfg = """
|
||||
frontend plain
|
||||
bind *:80
|
||||
mode http
|
||||
option httplog
|
||||
default_backend b1
|
||||
|
||||
backend b1
|
||||
mode http
|
||||
server s1 10.0.0.1:80
|
||||
"""
|
||||
fe = _get_frontend(parse_haproxy_config(cfg), "plain")
|
||||
# No filter / log-format present → new fields stay None (no behaviour change)
|
||||
assert fe.filters is None
|
||||
assert fe.log_format is None
|
||||
|
||||
def test_spop_check_backend_roundtrips_without_warning(self):
|
||||
result = parse_haproxy_config(ISSUE_38_CONFIG)
|
||||
be = next((b for b in result.backends if b.name == "coraza-spoa"), None)
|
||||
assert be is not None, "coraza-spoa backend should import"
|
||||
assert be.mode == "tcp"
|
||||
assert be.options and "option spop-check" in be.options
|
||||
# spop-check is now a known option → no spurious 'unknown option' warning
|
||||
assert not any(
|
||||
"coraza-spoa" in w and "spop-check" in w and "Unknown" in w
|
||||
for w in result.warnings
|
||||
)
|
||||
|
||||
|
||||
def _read_source(relpath):
|
||||
base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
with open(os.path.join(base, relpath), "r", encoding="utf-8") as fh:
|
||||
return fh.read()
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -329,9 +329,10 @@ async def _rollback_update(
|
||||
tcp_request_rules = $26, timeout_client = $27, timeout_http_request = $28,
|
||||
rate_limit = $29, compression = $30, log_separate = $31,
|
||||
monitor_uri = $32, maxconn = $33,
|
||||
cluster_id = $34, is_active = $35, last_config_status = $36,
|
||||
cluster_id = $34, is_active = $35, last_config_status = $36,
|
||||
log_format = $37, filters = $38,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $37
|
||||
WHERE id = $39
|
||||
""",
|
||||
old_values.get('name'),
|
||||
old_values.get('bind_address'),
|
||||
@@ -369,6 +370,8 @@ async def _rollback_update(
|
||||
old_values.get('cluster_id'),
|
||||
old_values.get('is_active'),
|
||||
old_values.get('last_config_status'),
|
||||
old_values.get('log_format'), # Issue #38
|
||||
old_values.get('filters'), # Issue #38
|
||||
entity_id
|
||||
)
|
||||
|
||||
|
||||
@@ -105,6 +105,11 @@ class ParsedFrontend:
|
||||
response_headers: Optional[str] = None
|
||||
options: Optional[str] = None # HAProxy frontend options (option httplog, option forwardfor, etc.)
|
||||
tcp_request_rules: Optional[str] = None # TCP request directives (for TCP mode)
|
||||
# Issue #38: SPOE (and other) filter directives + frontend log-format.
|
||||
# Stored as full directive lines; `filters` is newline-joined to preserve
|
||||
# ordering when multiple `filter ...` lines exist.
|
||||
log_format: Optional[str] = None # `log-format` / `log-format-sd` line(s)
|
||||
filters: Optional[str] = None # `filter ...` line(s), e.g. `filter spoe engine coraza config ...`
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -256,8 +261,23 @@ class HAProxyConfigParser:
|
||||
acl_rules_list = []
|
||||
use_backend_rules_list = []
|
||||
tcp_request_rules_list = []
|
||||
filters_list = []
|
||||
log_format_list = []
|
||||
|
||||
for line in lines:
|
||||
# Issue #38: capture `filter ...` (SPOE/Coraza etc.) and
|
||||
# `log-format`/`log-format-sd` directives. Pre-fix these matched
|
||||
# no branch below and were silently dropped, so an imported SPOE
|
||||
# config lost `filter spoe engine coraza ...` (→ HAProxy fatal
|
||||
# "unable to find SPOE engine") and the frontend log-format.
|
||||
# `continue` isolates them from the header/option handling below.
|
||||
if line.startswith('filter '):
|
||||
filters_list.append(line.strip())
|
||||
continue
|
||||
if re.match(r'^log-format(-sd)?\s', line, re.IGNORECASE):
|
||||
log_format_list.append(line.strip())
|
||||
continue
|
||||
|
||||
# Parse bind directive
|
||||
# IMPORTANT: Handle multiple bind lines correctly
|
||||
# Example: bind *:1002 (HTTP) and bind *:443 ssl (HTTPS)
|
||||
@@ -540,6 +560,13 @@ class HAProxyConfigParser:
|
||||
if tcp_request_rules_list:
|
||||
frontend.tcp_request_rules = '\n'.join(tcp_request_rules_list)
|
||||
|
||||
# Issue #38: assign captured SPOE filters + log-format
|
||||
if filters_list:
|
||||
frontend.filters = '\n'.join(filters_list)
|
||||
|
||||
if log_format_list:
|
||||
frontend.log_format = '\n'.join(log_format_list)
|
||||
|
||||
self.frontends.append(frontend)
|
||||
logger.info(f"Parsed frontend: {name} -> {frontend.default_backend}")
|
||||
|
||||
@@ -666,9 +693,14 @@ class HAProxyConfigParser:
|
||||
'transparent', 'abortonclose', 'allbackups', 'checkcache', 'clitcpka',
|
||||
'srvtcpka', 'http-no-delay', 'socket-stats', 'tcp-smart-accept',
|
||||
'tcp-smart-connect', 'independant-streams', 'log-separate-errors',
|
||||
'log-health-checks', 'accept-invalid-http-request', 'accept-invalid-http-response'
|
||||
'log-health-checks', 'accept-invalid-http-request', 'accept-invalid-http-response',
|
||||
# Issue #38: SPOP health check for SPOE agent backends
|
||||
# (e.g. coraza-spoa). Already collected below regardless, but
|
||||
# listing it suppresses the spurious "unknown option" warning
|
||||
# for the exact SPOE use-case.
|
||||
'spop-check'
|
||||
]
|
||||
|
||||
|
||||
if option_name not in valid_options:
|
||||
# Unknown/invalid option - add warning but still collect it
|
||||
self.warnings.append(
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": "1.8.8",
|
||||
"releaseName": "SPOE filter + frontend log-format support (Issue #38)",
|
||||
"releaseDate": "2026-07-10"
|
||||
}
|
||||
@@ -10,7 +10,6 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
volumes:
|
||||
- haproxy_configs:/etc/haproxy
|
||||
- ./version.json:/app/version.json:ro
|
||||
|
||||
frontend:
|
||||
image: haproxy-openmanager-frontend:localtest
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "haproxy-openmanager-frontend",
|
||||
"version": "1.8.2",
|
||||
"version": "1.8.8",
|
||||
"description": "HAProxy Load Balancer Management UI",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -458,6 +458,8 @@ backend web-backend
|
||||
{record.request_headers && <Tag color="blue">Req Headers</Tag>}
|
||||
{record.response_headers && <Tag color="green">Resp Headers</Tag>}
|
||||
{record.tcp_request_rules && <Tag color="purple">TCP Rules</Tag>}
|
||||
{record.filters && <Tag color="magenta">Filters</Tag>}
|
||||
{record.log_format && <Tag color="geekblue">Log Format</Tag>}
|
||||
{record.acl_rules && record.acl_rules.length > 0 && <Tag color="orange">{record.acl_rules.length} ACLs</Tag>}
|
||||
{record.use_backend_rules && record.use_backend_rules.length > 0 && <Tag color="cyan">{record.use_backend_rules.length} Routes</Tag>}
|
||||
</Space>
|
||||
@@ -1076,8 +1078,9 @@ backend web-backend
|
||||
size="small"
|
||||
expandable={{
|
||||
expandedRowRender: (frontend) => {
|
||||
const hasDetails = frontend.request_headers || frontend.response_headers ||
|
||||
frontend.options || frontend.tcp_request_rules ||
|
||||
const hasDetails = frontend.request_headers || frontend.response_headers ||
|
||||
frontend.options || frontend.tcp_request_rules ||
|
||||
frontend.filters || frontend.log_format ||
|
||||
(frontend.acl_rules && frontend.acl_rules.length > 0) ||
|
||||
(frontend.use_backend_rules && frontend.use_backend_rules.length > 0);
|
||||
|
||||
@@ -1157,12 +1160,52 @@ backend web-backend
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<MultiLineDiffRenderer
|
||||
<MultiLineDiffRenderer
|
||||
value={frontend.tcp_request_rules}
|
||||
changeInfo={frontend._changes?.tcp_request_rules}
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
{/* Issue #38: SPOE filters */}
|
||||
{frontend.filters && (
|
||||
<Descriptions.Item
|
||||
label={
|
||||
<span>
|
||||
Filters (SPOE/WAF)
|
||||
{frontend._changes?.filters && (
|
||||
<Tag color="green" style={{ marginLeft: 8, fontSize: '10px' }}>
|
||||
{frontend._changes.filters.old ? 'CHANGED' : 'NEW'}
|
||||
</Tag>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<MultiLineDiffRenderer
|
||||
value={frontend.filters}
|
||||
changeInfo={frontend._changes?.filters}
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
{/* Issue #38: frontend log-format */}
|
||||
{frontend.log_format && (
|
||||
<Descriptions.Item
|
||||
label={
|
||||
<span>
|
||||
Log Format
|
||||
{frontend._changes?.log_format && (
|
||||
<Tag color="green" style={{ marginLeft: 8, fontSize: '10px' }}>
|
||||
{frontend._changes.log_format.old ? 'CHANGED' : 'NEW'}
|
||||
</Tag>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<MultiLineDiffRenderer
|
||||
value={frontend.log_format}
|
||||
changeInfo={frontend._changes?.log_format}
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
{frontend.acl_rules && frontend.acl_rules.length > 0 && (
|
||||
<Descriptions.Item label={`ACL Rules (${frontend.acl_rules.length})`}>
|
||||
{frontend.acl_rules.map((acl, idx) => (
|
||||
|
||||
@@ -642,7 +642,11 @@ const FrontendManagement = () => {
|
||||
// Explicitly set options field to handle null/undefined case (NEW field)
|
||||
options: frontend.options || '',
|
||||
// BUGFIX: Explicitly set tcp_request_rules field to handle null/undefined case
|
||||
tcp_request_rules: frontend.tcp_request_rules || ''
|
||||
tcp_request_rules: frontend.tcp_request_rules || '',
|
||||
// Issue #38: SPOE filters + frontend log-format (null → '' so the
|
||||
// TextAreas populate on edit and round-trip on save, preventing null-wipe)
|
||||
log_format: frontend.log_format || '',
|
||||
filters: frontend.filters || ''
|
||||
});
|
||||
|
||||
// Update SSL field visibility after setting values
|
||||
@@ -2250,6 +2254,40 @@ tcp-request connection reject if { src -f /etc/haproxy/blacklist.lst }`}
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Issue #38: SPOE filters + frontend log-format */}
|
||||
<Row gutter={16}>
|
||||
<Col span={24}>
|
||||
<Form.Item
|
||||
name="filters"
|
||||
label="Filters (SPOE / WAF)"
|
||||
extra="HAProxy filter directives (one per line). Emitted before send-spoe-group rules."
|
||||
tooltip="e.g. Coraza WAF via SPOE. The referenced engine config file and its SPOA backend must exist on the HAProxy host."
|
||||
>
|
||||
<TextArea
|
||||
rows={3}
|
||||
placeholder={`Examples:
|
||||
filter spoe engine coraza config /etc/haproxy/coraza.cfg`}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={24}>
|
||||
<Form.Item
|
||||
name="log_format"
|
||||
label="Custom Log Format"
|
||||
extra="HAProxy log-format / log-format-sd directive (kept verbatim)"
|
||||
tooltip="Overrides option httplog/tcplog. Use the full directive including the quoted format string."
|
||||
>
|
||||
<TextArea
|
||||
rows={3}
|
||||
placeholder={'log-format "%ci:%cp [%t] %ft %b/%s %ST %B %{+Q}r"'}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Panel>
|
||||
</Collapse>
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"version": "1.8.2",
|
||||
"releaseName": "ACME nonce fix (ZeroSSL registration)",
|
||||
"releaseDate": "2026-06-25"
|
||||
}
|
||||
Reference in New Issue
Block a user