Files
Mustafa ULUKAYA 47cc79dcf7 fix(acme): address review findings on the challenge-backend hardening
Five confirmed findings from an adversarial review of the branch, four of them
regressions introduced by it.

Fall through to the next source when a stored URL cannot be resolved.
Stopping at the first non-empty candidate emitted a backend section with no
`server` line: the section exists so `haproxy -c` passes and Apply succeeds,
then every challenge request 503s from an empty backend with nothing to show
for it. Scheme-less values are common — the settings field was free text until
this branch — so this was reachable on real installs. Selection moved into
`select_acme_backend_source()` so it is testable and the skipped candidates are
logged rather than silently dropped.

Report a challenge backend with no server line. `extract_acme_backend_target`
returns None for that section, and the loopback filter skipped falsy targets,
so the case above would have been reported as "challenge route present in
applied config" — the new check confirming the very state it exists to catch.

Do not narrow the row set feeding the routing check's `fail` branch. Adding a
mode filter to the WHERE clause turned a tcp-only port-80 cluster from "ok"
into "fail", and the site wizard blocks submit on any failing check, so those
installs would have been locked on upgrade day. Mode is now examined in Python
and only downgrades to `warn`, using an expression that is character-for-
character the renderer's normalisation.

Match the agent's config selector. The applied-config lookup omitted
`is_active = TRUE`, so it could read a superseded row and report on a config
the nodes never received. Extraction now happens in SQL rather than pulling
whole configs — these run to hundreds of KB.

Select `acme_backend_url` when loading the existing cluster. It was absent, so
the entity snapshot recorded old_values as NULL unconditionally and rejecting
the pending version wiped the operator's per-cluster URL back to the global
loopback default — re-creating the exact failure this branch removes.

Also carry `acme_enabled` and `acme_backend_url` through cluster creation. The
create model declared neither and the INSERT wrote neither, so a cluster
created with ACME switched on came back switched off with no error shown.

Refuted and deliberately not changed: settings PUT re-validating a stored
loopback value (it validates only what is submitted), an apply-path connection
leak (the 422 propagates to a handler that closes it), and the modal discarding
backend rejection reasons (the envelope matches).
2026-08-11 19:13:07 +03:00

94 lines
3.3 KiB
Python

from pydantic import BaseModel, field_validator
from typing import Optional, List
from utils.acme_backend_url import AcmeBackendUrlError, validate_acme_backend_url
def _validated_acme_backend_url(value: Optional[str]) -> Optional[str]:
"""Shared field validator body for `acme_backend_url`.
Pydantic turns the raised ValueError into a 422 with this message attached, so
the operator sees why the value was refused instead of discovering months later
that HTTP-01 never worked. Returns the normalised value — callers persist THIS,
not the raw input, so surrounding whitespace never reaches haproxy.cfg.
"""
try:
return validate_acme_backend_url(value)
except AcmeBackendUrlError as exc:
raise ValueError(str(exc)) from None
class HAProxyClusterCreate(BaseModel):
name: str
description: Optional[str] = None
connection_type: str = "agent" # Only "agent" supported now
stats_socket_path: str = "/run/haproxy/admin.sock"
haproxy_config_path: str = "/etc/haproxy/haproxy.cfg"
haproxy_bin_path: str = "/usr/sbin/haproxy" # HAProxy binary path
keepalived_config_path: str = "/etc/keepalived/keepalived.conf" # HA/VIP: keepalived.conf path (Issue #27)
pool_id: Optional[int] = None # Which pool this cluster belongs to
# The create form submits both of these. Until they were declared here pydantic
# dropped them and the INSERT never carried them, so a cluster created with ACME
# switched on came back switched off with no error shown — the same silent-success
# failure this work exists to remove.
acme_enabled: Optional[bool] = None
acme_backend_url: Optional[str] = None
_validate_acme_backend_url = field_validator("acme_backend_url")(
_validated_acme_backend_url
)
class HAProxyClusterUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
connection_type: Optional[str] = None
stats_socket_path: Optional[str] = None
haproxy_config_path: Optional[str] = None
haproxy_bin_path: Optional[str] = None
keepalived_config_path: Optional[str] = None
pool_id: Optional[int] = None
is_active: Optional[bool] = None
acme_enabled: Optional[bool] = None
acme_backend_url: Optional[str] = None
_validate_acme_backend_url = field_validator("acme_backend_url")(
_validated_acme_backend_url
)
class HAProxyClusterResponse(BaseModel):
id: int
name: str
description: Optional[str] = None
# Installation options
installation_type: str
deployment_type: str
# Connection details
host: str
port: int
connection_type: str
# Status info
is_active: bool
is_default: bool
last_connected_at: Optional[str] = None
connection_status: str
connection_error: Optional[str] = None
haproxy_version: Optional[str] = None
created_at: str
class HAProxyGlobalConfig(BaseModel):
max_connections: int = 4096
timeout_connect: int = 10000
timeout_client: int = 60000
timeout_server: int = 60000
log_level: str = "info"
stats_enabled: bool = True
stats_uri: str = "/stats"
stats_port: int = 8404
class ConfigVersion(BaseModel):
version_name: str
description: Optional[str] = None
config_content: Optional[str] = None
cluster_id: Optional[int] = None