diff --git a/frontend/src/components/FirewallRuleBuilder.tsx b/frontend/src/components/FirewallRuleBuilder.tsx index 8ad478a..f69c9bb 100644 --- a/frontend/src/components/FirewallRuleBuilder.tsx +++ b/frontend/src/components/FirewallRuleBuilder.tsx @@ -17,9 +17,9 @@ import { Tooltip, Typography, } from 'antd'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { execFirewallRaw } from '../api/apiClient'; -import type { ChainOut, ExecResult, Expr, RuleOut, TableOut } from '../types/firewall'; +import type { ChainOut, ExecResult, RuleOut, TableOut } from '../types/firewall'; const { Option } = Select; const { TextArea } = Input; @@ -27,240 +27,342 @@ 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; +/* --- Token types and field maps --- */ + +type TokenType = + | 'ip' + | 'ip6' + | 'tcp' + | 'udp' + | 'udplite' + | 'sctp' + | 'dccp' + | 'icmp' + | 'icmpv6' + | 'meta' + | 'ct' + | 'ether' + | 'vlan' + | 'frag' + | 'payload' + | 'raw' + | 'counter' + | 'limit' + | 'log' + | 'nat' + | 'queue'; + +const TOKEN_FIELD_OPTIONS: Record< + TokenType, + Array<{ value: string; label: string; kind?: 'string' | 'number' | 'enum' }> +> = { + ip: [ + { value: 'dscp', label: 'dscp', kind: 'string' }, + { value: 'length', label: 'length', kind: 'string' }, + { value: 'id', label: 'id', kind: 'number' }, + { value: 'frag-off', label: 'frag-off', kind: 'string' }, + { value: 'ttl', label: 'ttl', kind: 'number' }, + { value: 'protocol', label: 'protocol', kind: 'string' }, + { value: 'checksum', label: 'checksum', kind: 'number' }, + { value: 'saddr', label: 'saddr', kind: 'string' }, + { value: 'daddr', label: 'daddr', kind: 'string' }, + { value: 'version', label: 'version', kind: 'number' }, + { value: 'hdrlength', label: 'hdrlength', kind: 'number' }, + ], + ip6: [ + { value: 'dscp', label: 'dscp', kind: 'string' }, + { value: 'flowlabel', label: 'flowlabel', kind: 'number' }, + { value: 'length', label: 'length', kind: 'number' }, + { value: 'nexthdr', label: 'nexthdr', kind: 'string' }, + { value: 'hoplimit', label: 'hoplimit', kind: 'number' }, + { value: 'saddr', label: 'saddr', kind: 'string' }, + { value: 'daddr', label: 'daddr', kind: 'string' }, + { value: 'version', label: 'version', kind: 'number' }, + ], + tcp: [ + { value: 'dport', label: 'dport', kind: 'number' }, + { value: 'sport', label: 'sport', kind: 'number' }, + { value: 'sequence', label: 'sequence', kind: 'number' }, + { value: 'ackseq', label: 'ackseq', kind: 'number' }, + { value: 'flags', label: 'flags', kind: 'string' }, + { value: 'window', label: 'window', kind: 'number' }, + { value: 'checksum', label: 'checksum', kind: 'number' }, + { value: 'urgptr', label: 'urgptr', kind: 'number' }, + { value: 'doff', label: 'doff', kind: 'number' }, + ], + udp: [ + { value: 'dport', label: 'dport', kind: 'number' }, + { value: 'sport', label: 'sport', kind: 'number' }, + { value: 'length', label: 'length', kind: 'number' }, + { value: 'checksum', label: 'checksum', kind: 'number' }, + ], + udplite: [ + { value: 'dport', label: 'dport', kind: 'number' }, + { value: 'sport', label: 'sport', kind: 'number' }, + { value: 'checksum', label: 'checksum', kind: 'number' }, + ], + sctp: [ + { value: 'dport', label: 'dport', kind: 'number' }, + { value: 'sport', label: 'sport', kind: 'number' }, + { value: 'checksum', label: 'checksum', kind: 'number' }, + { value: 'vtag', label: 'vtag', kind: 'number' }, + ], + dccp: [ + { value: 'dport', label: 'dport', kind: 'number' }, + { value: 'sport', label: 'sport', kind: 'number' }, + { value: 'type', label: 'type', kind: 'string' }, + ], + icmp: [ + { value: 'type', label: 'type', kind: 'string' }, + { value: 'code', label: 'code', kind: 'number' }, + { value: 'checksum', label: 'checksum', kind: 'number' }, + { value: 'id', label: 'id', kind: 'number' }, + { value: 'sequence', label: 'sequence', kind: 'number' }, + { value: 'mtu', label: 'mtu', kind: 'number' }, + { value: 'gateway', label: 'gateway', kind: 'number' }, + ], + icmpv6: [ + { value: 'type', label: 'type', kind: 'string' }, + { value: 'code', label: 'code', kind: 'number' }, + { value: 'checksum', label: 'checksum', kind: 'number' }, + { value: 'id', label: 'id', kind: 'number' }, + { value: 'sequence', label: 'sequence', kind: 'number' }, + { value: 'mtu', label: 'mtu', kind: 'number' }, + { value: 'max-delay', label: 'max-delay', kind: 'number' }, + ], + // META token includes many selectors — expanded from your reference + meta: [ + // packet info selectors + { value: 'pkttype', label: 'pkttype', kind: 'string' }, + { value: 'length', label: 'length', kind: 'number' }, + { value: 'protocol', label: 'protocol', kind: 'string' }, + { value: 'nfproto', label: 'nfproto', kind: 'string' }, + { value: 'l4proto', label: 'l4proto', kind: 'string' }, + + // interface selectors + { value: 'iif', label: 'iif (input ifindex)', kind: 'string' }, + { value: 'iifname', label: 'iifname', kind: 'string' }, + { value: 'iiftype', label: 'iiftype', kind: 'string' }, + { value: 'iifkind', label: 'iifkind', kind: 'string' }, + { value: 'iifgroup', label: 'iifgroup', kind: 'string' }, + { value: 'oif', label: 'oif (output ifindex)', kind: 'string' }, + { value: 'oifname', label: 'oifname', kind: 'string' }, + { value: 'oiftype', label: 'oiftype', kind: 'string' }, + { value: 'oifkind', label: 'oifkind', kind: 'string' }, + { value: 'oifgroup', label: 'oifgroup', kind: 'string' }, + { value: 'ibrname', label: 'ibrname (bridge in)', kind: 'string' }, + { value: 'obrname', label: 'obrname (bridge out)', kind: 'string' }, + { value: 'ibrvproto', label: 'ibrvproto', kind: 'string' }, + { value: 'ibrpvid', label: 'ibrpvid', kind: 'number' }, + { value: 'sdif', label: 'sdif (slave ifindex)', kind: 'number' }, + { value: 'sdifname', label: 'sdifname', kind: 'string' }, + + // mark / routing / priority + { value: 'mark', label: 'mark', kind: 'string' }, + { value: 'priority', label: 'priority', kind: 'string' }, + { value: 'rtclassid', label: 'rtclassid', kind: 'string' }, + + // socket UID / GID + { value: 'skuid', label: 'skuid', kind: 'string' }, + { value: 'skgid', label: 'skgid', kind: 'string' }, + + // time selectors + { value: 'time', label: 'time', kind: 'string' }, + { value: 'day', label: 'day', kind: 'string' }, + { value: 'hour', label: 'hour', kind: 'string' }, + + // security / misc + { value: 'cpu', label: 'cpu', kind: 'number' }, + { value: 'cgroup', label: 'cgroup', kind: 'number' }, + { value: 'secmark', label: 'secmark', kind: 'string' }, + { value: 'ipsec', label: 'ipsec', kind: 'string' }, + + // miscellaneous + { value: 'nftrace', label: 'nftrace', kind: 'string' }, + { value: 'random', label: 'random', kind: 'number' }, + + // older/general misc + { value: 'pkttype_alias', label: 'pkttype (alias)', kind: 'string' }, // kept for backwards/aliasing if needed + ], + ct: [ + { value: 'state', label: 'state', kind: 'string' }, + { value: 'direction', label: 'direction', kind: 'string' }, + { value: 'status', label: 'status', kind: 'string' }, + { value: 'mark', label: 'mark', kind: 'string' }, + { value: 'expiration', label: 'expiration', kind: 'string' }, + { value: 'helper', label: 'helper', kind: 'string' }, + ], + ether: [ + { value: 'saddr', label: 'saddr', kind: 'string' }, + { value: 'daddr', label: 'daddr', kind: 'string' }, + { value: 'type', label: 'type', kind: 'string' }, + ], + vlan: [ + { value: 'id', label: 'id', kind: 'number' }, + { value: 'cfi', label: 'cfi', kind: 'number' }, + { value: 'pcp', label: 'pcp', kind: 'number' }, + ], + frag: [ + { value: 'nexthdr', label: 'nexthdr', kind: 'string' }, + { value: 'reserved', label: 'reserved', kind: 'number' }, + { value: 'frag-off', label: 'frag-off', kind: 'number' }, + { value: 'more-fragments', label: 'more-fragments', kind: 'number' }, + { value: 'id', label: 'id', kind: 'number' }, + ], + payload: [{ value: 'payload', label: 'payload(protocol.field)', kind: 'string' }], + raw: [{ value: 'raw', label: 'raw text', kind: 'string' }], + counter: [{ value: 'counter', label: 'counter', kind: 'string' }], + limit: [{ value: 'limit', label: 'limit (rate)', kind: 'string' }], + log: [{ value: 'log', label: 'log', kind: 'string' }], + nat: [ + { value: 'dnat', label: 'dnat to', kind: 'string' }, + { value: 'snat', label: 'snat to', kind: 'string' }, + { value: 'masquerade', label: 'masquerade', kind: 'string' }, + ], + queue: [{ value: 'queue', label: 'queue num', kind: 'string' }], +}; + +const ENUM_VALUES: Record = { + // layer 4 protocols supported by nft (exposed to protocol selectors) + ip_protocols: ['ah', 'esp', 'udp', 'udplite', 'tcp', 'dccp', 'sctp', 'ipcomp', 'icmp'], + icmp_types: [ + 'echo-reply', + 'destination-unreachable', + 'source-quench', + 'redirect', + 'echo-request', + 'time-exceeded', + 'parameter-problem', + 'timestamp-request', + 'timestamp-reply', + 'info-request', + 'info-reply', + ], + icmpv6_types: ['destination-unreachable', 'packet-too-big', 'time-exceeded', 'echo-request', 'echo-reply'], + meta_pkttype: ['unicast', 'broadcast', 'multicast', 'other'], + ct_state: ['new', 'established', 'related', 'untracked'], + days: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'], + boolean_choices: ['true', 'false'], +}; + +/* --- token -> textual conversion --- */ +function tokenToText(token: any): string { + if (!token || !token.type) return ''; + const t = token.type as TokenType; + const d = token.data || {}; + if (t === 'ip' || t === 'ip6') { + const field = d.field; + if (!field) return ''; + return `${t} ${field} ${String(d.value ?? '').trim()}`.trim(); + } + if (t === 'tcp' || t === 'udp' || t === 'udplite' || t === 'sctp' || t === 'dccp') { + if (d.dport) return `${t} dport ${d.dport}`; + if (d.sport) return `${t} sport ${d.sport}`; + if (d.field && d.value !== undefined) return `${t} ${d.field} ${String(d.value)}`.trim(); + return t; + } + if (t === 'icmp' || t === 'icmpv6') { + const field = d.field; + if (!field) return ''; + return `${t} ${field} ${String(d.value ?? '').trim()}`.trim(); + } + if (t === 'meta') { + const field = d.field; + if (!field) return ''; + const val = d.value !== undefined ? String(d.value).trim() : ''; + return `meta ${field} ${val}`.trim(); + } + if (t === 'ct') { + const field = d.field; + if (!field) return ''; + return `ct ${field} ${String(d.value ?? '').trim()}`.trim(); + } + if (t === 'ether') { + const field = d.field; + if (!field) return ''; + return `ether ${field} ${String(d.value ?? '').trim()}`.trim(); + } + if (t === 'vlan' || t === 'frag' || t === 'payload') { + const field = d.field; + if (!field) return ''; + if (d.value !== undefined) return `${t} ${field} ${String(d.value)}`.trim(); + if (t === 'payload' && d.protocol && d.field) return `payload(${d.protocol}.${d.field})`; + return `${t} ${field}`.trim(); + } + if (t === 'counter') { + if (d.packets || d.bytes) { + return `counter${d.packets ? ` packets ${d.packets}` : ''}${d.bytes ? ` bytes ${d.bytes}` : ''}`.trim(); + } + return 'counter'; + } + if (t === 'limit') { + const rate = d.rate ?? d.value; + return rate ? `limit rate ${rate}` : 'limit'; + } + if (t === 'log') { + const parts: string[] = []; + if (d.level) parts.push(`level ${d.level}`); + if (d.group) parts.push(`group ${d.group}`); + if (d.snaplen) parts.push(`snaplen ${d.snaplen}`); + if (d.prefix) parts.push(`prefix "${d.prefix}"`); + if (parts.length === 0) return 'log'; + return `log ${parts.join(' ')}`.trim(); + } + if (t === 'nat') { + if (d.kind === 'dnat' && d.to) return `dnat to ${d.to}`; + if (d.kind === 'snat' && d.to) return `snat to ${d.to}`; + if (d.kind === 'masquerade') { + if (d.to) return `masquerade to ${d.to}`; + return 'masquerade'; + } + return 'nat'; + } + if (t === 'queue') { + if (d.num) return `queue num ${d.num}`; + return 'queue'; + } + if (t === 'raw') { + return (d.text ?? '').trim(); + } + return ''; } -/* ---------------------- - Small helper functions - ---------------------- */ +function generateCommandFromValues(values: FormValues) { + const tokens = Array.isArray(values.tokens) ? values.tokens : []; + const parts: string[] = []; -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); - } - } - } + for (const t of tokens) { + const txt = tokenToText(t); + if (txt) parts.push(txt); } - 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(); + parts.push(values.advanced.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}`); + const qnum = values.nfqueue ?? values.queue ?? 1; + const queueText = `queue num ${Number(qnum)}`; + const combined = parts.join(' '); + if (!/\bqueue(?:\s+num)?\b/i.test(combined)) { + parts.push(queueText); } else { - if (!hasQueue) textual = `${textual} queue num 0`.trim(); - else textual = textual.replace(/\bqueue(?:\s+num)?\s*\d*/i, `queue num 0`); + for (let i = 0; i < parts.length; i++) { + if (/\bqueue(?:\s+num)?\b/i.test(parts[i])) { + parts[i] = queueText; + break; + } + } } } + let actionText: string | null = null; + if (values.action === 'accept' || values.action === 'drop' || values.action === 'reject') { + actionText = values.action; + } + + const textual = (parts.join(' ') + (actionText ? ` ${actionText}` : '')).trim(); + const tableSelect = values.tableSelect; const chain = values.chainSelect || 'input'; const [family = 'inet', table = 'filter'] = tableSelect ? String(tableSelect).split(':') : ['inet', 'filter']; @@ -279,22 +381,28 @@ function generateCommandFromValues(values: FormValues): { cmd: string; textual: Component ------------------------- */ +interface RuleBuilderProps { + onCreated?: () => Promise | void; + tables?: TableOut[] | null; + rulesLoading?: boolean; + rulesError?: string | null; + refreshRules?: () => Promise; + onRulesChange?: (tables: TableOut[]) => void; +} + export const RuleBuilder: React.FC = (props: RuleBuilderProps) => { const [form] = Form.useForm(); + const [cmdPreview, setCmdPreview] = useState(''); 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 [insertBeforeOptions, setInsertBeforeOptions] = useState>([]); const updateInsertOptions = useCallback(() => { const ts = form.getFieldValue('tableSelect'); const cs = form.getFieldValue('chainSelect'); @@ -325,24 +433,25 @@ export const RuleBuilder: React.FC = (props: RuleBuilderProps) setInsertBeforeOptions(opts); }, [form, props.tables]); - const updatePreviewFromValues = useCallback( - (values?: FormValues) => { - const vals = values ?? form.getFieldsValue(); - const { cmd } = generateCommandFromValues(vals); + const previewTimerRef = useRef(null); + const schedulePreviewUpdate = useCallback(() => { + if (previewTimerRef.current) window.clearTimeout(previewTimerRef.current); + previewTimerRef.current = window.setTimeout(() => { + const v = form.getFieldsValue(); + const { cmd } = generateCommandFromValues(v); setCmdPreview(cmd); - }, - [form], - ); + previewTimerRef.current = null; + }, 40); + }, [form]); useEffect(() => { if (tableOptions.length > 0) { const first = tableOptions[0].value; form.setFieldsValue({ tableSelect: first, - protocolChoice: 'preset', - protocolPreset: 'icmp', action: 'drop', nfqueue: 1, + tokens: [], }); const [f, n] = String(first).split(':'); @@ -355,33 +464,31 @@ export const RuleBuilder: React.FC = (props: RuleBuilderProps) setTimeout(() => { updateInsertOptions(); - updatePreviewFromValues(); + schedulePreviewUpdate(); }, 0); } else { form.setFieldsValue({ - protocolChoice: 'preset', - protocolPreset: 'icmp', action: 'drop', nfqueue: 1, tableSelect: undefined, chainSelect: undefined, + tokens: [], }); setInsertBeforeOptions([]); - setTimeout(() => updatePreviewFromValues(), 0); + setTimeout(() => schedulePreviewUpdate(), 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(); + const cur = form.getFieldsValue(); + if (cur.nfqueue == null) form.setFieldsValue({ nfqueue: 1 }); + schedulePreviewUpdate(); // 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; @@ -393,9 +500,7 @@ export const RuleBuilder: React.FC = (props: RuleBuilderProps) 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 }); - } + if (!allValues.chainSelect) form.setFieldsValue({ chainSelect: tbl.chains[0].name }); } else { form.setFieldsValue({ chainSelect: undefined }); } @@ -403,56 +508,60 @@ export const RuleBuilder: React.FC = (props: RuleBuilderProps) } updateInsertOptions(); - updatePreviewFromValues(allValues); + schedulePreviewUpdate(); }, - [form, props.tables, updateInsertOptions, updatePreviewFromValues], + [form, props.tables, updateInsertOptions, schedulePreviewUpdate], ); 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); + try { + const validated = await form.validateFields(); + const { cmd } = generateCommandFromValues(validated); + Modal.confirm({ + title: 'Run raw nft command', + content: ( +
+ + About to run nft command in {String(validated.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); } - } catch (err: any) { - message.error('Execution failed: ' + (err?.message ?? String(err))); - } finally { - setLoading(false); - } - }, - }); + }, + }); + } catch (err) { + schedulePreviewUpdate(); + } }, - [props.refreshRules, props.onCreated], + [form, props.refreshRules, props.onCreated, schedulePreviewUpdate], ); const chainOptions = useMemo(() => { @@ -486,14 +595,41 @@ export const RuleBuilder: React.FC = (props: RuleBuilderProps) } }, [props.refreshRules, updateInsertOptions]); + /* helper styles */ + const tokenRowStyle: React.CSSProperties = { + display: 'flex', + gap: 8, + alignItems: 'center', + flexWrap: 'nowrap', + width: '100%', + }; + + const leftControlsStyle: React.CSSProperties = { + display: 'flex', + gap: 8, + alignItems: 'center', + minWidth: 72, + flex: '0 0 72px', + }; + + const typeSelectStyle: React.CSSProperties = { minWidth: 180, maxWidth: 260, flex: '0 0 220px' }; + const fieldSelectStyle: React.CSSProperties = { minWidth: 160, maxWidth: 260, flex: '0 0 220px' }; + const valueInputStyle: React.CSSProperties = { minWidth: 120, flex: '1 1 240px', maxWidth: '60%' }; + const actionControlsStyle: React.CSSProperties = { + minWidth: 96, + flex: '0 0 96px', + display: 'flex', + justifyContent: 'flex-end', + }; + return ( - + Add Firewall Rule (raw) - Builds a raw nft command and executes it via the raw endpoint. + Build a raw nft command using token builder and execute via raw endpoint. @@ -511,17 +647,16 @@ export const RuleBuilder: React.FC = (props: RuleBuilderProps) layout="vertical" form={form} initialValues={{ - protocolChoice: 'preset', - protocolPreset: 'icmp', action: 'drop', nfqueue: 1, tableSelect: tableOptions.length > 0 ? tableOptions[0].value : undefined, + tokens: [], }} onFinish={handleCreate} onValuesChange={onValuesChange} > - {/* Table & Chain selection */} - + {/* Table / chain */} + {insertBeforeOptions.length === 0 ? ( @@ -569,71 +704,546 @@ export const RuleBuilder: React.FC = (props: RuleBuilderProps) - Use when you want the new rule to appear before a known handle. Refresh tables to see latest handles. + Use when you want the new rule before a known handle. Refresh tables after changes. - {/* Protocol / addresses */} - - - - - - - Preset - Custom - - - - + - {isPresetProtocolChoice(form.getFieldsValue()) ? ( - - - - ) : ( - - - - )} + {/* Token builder header + add control */} + + + Token builder + + Build match and statement tokens. Verdict (accept/drop/reject/nfqueue) is set in Action below. + - - - - - - - - - - + + + {(fields, { add }) => ( +
+ +
+ )} +
- {/* Ports / action */} - - - - - - + - - - - - + {/* Tokens Form.List rendering */} + + {(fields, { remove, move }) => + fields.length === 0 ? ( + No tokens yet — add one using the selector above. + ) : ( + fields.map((field, idx) => ( +
+
+
+ + +
+
+ + + + + prev.tokens?.[field.name]?.type !== cur.tokens?.[field.name]?.type} + style={{ marginBottom: 0, width: '100%' }} + > + {() => { + const tokenType = form.getFieldValue(['tokens', field.name, 'type']) as TokenType | undefined; + const options = tokenType ? TOKEN_FIELD_OPTIONS[tokenType] || [] : []; + + if (tokenType === 'counter') { + return ( +
+ + counter + + + + + + + +
+ ); + } + + if (tokenType === 'limit') { + return ( +
+ + limit + + + + +
+ ); + } + + if (tokenType === 'log') { + return ( +
+ + log + + + + + + + + + + + + + +
+ ); + } + + if (tokenType === 'nat') { + return ( +
+ + + + + + +
+ ); + } + + if (tokenType === 'queue') { + return ( +
+ + queue + + + + + + + +
+ ); + } + + if (options.length > 0) { + return ( +
+ + + + + + prev.tokens?.[field.name]?.data?.field !== cur.tokens?.[field.name]?.data?.field || + prev.tokens?.[field.name]?.type !== cur.tokens?.[field.name]?.type + } + style={{ margin: 0, flex: '1 1 auto', minWidth: 0 }} + > + {() => { + const selField = form.getFieldValue(['tokens', field.name, 'data', 'field']); + const tType = form.getFieldValue(['tokens', field.name, 'type']); + const opts = tType ? TOKEN_FIELD_OPTIONS[tType as TokenType] || [] : []; + const meta = opts.find((o) => o.value === selField); + const kind = meta?.kind ?? 'string'; + + // numeric field + if (kind === 'number' || selField === 'random') { + return ( + + + + ); + } + + // protocol enum (layer4) — includes AH, ESP, UDP, UDPlite, TCP, DCCP, SCTP, IPComp + if ( + selField === 'protocol' && + (tType === 'ip' || tType === 'ip6' || tType === 'meta' || tType === 'payload') + ) { + return ( + + + + ); + } + + // pkttype enum + if (selField === 'pkttype' || selField === 'pkttype_alias') { + return ( + + + + ); + } + + // day enum + if (selField === 'day') { + return ( + + + + ); + } + + // boolean-ish selectors: ipsec, nftrace, secmark + if (selField === 'ipsec' || selField === 'nftrace' || selField === 'secmark') { + return ( + + + + ); + } + + // time: free text — allow ISO or ns since epoch + if (selField === 'time') { + return ( +
+ + + + + Use ISO timestamp (recommended) or integer nanoseconds since epoch. + +
+ ); + } + + // hour: HH:MM[:SS] + if (selField === 'hour') { + return ( +
+ + + + + Use 24-hour format. You may specify ranges like "09:00-17:00". + +
+ ); + } + + // ETHER fields: MAC addresses and EtherType + if (tType === 'ether') { + if (selField === 'saddr' || selField === 'daddr') { + return ( +
+ + + + + MAC address in hex (colon-separated). You can use broadcast + ff:ff:ff:ff:ff:ff or a specific MAC. Sets accept single MAC + or ranges/sets. + +
+ ); + } + if (selField === 'type') { + return ( +
+ + + + + EtherType value (hex) — e.g. 0x0800 for IPv4,{' '} + 0x86dd for IPv6. + +
+ ); + } + } + + // VLAN fields: numeric with ranges + if (tType === 'vlan') { + if (selField === 'id') { + return ( +
+ + + + + VLAN ID (0-4095). Example: 100. + +
+ ); + } + if (selField === 'cfi') { + return ( +
+ + + + + Drop Eligible Indicator (0 or 1). + +
+ ); + } + if (selField === 'pcp') { + return ( +
+ + + + + Priority Code Point (0-7). + +
+ ); + } + } + + // IP addresses: examples for ip / ip6 saddr/daddr + if ( + (tType === 'ip' || tType === 'ip6') && + (selField === 'saddr' || selField === 'daddr') + ) { + if (tType === 'ip') { + return ( +
+ + + + + IPv4 address, CIDR, list or range — e.g. 192.168.1.100 or{' '} + 192.168.1.0/24. + +
+ ); + } + return ( +
+ + + + + IPv6 address or CIDR — e.g. abcd::100 or{' '} + 2001:db8::/32. + +
+ ); + } + + // payload(protocol.field) helper example + if (tType === 'payload' || selField === 'payload') { + return ( +
+ + + + + Use protocol.field syntax — e.g. ip.protocol or{' '} + tcp.dport. + +
+ ); + } + + // default free-text input with example hint + return ( +
+ + + + + Enter the matching value. Examples: port numbers (53), CIDR ( + 10.0.0.0/8), sets ({'{1,2,3}'}), or ranges ( + 1-1024). + +
+ ); + }} +
+
+ ); + } + + return Unknown token type or no fields configured; + }} +
+
+ +
+ +
+
+
+ )) + ) + } +
+ + + + {/* Action + NFQUEUE */} + - + Drop Accept @@ -641,47 +1251,47 @@ export const RuleBuilder: React.FC = (props: RuleBuilderProps) NFQUEUE + - {/* NFQUEUE: required when selected, default 1 */} - {form.getFieldValue('action') === 'nfqueue' && ( - - - - )} + + prev.action !== cur.action} noStyle> + {() => + form.getFieldValue('action') === 'nfqueue' ? ( + + + + ) : null + } + - {/* Advanced */} + {/* advanced text */}