fix: HAProxy batch commands and improve routing/subdomain handling
- Fix haproxy_cmd_batch to send each command on separate connection (HAProxy Runtime API only processes first command on single connection) - HTTP frontend now routes to backends instead of redirecting to HTTPS - Add subdomain detection to avoid duplicate wildcard entries - Add reload verification with retry logic - Optimize SSL: TLS 1.3 ciphersuites, extended session lifetime - Add CPU steal monitoring script Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
"""Configuration management tools for HAProxy MCP Server."""
|
||||
|
||||
import fcntl
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
@@ -9,12 +8,9 @@ from ..config import (
|
||||
HAPROXY_CONTAINER,
|
||||
SUBPROCESS_TIMEOUT,
|
||||
STARTUP_RETRY_COUNT,
|
||||
StateField,
|
||||
STATE_MIN_COLUMNS,
|
||||
logger,
|
||||
)
|
||||
from ..exceptions import HaproxyError
|
||||
from ..validation import validate_ip, validate_port, validate_backend_name
|
||||
from ..haproxy_client import haproxy_cmd, haproxy_cmd_batch, reload_haproxy
|
||||
from ..file_ops import (
|
||||
atomic_write_file,
|
||||
@@ -76,7 +72,7 @@ def restore_servers_from_config() -> int:
|
||||
if not commands:
|
||||
return 0
|
||||
|
||||
# Execute all commands in single batch
|
||||
# Execute all commands
|
||||
try:
|
||||
haproxy_cmd_batch(commands)
|
||||
return len(server_info_list)
|
||||
@@ -141,6 +137,20 @@ def register_config_tools(mcp):
|
||||
if not success:
|
||||
return msg
|
||||
|
||||
# Wait for HAProxy to fully reload (new process takes over)
|
||||
# USR2 signal spawns new process but old one may still be serving
|
||||
time.sleep(2)
|
||||
|
||||
# Verify HAProxy is responding
|
||||
for _ in range(STARTUP_RETRY_COUNT):
|
||||
try:
|
||||
haproxy_cmd("show info")
|
||||
break
|
||||
except HaproxyError:
|
||||
time.sleep(0.5)
|
||||
else:
|
||||
return "HAProxy reloaded but not responding after reload"
|
||||
|
||||
# Restore servers from config after reload
|
||||
try:
|
||||
restored = restore_servers_from_config()
|
||||
@@ -191,82 +201,17 @@ def register_config_tools(mcp):
|
||||
def haproxy_restore_state() -> str:
|
||||
"""Restore server state from disk.
|
||||
|
||||
Uses batched commands for efficiency.
|
||||
Reads server configuration from servers.json and restores to HAProxy.
|
||||
|
||||
Returns:
|
||||
Summary of restored servers or error description
|
||||
"""
|
||||
try:
|
||||
with open(STATE_FILE, "r", encoding="utf-8") as f:
|
||||
try:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_SH)
|
||||
except OSError:
|
||||
pass # Continue without lock if not supported
|
||||
try:
|
||||
state = f.read()
|
||||
finally:
|
||||
try:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Build batch of all commands
|
||||
commands: list[str] = []
|
||||
server_info_list: list[tuple[str, str]] = []
|
||||
skipped = 0
|
||||
|
||||
for line in state.split("\n"):
|
||||
parts = line.split()
|
||||
if len(parts) >= STATE_MIN_COLUMNS and not line.startswith("#"):
|
||||
backend = parts[StateField.BE_NAME]
|
||||
server = parts[StateField.SRV_NAME]
|
||||
addr = parts[StateField.SRV_ADDR]
|
||||
port = parts[StateField.SRV_PORT]
|
||||
|
||||
# Skip disabled servers
|
||||
if addr == "0.0.0.0":
|
||||
continue
|
||||
|
||||
# Validate names from state file to prevent injection
|
||||
if not validate_backend_name(backend) or not validate_backend_name(server):
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Validate IP and port
|
||||
if not validate_ip(addr) or not validate_port(port):
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
commands.append(f"set server {backend}/{server} addr {addr} port {port}")
|
||||
commands.append(f"set server {backend}/{server} state ready")
|
||||
server_info_list.append((backend, server))
|
||||
|
||||
if not commands:
|
||||
result = "No servers to restore"
|
||||
if skipped:
|
||||
result += f", {skipped} entries skipped due to validation"
|
||||
return result
|
||||
|
||||
# Execute all commands in single batch
|
||||
try:
|
||||
haproxy_cmd_batch(commands)
|
||||
restored = len(server_info_list)
|
||||
except HaproxyError:
|
||||
# Fallback: try individual pairs
|
||||
restored = 0
|
||||
for i in range(0, len(commands), 2):
|
||||
try:
|
||||
haproxy_cmd_batch([commands[i], commands[i + 1]])
|
||||
restored += 1
|
||||
except HaproxyError as e:
|
||||
backend, server = server_info_list[i // 2]
|
||||
logger.warning("Failed to restore %s/%s: %s", backend, server, e)
|
||||
|
||||
result = f"Server state restored ({restored} servers)"
|
||||
if skipped:
|
||||
result += f", {skipped} entries skipped due to validation"
|
||||
return result
|
||||
except FileNotFoundError:
|
||||
return "Error: No saved state found"
|
||||
restored = restore_servers_from_config()
|
||||
if restored == 0:
|
||||
return "No servers to restore"
|
||||
return f"Server state restored ({restored} servers)"
|
||||
except HaproxyError as e:
|
||||
return f"Error: {e}"
|
||||
except (OSError, ValueError) as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@@ -159,9 +159,13 @@ def register_domain_tools(mcp):
|
||||
|
||||
# Find available pool (using cached entries)
|
||||
used_pools: set[str] = set()
|
||||
for _, backend in entries:
|
||||
registered_domains: set[str] = set()
|
||||
for entry_domain, backend in entries:
|
||||
if backend.startswith("pool_"):
|
||||
used_pools.add(backend)
|
||||
# Collect non-wildcard domains for subdomain check
|
||||
if not entry_domain.startswith("."):
|
||||
registered_domains.add(entry_domain)
|
||||
|
||||
pool = None
|
||||
for i in range(1, POOL_COUNT + 1):
|
||||
@@ -172,10 +176,24 @@ def register_domain_tools(mcp):
|
||||
if not pool:
|
||||
return f"Error: All {POOL_COUNT} pool backends are in use"
|
||||
|
||||
# Check if this is a subdomain of an existing domain
|
||||
# e.g., vault.anvil.it.com is subdomain if anvil.it.com exists
|
||||
is_subdomain = False
|
||||
parent_domain = None
|
||||
parts = domain.split(".")
|
||||
for i in range(1, len(parts)):
|
||||
candidate = ".".join(parts[i:])
|
||||
if candidate in registered_domains:
|
||||
is_subdomain = True
|
||||
parent_domain = candidate
|
||||
break
|
||||
|
||||
try:
|
||||
# Save to disk first (atomic write for persistence)
|
||||
entries.append((domain, pool))
|
||||
entries.append((f".{domain}", pool))
|
||||
# Only add wildcard for root domains, not subdomains
|
||||
if not is_subdomain:
|
||||
entries.append((f".{domain}", pool))
|
||||
try:
|
||||
save_map_file(entries)
|
||||
except IOError as e:
|
||||
@@ -184,7 +202,8 @@ def register_domain_tools(mcp):
|
||||
# Then update HAProxy map via Runtime API
|
||||
try:
|
||||
haproxy_cmd(f"add map {MAP_FILE_CONTAINER} {domain} {pool}")
|
||||
haproxy_cmd(f"add map {MAP_FILE_CONTAINER} .{domain} {pool}")
|
||||
if not is_subdomain:
|
||||
haproxy_cmd(f"add map {MAP_FILE_CONTAINER} .{domain} {pool}")
|
||||
except HaproxyError as e:
|
||||
# Rollback: remove the domain we just added from entries and re-save
|
||||
rollback_entries = [(d, b) for d, b in entries if d != domain and d != f".{domain}"]
|
||||
@@ -209,8 +228,12 @@ def register_domain_tools(mcp):
|
||||
return f"Domain {domain} added to {pool} but server config failed: {e}"
|
||||
|
||||
result = f"Domain {domain} added to {pool} with server {ip}:{http_port}"
|
||||
if is_subdomain:
|
||||
result += f" (subdomain of {parent_domain}, no wildcard)"
|
||||
else:
|
||||
result = f"Domain {domain} added to {pool} (no servers configured)"
|
||||
if is_subdomain:
|
||||
result += f" (subdomain of {parent_domain}, no wildcard)"
|
||||
|
||||
# Check certificate coverage
|
||||
cert_covered, cert_info = check_certificate_coverage(domain)
|
||||
|
||||
Reference in New Issue
Block a user