// src/components/RuleBuilder.tsx import { CopyOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'; import { Button, Card, Col, Divider, Form, Input, InputNumber, message, Modal, Radio, Row, Select, Space, Tooltip, Typography, } from 'antd'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { execFirewallRaw } from '../api/apiClient'; import type { ChainOut, ExecResult, Expr, RuleOut, TableOut } from '../types/firewall'; const { Option } = Select; const { TextArea } = Input; const { Title, Text } = Typography; type FormValues = Record; interface RuleBuilderProps { onCreated?: () => Promise | void; tables?: TableOut[] | null; rulesLoading?: boolean; rulesError?: string | null; refreshRules?: () => Promise; onRulesChange?: (tables: TableOut[]) => void; } /* ---------------------- Small helper functions ---------------------- */ function isPresetProtocolChoice(values: FormValues) { return (values.protocolChoice ?? 'preset') === 'preset'; } /** * Build NFT expression tokens (keeps your original logic). */ export function buildExprFromValues(values: FormValues): Expr[] { const expr: Expr[] = []; const protoChoice = values.protocolChoice ?? 'preset'; if (protoChoice === 'preset') { const preset = values.protocolPreset; if (preset === 'icmp') { expr.push({ match: { left: { payload: { protocol: 'ip', field: 'protocol' } }, op: '==', right: 'icmp', }, }); } else if (preset === 'icmpv6') { expr.push({ match: { left: { payload: { protocol: 'ip6', field: 'nexthdr' } }, op: '==', right: 'icmpv6', }, }); } else if (preset === 'tcp') { expr.push({ tcp: {} }); } else if (preset === 'udp') { expr.push({ udp: {} }); } } else { const custom = (values.protocolCustom || '').trim(); if (custom) { if (/^icmpv6$/i.test(custom)) { expr.push({ match: { left: { payload: { protocol: 'ip6', field: 'nexthdr' } }, op: '==', right: 'icmpv6', }, }); } else if (/^icmp$/i.test(custom)) { expr.push({ match: { left: { payload: { protocol: 'ip', field: 'protocol' } }, op: '==', right: 'icmp', }, }); } else if (/tcp/i.test(custom)) { expr.push({ tcp: {} }); } else if (/udp/i.test(custom)) { expr.push({ udp: {} }); } else { try { const parsed = JSON.parse(custom); expr.push(parsed); } catch { expr.push(custom); } } } } if (values.saddr) { expr.push({ match: { left: { payload: { protocol: 'ip', field: 'saddr' } }, op: '==', right: values.saddr, }, }); } if (values.daddr) { expr.push({ match: { left: { payload: { protocol: 'ip', field: 'daddr' } }, op: '==', right: values.daddr, }, }); } const chooseUdp = (preset?: string, custom?: string) => preset === 'udp' || (custom && /(udp)/i.test(custom)); if (values.sport) { const proto = chooseUdp(values.protocolPreset, values.protocolCustom) ? 'udp' : 'tcp'; expr.push({ [proto]: { sport: Number(values.sport) } } as any); } if (values.dport) { const proto = chooseUdp(values.protocolPreset, values.protocolCustom) ? 'udp' : 'tcp'; expr.push({ [proto]: { dport: Number(values.dport) } } as any); } if (values.advanced) { const adv = (values.advanced || '').trim(); try { expr.push(JSON.parse(adv)); } catch { expr.push(adv); } } const action = values.action || 'drop'; if (action === 'drop') expr.push({ drop: null }); else if (action === 'accept') expr.push({ accept: null }); else if (action === 'reject') expr.push({ reject: null }); else if (action === 'nfqueue' || action === 'queue') { const qnum = Number(values.nfqueue || 0); expr.push({ queue: Number.isFinite(qnum) && qnum > 0 ? qnum : 0 } as any); } return expr; } /** * Create a compact textual representation of an expr. */ export function textFromExpr(expr: Expr | null | undefined): string { if (!expr) return ''; if (!Array.isArray(expr)) return String(expr); const tokens: string[] = []; for (const part of expr) { if (typeof part === 'string') { tokens.push(part); continue; } if (!part || typeof part !== 'object') { tokens.push(String(part)); continue; } if ('match' in part) { const m = (part as any).match; const left = m.left; const right = m.right; if (left && left.payload && right !== undefined) { const p = left.payload; const prot = p.protocol; const field = p.field; if (prot && field && typeof right === 'string') { tokens.push(`${prot} ${field} ${right}`); continue; } } tokens.push('match'); continue; } if ('payload' in part) { const p = (part as any).payload; tokens.push(`payload(${p.protocol}.${p.field})`); continue; } if ('tcp' in part) { const v = (part as any).tcp; if (v && v.dport) tokens.push(`tcp dport ${v.dport}`); else if (v && v.sport) tokens.push(`tcp sport ${v.sport}`); else tokens.push('tcp'); continue; } if ('udp' in part) { const v = (part as any).udp; if (v && v.dport) tokens.push(`udp dport ${v.dport}`); else if (v && v.sport) tokens.push(`udp sport ${v.sport}`); else tokens.push('udp'); continue; } if ('drop' in part) { tokens.push('drop'); continue; } if ('accept' in part) { tokens.push('accept'); continue; } if ('reject' in part) { tokens.push('reject'); continue; } if ('counter' in part) { tokens.push('counter'); continue; } if ('queue' in part) { tokens.push(`queue ${(part as any).queue}`); continue; } tokens.push(Object.keys(part).sort().join('+')); } return tokens.join(' '); } /* ------------------------- Command generation helper ------------------------- */ function generateCommandFromValues(values: FormValues): { cmd: string; textual: string; position?: number | null } { const expr = buildExprFromValues(values); let textual = textFromExpr(expr).trim(); if (values.advanced && typeof values.advanced === 'string' && values.advanced.trim() !== '') { textual = `${textual} ${values.advanced.trim()}`.trim(); } if (values.action === 'nfqueue' || values.action === 'queue') { const qnum = values.nfqueue ?? values.queue ?? null; const qstr = qnum != null && String(qnum).trim() !== '' ? String(qnum).trim() : null; const hasQueue = /\bqueue(?:\s+num)?\b/i.test(textual); if (qstr) { if (!hasQueue) textual = `${textual} queue num ${qstr}`.trim(); else textual = textual.replace(/\bqueue(?:\s+num)?\s*\d*/i, `queue num ${qstr}`); } else { if (!hasQueue) textual = `${textual} queue num 0`.trim(); else textual = textual.replace(/\bqueue(?:\s+num)?\s*\d*/i, `queue num 0`); } } const tableSelect = values.tableSelect; const chain = values.chainSelect || 'input'; const [family = 'inet', table = 'filter'] = tableSelect ? String(tableSelect).split(':') : ['inet', 'filter']; const before = values.insertBeforeHandle; const hasBefore = before != null && String(before) !== ''; const verb = hasBefore ? 'insert' : 'add'; const positionPart = hasBefore ? ` position ${before}` : ''; const cmd = `${verb} rule ${family} ${table} ${chain}${positionPart} ${textual}`.replace(/\s+/g, ' ').trim(); return { cmd, textual, position: hasBefore ? Number(before) : undefined }; } /* ------------------------- Component ------------------------- */ export const RuleBuilder: React.FC = (props: RuleBuilderProps) => { const [form] = Form.useForm(); const [refreshing, setRefreshing] = useState(false); const [loading, setLoading] = useState(false); const [cmdPreview, setCmdPreview] = useState(''); const [insertBeforeOptions, setInsertBeforeOptions] = useState>([]); const tableOptions = useMemo( () => (props.tables || []).map((t) => ({ value: `${t.family}:${t.name}`, label: `${t.family}:${t.name}` })), [props.tables], ); const noTables = !(props.tables && props.tables.length > 0); const updateInsertOptions = useCallback(() => { const ts = form.getFieldValue('tableSelect'); const cs = form.getFieldValue('chainSelect'); if (!ts || !cs) { setInsertBeforeOptions([]); return; } const [family, table] = String(ts).split(':'); const tbl = props.tables?.find((t) => t.family === family && t.name === table); if (!tbl) { setInsertBeforeOptions([]); return; } const ch = (tbl.chains || []).find((c: ChainOut) => c.name === cs); if (!ch || !Array.isArray(ch.rules)) { setInsertBeforeOptions([]); return; } const opts = ch.rules .filter((r: RuleOut) => r && r.handle != null) .map((r: RuleOut) => ({ value: r.handle, label: `#${r.handle} — ${ r.text ?? (typeof r.expr === 'string' ? r.expr : JSON.stringify(r.expr || {}).slice(0, 120)) }`, })); setInsertBeforeOptions(opts); }, [form, props.tables]); const updatePreviewFromValues = useCallback( (values?: FormValues) => { const vals = values ?? form.getFieldsValue(); const { cmd } = generateCommandFromValues(vals); setCmdPreview(cmd); }, [form], ); useEffect(() => { if (tableOptions.length > 0) { const first = tableOptions[0].value; form.setFieldsValue({ tableSelect: first, protocolChoice: 'preset', protocolPreset: 'icmp', action: 'drop', nfqueue: 1, }); const [f, n] = String(first).split(':'); const tbl = props.tables?.find((t) => t.family === f && t.name === n); if (tbl && tbl.chains && tbl.chains.length > 0) { form.setFieldsValue({ chainSelect: tbl.chains[0].name }); } else { form.setFieldsValue({ chainSelect: undefined }); } setTimeout(() => { updateInsertOptions(); updatePreviewFromValues(); }, 0); } else { form.setFieldsValue({ protocolChoice: 'preset', protocolPreset: 'icmp', action: 'drop', nfqueue: 1, tableSelect: undefined, chainSelect: undefined, }); setInsertBeforeOptions([]); setTimeout(() => updatePreviewFromValues(), 0); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [props.tables, tableOptions.length]); useEffect(() => { const current = form.getFieldsValue(); if (current.nfqueue == null) form.setFieldsValue({ nfqueue: 1 }); updatePreviewFromValues(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const onValuesChange = useCallback( (_: any, allValues: FormValues) => { // If user switches to NFQUEUE and nfqueue is empty, seed it with 1 if (allValues.action === 'nfqueue' && (allValues.nfqueue == null || allValues.nfqueue === '')) { form.setFieldsValue({ nfqueue: 1 }); allValues.nfqueue = 1; } const ts = allValues.tableSelect; if (ts) { const [f, n] = String(ts).split(':'); const tbl = props.tables?.find((t) => t.family === f && t.name === n); if (tbl) { if (tbl.chains && tbl.chains.length > 0) { if (!allValues.chainSelect) { form.setFieldsValue({ chainSelect: tbl.chains[0].name }); } } else { form.setFieldsValue({ chainSelect: undefined }); } } } updateInsertOptions(); updatePreviewFromValues(allValues); }, [form, props.tables, updateInsertOptions, updatePreviewFromValues], ); const handleCreate = useCallback( async (values: FormValues) => { const { cmd } = generateCommandFromValues(values); Modal.confirm({ title: 'Run raw nft command', content: (
About to run nft command in {String(values.tableSelect ?? 'inet:filter')} (see preview). Command:
{cmd}
), okText: 'Run', onOk: async () => { setLoading(true); try { const out: ExecResult = await execFirewallRaw(cmd); const stderrText = out?.stderr ? String(out.stderr).trim() : ''; if (stderrText) { message.error(`Command produced stderr: ${stderrText}`); } else if (out && (out.rc === 0 || out.rc === -1)) { message.success('Command executed (rc ' + out.rc + ')'); if (props.refreshRules) await props.refreshRules(); if (props.onCreated) await props.onCreated(); } else { const info = out ? `rc:${out.rc}` + (out.stdout ? ` stdout:${out.stdout}` : '') + (out.stderr ? ` stderr:${out.stderr}` : '') : 'unknown result'; message.error('Command failed: ' + info); } } catch (err: any) { message.error('Execution failed: ' + (err?.message ?? String(err))); } finally { setLoading(false); } }, }); }, [props.refreshRules, props.onCreated], ); const chainOptions = useMemo(() => { const ts = form.getFieldValue('tableSelect'); if (!ts) return []; const [f, n] = String(ts).split(':'); const tbl = props.tables?.find((t) => t.family === f && t.name === n); if (!tbl) return []; return tbl.chains.map((c) => ( )); }, [form, props.tables]); const handleRefresh = useCallback(async () => { setRefreshing(true); try { if (props.refreshRules) { await props.refreshRules(); message.success('Rules refresh requested'); } else { message.info('No refresh function provided by parent.'); } } catch (err) { console.warn('refresh failed', err); message.error('Refresh failed'); } finally { updateInsertOptions(); setRefreshing(false); } }, [props.refreshRules, updateInsertOptions]); return ( Add Firewall Rule (raw) Builds a raw nft command and executes it via the raw endpoint.
0 ? tableOptions[0].value : undefined, }} onFinish={handleCreate} onValuesChange={onValuesChange} > {/* Table & Chain selection */} {/* Insert-before */} Use when you want the new rule to appear before a known handle. Refresh tables to see latest handles. {/* Protocol / addresses */} Preset Custom {isPresetProtocolChoice(form.getFieldsValue()) ? ( ) : ( )} {/* Ports / action */} Drop Accept Reject NFQUEUE {/* NFQUEUE: required when selected, default 1 */} {form.getFieldValue('action') === 'nfqueue' && ( )} {/* Advanced */}