feat: Add 'option httpchk' validation and auto-filtering across all layers

CRITICAL FIX: Prevent 'option httpchk' duplication in HAProxy config by implementing 3-layer validation:

1. BULK IMPORT PARSER:
   - Frontend: Filter out 'option httpchk' with warning (not applicable to frontends)
   - Backend: Already filtering 'option httpchk' (handled by health_check_uri field)

2. BACKEND API:
   - Backend create/update: Auto-filter 'option httpchk' from options field
   - Frontend create/update: Auto-filter 'option httpchk' from options field
   - Added filter_httpchk_from_options() helper function in both routers

3. FRONTEND UI:
   - Backend modal: Real-time warning when 'option httpchk' is typed
   - Frontend modal: Real-time warning when 'option httpchk' is typed
   - Warning messages guide users to use proper fields instead

Changes:
- backend/utils/haproxy_config_parser.py: Added httpchk filtering for frontend parsing
- backend/routers/backend.py: Added filter function + applied to create/update
- backend/routers/frontend.py: Added filter function + applied to create/update
- frontend/src/components/BackendServers.js: Added dynamic warning for httpchk
- frontend/src/components/FrontendManagement.js: Added dynamic warning for httpchk

User Experience:
 Bulk Import: Automatically filters httpchk, shows warning in preview
 Manual Entry: Shows real-time warning, auto-filters on save
 No Config Duplication: 'option httpchk' never appears twice in generated config

Impact: Users can safely paste or type 'option httpchk' without breaking HAProxy config. System automatically filters it and guides users to use the Health Check URI field instead.
This commit is contained in:
taylanbakircioglu
2025-11-12 18:10:40 +03:00
parent 0cf038a428
commit 4272dbb4ef
5 changed files with 135 additions and 10 deletions
+51 -1
View File
@@ -13,6 +13,33 @@ from services.haproxy_config import generate_haproxy_config_for_cluster
router = APIRouter(prefix="/api/backends", tags=["backends", "servers"])
logger = logging.getLogger(__name__)
def filter_httpchk_from_options(options: Optional[str]) -> Optional[str]:
"""
Filter out 'option httpchk' directives from options field.
These should be configured via the health_check_uri field instead to avoid duplication.
Args:
options: Multi-line string containing HAProxy option directives
Returns:
Filtered options string without 'option httpchk' lines, or None if empty
"""
if not options:
return options
# Split by newline, filter out httpchk lines, rejoin
filtered_lines = [
line for line in options.split('\n')
if line.strip() and 'httpchk' not in line.lower()
]
# Return None if no lines remain after filtering
if not filtered_lines:
return None
return '\n'.join(filtered_lines)
async def validate_user_cluster_access(user_id: int, cluster_id: int, conn):
"""Validate that user has access to the specified cluster"""
# Check if cluster exists
@@ -308,6 +335,17 @@ async def get_backends(cluster_id: Optional[int] = None, include_inactive: bool
"balance_method": backend["balance_method"],
"mode": backend["mode"],
"health_check_uri": backend.get("health_check_uri"),
"health_check_interval": backend.get("health_check_interval"),
"health_check_expected_status": backend.get("health_check_expected_status"),
"fullconn": backend.get("fullconn"),
"cookie_name": backend.get("cookie_name"),
"cookie_options": backend.get("cookie_options"),
"default_server_inter": backend.get("default_server_inter"),
"default_server_fall": backend.get("default_server_fall"),
"default_server_rise": backend.get("default_server_rise"),
"request_headers": backend.get("request_headers"),
"response_headers": backend.get("response_headers"),
"options": backend.get("options"),
"timeout_connect": backend.get("timeout_connect"),
"timeout_server": backend.get("timeout_server"),
"timeout_queue": backend.get("timeout_queue"),
@@ -441,6 +479,11 @@ async def create_backend(backend: BackendConfig, authorization: str = Header(Non
await close_database_connection(conn)
raise HTTPException(status_code=400, detail=f"Backend '{backend.name}' already exists")
# Filter out 'option httpchk' from options field (should use health_check_uri instead)
filtered_options = filter_httpchk_from_options(backend.options)
if filtered_options != backend.options and backend.options:
logger.info(f"Backend '{backend.name}': Filtered 'option httpchk' from options field. Use Health Check URI field instead.")
# Insert new backend
backend_id = await conn.fetchval("""
INSERT INTO backends (name, balance_method, mode, health_check_uri, health_check_interval,
@@ -453,7 +496,7 @@ async def create_backend(backend: BackendConfig, authorization: str = Header(Non
backend.health_check_uri, backend.health_check_interval,
backend.health_check_expected_status, backend.fullconn, backend.cookie_name, backend.cookie_options,
backend.default_server_inter, backend.default_server_fall, backend.default_server_rise,
backend.request_headers, backend.response_headers, backend.options,
backend.request_headers, backend.response_headers, filtered_options,
backend.timeout_connect, backend.timeout_server, backend.timeout_queue, backend.cluster_id)
# If cluster_id provided, create new config version for agents
@@ -741,6 +784,13 @@ async def update_backend(backend_id: int, backend_update: BackendConfigUpdate, r
old_backend_name = existing_backend['name']
new_backend_name = update_data.get('name', old_backend_name)
backend_name_changed = old_backend_name != new_backend_name
# Filter out 'option httpchk' from options field if present (should use health_check_uri instead)
if 'options' in update_data and update_data['options']:
filtered_options = filter_httpchk_from_options(update_data['options'])
if filtered_options != update_data['options']:
logger.info(f"Backend '{old_backend_name}': Filtered 'option httpchk' from options field. Use Health Check URI field instead.")
update_data['options'] = filtered_options
for field, value in update_data.items():
if field in ['name', 'balance_method', 'mode', 'health_check_uri', 'health_check_interval',
+39 -2
View File
@@ -13,6 +13,33 @@ from services.haproxy_config import generate_haproxy_config_for_cluster
router = APIRouter(prefix="/api/frontends", tags=["frontends"])
logger = logging.getLogger(__name__)
def filter_httpchk_from_options(options: Optional[str]) -> Optional[str]:
"""
Filter out 'option httpchk' directives from options field.
These are not applicable to frontends (health checks are for backends).
Args:
options: Multi-line string containing HAProxy option directives
Returns:
Filtered options string without 'option httpchk' lines, or None if empty
"""
if not options:
return options
# Split by newline, filter out httpchk lines, rejoin
filtered_lines = [
line for line in options.split('\n')
if line.strip() and 'httpchk' not in line.lower()
]
# Return None if no lines remain after filtering
if not filtered_lines:
return None
return '\n'.join(filtered_lines)
async def validate_user_cluster_access(user_id: int, cluster_id: int, conn):
"""Validate that user has access to the specified cluster"""
# Check if cluster exists
@@ -376,6 +403,11 @@ async def create_frontend(frontend: FrontendConfig, request: Request, authorizat
# Convert ssl_certificate_ids to JSONB for database
ssl_cert_ids_json = json.dumps(frontend.ssl_certificate_ids) if frontend.ssl_certificate_ids else '[]'
# Filter out 'option httpchk' from options field (not applicable to frontends)
filtered_options = filter_httpchk_from_options(frontend.options)
if filtered_options != frontend.options and frontend.options:
logger.info(f"Frontend '{frontend.name}': Filtered 'option httpchk' from options field. Health checks are for backends.")
# Insert new frontend with all form fields
frontend_id = await conn.fetchval("""
INSERT INTO frontends (
@@ -391,7 +423,7 @@ async def create_frontend(frontend: FrontendConfig, request: Request, authorizat
frontend.default_backend, frontend.mode, frontend.ssl_enabled,
frontend.ssl_certificate_id, ssl_cert_ids_json, frontend.ssl_port, frontend.ssl_cert_path, frontend.ssl_cert, frontend.ssl_verify,
json.dumps(frontend.acl_rules or []), json.dumps(frontend.redirect_rules or []), json.dumps(frontend.use_backend_rules or []),
frontend.request_headers, frontend.response_headers, frontend.options, frontend.tcp_request_rules, frontend.timeout_client, frontend.timeout_http_request,
frontend.request_headers, frontend.response_headers, filtered_options, frontend.tcp_request_rules, frontend.timeout_client, frontend.timeout_http_request,
frontend.rate_limit, frontend.compression, frontend.log_separate, frontend.monitor_uri,
frontend.cluster_id, frontend.maxconn)
@@ -623,6 +655,11 @@ async def update_frontend(frontend_id: int, frontend: FrontendConfig, request: R
# ENTERPRISE DUAL-MODE: Save ssl_certificate_ids (NEW) and ssl_certificate_id (OLD - backward compat)
ssl_cert_ids_json = json.dumps(frontend.ssl_certificate_ids) if frontend.ssl_certificate_ids else '[]'
# Filter out 'option httpchk' from options field (not applicable to frontends)
filtered_options = filter_httpchk_from_options(frontend.options)
if filtered_options != frontend.options and frontend.options:
logger.info(f"Frontend '{frontend.name}': Filtered 'option httpchk' from options field. Health checks are for backends.")
# Update frontend with all form fields (using preserved SSL values if needed)
await conn.execute("""
UPDATE frontends SET
@@ -638,7 +675,7 @@ async def update_frontend(frontend_id: int, frontend: FrontendConfig, request: R
frontend.default_backend, frontend.mode, ssl_enabled,
ssl_certificate_id, ssl_cert_ids_json, ssl_port, ssl_cert_path, ssl_cert, ssl_verify,
json.dumps(frontend.acl_rules or []), json.dumps(frontend.redirect_rules or []), json.dumps(frontend.use_backend_rules or []),
frontend.request_headers, frontend.response_headers, frontend.options, frontend.tcp_request_rules, frontend.timeout_client, frontend.timeout_http_request,
frontend.request_headers, frontend.response_headers, filtered_options, frontend.tcp_request_rules, frontend.timeout_client, frontend.timeout_http_request,
frontend.rate_limit, frontend.compression, frontend.log_separate, frontend.monitor_uri,
frontend.cluster_id, frontend.maxconn, frontend_id)
+10 -2
View File
@@ -310,7 +310,8 @@ class HAProxyConfigParser:
frontend.maxconn = int(maxconn_match.group(1))
# Parse options (httplog, forwardfor, etc.) - NEW for frontend support
if line.startswith('option '):
# 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:
@@ -318,7 +319,7 @@ class HAProxyConfigParser:
# List of valid HAProxy frontend options
valid_options = [
'httplog', 'tcplog', 'dontlognull', 'http-keep-alive', 'http-server-close',
'forwardfor', 'redispatch', 'http-use-proxy-header', 'httpchk', 'tcp-check',
'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',
@@ -337,6 +338,13 @@ class HAProxyConfigParser:
# 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 '):
+20 -4
View File
@@ -484,9 +484,11 @@ const BackendServers = () => {
const handleEditBackend = (backend) => {
setEditingBackend(backend);
// Debug: Check if options field exists in backend object
// Debug: Check if multi-line text fields exist in backend object
console.log('🔍 BACKEND EDIT DEBUG: Raw backend data:', backend);
console.log('🔍 BACKEND EDIT DEBUG: Options field:', backend.options);
console.log('🔍 BACKEND EDIT DEBUG: Request headers:', backend.request_headers);
console.log('🔍 BACKEND EDIT DEBUG: Response headers:', backend.response_headers);
backendForm.setFieldsValue({
...backend,
@@ -494,8 +496,10 @@ const BackendServers = () => {
timeout_connect: backend.timeout_connect || 10000,
timeout_server: backend.timeout_server || 60000,
timeout_queue: backend.timeout_queue || 60000,
// Explicitly set options to handle null/undefined cases
options: backend.options || ''
// Explicitly set multi-line text fields to handle null/undefined cases
options: backend.options || '',
request_headers: backend.request_headers || '',
response_headers: backend.response_headers || ''
});
setBackendModalVisible(true);
};
@@ -1792,10 +1796,22 @@ const BackendServers = () => {
name="options"
label="Backend Options"
tooltip="HAProxy backend options (one per line). Examples: option http-keep-alive, option forwardfor, option redispatch"
help={
backendForm.getFieldValue('options') &&
backendForm.getFieldValue('options').toLowerCase().includes('httpchk') ? (
<span style={{ color: '#faad14' }}>
Do not use "option httpchk" here. Use the "Health Check URI" field above instead to avoid duplication.
</span>
) : null
}
>
<Input.TextArea
rows={4}
placeholder="e.g., option http-keep-alive&#10;option forwardfor&#10;option redispatch"
placeholder="e.g., option http-keep-alive&#10;option forwardfor&#10;option redispatch"
onChange={(e) => {
// Force re-render to show/hide warning
backendForm.setFieldsValue({ options: e.target.value });
}}
/>
</Form.Item>
</Col>
+15 -1
View File
@@ -583,7 +583,9 @@ const FrontendManagement = () => {
ssl_certificate_ids: sslCertIds,
acl_rules: formattedAclRules,
redirect_rules: formattedRedirectRules,
use_backend_rules: formattedUseBackendRules
use_backend_rules: formattedUseBackendRules,
// Explicitly set options field to handle null/undefined case (NEW field)
options: frontend.options || ''
});
// Update SSL field visibility after setting values
@@ -1649,6 +1651,14 @@ http-response del-header Server`}
label="Frontend Options"
extra="HAProxy frontend options (one per line)"
tooltip="Examples: option httplog, option forwardfor, option dontlognull, option http-keep-alive"
help={
form.getFieldValue('options') &&
form.getFieldValue('options').toLowerCase().includes('httpchk') ? (
<span style={{ color: '#faad14' }}>
"option httpchk" is not applicable to frontends. Health checks are configured in backend definitions.
</span>
) : null
}
>
<TextArea
rows={4}
@@ -1657,6 +1667,10 @@ option httplog
option forwardfor
option dontlognull
option http-keep-alive`}
onChange={(e) => {
// Force re-render to show/hide warning
form.setFieldsValue({ options: e.target.value });
}}
/>
</Form.Item>
</Col>