mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-09-16 15:45:11 +00:00
9e2ea04777
Bulk import / manual edit silently dropped `filter spoe engine ...` (Coraza WAF) and frontend `log-format` because the parser recognised only a fixed directive set. The regenerated config then missed the SPOE engine, so HAProxy failed with "unable to find SPOE engine 'coraza' used by the send-spoe-group". - parser: capture `filter` + `log-format`/`log-format-sd` into new ParsedFrontend fields - db: additive nullable `log_format` + `filters` TEXT columns on frontends (SCHEMA_VERSION 8->9) - generator: new `filter` bucket flushed before http-request rules so `filter` precedes `send-spoe-group`; `log-format` kept in prelude - bulk import: preview dict, change-detection, persist (create + merge-update); cluster-aware SPOE pre-flight advisories (missing-filter + host-prerequisite) surfaced in the UI - manual CRUD: full round-trip (get/create/update) incl. React form fields (no null-wipe) - reject/rollback: restore the new columns; restore path + wizard helper kept in parity - backend `option spop-check` recognised (suppresses spurious warning for coraza-spoa) - tests: test_spoe_filter_import.py; full suite green (1079 passed)
1207 lines
62 KiB
Python
1207 lines
62 KiB
Python
"""
|
|
HAProxy Configuration Parser
|
|
Parses HAProxy config files and extracts frontend, backend, and server definitions
|
|
"""
|
|
|
|
import re
|
|
import logging
|
|
import shlex
|
|
from typing import Dict, List, Optional, Tuple
|
|
from dataclasses import dataclass, asdict
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class ParsedServer:
|
|
"""Parsed server definition"""
|
|
server_name: str
|
|
server_address: str
|
|
server_port: int
|
|
weight: int = 100
|
|
max_connections: Optional[int] = None
|
|
check_enabled: bool = True
|
|
check_port: Optional[int] = None # Health check port (different from server port)
|
|
backup_server: bool = False
|
|
ssl_enabled: bool = False
|
|
ssl_verify: Optional[str] = None # SSL verification (none, required)
|
|
|
|
# SSL Advanced Options (server SSL parameters)
|
|
ssl_sni: Optional[str] = None # SNI hostname for backend SSL connections
|
|
ssl_min_ver: Optional[str] = None # Minimum TLS version
|
|
ssl_max_ver: Optional[str] = None # Maximum TLS version
|
|
ssl_ciphers: Optional[str] = None # Cipher suite list
|
|
|
|
cookie_value: Optional[str] = None # Cookie value for session persistence
|
|
inter: Optional[int] = None # Health check interval
|
|
fall: Optional[int] = None # Number of failed checks before marking down
|
|
rise: Optional[int] = None # Number of successful checks before marking up
|
|
|
|
|
|
@dataclass
|
|
class ParsedBackend:
|
|
"""Parsed backend definition"""
|
|
name: str
|
|
mode: str = "http"
|
|
balance_method: str = "roundrobin"
|
|
health_check_uri: Optional[str] = None
|
|
health_check_interval: Optional[int] = 2000
|
|
health_check_expected_status: Optional[int] = None # Expected HTTP status code for health checks (only for HTTP mode)
|
|
fullconn: Optional[int] = None # Maximum concurrent connections
|
|
cookie_name: Optional[str] = None # Cookie name for session persistence
|
|
cookie_options: Optional[str] = None # Cookie options (insert, indirect, nocache, etc.)
|
|
default_server_inter: Optional[int] = None # Default health check interval for all servers
|
|
default_server_fall: Optional[int] = None # Default fall value for all servers
|
|
default_server_rise: Optional[int] = None # Default rise value for all servers
|
|
request_headers: Optional[str] = None # HTTP request headers (backend level)
|
|
response_headers: Optional[str] = None # HTTP response headers (backend level)
|
|
options: Optional[str] = None # HAProxy backend options (option http-keep-alive, option forwardfor, etc.)
|
|
timeout_connect: Optional[int] = None
|
|
timeout_server: Optional[int] = None
|
|
timeout_queue: Optional[int] = None
|
|
servers: List[ParsedServer] = None
|
|
|
|
def __post_init__(self):
|
|
if self.servers is None:
|
|
self.servers = []
|
|
|
|
|
|
@dataclass
|
|
class ParsedFrontend:
|
|
"""Parsed frontend definition"""
|
|
name: str
|
|
bind_address: str = "*"
|
|
bind_port: int = 80
|
|
default_backend: Optional[str] = None
|
|
mode: str = "http"
|
|
ssl_enabled: bool = False
|
|
ssl_port: Optional[int] = None
|
|
ssl_certificate_id: Optional[int] = None
|
|
ssl_cert_path: Optional[str] = None # Legacy: First SSL cert path (for backward compat)
|
|
ssl_cert_paths: Optional[List[str]] = None # CRITICAL: All SSL cert paths (for multiple certs)
|
|
ssl_cert: Optional[str] = None
|
|
ssl_verify: Optional[str] = None
|
|
|
|
# SSL Advanced Options (bind SSL parameters)
|
|
ssl_alpn: Optional[str] = None # Application-Layer Protocol Negotiation (e.g., "h2,http/1.1")
|
|
ssl_npn: Optional[str] = None # Next Protocol Negotiation (legacy)
|
|
ssl_ciphers: Optional[str] = None # Cipher suite list
|
|
ssl_ciphersuites: Optional[str] = None # TLS 1.3 cipher suites
|
|
ssl_min_ver: Optional[str] = None # Minimum TLS version
|
|
ssl_max_ver: Optional[str] = None # Maximum TLS version
|
|
ssl_strict_sni: Optional[bool] = False # Strict SNI requirement
|
|
|
|
timeout_client: Optional[int] = None
|
|
timeout_http_request: Optional[int] = None
|
|
maxconn: Optional[int] = None
|
|
rate_limit: Optional[int] = None
|
|
compression: bool = False
|
|
log_separate: bool = False
|
|
monitor_uri: Optional[str] = None
|
|
acl_rules: Optional[list] = None
|
|
use_backend_rules: Optional[list] = None # Changed from str to list for consistency
|
|
redirect_rules: Optional[list] = None
|
|
request_headers: Optional[str] = None
|
|
response_headers: Optional[str] = None
|
|
options: Optional[str] = None # HAProxy frontend options (option httplog, option forwardfor, etc.)
|
|
tcp_request_rules: Optional[str] = None # TCP request directives (for TCP mode)
|
|
# Issue #38: SPOE (and other) filter directives + frontend log-format.
|
|
# Stored as full directive lines; `filters` is newline-joined to preserve
|
|
# ordering when multiple `filter ...` lines exist.
|
|
log_format: Optional[str] = None # `log-format` / `log-format-sd` line(s)
|
|
filters: Optional[str] = None # `filter ...` line(s), e.g. `filter spoe engine coraza config ...`
|
|
|
|
|
|
@dataclass
|
|
class ParseResult:
|
|
"""Result of parsing HAProxy config"""
|
|
frontends: List[ParsedFrontend]
|
|
backends: List[ParsedBackend]
|
|
errors: List[str]
|
|
warnings: List[str]
|
|
|
|
|
|
class HAProxyConfigParser:
|
|
"""Parser for HAProxy configuration files"""
|
|
|
|
def __init__(self):
|
|
self.frontends = []
|
|
self.backends = []
|
|
self.errors = []
|
|
self.warnings = []
|
|
|
|
def parse(self, config_content: str) -> ParseResult:
|
|
"""
|
|
Parse HAProxy configuration content
|
|
|
|
Args:
|
|
config_content: HAProxy configuration file content
|
|
|
|
Returns:
|
|
ParseResult with parsed frontends, backends, and any errors/warnings
|
|
"""
|
|
self.frontends = []
|
|
self.backends = []
|
|
self.errors = []
|
|
self.warnings = []
|
|
|
|
try:
|
|
lines = config_content.strip().split('\n')
|
|
self._parse_lines(lines)
|
|
|
|
# Post-parse validation
|
|
self._validate_parsed_config()
|
|
|
|
except Exception as e:
|
|
self.errors.append(f"Failed to parse config: {str(e)}")
|
|
logger.error(f"Config parsing error: {e}")
|
|
|
|
return ParseResult(
|
|
frontends=self.frontends,
|
|
backends=self.backends,
|
|
errors=self.errors,
|
|
warnings=self.warnings
|
|
)
|
|
|
|
def _parse_lines(self, lines: List[str]):
|
|
"""Parse config lines and extract sections"""
|
|
current_section = None
|
|
current_section_type = None
|
|
current_section_lines = []
|
|
|
|
for line_num, line in enumerate(lines, 1):
|
|
# Remove comments and strip whitespace
|
|
line = re.sub(r'#.*$', '', line).strip()
|
|
|
|
if not line:
|
|
continue
|
|
|
|
# Check for section headers
|
|
frontend_match = re.match(r'^frontend\s+(\S+)', line, re.IGNORECASE)
|
|
backend_match = re.match(r'^backend\s+(\S+)', line, re.IGNORECASE)
|
|
|
|
if frontend_match:
|
|
# Process previous section if exists
|
|
if current_section and current_section_type == 'frontend':
|
|
self._parse_frontend_section(current_section, current_section_lines)
|
|
elif current_section and current_section_type == 'backend':
|
|
self._parse_backend_section(current_section, current_section_lines)
|
|
|
|
# Start new frontend section
|
|
current_section = frontend_match.group(1)
|
|
current_section_type = 'frontend'
|
|
current_section_lines = []
|
|
logger.debug(f"Found frontend section: {current_section}")
|
|
|
|
elif backend_match:
|
|
# Process previous section if exists
|
|
if current_section and current_section_type == 'frontend':
|
|
self._parse_frontend_section(current_section, current_section_lines)
|
|
elif current_section and current_section_type == 'backend':
|
|
self._parse_backend_section(current_section, current_section_lines)
|
|
|
|
# Start new backend section
|
|
current_section = backend_match.group(1)
|
|
current_section_type = 'backend'
|
|
current_section_lines = []
|
|
logger.debug(f"Found backend section: {current_section}")
|
|
|
|
elif re.match(r'^(global|defaults|listen)\s*(\S*)', line, re.IGNORECASE):
|
|
# Process previous section if exists
|
|
if current_section and current_section_type == 'frontend':
|
|
self._parse_frontend_section(current_section, current_section_lines)
|
|
elif current_section and current_section_type == 'backend':
|
|
self._parse_backend_section(current_section, current_section_lines)
|
|
|
|
# Detect unsupported section type and add warning
|
|
section_match = re.match(r'^(global|defaults|listen)\s*(\S*)', line, re.IGNORECASE)
|
|
section_type = section_match.group(1).lower()
|
|
section_name = section_match.group(2) or ''
|
|
|
|
if section_type == 'global':
|
|
self.warnings.append(
|
|
"Global section detected and skipped. Global directives are managed at cluster level."
|
|
)
|
|
elif section_type == 'defaults':
|
|
self.warnings.append(
|
|
"Defaults section detected and skipped. Default values are managed at cluster level."
|
|
)
|
|
elif section_type == 'listen':
|
|
listen_name_info = f" '{section_name}'" if section_name else ""
|
|
self.warnings.append(
|
|
f"Listen section{listen_name_info} detected and skipped. "
|
|
"Please convert 'listen' sections to separate 'frontend' and 'backend' sections for bulk import."
|
|
)
|
|
|
|
# Skip unsupported sections
|
|
current_section = None
|
|
current_section_type = None
|
|
current_section_lines = []
|
|
|
|
elif current_section:
|
|
# Add line to current section
|
|
current_section_lines.append(line)
|
|
|
|
# Process last section
|
|
if current_section and current_section_type == 'frontend':
|
|
self._parse_frontend_section(current_section, current_section_lines)
|
|
elif current_section and current_section_type == 'backend':
|
|
self._parse_backend_section(current_section, current_section_lines)
|
|
|
|
def _parse_frontend_section(self, name: str, lines: List[str]):
|
|
"""Parse frontend section"""
|
|
try:
|
|
frontend = ParsedFrontend(name=name)
|
|
ssl_cert_found = False
|
|
|
|
# Collect HTTP headers, ACL rules, use_backend rules, TCP rules, and options
|
|
request_headers_list = []
|
|
response_headers_list = []
|
|
options_list = []
|
|
acl_rules_list = []
|
|
use_backend_rules_list = []
|
|
tcp_request_rules_list = []
|
|
filters_list = []
|
|
log_format_list = []
|
|
|
|
for line in lines:
|
|
# Issue #38: capture `filter ...` (SPOE/Coraza etc.) and
|
|
# `log-format`/`log-format-sd` directives. Pre-fix these matched
|
|
# no branch below and were silently dropped, so an imported SPOE
|
|
# config lost `filter spoe engine coraza ...` (→ HAProxy fatal
|
|
# "unable to find SPOE engine") and the frontend log-format.
|
|
# `continue` isolates them from the header/option handling below.
|
|
if line.startswith('filter '):
|
|
filters_list.append(line.strip())
|
|
continue
|
|
if re.match(r'^log-format(-sd)?\s', line, re.IGNORECASE):
|
|
log_format_list.append(line.strip())
|
|
continue
|
|
|
|
# Parse bind directive
|
|
# IMPORTANT: Handle multiple bind lines correctly
|
|
# Example: bind *:1002 (HTTP) and bind *:443 ssl (HTTPS)
|
|
# Also support bind :80 (no address, defaults to all interfaces)
|
|
bind_match = re.match(r'^bind\s+([^:\s]*):(\d+)', line, re.IGNORECASE)
|
|
if bind_match:
|
|
port = int(bind_match.group(2))
|
|
address = bind_match.group(1).strip() if bind_match.group(1) else '*'
|
|
|
|
# Check for SSL in this bind line
|
|
if 'ssl' in line.lower():
|
|
# This is an SSL bind - store SSL port separately
|
|
frontend.ssl_enabled = True
|
|
frontend.ssl_port = port
|
|
|
|
# CRITICAL FIX: If this is the ONLY bind (no HTTP bind exists),
|
|
# set bind_port too so SSL-disabled import works correctly
|
|
if frontend.bind_port == 80: # Still default, no non-SSL bind found yet
|
|
frontend.bind_address = address if address != '*' else '*'
|
|
frontend.bind_port = port # Preserve port for SSL-disabled scenarios
|
|
|
|
# Check if SSL certificate path is specified in bind directive
|
|
if 'crt ' in line:
|
|
ssl_cert_found = True
|
|
# Extract certificate paths and SSL parameters
|
|
# CRITICAL FIX: Parse multiple crt paths even with alpn/other params after them
|
|
# Use shlex.split() to handle quoted values correctly
|
|
cert_paths = []
|
|
ssl_params_detected = []
|
|
try:
|
|
parts = shlex.split(line)
|
|
except ValueError:
|
|
# Fallback to simple split if shlex fails (malformed quotes)
|
|
parts = line.split()
|
|
|
|
i = 0
|
|
while i < len(parts):
|
|
part = parts[i]
|
|
|
|
# Extract SSL certificate paths
|
|
if part == 'crt' and i + 1 < len(parts):
|
|
cert_path = parts[i + 1]
|
|
# Stop if we hit SSL parameters
|
|
if not cert_path.startswith(('alpn', 'npn', 'ca-file', 'ca-ignore-err', 'ca-sign-file',
|
|
'ciphers', 'ciphersuites', 'crl-file', 'curves',
|
|
'ecdhe', 'force-sslv3', 'force-tlsv10', 'force-tlsv11',
|
|
'force-tlsv12', 'force-tlsv13', 'generate-certificates',
|
|
'no-sslv3', 'no-tls-tickets', 'no-tlsv10', 'no-tlsv11',
|
|
'no-tlsv12', 'no-tlsv13', 'prefer-client-ciphers',
|
|
'ssl-max-ver', 'ssl-min-ver', 'strict-sni', 'tls-ticket-keys',
|
|
'verify', 'accept-proxy', 'v4v6', 'v6only')):
|
|
cert_paths.append(cert_path)
|
|
|
|
# Parse SSL parameters and store them in frontend object
|
|
if part == 'alpn' and i + 1 < len(parts):
|
|
frontend.ssl_alpn = parts[i + 1]
|
|
ssl_params_detected.append(f"alpn {parts[i + 1]}")
|
|
i += 1 # Skip the value
|
|
elif part == 'npn' and i + 1 < len(parts):
|
|
frontend.ssl_npn = parts[i + 1]
|
|
ssl_params_detected.append(f"npn {parts[i + 1]}")
|
|
i += 1
|
|
elif part == 'ciphers' and i + 1 < len(parts):
|
|
frontend.ssl_ciphers = parts[i + 1]
|
|
ssl_params_detected.append(f"ciphers {parts[i + 1]}")
|
|
i += 1
|
|
elif part == 'ciphersuites' and i + 1 < len(parts):
|
|
frontend.ssl_ciphersuites = parts[i + 1]
|
|
ssl_params_detected.append(f"ciphersuites {parts[i + 1]}")
|
|
i += 1
|
|
elif part == 'ssl-min-ver' and i + 1 < len(parts):
|
|
frontend.ssl_min_ver = parts[i + 1]
|
|
ssl_params_detected.append(f"ssl-min-ver {parts[i + 1]}")
|
|
i += 1
|
|
elif part == 'ssl-max-ver' and i + 1 < len(parts):
|
|
frontend.ssl_max_ver = parts[i + 1]
|
|
ssl_params_detected.append(f"ssl-max-ver {parts[i + 1]}")
|
|
i += 1
|
|
elif part == 'strict-sni':
|
|
frontend.ssl_strict_sni = True
|
|
ssl_params_detected.append("strict-sni")
|
|
|
|
i += 1
|
|
|
|
if cert_paths:
|
|
# CRITICAL: Store ALL cert paths for multi-SSL auto-matching
|
|
frontend.ssl_cert_paths = cert_paths
|
|
# Legacy: Store first cert path for backward compatibility
|
|
frontend.ssl_cert_path = cert_paths[0]
|
|
|
|
self.warnings.append(
|
|
f"Frontend '{name}': {len(cert_paths)} SSL certificate(s) detected in config ({', '.join(cert_paths)}). "
|
|
"SSL certificates should be managed through the SSL Management page, not in the config file."
|
|
)
|
|
|
|
# Info message about SSL parameters that WERE imported
|
|
if ssl_params_detected:
|
|
self.warnings.append(
|
|
f"Frontend '{name}': SSL parameters imported: {', '.join(ssl_params_detected)}. "
|
|
"You can edit these in Frontend Management after import."
|
|
)
|
|
else:
|
|
# This is a regular HTTP bind - only set if not already set
|
|
# (to preserve the first non-SSL bind as primary)
|
|
if frontend.bind_port == 80: # Default value, not set yet
|
|
frontend.bind_address = address if address != '*' else '*'
|
|
frontend.bind_port = port
|
|
|
|
# Parse default_backend
|
|
backend_match = re.match(r'^default_backend\s+(\S+)', line, re.IGNORECASE)
|
|
if backend_match:
|
|
frontend.default_backend = backend_match.group(1)
|
|
|
|
# Parse use_backend (also used for default backend in simple configs)
|
|
use_backend_match = re.match(r'^use_backend\s+(\S+)', line, re.IGNORECASE)
|
|
if use_backend_match and not frontend.default_backend:
|
|
# If no default_backend is set yet, use first use_backend as default
|
|
frontend.default_backend = use_backend_match.group(1)
|
|
|
|
# Parse mode
|
|
mode_match = re.match(r'^mode\s+(\S+)', line, re.IGNORECASE)
|
|
if mode_match:
|
|
frontend.mode = mode_match.group(1).lower()
|
|
|
|
# Parse timeout client
|
|
timeout_client_match = re.match(r'^timeout\s+client\s+(\d+)(ms|s|m|h)?', line, re.IGNORECASE)
|
|
if timeout_client_match:
|
|
frontend.timeout_client = self._parse_timeout(
|
|
timeout_client_match.group(1),
|
|
timeout_client_match.group(2) or ''
|
|
)
|
|
|
|
# Parse maxconn
|
|
maxconn_match = re.match(r'^maxconn\s+(\d+)', line, re.IGNORECASE)
|
|
if maxconn_match:
|
|
frontend.maxconn = int(maxconn_match.group(1))
|
|
|
|
# Parse options (httplog, forwardfor, etc.) - NEW for frontend support
|
|
# Note: option httpchk is NOT applicable to frontends (health checks are for backends)
|
|
if line.startswith('option ') and 'httpchk' not in line:
|
|
# Validate option directive - check for known HAProxy options
|
|
option_match = re.match(r'^option\s+(\S+)', line, re.IGNORECASE)
|
|
if option_match:
|
|
option_name = option_match.group(1)
|
|
# List of valid HAProxy frontend options
|
|
valid_options = [
|
|
'httplog', 'tcplog', 'dontlognull', 'http-keep-alive', 'http-server-close',
|
|
'forwardfor', 'redispatch', 'http-use-proxy-header', 'tcp-check',
|
|
'contstats', 'http-pretend-keepalive', 'logasap', 'nolinger', 'persist',
|
|
'prefer-last-server', 'splice-auto', 'splice-request', 'splice-response',
|
|
'transparent', 'abortonclose', 'allbackups', 'checkcache', 'clitcpka',
|
|
'srvtcpka', 'http-no-delay', 'socket-stats', 'tcp-smart-accept',
|
|
'tcp-smart-connect', 'independant-streams', 'log-separate-errors',
|
|
'log-health-checks', 'accept-invalid-http-request', 'accept-invalid-http-response'
|
|
]
|
|
|
|
if option_name not in valid_options:
|
|
# Unknown/invalid option - add warning but still collect it
|
|
self.warnings.append(
|
|
f"Frontend '{name}': Unknown or invalid option '{option_name}' detected. "
|
|
"Please verify this directive is supported by your HAProxy version."
|
|
)
|
|
|
|
# Collect option for frontend.options field
|
|
options_list.append(line.strip())
|
|
continue # Skip options - don't add to request_headers
|
|
elif line.startswith('option ') and 'httpchk' in line:
|
|
# Warn user that httpchk is not applicable to frontends
|
|
self.warnings.append(
|
|
f"Frontend '{name}': 'option httpchk' is not applicable to frontends and will be skipped. "
|
|
"Health checks are configured in backend definitions."
|
|
)
|
|
continue
|
|
|
|
# Parse HTTP request headers
|
|
if line.startswith('http-request '):
|
|
# Check for unsupported capture directives
|
|
if 'capture.req.hdr' in line or 'capture.res.hdr' in line:
|
|
self.warnings.append(
|
|
f"Frontend '{name}': Capture directive detected and skipped: '{line.strip()}'. "
|
|
"Request/response capture directives require additional setup and are not supported in bulk import. "
|
|
"Please configure these manually after import."
|
|
)
|
|
continue # Skip this directive
|
|
|
|
# Check for use-service directives (ANY service)
|
|
# Services require specific HAProxy features, Lua scripts, or compile-time options
|
|
# Cannot be safely imported without knowing agent's HAProxy capabilities
|
|
if 'use-service' in line:
|
|
self.warnings.append(
|
|
f"Frontend '{name}': Service directive detected and skipped: '{line.strip()}'. "
|
|
"HAProxy services (prometheus-exporter, lua services, custom services) require "
|
|
"specific features, scripts, or compile-time options that may not be available. "
|
|
"Please configure service directives manually after import."
|
|
)
|
|
continue # Skip this directive
|
|
|
|
request_headers_list.append(line)
|
|
|
|
# Parse HTTP response headers
|
|
if line.startswith('http-response '):
|
|
# Check for unsupported capture references using regex for flexible matching
|
|
# Matches: %[capture.req.hdr(0)], %[capture.res.hdr(1)], capture.req.hdr, etc.
|
|
capture_pattern = r'(%\[)?capture\.(req|res)\.hdr(\(\d+\))?(\])?'
|
|
if re.search(capture_pattern, line):
|
|
self.warnings.append(
|
|
f"Frontend '{name}': Response header using capture reference detected and skipped: '{line.strip()}'. "
|
|
"This requires 'http-request capture' directive which is not supported in bulk import. "
|
|
"Please configure these manually after import."
|
|
)
|
|
continue # Skip this directive
|
|
response_headers_list.append(line)
|
|
|
|
# Parse ACL rules
|
|
if line.startswith('acl '):
|
|
acl_rules_list.append(line)
|
|
|
|
# Parse use_backend rules
|
|
if line.startswith('use_backend '):
|
|
use_backend_rules_list.append(line)
|
|
|
|
# Parse options (httplog, http-keep-alive, etc.)
|
|
# CRITICAL FIX: Do NOT add option directives to request_headers
|
|
# Options are separate HAProxy config directives, not HTTP headers
|
|
# They are skipped during bulk import as they need special handling
|
|
if line.startswith('option '):
|
|
# Validate option directive - check for known HAProxy options
|
|
option_match = re.match(r'^option\s+(\S+)', line, re.IGNORECASE)
|
|
if option_match:
|
|
option_name = option_match.group(1)
|
|
# List of valid HAProxy options (not exhaustive but covers common ones)
|
|
valid_options = [
|
|
'httplog', 'tcplog', 'dontlognull', 'http-keep-alive', 'http-server-close',
|
|
'forwardfor', 'redispatch', 'http-use-proxy-header', 'httpchk', 'tcp-check',
|
|
'contstats', 'http-pretend-keepalive', 'logasap', 'nolinger', 'persist',
|
|
'prefer-last-server', 'splice-auto', 'splice-request', 'splice-response',
|
|
'transparent', 'abortonclose', 'allbackups', 'checkcache', 'clitcpka',
|
|
'srvtcpka', 'http-no-delay', 'socket-stats', 'tcp-smart-accept',
|
|
'tcp-smart-connect', 'independant-streams', 'log-separate-errors',
|
|
'log-health-checks', 'accept-invalid-http-request', 'accept-invalid-http-response',
|
|
'idle-close-on-response'
|
|
]
|
|
|
|
if option_name not in valid_options:
|
|
# Unknown/invalid option - add warning and skip
|
|
self.warnings.append(
|
|
f"Frontend '{name}': Unknown or invalid option '{option_name}' detected and skipped. "
|
|
"Please verify this directive is supported by your HAProxy version."
|
|
)
|
|
else:
|
|
# Valid option but skip it - bulk import doesn't support custom options yet
|
|
self.warnings.append(
|
|
f"Frontend '{name}': HAProxy option '{option_name}' detected and skipped. "
|
|
f"Custom HAProxy options are not supported in bulk import. "
|
|
f"Configure options manually after import if needed."
|
|
)
|
|
continue # Always skip options - don't add to request_headers
|
|
|
|
# Parse TCP request directives
|
|
if line.startswith('tcp-request '):
|
|
tcp_request_rules_list.append(line)
|
|
|
|
# Combine collected headers, rules, and options
|
|
if request_headers_list:
|
|
frontend.request_headers = '\n'.join(request_headers_list)
|
|
|
|
if response_headers_list:
|
|
frontend.response_headers = '\n'.join(response_headers_list)
|
|
|
|
# NEW: Assign collected options to frontend
|
|
if options_list:
|
|
frontend.options = '\n'.join(options_list)
|
|
|
|
if acl_rules_list:
|
|
frontend.acl_rules = acl_rules_list
|
|
|
|
if use_backend_rules_list:
|
|
frontend.use_backend_rules = use_backend_rules_list
|
|
|
|
if tcp_request_rules_list:
|
|
frontend.tcp_request_rules = '\n'.join(tcp_request_rules_list)
|
|
|
|
# Issue #38: assign captured SPOE filters + log-format
|
|
if filters_list:
|
|
frontend.filters = '\n'.join(filters_list)
|
|
|
|
if log_format_list:
|
|
frontend.log_format = '\n'.join(log_format_list)
|
|
|
|
self.frontends.append(frontend)
|
|
logger.info(f"Parsed frontend: {name} -> {frontend.default_backend}")
|
|
|
|
except Exception as e:
|
|
self.errors.append(f"Failed to parse frontend '{name}': {str(e)}")
|
|
logger.error(f"Frontend parsing error: {e}")
|
|
|
|
def _parse_backend_section(self, name: str, lines: List[str]):
|
|
"""Parse backend section"""
|
|
try:
|
|
backend = ParsedBackend(name=name, servers=[])
|
|
|
|
# Collect HTTP headers and options
|
|
request_headers_list = []
|
|
response_headers_list = []
|
|
options_list = []
|
|
|
|
for line in lines:
|
|
# Parse mode
|
|
mode_match = re.match(r'^mode\s+(\S+)', line, re.IGNORECASE)
|
|
if mode_match:
|
|
backend.mode = mode_match.group(1).lower()
|
|
|
|
# Parse balance method with validation
|
|
balance_match = re.match(r'^balance\s+(\S+)', line, re.IGNORECASE)
|
|
if balance_match:
|
|
balance_value = balance_match.group(1).lower()
|
|
|
|
# HAProxy supported balance algorithms
|
|
supported_balance_methods = [
|
|
'roundrobin', # Each server used in turn
|
|
'static-rr', # Static round-robin (no dynamic weight)
|
|
'leastconn', # Server with least connections
|
|
'first', # First server with available slots
|
|
'source', # Based on source IP hash
|
|
'uri', # Based on URI hash
|
|
'url_param', # Based on URL parameter
|
|
'hdr', # Based on HTTP header
|
|
'random', # Random server selection (HAProxy 1.9+)
|
|
'rdp-cookie', # Based on RDP cookie
|
|
]
|
|
|
|
if balance_value in supported_balance_methods:
|
|
backend.balance_method = balance_value
|
|
else:
|
|
# Unknown balance method - use default and warn
|
|
self.warnings.append(
|
|
f"Backend '{name}': Unknown balance method '{balance_value}' detected. "
|
|
f"Supported methods: {', '.join(supported_balance_methods)}. "
|
|
f"Using default 'roundrobin' instead."
|
|
)
|
|
backend.balance_method = 'roundrobin'
|
|
|
|
# Parse timeout directives
|
|
timeout_connect_match = re.match(r'^timeout\s+connect\s+(\d+)(ms|s|m|h)?', line, re.IGNORECASE)
|
|
if timeout_connect_match:
|
|
backend.timeout_connect = self._parse_timeout(
|
|
timeout_connect_match.group(1),
|
|
timeout_connect_match.group(2) or ''
|
|
)
|
|
|
|
timeout_server_match = re.match(r'^timeout\s+server\s+(\d+)(ms|s|m|h)?', line, re.IGNORECASE)
|
|
if timeout_server_match:
|
|
backend.timeout_server = self._parse_timeout(
|
|
timeout_server_match.group(1),
|
|
timeout_server_match.group(2) or ''
|
|
)
|
|
|
|
# Parse health check
|
|
httpchk_match = re.match(r'^option\s+httpchk\s+(?:GET\s+)?(\S+)', line, re.IGNORECASE)
|
|
if httpchk_match:
|
|
backend.health_check_uri = httpchk_match.group(1)
|
|
|
|
# Parse http-check expect status
|
|
http_check_expect_match = re.match(r'^http-check\s+expect\s+status\s+(\d+)', line, re.IGNORECASE)
|
|
if http_check_expect_match:
|
|
backend.health_check_expected_status = int(http_check_expect_match.group(1))
|
|
|
|
# Parse fullconn
|
|
fullconn_match = re.match(r'^fullconn\s+(\d+)', line, re.IGNORECASE)
|
|
if fullconn_match:
|
|
backend.fullconn = int(fullconn_match.group(1))
|
|
|
|
# Parse cookie directive
|
|
cookie_match = re.match(r'^cookie\s+(\S+)\s+(.*)', line, re.IGNORECASE)
|
|
if cookie_match:
|
|
backend.cookie_name = cookie_match.group(1)
|
|
backend.cookie_options = cookie_match.group(2).strip()
|
|
|
|
# Parse default-server directive
|
|
default_server_match = re.match(r'^default-server\s+(.*)', line, re.IGNORECASE)
|
|
if default_server_match:
|
|
options = default_server_match.group(1)
|
|
# Parse inter, fall, rise from default-server
|
|
inter_match = re.search(r'inter\s+(\d+)(ms|s)?', options)
|
|
if inter_match:
|
|
backend.default_server_inter = self._parse_timeout(
|
|
inter_match.group(1),
|
|
inter_match.group(2) or 'ms'
|
|
)
|
|
|
|
fall_match = re.search(r'fall\s+(\d+)', options)
|
|
if fall_match:
|
|
backend.default_server_fall = int(fall_match.group(1))
|
|
|
|
rise_match = re.search(r'rise\s+(\d+)', options)
|
|
if rise_match:
|
|
backend.default_server_rise = int(rise_match.group(1))
|
|
|
|
# Parse options (http-keep-alive, tcp-check, etc.)
|
|
# Note: option httpchk is handled separately above
|
|
# NEW: Collect options for backend.options field
|
|
if line.startswith('option ') and 'httpchk' not in line:
|
|
# Validate option directive - check for known HAProxy options
|
|
option_match = re.match(r'^option\s+(\S+)', line, re.IGNORECASE)
|
|
if option_match:
|
|
option_name = option_match.group(1)
|
|
# List of valid HAProxy options
|
|
valid_options = [
|
|
'httplog', 'tcplog', 'dontlognull', 'http-keep-alive', 'http-server-close',
|
|
'forwardfor', 'redispatch', 'http-use-proxy-header', 'httpchk', 'tcp-check',
|
|
'contstats', 'http-pretend-keepalive', 'logasap', 'nolinger', 'persist',
|
|
'prefer-last-server', 'splice-auto', 'splice-request', 'splice-response',
|
|
'transparent', 'abortonclose', 'allbackups', 'checkcache', 'clitcpka',
|
|
'srvtcpka', 'http-no-delay', 'socket-stats', 'tcp-smart-accept',
|
|
'tcp-smart-connect', 'independant-streams', 'log-separate-errors',
|
|
'log-health-checks', 'accept-invalid-http-request', 'accept-invalid-http-response',
|
|
# Issue #38: SPOP health check for SPOE agent backends
|
|
# (e.g. coraza-spoa). Already collected below regardless, but
|
|
# listing it suppresses the spurious "unknown option" warning
|
|
# for the exact SPOE use-case.
|
|
'spop-check'
|
|
]
|
|
|
|
if option_name not in valid_options:
|
|
# Unknown/invalid option - add warning but still collect it
|
|
self.warnings.append(
|
|
f"Backend '{name}': Unknown or invalid option '{option_name}' detected. "
|
|
"Please verify this directive is supported by your HAProxy version."
|
|
)
|
|
|
|
# Collect option for backend.options field
|
|
options_list.append(line.strip())
|
|
continue # Skip options - don't add to request_headers
|
|
|
|
# Parse HTTP request headers
|
|
if line.startswith('http-request '):
|
|
# Check for unsupported capture directives
|
|
if 'capture.req.hdr' in line or 'capture.res.hdr' in line:
|
|
self.warnings.append(
|
|
f"Backend '{name}': Capture directive detected and skipped: '{line.strip()}'. "
|
|
"Request/response capture directives require additional setup and are not supported in bulk import. "
|
|
"Please configure these manually after import."
|
|
)
|
|
continue # Skip this directive
|
|
|
|
# Check for use-service directives (consistent with frontend parser)
|
|
# Services require specific HAProxy features, Lua scripts, or compile-time options
|
|
# Cannot be safely imported without knowing agent's HAProxy capabilities
|
|
if 'use-service' in line:
|
|
self.warnings.append(
|
|
f"Backend '{name}': Service directive detected and skipped: '{line.strip()}'. "
|
|
"HAProxy services (prometheus-exporter, lua services, custom services) require "
|
|
"specific features, scripts, or compile-time options that may not be available. "
|
|
"Please configure service directives manually after import."
|
|
)
|
|
continue # Skip this directive
|
|
|
|
request_headers_list.append(line)
|
|
|
|
# Parse HTTP response headers
|
|
if line.startswith('http-response '):
|
|
# Check for unsupported capture references
|
|
if 'capture.req.hdr' in line or 'capture.res.hdr' in line or '%[capture.req.hdr' in line or '%[capture.res.hdr' in line:
|
|
self.warnings.append(
|
|
f"Backend '{name}': Response header using capture reference detected and skipped: '{line.strip()}'. "
|
|
"This requires 'http-request capture' directive which is not supported in bulk import. "
|
|
"Please configure these manually after import."
|
|
)
|
|
continue # Skip this directive
|
|
response_headers_list.append(line)
|
|
|
|
# Parse server definitions
|
|
# CRITICAL FIX: Port is optional in HAProxy (defaults to backend's default port)
|
|
# Match both "server name addr:port" and "server name addr"
|
|
server_match = re.match(
|
|
r'^server\s+(\S+)\s+([^:\s]+)(?::(\d+))?(?:\s+(.*))?',
|
|
line,
|
|
re.IGNORECASE
|
|
)
|
|
if server_match:
|
|
# Port is optional - if not specified, use None (backend will use default)
|
|
port_str = server_match.group(3)
|
|
port = port_str if port_str else None
|
|
|
|
server = self._parse_server_line(
|
|
server_match.group(1), # server name
|
|
server_match.group(2), # address
|
|
port, # port (can be None)
|
|
server_match.group(4) or "" # options
|
|
)
|
|
backend.servers.append(server)
|
|
|
|
# Combine collected headers
|
|
if request_headers_list:
|
|
backend.request_headers = '\n'.join(request_headers_list)
|
|
|
|
if response_headers_list:
|
|
backend.response_headers = '\n'.join(response_headers_list)
|
|
|
|
# CRITICAL FIX: Filter out portless servers in TCP backends
|
|
# TCP backends REQUIRE explicit port specification
|
|
if backend.mode == 'tcp' and backend.servers:
|
|
original_count = len(backend.servers)
|
|
portless_servers = [s for s in backend.servers if s.server_port == 0]
|
|
|
|
if portless_servers:
|
|
# Remove portless servers from TCP backend
|
|
backend.servers = [s for s in backend.servers if s.server_port != 0]
|
|
|
|
# Add WARNING (not error) - allow backend creation with 0 servers
|
|
# User can add servers later via UI
|
|
portless_names = ', '.join([s.server_name for s in portless_servers[:5]])
|
|
if len(portless_servers) > 5:
|
|
portless_names += f" (and {len(portless_servers) - 5} more)"
|
|
|
|
self.warnings.append(
|
|
f"Backend '{name}' (TCP mode): {len(portless_servers)} server(s) without port specification "
|
|
f"({portless_names}). These servers have been EXCLUDED from import. "
|
|
f"Backend will be created with {len(backend.servers)} server(s). "
|
|
f"Add missing servers with ports via Backend Management page after import."
|
|
)
|
|
|
|
logger.warning(f"Backend '{name}': Excluded {len(portless_servers)} portless servers (TCP mode requires ports)")
|
|
|
|
# Assign collected options to backend (NEW)
|
|
if options_list:
|
|
backend.options = '\n'.join(options_list)
|
|
|
|
# DNS HOSTNAME DETECTION: Warn if servers use hostnames instead of IPs
|
|
if backend.servers:
|
|
hostname_servers = []
|
|
for server in backend.servers:
|
|
# Check if server_address is NOT an IP address (simple IPv4 check)
|
|
if not re.match(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$', server.server_address):
|
|
hostname_servers.append(f"{server.server_name} ({server.server_address})")
|
|
|
|
if hostname_servers:
|
|
hostname_list = ', '.join(hostname_servers[:3])
|
|
if len(hostname_servers) > 3:
|
|
hostname_list += f" (and {len(hostname_servers) - 3} more)"
|
|
|
|
self.warnings.append(
|
|
f"🌐 DNS: Backend '{name}' uses hostname(s): {hostname_list}. "
|
|
f"Ensure DNS resolution is configured on target servers. "
|
|
f"HAProxy will fail to start if hostnames cannot be resolved. "
|
|
f"Consider adding entries to /etc/hosts or configuring DNS server."
|
|
)
|
|
|
|
self.backends.append(backend)
|
|
logger.info(f"Parsed backend: {name} with {len(backend.servers)} servers")
|
|
|
|
except Exception as e:
|
|
self.errors.append(f"Failed to parse backend '{name}': {str(e)}")
|
|
logger.error(f"Backend parsing error: {e}")
|
|
|
|
def _parse_server_line(
|
|
self,
|
|
name: str,
|
|
address: str,
|
|
port: str, # Can be None if port not specified
|
|
options: str
|
|
) -> ParsedServer:
|
|
"""Parse server line and extract options"""
|
|
# CRITICAL FIX: Port can be None in HAProxy (uses backend default)
|
|
# If port is not specified, use 0 as placeholder (will use backend's default port)
|
|
server_port = int(port) if port else 0
|
|
|
|
server = ParsedServer(
|
|
server_name=name,
|
|
server_address=address,
|
|
server_port=server_port
|
|
)
|
|
|
|
# Parse options
|
|
if 'check' in options.lower():
|
|
server.check_enabled = True
|
|
|
|
if 'backup' in options.lower():
|
|
server.backup_server = True
|
|
|
|
if 'ssl' in options.lower():
|
|
server.ssl_enabled = True
|
|
|
|
# Parse weight
|
|
weight_match = re.search(r'weight\s+(\d+)', options, re.IGNORECASE)
|
|
if weight_match:
|
|
server.weight = int(weight_match.group(1))
|
|
|
|
# Parse maxconn
|
|
maxconn_match = re.search(r'maxconn\s+(\d+)', options, re.IGNORECASE)
|
|
if maxconn_match:
|
|
server.max_connections = int(maxconn_match.group(1))
|
|
|
|
# Parse check port
|
|
check_port_match = re.search(r'check\s+port\s+(\d+)', options, re.IGNORECASE)
|
|
if check_port_match:
|
|
server.check_port = int(check_port_match.group(1))
|
|
|
|
# Parse SSL verification
|
|
verify_match = re.search(r'verify\s+(none|required)', options, re.IGNORECASE)
|
|
ca_file_match = re.search(r'ca-file\s+(\S+)', options, re.IGNORECASE)
|
|
|
|
# CRITICAL FIX: HAProxy validation requires ca-file when verify=required
|
|
# If ca-file exists, we remove it but must also change verify to 'none'
|
|
# Otherwise HAProxy validation fails: "verify required but no CA file"
|
|
if verify_match and ca_file_match:
|
|
# Both verify and ca-file exist
|
|
ca_file_path = ca_file_match.group(1)
|
|
# Change verify to 'none' since we're removing ca-file
|
|
server.ssl_verify = 'none'
|
|
self.warnings.append(
|
|
f"SSL: Server '{name}' has 'verify required' with ca-file '{ca_file_path}'. "
|
|
f"CA file has been REMOVED and verify changed to 'none' to pass HAProxy validation. "
|
|
f"After import, edit this server, select SSL certificate from dropdown, then set verify to 'required'."
|
|
)
|
|
elif verify_match:
|
|
# Only verify exists (no ca-file)
|
|
server.ssl_verify = verify_match.group(1).lower()
|
|
elif ca_file_match:
|
|
# Only ca-file exists (no verify) - this is rare but handle it
|
|
ca_file_path = ca_file_match.group(1)
|
|
server.ssl_verify = 'none' # Default to none when ca-file removed
|
|
self.warnings.append(
|
|
f"SSL: Server '{name}' has ca-file '{ca_file_path}' which has been REMOVED. "
|
|
f"SSL verify set to 'none'. After import, select SSL certificate and configure verify."
|
|
)
|
|
|
|
# Parse SSL Advanced Options (server SSL parameters)
|
|
# Support both quoted and unquoted values
|
|
sni_match = re.search(r'sni\s+(?:"([^"]+)"|(\S+))', options, re.IGNORECASE)
|
|
if sni_match:
|
|
server.ssl_sni = (sni_match.group(1) or sni_match.group(2)).strip('"')
|
|
|
|
ssl_min_ver_match = re.search(r'ssl-min-ver\s+(?:"([^"]+)"|(\S+))', options, re.IGNORECASE)
|
|
if ssl_min_ver_match:
|
|
server.ssl_min_ver = (ssl_min_ver_match.group(1) or ssl_min_ver_match.group(2)).strip('"')
|
|
|
|
ssl_max_ver_match = re.search(r'ssl-max-ver\s+(?:"([^"]+)"|(\S+))', options, re.IGNORECASE)
|
|
if ssl_max_ver_match:
|
|
server.ssl_max_ver = (ssl_max_ver_match.group(1) or ssl_max_ver_match.group(2)).strip('"')
|
|
|
|
# Ciphers can be a long colon-separated list, support quoted values
|
|
ciphers_match = re.search(r'ciphers\s+(?:"([^"]+)"|([^\s]+))', options, re.IGNORECASE)
|
|
if ciphers_match:
|
|
server.ssl_ciphers = (ciphers_match.group(1) or ciphers_match.group(2)).strip('"')
|
|
|
|
# Parse cookie value
|
|
cookie_match = re.search(r'cookie\s+(\S+)', options, re.IGNORECASE)
|
|
if cookie_match:
|
|
server.cookie_value = cookie_match.group(1)
|
|
|
|
# Parse inter (health check interval)
|
|
inter_match = re.search(r'inter\s+(\d+)(ms|s)?', options, re.IGNORECASE)
|
|
if inter_match:
|
|
server.inter = self._parse_timeout(
|
|
inter_match.group(1),
|
|
inter_match.group(2) or 'ms'
|
|
)
|
|
|
|
# Parse fall
|
|
fall_match = re.search(r'fall\s+(\d+)', options, re.IGNORECASE)
|
|
if fall_match:
|
|
server.fall = int(fall_match.group(1))
|
|
|
|
# Parse rise
|
|
rise_match = re.search(r'rise\s+(\d+)', options, re.IGNORECASE)
|
|
if rise_match:
|
|
server.rise = int(rise_match.group(1))
|
|
|
|
return server
|
|
|
|
def _parse_timeout(self, value: str, unit: str) -> int:
|
|
"""Convert timeout value to milliseconds
|
|
|
|
HAProxy timeout units:
|
|
- ms: milliseconds (default if no unit)
|
|
- s: seconds
|
|
- m: minutes
|
|
- h: hours
|
|
"""
|
|
value = int(value)
|
|
|
|
if unit == 'ms' or unit == '':
|
|
# Milliseconds (explicit or default)
|
|
return value
|
|
elif unit == 's':
|
|
return value * 1000
|
|
elif unit == 'm':
|
|
return value * 60 * 1000
|
|
elif unit == 'h':
|
|
return value * 3600 * 1000
|
|
else:
|
|
# Fallback: treat as milliseconds
|
|
return value
|
|
|
|
def _validate_parsed_config(self):
|
|
"""
|
|
Post-parse validation of parsed configuration
|
|
Removes invalid entities and adds warnings for skipped items
|
|
"""
|
|
# CRITICAL: Reserved names that conflict with common HAProxy listen sections
|
|
# Agent preserves existing listen blocks (e.g., 'listen stats') from local config
|
|
# Creating frontends/backends with these names causes "proxy has same name" errors
|
|
reserved_names = {
|
|
'stats', # Conflicts with common 'listen stats' monitoring section
|
|
'haproxy-stats', # Alternative stats name
|
|
'haproxy_stats', # Alternative stats name with underscore
|
|
'monitoring', # Common monitoring section name
|
|
'admin', # Admin interface
|
|
'health', # Health check endpoint
|
|
'status', # Status page
|
|
}
|
|
|
|
# Issue #11: Backend names that are auto-managed by HAProxy OpenManager.
|
|
# These must NEVER be persisted in DB; they're injected per-Apply by haproxy_config.py.
|
|
# If user-provided config contains them (e.g. via bulk import or restore),
|
|
# parser will warn-and-skip to prevent duplicate sections on subsequent Apply.
|
|
reserved_backend_names = reserved_names | {
|
|
'_acme_challenge_backend', # Auto-injected ACME HTTP-01 challenge backend
|
|
}
|
|
|
|
# Check for duplicate frontend names - remove duplicates keeping first occurrence
|
|
# Also check for reserved names that conflict with listen sections
|
|
frontend_names_seen = set()
|
|
valid_frontends = []
|
|
for frontend in self.frontends:
|
|
# Check for reserved names first
|
|
if frontend.name.lower() in reserved_names:
|
|
self.warnings.append(
|
|
f"⚠️ SKIPPED: Frontend '{frontend.name}' uses a reserved name that conflicts with "
|
|
f"common HAProxy listen sections (e.g., 'listen stats'). Agent preserves existing "
|
|
f"listen blocks from local config, so creating a frontend with this name would cause "
|
|
f"'proxy has same name' errors. Rename this frontend to avoid conflicts."
|
|
)
|
|
continue
|
|
|
|
if frontend.name in frontend_names_seen:
|
|
self.warnings.append(
|
|
f"⚠️ SKIPPED: Duplicate frontend '{frontend.name}' - keeping first occurrence only."
|
|
)
|
|
else:
|
|
frontend_names_seen.add(frontend.name)
|
|
valid_frontends.append(frontend)
|
|
|
|
# Check for duplicate backend names - remove duplicates keeping first occurrence
|
|
# Also check for reserved names
|
|
backend_names_seen = set()
|
|
valid_backends = []
|
|
for backend in self.backends:
|
|
# Check for reserved names first (Issue #11: includes auto-managed backends)
|
|
if backend.name.lower() in reserved_backend_names:
|
|
self.warnings.append(
|
|
f"⚠️ SKIPPED: Backend '{backend.name}' uses a reserved/auto-managed name. "
|
|
f"This backend is managed automatically by HAProxy OpenManager and should not "
|
|
f"be defined manually."
|
|
)
|
|
continue
|
|
|
|
if backend.name in backend_names_seen:
|
|
self.warnings.append(
|
|
f"⚠️ SKIPPED: Duplicate backend '{backend.name}' - keeping first occurrence only."
|
|
)
|
|
else:
|
|
backend_names_seen.add(backend.name)
|
|
valid_backends.append(backend)
|
|
|
|
# Update lists with deduplicated entities
|
|
self.frontends = valid_frontends
|
|
self.backends = valid_backends
|
|
|
|
# Build set of valid backend names for reference checking
|
|
backend_names = {b.name for b in self.backends}
|
|
|
|
# CRITICAL VALIDATION: Remove backends without servers (HAProxy requirement)
|
|
backends_to_keep = []
|
|
removed_backend_names = set()
|
|
|
|
for backend in self.backends:
|
|
# Check if backend has no servers
|
|
if not backend.servers or len(backend.servers) == 0:
|
|
removed_backend_names.add(backend.name)
|
|
|
|
# Check if this backend had portless servers that were excluded
|
|
has_portless_warning = any(
|
|
f"Backend '{backend.name}'" in w and "EXCLUDED" in w
|
|
for w in self.warnings
|
|
)
|
|
|
|
if has_portless_warning:
|
|
self.warnings.append(
|
|
f"⚠️ SKIPPED: Backend '{backend.name}' has NO VALID SERVERS (all servers excluded due to missing port). "
|
|
f"HAProxy requires explicit port specification for TCP backends. This backend will NOT be created."
|
|
)
|
|
else:
|
|
self.warnings.append(
|
|
f"⚠️ SKIPPED: Backend '{backend.name}' has NO SERVERS. "
|
|
f"HAProxy requires at least one server per backend. This backend will NOT be created."
|
|
)
|
|
else:
|
|
# Backend has servers, but check if TCP backend still has portless servers (shouldn't happen but be safe)
|
|
if backend.mode == 'tcp':
|
|
portless_count = sum(1 for s in backend.servers if s.server_port == 0)
|
|
if portless_count > 0:
|
|
# This shouldn't happen (should be filtered during parse)
|
|
# but if it does, exclude those servers now
|
|
backend.servers = [s for s in backend.servers if s.server_port != 0]
|
|
self.warnings.append(
|
|
f"⚠️ Backend '{backend.name}' (TCP): Removed {portless_count} server(s) with missing port during validation."
|
|
)
|
|
|
|
# If no servers left after filtering, skip backend
|
|
if len(backend.servers) == 0:
|
|
removed_backend_names.add(backend.name)
|
|
self.warnings.append(
|
|
f"⚠️ SKIPPED: Backend '{backend.name}' has NO VALID SERVERS after port validation. "
|
|
f"This backend will NOT be created."
|
|
)
|
|
continue
|
|
|
|
backends_to_keep.append(backend)
|
|
|
|
self.backends = backends_to_keep
|
|
backend_names = {b.name for b in self.backends} # Update valid backend names
|
|
|
|
# CRITICAL VALIDATION: Remove or fix frontends with missing backends
|
|
frontends_to_keep = []
|
|
|
|
for frontend in self.frontends:
|
|
# Check for valid bind configuration
|
|
if not frontend.bind_port or frontend.bind_port == 0:
|
|
# If SSL port exists, use that instead
|
|
if frontend.ssl_enabled and frontend.ssl_port and frontend.ssl_port > 0:
|
|
# Frontend only has SSL bind, this is valid
|
|
pass
|
|
else:
|
|
self.warnings.append(
|
|
f"⚠️ SKIPPED: Frontend '{frontend.name}' has invalid bind port ({frontend.bind_port}) "
|
|
f"and no valid SSL port. This frontend will NOT be created."
|
|
)
|
|
continue
|
|
|
|
# Check if frontend has any backend routing
|
|
has_default_backend = frontend.default_backend is not None
|
|
# CRITICAL FIX: use_backend_rules is now a list, not a string
|
|
has_use_backend_rules = bool(frontend.use_backend_rules)
|
|
|
|
if not has_default_backend and not has_use_backend_rules:
|
|
# Frontend has no routing - check if it has redirect rules
|
|
has_redirect = frontend.request_headers and 'redirect' in frontend.request_headers.lower()
|
|
|
|
if has_redirect:
|
|
# Frontend only does redirects - this is valid, keep it
|
|
self.warnings.append(
|
|
f"✓ Frontend '{frontend.name}': No backend routing (redirect-only frontend). This is valid."
|
|
)
|
|
frontends_to_keep.append(frontend)
|
|
else:
|
|
# Frontend has no routing at all - skip it
|
|
self.warnings.append(
|
|
f"⚠️ SKIPPED: Frontend '{frontend.name}' has NO default_backend and NO routing rules. "
|
|
f"This frontend will NOT be created."
|
|
)
|
|
continue
|
|
|
|
# Check if referenced backend exists
|
|
if has_default_backend:
|
|
if frontend.default_backend in removed_backend_names:
|
|
self.warnings.append(
|
|
f"⚠️ SKIPPED: Frontend '{frontend.name}' references backend '{frontend.default_backend}' "
|
|
f"which was skipped (no servers). This frontend will NOT be created."
|
|
)
|
|
continue
|
|
elif frontend.default_backend not in backend_names:
|
|
self.warnings.append(
|
|
f"⚠️ WARNING: Frontend '{frontend.name}' references backend '{frontend.default_backend}' "
|
|
f"which is not defined in this config. Make sure this backend exists in your cluster, "
|
|
f"or the frontend will fail validation."
|
|
)
|
|
# Still keep the frontend - backend might exist in cluster
|
|
|
|
# Frontend passed all checks
|
|
frontends_to_keep.append(frontend)
|
|
|
|
self.frontends = frontends_to_keep
|
|
|
|
# Check for portless servers in HTTP mode
|
|
for backend in self.backends:
|
|
if backend.servers and backend.mode != 'tcp':
|
|
portless_servers = [s for s in backend.servers if s.server_port == 0]
|
|
if portless_servers:
|
|
server_names = ', '.join([s.server_name for s in portless_servers[:3]])
|
|
if len(portless_servers) > 3:
|
|
server_names += f" (and {len(portless_servers) - 3} more)"
|
|
|
|
self.warnings.append(
|
|
f"⚠️ Backend '{backend.name}' (HTTP): Servers without port: {server_names}. "
|
|
f"Default port 80 (or 443 for SSL) will be used."
|
|
)
|
|
|
|
# Check for invalid mode + health check combinations
|
|
if backend.mode == 'tcp' and backend.health_check_expected_status:
|
|
self.warnings.append(
|
|
f"⚠️ Backend '{backend.name}': HTTP health check status removed (TCP mode incompatible)."
|
|
)
|
|
|
|
# Check if all entities were filtered out
|
|
if len(self.frontends) == 0 and len(self.backends) == 0:
|
|
self.warnings.append(
|
|
"⚠️ No valid frontends or backends could be parsed. "
|
|
"Check the warnings above for details on what was skipped."
|
|
)
|
|
|
|
|
|
def parse_haproxy_config(config_content: str) -> ParseResult:
|
|
"""
|
|
Parse HAProxy configuration content
|
|
|
|
Args:
|
|
config_content: HAProxy configuration file content
|
|
|
|
Returns:
|
|
ParseResult with parsed entities
|
|
"""
|
|
parser = HAProxyConfigParser()
|
|
return parser.parse(config_content)
|
|
|