diff --git a/backend/routers/backend.py b/backend/routers/backend.py index 0b51ceb..a7256b1 100644 --- a/backend/routers/backend.py +++ b/backend/routers/backend.py @@ -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', diff --git a/backend/routers/frontend.py b/backend/routers/frontend.py index 12dcb0a..3d47e05 100644 --- a/backend/routers/frontend.py +++ b/backend/routers/frontend.py @@ -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) diff --git a/backend/utils/haproxy_config_parser.py b/backend/utils/haproxy_config_parser.py index 5ae42b5..8bda456 100644 --- a/backend/utils/haproxy_config_parser.py +++ b/backend/utils/haproxy_config_parser.py @@ -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 '): diff --git a/frontend/src/components/BackendServers.js b/frontend/src/components/BackendServers.js index 2c2a188..c03c136 100644 --- a/frontend/src/components/BackendServers.js +++ b/frontend/src/components/BackendServers.js @@ -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') ? ( + + ⚠️ Do not use "option httpchk" here. Use the "Health Check URI" field above instead to avoid duplication. + + ) : null + } > { + // Force re-render to show/hide warning + backendForm.setFieldsValue({ options: e.target.value }); + }} /> diff --git a/frontend/src/components/FrontendManagement.js b/frontend/src/components/FrontendManagement.js index 7c4eab2..9149674 100644 --- a/frontend/src/components/FrontendManagement.js +++ b/frontend/src/components/FrontendManagement.js @@ -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') ? ( + + ⚠️ "option httpchk" is not applicable to frontends. Health checks are configured in backend definitions. + + ) : null + } >