Compare commits

...

7 Commits

Author SHA1 Message Date
taylanbakircioglu c79391cd13 feat(acl): accept HAProxy -f pattern-file references with advisory warnings (v1.8.9, Issue #38)
The manual Frontend editor, wizard and visual ACL builder hard-rejected the ACL
`-f <file>` flag while bulk import accepted it. Worse, a frontend imported with
an `-f` ACL could not be edited at all (422) until the ACL was dropped.

The original guard predated the fail-safe apply flow: the agent runs `haproxy -c`
before every reload, so a missing pattern file is rejected safely and the previous
config keeps running. Pattern files are operator-managed host files — the same
policy adopted for SPOE filter configs in v1.8.8.

- models: remove the 5 `-f` hard rejects (frontend acl/redirect/use_backend
  validators + wizard string/dict-redirect guards); `$(`/backtick and X!X
  contradiction guards unchanged
- routers/frontend: `_pattern_file_warnings` helper; non-blocking warning on
  create + update responses listing referenced pattern files (empty when no
  rule uses `-f` — zero noise)
- routers/config: bulk-import preview advisory listing pattern files per
  frontend (cluster config-dir aware, next to the SPOE advisories)
- React: remove the FrontendManagement submit gate and SiteWizard step gate;
  ACLRuleBuilder renders informational notes instead of errors and re-adds
  `-f (pattern file on host)` to the flag dropdown; create path now renders
  server warnings like update
- tests: 4 reject-pins inverted to accept-pins; new test_acl_pattern_file_allow.py
  (accept/guards-kept/zero-noise/advisory); full suite green (1094 passed)
2026-07-14 00:27:11 +03:00
taylanbakircioglu 9e2ea04777 feat(haproxy): preserve SPOE filter + frontend log-format on import/edit (v1.8.8, Issue #38)
Bulk import / manual edit silently dropped `filter spoe engine ...` (Coraza WAF)
and frontend `log-format` because the parser recognised only a fixed directive
set. The regenerated config then missed the SPOE engine, so HAProxy failed with
"unable to find SPOE engine 'coraza' used by the send-spoe-group".

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Addresses #31.
2026-06-27 13:49:59 +03:00
36 changed files with 1200 additions and 368 deletions
+9
View File
@@ -59,6 +59,15 @@ AGENT_HEARTBEAT_TIMEOUT_SECONDS=15
# Config sync interval in seconds
AGENT_CONFIG_SYNC_INTERVAL_SECONDS=30
# ============================================================================
# BACKEND PERFORMANCE
# ============================================================================
# Number of uvicorn worker processes for the backend API (default: 1).
# On multi-core hosts, setting this to the core count (e.g. 2) lets the API
# use all cores. Safe to increase: background tasks are multi-replica safe
# (the k8s deployment already runs 2+ replicas via HPA).
UVICORN_WORKERS=1
# ============================================================================
# CORS CONFIGURATION
# ============================================================================
+7 -17
View File
@@ -21,27 +21,17 @@ jobs:
- name: read product version
id: prodversion
run: |
VERSION=$(jq -r .version version.json)
VERSION=$(jq -r .version backend/version.json)
if [ -z "$VERSION" ] || [ "$VERSION" = "null" ]; then
echo "Failed to read product version from version.json" >&2
echo "Failed to read product version from backend/version.json" >&2
exit 1
fi
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
# The backend image is built with `context: ./backend`, so the
# repo-root version.json is OUTSIDE the build context and never
# reaches the container. Backend `main.py` falls back to a
# compile-time constant when /app/version.json is missing, which
# caused a real production drift: a redeploy of the v1.5.2 tree
# silently still reported "v1.5.0" in `/api/version` because the
# constant in main.py had been bumped but the file was not
# available to read. Stage version.json into the backend
# context here so the canonical file IS shipped and the
# constant only serves as a defensive fallback. The staged file
# is gitignored to keep `git status` clean for developers.
- name: stage version.json into backend build context
run: cp version.json backend/version.json
# version.json now lives at backend/version.json (inside the ./backend build
# context), so `COPY . .` bakes it into the image directly — no staging step
# is needed and the backend reports the correct version in every deployment,
# not just this workflow's builds.
- name: set up qemu
uses: docker/setup-qemu-action@v3
@@ -91,7 +81,7 @@ jobs:
run: |
VERSION="${{ steps.prodversion.outputs.VERSION }}"
TAG="v${VERSION}"
RELEASE_NAME=$(jq -r '.releaseName // empty' version.json)
RELEASE_NAME=$(jq -r '.releaseName // empty' backend/version.json)
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "Release $TAG already exists, skipping."
else
-5
View File
@@ -39,11 +39,6 @@ venv.bak/
*.sqlite
*.sqlite3
# Build-time staged version.json (CI `cp version.json backend/`).
# The canonical file lives at repo root; this path is a transient
# copy for the backend Docker build context.
backend/version.json
# IDE
.vscode/
.idea/
+18 -1
View File
@@ -1738,6 +1738,19 @@ Add new HAProxy clusters through the web interface or directly via API:
}
```
### Performance Tuning *(v1.8.6)*
The backend API defaults to a **single uvicorn worker process**, which uses one CPU core. Agents poll the API every 30 seconds (heartbeat, config, pending-requests, upgrade checks), so larger fleets add a constant baseline load. Two ways to scale:
- **Docker Compose — worker processes**: set `UVICORN_WORKERS` in your `.env` (default `1`). On a multi-core host, matching the core count (e.g. `UVICORN_WORKERS=2` on a 2-core machine) lets the API use all cores:
```bash
echo "UVICORN_WORKERS=2" >> .env && docker-compose up -d backend
```
- **Kubernetes/OpenShift — replicas**: the shipped manifests already include an HPA for the backend (2→10 replicas, `k8s/manifests/13-hpa.yaml`); raise `minReplicas`/`maxReplicas` as needed.
Both are safe: all background tasks (ACME completion, renewals, agent monitoring) are multi-replica safe by design (atomic claims via `FOR UPDATE SKIP LOCKED`, PostgreSQL advisory locks).
**Diagnosing slow requests**: every API response carries an `X-Response-Time` header, and the backend logs `Slow request detected` (WARNING) for any request taking longer than 1 second — check those log lines to pinpoint slow endpoints before tuning anything else.
## API Reference
@@ -2069,7 +2082,7 @@ haproxy-openmanager/
├── docker-compose.yml # Docker Compose configuration
├── docker-compose.localtest.yml # Local development/testing overrides
├── docker-compose.test.yml # Test environment
├── version.json # Application version metadata
├── backend/version.json # Application version metadata (single source of truth)
├── build-images.sh # Build Docker images
├── pytest.ini # Pytest configuration
├── README.md # This file
@@ -2415,6 +2428,10 @@ 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.
+10 -2
View File
@@ -37,5 +37,13 @@ USER appuser
# Expose port
EXPOSE 8000
# Run the application in production mode (without --reload)
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
# Run the application in production mode (without --reload).
# UVICORN_WORKERS (default 1) opts into multiple worker processes on multi-core
# hosts; with 1 worker uvicorn runs in-process, identical to the flagless CMD
# this replaces. Falls back to WEB_CONCURRENCY when UVICORN_WORKERS is unset
# because flagless uvicorn honored WEB_CONCURRENCY (uvicorn config.py) — this
# keeps any deployment that relied on it byte-for-byte compatible. Background
# tasks are multi-replica safe (FOR UPDATE SKIP LOCKED / advisory locks), as
# already exercised by the k8s HPA deployment. `exec` keeps uvicorn as PID 1
# so signal handling is unchanged.
CMD ["sh", "-c", "exec uvicorn main:app --host 0.0.0.0 --port 8000 --workers ${UVICORN_WORKERS:-${WEB_CONCURRENCY:-1}}"]
+14 -2
View File
@@ -194,7 +194,14 @@ async def ensure_agents_table():
'use_backend_rules': "ALTER TABLE frontends ADD COLUMN use_backend_rules JSONB DEFAULT '[]'::jsonb;",
'request_headers': "ALTER TABLE frontends ADD COLUMN request_headers TEXT;",
'response_headers': "ALTER TABLE frontends ADD COLUMN response_headers TEXT;",
'maxconn': "ALTER TABLE frontends ADD COLUMN maxconn INTEGER;"
'maxconn': "ALTER TABLE frontends ADD COLUMN maxconn INTEGER;",
# Issue #38: SPOE filter directives (e.g. Coraza WAF) and frontend
# log-format were silently dropped on bulk-import / manual edit
# because the parser recognised only a fixed set of directives.
# These nullable TEXT columns persist them verbatim (multi-line for
# `filters`), mirroring the request_headers/options passthrough.
'log_format': "ALTER TABLE frontends ADD COLUMN log_format TEXT;",
'filters': "ALTER TABLE frontends ADD COLUMN filters TEXT;"
}
for col, query in frontend_columns.items():
@@ -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
View File
@@ -8,9 +8,13 @@ import redis
import asyncio
from datetime import datetime, timedelta
# Build/deploy marker for the v1.8.x (Issue #35, DNS-01) rollout — ensures the pipeline ships this commit's image.
_version_info = {"version": "1.8.3", "releaseName": "Agent heartbeat JSON fix", "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
)
)
)
)
+28 -45
View File
@@ -85,6 +85,11 @@ class FrontendConfig(BaseModel):
response_headers: Optional[str] = None
options: Optional[str] = None
tcp_request_rules: Optional[str] = None
# Issue #38: SPOE filter directives (Coraza WAF etc.) + frontend log-format.
# Passthrough TEXT (no validator) — SPOE `filter ... config <path>` legitimately
# references an operator-managed file, so the ACL `-f` guard must NOT apply here.
log_format: Optional[str] = None
filters: Optional[str] = None
timeout_client: Optional[int] = None
timeout_http_request: Optional[int] = None
rate_limit: Optional[int] = None
@@ -451,26 +456,17 @@ class FrontendConfig(BaseModel):
if any(dangerous in rule.lower() for dangerous in ['$(', '`']):
raise ValueError(f'ACL rule contains potentially dangerous content: "{rule}"')
# Phase K Phase D follow-up (Bulgu #12 round 3) — reject
# the HAProxy `-f <file>` pattern-file flag here too so the
# manual Frontend API mirrors the wizard's parity rule.
# HAProxy OpenManager does not provision pattern files
# onto the HAProxy node filesystem, so any `-f /path/...`
# reference will fail HAProxy's `-c` parse at apply time
# with "failed to open pattern file". Reject up-front so
# operators get the same actionable error from both the
# manual page and the wizard.
if re.search(r"(^|\s)-f(\s|$)", rule):
raise ValueError(
f'ACL rule "{rule}" uses the HAProxy `-f <file>` '
"pattern-file flag, which is not supported in "
"HAProxy OpenManager: the product does not "
"provision pattern files onto the HAProxy node "
"filesystem, so the reference would fail at "
"reload time. Use inline values instead "
"(e.g. `src 10.0.0.0/24` rather than "
"`src -f /etc/haproxy/admins.lst`)."
)
# Issue #38 follow-up — the `-f <file>` pattern-file flag
# is ACCEPTED here (the Bulgu #12 hard reject was removed).
# Pattern files are operator-managed host files, exactly
# like the SPOE `filter ... config <path>` reference this
# release started preserving: bulk import always accepted
# `-f`, the free-form fields (request_headers,
# tcp_request_rules) always accepted it, and the agent
# runs `haproxy -c` before every reload so a missing file
# fails safely (previous config keeps running). The route
# handlers surface a non-blocking warning listing the
# referenced pattern files instead.
validated_rules.append(rule)
@@ -510,20 +506,12 @@ class FrontendConfig(BaseModel):
if not any(rule.startswith(redirect_type) for redirect_type in valid_redirects):
raise ValueError(f'Invalid redirect rule: "{rule}". Must start with: location, prefix, or scheme.')
# Phase K Phase D follow-up (Bulgu #12 round 3) —
# mirror the wizard's `-f <file>` guard here. The
# `X !X` contradiction check used to live alongside
# this guard, but Bulgu #62 (round-22 audit) moved
# it into the route handler so updates can grandfather
# legacy rules created before the contradiction guard
# landed. See `routers/frontend.py::_collect_routing_rule_contradictions`.
if re.search(r"(^|\s)-f(\s|$)", rule):
raise ValueError(
f'Redirect rule "{rule}" uses the HAProxy `-f <file>` '
"pattern-file flag, which is not supported in "
"HAProxy OpenManager: the product does not provision "
"pattern files onto the HAProxy node filesystem."
)
# Issue #38 follow-up — `-f <file>` pattern-file references
# are ACCEPTED (Bulgu #12 hard reject removed; see
# validate_acl_rules for the full rationale). The `X !X`
# contradiction check lives in the route handler
# (`routers/frontend.py::_collect_routing_rule_contradictions`,
# Bulgu #62) and is unchanged.
validated_rules.append(rule)
@@ -531,9 +519,12 @@ class FrontendConfig(BaseModel):
@validator('use_backend_rules')
def validate_use_backend_rules_syntax(cls, v):
"""Phase K Phase D follow-up (Bulgu #12 round 3) — manual
Frontend API parity guard: reject `-f <file>` references
and dangerous shell patterns.
"""Manual Frontend API guard for dangerous shell patterns.
Issue #38 follow-up — the Bulgu #12 `-f <file>` hard reject
was removed (see validate_acl_rules for the rationale);
pattern-file references are operator-managed host files and
are surfaced as non-blocking warnings by the route handlers.
Bulgu #62 (round-22 audit) — the `X !X` contradiction check
previously lived here but moved into the route handler so
@@ -563,13 +554,5 @@ class FrontendConfig(BaseModel):
f'use_backend rule contains potentially dangerous '
f'content: "{rule}"'
)
if re.search(r"(^|\s)-f(\s|$)", rule):
raise ValueError(
f'use_backend rule "{rule}" uses the HAProxy '
"`-f <file>` pattern-file flag, which is not "
"supported in HAProxy OpenManager: the product "
"does not provision pattern files onto the HAProxy "
"node filesystem."
)
validated_rules.append(rule)
return validated_rules
+21 -51
View File
@@ -179,35 +179,17 @@ _MAX_RULE_STRING_LEN = 4096
# attempts.
_DANGEROUS_RULE_PATTERNS = ("$(", "`")
# Phase K Phase D follow-up (Bulgu #12 round 3) — the HAProxy `-f
# <file>` ACL/condition flag instructs HAProxy to load match patterns
# from a server-side file at parse time. HAProxy OpenManager is a
# fully-managed product: we do NOT provision pattern files onto the
# HAProxy node's filesystem, and operators have no UI to upload one.
# A `-f /some/path` reference therefore ALWAYS resolves to
# "file not found" when HAProxy's real `-c` parse runs at apply
# time, producing exactly the operator-reported failure mode:
# [ALERT] parsing ACL 'acl1' : failed to open pattern file </path>.
# [ALERT] parsing switching rule : no such ACL : 'acl1'.
#
# Surface this BEFORE persist by rejecting `-f` in any rule string
# that comes through the wizard / manual frontend API. Reject ALL
# variants (` -f `, leading `-f `, trailing `... -f`) defensively so
# operators cannot slip the flag through with creative spacing.
# The check is anchored to ACL/condition rule strings only; raw
# HAProxy snippet fields (tcp_request_rules, request_headers, ...)
# are NOT touched because those are inherently free-form and
# advanced operators may legitimately reference pre-provisioned
# pattern files there.
_ACL_FILE_FLAG_PATTERN = re.compile(r"(^|\s)-f(\s|$)")
_ACL_FILE_FLAG_MESSAGE = (
"pattern-file references with '-f <file>' are not supported in ACL / "
"use_backend / redirect rules: HAProxy OpenManager does not provision "
"pattern files onto the HAProxy node's filesystem, so the reference "
"would always fail at HAProxy reload time. Use inline values "
"instead (e.g. `acl is_admin src 10.0.0.0/24` rather than "
"`acl is_admin src -f /etc/haproxy/admins.lst`)."
)
# Issue #38 follow-up — the HAProxy `-f <file>` ACL/condition flag
# loads match patterns from a file on the HAProxy host. The Bulgu #12
# hard reject (`_ACL_FILE_FLAG_PATTERN`/`_ACL_FILE_FLAG_MESSAGE`) was
# removed: pattern files are operator-managed host files (exactly like
# the SPOE `filter ... config <path>` reference preserved since
# v1.8.8), bulk import and the free-form fields (tcp_request_rules,
# request_headers) always accepted them, and the agent runs
# `haproxy -c` before every reload so a missing file fails safely
# (the previous config keeps running). The manual frontend route
# handlers emit a non-blocking warning listing referenced pattern
# files (`routers/frontend.py::_pattern_file_warnings`).
# Phase K Phase D follow-up (Bulgu #13) — detect a routing /
# redirect rule whose condition references the SAME ACL in both
@@ -305,13 +287,10 @@ def _validate_haproxy_directive_string(
f"{field_label} entry contains potentially dangerous content: "
f"{pattern!r}"
)
# Phase K Phase D follow-up (Bulgu #12 round 3) — reject the
# HAProxy `-f <file>` pattern-file flag because OpenManager does
# not manage the HAProxy node filesystem. See the module-level
# `_ACL_FILE_FLAG_PATTERN` docstring for the full operator-
# reported failure mode this guards against.
if _ACL_FILE_FLAG_PATTERN.search(stripped):
raise ValueError(f"{field_label}: {_ACL_FILE_FLAG_MESSAGE}")
# Issue #38 follow-up — `-f <file>` pattern-file references are
# ACCEPTED (Bulgu #12 hard reject removed; see the module-level
# `_ACL_FILE_FLAG_PATTERN` comment). The route handlers surface
# a non-blocking pattern-file warning instead.
# Phase K Phase D follow-up (Bulgu #13) — for routing /
# redirect rules (not ACL definitions themselves), reject a
# condition that contains the same ACL in both positive and
@@ -1083,21 +1062,12 @@ class FrontendStep(BaseModel):
normalised: List[Union[str, dict]] = []
for el in v:
if isinstance(el, dict):
# Phase K Phase D follow-up (Bulgu #12 round 3
# extension) — dict-shaped redirect rules emit their
# `condition` / `target` fields VERBATIM into the
# rendered HAProxy directive. A dict with
# `condition: "if { src -f /etc/haproxy/x.lst }"`
# would slip past the string-only validator above
# and trigger the same operator-reported "failed to
# open pattern file" rejection at apply time. Reject
# `-f` in any string-shaped value the dict carries.
for field_name in ("condition", "target", "type"):
val = el.get(field_name)
if isinstance(val, str) and _ACL_FILE_FLAG_PATTERN.search(val):
raise ValueError(
f"redirect_rules.{field_name}: {_ACL_FILE_FLAG_MESSAGE}"
)
# Issue #38 follow-up — dict-shaped redirect rules may
# carry `-f <file>` pattern-file references in their
# `condition`/`target` values; these are ACCEPTED now
# (Bulgu #12 hard reject removed — operator-managed
# host files, fail-safe apply; see module-level
# `_ACL_FILE_FLAG_PATTERN` comment).
# Bulgu #13 extension — same contradiction guard
# for dict-shaped redirect conditions.
cond_val = el.get("condition")
+7 -3
View File
@@ -1695,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'
+12 -9
View File
@@ -3696,17 +3696,19 @@ async def confirm_restore_config_version(
# UPDATE existing frontend (ALL 8 parsed fields)
# CRITICAL FIX: Include maxconn and timeout_client so UI shows restored values
await conn.execute("""
UPDATE frontends
SET bind_address = $1, bind_port = $2, default_backend = $3,
UPDATE frontends
SET bind_address = $1, bind_port = $2, default_backend = $3,
mode = $4, ssl_enabled = $5, ssl_port = $6,
maxconn = $7, timeout_client = $8,
log_format = $11, filters = $12,
updated_at = CURRENT_TIMESTAMP, last_config_status = 'PENDING'
WHERE id = $9 AND cluster_id = $10
""",
""",
parsed_fe.bind_address, parsed_fe.bind_port, parsed_fe.default_backend,
parsed_fe.mode, parsed_fe.ssl_enabled, parsed_fe.ssl_port,
parsed_fe.maxconn, parsed_fe.timeout_client,
fe_id, cluster_id
fe_id, cluster_id,
parsed_fe.log_format, parsed_fe.filters # Issue #38
)
changes_summary["frontends_updated"] += 1
logger.info(f"RESTORE: Updated frontend '{parsed_fe.name}' (SSL: {parsed_fe.ssl_enabled}, maxconn: {parsed_fe.maxconn})")
@@ -3714,16 +3716,17 @@ async def confirm_restore_config_version(
# CREATE new frontend (ALL 8 parsed fields)
# CRITICAL FIX: Include maxconn and timeout_client so UI shows restored values
await conn.execute("""
INSERT INTO frontends
INSERT INTO frontends
(name, bind_address, bind_port, default_backend, mode, ssl_enabled, ssl_port,
maxconn, timeout_client,
cluster_id, is_active, last_config_status, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, TRUE, 'PENDING', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""",
cluster_id, log_format, filters, is_active, last_config_status, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, TRUE, 'PENDING', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""",
parsed_fe.name, parsed_fe.bind_address, parsed_fe.bind_port,
parsed_fe.default_backend, parsed_fe.mode, parsed_fe.ssl_enabled, parsed_fe.ssl_port,
parsed_fe.maxconn, parsed_fe.timeout_client,
cluster_id
cluster_id,
parsed_fe.log_format, parsed_fe.filters # Issue #38
)
changes_summary["frontends_created"] += 1
logger.info(f"RESTORE: Created frontend '{parsed_fe.name}' (SSL: {parsed_fe.ssl_enabled}, maxconn: {parsed_fe.maxconn})")
+96 -7
View File
@@ -855,6 +855,9 @@ async def parse_bulk_config(
"response_headers": frontend.response_headers,
"options": frontend.options,
"tcp_request_rules": frontend.tcp_request_rules,
# Issue #38: SPOE filters + frontend log-format
"log_format": frontend.log_format,
"filters": frontend.filters,
# CRITICAL: SSL Advanced Options (parsed from bind directive)
"ssl_alpn": frontend.ssl_alpn,
"ssl_npn": frontend.ssl_npn,
@@ -1089,7 +1092,69 @@ 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."
)
# Issue #38 follow-up: ACL `-f <file>` pattern-file advisory.
# Scan only the structured rule fields (acl/use_backend) —
# request_headers/tcp_request_rules were always free-form and
# warning on them now would add new noise for existing users.
_pattern_paths = []
for _rule in (_fe.get("acl_rules") or []) + (_fe.get("use_backend_rules") or []):
if isinstance(_rule, str):
_pattern_paths.extend(
re.findall(r"(?:^|\s)-f\s+(\S+)", _rule))
if _pattern_paths:
_uniq = sorted(set(_pattern_paths))
enhanced_warnings.append(
f"ℹ️ Frontend '{_fe['name']}': ACL/routing rules reference pattern "
f"file(s) {', '.join(_uniq)}. Each file must exist at that exact path "
f"on every HAProxy host in the cluster (cluster config dir: {_cfg_dir}) "
f"— HAProxy OpenManager does not create or distribute pattern files. "
f"A missing file fails safely at 'haproxy -c' (previous config keeps "
f"running)."
)
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 +1215,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 +2169,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 +2300,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 +2344,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({
+69 -12
View File
@@ -117,6 +117,41 @@ def _rule_contradiction_text(rule: Any) -> Optional[str]:
return None
def _pattern_file_warnings(
acl_rules: Optional[List[Any]] = None,
use_backend_rules: Optional[List[Any]] = None,
redirect_rules: Optional[List[Any]] = None,
) -> List[str]:
"""Issue #38 follow-up — non-blocking `-f <file>` pattern-file
advisory for the manual frontend API.
The Bulgu #12 hard reject was removed from the Pydantic models:
pattern files are operator-managed host files (same policy as the
SPOE `filter ... config <path>` reference preserved since v1.8.8)
and the agent's pre-reload `haproxy -c` makes a missing file fail
safely. This helper returns one warning listing the unique file
paths referenced across the rule fields, or [] when no rule uses
`-f` — operators who don't use pattern files see no change.
"""
paths: List[str] = []
for rules in (acl_rules, use_backend_rules, redirect_rules):
for rule in rules or []:
text = rule if isinstance(rule, str) else (
rule.get("condition") if isinstance(rule, dict) else None)
if isinstance(text, str):
paths.extend(re.findall(r"(?:^|\s)-f\s+(\S+)", text))
if not paths:
return []
uniq = sorted(set(paths))
return [
f"ACL/routing rules reference pattern file(s) {', '.join(uniq)}. "
f"Each file must exist at that exact path on every HAProxy host "
f"in the cluster — HAProxy OpenManager does not create or "
f"distribute pattern files. A missing file fails safely at "
f"'haproxy -c' (the previous config keeps running)."
]
def _collect_routing_rule_contradictions(
rules: List[Any], origin_label: str,
) -> List[Tuple[str, Any]]:
@@ -408,6 +443,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 +460,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 +490,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 +503,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 +519,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 +532,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 +642,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 +778,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 = []
@@ -825,12 +868,19 @@ async def create_frontend(frontend: FrontendConfig, request: Request, authorizat
user_agent=request.headers.get('user-agent')
)
return {
response: dict = {
"message": f"Frontend '{frontend.name}' created successfully",
"id": frontend_id,
"frontend": frontend.dict(),
"sync_results": sync_results
}
# Issue #38 follow-up — non-blocking pattern-file advisory
# (additive field; absent when no rule references `-f`).
pattern_warnings = _pattern_file_warnings(
frontend.acl_rules, frontend.use_backend_rules, frontend.redirect_rules)
if pattern_warnings:
response["warnings"] = pattern_warnings
return response
except HTTPException:
raise
except Exception as e:
@@ -1060,9 +1110,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 +1121,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 +1175,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,
@@ -1235,8 +1288,12 @@ async def update_frontend(frontend_id: int, frontend: FrontendConfig, request: R
# yellow toast on the next refresh. The save SUCCEEDED; the
# warnings only flag latent legacy data the operator may
# want to clean up at their convenience.
if contradiction_warnings:
response["warnings"] = contradiction_warnings
# Issue #38 follow-up — append the pattern-file advisory to
# the same list (additive; empty when no rule uses `-f`).
all_warnings = list(contradiction_warnings or []) + _pattern_file_warnings(
frontend.acl_rules, frontend.use_backend_rules, frontend.redirect_rules)
if all_warnings:
response["warnings"] = all_warnings
return response
except HTTPException:
raise
+4 -2
View File
@@ -56,14 +56,14 @@ async def create_frontend_row(
acl_rules, redirect_rules, use_backend_rules,
request_headers, response_headers, options, tcp_request_rules, timeout_client, timeout_http_request,
rate_limit, compression, log_separate, monitor_uri,
cluster_id, maxconn, updated_at
cluster_id, maxconn, log_format, filters, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12,
$13, $14, $15, $16, $17, $18, $19,
$20, $21, $22,
$23, $24, $25, $26, $27, $28,
$29, $30, $31, $32,
$33, $34, CURRENT_TIMESTAMP
$33, $34, $35, $36, CURRENT_TIMESTAMP
)
RETURNING id
""",
@@ -101,6 +101,8 @@ async def create_frontend_row(
getattr(payload, "monitor_uri", None),
cluster_id,
getattr(payload, "maxconn", None),
getattr(payload, "log_format", None), # Issue #38
getattr(payload, "filters", None), # Issue #38
)
if mark_pending:
+20 -1
View File
@@ -393,6 +393,12 @@ def _categorize_haproxy_directive(line: str) -> str:
return "prelude"
if s.startswith("acl "):
return "acl"
# Issue #38: SPOE (and other) `filter` directives must be declared BEFORE
# the `http-request send-spoe-group` rules that use them, otherwise HAProxy
# fails with "unable to find SPOE engine". Own bucket, flushed right after
# `prelude` and before tcp_req/acl/http_req (see flush order below).
if s.startswith("filter "):
return "filter"
if s.startswith("stick-table") or s.startswith("stick "):
return "stick"
if s.startswith("tcp-request"):
@@ -414,6 +420,7 @@ def _categorize_haproxy_directive(line: str) -> str:
or s.startswith("compression ")
or s.startswith("monitor-uri")
or s.startswith("log ")
or s.startswith("log-format") # Issue #38: log-format / log-format-sd
or s.startswith("description ")
or s.startswith("disabled")
or s.startswith("enabled")
@@ -903,7 +910,7 @@ async def generate_haproxy_config_for_cluster(cluster_id: int, conn: Optional[An
# "stick-table already declared").
# ─────────────────────────────────────────────────────────────────
_fe_buckets: Dict[str, List[str]] = {
"prelude": [], "stick": [], "tcp_req": [],
"prelude": [], "filter": [], "stick": [], "tcp_req": [],
"acl": [], "http_req": [], "http_resp": [],
"redirect": [], "use_be": [], "default_be": [],
}
@@ -996,6 +1003,17 @@ async def generate_haproxy_config_for_cluster(cluster_id: int, conn: Optional[An
if line_stripped and line_stripped not in ('[]', '{}', 'null', 'None'):
_emit_fe(f" {line_stripped}")
# Issue #38: emit frontend log-format and SPOE (etc.) filter directives.
# `log_format` routes to the `prelude` bucket, `filters` to the `filter`
# bucket (both via _emit_fe → _categorize_haproxy_directive), guaranteeing
# `filter ...` is rendered before the `http-request send-spoe-group` rules.
for _fld in ('log_format', 'filters'):
if frontend.get(_fld):
for line in frontend[_fld].split('\n'):
line_stripped = line.strip()
if line_stripped and line_stripped not in ('[]', '{}', 'null', 'None'):
_emit_fe(f" {line_stripped}")
# CRITICAL: Validate frontend-backend mode compatibility
if frontend.get('default_backend'):
default_backend_name = frontend['default_backend'].strip() if frontend['default_backend'] else ''
@@ -1223,6 +1241,7 @@ async def generate_haproxy_config_for_cluster(cluster_id: int, conn: Optional[An
# ─────────────────────────────────────────────────────────────
for _bucket_key in (
"prelude",
"filter",
"stick",
"tcp_req",
"acl",
@@ -0,0 +1,190 @@
"""Issue #38 follow-up — ACL `-f <file>` pattern-file support (v1.8.9).
The Bulgu #12 hard rejects were removed: pattern files are
operator-managed host files (same policy as the SPOE
`filter ... config <path>` reference preserved since v1.8.8), bulk
import always accepted `-f`, and the agent runs `haproxy -c` before
every reload so a missing file fails safely. These tests pin:
1. ACCEPT — the manual FrontendConfig model and the wizard models
accept `-f` in every rule field (string + dict shapes).
2. GUARDS KEPT — `$(`/backtick shell-substitution rejects and the
`X !X` contradiction machinery are unchanged.
3. WARNINGS — `_pattern_file_warnings` emits exactly one advisory
listing the referenced files, and NOTHING for `-f`-free rules
(zero-noise: existing users see no new output).
4. ADVISORY — the bulk-import preview advisory block scans
acl/use_backend rules (and only those fields).
"""
import re
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from models.frontend import FrontendConfig # noqa: E402
from routers.frontend import _pattern_file_warnings # noqa: E402
ACL_F = "blacklisted src -f /etc/haproxy/blacklist.lst"
UB_F = "be-secure if { src -f /etc/haproxy/allowlist.lst }"
REDIR_F = "location /blocked if { src -f /etc/haproxy/blacklist.lst }"
# ──────────────────────────────────────────────────────────────────────
# 1. ACCEPT — manual FrontendConfig model
# ──────────────────────────────────────────────────────────────────────
def test_frontend_config_accepts_acl_file_flag():
fe = FrontendConfig(name="fe1", bind_port=80, mode="http", acl_rules=[ACL_F])
assert fe.acl_rules == [ACL_F]
def test_frontend_config_accepts_use_backend_file_flag():
fe = FrontendConfig(
name="fe1", bind_port=80, mode="http", use_backend_rules=[UB_F])
assert fe.use_backend_rules == [UB_F]
def test_frontend_config_accepts_redirect_string_file_flag():
fe = FrontendConfig(
name="fe1", bind_port=80, mode="http", redirect_rules=[REDIR_F])
assert fe.redirect_rules == [REDIR_F]
def test_frontend_config_accepts_redirect_dict_file_flag():
rule = {"type": "scheme", "scheme": "https",
"condition": "if { src -f /etc/haproxy/blacklist.lst }"}
fe = FrontendConfig(
name="fe1", bind_port=80, mode="http", redirect_rules=[rule])
assert fe.redirect_rules == [rule]
# ──────────────────────────────────────────────────────────────────────
# 2. GUARDS KEPT — dangerous-content rejects unchanged
# ──────────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("bad_rule", [
"acl1 path $(rm -rf /)",
"acl1 path `id`",
])
def test_acl_shell_substitution_still_rejected(bad_rule):
from pydantic import ValidationError
with pytest.raises(ValidationError):
FrontendConfig(name="fe1", bind_port=80, mode="http", acl_rules=[bad_rule])
@pytest.mark.parametrize("bad_rule", [
"be1 if $(whoami)",
"be1 if `id`",
])
def test_use_backend_shell_substitution_still_rejected(bad_rule):
from pydantic import ValidationError
with pytest.raises(ValidationError):
FrontendConfig(
name="fe1", bind_port=80, mode="http", use_backend_rules=[bad_rule])
def test_contradiction_detection_still_works_on_file_flag_rules():
"""Interaction guard: a `-f` rule with an `X !X` contradiction is
still caught by the handler-level contradiction machinery — the
`-f` relaxation must not weaken that gate."""
from models.frontend import _frontend_has_acl_contradiction
assert _frontend_has_acl_contradiction(
"be1 if blacklisted !blacklisted") is True
# And a normal -f rule is NOT a contradiction.
assert _frontend_has_acl_contradiction(UB_F) is False
# ──────────────────────────────────────────────────────────────────────
# 3. WARNINGS — _pattern_file_warnings (zero-noise contract)
# ──────────────────────────────────────────────────────────────────────
def test_pattern_file_warnings_lists_unique_paths():
warnings = _pattern_file_warnings(
acl_rules=[ACL_F, "other src -f /etc/haproxy/blacklist.lst"],
use_backend_rules=[UB_F],
redirect_rules=[{"condition": "if { src -f /etc/haproxy/geo.lst }"}],
)
assert len(warnings) == 1
w = warnings[0]
assert "/etc/haproxy/blacklist.lst" in w
assert "/etc/haproxy/allowlist.lst" in w
assert "/etc/haproxy/geo.lst" in w
# Duplicate path listed once.
assert w.count("/etc/haproxy/blacklist.lst") == 1
# Non-blocking framing: mentions fail-safe haproxy -c.
assert "haproxy -c" in w
def test_pattern_file_warnings_empty_without_file_flag():
"""Zero-noise: operators who don't use `-f` must see NO warning."""
assert _pattern_file_warnings(
acl_rules=["is_api path_beg /api", "is_admin src 10.0.0.0/24"],
use_backend_rules=["be-api if is_api"],
redirect_rules=[{"type": "scheme", "scheme": "https",
"condition": "if !{ ssl_fc }"}],
) == []
assert _pattern_file_warnings() == []
def test_pattern_file_warnings_ignores_dash_f_substrings():
"""`-file`/`-foo` substrings must not trigger the advisory."""
assert _pattern_file_warnings(
acl_rules=["is_self path_beg /self-config-file",
"is_foo path_beg /foo -m beg"],
) == []
# ──────────────────────────────────────────────────────────────────────
# 4. Wizard models accept `-f` (string + dict) — parity
# ──────────────────────────────────────────────────────────────────────
def test_wizard_models_accept_file_flag():
from models.site_wizard import FrontendStep
fe = FrontendStep(
name="fe1", mode="http", bind_address="*", bind_port=80,
acl_rules=[ACL_F],
use_backend_rules=["be-x if blacklisted"],
redirect_rules=[{"type": "scheme", "target": "https",
"condition": "if { src -f /etc/haproxy/x.lst }"}],
)
assert fe.acl_rules == [ACL_F]
assert fe.redirect_rules[0]["condition"] == "if { src -f /etc/haproxy/x.lst }"
# ──────────────────────────────────────────────────────────────────────
# 5. Bulk-import preview advisory — source-level pin
# ──────────────────────────────────────────────────────────────────────
def test_parse_bulk_advisory_scans_only_structured_rule_fields():
"""The preview advisory scans acl_rules/use_backend_rules but NOT
request_headers/tcp_request_rules (always-free-form fields —
warning there would add new noise for existing users)."""
src = Path(__file__).resolve().parents[1] / "routers" / "config.py"
text = src.read_text()
block_start = text.index("pattern-file advisory")
block = text[block_start:block_start + 1200]
assert 'acl_rules' in block
assert 'use_backend_rules' in block
assert 'request_headers' not in block.split("_pattern_paths")[1], (
"advisory must not scan request_headers")
def test_no_dash_f_reject_left_in_models():
"""No model file may still hard-reject the `-f` flag."""
for rel in ("models/frontend.py", "models/site_wizard.py"):
text = (Path(__file__).resolve().parents[1] / rel).read_text()
for m in re.finditer(r"-f\(\\s\|\$\)", text):
ctx = text[max(0, m.start() - 400):m.start() + 400]
assert "raise ValueError" not in ctx, (
f"{rel}: a `-f` reject regex still sits next to a raise")
@@ -44,3 +44,18 @@ def test_b2_guard_precedes_every_fragment_system_info_use():
for name, script in (("linux", LINUX), ("macos", MACOS)):
assert script.count(" $system_info,") >= 1, f"{name}: fragment heartbeat form unexpectedly gone"
assert script.count(_B2_GUARD) == 2, f"{name}: each fragment call site must carry the guard"
def test_cleanup_does_not_self_kill_via_bare_haproxy_agent_pattern():
# Issue #31 (v1.8.4): the pre-installation cleanup kills processes by pgrep -f "$pattern". A bare
# "haproxy-agent" pattern also matches the installer's OWN path (install-haproxy-agent-*.sh) and a
# sudo/PAM ancestor, so the installer killed itself. The kill loop must target ONLY the installed
# agent (binary path + service/label), never the bare string.
for name, script in (("linux", LINUX), ("macos", MACOS)):
assert 'for pattern in "haproxy-agent"' not in script, (
f"{name}: pre-install cleanup uses the bare 'haproxy-agent' kill pattern -> self-kill (issue #31)"
)
# The narrowed, installer-safe pattern must be present (binary path via $INSTALL_DIR).
assert 'for pattern in "$INSTALL_DIR/haproxy-agent"' in script, (
f"{name}: cleanup must match the installed binary path, not a bare substring"
)
+108
View File
@@ -110,3 +110,111 @@ def test_nonce_scoped_per_directory():
assert got == "NONCE_A" # returns THIS CA's nonce
assert svc._nonce_by_dir.get("https://a.example/dir") is None # consumed (single-use)
assert svc._nonce_by_dir.get("https://b.example/dir") == "NONCE_B" # the other CA is untouched
def _sql_paren_depth(sql: str):
"""Parenthesis depth of a SQL string, counting only OUTSIDE '...' literals (with ''
escapes), `--` line comments and /* */ block comments. Single-pass state machine so a
`--` inside a literal or a `'` inside a comment cannot corrupt the count. Dollar-quoted
strings are out of scope (not used in this codebase). Returns (final_depth, min_depth).
"""
depth = 0
min_depth = 0
state = "normal"
i, n = 0, len(sql)
while i < n:
ch = sql[i]
nxt = sql[i + 1] if i + 1 < n else ""
if state == "normal":
if ch == "'":
state = "string"
elif ch == "-" and nxt == "-":
state = "line_comment"
i += 1
elif ch == "/" and nxt == "*":
state = "block_comment"
i += 1
elif ch == "(":
depth += 1
elif ch == ")":
depth -= 1
min_depth = min(min_depth, depth)
elif state == "string":
if ch == "'":
if nxt == "'":
i += 1 # escaped '' stays inside the literal
else:
state = "normal"
elif state == "line_comment":
if ch == "\n":
state = "normal"
else: # block_comment
if ch == "*" and nxt == "/":
state = "normal"
i += 1
i += 1
return depth, min_depth
def test_acme_sql_parentheses_balanced():
"""Issue #35 v1.8.5: the completion task's order-claim query shipped (v1.8.0-v1.8.4) with an
extra closing parenthesis, so EVERY 60s cycle died with `syntax error at or near ")"` and no
background ACME work (claim/finalize/download, DNS-01 publish, wizard-staged promotion,
retry, TXT cleanup) ever ran. The suite never caught it because the DB layer is mocked and
raw SQL never reaches a real parser. This guard scans the ACME modules' SQL string literals
for unbalanced parentheses.
Guard scope is deliberately conservative to avoid false positives on production changes:
keyword matching is case-sensitive (SQL is uppercase in this codebase; prose in docstrings
is not) and f-string fragments are excluded (they split at `{`, so a fragment may be
legitimately unbalanced).
"""
import ast
import re
backend_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
modules = [
"main.py",
os.path.join("services", "dns01_orchestrator.py"),
os.path.join("services", "acme_service.py"),
os.path.join("services", "letsencrypt_service.py"),
os.path.join("routers", "letsencrypt.py"),
os.path.join("routers", "acme_diagnostics.py"),
]
problems = []
for rel in modules:
with open(os.path.join(backend_dir, rel), encoding="utf-8") as fh:
tree = ast.parse(fh.read())
fstring_parts = {
id(const)
for joined in ast.walk(tree) if isinstance(joined, ast.JoinedStr)
for const in ast.walk(joined) if isinstance(const, ast.Constant)
}
for node in ast.walk(tree):
if not (isinstance(node, ast.Constant) and isinstance(node.value, str)):
continue
if id(node) in fstring_parts:
continue
sql = node.value
if not re.search(r"\b(SELECT|INSERT|UPDATE|DELETE)\b", sql):
continue
if not re.search(r"\b(FROM|INTO|SET|WHERE)\b", sql):
continue
depth, min_depth = _sql_paren_depth(sql)
if depth != 0 or min_depth < 0:
problems.append(f"{rel}:{node.lineno} (paren depth {depth:+d}, min {min_depth})")
assert not problems, f"Unbalanced parentheses in SQL literal(s): {problems}"
def test_sql_paren_depth_scanner():
# The guard's scanner itself: parens in literals/comments must not count; '' escapes and
# block comments handled; an extra ')' is reported via min_depth even if a later '(' would
# re-balance the total.
assert _sql_paren_depth("SELECT (1)") == (0, 0)
assert _sql_paren_depth("SELECT (1))") == (-1, -1) # the v1.8.0 bug shape
assert _sql_paren_depth("SELECT ')' , '((' FROM t") == (0, 0) # literals ignored
assert _sql_paren_depth("SELECT 'it''s ))' FROM t") == (0, 0) # '' escape stays inside
assert _sql_paren_depth("SELECT 1 -- comment ) (\nFROM t") == (0, 0) # line comment ignored
assert _sql_paren_depth("SELECT 1 /* ) */ FROM t") == (0, 0) # block comment ignored
assert _sql_paren_depth("SELECT 'a--b' AND (x=1\n)") == (0, 0) # -- inside literal is data
assert _sql_paren_depth("WHERE x) AND (y") == (0, -1) # net 0 but went negative
+61 -86
View File
@@ -206,41 +206,38 @@ def test_module_level_validate_haproxy_config_forwards_partial_fragment():
# ──────────────────────────────────────────────────────────────────────
# Wizard Pydantic gate: ACL `-f` flag must be REJECTED at submit.
# Issue #38 follow-up: ACL `-f <file>` pattern-file references are
# ACCEPTED (the Bulgu #12 hard reject was removed — pattern files are
# operator-managed host files, the agent's pre-reload `haproxy -c`
# makes a missing file fail safely, and bulk import always accepted
# `-f`). These tests pin the ACCEPT behaviour.
# ──────────────────────────────────────────────────────────────────────
def test_wizard_pydantic_rejects_acl_with_file_flag():
"""Pre-fix the wizard's ACL string validator passed
`acl name path -i -m reg -f /path` straight through. Apply-time
HAProxy `-c` then failed with "failed to open pattern file".
Pin that the validator now rejects `-f` at submit.
def test_wizard_pydantic_accepts_acl_with_file_flag():
"""Issue #38 follow-up — the wizard's ACL string validator must
ACCEPT `-f <file>` pattern-file references (Bulgu #12 reject
removed). Operators with large host-managed IP blacklists rely
on this in production.
"""
from models.site_wizard import FrontendStep
# Minimal valid wizard frontend kwargs — only the offending
# acl_rules entry should trigger the failure.
fe_kwargs = dict(
fe = FrontendStep(
name="fe1",
mode="http",
bind_address="*",
bind_port=80,
acl_rules=["acl1 path -i -m reg -f /path"],
)
from pydantic import ValidationError
with pytest.raises(ValidationError) as exc_info:
FrontendStep(**fe_kwargs)
msg = str(exc_info.value)
assert "-f" in msg or "pattern-file" in msg.lower(), (
f"Bulgu #12 regression: ACL -f flag must be rejected with a "
f"clear pattern-file error. Got: {msg}"
assert fe.acl_rules == ["acl1 path -i -m reg -f /path"], (
"ACL `-f` rule must round-trip verbatim through the wizard model"
)
@pytest.mark.parametrize(
"rule",
[
# Various spacing / position variants the regex must catch.
# Various spacing / position variants must all be accepted.
"acl1 path -f /etc/haproxy/list",
"acl1 path -i -f /tmp/x.lst",
"acl1 src -f /etc/haproxy/admins.lst",
@@ -249,23 +246,19 @@ def test_wizard_pydantic_rejects_acl_with_file_flag():
"acl1 path -f",
],
)
def test_wizard_pydantic_rejects_acl_with_file_flag_variants(rule):
"""Every spacing / position variant the operator might type must
be rejected. Pinned defensively so the regex never accidentally
relaxes to "only matches trailing -f".
"""
def test_wizard_pydantic_accepts_acl_with_file_flag_variants(rule):
"""Every spacing / position variant must be accepted verbatim
(Issue #38 follow-up — no `-f` shape may be rejected)."""
from models.site_wizard import FrontendStep
from pydantic import ValidationError
fe_kwargs = dict(
fe = FrontendStep(
name="fe1",
mode="http",
bind_address="*",
bind_port=80,
acl_rules=[rule],
)
with pytest.raises(ValidationError):
FrontendStep(**fe_kwargs)
assert fe.acl_rules == [rule]
def test_wizard_pydantic_does_not_falsely_match_dash_f_inside_token():
@@ -291,42 +284,29 @@ def test_wizard_pydantic_does_not_falsely_match_dash_f_inside_token():
assert len(fe.acl_rules) == 3
def test_manual_frontend_validator_rejects_acl_with_file_flag():
"""Parity check: the manual Frontend API
(`models/frontend.py::validate_acl_rules`) must apply the same
`-f` rejection. Operators see consistent behaviour from both the
wizard and the per-entity frontend page.
def test_manual_frontend_validator_accepts_acl_with_file_flag():
"""Parity check (Issue #38 follow-up): the manual Frontend API
(`models/frontend.py::validate_acl_rules`) must ACCEPT `-f`
pattern-file references, same as the wizard and bulk import.
"""
from models.frontend import FrontendConfig
from pydantic import ValidationError
with pytest.raises(ValidationError) as exc_info:
FrontendConfig(
name="fe1",
bind_port=80,
mode="http",
acl_rules=["acl1 path -i -m reg -f /path"],
)
msg = str(exc_info.value)
assert "-f" in msg or "pattern-file" in msg.lower(), (
f"Manual frontend API parity regression: ACL -f flag must be "
f"rejected. Got: {msg}"
fe = FrontendConfig(
name="fe1",
bind_port=80,
mode="http",
acl_rules=["acl1 path -i -m reg -f /path"],
)
assert fe.acl_rules == ["acl1 path -i -m reg -f /path"]
def test_wizard_pydantic_rejects_structured_redirect_dict_with_file_flag():
"""Round-3 audit extension — structured redirect dicts (the
alternative shape that `models/site_wizard.py::_validate_redirect_rules`
accepts alongside legacy strings) also flow through to
`services/haproxy_config.py::_format_redirect_rule` and emit
their `condition` / `target` verbatim into the rendered HAProxy
directive. Without the dict-aware reject the visual builder's
`-f` block could be bypassed by hand-crafting a dict payload
against the API — recreating the same `failed to open pattern
file` failure at apply time.
def test_wizard_pydantic_accepts_structured_redirect_dict_with_file_flag():
"""Issue #38 follow-up — structured redirect dicts carrying `-f`
pattern-file references in `condition`/`target` are ACCEPTED
(the Bulgu #12 dict-aware reject was removed together with the
string-rule reject).
"""
from models.site_wizard import FrontendStep, BackendStep
from pydantic import ValidationError
from models.site_wizard import FrontendStep
fe_kwargs = dict(
name="fe1",
@@ -334,37 +314,32 @@ def test_wizard_pydantic_rejects_structured_redirect_dict_with_file_flag():
mode="http",
)
# `condition` carrying `-f` must be rejected.
with pytest.raises(ValidationError) as exc_info:
FrontendStep(
**fe_kwargs,
redirect_rules=[
{
"type": "scheme",
"target": "https",
"condition": "if { src -f /etc/haproxy/admins.lst }",
}
],
)
msg = str(exc_info.value)
assert "pattern-file" in msg.lower() or "-f" in msg, msg
# `condition` carrying `-f` is accepted.
fe = FrontendStep(
**fe_kwargs,
redirect_rules=[
{
"type": "scheme",
"target": "https",
"condition": "if { src -f /etc/haproxy/admins.lst }",
}
],
)
assert fe.redirect_rules[0]["condition"] == "if { src -f /etc/haproxy/admins.lst }"
# `target` carrying `-f` must also be rejected (defence-in-depth
# for hand-crafted payloads).
with pytest.raises(ValidationError) as exc_info:
FrontendStep(
**fe_kwargs,
redirect_rules=[
{
"type": "location",
"target": "/foo -f /tmp/x.lst",
}
],
)
msg = str(exc_info.value)
assert "pattern-file" in msg.lower() or "-f" in msg, msg
# `target` carrying `-f` is accepted too.
fe = FrontendStep(
**fe_kwargs,
redirect_rules=[
{
"type": "location",
"target": "/foo -f /tmp/x.lst",
}
],
)
assert fe.redirect_rules[0]["target"] == "/foo -f /tmp/x.lst"
# Clean structured dict still passes — no false positive.
# Clean structured dict still passes.
FrontendStep(
**fe_kwargs,
redirect_rules=[
@@ -667,8 +642,8 @@ def test_user_reported_wizard_config_emits_no_false_warnings():
zero WARNINGs from the directives we expanded.
"""
# Distilled from the user's bulk-site-create snapshot, minus the
# `-f` ACL (which the new Pydantic gate rejects before this
# validator ever runs).
# `-f` ACL (accepted since the Issue #38 follow-up, but irrelevant
# to the directive-expansion warnings this test pins).
config = """# ─── Wizard candidate fragment (dry-run preview) ───
frontend fe-site1
bind *:80
+4 -2
View File
@@ -276,14 +276,16 @@ def test_categorize_routes_directives_correctly():
def test_emit_buckets_flushed_in_canonical_order():
"""The flush block at end of frontend processing must list buckets
in: prelude → stick → tcp_req → acl → http_req → http_resp →
in: prelude → filter → stick → tcp_req → acl → http_req → http_resp →
redirect → use_be → default_be. Pre-fix `http-request` rules
interleaved with `use_backend` rules in source order, producing
HAProxy parser warnings."""
HAProxy parser warnings. (Issue #38 added the `filter` bucket, flushed
right after `prelude` so SPOE `filter` lines precede `send-spoe-group`.)"""
src = _gen_src()
flush_match = re.search(
r'for\s+_bucket_key\s+in\s+\(\s*'
r'"prelude"\s*,\s*'
r'"filter"\s*,\s*'
r'"stick"\s*,\s*'
r'"tcp_req"\s*,\s*'
r'"acl"\s*,\s*'
+203
View File
@@ -0,0 +1,203 @@
"""
Issue #38 regression tests: HAProxy SPOE `filter` + frontend `log-format` support.
Bug: the bulk-config parser recognised only a fixed set of frontend directives,
so `filter spoe engine coraza config ...` and `log-format ...` were silently
dropped on import / manual edit. This regenerated a config missing the SPOE
engine definition, so HAProxy failed with
"unable to find SPOE engine 'coraza' used by the send-spoe-group 'coraza-req'".
These tests verify the end-to-end fix without requiring a database:
1. parser captures `filter` + `log-format` into the new ParsedFrontend fields;
2. `http-request send-spoe-group` is still preserved (regression guard);
3. the generator's directive categoriser + bucket flush order emit `filter`
BEFORE the `http-request send-spoe-group` rules and keep `log-format`;
4. reject/rollback restores the new columns;
5. a non-SPOE frontend is completely unaffected (zero-impact).
"""
import os
import re
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from utils.haproxy_config_parser import parse_haproxy_config, ParsedFrontend
from services.haproxy_config import _categorize_haproxy_directive
from models.frontend import FrontendConfig
# The exact frontend/backend config reported in Issue #38 (Coraza-SPOA).
ISSUE_38_CONFIG = r"""
frontend web-frontend
bind *:8073
mode http
log-format "%ci:%cp\ [%t]\ %ft\ %b/%s\ %ST\ %B\ %{+Q}r\ %[var(txn.coraza.id)]\ waf-hit:\ %[var(txn.coraza.fail)]"
filter spoe engine coraza config /etc/haproxy/coraza.cfg
http-request set-var(txn.coraza.app) str(haproxy_waf)
http-request send-spoe-group coraza coraza-req
http-request deny if { var(txn.coraza.fail) -m int eq 1 }
default_backend web-backend
backend web-backend
balance roundrobin
mode http
server server1 192.168.1.10:443 weight 100 ssl verify none
backend coraza-spoa
mode tcp
option spop-check
server coraza_spoa 192.168.12.21:9000
"""
def _get_frontend(parse_result, name):
for fe in parse_result.frontends:
if fe.name == name:
return fe
return None
class TestParserCapturesSpoe:
def test_filter_and_log_format_captured(self):
result = parse_haproxy_config(ISSUE_38_CONFIG)
fe = _get_frontend(result, "web-frontend")
assert fe is not None, "web-frontend should be parsed and kept"
assert fe.filters is not None
assert "filter spoe engine coraza config /etc/haproxy/coraza.cfg" in fe.filters
assert fe.log_format is not None
assert fe.log_format.startswith("log-format")
# the escaped/quoted format string must be preserved verbatim
assert "%[var(txn.coraza.fail)]" in fe.log_format
def test_send_spoe_group_still_preserved(self):
# Regression guard: http-request rules (incl. send-spoe-group) must
# still be collected into request_headers as before.
result = parse_haproxy_config(ISSUE_38_CONFIG)
fe = _get_frontend(result, "web-frontend")
assert fe.request_headers is not None
assert "send-spoe-group coraza coraza-req" in fe.request_headers
def test_multiple_filters_preserved_in_order(self):
cfg = """
frontend f1
bind *:80
mode http
filter compression
filter spoe engine coraza config /etc/haproxy/coraza.cfg
default_backend b1
backend b1
mode http
server s1 10.0.0.1:80
"""
fe = _get_frontend(parse_haproxy_config(cfg), "f1")
lines = fe.filters.split("\n")
assert lines == [
"filter compression",
"filter spoe engine coraza config /etc/haproxy/coraza.cfg",
]
def test_log_format_sd_variant_captured(self):
cfg = """
frontend f1
bind *:80
mode http
log-format-sd "[exampleSDID@1234 field=value]"
default_backend b1
backend b1
mode http
server s1 10.0.0.1:80
"""
fe = _get_frontend(parse_haproxy_config(cfg), "f1")
assert fe.log_format is not None
assert fe.log_format.startswith("log-format-sd")
class TestGeneratorOrderingContract:
"""The generator routes directives into ordered buckets. Verify SPOE
correctness at the (pure) categoriser + documented flush-order level."""
def test_filter_routes_to_filter_bucket(self):
assert _categorize_haproxy_directive(" filter spoe engine coraza config /x.cfg") == "filter"
def test_send_spoe_group_routes_to_http_req(self):
assert _categorize_haproxy_directive(" http-request send-spoe-group coraza coraza-req") == "http_req"
def test_log_format_routes_to_prelude(self):
assert _categorize_haproxy_directive(' log-format "%ci:%cp"') == "prelude"
assert _categorize_haproxy_directive(' log-format-sd "[x]"') == "prelude"
def test_flush_order_places_filter_before_http_req(self):
# The bucket flush order is the single source of truth for emission
# ordering. Assert `filter` is flushed before `http_req` (and after
# `prelude`), guaranteeing `filter ...` renders before
# `http-request send-spoe-group ...`.
src = _read_source("services/haproxy_config.py")
m = re.search(r"for _bucket_key in \((.*?)\):", src, re.DOTALL)
assert m, "bucket flush loop not found"
order = re.findall(r'"(\w+)"', m.group(1))
assert "filter" in order, "new 'filter' bucket missing from flush order"
assert order.index("prelude") < order.index("filter") < order.index("http_req")
class TestModelAndRollback:
def test_model_has_passthrough_fields(self):
fc = FrontendConfig(
name="f", bind_port=80,
filters="filter spoe engine coraza config /etc/haproxy/coraza.cfg",
log_format='log-format "%ci"',
)
assert fc.filters.startswith("filter spoe")
assert fc.log_format.startswith("log-format")
def test_dataclass_defaults_none(self):
fe = ParsedFrontend(name="f")
assert fe.filters is None
assert fe.log_format is None
def test_rollback_restores_new_columns(self):
# Reject/rollback of a frontend UPDATE must restore the new columns,
# otherwise the rejected (new) filters/log_format would persist.
src = _read_source("utils/entity_snapshot.py")
assert "log_format = $" in src
assert "filters = $" in src
assert "old_values.get('log_format')" in src
assert "old_values.get('filters')" in src
class TestZeroImpact:
def test_non_spoe_frontend_unaffected(self):
cfg = """
frontend plain
bind *:80
mode http
option httplog
default_backend b1
backend b1
mode http
server s1 10.0.0.1:80
"""
fe = _get_frontend(parse_haproxy_config(cfg), "plain")
# No filter / log-format present → new fields stay None (no behaviour change)
assert fe.filters is None
assert fe.log_format is None
def test_spop_check_backend_roundtrips_without_warning(self):
result = parse_haproxy_config(ISSUE_38_CONFIG)
be = next((b for b in result.backends if b.name == "coraza-spoa"), None)
assert be is not None, "coraza-spoa backend should import"
assert be.mode == "tcp"
assert be.options and "option spop-check" in be.options
# spop-check is now a known option → no spurious 'unknown option' warning
assert not any(
"coraza-spoa" in w and "spop-check" in w and "Unknown" in w
for w in result.warnings
)
def _read_source(relpath):
base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
with open(os.path.join(base, relpath), "r", encoding="utf-8") as fh:
return fh.read()
+71
View File
@@ -0,0 +1,71 @@
"""v1.8.7: app version is single-source and cannot silently drift.
The UI shows the version via GET /api/version, which returns main.py's `_version_info`. That MUST be
sourced from the one canonical file backend/version.json (co-located with main.py so `COPY . .`
bakes it into every image, regardless of pipeline). main.py's in-code fallback must NOT be a real
version, otherwise it drifts when version.json is bumped but the constant is forgotten — exactly
what left the UI reporting 1.8.4 after 1.8.5/1.8.6 shipped. These checks fail loudly on regression.
"""
import ast
import json
import os
import re
import pytest
_BACKEND = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # backend/
_VERSION_JSON = os.path.join(_BACKEND, "version.json")
_MAIN = os.path.join(_BACKEND, "main.py")
_SEMVER = re.compile(r"^\d+\.\d+\.\d+$")
def test_canonical_version_file_exists_and_valid():
assert os.path.exists(_VERSION_JSON), "backend/version.json (single source of truth) is missing"
with open(_VERSION_JSON) as f:
data = json.load(f)
assert _SEMVER.match(data.get("version", "")), \
f"backend/version.json version is not semver: {data.get('version')!r}"
assert data.get("releaseName"), "backend/version.json must have a releaseName"
def _main_fallback_version_info():
"""The literal dict assigned to _version_info in main.py (the in-code fallback)."""
with open(_MAIN) as f:
tree = ast.parse(f.read())
for node in ast.walk(tree):
if isinstance(node, ast.Assign) and isinstance(node.value, ast.Dict):
for t in node.targets:
if isinstance(t, ast.Name) and t.id == "_version_info":
return ast.literal_eval(node.value)
return None
def test_main_has_no_hardcoded_real_version():
fb = _main_fallback_version_info()
assert fb is not None, "could not find the _version_info fallback literal in main.py"
# Must be a neutral marker, never a real version that can drift out of sync.
assert not _SEMVER.match(str(fb.get("version", ""))), (
f"main.py hardcodes a real version {fb.get('version')!r}; it must be a neutral marker "
f"(e.g. 'unknown') so the version stays single-source in backend/version.json"
)
def test_main_loads_the_canonical_file_first():
# The first candidate path main.py reads must resolve to the co-located backend/version.json,
# so the correct version is available in every image (not dependent on CI staging).
with open(_MAIN) as f:
src = f.read()
assert 'os.path.dirname(__file__), "version.json"' in src, \
"main.py must read version.json co-located with the module (backend/version.json)"
def test_frontend_package_json_matches_when_present():
# Only meaningful in a full-repo checkout; the backend image build context (./backend) has no frontend/.
pkg = os.path.join(os.path.dirname(_BACKEND), "frontend", "package.json")
if not os.path.exists(pkg):
pytest.skip("frontend/package.json not in this context (e.g. backend-only image build)")
with open(_VERSION_JSON) as f:
canonical = json.load(f)["version"]
with open(pkg) as f:
fe = json.load(f)["version"]
assert fe == canonical, f"frontend/package.json {fe!r} != backend/version.json {canonical!r}"
+6 -1
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=""
+5 -1
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=""
+5 -2
View File
@@ -329,9 +329,10 @@ async def _rollback_update(
tcp_request_rules = $26, timeout_client = $27, timeout_http_request = $28,
rate_limit = $29, compression = $30, log_separate = $31,
monitor_uri = $32, maxconn = $33,
cluster_id = $34, is_active = $35, last_config_status = $36,
cluster_id = $34, is_active = $35, last_config_status = $36,
log_format = $37, filters = $38,
updated_at = CURRENT_TIMESTAMP
WHERE id = $37
WHERE id = $39
""",
old_values.get('name'),
old_values.get('bind_address'),
@@ -369,6 +370,8 @@ async def _rollback_update(
old_values.get('cluster_id'),
old_values.get('is_active'),
old_values.get('last_config_status'),
old_values.get('log_format'), # Issue #38
old_values.get('filters'), # Issue #38
entity_id
)
+34 -2
View File
@@ -105,6 +105,11 @@ class ParsedFrontend:
response_headers: Optional[str] = None
options: Optional[str] = None # HAProxy frontend options (option httplog, option forwardfor, etc.)
tcp_request_rules: Optional[str] = None # TCP request directives (for TCP mode)
# Issue #38: SPOE (and other) filter directives + frontend log-format.
# Stored as full directive lines; `filters` is newline-joined to preserve
# ordering when multiple `filter ...` lines exist.
log_format: Optional[str] = None # `log-format` / `log-format-sd` line(s)
filters: Optional[str] = None # `filter ...` line(s), e.g. `filter spoe engine coraza config ...`
@dataclass
@@ -256,8 +261,23 @@ class HAProxyConfigParser:
acl_rules_list = []
use_backend_rules_list = []
tcp_request_rules_list = []
filters_list = []
log_format_list = []
for line in lines:
# Issue #38: capture `filter ...` (SPOE/Coraza etc.) and
# `log-format`/`log-format-sd` directives. Pre-fix these matched
# no branch below and were silently dropped, so an imported SPOE
# config lost `filter spoe engine coraza ...` (→ HAProxy fatal
# "unable to find SPOE engine") and the frontend log-format.
# `continue` isolates them from the header/option handling below.
if line.startswith('filter '):
filters_list.append(line.strip())
continue
if re.match(r'^log-format(-sd)?\s', line, re.IGNORECASE):
log_format_list.append(line.strip())
continue
# Parse bind directive
# IMPORTANT: Handle multiple bind lines correctly
# Example: bind *:1002 (HTTP) and bind *:443 ssl (HTTPS)
@@ -540,6 +560,13 @@ class HAProxyConfigParser:
if tcp_request_rules_list:
frontend.tcp_request_rules = '\n'.join(tcp_request_rules_list)
# Issue #38: assign captured SPOE filters + log-format
if filters_list:
frontend.filters = '\n'.join(filters_list)
if log_format_list:
frontend.log_format = '\n'.join(log_format_list)
self.frontends.append(frontend)
logger.info(f"Parsed frontend: {name} -> {frontend.default_backend}")
@@ -666,9 +693,14 @@ class HAProxyConfigParser:
'transparent', 'abortonclose', 'allbackups', 'checkcache', 'clitcpka',
'srvtcpka', 'http-no-delay', 'socket-stats', 'tcp-smart-accept',
'tcp-smart-connect', 'independant-streams', 'log-separate-errors',
'log-health-checks', 'accept-invalid-http-request', 'accept-invalid-http-response'
'log-health-checks', 'accept-invalid-http-request', 'accept-invalid-http-response',
# Issue #38: SPOP health check for SPOE agent backends
# (e.g. coraza-spoa). Already collected below regardless, but
# listing it suppresses the spurious "unknown option" warning
# for the exact SPOE use-case.
'spop-check'
]
if option_name not in valid_options:
# Unknown/invalid option - add warning but still collect it
self.warnings.append(
+5
View File
@@ -0,0 +1,5 @@
{
"version": "1.8.9",
"releaseName": "ACL -f pattern-file support (Issue #38 follow-up)",
"releaseDate": "2026-07-13"
}
-1
View File
@@ -10,7 +10,6 @@ services:
dockerfile: Dockerfile
volumes:
- haproxy_configs:/etc/haproxy
- ./version.json:/app/version.json:ro
frontend:
image: haproxy-openmanager-frontend:localtest
+3
View File
@@ -51,6 +51,9 @@ services:
- LOG_LEVEL=INFO
- PUBLIC_URL=http://localhost:8080
- MANAGEMENT_BASE_URL=http://localhost:8080
# Empty when unset on the host: the image CMD then falls back to
# WEB_CONCURRENCY (uvicorn's native env) and finally to 1.
- UVICORN_WORKERS=${UVICORN_WORKERS:-}
volumes:
- haproxy_configs:/etc/haproxy
expose:
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "haproxy-openmanager-frontend",
"version": "1.8.3",
"version": "1.8.9",
"description": "HAProxy Load Balancer Management UI",
"license": "AGPL-3.0-or-later",
"dependencies": {
+39 -43
View File
@@ -53,19 +53,19 @@ const MATCH_TYPE_GROUPS = [
{ label: 'Advanced', options: MATCH_TYPES.filter(m => m.category === 'Advanced') },
];
// Phase K Phase D follow-up (Bulgu #12 round 3) — the `-f <file>`
// flag was removed from the visual builder because HAProxy OpenManager
// does not provision pattern files onto the HAProxy node filesystem.
// Allowing `-f` in the visual builder produced ACL rules that passed
// every UI / Pydantic / heuristic check but ALWAYS failed HAProxy's
// real `-c` parse at apply time with "failed to open pattern file".
// Operators reported a multi-page wizard run ending at the Apply
// Management red-badge for a footgun the UI made trivial to step on.
// The Pydantic validators on the manual API + wizard reject `-f`
// universally; the visual builder simply removes the option from the
// dropdown so operators cannot author the unsupported state.
// Issue #38 follow-up — `-f <file>` is back in the visual builder:
// the Bulgu #12 removal (and the matching Pydantic rejects) assumed a
// missing pattern file would surprise the operator at apply time, but
// the agent runs `haproxy -c` before every reload so a missing file
// fails safely (previous config keeps running), and bulk import plus
// the free-form fields always accepted `-f`. Pattern files are
// operator-managed host files, same policy as SPOE filter configs
// (v1.8.8). The value field carries the file path (e.g. flag `-f`
// + value `/etc/haproxy/blacklist.lst`); an informational note is
// rendered on rules that use it.
const FLAGS = [
{ value: '-i', label: '-i (case insensitive)' },
{ value: '-f', label: '-f (pattern file on host)' },
{ value: '-m beg', label: '-m beg (begins with)' },
{ value: '-m end', label: '-m end (ends with)' },
{ value: '-m sub', label: '-m sub (contains)' },
@@ -396,25 +396,23 @@ function ACLDefinitionCard({ rule, index, onChange, onDelete }) {
};
const isRaw = rule.raw !== undefined;
// Phase K Phase D follow-up (Bulgu #12 round 3) — surface `-f` flag
// usage inline. The Pydantic validator rejects the rule server-side,
// but operators benefit from seeing the error AS they type / when
// they re-open a draft that carries a `-f`-flagged rule (e.g. from
// a pre-fix draft). The error message matches the Pydantic error
// verbatim so support flows are consistent.
// Issue #38 follow-up — `-f <file>` pattern-file references are
// ACCEPTED now (the Bulgu #12 reject was removed server-side too).
// We still detect them, but only to render an informational note:
// the referenced file is operator-managed and must exist on every
// HAProxy host; a missing file fails safely at the agent's
// pre-reload `haproxy -c`.
const rawHasFileFlag = isRaw && typeof rule.raw === 'string' && ACL_FILE_FLAG_PATTERN.test(rule.raw);
const structuredHasFileFlag =
!isRaw && Array.isArray(rule.flags) && rule.flags.includes('-f');
const hasFileFlag = rawHasFileFlag || structuredHasFileFlag;
const cardStyleWithError = hasFileFlag
? { ...ruleCardStyle, border: `1px solid ${token.colorError}` }
: ruleCardStyle;
const cardStyleWithError = ruleCardStyle;
const FILE_FLAG_TOOLTIP =
"ACL pattern-file references (-f <file>) are not supported by "
+ "HAProxy OpenManager: the product does not provision pattern "
+ "files onto the HAProxy node filesystem, so the reference "
+ "would fail at HAProxy reload time. Remove '-f' and use inline "
+ "values instead.";
"This rule references a pattern file (-f <file>). The file must "
+ "exist at that exact path on every HAProxy host in the cluster — "
+ "HAProxy OpenManager does not create or distribute pattern files. "
+ "A missing file fails safely at 'haproxy -c' (the previous config "
+ "keeps running).";
if (isRaw) {
return (
@@ -427,11 +425,10 @@ function ACLDefinitionCard({ rule, index, onChange, onDelete }) {
onChange={(e) => onChange(index, { raw: e.target.value })}
placeholder="Raw ACL rule (e.g. my_acl path_beg /api)"
prefix={<Tag color="default" style={{ marginRight: 4 }}>RAW</Tag>}
status={hasFileFlag ? 'error' : undefined}
/>
</Tooltip>
{hasFileFlag && (
<Text type="danger" style={{ fontSize: 11, display: 'block', marginTop: 2 }}>
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginTop: 2 }}>
{FILE_FLAG_TOOLTIP}
</Text>
)}
@@ -549,12 +546,11 @@ function ACLDefinitionCard({ rule, index, onChange, onDelete }) {
onChange={(e) => onChange(index, { ...rule, value: e.target.value })}
placeholder={matchDef?.placeholder || 'Value'}
size="small"
status={structuredHasFileFlag ? 'error' : undefined}
/>
);
})()}
{structuredHasFileFlag && (
<Text type="danger" style={{ fontSize: 11, display: 'block', marginTop: 2 }}>
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginTop: 2 }}>
{FILE_FLAG_TOOLTIP}
</Text>
)}
@@ -891,11 +887,11 @@ export default function ACLRuleBuilder({ aclRules = [], useBackendRules = [], re
.map(d => d.name);
}, [aclDefs]);
// Phase K Phase D follow-up (Bulgu #12 round 3) — count rules that
// still carry the unsupported `-f <file>` flag. Surfaced as a
// section-level Alert so operators know the section as a whole
// has invalid rules even if individual cards / raw text would
// otherwise need scrolling to find them.
// Issue #38 follow-up — count rules that reference a `-f <file>`
// pattern file. Surfaced as a section-level informational Alert
// (non-blocking): the file is operator-managed and must exist on
// every HAProxy host; a missing file fails safely at the agent's
// pre-reload `haproxy -c`.
const fileFlagRuleCount = useMemo(() => {
let count = 0;
for (const d of aclDefs) {
@@ -1153,19 +1149,19 @@ export default function ACLRuleBuilder({ aclRules = [], useBackendRules = [], re
Define named conditions to match incoming requests by path, header, source IP, and more.
</Text>
{/* Phase K Phase D follow-up (Bulgu #12 round 3) — section-
level warning when one or more rules still carry the
unsupported `-f <file>` pattern-file flag. Render as a
blocking-style Alert so the operator notices BEFORE
Submit. The Pydantic validator rejects the same shape
server-side; this is the up-front authoring guardrail. */}
{/* Issue #38 follow-up — section-level informational note
when one or more rules reference `-f <file>` pattern
files. Non-blocking: pattern files are operator-managed
host files (the Bulgu #12 reject was removed) and a
missing file fails safely at the agent's pre-reload
`haproxy -c`. */}
{fileFlagRuleCount > 0 && (
<Alert
type="error"
type="info"
showIcon
style={{ marginBottom: 8 }}
message={`${fileFlagRuleCount} ACL rule${fileFlagRuleCount === 1 ? '' : 's'} use the unsupported \`-f <file>\` flag`}
description="HAProxy OpenManager does not provision pattern files onto the HAProxy node filesystem, so any `-f /path/...` reference would fail HAProxy reload at apply time with 'failed to open pattern file'. Remove the `-f` flag and switch to inline values (e.g. `src 10.0.0.0/24` instead of `src -f /etc/haproxy/admins.lst`)."
message={`${fileFlagRuleCount} ACL rule${fileFlagRuleCount === 1 ? '' : 's'} reference a \`-f <file>\` pattern file`}
description="The referenced file must exist at that exact path on every HAProxy host in the cluster — HAProxy OpenManager does not create or distribute pattern files. A missing file fails safely at 'haproxy -c' (the previous config keeps running)."
/>
)}
+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);
+46 -3
View File
@@ -458,6 +458,8 @@ backend web-backend
{record.request_headers && <Tag color="blue">Req Headers</Tag>}
{record.response_headers && <Tag color="green">Resp Headers</Tag>}
{record.tcp_request_rules && <Tag color="purple">TCP Rules</Tag>}
{record.filters && <Tag color="magenta">Filters</Tag>}
{record.log_format && <Tag color="geekblue">Log Format</Tag>}
{record.acl_rules && record.acl_rules.length > 0 && <Tag color="orange">{record.acl_rules.length} ACLs</Tag>}
{record.use_backend_rules && record.use_backend_rules.length > 0 && <Tag color="cyan">{record.use_backend_rules.length} Routes</Tag>}
</Space>
@@ -1076,8 +1078,9 @@ backend web-backend
size="small"
expandable={{
expandedRowRender: (frontend) => {
const hasDetails = frontend.request_headers || frontend.response_headers ||
frontend.options || frontend.tcp_request_rules ||
const hasDetails = frontend.request_headers || frontend.response_headers ||
frontend.options || frontend.tcp_request_rules ||
frontend.filters || frontend.log_format ||
(frontend.acl_rules && frontend.acl_rules.length > 0) ||
(frontend.use_backend_rules && frontend.use_backend_rules.length > 0);
@@ -1157,12 +1160,52 @@ backend web-backend
</span>
}
>
<MultiLineDiffRenderer
<MultiLineDiffRenderer
value={frontend.tcp_request_rules}
changeInfo={frontend._changes?.tcp_request_rules}
/>
</Descriptions.Item>
)}
{/* Issue #38: SPOE filters */}
{frontend.filters && (
<Descriptions.Item
label={
<span>
Filters (SPOE/WAF)
{frontend._changes?.filters && (
<Tag color="green" style={{ marginLeft: 8, fontSize: '10px' }}>
{frontend._changes.filters.old ? 'CHANGED' : 'NEW'}
</Tag>
)}
</span>
}
>
<MultiLineDiffRenderer
value={frontend.filters}
changeInfo={frontend._changes?.filters}
/>
</Descriptions.Item>
)}
{/* Issue #38: frontend log-format */}
{frontend.log_format && (
<Descriptions.Item
label={
<span>
Log Format
{frontend._changes?.log_format && (
<Tag color="green" style={{ marginLeft: 8, fontSize: '10px' }}>
{frontend._changes.log_format.old ? 'CHANGED' : 'NEW'}
</Tag>
)}
</span>
}
>
<MultiLineDiffRenderer
value={frontend.log_format}
changeInfo={frontend._changes?.log_format}
/>
</Descriptions.Item>
)}
{frontend.acl_rules && frontend.acl_rules.length > 0 && (
<Descriptions.Item label={`ACL Rules (${frontend.acl_rules.length})`}>
{frontend.acl_rules.map((acl, idx) => (
+70 -27
View File
@@ -642,7 +642,11 @@ const FrontendManagement = () => {
// Explicitly set options field to handle null/undefined case (NEW field)
options: frontend.options || '',
// BUGFIX: Explicitly set tcp_request_rules field to handle null/undefined case
tcp_request_rules: frontend.tcp_request_rules || ''
tcp_request_rules: frontend.tcp_request_rules || '',
// Issue #38: SPOE filters + frontend log-format (null → '' so the
// TextAreas populate on edit and round-trip on save, preventing null-wipe)
log_format: frontend.log_format || '',
filters: frontend.filters || ''
});
// Update SSL field visibility after setting values
@@ -862,31 +866,13 @@ const FrontendManagement = () => {
return;
}
// Phase K Phase D follow-up (Bulgu #12 round 3) — hard-gate any
// ACL / use_backend / redirect rule that carries the unsupported
// HAProxy `-f <file>` pattern-file flag. The Pydantic validator
// on the backend (`models/frontend.py::validate_acl_rules`)
// rejects the same shape; blocking here surfaces the error
// immediately at the manual frontend form and matches the wizard
// gate so operators see consistent behaviour between the two
// entry points.
const FILE_FLAG_RE = /(?:^|\s)-f(?:\s|$)/;
const aclRulesAll = [
...(aclBuilderData.aclRules || []),
...(aclBuilderData.useBackendRules || []),
...(aclBuilderData.redirectRules || []).map(
(r) => (typeof r === 'string' ? r : ''),
),
];
if (aclRulesAll.some((r) => typeof r === 'string' && FILE_FLAG_RE.test(r))) {
message.error(
'One or more ACL / routing / redirect rules use the unsupported HAProxy ' +
'`-f <file>` pattern-file flag. HAProxy OpenManager does not provision ' +
'pattern files onto the HAProxy node filesystem, so the reference would ' +
'fail at reload time. Remove the `-f` flag and use inline values instead.'
);
return;
}
// Issue #38 follow-up — the Bulgu #12 client-side hard gate for
// the ACL `-f <file>` pattern-file flag was removed together with
// the server-side Pydantic rejects: pattern files are operator-
// managed host files (same policy as SPOE filter configs since
// v1.8.8) and the agent's pre-reload `haproxy -c` makes a missing
// file fail safely. The server response now carries a non-blocking
// warning listing the referenced files (rendered below).
// Phase K Phase D follow-up (Bulgu #13) — gate for
// self-contradictory routing / redirect conditions (`X !X`).
@@ -1178,8 +1164,31 @@ const FrontendManagement = () => {
} else {
message.success('Frontend created successfully');
}
// Issue #38 follow-up — surface server-emitted warnings on
// CREATE too (e.g. the `-f <file>` pattern-file advisory).
// Mirrors the update-branch rendering above.
const createWarnings = Array.isArray(response.data?.warnings)
? response.data.warnings
: [];
if (createWarnings.length > 0) {
message.warning(
<div>
<div><strong>Frontend saved, but the server flagged {createWarnings.length} rule warning(s):</strong></div>
<div style={{ marginTop: 6, fontSize: '12px', fontFamily: 'monospace' }}>
{createWarnings.slice(0, 5).map((w, i) => (
<div key={i}>• {w.length > 240 ? `${w.slice(0, 237)}...` : w}</div>
))}
{createWarnings.length > 5 && (
<div>(+{createWarnings.length - 5} more)</div>
)}
</div>
</div>,
10,
);
}
}
setModalVisible(false);
fetchFrontends();
fetchSSLCertificates(); // Refresh SSL certificates after frontend update
@@ -2250,6 +2259,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>
+9 -30
View File
@@ -3177,36 +3177,15 @@ const SiteWizard = () => {
);
return;
}
// Phase K Phase D follow-up (Bulgu #12 round 3) —
// hard-gate the Step 2 → Step 3 advance on any ACL
// rule that carries the unsupported `-f <file>`
// pattern-file flag. The Pydantic validator rejects
// the same shape at submit, but blocking the Next
// button here surfaces the error immediately at
// its source step (the ACL builder is right above)
// instead of bouncing the operator from Step 4's
// dry-run card back to Step 2 with a less-specific
// jumpback button. The ACLRuleBuilder ALSO renders
// a section-level red Alert when this state is
// active so the operator already sees what to fix.
const FILE_FLAG_RE = /(?:^|\s)-f(?:\s|$)/;
const aclRulesAll = [
...(aclBuilderData.aclRules || []),
...(aclBuilderData.useBackendRules || []),
...(aclBuilderData.redirectRules || []).map(
(r) => (typeof r === 'string' ? r : ''),
),
];
if (aclRulesAll.some((r) => typeof r === 'string' && FILE_FLAG_RE.test(r))) {
message.error(
'One or more rules use the unsupported HAProxy `-f <file>` ' +
'pattern-file flag. HAProxy OpenManager does not provision ' +
'pattern files onto the HAProxy node filesystem, so the ' +
'reference would fail at reload time. Remove the `-f` flag ' +
'and use inline values instead before continuing.'
);
return;
}
// Issue #38 follow-up — the Bulgu #12 Step 2 → 3
// hard gate for the ACL `-f <file>` pattern-file
// flag was removed together with the server-side
// Pydantic rejects: pattern files are operator-
// managed host files (same policy as SPOE filter
// configs since v1.8.8) and the agent's pre-reload
// `haproxy -c` makes a missing file fail safely.
// The ACLRuleBuilder renders an informational note
// on `-f` rules instead of a blocking error.
// Phase K Phase D follow-up (Bulgu #13) — block
// advance when any routing / redirect rule has a
// self-contradictory condition (`acl1 !acl1`).
-5
View File
@@ -1,5 +0,0 @@
{
"version": "1.8.3",
"releaseName": "Agent heartbeat JSON fix",
"releaseDate": "2026-06-25"
}