add chain and table addition to frontend
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 10s

This commit is contained in:
2026-02-23 20:21:16 +01:00
parent dfa9a4a343
commit 6636f72f11
5 changed files with 1100 additions and 222 deletions

View File

@@ -1,239 +1,451 @@
// src/components/RulesView.tsx
import { DeleteOutlined, EyeOutlined, ReloadOutlined } from '@ant-design/icons';
import { Button, Card, Collapse, Drawer, Empty, Popconfirm, Space, Spin, Table, Typography, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { deleteRule, fetchRuleset } from '../api/apiClient';
import { ChainOut, RulesetModel, TableOut } from '../types/firewall';
import { Alert, Button, Card, Modal, Space, Spin, Table, Typography, message } from 'antd';
import { ColumnsType } from 'antd/lib/table';
import { ReactElement, useEffect, useState } from 'react';
import { execFirewallRaw, fetchRuleset } from '../api/apiClient';
import { CmdResult, ExecResult } from '../types/firewall';
import FirewallAddChainModal from './FireWallAddChainModal';
import FirewallAddTableModal from './FireWallAddTableModal';
const { Panel } = Collapse;
const { Paragraph, Text } = Typography;
const { Paragraph, Text, Title } = Typography;
interface RuleRow {
key: string;
handle?: number | null;
text: string;
expr: any;
comment?: string | null;
position?: any;
// context for delete
family: string;
table: string;
chain: string;
type NFTRule = {
handle?: number | string;
expr?: any;
[k: string]: any;
};
type NFTChain = {
name: string;
type?: string | null;
hook?: string | null;
priority?: number | null;
policy?: string | null;
rules: NFTRule[];
};
type NFTTable = {
family?: string | null;
name: string;
chains: NFTChain[];
};
/* -------------------------
Extract NFT structure
------------------------- */
function extractTablesFromParsed(parsed: any): NFTTable[] {
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 [];
}
/**
* Render rules inside an antd Table. The UI layout:
* - Collapse per table (title = family:name)
* - Inside each table -> collapse per chain (only chains that have rules)
* - Each chain shows a Table of rules with delete and inspect actions
*/
export const RulesView: React.FC<{ apiBase?: string }> = ({ apiBase = '' }) => {
const [loading, setLoading] = useState(false);
const [ruleset, setRuleset] = useState<RulesetModel | null>(null);
const [rawFallback, setRawFallback] = useState<string | null>(null);
const [exprDrawerVisible, setExprDrawerVisible] = useState(false);
const [exprForView, setExprForView] = useState<any>(null);
const [selectedRuleContext, setSelectedRuleContext] = useState<{
family: string;
table: string;
chain: string;
handle?: number | null;
} | null>(null);
/* -------------------------
Rule preview
------------------------- */
function rulePreview(rule: NFTRule): string {
if (typeof rule === 'string') return rule;
if (rule.expr && typeof rule.expr === 'string') return rule.expr;
if (rule.rule && typeof rule.rule === 'string') return rule.rule;
if (rule.handle && Object.keys(rule).length === 1) return `handle ${rule.handle}`;
const fetchRules = useCallback(async () => {
try {
return JSON.stringify(rule, null, 2);
} catch {
return String(rule);
}
}
/* Helper: determine success by rc
Some endpoints return rc === -1 on success in your environment,
so treat rc === 0 or rc === -1 as success. */
function isSuccessRc(out?: ExecResult | null): boolean {
if (!out) return false;
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<string | null>(null);
const [tables, setTables] = useState<NFTTable[]>([]);
const [results, setResults] = useState<CmdResult[]>([]);
const [isOpenTableCreatorModal, setIsOpenTableCreatorModal] = useState(false);
const [isOpenChainCreatorModal, setIsOpenChainCreatorModal] = useState(false);
async function loadRuleset() {
setLoading(true);
try {
const data = await fetchRuleset();
if (!data || data.ruleset === null) {
setRuleset({ tables: [] });
setRawFallback(null);
} else if (typeof data.ruleset === 'string') {
// fallback textual ruleset
setRawFallback(data.ruleset);
setRuleset({ tables: [] });
} else {
setRuleset(data.ruleset as RulesetModel);
setRawFallback(null);
}
const res = await fetchRuleset();
const parsed = extractTablesFromParsed((res as any).ruleset);
setTables(parsed);
setError(null);
} catch (err: any) {
message.error(`Failed to load ruleset: ${err?.message ?? err}`);
setRuleset({ tables: [] });
setRawFallback(null);
setError(err?.message ?? String(err));
} finally {
setLoading(false);
}
}, [apiBase]);
useEffect(() => {
fetchRules();
}, [fetchRules]);
const onDelete = useCallback(
async (row: RuleRow) => {
if (!row.handle) {
message.error('Rule has no handle and cannot be deleted via API.');
return;
}
setLoading(true);
try {
await deleteRule(row.handle, row.family, row.table, row.chain);
message.success('Rule deleted');
await fetchRules();
} catch (err: any) {
message.error(`Delete failed: ${err?.message ?? err}`);
} finally {
setLoading(false);
}
},
[apiBase, fetchRules],
);
const openExprViewer = useCallback((expr: any) => {
setExprForView(expr);
setExprDrawerVisible(true);
}, []);
const closeExprViewer = useCallback(() => {
setExprForView(null);
setExprDrawerVisible(false);
}, []);
// columns for rule table
const columns: ColumnsType<RuleRow> = useMemo(
() => [
{
title: 'Handle',
dataIndex: 'handle',
key: 'handle',
width: 100,
render: (val) => val ?? '-',
},
{
title: 'Rule',
dataIndex: 'text',
key: 'text',
render: (txt: string, rec: RuleRow) => (
<Paragraph copyable={{ text: txt }} style={{ margin: 0 }}>
{txt}
</Paragraph>
),
},
{
title: 'Comment',
dataIndex: 'comment',
key: 'comment',
width: 200,
render: (c) => (c ? <Text>{c}</Text> : null),
},
{
title: 'Actions',
key: 'actions',
width: 160,
align: 'right',
render: (_, rec) => (
<Space>
<Button type="default" icon={<EyeOutlined />} size="small" onClick={() => openExprViewer(rec.expr)}>
View
</Button>
<Popconfirm title="Delete this rule?" onConfirm={() => onDelete(rec)} okText="Delete" cancelText="Cancel">
<Button danger icon={<DeleteOutlined />} size="small">
Delete
</Button>
</Popconfirm>
</Space>
),
},
],
[onDelete, openExprViewer],
);
// flatten rules for each chain into a table datasource
const renderChainTable = (table: TableOut, chain: ChainOut) => {
const data: RuleRow[] = chain.rules.map((r, idx) => ({
key: `${table.family}:${table.name}:${chain.name}:${String(r.handle ?? idx)}`,
handle: r.handle ?? null,
text: r.text,
expr: r.expr,
comment: r.comment ?? null,
position: r.position,
family: table.family,
table: table.name,
chain: chain.name,
}));
return (
<Table columns={columns} dataSource={data} pagination={{ pageSize: 8 }} size="small" rowKey={(rec) => rec.key} />
);
};
// UI when JSON is not available
if (!loading && rawFallback) {
return (
<Card>
<Space direction="vertical" style={{ width: '100%' }}>
<Text strong>Ruleset (raw)</Text>
<Paragraph style={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace' }}>{rawFallback}</Paragraph>
<Button icon={<ReloadOutlined />} onClick={() => fetchRules()}>
Refresh
</Button>
</Space>
</Card>
);
}
return (
<Card
title="Firewall rules"
extra={
<Space>
<Button icon={<ReloadOutlined />} onClick={() => fetchRules()} loading={loading}>
Refresh
</Button>
</Space>
useEffect(() => {
loadRuleset();
}, []);
// helper to run multiple commands sequentially and collect results
async function runCommands(cmds: string[]) {
const acc: CmdResult[] = [];
for (const cmd of cmds) {
try {
const out = (await execFirewallRaw(cmd)) as ExecResult;
if (isSuccessRc(out)) {
acc.push({ cmd, out });
} else {
acc.push({ cmd, out, err: out ? `stderr: ${out.stderr ?? ''} rc: ${out.rc}` : 'Unknown error' });
}
} catch (err: any) {
acc.push({ cmd, err: err?.message ?? String(err) });
}
>
{loading ? (
<div style={{ textAlign: 'center', padding: 40 }}>
<Spin size="large" />
</div>
) : ruleset && ruleset.tables.length > 0 ? (
<Collapse accordion>
{ruleset.tables.map((t) => {
// only show chains that have rules
const chainsWithRules = t.chains.filter((c) => c.rules && c.rules.length > 0);
if (chainsWithRules.length === 0) return null;
return (
<Panel header={`${t.family}:${t.name}`} key={`${t.family}:${t.name}`}>
<Collapse>
{chainsWithRules.map((c) => (
<Panel header={`${c.name} — ${c.rules.length} rule(s)`} key={`${t.family}:${t.name}:${c.name}`}>
{renderChainTable(t, c)}
</Panel>
))}
</Collapse>
</Panel>
);
})}
</Collapse>
) : (
<Empty description="No rules found" />
)}
}
// prepend new results so the latest are visible first
setResults((prev) => [...acc, ...prev]);
return acc;
}
<Drawer
title="Rule expression (JSON)"
placement="right"
width={640}
onClose={closeExprViewer}
open={exprDrawerVisible}
// delete a single rule
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: (
<>
<Paragraph>Are you sure you want to delete this rule?</Paragraph>
<pre style={{ whiteSpace: 'pre-wrap' }}>{cmd}</pre>
</>
),
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 a chain (non-force)
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: (
<>
<Paragraph>
This will delete the chain <i>{chain}</i> in table <i>{table}</i> unrevertably.
</Paragraph>
<pre style={{ whiteSpace: 'pre-wrap' }}>{cmd}</pre>
</>
),
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 a table (non-force)
async function handleDeleteTable(family: string | null | undefined, table: string) {
const cmd = `delete table ${family ?? 'inet'} ${table}`;
Modal.confirm({
title: 'Delete Table',
content: (
<>
<Paragraph>
This will delete the table <i>{table}</i> including all its chains and rules unrevertably.
</Paragraph>
<pre style={{ whiteSpace: 'pre-wrap' }}>{cmd}</pre>
</>
),
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();
}
},
});
}
// Refresh handler
async function handleRefresh() {
setRefreshing(true);
try {
await loadRuleset();
message.success('Ruleset refreshed');
} catch {
// loadRuleset sets error state
} finally {
setRefreshing(false);
}
}
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 (
<>
<FirewallAddTableModal open={isOpenTableCreatorModal} onClose={() => setIsOpenTableCreatorModal(false)} />
<Card
title="Firewall Tables"
extra={
<Space>
<Button onClick={handleRefresh} loading={refreshing}>
Refresh
</Button>
<Button onClick={() => setIsOpenTableCreatorModal(true)}>Add Table</Button>
</Space>
}
>
<Paragraph>
<Text strong>Expression (raw JSON)</Text>
</Paragraph>
<pre style={{ whiteSpace: 'pre-wrap' }}>{JSON.stringify(exprForView, null, 2)}</pre>
</Drawer>
</Card>
);
};
{tables.length === 0 && (
<Alert
type="info"
title="No firewall tables found"
description="You can create a new table using the button above."
style={{ marginBottom: 12 }}
/>
)}
{tables.map((table) => {
const totalRules = table.chains.reduce((acc, c) => acc + c.rules.length, 0);
export default RulesView;
return (
<Card
key={`${table.family ?? 'any'}:${table.name}`}
type="inner"
style={{ marginBottom: 16 }}
title={
<div style={{ display: 'flex', width: '100%', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Title level={5} style={{ margin: 0 }}>
Table "{table.name}"
</Title>
<Text type="secondary">
<b>Family:</b> {table.family ?? 'unknown'} &nbsp; <b>Chains:</b> {table.chains.length} &nbsp;{' '}
<b>Total Rules:</b> {totalRules}
</Text>
</div>
<Space>
<Button danger size="small" onClick={() => handleDeleteTable(table.family, table.name)}>
Delete Table
</Button>
</Space>
</div>
}
extra={<Button onClick={() => setIsOpenChainCreatorModal(true)}>Add Chain</Button>}
>
<FirewallAddChainModal
open={isOpenChainCreatorModal}
onClose={() => setIsOpenChainCreatorModal(false)}
table={{ family: table.family, name: table.name }}
/>
<Space direction="vertical" style={{ width: '100%' }}>
{table.chains.map((chain) => {
const columns: ColumnsType<any> = [
{ title: 'Rule #', dataIndex: 'idx', width: 80 },
{
title: 'Handle',
dataIndex: 'handle',
render: (v) => v ?? '-',
},
{
title: 'Rule Content',
dataIndex: 'raw',
render: (v) => <Paragraph copyable>{v}</Paragraph>,
},
{
title: 'Actions',
render: (_: any, rec: any) =>
rec.handle ? (
<Space>
<Button
danger
size="small"
onClick={() => handleDeleteRule(table.family, table.name, chain.name, rec.handle)}
>
Delete Rule
</Button>
</Space>
) : (
<Space>
<Button size="small" disabled>
Delete Rule
</Button>
</Space>
),
},
];
const dataSource = chain.rules.map((r, idx) => ({
key: idx,
idx: idx + 1,
handle: r.handle ?? null,
raw: rulePreview(r),
}));
return (
<Card
key={`${table.name}:${chain.name}`}
type="inner"
style={{ marginTop: 12 }}
title={
<div
style={{
display: 'flex',
width: '100%',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<div>
<Text strong>{chain.name}</Text>
<Text type="secondary" style={{ marginLeft: 12 }}>
<b>Hook:</b> {chain.hook ?? '-'} &nbsp; <b>Policy:</b> {chain.policy ?? '-'}
</Text>
</div>
<Space>
<Button
size="small"
onClick={() => handleDeleteChain(table.family, table.name, chain.name)}
>
Delete Chain
</Button>
</Space>
</div>
}
>
<Table
style={{ marginTop: 12 }}
size="small"
columns={columns}
dataSource={dataSource}
pagination={false}
/>
</Card>
);
})}
</Space>
</Card>
);
})}
</Card>
</>
);
}