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

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

- models: remove the 5 `-f` hard rejects (frontend acl/redirect/use_backend
  validators + wizard string/dict-redirect guards); `$(`/backtick and X!X
  contradiction guards unchanged
- routers/frontend: `_pattern_file_warnings` helper; non-blocking warning on
  create + update responses listing referenced pattern files (empty when no
  rule uses `-f` — zero noise)
- routers/config: bulk-import preview advisory listing pattern files per
  frontend (cluster config-dir aware, next to the SPOE advisories)
- React: remove the FrontendManagement submit gate and SiteWizard step gate;
  ACLRuleBuilder renders informational notes instead of errors and re-adds
  `-f (pattern file on host)` to the flag dropdown; create path now renders
  server warnings like update
- tests: 4 reject-pins inverted to accept-pins; new test_acl_pattern_file_allow.py
  (accept/guards-kept/zero-noise/advisory); full suite green (1094 passed)
2026-07-14 00:27:11 +03:00

2250 lines
108 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
v1.5.0 Feature B (Issue #14): Pydantic models for the New Site Setup
Wizard.
The wizard accepts an entire host bundle (cluster, domains, backend, server(s),
HTTP frontend, SSL choice, optional ACME) in a single atomic POST. The
backend then opens a single transaction and creates all entities in order.
Design notes:
- We deliberately use Pydantic v2 syntax (`pattern=` not the deprecated
`regex=`).
- M22: ssl.mode='acme' MUST set apply_immediately=true (the wizard does NOT
let the user save an ACME-staged order without an actual config-version
apply that the agent can confirm).
- Round 10 micro-finding: ssl.mode='acme' also requires frontend.mode='http'
(HTTP-01 challenge is HTTP-only).
- M19: frontend.https_redirect (UI sugar) is expanded server-side to a
redirect_rules JSONB row; the wizard rejects payloads that set BOTH
https_redirect=true AND a non-empty redirect_rules list.
- Backend names beginning with `_` are reserved for system-managed entities
(e.g. `_acme_challenge_backend`); rejected at validation time.
"""
import re
from typing import Any, List, Literal, Optional, Union
from pydantic import BaseModel, Field, field_validator, model_validator
# Re-use the canonical domain regex (M10) so client + server stay in sync.
from utils.domain_validation import DOMAIN_REGEX, validate_domain
# Backend name: HAProxy-section-name-safe identifier. We additionally forbid
# leading underscore (system-managed names) and the literal HAProxy-reserved
# names mentioned in the v1.5.0 plan.
_BACKEND_NAME_REGEX = r"^[a-zA-Z][a-zA-Z0-9_-]{0,63}$"
_FRONTEND_NAME_REGEX = r"^[a-zA-Z][a-zA-Z0-9_-]{0,63}$"
_SERVER_NAME_REGEX = r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$"
# Bulgu #43 (round-17 audit) — HAProxy section-type keywords are reserved
# globals in HAProxy's grammar. A section directive of the form
# `<section_type> <name>` is grammatically legal even when <name> equals
# another section-type keyword (e.g. `backend defaults`), but the result
# is impossible to scan, confuses every operator who reads the rendered
# config, and breaks downstream tooling that grep's for sections by name.
# We reject these as backend/frontend/server names at the wizard
# boundary so the operator gets a single, actionable error rather than
# producing a config that loads but is unreadable.
# Source: HAProxy 2.6+ configuration manual, section "2. Quick reminder
# about HTTP" and the section-type table. Lowercased; the field
# validators compare case-insensitively.
_HAPROXY_SECTION_KEYWORDS = frozenset({
"global", "defaults", "listen", "frontend", "backend",
"peers", "mailers", "resolvers", "cache", "program", "ring",
"userlist", "http-errors", "fcgi-app", "crt-store", "traces",
"ssl-engine", "cpu-map",
})
# ACL operators / condition keywords that, when used as a section name,
# would silently shadow grammar tokens in `use_backend X if Y` strings.
# Reject defensively so operators can't paint themselves into a corner.
_ACL_RESERVED_KEYWORDS = frozenset({"if", "unless", "or", "and"})
def _validate_not_haproxy_keyword(value: str, field_label: str) -> str:
"""Bulgu #43 (round-17 audit) — common implementation shared by
backend / frontend / server name validators. Performs the
case-insensitive keyword check and raises a clear ValueError when
the operator-supplied name collides with a HAProxy section keyword
or ACL operator."""
if not value:
return value
low = value.strip().lower()
if low in _HAPROXY_SECTION_KEYWORDS:
raise ValueError(
f"{field_label}={value!r} collides with the HAProxy section "
f"keyword '{low}'. While HAProxy's grammar technically allows "
f"`{low} {low}` as a section header, the resulting config is "
"unreadable and breaks tooling that parses sections by name. "
"Pick a non-keyword identifier."
)
if low in _ACL_RESERVED_KEYWORDS:
raise ValueError(
f"{field_label}={value!r} collides with the ACL operator "
f"keyword '{low}', which is used by `use_backend X if Y` "
"directives. Pick a different identifier to avoid grammar "
"ambiguity."
)
return value
# Bulgu #44 (round-17 audit) — bind_address character class. HAProxy
# accepts several syntaxes (`*`, `0.0.0.0`, IPv6 in brackets `[::1]`,
# DNS name resolved at start, `@<varname>` env reference). We
# accept a conservative class that covers all common forms and
# explicitly rejects whitespace + shell metacharacters. The renderer
# emits the value verbatim as the host part of `bind <addr>:<port>`,
# so a value like `'foo bar'` would produce `bind foo bar:80` which
# HAProxy tokenises as `bar:80` and ignores `foo` (the operator's
# IP allocation is silently wrong). Reject upfront so the operator
# sees `'foo bar' is not a valid bind_address` instead of debugging a
# misrouted port.
_BIND_ADDRESS_REGEX = re.compile(
# Allow:
# - bare `*`
# - IPv4 dotted form
# - IPv6 raw (no brackets, e.g. `::`) — HAProxy accepts in `bind`
# - IPv6 in brackets `[::1]`
# - IPv6 with scope-id `[fe80::1%eth0]` — Bulgu #59 (round-20).
# Link-local IPv6 binds require the interface name (`%eth0`,
# `%bond0.42`, …) so HAProxy can pick the right NIC. Pre-fix
# `_BIND_ADDRESS_REGEX` rejected the `%` character outright
# and link-local listeners had to drop down to the manual
# frontend API. The character class also accepts digits in
# case the operator uses a numeric zone index (RFC 4007 §11).
# - hostnames a-z 0-9 . -
# - `@<varname>` HAProxy env reference (advanced)
r"^(?:"
r"\*"
r"|@[A-Za-z_][A-Za-z0-9_]*"
r"|\[[0-9a-fA-F:.]{1,40}(?:%[A-Za-z0-9._-]{1,16})?\]"
r"|[0-9a-zA-Z.:_-]{1,63}"
r")$"
)
# Bulgu #45 (round-17 audit) — HAProxy parses timeout values as a
# signed 32-bit integer of milliseconds (signed int upper bound:
# 2_147_483_647 ms ≈ 24.85 days). Values above that overflow the
# parser; values close to it are operator confusion. The wizard
# caps at 24h × 30 = 30 days ≈ 2_592_000_000 ms which already
# overflows int32, so we cap below that. 24h = 86_400_000 ms is the
# longest reasonable HAProxy timeout — anything beyond that is
# almost certainly a missing-unit typo (e.g. operator typed
# 60_000_000 thinking it was seconds).
_MAX_HAPROXY_TIMEOUT_MS = 86_400_000 # 24 hours, well below int32 overflow
# Bulgu #46 (round-17 audit) — HAProxy `maxconn` / `rate_limit` are
# unsigned ints in the parser but practical limits are bounded by
# the agent's `ulimit -n` (file descriptors). A typical Linux box
# defaults to 1024 nofile, tuned production typically tops out at
# 1M. Anything > 1M is almost certainly a typo and produces a
# config that the agent fails to load (`socket(): too many open
# files`). Cap defensively.
_MAX_HAPROXY_CONN_LIMIT = 1_000_000
# Phase K (Site Wizard validation hardening) — safety validators for
# HAProxy directive fragments that the ACL builder serialises into the
# wizard payload. Mirrors the danger-pattern set the manual frontend
# API has been enforcing on raw ACL strings since pre-R14
# (`backend/models/frontend.py::validate_acl_rules`). The wizard never
# inherited that protection because the rule fields were typed as
# `List[dict]` and never reached a string-aware validator — every ACL
# attempt instead failed with "Input should be a valid dictionary".
#
# 4 KB per element keeps us under the existing R14 hardening posture
# (PEM fields capped at 64 KB; rule strings should be at most ~1 KB
# in practice, so 4 KB is a comfortable headroom that still defeats
# pathological-DoS payloads).
_MAX_RULE_STRING_LEN = 4096
# Bulgu #64 (round-22 audit) — the previous danger-pattern list was
# `("system", "exec", "eval", "$(", "`")` and matched the substring
# anywhere in the rule string. That blocked legitimate ACL names
# such as `acl is_system path_beg /admin`, `acl my_subsystem …`,
# or `acl block_executable path_end .exe`, because the literal
# words happen to be common in operator-facing naming. HAProxy is
# NOT a shell — there is no `system`/`exec`/`eval` directive, and
# substrings of these tokens carry no runtime meaning inside a
# rendered HAProxy config. The substring check was over-cautious
# and a real false-positive trap on the UPDATE path (legacy rows
# could not be edited at all).
#
# The retained patterns are shell-substitution markers (`$(`,
# backtick) — these don't appear in any legitimate HAProxy
# directive shape; if an operator pastes them in it almost always
# means a copy-paste from a shell script that needs review. The
# newline/CR check below still defeats actual directive-injection
# attempts.
_DANGEROUS_RULE_PATTERNS = ("$(", "`")
# Issue #38 follow-up — the HAProxy `-f <file>` ACL/condition flag
# loads match patterns from a file on the HAProxy host. The Bulgu #12
# hard reject (`_ACL_FILE_FLAG_PATTERN`/`_ACL_FILE_FLAG_MESSAGE`) was
# removed: pattern files are operator-managed host files (exactly like
# the SPOE `filter ... config <path>` reference preserved since
# v1.8.8), bulk import and the free-form fields (tcp_request_rules,
# request_headers) always accepted them, and the agent runs
# `haproxy -c` before every reload so a missing file fails safely
# (the previous config keeps running). The manual frontend route
# handlers emit a non-blocking warning listing referenced pattern
# files (`routers/frontend.py::_pattern_file_warnings`).
# Phase K Phase D follow-up (Bulgu #13) — detect a routing /
# redirect rule whose condition references the SAME ACL in both
# positive and negated form (e.g. `use_backend foo if acl1 !acl1`).
# HAProxy accepts the syntax but the predicate `X AND NOT X` is
# permanently false, so the rule never fires and traffic silently
# falls through to `default_backend`. The wizard's visual builder
# (mode="tags" Select for routing conditions) previously allowed
# the operator to pick both forms; the auto-dedup there is the
# first guard, this is the server-side gate.
_ACL_NAME_TOKEN = re.compile(r"^!?([A-Za-z_][\w.-]*)$")
_ACL_CONTRADICTION_MESSAGE = (
"self-contradictory condition: the same ACL appears in both "
"positive and negated form (e.g. `acl1 !acl1`). HAProxy accepts "
"the syntax but the predicate `X AND NOT X` is always false, so "
"the rule never fires and traffic silently falls through to "
"`default_backend`. Remove one of the two tokens."
)
def _detect_acl_contradiction(directive: str) -> List[str]:
"""Return the list of ACL names that appear in BOTH positive
and negated form in the given directive string. Empty list
means no obvious self-contradiction.
Only flags pure ACL identifier tokens (`acl1`, `!acl1`). Does
not interpret anonymous ACLs (`{ ssl_fc }`) or compound forms.
"""
pos: set = set()
neg: set = set()
for raw in directive.split():
if raw in ("if", "unless"):
continue
m = _ACL_NAME_TOKEN.match(raw)
if not m:
continue
name = m.group(1)
if raw.startswith("!"):
neg.add(name)
else:
pos.add(name)
return sorted(pos & neg)
def _validate_haproxy_directive_string(
value: Any,
field_label: str,
*,
check_acl_contradiction: bool = False,
) -> str:
"""Shared safety validator for ACL / use_backend / redirect rule strings.
Returns the trimmed string on success; raises ValueError with a clear,
operator-friendly message on any of:
- non-string element
- empty / whitespace-only string
- string longer than 4 KB (R14 hardening posture)
- newline / carriage return embedded in the string (HAProxy
directives are line-oriented; a multi-line string would inject
arbitrary directives into the rendered config)
- dangerous shell-substitution / interpolation patterns the manual
frontend API also rejects (`system`, `exec`, `eval`, `$(`,
backtick).
- `-f <file>` pattern-file references (operator cannot provision
files onto the HAProxy node).
- (when `check_acl_contradiction=True`) the same ACL appearing in
both positive AND negated form, producing a permanently-false
predicate.
"""
if not isinstance(value, str):
raise ValueError(
f"{field_label} entries must be HAProxy directive strings; "
f"got {type(value).__name__}"
)
stripped = value.strip()
if not stripped:
raise ValueError(
f"{field_label} entries must not be empty / whitespace-only"
)
if len(stripped) > _MAX_RULE_STRING_LEN:
raise ValueError(
f"{field_label} entry exceeds {_MAX_RULE_STRING_LEN} characters "
f"(got {len(stripped)})"
)
if "\n" in stripped or "\r" in stripped:
raise ValueError(
f"{field_label} entries must not contain line breaks "
"(HAProxy directives are line-oriented; embedded newlines "
"would inject arbitrary directives into the rendered config)"
)
lowered = stripped.lower()
for pattern in _DANGEROUS_RULE_PATTERNS:
if pattern in lowered:
raise ValueError(
f"{field_label} entry contains potentially dangerous content: "
f"{pattern!r}"
)
# Issue #38 follow-up — `-f <file>` pattern-file references are
# ACCEPTED (Bulgu #12 hard reject removed; see the module-level
# `_ACL_FILE_FLAG_PATTERN` comment). The route handlers surface
# a non-blocking pattern-file warning instead.
# Phase K Phase D follow-up (Bulgu #13) — for routing /
# redirect rules (not ACL definitions themselves), reject a
# condition that contains the same ACL in both positive and
# negated polarity. The wizard's visual builder dedups this
# at edit time; this server-side gate catches hand-crafted
# API payloads and stale drafts that may have been saved
# before the UI dedup landed.
if check_acl_contradiction:
conflicts = _detect_acl_contradiction(stripped)
if conflicts:
raise ValueError(
f"{field_label}: {_ACL_CONTRADICTION_MESSAGE} "
f"Conflicting ACL(s): {', '.join(conflicts)}."
)
return stripped
class ServerStep(BaseModel):
server_name: str = Field(..., pattern=_SERVER_NAME_REGEX)
server_address: str = Field(..., min_length=1, max_length=253)
server_port: int = Field(..., ge=1, le=65535)
@field_validator("server_name")
@classmethod
def _server_name_not_haproxy_keyword(cls, v: str) -> str:
"""Bulgu #43 (round-17 audit) — guard against HAProxy section
keywords and ACL operators in server names. While the rendered
`server defaults 10.0.0.1:80 …` is technically legal, the
operator can no longer grep for `default-server` lines without
false positives. Reject defensively."""
return _validate_not_haproxy_keyword(v, "server.server_name")
@field_validator("server_address")
@classmethod
def _validate_server_address(cls, v: str) -> str:
"""Bulgu #17 (round-7 audit): pre-fix `server_address`
only enforced `min_length=1`, which accepted single-space
and tab-only strings. The renderer then emits
`server srv1 :8080` (literally a leading space before the
colon) which HAProxy's parser rejects with a generic
syntax error.
Strip and re-check non-empty. We do NOT validate IP /
hostname syntax here — operators sometimes intentionally
use DNS names that resolve to internal hosts only at
runtime — but whitespace-only is unambiguously garbage.
"""
if v is None:
return v
stripped = v.strip()
if not stripped:
raise ValueError(
"server.server_address must not be empty or whitespace-only"
)
# Reject embedded whitespace too — HAProxy splits the line
# at the first space, so 'srv1 1.1.1.1' would parse as
# name='srv1', address='1.1.1.1' which is NOT what the
# operator typed.
if any(ch.isspace() for ch in stripped):
raise ValueError(
f"server.server_address must not contain whitespace "
f"(got {v!r}). HAProxy parses server lines token-by-"
f"token; an embedded space would shift the address "
f"into a keyword position."
)
return stripped
weight: int = Field(default=100, ge=0, le=256)
# Bulgu #46 (round-17 audit) — per-server maxconn upper bound: a
# single server cannot reasonably hold more than the cluster's
# global maxconn, so capping at the same defensive 1M ceiling is
# safe and rules out typos like `10**12`.
max_connections: Optional[int] = Field(
default=None, ge=0, le=_MAX_HAPROXY_CONN_LIMIT,
)
# Advanced health-check tuning (HAProxy `inter`, `fall`, `rise`)
check_enabled: bool = True
check_port: Optional[int] = Field(default=None, ge=1, le=65535)
# Bulgu #22 (round-11 audit): HAProxy rejects `inter 0`, `fall 0`,
# `rise 0` with parser errors ("inter: minimum 1ms", "fall/rise:
# argument 0 is invalid range from 1 to 100"). Pre-fix the wizard
# accepted 0 and surfaced the failure only at apply-time via the
# agent's `haproxy -c`. Tighten to `ge=1` so the wizard rejects
# at submit-time with a clear field-level error.
#
# Bulgu #45 (round-17 audit): `inter` is a ms timeout — cap at 24h
# (well below int32 overflow). `fall`/`rise` are check counts —
# HAProxy documents the upper bound as 100; we mirror it. Pre-fix
# the wizard accepted `inter=10**12`, `fall=10**6` which the
# renderer emitted verbatim; HAProxy parser overflow surfaced
# only at apply time.
inter: Optional[int] = Field(
default=None, ge=1, le=_MAX_HAPROXY_TIMEOUT_MS,
description="Health check interval in ms (HAProxy requires >= 1, cap 24h)",
)
fall: Optional[int] = Field(
default=None, ge=1, le=100,
description="Failed checks before marking server DOWN (HAProxy 1-100)",
)
rise: Optional[int] = Field(
default=None, ge=1, le=100,
description="Successful checks before marking server UP (HAProxy 1-100)",
)
# Advanced server flags
backup_server: bool = False
cookie_value: Optional[str] = Field(default=None, max_length=255, description="Sticky session cookie value")
# SSL/TLS to backend (HAProxy `server ... ssl`)
ssl_enabled: bool = False
ssl_verify: Optional[Literal["none", "required"]] = None
ssl_sni: Optional[str] = Field(default=None, max_length=253)
ssl_min_ver: Optional[Literal["TLSv1.0", "TLSv1.1", "TLSv1.2", "TLSv1.3"]] = None
ssl_max_ver: Optional[Literal["TLSv1.0", "TLSv1.1", "TLSv1.2", "TLSv1.3"]] = None
ssl_ciphers: Optional[str] = Field(default=None, max_length=2048)
@field_validator("cookie_value", "ssl_sni", "ssl_ciphers")
@classmethod
def _validate_single_line_server_value(
cls, v: Optional[str], info
) -> Optional[str]:
"""Bulgu #33 (round-14 audit): the wizard interpolates these
fields directly into the rendered `server <name> <addr>:<port>
... cookie <val> sni <val> ciphers <val>` line. Pre-fix the
Pydantic model only enforced `max_length`, so an operator with
wizard permission could embed a newline:
cookie_value = "srv1\\n use_backend evil if always\\n"
The renderer split the line at the newline and emitted:
server srv1 1.1.1.1:80 ... cookie srv1
use_backend evil if always
— smuggling a directive into the parent backend block. The
same smuggling vector exists for `ssl_sni` (`sni <val>`) and
`ssl_ciphers` (`ciphers <val>`) because both land on the same
server line. None of these values are legitimately multi-line:
* `cookie_value` is a short identifier (HAProxy stores it
in the Set-Cookie response header verbatim).
* `ssl_sni` is a single hostname (DNS / FQDN form).
* `ssl_ciphers` is an OpenSSL cipher spec — colon-separated,
no whitespace.
Reject any newline (\\n, \\r, \\r\\n). Additionally reject
embedded whitespace inside `ssl_sni` (HAProxy splits the
server line at the first space, so a space inside the SNI
value would shift later keywords into wrong positions). The
cipher list and cookie value tolerate `tab` historically; we
keep the strict newline-only rejection there to stay
backward-compatible with operator inputs.
Multi-line free-form fields (`request_headers`,
`response_headers`, `tcp_request_rules`, raw `options`) are
intentionally line-oriented and ARE NOT touched here.
"""
if v is None:
return v
if '\n' in v or '\r' in v:
field_name = info.field_name if info else 'server field'
raise ValueError(
f"server.{field_name} must not contain line breaks "
"(HAProxy server lines are single-line; embedded "
"newlines would smuggle additional directives into "
"the rendered backend block)"
)
# ssl_sni is a hostname — HAProxy's server-line tokenizer
# splits at the first whitespace, so any embedded space would
# shift later server keywords (`ciphers`, `inter`, `check`,
# …) into the wrong positions.
if info and info.field_name == "ssl_sni":
if any(ch.isspace() for ch in v):
raise ValueError(
f"server.ssl_sni must not contain whitespace "
f"(got {v!r}). HAProxy's server-line tokenizer "
"splits at the first space, so a space inside the "
"SNI value would corrupt the rendered config."
)
# Bulgu #90 (round-24 audit) — `cookie_value` is interpolated
# into the rendered `server <name> <addr>:<port> ... cookie
# <value> ...` line. HAProxy tokenises the line by whitespace
# and treats `;` as an INLINE COMMENT — and RFC 6265 cookie-
# value grammar separately disallows whitespace, `;`, `,`,
# `\\` and `"`. Pre-fix the validator only rejected newlines,
# so an operator could set `cookie_value="srv1; secure"` and
# see HAProxy silently truncate the server line at `;`
# (everything after becomes a comment). The Set-Cookie header
# rendered to clients would also fail the RFC's cookie-value
# grammar and most browsers drop the cookie, breaking session
# affinity without any error surface. Restrict to the
# conservative intersection — alphanumeric plus `_.-` —
# identical to backend.cookie_name. Legitimate session
# identifiers all fit in this set.
if info and info.field_name == "cookie_value":
import re as _re
if not _re.fullmatch(r"[A-Za-z0-9_.\-]+", v):
raise ValueError(
"server.cookie_value must contain only alphanumerics, "
"'.', '_' or '-' (HAProxy tokenises the server line "
"by whitespace and treats ';' as an inline comment; "
"RFC 6265 separately disallows whitespace, ';', ',' "
"and backslash in cookie values). Got "
f"{v!r}."
)
return v
# R17 (label corrected R18): CA bundle used by HAProxy to VERIFY the
# upstream server's TLS certificate. Maps to the `ca-file` directive
# on the HAProxy server line (services/haproxy_config.py:785). The
# field is named `ssl_certificate_id` for parity with the manual
# BackendServers create endpoint; semantics is "CA verification
# bundle", NOT "client cert presented by HAProxy". The latter would
# require a separate `crt` directive which is not exposed by the
# wizard or the manual UI today.
# Optional + None default = backward-compatible with v1.5.0 saved drafts
# and direct API callers that don't send the field.
ssl_certificate_id: Optional[int] = Field(
default=None, ge=1,
description="CA bundle used by HAProxy to verify the upstream server's TLS certificate (HAProxy `ca-file` directive on the server line). FK -> ssl_certificates.id.",
)
@field_validator("ssl_verify", mode="before")
@classmethod
def coerce_ssl_verify_empty_to_none(cls, v):
"""PR-2 (R11.B): UI Select widgets clear to '' (empty string)
which the strict Literal would reject. Coerce '' / sentinels
/ legacy 'optional' (server-side mTLS doesn't support it) to
None so the generator omits the directive."""
if v is None:
return None
if isinstance(v, str):
stripped = v.strip().lower()
if stripped in ("", "[]", "{}", "null", "optional"):
return None
if stripped == "none":
return "none"
if stripped == "required":
return "required"
return v
@model_validator(mode="after")
def reject_server_ca_bundle_without_ssl(self):
"""R17 (renamed in R18): ssl_certificate_id (HAProxy `ca-file`)
is meaningless when the upstream server connection itself is
plaintext. Reject the combination at the wizard layer so users
get a clear error instead of HAProxy silently ignoring the
ca-file directive."""
if self.ssl_certificate_id is not None and not self.ssl_enabled:
raise ValueError(
"server.ssl_certificate_id requires server.ssl_enabled=true. "
"Enable SSL to backend or clear the CA bundle selection."
)
# R18c audit fix (round 3 #6): refuse to accept TLS 1.0 / 1.1
# for upstream server connections. Both protocols were
# formally deprecated by RFC 8996 (March 2021); modern
# browsers, Cloudflare, AWS and Azure have long since
# disabled them, and accepting them on the wizard surface
# creates a route for operators to accidentally ship
# downgrade-prone configurations. The Pydantic Literal
# accepts the strings so that existing DB rows can still
# round-trip through API serializers (responses are not
# blocked); the wizard CREATE path explicitly rejects new
# values with a clear error. Operators with a hard
# requirement can still author the backend through the
# regular Backends UI which has its own audited path.
for fld in ("ssl_min_ver", "ssl_max_ver"):
v = getattr(self, fld, None)
if v in ("TLSv1.0", "TLSv1.1"):
raise ValueError(
f"server.{fld}={v} is no longer accepted by the wizard. "
"TLS 1.0 / 1.1 are deprecated by RFC 8996. Use TLSv1.2 "
"or TLSv1.3."
)
return self
class BackendStep(BaseModel):
name: str = Field(..., pattern=_BACKEND_NAME_REGEX)
# NOTE: HAProxy supports parametric algorithms `hdr(<name>)`,
# `url_param(<name>)` and `rdp-cookie(<name>)`. The wizard does NOT
# accept those today because the parameter (header / query name)
# would need a dedicated input. Without the parameter HAProxy rejects
# the bare directive (`balance hdr` is invalid syntax). Stick to
# parameter-free algorithms here; advanced users wanting parametric
# balance can author the backend in the regular Backends UI.
balance_method: Literal[
"roundrobin", "leastconn", "static-rr", "first", "source", "uri", "random",
] = "roundrobin"
mode: Literal["http", "tcp"] = "http"
health_check_uri: Optional[str] = Field(default="/", max_length=2048)
# Bulgu #45 (round-17 audit) — cap health-check + timeout values at
# HAProxy's safe ms upper bound (24h, well below int32 overflow at
# ~24.85 days). Pre-fix the wizard accepted `timeout_connect=10**18`
# which produced a config the agent's `haproxy -c` rejected with
# an opaque "invalid timeout" message.
health_check_interval: Optional[int] = Field(
default=2000, ge=100, le=_MAX_HAPROXY_TIMEOUT_MS,
)
health_check_expected_status: Optional[int] = Field(default=200, ge=100, le=599)
timeout_connect: Optional[int] = Field(
default=10000, ge=100, le=_MAX_HAPROXY_TIMEOUT_MS,
)
timeout_server: Optional[int] = Field(
default=60000, ge=100, le=_MAX_HAPROXY_TIMEOUT_MS,
)
timeout_queue: Optional[int] = Field(
default=60000, ge=100, le=_MAX_HAPROXY_TIMEOUT_MS,
)
# Bulgu #46 (round-17 audit) — fullconn cap. HAProxy parses this as
# uint; >1M is almost always a typo.
fullconn: Optional[int] = Field(
default=None, ge=0, le=_MAX_HAPROXY_CONN_LIMIT,
description="Backend total active connections threshold",
)
options: Optional[str] = Field(default=None, max_length=8192)
# Sticky-session via cookie persistence (HAProxy `cookie SRVID insert indirect nocache`)
cookie_name: Optional[str] = Field(default=None, max_length=128)
cookie_options: Optional[str] = Field(default=None, max_length=512)
# Default-server-* directives (defaults applied to every server)
# Bulgu #22 (round-11 audit): same HAProxy parser constraint as
# per-server inter/fall/rise — values must be >= 1 or HAProxy
# rejects the `default-server` line at parse time.
# Bulgu #45 (round-17 audit) — apply the same upper bounds the
# per-server fields enforce so default-server caps cannot exceed
# what each server can.
default_server_inter: Optional[int] = Field(
default=None, ge=1, le=_MAX_HAPROXY_TIMEOUT_MS,
description="Default health interval (ms, HAProxy 1-86400000)",
)
default_server_fall: Optional[int] = Field(
default=None, ge=1, le=100,
description="Default fall count (HAProxy 1-100)",
)
default_server_rise: Optional[int] = Field(
default=None, ge=1, le=100,
description="Default rise count (HAProxy 1-100)",
)
# Header injection (multi-line; validated by HAProxy at apply time)
request_headers: Optional[str] = Field(default=None, max_length=8192)
response_headers: Optional[str] = Field(default=None, max_length=8192)
@field_validator("name")
@classmethod
def reject_system_prefix(cls, v: str) -> str:
if v.startswith("_"):
raise ValueError(
"Backend name must not start with '_' (reserved for system-managed entities)"
)
# Bulgu #43 (round-17 audit) — additional HAProxy section
# keyword check (defaults / global / listen / frontend / …).
return _validate_not_haproxy_keyword(v, "backend.name")
@field_validator("cookie_name", "cookie_options")
@classmethod
def _validate_single_line_cookie_field(
cls, v: Optional[str], info
) -> Optional[str]:
"""Bulgu #18 (round-8 audit): `cookie_name` and `cookie_options`
are single-line fields by HAProxy syntax (one `cookie <name>
[options]*` directive per backend block). Pre-fix the Pydantic
model only enforced `max_length`, so an operator could embed
a newline:
cookie_options = "insert indirect\\n server evil 8.8.8.8:80"
The renderer then split the value at the newline and emitted:
cookie SRVNAME insert indirect
server evil 8.8.8.8:80
— smuggling a `server` line into the backend block. Reject any
newline (\\n, \\r, \\r\\n) in these fields. Multi-line free-form
fields like `request_headers` / `response_headers` /
`tcp_request_rules` are intentionally line-oriented and ARE
NOT touched here.
Bulgu #90 (round-24 audit) — extend the validator beyond
newlines. Pre-fix `cookie_name` accepted strings like
`SESS'; DROP TABLE backends; --` (the SQL substring is
harmless thanks to parameterised queries, but the `;` is
HAProxy's INLINE COMMENT character: the renderer emits
`cookie SESS'; DROP TABLE backends; -- insert indirect
nocache`, which HAProxy parses as `cookie SESS'` followed by
an inline comment that swallows the persistence options the
operator typed). Result: session affinity silently broken
and the operator has zero diagnostic signal — the wizard
accepted the input, the apply succeeded, but cookies never
get re-emitted with the expected name. The same trap exists
for any HAProxy-section-keyword or whitespace token because
HAProxy tokenises by space.
`cookie_name` MUST be a single token. We use the conservative
intersection of RFC 6265 cookie-token chars and HAProxy
directive-name chars: alphanumeric plus `_.-`. Operators
with legitimate session cookies all live inside this set.
`cookie_options` is a space-separated keyword list
(`insert indirect nocache` etc., optionally `domain example.
com`, `attr SameSite=Lax`). Allow letters/digits/space/dot/
hyphen/underscore/equals. Reject `;` (comment), backslash,
quotes, and other shell metacharacters.
"""
if v is None:
return v
if '\n' in v or '\r' in v:
field_name = info.field_name if info else 'cookie field'
raise ValueError(
f"backend.{field_name} must not contain line breaks "
"(HAProxy `cookie` directive is single-line; embedded "
"newlines would smuggle additional directives into "
"the rendered config)"
)
field_name = info.field_name if info else "cookie field"
if field_name == "cookie_name":
import re as _re
if not _re.fullmatch(r"[A-Za-z0-9_.\-]+", v):
raise ValueError(
"backend.cookie_name must contain only alphanumerics, "
"'.', '_' or '-' (HAProxy tokenises the `cookie` "
"directive by whitespace and treats ';' as an inline "
"comment — anything else silently truncates the "
"rendered persistence options). Got "
f"{v!r}."
)
elif field_name == "cookie_options":
import re as _re
if not _re.fullmatch(r"[A-Za-z0-9_.=\- ]*", v):
raise ValueError(
"backend.cookie_options must contain only "
"alphanumerics, spaces, '=', '.', '_' or '-' "
"(HAProxy parses `;` as an inline comment, and "
"quoting / backslash metacharacters are not "
f"part of the `cookie` keyword grammar). Got {v!r}."
)
return v
@field_validator("health_check_uri")
@classmethod
def _validate_health_check_uri(cls, v: Optional[str]) -> Optional[str]:
"""Bulgu #17 (round-7 audit): HAProxy's `option httpchk GET <uri>`
emits the URI verbatim into the health-check HTTP request.
Pre-fix the wizard accepted `health_check_uri='hh1'` (no
leading slash) and emitted `option httpchk GET hh1`. The
agent's HTTP probe then sends `GET hh1 HTTP/1.0` which the
upstream silently returns 400 for — the operator's health
check is permanently failing.
Same gating as `frontend.monitor_uri` (Bulgu #16):
non-empty, leading `/`, no whitespace.
"""
if v is None:
return v
s = v.strip()
if not s:
# Empty string is a misconfiguration — the field is
# Optional[str] with default '/', so an operator who
# really wants to disable the health check should set
# `check_enabled=False` on the per-server level, not
# blank the backend default.
raise ValueError(
"backend.health_check_uri must not be empty. "
"Use the default '/' or a real path. To disable "
"health checks, set `check_enabled=False` on each "
"server instead."
)
if not s.startswith('/'):
raise ValueError(
"backend.health_check_uri must start with '/' "
"(HAProxy emits the value verbatim into the "
f"health-check HTTP request, got {v!r})"
)
if any(ch.isspace() for ch in s):
raise ValueError(
"backend.health_check_uri must not contain whitespace "
f"(HAProxy directive parser would break, got {v!r})"
)
return s
@model_validator(mode="after")
def reject_cookie_on_tcp_mode(self):
"""v1.5.0 R12: HAProxy `cookie` directive is HTTP-only. Asking
for sticky-cookie persistence on a TCP backend produces an
invalid config that HAProxy refuses to load. Catch it at the
wizard layer with a clear error rather than letting it surface
as an opaque `option httpchk` / cookie syntax error at apply
time.
Bulgu #56 (round-19 audit) — backend-side companion of the
FrontendStep guard. `request_headers` / `response_headers` on a
backend render via haproxy_config.py:1306-1321 as
`http-request set-header …` / `http-response set-header …`
directives, and HAProxy's parser refuses these inside a
`mode tcp` backend block exactly the same way it does for
frontends ("'http-response' is not allowed in 'backend' section
in mode tcp"). Reject the combo at submit time so the operator
does not wedge the cluster's apply queue with a parse error
only discovered after the rows are inserted.
"""
if self.mode == "tcp":
tcp_blockers: List[str] = []
if self.cookie_name:
tcp_blockers.append("cookie_name")
if self.request_headers:
tcp_blockers.append("request_headers")
if self.response_headers:
tcp_blockers.append("response_headers")
# Bulgu #61 (round-21 audit) — `balance uri` is HTTP-only.
#
# HAProxy configuration manual section 4.2 on `balance`:
# "uri — Note that this algorithm may only be used in
# an HTTP backend."
#
# The wizard's renderer emits `balance <method>` for any
# backend regardless of mode. Pre-fix a payload with
# `mode='tcp' + balance_method='uri'` rendered cleanly,
# the agent's `haproxy -c` then refused the config with
# the same kind of parse error as the round-19 #56
# `http-response`-in-tcp-mode failure, blocking the
# cluster's apply queue until an operator manually
# corrected the entity. Catch the combination here so
# the operator sees an actionable message at submit
# time instead of after the rows are inserted.
#
# Other parametric HTTP-only methods (`hdr(...)`,
# `url_param(...)`) are intentionally not in the
# wizard's enum (see line 545-552 above) so they
# cannot reach this validator.
if self.balance_method == "uri":
tcp_blockers.append("balance_method='uri'")
if tcp_blockers:
raise ValueError(
f"backend.mode='tcp' is incompatible with the "
f"following HTTP-only field(s): {', '.join(tcp_blockers)}. "
"HAProxy rejects these directives in a TCP-mode "
"backend at parse time. Switch to mode='http' OR "
"unset the listed field(s) and resubmit."
)
return self
class FrontendStep(BaseModel):
name: str = Field(..., pattern=_FRONTEND_NAME_REGEX)
mode: Literal["http", "tcp"] = "http"
bind_address: str = Field(default="*", max_length=64)
bind_port: int = Field(default=80, ge=1, le=65535)
https_redirect: bool = False
# Phase K: `redirect_rules` is intentionally heterogeneous to keep
# `services/haproxy_config.py::_format_redirect_rule` (which accepts
# both legacy raw strings and structured dicts) backward compatible.
# `acl_rules` and `use_backend_rules` are pure `List[str]` because
# the renderer only knows how to emit string elements there
# (non-strings are silently dropped with a warning, and
# `routers/backend.py` does substring-match on these lists when
# cleaning up after a backend deletion — both paths assume strings).
# Bulgu #58 (round-20 audit) — cap list lengths.
#
# Pre-fix `acl_rules` / `use_backend_rules` / `redirect_rules` had
# NO max_length, so an authenticated wizard user could POST a
# frontend payload with tens of thousands of rules. Two failure
# modes:
# 1. The body itself passes the 256KB draft cap (`SiteDraftCreate`
# enforces that for drafts, but POST /api/sites accepts a
# larger inline body up to FastAPI/uvicorn's default request
# size). The wizard happily INSERTs a frontend whose JSONB
# columns are megabytes wide.
# 2. The agent's HAProxy config render expands every entry into
# a line; the generated config can easily push past HAProxy's
# parser memory limits and reload becomes a several-second
# stop-the-world event.
#
# Real wizard frontends rarely need more than a dozen rules. The
# cap is set to 256 per list — generous for advanced multi-domain
# proxies but bounded enough to refuse abuse. Operators with a
# genuine 257-rule frontend can split the work across two
# frontends (or use a regex-aggregating ACL).
redirect_rules: List[Union[str, dict]] = Field(
default_factory=list, max_length=256,
)
acl_rules: List[str] = Field(default_factory=list, max_length=256)
use_backend_rules: List[str] = Field(default_factory=list, max_length=256)
options: Optional[str] = Field(default=None, max_length=8192)
tcp_request_rules: Optional[str] = Field(default=None, max_length=8192)
# Bulgu #45 (round-17 audit) — frontend timeouts share the same
# ms upper bound as backend timeouts (HAProxy parser is signed
# int32 of ms). Cap at 24h to defend against typos like
# 60_000_000 ("operator meant seconds").
timeout_client: Optional[int] = Field(
default=None, ge=100, le=_MAX_HAPROXY_TIMEOUT_MS,
)
timeout_http_request: Optional[int] = Field(
default=None, ge=100, le=_MAX_HAPROXY_TIMEOUT_MS,
)
# Bulgu #46 (round-17 audit) — DoS-shaped maxconn / rate_limit.
# HAProxy parses these as uint, but practical limits are bounded
# by the agent's `ulimit -n`. 1M is a defensive ceiling well above
# any realistic production value.
maxconn: Optional[int] = Field(
default=None, ge=1, le=_MAX_HAPROXY_CONN_LIMIT,
)
rate_limit: Optional[int] = Field(
default=None, ge=0, le=_MAX_HAPROXY_CONN_LIMIT,
description="Per-frontend rate limit (req/sec)",
)
compression: bool = Field(default=False, description="Enable HAProxy gzip compression")
log_separate: bool = Field(default=False, description="Use a dedicated log section for this frontend")
monitor_uri: Optional[str] = Field(default=None, max_length=255, description="HAProxy `monitor-uri` for health probes")
# Header injection
request_headers: Optional[str] = Field(default=None, max_length=8192)
response_headers: Optional[str] = Field(default=None, max_length=8192)
# Internal: server-side injects backend.name as default_backend before
# delegating to frontend_service.create_frontend_row. Always None on the
# API surface — clients should NOT set it.
default_backend: Optional[str] = Field(default=None, max_length=255)
@field_validator("name")
@classmethod
def reject_system_prefix(cls, v: str) -> str:
if v.startswith("_"):
raise ValueError(
"Frontend name must not start with '_' (reserved for system-managed entities)"
)
# Bulgu #43 (round-17 audit) — section keyword check.
return _validate_not_haproxy_keyword(v, "frontend.name")
@field_validator("bind_address")
@classmethod
def _validate_bind_address(cls, v: str) -> str:
"""Bulgu #44 (round-17 audit) — `bind_address` is interpolated
verbatim into the rendered `bind <addr>:<port>` line. Pre-fix
the only constraint was `max_length=64`, which accepted:
* `'foo bar'` → renders `bind foo bar:80` (HAProxy parses
`bar:80` and SILENTLY discards `foo` — operator's IP
allocation is wrong without any error);
* `'$(...)'` → potential shell-metacharacter confusion in
tooling that pipes the rendered config through `sh -c`;
* `'-r'` → looks like an HAProxy CLI flag in admin
grep output;
* `'/etc/x'` → path-style nonsense.
Constrain to the HAProxy `bind` host syntax (covers `*`, IPv4
dotted, IPv6 raw or bracketed, hostnames, and HAProxy env
references `@<varname>`). The result is a strict-but-complete
positive list."""
if v is None:
return v
stripped = v.strip()
if not stripped:
raise ValueError(
"frontend.bind_address must not be empty or whitespace-only "
"(use '*' for all interfaces)"
)
if stripped != v:
raise ValueError(
"frontend.bind_address must not contain leading or trailing "
"whitespace"
)
if any(ch.isspace() for ch in stripped):
raise ValueError(
"frontend.bind_address must not contain whitespace "
f"(got {v!r}). HAProxy's `bind` parser splits at the first "
"space, so an embedded space silently truncates the address."
)
# Reject leading hyphen up-front so the operator never gets
# confused with HAProxy / shell CLI flag tokens (`-r`,
# `-d`, …) in admin grep output.
if stripped.startswith("-"):
raise ValueError(
f"frontend.bind_address={v!r} must not start with '-' "
"(would shadow HAProxy / shell CLI flag tokens in admin "
"grep output)."
)
if not _BIND_ADDRESS_REGEX.match(stripped):
raise ValueError(
f"frontend.bind_address={v!r} is not a valid HAProxy bind "
"host. Accepted forms: '*' (all), '0.0.0.0' / '127.0.0.1' "
"(IPv4), '[::]' / '[::1]' (IPv6 bracketed), '<hostname>', "
"'@<varname>' (HAProxy env reference)."
)
return stripped
@field_validator("monitor_uri")
@classmethod
def _validate_monitor_uri(cls, v: Optional[str]) -> Optional[str]:
"""Bulgu #16 (round-6 audit): HAProxy's `monitor-uri` directive
expects an absolute path beginning with `/`. Pre-fix the wizard
accepted `monitor_uri='hel'` (no leading slash) and emitted
`monitor-uri hel` into the frontend block. HAProxy then treats
the value as a relative match and the operator's health-probe
URL silently returns 503.
Additional reject criteria:
* embedded whitespace breaks the directive at the parser
(everything after the first space becomes an unknown
keyword);
* embedded `\n` would smuggle a second directive into the
block (we already reject newlines in `acl_rules` via
`_validate_haproxy_directive_string`, but `monitor_uri`
was a separate code path).
"""
if v is None:
return v
s = v.strip()
if not s:
return None
if not s.startswith('/'):
raise ValueError(
"frontend.monitor_uri must start with '/' "
"(HAProxy `monitor-uri` requires an absolute path, "
f"got {v!r})"
)
# Reject any whitespace including \t, \r, \n.
if any(ch.isspace() for ch in s):
raise ValueError(
"frontend.monitor_uri must not contain whitespace "
"(HAProxy's `monitor-uri` parser splits the line at "
f"the first space, got {v!r})"
)
return s
@field_validator("acl_rules", mode="before")
@classmethod
def _validate_acl_rules(cls, v: Any) -> List[str]:
"""Phase K: enforce string-only contract + manual-API security
parity (newline reject, danger pattern reject, length bound).
"""
if v is None:
return []
if not isinstance(v, list):
raise ValueError("acl_rules must be a list of HAProxy directive strings")
return [_validate_haproxy_directive_string(el, "acl_rules") for el in v]
@field_validator("use_backend_rules", mode="before")
@classmethod
def _validate_use_backend_rules(cls, v: Any) -> List[str]:
"""Phase K: same contract as acl_rules — string only, sanitised.
Phase K Phase D follow-up (Bulgu #13) — additionally rejects
self-contradictory conditions (`X AND NOT X`) because those
produce dead-code routing rules that silently fall through to
`default_backend`.
"""
if v is None:
return []
if not isinstance(v, list):
raise ValueError("use_backend_rules must be a list of HAProxy directive strings")
return [
_validate_haproxy_directive_string(
el, "use_backend_rules", check_acl_contradiction=True
)
for el in v
]
@field_validator("redirect_rules", mode="before")
@classmethod
def _validate_redirect_rules(cls, v: Any) -> List[Union[str, dict]]:
"""Phase K: heterogeneous contract — accept legacy raw-string
fragments AND structured dicts (matching
`services/haproxy_config.py::_format_redirect_rule`). Strings
get the same safety pass as ACL / use_backend; dicts also
get a `-f` flag rejection on their `condition` /
`target` fields (Bulgu #12 round 3 extension) since the
renderer at `_format_redirect_rule` emits those fields
verbatim into the HAProxy directive string."""
if v is None:
return []
if not isinstance(v, list):
raise ValueError(
"redirect_rules must be a list of HAProxy directive strings or "
"structured dicts"
)
normalised: List[Union[str, dict]] = []
for el in v:
if isinstance(el, dict):
# Issue #38 follow-up — dict-shaped redirect rules may
# carry `-f <file>` pattern-file references in their
# `condition`/`target` values; these are ACCEPTED now
# (Bulgu #12 hard reject removed — operator-managed
# host files, fail-safe apply; see module-level
# `_ACL_FILE_FLAG_PATTERN` comment).
# Bulgu #13 extension — same contradiction guard
# for dict-shaped redirect conditions.
cond_val = el.get("condition")
if isinstance(cond_val, str):
conflicts = _detect_acl_contradiction(cond_val)
if conflicts:
raise ValueError(
f"redirect_rules.condition: "
f"{_ACL_CONTRADICTION_MESSAGE} "
f"Conflicting ACL(s): {', '.join(conflicts)}."
)
normalised.append(el)
elif isinstance(el, str):
normalised.append(
_validate_haproxy_directive_string(
el, "redirect_rules", check_acl_contradiction=True
)
)
else:
raise ValueError(
"redirect_rules entries must be HAProxy directive strings "
f"or structured dicts; got {type(el).__name__}"
)
return normalised
@model_validator(mode="after")
def reject_redirect_conflict(self) -> "FrontendStep":
# M19: https_redirect (UI sugar) is server-side expanded to a
# redirect_rules row. Cannot coexist with explicit redirect_rules.
if self.https_redirect and self.redirect_rules:
raise ValueError(
"https_redirect and redirect_rules are mutually exclusive: "
"set redirect_rules manually OR enable https_redirect, not both"
)
return self
@model_validator(mode="after")
def reject_tcp_mode_with_https_redirect(self) -> "FrontendStep":
"""Phase K: TCP-mode frontends operate at L4 and cannot inspect
HTTP headers, so an `http-request redirect` / `redirect scheme`
directive is meaningless on a TCP frontend. The renderer at
`services/haproxy_config.py:829-845` does NOT branch on mode
before emitting redirect lines, so without this guard a
`mode='tcp' + https_redirect=true` payload would silently
produce a config that the agent's `haproxy -c` rejects only at
apply time. Reject it here so the operator gets immediate,
actionable feedback instead of a post-apply red badge."""
if self.mode == "tcp" and self.https_redirect:
raise ValueError(
"frontend.mode='tcp' is incompatible with "
"frontend.https_redirect=true: TCP frontends operate at L4 "
"and cannot inspect HTTP headers. Switch to mode='http' or "
"disable the HTTP→HTTPS redirect switch."
)
# Bulgu #19 (round-9 audit) — generalisation of the
# https_redirect guard to ANY explicit redirect_rules entry.
# Pre-fix the operator could submit:
#
# frontend.mode = 'tcp'
# frontend.https_redirect = false ← passes the older check
# frontend.redirect_rules = [{...scheme rule...}]
#
# and the renderer would happily emit `redirect ...` lines
# inside a `mode tcp` frontend. The agent's `haproxy -c`
# then refuses to load the config with the same fatal
# parse error described above. Reject explicit redirect
# rules on TCP frontends too. Empty list is fine.
if self.mode == "tcp" and self.redirect_rules:
raise ValueError(
"frontend.mode='tcp' is incompatible with "
"frontend.redirect_rules: HAProxy `redirect` directives "
"are HTTP-only. Switch to mode='http' or remove the "
"redirect_rules entries to continue."
)
# Bulgu #19 (round-9 audit) — `acl_rules` / `use_backend_rules`
# are also HTTP-leaning: the renderer emits them as
# `acl ...` + `use_backend ...` lines which HAProxy ONLY
# honors in HTTP frontends (TCP frontends use
# `tcp-request content use-backend` instead). Pre-fix the
# wizard accepted these on a TCP frontend and the agent
# silently routed everything to `default_backend`. We do
# NOT reject ACL/use_backend on TCP outright (operators may
# use them via `tcp-request content` snippets in
# `tcp_request_rules`) — but warn-via-strict-rule shape is
# outside this audit. Pin only the redirect mismatch.
# Bulgu #20 (round-10 audit) — additional TCP-mode guards.
# The renderer in `services/haproxy_config.py` emits the
# following directives UNCONDITIONALLY (not gated on mode):
#
# * `compression algo gzip` — HAProxy refuses to load a
# TCP frontend with a `compression` directive: it can
# only operate at L7. Parse error at apply.
# * `monitor-uri <path>` — HAProxy 2.4+ rejects this in a
# TCP frontend ("monitor-uri requires HTTP mode" parse
# error).
# * `rate_limit` expands to `stick-table` + a pair of
# `http-request track-sc0 src` + `http-request deny`
# lines. The `http-request` family is HTTP-only — TCP
# parse error.
# * `timeout http-request <ms>` — softly accepted by
# HAProxy on TCP frontends but emitted as a
# "directive ignored in mode tcp" warning, surfacing
# in Apply Management as a confusing post-apply
# warning on a config that did not need that timeout.
#
# Reject each at the model boundary so the operator sees a
# clear "switch mode or unset X" message instead of an
# opaque apply-time parse error.
if self.mode == "tcp":
tcp_blockers: List[str] = []
if self.compression:
tcp_blockers.append("compression=true")
if self.monitor_uri:
tcp_blockers.append("monitor_uri")
if self.rate_limit is not None and self.rate_limit > 0:
tcp_blockers.append("rate_limit")
if self.timeout_http_request is not None:
tcp_blockers.append("timeout_http_request")
# Bulgu #56 (round-19 audit) — request_headers / response_headers
# emit `http-request set-header …` / `http-response set-header …`
# directives via haproxy_config.py:1063-1074 unconditionally
# (the renderer does NOT branch on mode). HAProxy's parser
# then refuses to load the config:
#
# [ALERT] : config: 'http-response' is not allowed in
# 'frontend' section in mode tcp
#
# Pre-fix the wizard happily accepted the combo, the agent
# tried to reload, the parse error blocked ALL subsequent
# applies on the cluster (the apply queue marks the version
# as FAILED but cannot move forward without operator
# intervention). Reject the combination at submit time.
if self.request_headers:
tcp_blockers.append("request_headers")
if self.response_headers:
tcp_blockers.append("response_headers")
if tcp_blockers:
raise ValueError(
f"frontend.mode='tcp' is incompatible with the "
f"following HTTP-only field(s): {', '.join(tcp_blockers)}. "
"HAProxy rejects these directives in a TCP-mode "
"frontend at parse time. Switch to mode='http' OR "
"unset the listed field(s) and resubmit."
)
return self
class SSLChoice(BaseModel):
"""SSL configuration leg of the wizard.
mode='acme' -> stage an ACME order, defer HTTPS frontend creation to
_complete_certificate's post_completion_actions.
mode='upload' -> upload a PEM cert+key now, create HTTPS frontend in
the same atomic transaction.
mode='existing' -> reuse a pre-existing ssl_certificate id (admin
previously imported), create HTTPS frontend.
mode='none' -> HTTP-only host (no HTTPS frontend, no cert).
"""
mode: Literal["acme", "upload", "existing", "none"]
# mode=upload
name: Optional[str] = Field(default=None, max_length=255)
# R14 hardening: bound PEM payloads. A real RSA-4096 cert is ~2KB,
# a typical chain is ~6KB. 64KB per field is a comfortable 10x
# safety margin while preventing pathological DOS-shaped uploads
# from inflating request memory and the SSL service downstream.
certificate_content: Optional[str] = Field(default=None, max_length=65536)
private_key_content: Optional[str] = Field(default=None, max_length=65536)
chain_content: Optional[str] = Field(default=None, max_length=65536)
# mode=existing
ssl_certificate_id: Optional[int] = None
# mode=acme
auto_renew: bool = True
# NOTE: per-cert renewal-before-days override is NOT yet plumbed
# through to the renewal scheduler (v1.6.0). We deliberately omit the
# field from the wizard payload to avoid offering a placebo control
# in the UI — global `acme.renew_before_days` still applies.
account_id: Optional[int] = None # if None, server picks the latest valid account
# HTTPS frontend overrides (used by upload/existing/acme post-completion):
https_bind_port: int = Field(default=443, ge=1, le=65535)
https_frontend_name_suffix: Optional[str] = Field(default="-https", max_length=64)
# ----- Advanced TLS / HTTPS frontend tuning (HAProxy 2.4+ bind directives)
# IMPORTANT: defaults are intentionally None so v1.5.0 first-deploy
# behaviour is preserved unchanged. Setting these defaults to e.g.
# 'TLSv1.2' or 'h2,http/1.1' would change the cipher/ALPN profile of
# newly-created HTTPS frontends compared with the first 1.5.0 release —
# a silent backward-compat regression. The wizard UI populates the
# form with sensible *form-level* initial values; the model stays
# neutral so direct API callers and saved drafts behave identically.
ssl_alpn: Optional[str] = Field(
default=None,
max_length=128,
description="Comma-separated ALPN protocols (e.g. 'h2,http/1.1')",
)
ssl_min_ver: Optional[Literal["TLSv1.0", "TLSv1.1", "TLSv1.2", "TLSv1.3"]] = Field(
default=None,
description="Minimum TLS version (HAProxy `ssl-min-ver`)",
)
ssl_max_ver: Optional[Literal["TLSv1.0", "TLSv1.1", "TLSv1.2", "TLSv1.3"]] = None
ssl_ciphers: Optional[str] = Field(
default=None, max_length=2048,
description="OpenSSL cipher list (HAProxy `ciphers`)",
)
ssl_ciphersuites: Optional[str] = Field(
default=None, max_length=2048,
description="TLS 1.3 ciphersuites (HAProxy `ciphersuites`)",
)
ssl_strict_sni: bool = Field(
default=False,
description="Reject TLS handshakes without matching SNI (HAProxy `strict-sni`)",
)
# R17 minimum-parity: client cert verification (mTLS) on the HTTPS bind.
# Same Literal set as the manual frontend create endpoint (FrontendConfig).
# Default None (unset) so v1.5.0 first-deploy hosts continue serving
# anonymous TLS — switching this on is an opt-in security upgrade.
ssl_verify: Optional[Literal["none", "optional", "required"]] = Field(
default=None,
description="mTLS client cert auth on the HTTPS bind: 'none' "
"(disabled), 'optional' (accept anonymous), or 'required' (reject "
"anonymous). Default None means the bind directive is omitted.",
)
@field_validator("ssl_verify", mode="before")
@classmethod
def coerce_ssl_verify_empty_to_none(cls, v):
"""PR-2 (R11.B): UI Select widgets clear to '' (empty string).
Coerce empty / sentinel values to None so the generator omits
the directive. Genuine values pass through for Literal check.
"""
if v is None:
return None
if isinstance(v, str):
stripped = v.strip().lower()
if stripped in ("", "[]", "{}", "null"):
return None
if stripped in ("none", "optional", "required"):
return stripped
return v
hsts_enabled: bool = Field(
default=False,
description="Inject HTTP Strict-Transport-Security header on HTTPS responses",
)
hsts_max_age: int = Field(
default=31536000, ge=0, le=63072000,
description=(
"HSTS max-age in seconds (default 1 year). Capped at "
"63072000 (2 years), the largest value the HSTS preload "
"list currently accepts. R18b audit fix (round 4 #D): "
"pre-fix the field accepted unbounded ints, which let an "
"operator emit `Strict-Transport-Security: max-age=10**18` "
"and effectively pin the host to HTTPS forever in every "
"browser that observed the response — recovery requires "
"user-side cache invalidation."
),
)
hsts_include_subdomains: bool = True
hsts_preload: bool = False
@field_validator("name")
@classmethod
def _validate_ssl_name_no_path_traversal(cls, v: Optional[str]) -> Optional[str]:
"""Bulgu #21 (round-11 audit): ssl.name is interpolated into the
on-disk certificate path by haproxy_config.py:
cert_path = f"/etc/ssl/haproxy/{ssl_cert['name']}.pem"
and emitted into the rendered HAProxy config. The agent then
runs `mv "$temp_cert_file" "$cert_file_path"` as root, which
means a name like '../../tmp/evil' resolves to '/tmp/evil.pem'
and would let an operator with ssl.create permission overwrite
arbitrary `*.pem` files on the agent host (privilege-escalation
vector: ssl-upload-only operator gains arbitrary-file-write).
The trailing `.pem` suffix mitigates common exploit paths
(cron.d, profile.d, authorized_keys) but is defense-only — the
right fix is to constrain `name` to a safe filename character
class at the wizard boundary.
Restrict to `[A-Za-z0-9_.-]` and explicitly reject:
* empty string
* leading dot (hidden files / `.pem` accidentally collapsing
to a path component named `.pem`)
* embedded `..` (path traversal)
* leading `-` (HAProxy CLI flag confusion when admins inspect
files; also matches /etc/ssl/haproxy/-rf as accidental rm
target)
"""
if v is None:
return v
stripped = v.strip()
if not stripped:
return v
if stripped != v:
raise ValueError(
"ssl.name must not contain leading or trailing whitespace"
)
if len(stripped) > 200:
raise ValueError("ssl.name must be 200 characters or fewer")
import re as _re
if not _re.match(r'^[A-Za-z0-9_.-]+$', stripped):
raise ValueError(
f"ssl.name={v!r} contains forbidden characters — only "
"letters, digits, underscore, hyphen, and dot are allowed "
"(the certificate name is used as a filename component "
"under /etc/ssl/haproxy/)."
)
if ".." in stripped:
raise ValueError(
f"ssl.name={v!r} must not contain '..' (path traversal)"
)
if stripped.startswith("."):
raise ValueError(
f"ssl.name={v!r} must not start with '.' (hidden filename)"
)
if stripped.startswith("-"):
raise ValueError(
f"ssl.name={v!r} must not start with '-' (CLI flag confusion)"
)
return stripped
@field_validator("https_frontend_name_suffix")
@classmethod
def _validate_https_suffix(cls, v: Optional[str]) -> Optional[str]:
"""Bulgu #16 (round-6 audit): the suffix is appended to the
HTTP frontend's name to form the HTTPS frontend's name.
For the result to be a valid HAProxy frontend name (matches
`^[a-zA-Z][a-zA-Z0-9_-]{0,63}$`) the suffix must:
* be non-empty (empty suffix would generate an HTTPS
frontend that collides with the HTTP frontend's name
and surfaces as a confusing fe-name collision error
late at submit time);
* contain ONLY `[a-zA-Z0-9_-]` (the HAProxy frontend-
name character class) so the composite name doesn't
break the parser.
Pre-fix the wizard accepted `https_frontend_name_suffix=""`
and `"-with spaces "` and only surfaced the error AFTER a
round-trip through `create_frontend_row`, which by then
had already created the backend + servers + ACME order.
"""
if v is None:
return v
if v == "":
raise ValueError(
"ssl.https_frontend_name_suffix must not be empty — "
"set a non-empty suffix (default '-https') so the "
"HTTPS frontend's name does not collide with the "
"HTTP frontend's name."
)
import re as _re
if not _re.match(r'^[a-zA-Z0-9_-]+$', v):
raise ValueError(
f"ssl.https_frontend_name_suffix={v!r} must only "
"contain letters, digits, '_' or '-' (the HAProxy "
"frontend-name character class). The composite "
"`<frontend.name><suffix>` must be a valid HAProxy "
"frontend name."
)
return v
@model_validator(mode="after")
def reject_hsts_preload_without_hsts(self):
"""R18b audit fix (round 5 #M): an operator can independently
toggle `hsts_preload`/`hsts_include_subdomains` even with
`hsts_enabled=False`. The wizard then persists the booleans
but emits NO Strict-Transport-Security header at all (the
emit is gated on `hsts_enabled`). Result: the operator sees
"preload on" and assumes the site is preload-eligible while
in reality the header is absent, so the preload list will
reject submission. Surface the contradiction at validation
time instead of letting the misconfig sit in the DB.
"""
if self.hsts_preload and not self.hsts_enabled:
raise ValueError(
"ssl.hsts_preload=true requires ssl.hsts_enabled=true. "
"Preload submission also requires include_subdomains and "
"max-age >= 31536000."
)
# The HSTS preload list (https://hstspreload.org) explicitly
# requires max_age >= 1 year and include_subdomains=true. Reject
# combinations that would never satisfy submission so we don't
# mislead operators chasing preload eligibility.
if self.hsts_preload and self.hsts_max_age < 31536000:
raise ValueError(
"ssl.hsts_preload=true requires hsts_max_age >= 31536000 "
"(1 year) per the HSTS preload list policy."
)
if self.hsts_preload and not self.hsts_include_subdomains:
raise ValueError(
"ssl.hsts_preload=true requires hsts_include_subdomains=true "
"per the HSTS preload list policy."
)
# R18c audit fix (round 3 #6): refuse TLS 1.0 / 1.1 on the
# HTTPS frontend bind for the same RFC 8996 reasons as the
# backend server validator. Operators can still author the
# frontend through the regular Frontends UI; the wizard
# surface stays modern.
for fld in ("ssl_min_ver", "ssl_max_ver"):
v = getattr(self, fld, None)
if v in ("TLSv1.0", "TLSv1.1"):
raise ValueError(
f"ssl.{fld}={v} is no longer accepted by the wizard. "
"TLS 1.0 / 1.1 are deprecated by RFC 8996. Use TLSv1.2 "
"or TLSv1.3."
)
# Bulgu #48 (round-17 audit) — ALPN ↔ TLS min-version
# consistency. RFC 7540 (HTTP/2) section 9.2 makes TLS 1.2
# the MINIMUM for HTTP/2 over TLS, and major browsers
# additionally require the `EXTENDED_MASTER_SECRET` + AEAD
# ciphers introduced at TLS 1.2. An operator who picks
# `ssl_alpn='h2,http/1.1'` together with `ssl_min_ver=
# 'TLSv1.0'` (allowed by the Literal) creates a config
# where TLS 1.0/1.1 clients DOWNGRADE — but the server STILL
# advertises h2 in the ALPN list. Modern browsers refuse the
# handshake; old browsers fall back to HTTP/1.1; the operator
# sees inconsistent breakage with no obvious cause. With the
# TLS 1.0/1.1 reject above already in place, the practical
# surface is `ssl_alpn='h2,…'` plumbed alongside an unset
# `ssl_min_ver` (defaults to whatever HAProxy/OpenSSL
# negotiates, typically 1.0 on old OS images). Require
# `ssl_min_ver` to be set to 1.2+ when h2 is advertised so
# the operator sees a clear "set ssl_min_ver=TLSv1.2"
# message instead of debugging browser-side ERR_SPDY_…
# errors weeks later.
alpn = (self.ssl_alpn or "").lower().strip()
if alpn and "h2" in [tok.strip() for tok in alpn.split(",")]:
min_ver = self.ssl_min_ver
# min_ver=None means "let HAProxy choose" which may
# negotiate < TLSv1.2 on older agents. Require explicit
# 1.2+ so HTTP/2 is unambiguously safe.
if min_ver not in ("TLSv1.2", "TLSv1.3"):
raise ValueError(
f"ssl.ssl_alpn={self.ssl_alpn!r} advertises h2 (HTTP/2) "
"but ssl.ssl_min_ver is "
f"{min_ver if min_ver else 'unset (HAProxy default)'}. "
"RFC 7540 section 9.2 mandates TLS 1.2+ for h2; "
"modern browsers refuse the handshake otherwise. "
"Set ssl.ssl_min_ver to 'TLSv1.2' or 'TLSv1.3', OR "
"remove 'h2' from the ALPN list."
)
return self
@model_validator(mode="after")
def reject_ssl_verify_without_ca_file(self) -> "SSLChoice":
"""Bulgu #26 (round-12 audit): SSLChoice.ssl_verify accepts
'optional' and 'required' but the renderer's
`_resolve_frontend_client_ca_path` always returns None (PR-7
placeholder), which triggers the safeguard in
`_apply_bind_ssl_verify` that SILENTLY DROPS the `verify`
directive to prevent a fatal HAProxy ALERT. Result: an
operator who selects "required — reject anonymous" in the UI
gets a cert row with `ssl_verify='required'`, sees no error
anywhere, but the rendered HAProxy bind has NO `verify` token
at all. mTLS is silently disabled. The site appears to be
configured for mTLS in the OpenManager UI / DB / preview, but
the actual TLS handshake accepts anonymous clients.
Until the ca-file column (PR-7) is wired through the wizard,
reject `ssl_verify in ('optional', 'required')` at submit so
the operator gets a clear, actionable error instead of a
silent misconfig.
"""
if self.ssl_verify in ("optional", "required"):
raise ValueError(
f"ssl.ssl_verify='{self.ssl_verify}' requires a client-CA "
"bundle (HAProxy `ca-file`). The Site Wizard does not yet "
"plumb the ca-file field, so the renderer SILENTLY DROPS "
"the `verify` directive to avoid a fatal HAProxy parse "
"error — your mTLS selection would NOT actually be "
"enforced. Set ssl.ssl_verify to None (or 'none') here "
"and configure mTLS via Frontend Management → Advanced "
"TLS once the wizard exposes the client-CA bundle field."
)
return self
@field_validator("ssl_alpn")
@classmethod
def _validate_ssl_alpn(cls, v: Optional[str]) -> Optional[str]:
"""Bulgu #27 (round-12 audit): ssl_alpn is emitted verbatim
into `bind ... alpn <value>`. HAProxy expects a comma-
separated list of ALPN protocol identifiers (RFC 7301);
whitespace inside a token or a stray separator triggers
parse errors at apply time. Pre-fix the wizard accepted
any string up to 128 chars, so `ssl_alpn='h2, http/1.1'`
(note the space after the comma) made it through and
broke apply with a confusing 'no shared cipher' surface.
Accepted token grammar: `[A-Za-z0-9._/-]+` per token. IANA
ALPN registry IDs (`h2`, `http/1.1`, `acme-tls/1`, etc.)
all match this. Tokens are split on `,` and each token is
stripped before validation so "h2, http/1.1" is the same
as "h2,http/1.1" — but we re-join with no spaces because
HAProxy is whitespace-strict.
"""
if v is None:
return v
stripped = v.strip()
if not stripped:
return None
if len(stripped) > 128:
raise ValueError("ssl.ssl_alpn must be 128 characters or fewer")
import re as _re
tokens_raw = [t.strip() for t in stripped.split(",")]
if any(not t for t in tokens_raw):
raise ValueError(
"ssl.ssl_alpn must not contain empty tokens "
"(e.g. trailing comma or 'h2,,http/1.1')"
)
for tok in tokens_raw:
if not _re.match(r"^[A-Za-z0-9._/-]+$", tok):
raise ValueError(
f"ssl.ssl_alpn token {tok!r} contains invalid characters. "
"Each ALPN protocol identifier must match the IANA "
"ALPN registry grammar (e.g. 'h2', 'http/1.1', "
"'acme-tls/1'); use only letters, digits, '.', '_', "
"'/' or '-'."
)
return ",".join(tokens_raw)
@field_validator("ssl_ciphers", "ssl_ciphersuites")
@classmethod
def _validate_ssl_cipher_list(
cls, v: Optional[str], info
) -> Optional[str]:
"""Bulgu #33 (round-14 audit): `ssl_ciphers` / `ssl_ciphersuites`
get interpolated directly into `bind ... ciphers <value>` and
`bind ... ciphersuites <value>` directives. OpenSSL cipher
specifications are colon-separated identifiers with optional
`!` / `+` / `-` / `@` operators (e.g.
`ECDHE-RSA-AES128-GCM-SHA256:!aNULL:!MD5`). No whitespace,
no newlines.
Pre-fix the wizard only enforced `max_length=2048`, so a
newline-bearing value would split the bind line and inject
arbitrary HAProxy directives into the frontend block. We
reject any control character, newline, AND any embedded
whitespace (HAProxy's bind-line tokenizer splits at the first
space, so a space inside the cipher spec would shift later
keywords like `ssl-min-ver` / `alpn` into wrong positions).
We do NOT validate the cipher-name grammar itself — that's
OpenSSL's job and the catalogue evolves; the validator's
scope is strict shape-only (no whitespace, no control chars,
no newlines).
"""
if v is None:
return v
stripped = v.strip()
if not stripped:
return None
if '\n' in stripped or '\r' in stripped:
field_name = info.field_name if info else 'cipher field'
raise ValueError(
f"ssl.{field_name} must not contain line breaks "
"(HAProxy bind directives are single-line; embedded "
"newlines would smuggle additional directives into "
"the rendered frontend block)"
)
if any(ch.isspace() for ch in stripped):
field_name = info.field_name if info else 'cipher field'
raise ValueError(
f"ssl.{field_name} must not contain whitespace "
f"(got {v!r}). OpenSSL cipher specs are colon-"
"separated with no embedded spaces; HAProxy's "
"bind-line tokenizer would otherwise read the rest "
"of the value as separate keywords."
)
return stripped
@model_validator(mode="after")
def reject_inverted_tls_versions(self) -> "SSLChoice":
"""Phase K: when both `ssl_min_ver` and `ssl_max_ver` are set,
require min <= max. HAProxy emits `ssl-min-ver` / `ssl-max-ver`
bind options independently; if `min > max` the resulting bind
accepts no TLS handshakes at all (the agent's `haproxy -c`
accepts the syntax but every TLS handshake fails at runtime
with a generic "no shared cipher" error, which is surprisingly
hard to diagnose). Surface the contradiction at the wizard
boundary instead of letting a saved-and-applied site sit in a
broken state."""
order = {
"TLSv1.0": 0,
"TLSv1.1": 1,
"TLSv1.2": 2,
"TLSv1.3": 3,
}
if self.ssl_min_ver and self.ssl_max_ver:
if order[self.ssl_min_ver] > order[self.ssl_max_ver]:
raise ValueError(
f"ssl.ssl_min_ver={self.ssl_min_ver} cannot be greater "
f"than ssl.ssl_max_ver={self.ssl_max_ver}: an inverted "
"TLS range causes every handshake to fail at runtime."
)
return self
class SiteCreate(BaseModel):
"""Top-level Site Wizard create payload.
NOTE: This class was renamed from `ProxiedHostCreate` as part of
the v1.5.x Site rebrand. A backward-compat alias
`ProxiedHostCreate = SiteCreate` is exported at the bottom of this
module so existing imports / pickled instances keep working.
"""
cluster_id: int = Field(..., ge=1)
domains: List[str] = Field(..., min_length=1, max_length=100)
backend: BackendStep
servers: List[ServerStep] = Field(..., min_length=1, max_length=50)
frontend: FrontendStep
ssl: SSLChoice
apply_immediately: bool = False
@field_validator("servers")
@classmethod
def reject_all_zero_weight_servers(cls, v: List["ServerStep"]) -> List["ServerStep"]:
"""Bulgu #50 (round-18 audit) — sibling of Bulgu #49 (all-backup).
HAProxy uses `weight` to compute each server's share of incoming
traffic; `weight=0` is the operator's "drain" signal — the
server keeps existing keep-alive connections but receives ZERO
new requests. If every server in a backend has weight=0, new
traffic has nowhere to go: HAProxy returns 503 (or queues
until queue overflow) for every request that would normally
round-robin into this backend.
Pre-fix the wizard's per-server `weight: ge=0` accepted 0 and
the operator never saw a warning. The all-zero state was
usually a typo from copy-pasting from an active-active to
active-passive layout (operator meant to set ONE weight to 0
as the failover slot).
The check fires only for 2+ servers: a single-server backend
with weight=0 is sometimes a legitimate "this backend is
draining for maintenance" pattern.
Distinct from Bulgu #49: the operator could mark all servers
primary (backup_server=false) AND all weight=0 — that combo
passes #49 but hits #50."""
if not v or len(v) < 2:
return v
non_zero_weight = sum(
1 for s in v if int(getattr(s, "weight", 100) or 0) > 0
)
if non_zero_weight == 0:
raise ValueError(
f"All {len(v)} servers have weight=0 (drain). HAProxy "
"routes new requests by weighted round-robin — with "
"every weight at 0 the backend silently 503s every "
"new request. Set at least one server's weight to >0, "
"or remove the drained server(s) entirely if they are "
"no longer needed."
)
return v
@field_validator("servers")
@classmethod
def reject_all_backup_servers(cls, v: List["ServerStep"]) -> List["ServerStep"]:
"""Bulgu #49 (round-17 audit) — HAProxy's `backup` flag
designates a server as a fail-over slot: it only receives
traffic when ALL primary (non-backup) servers in the same
backend are marked DOWN. If every server in the backend is
flagged `backup_server=true`, HAProxy considers the backend
permanently unable to serve traffic — every request lands on
`default_backend` or hits the cluster's no-server error
response. The wizard accepts this combination silently because
per-server validators don't see the rest of the list.
Pre-fix the operator paste-error of "mark all 3 as backup
because they're standby" surfaced as a hard-to-debug 503
cascade post-apply: every health probe succeeds, but no
request ever reaches a server.
Reject upfront so the operator either un-marks one as
primary or drops the backup flag entirely. The check fires
only when there are 2+ servers (a single backup server is a
sensible "no traffic during deploy" pattern that the
operator may intentionally toggle on for short windows)."""
if not v or len(v) < 2:
return v
primary_count = sum(
1 for s in v if not bool(getattr(s, "backup_server", False))
)
if primary_count == 0:
raise ValueError(
f"All {len(v)} servers are flagged backup_server=true. "
"HAProxy's `backup` slot only receives traffic when at "
"least one PRIMARY (non-backup) server is UP — with no "
"primaries the backend silently black-holes every "
"request. Un-flag at least one server, or remove the "
"backup flag entirely if you want all of them serving."
)
return v
@field_validator("servers")
@classmethod
def reject_duplicate_server_names(cls, v: List["ServerStep"]) -> List["ServerStep"]:
"""Bulgu #17 (round-7 audit): HAProxy requires server names to
be unique within a backend block. Two `server srv1 ...` lines
in the same backend produce a config the agent's `haproxy -c`
REJECTS at apply time with:
[ALERT] ... : Proxy 'be-foo' : duplicate server 'srv1'
Pre-fix the wizard happily forwarded the duplicate names to
`create_backend_server_row` which only checks for backend-
scoped uniqueness at the DB level (UNIQUE constraint), so the
first server row was created, the second hit a UniqueViolation,
and the wizard surfaced a generic 500 with the constraint
name. Catching it here gives the operator a clear, actionable
message naming the duplicate(s).
Address+port duplicates are NOT rejected — operators sometimes
intentionally route the same upstream through two aliases
(e.g. `srv1-primary` + `srv1-canary`) — but server-name
duplication is unambiguously a typo.
"""
if not v:
return v
seen: dict = {}
duplicates: List[str] = []
for s in v:
name = (s.server_name or '').strip()
if not name:
continue
if name in seen:
duplicates.append(name)
else:
seen[name] = True
if duplicates:
dup_unique = sorted(set(duplicates))
raise ValueError(
f"Duplicate server names within the same backend: "
f"{', '.join(dup_unique)}. HAProxy requires unique "
f"`server <name>` tokens — rename the duplicate(s)."
)
return v
@field_validator("domains")
@classmethod
def normalise_domains(cls, v: List[str]) -> List[str]:
"""Normalize each domain (lowercase, trim) and reject
duplicates AFTER normalisation.
Bulgu #16 (round-6 audit): pre-fix the wizard accepted
`["Site.com", "site.com", "www.SITE.com"]` and forwarded all
three to:
* the SSL upload / ACME order — Let's Encrypt rejects
duplicate identifiers in the same order with an
opaque error;
* the HSTS / `domains_handled` JSONB column — duplicates
inflate audit logs and bypass per-domain rate limits;
* the ACME-challenge ACL routing — duplicates have no
effect but bloat the rendered config.
We dedupe POST-normalisation (case-insensitive) so the
operator's "Site.com" / "site.com" pair is caught and
rejected with a clear list of conflicting entries. The
original ORDER of first occurrence is preserved.
"""
if not v:
return v
normalised = [validate_domain(d) for d in v]
seen: set = set()
duplicates: List[str] = []
unique_in_order: List[str] = []
for d in normalised:
if d in seen:
duplicates.append(d)
else:
seen.add(d)
unique_in_order.append(d)
if duplicates:
# De-dup the duplicate list too so the error message is
# readable when the operator submitted three copies of
# the same string.
dup_unique = sorted(set(duplicates))
raise ValueError(
f"Duplicate domain entries detected (case-insensitive after "
f"normalisation): {', '.join(dup_unique)}. Remove the "
f"duplicate(s) and resubmit."
)
return unique_in_order
@model_validator(mode="after")
def enforce_acme_apply_and_http(self) -> "SiteCreate":
"""M22: acme mode REQUIRES apply_immediately=true so the agent can
confirm the bulk-site-create-{ts} version (gating the
deferred LE API call).
Round 10 micro-finding: acme mode also REQUIRES frontend.mode='http'
because HTTP-01 challenge is served on port 80 plain HTTP only.
Bulgu #31: ssl.mode='upload' MUST carry non-empty PEM payload —
otherwise create_cert_row would silently insert an unusable cert
and break HAProxy reload at apply time.
Bulgu #31b: ssl.mode='existing' MUST carry ssl_certificate_id —
otherwise the wizard would fall through to the runtime check with
a confusing 400 instead of a clean 422.
"""
if self.ssl.mode == "acme":
if not self.apply_immediately:
raise ValueError(
"ssl.mode='acme' requires apply_immediately=true (the agent must "
"confirm the new HTTP frontend before the LE API call can run)"
)
if self.frontend.mode != "http":
raise ValueError(
"ssl.mode='acme' requires frontend.mode='http' (HTTP-01 challenge)"
)
# Bulgu #34 (round-15 audit) — DO NOT hard-block non-80 ports here.
#
# Previously this model validator rejected ANY ACME payload with
# `frontend.bind_port != 80`. That assumed the wizard's new
# frontend is the ONLY thing on port 80 — true for solo-site
# clusters, but FALSE for the canonical enterprise pattern where
# one shared HTTP frontend on port 80 host-routes traffic to many
# backends. Operators with such clusters were stuck:
#
# * keep bind_port=80 → `Bind *:80 already used by frontend X`
# hard error (the cluster's shared port-80 frontend collides).
# * change bind_port → this validator rejected the payload.
#
# The actual HTTP-01 routing chain only requires:
# 1. cluster.acme_enabled = TRUE (renderer injects the
# `/.well-known/acme-challenge/` ACL into EVERY HTTP-mode
# frontend in the cluster — see haproxy_config.py:974-978).
# 2. SOME HTTP-mode frontend in the cluster listens on port 80
# (so LE's plain-HTTP probe can land somewhere).
# 3. `_acme_challenge_backend` proxies to OpenManager which
# serves the token regardless of which domain LE asked for.
#
# When the cluster ALREADY has a port-80 HTTP frontend that is
# not the wizard's new one, the wizard's new frontend can bind
# ANY free port — the challenge will still be served by the
# existing port-80 frontend. The route handler does the
# cluster-aware check (it needs DB access; model validators
# don't have it). See `_validate_acme_port80_reachable` in
# routers/site_wizard.py.
#
# We keep the model-level check for the OBVIOUSLY-WRONG case
# (port < 1 / > 65535 is already covered by FrontendStep field
# constraints) but defer the cluster-aware "is some port-80
# frontend reachable?" decision to the route handler.
# R16 hardening (#R16-1): wildcard domains (e.g. '*.example.com')
# require the DNS-01 challenge per Let's Encrypt rules — HTTP-01
# is server-side validation against a single hostname's HTTP
# endpoint and cannot prove ownership of an entire DNS subtree.
# Pre-R16 the wizard happily forwarded '*.example.com' to LE,
# which then rejected the order with "wildcard requires DNS-01"
# — the user only saw an opaque order error long after submit.
# Reject upfront with a clear, actionable message.
wildcard_domains = [d for d in self.domains if d.startswith("*.")]
if wildcard_domains:
raise ValueError(
f"ssl.mode='acme' (HTTP-01) cannot issue wildcard certs "
f"({', '.join(wildcard_domains)}). Let's Encrypt requires "
"DNS-01 for wildcards. Either remove the wildcard domain "
"or pick ssl.mode='upload' / 'existing' with a wildcard "
"cert obtained out-of-band."
)
# Bulgu #30 (round-13 audit) — explicit scheme=https redirect_rules
# entries bypass the auto-generated ACME-safe condition that
# `_build_redirect_rules` emits for `https_redirect=true`.
# FrontendStep already rejects the (https_redirect=true,
# redirect_rules non-empty) combination as mutually exclusive,
# so operators reach this branch by manually typing a
# scheme→https redirect into `redirect_rules`. On an ACME
# site that pattern would 301 the LE HTTP-01 challenge
# request to the (not-yet-existing) HTTPS endpoint and the
# order would fail at validation (see Bulgu #29 for the
# detailed sequence). Reject these rules at the model
# boundary so the operator gets a clear, actionable
# message before submit. Allow rules that already encode
# the `path_beg /.well-known/acme-challenge/` exclusion in
# their condition — those are operator-curated and safe.
unsafe_scheme_redirects: List[str] = []
for _idx, _rule in enumerate(self.frontend.redirect_rules or []):
if isinstance(_rule, dict):
if (_rule.get("type") or "").strip().lower() != "scheme":
continue
if (_rule.get("scheme") or "").strip().lower() != "https":
continue
_cond = (_rule.get("condition") or "")
if "/.well-known/acme-challenge" not in _cond:
unsafe_scheme_redirects.append(
f"redirect_rules[{_idx}] (dict)"
)
elif isinstance(_rule, str):
# Legacy raw-string entries. Look for the
# canonical `scheme https` token; if the rule does
# NOT carry the challenge-path exclusion we treat
# it as unsafe.
_s = _rule.strip().lower()
if "scheme https" in _s and "/.well-known/acme-challenge" not in _s:
unsafe_scheme_redirects.append(
f"redirect_rules[{_idx}] (string)"
)
if unsafe_scheme_redirects:
raise ValueError(
"ssl.mode='acme' rejects explicit scheme=https "
"redirect_rules without an ACME HTTP-01 challenge "
"exclusion ("
+ ", ".join(unsafe_scheme_redirects)
+ "). The HTTP frontend on port 80 must serve "
"`/.well-known/acme-challenge/<token>` for Let's "
"Encrypt validation; a blanket scheme→https "
"redirect would 301 the challenge to an HTTPS "
"endpoint that does not exist yet (the HTTPS "
"frontend is created AFTER issuance succeeds). "
"Use frontend.https_redirect=true instead — the "
"wizard renders an ACME-safe condition "
"automatically — or add "
"`!{ path_beg /.well-known/acme-challenge/ }` to "
"your redirect rule's condition."
)
# R14 hardening (#R14-2): when SSL is enabled (any of acme / upload /
# existing) the wizard creates BOTH an HTTP frontend (bind_port) AND
# an HTTPS frontend (https_bind_port) on the same agent IPs. If the
# user fat-fingers https_bind_port to match bind_port, HAProxy will
# refuse to load the config because the same address:port can't be
# bound by two frontends. Catch it at the wizard layer with a clear
# ValueError instead of an opaque agent reload failure later.
if self.ssl.mode in ("acme", "upload", "existing"):
if self.frontend.bind_port == self.ssl.https_bind_port:
raise ValueError(
f"ssl.https_bind_port={self.ssl.https_bind_port} cannot equal "
f"frontend.bind_port={self.frontend.bind_port} — the HTTP and "
"HTTPS frontends must bind to different ports on the same agent IPs."
)
# Bulgu #28 (round-12 audit): ssl.mode='none' (HTTP-only host)
# combined with frontend.https_redirect=true (or an explicit
# scheme=https redirect rule) is a self-bricking configuration:
# the HTTP frontend emits `redirect scheme https code 301` for
# every request but the wizard never creates an HTTPS frontend
# (mode='none' skips the HTTPS bind), so the redirect points
# at a port that has nothing listening. Browsers loop on the
# redirect, hit a connection-refused, and the site is
# effectively offline. Pre-fix the wizard accepted this combo
# silently and the operator only noticed when their site went
# dark post-apply.
if self.ssl.mode == "none":
scheme_redirects = [
r for r in (self.frontend.redirect_rules or [])
if isinstance(r, dict) and (r.get("type") == "scheme"
or (r.get("scheme") or "").lower() == "https")
]
if self.frontend.https_redirect or scheme_redirects:
raise ValueError(
"frontend.https_redirect=true (or an explicit "
"scheme→https redirect rule) requires ssl.mode in "
"('acme','upload','existing'). With ssl.mode='none' "
"the wizard does not create an HTTPS frontend, so "
"the redirect would point at a port with nothing "
"listening — every visitor would see a "
"connection-refused error. Either set ssl.mode to "
"issue a cert (acme / upload / existing) or clear "
"the https_redirect flag."
)
if self.ssl.mode == "upload":
cert_pem = (self.ssl.certificate_content or "").strip()
key_pem = (self.ssl.private_key_content or "").strip()
if not cert_pem or "-----BEGIN" not in cert_pem:
raise ValueError(
"ssl.mode='upload' requires a non-empty PEM-encoded certificate_content "
"(if you resumed a draft, PEM fields were stripped at save time and must be re-entered)"
)
if not key_pem or "-----BEGIN" not in key_pem:
raise ValueError(
"ssl.mode='upload' requires a non-empty PEM-encoded private_key_content "
"(if you resumed a draft, PEM fields were stripped at save time and must be re-entered)"
)
if not (self.ssl.name or "").strip():
raise ValueError("ssl.mode='upload' requires ssl.name (the certificate label)")
elif self.ssl.mode == "existing":
if self.ssl.ssl_certificate_id is None:
raise ValueError("ssl.mode='existing' requires ssl.ssl_certificate_id")
# Bulgu #17 (round-7 audit): frontend / backend mode MUST
# match. HAProxy rejects a `mode http` frontend that calls
# `use_backend <name>` / `default_backend <name>` against a
# `mode tcp` backend (and vice-versa) — the parser fires:
#
# [ALERT] : Proxy 'fe-foo' : in mode tcp, cannot use
# 'http' mode backend 'be-foo'.
#
# Pre-fix the wizard accepted any combination and the
# mismatch only surfaced at the agent's `haproxy -c` step
# AFTER the entities had been created with PENDING status.
# Catch it at the model boundary so the operator never even
# reaches Apply Management with a broken pair.
#
# ORDERING: this check runs AFTER the ACME-specific
# `frontend.mode='http'` enforcement above so an ACME
# payload with a TCP frontend surfaces the ACME-specific
# message first (which is more actionable — "switch to http
# OR pick a different ssl.mode"). Generic mismatches that
# are not ACME-specific land here.
if self.frontend.mode != self.backend.mode:
raise ValueError(
f"frontend.mode='{self.frontend.mode}' must match "
f"backend.mode='{self.backend.mode}'. HAProxy refuses "
"to load a config where a `use_backend` / "
"`default_backend` directive crosses HTTP / TCP "
"modes. Switch either the frontend or the backend "
"to the same mode and resubmit."
)
# Bulgu #57 (round-19 audit) — HSTS is meaningless on TCP.
#
# The HSTS header is emitted by the renderer as
# `http-response set-header Strict-Transport-Security …`. That
# directive only exists in HTTP mode — the renderer + parser
# combo bails at apply time with the same "not allowed in mode
# tcp" parse error covered by round-19 Bulgu #56 for the
# plain header fields. The wizard further auto-injects the
# HSTS line into the cloned HTTPS frontend payload (see
# routers/site_wizard.py around the `model_copy(...)` block),
# so even if the operator's typed payload is clean, the
# implicit HSTS injection would land on a TCP frontend.
#
# Reject the combination at submit time. The two escape
# hatches mirror Bulgu #51:
# * switch frontend.mode to 'http' so a real HTTPS frontend
# exists in HTTP mode and can carry the header, OR
# * set hsts_enabled=false.
#
# The check is ordered AFTER the per-field ACME / mode-match
# checks so the most specific message fires first; an ACME
# TCP payload (which already fails the
# `ssl.mode='acme' requires frontend.mode='http'` check above)
# never reaches this branch.
if self.frontend.mode == "tcp" and bool(getattr(self.ssl, "hsts_enabled", False)):
raise ValueError(
"frontend.mode='tcp' is incompatible with hsts_enabled=true. "
"HSTS is delivered via an HTTP response header; HAProxy "
"refuses to load a TCP frontend with `http-response "
"set-header` directives. Either switch frontend.mode "
"to 'http' or set hsts_enabled=false."
)
# Bulgu #51 (round-18 audit) — HSTS without HTTPS is a no-op.
#
# The wizard renders `http-response set-header
# Strict-Transport-Security ...` ONLY on the auto-generated
# HTTPS frontend (see haproxy_config.py - HSTS block guarded
# by ssl.mode != 'none'). When the operator picks
# `ssl.mode='none'` (plain HTTP site) but flips
# `hsts_enabled=true` thinking it will "force HTTPS via the
# browser pin", the renderer produces ZERO HSTS headers —
# the wizard accepted the toggle, the preview diff shows
# nothing about HSTS, and the operator is left with a false
# sense of security.
#
# The fix is to reject the inconsistent payload at submit
# time with an actionable message. Three escape hatches:
# 1. Switch ssl.mode to 'upload'/'existing'/'acme' so an
# HTTPS frontend actually exists.
# 2. Toggle hsts_enabled=false (user really only wants
# plain HTTP).
# 3. (Out-of-scope of the wizard) deploy an upstream
# HSTS-aware proxy — the wizard cannot help with this.
#
# We only fire when hsts_enabled is explicitly truthy on
# the SSLChoice payload, so legacy/None values do not
# regress.
if self.ssl.mode == "none" and bool(getattr(self.ssl, "hsts_enabled", False)):
raise ValueError(
"ssl.mode='none' is incompatible with hsts_enabled=true. "
"HSTS headers are emitted only on the HTTPS frontend; "
"with ssl.mode='none' the wizard does not create one, so "
"the toggle has no effect (silent misconfiguration). "
"Either set hsts_enabled=false or switch ssl.mode to "
"'upload', 'existing', or 'acme' so an HTTPS frontend "
"exists to carry the header."
)
return self
class SitePreflightAcme(BaseModel):
"""Body for POST /api/sites/preflight-acme."""
cluster_id: int = Field(..., ge=1)
domains: List[str] = Field(..., min_length=1, max_length=100)
@field_validator("domains")
@classmethod
def normalise(cls, v: List[str]) -> List[str]:
return [validate_domain(d) for d in v]
class SiteDraftCreate(BaseModel):
"""Body for POST /api/sites/drafts.
M14/M9: PEM (private_key_content / certificate_content) is server-side
stripped before persistence to avoid storing keys at rest in the
wizard_drafts.payload JSONB.
R14 hardening: bound the persisted payload size. Without an upper
bound, an authenticated user could POST 10MB of arbitrary JSON,
inflate the wizard_drafts.payload JSONB column, and (over time)
fill enterprise storage. The wizard itself produces ~520KB of
JSON for a richly-configured host, so a 256KB cap is generous and
still bounded. The retention task (30 days) provides a second
layer of cleanup.
"""
title: Optional[str] = Field(default=None, max_length=255)
payload: dict
@model_validator(mode="after")
def reject_oversized_payload(self):
"""Hard cap the JSON-serialised payload at 256KB."""
try:
import json as _json
serialised = _json.dumps(self.payload, default=str)
except Exception as exc: # pragma: no cover — JSON serialisation failure
raise ValueError(f"draft.payload is not JSON-serialisable: {exc}")
if len(serialised.encode("utf-8")) > 256 * 1024:
raise ValueError(
"draft.payload exceeds the 256KB size limit. Trim large blobs "
"(certificate_content / private_key_content / oversized rule "
"lists) before saving as draft."
)
return self
def _strip_pem_from_payload(payload: Any) -> Any:
"""Recursively scrub any keys that look like PEM-bearing fields."""
sensitive_keys = {
"private_key_content",
"certificate_content",
"chain_content",
"private_key",
}
if isinstance(payload, dict):
out = {}
for k, v in payload.items():
if k in sensitive_keys:
# Replace with a placeholder so the wizard knows the field
# was scrubbed and prompts the user to re-enter it.
out[k] = ""
else:
out[k] = _strip_pem_from_payload(v)
return out
if isinstance(payload, list):
return [_strip_pem_from_payload(x) for x in payload]
return payload
# ---------------------------------------------------------------------------
# Backward-compat aliases (Phase C of the Site rebrand).
#
# The Pydantic model classes were renamed:
# ProxiedHostCreate -> SiteCreate
# ProxiedHostPreflightAcme -> SitePreflightAcme
# ProxiedHostDraftCreate -> SiteDraftCreate
#
# We keep module-level aliases pointing at the new classes so any
# existing import (`from models.site_wizard import ProxiedHostCreate`)
# or pickled object continues to work without churn. The aliases are
# direct references (not subclasses) so OpenAPI / JSON-Schema only sees
# the canonical `SiteCreate` etc. names — external API consumers don't
# get a second ghost schema.
# ---------------------------------------------------------------------------
ProxiedHostCreate = SiteCreate
ProxiedHostPreflightAcme = SitePreflightAcme
ProxiedHostDraftCreate = SiteDraftCreate