From 64d3a973bccc07456066eaf692522e417ca49e07 Mon Sep 17 00:00:00 2001 From: malmert Date: Sat, 28 Feb 2026 17:55:03 +0100 Subject: [PATCH] JSON WORKS HERE TAG --- frontend/src/api/apiClient.ts | 11 +- .../src/components/FirewallRuleBuilder.tsx | 620 ++++++++---------- .../src/components/FirewallRulesetViewer.tsx | 533 +++++---------- frontend/src/pages/Firewall.tsx | 57 +- frontend/src/types/firewall.ts | 56 +- 5 files changed, 498 insertions(+), 779 deletions(-) diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index a448d86..390bf96 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -1,6 +1,6 @@ // src/apiClient.ts import axios from 'axios'; -import { CreateRuleRequest, ExecResult, MoveRequest, MoveResult, RulesetModel } from '../types/firewall'; +import { CreateRuleRequest, ExecResult, RulesetModel } from '../types/firewall'; import { BridgeCreateRequest, BridgeInfo, @@ -125,8 +125,8 @@ export const fetchPackets = async (limit = 100): Promise => { * Returns: { ruleset: RulesetModel | string | null } * - If the server returns a raw textual fallback (string), the caller should handle it. */ -export const fetchRuleset = async (): Promise<{ ruleset: RulesetModel | string | null }> => { - const res = await api.get<{ ruleset: RulesetModel | string | null }>('/firewall/rules'); +export const fetchRuleset = async (): Promise<{ ruleset: RulesetModel }> => { + const res = await api.get<{ ruleset: RulesetModel }>('/firewall/rules'); return res.data; }; @@ -142,11 +142,6 @@ export const deleteRule = async (handle: number, family: string, table: string, return res.data; }; -export const moveRule = async (req: MoveRequest): Promise => { - const res = await api.post(`/firewall/rules/${encodeURIComponent(String(req.before_handle))}/move`, req); - return res.data; -}; - /** * createRuleJson - POST /firewall/rules * Body: CreateRuleRequest (must include expr) diff --git a/frontend/src/components/FirewallRuleBuilder.tsx b/frontend/src/components/FirewallRuleBuilder.tsx index fc54e08..05d5cb9 100644 --- a/frontend/src/components/FirewallRuleBuilder.tsx +++ b/frontend/src/components/FirewallRuleBuilder.tsx @@ -18,20 +18,33 @@ import { Typography, } from 'antd'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { createRuleJson, fetchRuleset } from '../api/apiClient'; -import { Expr, TableOut } from '../types/firewall'; +import { createRuleJson } from '../api/apiClient'; +import { ChainOut, Expr, RuleOut, TableOut } from '../types/firewall'; const { Option } = Select; const { TextArea } = Input; const { Title, Text } = Typography; +const MANUAL_TABLE = '__manual__'; +const MANUAL_CHAIN = '__manual_chain__'; + +type FormValues = Record; + interface RuleBuilderProps { onCreated?: () => Promise | void; + tables?: TableOut[] | null; + rulesLoading?: boolean; + rulesError?: string | null; + refreshRules?: () => Promise; + onRulesChange?: (tables: TableOut[]) => void; } -/* ---------- helpers ---------- */ +/* Helpers (unchanged) */ +function isPresetProtocolChoice(values: FormValues) { + return (values.protocolChoice ?? 'preset') === 'preset'; +} -function buildExprFromValues(values: any): Expr[] { +export function buildExprFromValues(values: FormValues): Expr[] { const expr: Expr[] = []; const protoChoice = values.protocolChoice ?? 'preset'; @@ -82,7 +95,6 @@ function buildExprFromValues(values: any): Expr[] { } else if (/udp/i.test(custom)) { expr.push({ udp: {} }); } else { - // allow user to paste a JSON token string (advanced) try { const parsed = JSON.parse(custom); expr.push(parsed); @@ -112,30 +124,21 @@ function buildExprFromValues(values: any): Expr[] { }); } + const chooseUdp = (preset?: string, custom?: string) => preset === 'udp' || (custom && /(udp)/i.test(custom)); + if (values.sport) { - const useUdp = - values.protocolPreset === 'udp' || - (values.protocolChoice === 'custom' && /(udp)/i.test(values.protocolCustom || '')); - const proto = useUdp ? 'udp' : 'tcp'; - const obj: any = {}; - obj[proto] = { sport: Number(values.sport) }; - expr.push(obj); + const proto = chooseUdp(values.protocolPreset, values.protocolCustom) ? 'udp' : 'tcp'; + expr.push({ [proto]: { sport: Number(values.sport) } } as any); } if (values.dport) { - const useUdp = - values.protocolPreset === 'udp' || - (values.protocolChoice === 'custom' && /(udp)/i.test(values.protocolCustom || '')); - const proto = useUdp ? 'udp' : 'tcp'; - const obj: any = {}; - obj[proto] = { dport: Number(values.dport) }; - expr.push(obj); + 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(); + const adv = (values.advanced || '').trim(); try { - const parsed = JSON.parse(adv); - expr.push(parsed); + expr.push(JSON.parse(adv)); } catch { expr.push(adv); } @@ -146,299 +149,264 @@ function buildExprFromValues(values: any): Expr[] { else if (action === 'accept') expr.push({ accept: null }); else if (action === 'reject') expr.push({ reject: null }); else if (action === 'nfqueue' || action === 'queue') { - // include a numeric queue token in the expr. backend should accept this shape. const qnum = Number(values.nfqueue || 0); - if (Number.isFinite(qnum) && qnum > 0) { - expr.push({ queue: qnum }); - } else { - // if no valid queue number provided, just push a generic queue token (backend may reject) - expr.push({ queue: 0 }); - } + expr.push({ queue: Number.isFinite(qnum) && qnum > 0 ? qnum : 0 } as any); } return expr; } -function textFromExpr(expr: Expr): string { - if (expr == null) return ''; - if (Array.isArray(expr)) { - const tokens: string[] = []; - for (const part of expr) { - if (typeof part === 'string') { - tokens.push(part); - } else if (part && typeof part === 'object') { - 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; - } - tokens.push('match'); - } else if ('payload' in part) { - const p = (part as any).payload; - const prot = p.protocol; - const field = p.field; - tokens.push(`payload(${prot}.${field})`); - } else 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'); - } else 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'); - } else if ('drop' in part) { - tokens.push('drop'); - } else if ('accept' in part) { - tokens.push('accept'); - } else if ('reject' in part) { - tokens.push('reject'); - } else if ('counter' in part) { - tokens.push('counter'); - } else if ('queue' in part) { - // show NFQUEUE / queue - const q = (part as any).queue; - tokens.push(`queue ${q}`); - } else { - const keys = Object.keys(part).sort().join('+'); - tokens.push(keys); - } - } else { - tokens.push(String(part)); - } +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; } - return tokens.join(' '); + 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 String(expr); + return tokens.join(' '); } -/* ---------- component ---------- */ - -export const RuleBuilder: React.FC = ({ onCreated }) => { - const [loading, setLoading] = useState(false); - const [tables, setTables] = useState([]); +/* Component */ +export const RuleBuilder: React.FC = (props: RuleBuilderProps) => { const [form] = Form.useForm(); - - const [cmdPreview, setCmdPreview] = useState(''); - const [requestJsonPreview, setRequestJsonPreview] = useState(null); const [refreshing, setRefreshing] = useState(false); + const [loading, setLoading] = useState(false); - async function loadTables() { - setRefreshing(true); - try { - const res = await fetchRuleset(); - if (!res || res.ruleset === null) { - setTables([]); - } else if (typeof res.ruleset === 'string') { - setTables([]); - } else { - setTables(res.ruleset.tables ?? []); - } - } catch (err) { - console.warn('fetchRuleset failed:', err); - message.warning('Could not fetch tables/chains; you can still create rules manually.'); - setTables([]); - } finally { - setRefreshing(false); + const [cmdPreview, setCmdPreview] = useState(''); + const [requestJsonPreview, setRequestJsonPreview] = useState(null); + + // state for insert-before options (so we can recompute on both table change and form change) + const [insertBeforeOptions, setInsertBeforeOptions] = useState>([]); + + // Resolve table selection convenience + const resolveTable = useCallback((values: FormValues) => { + const tableSelect = values.tableSelect; + if (!tableSelect || tableSelect === MANUAL_TABLE) { + return { + family: values.familyManual || 'inet', + table: values.tableManual || 'filter', + }; } - } - - // fetch on mount - useEffect(() => { - loadTables(); + const [family = 'inet', table = 'filter'] = String(tableSelect).split(':'); + return { family, table }; }, []); const tableOptions = useMemo( - () => tables.map((t) => ({ family: t.family, name: t.name, label: `${t.family}:${t.name}` })), - [tables], + () => (props.tables || []).map((t) => ({ value: `${t.family}:${t.name}`, label: `${t.family}:${t.name}` })), + [props.tables], ); - // set sensible defaults once tables are known + // compute insert-before options from current form selection + props.tables + const updateInsertOptions = useCallback(() => { + const ts = form.getFieldValue('tableSelect'); + const cs = form.getFieldValue('chainSelect'); + if (!ts || ts === MANUAL_TABLE || !cs || cs === MANUAL_CHAIN) { + 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]); + + // When the (parent-supplied) tables change, pick sensible defaults and update previews + insert options. useEffect(() => { if (tableOptions.length > 0) { - const first = tableOptions[0]; + const first = tableOptions[0].value; form.setFieldsValue({ - tableSelect: `${first.family}:${first.name}`, + tableSelect: first, protocolChoice: 'preset', protocolPreset: 'icmp', action: 'drop', }); - // also set chainSelect to the first chain for that table if exists - const tbl = tables.find((x) => x.family === first.family && x.name === first.name); + + 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: '__manual_chain__' }); + form.setFieldsValue({ chainSelect: MANUAL_CHAIN }); } + + // recompute insert-before options after defaults set + // give AntD a tick to flush form fields, then compute options & previews + setTimeout(() => { + updateInsertOptions(); + updatePreviews(); + }, 0); } else { form.setFieldsValue({ - tableSelect: '__manual__', + tableSelect: MANUAL_TABLE, protocolChoice: 'preset', protocolPreset: 'icmp', action: 'drop', - chainSelect: '__manual_chain__', + chainSelect: MANUAL_CHAIN, }); + setInsertBeforeOptions([]); + setTimeout(() => updatePreviews(), 0); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tableOptions]); + // only when parent tables change + }, [props.tables, tableOptions.length, updateInsertOptions]); - // update previews when form changes + // preview update (reads form values) const updatePreviews = useCallback(() => { - const vals = form.getFieldsValue(); - const expr = buildExprFromValues(vals); + const values = form.getFieldsValue(); + const expr = buildExprFromValues(values); + + const { family, table } = resolveTable(values); - // compute family/table/chain choices for cmd + request JSON - let family = 'inet'; - let tableName = 'filter'; let chain = 'input'; + const chainVal = values.chainSelect; + if (!chainVal || chainVal === MANUAL_CHAIN) chain = values.chainManual || chain; + else chain = chainVal; - const tableSelect = vals.tableSelect; - if (tableSelect && tableSelect !== '__manual__') { - const [f, n] = String(tableSelect).split(':'); - family = f || family; - tableName = n || tableName; - } else { - family = vals.familyManual || family; - tableName = vals.tableManual || tableName; - } - - const chainVal = vals.chainSelect; - if (!chainVal || chainVal === '__manual_chain__') { - chain = vals.chainManual || chain; - } else { - chain = chainVal; - } - - // compute insertion position if user selected an "insert after" handle - let positionToken = ''; - if ( - vals.insertAfterHandle && - tableSelect && - tableSelect !== '__manual__' && - chain && - chain !== '__manual_chain__' - ) { - // find index of the selected handle in current tables state - try { - const [f, n] = String(tableSelect).split(':'); - const tbl = tables.find((t) => t.family === f && t.name === n); - if (tbl) { - const ch = tbl.chains.find((c) => c.name === chain); - if (ch) { - const idx = ch.rules.findIndex((r) => String(r.handle) === String(vals.insertAfterHandle)); - if (idx >= 0) { - const pos = idx + 1; // insert after -> index+1 - positionToken = ` position ${pos}`; - } - } - } - } catch { - positionToken = ''; - } - } - + const beforeHandle = values.insertBeforeHandle; const short = textFromExpr(expr); - const cmd = `add rule ${family} ${tableName} ${chain}${positionToken} ${short}`.trim(); - setCmdPreview(cmd); + const cmdFriendly = beforeHandle + ? `add rule ${family} ${table} ${chain} position ${beforeHandle} ${short}` + : `add rule ${family} ${table} ${chain} ${short}`; + setCmdPreview(cmdFriendly); - const reqObj: any = { family, table: tableName, chain, expr }; - if (positionToken) { - // extract numeric pos and include in request body - const m = positionToken.match(/position\s+(\d+)/); - if (m) { - reqObj.position = Number(m[1]); - } + const req: any = { family, table, chain, expr }; + if (beforeHandle != null && beforeHandle !== '') { + // backend expects "position" + req.position = Number(beforeHandle); } - setRequestJsonPreview(reqObj); - }, [form, tables]); + setRequestJsonPreview(req); + }, [form, resolveTable]); + // run once at mount to compute previews from initial values useEffect(() => { updatePreviews(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // keep preview updated on values change + // when user changes form fields const onValuesChange = useCallback(() => { updatePreviews(); - // when tableSelect changes, auto-select chain (first) if any - const vals = form.getFieldsValue(); - const ts = vals.tableSelect; - if (ts && ts !== '__manual__') { + // keep chain in sync when tableSelect changes + const values = form.getFieldsValue(); + const ts = values.tableSelect; + if (ts && ts !== MANUAL_TABLE) { const [f, n] = String(ts).split(':'); - const tbl = tables.find((t) => t.family === f && t.name === n); + const tbl = props.tables?.find((t) => t.family === f && t.name === n); if (tbl) { if (tbl.chains && tbl.chains.length > 0) { - // if currently no chain selected or manual, set to first available chain - const cs = form.getFieldValue('chainSelect'); - if (!cs || cs === '__manual_chain__') { + const currentChain = form.getFieldValue('chainSelect'); + if (!currentChain || currentChain === MANUAL_CHAIN) { form.setFieldsValue({ chainSelect: tbl.chains[0].name }); } } else { - // if no chains, set chainSelect to manual so user can type - form.setFieldsValue({ chainSelect: '__manual_chain__' }); + form.setFieldsValue({ chainSelect: MANUAL_CHAIN }); } } } - }, [form, tables, updatePreviews]); - // submit handler: build expr and call createRuleJson + // recompute insert-before options when the user changes table/chain + updateInsertOptions(); + }, [form, props.tables, updatePreviews, updateInsertOptions]); + + // create handler — component will call props.refreshRules if provided; otherwise it will NOT fetch rules itself. const handleCreate = useCallback( - async (values: any) => { - const tableSelect = values.tableSelect; - let family: string; - let tableName: string; - if (tableSelect === '__manual__' || !tableSelect) { - family = values.familyManual || 'inet'; - tableName = values.tableManual || 'filter'; - } else { - [family, tableName] = String(tableSelect).split(':'); - } - - let chain: string | undefined = values.chainSelect; - if (!chain || chain === '__manual_chain__') { - chain = values.chainManual || 'input'; - } - + async (values: FormValues) => { + const { family, table } = resolveTable(values); + const chain = + !values.chainSelect || values.chainSelect === MANUAL_CHAIN ? values.chainManual || 'input' : values.chainSelect; const expr = buildExprFromValues(values); - // compute optional position from insertAfterHandle - const reqObj: any = { family, table: tableName, chain, expr }; - if ( - values.insertAfterHandle && - tableSelect && - tableSelect !== '__manual__' && - chain && - chain !== '__manual_chain__' - ) { - // locate index of handle and set position = index+1 - const [f, n] = String(tableSelect).split(':'); - const tbl = tables.find((t) => t.family === f && t.name === n); - if (tbl) { - const ch = tbl.chains.find((c) => c.name === chain); - if (ch) { - const idx = ch.rules.findIndex((r) => String(r.handle) === String(values.insertAfterHandle)); - if (idx >= 0) { - reqObj.position = idx + 1; - } - } - } + const req: any = { family, table, chain, expr }; + if (values.insertBeforeHandle != null && values.insertBeforeHandle !== '') { + // backend expects "position" (per your request) + req.position = Number(values.insertBeforeHandle); } const textual = textFromExpr(expr); - const posStr = reqObj.position !== undefined ? ` position ${reqObj.position}` : ''; - const cmd = `add rule ${family} ${tableName} ${chain}${posStr} ${textual}`.trim(); + const previewHint = req.position ? ` ${req.position}` : ''; + const cmd = `add rule ${family} ${table} ${chain}${previewHint} ${textual}`.trim(); Modal.confirm({ title: 'Create rule (JSON)', @@ -447,7 +415,7 @@ export const RuleBuilder: React.FC = ({ onCreated }) => { About to create rule in{' '} - {family}:{tableName} {chain} + {family}:{table} {chain} @@ -455,20 +423,22 @@ export const RuleBuilder: React.FC = ({ onCreated }) => {
{cmd || '(no textual preview available)'}
Full JSON NFT request: -
{JSON.stringify(reqObj, null, 2)}
+
{JSON.stringify(req, null, 2)}
), okText: 'Create', onOk: async () => { setLoading(true); try { - const res = await createRuleJson(reqObj); + const res = await createRuleJson(req); if (res && res.rc === 0) { message.success('Rule created'); - // refresh tables/chains after successful creation - await loadTables(); - if (onCreated) await onCreated(); - form.resetFields(['advanced', 'insertAfterHandle']); + if (props.refreshRules) { + await props.refreshRules(); + } else { + message.info('Parent did not provide a refresh function; please refresh ruleset externally.'); + } + if (props.onCreated) await props.onCreated(); } else { message.error(`Create failed: ${res?.stderr ?? 'unknown error'}`); } @@ -480,12 +450,10 @@ export const RuleBuilder: React.FC = ({ onCreated }) => { const rc = Number(data.rc ?? -1); const stderr = data.stderr ?? data; if (rc === 0) { - message.warn( - 'Rule appears to have been created, but server returned an error status. Check output for details.', - ); - await loadTables(); - if (onCreated) await onCreated(); - form.resetFields(['advanced', 'insertAfterHandle']); + message.warn('Rule may have been created; check ruleset.'); + if (props.refreshRules) { + await props.refreshRules(); + } } else { const errMsg = typeof stderr === 'string' ? stderr : JSON.stringify(stderr); message.error(`Create failed: ${errMsg}`); @@ -504,56 +472,41 @@ export const RuleBuilder: React.FC = ({ onCreated }) => { }, }); }, - [form, onCreated, tables], + [form, props.refreshRules, resolveTable], ); - // prepare chain options for currently selected table const chainOptions = useMemo(() => { const ts = form.getFieldValue('tableSelect'); - if (ts && ts !== '__manual__') { - const [f, n] = String(ts).split(':'); - const tbl = tables.find((t) => t.family === f && t.name === n); - if (tbl && tbl.chains.length > 0) { - return ( - <> - {tbl.chains.map((c) => ( - - ))} - - - ); - } - } - return ( - <> - - - ); - }, [form, tables]); - - // build insert-after options for currently selected table+chain - const insertAfterOptions = useMemo(() => { - const ts = form.getFieldValue('tableSelect'); - const cs = form.getFieldValue('chainSelect'); - if (!ts || ts === '__manual__' || !cs || cs === '__manual_chain__') return []; + if (!ts || ts === MANUAL_TABLE) return null; const [f, n] = String(ts).split(':'); - const tbl = tables.find((t) => t.family === f && t.name === n); - if (!tbl) return []; - const ch = tbl.chains.find((c) => c.name === cs); - if (!ch || !Array.isArray(ch.rules)) return []; - return ch.rules - .filter((r: any) => r && r.handle !== undefined && r.handle !== null) - .map((r: any) => ({ - value: r.handle, - label: `#${r.handle} — ${r.text ?? (typeof r.expr === 'string' ? r.expr : JSON.stringify(r.expr || r).slice(0, 120))}`, - })); - }, [form, tables]); + const tbl = props.tables?.find((t) => t.family === f && t.name === n); + if (!tbl) return null; + 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 { + // parent should update props.tables — update options once props.tables changes + // but ensure we recompute in case parent updated in place + updateInsertOptions(); + setRefreshing(false); + } + }, [props.refreshRules, updateInsertOptions]); return ( @@ -566,9 +519,10 @@ export const RuleBuilder: React.FC = ({ onCreated }) => { Builds an nft JSON expr and sends it to the server. If rendering fails, use the Raw UI. + - @@ -584,7 +538,7 @@ export const RuleBuilder: React.FC = ({ onCreated }) => { protocolChoice: 'preset', protocolPreset: 'icmp', action: 'drop', - tableSelect: tableOptions.length > 0 ? `${tableOptions[0].family}:${tableOptions[0].name}` : '__manual__', + tableSelect: tableOptions.length > 0 ? tableOptions[0].value : MANUAL_TABLE, }} onFinish={handleCreate} onValuesChange={onValuesChange} @@ -595,11 +549,11 @@ export const RuleBuilder: React.FC = ({ onCreated }) => { @@ -607,7 +561,7 @@ export const RuleBuilder: React.FC = ({ onCreated }) => { - {form.getFieldValue('tableSelect') === '__manual__' ? ( + {form.getFieldValue('tableSelect') === MANUAL_TABLE ? ( @@ -628,13 +582,19 @@ export const RuleBuilder: React.FC = ({ onCreated }) => { - + - {form.getFieldValue('chainSelect') === '__manual_chain__' || - form.getFieldValue('tableSelect') === '__manual__' ? ( + {form.getFieldValue('chainSelect') === MANUAL_CHAIN || + form.getFieldValue('tableSelect') === MANUAL_TABLE ? ( @@ -646,24 +606,24 @@ export const RuleBuilder: React.FC = ({ onCreated }) => { - {/* Insert-after control */} - {form.getFieldValue('tableSelect') !== '__manual__' && + {/* Insert-before control */} + {form.getFieldValue('tableSelect') !== MANUAL_TABLE && form.getFieldValue('chainSelect') && - form.getFieldValue('chainSelect') !== '__manual_chain__' && ( + form.getFieldValue('chainSelect') !== MANUAL_CHAIN && ( - + {insertBeforeOptions.length === 0 ? ( ) : ( - insertAfterOptions.map((o: any) => ( + insertBeforeOptions.map((o) => ( @@ -672,12 +632,6 @@ export const RuleBuilder: React.FC = ({ onCreated }) => { - - - Use when you want the new rule to appear right after a known handle. Refresh tables to see latest - handles. - - )} @@ -695,11 +649,7 @@ export const RuleBuilder: React.FC = ({ onCreated }) => { - {form.getFieldValue('protocolChoice') === 'custom' ? ( - - - - ) : ( + {isPresetProtocolChoice(form.getFieldsValue()) ? ( + ) : ( + + + )} @@ -732,11 +686,13 @@ export const RuleBuilder: React.FC = ({ onCreated }) => { + + @@ -796,12 +752,12 @@ export const RuleBuilder: React.FC = ({ onCreated }) => { return; } if (!cmdPreview) return; - // copy the textual preview as plain text navigator.clipboard?.writeText(cmdPreview); message.success('Command preview copied'); }} /> + diff --git a/frontend/src/components/FirewallRulesetViewer.tsx b/frontend/src/components/FirewallRulesetViewer.tsx index 077df19..144a25d 100644 --- a/frontend/src/components/FirewallRulesetViewer.tsx +++ b/frontend/src/components/FirewallRulesetViewer.tsx @@ -1,123 +1,30 @@ // src/components/FirewallTables.tsx -import { ArrowDownOutlined, ArrowUpOutlined, DeleteOutlined } from '@ant-design/icons'; -import { Alert, Button, Card, Divider, message, Modal, Space, Spin, Table, Typography } from 'antd'; +import { ArrowDownOutlined, DeleteOutlined } from '@ant-design/icons'; +import { Alert, Button, Card, Divider, message, Modal, Space, Table, Typography } from 'antd'; import { ColumnsType } from 'antd/lib/table'; -import { ReactElement, useEffect, useState } from 'react'; -import { execFirewallRaw, fetchRuleset, moveRule } from '../api/apiClient'; -import type { CmdResult, ExecResult } from '../types/firewall'; +import { ReactElement, useCallback, useMemo, useState } from 'react'; +import { execFirewallRaw } from '../api/apiClient'; +import type { CmdResult, ExecResult, RuleOut, TableOut } from '../types/firewall'; import FirewallAddChainModal from './FireWallAddChainModal'; import FirewallAddTableModal from './FireWallAddTableModal'; const { Paragraph, Text, Title } = Typography; -/* Types */ -type NFTRule = { - handle?: number | string; - expr?: any; - rule?: any; - text?: string; - [k: string]: any; -}; +/* ---------- Helpers ---------- */ -type NFTChain = { - name: string; - type?: string | null; - hook?: string | null; - priority?: number | null; - policy?: string | null; - rules: NFTRule[]; -}; +/** + * Render a human friendly one-line representation of a rule. + * Accepts RuleOut (typed) but remains defensive against unexpected shapes. + */ +function renderRuleFriendly(rule: RuleOut | any): string { + // prefer `text` if present + if (rule?.text && typeof rule.text === 'string' && rule.text.trim() !== '') return rule.text; -type NFTTable = { - family?: string | null; - name: string; - chains: NFTChain[]; -}; + // if expr is a string, show it + if (rule?.expr && typeof rule.expr === 'string') return rule.expr; -/* Extract ruleset -> tables (unchanged) */ -function extractTablesFromParsed(parsed: any): NFTTable[] { - if (!parsed) return []; - - if (Array.isArray(parsed.nftables)) { - const tablesMap = new Map(); - const chainsMap = new Map(); - - for (const item of parsed.nftables) { - if (item.table) { - const t = item.table; - const key = `${t.family ?? 'n/a'}:${t.name}`; - if (!tablesMap.has(key)) { - tablesMap.set(key, { family: t.family ?? null, name: t.name, chains: [] }); - } - } else if (item.chain) { - const c = item.chain; - const fam = c.family ?? 'n/a'; - const table = c.table ?? 'n/a'; - const tableKey = `${fam}:${table}`; - - if (!tablesMap.has(tableKey)) { - tablesMap.set(tableKey, { family: c.family ?? null, name: table, chains: [] }); - } - - const chainKey = `${fam}:${table}:${c.name}`; - const chainObj: NFTChain = { - name: c.name, - type: c.type ?? null, - hook: c.hook ?? null, - priority: (c.priority as number) ?? null, - policy: c.policy ?? null, - rules: [], - }; - - chainsMap.set(chainKey, chainObj); - tablesMap.get(tableKey)!.chains.push(chainObj); - } else if (item.rule) { - const r = item.rule; - const fam = r.family ?? r.table?.family ?? 'n/a'; - const table = r.table ?? r.table?.name ?? r.table_name ?? 'n/a'; - const chainName = r.chain ?? r.chain?.name ?? 'unknown'; - - const tableKey = `${fam}:${table}`; - const chainKey = `${fam}:${table}:${chainName}`; - - if (!tablesMap.has(tableKey)) { - tablesMap.set(tableKey, { family: fam ?? null, name: table, chains: [] }); - } - - if (!chainsMap.has(chainKey)) { - const newChain: NFTChain = { name: chainName, rules: [] }; - chainsMap.set(chainKey, newChain); - tablesMap.get(tableKey)!.chains.push(newChain); - } - - tablesMap - .get(tableKey)! - .chains.find((c) => c.name === chainName)! - .rules.push({ ...r }); - } - } - - return Array.from(tablesMap.values()); - } - - if (Array.isArray(parsed.tables)) { - return parsed.tables.map((t: any) => ({ - family: t.family ?? null, - name: t.name, - chains: t.chains ?? [], - })); - } - - return []; -} - -/* Friendly rule renderer (keeps previous logic) */ -function renderRuleFriendly(rule: NFTRule): string { - if (rule.text && typeof rule.text === 'string' && rule.text.trim() !== '') return rule.text; - if (typeof rule.rule === 'string') return rule.rule; - if (rule.expr && typeof rule.expr === 'string') return rule.expr; - - const expr = rule.expr ?? rule; + // if expr is an array, try to pretty print common tokens + const expr = rule?.expr ?? rule; if (Array.isArray(expr)) { const tokens: string[] = []; for (const part of expr) { @@ -200,7 +107,8 @@ function renderRuleFriendly(rule: NFTRule): string { if (tokens.length > 0) return tokens.join(' '); } - if (rule.expr && typeof rule.expr === 'object') { + // fallback: if expr is an object, stringify a short excerpt + if (rule?.expr && typeof rule.expr === 'object') { try { return JSON.stringify(rule.expr, (_k, v) => (v === undefined ? null : v)).slice(0, 500); } catch { @@ -208,6 +116,7 @@ function renderRuleFriendly(rule: NFTRule): string { } } + // final fallback: stringify whole rule try { return JSON.stringify(rule, null, 2); } catch { @@ -221,36 +130,24 @@ function isSuccessRc(out?: ExecResult | null): boolean { return out.rc === 0 || out.rc === -1; } -/* Component */ -export default function FirewallTables(): ReactElement { - const [loading, setLoading] = useState(true); - const [refreshing, setRefreshing] = useState(false); - const [error, setError] = useState(null); - const [tables, setTables] = useState([]); - const [results, setResults] = useState([]); +/* ---------- Props ---------- */ +type Props = { + tables: TableOut[]; // passed from parent + error?: Error | null; + refreshRules: () => Promise; // trigger to re-fetch ruleset +}; +/* ---------- Component ---------- */ + +export default function FirewallTables({ tables, error, refreshRules: refresh }: Props): ReactElement { + // local UI state, non-persistent + const [refreshing, setRefreshing] = useState(false); + const [results, setResults] = useState([]); const [isOpenTableCreatorModal, setIsOpenTableCreatorModal] = useState(false); const [isOpenChainCreatorModal, setIsOpenChainCreatorModal] = useState(false); - async function loadRuleset() { - setLoading(true); - try { - const res = await fetchRuleset(); - const parsed = extractTablesFromParsed((res as any).ruleset); - setTables(parsed); - setError(null); - } catch (err: any) { - setError(err?.message ?? String(err)); - } finally { - setLoading(false); - } - } - - useEffect(() => { - loadRuleset(); - }, []); - - async function runCommands(cmds: string[]) { + // run raw nft commands sequentially and collect results (used for delete ops etc.) + const runCommands = useCallback(async (cmds: string[]) => { const acc: CmdResult[] = []; for (const cmd of cmds) { try { @@ -261,230 +158,122 @@ export default function FirewallTables(): ReactElement { acc.push({ cmd, err: err?.message ?? String(err) }); } } + // prepend new results to history setResults((prev) => [...acc, ...prev]); return acc; - } + }, []); - // Delete helpers (unchanged behavior) - async function handleDeleteRule( - family: string | null | undefined, - table: string, - chain: string, - handle: number | string, - ) { - const cmd = `delete rule ${family ?? 'inet'} ${table} ${chain} handle ${handle}`; - Modal.confirm({ - title: 'Delete Rule', - content: ( - <> - Are you sure you want to delete this rule? - - {cmd} - - ), - onOk: async () => { - try { - const res = await runCommands([cmd]); - const first = res[0]; - if (!first.err) message.success('Rule deleted'); - else message.error('Delete returned error — check results panel'); - } catch (err: any) { - message.error('Delete failed: ' + (err?.message ?? String(err))); - } finally { - await loadRuleset(); - } - }, - }); - } + // Delete rule + const handleDeleteRule = useCallback( + async (family: string | null | undefined, table: string, chain: string, handle: number | string) => { + const cmd = `delete rule ${family ?? 'inet'} ${table} ${chain} handle ${handle}`; + Modal.confirm({ + title: 'Delete Rule', + content: ( + <> + Are you sure you want to delete this rule? + + {cmd} + + ), + onOk: async () => { + try { + const res = await runCommands([cmd]); + const first = res[0]; + if (!first.err) message.success('Rule deleted'); + else message.error('Delete returned error — check results panel'); + } catch (err: any) { + message.error('Delete failed: ' + (err?.message ?? String(err))); + } finally { + await refresh(); + } + }, + }); + }, + [runCommands, refresh], + ); - async function handleDeleteChain(family: string | null | undefined, table: string, chain: string) { - const cmd = `delete chain ${family ?? 'inet'} ${table} ${chain}`; - Modal.confirm({ - title: 'Delete Chain', - content: ( - <> - - This will delete the chain {chain} in table {table} unrevertably. - - - {cmd} - - ), - onOk: async () => { - try { - const res = await runCommands([cmd]); - const first = res[0]; - if (!first.err) message.success(`Chain ${chain} deleted`); - else message.error(`Chain deletion returned error — check results panel`); - } catch (err: any) { - message.error('Chain deletion failed: ' + (err?.message ?? String(err))); - } finally { - await loadRuleset(); - } - }, - }); - } + // Delete chain + const handleDeleteChain = useCallback( + async (family: string | null | undefined, table: string, chain: string) => { + const cmd = `delete chain ${family ?? 'inet'} ${table} ${chain}`; + Modal.confirm({ + title: 'Delete Chain', + content: ( + <> + + This will delete the chain {chain} in table {table} unrevertably. + + + {cmd} + + ), + onOk: async () => { + try { + const res = await runCommands([cmd]); + const first = res[0]; + if (!first.err) message.success(`Chain ${chain} deleted`); + else message.error(`Chain deletion returned error — check results panel`); + } catch (err: any) { + message.error('Chain deletion failed: ' + (err?.message ?? String(err))); + } finally { + await refresh(); + } + }, + }); + }, + [runCommands, refresh], + ); - async function handleDeleteTable(family: string | null | undefined, table: string) { - const cmd = `delete table ${family ?? 'inet'} ${table}`; - Modal.confirm({ - title: 'Delete Table', - content: ( - <> - - This will delete the table {table} including all its chains and rules unrevertably. - - - {cmd} - - ), - onOk: async () => { - try { - const res = await runCommands([cmd]); - const first = res[0]; - if (!first.err) message.success(`Table ${table} deleted`); - else message.error(`Table deletion returned error — check results panel`); - } catch (err: any) { - message.error('Table deletion failed: ' + (err?.message ?? String(err))); - } finally { - await loadRuleset(); - } - }, - }); - } + // Delete table + const handleDeleteTable = useCallback( + async (family: string | null | undefined, table: string) => { + const cmd = `delete table ${family ?? 'inet'} ${table}`; + Modal.confirm({ + title: 'Delete Table', + content: ( + <> + + This will delete the table {table} including all its chains and rules unrevertably. + + + {cmd} + + ), + onOk: async () => { + try { + const res = await runCommands([cmd]); + const first = res[0]; + if (!first.err) message.success(`Table ${table} deleted`); + else message.error(`Table deletion returned error — check results panel`); + } catch (err: any) { + message.error('Table deletion failed: ' + (err?.message ?? String(err))); + } finally { + await refresh(); + } + }, + }); + }, + [runCommands, refresh], + ); - async function handleRefresh() { + // manual refresh trigger (exposed to UI) + const handleRefresh = useCallback(async () => { setRefreshing(true); try { - await loadRuleset(); + await refresh(); message.success('Ruleset refreshed'); } catch { // ignore } finally { setRefreshing(false); } - } + }, [refresh]); - // Move using position (0-based). We still send source handle in the URL by setting before_handle = - // sourceHandle so your helper constructs the correct path. The body contains "position". - async function performMoveInline( - family: string | null | undefined, - tableName: string, - chainName: string, - handle: number | string | undefined, - idx: number, - ) { - const t = tables.find((x) => x.family === family && x.name === tableName); - if (!t) { - message.error('Table not found in local state'); - return; - } - const ch = t.chains.find((c) => c.name === chainName); - if (!ch) { - message.error('Chain not found in local state'); - return; - } + const tablesToRender = useMemo(() => tables ?? [], [tables]); - const lastIndex = ch.rules.length - 1; - - // if no handle -> disallow - if (handle === undefined || handle === null) { - message.error('Rule has no handle; cannot perform server-side move.'); - return; - } - - // compute target positions for up/down actions invoked from UI; we assume caller computed newPos already - // but here we'll ask the user which direction via a small confirm for safety. - const confirm = await new Promise((resolve) => { - Modal.confirm({ - title: 'Move rule', - content: `Move rule #${handle} (position ${idx + 1} of ${lastIndex + 1})?`, - okText: 'Move up one', - cancelText: 'Move down one', - onOk: () => resolve(true), - onCancel: () => resolve(false), - }); - }); - - // But above confirm uses ok/cancel as a quick choice. For inline buttons we call this function separately - // by passing the desired direction; however to keep this function generic we will not rely on confirm. - // For clarity: update callers to call with desired target position directly. (We handle here both.) - return; - } - - // Inline-specific wrappers: moveUp / moveDown (these compute position and call moveRule) - async function moveUp(family: string, tableName: string, chainName: string, handle: number | string, idx: number) { - if (idx <= 0) { - message.warning('Already at top'); - return; - } - // target position: idx - 1 - const targetPos = idx - 1; - await callMoveApi(family, tableName, chainName, handle, targetPos); - } - - async function moveDown(family: string, tableName: string, chainName: string, handle: number | string, idx: number) { - const t = tables.find((x) => x.family === family && x.name === tableName); - if (!t) return; - const ch = t.chains.find((c) => c.name === chainName); - if (!ch) return; - const lastIndex = ch.rules.length - 1; - if (idx >= lastIndex) { - message.warning('Already at bottom'); - return; - } - // target position: idx + 1 - const targetPos = idx + 1; - await callMoveApi(family, tableName, chainName, handle, targetPos); - } - - // call your moveRule helper; we must supply before_handle as the source handle so the helper puts that in the URL - // while the server will use 'position' from the body to place the rule. - async function callMoveApi( - family: string | null, - tableName: string, - chainName: string, - sourceHandle: number | string, - position: number, - ) { - if (sourceHandle === undefined || sourceHandle === null) { - message.error('Cannot move rule without a source handle.'); - return; - } - - const reqBody = { - family: family, - table: tableName, - chain: chainName, - position: Number(position), - before_handle: Number(sourceHandle), - } as any; - - message.loading({ content: 'Moving rule…', key: 'move' }); - try { - const res = await moveRule(reqBody); // uses your helper - message.success({ content: 'Rule moved', key: 'move' }); - } catch (err: any) { - const detail = err?.response?.data ?? err?.message ?? String(err); - message.error({ - content: 'Move failed: ' + (typeof detail === 'string' ? detail : JSON.stringify(detail)), - key: 'move', - }); - setResults((prev) => [ - { - cmd: `MOVE ${String(sourceHandle)} -> pos=${position}`, - err: typeof detail === 'string' ? detail : JSON.stringify(detail), - }, - ...prev, - ]); - } finally { - await loadRuleset(); - } - } - - if (loading) return ; - - if (error) return ; + if (error) + return ; return ( <> @@ -500,16 +289,17 @@ export default function FirewallTables(): ReactElement { } > - {tables.length === 0 && ( + {tablesToRender.length === 0 && ( )} - {tables.map((table) => { - const totalRules = table.chains.reduce((acc, c) => acc + c.rules.length, 0); + + {tablesToRender.map((table) => { + const totalRules = table.chains.reduce((acc, c) => acc + (c.rules?.length ?? 0), 0); return ( {table.chains.map((chain) => { - const lastIndex = chain.rules.length - 1; const columns: ColumnsType = [ { title: 'Rule #', dataIndex: 'idx', width: 80 }, { title: 'Handle', dataIndex: 'handle', - width: 120, + width: 80, render: (v) => v ?? '-', }, { title: 'Rule', dataIndex: 'raw', - render: (v) => ( - - {v} - - ), + render: (v) => {v}, }, { title: 'Actions', dataIndex: 'actions', - width: 160, + width: 80, render: (_: any, rec: any) => rec.handle ? ( - -