JSON WORKS HERE TAG
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s

This commit is contained in:
2026-02-28 17:55:03 +01:00
parent eb1eef23c4
commit 64d3a973bc
5 changed files with 498 additions and 779 deletions

View File

@@ -1,6 +1,6 @@
// src/apiClient.ts // src/apiClient.ts
import axios from 'axios'; import axios from 'axios';
import { CreateRuleRequest, ExecResult, MoveRequest, MoveResult, RulesetModel } from '../types/firewall'; import { CreateRuleRequest, ExecResult, RulesetModel } from '../types/firewall';
import { import {
BridgeCreateRequest, BridgeCreateRequest,
BridgeInfo, BridgeInfo,
@@ -125,8 +125,8 @@ export const fetchPackets = async (limit = 100): Promise<any> => {
* Returns: { ruleset: RulesetModel | string | null } * Returns: { ruleset: RulesetModel | string | null }
* - If the server returns a raw textual fallback (string), the caller should handle it. * - If the server returns a raw textual fallback (string), the caller should handle it.
*/ */
export const fetchRuleset = async (): Promise<{ ruleset: RulesetModel | string | null }> => { export const fetchRuleset = async (): Promise<{ ruleset: RulesetModel }> => {
const res = await api.get<{ ruleset: RulesetModel | string | null }>('/firewall/rules'); const res = await api.get<{ ruleset: RulesetModel }>('/firewall/rules');
return res.data; return res.data;
}; };
@@ -142,11 +142,6 @@ export const deleteRule = async (handle: number, family: string, table: string,
return res.data; return res.data;
}; };
export const moveRule = async (req: MoveRequest): Promise<MoveResult> => {
const res = await api.post<MoveResult>(`/firewall/rules/${encodeURIComponent(String(req.before_handle))}/move`, req);
return res.data;
};
/** /**
* createRuleJson - POST /firewall/rules * createRuleJson - POST /firewall/rules
* Body: CreateRuleRequest (must include expr) * Body: CreateRuleRequest (must include expr)

View File

@@ -18,20 +18,33 @@ import {
Typography, Typography,
} from 'antd'; } from 'antd';
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { createRuleJson, fetchRuleset } from '../api/apiClient'; import { createRuleJson } from '../api/apiClient';
import { Expr, TableOut } from '../types/firewall'; import { ChainOut, Expr, RuleOut, TableOut } from '../types/firewall';
const { Option } = Select; const { Option } = Select;
const { TextArea } = Input; const { TextArea } = Input;
const { Title, Text } = Typography; const { Title, Text } = Typography;
const MANUAL_TABLE = '__manual__';
const MANUAL_CHAIN = '__manual_chain__';
type FormValues = Record<string, any>;
interface RuleBuilderProps { interface RuleBuilderProps {
onCreated?: () => Promise<void> | void; onCreated?: () => Promise<void> | void;
tables?: TableOut[] | null;
rulesLoading?: boolean;
rulesError?: string | null;
refreshRules?: () => Promise<void>;
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 expr: Expr[] = [];
const protoChoice = values.protocolChoice ?? 'preset'; const protoChoice = values.protocolChoice ?? 'preset';
@@ -82,7 +95,6 @@ function buildExprFromValues(values: any): Expr[] {
} else if (/udp/i.test(custom)) { } else if (/udp/i.test(custom)) {
expr.push({ udp: {} }); expr.push({ udp: {} });
} else { } else {
// allow user to paste a JSON token string (advanced)
try { try {
const parsed = JSON.parse(custom); const parsed = JSON.parse(custom);
expr.push(parsed); 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) { if (values.sport) {
const useUdp = const proto = chooseUdp(values.protocolPreset, values.protocolCustom) ? 'udp' : 'tcp';
values.protocolPreset === 'udp' || expr.push({ [proto]: { sport: Number(values.sport) } } as any);
(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);
} }
if (values.dport) { if (values.dport) {
const useUdp = const proto = chooseUdp(values.protocolPreset, values.protocolCustom) ? 'udp' : 'tcp';
values.protocolPreset === 'udp' || expr.push({ [proto]: { dport: Number(values.dport) } } as any);
(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);
} }
if (values.advanced) { if (values.advanced) {
const adv = values.advanced.trim(); const adv = (values.advanced || '').trim();
try { try {
const parsed = JSON.parse(adv); expr.push(JSON.parse(adv));
expr.push(parsed);
} catch { } catch {
expr.push(adv); expr.push(adv);
} }
@@ -146,27 +149,27 @@ function buildExprFromValues(values: any): Expr[] {
else if (action === 'accept') expr.push({ accept: null }); else if (action === 'accept') expr.push({ accept: null });
else if (action === 'reject') expr.push({ reject: null }); else if (action === 'reject') expr.push({ reject: null });
else if (action === 'nfqueue' || action === 'queue') { 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); const qnum = Number(values.nfqueue || 0);
if (Number.isFinite(qnum) && qnum > 0) { expr.push({ queue: Number.isFinite(qnum) && qnum > 0 ? qnum : 0 } as any);
expr.push({ queue: qnum });
} else {
// if no valid queue number provided, just push a generic queue token (backend may reject)
expr.push({ queue: 0 });
}
} }
return expr; return expr;
} }
function textFromExpr(expr: Expr): string { export function textFromExpr(expr: Expr | null | undefined): string {
if (expr == null) return ''; if (!expr) return '';
if (Array.isArray(expr)) { if (!Array.isArray(expr)) return String(expr);
const tokens: string[] = []; const tokens: string[] = [];
for (const part of expr) { for (const part of expr) {
if (typeof part === 'string') { if (typeof part === 'string') {
tokens.push(part); tokens.push(part);
} else if (part && typeof part === 'object') { continue;
}
if (!part || typeof part !== 'object') {
tokens.push(String(part));
continue;
}
if ('match' in part) { if ('match' in part) {
const m = (part as any).match; const m = (part as any).match;
const left = m.left; const left = m.left;
@@ -179,266 +182,231 @@ function textFromExpr(expr: Expr): string {
tokens.push(`${prot} ${field} ${right}`); tokens.push(`${prot} ${field} ${right}`);
continue; continue;
} }
}
tokens.push('match'); tokens.push('match');
continue; continue;
} }
tokens.push('match'); if ('payload' in part) {
} else if ('payload' in part) {
const p = (part as any).payload; const p = (part as any).payload;
const prot = p.protocol; tokens.push(`payload(${p.protocol}.${p.field})`);
const field = p.field; continue;
tokens.push(`payload(${prot}.${field})`); }
} else if ('tcp' in part) { if ('tcp' in part) {
const v = (part as any).tcp; const v = (part as any).tcp;
if (v && v.dport) tokens.push(`tcp dport ${v.dport}`); if (v && v.dport) tokens.push(`tcp dport ${v.dport}`);
else if (v && v.sport) tokens.push(`tcp sport ${v.sport}`); else if (v && v.sport) tokens.push(`tcp sport ${v.sport}`);
else tokens.push('tcp'); else tokens.push('tcp');
} else if ('udp' in part) { continue;
}
if ('udp' in part) {
const v = (part as any).udp; const v = (part as any).udp;
if (v && v.dport) tokens.push(`udp dport ${v.dport}`); if (v && v.dport) tokens.push(`udp dport ${v.dport}`);
else if (v && v.sport) tokens.push(`udp sport ${v.sport}`); else if (v && v.sport) tokens.push(`udp sport ${v.sport}`);
else tokens.push('udp'); else tokens.push('udp');
} else if ('drop' in part) { continue;
}
if ('drop' in part) {
tokens.push('drop'); tokens.push('drop');
} else if ('accept' in part) { continue;
}
if ('accept' in part) {
tokens.push('accept'); tokens.push('accept');
} else if ('reject' in part) { continue;
}
if ('reject' in part) {
tokens.push('reject'); tokens.push('reject');
} else if ('counter' in part) { continue;
}
if ('counter' in part) {
tokens.push('counter'); tokens.push('counter');
} else if ('queue' in part) { continue;
// 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 { if ('queue' in part) {
tokens.push(String(part)); tokens.push(`queue ${(part as any).queue}`);
continue;
} }
tokens.push(Object.keys(part).sort().join('+'));
} }
return tokens.join(' '); return tokens.join(' ');
}
return String(expr);
} }
/* ---------- component ---------- */ /* Component */
export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps) => {
export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
const [loading, setLoading] = useState(false);
const [tables, setTables] = useState<TableOut[]>([]);
const [form] = Form.useForm(); const [form] = Form.useForm();
const [cmdPreview, setCmdPreview] = useState<string>('');
const [requestJsonPreview, setRequestJsonPreview] = useState<any>(null);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [loading, setLoading] = useState(false);
async function loadTables() { const [cmdPreview, setCmdPreview] = useState('');
setRefreshing(true); const [requestJsonPreview, setRequestJsonPreview] = useState<any>(null);
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);
}
}
// fetch on mount // state for insert-before options (so we can recompute on both table change and form change)
useEffect(() => { const [insertBeforeOptions, setInsertBeforeOptions] = useState<Array<{ value: any; label: string }>>([]);
loadTables();
// 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( const tableOptions = useMemo(
() => tables.map((t) => ({ family: t.family, name: t.name, label: `${t.family}:${t.name}` })), () => (props.tables || []).map((t) => ({ value: `${t.family}:${t.name}`, label: `${t.family}:${t.name}` })),
[tables], [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(() => { useEffect(() => {
if (tableOptions.length > 0) { if (tableOptions.length > 0) {
const first = tableOptions[0]; const first = tableOptions[0].value;
form.setFieldsValue({ form.setFieldsValue({
tableSelect: `${first.family}:${first.name}`, tableSelect: first,
protocolChoice: 'preset', protocolChoice: 'preset',
protocolPreset: 'icmp', protocolPreset: 'icmp',
action: 'drop', 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) { if (tbl && tbl.chains && tbl.chains.length > 0) {
form.setFieldsValue({ chainSelect: tbl.chains[0].name }); form.setFieldsValue({ chainSelect: tbl.chains[0].name });
} else { } 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 { } else {
form.setFieldsValue({ form.setFieldsValue({
tableSelect: '__manual__', tableSelect: MANUAL_TABLE,
protocolChoice: 'preset', protocolChoice: 'preset',
protocolPreset: 'icmp', protocolPreset: 'icmp',
action: 'drop', action: 'drop',
chainSelect: '__manual_chain__', chainSelect: MANUAL_CHAIN,
}); });
setInsertBeforeOptions([]);
setTimeout(() => updatePreviews(), 0);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // only when parent tables change
}, [tableOptions]); }, [props.tables, tableOptions.length, updateInsertOptions]);
// update previews when form changes // preview update (reads form values)
const updatePreviews = useCallback(() => { const updatePreviews = useCallback(() => {
const vals = form.getFieldsValue(); const values = form.getFieldsValue();
const expr = buildExprFromValues(vals); 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'; let chain = 'input';
const chainVal = values.chainSelect;
if (!chainVal || chainVal === MANUAL_CHAIN) chain = values.chainManual || chain;
else chain = chainVal;
const tableSelect = vals.tableSelect; const beforeHandle = values.insertBeforeHandle;
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 short = textFromExpr(expr); const short = textFromExpr(expr);
const cmd = `add rule ${family} ${tableName} ${chain}${positionToken} ${short}`.trim(); const cmdFriendly = beforeHandle
setCmdPreview(cmd); ? `add rule ${family} ${table} ${chain} position ${beforeHandle} ${short}`
: `add rule ${family} ${table} ${chain} ${short}`;
setCmdPreview(cmdFriendly);
const reqObj: any = { family, table: tableName, chain, expr }; const req: any = { family, table, chain, expr };
if (positionToken) { if (beforeHandle != null && beforeHandle !== '') {
// extract numeric pos and include in request body // backend expects "position"
const m = positionToken.match(/position\s+(\d+)/); req.position = Number(beforeHandle);
if (m) {
reqObj.position = Number(m[1]);
} }
} setRequestJsonPreview(req);
setRequestJsonPreview(reqObj); }, [form, resolveTable]);
}, [form, tables]);
// run once at mount to compute previews from initial values
useEffect(() => { useEffect(() => {
updatePreviews(); updatePreviews();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// keep preview updated on values change // when user changes form fields
const onValuesChange = useCallback(() => { const onValuesChange = useCallback(() => {
updatePreviews(); updatePreviews();
// when tableSelect changes, auto-select chain (first) if any // keep chain in sync when tableSelect changes
const vals = form.getFieldsValue(); const values = form.getFieldsValue();
const ts = vals.tableSelect; const ts = values.tableSelect;
if (ts && ts !== '__manual__') { if (ts && ts !== MANUAL_TABLE) {
const [f, n] = String(ts).split(':'); 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) {
if (tbl.chains && tbl.chains.length > 0) { if (tbl.chains && tbl.chains.length > 0) {
// if currently no chain selected or manual, set to first available chain const currentChain = form.getFieldValue('chainSelect');
const cs = form.getFieldValue('chainSelect'); if (!currentChain || currentChain === MANUAL_CHAIN) {
if (!cs || cs === '__manual_chain__') {
form.setFieldsValue({ chainSelect: tbl.chains[0].name }); form.setFieldsValue({ chainSelect: tbl.chains[0].name });
} }
} else { } 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( const handleCreate = useCallback(
async (values: any) => { async (values: FormValues) => {
const tableSelect = values.tableSelect; const { family, table } = resolveTable(values);
let family: string; const chain =
let tableName: string; !values.chainSelect || values.chainSelect === MANUAL_CHAIN ? values.chainManual || 'input' : values.chainSelect;
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';
}
const expr = buildExprFromValues(values); const expr = buildExprFromValues(values);
// compute optional position from insertAfterHandle const req: any = { family, table, chain, expr };
const reqObj: any = { family, table: tableName, chain, expr }; if (values.insertBeforeHandle != null && values.insertBeforeHandle !== '') {
if ( // backend expects "position" (per your request)
values.insertAfterHandle && req.position = Number(values.insertBeforeHandle);
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 textual = textFromExpr(expr); const textual = textFromExpr(expr);
const posStr = reqObj.position !== undefined ? ` position ${reqObj.position}` : ''; const previewHint = req.position ? ` ${req.position}` : '';
const cmd = `add rule ${family} ${tableName} ${chain}${posStr} ${textual}`.trim(); const cmd = `add rule ${family} ${table} ${chain}${previewHint} ${textual}`.trim();
Modal.confirm({ Modal.confirm({
title: 'Create rule (JSON)', title: 'Create rule (JSON)',
@@ -447,7 +415,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
<Text> <Text>
About to create rule in{' '} About to create rule in{' '}
<b> <b>
{family}:{tableName} {chain} {family}:{table} {chain}
</b> </b>
</Text> </Text>
<Divider /> <Divider />
@@ -455,20 +423,22 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
<pre style={{ whiteSpace: 'pre-wrap', marginTop: 8 }}>{cmd || '(no textual preview available)'}</pre> <pre style={{ whiteSpace: 'pre-wrap', marginTop: 8 }}>{cmd || '(no textual preview available)'}</pre>
<Divider /> <Divider />
<Text strong>Full JSON NFT request:</Text> <Text strong>Full JSON NFT request:</Text>
<pre style={{ whiteSpace: 'pre-wrap', marginTop: 8 }}>{JSON.stringify(reqObj, null, 2)}</pre> <pre style={{ whiteSpace: 'pre-wrap', marginTop: 8 }}>{JSON.stringify(req, null, 2)}</pre>
</div> </div>
), ),
okText: 'Create', okText: 'Create',
onOk: async () => { onOk: async () => {
setLoading(true); setLoading(true);
try { try {
const res = await createRuleJson(reqObj); const res = await createRuleJson(req);
if (res && res.rc === 0) { if (res && res.rc === 0) {
message.success('Rule created'); message.success('Rule created');
// refresh tables/chains after successful creation if (props.refreshRules) {
await loadTables(); await props.refreshRules();
if (onCreated) await onCreated(); } else {
form.resetFields(['advanced', 'insertAfterHandle']); message.info('Parent did not provide a refresh function; please refresh ruleset externally.');
}
if (props.onCreated) await props.onCreated();
} else { } else {
message.error(`Create failed: ${res?.stderr ?? 'unknown error'}`); message.error(`Create failed: ${res?.stderr ?? 'unknown error'}`);
} }
@@ -480,12 +450,10 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
const rc = Number(data.rc ?? -1); const rc = Number(data.rc ?? -1);
const stderr = data.stderr ?? data; const stderr = data.stderr ?? data;
if (rc === 0) { if (rc === 0) {
message.warn( message.warn('Rule may have been created; check ruleset.');
'Rule appears to have been created, but server returned an error status. Check output for details.', if (props.refreshRules) {
); await props.refreshRules();
await loadTables(); }
if (onCreated) await onCreated();
form.resetFields(['advanced', 'insertAfterHandle']);
} else { } else {
const errMsg = typeof stderr === 'string' ? stderr : JSON.stringify(stderr); const errMsg = typeof stderr === 'string' ? stderr : JSON.stringify(stderr);
message.error(`Create failed: ${errMsg}`); message.error(`Create failed: ${errMsg}`);
@@ -504,56 +472,41 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
}, },
}); });
}, },
[form, onCreated, tables], [form, props.refreshRules, resolveTable],
); );
// prepare chain options for currently selected table
const chainOptions = useMemo(() => { const chainOptions = useMemo(() => {
const ts = form.getFieldValue('tableSelect'); const ts = form.getFieldValue('tableSelect');
if (ts && ts !== '__manual__') { if (!ts || ts === MANUAL_TABLE) return null;
const [f, n] = String(ts).split(':'); 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 && tbl.chains.length > 0) { if (!tbl) return null;
return ( return tbl.chains.map((c) => (
<>
{tbl.chains.map((c) => (
<Option key={c.name} value={c.name}> <Option key={c.name} value={c.name}>
{c.name} {c.name}
</Option> </Option>
))} ));
<Option key="__manual_chain__" value="__manual_chain__"> }, [form, props.tables]);
Manual chain...
</Option>
</>
);
}
}
return (
<>
<Option key="__manual_chain__" value="__manual_chain__">
Manual chain...
</Option>
</>
);
}, [form, tables]);
// build insert-after options for currently selected table+chain const handleRefresh = useCallback(async () => {
const insertAfterOptions = useMemo(() => { setRefreshing(true);
const ts = form.getFieldValue('tableSelect'); try {
const cs = form.getFieldValue('chainSelect'); if (props.refreshRules) {
if (!ts || ts === '__manual__' || !cs || cs === '__manual_chain__') return []; await props.refreshRules();
const [f, n] = String(ts).split(':'); message.success('Rules refresh requested');
const tbl = tables.find((t) => t.family === f && t.name === n); } else {
if (!tbl) return []; message.info('No refresh function provided by parent.');
const ch = tbl.chains.find((c) => c.name === cs); }
if (!ch || !Array.isArray(ch.rules)) return []; } catch (err) {
return ch.rules console.warn('refresh failed', err);
.filter((r: any) => r && r.handle !== undefined && r.handle !== null) message.error('Refresh failed');
.map((r: any) => ({ } finally {
value: r.handle, // parent should update props.tables — update options once props.tables changes
label: `#${r.handle} — ${r.text ?? (typeof r.expr === 'string' ? r.expr : JSON.stringify(r.expr || r).slice(0, 120))}`, // but ensure we recompute in case parent updated in place
})); updateInsertOptions();
}, [form, tables]); setRefreshing(false);
}
}, [props.refreshRules, updateInsertOptions]);
return ( return (
<Card style={{ maxWidth: 980 }}> <Card style={{ maxWidth: 980 }}>
@@ -566,9 +519,10 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
Builds an nft JSON <code>expr</code> and sends it to the server. If rendering fails, use the Raw UI. Builds an nft JSON <code>expr</code> and sends it to the server. If rendering fails, use the Raw UI.
</Text> </Text>
</Col> </Col>
<Col> <Col>
<Space> <Space>
<Button icon={<ReloadOutlined />} onClick={loadTables} loading={refreshing}> <Button icon={<ReloadOutlined />} onClick={handleRefresh} loading={refreshing}>
Refresh tables Refresh tables
</Button> </Button>
</Space> </Space>
@@ -584,7 +538,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
protocolChoice: 'preset', protocolChoice: 'preset',
protocolPreset: 'icmp', protocolPreset: 'icmp',
action: 'drop', action: 'drop',
tableSelect: tableOptions.length > 0 ? `${tableOptions[0].family}:${tableOptions[0].name}` : '__manual__', tableSelect: tableOptions.length > 0 ? tableOptions[0].value : MANUAL_TABLE,
}} }}
onFinish={handleCreate} onFinish={handleCreate}
onValuesChange={onValuesChange} onValuesChange={onValuesChange}
@@ -595,11 +549,11 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
<Form.Item name="tableSelect" label="Table (family:name)" rules={[{ required: true }]}> <Form.Item name="tableSelect" label="Table (family:name)" rules={[{ required: true }]}>
<Select> <Select>
{tableOptions.map((t) => ( {tableOptions.map((t) => (
<Option key={`${t.family}:${t.name}`} value={`${t.family}:${t.name}`}> <Option key={t.value} value={t.value}>
{t.label} {t.label}
</Option> </Option>
))} ))}
<Option key="__manual__" value="__manual__"> <Option key={MANUAL_TABLE} value={MANUAL_TABLE}>
Manual / new table... Manual / new table...
</Option> </Option>
</Select> </Select>
@@ -607,7 +561,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
</Col> </Col>
<Col xs={24} sm={12}> <Col xs={24} sm={12}>
{form.getFieldValue('tableSelect') === '__manual__' ? ( {form.getFieldValue('tableSelect') === MANUAL_TABLE ? (
<Space style={{ width: '100%' }}> <Space style={{ width: '100%' }}>
<Form.Item name="familyManual" label="Family" style={{ flex: 1 }}> <Form.Item name="familyManual" label="Family" style={{ flex: 1 }}>
<Input placeholder="inet, bridge, ip, ip6 ..." /> <Input placeholder="inet, bridge, ip, ip6 ..." />
@@ -628,13 +582,19 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
<Row gutter={16}> <Row gutter={16}>
<Col xs={24} sm={12}> <Col xs={24} sm={12}>
<Form.Item name="chainSelect" label="Chain (select)" rules={[{ required: true }]}> <Form.Item name="chainSelect" label="Chain (select)" rules={[{ required: true }]}>
<Select>{chainOptions}</Select> <Select>
{chainOptions ?? (
<Option key={MANUAL_CHAIN} value={MANUAL_CHAIN}>
Manual chain...
</Option>
)}
</Select>
</Form.Item> </Form.Item>
</Col> </Col>
<Col xs={24} sm={12}> <Col xs={24} sm={12}>
{form.getFieldValue('chainSelect') === '__manual_chain__' || {form.getFieldValue('chainSelect') === MANUAL_CHAIN ||
form.getFieldValue('tableSelect') === '__manual__' ? ( form.getFieldValue('tableSelect') === MANUAL_TABLE ? (
<Form.Item name="chainManual" label="Chain (manual)"> <Form.Item name="chainManual" label="Chain (manual)">
<Input placeholder="input, forward, output" /> <Input placeholder="input, forward, output" />
</Form.Item> </Form.Item>
@@ -646,24 +606,24 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
</Col> </Col>
</Row> </Row>
{/* Insert-after control */} {/* Insert-before control */}
{form.getFieldValue('tableSelect') !== '__manual__' && {form.getFieldValue('tableSelect') !== MANUAL_TABLE &&
form.getFieldValue('chainSelect') && form.getFieldValue('chainSelect') &&
form.getFieldValue('chainSelect') !== '__manual_chain__' && ( form.getFieldValue('chainSelect') !== MANUAL_CHAIN && (
<Row gutter={16}> <Row gutter={16}>
<Col xs={24} sm={12}> <Col xs={24} sm={12}>
<Form.Item <Form.Item
name="insertAfterHandle" name="insertBeforeHandle"
label="Insert after (optional)" label="Insert before (optional)"
help="Pick an existing rule handle to insert *after*. If left empty the rule will be appended." help="Pick an existing rule handle to insert the new rule before. If left empty the rule will be appended."
> >
<Select allowClear placeholder="Append (no insert-after)"> <Select allowClear placeholder="Append (no insert-before)">
{insertAfterOptions.length === 0 ? ( {insertBeforeOptions.length === 0 ? (
<Option value="__none__" disabled> <Option value="__none__" disabled>
(no rules available) (no rules available)
</Option> </Option>
) : ( ) : (
insertAfterOptions.map((o: any) => ( insertBeforeOptions.map((o) => (
<Option key={String(o.value)} value={o.value}> <Option key={String(o.value)} value={o.value}>
{o.label} {o.label}
</Option> </Option>
@@ -672,12 +632,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
</Select> </Select>
</Form.Item> </Form.Item>
</Col> </Col>
<Col xs={24} sm={12} style={{ display: 'flex', alignItems: 'flex-end' }}>
<Text type="secondary">
Use when you want the new rule to appear right after a known handle. Refresh tables to see latest
handles.
</Text>
</Col>
</Row> </Row>
)} )}
@@ -695,11 +649,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
</Space> </Space>
</Form.Item> </Form.Item>
{form.getFieldValue('protocolChoice') === 'custom' ? ( {isPresetProtocolChoice(form.getFieldsValue()) ? (
<Form.Item name="protocolCustom" label="Protocol (custom)">
<Input placeholder="e.g. icmp, icmpv6, tcp, udp, meta ..." />
</Form.Item>
) : (
<Form.Item name="protocolPreset" label="Protocol (preset)"> <Form.Item name="protocolPreset" label="Protocol (preset)">
<Select> <Select>
<Option value="">(none)</Option> <Option value="">(none)</Option>
@@ -709,6 +659,10 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
<Option value="udp">UDP</Option> <Option value="udp">UDP</Option>
</Select> </Select>
</Form.Item> </Form.Item>
) : (
<Form.Item name="protocolCustom" label="Protocol (custom)">
<Input placeholder="e.g. icmp, icmpv6, tcp, udp, meta ..." />
</Form.Item>
)} )}
</Col> </Col>
@@ -732,11 +686,13 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
<InputNumber style={{ width: '100%' }} min={1} max={65535} /> <InputNumber style={{ width: '100%' }} min={1} max={65535} />
</Form.Item> </Form.Item>
</Col> </Col>
<Col xs={24} sm={6}> <Col xs={24} sm={6}>
<Form.Item name="dport" label="Dest Port"> <Form.Item name="dport" label="Dest Port">
<InputNumber style={{ width: '100%' }} min={1} max={65535} /> <InputNumber style={{ width: '100%' }} min={1} max={65535} />
</Form.Item> </Form.Item>
</Col> </Col>
<Col xs={24} sm={12}> <Col xs={24} sm={12}>
<Form.Item name="action" label="Action" rules={[{ required: true }]} initialValue="drop"> <Form.Item name="action" label="Action" rules={[{ required: true }]} initialValue="drop">
<Radio.Group> <Radio.Group>
@@ -796,12 +752,12 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
return; return;
} }
if (!cmdPreview) return; if (!cmdPreview) return;
// copy the textual preview as plain text
navigator.clipboard?.writeText(cmdPreview); navigator.clipboard?.writeText(cmdPreview);
message.success('Command preview copied'); message.success('Command preview copied');
}} }}
/> />
</Tooltip> </Tooltip>
<Button type="primary" htmlType="submit" loading={loading} icon={<PlusOutlined />}> <Button type="primary" htmlType="submit" loading={loading} icon={<PlusOutlined />}>
Create rule Create rule
</Button> </Button>

View File

@@ -1,123 +1,30 @@
// src/components/FirewallTables.tsx // src/components/FirewallTables.tsx
import { ArrowDownOutlined, ArrowUpOutlined, DeleteOutlined } from '@ant-design/icons'; import { ArrowDownOutlined, DeleteOutlined } from '@ant-design/icons';
import { Alert, Button, Card, Divider, message, Modal, Space, Spin, Table, Typography } from 'antd'; import { Alert, Button, Card, Divider, message, Modal, Space, Table, Typography } from 'antd';
import { ColumnsType } from 'antd/lib/table'; import { ColumnsType } from 'antd/lib/table';
import { ReactElement, useEffect, useState } from 'react'; import { ReactElement, useCallback, useMemo, useState } from 'react';
import { execFirewallRaw, fetchRuleset, moveRule } from '../api/apiClient'; import { execFirewallRaw } from '../api/apiClient';
import type { CmdResult, ExecResult } from '../types/firewall'; import type { CmdResult, ExecResult, RuleOut, TableOut } from '../types/firewall';
import FirewallAddChainModal from './FireWallAddChainModal'; import FirewallAddChainModal from './FireWallAddChainModal';
import FirewallAddTableModal from './FireWallAddTableModal'; import FirewallAddTableModal from './FireWallAddTableModal';
const { Paragraph, Text, Title } = Typography; const { Paragraph, Text, Title } = Typography;
/* Types */ /* ---------- Helpers ---------- */
type NFTRule = {
handle?: number | string;
expr?: any;
rule?: any;
text?: string;
[k: string]: any;
};
type NFTChain = { /**
name: string; * Render a human friendly one-line representation of a rule.
type?: string | null; * Accepts RuleOut (typed) but remains defensive against unexpected shapes.
hook?: string | null; */
priority?: number | null; function renderRuleFriendly(rule: RuleOut | any): string {
policy?: string | null; // prefer `text` if present
rules: NFTRule[]; if (rule?.text && typeof rule.text === 'string' && rule.text.trim() !== '') return rule.text;
};
type NFTTable = { // if expr is a string, show it
family?: string | null; if (rule?.expr && typeof rule.expr === 'string') return rule.expr;
name: string;
chains: NFTChain[];
};
/* Extract ruleset -> tables (unchanged) */ // if expr is an array, try to pretty print common tokens
function extractTablesFromParsed(parsed: any): NFTTable[] { const expr = rule?.expr ?? rule;
if (!parsed) return [];
if (Array.isArray(parsed.nftables)) {
const tablesMap = new Map<string, NFTTable>();
const chainsMap = new Map<string, NFTChain>();
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 (Array.isArray(expr)) { if (Array.isArray(expr)) {
const tokens: string[] = []; const tokens: string[] = [];
for (const part of expr) { for (const part of expr) {
@@ -200,7 +107,8 @@ function renderRuleFriendly(rule: NFTRule): string {
if (tokens.length > 0) return tokens.join(' '); 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 { try {
return JSON.stringify(rule.expr, (_k, v) => (v === undefined ? null : v)).slice(0, 500); return JSON.stringify(rule.expr, (_k, v) => (v === undefined ? null : v)).slice(0, 500);
} catch { } catch {
@@ -208,6 +116,7 @@ function renderRuleFriendly(rule: NFTRule): string {
} }
} }
// final fallback: stringify whole rule
try { try {
return JSON.stringify(rule, null, 2); return JSON.stringify(rule, null, 2);
} catch { } catch {
@@ -221,36 +130,24 @@ function isSuccessRc(out?: ExecResult | null): boolean {
return out.rc === 0 || out.rc === -1; return out.rc === 0 || out.rc === -1;
} }
/* Component */ /* ---------- Props ---------- */
export default function FirewallTables(): ReactElement { type Props = {
const [loading, setLoading] = useState(true); tables: TableOut[]; // passed from parent
const [refreshing, setRefreshing] = useState(false); error?: Error | null;
const [error, setError] = useState<string | null>(null); refreshRules: () => Promise<void>; // trigger to re-fetch ruleset
const [tables, setTables] = useState<NFTTable[]>([]); };
const [results, setResults] = useState<CmdResult[]>([]);
/* ---------- 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<CmdResult[]>([]);
const [isOpenTableCreatorModal, setIsOpenTableCreatorModal] = useState(false); const [isOpenTableCreatorModal, setIsOpenTableCreatorModal] = useState(false);
const [isOpenChainCreatorModal, setIsOpenChainCreatorModal] = useState(false); const [isOpenChainCreatorModal, setIsOpenChainCreatorModal] = useState(false);
async function loadRuleset() { // run raw nft commands sequentially and collect results (used for delete ops etc.)
setLoading(true); const runCommands = useCallback(async (cmds: string[]) => {
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[]) {
const acc: CmdResult[] = []; const acc: CmdResult[] = [];
for (const cmd of cmds) { for (const cmd of cmds) {
try { try {
@@ -261,17 +158,14 @@ export default function FirewallTables(): ReactElement {
acc.push({ cmd, err: err?.message ?? String(err) }); acc.push({ cmd, err: err?.message ?? String(err) });
} }
} }
// prepend new results to history
setResults((prev) => [...acc, ...prev]); setResults((prev) => [...acc, ...prev]);
return acc; return acc;
} }, []);
// Delete helpers (unchanged behavior) // Delete rule
async function handleDeleteRule( const handleDeleteRule = useCallback(
family: string | null | undefined, async (family: string | null | undefined, table: string, chain: string, handle: number | string) => {
table: string,
chain: string,
handle: number | string,
) {
const cmd = `delete rule ${family ?? 'inet'} ${table} ${chain} handle ${handle}`; const cmd = `delete rule ${family ?? 'inet'} ${table} ${chain} handle ${handle}`;
Modal.confirm({ Modal.confirm({
title: 'Delete Rule', title: 'Delete Rule',
@@ -291,13 +185,17 @@ export default function FirewallTables(): ReactElement {
} catch (err: any) { } catch (err: any) {
message.error('Delete failed: ' + (err?.message ?? String(err))); message.error('Delete failed: ' + (err?.message ?? String(err)));
} finally { } finally {
await loadRuleset(); await refresh();
} }
}, },
}); });
} },
[runCommands, refresh],
);
async function handleDeleteChain(family: string | null | undefined, table: string, chain: string) { // Delete chain
const handleDeleteChain = useCallback(
async (family: string | null | undefined, table: string, chain: string) => {
const cmd = `delete chain ${family ?? 'inet'} ${table} ${chain}`; const cmd = `delete chain ${family ?? 'inet'} ${table} ${chain}`;
Modal.confirm({ Modal.confirm({
title: 'Delete Chain', title: 'Delete Chain',
@@ -319,13 +217,17 @@ export default function FirewallTables(): ReactElement {
} catch (err: any) { } catch (err: any) {
message.error('Chain deletion failed: ' + (err?.message ?? String(err))); message.error('Chain deletion failed: ' + (err?.message ?? String(err)));
} finally { } finally {
await loadRuleset(); await refresh();
} }
}, },
}); });
} },
[runCommands, refresh],
);
async function handleDeleteTable(family: string | null | undefined, table: string) { // Delete table
const handleDeleteTable = useCallback(
async (family: string | null | undefined, table: string) => {
const cmd = `delete table ${family ?? 'inet'} ${table}`; const cmd = `delete table ${family ?? 'inet'} ${table}`;
Modal.confirm({ Modal.confirm({
title: 'Delete Table', title: 'Delete Table',
@@ -347,144 +249,31 @@ export default function FirewallTables(): ReactElement {
} catch (err: any) { } catch (err: any) {
message.error('Table deletion failed: ' + (err?.message ?? String(err))); message.error('Table deletion failed: ' + (err?.message ?? String(err)));
} finally { } finally {
await loadRuleset(); await refresh();
} }
}, },
}); });
} },
[runCommands, refresh],
);
async function handleRefresh() { // manual refresh trigger (exposed to UI)
const handleRefresh = useCallback(async () => {
setRefreshing(true); setRefreshing(true);
try { try {
await loadRuleset(); await refresh();
message.success('Ruleset refreshed'); message.success('Ruleset refreshed');
} catch { } catch {
// ignore // ignore
} finally { } finally {
setRefreshing(false); setRefreshing(false);
} }
} }, [refresh]);
// Move using position (0-based). We still send source handle in the URL by setting before_handle = const tablesToRender = useMemo(() => tables ?? [], [tables]);
// 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 lastIndex = ch.rules.length - 1; if (error)
return <Alert type="error" message="Failed to load firewall rules" description={error.message ?? String(error)} />;
// 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<boolean>((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 <Spin size="large" style={{ display: 'block', margin: '40px auto' }} />;
if (error) return <Alert type="error" message="Failed to load firewall rules" description={error} />;
return ( return (
<> <>
@@ -500,16 +289,17 @@ export default function FirewallTables(): ReactElement {
</Space> </Space>
} }
> >
{tables.length === 0 && ( {tablesToRender.length === 0 && (
<Alert <Alert
type="info" type="info"
title="No firewall tables found" message="No firewall tables found"
description="You can create a new table using the button above." description="You can create a new table using the button above."
style={{ marginBottom: 12 }} style={{ marginBottom: 12 }}
/> />
)} )}
{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 ( return (
<Card <Card
@@ -550,65 +340,40 @@ export default function FirewallTables(): ReactElement {
<Space direction="vertical" style={{ width: '100%' }}> <Space direction="vertical" style={{ width: '100%' }}>
{table.chains.map((chain) => { {table.chains.map((chain) => {
const lastIndex = chain.rules.length - 1;
const columns: ColumnsType<any> = [ const columns: ColumnsType<any> = [
{ title: 'Rule #', dataIndex: 'idx', width: 80 }, { title: 'Rule #', dataIndex: 'idx', width: 80 },
{ {
title: 'Handle', title: 'Handle',
dataIndex: 'handle', dataIndex: 'handle',
width: 120, width: 80,
render: (v) => v ?? '-', render: (v) => v ?? '-',
}, },
{ {
title: 'Rule', title: 'Rule',
dataIndex: 'raw', dataIndex: 'raw',
render: (v) => ( render: (v) => <Paragraph ellipsis={{ rows: 2, expandable: false }}>{v}</Paragraph>,
<Paragraph ellipsis={{ rows: 2, expandable: false }} copyable>
{v}
</Paragraph>
),
}, },
{ {
title: 'Actions', title: 'Actions',
dataIndex: 'actions', dataIndex: 'actions',
width: 160, width: 80,
render: (_: any, rec: any) => render: (_: any, rec: any) =>
rec.handle ? ( rec.handle ? (
<Space>
<Button <Button
danger danger
size="small" size="small"
icon={<DeleteOutlined />} icon={<DeleteOutlined />}
onClick={() => handleDeleteRule(table.family, table.name, chain.name, rec.handle)} onClick={() => handleDeleteRule(table.family, table.name, chain.name, rec.handle)}
/> />
<Button
size="small"
icon={<ArrowUpOutlined />}
disabled={rec.idx <= 1 || rec.handle === undefined || rec.handle === null}
onClick={() =>
moveUp(table.family ?? '', table.name, chain.name, rec.handle, rec.idx - 1)
}
/>
<Button
size="small"
icon={<ArrowDownOutlined />}
disabled={rec.idx - 1 >= lastIndex || rec.handle === undefined || rec.handle === null}
onClick={() =>
moveDown(table.family ?? '', table.name, chain.name, rec.handle, rec.idx - 1)
}
/>
</Space>
) : ( ) : (
<Space> <Space>
<Button size="small" disabled icon={<DeleteOutlined />} />
<Button size="small" disabled icon={<ArrowUpOutlined />} />
<Button size="small" disabled icon={<ArrowDownOutlined />} /> <Button size="small" disabled icon={<ArrowDownOutlined />} />
</Space> </Space>
), ),
}, },
]; ];
const dataSource = chain.rules.map((r, idx) => ({ const dataSource = (chain.rules ?? []).map((r: RuleOut, idx: number) => ({
key: `${chain.name}:${idx}`, key: `${chain.name}:${idx}`,
idx: idx + 1, idx: idx + 1,
handle: r.handle ?? null, handle: r.handle ?? null,

View File

@@ -1,11 +1,62 @@
// src/pages/Firewall.tsx
import { Alert, Col, Row, Spin } from 'antd';
import React, { useCallback, useEffect, useState } from 'react';
import { fetchRuleset } from '../api/apiClient';
import RuleBuilder from '../components/FirewallRuleBuilder'; import RuleBuilder from '../components/FirewallRuleBuilder';
import RulesView from '../components/FirewallRulesetViewer'; import RulesView from '../components/FirewallRulesetViewer';
import { TableOut } from '../types/firewall';
const EMPTY_TABLES: TableOut[] = [];
export const Firewall: React.FC = () => { export const Firewall: React.FC = () => {
return ( const [tables, setTables] = useState<TableOut[]>(EMPTY_TABLES);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<Error | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetchRuleset();
if (!res || res.ruleset == null || typeof res.ruleset === 'string') {
setTables(EMPTY_TABLES);
} else {
setTables(res.ruleset.tables ?? EMPTY_TABLES);
}
} catch (err: any) {
setError(err instanceof Error ? err : new Error(String(err)));
setTables(EMPTY_TABLES);
} finally {
setLoading(false);
}
}, []);
// fetch once on mount
useEffect(() => {
void load();
}, [load]);
// refresh() can be passed down to children to trigger a re-fetch
const refreshRules = useCallback(async () => {
await load();
}, [load]);
return loading ? (
<Spin tip="Loading firewall rules..." />
) : (
<div className="firewall-page"> <div className="firewall-page">
<RulesView /> <Row gutter={16}>
<RuleBuilder /> <Col xs={24}>
{error && <Alert type="error" message="Could not load ruleset" description={String(error)} showIcon />}
</Col>
<Col xs={24}>
<RulesView tables={tables} error={error} refreshRules={refreshRules} />
</Col>
<Col xs={24} style={{ marginTop: 16 }}>
<RuleBuilder tables={tables} refreshRules={refreshRules} />
</Col>
</Row>
</div> </div>
); );
}; };

View File

@@ -10,6 +10,10 @@ export interface RuleOut {
export interface ChainOut { export interface ChainOut {
name: string; name: string;
type?: string | null;
hook?: string | null;
priority?: number | null;
policy?: string | null;
rules: RuleOut[]; rules: RuleOut[];
} }
@@ -73,55 +77,3 @@ export interface ExecResult {
/** Standard error from nft (may be null/undefined). */ /** Standard error from nft (may be null/undefined). */
stderr?: string | null; stderr?: string | null;
} }
/**
* Request body for moving a rule.
* Exactly one of position / before_handle / to_top / to_bottom is typically provided.
*
* Examples:
* - Move to top: { family: "bridge", table: "filter", chain: "forward", to_top: true }
* - Move before handle: { family: "inet", table: "filter", chain: "input", before_handle: 42 }
* - Insert at position: { family: "ip", table: "filter", chain: "forward", position: 0 }
*/
export interface MoveRequest {
/** Table family, e.g. "bridge", "inet", "ip", "ip6". */
family: string;
/** Table name, e.g. "filter". */
table: string;
/** Chain name, e.g. "forward". */
chain: string;
/** Zero-based position to insert at (0 = top). Optional. */
position?: number | null;
/** Insert before this existing handle in the same chain. Optional. */
before_handle?: number | null;
/** Move to top (equivalent to position=0). Optional; default false on server. */
to_top?: boolean;
/** Move to bottom (append). Optional; default false on server. */
to_bottom?: boolean;
}
/**
* Sub-result for the move action (what was added / what was deleted).
* Mirrors MoveSubResult(BaseModel).
*/
export interface MoveSubResult {
/** Executed command as text (e.g. "add rule ..."). */
cmd: string;
/** Exec result if command was executed (may be null/undefined). */
out?: ExecResult | null;
/** Error string if the operation failed (may be null/undefined). */
err?: string | null;
}
/**
* Overall move result: what was added and what was deleted.
* Mirrors MoveResult(BaseModel).
*/
export interface MoveResult {
added: MoveSubResult;
deleted: MoveSubResult;
}