mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-09-22 18:43:26 +00:00
2e7db4d99f
A live-deployment audit pass over the v1.5.0 Site Wizard + ACME Diagnostic Panel surface. Two adversarial review rounds (R23, R24) each capped by an end-to-end smoke test against a multi-cluster staging deployment. Bulgu #83 — Frontend Management page warned about stale data without a clear retry CTA. The toast now carries an in-place "Reload" action and the page-level Empty state surfaces the same recovery affordance, so operators never get stuck on a stale-data view without an obvious way out. Bulgu #84 — ACME diagnostics ran with the wrong "last_heartbeat" column reference against the agents table. Aligned the SELECT with the actual schema column (`last_seen`); pinned by an idempotent regression test in `test_acme_diagnostics.py`. Bulgu #85 — ACME order error_detail rendering could leak the raw asyncpg/SQL exception class name when humanize_error_detail encountered an unhandled CA response shape. Added a backwards- compatible fallback branch that emits an "ACME error (raw)" panel without exposing parse_error class name to the user. Bulgu #86 — Multi-cluster apply with concurrent rejects could leave wizard_staged orders dangling without their parent draft. Pinned via reject_order_with_cluster_orphan test. Bulgu #87 — Frontend Management page list virtualization mis-keyed during a re-sort + stale-row replace race; fixed by keying rows on `id + version` so React reconciler does not reuse DOM for a logically different row. Bulgu #88 — Site Wizard "Cancel" mid-flow now surfaces an unsaved-draft prompt with explicit Save / Discard buttons (and the same prompt on browser tab close), so the operator never loses 5 steps of input to an accidental ESC. Bulgu #89 — Existing-cert SSL mode showed an empty dropdown when the cluster had >100 certs because the listing endpoint default-limited results. Endpoint now exposes pagination AND the wizard switches to client-side filtering above 50 rows. Bulgu #90 — ACME pre-check on the wizard preview path did NOT re-validate the account against `letsencrypt_accounts` if the operator stepped Back/Forward between SSL and Review. Added a debounced re-validation on Review entry. Bulgu #93 — Site Wizard hsts_enabled toggle in HTTPS frontend was idempotent-by-name (the generated `http-response set-header Strict-Transport-Security` line could duplicate across a Save + Apply cycle). The renderer now upserts the header in place. Cumulative outcome: backend pytest 1084/1084, frontend lint clean, and a 6-hour live-deployment smoke session against staging with no regressions reported.
3403 lines
160 KiB
Python
3403 lines
160 KiB
Python
"""
|
||
v1.5.0 Feature B (Issue #14): New Site Setup Wizard router.
|
||
|
||
The `Proxied Host` brand name was retired in favour of `Site` for
|
||
the user-facing surface; this module file (`routers/site_wizard.py`)
|
||
and the API URL prefix (`/api/sites`) follow the new naming. The
|
||
legacy URL prefix `/api/proxied-hosts` is preserved as a backward-
|
||
compat alias (registered at app-mount time in `main.py`) so any
|
||
external integrator still pointing at the old slug keeps working
|
||
during a transition window.
|
||
|
||
Endpoints (plural prefix `/api/sites`):
|
||
- GET /api/sites/suggest - smart-default suggestions
|
||
- POST /api/sites/preflight-acme - run ACME diagnostics shape
|
||
- POST /api/sites/preview - non-mutating diff preview
|
||
- POST /api/sites - atomic multi-entity create
|
||
- POST /api/sites/drafts - save wizard draft (PEM stripped)
|
||
- GET /api/sites/drafts - list current user's drafts
|
||
- DELETE /api/sites/drafts/{draft_id} - delete a draft
|
||
|
||
Legacy alias (deprecated, hidden from OpenAPI):
|
||
- /api/proxied-hosts/* → 308 redirect to /api/sites/*
|
||
|
||
The atomic create flow is ordered:
|
||
1. Pre-create collision checks (R35/M12 + M43/R60)
|
||
2. backend_service.create_backend_row
|
||
3. backend_service.create_server_row * N
|
||
4. SSL branch by mode:
|
||
acme -> NO ssl row written (R16); HTTPS frontend deferred
|
||
upload -> ssl_service.create_cert_row + HTTPS frontend
|
||
existing -> ssl_service.select_existing_cert + HTTPS frontend
|
||
none -> skip
|
||
5. HTTP frontend (always)
|
||
6. config_versions PENDING with metadata.bulk_snapshots + pre_apply_snapshot
|
||
(version_name = 'bulk-site-create-{ts}'; the legacy
|
||
prefix 'bulk-proxied-host-create-{ts}' is still
|
||
recognised by the cluster reject path for backward
|
||
compat with already-applied versions)
|
||
7. POST-COMMIT:
|
||
a) apply_immediately=true -> apply_service.apply_cluster_pending
|
||
b) ssl.mode='acme' -> letsencrypt_service.create_order_staged
|
||
with post_completion_actions JSONB
|
||
c) extend metadata.bulk_snapshots to include the staged
|
||
letsencrypt_order id (R43/M27)
|
||
"""
|
||
|
||
import json
|
||
import logging
|
||
import re
|
||
import time
|
||
from datetime import datetime
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
from asyncpg.exceptions import (
|
||
ForeignKeyViolationError,
|
||
UndefinedColumnError,
|
||
UndefinedTableError,
|
||
UniqueViolationError,
|
||
)
|
||
from fastapi import APIRouter, Header, HTTPException
|
||
from pydantic import ValidationError
|
||
|
||
from auth_middleware import check_user_permission, get_current_user_from_token
|
||
from database.connection import close_database_connection, get_database_connection
|
||
from models.site_wizard import (
|
||
SiteCreate,
|
||
SiteDraftCreate,
|
||
SitePreflightAcme,
|
||
SSLChoice,
|
||
_strip_pem_from_payload,
|
||
)
|
||
from services.acme_diagnostics import run_checks
|
||
from services.backend_service import create_backend_row, create_server_row
|
||
from services.frontend_service import check_bind_port_collision, create_frontend_row
|
||
from services.ssl_service import (
|
||
create_cert_row,
|
||
ensure_cluster_junction,
|
||
select_existing_cert,
|
||
validate_server_ca_bundle_eligibility,
|
||
)
|
||
from services.letsencrypt_service import create_order_staged
|
||
from utils.activity_log import record_event
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter(
|
||
prefix="/api/sites",
|
||
# The HTTP prefix is `/api/sites` to match the user-visible "Site
|
||
# Wizard" rebrand. The legacy `/api/proxied-hosts/*` slug is
|
||
# preserved as a hidden 308-redirect alias on `main.py` so existing
|
||
# external integrators / runbooks continue to work during the
|
||
# transition window.
|
||
tags=["Sites (New Site Wizard)"],
|
||
)
|
||
|
||
_RATE_LIMIT_PER_MIN = 5
|
||
|
||
|
||
def _now_ts() -> int:
|
||
return int(time.time())
|
||
|
||
|
||
async def _validate_user_cluster_access(user_id: int, cluster_id: int, conn) -> None:
|
||
"""Mirror of routers/frontend.py:validate_user_cluster_access (admin
|
||
bypass + user_pool_access table check). Raises HTTPException on denial.
|
||
"""
|
||
cluster_exists = await conn.fetchval(
|
||
"SELECT id FROM haproxy_clusters WHERE id = $1", cluster_id
|
||
)
|
||
if not cluster_exists:
|
||
raise HTTPException(status_code=404, detail="Cluster not found")
|
||
|
||
is_admin = await conn.fetchval("SELECT is_admin FROM users WHERE id = $1", user_id)
|
||
if is_admin:
|
||
return
|
||
|
||
table_exists = await conn.fetchval(
|
||
"""
|
||
SELECT EXISTS (
|
||
SELECT 1 FROM information_schema.tables WHERE table_name = 'user_pool_access'
|
||
)
|
||
"""
|
||
)
|
||
if not table_exists:
|
||
return
|
||
|
||
user_access = await conn.fetchrow(
|
||
"""
|
||
SELECT upa.access_level
|
||
FROM haproxy_clusters hc
|
||
JOIN haproxy_cluster_pools hcp ON hc.pool_id = hcp.id
|
||
JOIN user_pool_access upa ON hcp.id = upa.pool_id
|
||
WHERE upa.user_id = $1 AND hc.id = $2 AND upa.is_active = TRUE
|
||
AND (upa.expires_at IS NULL OR upa.expires_at > CURRENT_TIMESTAMP)
|
||
""",
|
||
user_id,
|
||
cluster_id,
|
||
)
|
||
if not user_access:
|
||
raise HTTPException(
|
||
status_code=403,
|
||
detail="You don't have access to this cluster.",
|
||
)
|
||
|
||
|
||
async def _can_use_wizard(user_id: int, *, current_user: Optional[dict] = None) -> bool:
|
||
"""Bug A fix: drafts + suggest are PERSONAL/UTILITY — every authenticated
|
||
user may keep their own draft work and probe wizard helpers. Granular
|
||
RBAC (`frontend.create`, `ssl.create`, etc.) still gates the actual
|
||
create endpoint; this wider check just lets the user reach the wizard
|
||
UI without a 403 wall when they hold a read-only role or are
|
||
mid-permission-rotation.
|
||
|
||
R12 perf fix: previously this helper called `check_user_permission`
|
||
up to 6 times in sequence — each call opened a fresh DB connection
|
||
and ran a roles-query — so on every drafts list / save / preview the
|
||
backend issued up to 6 separate Postgres round-trips. Now we:
|
||
* short-circuit on `current_user.is_admin` (no query at all), and
|
||
* fall back to a single `get_user_permissions` call and decide in
|
||
memory.
|
||
|
||
Returns True if the user is admin OR has ANY of the host-management
|
||
permissions.
|
||
"""
|
||
# Admin shortcut — get_current_user_from_token already populates
|
||
# `is_admin` from the users table; if the caller passes the user dict
|
||
# we can skip the roles roundtrip entirely.
|
||
if current_user and current_user.get("is_admin"):
|
||
return True
|
||
|
||
try:
|
||
from auth_middleware import get_user_permissions
|
||
perms = await get_user_permissions(user_id)
|
||
except Exception:
|
||
# On error treat as denied; the existing endpoints already have
|
||
# broader auth checks, so failing closed is safe here.
|
||
return False
|
||
|
||
# R18c round 10 (CRITICAL): seeded role permissions in
|
||
# database/migrations.py use the PLURAL resource names — `frontends`,
|
||
# `backends`, `ssl` — to match the CRUD routers (frontend.py /
|
||
# backend.py / ssl.py). Pre-round-10 this loop checked SINGULAR
|
||
# `frontend` / `backend` / `ssl`, which never existed in any seeded
|
||
# role's permissions JSONB. Result: every non-admin user got
|
||
# `_can_use_wizard=False` regardless of how many wizard-eligible
|
||
# permissions their role granted, and the R18c-#7 admin bypass was
|
||
# the ONLY thing keeping the feature operable. The fix here aligns
|
||
# the resource keys with the rest of the codebase. `ssl` was already
|
||
# in the right namespace (ssl.* is the seeded form for both the
|
||
# wizard and the SSL Management page).
|
||
candidate_perms = (
|
||
("frontends", "read"),
|
||
("frontends", "create"),
|
||
("ssl", "read"),
|
||
("ssl", "create"),
|
||
("backends", "create"),
|
||
("backends", "read"),
|
||
)
|
||
for resource, action in candidate_perms:
|
||
if perms.get(resource, {}).get(action, False):
|
||
return True
|
||
return False
|
||
|
||
|
||
async def _enforce_rate_limit(conn, user_id: int, action: str) -> None:
|
||
"""Per-user-per-minute rate limit driven by user_activity_logs.
|
||
|
||
Phase D/I rebrand: the action_name emitted by activity_logger
|
||
moved from `proxied_host_*` to `site_*` for the same logical
|
||
operation (e.g. `site_acme_preflight` is the post-rebrand name
|
||
of the preflight log row). The rate-limit COUNT(*) must look at
|
||
BOTH names so:
|
||
- the limit cannot be bypassed by an attacker who picks the
|
||
legacy action_name (it is no longer emitted, but defensive),
|
||
- operators whose recent log rows pre-date the rename are still
|
||
counted (a deploy roll happens mid-minute and we don't want
|
||
a sudden burst of free quota for an in-flight session).
|
||
|
||
The caller passes the canonical `site_*` action; we derive the
|
||
legacy `proxied_host_*` companion automatically when the action
|
||
starts with `site_` so callers don't have to track both names.
|
||
"""
|
||
aliases = [action]
|
||
if action.startswith("site_"):
|
||
legacy = "proxied_host_" + action[len("site_"):]
|
||
aliases.append(legacy)
|
||
cnt = await conn.fetchval(
|
||
"""
|
||
SELECT COUNT(*) FROM user_activity_logs
|
||
WHERE user_id = $1 AND action = ANY($2::text[])
|
||
AND created_at >= NOW() - INTERVAL '60 seconds'
|
||
""",
|
||
user_id,
|
||
aliases,
|
||
)
|
||
if cnt is not None and cnt >= _RATE_LIMIT_PER_MIN:
|
||
raise HTTPException(
|
||
status_code=429,
|
||
detail=f"Rate limit exceeded: {action} allowed {_RATE_LIMIT_PER_MIN}/min",
|
||
)
|
||
|
||
|
||
async def _resolve_default_acme_account(conn) -> Optional[int]:
|
||
"""Round 12 explicit fix: pick the most-recent valid ACME account."""
|
||
return await conn.fetchval(
|
||
"""
|
||
SELECT id FROM letsencrypt_accounts
|
||
WHERE status = 'valid'
|
||
ORDER BY created_at DESC
|
||
LIMIT 1
|
||
"""
|
||
)
|
||
|
||
|
||
async def _find_cluster_port80_http_frontend(
|
||
conn, cluster_id: int, exclude_name: Optional[str] = None
|
||
) -> Optional[dict]:
|
||
"""Bulgu #34 (round-15 audit) — return a dict describing the cluster's
|
||
existing port-80 HTTP-mode frontend, if any, so the wizard can decide
|
||
whether the new site needs its own port-80 binding.
|
||
|
||
The renderer (services/haproxy_config.py:974-978) injects the
|
||
`/.well-known/acme-challenge/` ACL + `use_backend
|
||
_acme_challenge_backend` into EVERY HTTP-mode frontend in a cluster
|
||
with `acme_enabled=true`. So when the cluster already has SOME HTTP
|
||
frontend listening on port 80, Let's Encrypt's HTTP-01 probe lands
|
||
on THAT frontend and the agent serves the token regardless of which
|
||
domain LE asked for — the new wizard frontend does NOT need to be
|
||
on port 80.
|
||
|
||
Returns the most-relevant row (any active HTTP-mode frontend bound to
|
||
*:80 or to any of the cluster agents' addresses on :80) or None.
|
||
|
||
`exclude_name` lets the caller skip the frontend currently being
|
||
created/extended (useful for preflight UI hints).
|
||
"""
|
||
rows = await conn.fetch(
|
||
"""
|
||
SELECT id, name, bind_address, bind_port, mode
|
||
FROM frontends
|
||
WHERE cluster_id = $1
|
||
AND is_active = TRUE
|
||
AND mode = 'http'
|
||
AND bind_port = 80
|
||
AND ($2::text IS NULL OR name <> $2)
|
||
ORDER BY bind_address ASC, id ASC
|
||
LIMIT 1
|
||
""",
|
||
cluster_id,
|
||
exclude_name,
|
||
)
|
||
if rows:
|
||
r = rows[0]
|
||
return {
|
||
"id": r["id"],
|
||
"name": r["name"],
|
||
"bind_address": r["bind_address"],
|
||
"bind_port": r["bind_port"],
|
||
"mode": r["mode"],
|
||
}
|
||
return None
|
||
|
||
|
||
async def _validate_acme_port80_reachable(
|
||
conn, body: SiteCreate
|
||
) -> Optional[str]:
|
||
"""Bulgu #34 (round-15 audit) — cluster-aware HTTP-01 reachability check.
|
||
|
||
The legacy SiteCreate model rejected ANY ACME payload that did not
|
||
bind on port 80 outright. That blanket rule didn't fit the canonical
|
||
enterprise pattern (one shared port-80 frontend host-routing many
|
||
sites), so operators on multi-tenant clusters could not use ACME at
|
||
all from the wizard.
|
||
|
||
Returns:
|
||
* None when the payload is acceptable for ACME.
|
||
* An actionable human-readable error string otherwise.
|
||
|
||
The route handler converts the non-None return into an HTTPException.
|
||
Both `create_site` and `preview_create` use it so the message is
|
||
consistent across preview and submit.
|
||
"""
|
||
if body.ssl.mode != "acme":
|
||
return None
|
||
if body.frontend.bind_port == 80:
|
||
# The wizard's own new frontend will serve the challenge.
|
||
return None
|
||
# Non-80 wizard frontend → the cluster MUST already have a port-80
|
||
# HTTP frontend that the renderer is auto-injecting the ACME ACL
|
||
# into. Otherwise LE's HTTP-01 probe has nowhere to land.
|
||
existing = await _find_cluster_port80_http_frontend(
|
||
conn, body.cluster_id, exclude_name=body.frontend.name
|
||
)
|
||
if existing is None:
|
||
return (
|
||
f"ssl.mode='acme' with frontend.bind_port={body.frontend.bind_port} "
|
||
f"requires the cluster to already have a port-80 HTTP frontend "
|
||
"that Let's Encrypt's HTTP-01 probe can reach. Cluster "
|
||
f"{body.cluster_id} has no such frontend, so the order would "
|
||
"fail at validation. Options: "
|
||
"(a) set frontend.bind_port=80 so the wizard's new frontend "
|
||
"serves the challenge itself, or "
|
||
"(b) first create / activate a shared port-80 HTTP frontend "
|
||
"on this cluster (Frontends UI → Add), then re-run the "
|
||
"wizard with your preferred non-standard bind port, or "
|
||
"(c) switch ssl.mode to 'upload' / 'existing' (no HTTP-01 "
|
||
"challenge needed)."
|
||
)
|
||
# The renderer only injects the ACME ACL when cluster.acme_enabled
|
||
# is on. The route handler checks the flag separately (and gives a
|
||
# dedicated error), so we only need to verify reachability here.
|
||
return None
|
||
|
||
|
||
_HOST_ACL_RE = re.compile(
|
||
# Captures hdr(host)[,lower] [-i] tokens followed by domain values
|
||
# until the next ACL flag/EOL. Examples this MUST match:
|
||
# acl host_x hdr(host) -i example.com
|
||
# acl host_x hdr(host),lower example.com www.example.com
|
||
# acl host_x hdr_dom(host) example.com
|
||
# acl host_x req.hdr(host) -i example.com
|
||
r"\b(?:hdr|req\.hdr|hdr_dom|hdr_str|hdr_beg|hdr_end|hdr_reg)\s*"
|
||
r"\(\s*host\s*\)(?:\s*,\s*[a-z_]+)*\s+(?:-[a-zA-Z]+\s+)*"
|
||
r"(?P<values>[^#\n]+)",
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
|
||
def _extract_host_values_from_acl(rule: str) -> List[str]:
|
||
"""Bulgu #37 (round-16 audit) — coarse parser for host-match values
|
||
inside a single HAProxy `acl …` directive string.
|
||
|
||
The wizard stores `acl_rules` as a JSONB list of operator-typed
|
||
directive strings (e.g. `host_example hdr(host) -i example.com
|
||
www.example.com`). To detect when a new wizard site claims a
|
||
domain that ANOTHER active frontend in the same cluster already
|
||
routes, we parse out the values following `hdr(host)` / its
|
||
aliases.
|
||
|
||
The parser intentionally errs on the side of OVER-collecting (we
|
||
surface the operator with the matched ACL string anyway, so a
|
||
false positive turns into a clear warning rather than a silent
|
||
miss). Tokens beginning with `-` (HAProxy flags) and tokens
|
||
consumed by trailing match keywords (`if`, `unless`) are dropped
|
||
so we don't mistake `if`/`unless` for domain values.
|
||
|
||
Returns the lowercased deduped value list.
|
||
"""
|
||
if not rule or not isinstance(rule, str):
|
||
return []
|
||
match = _HOST_ACL_RE.search(rule)
|
||
if not match:
|
||
return []
|
||
raw = match.group("values").strip()
|
||
out: List[str] = []
|
||
seen: set = set()
|
||
for tok in raw.split():
|
||
if not tok:
|
||
continue
|
||
low = tok.lower()
|
||
# Stop at trailing match operator keywords (the wizard doesn't
|
||
# emit these inline with the values, but operator-typed
|
||
# `acl_rules` strings might).
|
||
if low in ("if", "unless", "or", "and", "&&", "||"):
|
||
break
|
||
# Drop HAProxy flags (-i, -m, -f, ...). These can appear
|
||
# MID-value list if the operator chained two patterns; we
|
||
# treat that as the end-of-value.
|
||
if low.startswith("-"):
|
||
continue
|
||
# Strip wrapping quotes (operators sometimes quote domains).
|
||
cleaned = low.strip('"').strip("'")
|
||
if not cleaned or cleaned in seen:
|
||
continue
|
||
seen.add(cleaned)
|
||
out.append(cleaned)
|
||
return out
|
||
|
||
|
||
async def _find_cluster_domain_routing_collisions(
|
||
conn, cluster_id: int, domains: List[str], exclude_frontend_name: Optional[str] = None
|
||
) -> List[dict]:
|
||
"""Bulgu #37 (round-16 audit) — scan the cluster's existing frontends
|
||
for `acl … hdr(host) … <domain>` matches against the wizard's new
|
||
domain list.
|
||
|
||
Two sites in the same cluster claiming the same Host-header value
|
||
create undefined routing — HAProxy picks the first frontend that
|
||
binds the matching address:port, and once a request lands there
|
||
the `default_backend` (or use_backend ACL hit order) decides. The
|
||
wizard's new frontend on a different port wouldn't directly
|
||
collide, but the OPERATOR likely thinks the new site is the
|
||
authoritative routing target — wrong.
|
||
|
||
Returns a list of `{frontend_id, frontend_name, acl_rule,
|
||
conflicting_domains}` dicts (empty when no collisions). Each entry
|
||
is human-readable so the caller can surface it directly as a
|
||
warning / blocking_error.
|
||
"""
|
||
if not domains:
|
||
return []
|
||
domain_lookup = {d.lower(): d for d in domains}
|
||
# Bulgu #53 (round-18 audit) — also scan `use_backend_rules`.
|
||
#
|
||
# Operators can express host-based routing two equivalent ways:
|
||
#
|
||
# (A) name the ACL separately and reference it:
|
||
# acl_rules = ["host_x hdr(host) -i example.com"]
|
||
# use_backend_rules = ["mybe if host_x"]
|
||
#
|
||
# (B) inline the host condition directly inside use_backend:
|
||
# use_backend_rules = ["mybe if { hdr(host) -i example.com }"]
|
||
#
|
||
# The pre-round-18 collision scan only walked `acl_rules`, so form
|
||
# (B) on an existing frontend would never match the new wizard
|
||
# site's domain list — silently allowing two frontends to both
|
||
# answer for `example.com`.
|
||
#
|
||
# `redirect_rules` and `tcp_request_rules` can carry the same
|
||
# inline hdr(host) pattern. Both are extracted here so the
|
||
# scan covers every place a routing decision is taken on the
|
||
# Host: header.
|
||
rows = await conn.fetch(
|
||
"""
|
||
SELECT id, name, acl_rules, use_backend_rules,
|
||
redirect_rules, tcp_request_rules
|
||
FROM frontends
|
||
WHERE cluster_id = $1
|
||
AND is_active = TRUE
|
||
AND ($2::text IS NULL OR name <> $2)
|
||
""",
|
||
cluster_id,
|
||
exclude_frontend_name,
|
||
)
|
||
|
||
def _decode_jsonb_list(raw) -> List:
|
||
"""JSONB → Python list (handles str/list/None)."""
|
||
if not raw:
|
||
return []
|
||
try:
|
||
if isinstance(raw, str):
|
||
decoded = json.loads(raw)
|
||
else:
|
||
decoded = raw
|
||
except (json.JSONDecodeError, TypeError):
|
||
return []
|
||
return decoded if isinstance(decoded, list) else []
|
||
|
||
def _row_field(row, key):
|
||
"""Defensive accessor — asyncpg Records and dict-like stubs
|
||
both expose `row[key]`, but a row that was built with a SELECT
|
||
list missing one of the new round-18 columns would raise
|
||
KeyError. Treat 'missing' as 'no rule of that origin'."""
|
||
try:
|
||
return row[key]
|
||
except (KeyError, IndexError):
|
||
return None
|
||
|
||
collisions: List[dict] = []
|
||
for r in rows:
|
||
# Merge every source of host conditions on this frontend into
|
||
# a single (origin_label, rule_string) sequence. The label is
|
||
# surfaced in the warning so the operator knows WHERE the
|
||
# conflicting rule lives on the offending frontend.
|
||
candidates: List[tuple] = []
|
||
for rule in _decode_jsonb_list(_row_field(r, "acl_rules")):
|
||
if isinstance(rule, str):
|
||
candidates.append(("acl_rules", rule))
|
||
for rule in _decode_jsonb_list(_row_field(r, "use_backend_rules")):
|
||
if isinstance(rule, str):
|
||
candidates.append(("use_backend_rules", rule))
|
||
for rule in _decode_jsonb_list(_row_field(r, "redirect_rules")):
|
||
# redirect_rules can be either operator-typed strings or
|
||
# dict objects with `{"condition": "..."}`. Coerce both.
|
||
if isinstance(rule, str):
|
||
candidates.append(("redirect_rules", rule))
|
||
elif isinstance(rule, dict):
|
||
cond = rule.get("condition") or rule.get("if") or ""
|
||
if isinstance(cond, str) and cond:
|
||
candidates.append(("redirect_rules", cond))
|
||
for rule in _decode_jsonb_list(_row_field(r, "tcp_request_rules")):
|
||
if isinstance(rule, str):
|
||
candidates.append(("tcp_request_rules", rule))
|
||
|
||
if not candidates:
|
||
continue
|
||
for origin, rule in candidates:
|
||
host_values = _extract_host_values_from_acl(rule)
|
||
if not host_values:
|
||
continue
|
||
conflicting = sorted(
|
||
set(host_values) & set(domain_lookup.keys())
|
||
)
|
||
if conflicting:
|
||
collisions.append({
|
||
"frontend_id": r["id"],
|
||
"frontend_name": r["name"],
|
||
"acl_rule": rule.strip(),
|
||
"rule_origin": origin,
|
||
"conflicting_domains": conflicting,
|
||
})
|
||
return collisions
|
||
|
||
|
||
async def _find_pending_acme_order_overlap(
|
||
conn, domains: List[str], exclude_order_id: Optional[int] = None
|
||
) -> List[dict]:
|
||
"""Bulgu #38 (round-16 audit) — detect open ACME orders that
|
||
already cover any of the wizard's domains.
|
||
|
||
Both Let's Encrypt rate-limits and the agent's order-completion
|
||
state machine assume each domain is in AT MOST ONE active order
|
||
at a time. Two wizard runs racing the same domain produce:
|
||
* duplicate LE orders (counts against the 300-new-orders /
|
||
3-hour limit unnecessarily);
|
||
* duplicate cert rows once both complete;
|
||
* undefined `use_backend` order for the deferred HTTPS frontend
|
||
create action.
|
||
|
||
Active statuses we treat as overlapping:
|
||
* 'wizard_staged' — saved by the wizard, agent not yet
|
||
confirmed.
|
||
* 'pending' — LE has the order, awaiting validation.
|
||
* 'processing' — finalize in flight.
|
||
* 'ready' — finalize permitted but not yet run.
|
||
* 'valid_pending_apply' — cert downloaded, HTTPS frontend create
|
||
still queued.
|
||
|
||
Returns `[{order_id, status, overlapping_domains, created_at}]`
|
||
(empty when no overlap).
|
||
"""
|
||
if not domains:
|
||
return []
|
||
rows = await conn.fetch(
|
||
"""
|
||
SELECT id, status, domains, created_at
|
||
FROM letsencrypt_orders
|
||
WHERE status IN (
|
||
'wizard_staged', 'pending', 'processing', 'ready',
|
||
'valid_pending_apply'
|
||
)
|
||
AND ($2::bigint IS NULL OR id <> $2)
|
||
AND EXISTS (
|
||
SELECT 1 FROM jsonb_array_elements_text(domains) AS d
|
||
WHERE LOWER(d) = ANY($1::text[])
|
||
)
|
||
ORDER BY created_at DESC
|
||
""",
|
||
[d.lower() for d in domains],
|
||
exclude_order_id,
|
||
)
|
||
overlaps: List[dict] = []
|
||
domain_lookup = {d.lower() for d in domains}
|
||
for r in rows:
|
||
order_domains_raw = r["domains"]
|
||
try:
|
||
if isinstance(order_domains_raw, str):
|
||
order_domains = json.loads(order_domains_raw)
|
||
else:
|
||
order_domains = order_domains_raw
|
||
except (json.JSONDecodeError, TypeError):
|
||
order_domains = []
|
||
if not isinstance(order_domains, list):
|
||
continue
|
||
order_lower = {str(d).lower() for d in order_domains}
|
||
overlap = sorted(order_lower & domain_lookup)
|
||
if not overlap:
|
||
continue
|
||
overlaps.append({
|
||
"order_id": r["id"],
|
||
"status": r["status"],
|
||
"overlapping_domains": overlap,
|
||
"created_at": str(r["created_at"]) if r["created_at"] else None,
|
||
})
|
||
return overlaps
|
||
|
||
|
||
def _describe_reachable_urls(body: "SiteCreate") -> List[str]:
|
||
"""Bulgu #39 (round-16 audit) — spell out the URLs operators can
|
||
actually reach the new site at, given the wizard's chosen ports.
|
||
|
||
When `frontend.bind_port != 80` or `ssl.https_bind_port != 443` the
|
||
site is NOT reachable at the bare `http(s)://<domain>` URL a
|
||
browser bookmarks by default. Operators on multi-tenant clusters
|
||
(where the default ports are already taken by shared frontends)
|
||
routinely don't realise this until end-users complain. Surfacing
|
||
the concrete URLs at preview time makes the implication
|
||
impossible to miss.
|
||
|
||
Returns a list of human-readable URL hints, one per domain × port
|
||
combination the wizard will actually expose.
|
||
"""
|
||
out: List[str] = []
|
||
http_port = body.frontend.bind_port
|
||
https_port = body.ssl.https_bind_port if body.ssl.mode != "none" else None
|
||
for d in (body.domains or [])[:5]: # cap to 5 to keep banners short
|
||
# Skip wildcards in the URL hint (browsers don't request them).
|
||
d_show = d.replace("*.", "")
|
||
if body.frontend.mode == "http":
|
||
if http_port == 80:
|
||
out.append(f"http://{d_show}/")
|
||
else:
|
||
out.append(f"http://{d_show}:{http_port}/")
|
||
if https_port is not None:
|
||
if https_port == 443:
|
||
out.append(f"https://{d_show}/")
|
||
else:
|
||
out.append(f"https://{d_show}:{https_port}/")
|
||
return out
|
||
|
||
|
||
def _explain_bind_collision(
|
||
*,
|
||
bind_address: str,
|
||
bind_port: int,
|
||
colliding_frontend_id: int,
|
||
ssl_mode: str,
|
||
is_https: bool = False,
|
||
) -> str:
|
||
"""Bulgu #35 (round-15 audit) — actionable bind-collision message.
|
||
|
||
Pre-fix the wizard surfaced collisions with the bare line:
|
||
|
||
Bind *:80 already used (frontend id=522)
|
||
|
||
which left the operator guessing what to do. Two valid workflows
|
||
exist on a multi-tenant cluster — pick a different port for the
|
||
new frontend, or extend the existing frontend to also host this
|
||
domain — and the operator should see both. The message also
|
||
explains the special ACME case: when the existing port-80 frontend
|
||
on the cluster ALREADY routes /.well-known/acme-challenge/, the
|
||
operator can use ACME on a different port without losing
|
||
auto-issuance.
|
||
"""
|
||
base = (
|
||
f"Bind {bind_address}:{bind_port} is already used by frontend "
|
||
f"id={colliding_frontend_id} in this cluster."
|
||
)
|
||
is_default_http = (bind_port == 80 and not is_https)
|
||
is_default_https = (bind_port == 443 and is_https)
|
||
hints: List[str] = []
|
||
if is_default_http:
|
||
hints.append(
|
||
"(a) change frontend.bind_port to a free port (e.g. 8080). "
|
||
"On ACME mode the cluster's existing port-80 frontend will "
|
||
"still serve the Let's Encrypt HTTP-01 challenge for your "
|
||
"domain — your new frontend does not need to be on port 80."
|
||
)
|
||
elif is_default_https:
|
||
hints.append(
|
||
"(a) change ssl.https_bind_port to a free port (e.g. 8443). "
|
||
"Browser traffic to https://<domain> defaults to port 443, "
|
||
"so a non-443 HTTPS bind means clients have to type "
|
||
"`:8443` explicitly — useful for internal sites or "
|
||
"test environments."
|
||
)
|
||
else:
|
||
hints.append(
|
||
"(a) pick a different bind port that is free on this cluster."
|
||
)
|
||
hints.append(
|
||
f"(b) extend the existing frontend id={colliding_frontend_id} "
|
||
"with a host-based routing rule (Frontends UI → edit → Add "
|
||
"ACL + use_backend) so it forwards your new domain to the "
|
||
"wizard-created backend. This is the canonical multi-tenant "
|
||
"HAProxy pattern."
|
||
)
|
||
if ssl_mode == "acme" and is_default_http:
|
||
hints.append(
|
||
"(c) if you are not married to ACME for this site, switch "
|
||
"ssl.mode to 'upload' or 'existing' which uses any free "
|
||
"port and skips the port-80 HTTP-01 step."
|
||
)
|
||
return base + " Options: " + " ".join(hints)
|
||
|
||
|
||
def _build_redirect_rules(payload: SiteCreate) -> List[dict]:
|
||
"""M19: expand https_redirect=true into a redirect_rules JSONB row.
|
||
|
||
R11.A-1 fix: pre-fix this row carried both a `type:'scheme'` and a
|
||
`location:<url>` field. HAProxy's `redirect scheme` directive
|
||
only accepts a literal scheme name (`https`/`http`) — passing a
|
||
URL there made the agent's `haproxy -c` reject the config with a
|
||
parser error. The `location` field is reserved for
|
||
`redirect location <URL>`. Emit the canonical scheme-redirect
|
||
payload (`scheme` + `code` + `condition`); the generator's
|
||
`_format_redirect_rule` reads `scheme` directly.
|
||
|
||
Bulgu #29 (round-13 audit) — ACME HTTP-01 challenge defence.
|
||
Pre-fix the auto-generated condition was simply ``!{ ssl_fc }``
|
||
(redirect ALL plain-HTTP traffic to HTTPS). On an ssl.mode='acme'
|
||
site that combination produced a self-defeating config:
|
||
|
||
1. The wizard creates an HTTP frontend on :80 (mandatory for
|
||
HTTP-01).
|
||
2. The wizard also enables the scheme→https redirect on the
|
||
same frontend.
|
||
3. The agent emits `acl is_acme_challenge path_beg
|
||
/.well-known/acme-challenge/` + `use_backend
|
||
_acme_challenge_backend if is_acme_challenge` — BUT
|
||
HAProxy processes `redirect` rules BEFORE `use_backend`
|
||
in the request-analysis phase.
|
||
4. LE's validator fetches
|
||
`http://<domain>/.well-known/acme-challenge/<token>` and
|
||
the HTTP frontend immediately returns
|
||
`301 https://<domain>/.well-known/acme-challenge/<token>`.
|
||
5. LE follows the 301 to the HTTPS port — but in ACME mode
|
||
the HTTPS frontend is DEFERRED until issuance succeeds
|
||
(`_execute_post_completion_actions`). Nothing is listening
|
||
on :443 yet, so the follow-up handshake times out and the
|
||
order fails with an opaque "fetching … failed" error.
|
||
|
||
Fix: render the canonical scheme→https redirect with an extra
|
||
`!{ path_beg /.well-known/acme-challenge/ }` clause so the
|
||
redirect SKIPS challenge paths and HAProxy falls through to the
|
||
`use_backend _acme_challenge_backend` line emitted in the
|
||
`use_be` bucket. The exclusion is harmless on non-ACME sites
|
||
(no one legitimately probes `/.well-known/acme-challenge/<token>`
|
||
on a plain HTTP site, and even if they did the response is
|
||
semantically equivalent to a 301-then-404). HAProxy's `if A B`
|
||
grammar is an implicit AND, matching the legacy single-clause
|
||
behaviour for every non-challenge request.
|
||
"""
|
||
if payload.frontend.https_redirect:
|
||
return [
|
||
{
|
||
"type": "scheme",
|
||
"scheme": "https",
|
||
"code": 301,
|
||
"condition": (
|
||
"!{ ssl_fc } "
|
||
"!{ path_beg /.well-known/acme-challenge/ }"
|
||
),
|
||
}
|
||
]
|
||
return list(payload.frontend.redirect_rules or [])
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Phase K Phase C — shared candidate-config synthesizer for the dry-run gate
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _build_candidate_fragment(body: SiteCreate) -> str:
|
||
"""Render the wizard's would-be entities as a HAProxy config fragment.
|
||
|
||
This is the dry-run twin of what
|
||
`services/haproxy_config.py::generate_haproxy_config_for_cluster`
|
||
would emit if the rows had already been INSERTed. We hand-render
|
||
a minimal-but-shape-correct frontend / backend / HTTPS frontend
|
||
block from the request body so the dry-run can run
|
||
`HAProxyConfigValidator` without DB writes.
|
||
|
||
The fragment is intentionally conservative — it covers every
|
||
field that affects the validator's heuristic checks (binds,
|
||
`default_backend`, `verify required`, ssl `crt`, HSTS / strict-
|
||
sni, ACL / use_backend / redirect rules) but not every cosmetic
|
||
rendering detail. The agent's `haproxy -c -f` on apply remains
|
||
the ultimate source of truth.
|
||
"""
|
||
from services.haproxy_config import _format_redirect_rule
|
||
|
||
fe = body.frontend
|
||
be = body.backend
|
||
ssl = body.ssl
|
||
lines: List[str] = ["", "# ─── Wizard candidate fragment (dry-run preview) ───"]
|
||
|
||
# ─── HTTP frontend ────────────────────────────────────────────
|
||
lines.append(f"frontend {fe.name}")
|
||
lines.append(f" mode {fe.mode}")
|
||
lines.append(f" bind {fe.bind_address}:{fe.bind_port}")
|
||
if fe.maxconn:
|
||
lines.append(f" maxconn {fe.maxconn}")
|
||
if fe.timeout_client:
|
||
lines.append(f" timeout client {fe.timeout_client}ms")
|
||
if fe.timeout_http_request:
|
||
lines.append(f" timeout http-request {fe.timeout_http_request}ms")
|
||
if fe.compression:
|
||
lines.append(" compression algo gzip")
|
||
if fe.monitor_uri:
|
||
lines.append(f" monitor-uri {fe.monitor_uri}")
|
||
if fe.options:
|
||
for raw in fe.options.splitlines():
|
||
stripped = raw.strip()
|
||
if stripped:
|
||
lines.append(f" {stripped}")
|
||
if fe.mode == "tcp" and fe.tcp_request_rules:
|
||
for raw in fe.tcp_request_rules.splitlines():
|
||
stripped = raw.strip()
|
||
if stripped:
|
||
lines.append(f" {stripped}")
|
||
# Bulgu #19 (round-9 audit): the wizard's `request_headers` /
|
||
# `response_headers` fields carry FULL HAProxy directive lines —
|
||
# the operator pastes lines like:
|
||
#
|
||
# http-request add-header X-Forwarded-Proto https
|
||
# http-request set-header X-Real-IP %[src]
|
||
#
|
||
# (the FrontendManagement / BulkConfigImport UIs both document
|
||
# this format in the textarea placeholder, and the real renderer
|
||
# at `services/haproxy_config.py:1063-1074` emits each line
|
||
# VERBATIM with 4-space indent).
|
||
#
|
||
# Pre-fix the dry-run twin PREFIXED every line with
|
||
# ` http-request set-header` / ` http-response set-header`,
|
||
# which produced double-prefixed garbage like:
|
||
#
|
||
# http-request set-header http-request set-header X-Real-IP …
|
||
#
|
||
# That broke validator heuristic checks during Step 5 and
|
||
# surfaced as misleading "directive may not be valid" warnings on
|
||
# configurations that were actually fine. Emitting the lines
|
||
# verbatim makes the dry-run preview a true twin of the real
|
||
# render path.
|
||
if fe.request_headers:
|
||
for raw in fe.request_headers.splitlines():
|
||
stripped = raw.strip()
|
||
if stripped:
|
||
lines.append(f" {stripped}")
|
||
if fe.response_headers:
|
||
for raw in fe.response_headers.splitlines():
|
||
stripped = raw.strip()
|
||
if stripped:
|
||
lines.append(f" {stripped}")
|
||
for rule in (fe.acl_rules or []):
|
||
if isinstance(rule, str) and rule.strip():
|
||
lines.append(f" acl {rule.strip()}")
|
||
for rule in _build_redirect_rules(body):
|
||
formatted = _format_redirect_rule(rule)
|
||
if formatted:
|
||
# `_format_redirect_rule` already returns a 4-space-indented
|
||
# `redirect …` line.
|
||
lines.append(formatted)
|
||
for rule in (fe.use_backend_rules or []):
|
||
if isinstance(rule, str) and rule.strip():
|
||
lines.append(f" use_backend {rule.strip()}")
|
||
lines.append(f" default_backend {be.name}")
|
||
|
||
# ─── HTTPS frontend (upload / existing only — ACME's HTTPS bind is
|
||
# created post-completion, so we omit it from the dry-run to
|
||
# match what create_site would actually persist for ACME) ───
|
||
if ssl.mode in ("upload", "existing"):
|
||
https_name = f"{fe.name}{ssl.https_frontend_name_suffix or '-https'}"
|
||
cert_path = f"/etc/ssl/haproxy/{(ssl.name or fe.name)}.pem"
|
||
bind_parts: List[str] = [
|
||
f"{fe.bind_address}:{ssl.https_bind_port}",
|
||
"ssl",
|
||
"crt",
|
||
cert_path,
|
||
]
|
||
if ssl.ssl_alpn:
|
||
bind_parts.append(f"alpn {ssl.ssl_alpn}")
|
||
if ssl.ssl_min_ver:
|
||
bind_parts.append(f"ssl-min-ver {ssl.ssl_min_ver}")
|
||
if ssl.ssl_max_ver:
|
||
bind_parts.append(f"ssl-max-ver {ssl.ssl_max_ver}")
|
||
if ssl.ssl_ciphers:
|
||
bind_parts.append(f"ciphers {ssl.ssl_ciphers}")
|
||
if ssl.ssl_ciphersuites:
|
||
bind_parts.append(f"ciphersuites {ssl.ssl_ciphersuites}")
|
||
if ssl.ssl_strict_sni:
|
||
bind_parts.append("strict-sni")
|
||
if ssl.ssl_verify:
|
||
bind_parts.append(f"verify {ssl.ssl_verify}")
|
||
lines.append("")
|
||
lines.append(f"frontend {https_name}")
|
||
lines.append(f" mode {fe.mode}")
|
||
lines.append(f" bind {' '.join(bind_parts)}")
|
||
if ssl.hsts_enabled:
|
||
hsts_parts = [f"max-age={ssl.hsts_max_age}"]
|
||
if ssl.hsts_include_subdomains:
|
||
hsts_parts.append("includeSubDomains")
|
||
if ssl.hsts_preload:
|
||
hsts_parts.append("preload")
|
||
hsts_value = "; ".join(hsts_parts)
|
||
lines.append(
|
||
f' http-response set-header Strict-Transport-Security "{hsts_value}"'
|
||
)
|
||
for rule in (fe.acl_rules or []):
|
||
if isinstance(rule, str) and rule.strip():
|
||
lines.append(f" acl {rule.strip()}")
|
||
for rule in (fe.use_backend_rules or []):
|
||
if isinstance(rule, str) and rule.strip():
|
||
lines.append(f" use_backend {rule.strip()}")
|
||
lines.append(f" default_backend {be.name}")
|
||
|
||
# ─── Backend ──────────────────────────────────────────────────
|
||
lines.append("")
|
||
lines.append(f"backend {be.name}")
|
||
lines.append(f" mode {be.mode}")
|
||
if be.balance_method:
|
||
lines.append(f" balance {be.balance_method}")
|
||
if getattr(be, "cookie_name", None):
|
||
# Bulgu #19 (round-9 audit): include cookie_options in the
|
||
# dry-run so the validator's heuristic that examines the full
|
||
# `cookie <name> <opts>` line sees the same string the real
|
||
# renderer will produce at apply time.
|
||
cookie_line = f" cookie {be.cookie_name} insert indirect nocache"
|
||
cookie_opts = (getattr(be, "cookie_options", None) or "").strip()
|
||
if cookie_opts and cookie_opts not in ('[]', '{}', 'null', 'None'):
|
||
# Strip the canonical defaults already emitted above so we
|
||
# don't repeat them when the operator's `cookie_options`
|
||
# carries the canonical form too.
|
||
cookie_line = f" cookie {be.cookie_name} {cookie_opts}"
|
||
lines.append(cookie_line)
|
||
if getattr(be, "timeout_connect", None):
|
||
lines.append(f" timeout connect {be.timeout_connect}ms")
|
||
if getattr(be, "timeout_server", None):
|
||
lines.append(f" timeout server {be.timeout_server}ms")
|
||
# Bulgu #19 (round-9 audit): mirror the real renderer's emission
|
||
# of backend-level header injections so the dry-run can catch any
|
||
# operator-typed directive that would only trigger a validator
|
||
# warning at apply time (e.g. `option httpchk` accidentally
|
||
# pasted into `request_headers` instead of `options`).
|
||
if getattr(be, "request_headers", None):
|
||
for raw in be.request_headers.splitlines():
|
||
stripped = raw.strip()
|
||
if stripped and stripped not in ('[]', '{}', 'null', 'None'):
|
||
lines.append(f" {stripped}")
|
||
if getattr(be, "response_headers", None):
|
||
for raw in be.response_headers.splitlines():
|
||
stripped = raw.strip()
|
||
if stripped and stripped not in ('[]', '{}', 'null', 'None'):
|
||
lines.append(f" {stripped}")
|
||
|
||
for s in body.servers:
|
||
server_parts: List[str] = [
|
||
f"server {s.server_name}",
|
||
f"{s.server_address}:{s.server_port}",
|
||
]
|
||
if s.weight is not None:
|
||
server_parts.append(f"weight {s.weight}")
|
||
if s.check_enabled:
|
||
chk = ["check"]
|
||
if s.check_port:
|
||
chk.append(f"port {s.check_port}")
|
||
if s.inter:
|
||
chk.append(f"inter {s.inter}")
|
||
if s.fall:
|
||
chk.append(f"fall {s.fall}")
|
||
if s.rise:
|
||
chk.append(f"rise {s.rise}")
|
||
server_parts.append(" ".join(chk))
|
||
if s.max_connections:
|
||
server_parts.append(f"maxconn {s.max_connections}")
|
||
lines.append(f" {' '.join(server_parts)}")
|
||
|
||
return "\n".join(lines) + "\n"
|
||
|
||
|
||
async def _synthesize_candidate_haproxy_config(
|
||
body: SiteCreate,
|
||
conn,
|
||
*,
|
||
entities_already_inserted: bool = False,
|
||
) -> str:
|
||
"""Render the candidate HAProxy config for a wizard payload.
|
||
|
||
Two callers, two paths — same helper:
|
||
|
||
* `entities_already_inserted=True` (create_site post-insert
|
||
validation gate): the wizard's INSERTs are already in the active
|
||
transaction, so the cluster's renderer sees them. Just return
|
||
the renderer's output.
|
||
* `entities_already_inserted=False` (preview dry-run gate): the
|
||
INSERTs have not happened. Render the cluster's CURRENT config
|
||
and append a candidate fragment derived from `body` so the
|
||
validator sees the post-create shape without any DB writes.
|
||
|
||
Both paths share this helper so the dry-run gate and the apply
|
||
gate can never silently desync. Pinned by
|
||
`tests/test_site_wizard_phase_k.py::
|
||
test_phase_k_create_site_and_preview_use_same_synthesis_helper`.
|
||
"""
|
||
from services.haproxy_config import generate_haproxy_config_for_cluster
|
||
|
||
existing = await generate_haproxy_config_for_cluster(body.cluster_id, conn)
|
||
if entities_already_inserted:
|
||
return existing
|
||
return existing + _build_candidate_fragment(body)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# GET /suggest
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@router.get("/suggest")
|
||
async def suggest_defaults(
|
||
cluster_id: int,
|
||
domain: Optional[str] = None,
|
||
authorization: str = Header(None),
|
||
):
|
||
"""Smart-default suggestions for the wizard form.
|
||
|
||
Returns suggested backend/frontend names derived from the first domain
|
||
plus a default backend port (80 if user typed an HTTP host, else 8080).
|
||
"""
|
||
current_user = await get_current_user_from_token(authorization)
|
||
if not await _can_use_wizard(current_user["id"], current_user=current_user):
|
||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||
|
||
conn = await get_database_connection()
|
||
try:
|
||
await _validate_user_cluster_access(current_user["id"], cluster_id, conn)
|
||
|
||
slug = "newhost"
|
||
if domain:
|
||
# Bulgu #93 (round-24 audit) — IDN / Unicode safety. PRE-FIX
|
||
# the suggest endpoint used `c.isalnum()`, which is Unicode-
|
||
# aware and returns True for non-ASCII letters (ü, é, ñ, …).
|
||
# An operator who typed `bücher.example.com` got back
|
||
# `backend_name="be-bücher"`, dropped that into the wizard
|
||
# form, and then hit a hard 422 at create time because the
|
||
# backend/frontend name validator regex
|
||
# `^[a-zA-Z][a-zA-Z0-9_-]{0,63}$` is ASCII-only. The wizard
|
||
# CREATE path also forces the domain itself through punycode
|
||
# (the validator rejects raw Unicode with a "use 'xn--…'"
|
||
# hint). Make `suggest` honour the same on-the-wire ASCII
|
||
# contract: convert each label to its IDN/punycode form
|
||
# FIRST, then sanitise to the alphanumeric / `-_` set the
|
||
# entity-name regex permits. The result is a name the
|
||
# operator can submit to /api/sites without re-typing.
|
||
first_label = (
|
||
domain.replace("*.", "").split(".")[0]
|
||
if domain.replace("*.", "")
|
||
else ""
|
||
)
|
||
ascii_label = first_label
|
||
if first_label and not first_label.isascii():
|
||
try:
|
||
ascii_label = first_label.encode("idna").decode("ascii")
|
||
except (UnicodeError, UnicodeDecodeError):
|
||
# IDN encoding failed (empty label, invalid chars,
|
||
# etc.) — fall back to stripping non-ASCII to '-'
|
||
# so we still produce a usable slug.
|
||
ascii_label = "".join(
|
||
c if c.isascii() and (c.isalnum() or c in ("-", "_"))
|
||
else "-"
|
||
for c in first_label
|
||
)
|
||
slug = (ascii_label or "newhost")[:32].lower()
|
||
slug = "".join(
|
||
c if c.isascii() and (c.isalnum() or c in ("-", "_"))
|
||
else "-"
|
||
for c in slug
|
||
)
|
||
if slug.startswith("_"):
|
||
slug = "h-" + slug.lstrip("_")
|
||
if not slug or not slug[0].isalpha():
|
||
slug = "h-" + slug
|
||
|
||
return {
|
||
"backend_name": f"be-{slug}",
|
||
"frontend_name": f"fe-{slug}",
|
||
"https_frontend_name": f"fe-{slug}-https",
|
||
"backend_port_suggestion": 8080,
|
||
"ssl_certificate_name_suggestion": f"cert-{slug}",
|
||
}
|
||
finally:
|
||
await close_database_connection(conn)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# POST /preflight-acme
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@router.post("/preflight-acme")
|
||
async def preflight_acme(
|
||
body: SitePreflightAcme,
|
||
authorization: str = Header(None),
|
||
):
|
||
"""Run pre-create ACME diagnostics for the proposed domains+cluster."""
|
||
current_user = await get_current_user_from_token(authorization)
|
||
if not await check_user_permission(
|
||
current_user["id"], "ssl", "read", current_user=current_user
|
||
):
|
||
raise HTTPException(status_code=403, detail="Insufficient permissions: ssl.read required")
|
||
|
||
conn = await get_database_connection()
|
||
try:
|
||
await _validate_user_cluster_access(current_user["id"], body.cluster_id, conn)
|
||
# Phase D/I: canonical action name post-rebrand. The
|
||
# _enforce_rate_limit helper auto-aliases this to its legacy
|
||
# `proxied_host_acme_preflight` companion so a deploy mid-minute
|
||
# cannot bypass the limit.
|
||
await _enforce_rate_limit(conn, current_user["id"], "site_acme_preflight")
|
||
|
||
account_id = await _resolve_default_acme_account(conn)
|
||
if not account_id:
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail="No valid ACME account exists. Configure Let's Encrypt before using ACME mode.",
|
||
)
|
||
|
||
results = await run_checks(
|
||
conn,
|
||
domains=body.domains,
|
||
cluster_ids=[body.cluster_id],
|
||
account_id=account_id,
|
||
)
|
||
return {
|
||
"cluster_id": body.cluster_id,
|
||
"domains": body.domains,
|
||
"checks": results,
|
||
"generated_at": datetime.utcnow().isoformat() + "Z",
|
||
}
|
||
finally:
|
||
await close_database_connection(conn)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# POST /preview
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@router.post("/preview")
|
||
async def preview_create(
|
||
body: SiteCreate,
|
||
authorization: str = Header(None),
|
||
validate_haproxy_config: bool = False,
|
||
):
|
||
"""Read-only diff preview. Returns the entities that WOULD be created
|
||
plus collision warnings. Performs NO writes.
|
||
|
||
Phase K Phase C: when `validate_haproxy_config=true` (passed as a
|
||
query string parameter to keep the existing `SiteCreate` body
|
||
schema untouched), the endpoint additionally synthesises the
|
||
candidate HAProxy config via `_synthesize_candidate_haproxy_config`
|
||
and runs `HAProxyConfigValidator` over it. The validation result
|
||
is returned as a `validation` block inside the same 200 OK
|
||
envelope (the response is `200` even when the validation finds
|
||
errors — the wizard frontend renders them inline; the actual
|
||
`POST /api/sites` is the gate that 422s on errors).
|
||
"""
|
||
current_user = await get_current_user_from_token(authorization)
|
||
if not await _can_use_wizard(current_user["id"], current_user=current_user):
|
||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||
|
||
conn = await get_database_connection()
|
||
try:
|
||
await _validate_user_cluster_access(current_user["id"], body.cluster_id, conn)
|
||
|
||
# Bulgu #88 / #89 (round-24 audit) — cluster-RBAC parity with
|
||
# `create_site` for SSL certificate references. PRE-FIX the
|
||
# preview path (`POST /api/sites/preview`) skipped the
|
||
# `select_existing_cert()` gate that `create_site` runs at
|
||
# lines 2298-2308 (per-server CA bundle) and 2398-2405
|
||
# (HTTPS bind cert). The omission let an authenticated wizard
|
||
# user pass `ssl.mode='existing', ssl_certificate_id=<X>` (or
|
||
# `servers[i].ssl_certificate_id=<X>`) where cert `X` belongs
|
||
# to a DIFFERENT cluster and receive the full rendered
|
||
# `would_create` envelope back — leaking cert id metadata
|
||
# across tenant boundaries. The actual submit (`POST /api/
|
||
# sites`) does enforce the gate, so this is a preview-only
|
||
# information leak, not a write-path escalation. The fix is
|
||
# to mirror the same `select_existing_cert(conn, id, cluster_
|
||
# id)` predicate (which already encodes the "global OR
|
||
# junction-bound" rule defined in `ssl_service.py:331-370`)
|
||
# so the preview returns 400 with a clear hint rather than
|
||
# 200 with a leaked render. We deliberately keep the error
|
||
# phrasing identical to the create-time message so wizard
|
||
# UI handlers that already match on "not found / inactive"
|
||
# need no client changes.
|
||
if body.ssl.mode == "existing" and body.ssl.ssl_certificate_id:
|
||
_preview_resolved = await select_existing_cert(
|
||
conn, body.ssl.ssl_certificate_id, body.cluster_id,
|
||
)
|
||
if not _preview_resolved:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=(
|
||
f"ssl_certificate_id {body.ssl.ssl_certificate_id} "
|
||
"not found / inactive / not bound to this cluster"
|
||
),
|
||
)
|
||
for _idx, _srv in enumerate(body.servers or []):
|
||
_srv_cert_id = getattr(_srv, "ssl_certificate_id", None)
|
||
if _srv_cert_id:
|
||
if not await select_existing_cert(
|
||
conn, _srv_cert_id, body.cluster_id,
|
||
):
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=(
|
||
f"servers[{_idx}].ssl_certificate_id={_srv_cert_id} "
|
||
"not found / inactive / not bound to this cluster"
|
||
),
|
||
)
|
||
|
||
# Phase K Phase C: rate-limit ONLY the dry-run code path so
|
||
# legacy callers (e.g. `SiteDrafts.handlePreview` which never
|
||
# sets the flag) keep their unrestricted preview budget. The
|
||
# dry-run is much heavier (config render + heuristic
|
||
# validator) and is auto-fired on every Step 4 entry — without
|
||
# this guard a stuck retry loop in the wizard could hammer the
|
||
# validator. Limit matches `_RATE_LIMIT_PER_MIN=5` used by
|
||
# preflight_acme.
|
||
if validate_haproxy_config:
|
||
await _enforce_rate_limit(conn, current_user["id"], "site_previewed")
|
||
logger.info(
|
||
"WIZARD: dry-run /preview ENTER "
|
||
f"user_id={current_user['id']} cluster_id={body.cluster_id} "
|
||
f"ssl_mode={body.ssl.mode}"
|
||
)
|
||
|
||
warnings: List[str] = []
|
||
|
||
# Phase 4 (R11-audit): preview must surface the same reserved-name
|
||
# rule the create endpoint hard-blocks on, so operators see the
|
||
# warning UP FRONT (before they invest time filling the wizard
|
||
# only to be 400'd at submit). Same reserved set as the
|
||
# `body.frontend.name`/`body.backend.name` guards in
|
||
# create_site (Phase 3).
|
||
_PREVIEW_RESERVED_NAMES = {
|
||
"stats", "haproxy-stats", "haproxy_stats",
|
||
"monitoring", "admin", "health", "status",
|
||
}
|
||
if body.frontend.name.lower() in _PREVIEW_RESERVED_NAMES:
|
||
warnings.append(
|
||
f"Frontend name '{body.frontend.name}' is reserved "
|
||
"(collides with HAProxy listen sections like 'listen "
|
||
"stats') and will be rejected at create time"
|
||
)
|
||
if body.backend.name.lower() in _PREVIEW_RESERVED_NAMES:
|
||
warnings.append(
|
||
f"Backend name '{body.backend.name}' is reserved "
|
||
"(collides with HAProxy listen sections like 'listen "
|
||
"stats') and will be rejected at create time"
|
||
)
|
||
|
||
# Backend name collision
|
||
be_existing = await conn.fetchval(
|
||
"SELECT id FROM backends WHERE name=$1 AND cluster_id=$2 AND is_active=TRUE",
|
||
body.backend.name,
|
||
body.cluster_id,
|
||
)
|
||
if be_existing:
|
||
warnings.append(f"Backend '{body.backend.name}' already exists in cluster")
|
||
|
||
# Frontend name collision
|
||
fe_existing = await conn.fetchval(
|
||
"SELECT id FROM frontends WHERE name=$1 AND cluster_id=$2 AND is_active=TRUE",
|
||
body.frontend.name,
|
||
body.cluster_id,
|
||
)
|
||
if fe_existing:
|
||
warnings.append(f"Frontend '{body.frontend.name}' already exists in cluster")
|
||
|
||
# Bind-port collision (HTTP frontend)
|
||
# Bulgu #36 (round-15 audit) — collisions are BLOCKING at submit
|
||
# time (`create_site` raises 400). Surfacing them as plain
|
||
# `warnings` previously left operators wondering whether they
|
||
# could proceed; they almost always could NOT. The preview now
|
||
# adds the same row to a dedicated `blocking_errors` list AND
|
||
# the legacy `warnings` array so existing callers keep working
|
||
# while the wizard UI can highlight the blocker explicitly.
|
||
blocking_errors: List[str] = []
|
||
bind_collision = await check_bind_port_collision(
|
||
conn,
|
||
body.cluster_id,
|
||
body.frontend.bind_address,
|
||
body.frontend.bind_port,
|
||
)
|
||
if bind_collision:
|
||
# Bulgu #35 (round-15 audit) — same actionable message both
|
||
# at preview and at submit.
|
||
msg = _explain_bind_collision(
|
||
bind_address=body.frontend.bind_address,
|
||
bind_port=body.frontend.bind_port,
|
||
colliding_frontend_id=bind_collision,
|
||
ssl_mode=body.ssl.mode,
|
||
is_https=False,
|
||
)
|
||
warnings.append(msg)
|
||
blocking_errors.append(msg)
|
||
|
||
# Bulgu #11 / R12 fix: HTTPS frontend pre-check warnings for ALL
|
||
# https-creating modes — upload, existing AND acme. acme used to
|
||
# defer this to post-completion; surfacing it as a preview
|
||
# warning lets the user fix the conflict before burning an LE
|
||
# rate-limit quota.
|
||
if body.ssl.mode in ("upload", "existing", "acme"):
|
||
https_suffix = body.ssl.https_frontend_name_suffix or "-https"
|
||
https_fe_name = f"{body.frontend.name}{https_suffix}"
|
||
https_name_existing = await conn.fetchval(
|
||
"SELECT id FROM frontends WHERE name=$1 AND cluster_id=$2 AND is_active=TRUE",
|
||
https_fe_name, body.cluster_id,
|
||
)
|
||
if https_name_existing:
|
||
msg = (
|
||
f"HTTPS frontend name '{https_fe_name}' already exists in "
|
||
f"cluster (frontend id={https_name_existing}). Adjust "
|
||
"ssl.https_frontend_name_suffix or frontend.name; "
|
||
"this is a hard block at submit time."
|
||
)
|
||
warnings.append(msg)
|
||
blocking_errors.append(msg)
|
||
https_bind_existing = await check_bind_port_collision(
|
||
conn, body.cluster_id, body.frontend.bind_address, body.ssl.https_bind_port,
|
||
)
|
||
if https_bind_existing:
|
||
msg = _explain_bind_collision(
|
||
bind_address=body.frontend.bind_address,
|
||
bind_port=body.ssl.https_bind_port,
|
||
colliding_frontend_id=https_bind_existing,
|
||
ssl_mode=body.ssl.mode,
|
||
is_https=True,
|
||
)
|
||
warnings.append(msg)
|
||
blocking_errors.append(msg)
|
||
|
||
# Bulgu #34 (round-15 audit) — cluster-aware ACME port-80
|
||
# reachability preflight at preview time. If the operator
|
||
# picked a non-80 bind for ACME, surface immediately whether
|
||
# the cluster has another port-80 HTTP frontend that LE can
|
||
# land on. Without one, submit will 400 — better to know
|
||
# before clicking Create.
|
||
if body.ssl.mode == "acme":
|
||
acme_reach_err = await _validate_acme_port80_reachable(conn, body)
|
||
if acme_reach_err:
|
||
warnings.append(acme_reach_err)
|
||
blocking_errors.append(acme_reach_err)
|
||
|
||
# Bulgu #37 (round-16 audit) — cross-frontend domain ACL
|
||
# collision detection. Two sites in the same cluster claiming
|
||
# the same Host-header value via separate frontends produce
|
||
# ambiguous routing; operators rarely realise this until end
|
||
# users hit the wrong backend. Surface every overlap so the
|
||
# operator can either de-duplicate the domain list or extend
|
||
# the existing frontend instead of creating a new site.
|
||
domain_collisions = await _find_cluster_domain_routing_collisions(
|
||
conn, body.cluster_id, body.domains,
|
||
exclude_frontend_name=body.frontend.name,
|
||
)
|
||
for col in domain_collisions:
|
||
msg = (
|
||
f"Domain {', '.join(col['conflicting_domains'])} is "
|
||
f"already routed by frontend '{col['frontend_name']}' "
|
||
f"(id={col['frontend_id']}) in this cluster via ACL: "
|
||
f"`{col['acl_rule']}`. Creating a second site for the "
|
||
"same host produces ambiguous routing — extend that "
|
||
"frontend's `use_backend` rules instead of creating a "
|
||
"new site, OR drop the conflicting domain from this "
|
||
"wizard."
|
||
)
|
||
warnings.append(msg)
|
||
blocking_errors.append(msg)
|
||
|
||
# Bulgu #38 (round-16 audit) — pending ACME order overlap.
|
||
# Detect open Let's Encrypt orders that already cover any of
|
||
# the wizard's domains so the operator doesn't burn an LE
|
||
# rate-limit quota on a doomed second order.
|
||
if body.ssl.mode == "acme":
|
||
acme_overlaps = await _find_pending_acme_order_overlap(
|
||
conn, body.domains,
|
||
)
|
||
for ov in acme_overlaps:
|
||
msg = (
|
||
f"Let's Encrypt order id={ov['order_id']} "
|
||
f"(status={ov['status']}, created {ov['created_at']}) "
|
||
f"already covers domain(s) "
|
||
f"{', '.join(ov['overlapping_domains'])}. Issuing a "
|
||
"second order for the same domain wastes an LE rate-"
|
||
"limit slot and produces a duplicate cert. Wait for "
|
||
"the existing order to finalise, OR cancel it via "
|
||
f"the LE Orders page, before re-running this wizard."
|
||
)
|
||
warnings.append(msg)
|
||
blocking_errors.append(msg)
|
||
|
||
# Bulgu #39 (round-16 audit) — non-default port URL hint.
|
||
# When the wizard's chosen ports differ from 80/443, browsers
|
||
# navigating to the bare `http(s)://<domain>` won't reach the
|
||
# new site. Operators on multi-tenant clusters with the
|
||
# default ports already taken routinely don't realise this
|
||
# until end-users complain — surface the actual reachable
|
||
# URLs prominently. (Informational, not blocking — a non-80
|
||
# bind is sometimes intentional, e.g. for internal-only
|
||
# services.)
|
||
if (
|
||
body.frontend.bind_port != 80
|
||
or (body.ssl.mode != "none" and body.ssl.https_bind_port != 443)
|
||
):
|
||
urls = _describe_reachable_urls(body)
|
||
if urls:
|
||
warnings.append(
|
||
"Non-default port(s) selected — clients must use the "
|
||
f"explicit URL(s): {', '.join(urls)}. Browsers do "
|
||
"not auto-append non-80/443 ports, so bare "
|
||
"http(s)://<domain> bookmarks WILL NOT reach this "
|
||
"frontend. If you need vanity URLs (port 80 / 443) "
|
||
"extend the existing shared frontend on the desired "
|
||
"port instead of creating a separate one."
|
||
)
|
||
|
||
# Bulgu #40 (round-16 audit) — HSTS includeSubDomains scope.
|
||
# `Strict-Transport-Security` with `includeSubDomains` is a
|
||
# one-way commitment: browsers cache the decision for max-age
|
||
# seconds and refuse to fall back to HTTP for ANY subdomain.
|
||
# When the wizard's domain list contains a deep apex (e.g.
|
||
# `example.com` rather than `app.example.com`), the lock-in
|
||
# affects every existing AND future subdomain in the tree.
|
||
# Operators rarely realise the blast radius until an
|
||
# unrelated subdomain breaks weeks later.
|
||
if (
|
||
body.ssl.mode in ("upload", "existing", "acme")
|
||
and getattr(body.ssl, "hsts_enabled", False)
|
||
and getattr(body.ssl, "hsts_include_subdomains", False)
|
||
):
|
||
apex_like = [
|
||
d for d in (body.domains or [])
|
||
if d and not d.startswith("*.") and d.count(".") <= 1
|
||
]
|
||
if apex_like:
|
||
warnings.append(
|
||
"HSTS includeSubDomains is enabled for apex-like "
|
||
f"domain(s) {', '.join(apex_like)}. ALL subdomains "
|
||
"of these names will be locked to HTTPS for "
|
||
f"max-age={getattr(body.ssl, 'hsts_max_age', 0)}s; "
|
||
"browsers refuse HTTP fallback even for subdomains "
|
||
"that are not (yet) HTTPS-ready. Drop "
|
||
"hsts_include_subdomains if you have non-HTTPS "
|
||
"subdomains, or shorten max-age while migrating."
|
||
)
|
||
if getattr(body.ssl, "hsts_preload", False):
|
||
warnings.append(
|
||
"HSTS preload is enabled. Submission to the "
|
||
"browser preload list "
|
||
"(https://hstspreload.org/) is MANUAL and SLOW to "
|
||
"reverse — removal can take months. Confirm the "
|
||
"preload-list prerequisites (max-age >= 31536000, "
|
||
"includeSubDomains, valid HTTPS for ALL subdomains) "
|
||
"before clicking Create."
|
||
)
|
||
|
||
# Bulgu #41 (round-16 audit) — server address:port duplication.
|
||
# HAProxy accepts two `server <name> 10.0.0.1:80` lines under
|
||
# the same backend (it routes them as independent slots), but
|
||
# in practice this is almost always a copy/paste typo — the
|
||
# second slot adds load to the same upstream while inflating
|
||
# health-check traffic and slot accounting. Surface as a soft
|
||
# warning (not blocking) — the existing comment in
|
||
# reject_duplicate_server_names explicitly notes the address-
|
||
# port-duplicate case can be intentional for canary aliases.
|
||
if body.servers:
|
||
seen_addr_port: dict = {}
|
||
dup_pairs: List[str] = []
|
||
for s in body.servers:
|
||
key = (
|
||
(s.server_address or "").strip().lower(),
|
||
int(s.server_port),
|
||
)
|
||
if not key[0]:
|
||
continue
|
||
if key in seen_addr_port:
|
||
dup_pairs.append(
|
||
f"{seen_addr_port[key]}+{s.server_name}={key[0]}:{key[1]}"
|
||
)
|
||
else:
|
||
seen_addr_port[key] = s.server_name
|
||
if dup_pairs:
|
||
warnings.append(
|
||
f"Multiple servers point to the same address:port "
|
||
f"({', '.join(dup_pairs)}). HAProxy accepts this "
|
||
"but it doubles health-check load and rarely "
|
||
"matches intent — if this is a canary alias keep "
|
||
"it; otherwise drop the duplicate."
|
||
)
|
||
|
||
# preserved_listen_blocks NAME collision check (M43/R60 — name-only,
|
||
# NOT bind regex; agent stores names as JSONB array of strings).
|
||
preserved_rows = await conn.fetch(
|
||
"""
|
||
SELECT preserved_listen_blocks
|
||
FROM agents a
|
||
JOIN haproxy_clusters hc ON hc.pool_id = a.pool_id
|
||
WHERE hc.id = $1 AND a.preserved_listen_blocks IS NOT NULL
|
||
""",
|
||
body.cluster_id,
|
||
)
|
||
all_preserved_names = set()
|
||
for r in preserved_rows:
|
||
raw = r["preserved_listen_blocks"]
|
||
try:
|
||
names = json.loads(raw) if isinstance(raw, str) else (raw or [])
|
||
except json.JSONDecodeError:
|
||
names = []
|
||
for name in names:
|
||
if isinstance(name, str):
|
||
all_preserved_names.add(name.lower())
|
||
if body.backend.name.lower() in all_preserved_names:
|
||
warnings.append(
|
||
f"Backend name '{body.backend.name}' collides with an agent-preserved listen block"
|
||
)
|
||
if body.frontend.name.lower() in all_preserved_names:
|
||
warnings.append(
|
||
f"Frontend name '{body.frontend.name}' collides with an agent-preserved listen block"
|
||
)
|
||
|
||
# ACME mode: ensure account + warn on apply_immediately requirement
|
||
if body.ssl.mode == "acme":
|
||
# Bulgu #60 (round-20 audit) — preview must validate the
|
||
# OPERATOR-PROVIDED account_id, not just the auto-resolved
|
||
# default. Pre-fix the preview only ran
|
||
# `_resolve_default_acme_account` regardless of whether the
|
||
# operator had explicitly chosen an account on the wizard
|
||
# form. So if the operator picked an account that has been
|
||
# deactivated / revoked since they opened the wizard, the
|
||
# preview cheerfully returned `would_create=...` and the
|
||
# operator only discovered the dead account at submit time
|
||
# (400 from create_site's per-account validity check).
|
||
#
|
||
# Mirror the create_site validity gate here:
|
||
# - explicit account_id → SELECT id, status FROM
|
||
# letsencrypt_accounts WHERE id = $1; warn if missing
|
||
# or status != 'valid'.
|
||
# - no explicit account → fall back to
|
||
# `_resolve_default_acme_account` and warn if NULL.
|
||
if body.ssl.account_id is not None:
|
||
explicit_acc = await conn.fetchrow(
|
||
"SELECT id, status FROM letsencrypt_accounts WHERE id = $1",
|
||
body.ssl.account_id,
|
||
)
|
||
if explicit_acc is None:
|
||
warnings.append(
|
||
f"ssl.account_id={body.ssl.account_id} not found. "
|
||
"Submit will be rejected (400). Pick a different "
|
||
"account from Let's Encrypt → Accounts or clear "
|
||
"the field to auto-pick the latest valid account."
|
||
)
|
||
elif (explicit_acc["status"] or "").lower() != "valid":
|
||
warnings.append(
|
||
f"ssl.account_id={body.ssl.account_id} has "
|
||
f"status='{explicit_acc['status']}' (not 'valid'). "
|
||
"Submit will be rejected (400). Re-register or "
|
||
"rotate the account, or clear the field to "
|
||
"auto-pick the latest valid account."
|
||
)
|
||
else:
|
||
acc = await _resolve_default_acme_account(conn)
|
||
if not acc:
|
||
warnings.append(
|
||
"ssl.mode='acme' requires at least one valid letsencrypt_accounts row"
|
||
)
|
||
# Bulgu #32 (round-13 audit) — surface the cluster-level
|
||
# `acme_enabled` flag UP FRONT in preview. The create
|
||
# endpoint hard-rejects ssl.mode='acme' on a cluster with
|
||
# acme_enabled=false with a 400, because the HAProxy
|
||
# config generator only injects the
|
||
# `/.well-known/acme-challenge` ACL/use_backend block
|
||
# when the flag is true (see haproxy_config.py:975).
|
||
# Pre-fix preview returned a clean "would_create" envelope
|
||
# in that case and the operator only discovered the
|
||
# cluster-level block at submit — sometimes after burning
|
||
# several rate-limited preview cycles on a broken config.
|
||
# Emit a warning so step 4 of the wizard surfaces the
|
||
# block before the operator clicks Create.
|
||
cluster_acme_row = await conn.fetchrow(
|
||
"SELECT acme_enabled FROM haproxy_clusters WHERE id = $1",
|
||
body.cluster_id,
|
||
)
|
||
if cluster_acme_row and not cluster_acme_row.get("acme_enabled"):
|
||
warnings.append(
|
||
f"cluster_id={body.cluster_id} has acme_enabled=false. "
|
||
"Submit will be rejected (400) — the cluster's HAProxy "
|
||
"config will not route /.well-known/acme-challenge/ "
|
||
"requests, so HTTP-01 validation cannot succeed. "
|
||
"Enable ACME on this cluster (Cluster Settings → "
|
||
"ACME) or switch ssl.mode to upload/existing/none."
|
||
)
|
||
|
||
# Bulgu #55 (round-19 audit) — warn when apply_immediately=true
|
||
# against a cluster that has zero online agents.
|
||
#
|
||
# Pre-fix path:
|
||
# 1. Wizard inserts backend/server/frontend rows successfully.
|
||
# 2. apply_cluster_pending consolidates them into an APPLIED
|
||
# config_versions row.
|
||
# 3. notify_agents_config_change tries to push to all agents
|
||
# — with zero online agents, no agent gets the new config.
|
||
# 4. The DB now says the version is APPLIED but the running
|
||
# HAProxy nodes have no knowledge of the new site.
|
||
# 5. The operator's request returned 200 (everything looked
|
||
# fine), but http://newsite.example.com/ returns 404 from
|
||
# the unmodified HAProxy node, with no indication WHY.
|
||
#
|
||
# We can't hard-block (legitimate cause: pre-staged config for a
|
||
# maintenance window where agents are intentionally down), so
|
||
# surface this as a WARNING. ACME mode is even worse — the LE
|
||
# order will be staged but the challenge ACL never lands on any
|
||
# HAProxy, the order's HTTP-01 validation fails after 24h and
|
||
# the wizard_staged timeout cleanup invalidates it.
|
||
if body.apply_immediately:
|
||
online_agent_count = await conn.fetchval(
|
||
"""
|
||
SELECT COUNT(*)::int
|
||
FROM agents a
|
||
JOIN haproxy_clusters c ON c.pool_id = a.pool_id
|
||
WHERE c.id = $1
|
||
AND a.enabled = TRUE
|
||
AND a.status = 'online'
|
||
""",
|
||
body.cluster_id,
|
||
) or 0
|
||
if online_agent_count == 0:
|
||
msg = (
|
||
f"cluster_id={body.cluster_id} has 0 online agents. "
|
||
"apply_immediately=true will mark the new config "
|
||
"version as APPLIED in the database, but no HAProxy "
|
||
"node will receive it until at least one agent comes "
|
||
"back online and pulls the version. The site will "
|
||
"return 404 from the running HAProxy until then."
|
||
)
|
||
if body.ssl.mode == "acme":
|
||
msg += (
|
||
" ACME mode is especially risky in this state: "
|
||
"the LE HTTP-01 challenge cannot be answered "
|
||
"while agents are offline, and the order will "
|
||
"eventually be invalidated by the wizard_staged "
|
||
"24h timeout."
|
||
)
|
||
warnings.append(msg)
|
||
|
||
# Phase K Phase C: optional HAProxy dry-run validation. We
|
||
# synthesise the candidate config, run the heuristic
|
||
# validator, and bucket results by severity. A validator
|
||
# crash returns `is_valid: null` + `validator_error` (matches
|
||
# `create_site`'s non-fatal posture at site_wizard.py:1118-
|
||
# 1124). The endpoint always returns HTTP 200 regardless of
|
||
# severity — the wizard frontend renders errors inline next
|
||
# to the existing `would_create` echo and keeps Create
|
||
# disabled while errors are present. The actual gate stays
|
||
# `POST /api/sites` itself, which 422s on real errors.
|
||
validation_block: Optional[Dict[str, Any]] = None
|
||
if validate_haproxy_config:
|
||
t0 = time.time()
|
||
try:
|
||
from utils.haproxy_validator import (
|
||
HAProxyConfigValidator,
|
||
ValidationLevel,
|
||
)
|
||
candidate = await _synthesize_candidate_haproxy_config(
|
||
body, conn, entities_already_inserted=False
|
||
)
|
||
# Phase K Phase D follow-up (Bulgu #12): the wizard
|
||
# candidate / cluster synthesis intentionally OMITS the
|
||
# global+defaults blocks (the agent merges them with
|
||
# its local copy on disk at apply time). Pass
|
||
# `partial_fragment=True` so the heuristic validator
|
||
# suppresses the "Missing 'global' section" WARNING —
|
||
# otherwise every dry-run shows a spurious warning
|
||
# even though the agent's real `haproxy -c` parse is
|
||
# perfectly happy with the merged result.
|
||
report = HAProxyConfigValidator().validate_config(
|
||
candidate, partial_fragment=True
|
||
)
|
||
|
||
def _serialize(r):
|
||
return {
|
||
"line": getattr(r, "line_number", None),
|
||
"section": getattr(r, "section", None),
|
||
"message": getattr(r, "message", ""),
|
||
"directive": getattr(r, "directive", None),
|
||
"suggestion": getattr(r, "suggestion", None),
|
||
}
|
||
|
||
errs = [_serialize(r) for r in report.results
|
||
if r.level == ValidationLevel.ERROR][:50]
|
||
warns = [_serialize(r) for r in report.results
|
||
if r.level == ValidationLevel.WARNING][:50]
|
||
infos = [_serialize(r) for r in report.results
|
||
if r.level in (ValidationLevel.INFO, ValidationLevel.SUGGESTION)][:50]
|
||
validation_block = {
|
||
"is_valid": bool(report.is_valid),
|
||
"error_count": int(getattr(report, "error_count", len(errs))),
|
||
"warning_count": int(getattr(report, "warning_count", len(warns))),
|
||
"errors": errs,
|
||
"warnings": warns,
|
||
"infos": infos,
|
||
}
|
||
logger.info(
|
||
"WIZARD: dry-run /preview EXIT "
|
||
f"user_id={current_user['id']} cluster_id={body.cluster_id} "
|
||
f"errors={len(errs)} warnings={len(warns)} "
|
||
f"duration_ms={int((time.time() - t0) * 1000)}"
|
||
)
|
||
except Exception as val_err:
|
||
logger.warning(
|
||
"WIZARD: dry-run /preview validator crashed "
|
||
f"(non-fatal, user sees `unavailable` state): {val_err}"
|
||
)
|
||
validation_block = {
|
||
"is_valid": None,
|
||
"error_count": 0,
|
||
"warning_count": 0,
|
||
"errors": [],
|
||
"warnings": [],
|
||
"infos": [],
|
||
"validator_error": str(val_err)[:512],
|
||
}
|
||
|
||
# R18b audit fix (parity B): preview must echo every wizard
|
||
# field that affects the persisted entity / generated HAProxy
|
||
# config. Pre-fix the response only echoed the bare name +
|
||
# bind tuple, so an operator could see "looks fine" while the
|
||
# actual create added (or omitted) ssl_verify, per-server
|
||
# ssl_certificate_id (CA bundle), TLS min/max etc. The
|
||
# purpose of preview is to BUILD TRUST in what create does;
|
||
# asymmetric output defeats it.
|
||
# Phase K Phase D (Bulgu #4): full-parity preview payload.
|
||
# Pre-fix the wizard returned a minimal subset of fields, so
|
||
# an operator who set per-server check timings, backend
|
||
# cookie persistence, frontend maxconn, HSTS, or
|
||
# ciphersuites had NO visibility on the SiteDrafts preview
|
||
# modal that those values would actually be applied. The
|
||
# purpose of preview is "show me everything that will land
|
||
# on disk so I can audit it before Apply Management". Echo
|
||
# every operator-settable field that affects the persisted
|
||
# entity / rendered HAProxy config — UI side then picks
|
||
# which to render (it can collapse defaults if it wants).
|
||
# Backward compat: existing keys keep the same shape, only
|
||
# additive new keys.
|
||
return {
|
||
"would_create": {
|
||
"cluster_id": body.cluster_id,
|
||
"domains": list(body.domains or []),
|
||
"backend": {
|
||
"name": body.backend.name,
|
||
"balance_method": body.backend.balance_method,
|
||
"mode": body.backend.mode,
|
||
# R18c Phase K Phase D additive fields
|
||
"cookie_name": getattr(body.backend, "cookie_name", None),
|
||
"timeout_connect": getattr(body.backend, "timeout_connect", None),
|
||
"timeout_server": getattr(body.backend, "timeout_server", None),
|
||
"http_check_method": getattr(body.backend, "http_check_method", None),
|
||
"http_check_uri": getattr(body.backend, "http_check_uri", None),
|
||
"options": getattr(body.backend, "options", None),
|
||
},
|
||
"servers": [
|
||
{
|
||
"server_name": s.server_name,
|
||
"server_address": s.server_address,
|
||
"server_port": s.server_port,
|
||
"weight": s.weight,
|
||
"check_enabled": s.check_enabled,
|
||
"ssl_enabled": s.ssl_enabled,
|
||
"ssl_verify": s.ssl_verify,
|
||
"ssl_certificate_id": s.ssl_certificate_id,
|
||
"ssl_min_ver": s.ssl_min_ver,
|
||
"ssl_max_ver": s.ssl_max_ver,
|
||
# R18c Phase K Phase D additive fields
|
||
"max_connections": getattr(s, "max_connections", None),
|
||
"inter": getattr(s, "inter", None),
|
||
"fall": getattr(s, "fall", None),
|
||
"rise": getattr(s, "rise", None),
|
||
"check_port": getattr(s, "check_port", None),
|
||
"backup_server": getattr(s, "backup_server", None),
|
||
"cookie_value": getattr(s, "cookie_value", None),
|
||
"ssl_sni": getattr(s, "ssl_sni", None),
|
||
"ssl_ciphers": getattr(s, "ssl_ciphers", None),
|
||
}
|
||
for s in body.servers
|
||
],
|
||
"frontend_http": {
|
||
"name": body.frontend.name,
|
||
"bind": f"{body.frontend.bind_address}:{body.frontend.bind_port}",
|
||
"mode": body.frontend.mode,
|
||
# R18c Phase K Phase D additive fields
|
||
"maxconn": getattr(body.frontend, "maxconn", None),
|
||
"timeout_client": getattr(body.frontend, "timeout_client", None),
|
||
"timeout_http_request": getattr(body.frontend, "timeout_http_request", None),
|
||
"compression_enabled": getattr(body.frontend, "compression_enabled", None),
|
||
"monitor_uri": getattr(body.frontend, "monitor_uri", None),
|
||
"https_redirect": getattr(body.frontend, "https_redirect", None),
|
||
"acl_rules_count": len(getattr(body.frontend, "acl_rules", []) or []),
|
||
"use_backend_rules_count": len(getattr(body.frontend, "use_backend_rules", []) or []),
|
||
"redirect_rules_count": len(getattr(body.frontend, "redirect_rules", []) or []),
|
||
"options": getattr(body.frontend, "options", None),
|
||
},
|
||
"frontend_https": (
|
||
{
|
||
"name": f"{body.frontend.name}{body.ssl.https_frontend_name_suffix or '-https'}",
|
||
"bind": f"{body.frontend.bind_address}:{body.ssl.https_bind_port}",
|
||
"deferred": body.ssl.mode == "acme",
|
||
"ssl_alpn": body.ssl.ssl_alpn,
|
||
"ssl_min_ver": body.ssl.ssl_min_ver,
|
||
"ssl_max_ver": body.ssl.ssl_max_ver,
|
||
"ssl_ciphers": body.ssl.ssl_ciphers,
|
||
"ssl_strict_sni": body.ssl.ssl_strict_sni,
|
||
# R18 minimum-parity field: inbound mTLS verify mode.
|
||
"ssl_verify": body.ssl.ssl_verify,
|
||
# R18 minimum-parity field: existing-cert id (none in upload/acme).
|
||
"ssl_certificate_id": body.ssl.ssl_certificate_id,
|
||
# R18c Phase K Phase D additive fields
|
||
"ssl_ciphersuites": getattr(body.ssl, "ssl_ciphersuites", None),
|
||
}
|
||
if body.ssl.mode in ("upload", "existing", "acme")
|
||
else None
|
||
),
|
||
"ssl_mode": body.ssl.mode,
|
||
"https_redirect_rules": _build_redirect_rules(body),
|
||
# R18b round 2 audit fix: HSTS shaping lives on
|
||
# `SSLChoice` (body.ssl.hsts_*), NOT on `FrontendStep`.
|
||
# Pre-fix the preview pulled from `body.frontend` —
|
||
# which has no hsts_* attrs — so the dict was always
|
||
# `{enabled: False, max_age: None, ...}` regardless of
|
||
# the operator's actual selection. The actual create
|
||
# path correctly reads from `body.ssl` (see line ~764),
|
||
# so preview disagreed with create.
|
||
"hsts": {
|
||
"enabled": getattr(body.ssl, "hsts_enabled", False),
|
||
"max_age": getattr(body.ssl, "hsts_max_age", None),
|
||
"include_subdomains": getattr(body.ssl, "hsts_include_subdomains", False),
|
||
"preload": getattr(body.ssl, "hsts_preload", False),
|
||
},
|
||
},
|
||
"warnings": warnings,
|
||
# Bulgu #36 (round-15 audit) — preview surfaces explicit
|
||
# blocker errors (collision + cluster-state failures the
|
||
# submit endpoint hard-rejects) so the wizard UI can grey
|
||
# out Create instead of letting the user click through into
|
||
# an unexpected 400. Empty list means "preview is clean".
|
||
"blocking_errors": blocking_errors,
|
||
"apply_immediately": body.apply_immediately,
|
||
"version_name_template": "bulk-site-create-<ts>",
|
||
"validation": validation_block,
|
||
}
|
||
finally:
|
||
await close_database_connection(conn)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# POST / (atomic create — Section 6.2 of plan)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _entity_snapshot(entity_type: str, entity_id: int) -> dict:
|
||
"""CREATE-style snapshot for bulk_snapshots metadata. Compatible with
|
||
rollback_entity_from_snapshot's expected shape."""
|
||
return {
|
||
"entity_snapshot": {
|
||
"entity_type": entity_type,
|
||
"entity_id": entity_id,
|
||
"operation": "CREATE",
|
||
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||
"old_values": {},
|
||
"new_values": {},
|
||
"changed_fields": [],
|
||
}
|
||
}
|
||
|
||
|
||
@router.post("")
|
||
async def create_site(
|
||
body: SiteCreate,
|
||
authorization: str = Header(None),
|
||
):
|
||
"""Atomic multi-entity create for a new site (Issue #14).
|
||
|
||
Returns one of these statuses:
|
||
- created_pending -> all entities created, version PENDING (apply_immediately=false)
|
||
- created_applied -> all entities created + apply succeeded
|
||
- created_pending_apply_failed -> entities created, PENDING version exists, apply failed
|
||
- applied_acme_staging_failed -> entities created+applied, but ACME staging failed (rare)
|
||
"""
|
||
current_user = await get_current_user_from_token(authorization)
|
||
user_id = current_user["id"]
|
||
|
||
# Composite RBAC: backend+frontend create, plus apply.execute when applying.
|
||
# R18c round 7 (Bulgu 1): pass current_user so admin bypass skips the
|
||
# extra is_admin DB roundtrip (4 calls below).
|
||
# R18c round 10 (CRITICAL): seeded permissions are PLURAL
|
||
# (`frontends.create`, `backends.create`); see _can_use_wizard for
|
||
# the full rationale. SSL is already singular by design.
|
||
for resource, action in (("backends", "create"), ("frontends", "create"), ("ssl", "create")):
|
||
if not await check_user_permission(user_id, resource, action, current_user=current_user):
|
||
raise HTTPException(
|
||
status_code=403,
|
||
detail=f"Insufficient permissions: {resource}.{action} required",
|
||
)
|
||
if body.apply_immediately and not await check_user_permission(
|
||
user_id, "apply", "execute", current_user=current_user
|
||
):
|
||
raise HTTPException(
|
||
status_code=403, detail="Insufficient permissions: apply.execute required"
|
||
)
|
||
|
||
conn = await get_database_connection()
|
||
try:
|
||
await _validate_user_cluster_access(user_id, body.cluster_id, conn)
|
||
|
||
# Bulgu #87 (round-23 audit) — rate-limit the actual create
|
||
# endpoint. Pre-fix /api/sites/preview and
|
||
# /api/sites/preflight-acme were rate-limited (5/min, see
|
||
# `_enforce_rate_limit` callers above) but POST /api/sites
|
||
# itself had NO cap. Round-3 live testing fired 10 wizard
|
||
# creates in a single second against demo-cluster1; without
|
||
# the cap an authenticated user / script can spam-create
|
||
# entities until the cluster apply queue clogs.
|
||
#
|
||
# Action name must match what the router itself logs at the
|
||
# end of create_site (`wizard_create_site` — see
|
||
# `_log_user_activity(... action="wizard_create_site", ...)`
|
||
# at the success path below). The activity_logger middleware
|
||
# intentionally skips this endpoint to avoid double-logging
|
||
# (see middleware/activity_logger.py:74-110), so we must
|
||
# rate-limit on the SAME action the router emits.
|
||
#
|
||
# 5/min matches the preview / preflight budget — bulk
|
||
# operators have the dedicated /api/sites/bulk-import path
|
||
# for larger batches.
|
||
await _enforce_rate_limit(conn, user_id, "wizard_create_site")
|
||
|
||
# ----- Pre-create checks (must succeed before transaction)
|
||
|
||
# Phase 3 (R11-audit follow-up): reserved-name check parity with
|
||
# `routers/frontend.py::create_frontend` and
|
||
# `routers/backend.py::create_backend`. These names collide with
|
||
# the well-known `listen stats` / `listen monitoring` blocks that
|
||
# agents preserve from local config; manual create endpoints
|
||
# already 400 on these. The wizard now refuses them too instead
|
||
# of letting the apply-time `haproxy -c` fail with the
|
||
# confusing "proxy has same name" error.
|
||
_RESERVED_NAMES = {
|
||
"stats", "haproxy-stats", "haproxy_stats",
|
||
"monitoring", "admin", "health", "status",
|
||
}
|
||
if body.frontend.name.lower() in _RESERVED_NAMES:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=(
|
||
f"Frontend name '{body.frontend.name}' is reserved. "
|
||
"It conflicts with common HAProxy listen sections "
|
||
"(e.g. 'listen stats'). Please choose a different name."
|
||
),
|
||
)
|
||
if body.backend.name.lower() in _RESERVED_NAMES:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=(
|
||
f"Backend name '{body.backend.name}' is reserved. "
|
||
"It conflicts with common HAProxy listen sections "
|
||
"(e.g. 'listen stats'). Please choose a different name."
|
||
),
|
||
)
|
||
|
||
be_collision = await conn.fetchval(
|
||
"SELECT id FROM backends WHERE name=$1 AND cluster_id=$2 AND is_active=TRUE",
|
||
body.backend.name,
|
||
body.cluster_id,
|
||
)
|
||
if be_collision:
|
||
raise HTTPException(
|
||
status_code=400, detail=f"Backend '{body.backend.name}' already exists"
|
||
)
|
||
fe_collision = await conn.fetchval(
|
||
"SELECT id FROM frontends WHERE name=$1 AND cluster_id=$2 AND is_active=TRUE",
|
||
body.frontend.name,
|
||
body.cluster_id,
|
||
)
|
||
if fe_collision:
|
||
raise HTTPException(
|
||
status_code=400, detail=f"Frontend '{body.frontend.name}' already exists"
|
||
)
|
||
|
||
bind_collision = await check_bind_port_collision(
|
||
conn, body.cluster_id, body.frontend.bind_address, body.frontend.bind_port
|
||
)
|
||
if bind_collision:
|
||
# Bulgu #35 (round-15 audit) — actionable collision message.
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=_explain_bind_collision(
|
||
bind_address=body.frontend.bind_address,
|
||
bind_port=body.frontend.bind_port,
|
||
colliding_frontend_id=bind_collision,
|
||
ssl_mode=body.ssl.mode,
|
||
is_https=False,
|
||
),
|
||
)
|
||
|
||
# Bulgu #11 / R12 fix: pre-check HTTPS frontend name + bind port
|
||
# collisions for ALL https-creating modes — upload, existing, AND
|
||
# acme. Previously acme deferred this to _execute_post_completion_
|
||
# actions which only runs after Let's Encrypt has issued the cert
|
||
# (potentially many minutes later). Detecting the collision up
|
||
# front lets the user fix it before they spend an LE rate-limit
|
||
# quota on a doomed order.
|
||
if body.ssl.mode in ("upload", "existing", "acme"):
|
||
https_suffix = body.ssl.https_frontend_name_suffix or "-https"
|
||
https_fe_name = f"{body.frontend.name}{https_suffix}"
|
||
https_name_collision = await conn.fetchval(
|
||
"SELECT id FROM frontends WHERE name=$1 AND cluster_id=$2 AND is_active=TRUE",
|
||
https_fe_name,
|
||
body.cluster_id,
|
||
)
|
||
if https_name_collision:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"HTTPS frontend name '{https_fe_name}' already exists "
|
||
f"(adjust ssl.https_frontend_name_suffix or frontend.name)",
|
||
)
|
||
https_bind_collision = await check_bind_port_collision(
|
||
conn, body.cluster_id, body.frontend.bind_address, body.ssl.https_bind_port
|
||
)
|
||
if https_bind_collision:
|
||
# Bulgu #35 (round-15 audit) — actionable HTTPS collision message.
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=_explain_bind_collision(
|
||
bind_address=body.frontend.bind_address,
|
||
bind_port=body.ssl.https_bind_port,
|
||
colliding_frontend_id=https_bind_collision,
|
||
ssl_mode=body.ssl.mode,
|
||
is_https=True,
|
||
),
|
||
)
|
||
|
||
# ACME mode: resolve account UP FRONT so the post-commit step has it
|
||
acme_account_id: Optional[int] = None
|
||
if body.ssl.mode == "acme":
|
||
# R18c audit fix (round 5 #4 — KRITIK functional): refuse
|
||
# to stage an ACME order if the target cluster has
|
||
# `acme_enabled=false`. PRE-FIX the wizard happily
|
||
# created the wizard_staged order, but the HAProxy
|
||
# config generator only injects the
|
||
# `/.well-known/acme-challenge` routing block when the
|
||
# cluster's `acme_enabled` flag is true (see
|
||
# haproxy_config.py:357). With the flag off, the agent
|
||
# reload deployed a config that did NOT route challenge
|
||
# requests, so the order's HTTP-01 validation failed and
|
||
# the operator saw a generic "ACME order failed" with no
|
||
# hint that the cluster's own toggle was the cause. The
|
||
# explicit 400 here makes the misconfiguration visible
|
||
# at submit time so the operator can flip the toggle on
|
||
# the cluster page before re-submitting.
|
||
cluster_acme_row = await conn.fetchrow(
|
||
"SELECT acme_enabled FROM haproxy_clusters WHERE id = $1",
|
||
body.cluster_id,
|
||
)
|
||
if cluster_acme_row and not cluster_acme_row.get("acme_enabled"):
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=(
|
||
f"cluster_id={body.cluster_id} has acme_enabled=false. "
|
||
"Wizard cannot stage an ACME order: the HAProxy config "
|
||
"for this cluster will not route /.well-known/acme-challenge "
|
||
"requests, so the HTTP-01 validation would always fail. "
|
||
"Enable ACME on the cluster (Cluster Management → ACME "
|
||
"settings) before retrying, or pick a different SSL mode."
|
||
),
|
||
)
|
||
# Bulgu #34 (round-15 audit) — cluster-aware port-80 reachability.
|
||
# Now that the model-level `bind_port=80` requirement has been
|
||
# relaxed for multi-tenant clusters, the route handler is the
|
||
# last gate. If the new frontend is on a non-80 port we must
|
||
# confirm SOME other port-80 HTTP frontend exists in the
|
||
# cluster — otherwise LE's HTTP-01 probe has nowhere to land.
|
||
acme_reach_error = await _validate_acme_port80_reachable(conn, body)
|
||
if acme_reach_error:
|
||
raise HTTPException(status_code=400, detail=acme_reach_error)
|
||
|
||
# Bulgu #38 (round-16 audit) — pending ACME order overlap.
|
||
# Block submit when another in-flight order already covers
|
||
# any of the wizard's domains so the operator doesn't race
|
||
# two orders against the same LE rate-limit slot.
|
||
acme_overlaps = await _find_pending_acme_order_overlap(
|
||
conn, body.domains,
|
||
)
|
||
if acme_overlaps:
|
||
first = acme_overlaps[0]
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail=(
|
||
f"Domain(s) "
|
||
f"{', '.join(first['overlapping_domains'])} are "
|
||
f"already in an active Let's Encrypt order "
|
||
f"(id={first['order_id']}, "
|
||
f"status={first['status']}, "
|
||
f"created {first['created_at']}). Wait for it to "
|
||
"finalise, OR cancel it via the LE Orders page, "
|
||
"before re-running this wizard. Re-issuing a "
|
||
"second cert for the same names burns an LE "
|
||
"rate-limit slot and leaves duplicate cert rows "
|
||
"behind."
|
||
),
|
||
)
|
||
|
||
# Bulgu #37 (round-16 audit) — cross-frontend domain ACL
|
||
# collision. Applies to ALL SSL modes (not just ACME) because
|
||
# the same Host: header on two different frontends produces
|
||
# ambiguous routing regardless of TLS termination. Hard-block
|
||
# here so the operator sees the conflict at submit time.
|
||
domain_collisions = await _find_cluster_domain_routing_collisions(
|
||
conn, body.cluster_id, body.domains,
|
||
exclude_frontend_name=body.frontend.name,
|
||
)
|
||
if domain_collisions:
|
||
first = domain_collisions[0]
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail=(
|
||
f"Domain(s) "
|
||
f"{', '.join(first['conflicting_domains'])} are "
|
||
f"already routed by frontend '{first['frontend_name']}' "
|
||
f"(id={first['frontend_id']}) via ACL "
|
||
f"`{first['acl_rule']}`. Two sites in the same "
|
||
"cluster claiming the same Host header produce "
|
||
"undefined routing. Either remove the overlapping "
|
||
"domain from this wizard, or extend the existing "
|
||
"frontend with a new `use_backend` rule instead of "
|
||
"creating a second site."
|
||
),
|
||
)
|
||
if body.ssl.account_id is not None:
|
||
# R13 fix: validate user-supplied account_id BEFORE we
|
||
# rely on it. Previously `body.ssl.account_id or
|
||
# _resolve_default_acme_account()` accepted any truthy
|
||
# int — so account_id=999 (deleted / from another tenant
|
||
# / typo) silently bypassed validation and surfaced as
|
||
# an FK violation deep inside create_order_staged, which
|
||
# the user only saw as a generic 500 long after submit.
|
||
row = await conn.fetchrow(
|
||
"""
|
||
SELECT id, status FROM letsencrypt_accounts
|
||
WHERE id = $1
|
||
""",
|
||
body.ssl.account_id,
|
||
)
|
||
if not row:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"ssl.account_id={body.ssl.account_id} does not exist",
|
||
)
|
||
if row["status"] and row["status"] != "valid":
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"ssl.account_id={body.ssl.account_id} is not valid "
|
||
f"(status='{row['status']}'). Pick a different account or "
|
||
"leave the field empty to auto-pick the latest valid one.",
|
||
)
|
||
acme_account_id = body.ssl.account_id
|
||
else:
|
||
acme_account_id = await _resolve_default_acme_account(conn)
|
||
if not acme_account_id:
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail="No valid ACME account exists. Configure Let's Encrypt first.",
|
||
)
|
||
|
||
# preserved_listen_blocks NAME collision (M43/R60)
|
||
preserved_rows = await conn.fetch(
|
||
"""
|
||
SELECT preserved_listen_blocks
|
||
FROM agents a
|
||
JOIN haproxy_clusters hc ON hc.pool_id = a.pool_id
|
||
WHERE hc.id = $1 AND a.preserved_listen_blocks IS NOT NULL
|
||
""",
|
||
body.cluster_id,
|
||
)
|
||
preserved_names = set()
|
||
for r in preserved_rows:
|
||
raw = r["preserved_listen_blocks"]
|
||
try:
|
||
names = json.loads(raw) if isinstance(raw, str) else (raw or [])
|
||
except json.JSONDecodeError:
|
||
names = []
|
||
for n in names:
|
||
if isinstance(n, str):
|
||
preserved_names.add(n.lower())
|
||
if body.backend.name.lower() in preserved_names:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"Backend name '{body.backend.name}' collides with an agent-preserved listen block",
|
||
)
|
||
if body.frontend.name.lower() in preserved_names:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"Frontend name '{body.frontend.name}' collides with an agent-preserved listen block",
|
||
)
|
||
|
||
# Capture pre_apply_snapshot (current applied config) for reject path
|
||
pre_apply_snapshot_row = await conn.fetchrow(
|
||
"""
|
||
SELECT config_content FROM config_versions
|
||
WHERE cluster_id = $1 AND status = 'APPLIED' AND config_content IS NOT NULL
|
||
ORDER BY created_at DESC LIMIT 1
|
||
""",
|
||
body.cluster_id,
|
||
)
|
||
pre_apply_snapshot = (pre_apply_snapshot_row or {}).get("config_content") or ""
|
||
|
||
ts = _now_ts()
|
||
# Phase D: version-name rename `bulk-proxied-host-create-{ts}`
|
||
# → `bulk-site-create-{ts}` to match the user-visible "Site"
|
||
# rebrand. The reject path on `cluster.py` recognises BOTH
|
||
# prefixes so historical APPLIED versions (created before this
|
||
# rename) keep behaving correctly during reject/undo.
|
||
#
|
||
# Bulgu #86 (round-23 audit) — append a short UUID suffix so
|
||
# two wizard POST /api/sites calls submitted within the SAME
|
||
# epoch second cannot collide on the
|
||
# `config_versions(cluster_id, version_name)` UNIQUE
|
||
# constraint. Pre-fix `version_name = f"bulk-site-create-{ts}"`
|
||
# gave seconds resolution, so an operator who clicked "Create"
|
||
# twice in rapid succession (or any back-to-back API
|
||
# automation) saw the SECOND call 409 with the generic
|
||
# `UniqueViolationError` fall-through message:
|
||
#
|
||
# "A wizard entity with this name already exists on the
|
||
# cluster (UNIQUE constraint). Pick a different name."
|
||
#
|
||
# — even though the operator-chosen backend / frontend / SSL
|
||
# names were unique. The actual collision was on the
|
||
# auto-generated `version_name` and re-naming the wizard
|
||
# inputs did NOT help. The reject_pending_changes path on
|
||
# cluster.py:4179 prefix-matches `bulk-site-create-*` so
|
||
# appending a unique suffix preserves the historical
|
||
# rollback / undo semantics. 6 hex chars give 16M-room before
|
||
# birthday collisions, vastly more than the per-second
|
||
# request volume an operator can sustain through the wizard.
|
||
import uuid
|
||
version_name = f"bulk-site-create-{ts}-{uuid.uuid4().hex[:6]}"
|
||
bulk_snapshots: List[dict] = []
|
||
created_ids: Dict[str, Any] = {}
|
||
|
||
# ----- Atomic transaction
|
||
async with conn.transaction():
|
||
# Bulgu #54 (round-19 audit) — serialize concurrent wizard
|
||
# creates against the SAME cluster.
|
||
#
|
||
# Pre-fix the bind-port / frontend-name / HTTPS-bind-port
|
||
# collision checks (lines ~1813-1864) ran OUTSIDE this
|
||
# transaction. Two operators (or the same operator's two
|
||
# browser tabs) submitting wizards back-to-back against the
|
||
# same cluster could both pass the pre-flight check (no
|
||
# row exists yet for either), both enter their own
|
||
# transactions, and both INSERT — there is NO UNIQUE
|
||
# constraint on (cluster_id, bind_address, bind_port), so
|
||
# the second INSERT silently succeeds and the cluster ends
|
||
# up with two frontends bound to the same port. The next
|
||
# apply's `haproxy -c` fails with "duplicate bind" and
|
||
# blocks ALL subsequent applies on the cluster until an
|
||
# operator manually deletes one of the rows.
|
||
#
|
||
# Acquire a cluster-scoped transactional advisory lock as
|
||
# the FIRST statement of the wizard's create transaction.
|
||
# `pg_advisory_xact_lock` automatically releases on COMMIT
|
||
# or ROLLBACK so we never need to remember to unlock.
|
||
# Namespace key 18181819 (round-19 specific) avoids
|
||
# colliding with the existing draft-cap lock (18181818).
|
||
# Concurrent wizard runs for DIFFERENT clusters proceed
|
||
# in parallel; only the same-cluster races serialise.
|
||
await conn.execute(
|
||
"SELECT pg_advisory_xact_lock($1, $2)",
|
||
18181819, int(body.cluster_id),
|
||
)
|
||
|
||
# Bulgu #54 (round-19 audit) — re-check bind / name
|
||
# collisions INSIDE the lock. The pre-flight checks
|
||
# at lines ~1813-1864 already ran before this point
|
||
# (so unrelated callers get fast 400s without waiting
|
||
# for the lock), but a concurrent wizard transaction
|
||
# that committed between the pre-flight and the lock
|
||
# acquisition could have introduced a colliding row.
|
||
# Re-running the same helpers under the lock is cheap
|
||
# (indexed lookups) and closes the race.
|
||
late_bind = await check_bind_port_collision(
|
||
conn, body.cluster_id, body.frontend.bind_address, body.frontend.bind_port
|
||
)
|
||
if late_bind:
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail=_explain_bind_collision(
|
||
bind_address=body.frontend.bind_address,
|
||
bind_port=body.frontend.bind_port,
|
||
colliding_frontend_id=late_bind,
|
||
ssl_mode=body.ssl.mode,
|
||
is_https=False,
|
||
),
|
||
)
|
||
late_fe_name = await conn.fetchval(
|
||
"SELECT id FROM frontends WHERE name=$1 AND cluster_id=$2 AND is_active=TRUE",
|
||
body.frontend.name, body.cluster_id,
|
||
)
|
||
if late_fe_name:
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail=(
|
||
f"Frontend '{body.frontend.name}' was created by "
|
||
"another request just now. Pick a different "
|
||
"name and resubmit."
|
||
),
|
||
)
|
||
if body.ssl.mode in ("upload", "existing", "acme"):
|
||
_https_suffix = body.ssl.https_frontend_name_suffix or "-https"
|
||
_https_fe_name = f"{body.frontend.name}{_https_suffix}"
|
||
late_https_name = await conn.fetchval(
|
||
"SELECT id FROM frontends WHERE name=$1 AND cluster_id=$2 AND is_active=TRUE",
|
||
_https_fe_name, body.cluster_id,
|
||
)
|
||
if late_https_name:
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail=(
|
||
f"HTTPS frontend '{_https_fe_name}' was "
|
||
"created by another request just now. "
|
||
"Adjust ssl.https_frontend_name_suffix or "
|
||
"frontend.name and resubmit."
|
||
),
|
||
)
|
||
late_https_bind = await check_bind_port_collision(
|
||
conn, body.cluster_id, body.frontend.bind_address, body.ssl.https_bind_port
|
||
)
|
||
if late_https_bind:
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail=_explain_bind_collision(
|
||
bind_address=body.frontend.bind_address,
|
||
bind_port=body.ssl.https_bind_port,
|
||
colliding_frontend_id=late_https_bind,
|
||
ssl_mode=body.ssl.mode,
|
||
is_https=True,
|
||
),
|
||
)
|
||
|
||
be_id = await create_backend_row(conn, body.backend, body.cluster_id, mark_pending=True)
|
||
created_ids["backend_id"] = be_id
|
||
bulk_snapshots.append(_entity_snapshot("backend", be_id))
|
||
|
||
server_ids: List[int] = []
|
||
for idx, srv in enumerate(body.servers):
|
||
# R18b audit fix (round 4 #C): per-server CA-bundle
|
||
# `ssl_certificate_id` (HAProxy `ca-file` for upstream
|
||
# verification) bypassed cluster scoping pre-R18b.
|
||
# Mirror the listing/`select_existing_cert` rule so a
|
||
# cluster-A operator cannot reference a cluster-B-only
|
||
# cert id and silently use it as the upstream CA bundle.
|
||
srv_cert_id = getattr(srv, "ssl_certificate_id", None)
|
||
if srv_cert_id is not None:
|
||
eligible = await validate_server_ca_bundle_eligibility(
|
||
conn, srv_cert_id, body.cluster_id
|
||
)
|
||
if not eligible:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=(
|
||
f"server[{idx}].ssl_certificate_id={srv_cert_id} "
|
||
f"is not visible to cluster {body.cluster_id}. "
|
||
"Pick a cert that is global or already bound to "
|
||
"this cluster."
|
||
),
|
||
)
|
||
sid = await create_server_row(
|
||
conn, be_id, body.backend.name, body.cluster_id, srv, mark_pending=True
|
||
)
|
||
server_ids.append(sid)
|
||
bulk_snapshots.append(_entity_snapshot("server", sid))
|
||
created_ids["server_ids"] = server_ids
|
||
|
||
# Phase 3 (R11-audit follow-up): drop `option httpchk` from the
|
||
# frontend `options` block. `option httpchk` is a backend-only
|
||
# health-check directive — when it appears under a frontend
|
||
# the agent reload emits a warning. `routers/frontend.py`
|
||
# already strips it via `filter_httpchk_from_options`; the
|
||
# wizard now mirrors that behaviour so a draft populated by
|
||
# an operator who blindly copied `option httpchk` from a
|
||
# template still produces a clean, warning-free config.
|
||
def _filter_httpchk_from_options(options: Optional[str]) -> Optional[str]:
|
||
if not options:
|
||
return options
|
||
kept = [
|
||
line for line in options.splitlines()
|
||
if line.strip().lower() != "option httpchk"
|
||
]
|
||
return "\n".join(kept) if kept else None
|
||
|
||
_frontend_options_filtered = _filter_httpchk_from_options(body.frontend.options)
|
||
|
||
# Wire backend as default_backend for the HTTP frontend
|
||
http_frontend_payload = body.frontend.model_copy(update={
|
||
"default_backend": body.backend.name,
|
||
"redirect_rules": _build_redirect_rules(body),
|
||
"https_redirect": False, # already expanded into redirect_rules
|
||
"options": _frontend_options_filtered,
|
||
})
|
||
|
||
ssl_certificate_id_for_https: Optional[int] = None
|
||
if body.ssl.mode == "upload":
|
||
# Bulgu #25 (round-12 audit): verify the uploaded cert's
|
||
# SAN/CN entries cover EVERY wizard domain. Pre-fix the
|
||
# wizard happily deployed a cert for site-A while the
|
||
# operator's wizard listed site-B in `domains` — HAProxy
|
||
# served the wrong cert and every browser TLS handshake
|
||
# failed with NET::ERR_CERT_COMMON_NAME_INVALID. We
|
||
# parse the PEM once here (and `create_cert_row` parses
|
||
# it again — the duplicate is cheap and keeps the two
|
||
# call sites independent).
|
||
from utils.ssl_parser import (
|
||
parse_ssl_certificate as _parse_cert,
|
||
find_uncovered_domains,
|
||
)
|
||
_cert_info = _parse_cert(body.ssl.certificate_content or "")
|
||
if not _cert_info.get("error"):
|
||
_cert_domains = _cert_info.get("all_domains") or []
|
||
uncovered = find_uncovered_domains(list(body.domains or []), _cert_domains)
|
||
if uncovered:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=(
|
||
f"SSL certificate does not cover the following wizard "
|
||
f"domain(s): {', '.join(uncovered)}. Cert SAN/CN list: "
|
||
f"{', '.join(_cert_domains) or '(empty)'}. Either upload "
|
||
"a cert whose SAN list includes every wizard domain "
|
||
"(wildcards like '*.example.com' match a single label) "
|
||
"or remove the uncovered domain(s) from the wizard."
|
||
),
|
||
)
|
||
|
||
cert_payload_obj = type("_CertObj", (), {})()
|
||
cert_payload_obj.name = body.ssl.name or f"cert-{body.backend.name}-{ts}"
|
||
cert_payload_obj.certificate_content = body.ssl.certificate_content or ""
|
||
cert_payload_obj.private_key_content = body.ssl.private_key_content or ""
|
||
cert_payload_obj.chain_content = body.ssl.chain_content or None
|
||
cert_payload_obj.primary_domain = (body.domains or [None])[0]
|
||
cert_payload_obj.all_domains = list(body.domains)
|
||
cert_payload_obj.usage_type = "frontend"
|
||
|
||
cert_id = await create_cert_row(conn, cert_payload_obj, body.cluster_id)
|
||
ssl_certificate_id_for_https = cert_id
|
||
created_ids["ssl_certificate_id"] = cert_id
|
||
bulk_snapshots.append(_entity_snapshot("ssl_certificate", cert_id))
|
||
elif body.ssl.mode == "existing":
|
||
if not body.ssl.ssl_certificate_id:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="ssl.mode='existing' requires ssl_certificate_id",
|
||
)
|
||
resolved = await select_existing_cert(
|
||
conn, body.ssl.ssl_certificate_id, body.cluster_id
|
||
)
|
||
if not resolved:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"ssl_certificate_id {body.ssl.ssl_certificate_id} not found / inactive",
|
||
)
|
||
|
||
# Bulgu #25 (round-12 audit) — existing-cert branch: read
|
||
# the cert's stored SAN list from the ssl_certificates
|
||
# row and apply the same coverage check as the upload
|
||
# branch above. Pre-fix an operator could pick a cert
|
||
# for site-A and run the wizard for site-B's domain;
|
||
# the wizard would bind the HTTPS frontend to the
|
||
# wrong cert and surface a runtime TLS-handshake
|
||
# failure rather than a clean 400 at submit time.
|
||
from utils.ssl_parser import find_uncovered_domains
|
||
_cert_row = await conn.fetchrow(
|
||
"SELECT all_domains, status, days_until_expiry, primary_domain "
|
||
"FROM ssl_certificates WHERE id = $1",
|
||
resolved,
|
||
)
|
||
if _cert_row:
|
||
try:
|
||
_stored_domains = _cert_row["all_domains"]
|
||
if isinstance(_stored_domains, str):
|
||
import json as _json
|
||
_stored_domains = _json.loads(_stored_domains)
|
||
_stored_domains = list(_stored_domains or [])
|
||
except Exception:
|
||
_stored_domains = []
|
||
# Fall back to primary_domain if SAN list is unset
|
||
# (older imports lack it).
|
||
if not _stored_domains and _cert_row["primary_domain"]:
|
||
_stored_domains = [_cert_row["primary_domain"]]
|
||
if _stored_domains:
|
||
uncovered = find_uncovered_domains(
|
||
list(body.domains or []), _stored_domains
|
||
)
|
||
if uncovered:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=(
|
||
f"Existing SSL certificate id={resolved} does not "
|
||
f"cover the following wizard domain(s): "
|
||
f"{', '.join(uncovered)}. Cert SAN/CN list: "
|
||
f"{', '.join(_stored_domains)}. Pick a different "
|
||
"certificate or remove the uncovered domain(s)."
|
||
),
|
||
)
|
||
# Bulgu #24 (round-12 audit) — existing-cert branch:
|
||
# also reject if the chosen cert is already expired,
|
||
# mirroring the upload-mode rejection in create_cert_row.
|
||
if (_cert_row["status"] or "").lower() == "expired":
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=(
|
||
f"Existing SSL certificate id={resolved} is expired "
|
||
f"({_cert_row['days_until_expiry']} days past notAfter). "
|
||
"Pick a non-expired certificate or upload a fresh one."
|
||
),
|
||
)
|
||
|
||
ssl_certificate_id_for_https = resolved
|
||
created_ids["ssl_certificate_id"] = resolved
|
||
|
||
# HTTP frontend is always created
|
||
http_fe_id = await create_frontend_row(
|
||
conn,
|
||
http_frontend_payload,
|
||
body.cluster_id,
|
||
ssl_certificate_id=None,
|
||
ssl_enabled=False,
|
||
mark_pending=True,
|
||
)
|
||
created_ids["http_frontend_id"] = http_fe_id
|
||
bulk_snapshots.append(_entity_snapshot("frontend", http_fe_id))
|
||
|
||
# HTTPS frontend for upload/existing modes (acme defers it)
|
||
if body.ssl.mode in ("upload", "existing") and ssl_certificate_id_for_https:
|
||
# v1.5.0 advanced TLS: pass ALPN, TLS versions, ciphers, HSTS
|
||
# tuning through to create_frontend_row (which already supports
|
||
# ssl_alpn / ssl_ciphers / ssl_strict_sni / etc.).
|
||
#
|
||
# R12 fix: idempotent HSTS injection. If the user already wrote
|
||
# a Strict-Transport-Security directive into response_headers
|
||
# (e.g. resumed from a draft, or hand-crafted advanced rules),
|
||
# do NOT append a second one — duplicate headers confuse some
|
||
# clients and inflate the HAProxy config.
|
||
hsts_response_headers = body.frontend.response_headers or ""
|
||
_hsts_already_present = (
|
||
"strict-transport-security" in hsts_response_headers.lower()
|
||
)
|
||
if body.ssl.hsts_enabled and not _hsts_already_present:
|
||
hsts_value = f"max-age={body.ssl.hsts_max_age}"
|
||
if body.ssl.hsts_include_subdomains:
|
||
hsts_value += "; includeSubDomains"
|
||
if body.ssl.hsts_preload:
|
||
hsts_value += "; preload"
|
||
hsts_line = f'http-response set-header Strict-Transport-Security "{hsts_value}"'
|
||
hsts_response_headers = (
|
||
(hsts_response_headers + "\n" + hsts_line)
|
||
if hsts_response_headers
|
||
else hsts_line
|
||
)
|
||
https_payload = body.frontend.model_copy(update={
|
||
"default_backend": body.backend.name,
|
||
"redirect_rules": [],
|
||
"https_redirect": False,
|
||
"response_headers": hsts_response_headers or None,
|
||
# Phase 3: same `option httpchk` strip on the HTTPS
|
||
# frontend payload (it inherits `body.frontend.options`).
|
||
"options": _frontend_options_filtered,
|
||
})
|
||
# Inject TLS tuning fields onto the payload (FrontendStep does
|
||
# not declare them, but create_frontend_row reads them via
|
||
# getattr — set them as ad-hoc attrs).
|
||
for attr_name, attr_val in (
|
||
("ssl_alpn", body.ssl.ssl_alpn),
|
||
("ssl_ciphers", body.ssl.ssl_ciphers),
|
||
("ssl_ciphersuites", body.ssl.ssl_ciphersuites),
|
||
("ssl_min_ver", body.ssl.ssl_min_ver),
|
||
("ssl_max_ver", body.ssl.ssl_max_ver),
|
||
("ssl_strict_sni", body.ssl.ssl_strict_sni),
|
||
# R17 minimum-parity: mTLS client cert auth on HTTPS bind.
|
||
# Manual frontend create endpoint accepts this; wizard now
|
||
# surfaces it via SSLChoice.ssl_verify so enterprise users
|
||
# don't have to drop down to manual create just for mTLS.
|
||
("ssl_verify", body.ssl.ssl_verify),
|
||
):
|
||
object.__setattr__(https_payload, attr_name, attr_val)
|
||
suffix = body.ssl.https_frontend_name_suffix or "-https"
|
||
https_name = f"{body.frontend.name}{suffix}"
|
||
https_fe_id = await create_frontend_row(
|
||
conn,
|
||
https_payload,
|
||
body.cluster_id,
|
||
ssl_certificate_id=ssl_certificate_id_for_https,
|
||
ssl_enabled=True,
|
||
bind_port_override=body.ssl.https_bind_port,
|
||
name_override=https_name,
|
||
mark_pending=True,
|
||
)
|
||
created_ids["https_frontend_id"] = https_fe_id
|
||
bulk_snapshots.append(_entity_snapshot("frontend", https_fe_id))
|
||
|
||
# Generate fresh HAProxy config + checksum for the new version.
|
||
#
|
||
# R18c audit fix (round 1 #4 — KRITIK): pass the active
|
||
# transaction connection. Pre-fix the call obtained a
|
||
# SECOND pooled connection, which under PostgreSQL READ
|
||
# COMMITTED cannot see the uncommitted INSERTs that
|
||
# just created the wizard's backend / servers / HTTP
|
||
# frontend / HTTPS frontend in this same transaction.
|
||
# Result: the freshly-built config_versions snapshot
|
||
# OMITTED the wizard-created entities, so the operator's
|
||
# subsequent apply re-deployed a config without the new
|
||
# site even though the wizard returned "created
|
||
# successfully". This silent inconsistency was the root
|
||
# of the "wizard says success but agent reload doesn't
|
||
# show new bind" class of reports.
|
||
try:
|
||
# Phase K Phase C: route through the shared candidate
|
||
# synthesizer so the dry-run gate (`/preview` with
|
||
# `validate_haproxy_config=true`) and the apply gate
|
||
# (this `create_site` block) can never silently
|
||
# desync. Pinned by
|
||
# `tests/test_site_wizard_phase_k.py::
|
||
# test_phase_k_create_site_and_preview_use_same_synthesis_helper`.
|
||
config_content = await _synthesize_candidate_haproxy_config(
|
||
body, conn, entities_already_inserted=True
|
||
)
|
||
except Exception as cfg_err:
|
||
logger.error(f"WIZARD: config gen failed: {cfg_err}")
|
||
config_content = ""
|
||
|
||
# Phase 2 (PR-5): pre-persist HAProxy config validation gate.
|
||
# Generation already applies the in-line safeguards
|
||
# (`_apply_bind_ssl_verify`, `_format_redirect_rule`,
|
||
# `_categorize_haproxy_directive` bucket order, stick-table
|
||
# dedup) so the rendered config is structurally sound. As a
|
||
# defence-in-depth pass we ALSO run HAProxyConfigValidator on
|
||
# the generated string and ABORT the transaction if any
|
||
# ERROR-level diagnostic fires — the user-stated rule is "UI
|
||
# must not allow operations that fail haproxy validation",
|
||
# so we refuse to persist a config_versions row that would
|
||
# not reload cleanly. A validator crash is non-fatal (the
|
||
# apply-time `haproxy -c` on the agent is the ultimate
|
||
# gate); we only block on real ERROR-level findings.
|
||
if config_content:
|
||
try:
|
||
from utils.haproxy_validator import (
|
||
HAProxyConfigValidator,
|
||
ValidationLevel,
|
||
)
|
||
# Phase K Phase D follow-up (Bulgu #12): same as
|
||
# the /preview dry-run — the wizard's synthesised
|
||
# cluster config is a PARTIAL fragment that the
|
||
# agent merges with its local global+defaults at
|
||
# reload time. Skip the global/defaults missing-
|
||
# section diagnostics so the apply-time gate does
|
||
# not refuse to persist a perfectly valid wizard
|
||
# output on a spurious WARNING (the gate currently
|
||
# only blocks on ERROR-level, but emitting WARN
|
||
# noise still leaks to the operator-visible
|
||
# response trail and the version-history page).
|
||
_val_report = HAProxyConfigValidator().validate_config(
|
||
config_content, partial_fragment=True
|
||
)
|
||
_val_errors = [
|
||
r for r in _val_report.results
|
||
if r.level == ValidationLevel.ERROR
|
||
]
|
||
if _val_errors:
|
||
_err_payload = [
|
||
{
|
||
"line": e.line_number,
|
||
"section": e.section,
|
||
"message": e.message,
|
||
"directive": e.directive,
|
||
}
|
||
for e in _val_errors[:20] # cap response payload size
|
||
]
|
||
logger.error(
|
||
"WIZARD: pre-persist haproxy validation FAILED: "
|
||
f"{len(_val_errors)} error(s) — first: {_err_payload[0]}"
|
||
)
|
||
raise HTTPException(
|
||
status_code=422,
|
||
detail={
|
||
"error": "haproxy_validation_failed",
|
||
"message": (
|
||
f"Generated HAProxy configuration would fail "
|
||
f"validation ({len(_val_errors)} error(s)). "
|
||
"The wizard refused to persist a config that "
|
||
"would not reload cleanly. Adjust the inputs "
|
||
"and try again."
|
||
),
|
||
"errors": _err_payload,
|
||
},
|
||
)
|
||
except HTTPException:
|
||
raise # propagate so the transaction rolls back
|
||
except Exception as _val_err:
|
||
# Validator itself crashed — non-fatal. The apply-time
|
||
# `haproxy -c` on the agent will catch any real syntax
|
||
# issue; do NOT block create on a defensive-validator
|
||
# bug.
|
||
logger.warning(
|
||
f"WIZARD: pre-persist validator crashed "
|
||
f"(non-fatal, apply-time haproxy -c remains the ultimate "
|
||
f"gate): {_val_err}"
|
||
)
|
||
|
||
import hashlib
|
||
config_hash = hashlib.sha256(config_content.encode()).hexdigest()
|
||
|
||
metadata = {
|
||
"wizard": "site_create",
|
||
"version": "v1.5.0",
|
||
"created_by_user_id": user_id,
|
||
"ssl_mode": body.ssl.mode,
|
||
"domains": list(body.domains),
|
||
"bulk_snapshots": bulk_snapshots,
|
||
"pre_apply_snapshot": pre_apply_snapshot,
|
||
}
|
||
|
||
config_version_id = await conn.fetchval(
|
||
"""
|
||
INSERT INTO config_versions
|
||
(cluster_id, version_name, config_content, checksum, created_by,
|
||
is_active, status, description, metadata)
|
||
VALUES ($1, $2, $3, $4, $5, FALSE, 'PENDING', $6, $7::jsonb)
|
||
RETURNING id
|
||
""",
|
||
body.cluster_id,
|
||
version_name,
|
||
config_content,
|
||
config_hash,
|
||
user_id,
|
||
f"Wizard-created site '{body.backend.name}' for {', '.join(body.domains)}",
|
||
json.dumps(metadata),
|
||
)
|
||
created_ids["config_version_id"] = config_version_id
|
||
|
||
# ----- POST-COMMIT actions
|
||
response_status = "created_pending"
|
||
apply_result: Optional[Dict[str, Any]] = None
|
||
acme_order_id: Optional[int] = None
|
||
acme_staging_error: Optional[str] = None
|
||
|
||
if body.apply_immediately:
|
||
try:
|
||
from services.apply_service import apply_cluster_pending
|
||
apply_result = await apply_cluster_pending(
|
||
body.cluster_id, user_id=user_id
|
||
)
|
||
response_status = "created_applied"
|
||
except Exception as apply_err:
|
||
logger.error(f"WIZARD: apply failed for cluster {body.cluster_id}: {apply_err}")
|
||
response_status = "created_pending_apply_failed"
|
||
apply_result = {"error": str(apply_err)}
|
||
|
||
# ACME staging — only after apply succeeded (so version_name is real)
|
||
if body.ssl.mode == "acme" and response_status == "created_applied":
|
||
try:
|
||
# Re-acquire the connection for the post-commit work; we
|
||
# already closed our transaction above.
|
||
post_conn = await get_database_connection()
|
||
try:
|
||
suffix = body.ssl.https_frontend_name_suffix or "-https"
|
||
https_name = f"{body.frontend.name}{suffix}"
|
||
|
||
# Build the deferred HTTPS frontend payload that
|
||
# _complete_certificate's post_completion_actions will
|
||
# execute once the cert is downloaded.
|
||
# v1.5.0 advanced TLS: persist HSTS + ALPN + TLS tuning
|
||
# in the deferred frontend_config so post-completion
|
||
# action creates a properly-configured HTTPS frontend.
|
||
# R12 fix: idempotent (skip if user already wrote HSTS).
|
||
hsts_acme_headers = body.frontend.response_headers or ""
|
||
_hsts_already = (
|
||
"strict-transport-security" in hsts_acme_headers.lower()
|
||
)
|
||
if body.ssl.hsts_enabled and not _hsts_already:
|
||
hsts_value = f"max-age={body.ssl.hsts_max_age}"
|
||
if body.ssl.hsts_include_subdomains:
|
||
hsts_value += "; includeSubDomains"
|
||
if body.ssl.hsts_preload:
|
||
hsts_value += "; preload"
|
||
hsts_line = f'http-response set-header Strict-Transport-Security "{hsts_value}"'
|
||
hsts_acme_headers = (
|
||
(hsts_acme_headers + "\n" + hsts_line)
|
||
if hsts_acme_headers
|
||
else hsts_line
|
||
)
|
||
deferred_https_action = {
|
||
# v1.5.0 R12: bumped schema_version to 2 to signal
|
||
# that the new advanced fields (ssl_alpn / hsts /
|
||
# tls versions / etc.) are present. Reader treats
|
||
# missing keys as None so v1 actions still execute
|
||
# cleanly post-upgrade.
|
||
"type": "create_frontend",
|
||
"schema_version": 2,
|
||
"frontend_config": {
|
||
# core
|
||
"name": https_name,
|
||
"mode": body.frontend.mode,
|
||
"bind_address": body.frontend.bind_address,
|
||
"bind_port": body.ssl.https_bind_port,
|
||
"default_backend": body.backend.name,
|
||
"ssl_enabled": True,
|
||
"cluster_id": body.cluster_id,
|
||
"_auto_apply": True,
|
||
"_user_id": user_id,
|
||
# advanced TLS (HAProxy 2.4+) — read by
|
||
# frontend_service.create_frontend_row via
|
||
# getattr on the SimpleNamespace shim assembled
|
||
# in routers/letsencrypt.py::
|
||
# _execute_post_completion_actions.
|
||
"ssl_alpn": body.ssl.ssl_alpn,
|
||
"ssl_ciphers": body.ssl.ssl_ciphers,
|
||
"ssl_ciphersuites": body.ssl.ssl_ciphersuites,
|
||
"ssl_min_ver": body.ssl.ssl_min_ver,
|
||
"ssl_max_ver": body.ssl.ssl_max_ver,
|
||
"ssl_strict_sni": body.ssl.ssl_strict_sni,
|
||
# R17 minimum-parity: mTLS client cert auth.
|
||
"ssl_verify": body.ssl.ssl_verify,
|
||
# frontend tuning + headers (HSTS lands here)
|
||
"response_headers": hsts_acme_headers or None,
|
||
"request_headers": body.frontend.request_headers,
|
||
"compression": body.frontend.compression,
|
||
"log_separate": body.frontend.log_separate,
|
||
"monitor_uri": body.frontend.monitor_uri,
|
||
"maxconn": body.frontend.maxconn,
|
||
"rate_limit": body.frontend.rate_limit,
|
||
"timeout_client": body.frontend.timeout_client,
|
||
"timeout_http_request": body.frontend.timeout_http_request,
|
||
"options": body.frontend.options,
|
||
"tcp_request_rules": body.frontend.tcp_request_rules,
|
||
# routing — explicitly empty for the HTTPS sibling
|
||
# (matches the upload/existing branch above).
|
||
"redirect_rules": [],
|
||
"acl_rules": list(body.frontend.acl_rules or []),
|
||
"use_backend_rules": list(body.frontend.use_backend_rules or []),
|
||
},
|
||
}
|
||
|
||
# Bulgu #30 fix: gate the staged order on the CONSOLIDATED
|
||
# version name (`apply-consolidated-{ts}`), which is what
|
||
# the agent reports back via /config-applied. The original
|
||
# PENDING version (`bulk-site-create-{ts}`) is
|
||
# marked APPLIED+is_active=FALSE by apply_pending_changes
|
||
# and never appears in agents.applied_config_version, so
|
||
# comparing against it would block promotion forever.
|
||
gating_version_name = (
|
||
(apply_result or {}).get("latest_version") or version_name
|
||
)
|
||
|
||
acme_order_id = await create_order_staged(
|
||
post_conn,
|
||
account_id=acme_account_id,
|
||
domains=list(body.domains),
|
||
cluster_ids=[body.cluster_id],
|
||
post_completion_actions=[deferred_https_action],
|
||
pending_apply_version_name=gating_version_name,
|
||
created_by=user_id,
|
||
)
|
||
|
||
# Extend the version metadata so reject force-delete
|
||
# also cleans this staged order. (R43/M27)
|
||
await post_conn.execute(
|
||
"""
|
||
UPDATE config_versions
|
||
SET metadata = jsonb_set(
|
||
COALESCE(metadata, '{}'::jsonb),
|
||
'{bulk_snapshots}',
|
||
COALESCE(metadata->'bulk_snapshots', '[]'::jsonb)
|
||
|| $2::jsonb,
|
||
true
|
||
)
|
||
WHERE id = $1
|
||
""",
|
||
config_version_id,
|
||
json.dumps([_entity_snapshot("letsencrypt_order", acme_order_id)]),
|
||
)
|
||
|
||
await record_event(
|
||
acme_order_id,
|
||
"wizard_staged",
|
||
severity="INFO",
|
||
message=f"Wizard staged ACME order for {', '.join(body.domains)}",
|
||
details={
|
||
"version_name": version_name,
|
||
"gating_version_name": gating_version_name,
|
||
"cluster_id": body.cluster_id,
|
||
"user_id": user_id,
|
||
},
|
||
conn=post_conn,
|
||
)
|
||
finally:
|
||
await close_database_connection(post_conn)
|
||
except Exception as acme_err:
|
||
logger.error(f"WIZARD: ACME staging failed: {acme_err}")
|
||
response_status = "applied_acme_staging_failed"
|
||
acme_staging_error = str(acme_err)
|
||
|
||
# R18b audit fix (round 6 #15): the activity-logger middleware
|
||
# only records 2xx HTTP responses with the bare status code.
|
||
# The wizard create endpoint can return 200 with a `status`
|
||
# body of `created_pending_apply_failed` or
|
||
# `applied_acme_staging_failed` — the audit trail then claims
|
||
# "wizard succeeded" while the operator's downstream apply or
|
||
# ACME staging failed. Emit an explicit user_activity_logs row
|
||
# capturing the wizard outcome so the audit trail reflects
|
||
# reality regardless of HTTP status interpretation by the
|
||
# generic middleware.
|
||
#
|
||
# R18b round 7 refinement: the emit is fire-and-forget via
|
||
# `asyncio.create_task`. Pre-fix the awaited call added a
|
||
# secondary DB INSERT to the wizard's tail latency. The audit
|
||
# log helper already swallows its own exceptions and the
|
||
# response data is fully prepared; spawning the task lets
|
||
# the wizard return as soon as the transaction is committed.
|
||
try:
|
||
import asyncio as _asyncio
|
||
from utils.activity_log import log_user_activity as _log_user_activity
|
||
_asyncio.create_task(_log_user_activity(
|
||
user_id=user_id,
|
||
action="wizard_create_site",
|
||
resource_type="site",
|
||
resource_id=str(version_name) if version_name else None,
|
||
details={
|
||
"wizard_status": response_status,
|
||
"cluster_id": body.cluster_id,
|
||
"domains": body.domains,
|
||
"ssl_mode": body.ssl.mode,
|
||
"version_name": version_name,
|
||
"apply_immediately": body.apply_immediately,
|
||
"acme_order_id": acme_order_id,
|
||
"acme_staging_error": acme_staging_error,
|
||
"apply_error": (
|
||
apply_result.get("error")
|
||
if isinstance(apply_result, dict) else None
|
||
),
|
||
},
|
||
))
|
||
except Exception as audit_err:
|
||
# Audit logging must never break the main flow.
|
||
logger.debug(f"WIZARD: audit log emit failed: {audit_err}")
|
||
|
||
return {
|
||
"status": response_status,
|
||
"version_name": version_name,
|
||
"created_ids": created_ids,
|
||
"apply_result": apply_result,
|
||
"acme_order_id": acme_order_id,
|
||
"acme_staging_error": acme_staging_error,
|
||
}
|
||
except HTTPException:
|
||
raise
|
||
except ValidationError as ve:
|
||
# 422 envelope (R57/M38) — return field-level errors
|
||
raise HTTPException(status_code=422, detail=ve.errors())
|
||
except UndefinedColumnError as uce:
|
||
# R18c audit fix (round 1 #3): if migrations are behind on a
|
||
# rolling deploy, the wizard's INSERTs may reference columns
|
||
# that haven't been added yet (e.g. backend_servers
|
||
# `ssl_certificate_id`, frontends advanced TLS columns).
|
||
# Pre-fix this surfaced as an unguarded asyncpg error inside
|
||
# `except Exception` → 500 with raw column-name leakage. Map
|
||
# to 503 with the same "run migrations" hint as the missing-
|
||
# table case so operators have one consistent recovery
|
||
# signal regardless of whether the schema gap is a column or
|
||
# a table.
|
||
msg = str(uce)
|
||
logger.error(f"WIZARD: undefined column on create (migration lag?): {msg}")
|
||
raise HTTPException(
|
||
status_code=503,
|
||
detail=(
|
||
"A required column is missing — the database appears to "
|
||
"be behind on migrations. Run the backend migration step "
|
||
"before creating wizard sites."
|
||
),
|
||
)
|
||
except UndefinedTableError as ute:
|
||
# R18b audit fix (round 5 #B): if the API process is brought
|
||
# up against a database where `run_all_migrations` has not
|
||
# finished (e.g. mid-rolling-deploy, or an operator restored a
|
||
# snapshot from before R18 schema changes), the SSL eligibility
|
||
# query touches `ssl_certificate_clusters` which may not exist
|
||
# yet and the wizard surfaces a generic 500 with a raw asyncpg
|
||
# message — operator has no actionable signal. Map to 503 with
|
||
# a "run migrations" hint so the operator immediately knows
|
||
# the cause and remediation.
|
||
msg = str(ute)
|
||
logger.error(f"WIZARD: undefined table on create (migration lag?): {msg}")
|
||
raise HTTPException(
|
||
status_code=503,
|
||
detail=(
|
||
"A required table is missing — the database appears to "
|
||
"be behind on migrations (likely `ssl_certificate_clusters` "
|
||
"or another R18+ schema artefact). Run the backend "
|
||
"migration step before creating wizard sites."
|
||
),
|
||
)
|
||
except ForeignKeyViolationError as fve:
|
||
# R18b audit fix (round 4 #E): a cert / cluster / backend the
|
||
# wizard relied on was deleted between pre-flight validation
|
||
# and the FK enforcement on INSERT. The transaction rolls
|
||
# back cleanly (no partial state), but pre-fix the operator
|
||
# saw a generic 500. 409 with a hint maps the race to a
|
||
# retry-with-fresh-state action instead of a "service broken"
|
||
# signal.
|
||
msg = str(fve)
|
||
logger.info(f"WIZARD: FK violation on create: {msg}")
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail=(
|
||
"A referenced entity (SSL certificate, cluster, or "
|
||
"backend) was deleted while the wizard was creating "
|
||
"this site. Reload the wizard and reselect dependent "
|
||
"fields, then retry."
|
||
),
|
||
)
|
||
except UniqueViolationError as uve:
|
||
# R18b audit fix (round 3 #4): TWO operators racing the same
|
||
# wizard-host name on the same cluster both pass the
|
||
# `is_active=TRUE` pre-flight collision check and enter the
|
||
# transaction; the SECOND insert hits the UNIQUE(name,
|
||
# cluster_id) constraint. Pre-fix that bubbled up as a
|
||
# generic 500 with no operator-actionable detail. The same
|
||
# constraint also fires when a soft-deleted (is_active=FALSE)
|
||
# row still occupies the (name, cluster_id) tuple — pre-flight
|
||
# only checks active rows. 409 with a precise hint maps the
|
||
# race to "pick a different name" instead of "something
|
||
# broke".
|
||
msg = str(uve)
|
||
logger.info(f"WIZARD: name conflict on create: {msg}")
|
||
# Bulgu #85 (round-23 audit) — extract the offending constraint
|
||
# name from the asyncpg message so the operator-visible detail
|
||
# can pin-point WHICH entity collided. Pre-fix the handler only
|
||
# recognised `backends_*_key` and `frontends_*_key`; any other
|
||
# constraint (ssl_certificates, backend_servers, config_versions
|
||
# …) fell through to the generic "wizard entity with this name"
|
||
# message which is useless for debugging — the operator has to
|
||
# open the server log and the engineer has to ssh-bounce to
|
||
# extract `constraint=<name>` from the exception detail.
|
||
#
|
||
# ``asyncpg.UniqueViolationError.constraint_name`` is the
|
||
# canonical structured field; ``str(uve)`` only contains the
|
||
# human-formatted DETAIL line. Prefer the attribute, fall back
|
||
# to substring scanning so we stay robust if asyncpg ever stops
|
||
# exposing it.
|
||
constraint = getattr(uve, "constraint_name", None) or ""
|
||
msg_lower = msg.lower()
|
||
if "backends_name_cluster_id_key" in msg or 'backends_name' in msg \
|
||
or constraint == "backends_name_cluster_id_key":
|
||
detail = (
|
||
f"A backend named '{body.backend.name}' already exists on "
|
||
"the cluster (possibly soft-deleted). Pick a different "
|
||
"backend name or restore/permanently-delete the existing "
|
||
"row."
|
||
)
|
||
elif "frontends_name_cluster_id_key" in msg or "frontends_name" in msg \
|
||
or constraint == "frontends_name_cluster_id_key":
|
||
detail = (
|
||
f"A frontend named '{body.frontend.name}' (or its auto-"
|
||
f"derived HTTPS sibling) already exists on the cluster "
|
||
"(possibly soft-deleted). Pick a different frontend name "
|
||
"or restore/permanently-delete the existing row."
|
||
)
|
||
elif "ssl_certificates" in msg_lower or "ssl_certificates" in constraint \
|
||
or "ssl_cert" in constraint:
|
||
ssl_name = getattr(body.ssl, "name", None) or "(unnamed)"
|
||
detail = (
|
||
f"An SSL certificate named '{ssl_name}' already exists on "
|
||
"the cluster (possibly soft-deleted). Pick a different "
|
||
"ssl.name or restore/permanently-delete the existing "
|
||
"certificate row."
|
||
)
|
||
elif "backend_servers" in msg_lower or "backend_servers" in constraint:
|
||
detail = (
|
||
"Two servers in the same backend share a server_name. "
|
||
"HAProxy requires `server <name>` tokens to be unique "
|
||
"within a backend block. Rename the duplicate(s) and "
|
||
"resubmit."
|
||
)
|
||
else:
|
||
# Echo the constraint name (a stable, non-secret schema
|
||
# identifier) in the detail so a human reading the toast
|
||
# can grep the codebase for the matching CREATE TABLE
|
||
# without needing server-log access. Names like
|
||
# `proxied_hosts_pkey` or `config_versions_unique` are
|
||
# safe to surface — they're public schema info.
|
||
con_hint = f" (constraint={constraint})" if constraint else ""
|
||
detail = (
|
||
f"A wizard entity with this name already exists on the "
|
||
f"cluster (UNIQUE constraint{con_hint}). Pick a different "
|
||
"name or retry; if the conflict persists contact the "
|
||
"platform team."
|
||
)
|
||
raise HTTPException(status_code=409, detail=detail)
|
||
except Exception as e:
|
||
# R18c round 10 (M3): pre-round-10 we surfaced `detail=str(e)` to
|
||
# the client, which leaks SQL fragments, internal identifiers,
|
||
# exception class names, and occasionally file paths to the
|
||
# browser. Log the full exception server-side with a correlation
|
||
# id (uuid4) so an operator can find the matching server log
|
||
# entry from the toast they see in the UI; return a stable
|
||
# generic detail to keep info disclosure low.
|
||
import uuid
|
||
correlation_id = uuid.uuid4().hex[:12]
|
||
logger.exception(
|
||
"WIZARD: unexpected failure [correlation_id=%s] user_id=%s: %s",
|
||
correlation_id, user_id, e,
|
||
)
|
||
raise HTTPException(
|
||
status_code=500,
|
||
detail=(
|
||
"Wizard create failed unexpectedly. Please retry; if the "
|
||
f"error persists contact the platform team and reference "
|
||
f"correlation id {correlation_id} (server logs)."
|
||
),
|
||
)
|
||
finally:
|
||
await close_database_connection(conn)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Drafts CRUD
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@router.post("/drafts")
|
||
async def save_draft(
|
||
body: SiteDraftCreate,
|
||
authorization: str = Header(None),
|
||
):
|
||
current_user = await get_current_user_from_token(authorization)
|
||
user_id = current_user["id"]
|
||
if not await _can_use_wizard(user_id, current_user=current_user):
|
||
raise HTTPException(
|
||
status_code=403,
|
||
detail="Insufficient permissions: at least one of frontend.read/create, "
|
||
"ssl.read/create or backend.create is required to use the wizard",
|
||
)
|
||
|
||
sanitized = _strip_pem_from_payload(body.payload or {})
|
||
|
||
conn = await get_database_connection()
|
||
try:
|
||
# R14 hardening (#R14-1): cap drafts per user. Without a cap a
|
||
# single user can grow wizard_drafts indefinitely (within the 30d
|
||
# retention window). 50 active drafts per user is generous for
|
||
# human use and bounded for the table.
|
||
#
|
||
# R18 audit fix: the COUNT and INSERT below ran sequentially on
|
||
# the same connection but were NOT wrapped in a transaction, so
|
||
# two parallel saves could both observe count=49 and both
|
||
# insert ⇒ the cap was advisory only. We now wrap both
|
||
# statements in a single transaction AND take a per-user
|
||
# advisory lock for the duration. The advisory lock key is
|
||
# derived from `user_id` and a stable namespace constant so
|
||
# admins doing parallel work on different users are not
|
||
# serialised against each other.
|
||
async with conn.transaction():
|
||
# Stable namespace tag for "wizard_drafts cap" — chosen
|
||
# arbitrary but deterministic. Postgres advisory locks
|
||
# take two int4 args; we use (namespace, user_id).
|
||
await conn.execute("SELECT pg_advisory_xact_lock($1, $2)", 18181818, int(user_id))
|
||
# Phase I: dual-filter — accept BOTH the post-rebrand
|
||
# `site` value and the legacy `proxied_host` value so the
|
||
# 50-draft cap still counts pre-rename drafts owned by
|
||
# this user. The `(user_id, wizard_type, updated_at)`
|
||
# composite index handles the IN-list as a B-tree
|
||
# bitmap merge so the cap check stays O(log n).
|
||
existing = await conn.fetchval(
|
||
"""
|
||
SELECT COUNT(*)::int FROM wizard_drafts
|
||
WHERE user_id = $1
|
||
AND wizard_type IN ('site', 'proxied_host')
|
||
AND expires_at > NOW()
|
||
""",
|
||
user_id,
|
||
)
|
||
if existing is not None and existing >= 50:
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail=(
|
||
"You already have 50 active wizard drafts. Delete some "
|
||
"from 'Site Drafts' before saving a new one."
|
||
),
|
||
)
|
||
|
||
# Bulgu #1 fix: return the REAL expires_at from the DB default
|
||
# (created_at + INTERVAL '30 days') instead of NOW(). Otherwise the
|
||
# frontend prompts users that the draft expires today.
|
||
# Phase I: new INSERTs land with the canonical `site`
|
||
# value (matches the schema-level DEFAULT post-migration).
|
||
# The list/delete paths use IN ('site', 'proxied_host')
|
||
# so pre-rename drafts owned by the same user still
|
||
# surface in the listing — no row-level UPDATE migration
|
||
# is needed (existing rows are untouched).
|
||
row = await conn.fetchrow(
|
||
"""
|
||
INSERT INTO wizard_drafts (user_id, wizard_type, title, payload)
|
||
VALUES ($1, 'site', $2, $3::jsonb)
|
||
RETURNING id, expires_at, created_at, updated_at
|
||
""",
|
||
user_id,
|
||
body.title,
|
||
json.dumps(sanitized),
|
||
)
|
||
def _iso(d):
|
||
return d.isoformat().replace("+00:00", "Z") if d else None
|
||
return {
|
||
"id": row["id"],
|
||
"title": body.title,
|
||
"expires_at": _iso(row["expires_at"]),
|
||
"created_at": _iso(row["created_at"]),
|
||
"updated_at": _iso(row["updated_at"]),
|
||
}
|
||
finally:
|
||
await close_database_connection(conn)
|
||
|
||
|
||
@router.get("/drafts")
|
||
async def list_drafts(authorization: str = Header(None)):
|
||
current_user = await get_current_user_from_token(authorization)
|
||
user_id = current_user["id"]
|
||
if not await _can_use_wizard(user_id, current_user=current_user):
|
||
raise HTTPException(
|
||
status_code=403,
|
||
detail="Insufficient permissions: at least one of frontend.read/create, "
|
||
"ssl.read/create or backend.create is required to use the wizard",
|
||
)
|
||
|
||
conn = await get_database_connection()
|
||
try:
|
||
# Phase I: dual-filter so drafts saved before the Site
|
||
# rebrand (wizard_type='proxied_host') still appear in the
|
||
# operator's draft list alongside post-rebrand drafts
|
||
# (wizard_type='site').
|
||
rows = await conn.fetch(
|
||
"""
|
||
SELECT id, title, payload, expires_at, created_at, updated_at
|
||
FROM wizard_drafts
|
||
WHERE user_id = $1
|
||
AND wizard_type IN ('site', 'proxied_host')
|
||
AND expires_at > NOW()
|
||
ORDER BY updated_at DESC
|
||
""",
|
||
user_id,
|
||
)
|
||
|
||
# R18c round 7 (Bulgu 4): augment each draft with ssl_cert_summary
|
||
# when ssl.mode='existing' and a referenced cert id is present.
|
||
# Operators reported that the drafts list showed a generic "30
|
||
# days" expiry (the draft TTL) instead of the SELECTED ssl
|
||
# certificate's actual expiry — confusing on a screen mixing two
|
||
# unrelated countdowns. We now JOIN ssl_certificates in a single
|
||
# batch query (no N+1) so the UI can render the proper cert
|
||
# name, status and expiry mirroring FrontendManagement's SSL/TLS
|
||
# column.
|
||
def _payload(r):
|
||
p = r["payload"]
|
||
if isinstance(p, str):
|
||
try:
|
||
return json.loads(p)
|
||
except Exception:
|
||
return {}
|
||
return p or {}
|
||
|
||
cert_ids = set()
|
||
parsed_payloads = []
|
||
for r in rows:
|
||
p = _payload(r)
|
||
parsed_payloads.append(p)
|
||
ssl_obj = p.get("ssl") if isinstance(p, dict) else None
|
||
if isinstance(ssl_obj, dict) and ssl_obj.get("mode") == "existing":
|
||
cid = ssl_obj.get("ssl_certificate_id")
|
||
if isinstance(cid, int):
|
||
cert_ids.add(cid)
|
||
|
||
cert_map = {}
|
||
if cert_ids:
|
||
cert_rows = await conn.fetch(
|
||
"""
|
||
SELECT id, name, primary_domain AS domain, expiry_date,
|
||
days_until_expiry, status
|
||
FROM ssl_certificates
|
||
WHERE id = ANY($1::int[]) AND is_active = TRUE
|
||
""",
|
||
list(cert_ids),
|
||
)
|
||
cert_map = {c["id"]: c for c in cert_rows}
|
||
|
||
drafts_out = []
|
||
for r, p in zip(rows, parsed_payloads):
|
||
ssl_cert_summary = None
|
||
ssl_obj = p.get("ssl") if isinstance(p, dict) else None
|
||
if isinstance(ssl_obj, dict) and ssl_obj.get("mode") == "existing":
|
||
cid = ssl_obj.get("ssl_certificate_id")
|
||
if isinstance(cid, int):
|
||
c = cert_map.get(cid)
|
||
if c:
|
||
ssl_cert_summary = {
|
||
"id": c["id"],
|
||
"name": c["name"],
|
||
"domain": c["domain"],
|
||
"expiry_date": (
|
||
c["expiry_date"].isoformat().replace("+00:00", "Z")
|
||
if c["expiry_date"] else None
|
||
),
|
||
"days_until_expiry": c["days_until_expiry"],
|
||
"status": c["status"],
|
||
}
|
||
else:
|
||
# Cert was deleted or marked inactive after the
|
||
# draft was saved. Tell the UI explicitly so it
|
||
# can warn the operator instead of silently
|
||
# falling back to "no cert".
|
||
ssl_cert_summary = {"id": cid, "deleted": True}
|
||
|
||
drafts_out.append({
|
||
"id": r["id"],
|
||
"title": r["title"],
|
||
# R18c round 8 (Bulgu A): asyncpg has no JSONB codec
|
||
# registered on the pool, so r["payload"] comes back as a
|
||
# raw JSON string. Returning the string verbatim caused
|
||
# the drafts UI to render `r.payload?.domains` as
|
||
# undefined (it's `string.domains`, not `dict.domains`),
|
||
# which is why the table showed empty Domains/Cluster
|
||
# columns AND why Resume sent the wizard a string that
|
||
# JSON.parse turned back into a string — never hydrating
|
||
# the form. We always return a parsed dict so the FE
|
||
# contract is stable regardless of asyncpg behaviour.
|
||
"payload": p if isinstance(p, dict) else {},
|
||
"ssl_cert_summary": ssl_cert_summary,
|
||
"expires_at": r["expires_at"].isoformat().replace("+00:00", "Z") if r["expires_at"] else None,
|
||
"created_at": r["created_at"].isoformat().replace("+00:00", "Z") if r["created_at"] else None,
|
||
"updated_at": r["updated_at"].isoformat().replace("+00:00", "Z") if r["updated_at"] else None,
|
||
})
|
||
|
||
return {"drafts": drafts_out}
|
||
finally:
|
||
await close_database_connection(conn)
|
||
|
||
|
||
@router.delete("/drafts/{draft_id}")
|
||
async def delete_draft(draft_id: int, authorization: str = Header(None)):
|
||
current_user = await get_current_user_from_token(authorization)
|
||
user_id = current_user["id"]
|
||
if not await _can_use_wizard(user_id, current_user=current_user):
|
||
raise HTTPException(
|
||
status_code=403,
|
||
detail="Insufficient permissions: at least one of frontend.read/create, "
|
||
"ssl.read/create or backend.create is required to use the wizard",
|
||
)
|
||
|
||
conn = await get_database_connection()
|
||
try:
|
||
# R18b audit fix (round 6 #11): use DELETE ... RETURNING to
|
||
# detect 0-rows-affected reliably. Pre-fix the route relied
|
||
# on `result.endswith("0")`, which silently became "draft
|
||
# deleted OK" if asyncpg's status string format ever
|
||
# changed (e.g. driver upgrade returned bytes instead of
|
||
# str, or future asyncpg versions changed "DELETE 0" to a
|
||
# different shape). RETURNING returns an explicit row when
|
||
# the delete fired, NULL when it didn't — unambiguous.
|
||
deleted_id = await conn.fetchval(
|
||
"DELETE FROM wizard_drafts WHERE id = $1 AND user_id = $2 RETURNING id",
|
||
draft_id,
|
||
user_id,
|
||
)
|
||
if deleted_id is None:
|
||
raise HTTPException(status_code=404, detail="Draft not found")
|
||
return {"deleted": draft_id}
|
||
finally:
|
||
await close_database_connection(conn)
|