fix: Add security and reliability improvements
1. File locking for config operations
- add_server_to_config() and remove_server_from_config() now use
exclusive file locking (fcntl.LOCK_EX) to prevent race conditions
- Prevents data loss from concurrent modifications
2. Bulk server limit
- Add MAX_BULK_SERVERS = 10 constant
- haproxy_add_servers() now rejects requests exceeding limit
- Prevents potential DoS via large payloads
3. HAProxy command response validation
- Add haproxy_cmd_checked() helper function
- Validates responses for error indicators (No such, not found, etc.)
- State-modifying commands now properly detect and report failures
- Read-only commands continue using haproxy_cmd()
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -82,6 +82,7 @@ STARTUP_RETRY_COUNT = 10 # HAProxy ready check retries
|
|||||||
STATE_MIN_COLUMNS = 19 # Minimum columns in HAProxy server state output
|
STATE_MIN_COLUMNS = 19 # Minimum columns in HAProxy server state output
|
||||||
SOCKET_TIMEOUT = 5 # seconds for HAProxy socket connection
|
SOCKET_TIMEOUT = 5 # seconds for HAProxy socket connection
|
||||||
SOCKET_RECV_TIMEOUT = 30 # seconds for HAProxy socket recv loop
|
SOCKET_RECV_TIMEOUT = 30 # seconds for HAProxy socket recv loop
|
||||||
|
MAX_BULK_SERVERS = 10 # Max servers per bulk add call
|
||||||
|
|
||||||
|
|
||||||
class HaproxyError(Exception):
|
class HaproxyError(Exception):
|
||||||
@@ -169,6 +170,29 @@ def haproxy_cmd(command: str) -> str:
|
|||||||
raise HaproxyError(str(e)) from e
|
raise HaproxyError(str(e)) from e
|
||||||
|
|
||||||
|
|
||||||
|
def haproxy_cmd_checked(command: str) -> str:
|
||||||
|
"""Send command to HAProxy and raise on error response.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command: HAProxy command to execute
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Command response
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HaproxyError: If HAProxy returns an error message
|
||||||
|
"""
|
||||||
|
result = haproxy_cmd(command)
|
||||||
|
# HAProxy returns empty string on success, error messages on failure
|
||||||
|
error_indicators = ["No such", "not found", "error", "failed", "invalid", "unknown"]
|
||||||
|
if result:
|
||||||
|
result_lower = result.lower()
|
||||||
|
for indicator in error_indicators:
|
||||||
|
if indicator.lower() in result_lower:
|
||||||
|
raise HaproxyError(f"HAProxy command failed: {result.strip()}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def reload_haproxy() -> Tuple[bool, str]:
|
def reload_haproxy() -> Tuple[bool, str]:
|
||||||
"""Validate and reload HAProxy configuration.
|
"""Validate and reload HAProxy configuration.
|
||||||
|
|
||||||
@@ -485,7 +509,7 @@ def save_servers_config(config: Dict[str, Any]) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def add_server_to_config(domain: str, slot: int, ip: str, http_port: int) -> None:
|
def add_server_to_config(domain: str, slot: int, ip: str, http_port: int) -> None:
|
||||||
"""Add server configuration to persistent storage.
|
"""Add server configuration to persistent storage with file locking.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
domain: Domain name
|
domain: Domain name
|
||||||
@@ -493,29 +517,38 @@ def add_server_to_config(domain: str, slot: int, ip: str, http_port: int) -> Non
|
|||||||
ip: Server IP address
|
ip: Server IP address
|
||||||
http_port: HTTP port
|
http_port: HTTP port
|
||||||
"""
|
"""
|
||||||
|
lock_path = f"{SERVERS_FILE}.lock"
|
||||||
|
with open(lock_path, 'w') as lock_file:
|
||||||
|
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
||||||
|
try:
|
||||||
config = load_servers_config()
|
config = load_servers_config()
|
||||||
if domain not in config:
|
if domain not in config:
|
||||||
config[domain] = {}
|
config[domain] = {}
|
||||||
config[domain][str(slot)] = {
|
config[domain][str(slot)] = {"ip": ip, "http_port": http_port}
|
||||||
"ip": ip,
|
|
||||||
"http_port": http_port
|
|
||||||
}
|
|
||||||
save_servers_config(config)
|
save_servers_config(config)
|
||||||
|
finally:
|
||||||
|
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
||||||
|
|
||||||
|
|
||||||
def remove_server_from_config(domain: str, slot: int) -> None:
|
def remove_server_from_config(domain: str, slot: int) -> None:
|
||||||
"""Remove server configuration from persistent storage.
|
"""Remove server configuration from persistent storage with file locking.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
domain: Domain name
|
domain: Domain name
|
||||||
slot: Server slot to remove
|
slot: Server slot to remove
|
||||||
"""
|
"""
|
||||||
|
lock_path = f"{SERVERS_FILE}.lock"
|
||||||
|
with open(lock_path, 'w') as lock_file:
|
||||||
|
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
||||||
|
try:
|
||||||
config = load_servers_config()
|
config = load_servers_config()
|
||||||
if domain in config and str(slot) in config[domain]:
|
if domain in config and str(slot) in config[domain]:
|
||||||
del config[domain][str(slot)]
|
del config[domain][str(slot)]
|
||||||
if not config[domain]:
|
if not config[domain]:
|
||||||
del config[domain]
|
del config[domain]
|
||||||
save_servers_config(config)
|
save_servers_config(config)
|
||||||
|
finally:
|
||||||
|
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
||||||
|
|
||||||
|
|
||||||
def remove_domain_from_config(domain: str) -> None:
|
def remove_domain_from_config(domain: str) -> None:
|
||||||
@@ -891,8 +924,8 @@ def haproxy_add_server(domain: str, slot: int, ip: str, http_port: int = 80) ->
|
|||||||
results = []
|
results = []
|
||||||
for suffix, port in get_server_suffixes(http_port):
|
for suffix, port in get_server_suffixes(http_port):
|
||||||
server = f"{server_prefix}{suffix}_{slot}"
|
server = f"{server_prefix}{suffix}_{slot}"
|
||||||
haproxy_cmd(f"set server {backend}/{server} addr {ip} port {port}")
|
haproxy_cmd_checked(f"set server {backend}/{server} addr {ip} port {port}")
|
||||||
haproxy_cmd(f"set server {backend}/{server} state ready")
|
haproxy_cmd_checked(f"set server {backend}/{server} state ready")
|
||||||
results.append(f"{server} → {ip}:{port}")
|
results.append(f"{server} → {ip}:{port}")
|
||||||
|
|
||||||
# Save to persistent config
|
# Save to persistent config
|
||||||
@@ -942,6 +975,9 @@ def haproxy_add_servers(domain: str, servers: str) -> str:
|
|||||||
if not server_list:
|
if not server_list:
|
||||||
return "Error: servers array is empty"
|
return "Error: servers array is empty"
|
||||||
|
|
||||||
|
if len(server_list) > MAX_BULK_SERVERS:
|
||||||
|
return f"Error: Cannot add more than {MAX_BULK_SERVERS} servers at once"
|
||||||
|
|
||||||
# Validate all servers first before adding any
|
# Validate all servers first before adding any
|
||||||
validated_servers = []
|
validated_servers = []
|
||||||
validation_errors = []
|
validation_errors = []
|
||||||
@@ -1014,8 +1050,8 @@ def haproxy_add_servers(domain: str, servers: str) -> str:
|
|||||||
try:
|
try:
|
||||||
for suffix, port in get_server_suffixes(http_port):
|
for suffix, port in get_server_suffixes(http_port):
|
||||||
server = f"{server_prefix}{suffix}_{slot}"
|
server = f"{server_prefix}{suffix}_{slot}"
|
||||||
haproxy_cmd(f"set server {backend}/{server} addr {ip} port {port}")
|
haproxy_cmd_checked(f"set server {backend}/{server} addr {ip} port {port}")
|
||||||
haproxy_cmd(f"set server {backend}/{server} state ready")
|
haproxy_cmd_checked(f"set server {backend}/{server} state ready")
|
||||||
|
|
||||||
# Save to persistent config
|
# Save to persistent config
|
||||||
add_server_to_config(domain, slot, ip, http_port)
|
add_server_to_config(domain, slot, ip, http_port)
|
||||||
@@ -1063,8 +1099,8 @@ def haproxy_remove_server(domain: str, slot: int) -> str:
|
|||||||
|
|
||||||
# HTTP only - single server per slot
|
# HTTP only - single server per slot
|
||||||
server = f"{server_prefix}_{slot}"
|
server = f"{server_prefix}_{slot}"
|
||||||
haproxy_cmd(f"set server {backend}/{server} state maint")
|
haproxy_cmd_checked(f"set server {backend}/{server} state maint")
|
||||||
haproxy_cmd(f"set server {backend}/{server} addr 0.0.0.0 port 0")
|
haproxy_cmd_checked(f"set server {backend}/{server} addr 0.0.0.0 port 0")
|
||||||
|
|
||||||
# Remove from persistent config
|
# Remove from persistent config
|
||||||
remove_server_from_config(domain, slot)
|
remove_server_from_config(domain, slot)
|
||||||
@@ -1323,8 +1359,8 @@ def haproxy_set_server_state(backend: str, server: str, state: str) -> str:
|
|||||||
if state not in ["ready", "drain", "maint"]:
|
if state not in ["ready", "drain", "maint"]:
|
||||||
return "Error: state must be 'ready', 'drain', or 'maint'"
|
return "Error: state must be 'ready', 'drain', or 'maint'"
|
||||||
try:
|
try:
|
||||||
result = haproxy_cmd(f"set server {backend}/{server} state {state}")
|
haproxy_cmd_checked(f"set server {backend}/{server} state {state}")
|
||||||
return result if result else f"Server {backend}/{server} set to {state}"
|
return f"Server {backend}/{server} set to {state}"
|
||||||
except HaproxyError as e:
|
except HaproxyError as e:
|
||||||
return f"Error: {e}"
|
return f"Error: {e}"
|
||||||
|
|
||||||
@@ -1392,8 +1428,8 @@ def haproxy_set_server_weight(backend: str, server: str, weight: int) -> str:
|
|||||||
if not (0 <= weight <= 256):
|
if not (0 <= weight <= 256):
|
||||||
return "Error: weight must be between 0 and 256"
|
return "Error: weight must be between 0 and 256"
|
||||||
try:
|
try:
|
||||||
result = haproxy_cmd(f"set server {backend}/{server} weight {weight}")
|
haproxy_cmd_checked(f"set server {backend}/{server} weight {weight}")
|
||||||
return result if result else f"Server {backend}/{server} weight set to {weight}"
|
return f"Server {backend}/{server} weight set to {weight}"
|
||||||
except HaproxyError as e:
|
except HaproxyError as e:
|
||||||
return f"Error: {e}"
|
return f"Error: {e}"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user