/** * FirewallPanel — Firewall rule editor with visual builder, table view, * action color coding, rule reordering, and NAT management. * * CFG-03: Displays filter rules and NAT rules in separate sub-tabs, * provides a visual rule builder form, and supports move up/down reordering. */ import { useState, useCallback } from 'react' import { toast } from 'sonner' import { Plus, MoreHorizontal, Pencil, Trash2, Eye, EyeOff, Shield, Network, } from 'lucide-react' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Checkbox } from '@/components/ui/checkbox' import { Skeleton } from '@/components/ui/skeleton' import { Dialog, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription, } from '@/components/ui/dialog' import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue, } from '@/components/ui/select' import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, } from '@/components/ui/dropdown-menu' import { cn } from '@/lib/utils' import { configEditorApi } from '@/lib/configEditorApi' import { useConfigBrowse, useConfigPanel } from '@/hooks/useConfigPanel' import { SafetyToggle } from '@/components/config/SafetyToggle' import { ChangePreviewModal } from '@/components/config/ChangePreviewModal' import type { ConfigPanelProps, ConfigChange } from '@/lib/configPanelTypes' // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- type SubTab = 'filter' | 'nat' type FilterAction = 'accept' | 'drop' | 'reject' | 'log' | 'jump' | 'passthrough' type FilterChain = 'input' | 'forward' | 'output' type NatChain = 'srcnat' | 'dstnat' type NatAction = 'masquerade' | 'dst-nat' | 'src-nat' | 'redirect' | 'netmap' type Protocol = 'tcp' | 'udp' | 'icmp' | '' interface FilterFormData { chain: FilterChain action: FilterAction protocol: Protocol 'src-address': string 'dst-address': string 'src-port': string 'dst-port': string 'in-interface': string 'out-interface': string comment: string disabled: boolean } interface NatFormData { chain: NatChain action: NatAction protocol: Protocol 'src-address': string 'dst-address': string 'src-port': string 'dst-port': string 'to-addresses': string 'to-ports': string comment: string disabled: boolean } type FormMode = 'add' | 'edit' // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const FILTER_PATH = '/ip/firewall/filter' const NAT_PATH = '/ip/firewall/nat' const FILTER_CHAINS: FilterChain[] = ['input', 'forward', 'output'] const FILTER_ACTIONS: FilterAction[] = ['accept', 'drop', 'reject', 'log', 'jump', 'passthrough'] const NAT_CHAINS: NatChain[] = ['srcnat', 'dstnat'] const NAT_ACTIONS: NatAction[] = ['masquerade', 'dst-nat', 'src-nat', 'redirect', 'netmap'] const PROTOCOLS: Protocol[] = ['tcp', 'udp', 'icmp', ''] const DEFAULT_FILTER_FORM: FilterFormData = { chain: 'input', action: 'accept', protocol: '', 'src-address': '', 'dst-address': '', 'src-port': '', 'dst-port': '', 'in-interface': '', 'out-interface': '', comment: '', disabled: false, } const DEFAULT_NAT_FORM: NatFormData = { chain: 'srcnat', action: 'masquerade', protocol: '', 'src-address': '', 'dst-address': '', 'src-port': '', 'dst-port': '', 'to-addresses': '', 'to-ports': '', comment: '', disabled: false, } // --------------------------------------------------------------------------- // Color helpers // --------------------------------------------------------------------------- function getFilterActionColor(action: string): string { switch (action) { case 'accept': return 'border-l-4 border-l-success/60' case 'drop': return 'border-l-4 border-l-error/60' case 'reject': return 'border-l-4 border-l-warning/60' case 'log': return 'border-l-4 border-l-info/60' default: return 'border-l-4 border-l-border' } } function getNatActionColor(action: string): string { switch (action) { case 'masquerade': return 'border-l-4 border-l-success/60' case 'dst-nat': return 'border-l-4 border-l-info/60' case 'src-nat': return 'border-l-4 border-l-warning/60' default: return 'border-l-4 border-l-border' } } function getActionBadgeClasses(action: string): string { switch (action) { case 'accept': case 'masquerade': return 'bg-success/15 text-success border-success/30' case 'drop': return 'bg-error/15 text-error border-error/30' case 'reject': return 'bg-warning/15 text-warning border-warning/30' case 'log': case 'dst-nat': return 'bg-info/15 text-info border-info/30' case 'src-nat': case 'redirect': return 'bg-warning/15 text-warning border-warning/30' default: return 'bg-elevated text-text-secondary border-border' } } // --------------------------------------------------------------------------- // Validation helpers // --------------------------------------------------------------------------- const IP_CIDR_REGEX = /^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$/ const PORT_RANGE_REGEX = /^\d{1,5}(-\d{1,5})?$/ function validateIpCidr(value: string): string | null { if (!value) return null if (!IP_CIDR_REGEX.test(value)) { return 'Invalid IP address or CIDR notation (e.g., 192.168.1.0/24)' } const parts = value.split('/')[0].split('.') if (parts.some((p) => parseInt(p, 10) > 255)) { return 'IP octets must be 0-255' } const cidr = value.split('/')[1] if (cidr && (parseInt(cidr, 10) < 0 || parseInt(cidr, 10) > 32)) { return 'CIDR prefix must be 0-32' } return null } function validatePort(value: string): string | null { if (!value) return null if (!PORT_RANGE_REGEX.test(value)) { return 'Invalid port (number or range e.g., 80 or 1024-65535)' } const parts = value.split('-').map((p) => parseInt(p, 10)) if (parts.some((p) => p < 0 || p > 65535)) { return 'Port must be 0-65535' } if (parts.length === 2 && parts[0] > parts[1]) { return 'Start port must be less than end port' } return null } // --------------------------------------------------------------------------- // FirewallPanel // --------------------------------------------------------------------------- export function FirewallPanel({ tenantId, deviceId, active }: ConfigPanelProps) { const [subTab, setSubTab] = useState('filter') const [previewOpen, setPreviewOpen] = useState(false) // Data loading const filterQuery = useConfigBrowse(tenantId, deviceId, FILTER_PATH, { enabled: active }) const natQuery = useConfigBrowse(tenantId, deviceId, NAT_PATH, { enabled: active }) // Config panel (change management + apply) const panel = useConfigPanel(tenantId, deviceId, 'firewall') // Filter rule form state const [filterFormOpen, setFilterFormOpen] = useState(false) const [filterFormMode, setFilterFormMode] = useState('add') const [filterFormData, setFilterFormData] = useState(DEFAULT_FILTER_FORM) const [filterEditId, setFilterEditId] = useState(null) // NAT rule form state const [natFormOpen, setNatFormOpen] = useState(false) const [natFormMode, setNatFormMode] = useState('add') const [natFormData, setNatFormData] = useState(DEFAULT_NAT_FORM) const [natEditId, setNatEditId] = useState(null) // Move in progress const [movingId, setMovingId] = useState(null) // ------------------------------------------------------------------------- // Filter rule handlers // ------------------------------------------------------------------------- const openAddFilter = useCallback(() => { setFilterFormMode('add') setFilterFormData(DEFAULT_FILTER_FORM) setFilterEditId(null) setFilterFormOpen(true) }, []) const openEditFilter = useCallback((entry: Record) => { setFilterFormMode('edit') setFilterFormData({ chain: (entry.chain || 'input') as FilterChain, action: (entry.action || 'accept') as FilterAction, protocol: (entry.protocol || '') as Protocol, 'src-address': entry['src-address'] || '', 'dst-address': entry['dst-address'] || '', 'src-port': entry['src-port'] || '', 'dst-port': entry['dst-port'] || '', 'in-interface': entry['in-interface'] || '', 'out-interface': entry['out-interface'] || '', comment: entry.comment || '', disabled: entry.disabled === 'true', }) setFilterEditId(entry['.id'] || null) setFilterFormOpen(true) }, []) const submitFilterForm = useCallback(() => { // Validate const srcErr = validateIpCidr(filterFormData['src-address']) const dstErr = validateIpCidr(filterFormData['dst-address']) const srcPortErr = validatePort(filterFormData['src-port']) const dstPortErr = validatePort(filterFormData['dst-port']) const errors = [srcErr, dstErr, srcPortErr, dstPortErr].filter(Boolean) if (errors.length > 0) { toast.error('Validation error', { description: errors[0]! }) return } // Build properties (only include non-empty values) const props: Record = {} props.chain = filterFormData.chain props.action = filterFormData.action if (filterFormData.protocol) props.protocol = filterFormData.protocol if (filterFormData['src-address']) props['src-address'] = filterFormData['src-address'] if (filterFormData['dst-address']) props['dst-address'] = filterFormData['dst-address'] if (filterFormData['src-port']) props['src-port'] = filterFormData['src-port'] if (filterFormData['dst-port']) props['dst-port'] = filterFormData['dst-port'] if (filterFormData['in-interface']) props['in-interface'] = filterFormData['in-interface'] if (filterFormData['out-interface']) props['out-interface'] = filterFormData['out-interface'] if (filterFormData.comment) props.comment = filterFormData.comment if (filterFormData.disabled) props.disabled = 'true' const change: ConfigChange = { operation: filterFormMode === 'add' ? 'add' : 'set', path: FILTER_PATH, entryId: filterFormMode === 'edit' ? filterEditId ?? undefined : undefined, properties: props, description: filterFormMode === 'add' ? `Add ${filterFormData.action} rule on ${filterFormData.chain} chain` : `Edit filter rule ${filterEditId ?? ''}`, } panel.addChange(change) setFilterFormOpen(false) toast.success(`Filter rule ${filterFormMode === 'add' ? 'added' : 'updated'} in pending changes`) }, [filterFormData, filterFormMode, filterEditId, panel]) // ------------------------------------------------------------------------- // NAT rule handlers // ------------------------------------------------------------------------- const openAddNat = useCallback(() => { setNatFormMode('add') setNatFormData(DEFAULT_NAT_FORM) setNatEditId(null) setNatFormOpen(true) }, []) const openEditNat = useCallback((entry: Record) => { setNatFormMode('edit') setNatFormData({ chain: (entry.chain || 'srcnat') as NatChain, action: (entry.action || 'masquerade') as NatAction, protocol: (entry.protocol || '') as Protocol, 'src-address': entry['src-address'] || '', 'dst-address': entry['dst-address'] || '', 'src-port': entry['src-port'] || '', 'dst-port': entry['dst-port'] || '', 'to-addresses': entry['to-addresses'] || '', 'to-ports': entry['to-ports'] || '', comment: entry.comment || '', disabled: entry.disabled === 'true', }) setNatEditId(entry['.id'] || null) setNatFormOpen(true) }, []) const submitNatForm = useCallback(() => { // Validate const srcErr = validateIpCidr(natFormData['src-address']) const dstErr = validateIpCidr(natFormData['dst-address']) const srcPortErr = validatePort(natFormData['src-port']) const dstPortErr = validatePort(natFormData['dst-port']) const errors = [srcErr, dstErr, srcPortErr, dstPortErr].filter(Boolean) if (errors.length > 0) { toast.error('Validation error', { description: errors[0]! }) return } const props: Record = {} props.chain = natFormData.chain props.action = natFormData.action if (natFormData.protocol) props.protocol = natFormData.protocol if (natFormData['src-address']) props['src-address'] = natFormData['src-address'] if (natFormData['dst-address']) props['dst-address'] = natFormData['dst-address'] if (natFormData['src-port']) props['src-port'] = natFormData['src-port'] if (natFormData['dst-port']) props['dst-port'] = natFormData['dst-port'] if (natFormData['to-addresses']) props['to-addresses'] = natFormData['to-addresses'] if (natFormData['to-ports']) props['to-ports'] = natFormData['to-ports'] if (natFormData.comment) props.comment = natFormData.comment if (natFormData.disabled) props.disabled = 'true' const change: ConfigChange = { operation: natFormMode === 'add' ? 'add' : 'set', path: NAT_PATH, entryId: natFormMode === 'edit' ? natEditId ?? undefined : undefined, properties: props, description: natFormMode === 'add' ? `Add ${natFormData.action} NAT rule on ${natFormData.chain} chain` : `Edit NAT rule ${natEditId ?? ''}`, } panel.addChange(change) setNatFormOpen(false) toast.success(`NAT rule ${natFormMode === 'add' ? 'added' : 'updated'} in pending changes`) }, [natFormData, natFormMode, natEditId, panel]) // ------------------------------------------------------------------------- // Shared rule actions // ------------------------------------------------------------------------- const handleToggleDisable = useCallback( (entry: Record, path: string) => { const isDisabled = entry.disabled === 'true' const change: ConfigChange = { operation: 'set', path, entryId: entry['.id'], properties: { disabled: isDisabled ? 'false' : 'true' }, description: `${isDisabled ? 'Enable' : 'Disable'} rule ${entry['.id'] ?? ''}`, } panel.addChange(change) toast.success(`Rule ${isDisabled ? 'enable' : 'disable'} added to pending changes`) }, [panel], ) const handleDeleteRule = useCallback( (entry: Record, path: string) => { const change: ConfigChange = { operation: 'remove', path, entryId: entry['.id'], properties: {}, description: `Delete rule ${entry['.id'] ?? ''} (${entry.action || 'unknown'} on ${entry.chain || 'unknown'})`, } panel.addChange(change) toast.success('Rule deletion added to pending changes') }, [panel], ) const handleMoveRule = useCallback( async (entry: Record, index: number, direction: 'up' | 'down') => { const ruleId = entry['.id'] if (!ruleId) return const destination = direction === 'up' ? index - 1 : index + 1 if (destination < 0) return setMovingId(ruleId) try { const command = `${FILTER_PATH}/move .id=${ruleId} destination=${destination}` const result = await configEditorApi.execute(tenantId, deviceId, command) if (!result.success) { throw new Error(result.error ?? 'Move failed') } toast.success(`Rule moved ${direction}`) filterQuery.refetch() } catch (err) { toast.error('Failed to move rule', { description: err instanceof Error ? err.message : 'Unknown error', }) } finally { setMovingId(null) } }, [tenantId, deviceId, filterQuery], ) // ------------------------------------------------------------------------- // Apply flow // ------------------------------------------------------------------------- const handleApply = useCallback(() => { panel.applyChanges() setPreviewOpen(false) }, [panel]) // ------------------------------------------------------------------------- // Render // ------------------------------------------------------------------------- return (
{/* Header bar */}
{/* Sub-tabs */}
{/* Safety toggle + apply */}
{/* Tables */} {subTab === 'filter' ? ( handleToggleDisable(e, FILTER_PATH)} onDelete={(e) => handleDeleteRule(e, FILTER_PATH)} onMoveUp={(e, i) => handleMoveRule(e, i, 'up')} onMoveDown={(e, i) => handleMoveRule(e, i, 'down')} movingId={movingId} /> ) : ( handleToggleDisable(e, NAT_PATH)} onDelete={(e) => handleDeleteRule(e, NAT_PATH)} /> )} {/* Filter Rule Builder Dialog */} {/* NAT Rule Builder Dialog */} {/* Change Preview Modal */}
) } // =========================================================================== // Filter Rules Table // =========================================================================== interface FilterRulesTableProps { entries: Record[] isLoading: boolean error: Error | null onRetry: () => void onAdd: () => void onEdit: (entry: Record) => void onToggleDisable: (entry: Record) => void onDelete: (entry: Record) => void onMoveUp: (entry: Record, index: number) => void onMoveDown: (entry: Record, index: number) => void movingId: string | null } function FilterRulesTable({ entries, isLoading, error, onRetry, onAdd, onEdit, onToggleDisable, onDelete, onMoveUp, onMoveDown, movingId, }: FilterRulesTableProps) { if (error) { return (

Failed to load filter rules: {error.message}

) } return (

Filter Rules ({isLoading ? '...' : entries.length})

{isLoading ? ( ) : entries.length === 0 ? ( ) : ( entries.map((entry, index) => { const isDisabled = entry.disabled === 'true' const isMoving = movingId === entry['.id'] return ( ) }) )}
# Chain Action Src Address Dst Address Protocol Dst Port In Iface Out Iface Comment Dis
No filter rules configured
{index + 1} {entry.chain || } {entry.action || 'unknown'} {entry['src-address'] || } {entry['dst-address'] || } {entry.protocol || } {entry['dst-port'] || } {entry['in-interface'] || } {entry['out-interface'] || } {entry.comment || } {isDisabled ? ( ) : null} onEdit(entry)}> Edit onToggleDisable(entry)}> {isDisabled ? ( <> Enable ) : ( <> Disable )} onMoveUp(entry, index)} disabled={index === 0 || isMoving} > Move Up onMoveDown(entry, index)} disabled={index === entries.length - 1 || isMoving} > Move Down onDelete(entry)} className="text-error focus:text-error" > Delete
) } // =========================================================================== // NAT Rules Table // =========================================================================== interface NatRulesTableProps { entries: Record[] isLoading: boolean error: Error | null onRetry: () => void onAdd: () => void onEdit: (entry: Record) => void onToggleDisable: (entry: Record) => void onDelete: (entry: Record) => void } function NatRulesTable({ entries, isLoading, error, onRetry, onAdd, onEdit, onToggleDisable, onDelete, }: NatRulesTableProps) { if (error) { return (

Failed to load NAT rules: {error.message}

) } return (

NAT Rules ({isLoading ? '...' : entries.length})

{isLoading ? ( ) : entries.length === 0 ? ( ) : ( entries.map((entry, index) => { const isDisabled = entry.disabled === 'true' return ( ) }) )}
# Chain Action Src Address Dst Address Protocol Dst Port To Addresses To Ports Comment Dis
No NAT rules configured
{index + 1} {entry.chain || } {entry.action || 'unknown'} {entry['src-address'] || } {entry['dst-address'] || } {entry.protocol || } {entry['dst-port'] || } {entry['to-addresses'] || } {entry['to-ports'] || } {entry.comment || } {isDisabled ? ( ) : null} onEdit(entry)}> Edit onToggleDisable(entry)}> {isDisabled ? ( <> Enable ) : ( <> Disable )} onDelete(entry)} className="text-error focus:text-error" > Delete
) } // =========================================================================== // Filter Rule Dialog (Visual Builder) // =========================================================================== interface FilterRuleDialogProps { open: boolean onOpenChange: (open: boolean) => void mode: FormMode data: FilterFormData onChange: (data: FilterFormData) => void onSubmit: () => void } function FilterRuleDialog({ open, onOpenChange, mode, data, onChange, onSubmit, }: FilterRuleDialogProps) { const updateField = (key: K, value: FilterFormData[K]) => { onChange({ ...data, [key]: value }) } return ( {mode === 'add' ? 'Add Filter Rule' : 'Edit Filter Rule'} Configure firewall filter rule properties. Only non-empty fields will be applied.
{/* Chain */}
{/* Action */}
{/* Protocol */}
{/* Src Address */}
updateField('src-address', e.target.value)} />
{/* Dst Address */}
updateField('dst-address', e.target.value)} />
{/* Src Port */}
updateField('src-port', e.target.value)} />
{/* Dst Port */}
updateField('dst-port', e.target.value)} />
{/* In Interface */}
updateField('in-interface', e.target.value)} />
{/* Out Interface */}
updateField('out-interface', e.target.value)} />
{/* Comment */}
updateField('comment', e.target.value)} />
{/* Disabled */}
updateField('disabled', checked === true)} />
) } // =========================================================================== // NAT Rule Dialog (Visual Builder) // =========================================================================== interface NatRuleDialogProps { open: boolean onOpenChange: (open: boolean) => void mode: FormMode data: NatFormData onChange: (data: NatFormData) => void onSubmit: () => void } function NatRuleDialog({ open, onOpenChange, mode, data, onChange, onSubmit, }: NatRuleDialogProps) { const updateField = (key: K, value: NatFormData[K]) => { onChange({ ...data, [key]: value }) } return ( {mode === 'add' ? 'Add NAT Rule' : 'Edit NAT Rule'} Configure NAT rule properties. Only non-empty fields will be applied.
{/* Chain */}
{/* Action */}
{/* Protocol */}
{/* Src Address */}
updateField('src-address', e.target.value)} />
{/* Dst Address */}
updateField('dst-address', e.target.value)} />
{/* Src Port */}
updateField('src-port', e.target.value)} />
{/* Dst Port */}
updateField('dst-port', e.target.value)} />
{/* To Addresses */}
updateField('to-addresses', e.target.value)} />
{/* To Ports */}
updateField('to-ports', e.target.value)} />
{/* Comment */}
updateField('comment', e.target.value)} />
{/* Disabled */}
updateField('disabled', checked === true)} />
) } // =========================================================================== // Shared sub-components // =========================================================================== function CellEmpty() { return } function LoadingRows({ cols }: { cols: number }) { return ( <> {Array.from({ length: 5 }).map((_, i) => ( {Array.from({ length: cols }).map((_, j) => ( ))} ))} ) }