nft rule post json
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
This commit is contained in:
576
frontend/src/components/FirewallRuleBuilder.tsx
Normal file
576
frontend/src/components/FirewallRuleBuilder.tsx
Normal file
@@ -0,0 +1,576 @@
|
||||
// src/components/RuleBuilder.tsx
|
||||
import { CopyOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Divider,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
message,
|
||||
Modal,
|
||||
Radio,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { createRuleJson, fetchRuleset } from '../api/apiClient';
|
||||
import { Expr, TableOut } from '../types/firewall';
|
||||
|
||||
const { Option } = Select;
|
||||
const { TextArea } = Input;
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
interface RuleBuilderProps {
|
||||
onCreated?: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: create an nft JSON 'expr' array from form values.
|
||||
* The produced expr is intentionally conservative and designed to be renderable by the backend's renderer.
|
||||
*/
|
||||
function buildExprFromValues(values: any): Expr[] {
|
||||
const expr: Expr[] = [];
|
||||
|
||||
// protocol
|
||||
const protoChoice = values.protocolChoice ?? 'preset';
|
||||
if (protoChoice === 'preset') {
|
||||
const preset = values.protocolPreset;
|
||||
if (preset === 'icmp') {
|
||||
expr.push({
|
||||
match: {
|
||||
left: { payload: { protocol: 'ip', field: 'protocol' } },
|
||||
op: '==',
|
||||
right: 'icmp',
|
||||
},
|
||||
});
|
||||
} else if (preset === 'icmpv6') {
|
||||
expr.push({
|
||||
match: {
|
||||
left: { payload: { protocol: 'ip6', field: 'nexthdr' } },
|
||||
op: '==',
|
||||
right: 'icmpv6',
|
||||
},
|
||||
});
|
||||
} else if (preset === 'tcp') {
|
||||
// meta l4proto tcp can be expressed as a 'match' fallback, but backend handles tcp dicts for ports
|
||||
// include a simple token so renderer can show "tcp"
|
||||
expr.push({ tcp: {} });
|
||||
} else if (preset === 'udp') {
|
||||
expr.push({ udp: {} });
|
||||
}
|
||||
} else {
|
||||
// custom protocol — the UI accepts free text; attempt to produce a match if the user entered "icmp" etc.
|
||||
const custom = (values.protocolCustom || '').trim();
|
||||
if (custom) {
|
||||
// simple heuristics
|
||||
if (/^icmpv6$/i.test(custom)) {
|
||||
expr.push({
|
||||
match: {
|
||||
left: { payload: { protocol: 'ip6', field: 'nexthdr' } },
|
||||
op: '==',
|
||||
right: 'icmpv6',
|
||||
},
|
||||
});
|
||||
} else if (/^icmp$/i.test(custom)) {
|
||||
expr.push({
|
||||
match: {
|
||||
left: { payload: { protocol: 'ip', field: 'protocol' } },
|
||||
op: '==',
|
||||
right: 'icmp',
|
||||
},
|
||||
});
|
||||
} else if (/tcp/i.test(custom)) {
|
||||
expr.push({ tcp: {} });
|
||||
} else if (/udp/i.test(custom)) {
|
||||
expr.push({ udp: {} });
|
||||
} else {
|
||||
// fallback: include as generic token (string) -- backend may not accept this
|
||||
expr.push(custom);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// source/destination addresses (encoded as payload matches)
|
||||
if (values.saddr) {
|
||||
expr.push({
|
||||
match: {
|
||||
left: { payload: { protocol: 'ip', field: 'saddr' } },
|
||||
op: '==',
|
||||
right: values.saddr,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (values.daddr) {
|
||||
expr.push({
|
||||
match: {
|
||||
left: { payload: { protocol: 'ip', field: 'daddr' } },
|
||||
op: '==',
|
||||
right: values.daddr,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ports for tcp/udp - encode with tcp/udp dicts if provided
|
||||
if (values.sport) {
|
||||
// heuristics: if protocol preset is udp or custom mentions udp -> use udp
|
||||
const useUdp =
|
||||
values.protocolPreset === 'udp' ||
|
||||
(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) {
|
||||
const useUdp =
|
||||
values.protocolPreset === 'udp' ||
|
||||
(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);
|
||||
}
|
||||
|
||||
// advanced free-text: we include as a string token so the backend can either render or reject
|
||||
if (values.advanced) {
|
||||
// try to include as raw JSON if looks like JSON, else include as string token
|
||||
const adv = values.advanced.trim();
|
||||
try {
|
||||
const parsed = JSON.parse(adv);
|
||||
// if parsed is an object or array, append it directly
|
||||
expr.push(parsed);
|
||||
} catch {
|
||||
// push as raw string token (backend may fail to render — user can use Raw)
|
||||
expr.push(adv);
|
||||
}
|
||||
}
|
||||
|
||||
// action: drop/accept/reject (we encode as dicts)
|
||||
const action = values.action || 'drop';
|
||||
if (action === 'drop') expr.push({ drop: null });
|
||||
else if (action === 'accept') expr.push({ accept: null });
|
||||
else if (action === 'reject') expr.push({ reject: null }); // nft supports 'reject' textual; JSON might differ, backend may reject
|
||||
|
||||
return expr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic short textual serializer for expr (for preview)
|
||||
* Mirrors backend's serializer heuristics so preview matches server-side text generation.
|
||||
*/
|
||||
function textFromExpr(expr: Expr): string {
|
||||
if (expr == null) return '';
|
||||
if (Array.isArray(expr)) {
|
||||
const tokens: string[] = [];
|
||||
for (const part of expr) {
|
||||
if (typeof part === 'string') {
|
||||
tokens.push(part);
|
||||
} else if (part && 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 && right !== undefined) {
|
||||
const p = left.payload;
|
||||
const prot = p.protocol;
|
||||
const field = p.field;
|
||||
if (prot && field && typeof right === 'string') {
|
||||
tokens.push(`${prot} ${field} ${right}`);
|
||||
continue;
|
||||
}
|
||||
tokens.push('match');
|
||||
continue;
|
||||
}
|
||||
tokens.push('match');
|
||||
} else if ('payload' in part) {
|
||||
const p = (part as any).payload;
|
||||
const prot = p.protocol;
|
||||
const field = p.field;
|
||||
tokens.push(`payload(${prot}.${field})`);
|
||||
} else 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');
|
||||
} else 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');
|
||||
} else if ('drop' in part) {
|
||||
tokens.push('drop');
|
||||
} else if ('accept' in part) {
|
||||
tokens.push('accept');
|
||||
} else if ('counter' in part) {
|
||||
tokens.push('counter');
|
||||
} else {
|
||||
const keys = Object.keys(part).sort().join('+');
|
||||
tokens.push(keys);
|
||||
}
|
||||
} else {
|
||||
tokens.push(String(part));
|
||||
}
|
||||
}
|
||||
return tokens.join(' ');
|
||||
}
|
||||
return String(expr);
|
||||
}
|
||||
|
||||
export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [tables, setTables] = useState<TableOut[]>([]);
|
||||
const [form] = Form.useForm();
|
||||
const [preview, setPreview] = useState('');
|
||||
const [exprPreview, setExprPreview] = useState<Expr | null>(null);
|
||||
|
||||
// fetch tables/chains on mount
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetchRuleset()
|
||||
.then((res) => {
|
||||
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(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const tableOptions = useMemo(
|
||||
() => tables.map((t) => ({ family: t.family, name: t.name, label: `${t.family}:${t.name}` })),
|
||||
[tables],
|
||||
);
|
||||
|
||||
// set initial defaults
|
||||
useEffect(() => {
|
||||
if (tableOptions.length > 0) {
|
||||
const first = tableOptions[0];
|
||||
form.setFieldsValue({
|
||||
tableSelect: `${first.family}:${first.name}`,
|
||||
protocolChoice: 'preset',
|
||||
protocolPreset: 'icmp',
|
||||
action: 'drop',
|
||||
});
|
||||
} else {
|
||||
form.setFieldsValue({
|
||||
tableSelect: '__manual__',
|
||||
protocolChoice: 'preset',
|
||||
protocolPreset: 'icmp',
|
||||
action: 'drop',
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tableOptions]);
|
||||
|
||||
// update previews when form changes
|
||||
const updatePreviews = useCallback(() => {
|
||||
const vals = form.getFieldsValue();
|
||||
const expr = buildExprFromValues(vals);
|
||||
setExprPreview(expr);
|
||||
setPreview(textFromExpr(expr));
|
||||
}, [form]);
|
||||
|
||||
useEffect(() => {
|
||||
updatePreviews();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// keep preview updated on values change
|
||||
const onValuesChange = useCallback(() => {
|
||||
updatePreviews();
|
||||
}, [updatePreviews]);
|
||||
|
||||
// submit handler: build expr and call createRuleJson
|
||||
const handleCreate = useCallback(
|
||||
async (values: any) => {
|
||||
const tableSelect = values.tableSelect;
|
||||
let family: string;
|
||||
let tableName: string;
|
||||
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);
|
||||
// POST JSON
|
||||
Modal.confirm({
|
||||
title: 'Create rule (JSON)',
|
||||
content: (
|
||||
<div>
|
||||
<Text>
|
||||
About to create rule in{' '}
|
||||
<b>
|
||||
{family}:{tableName} {chain}
|
||||
</b>
|
||||
</Text>
|
||||
<Divider />
|
||||
<Text strong>Preview:</Text>
|
||||
<pre style={{ whiteSpace: 'pre-wrap', marginTop: 8 }}>{preview || '(no textual preview available)'}</pre>
|
||||
<Divider />
|
||||
<Text strong>Expression (JSON):</Text>
|
||||
<pre style={{ whiteSpace: 'pre-wrap', marginTop: 8 }}>{JSON.stringify(expr, null, 2)}</pre>
|
||||
</div>
|
||||
),
|
||||
okText: 'Create',
|
||||
onOk: async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const req = { family, table: tableName, chain, expr };
|
||||
const res = await createRuleJson(req);
|
||||
if (res && res.rc === 0) {
|
||||
message.success('Rule created');
|
||||
if (onCreated) await onCreated();
|
||||
form.resetFields(['advanced']);
|
||||
} else {
|
||||
message.error(`Create failed: ${res?.stderr ?? 'unknown error'}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
message.error(`Create failed: ${err?.message ?? String(err)}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
[form, preview, onCreated],
|
||||
);
|
||||
|
||||
return (
|
||||
<Card style={{ maxWidth: 980 }}>
|
||||
<Title level={4}>Add Firewall Rule (JSON)</Title>
|
||||
<Text type="secondary">
|
||||
This builder constructs a native nft JSON <code>expr</code> and sends it to the server. If the server cannot
|
||||
render your expression to the system's nft syntax, use the Raw command UI instead.
|
||||
</Text>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Form
|
||||
layout="vertical"
|
||||
form={form}
|
||||
initialValues={{
|
||||
protocolChoice: 'preset',
|
||||
protocolPreset: 'icmp',
|
||||
action: 'drop',
|
||||
tableSelect: tableOptions.length > 0 ? `${tableOptions[0].family}:${tableOptions[0].name}` : '__manual__',
|
||||
}}
|
||||
onFinish={handleCreate}
|
||||
onValuesChange={onValuesChange}
|
||||
>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Form.Item name="tableSelect" label="Table (family:name)" rules={[{ required: true }]}>
|
||||
<Select>
|
||||
{tableOptions.map((t) => (
|
||||
<Option key={`${t.family}:${t.name}`} value={`${t.family}:${t.name}`}>
|
||||
{t.label}
|
||||
</Option>
|
||||
))}
|
||||
<Option key="__manual__" value="__manual__">
|
||||
Manual / new table...
|
||||
</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={12}>
|
||||
{form.getFieldValue('tableSelect') === '__manual__' ? (
|
||||
<Space style={{ width: '100%' }}>
|
||||
<Form.Item name="familyManual" label="Family" style={{ flex: 1 }}>
|
||||
<Input placeholder="inet, bridge, ip, ip6 ..." />
|
||||
</Form.Item>
|
||||
<Form.Item name="tableManual" label="Table" style={{ flex: 1 }}>
|
||||
<Input placeholder="filter" />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
) : (
|
||||
<Form.Item label="Table (preview)">
|
||||
<Input value={form.getFieldValue('tableSelect')} readOnly />
|
||||
</Form.Item>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Form.Item name="chainSelect" label="Chain (select)">
|
||||
<Select>
|
||||
{(() => {
|
||||
const ts = form.getFieldValue('tableSelect');
|
||||
if (ts && ts !== '__manual__') {
|
||||
const [f, n] = String(ts).split(':');
|
||||
const tbl = tables.find((t) => t.family === f && t.name === n);
|
||||
if (tbl && tbl.chains.length > 0) {
|
||||
return tbl.chains.map((c) => (
|
||||
<Option key={c.name} value={c.name}>
|
||||
{c.name}
|
||||
</Option>
|
||||
));
|
||||
}
|
||||
}
|
||||
return [
|
||||
<Option key="__manual_chain__" value="__manual_chain__">
|
||||
Manual chain...
|
||||
</Option>,
|
||||
];
|
||||
})()}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={12}>
|
||||
{form.getFieldValue('chainSelect') === '__manual_chain__' ||
|
||||
form.getFieldValue('tableSelect') === '__manual__' ? (
|
||||
<Form.Item name="chainManual" label="Chain (manual)">
|
||||
<Input placeholder="input, forward, output" />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item label="Chain (preview)">
|
||||
<Input value={form.getFieldValue('chainSelect') ?? ''} readOnly />
|
||||
</Form.Item>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={8}>
|
||||
<Form.Item label="Protocol selection" style={{ marginBottom: 0 }}>
|
||||
<Space>
|
||||
<Form.Item name="protocolChoice" noStyle>
|
||||
<Radio.Group>
|
||||
<Radio.Button value="preset">Preset</Radio.Button>
|
||||
<Radio.Button value="custom">Custom</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
|
||||
{form.getFieldValue('protocolChoice') === 'custom' ? (
|
||||
<Form.Item name="protocolCustom" label="Protocol (custom)">
|
||||
<Input placeholder="e.g. icmp, icmpv6, meta l4proto tcp" />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item name="protocolPreset" label="Protocol (preset)">
|
||||
<Select>
|
||||
<Option value="">(none)</Option>
|
||||
<Option value="icmp">ICMP (IPv4)</Option>
|
||||
<Option value="icmpv6">ICMPv6</Option>
|
||||
<Option value="tcp">TCP</Option>
|
||||
<Option value="udp">UDP</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={8}>
|
||||
<Form.Item name="saddr" label="Source IP / CIDR">
|
||||
<Input placeholder="10.0.0.0/8 or 192.168.1.5" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={8}>
|
||||
<Form.Item name="daddr" label="Destination IP / CIDR">
|
||||
<Input placeholder="172.16.0.0/12" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={6}>
|
||||
<Form.Item name="sport" label="Source Port">
|
||||
<InputNumber style={{ width: '100%' }} min={1} max={65535} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={6}>
|
||||
<Form.Item name="dport" label="Dest Port">
|
||||
<InputNumber style={{ width: '100%' }} min={1} max={65535} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Form.Item name="action" label="Action" rules={[{ required: true }]} initialValue="drop">
|
||||
<Radio.Group>
|
||||
<Radio value="drop">Drop</Radio>
|
||||
<Radio value="accept">Accept</Radio>
|
||||
<Radio value="reject">Reject</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<Col span={24}>
|
||||
<Form.Item name="advanced" label="Advanced (optional)">
|
||||
<TextArea
|
||||
rows={3}
|
||||
placeholder="Append arbitrary JSON token or text (advanced). Use Raw endpoint for complex commands."
|
||||
/>
|
||||
<Text type="secondary">
|
||||
Advanced is appended to the expr as a token. Prefer structured fields where possible.
|
||||
</Text>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Form.Item label="Preview">
|
||||
<Space style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ flex: 1, marginRight: 12 }}>
|
||||
<Text strong>Short preview:</Text>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<Text code copyable={{ text: preview || '' }}>
|
||||
{preview || '(no textual preview available)'}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ width: 320 }}>
|
||||
<Tooltip title="Copy JSON expr">
|
||||
<Button
|
||||
icon={<CopyOutlined />}
|
||||
size="small"
|
||||
onClick={() => {
|
||||
if (!exprPreview) return;
|
||||
navigator.clipboard?.writeText(JSON.stringify(exprPreview, null, 2));
|
||||
message.success('Expression JSON copied');
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Button type="primary" htmlType="submit" loading={loading} icon={<PlusOutlined />}>
|
||||
Create rule
|
||||
</Button>
|
||||
</div>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Divider />
|
||||
<Text strong>Expression (JSON):</Text>
|
||||
<pre style={{ whiteSpace: 'pre-wrap', background: '#f6f6f6', padding: 12 }}>
|
||||
{JSON.stringify(exprPreview, null, 2)}
|
||||
</pre>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default RuleBuilder;
|
||||
Reference in New Issue
Block a user