Files
mitm-webserver/frontend/src/components/FirewallRuleBuilder.tsx
malmert 90da926415
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 10s
good builder
2026-02-28 22:00:20 +01:00

1739 lines
79 KiB
TypeScript

// src/components/RuleBuilder.tsx
//
// Extended RuleBuilder using the user's canonical match list:
// 1) Metadata & Connection Tracking (meta, ct)
// 2) Layer 3 Network Headers (ip, ip6)
// 3) Layer 4 Transport Headers (tcp, udp, icmp) — appear when chosen
// 4) Layer 2 Ethernet & VLAN (ether, vlan)
//
// The UI provides rich dropdowns / placeholders / short explanations for every token subfield.
//
// NOTE: This file replaces the token lists and per-field UI to strictly follow the user's canonical list.
import { CopyOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import {
Button,
Card,
Checkbox,
Col,
Divider,
Form,
Input,
InputNumber,
message,
Modal,
Radio,
Row,
Select,
Space,
Tooltip,
Typography,
} from 'antd';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { execFirewallRaw } from '../api/apiClient';
import type { ChainOut, ExecResult, RuleOut, TableOut } from '../types/firewall';
const { Option } = Select;
const { TextArea } = Input;
const { Title, Text } = Typography;
type FormValues = Record<string, any>;
/* ----------------------
Token types (canonical per user)
---------------------- */
type TokenType =
| 'meta'
| 'ct'
| 'ip'
| 'ip6'
| 'tcp'
| 'udp'
| 'icmp'
| 'ether'
| 'vlan'
| 'payload'
| 'raw'
| 'counter'
| 'limit'
| 'log'
| 'nat'
| 'queue';
/* ----------------------
TOKEN_FIELD_OPTIONS
Each token lists allowed subfields (exactly the fields from the user's canonical list).
The `kind` tells the UI which input widget to show (number, enum, string).
---------------------- */
const TOKEN_FIELD_OPTIONS: Record<
TokenType,
Array<{ value: string; label: string; kind?: 'string' | 'number' | 'enum' }>
> = {
/* 1) Metadata & Connection Tracking */
meta: [
{ value: 'iifname', label: 'iifname (input interface)', kind: 'string' },
{ value: 'oifname', label: 'oifname (output interface)', kind: 'string' },
{ value: 'l4proto', label: 'l4proto (protocol L4)', kind: 'enum' }, // tcp/udp/icmp/...
{ value: 'day', label: 'day (day of week)', kind: 'enum' },
{ value: 'hour', label: 'hour (hour of day/range)', kind: 'string' },
{ value: 'pkttype', label: 'pkttype (packet type)', kind: 'enum' },
{ value: 'mark', label: 'mark (packet mark)', kind: 'string' },
{ value: 'skuid', label: 'skuid (socket UID)', kind: 'number' },
{ value: 'skgid', label: 'skgid (socket GID)', kind: 'number' },
],
ct: [
{ value: 'state', label: 'state (ct state)', kind: 'enum' },
{ value: 'direction', label: 'direction (original/reply)', kind: 'enum' },
{ value: 'status', label: 'status', kind: 'string' },
{ value: 'mark', label: 'mark (conntrack mark)', kind: 'string' },
{ value: 'expiration', label: 'expiration', kind: 'string' },
{ value: 'helper', label: 'helper', kind: 'string' },
],
/* 2) Layer 3: Network Headers */
ip: [
{ value: 'saddr', label: 'saddr (source IPv4)', kind: 'string' },
{ value: 'daddr', label: 'daddr (destination IPv4)', kind: 'string' },
{ value: 'protocol', label: 'protocol (L4) — alias to l4proto', kind: 'enum' },
{ value: 'dscp', label: 'dscp (DSCP)', kind: 'enum' },
{ value: 'ttl', label: 'ttl (time to live)', kind: 'number' },
{ value: 'frag-off', label: 'frag-off (fragment bits)', kind: 'string' },
],
ip6: [
{ value: 'saddr', label: 'saddr (source IPv6)', kind: 'string' },
{ value: 'daddr', label: 'daddr (destination IPv6)', kind: 'string' },
{ value: 'nexthdr', label: 'nexthdr (protocol / next header)', kind: 'enum' },
{ value: 'dscp', label: 'dscp (DSCP)', kind: 'enum' },
{ value: 'hoplimit', label: 'hoplimit (IPv6 hop limit)', kind: 'number' },
{ value: 'flowlabel', label: 'flowlabel', kind: 'number' },
],
/* 3) Layer 4: Transport Headers (appear only when token type tcp/udp/icmp is chosen) */
tcp: [
{ value: 'sport', label: 'sport (source port)', kind: 'number' },
{ value: 'dport', label: 'dport (destination port)', kind: 'number' },
{ value: 'flags', label: 'flags (tcp flags bitmask)', kind: 'enum' },
],
udp: [
{ value: 'sport', label: 'sport (source port)', kind: 'number' },
{ value: 'dport', label: 'dport (destination port)', kind: 'number' },
],
icmp: [
{ value: 'type', label: 'type (icmp type)', kind: 'enum' },
{ value: 'code', label: 'code (icmp code)', kind: 'enum' },
],
/* 4) Layer 2: Ethernet & VLAN */
ether: [
{ value: 'saddr', label: 'saddr (src MAC)', kind: 'string' },
{ value: 'daddr', label: 'daddr (dst MAC)', kind: 'string' },
{ value: 'type', label: 'type (ethertype)', kind: 'enum' },
],
vlan: [
{ value: 'id', label: 'id (VLAN ID)', kind: 'number' },
// CFI/DEI and PCP exist but user's list specified only VLAN ID; add PCP & DEI as optional helpers:
{ value: 'pcp', label: 'pcp (priority code point)', kind: 'number' },
{ value: 'cfi', label: 'cfi / DEI (drop eligible)', kind: 'number' },
],
/* leftovers and statements */
payload: [{ value: 'payload', label: 'payload(protocol.field)', kind: 'string' }],
raw: [{ value: 'raw', label: 'raw text', kind: 'string' }],
counter: [{ value: 'counter', label: 'counter', kind: 'string' }],
limit: [{ value: 'limit', label: 'limit (rate)', kind: 'string' }],
log: [{ value: 'log', label: 'log', kind: 'string' }],
nat: [
{ value: 'dnat', label: 'dnat to', kind: 'string' },
{ value: 'snat', label: 'snat to', kind: 'string' },
{ value: 'masquerade', label: 'masquerade', kind: 'string' },
],
queue: [{ value: 'queue', label: 'queue num', kind: 'string' }],
};
/* ----------------------
ENUM_VALUES (dropdown contents)
Keep these aligned with the user's canonical lists.
---------------------- */
const ENUM_VALUES: Record<string, string[]> = {
l4proto: ['tcp', 'udp', 'icmp', 'icmpv6', 'igmp', 'esp', 'ah'],
days: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'],
pkttype: ['unicast', 'multicast', 'broadcast', 'other'],
ct_state: ['new', 'established', 'related', 'invalid', 'untracked'],
ct_direction: ['original', 'reply'],
// ICMP message *types* (used for e.g. echo-request/echo-reply)
icmp_types: ['echo-request', 'echo-reply', 'destination-unreachable'],
// IPv4 reject *reasons* (ICMPv4 codes / textual reasons used with `reject with icmp type <reason>`)
icmpv4_reasons: [
'net-unreachable',
'host-unreachable',
'prot-unreachable',
'port-unreachable', // default
'net-prohibited',
'host-prohibited',
'admin-prohibited',
],
// IPv6 reject reasons (ICMPv6 textual reasons)
icmpv6_reasons: ['no-route', 'admin-prohibited', 'addr-unreachable', 'port-unreachable'],
dscp_values: [
'cs0',
'cs1',
'cs2',
'cs3',
'cs4',
'cs5',
'cs6',
'cs7',
'af11',
'af12',
'af13',
'af21',
'af22',
'af23',
'af31',
'af32',
'af33',
'af41',
'af42',
'af43',
'ef',
],
tcp_flags: ['fin', 'syn', 'rst', 'psh', 'ack', 'urg', 'ece', 'cwr'],
ethertypes: ['ip', 'ip6', 'arp', 'vlan', 'loopback'],
// top-level reject types used in select control. Note `icmpv6` spelled out.
reject_types: ['icmp', 'icmpv6', 'icmpx', 'tcp-reset'],
};
/* ----------------------
tokenToText: produce nft textual representation from token value
(keeps command generation consistent with the UI)
---------------------- */
function tokenToText(token: any): string {
if (!token || !token.type) return '';
const t = token.type as TokenType;
const d = token.data || {};
// META
if (t === 'meta') {
const f = d.field;
if (!f) return '';
// special formatting: meta l4proto <proto>
if (f === 'l4proto') {
return `meta l4proto ${String(d.value ?? '')}`.trim();
}
if (f === 'iifname' || f === 'oifname') {
return `meta ${f} ${String(d.value ?? '')}`.trim();
}
if (f === 'day') {
return `meta day ${String(d.value ?? '')}`.trim();
}
if (f === 'hour') {
return `meta hour ${String(d.value ?? '')}`.trim();
}
if (f === 'pkttype') {
return `meta pkttype ${String(d.value ?? '')}`.trim();
}
if (f === 'mark') {
return `meta mark ${String(d.value ?? '')}`.trim();
}
if (f === 'skuid' || f === 'skgid') {
return `meta ${f} ${String(d.value ?? '')}`.trim();
}
return `meta ${f} ${String(d.value ?? '')}`.trim();
}
// CT
if (t === 'ct') {
const f = d.field;
if (!f) return '';
return `ct ${f} ${String(d.value ?? '')}`.trim();
}
// IP/IPv6
if (t === 'ip' || t === 'ip6') {
const f = d.field;
if (!f) return '';
// saddr/daddr: allow CIDR/list/range raw text
return `${t} ${f} ${String(d.value ?? '')}`.trim();
}
// Transport protocols
if (t === 'tcp' || t === 'udp') {
const f = d.field;
if (!f) return t;
if (f === 'dport' || f === 'sport') {
return `${t} ${f} ${String(d.value ?? '')}`.trim();
}
if (f === 'flags') {
// flags could be array or comma-separated
const vals = Array.isArray(d.value)
? d.value
: String(d.value ?? '')
.split(',')
.map((s: string) => s.trim())
.filter(Boolean);
if (vals.length === 0) return t;
// render as: tcp flags { syn, ack }
return `${t} flags { ${vals.join(', ')} }`;
}
return `${t} ${f} ${String(d.value ?? '')}`.trim();
}
if (t === 'icmp') {
const f = d.field;
if (!f) return 'icmp';
return `icmp ${f} ${String(d.value ?? '')}`.trim();
}
// ETHER
if (t === 'ether') {
const f = d.field;
if (!f) return '';
return `ether ${f} ${String(d.value ?? '')}`.trim();
}
// VLAN
if (t === 'vlan') {
const f = d.field;
if (!f) return 'vlan';
return `vlan ${f} ${String(d.value ?? '')}`.trim();
}
// Statements
if (t === 'counter') {
if (d.packets || d.bytes) {
return `counter${d.packets ? ` packets ${d.packets}` : ''}${d.bytes ? ` bytes ${d.bytes}` : ''}`.trim();
}
return 'counter';
}
if (t === 'limit') {
const r = d.rate ?? d.value;
return r ? `limit rate ${r}` : 'limit';
}
if (t === 'log') {
const parts: string[] = [];
if (d.level) parts.push(`level ${d.level}`);
if (d.group) parts.push(`group ${d.group}`);
if (d.snaplen) parts.push(`snaplen ${d.snaplen}`);
if (d.prefix) parts.push(`prefix "${d.prefix}"`);
return parts.length ? `log ${parts.join(' ')}` : 'log';
}
if (t === 'nat') {
if (d.kind === 'dnat' && d.to) return `dnat to ${d.to}`;
if (d.kind === 'snat' && d.to) return `snat to ${d.to}`;
if (d.kind === 'masquerade') return d.to ? `masquerade to ${d.to}` : 'masquerade';
return 'nat';
}
if (t === 'queue') {
if (d.num) {
// allow optional extra token words following queue num, e.g. "queue num 1 bypass"
const extra = d.extra ? ` ${String(d.extra)}` : '';
return `queue num ${d.num}${extra}`.trim();
}
return 'queue';
}
if (t === 'raw') {
return String(d.text ?? '').trim();
}
if (t === 'payload') {
if (d.value) return `payload(${d.value})`;
return 'payload';
}
return '';
}
/* ----------------------
generateCommandFromValues (build textual + final nft add/insert)
---------------------- */
function generateCommandFromValues(values: FormValues) {
const tokens = Array.isArray(values.tokens) ? values.tokens : [];
const parts: string[] = [];
for (const t of tokens) {
const txt = tokenToText(t);
if (txt) parts.push(txt);
}
if (values.advanced && typeof values.advanced === 'string' && values.advanced.trim() !== '') {
parts.push(values.advanced.trim());
}
// Build queue text for NFQUEUE action or queue token
if (values.action === 'nfqueue' || values.action === 'queue') {
const qnum = values.nfqueue ?? values.queue ?? 1;
const bypass = values.nfqueue_bypass ? ' bypass' : '';
const queueText = `queue num ${Number(qnum)}${bypass}`;
const combined = parts.join(' ');
if (!/\bqueue(?:\s+num)?\b/i.test(combined)) {
parts.push(queueText);
} else {
for (let i = 0; i < parts.length; i++) {
if (/\bqueue(?:\s+num)?\b/i.test(parts[i])) {
parts[i] = queueText;
break;
}
}
}
}
// Build action/reject/nfqueue textual suffix
let actionText: string | null = null;
if (values.action === 'accept' || values.action === 'drop') {
actionText = values.action;
} else if (values.action === 'reject') {
// reject requires a rejectType (form enforces it)
const rtype = values.rejectType;
if (!rtype) {
actionText = 'reject'; // fallback, though form validation should prevent this
} else if (rtype === 'tcp-reset') {
// nft "reject with tcp reset"
actionText = 'reject with tcp reset';
} else if (rtype === 'icmp') {
// IPv4: "reject with icmp type <reason>"
const reason = values.rejectIcmpReason || '';
actionText = reason ? `reject with icmp type ${reason}` : 'reject';
} else if (rtype === 'icmpv6') {
// IPv6: "reject with icmpv6 type <reason>"
const reason = values.rejectIcmp6Reason || '';
actionText = reason ? `reject with icmpv6 type ${reason}` : 'reject';
} else if (rtype === 'icmpx') {
// inet family abstraction (icmpx)
const reason = values.rejectIcmpxReason || '';
actionText = reason ? `reject with icmpx type ${reason}` : 'reject';
} else {
actionText = 'reject';
}
} else if (values.action === 'nfqueue') {
// NFQUEUE action is represented by queue token above; no extra action verb
actionText = null;
}
const textual = (parts.join(' ') + (actionText ? ` ${actionText}` : '')).trim();
const tableSelect = values.tableSelect;
const chain = values.chainSelect || 'input';
const [family = 'inet', table = 'filter'] = tableSelect ? String(tableSelect).split(':') : ['inet', 'filter'];
const before = values.insertBeforeHandle;
const hasBefore = before != null && String(before) !== '';
const verb = hasBefore ? 'insert' : 'add';
const positionPart = hasBefore ? ` position ${before}` : '';
const cmd = `${verb} rule ${family} ${table} ${chain}${positionPart} ${textual}`.replace(/\s+/g, ' ').trim();
return { cmd, textual, position: hasBefore ? Number(before) : undefined };
}
/* -------------------------
Component
------------------------- */
interface RuleBuilderProps {
onCreated?: () => Promise<void> | void;
tables?: TableOut[] | null;
rulesLoading?: boolean;
rulesError?: string | null;
refreshRules?: () => Promise<void>;
onRulesChange?: (tables: TableOut[]) => void;
}
export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps) => {
const [form] = Form.useForm();
const [cmdPreview, setCmdPreview] = useState('');
const [refreshing, setRefreshing] = useState(false);
const [loading, setLoading] = useState(false);
const tableOptions = useMemo(
() => (props.tables || []).map((t) => ({ value: `${t.family}:${t.name}`, label: `${t.family}:${t.name}` })),
[props.tables],
);
const noTables = !(props.tables && props.tables.length > 0);
const [insertBeforeOptions, setInsertBeforeOptions] = useState<Array<{ value: any; label: string }>>([]);
const updateInsertOptions = useCallback(() => {
const ts = form.getFieldValue('tableSelect');
const cs = form.getFieldValue('chainSelect');
if (!ts || !cs) {
setInsertBeforeOptions([]);
return;
}
const [family, table] = String(ts).split(':');
const tbl = props.tables?.find((t) => t.family === family && t.name === table);
if (!tbl) {
setInsertBeforeOptions([]);
return;
}
const ch = (tbl.chains || []).find((c: ChainOut) => c.name === cs);
if (!ch || !Array.isArray(ch.rules)) {
setInsertBeforeOptions([]);
return;
}
const opts = ch.rules
.filter((r: RuleOut) => r && r.handle != null)
.map((r: RuleOut) => ({
value: r.handle,
label: `#${r.handle} — ${r.text ?? (typeof r.expr === 'string' ? r.expr : JSON.stringify(r.expr || {}).slice(0, 120))}`,
}));
setInsertBeforeOptions(opts);
}, [form, props.tables]);
const previewTimerRef = useRef<number | null>(null);
const schedulePreviewUpdate = useCallback(() => {
if (previewTimerRef.current) window.clearTimeout(previewTimerRef.current);
previewTimerRef.current = window.setTimeout(() => {
const v = form.getFieldsValue();
const { cmd } = generateCommandFromValues(v);
setCmdPreview(cmd);
previewTimerRef.current = null;
}, 40);
}, [form]);
useEffect(() => {
if (tableOptions.length > 0) {
const first = tableOptions[0].value;
form.setFieldsValue({
tableSelect: first,
action: 'drop',
nfqueue: 1,
nfqueue_bypass: false,
tokens: [],
});
const [f, n] = String(first).split(':');
const tbl = props.tables?.find((t) => t.family === f && t.name === n);
if (tbl && tbl.chains && tbl.chains.length > 0) {
form.setFieldsValue({ chainSelect: tbl.chains[0].name });
} else {
form.setFieldsValue({ chainSelect: undefined });
}
setTimeout(() => {
updateInsertOptions();
schedulePreviewUpdate();
}, 0);
} else {
form.setFieldsValue({
action: 'drop',
nfqueue: 1,
nfqueue_bypass: false,
tableSelect: undefined,
chainSelect: undefined,
tokens: [],
});
setInsertBeforeOptions([]);
setTimeout(() => schedulePreviewUpdate(), 0);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.tables, tableOptions.length]);
useEffect(() => {
const cur = form.getFieldsValue();
if (cur.nfqueue == null) form.setFieldsValue({ nfqueue: 1 });
schedulePreviewUpdate();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onValuesChange = useCallback(
(_: any, allValues: FormValues) => {
if (allValues.action === 'nfqueue' && (allValues.nfqueue == null || allValues.nfqueue === '')) {
form.setFieldsValue({ nfqueue: 1 });
allValues.nfqueue = 1;
}
const ts = allValues.tableSelect;
if (ts) {
const [f, n] = String(ts).split(':');
const tbl = props.tables?.find((t) => t.family === f && t.name === n);
if (tbl) {
if (tbl.chains && tbl.chains.length > 0) {
if (!allValues.chainSelect) form.setFieldsValue({ chainSelect: tbl.chains[0].name });
} else {
form.setFieldsValue({ chainSelect: undefined });
}
}
}
updateInsertOptions();
schedulePreviewUpdate();
},
[form, props.tables, updateInsertOptions, schedulePreviewUpdate],
);
const handleCreate = useCallback(
async (values: FormValues) => {
try {
const validated = await form.validateFields();
const { cmd } = generateCommandFromValues(validated);
Modal.confirm({
title: 'Run raw nft command',
content: (
<div>
<Text>
About to run nft command in <b>{String(validated.tableSelect ?? 'inet:filter')}</b> (see preview).
</Text>
<Divider />
<Text strong>Command:</Text>
<pre style={{ whiteSpace: 'pre-wrap', marginTop: 8 }}>{cmd}</pre>
</div>
),
okText: 'Run',
onOk: async () => {
setLoading(true);
try {
const out: ExecResult = await execFirewallRaw(cmd);
const stderrText = out?.stderr ? String(out.stderr).trim() : '';
if (stderrText) {
message.error(`Command produced stderr: ${stderrText}`);
} else if (out && (out.rc === 0 || out.rc === -1)) {
message.success('Command executed (rc ' + out.rc + ')');
if (props.refreshRules) await props.refreshRules();
if (props.onCreated) await props.onCreated();
} else {
const info = out
? `rc:${out.rc}` +
(out.stdout ? ` stdout:${out.stdout}` : '') +
(out.stderr ? ` stderr:${out.stderr}` : '')
: 'unknown result';
message.error('Command failed: ' + info);
}
} catch (err: any) {
message.error('Execution failed: ' + (err?.message ?? String(err)));
} finally {
setLoading(false);
}
},
});
} catch (err) {
schedulePreviewUpdate();
}
},
[form, props.refreshRules, props.onCreated, schedulePreviewUpdate],
);
const chainOptions = useMemo(() => {
const ts = form.getFieldValue('tableSelect');
if (!ts) return [];
const [f, n] = String(ts).split(':');
const tbl = props.tables?.find((t) => t.family === f && t.name === n);
if (!tbl) return [];
return tbl.chains.map((c) => (
<Option key={c.name} value={c.name}>
{c.name}
</Option>
));
}, [form, props.tables]);
const handleRefresh = useCallback(async () => {
setRefreshing(true);
try {
if (props.refreshRules) {
await props.refreshRules();
message.success('Rules refresh requested');
} else {
message.info('No refresh function provided by parent.');
}
} catch (err) {
console.warn('refresh failed', err);
message.error('Refresh failed');
} finally {
updateInsertOptions();
setRefreshing(false);
}
}, [props.refreshRules, updateInsertOptions]);
/* helper styles */
const tokenRowStyle: React.CSSProperties = {
display: 'flex',
gap: 8,
alignItems: 'center',
flexWrap: 'nowrap',
width: '100%',
};
const leftControlsStyle: React.CSSProperties = {
display: 'flex',
gap: 8,
alignItems: 'center',
minWidth: 72,
flex: '0 0 72px',
};
const typeSelectStyle: React.CSSProperties = { minWidth: 180, maxWidth: 260, flex: '0 0 220px' };
const fieldSelectStyle: React.CSSProperties = { minWidth: 160, maxWidth: 260, flex: '0 0 220px' };
const valueInputStyle: React.CSSProperties = { minWidth: 120, flex: '1 1 240px', maxWidth: '60%' };
const actionControlsStyle: React.CSSProperties = {
minWidth: 96,
flex: '0 0 96px',
display: 'flex',
justifyContent: 'flex-end',
};
return (
<Card>
<Row justify="space-between" align="middle">
<Col>
<Title level={4} style={{ margin: 0 }}>
Add Firewall Rule (raw)
</Title>
<Text type="secondary">
Build an nft rule using canonical match list (meta/ct/ip/ip6/tcp/udp/icmp/ether/vlan).
</Text>
</Col>
<Col>
<Space>
<Button icon={<ReloadOutlined />} onClick={handleRefresh} loading={refreshing}>
Refresh tables
</Button>
</Space>
</Col>
</Row>
<Divider />
<Form
layout="vertical"
form={form}
initialValues={{
action: 'drop',
nfqueue: 1,
nfqueue_bypass: false,
tableSelect: tableOptions.length > 0 ? tableOptions[0].value : undefined,
tokens: [],
}}
onFinish={handleCreate}
onValuesChange={onValuesChange}
>
{/* Table / chain */}
<Row gutter={16} align="middle">
<Col xs={24} sm={12}>
<Form.Item name="tableSelect" label="Table (family:name)" rules={[{ required: true }]}>
<Select placeholder={noTables ? 'No tables available' : 'Select table'} disabled={noTables}>
{tableOptions.map((t) => (
<Option key={t.value} value={t.value}>
{t.label}
</Option>
))}
</Select>
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item name="chainSelect" label="Chain (select)" rules={[{ required: true }]}>
<Select placeholder="Select chain" disabled={noTables || chainOptions.length === 0}>
{chainOptions}
</Select>
</Form.Item>
</Col>
</Row>
{/* Insert before */}
<Row gutter={16} align="middle">
<Col xs={24} sm={12}>
<Form.Item
name="insertBeforeHandle"
label="Insert before (optional)"
help="Pick a rule handle to insert before (leave empty to append)."
>
<Select allowClear placeholder="Append (no insert-before)">
{insertBeforeOptions.length === 0 ? (
<Option value="__none__" disabled>
(no rules available)
</Option>
) : (
insertBeforeOptions.map((o) => (
<Option key={String(o.value)} value={o.value}>
{o.label}
</Option>
))
)}
</Select>
</Form.Item>
</Col>
<Col xs={24} sm={12} style={{ display: 'flex', alignItems: 'flex-end' }}>
<Text type="secondary">
Use when you want the new rule before a known handle. Refresh tables after changes.
</Text>
</Col>
</Row>
<Divider />
{/* Token builder header + add control */}
<Row align="middle" justify="space-between" style={{ marginBottom: 8 }}>
<Col>
<Text strong>Token builder</Text>
<Text type="secondary" style={{ marginLeft: 8 }}>
Add tokens from the canonical lists (Meta/CT/IPv4/IPv6/Transport/Ether/VLAN). Each subfield has guidance.
</Text>
</Col>
<Col>
<Form.List name="tokens">
{(fields, { add }) => (
<div style={{ display: 'flex', gap: 8 }}>
<Select
placeholder="Add token..."
onSelect={(val: TokenType) => {
// sensible defaults per token type
const defaultData =
val === 'meta'
? { field: 'iifname', value: '' }
: val === 'ct'
? { field: 'state', value: 'new' }
: val === 'ip'
? { field: 'saddr', value: '' }
: val === 'ip6'
? { field: 'saddr', value: '' }
: val === 'tcp'
? { field: 'dport', value: '' }
: val === 'udp'
? { field: 'dport', value: '' }
: val === 'icmp'
? { field: 'type', value: '' }
: val === 'ether'
? { field: 'daddr', value: '' }
: val === 'vlan'
? { field: 'id', value: '' }
: val === 'payload'
? { field: 'payload', value: '' }
: val === 'raw'
? { text: '' }
: {};
add({ type: val, data: defaultData });
setTimeout(() => schedulePreviewUpdate(), 40);
}}
style={{ width: 320 }}
dropdownMatchSelectWidth={false}
disabled={noTables}
>
<Option value="meta">meta (metadata & scheduling)</Option>
<Option value="ct">ct (connection tracking)</Option>
<Option value="ip">ip (IPv4)</Option>
<Option value="ip6">ip6 (IPv6)</Option>
<Option value="tcp">tcp (TCP transport)</Option>
<Option value="udp">udp (UDP transport)</Option>
<Option value="icmp">icmp (ICMP)</Option>
<Option value="ether">ether (Ethernet / L2)</Option>
<Option value="vlan">vlan (802.1Q)</Option>
<Option value="payload">payload(protocol.field)</Option>
<Option value="counter">counter</Option>
<Option value="limit">limit (rate)</Option>
<Option value="log">log</Option>
<Option value="nat">nat (dnat/snat/masquerade)</Option>
<Option value="queue">queue (NFQUEUE)</Option>
<Option value="raw">raw (free text)</Option>
</Select>
</div>
)}
</Form.List>
</Col>
</Row>
<Divider />
{/* Tokens Form.List rendering */}
<Form.List name="tokens">
{(fields, { remove, move }) =>
fields.length === 0 ? (
<Text type="secondary">No tokens yet — add one using the selector above.</Text>
) : (
fields.map((field, idx) => (
<div key={field.key} style={{ marginBottom: 8 }}>
<div style={tokenRowStyle}>
<div style={leftControlsStyle}>
<Button size="small" onClick={() => move(idx, idx - 1)} disabled={idx === 0}>
▲
</Button>
<Button size="small" onClick={() => move(idx, idx + 1)} disabled={idx === fields.length - 1}>
▼
</Button>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flex: 1, minWidth: 0 }}>
{/* Token type select */}
<Form.Item name={[field.name, 'type']} style={{ marginBottom: 0 }}>
<Select style={typeSelectStyle}>
{Object.keys(TOKEN_FIELD_OPTIONS).map((k) => (
<Option key={k} value={k}>
{k}
</Option>
))}
</Select>
</Form.Item>
{/* Token field + value UI (depends on token type and subfield) */}
<Form.Item
shouldUpdate={(prev, cur) =>
prev.tokens?.[field.name]?.type !== cur.tokens?.[field.name]?.type ||
prev.tokens?.[field.name]?.data?.field !== cur.tokens?.[field.name]?.data?.field
}
style={{ marginBottom: 0, width: '100%' }}
>
{() => {
const tokenType = form.getFieldValue(['tokens', field.name, 'type']) as TokenType | undefined;
const options = tokenType ? TOKEN_FIELD_OPTIONS[tokenType] || [] : [];
// COUNTER special-case
if (tokenType === 'counter') {
return (
<div style={{ display: 'flex', gap: 8, alignItems: 'center', width: '100%' }}>
<Text strong style={{ flex: '0 0 auto' }}>
counter
</Text>
<Form.Item name={[field.name, 'data', 'packets']} style={{ margin: 0 }}>
<InputNumber placeholder="packets" min={0} style={{ minWidth: 120 }} />
</Form.Item>
<Form.Item name={[field.name, 'data', 'bytes']} style={{ margin: 0 }}>
<InputNumber placeholder="bytes" min={0} style={{ minWidth: 120 }} />
</Form.Item>
<Text type="secondary" style={{ marginLeft: 8 }}>
Specify counters explicitly — nftables does not add counters by default.
</Text>
</div>
);
}
// LIMIT special-case
if (tokenType === 'limit') {
return (
<div style={{ display: 'flex', gap: 8, alignItems: 'center', width: '100%' }}>
<Text strong style={{ flex: '0 0 auto' }}>
limit
</Text>
<Form.Item name={[field.name, 'data', 'rate']} style={{ margin: 0, flex: '1 1 auto' }}>
<Input placeholder='e.g. "400/minute" or "over 40/day"' style={{ width: '100%' }} />
</Form.Item>
<Text type="secondary">
Rate expressions: <code>5/second</code>, <code>400/minute</code>,{' '}
<code>over 40/day</code>.
</Text>
</div>
);
}
// LOG special-case
if (tokenType === 'log') {
return (
<div style={{ display: 'flex', gap: 8, alignItems: 'center', width: '100%' }}>
<Text strong style={{ flex: '0 0 auto' }}>
log
</Text>
<Form.Item name={[field.name, 'data', 'level']} style={{ margin: 0 }}>
<Select style={{ minWidth: 120 }}>
<Option value="emerg">emerg</Option>
<Option value="alert">alert</Option>
<Option value="crit">crit</Option>
<Option value="err">err</Option>
<Option value="warn">warn</Option>
<Option value="notice">notice</Option>
<Option value="info">info</Option>
<Option value="debug">debug</Option>
</Select>
</Form.Item>
<Form.Item name={[field.name, 'data', 'group']} style={{ margin: 0 }}>
<InputNumber placeholder="group" style={{ minWidth: 92 }} />
</Form.Item>
<Form.Item name={[field.name, 'data', 'snaplen']} style={{ margin: 0 }}>
<InputNumber placeholder="snaplen" style={{ minWidth: 92 }} />
</Form.Item>
<Form.Item
name={[field.name, 'data', 'prefix']}
style={{ margin: 0, flex: '1 1 auto' }}
>
<Input placeholder='prefix (e.g. "DROP-SSH")' />
</Form.Item>
<Text type="secondary">
Log options — level, group, snaplen, prefix. Prefix is useful to filter logs.
</Text>
</div>
);
}
// QUEUE special-case inside token list (separate from NFQUEUE action)
if (tokenType === 'queue') {
return (
<div style={{ display: 'flex', gap: 8, alignItems: 'center', width: '100%' }}>
<Text strong style={{ flex: '0 0 auto' }}>
queue
</Text>
<Form.Item name={[field.name, 'data', 'num']} style={{ margin: 0 }}>
<InputNumber placeholder="queue number" min={0} style={{ minWidth: 96 }} />
</Form.Item>
<Form.Item name={[field.name, 'data', 'extra']} style={{ margin: 0, flex: '1 1 auto' }}>
<Input placeholder='extra (e.g. "bypass" or "fanout")' />
</Form.Item>
<Text type="secondary">
NFQUEUE/queue options — set queue number and optional extra parameters.
</Text>
</div>
);
}
// NAT special-case
if (tokenType === 'nat') {
return (
<div style={{ display: 'flex', gap: 8, alignItems: 'center', width: '100%' }}>
<Form.Item name={[field.name, 'data', 'kind']} style={{ margin: 0 }}>
<Select style={{ minWidth: 120 }}>
<Option value="dnat">dnat</Option>
<Option value="snat">snat</Option>
<Option value="masquerade">masquerade</Option>
</Select>
</Form.Item>
<Form.Item name={[field.name, 'data', 'to']} style={{ margin: 0, flex: '1 1 auto' }}>
<Input placeholder="to (ip[:port] or :port-range), e.g. 10.0.0.5:8080" />
</Form.Item>
<Text type="secondary">
NAT target examples: <code>10.0.0.5:8080</code>, <code>:80-90</code>, or{' '}
<code>10.0.0.5</code>.
</Text>
</div>
);
}
// Generic tokens with subfield dropdown
if (options.length > 0) {
return (
<div
style={{ display: 'flex', gap: 8, alignItems: 'center', width: '100%', minWidth: 0 }}
>
<Form.Item
name={[field.name, 'data', 'field']}
style={{ margin: 0 }}
initialValue={options[0].value}
>
<Select style={fieldSelectStyle}>
{options.map((o) => (
<Option key={o.value} value={o.value}>
{o.label}
</Option>
))}
</Select>
</Form.Item>
<Form.Item
shouldUpdate={(prev, cur) =>
prev.tokens?.[field.name]?.data?.field !== cur.tokens?.[field.name]?.data?.field ||
prev.tokens?.[field.name]?.type !== cur.tokens?.[field.name]?.type
}
style={{ margin: 0, flex: '1 1 auto', minWidth: 0 }}
>
{() => {
const selField = form.getFieldValue(['tokens', field.name, 'data', 'field']);
const tType = form.getFieldValue(['tokens', field.name, 'type']);
const opts = tType ? TOKEN_FIELD_OPTIONS[tType as TokenType] || [] : [];
const meta = opts.find((o) => o.value === selField);
const kind = meta?.kind ?? 'string';
/* --- Field-specific UIs & helpers (placeholders + explanatory text) --- */
// STRING typed helpers for interface names
if (tType === 'meta' && (selField === 'iifname' || selField === 'oifname')) {
return (
<div>
<Form.Item name={[field.name, 'data', 'value']} style={{ margin: 0 }}>
<Input placeholder='Interface name, e.g. "eth0" or "lo"' />
</Form.Item>
<Text type="secondary">
Enter a system interface name. Use the interface list on your host (ip
link).
</Text>
</div>
);
}
// L4PROTO dropdown for meta.l4proto
if (tType === 'meta' && selField === 'l4proto') {
return (
<div>
<Form.Item name={[field.name, 'data', 'value']} style={{ margin: 0 }}>
<Select placeholder="Select L4 protocol" style={{ minWidth: 180 }}>
{(ENUM_VALUES.l4proto || []).map((p) => (
<Option key={p} value={p}>
{p.toUpperCase()}
</Option>
))}
</Select>
</Form.Item>
<Text type="secondary">Choose a transport protocol (TCP/UDP/ICMP/...)</Text>
</div>
);
}
// Day of week (meta.day)
if (tType === 'meta' && selField === 'day') {
return (
<div>
<Form.Item name={[field.name, 'data', 'value']} style={{ margin: 0 }}>
<Select placeholder="Select day" style={{ minWidth: 140 }}>
{(ENUM_VALUES.days || []).map((d) => (
<Option key={d} value={d}>
{d}
</Option>
))}
</Select>
</Form.Item>
<Text type="secondary">
Pick a weekday. Use with meta.hour to create schedules.
</Text>
</div>
);
}
// Hour range (meta.hour) — free text but show placeholder/range hint
if (tType === 'meta' && selField === 'hour') {
return (
<div>
<Form.Item name={[field.name, 'data', 'value']} style={{ margin: 0 }}>
<Input placeholder='Single "HH:MM" or range "08:00-17:00"' />
</Form.Item>
<Text type="secondary">
Enter a time or range in 24-hour format. Examples: <code>09:00</code> or{' '}
<code>08:00-17:00</code>.
</Text>
</div>
);
}
// Packet type (meta.pkttype)
if (tType === 'meta' && selField === 'pkttype') {
return (
<div>
<Form.Item name={[field.name, 'data', 'value']} style={{ margin: 0 }}>
<Select style={{ minWidth: 140 }}>
{(ENUM_VALUES.pkttype || []).map((v) => (
<Option key={v} value={v}>
{v}
</Option>
))}
</Select>
</Form.Item>
<Text type="secondary">
Packet type: unicast, multicast, broadcast, other.
</Text>
</div>
);
}
// Packet/conn mark
if ((tType === 'meta' || tType === 'ct') && selField === 'mark') {
return (
<div>
<Form.Item name={[field.name, 'data', 'value']} style={{ margin: 0 }}>
<Input placeholder="Packet/conn mark (hex or int), e.g. 0x100 or 256" />
</Form.Item>
<Text type="secondary">
A 32-bit mark used for routing/classing. Hex or decimal allowed.
</Text>
</div>
);
}
// skuid / skgid
if (tType === 'meta' && (selField === 'skuid' || selField === 'skgid')) {
return (
<div>
<Form.Item name={[field.name, 'data', 'value']} style={{ margin: 0 }}>
<InputNumber
placeholder="UID or GID (numeric)"
min={0}
style={{ width: '100%' }}
/>
</Form.Item>
<Text type="secondary">
UID/GID from the originating socket. Use numeric values or system names (if
supported by backend).
</Text>
</div>
);
}
// CT state
if (tType === 'ct' && selField === 'state') {
return (
<div>
<Form.Item name={[field.name, 'data', 'value']} style={{ margin: 0 }}>
<Select
style={{ minWidth: 180 }}
mode="multiple"
placeholder="Select ct state(s)"
>
{(ENUM_VALUES.ct_state || []).map((s) => (
<Option key={s} value={s}>
{s}
</Option>
))}
</Select>
</Form.Item>
<Text type="secondary">
Connection states. Pick one or multiple: new, established, related, invalid,
untracked.
</Text>
</div>
);
}
// CT direction
if (tType === 'ct' && selField === 'direction') {
return (
<div>
<Form.Item name={[field.name, 'data', 'value']} style={{ margin: 0 }}>
<Select style={{ minWidth: 140 }}>
{(ENUM_VALUES.ct_direction || []).map((d) => (
<Option key={d} value={d}>
{d}
</Option>
))}
</Select>
</Form.Item>
<Text type="secondary">
Direction relative to connection: original or reply.
</Text>
</div>
);
}
/* --- IP / IP6 address helpers --- */
if (
(tType === 'ip' || tType === 'ip6') &&
(selField === 'saddr' || selField === 'daddr')
) {
if (tType === 'ip') {
return (
<div>
<Form.Item name={[field.name, 'data', 'value']} style={{ margin: 0 }}>
<Input placeholder="IPv4 (single or CIDR) e.g. 192.168.1.100 or 192.168.1.0/24" />
</Form.Item>
<Text type="secondary">
IPv4 address, CIDR, list or range. Example: <code>192.168.1.0/24</code>.
</Text>
</div>
);
}
return (
<div>
<Form.Item name={[field.name, 'data', 'value']} style={{ margin: 0 }}>
<Input placeholder="IPv6 e.g. abcd::100 or 2001:db8::/32" />
</Form.Item>
<Text type="secondary">
IPv6 address or CIDR. Example: <code>2001:db8::/32</code>.
</Text>
</div>
);
}
// protocol / nexthdr / ip.protocol (L4 protocol): show l4proto list
if (
(tType === 'ip' && selField === 'protocol') ||
(tType === 'ip6' && selField === 'nexthdr')
) {
return (
<div>
<Form.Item name={[field.name, 'data', 'value']} style={{ margin: 0 }}>
<Select placeholder="Select protocol" style={{ minWidth: 160 }}>
{(ENUM_VALUES.l4proto || []).map((p) => (
<Option key={p} value={p}>
{p.toUpperCase()}
</Option>
))}
</Select>
</Form.Item>
<Text type="secondary">
Transport / next header protocol. Example: <code>tcp</code>,{' '}
<code>udp</code>, <code>esp</code>.
</Text>
</div>
);
}
// DSCP
if ((tType === 'ip' || tType === 'ip6') && selField === 'dscp') {
return (
<div>
<Form.Item name={[field.name, 'data', 'value']} style={{ margin: 0 }}>
<Select style={{ minWidth: 160 }} placeholder="Select DSCP">
{(ENUM_VALUES.dscp_values || []).map((d) => (
<Option key={d} value={d}>
{d}
</Option>
))}
</Select>
</Form.Item>
<Text type="secondary">
DSCP value (cs0-cs7, af*, ef). Used for QoS marking.
</Text>
</div>
);
}
// TTL / hoplimit numeric
if (
(tType === 'ip' && selField === 'ttl') ||
(tType === 'ip6' && selField === 'hoplimit')
) {
return (
<div>
<Form.Item name={[field.name, 'data', 'value']} style={{ margin: 0 }}>
<InputNumber
min={0}
max={255}
style={valueInputStyle}
placeholder="0-255"
/>
</Form.Item>
<Text type="secondary">Numeric TTL / Hop Limit (0-255).</Text>
</div>
);
}
// IP fragment bits (frag-off) — single string placeholder
if (tType === 'ip' && selField === 'frag-off') {
return (
<div>
<Form.Item name={[field.name, 'data', 'value']} style={{ margin: 0 }}>
<Input placeholder='fragment flags (e.g., "reserved", "dont-fragment", "more-fragments")' />
</Form.Item>
<Text type="secondary">
Fragmentation flags / mask. Use known keywords or bitmasks.
</Text>
</div>
);
}
/* --- Transport: TCP/UDP/ICMP --- */
// Ports: allow numeric or service name
if (
(tType === 'tcp' || tType === 'udp') &&
(selField === 'dport' || selField === 'sport')
) {
return (
<div>
<Form.Item name={[field.name, 'data', 'value']} style={{ margin: 0 }}>
<Input placeholder="Port number or name, e.g. 22 or ssh or 53" />
</Form.Item>
<Text type="secondary">
Port number (1-65535) or well-known name (ssh/http/dns).
</Text>
</div>
);
}
// TCP flags multi-select
if (tType === 'tcp' && selField === 'flags') {
return (
<div>
<Form.Item name={[field.name, 'data', 'value']} style={{ margin: 0 }}>
<Select
mode="multiple"
placeholder="Select flags"
style={{ minWidth: 220 }}
>
{(ENUM_VALUES.tcp_flags || []).map((f) => (
<Option key={f} value={f}>
{f.toUpperCase()}
</Option>
))}
</Select>
</Form.Item>
<Text type="secondary">
Pick one or more TCP flags (syn, ack, fin, rst, psh, urg, ece, cwr).
</Text>
</div>
);
}
// ICMP type/code dropdowns
if (tType === 'icmp' && selField === 'type') {
return (
<div>
<Form.Item name={[field.name, 'data', 'value']} style={{ margin: 0 }}>
<Select placeholder="Select ICMP type" style={{ minWidth: 200 }}>
{(ENUM_VALUES.icmp_types || []).map((t) => (
<Option key={t} value={t}>
{t}
</Option>
))}
</Select>
</Form.Item>
<Text type="secondary">
ICMP types: e.g. <code>echo-request</code>, <code>echo-reply</code>.
</Text>
</div>
);
}
if (tType === 'icmp' && selField === 'code') {
return (
<div>
<Form.Item name={[field.name, 'data', 'value']} style={{ margin: 0 }}>
<Select placeholder="Select ICMP code" style={{ minWidth: 200 }}>
{(ENUM_VALUES.icmpv4_reasons || []).map((c) => (
<Option key={c} value={c}>
{c}
</Option>
))}
</Select>
</Form.Item>
<Text type="secondary">
ICMP codes such as <code>net-unreachable</code> or{' '}
<code>host-unreachable</code>.
</Text>
</div>
);
}
/* --- Layer 2: Ethernet / VLAN --- */
if (tType === 'ether') {
if (selField === 'saddr' || selField === 'daddr') {
return (
<div>
<Form.Item name={[field.name, 'data', 'value']} style={{ margin: 0 }}>
<Input placeholder="MAC address, e.g. 00:11:22:33:44:55 or ff:ff:ff:ff:ff:ff" />
</Form.Item>
<Text type="secondary">
MAC address (colon-separated). Accepts single addresses, sets or ranges
where supported.
</Text>
</div>
);
}
if (selField === 'type') {
return (
<div>
<Form.Item name={[field.name, 'data', 'value']} style={{ margin: 0 }}>
<Select style={{ minWidth: 200 }} placeholder="Select EtherType">
{(ENUM_VALUES.ethertypes || []).map((e) => (
<Option key={e} value={e}>
{e}
</Option>
))}
</Select>
</Form.Item>
<Text type="secondary">EtherType: choose IPv4, IPv6, ARP, VLAN, etc.</Text>
</div>
);
}
}
// VLAN ID / PCP / CFI
if (tType === 'vlan') {
if (selField === 'id') {
return (
<div>
<Form.Item name={[field.name, 'data', 'value']} style={{ margin: 0 }}>
<InputNumber
placeholder="VLAN ID (1-4094)"
min={1}
max={4094}
style={{ width: '100%' }}
/>
</Form.Item>
<Text type="secondary">
VLAN ID (1-4094). Example: <code>100</code>.
</Text>
</div>
);
}
if (selField === 'pcp') {
return (
<div>
<Form.Item name={[field.name, 'data', 'value']} style={{ margin: 0 }}>
<InputNumber
placeholder="PCP (0-7)"
min={0}
max={7}
style={{ width: '100%' }}
/>
</Form.Item>
<Text type="secondary">Priority Code Point (0-7).</Text>
</div>
);
}
if (selField === 'cfi') {
return (
<div>
<Form.Item name={[field.name, 'data', 'value']} style={{ margin: 0 }}>
<InputNumber
placeholder="CFI / DEI (0 or 1)"
min={0}
max={1}
style={{ width: '100%' }}
/>
</Form.Item>
<Text type="secondary">Drop Eligible Indicator (0 or 1).</Text>
</div>
);
}
}
/* --- Payload / default free text input --- */
if (tType === 'payload' || (kind === 'string' && !selField)) {
return (
<div>
<Form.Item name={[field.name, 'data', 'value']} style={{ margin: 0 }}>
<Input placeholder="protocol.field (e.g. ip.protocol or tcp.dport)" />
</Form.Item>
<Text type="secondary">
Raw payload selector using <code>protocol.field</code> syntax, e.g.{' '}
<code>tcp.dport</code>.
</Text>
</div>
);
}
// Default fallback free-text with helpful examples
return (
<div>
<Form.Item name={[field.name, 'data', 'value']} style={{ margin: 0 }}>
<Input
placeholder="value (or list/range/map). Examples: 53, 10.0.0.0/8, {1,2,3}, 1-1024"
style={{ width: '100%' }}
/>
</Form.Item>
<Text type="secondary">
Enter the matching value. Examples: port numbers (<code>53</code>), CIDR (
<code>10.0.0.0/8</code>), sets (<code>{'{1,2,3}'}</code>), or ranges (
<code>1-1024</code>).
</Text>
</div>
);
}}
</Form.Item>
</div>
);
}
return <Text type="secondary">Unknown token type or no fields configured</Text>;
}}
</Form.Item>
</div>
<div style={actionControlsStyle}>
<Button danger size="small" onClick={() => remove(field.name)}>
Delete
</Button>
</div>
</div>
</div>
))
)
}
</Form.List>
<Divider />
{/* Action + NFQUEUE + Reject options: render action radios, then render
reject subform and nfqueue subform directly under it (same column) */}
<Row gutter={16} align="top">
<Col xs={24} sm={12}>
<Form.Item name="action" label="Action / verdict" rules={[{ required: true }]}>
<Radio.Group>
<Radio value="drop">Drop</Radio>
<Radio value="accept">Accept</Radio>
<Radio value="reject">Reject</Radio>
<Radio value="nfqueue">NFQUEUE</Radio>
</Radio.Group>
</Form.Item>
{/* Reject options (render under radios, same column) */}
<Form.Item shouldUpdate={(prev, cur) => prev.action !== cur.action} noStyle>
{() =>
form.getFieldValue('action') === 'reject' ? (
<>
<Form.Item
name="rejectType"
label="Reject mode"
rules={[{ required: true, message: 'Select a reject mode' }]}
>
<Select placeholder="Select reject mode">
{ENUM_VALUES.reject_types.map((r) => (
<Option key={r} value={r}>
{r}
</Option>
))}
</Select>
</Form.Item>
{/* IPv4 reject reasons */}
<Form.Item shouldUpdate={(prev, cur) => prev.rejectType !== cur.rejectType} noStyle>
{() =>
form.getFieldValue('rejectType') === 'icmp' ? (
<Row gutter={8} align="middle">
<Col span={12}>
<Form.Item
name="rejectIcmpReason"
label="ICMP reason (IPv4)"
rules={[{ required: true, message: 'ICMP reason is required for icmp reject' }]}
>
<Select placeholder="Select ICMP reason">
{(ENUM_VALUES.icmpv4_reasons || []).map((r) => (
<Option key={r} value={r}>
{r}
</Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
) : null
}
</Form.Item>
{/* IPv6 reject reasons */}
<Form.Item shouldUpdate={(prev, cur) => prev.rejectType !== cur.rejectType} noStyle>
{() =>
form.getFieldValue('rejectType') === 'icmpv6' ? (
<Row gutter={8} align="middle">
<Col span={12}>
<Form.Item
name="rejectIcmp6Reason"
label="ICMPv6 reason"
rules={[{ required: true, message: 'ICMPv6 reason is required for icmpv6 reject' }]}
>
<Select placeholder="Select ICMPv6 reason">
{(ENUM_VALUES.icmpv6_reasons || []).map((r) => (
<Option key={r} value={r}>
{r}
</Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
) : null
}
</Form.Item>
{/* icmpx (inet) */}
<Form.Item shouldUpdate={(prev, cur) => prev.rejectType !== cur.rejectType} noStyle>
{() =>
form.getFieldValue('rejectType') === 'icmpx' ? (
<Row gutter={8} align="middle">
<Col span={12}>
<Form.Item
name="rejectIcmpxReason"
label="ICMPX reason (inet)"
rules={[{ required: true, message: 'ICMPX reason is required for icmpx reject' }]}
>
<Select placeholder="Select ICMPX reason">
{[...(ENUM_VALUES.icmpv6_reasons || []), ...(ENUM_VALUES.icmpv4_reasons || [])].map(
(r) => (
<Option key={r} value={r}>
{r}
</Option>
),
)}
</Select>
</Form.Item>
</Col>
</Row>
) : null
}
</Form.Item>
</>
) : null
}
</Form.Item>
{/* NFQUEUE options (now rendered under radios in same column) */}
<Form.Item shouldUpdate={(prev, cur) => prev.action !== cur.action} noStyle>
{() =>
form.getFieldValue('action') === 'nfqueue' ? (
<>
<Form.Item
name="nfqueue"
label="Queue number"
rules={[
{ required: true, message: 'Queue number is required when NFQUEUE is selected' },
{ type: 'number', min: 1, message: 'Queue number must be at least 1' },
]}
initialValue={1}
>
<InputNumber style={{ width: '100%' }} min={1} max={65535} />
</Form.Item>
<Form.Item name="nfqueue_bypass" valuePropName="checked">
<Checkbox
onChange={() => {
// update preview immediately
schedulePreviewUpdate();
}}
>
Bypass kernel queuing (append <code>bypass</code> to queue)
</Checkbox>
</Form.Item>
<Text type="secondary" style={{ display: 'block', marginTop: 6 }}>
When checked, the generated queue statement will include <code>bypass</code> (e.g.
<code> queue num 1 bypass</code>).
</Text>
</>
) : null
}
</Form.Item>
</Col>
{/* right column is free for notes / quick helpers */}
<Col xs={24} sm={12}>
<Text type="secondary">
Use NFQUEUE to hand packets to userspace. Full reject support requires kernel &gt;= 3.18 — when using
reject you can choose ICMP/ICMPv6/tcp-reset or the inet-level icmpx abstraction. Not supported on bridges.
</Text>
</Col>
</Row>
{/* advanced text */}
<Row>
<Col span={24}>
<Form.Item name="advanced" label="Advanced (optional)">
<TextArea
rows={2}
placeholder='Append advanced text (e.g. "limit rate 5/second" or "comment \"accept DNS\""). Use this for expressions not covered by the UI.'
/>
</Form.Item>
</Col>
</Row>
<Divider />
{/* preview + run */}
<Form.Item>
<div style={{ display: 'flex', gap: 12, alignItems: 'center', width: '100%' }}>
<div style={{ flex: 1 }}>
<Text strong>nft command preview:</Text>
<div style={{ marginTop: 6 }}>
<Text code copyable={{ text: cmdPreview || '' }}>
{cmdPreview || '(no textual preview available)'}
</Text>
</div>
</div>
<div style={{ display: 'flex', gap: 8, minWidth: 320, justifyContent: 'flex-end' }}>
<Tooltip title="Copy command to clipboard">
<Button
icon={<CopyOutlined />}
size="small"
onClick={() => {
if (!cmdPreview) return;
navigator.clipboard?.writeText(cmdPreview);
message.success('Command copied');
}}
/>
</Tooltip>
<Button type="primary" htmlType="submit" loading={loading} icon={<PlusOutlined />} disabled={noTables}>
Run command
</Button>
</div>
</div>
</Form.Item>
</Form>
</Card>
);
};
export default RuleBuilder;