add chain and table addition to frontend
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 10s
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 10s
This commit is contained in:
@@ -163,6 +163,12 @@ export const createRuleJson = async (req: CreateRuleRequest): Promise<ExecResult
|
|||||||
const res = await api.post<ExecResult>("/firewall/rules", req);
|
const res = await api.post<ExecResult>("/firewall/rules", req);
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const execFirewallRaw = async (cmd: string): Promise<ExecResult> => {
|
||||||
|
const res = await api.post<ExecResult>("/firewall/raw", { "cmd": cmd });
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
/* -------------------------
|
/* -------------------------
|
||||||
Scripts
|
Scripts
|
||||||
------------------------- */
|
------------------------- */
|
||||||
|
|||||||
399
frontend/src/components/FireWallAddChainModal.tsx
Normal file
399
frontend/src/components/FireWallAddChainModal.tsx
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
// src/components/AddChainModal.tsx
|
||||||
|
import { CopyOutlined } from '@ant-design/icons';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Modal,
|
||||||
|
Row,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import { ReactElement, useEffect, useState } from 'react';
|
||||||
|
import { execFirewallRaw, fetchRuleset } from '../api/apiClient';
|
||||||
|
import { CmdResult, ExecResult } from '../types/firewall';
|
||||||
|
|
||||||
|
const { Paragraph, Text } = Typography;
|
||||||
|
const { Option } = Select;
|
||||||
|
|
||||||
|
type TableProp = {
|
||||||
|
family: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
table?: TableProp; // if provided, family & tableName are prefilled & readonly
|
||||||
|
startOnPreview?: boolean;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FAMILY_DESCRIPTIONS: Record<string, string> = {
|
||||||
|
ip: 'IPv4 packets.',
|
||||||
|
ip6: 'IPv6 packets.',
|
||||||
|
inet: 'Both IPv4 and IPv6.',
|
||||||
|
arp: 'ARP packets.',
|
||||||
|
bridge: 'Packets passing through a bridge.',
|
||||||
|
netdev: 'Direct filtering on a network device (ingress).',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FirewallAddChainModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
table,
|
||||||
|
startOnPreview = false,
|
||||||
|
onSuccess,
|
||||||
|
}: Props): ReactElement {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [loadingRuleset, setLoadingRuleset] = useState(false);
|
||||||
|
const [rulesetEmpty, setRulesetEmpty] = useState<boolean>(false);
|
||||||
|
const [step, setStep] = useState<number>(startOnPreview ? 1 : 0);
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
const [results, setResults] = useState<CmdResult[]>([]);
|
||||||
|
const [selectedFamily, setSelectedFamily] = useState<string>(table?.family ?? 'bridge');
|
||||||
|
|
||||||
|
// set defaults and prefill when modal opens or table prop changes
|
||||||
|
useEffect(() => {
|
||||||
|
// only prefill family/tableName if provided by prop
|
||||||
|
form.setFieldsValue({
|
||||||
|
family: table?.family ?? 'bridge',
|
||||||
|
tableName: table?.name ?? 'filter',
|
||||||
|
// intentionally do NOT set chainName here so user input is preserved normally
|
||||||
|
type: 'filter',
|
||||||
|
hook: 'forward',
|
||||||
|
priority: 0,
|
||||||
|
policy: 'accept',
|
||||||
|
});
|
||||||
|
setSelectedFamily(table?.family ?? 'bridge');
|
||||||
|
|
||||||
|
if (open) {
|
||||||
|
refreshRuleset();
|
||||||
|
setResults([]);
|
||||||
|
setRunning(false);
|
||||||
|
setStep(startOnPreview ? 1 : 0);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, table]);
|
||||||
|
|
||||||
|
async function refreshRuleset() {
|
||||||
|
setLoadingRuleset(true);
|
||||||
|
try {
|
||||||
|
const res = await fetchRuleset();
|
||||||
|
const ruleset = (res as any).ruleset;
|
||||||
|
if (ruleset && Array.isArray(ruleset.tables)) {
|
||||||
|
setRulesetEmpty(ruleset.tables.length === 0);
|
||||||
|
} else {
|
||||||
|
setRulesetEmpty(false);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
// don't block UI — warn user
|
||||||
|
message.warning('Could not load ruleset: ' + (err?.message ?? String(err)));
|
||||||
|
} finally {
|
||||||
|
setLoadingRuleset(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build add chain command — now reads chainName from values OR live form value
|
||||||
|
function buildCommands(values: any): string[] {
|
||||||
|
const family = values.family ?? form.getFieldValue('family') ?? table?.family ?? 'bridge';
|
||||||
|
const tableName = values.tableName ?? form.getFieldValue('tableName') ?? table?.name ?? 'filter';
|
||||||
|
|
||||||
|
// prefer explicitly provided values.chainName, otherwise read from form live state
|
||||||
|
const chainFromValues =
|
||||||
|
values && typeof values.chainName === 'string' && values.chainName.trim().length > 0
|
||||||
|
? values.chainName.trim()
|
||||||
|
: undefined;
|
||||||
|
const chainFromForm = form.getFieldValue('chainName');
|
||||||
|
const chain =
|
||||||
|
chainFromValues ||
|
||||||
|
(typeof chainFromForm === 'string' && chainFromForm.trim().length > 0 ? chainFromForm.trim() : undefined) ||
|
||||||
|
'mychain';
|
||||||
|
|
||||||
|
const type = values.type ?? form.getFieldValue('type') ?? 'filter';
|
||||||
|
const hook = values.hook ?? form.getFieldValue('hook') ?? 'forward';
|
||||||
|
const priority =
|
||||||
|
typeof (values.priority ?? form.getFieldValue('priority')) === 'number'
|
||||||
|
? (values.priority ?? form.getFieldValue('priority'))
|
||||||
|
: 0;
|
||||||
|
const policy = values.policy ?? form.getFieldValue('policy') ?? '';
|
||||||
|
|
||||||
|
const policyPart = policy ? ` policy ${policy} ;` : '';
|
||||||
|
const cmd = `add chain ${family} ${tableName} ${chain} { type ${type} hook ${hook} priority ${priority} ;${policyPart} }`;
|
||||||
|
return [cmd];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute commands sequentially
|
||||||
|
async function executeCommands(cmds: string[]) {
|
||||||
|
setRunning(true);
|
||||||
|
setResults([]);
|
||||||
|
const acc: CmdResult[] = [];
|
||||||
|
|
||||||
|
for (const cmd of cmds) {
|
||||||
|
try {
|
||||||
|
const out = (await execFirewallRaw(cmd)) as ExecResult;
|
||||||
|
const success = out && (out.rc === 0 || out.rc === -1);
|
||||||
|
if (success) acc.push({ cmd, out });
|
||||||
|
else acc.push({ cmd, out, err: `rc: ${out.rc} stderr: ${out.stderr ?? ''}` });
|
||||||
|
} catch (err: any) {
|
||||||
|
acc.push({ cmd, err: err?.message ?? String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setResults(acc);
|
||||||
|
setRunning(false);
|
||||||
|
|
||||||
|
// refresh ruleset after running
|
||||||
|
try {
|
||||||
|
await refreshRuleset();
|
||||||
|
} catch {
|
||||||
|
/* ignore - refreshRuleset handles messaging */
|
||||||
|
}
|
||||||
|
|
||||||
|
const hadError = acc.some((r) => r.err);
|
||||||
|
if (!hadError) {
|
||||||
|
message.success('Chain created and ruleset refreshed');
|
||||||
|
onClose();
|
||||||
|
if (onSuccess) onSuccess();
|
||||||
|
} else {
|
||||||
|
message.error('Some commands returned errors — see results in the modal');
|
||||||
|
setStep(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFooter() {
|
||||||
|
return (
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (step === 0) onClose();
|
||||||
|
else setStep(0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{step === 0 ? 'Cancel' : 'Back'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{step === 0 ? (
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
const txt = buildCommands(form.getFieldsValue()).join('\n');
|
||||||
|
navigator.clipboard.writeText(txt).then(() => message.success('Command copied'));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Copy Command
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={async () => {
|
||||||
|
// validate required fields before going to preview
|
||||||
|
try {
|
||||||
|
await form.validateFields(['chainName', ...(table ? [] : ['tableName', 'family'])]);
|
||||||
|
setStep(1);
|
||||||
|
} catch (e) {
|
||||||
|
// validation errors shown by AntD
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Preview
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
loading={running}
|
||||||
|
onClick={() => executeCommands(buildCommands(form.getFieldsValue()))}
|
||||||
|
>
|
||||||
|
Execute
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadingRuleset) {
|
||||||
|
return (
|
||||||
|
<Modal title="Add Chain" open={open} onCancel={onClose} footer={null} width={800}>
|
||||||
|
<div style={{ textAlign: 'center', padding: 28 }}>
|
||||||
|
<Spin />
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPrefilled = Boolean(table?.family && table?.name);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title="Add Chain" open={open} onCancel={onClose} width={800} footer={renderFooter()} destroyOnClose>
|
||||||
|
{/* Step 0: Form */}
|
||||||
|
{step === 0 && (
|
||||||
|
<div>
|
||||||
|
{rulesetEmpty && (
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
message="No firewall tables found"
|
||||||
|
description="You will need to create a table first before adding a chain."
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
onValuesChange={(changed) => {
|
||||||
|
if (changed.family) setSelectedFamily(changed.family);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Row gutter={12}>
|
||||||
|
{/* family & table: show inputs only when not provided via props */}
|
||||||
|
<Col span={8}>
|
||||||
|
{isPrefilled ? (
|
||||||
|
<Form.Item label="Family">
|
||||||
|
<Text strong>{table!.family}</Text>
|
||||||
|
<Paragraph type="secondary" style={{ marginTop: 8 }}>
|
||||||
|
{FAMILY_DESCRIPTIONS[table!.family]}
|
||||||
|
</Paragraph>
|
||||||
|
</Form.Item>
|
||||||
|
) : (
|
||||||
|
<Form.Item name="family" label="Family" rules={[{ required: true }]}>
|
||||||
|
<Select defaultValue="bridge">
|
||||||
|
<Option value="ip">ip</Option>
|
||||||
|
<Option value="ip6">ip6</Option>
|
||||||
|
<Option value="inet">inet</Option>
|
||||||
|
<Option value="arp">arp</Option>
|
||||||
|
<Option value="bridge">bridge</Option>
|
||||||
|
<Option value="netdev">netdev</Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col span={8}>
|
||||||
|
{isPrefilled ? (
|
||||||
|
<Form.Item label="Table">
|
||||||
|
<Text strong>{table!.name}</Text>
|
||||||
|
</Form.Item>
|
||||||
|
) : (
|
||||||
|
<Form.Item name="tableName" label="Table Name" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="filter" />
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col span={8}>
|
||||||
|
<Form.Item name="chainName" label="Chain Name" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="e.g. forward" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={12} style={{ marginTop: 8 }}>
|
||||||
|
<Col span={6}>
|
||||||
|
<Form.Item name="type" label="Type" initialValue="filter">
|
||||||
|
<Select>
|
||||||
|
<Option value="filter">filter</Option>
|
||||||
|
<Option value="nat">nat</Option>
|
||||||
|
<Option value="route">route</Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col span={6}>
|
||||||
|
<Form.Item name="hook" label="Hook" initialValue="forward">
|
||||||
|
<Select>
|
||||||
|
<Option value="input">input</Option>
|
||||||
|
<Option value="forward">forward</Option>
|
||||||
|
<Option value="output">output</Option>
|
||||||
|
<Option value="ingress">ingress</Option>
|
||||||
|
<Option value="egress">egress</Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col span={6}>
|
||||||
|
<Form.Item name="priority" label="Priority" initialValue={0}>
|
||||||
|
<InputNumber style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col span={6}>
|
||||||
|
<Form.Item name="policy" label="Policy" initialValue="accept">
|
||||||
|
<Select>
|
||||||
|
<Option value="accept">accept</Option>
|
||||||
|
<Option value="drop">drop</Option>
|
||||||
|
<Option value="">(none)</Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 1: Preview & Results */}
|
||||||
|
{step === 1 && (
|
||||||
|
<>
|
||||||
|
<Card title="Command preview" style={{ marginBottom: 12 }}>
|
||||||
|
<Paragraph>
|
||||||
|
The command below will be executed on the server (server will prefix with <Text code>nft</Text>):
|
||||||
|
</Paragraph>
|
||||||
|
<pre style={{ whiteSpace: 'pre-wrap', background: '#fafafa', padding: 12 }}>
|
||||||
|
{buildCommands(form.getFieldsValue()).join('\n')}
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
<Space style={{ marginTop: 12 }}>
|
||||||
|
<Button
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
const txt = buildCommands(form.getFieldsValue()).join('\n');
|
||||||
|
navigator.clipboard.writeText(txt).then(() => message.success('Command copied'));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Copy command
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{results.length > 0 && (
|
||||||
|
<Card title="Execution results" style={{ marginTop: 12 }}>
|
||||||
|
{results.map((r, i) => (
|
||||||
|
<div key={i} style={{ marginBottom: 12 }}>
|
||||||
|
<Text strong>{r.cmd}</Text>
|
||||||
|
{r.err ? (
|
||||||
|
<Paragraph type="danger">{r.err}</Paragraph>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<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}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
250
frontend/src/components/FireWallAddTableModal.tsx
Normal file
250
frontend/src/components/FireWallAddTableModal.tsx
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
// src/components/FirewallManager.tsx
|
||||||
|
import { Button, Card, Col, Form, Input, Modal, Row, Select, Space, Spin, Typography, message } from 'antd';
|
||||||
|
import { ReactElement, useEffect, useState } from 'react';
|
||||||
|
import { execFirewallRaw, fetchRuleset } from '../api/apiClient';
|
||||||
|
|
||||||
|
const { Paragraph, Text } = Typography;
|
||||||
|
const { Option } = Select;
|
||||||
|
|
||||||
|
type ExecResult = {
|
||||||
|
rc: number;
|
||||||
|
stdout?: string | null;
|
||||||
|
stderr?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CmdResult = {
|
||||||
|
cmd: string;
|
||||||
|
out?: ExecResult;
|
||||||
|
err?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
startOnPreview?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FAMILY_DESCRIPTIONS: Record<string, string> = {
|
||||||
|
ip: 'IPv4 packets.',
|
||||||
|
ip6: 'IPv6 packets.',
|
||||||
|
inet: 'Both IPv4 and IPv6.',
|
||||||
|
arp: 'ARP packets.',
|
||||||
|
bridge: 'Packets passing through a bridge.',
|
||||||
|
netdev: 'Direct filtering on a network device (ingress).',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FirewallAddTableModal({ open, onClose, startOnPreview = false }: Props): ReactElement {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [localLoadingRuleset, setLocalLoadingRuleset] = useState(false);
|
||||||
|
const [rulesetEmpty, setRulesetEmpty] = useState<boolean>(false);
|
||||||
|
const [step, setStep] = useState<number>(startOnPreview ? 1 : 0);
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
const [results, setResults] = useState<CmdResult[]>([]);
|
||||||
|
const [selectedFamily, setSelectedFamily] = useState<string>('bridge');
|
||||||
|
|
||||||
|
// initialize and refresh when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
form.setFieldsValue({
|
||||||
|
family: 'bridge',
|
||||||
|
tableName: 'filter',
|
||||||
|
});
|
||||||
|
setSelectedFamily('bridge');
|
||||||
|
|
||||||
|
if (open) {
|
||||||
|
refreshRuleset();
|
||||||
|
setResults([]);
|
||||||
|
setRunning(false);
|
||||||
|
setStep(startOnPreview ? 1 : 0);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
async function refreshRuleset() {
|
||||||
|
setLocalLoadingRuleset(true);
|
||||||
|
try {
|
||||||
|
const res = await fetchRuleset();
|
||||||
|
const ruleset = (res as any).ruleset;
|
||||||
|
if (ruleset && Array.isArray(ruleset.tables)) {
|
||||||
|
setRulesetEmpty(ruleset.tables.length === 0);
|
||||||
|
} else {
|
||||||
|
setRulesetEmpty(false);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
message.warning('Could not load ruleset: ' + (err?.message ?? String(err)));
|
||||||
|
} finally {
|
||||||
|
setLocalLoadingRuleset(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build commands: only create table
|
||||||
|
function buildCommands(values: any): string[] {
|
||||||
|
const family = values.family ?? 'bridge';
|
||||||
|
const table = values.tableName ?? 'filter';
|
||||||
|
return [`add table ${family} ${table}`];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute commands sequentially
|
||||||
|
async function executeCommands(cmds: string[]) {
|
||||||
|
setRunning(true);
|
||||||
|
setResults([]);
|
||||||
|
|
||||||
|
const acc: CmdResult[] = [];
|
||||||
|
for (const cmd of cmds) {
|
||||||
|
try {
|
||||||
|
const out = (await execFirewallRaw(cmd)) as ExecResult;
|
||||||
|
const success = out && (out.rc === 0 || out.rc === -1);
|
||||||
|
if (success) acc.push({ cmd, out });
|
||||||
|
else acc.push({ cmd, out, err: `rc: ${out.rc} stderr: ${out.stderr ?? ''}` });
|
||||||
|
} catch (err: any) {
|
||||||
|
acc.push({ cmd, err: err?.message ?? String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setResults(acc);
|
||||||
|
setRunning(false);
|
||||||
|
|
||||||
|
// refresh ruleset after running
|
||||||
|
try {
|
||||||
|
await refreshRuleset();
|
||||||
|
} catch {
|
||||||
|
// ignore — refreshRuleset handles messaging
|
||||||
|
}
|
||||||
|
|
||||||
|
const hadError = acc.some((r) => r.err);
|
||||||
|
if (!hadError) {
|
||||||
|
message.success('Table created and ruleset refreshed');
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
message.error('Some commands returned errors — see results in the modal');
|
||||||
|
setStep(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFooter() {
|
||||||
|
return (
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (step === 0) onClose();
|
||||||
|
else setStep(0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{step === 0 ? 'Cancel' : 'Back'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{step === 0 ? (
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" onClick={() => setStep(1)}>
|
||||||
|
Preview
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
loading={running}
|
||||||
|
onClick={() => executeCommands(buildCommands(form.getFieldsValue()))}
|
||||||
|
>
|
||||||
|
Execute
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// show spinner while checking ruleset
|
||||||
|
if (localLoadingRuleset) {
|
||||||
|
return (
|
||||||
|
<Modal title="Create Table" open={open} onCancel={onClose} footer={null} width={700}>
|
||||||
|
<div style={{ textAlign: 'center', padding: 28 }}>
|
||||||
|
<Spin />
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title="Create Table" open={open} onCancel={onClose} width={700} footer={renderFooter()} destroyOnClose>
|
||||||
|
{/* Step 0: minimal form */}
|
||||||
|
{step === 0 && (
|
||||||
|
<div>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
onValuesChange={(changed) => {
|
||||||
|
if (changed.family) setSelectedFamily(changed.family);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Row gutter={12}>
|
||||||
|
<Col span={8}>
|
||||||
|
<Form.Item name="family" label="Family" rules={[{ required: true }]}>
|
||||||
|
<Select defaultValue="bridge" value={selectedFamily}>
|
||||||
|
<Option value="ip">ip</Option>
|
||||||
|
<Option value="ip6">ip6</Option>
|
||||||
|
<Option value="inet">inet</Option>
|
||||||
|
<Option value="arp">arp</Option>
|
||||||
|
<Option value="bridge">bridge</Option>
|
||||||
|
<Option value="netdev">netdev</Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
<Paragraph type="secondary" style={{ marginTop: 8 }}>
|
||||||
|
{FAMILY_DESCRIPTIONS[selectedFamily]}
|
||||||
|
</Paragraph>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col span={8}>
|
||||||
|
<Form.Item name="tableName" label="Table Name" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 1: preview + results */}
|
||||||
|
{step === 1 && (
|
||||||
|
<>
|
||||||
|
<Card title="Command preview" style={{ marginBottom: 12 }}>
|
||||||
|
<Paragraph>
|
||||||
|
The command below will be executed on the server (server will prefix with <Text code>nft</Text>):
|
||||||
|
</Paragraph>
|
||||||
|
<pre style={{ whiteSpace: 'pre-wrap', background: '#fafafa', padding: 12 }}>
|
||||||
|
{buildCommands(form.getFieldsValue()).join('\n')}
|
||||||
|
</pre>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{results.length > 0 && (
|
||||||
|
<Card title="Execution results" style={{ marginTop: 12 }}>
|
||||||
|
{results.map((r, i) => (
|
||||||
|
<div key={i} style={{ marginBottom: 12 }}>
|
||||||
|
<Text strong>{r.cmd}</Text>
|
||||||
|
{r.err ? (
|
||||||
|
<Paragraph type="danger">{r.err}</Paragraph>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<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}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,239 +1,451 @@
|
|||||||
// src/components/RulesView.tsx
|
import { Alert, Button, Card, Modal, Space, Spin, Table, Typography, message } from 'antd';
|
||||||
import { DeleteOutlined, EyeOutlined, ReloadOutlined } from '@ant-design/icons';
|
import { ColumnsType } from 'antd/lib/table';
|
||||||
import { Button, Card, Collapse, Drawer, Empty, Popconfirm, Space, Spin, Table, Typography, message } from 'antd';
|
import { ReactElement, useEffect, useState } from 'react';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import { execFirewallRaw, fetchRuleset } from '../api/apiClient';
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import { CmdResult, ExecResult } from '../types/firewall';
|
||||||
import { deleteRule, fetchRuleset } from '../api/apiClient';
|
import FirewallAddChainModal from './FireWallAddChainModal';
|
||||||
import { ChainOut, RulesetModel, TableOut } from '../types/firewall';
|
import FirewallAddTableModal from './FireWallAddTableModal';
|
||||||
|
|
||||||
const { Panel } = Collapse;
|
const { Paragraph, Text, Title } = Typography;
|
||||||
const { Paragraph, Text } = Typography;
|
|
||||||
|
|
||||||
interface RuleRow {
|
type NFTRule = {
|
||||||
key: string;
|
handle?: number | string;
|
||||||
handle?: number | null;
|
expr?: any;
|
||||||
text: string;
|
[k: string]: any;
|
||||||
expr: any;
|
|
||||||
comment?: string | null;
|
|
||||||
position?: any;
|
|
||||||
// context for delete
|
|
||||||
family: string;
|
|
||||||
table: string;
|
|
||||||
chain: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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);
|
|
||||||
|
|
||||||
const fetchRules = useCallback(async () => {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
message.error(`Failed to load ruleset: ${err?.message ?? err}`);
|
|
||||||
setRuleset({ tables: [] });
|
|
||||||
setRawFallback(null);
|
|
||||||
} 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
|
type NFTChain = {
|
||||||
if (!loading && rawFallback) {
|
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 [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------
|
||||||
|
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}`;
|
||||||
|
|
||||||
|
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 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();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 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) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// prepend new results so the latest are visible first
|
||||||
|
setResults((prev) => [...acc, ...prev]);
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
return (
|
||||||
<Card>
|
<>
|
||||||
<Space direction="vertical" style={{ width: '100%' }}>
|
<FirewallAddTableModal open={isOpenTableCreatorModal} onClose={() => setIsOpenTableCreatorModal(false)} />
|
||||||
<Text strong>Ruleset (raw)</Text>
|
<Card
|
||||||
<Paragraph style={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace' }}>{rawFallback}</Paragraph>
|
title="Firewall Tables"
|
||||||
<Button icon={<ReloadOutlined />} onClick={() => fetchRules()}>
|
extra={
|
||||||
|
<Space>
|
||||||
|
<Button onClick={handleRefresh} loading={refreshing}>
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button onClick={() => setIsOpenTableCreatorModal(true)}>Add Table</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</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);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
title="Firewall rules"
|
key={`${table.family ?? 'any'}:${table.name}`}
|
||||||
extra={
|
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>
|
<Space>
|
||||||
<Button icon={<ReloadOutlined />} onClick={() => fetchRules()} loading={loading}>
|
<Button danger size="small" onClick={() => handleDeleteTable(table.family, table.name)}>
|
||||||
Refresh
|
Delete Table
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</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 ?? '-'} <b>Policy:</b> {chain.policy ?? '-'}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleDeleteChain(table.family, table.name, chain.name)}
|
||||||
|
>
|
||||||
|
Delete Chain
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{loading ? (
|
<Table
|
||||||
<div style={{ textAlign: 'center', padding: 40 }}>
|
style={{ marginTop: 12 }}
|
||||||
<Spin size="large" />
|
size="small"
|
||||||
</div>
|
columns={columns}
|
||||||
) : ruleset && ruleset.tables.length > 0 ? (
|
dataSource={dataSource}
|
||||||
<Collapse accordion>
|
pagination={false}
|
||||||
{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" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Drawer
|
|
||||||
title="Rule expression (JSON)"
|
|
||||||
placement="right"
|
|
||||||
width={640}
|
|
||||||
onClose={closeExprViewer}
|
|
||||||
open={exprDrawerVisible}
|
|
||||||
>
|
|
||||||
<Paragraph>
|
|
||||||
<Text strong>Expression (raw JSON)</Text>
|
|
||||||
</Paragraph>
|
|
||||||
<pre style={{ whiteSpace: 'pre-wrap' }}>{JSON.stringify(exprForView, null, 2)}</pre>
|
|
||||||
</Drawer>
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
})}
|
||||||
|
</Space>
|
||||||
export default RulesView;
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,3 +40,14 @@ export interface ExecResult {
|
|||||||
stdout?: string | null;
|
stdout?: string | null;
|
||||||
stderr?: string | null;
|
stderr?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CmdResult {
|
||||||
|
cmd: string;
|
||||||
|
out?: ExecResult;
|
||||||
|
err?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface TableProp {
|
||||||
|
family: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user