mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-09-11 21:38:55 +00:00
02b1cb2bca
Closes #13, Closes #14. This release squashes the v1.4.0 → v1.5.0 development line. v1.4.0 shipped the ACME stability & enterprise audit (Issues #10/#11/#12). v1.5.0 builds on that foundation with two co-equal headline features plus a 22-round audit campaign hardening the prior configuration surface. License remains MIT for v1.5.0 (relicense to AGPL-3.0 lands in v1.5.2). ------------------------------------------------------------------ HEADLINE FEATURE A — ACME Diagnostic Panel (Issue #13) ------------------------------------------------------------------ A live pre-flight + post-failure diagnostic surface for every ACME order, reachable from the ACME Automation page. The panel exists to make ACME failures legible to operators who do NOT have shell access to the API host. Endpoints (`backend/routers/acme_diagnostics.py`): POST /api/letsencrypt/orders/{order_id}/diagnostics Run the full 5-check suite (DNS / port-80 / routing / account / agents) and humanize the order's `error_detail` (>=11 RFC-8555 problem types, backwards compatible with legacy plain-string failures). POST /api/letsencrypt/orders/{order_id}/diagnostics/ {check_id}/rerun Re-run a single check in place — used by the "Re-run" button on every row of the modal's pre-flight table. GET /api/letsencrypt/orders/{order_id}/events Merged event timeline combining the typed `acme_order_events` rows with correlated `user_activity_logs` entries (resource_type = 'letsencrypt_order' AND resource_id = order_id). The diagnostic modal auto-tails this timeline every 5 seconds while open. Service-level checks (`backend/services/acme_diagnostics.py`): * DNS resolution via stdlib socket.gethostbyname_ex through run_in_executor (intentionally avoiding an aiodns runtime dep for v1.5.0). * Port-80 HEAD probe, target locked to the order's domains, success on HTTP 200 OR 404, warns on egress timeout (corp egress policies routinely blackhole outbound 80 — fail-hard would be too noisy). * SSRF guard: probe refuses non-public IPs and surfaces the skip in the diagnostic result; IPv4-mapped IPv6 normalisation closes the `::ffff:169.254.169.254` cloud-metadata vector. * HAProxy routing presence check: matches the order's cluster_ids to a port-80 HTTP frontend. * ACME account validity check against `letsencrypt_accounts`. * Agent presence check (>=1 active agent in target cluster). * Every sub-check wrapped in a wall-clock timeout to bound impact on the API event loop. RBAC: ssl.read for run, ssl.read for events. Per-user 5/min rate limit on both run and rerun, backed by the (user_id, action, created_at DESC) composite index. Frontend (`frontend/src/components/ACMEAutomation.js`): * "Diagnose" button on every order row + the existing "stuck order" warning row. * Modal with two tabs: - Pre-flight Checks (Antd Table with status pills + Re-run buttons + humanized error banner) - Event Log (Antd Timeline with auto-tail polling, scroll- to-bottom, pause-on-hover) * Correlation IDs surfaced in error banners and individual check fail details for backend-log lookup. ------------------------------------------------------------------ HEADLINE FEATURE B — Site Setup Wizard (Issue #14) ------------------------------------------------------------------ A single guided flow that creates a Backend + Servers + HTTP Frontend (and optional HTTPS Frontend) in one atomic transaction. Endpoints (`backend/routers/site_wizard.py`): POST /api/site-wizard/preview — diff-preview the changeset POST /api/site-wizard/create — atomic execute POST /api/site-wizard/reject — clean rollback (including any wizard_staged ACME orders) GET /api/site-wizard/drafts — draft persistence PUT /api/site-wizard/drafts/{id} — save/update DELETE /api/site-wizard/drafts/{id} Feature surface: * One screen captures both backend (mode + servers) AND frontend (http + optional https + SSL mode) inputs. * SSL modes: ACME (new order, HTTP-01 only for v1.5.0), Upload (existing PEM), Existing (link to a stored cert), or None. * ACME-staged path: wizard_staged_until watermark on the `letsencrypt_orders` row defers finalisation until agent confirmation; per-mode reject cleanly cancels and rolls back the staged order. * Live diff preview against the cluster's current generated config (renderer-evolution noise stripped — track-sc<N> dedup, per-server cookie strip, defaults-cookie inheritance, listen-block flattening). * Draft persistence with PEM stripped at save time (private keys never round-trip through the drafts table). * Per-cluster multi-tenancy: drafts and wizard_staged orders are isolated to the creating user's cluster scope. Frontend (`frontend/src/components/SiteWizard.js`): * 4-step Antd Steps flow: Backend → Frontend → SSL → Review. * Render the live diff preview inline before commit. * Antd Form-level validation mirrors backend Pydantic validators (numeric bounds, HAProxy reserved keywords, ALPN consistency, IPv6 scope-id, domain regex, server name dedup). ------------------------------------------------------------------ AUDIT CAMPAIGN — Rounds 1 → 22 (Bulgu #1 → #82) ------------------------------------------------------------------ v1.5.0 includes 22 adversarial review passes. Each round produced its own commit set in the corporate development line; this squash collapses those into the v1.5.0 release artefact. Highlights: Round 1-4 Site Wizard core: dry-run parity, single-line value injection guard, ACL -f pattern-file block, SSL parity, timeout regex, form-state pin. Round 5-7 defaults-cookie inheritance, server-named-cookie guard, fe/be mode mismatch, duplicate server names, health_check_uri + server_address validators. Round 8-10 cookie_name / cookie_options newline-injection guard, dry-run parity (round 9), TCP-mode HTTP-only feature blockers. Round 11 SSL name path traversal + health-check >= 1. Round 12-13 SSL & ACME deep dive (Bulgu #23-#32). Round 14 single-line value injection (Bulgu #33). Round 15-17 ACME multi-tenant UX, numeric bounds, HAProxy reserved keywords, ALPN/TLS consistency, all-backup, multi-domain & multi-user enterprise edges, drain/HSTS/post-completion (Bulgu #34-#53). Round 18-21 concurrency, agent state, TCP-mode HTTP-only, list size caps, IPv6 scope-id, preview account validation, TCP backend + balance uri reject (Bulgu #54-#61). Round 22 FE error visibility + 3x stale-data lockouts, referential integrity + cascade safety, authentication & authorization, multi-cluster isolation, apply_pending_changes concurrency, script injection + bulk import multi-tenancy, prefix-stripped signature comparison (Bulgu #62-#82). ------------------------------------------------------------------ NO CORPORATE-SPECIFIC ARTIFACTS ------------------------------------------------------------------ This squash deliberately sanitises corporate hostnames, container registry references, and TLS secret names into generic placeholders (`your-registry.example.com/your-org`, `haproxy-openmanager*.example.com`, `wildcard-tls`, `taylanbakircioglu/haproxy-openmanager-*`) so the public artefact contains no internal infrastructure detail. Pilot / development history that retained those values stays in the corporate fork and is NOT part of this commit.
775 lines
30 KiB
Python
775 lines
30 KiB
Python
"""
|
|
Production-Ready HAProxy Configuration Validation
|
|
Provides comprehensive syntax checking, validation, and optimization suggestions
|
|
"""
|
|
|
|
import re
|
|
import logging
|
|
from typing import Dict, List, Tuple, Optional, Any
|
|
from dataclasses import dataclass
|
|
from enum import Enum
|
|
|
|
logger = logging.getLogger("haproxy_openmanager.config_validator")
|
|
|
|
class ValidationLevel(Enum):
|
|
"""Validation severity levels"""
|
|
ERROR = "error"
|
|
WARNING = "warning"
|
|
INFO = "info"
|
|
SUGGESTION = "suggestion"
|
|
|
|
@dataclass
|
|
class ValidationResult:
|
|
"""Single validation result"""
|
|
level: ValidationLevel
|
|
message: str
|
|
line_number: Optional[int] = None
|
|
section: Optional[str] = None
|
|
directive: Optional[str] = None
|
|
suggestion: Optional[str] = None
|
|
|
|
@dataclass
|
|
class ConfigValidationReport:
|
|
"""Complete validation report"""
|
|
is_valid: bool
|
|
error_count: int
|
|
warning_count: int
|
|
suggestion_count: int
|
|
results: List[ValidationResult]
|
|
syntax_score: float # 0-100 score
|
|
performance_score: float # 0-100 score
|
|
security_score: float # 0-100 score
|
|
|
|
class HAProxyConfigValidator:
|
|
"""Comprehensive HAProxy configuration validator"""
|
|
|
|
def __init__(self):
|
|
self.results = []
|
|
self.sections = {}
|
|
self.current_section = None
|
|
self.line_number = 0
|
|
# Phase K Phase D follow-up (Bulgu #12) — when the input is
|
|
# a PARTIAL config (wizard candidate fragment OR an in-place
|
|
# apply-time synthesis that DOES NOT include the global /
|
|
# defaults blocks because the agent merges them with its
|
|
# local copy on disk), the "Missing 'global' section" /
|
|
# "Consider adding 'defaults' section" diagnostics are pure
|
|
# false positives that confuse operators and inflate the
|
|
# warning count. We auto-detect partial fragments by
|
|
# looking for the wizard's own marker comment OR for the
|
|
# absence of any global/defaults section AT LEAST ONE
|
|
# frontend/backend section emitted.
|
|
self._is_partial_fragment = False
|
|
|
|
# Phase K Phase D follow-up (Bulgu #12 / round 3): Valid
|
|
# directives by section. The legacy sets below were a small,
|
|
# hand-picked subset that surfaced spurious WARNINGs for many
|
|
# well-formed wizard / manual configs:
|
|
# - `stick-table` / `stick` are valid in BOTH frontend AND
|
|
# backend (HAProxy 1.6+). The wizard emits stick-table on
|
|
# frontends with rate-limit WAF rules; the heuristic
|
|
# flagged each emission as "may not be valid".
|
|
# - `tcp-request` / `tcp-response` are valid in frontend AND
|
|
# backend (used for L4 inspection, content acceptance,
|
|
# custom track-sc rules).
|
|
# - `cookie` is the canonical session-stickiness directive
|
|
# in BACKEND. Pre-fix the validator flagged every wizard
|
|
# backend with cookie-based stickiness as "may not be
|
|
# valid".
|
|
# The post-fix sets are still NOT exhaustive (HAProxy has
|
|
# ~200 directives) but they cover the full surface area of
|
|
# what the wizard, manual Frontend/Backend management pages
|
|
# and config_templates can EVER emit, plus the most common
|
|
# operator-authored directives in raw-mode editors. Anything
|
|
# outside this set still emits a low-severity WARNING (never
|
|
# an ERROR), so a typo is still surfaced — we just stop
|
|
# crying wolf on valid configs.
|
|
# NOTE on lookup mechanics: `_validate_directive` splits the
|
|
# line by whitespace and checks `parts[0]` against the section
|
|
# set. So multi-word directive forms (e.g. `monitor fail`) DO
|
|
# NOT need to be listed — only the first token matters.
|
|
# Likewise `no option httplog` looks up `no` (a valid HAProxy
|
|
# negation prefix recognised in entity sections), which is
|
|
# included below.
|
|
self.valid_directives = {
|
|
'global': {
|
|
'daemon', 'master-worker', 'nbproc', 'nbthread', 'cpu-map',
|
|
'stats', 'user', 'group', 'chroot', 'pidfile', 'log', 'log-tag',
|
|
'maxconn', 'ulimit-n', 'spread-checks',
|
|
'ssl-default-bind-options', 'ssl-default-bind-ciphers', 'ca-base',
|
|
'crt-base',
|
|
'tune.bufsize', 'tune.maxrewrite',
|
|
'tune.rcvbuf.client', 'tune.rcvbuf.server',
|
|
'tune.sndbuf.client', 'tune.sndbuf.server',
|
|
'tune.ssl.default-dh-param', 'tune.ssl.cachesize',
|
|
'tune.ssl.lifetime', 'tune.ssl.maxrecord',
|
|
'tune.fd.edge-triggered',
|
|
'description', 'numa-cpu-mapping', 'no-numa-cpu-mapping',
|
|
'thread-groups', 'stats-file', 'unix-bind',
|
|
'presetenv', 'setenv',
|
|
'ssl-server-verify', 'ssl-mode-async',
|
|
'h1-case-adjust', 'h1-case-adjust-file',
|
|
'hard-stop-after',
|
|
'wurfl-data-file', 'wurfl-information-list',
|
|
'wurfl-information-list-separator', 'wurfl-cache-size',
|
|
'wurfl-engine-mode',
|
|
'cluster-secret', 'expose-experimental-directives',
|
|
'51degrees-data-file',
|
|
'no-quic', 'limited-quic', 'mworker-max-reloads',
|
|
},
|
|
'defaults': {
|
|
'mode', 'balance', 'option', 'timeout', 'retries', 'maxconn',
|
|
'http-request', 'http-response', 'http-after-response',
|
|
'errorfile', 'errorloc', 'errorloc302', 'errorloc303',
|
|
'http-error',
|
|
'default-server', 'default_backend', 'dispatch',
|
|
'log', 'log-tag', 'log-format', 'log-format-sd',
|
|
'compression', 'http-check', 'http-reuse',
|
|
'cookie', 'monitor-uri',
|
|
'load-server-state-from-file',
|
|
'http-send-name-header',
|
|
'fullconn', 'unique-id-format', 'unique-id-header',
|
|
'tcp-request', 'tcp-response', 'persist',
|
|
'enabled', 'disabled', 'hash-type', 'capture',
|
|
'rate-limit', 'description',
|
|
},
|
|
'frontend': {
|
|
'bind', 'mode', 'option', 'no', 'timeout', 'maxconn',
|
|
'default_backend', 'use_backend',
|
|
'acl', 'http-request', 'http-response', 'http-after-response',
|
|
'redirect', 'capture',
|
|
'monitor-uri', 'monitor',
|
|
'log', 'log-format', 'log-format-sd', 'log-tag',
|
|
'compression', 'rate-limit',
|
|
'stick-table', 'stick',
|
|
'tcp-request', 'tcp-response',
|
|
'errorfile', 'errorloc', 'errorloc302', 'errorloc303',
|
|
'http-error',
|
|
'description', 'id', 'filter',
|
|
'unique-id-format', 'unique-id-header', 'declare',
|
|
'http-reuse', 'maxidle', 'maxlife',
|
|
'enabled', 'disabled', 'http-send-name-header',
|
|
'http-buffer-request',
|
|
},
|
|
'backend': {
|
|
'mode', 'balance', 'option', 'no', 'timeout',
|
|
'server', 'default-server',
|
|
'http-request', 'http-response', 'http-after-response',
|
|
'stick-table', 'stick', 'hash-type',
|
|
'log', 'log-format', 'log-format-sd', 'log-tag',
|
|
'compression', 'http-check', 'http-reuse',
|
|
'cookie', 'appsession',
|
|
'tcp-request', 'tcp-response', 'tcp-check',
|
|
'retries', 'fullconn', 'dispatch',
|
|
'redirect', 'use-server', 'use_backend',
|
|
'acl', 'capture',
|
|
'errorfile', 'errorloc', 'errorloc302', 'errorloc303',
|
|
'http-error',
|
|
'description', 'id', 'filter',
|
|
'rate-limit', 'declare',
|
|
'email-alert', 'force-persist', 'ignore-persist',
|
|
'enabled', 'disabled', 'load-server-state-from-file',
|
|
'http-send-name-header', 'persist',
|
|
'transparent', 'source',
|
|
},
|
|
'listen': {
|
|
'bind', 'mode', 'balance', 'option', 'no', 'timeout',
|
|
'server', 'default-server', 'maxconn',
|
|
'http-request', 'http-response', 'http-after-response',
|
|
'acl', 'log', 'log-format', 'log-format-sd',
|
|
'stick-table', 'stick', 'tcp-request', 'tcp-response',
|
|
'cookie', 'use_backend', 'capture', 'redirect',
|
|
'http-check', 'tcp-check',
|
|
'errorfile', 'errorloc', 'errorloc302', 'errorloc303',
|
|
'http-error',
|
|
'description', 'id', 'filter',
|
|
'monitor-uri', 'monitor',
|
|
'compression', 'retries', 'fullconn', 'hash-type',
|
|
'rate-limit',
|
|
},
|
|
}
|
|
|
|
def validate_config(
|
|
self,
|
|
config_content: str,
|
|
partial_fragment: bool = False,
|
|
) -> ConfigValidationReport:
|
|
"""Validate complete HAProxy configuration.
|
|
|
|
Phase K Phase D follow-up (Bulgu #12) — `partial_fragment=True`
|
|
signals that the caller intentionally synthesised a partial
|
|
config that EXCLUDES `global` / `defaults` sections (the
|
|
agent merges them with its local copy on the HAProxy node).
|
|
With this flag the validator skips the "Missing 'global'
|
|
section" / "Consider adding 'defaults' section" diagnostics
|
|
that are pure false positives for the wizard's dry-run and
|
|
the wizard's apply-time pre-persist gate. When the flag is
|
|
unset (False, the default) AND the input clearly looks
|
|
partial (no global/defaults but at least one
|
|
frontend/backend), the validator auto-detects via the
|
|
wizard's marker comment so callers that forget to pass
|
|
the flag still don't trigger the warning.
|
|
"""
|
|
self.results = []
|
|
self.sections = {}
|
|
self.current_section = None
|
|
self.line_number = 0
|
|
# Caller-explicit flag wins; auto-detect via marker comment
|
|
# below for backwards compatibility with older callers.
|
|
self._is_partial_fragment = bool(partial_fragment)
|
|
|
|
lines = config_content.split('\n')
|
|
|
|
# Phase K Phase D (Bulgu #12) — auto-detect the wizard's own
|
|
# marker comment so callers that forget to pass
|
|
# `partial_fragment=True` still get the suppressed warnings.
|
|
# The marker is emitted by
|
|
# `routers/site_wizard.py::_build_candidate_fragment` and
|
|
# `services/haproxy_config.py` when assembling a cluster
|
|
# synthesis without the global/defaults preamble.
|
|
for raw_line in lines:
|
|
sline = raw_line.strip()
|
|
if (
|
|
'Wizard candidate fragment' in sline
|
|
or 'agent will preserve existing global' in sline.lower()
|
|
):
|
|
self._is_partial_fragment = True
|
|
break
|
|
|
|
# Parse and validate each line
|
|
for line_num, line in enumerate(lines, 1):
|
|
self.line_number = line_num
|
|
self._validate_line(line.strip())
|
|
|
|
# Perform section-level validations
|
|
self._validate_sections()
|
|
|
|
# Calculate scores
|
|
error_count = len([r for r in self.results if r.level == ValidationLevel.ERROR])
|
|
warning_count = len([r for r in self.results if r.level == ValidationLevel.WARNING])
|
|
suggestion_count = len([r for r in self.results if r.level == ValidationLevel.SUGGESTION])
|
|
|
|
syntax_score = self._calculate_syntax_score()
|
|
performance_score = self._calculate_performance_score()
|
|
security_score = self._calculate_security_score()
|
|
|
|
return ConfigValidationReport(
|
|
is_valid=error_count == 0,
|
|
error_count=error_count,
|
|
warning_count=warning_count,
|
|
suggestion_count=suggestion_count,
|
|
results=self.results,
|
|
syntax_score=syntax_score,
|
|
performance_score=performance_score,
|
|
security_score=security_score
|
|
)
|
|
|
|
def _validate_line(self, line: str):
|
|
"""Validate individual line"""
|
|
if not line or line.startswith('#'):
|
|
return
|
|
|
|
# Check for section headers
|
|
if self._is_section_header(line):
|
|
self._validate_section_header(line)
|
|
return
|
|
|
|
# Skip empty lines
|
|
if not line.strip():
|
|
return
|
|
|
|
# Validate directive
|
|
if self.current_section:
|
|
self._validate_directive(line)
|
|
|
|
def _is_section_header(self, line: str) -> bool:
|
|
"""Check if line is a section header"""
|
|
return bool(re.match(r'^(global|defaults|frontend|backend|listen)\s', line))
|
|
|
|
def _validate_section_header(self, line: str):
|
|
"""Validate section header syntax"""
|
|
parts = line.split()
|
|
section_type = parts[0]
|
|
|
|
if section_type in ['frontend', 'backend', 'listen']:
|
|
if len(parts) < 2:
|
|
self._add_result(
|
|
ValidationLevel.ERROR,
|
|
f"Section '{section_type}' requires a name",
|
|
suggestion=f"{section_type} my_{section_type}_name"
|
|
)
|
|
return
|
|
|
|
section_name = parts[1]
|
|
|
|
# Check name format
|
|
if not re.match(r'^[a-zA-Z0-9_.-]+$', section_name):
|
|
self._add_result(
|
|
ValidationLevel.WARNING,
|
|
f"Section name '{section_name}' contains invalid characters",
|
|
suggestion="Use only letters, numbers, dot, underscore and hyphen"
|
|
)
|
|
|
|
# Set current section for validation
|
|
self.current_section = section_type
|
|
if section_type not in self.sections:
|
|
self.sections[section_type] = []
|
|
|
|
if len(parts) > 1:
|
|
self.sections[section_type].append(parts[1])
|
|
|
|
def _validate_directive(self, line: str):
|
|
"""Validate directive within current section"""
|
|
if not self.current_section:
|
|
self._add_result(
|
|
ValidationLevel.ERROR,
|
|
"Directive found outside of any section",
|
|
suggestion="Add directive inside a section (global, defaults, frontend, backend, or listen)"
|
|
)
|
|
return
|
|
|
|
parts = line.split()
|
|
if not parts:
|
|
return
|
|
|
|
directive = parts[0]
|
|
|
|
# Check if directive is valid for current section
|
|
valid_directives = self.valid_directives.get(self.current_section, set())
|
|
|
|
if directive not in valid_directives:
|
|
self._add_result(
|
|
ValidationLevel.WARNING,
|
|
f"Directive '{directive}' may not be valid in '{self.current_section}' section",
|
|
directive=directive
|
|
)
|
|
|
|
# Validate specific directives
|
|
self._validate_specific_directive(directive, parts[1:])
|
|
|
|
def _validate_specific_directive(self, directive: str, args: List[str]):
|
|
"""Validate specific directive syntax and values"""
|
|
|
|
if directive == 'bind':
|
|
self._validate_bind_directive(args)
|
|
elif directive == 'server':
|
|
self._validate_server_directive(args)
|
|
elif directive == 'timeout':
|
|
self._validate_timeout_directive(args)
|
|
elif directive == 'balance':
|
|
self._validate_balance_directive(args)
|
|
elif directive == 'mode':
|
|
self._validate_mode_directive(args)
|
|
elif directive == 'option':
|
|
self._validate_option_directive(args)
|
|
elif directive == 'maxconn':
|
|
self._validate_maxconn_directive(args)
|
|
|
|
def _validate_bind_directive(self, args: List[str]):
|
|
"""Validate bind directive"""
|
|
if not args:
|
|
self._add_result(
|
|
ValidationLevel.ERROR,
|
|
"bind directive requires address:port",
|
|
directive="bind",
|
|
suggestion="bind *:80 or bind 192.168.1.1:443"
|
|
)
|
|
return
|
|
|
|
bind_addr = args[0]
|
|
|
|
# Check format
|
|
if ':' not in bind_addr:
|
|
self._add_result(
|
|
ValidationLevel.WARNING,
|
|
"bind address should include port",
|
|
directive="bind",
|
|
suggestion=f"{bind_addr}:80"
|
|
)
|
|
|
|
# Check for SSL options
|
|
if 'ssl' in args:
|
|
if 'crt' not in ' '.join(args):
|
|
self._add_result(
|
|
ValidationLevel.WARNING,
|
|
"SSL binding without certificate specified",
|
|
directive="bind",
|
|
suggestion="Add 'crt /path/to/certificate.pem'"
|
|
)
|
|
|
|
def _validate_server_directive(self, args: List[str]):
|
|
"""Validate server directive"""
|
|
if len(args) < 2:
|
|
self._add_result(
|
|
ValidationLevel.ERROR,
|
|
"server directive requires name and address",
|
|
directive="server",
|
|
suggestion="server web1 192.168.1.10:80 check"
|
|
)
|
|
return
|
|
|
|
server_name = args[0]
|
|
server_addr = args[1]
|
|
|
|
# Check server name format
|
|
if not re.match(r'^[a-zA-Z0-9_.-]+$', server_name):
|
|
self._add_result(
|
|
ValidationLevel.WARNING,
|
|
f"Server name '{server_name}' contains invalid characters",
|
|
directive="server",
|
|
suggestion="Use only letters, numbers, dot, underscore and hyphen"
|
|
)
|
|
|
|
# Check address format
|
|
if ':' not in server_addr:
|
|
self._add_result(
|
|
ValidationLevel.WARNING,
|
|
"Server address should include port",
|
|
directive="server",
|
|
suggestion=f"{server_addr}:80"
|
|
)
|
|
|
|
# Recommend health checks
|
|
if 'check' not in args:
|
|
self._add_result(
|
|
ValidationLevel.SUGGESTION,
|
|
"Consider adding health check to server",
|
|
directive="server",
|
|
suggestion=f"server {server_name} {server_addr} check"
|
|
)
|
|
|
|
def _validate_timeout_directive(self, args: List[str]):
|
|
"""Validate timeout directive"""
|
|
if len(args) < 2:
|
|
self._add_result(
|
|
ValidationLevel.ERROR,
|
|
"timeout directive requires type and value",
|
|
directive="timeout",
|
|
suggestion="timeout connect 5s"
|
|
)
|
|
return
|
|
|
|
timeout_type = args[0]
|
|
timeout_value = args[1]
|
|
|
|
# Check timeout type
|
|
valid_timeout_types = {
|
|
'client', 'server', 'connect', 'queue', 'tunnel', 'http-request',
|
|
'http-keep-alive', 'check', 'tarpit'
|
|
}
|
|
|
|
if timeout_type not in valid_timeout_types:
|
|
self._add_result(
|
|
ValidationLevel.WARNING,
|
|
f"Unknown timeout type '{timeout_type}'",
|
|
directive="timeout"
|
|
)
|
|
|
|
# Validate timeout value format
|
|
#
|
|
# HAProxy accepts the unit suffixes: `us` (microseconds), `ms`
|
|
# (milliseconds), `s` (seconds), `m` (minutes), `h` (hours),
|
|
# `d` (days). A bare integer (no suffix) is also valid and is
|
|
# interpreted as milliseconds (HAProxy docs: "Time values").
|
|
#
|
|
# Phase K Phase D follow-up (Bulgu #10) — the pre-fix regex
|
|
# was `^\d+[smhd]?$`, which rejected the perfectly valid
|
|
# multi-character `us` and `ms` suffixes. Site Wizard's
|
|
# config synthesis emits `timeout connect 10000ms` /
|
|
# `timeout server 60000ms` / `timeout client 100ms` so the
|
|
# dry-run preview surfaced 10+ FALSE-POSITIVE errors on
|
|
# the wizard's own defaults, blocking Create even though
|
|
# the real HAProxy `-c` parse accepts the config.
|
|
if not re.match(r'^\d+(us|ms|s|m|h|d)?$', timeout_value):
|
|
self._add_result(
|
|
ValidationLevel.ERROR,
|
|
f"Invalid timeout value '{timeout_value}'",
|
|
directive="timeout",
|
|
suggestion="Use format like '5s', '30000ms', '1m'"
|
|
)
|
|
|
|
def _validate_balance_directive(self, args: List[str]):
|
|
"""Validate balance directive"""
|
|
if not args:
|
|
self._add_result(
|
|
ValidationLevel.ERROR,
|
|
"balance directive requires algorithm",
|
|
directive="balance",
|
|
suggestion="balance roundrobin"
|
|
)
|
|
return
|
|
|
|
algorithm = args[0]
|
|
valid_algorithms = {
|
|
'roundrobin', 'static-rr', 'leastconn', 'first', 'source', 'uri',
|
|
'url_param', 'hdr', 'rdp-cookie'
|
|
}
|
|
|
|
if algorithm not in valid_algorithms:
|
|
self._add_result(
|
|
ValidationLevel.WARNING,
|
|
f"Unknown balance algorithm '{algorithm}'",
|
|
directive="balance",
|
|
suggestion="Use roundrobin, leastconn, or source"
|
|
)
|
|
|
|
def _validate_mode_directive(self, args: List[str]):
|
|
"""Validate mode directive"""
|
|
if not args:
|
|
self._add_result(
|
|
ValidationLevel.ERROR,
|
|
"mode directive requires value",
|
|
directive="mode",
|
|
suggestion="mode http"
|
|
)
|
|
return
|
|
|
|
mode = args[0]
|
|
valid_modes = {'http', 'tcp', 'health'}
|
|
|
|
if mode not in valid_modes:
|
|
self._add_result(
|
|
ValidationLevel.ERROR,
|
|
f"Invalid mode '{mode}'",
|
|
directive="mode",
|
|
suggestion="Use 'http' or 'tcp'"
|
|
)
|
|
|
|
def _validate_option_directive(self, args: List[str]):
|
|
"""Validate option directive"""
|
|
if not args:
|
|
self._add_result(
|
|
ValidationLevel.ERROR,
|
|
"option directive requires value",
|
|
directive="option"
|
|
)
|
|
return
|
|
|
|
option = args[0]
|
|
common_options = {
|
|
'httplog', 'tcplog', 'dontlognull', 'log-health-checks',
|
|
'httpchk', 'ssl-hello-chk', 'tcp-check', 'forwardfor',
|
|
'httpclose', 'http-keep-alive', 'abortonclose'
|
|
}
|
|
|
|
if option not in common_options:
|
|
self._add_result(
|
|
ValidationLevel.INFO,
|
|
f"Option '{option}' not in common options list",
|
|
directive="option"
|
|
)
|
|
|
|
def _validate_maxconn_directive(self, args: List[str]):
|
|
"""Validate maxconn directive"""
|
|
if not args:
|
|
self._add_result(
|
|
ValidationLevel.ERROR,
|
|
"maxconn directive requires value",
|
|
directive="maxconn",
|
|
suggestion="maxconn 2000"
|
|
)
|
|
return
|
|
|
|
try:
|
|
value = int(args[0])
|
|
if value <= 0:
|
|
self._add_result(
|
|
ValidationLevel.ERROR,
|
|
"maxconn value must be positive",
|
|
directive="maxconn"
|
|
)
|
|
elif value > 65535:
|
|
self._add_result(
|
|
ValidationLevel.WARNING,
|
|
"maxconn value seems very high, check system limits",
|
|
directive="maxconn"
|
|
)
|
|
except ValueError:
|
|
self._add_result(
|
|
ValidationLevel.ERROR,
|
|
f"maxconn value must be a number",
|
|
directive="maxconn"
|
|
)
|
|
|
|
def _validate_sections(self):
|
|
"""Validate section-level requirements"""
|
|
|
|
# Phase K Phase D (Bulgu #12): skip the "Missing 'global' /
|
|
# 'defaults'" diagnostics on partial-fragment inputs. The
|
|
# wizard / cluster-synthesis emit fragments where the agent
|
|
# MERGES the local global+defaults blocks at apply time —
|
|
# the heuristic is being shown only the entity blocks, so
|
|
# complaining about missing global is misleading.
|
|
if not self._is_partial_fragment:
|
|
if 'global' not in self.sections:
|
|
self._add_result(
|
|
ValidationLevel.WARNING,
|
|
"Missing 'global' section - recommended for production",
|
|
suggestion="Add global section with basic settings"
|
|
)
|
|
|
|
if 'defaults' not in self.sections:
|
|
self._add_result(
|
|
ValidationLevel.SUGGESTION,
|
|
"Consider adding 'defaults' section for common settings",
|
|
suggestion="Add defaults section to reduce configuration duplication"
|
|
)
|
|
|
|
# Check balance of frontends and backends (still useful for
|
|
# both complete configs AND partial fragments — a fragment
|
|
# that emits a frontend without its referenced backend is
|
|
# a real authoring bug).
|
|
frontend_count = len(self.sections.get('frontend', []))
|
|
backend_count = len(self.sections.get('backend', []))
|
|
|
|
if frontend_count > 0 and backend_count == 0:
|
|
self._add_result(
|
|
ValidationLevel.WARNING,
|
|
"Frontends defined without backends",
|
|
suggestion="Add backend sections for your frontends"
|
|
)
|
|
|
|
def _calculate_syntax_score(self) -> float:
|
|
"""Calculate syntax quality score (0-100)"""
|
|
total_issues = len(self.results)
|
|
error_weight = 10
|
|
warning_weight = 5
|
|
suggestion_weight = 1
|
|
|
|
penalty = sum(
|
|
error_weight if r.level == ValidationLevel.ERROR else
|
|
warning_weight if r.level == ValidationLevel.WARNING else
|
|
suggestion_weight if r.level == ValidationLevel.SUGGESTION else 1
|
|
for r in self.results
|
|
)
|
|
|
|
# Base score calculation
|
|
if total_issues == 0:
|
|
return 100.0
|
|
|
|
# Penalize based on severity
|
|
score = max(0, 100 - penalty)
|
|
return round(score, 1)
|
|
|
|
def _calculate_performance_score(self) -> float:
|
|
"""Calculate performance optimization score (0-100)"""
|
|
score = 75.0 # Base score
|
|
|
|
# Check for performance-related configurations
|
|
performance_indicators = {
|
|
'has_timeouts': False,
|
|
'has_compression': False,
|
|
'has_health_checks': False,
|
|
'has_keepalive': False,
|
|
'proper_logging': False
|
|
}
|
|
|
|
config_text = ' '.join(r.message for r in self.results)
|
|
|
|
if 'timeout' in config_text.lower():
|
|
performance_indicators['has_timeouts'] = True
|
|
score += 5
|
|
|
|
if 'compression' in config_text.lower():
|
|
performance_indicators['has_compression'] = True
|
|
score += 5
|
|
|
|
if 'check' in config_text.lower():
|
|
performance_indicators['has_health_checks'] = True
|
|
score += 10
|
|
|
|
if 'keep-alive' in config_text.lower():
|
|
performance_indicators['has_keepalive'] = True
|
|
score += 5
|
|
|
|
return min(100.0, round(score, 1))
|
|
|
|
def _calculate_security_score(self) -> float:
|
|
"""Calculate security configuration score (0-100)"""
|
|
score = 70.0 # Base score
|
|
|
|
# Security indicators
|
|
security_checks = {
|
|
'ssl_configured': False,
|
|
'secure_headers': False,
|
|
'logging_enabled': False,
|
|
'user_group_set': False
|
|
}
|
|
|
|
config_text = ' '.join(r.message for r in self.results)
|
|
|
|
if 'ssl' in config_text.lower() or 'https' in config_text.lower():
|
|
security_checks['ssl_configured'] = True
|
|
score += 10
|
|
|
|
if 'http-response set-header' in config_text.lower():
|
|
security_checks['secure_headers'] = True
|
|
score += 5
|
|
|
|
if 'log' in config_text.lower():
|
|
security_checks['logging_enabled'] = True
|
|
score += 10
|
|
|
|
if 'user' in config_text.lower() and 'group' in config_text.lower():
|
|
security_checks['user_group_set'] = True
|
|
score += 5
|
|
|
|
return min(100.0, round(score, 1))
|
|
|
|
def _add_result(self, level: ValidationLevel, message: str, directive: str = None, suggestion: str = None):
|
|
"""Add validation result"""
|
|
result = ValidationResult(
|
|
level=level,
|
|
message=message,
|
|
line_number=self.line_number,
|
|
section=self.current_section,
|
|
directive=directive,
|
|
suggestion=suggestion
|
|
)
|
|
self.results.append(result)
|
|
|
|
def validate_haproxy_config(
|
|
config_content: str,
|
|
partial_fragment: bool = False,
|
|
) -> ConfigValidationReport:
|
|
"""Main function to validate HAProxy configuration.
|
|
|
|
Phase K Phase D follow-up (Bulgu #12) — `partial_fragment` is
|
|
forwarded to the validator instance so callers that synthesise
|
|
a partial config (no global/defaults sections — agent merges
|
|
them locally) can silence the "Missing 'global' section"
|
|
false-positive WARNING. Defaults to False for backwards
|
|
compatibility with the manual config-import path that DOES
|
|
validate a complete on-disk config.
|
|
"""
|
|
validator = HAProxyConfigValidator()
|
|
return validator.validate_config(config_content, partial_fragment=partial_fragment)
|
|
|
|
def get_validation_summary(report: ConfigValidationReport) -> Dict[str, Any]:
|
|
"""Get validation summary for API response"""
|
|
return {
|
|
"is_valid": report.is_valid,
|
|
"overall_score": round((report.syntax_score + report.performance_score + report.security_score) / 3, 1),
|
|
"scores": {
|
|
"syntax": report.syntax_score,
|
|
"performance": report.performance_score,
|
|
"security": report.security_score
|
|
},
|
|
"issue_counts": {
|
|
"errors": report.error_count,
|
|
"warnings": report.warning_count,
|
|
"suggestions": report.suggestion_count,
|
|
"total": len(report.results)
|
|
},
|
|
"issues": [
|
|
{
|
|
"level": result.level.value,
|
|
"message": result.message,
|
|
"line": result.line_number,
|
|
"section": result.section,
|
|
"directive": result.directive,
|
|
"suggestion": result.suggestion
|
|
}
|
|
for result in report.results
|
|
]
|
|
} |