diff --git a/frontend/src/components/FireWallAddChainModal.tsx b/frontend/src/components/FireWallAddChainModal.tsx index 2379d40..6e31719 100644 --- a/frontend/src/components/FireWallAddChainModal.tsx +++ b/frontend/src/components/FireWallAddChainModal.tsx @@ -111,7 +111,7 @@ export default function FirewallAddChainModal({ values && typeof values.chainName === 'string' && values.chainName.trim().length > 0 ? values.chainName.trim() : undefined; - const chainFromForm = form.getFieldValue('chainName'); + const chainFromForm = form.getFieldValue('hook'); const chain = chainFromValues || (typeof chainFromForm === 'string' && chainFromForm.trim().length > 0 ? chainFromForm.trim() : undefined) || @@ -287,12 +287,6 @@ export default function FirewallAddChainModal({ )} - - - - - - diff --git a/frontend/src/components/FirewallRuleBuilder.tsx b/frontend/src/components/FirewallRuleBuilder.tsx index 05d5cb9..8ad478a 100644 --- a/frontend/src/components/FirewallRuleBuilder.tsx +++ b/frontend/src/components/FirewallRuleBuilder.tsx @@ -18,16 +18,13 @@ import { Typography, } from 'antd'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { createRuleJson } from '../api/apiClient'; -import { ChainOut, Expr, RuleOut, TableOut } from '../types/firewall'; +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; -const MANUAL_TABLE = '__manual__'; -const MANUAL_CHAIN = '__manual_chain__'; - type FormValues = Record; interface RuleBuilderProps { @@ -39,11 +36,17 @@ interface RuleBuilderProps { onRulesChange?: (tables: TableOut[]) => void; } -/* Helpers (unchanged) */ +/* ---------------------- + 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[] = []; @@ -156,6 +159,9 @@ export function buildExprFromValues(values: FormValues): Expr[] { 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); @@ -230,52 +236,78 @@ export function textFromExpr(expr: Expr | null | undefined): string { return tokens.join(' '); } -/* Component */ +/* ------------------------- + 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 [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', - }; - } - const [family = 'inet', table = 'filter'] = String(tableSelect).split(':'); - return { family, table }; - }, []); - const tableOptions = useMemo( () => (props.tables || []).map((t) => ({ value: `${t.family}:${t.name}`, label: `${t.family}:${t.name}` })), [props.tables], ); - // compute insert-before options from current form selection + 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 || ts === MANUAL_TABLE || !cs || cs === MANUAL_CHAIN) { + 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([]); @@ -286,13 +318,22 @@ export const RuleBuilder: React.FC = (props: RuleBuilderProps) .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))}`, + 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. + 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; @@ -301,6 +342,7 @@ export const RuleBuilder: React.FC = (props: RuleBuilderProps) protocolChoice: 'preset', protocolPreset: 'icmp', action: 'drop', + nfqueue: 1, }); const [f, n] = String(first).split(':'); @@ -308,179 +350,117 @@ export const RuleBuilder: React.FC = (props: RuleBuilderProps) if (tbl && tbl.chains && tbl.chains.length > 0) { form.setFieldsValue({ chainSelect: tbl.chains[0].name }); } else { - form.setFieldsValue({ chainSelect: MANUAL_CHAIN }); + form.setFieldsValue({ chainSelect: undefined }); } - // recompute insert-before options after defaults set - // give AntD a tick to flush form fields, then compute options & previews setTimeout(() => { updateInsertOptions(); - updatePreviews(); + updatePreviewFromValues(); }, 0); } else { form.setFieldsValue({ - tableSelect: MANUAL_TABLE, protocolChoice: 'preset', protocolPreset: 'icmp', action: 'drop', - chainSelect: MANUAL_CHAIN, + nfqueue: 1, + tableSelect: undefined, + chainSelect: undefined, }); setInsertBeforeOptions([]); - setTimeout(() => updatePreviews(), 0); + setTimeout(() => updatePreviewFromValues(), 0); } - // only when parent tables change - }, [props.tables, tableOptions.length, updateInsertOptions]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.tables, tableOptions.length]); - // preview update (reads form values) - const updatePreviews = useCallback(() => { - const values = form.getFieldsValue(); - const expr = buildExprFromValues(values); - - const { family, table } = resolveTable(values); - - let chain = 'input'; - const chainVal = values.chainSelect; - if (!chainVal || chainVal === MANUAL_CHAIN) chain = values.chainManual || chain; - else chain = chainVal; - - const beforeHandle = values.insertBeforeHandle; - const short = textFromExpr(expr); - const cmdFriendly = beforeHandle - ? `add rule ${family} ${table} ${chain} position ${beforeHandle} ${short}` - : `add rule ${family} ${table} ${chain} ${short}`; - setCmdPreview(cmdFriendly); - - const req: any = { family, table, chain, expr }; - if (beforeHandle != null && beforeHandle !== '') { - // backend expects "position" - req.position = Number(beforeHandle); - } - setRequestJsonPreview(req); - }, [form, resolveTable]); - - // run once at mount to compute previews from initial values useEffect(() => { - updatePreviews(); + const current = form.getFieldsValue(); + if (current.nfqueue == null) form.setFieldsValue({ nfqueue: 1 }); + updatePreviewFromValues(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // when user changes form fields - const onValuesChange = useCallback(() => { - updatePreviews(); + 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; + } - // 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 = props.tables?.find((t) => t.family === f && t.name === n); - if (tbl) { - if (tbl.chains && tbl.chains.length > 0) { - const currentChain = form.getFieldValue('chainSelect'); - if (!currentChain || currentChain === MANUAL_CHAIN) { - form.setFieldsValue({ chainSelect: tbl.chains[0].name }); + 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 }); } - } else { - form.setFieldsValue({ chainSelect: MANUAL_CHAIN }); } } - } - // recompute insert-before options when the user changes table/chain - updateInsertOptions(); - }, [form, props.tables, updatePreviews, updateInsertOptions]); + updateInsertOptions(); + updatePreviewFromValues(allValues); + }, + [form, props.tables, updateInsertOptions, updatePreviewFromValues], + ); - // create handler — component will call props.refreshRules if provided; otherwise it will NOT fetch rules itself. const handleCreate = useCallback( 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); - - 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 previewHint = req.position ? ` ${req.position}` : ''; - const cmd = `add rule ${family} ${table} ${chain}${previewHint} ${textual}`.trim(); + const { cmd } = generateCommandFromValues(values); Modal.confirm({ - title: 'Create rule (JSON)', + title: 'Run raw nft command', content: (
- About to create rule in{' '} - - {family}:{table} {chain} - + About to run nft command in {String(values.tableSelect ?? 'inet:filter')} (see preview). - Generated nft command: -
{cmd || '(no textual preview available)'}
- - Full JSON NFT request: -
{JSON.stringify(req, null, 2)}
+ Command: +
{cmd}
), - okText: 'Create', + okText: 'Run', onOk: async () => { setLoading(true); try { - const res = await createRuleJson(req); - if (res && res.rc === 0) { - message.success('Rule created'); - if (props.refreshRules) { - await props.refreshRules(); - } else { - message.info('Parent did not provide a refresh function; please refresh ruleset externally.'); - } + 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 { - message.error(`Create failed: ${res?.stderr ?? 'unknown error'}`); + 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) { - const resp = err?.response; - if (resp && resp.data) { - const data = resp.data; - if (typeof data === 'object' && (typeof data.rc === 'number' || 'stderr' in data)) { - const rc = Number(data.rc ?? -1); - const stderr = data.stderr ?? data; - if (rc === 0) { - 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}`); - } - } else if (resp.data.detail) { - message.error(`Create failed: ${resp.data.detail}`); - } else { - message.error(`Create failed: ${JSON.stringify(resp.data)}`); - } - } else { - message.error(`Create failed: ${err?.message ?? String(err)}`); - } + message.error('Execution failed: ' + (err?.message ?? String(err))); } finally { setLoading(false); } }, }); }, - [form, props.refreshRules, resolveTable], + [props.refreshRules, props.onCreated], ); const chainOptions = useMemo(() => { const ts = form.getFieldValue('tableSelect'); - if (!ts || ts === MANUAL_TABLE) return null; + if (!ts) return []; const [f, n] = String(ts).split(':'); const tbl = props.tables?.find((t) => t.family === f && t.name === n); - if (!tbl) return null; + if (!tbl) return []; return tbl.chains.map((c) => (