nft rule post json
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s

This commit is contained in:
2026-02-11 21:26:58 +01:00
parent c34b71f5d7
commit 6c5b5ef4a1
9 changed files with 1000 additions and 267 deletions

View 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;