All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 10s
1739 lines
79 KiB
TypeScript
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 >= 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;
|