import React, { useState, useEffect } from 'react'; import { Card, Table, Button, Modal, Form, Input, Select, Space, message, Popconfirm, Tag, Tooltip, Row, Col, Typography, Divider, InputNumber, Switch, List, Badge, Tabs, Statistic, Alert, Spin, theme } from 'antd'; import { getAgentSyncColor, getConfigStatusColor, getEntityStatusColor } from '../utils/colors'; import EntitySyncStatus from './EntitySyncStatus'; import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined, EyeOutlined, SettingOutlined, CheckCircleOutlined, ExclamationCircleOutlined, UserOutlined, GlobalOutlined, ClockCircleOutlined, FireOutlined, BugOutlined, SecurityScanOutlined, StopOutlined, HistoryOutlined, PlayCircleOutlined } from '@ant-design/icons'; import axios from 'axios'; import { useNavigate } from 'react-router-dom'; import { useCluster } from '../contexts/ClusterContext'; import { VersionHistory } from './VersionHistory'; import { extractApiError } from '../utils/apiError'; // Error Boundary Component class WAFErrorBoundary extends React.Component { constructor(props) { super(props); this.state = { hasError: false, error: null }; } static getDerivedStateFromError(error) { return { hasError: true, error }; } componentDidCatch(error, errorInfo) { console.error('WAF Management Error:', error, errorInfo); } render() { if (this.state.hasError) { return ( window.location.reload()}> Refresh Page } /> ); } return this.props.children; } } const { Title, Text } = Typography; const { Option } = Select; const { TextArea } = Input; const { TabPane } = Tabs; // HAProxy WAF Validation Utilities const WAFValidationUtils = { // Validate IP addresses and CIDR blocks validateIPAddresses: (value) => { if (!value || !value.trim()) return { valid: true }; const lines = value.split('\n').map(line => line.trim()).filter(line => line); const errors = []; lines.forEach((line, index) => { // Check for valid IP or CIDR format const ipRegex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(?:\/(?:[0-9]|[1-2][0-9]|3[0-2]))?$/; if (!ipRegex.test(line)) { errors.push(`Line ${index + 1}: "${line}" is not a valid IP address or CIDR block`); } }); return { valid: errors.length === 0, errors: errors }; }, // Validate HAProxy condition syntax validateHAProxyCondition: (value) => { if (!value || !value.trim()) return { valid: true }; const condition = value.trim(); const errors = []; // Allow complete HAProxy directives if (condition.startsWith('http-request') || condition.startsWith('http-response') || condition.startsWith('acl')) { return { valid: true }; } // Check for HAProxy ACL expression patterns const hasValidKeywords = /\b(req\.|res\.|src|dst|hdr|method|path|body|url)\b/.test(condition); const hasValidOperators = /\b(-m|-f|-i|eq|ne|gt|lt|ge|le)\b/.test(condition); const hasBraces = condition.includes('{') && condition.includes('}'); if (!hasValidKeywords && !hasBraces) { errors.push('Condition should contain HAProxy keywords like req., res., src, dst, hdr, method, path'); } // Check for obviously invalid patterns if (condition.split(' ').length < 2 && !hasBraces) { errors.push('Condition appears too simple. Example: { req.hdr(user-agent) -m sub bot }'); } // Check for dangerous characters that might break config if (/[;&|`$(){}[\]\\]/.test(condition) && !hasBraces) { errors.push('Condition contains potentially dangerous characters. Use proper HAProxy syntax.'); } return { valid: errors.length === 0, errors: errors, examples: [ '{ req.hdr(user-agent) -m sub bot }', '{ src -f /etc/haproxy/whitelist.lst }', '{ req.hdr(host) -m reg ^api\\. }', '{ path -m beg /admin/ }' ] }; }, // Validate regex patterns validateRegexPattern: (value) => { if (!value || !value.trim()) return { valid: true }; try { new RegExp(value); return { valid: true }; } catch (error) { return { valid: false, errors: [`Invalid regex pattern: ${error.message}`], examples: [ '^/admin/', '.*(union|select).*', '\\.(php|asp|jsp)$' ] }; } }, // Validate country codes (ISO 3166-1) validateCountryCodes: (value) => { if (!value || !value.trim()) return { valid: true }; const codes = value.split(',').map(code => code.trim().toUpperCase()).filter(code => code); const errors = []; const validCountryCodes = [ 'AD', 'AE', 'AF', 'AG', 'AI', 'AL', 'AM', 'AO', 'AQ', 'AR', 'AS', 'AT', 'AU', 'AW', 'AX', 'AZ', 'BA', 'BB', 'BD', 'BE', 'BF', 'BG', 'BH', 'BI', 'BJ', 'BL', 'BM', 'BN', 'BO', 'BQ', 'BR', 'BS', 'BT', 'BV', 'BW', 'BY', 'BZ', 'CA', 'CC', 'CD', 'CF', 'CG', 'CH', 'CI', 'CK', 'CL', 'CM', 'CN', 'CO', 'CR', 'CU', 'CV', 'CW', 'CX', 'CY', 'CZ', 'DE', 'DJ', 'DK', 'DM', 'DO', 'DZ', 'EC', 'EE', 'EG', 'EH', 'ER', 'ES', 'ET', 'FI', 'FJ', 'FK', 'FM', 'FO', 'FR', 'GA', 'GB', 'GD', 'GE', 'GF', 'GG', 'GH', 'GI', 'GL', 'GM', 'GN', 'GP', 'GQ', 'GR', 'GS', 'GT', 'GU', 'GW', 'GY', 'HK', 'HM', 'HN', 'HR', 'HT', 'HU', 'ID', 'IE', 'IL', 'IM', 'IN', 'IO', 'IQ', 'IR', 'IS', 'IT', 'JE', 'JM', 'JO', 'JP', 'KE', 'KG', 'KH', 'KI', 'KM', 'KN', 'KP', 'KR', 'KW', 'KY', 'KZ', 'LA', 'LB', 'LC', 'LI', 'LK', 'LR', 'LS', 'LT', 'LU', 'LV', 'LY', 'MA', 'MC', 'MD', 'ME', 'MF', 'MG', 'MH', 'MK', 'ML', 'MM', 'MN', 'MO', 'MP', 'MQ', 'MR', 'MS', 'MT', 'MU', 'MV', 'MW', 'MX', 'MY', 'MZ', 'NA', 'NC', 'NE', 'NF', 'NG', 'NI', 'NL', 'NO', 'NP', 'NR', 'NU', 'NZ', 'OM', 'PA', 'PE', 'PF', 'PG', 'PH', 'PK', 'PL', 'PM', 'PN', 'PR', 'PS', 'PT', 'PW', 'PY', 'QA', 'RE', 'RO', 'RS', 'RU', 'RW', 'SA', 'SB', 'SC', 'SD', 'SE', 'SG', 'SH', 'SI', 'SJ', 'SK', 'SL', 'SM', 'SN', 'SO', 'SR', 'SS', 'ST', 'SV', 'SX', 'SY', 'SZ', 'TC', 'TD', 'TF', 'TG', 'TH', 'TJ', 'TK', 'TL', 'TM', 'TN', 'TO', 'TR', 'TT', 'TV', 'TW', 'TZ', 'UA', 'UG', 'UM', 'US', 'UY', 'UZ', 'VA', 'VC', 'VE', 'VG', 'VI', 'VN', 'VU', 'WF', 'WS', 'YE', 'YT', 'ZA', 'ZM', 'ZW' ]; codes.forEach(code => { if (code.length !== 2) { errors.push(`"${code}" should be 2 characters (ISO 3166-1 format)`); } else if (!validCountryCodes.includes(code)) { errors.push(`"${code}" is not a valid ISO 3166-1 country code`); } }); return { valid: errors.length === 0, errors: errors, examples: ['CN,RU,KP', 'US,CA', 'DE,FR,IT'] }; }, // Validate size values (bytes) validateSizeValue: (value, fieldName = 'Size') => { if (!value) return { valid: true }; const numValue = parseInt(value); const errors = []; if (isNaN(numValue) || numValue <= 0) { errors.push(`${fieldName} must be a positive number`); } else if (numValue > 2147483647) { // 2GB limit for HAProxy errors.push(`${fieldName} cannot exceed 2GB (2147483647 bytes)`); } else if (numValue < 1024 && fieldName.includes('Size')) { errors.push(`${fieldName} should be at least 1024 bytes (1KB) for practical use`); } return { valid: errors.length === 0, errors: errors, examples: ['1048576 (1MB)', '10485760 (10MB)', '104857600 (100MB)'] }; }, // Validate rate limit values validateRateLimit: (requests, window) => { const errors = []; if (requests && (isNaN(requests) || requests <= 0 || requests > 10000)) { errors.push('Max Requests should be between 1 and 10000'); } if (window && (isNaN(window) || window < 1 || window > 3600)) { errors.push('Time Window should be between 1 and 3600 seconds'); } if (requests && window && requests > window * 100) { errors.push('Max Requests seems too high for the time window (max ~100 req/sec)'); } return { valid: errors.length === 0, errors: errors }; } }; const WAFManagement = () => { const { token } = theme.useToken(); const { selectedCluster } = useCluster(); const navigate = useNavigate(); const [rules, setRules] = useState([]); const [stats, setStats] = useState({}); const [loading, setLoading] = useState(false); const [applyLoading, setApplyLoading] = useState(false); const [pendingChanges, setPendingChanges] = useState(false); const [versionModalVisible, setVersionModalVisible] = useState(false); const [selectedEntityForVersion, setSelectedEntityForVersion] = useState(null); const [modalVisible, setModalVisible] = useState(false); const [statsModalVisible, setStatsModalVisible] = useState(false); const [editingRule, setEditingRule] = useState(null); const [selectedRuleType, setSelectedRuleType] = useState('rate_limit'); const [form] = Form.useForm(); const [searchText, setSearchText] = useState(''); const [filteredRules, setFilteredRules] = useState([]); const [frontends, setFrontends] = useState([]); const [showPending, setShowPending] = useState(true); // Default TRUE: users must see their changes const [showRejected, setShowRejected] = useState(true); // Default TRUE: users must see rejected items const [refreshKey, setRefreshKey] = useState(0); // Persist toggle states across navigation useEffect(() => { const sp = localStorage.getItem('waf:showPending'); const sr = localStorage.getItem('waf:showRejected'); // Default to true for showPending if not set in localStorage if (sp !== null) setShowPending(sp === 'true'); else setShowPending(true); // Show PENDING by default if (sr !== null) setShowRejected(sr === 'true'); }, []); const onToggleShowPending = (checked) => { setShowPending(checked); localStorage.setItem('waf:showPending', String(checked)); }; const onToggleShowRejected = (checked) => { setShowRejected(checked); localStorage.setItem('waf:showRejected', String(checked)); }; useEffect(() => { // CRITICAL FIX: Clear state when cluster changes to prevent showing other cluster's data if (selectedCluster) { setRules([]); setFilteredRules([]); setFrontends([]); } fetchRules(); fetchStats(); checkPendingChanges(); fetchFrontends(); }, [selectedCluster]); useEffect(() => { setFilteredRules(applyStatusFilters(rules)); }, [rules, showPending, showRejected]); const handleSearch = (value) => { setSearchText(value); if (!value) { setFilteredRules(applyStatusFilters(rules)); } else { const filtered = rules.filter(rule => rule.name.toLowerCase().includes(value.toLowerCase()) || rule.rule_type.toLowerCase().includes(value.toLowerCase()) || rule.description?.toLowerCase().includes(value.toLowerCase()) || rule.action.toLowerCase().includes(value.toLowerCase()) ); setFilteredRules(applyStatusFilters(filtered)); } }; const applyStatusFilters = (items) => { return (items || []).filter(item => { const isPending = !!item.has_pending_config; const status = item.config_status || (isPending ? 'PENDING' : 'APPLIED'); if (!showPending && isPending) return false; if (!showRejected && status === 'REJECTED') return false; return true; }); }; const fetchRules = async () => { if (!selectedCluster) return; setLoading(true); try { const response = await axios.get(`/api/waf/rules?cluster_id=${selectedCluster.id}`); const fetchedRules = response.data.rules || []; setRules(fetchedRules); // Apply filters immediately to maintain consistency setFilteredRules(applyStatusFilters(fetchedRules)); } catch (error) { message.error('Failed to fetch WAF rules: ' + (extractApiError(error, error.message))); } finally { setLoading(false); } }; const fetchFrontends = async () => { if (!selectedCluster) return; try { const response = await axios.get(`/api/frontends?cluster_id=${selectedCluster.id}`, { headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache' } }); setFrontends(response.data.frontends || []); } catch (error) { console.error('Failed to fetch frontends:', error); } }; const fetchStats = async () => { try { const response = await axios.get('/api/waf/stats'); setStats(response.data); } catch (error) { console.error('Failed to fetch WAF stats:', error); } }; const fetchEntityAgentSync = async (entityType, entityId) => { if (!selectedCluster) return null; try { const token = localStorage.getItem('token'); const response = await axios.get(`/api/clusters/${selectedCluster.id}/entity-sync/${entityType}/${entityId}`, { headers: { Authorization: `Bearer ${token}` } }); return response.data; } catch (error) { console.error(`Failed to fetch entity sync for ${entityType}/${entityId}:`, error); return null; } }; // Check for pending configuration changes const checkPendingChanges = async () => { if (!selectedCluster) return; try { console.log('๐ŸŽฏ WAF APPLY DEBUG: Checking pending changes for cluster:', selectedCluster.id); const response = await axios.get(`/api/clusters/${selectedCluster.id}/config-versions`, { headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } }); const versions = response.data.versions || []; const hasPending = versions.some(version => version.status === 'PENDING'); console.log('๐ŸŽฏ WAF APPLY DEBUG: Has pending changes:', hasPending); setPendingChanges(hasPending); } catch (error) { console.error('๐ŸŽฏ WAF APPLY DEBUG: Failed to check pending changes:', error); } }; // Apply pending configuration changes const handleApplyChanges = async () => { if (!selectedCluster) return; setApplyLoading(true); try { const token = localStorage.getItem('token'); if (!token || token === 'null' || token.trim() === '') { message.error('Authentication required. Please login again.'); return; } const response = await axios.post( `/api/clusters/${selectedCluster.id}/apply-changes`, {}, { headers: { 'Authorization': `Bearer ${token}` } } ); message.success(response.data.message); // Refresh pending changes status from server await checkPendingChanges(); // Also refresh the WAF rules list to update Config Status column fetchRules(); // Show sync results if available if (response.data.sync_results && response.data.sync_results.length > 0) { const agentResults = response.data.sync_results.filter(r => r.success); if (agentResults.length > 0) { message.info(`Configuration published. ${agentResults.length} agent(s) notified, sync in progress.`); } } } catch (error) { console.error('WAF Apply changes failed:', error); if (error.response?.status === 401) { message.error('Authentication failed. Please login again.'); } else if (error.response?.data?.error === 'COMPREHENSIVE_VALIDATION_FAILED') { // Handle comprehensive validation errors from API const validationErrors = error.response.data.validation_errors || []; Modal.error({ title: '๐Ÿšซ Configuration Validation Failed', content: (

Multiple configuration issues detected!

{error.response.data.message}

{validationErrors.map((error, index) => (

{error.type === 'BACKENDS_WITHOUT_SERVERS' && '๐Ÿ”ธ Backends Missing Servers'} {error.type === 'UNUSED_BACKENDS' && '๐Ÿ”ธ Unused Backends'} {error.type === 'FRONTENDS_WITH_MISSING_BACKENDS' && '๐Ÿ”ธ Frontend โ†’ Backend Mismatch'} {error.type === 'NO_FRONTENDS' && '๐Ÿ”ธ No Frontends Defined'}

{error.message}

Items:

    {error.items.map((item, idx) => (
  • {item}
  • ))}

๐Ÿ’ก Solution: {error.solution}

))}

๐ŸŽฏ Quick Actions:

), width: 800, okText: 'I Understand', okType: 'primary' }); } else if (error.response?.data?.error === 'BACKEND_VALIDATION_FAILED') { // Legacy: Handle old backend validation error from API const backendNames = error.response.data.details?.backends_without_servers || []; Modal.error({ title: '๐Ÿšซ Configuration Apply Failed', content: (

Backend validation failed!

{error.response.data.message}


Backends without servers:

โœ… Solution: Go to Backend Management and add servers to these backends before applying WAF changes.


), width: 600, okText: 'I Understand', okType: 'default' }); } else { message.error(`Failed to apply changes: ${extractApiError(error, error.message)}`); } } finally { setApplyLoading(false); } }; // Version history modal handlers const handleShowVersionHistory = (record) => { if (record && record.id) { setSelectedEntityForVersion({ entityType: 'waf', entityId: record.id }); setVersionModalVisible(true); } else { // For global version history button (if no record passed) if (selectedCluster) { setSelectedEntityForVersion({ entityType: 'cluster', entityId: selectedCluster.id }); setVersionModalVisible(true); } else { message.warning('Please select a cluster first'); } } }; const handleVersionModalCancel = () => { setVersionModalVisible(false); setSelectedEntityForVersion(null); }; const handleRestoreSuccess = (restoreData) => { // Refresh pending changes status after successful restore checkPendingChanges(); // Refresh WAF rules list to show updated data fetchRules(); message.info(
Configuration restored as PENDING
Use "Apply Changes" button to activate the restored configuration
, 4 ); }; const handleAdd = () => { form.resetFields(); setEditingRule(null); setSelectedRuleType('rate_limit'); setModalVisible(true); }; const handleEdit = (rule) => { // DEBUG: Log rule data to identify why form fields are empty console.log('WAF Edit Debug - Rule data:', rule); console.log('WAF Edit Debug - Rule config:', rule.config); console.log('WAF Edit Debug - Frontend IDs:', rule.frontend_ids); const formValues = { name: rule.name, rule_type: rule.rule_type, action: rule.action, priority: rule.priority, is_active: rule.is_active, description: rule.description, frontend_ids: rule.frontend_ids, ...rule.config, }; console.log('WAF Edit Debug - Form values being set:', formValues); form.setFieldsValue(formValues); setEditingRule(rule); setSelectedRuleType(rule.rule_type); setModalVisible(true); }; const handleDelete = async (ruleId) => { try { // Send delete action to backend await axios.post(`/api/waf/rules/${ruleId}/toggle`, null, { params: { action: 'delete', cluster_id: selectedCluster?.id } }); message.success('WAF rule marked for deletion. Go to Apply Changes to remove it from agents.'); fetchRules(); checkPendingChanges(); } catch (error) { message.error('Failed to delete rule: ' + (extractApiError(error, error.message))); } }; const handleToggle = async (ruleId) => { try { await axios.post(`/api/waf/rules/${ruleId}/toggle`, null, { params: { cluster_id: selectedCluster?.id } }); message.success('WAF rule status updated'); fetchRules(); checkPendingChanges(); } catch (error) { message.error(`Failed to toggle rule: ${extractApiError(error, '')}`); } }; const handleSubmit = async (values) => { try { // Consolidate all rule-specific fields into a single 'config' object const { name, rule_type, action, priority, is_active, description, frontend_ids, ...config } = values; // Clean up ip_addresses from textarea if (config.ip_addresses) { config.ip_addresses = config.ip_addresses.split('\n').map(ip => ip.trim()).filter(ip => ip); } // Clean up countries from input if (config.countries) { config.countries = config.countries.split(',').map(c => c.trim()).filter(c => c); } const ruleData = { name, rule_type, action, priority, is_active, description, frontend_ids, config, }; if (editingRule) { await axios.put(`/api/waf/rules/${editingRule.id}`, ruleData, { params: { cluster_id: selectedCluster?.id } }); message.success('WAF rule updated successfully. Go to Apply Changes to activate.'); } else { await axios.post('/api/waf/rules', ruleData, { params: { cluster_id: selectedCluster?.id } }); message.success('WAF rule created successfully. Go to Apply Changes to activate.'); } setModalVisible(false); fetchRules(); checkPendingChanges(); } catch (error) { message.error('Failed to save rule: ' + (extractApiError(error, error.message))); } }; const getRuleTypeIcon = (type) => { const icons = { 'rate_limit': , 'ip_filter': , 'header_filter': , 'request_filter': , 'geo_block': , 'size_limit': , }; return icons[type] || ; }; const getActionColor = (action) => { const colors = { 'block': 'red', 'allow': 'green', 'log': 'blue', 'redirect': 'orange', }; return colors[action] || 'default'; }; const renderRuleConfig = (rule) => { switch (rule.rule_type) { case 'rate_limit': return `${rule.config.rate_limit_requests} req/${rule.config.rate_limit_window}s`; case 'ip_filter': return `${rule.config.ip_action}: ${rule.config.ip_addresses?.length || 0} IPs`; case 'header_filter': return `${rule.config.header_name}: ${rule.config.header_condition}`; case 'request_filter': return `${rule.config.http_method} ${rule.config.path_pattern}`; case 'geo_block': return `${rule.config.geo_action}: ${rule.config.countries?.length || 0} countries`; case 'size_limit': return `Max: ${rule.config.max_request_size ? (rule.config.max_request_size / 1024 / 1024).toFixed(1) + 'MB' : 'N/A'}`; default: return '-'; } }; const columns = [ { title: 'Rule', dataIndex: 'name', key: 'name', render: (text, record) => ( {getRuleTypeIcon(record.rule_type)}
{text}
Priority: {record.priority}
), }, { title: 'Sync Status', key: 'sync_status', render: (_, record) => ( ), }, { title: 'Type', dataIndex: 'rule_type', key: 'rule_type', render: (type) => ( {type.replace('_', ' ').toUpperCase()} ), }, { title: 'Action', dataIndex: 'action', key: 'action', render: (action) => ( {action.toUpperCase()} ), }, { title: 'Configuration', key: 'config', render: (_, record) => ( {renderRuleConfig(record)} ), }, { title: 'Frontend Usage', dataIndex: 'frontend_count', key: 'frontend_count', render: (count) => ( 0 ? 'green' : 'default'}>{count} ), }, { title: 'Status', dataIndex: 'is_active', key: 'is_active', render: (isActive, record) => ( handleToggle(record.id)} /> ), }, { title: 'Created', dataIndex: 'created_at', key: 'created_at', render: (date) => date ? new Date(date).toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '-', }, { title: 'Last Update', dataIndex: 'updated_at', key: 'updated_at', render: (date) => date ? new Date(date).toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '-', }, { title: 'Config Status', key: 'config_status', render: (_, record) => { const status = record.last_config_status || (record.has_pending_config ? 'PENDING' : 'APPLIED'); const color = getConfigStatusColor(status); return ( {status} ); }, }, { title: 'Actions', key: 'actions', render: (_, record) => ( {(record.has_pending_config || false) && ( )}