mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-09-21 18:13:25 +00:00
02b1cb2bca
Closes #13, Closes #14. This release squashes the v1.4.0 → v1.5.0 development line. v1.4.0 shipped the ACME stability & enterprise audit (Issues #10/#11/#12). v1.5.0 builds on that foundation with two co-equal headline features plus a 22-round audit campaign hardening the prior configuration surface. License remains MIT for v1.5.0 (relicense to AGPL-3.0 lands in v1.5.2). ------------------------------------------------------------------ HEADLINE FEATURE A — ACME Diagnostic Panel (Issue #13) ------------------------------------------------------------------ A live pre-flight + post-failure diagnostic surface for every ACME order, reachable from the ACME Automation page. The panel exists to make ACME failures legible to operators who do NOT have shell access to the API host. Endpoints (`backend/routers/acme_diagnostics.py`): POST /api/letsencrypt/orders/{order_id}/diagnostics Run the full 5-check suite (DNS / port-80 / routing / account / agents) and humanize the order's `error_detail` (>=11 RFC-8555 problem types, backwards compatible with legacy plain-string failures). POST /api/letsencrypt/orders/{order_id}/diagnostics/ {check_id}/rerun Re-run a single check in place — used by the "Re-run" button on every row of the modal's pre-flight table. GET /api/letsencrypt/orders/{order_id}/events Merged event timeline combining the typed `acme_order_events` rows with correlated `user_activity_logs` entries (resource_type = 'letsencrypt_order' AND resource_id = order_id). The diagnostic modal auto-tails this timeline every 5 seconds while open. Service-level checks (`backend/services/acme_diagnostics.py`): * DNS resolution via stdlib socket.gethostbyname_ex through run_in_executor (intentionally avoiding an aiodns runtime dep for v1.5.0). * Port-80 HEAD probe, target locked to the order's domains, success on HTTP 200 OR 404, warns on egress timeout (corp egress policies routinely blackhole outbound 80 — fail-hard would be too noisy). * SSRF guard: probe refuses non-public IPs and surfaces the skip in the diagnostic result; IPv4-mapped IPv6 normalisation closes the `::ffff:169.254.169.254` cloud-metadata vector. * HAProxy routing presence check: matches the order's cluster_ids to a port-80 HTTP frontend. * ACME account validity check against `letsencrypt_accounts`. * Agent presence check (>=1 active agent in target cluster). * Every sub-check wrapped in a wall-clock timeout to bound impact on the API event loop. RBAC: ssl.read for run, ssl.read for events. Per-user 5/min rate limit on both run and rerun, backed by the (user_id, action, created_at DESC) composite index. Frontend (`frontend/src/components/ACMEAutomation.js`): * "Diagnose" button on every order row + the existing "stuck order" warning row. * Modal with two tabs: - Pre-flight Checks (Antd Table with status pills + Re-run buttons + humanized error banner) - Event Log (Antd Timeline with auto-tail polling, scroll- to-bottom, pause-on-hover) * Correlation IDs surfaced in error banners and individual check fail details for backend-log lookup. ------------------------------------------------------------------ HEADLINE FEATURE B — Site Setup Wizard (Issue #14) ------------------------------------------------------------------ A single guided flow that creates a Backend + Servers + HTTP Frontend (and optional HTTPS Frontend) in one atomic transaction. Endpoints (`backend/routers/site_wizard.py`): POST /api/site-wizard/preview — diff-preview the changeset POST /api/site-wizard/create — atomic execute POST /api/site-wizard/reject — clean rollback (including any wizard_staged ACME orders) GET /api/site-wizard/drafts — draft persistence PUT /api/site-wizard/drafts/{id} — save/update DELETE /api/site-wizard/drafts/{id} Feature surface: * One screen captures both backend (mode + servers) AND frontend (http + optional https + SSL mode) inputs. * SSL modes: ACME (new order, HTTP-01 only for v1.5.0), Upload (existing PEM), Existing (link to a stored cert), or None. * ACME-staged path: wizard_staged_until watermark on the `letsencrypt_orders` row defers finalisation until agent confirmation; per-mode reject cleanly cancels and rolls back the staged order. * Live diff preview against the cluster's current generated config (renderer-evolution noise stripped — track-sc<N> dedup, per-server cookie strip, defaults-cookie inheritance, listen-block flattening). * Draft persistence with PEM stripped at save time (private keys never round-trip through the drafts table). * Per-cluster multi-tenancy: drafts and wizard_staged orders are isolated to the creating user's cluster scope. Frontend (`frontend/src/components/SiteWizard.js`): * 4-step Antd Steps flow: Backend → Frontend → SSL → Review. * Render the live diff preview inline before commit. * Antd Form-level validation mirrors backend Pydantic validators (numeric bounds, HAProxy reserved keywords, ALPN consistency, IPv6 scope-id, domain regex, server name dedup). ------------------------------------------------------------------ AUDIT CAMPAIGN — Rounds 1 → 22 (Bulgu #1 → #82) ------------------------------------------------------------------ v1.5.0 includes 22 adversarial review passes. Each round produced its own commit set in the corporate development line; this squash collapses those into the v1.5.0 release artefact. Highlights: Round 1-4 Site Wizard core: dry-run parity, single-line value injection guard, ACL -f pattern-file block, SSL parity, timeout regex, form-state pin. Round 5-7 defaults-cookie inheritance, server-named-cookie guard, fe/be mode mismatch, duplicate server names, health_check_uri + server_address validators. Round 8-10 cookie_name / cookie_options newline-injection guard, dry-run parity (round 9), TCP-mode HTTP-only feature blockers. Round 11 SSL name path traversal + health-check >= 1. Round 12-13 SSL & ACME deep dive (Bulgu #23-#32). Round 14 single-line value injection (Bulgu #33). Round 15-17 ACME multi-tenant UX, numeric bounds, HAProxy reserved keywords, ALPN/TLS consistency, all-backup, multi-domain & multi-user enterprise edges, drain/HSTS/post-completion (Bulgu #34-#53). Round 18-21 concurrency, agent state, TCP-mode HTTP-only, list size caps, IPv6 scope-id, preview account validation, TCP backend + balance uri reject (Bulgu #54-#61). Round 22 FE error visibility + 3x stale-data lockouts, referential integrity + cascade safety, authentication & authorization, multi-cluster isolation, apply_pending_changes concurrency, script injection + bulk import multi-tenancy, prefix-stripped signature comparison (Bulgu #62-#82). ------------------------------------------------------------------ NO CORPORATE-SPECIFIC ARTIFACTS ------------------------------------------------------------------ This squash deliberately sanitises corporate hostnames, container registry references, and TLS secret names into generic placeholders (`your-registry.example.com/your-org`, `haproxy-openmanager*.example.com`, `wildcard-tls`, `taylanbakircioglu/haproxy-openmanager-*`) so the public artefact contains no internal infrastructure detail. Pilot / development history that retained those values stays in the corporate fork and is NOT part of this commit.
193 lines
8.3 KiB
Python
193 lines
8.3 KiB
Python
"""
|
|
Phase 2 (R11-audit follow-up, PR-5 scope): pre-persist HAProxy
|
|
config validator gate on the Site Wizard create endpoint.
|
|
|
|
The wizard now runs the rendered config through
|
|
``HAProxyConfigValidator`` BEFORE writing the ``config_versions``
|
|
PENDING row, and aborts the transaction (HTTP 422) if any
|
|
ERROR-level diagnostic fires. This is a defence-in-depth layer
|
|
on top of:
|
|
|
|
* Pydantic Literal/coerce validation on the request body.
|
|
* DB UNIQUE / FOREIGN KEY / partial-index constraints.
|
|
* Generator-side safeguards (``_apply_bind_ssl_verify``,
|
|
``_format_redirect_rule``, bucket-ordered emit, stick-table
|
|
dedup).
|
|
|
|
The user-stated rule is: "the UI must not allow any operation
|
|
that would fail HAProxy validation". This test suite pins the
|
|
gate's source-level structure so a future refactor cannot remove
|
|
it silently.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
_ROOT = Path(__file__).resolve().parents[1]
|
|
_ROUTER = _ROOT / "routers" / "site_wizard.py"
|
|
|
|
|
|
def _src() -> str:
|
|
return _ROUTER.read_text()
|
|
|
|
|
|
def test_pre_persist_validator_gate_imports_validator():
|
|
"""The wizard create endpoint must import the validator INSIDE
|
|
the transaction (lazy import) so a missing module never breaks
|
|
cold-start, and call its `validate_config` on the generated
|
|
string."""
|
|
src = _src()
|
|
assert "HAProxyConfigValidator" in src, (
|
|
"Phase 2 regression: HAProxyConfigValidator import is gone "
|
|
"from the wizard router — the pre-persist gate is missing."
|
|
)
|
|
assert "ValidationLevel" in src, (
|
|
"Phase 2 regression: ValidationLevel import is gone — gate "
|
|
"cannot distinguish ERROR from WARNING/SUGGESTION."
|
|
)
|
|
|
|
|
|
def test_pre_persist_gate_runs_inside_transaction():
|
|
"""The validator call must live INSIDE the
|
|
`async with conn.transaction():` block so that a 422 raises
|
|
BEFORE the `INSERT INTO config_versions ...` and the transaction
|
|
rolls back, leaving the database clean (no orphan
|
|
backend / server / frontend rows for an invalid wizard run)."""
|
|
src = _src()
|
|
# Find the transaction block start
|
|
tx_idx = src.find("async with conn.transaction():")
|
|
assert tx_idx > 0, "transaction block not found in wizard router"
|
|
# The validator call must appear after the transaction start
|
|
val_idx = src.find("HAProxyConfigValidator()", tx_idx)
|
|
assert val_idx > tx_idx, (
|
|
"Phase 2 regression: HAProxyConfigValidator call must be "
|
|
"INSIDE the wizard's `async with conn.transaction():` "
|
|
"block so a 422 rolls back the freshly-inserted entities."
|
|
)
|
|
# The config_versions INSERT must come AFTER the validator gate
|
|
insert_idx = src.find("INSERT INTO config_versions", val_idx)
|
|
assert insert_idx > val_idx, (
|
|
"Phase 2 regression: validator gate is AFTER the "
|
|
"`INSERT INTO config_versions` — gate is structurally "
|
|
"ineffective (the PENDING row is already persisted)."
|
|
)
|
|
|
|
|
|
def test_pre_persist_gate_blocks_on_error_level_only():
|
|
"""The gate must filter for ERROR-level results — a WARNING
|
|
(e.g. an unknown but harmless directive) must NOT block create.
|
|
Only fatal-equivalent diagnostics abort the transaction."""
|
|
src = _src()
|
|
# The list comprehension that filters validator results must
|
|
# reference ValidationLevel.ERROR explicitly.
|
|
filter_pattern = re.compile(
|
|
r"if r\.level == ValidationLevel\.ERROR",
|
|
re.MULTILINE,
|
|
)
|
|
assert filter_pattern.search(src), (
|
|
"Phase 2 regression: validator gate must filter "
|
|
"`r.level == ValidationLevel.ERROR` — without the filter "
|
|
"the gate would block on harmless WARNING-level findings."
|
|
)
|
|
|
|
|
|
def test_pre_persist_gate_raises_422_with_structured_error_payload():
|
|
"""When the gate fires it must raise HTTP 422 (Unprocessable
|
|
Entity) with a structured `errors` array so the frontend can
|
|
render the issues as a list, not a giant dump."""
|
|
src = _src()
|
|
# 422 status code
|
|
assert "status_code=422" in src, (
|
|
"Phase 2 regression: validator gate must use HTTP 422, "
|
|
"not a generic 400 — 422 is the correct status for a "
|
|
"syntactically-valid request that fails business / "
|
|
"validation rules."
|
|
)
|
|
# Structured error tag
|
|
assert '"haproxy_validation_failed"' in src, (
|
|
"Phase 2 regression: validator gate must surface the "
|
|
"`haproxy_validation_failed` error tag so the frontend "
|
|
"can route the response into the dedicated banner."
|
|
)
|
|
# Structured `errors` field with line/message
|
|
assert '"line": e.line_number' in src and '"message": e.message' in src, (
|
|
"Phase 2 regression: validator gate response must include "
|
|
"per-error `line` + `message` so the operator can locate "
|
|
"the offending rule."
|
|
)
|
|
|
|
|
|
def test_pre_persist_gate_caps_error_payload():
|
|
"""Defence: a misconfigured cluster could produce hundreds of
|
|
validator findings. The response payload caps to the first 20
|
|
so we don't blow up the wire / UI table with 500+ rows."""
|
|
src = _src()
|
|
# The slice [:20] must be present on the validator-error list
|
|
assert "_val_errors[:20]" in src, (
|
|
"Phase 2 regression: validator gate response should cap "
|
|
"the error array (first 20 items) — uncapped payloads can "
|
|
"blow up the response size for misconfigured clusters."
|
|
)
|
|
|
|
|
|
def test_pre_persist_gate_validator_crash_is_non_fatal():
|
|
"""If the validator itself crashes (NEW directive it doesn't
|
|
recognise, edge case in our static rules) the wizard create
|
|
must NOT block — the apply-time `haproxy -c` on the agent is
|
|
the ultimate authority. We log a WARNING and proceed."""
|
|
src = _src()
|
|
# The validator's own try/except must catch the broad Exception
|
|
# AFTER re-raising HTTPException so 422s still escape.
|
|
assert "except HTTPException:" in src, (
|
|
"Phase 2 regression: validator gate must let HTTPException "
|
|
"bubble up (transaction rollback path) but swallow other "
|
|
"exceptions to keep validator bugs from blocking creation."
|
|
)
|
|
assert "non-fatal" in src.lower() or "validator crashed" in src.lower(), (
|
|
"Phase 2 regression: validator gate's broad-except branch "
|
|
"must log a clear 'non-fatal / validator crashed' message "
|
|
"so operators can tell a validator bug from an actual "
|
|
"config problem in the logs."
|
|
)
|
|
|
|
|
|
def test_pre_persist_gate_only_runs_on_non_empty_config():
|
|
"""If the upstream config-generation step itself raised, the
|
|
string is empty and we already logged ERROR. Skipping the
|
|
validator on an empty string avoids a misleading 'config has
|
|
no `frontend` section' warning when the real cause was the
|
|
generator crash above.
|
|
|
|
Phase K Phase C: site_wizard.py now also calls
|
|
`HAProxyConfigValidator()` from inside `preview_create` on the
|
|
dry-run path (gated by `if validate_haproxy_config:`). The
|
|
create-time gate stays guarded by `if config_content:` — the
|
|
pin walks past the preview-side call so it lands on the
|
|
create_site validator call, which is the path this test
|
|
actually pins.
|
|
"""
|
|
src = _src()
|
|
# Pin the create_site path's validator call. There are two
|
|
# validator references after Phase K: one in `preview_create`
|
|
# (gated by `if validate_haproxy_config:`) and one in
|
|
# `create_site` (gated by `if config_content:`). We want the
|
|
# latter — the create-time gate where empty config_content can
|
|
# surface from a generator crash.
|
|
create_idx = src.find("async def create_site(")
|
|
assert create_idx > 0, "create_site function not found"
|
|
val_idx = src.find("HAProxyConfigValidator()", create_idx)
|
|
assert val_idx > 0, "HAProxyConfigValidator() call not found inside create_site"
|
|
# Phase K Phase D follow-up (Bulgu #12 round 3): the explanatory
|
|
# comment block above the validator call grew when we forwarded
|
|
# `partial_fragment=True` to suppress false-positive global-section
|
|
# warnings. Widen the lookback window so the pin keeps catching the
|
|
# guard while accommodating the larger inline docs.
|
|
pre_window = src[max(0, val_idx - 1500) : val_idx]
|
|
assert "if config_content:" in pre_window, (
|
|
"Phase 2 regression: validator gate must be guarded by "
|
|
"`if config_content:` — running the validator on an empty "
|
|
"string surfaces misleading 'no frontend section' findings "
|
|
"when the real failure was upstream config generation."
|
|
)
|