mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-09-21 10:03:29 +00:00
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:
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 '):
|
||||
|
||||
@@ -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 option forwardfor option redispatch"
|
||||
placeholder="e.g., option http-keep-alive option forwardfor option redispatch"
|
||||
onChange={(e) => {
|
||||
// Force re-render to show/hide warning
|
||||
backendForm.setFieldsValue({ options: e.target.value });
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user