All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 1m40s
449 lines
15 KiB
TypeScript
449 lines
15 KiB
TypeScript
import { ArrowDownOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons';
|
|
import { Alert, Button, Card, Divider, Modal, notification, Space, Spin, 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;
|
|
|
|
|
|
function renderRuleFriendly(rule: RuleOut | any): string {
|
|
if (rule?.text && typeof rule.text === 'string' && rule.text.trim() !== '') return rule.text;
|
|
if (rule?.expr && typeof rule.expr === 'string') return rule.expr;
|
|
|
|
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(' ');
|
|
}
|
|
|
|
if (rule?.expr && typeof rule.expr === 'object') {
|
|
try {
|
|
return JSON.stringify(rule.expr, (_k, v) => (v === undefined ? null : v)).slice(0, 500);
|
|
} catch {
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
type Props = {
|
|
tables: TableOut[]; // passed from parent
|
|
error?: Error | null;
|
|
refreshRules: () => Promise<void>; // trigger to re-fetch ruleset
|
|
};
|
|
|
|
|
|
export default function FirewallTables({ tables, error, refreshRules: refresh }: Props): ReactElement {
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [isOpenTableCreatorModal, setIsOpenTableCreatorModal] = useState(false);
|
|
const [isOpenChainCreatorModal, setIsOpenChainCreatorModal] = useState(false);
|
|
|
|
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) });
|
|
}
|
|
}
|
|
return acc;
|
|
}, []);
|
|
|
|
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) {
|
|
notification.success({ message: 'Rule deleted', description: cmd });
|
|
} else {
|
|
notification.error({ message: 'Delete returned error', description: first.err });
|
|
}
|
|
} catch (err: any) {
|
|
notification.error({ message: 'Delete failed', description: err?.message ?? String(err) });
|
|
} finally {
|
|
try {
|
|
await refresh();
|
|
} catch {
|
|
}
|
|
}
|
|
},
|
|
});
|
|
},
|
|
[runCommands, refresh],
|
|
);
|
|
|
|
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) {
|
|
notification.success({ message: `Chain ${chain} deleted`, description: cmd });
|
|
} else {
|
|
notification.error({ message: 'Chain deletion returned error', description: first.err });
|
|
}
|
|
} catch (err: any) {
|
|
notification.error({ message: 'Chain deletion failed', description: err?.message ?? String(err) });
|
|
} finally {
|
|
try {
|
|
await refresh();
|
|
} catch {
|
|
}
|
|
}
|
|
},
|
|
});
|
|
},
|
|
[runCommands, refresh],
|
|
);
|
|
|
|
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) {
|
|
notification.success({ message: `Table ${table} deleted`, description: cmd });
|
|
} else {
|
|
notification.error({ message: 'Table deletion returned error', description: first.err });
|
|
}
|
|
} catch (err: any) {
|
|
notification.error({ message: 'Table deletion failed', description: err?.message ?? String(err) });
|
|
} finally {
|
|
try {
|
|
await refresh();
|
|
} catch {
|
|
}
|
|
}
|
|
},
|
|
});
|
|
},
|
|
[runCommands, refresh],
|
|
);
|
|
|
|
const handleRefresh = useCallback(async () => {
|
|
setRefreshing(true);
|
|
try {
|
|
await refresh();
|
|
notification.success({ message: 'Ruleset refreshed' });
|
|
} catch (err: any) {
|
|
notification.error({ message: 'Refresh failed', description: err?.message ?? String(err) });
|
|
} 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={(created?: boolean) => {
|
|
setIsOpenTableCreatorModal(false);
|
|
if (created) {
|
|
void refresh().catch(() => {});
|
|
}
|
|
}}
|
|
/>
|
|
|
|
<Card
|
|
title="Firewall Tables"
|
|
extra={
|
|
<Space>
|
|
<Button
|
|
onClick={handleRefresh}
|
|
loading={refreshing}
|
|
icon={refreshing ? <Spin size="small" /> : <ReloadOutlined />}
|
|
>
|
|
Refresh Ruleset
|
|
</Button>
|
|
<Button type="primary" 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'} <b>Chains:</b> {table.chains.length} {' '}
|
|
<b>Total Rules:</b> {totalRules}
|
|
</Text>
|
|
</div>
|
|
|
|
<Space>
|
|
<Button
|
|
danger
|
|
icon={<DeleteOutlined />}
|
|
onClick={() => handleDeleteTable(table.family, table.name)}
|
|
/>
|
|
<Button type="primary" onClick={() => setIsOpenChainCreatorModal(true)}>
|
|
Add Chain
|
|
</Button>
|
|
</Space>
|
|
</div>
|
|
}
|
|
>
|
|
|
|
<FirewallAddChainModal
|
|
open={isOpenChainCreatorModal}
|
|
onClose={(created?: boolean) => {
|
|
setIsOpenChainCreatorModal(false);
|
|
if (created) {
|
|
void refresh().catch(() => {});
|
|
}
|
|
}}
|
|
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 ?? '-'} <b>Type:</b> {chain.type ?? '-'} {' '}
|
|
<b>Policy:</b> {chain.policy ?? '-'} <b>Priority:</b> {chain.priority ?? '-'}
|
|
</Text>
|
|
</div>
|
|
|
|
<Space>
|
|
<Button
|
|
danger
|
|
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>
|
|
</>
|
|
);
|
|
}
|