Files
mitm-webserver/frontend/src/components/FirewallRulesetViewer.tsx
malmert 5930794a4e
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s
nft tables api pretty text
2026-02-28 22:04:13 +01:00

468 lines
16 KiB
TypeScript

// src/components/FirewallTables.tsx
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, 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;
/* ---------- Helpers ---------- */
/**
* 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;
// if expr is a string, show it
if (rule?.expr && typeof rule.expr === 'string') return rule.expr;
// 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) {
if (part == null) continue;
if (typeof part === 'string' || typeof part === 'number') {
tokens.push(String(part));
continue;
}
if (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 && (typeof right === 'string' || typeof right === 'number')) {
const p = left.payload;
const prot = p.protocol;
const field = p.field;
if (prot && field) {
tokens.push(`${prot} ${field} ${right}`);
continue;
}
}
tokens.push('match');
continue;
}
if ('payload' in part) {
const p = (part as any).payload;
if (p?.protocol && p?.field) {
tokens.push(`payload(${p.protocol}.${p.field})`);
continue;
}
tokens.push('payload');
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 ('counter' in part) {
tokens.push('counter');
continue;
}
if ('queue' in part) {
const q = (part as any).queue;
let tok = 'queue';
if (typeof q === 'object' && q !== null) {
const num = q.num ?? q.number ?? q.queue_number ?? q.from ?? q.range;
if (num !== undefined) tok += ` num ${num}`;
if (q.bypass) tok += ' bypass';
} else if (typeof q === 'number') {
tok += ` num ${q}`;
} else if (typeof q === 'string') {
tok += ` num ${q}`;
}
tokens.push(tok);
continue;
}
tokens.push(Object.keys(part).sort().join('+'));
continue;
}
}
if (tokens.length > 0) return tokens.join(' ');
}
// 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 {
// fallthrough
}
}
// final fallback: stringify whole rule
try {
return JSON.stringify(rule, null, 2);
} catch {
return String(rule);
}
}
/* Helper: success RC */
function isSuccessRc(out?: ExecResult | null): boolean {
if (!out) return false;
return out.rc === 0 || out.rc === -1;
}
/* ---------- Props ---------- */
type Props = {
tables: TableOut[]; // passed from parent
error?: Error | null;
refreshRules: () => Promise<void>; // 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<CmdResult[]>([]);
const [isOpenTableCreatorModal, setIsOpenTableCreatorModal] = useState(false);
const [isOpenChainCreatorModal, setIsOpenChainCreatorModal] = useState(false);
// 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 {
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) });
}
}
// prepend new results to history
setResults((prev) => [...acc, ...prev]);
return acc;
}, []);
// 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: (
<>
<Paragraph>Are you sure you want to delete this rule?</Paragraph>
<Divider />
<Paragraph copyable>{cmd}</Paragraph>
</>
),
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],
);
// 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: (
<>
<Paragraph>
This will delete the chain <i>{chain}</i> in table <i>{table}</i> unrevertably.
</Paragraph>
<Divider />
<Paragraph copyable>{cmd}</Paragraph>
</>
),
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],
);
// 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: (
<>
<Paragraph>
This will delete the table <i>{table}</i> including all its chains and rules unrevertably.
</Paragraph>
<Divider />
<Paragraph copyable>{cmd}</Paragraph>
</>
),
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],
);
// manual refresh trigger (exposed to UI)
const handleRefresh = useCallback(async () => {
setRefreshing(true);
try {
await refresh();
message.success('Ruleset refreshed');
} catch {
// ignore
} finally {
setRefreshing(false);
}
}, [refresh]);
const tablesToRender = useMemo(() => tables ?? [], [tables]);
if (error)
return <Alert type="error" message="Failed to load firewall rules" description={error.message ?? String(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>
}
>
{tablesToRender.length === 0 && (
<Alert
type="info"
message="No firewall tables found"
description="You can create a new table using the button above."
style={{ marginBottom: 12 }}
/>
)}
{tablesToRender.map((table) => {
const totalRules = table.chains.reduce((acc, c) => acc + (c.rules?.length ?? 0), 0);
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"
icon={<DeleteOutlined />}
onClick={() => handleDeleteTable(table.family, table.name)}
/>
<Button size="small" onClick={() => setIsOpenChainCreatorModal(true)}>
Add Chain
</Button>
</Space>
</div>
}
>
<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',
width: 80,
render: (v) => v ?? '-',
},
{
title: 'Rule',
dataIndex: 'frontendParsed',
render: (v) => <Paragraph ellipsis={{ rows: 2, expandable: false }}>{v}</Paragraph>,
},
{
title: 'Actions',
dataIndex: 'actions',
width: 80,
render: (_: any, rec: any) =>
rec.handle ? (
<Button
danger
size="small"
icon={<DeleteOutlined />}
onClick={() => handleDeleteRule(table.family, table.name, chain.name, rec.handle)}
/>
) : (
<Space>
<Button size="small" disabled icon={<ArrowDownOutlined />} />
</Space>
),
},
];
const dataSource = (chain.rules ?? []).map((r: RuleOut, idx: number) => ({
key: `${chain.name}:${idx}`,
idx: idx + 1,
handle: r.handle ?? null,
frontendParsed: renderRuleFriendly(r),
backendtext: r.text,
}));
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; &nbsp; <b>Type:</b> {chain.type ?? '-'} &nbsp;{' '}
<b>Policy:</b> {chain.policy ?? '-'} &nbsp; <b>Priority:</b> {chain.priority ?? '-'}
</Text>
</div>
<Space>
<Button
size="small"
icon={<DeleteOutlined />}
onClick={() => handleDeleteChain(table.family, table.name, chain.name)}
/>
</Space>
</div>
}
>
<Table
style={{ marginTop: 12 }}
size="small"
columns={columns}
dataSource={dataSource}
pagination={false}
/>
</Card>
);
})}
</Space>
</Card>
);
})}
</Card>
{/* Results panel */}
{results.length > 0 && (
<Card title="Command Results" style={{ marginTop: 20 }}>
{results.map((r, i) => (
<div key={i} style={{ marginBottom: 12 }}>
<Text strong>{r.cmd}</Text>
{r.err ? (
<Paragraph type="danger" style={{ marginTop: 6 }}>
{r.err}
</Paragraph>
) : r.out ? (
<>
<Paragraph>
<Text type="secondary">rc:</Text> {r.out.rc}
</Paragraph>
{r.out.stdout ? (
<>
<Text type="secondary">stdout:</Text>
<pre style={{ whiteSpace: 'pre-wrap', background: '#fff', padding: 8 }}>{r.out.stdout}</pre>
</>
) : null}
{r.out.stderr ? (
<>
<Text type="secondary">stderr:</Text>
<pre style={{ whiteSpace: 'pre-wrap', background: '#fff', padding: 8 }}>{r.out.stderr}</pre>
</>
) : null}
</>
) : null}
</div>
))}
</Card>
)}
</>
);
}