file structure and comments unified
Some checks failed
Build and Deploy MITM Webserver / build (push) Failing after 3s

This commit is contained in:
2026-03-06 19:03:26 +01:00
parent 7b9a7d3a4b
commit b60a1d3118
20 changed files with 697 additions and 1823 deletions

View File

@@ -1,4 +1,3 @@
// src/components/AddChainModal.tsx
import { CopyOutlined } from '@ant-design/icons';
import {
Alert,
@@ -67,12 +66,10 @@ export default function FirewallAddChainModal({
const isPrefilled = Boolean(table?.family && table?.name);
// init form values when modal opens or table prop changes
useEffect(() => {
form.setFieldsValue({
family: table?.family ?? 'bridge',
tableName: table?.name ?? 'filter',
// Do not override chainName if the user has typed it previously
type: 'filter',
hook: 'forward',
priority: 0,
@@ -111,7 +108,6 @@ export default function FirewallAddChainModal({
}
}
// Build add chain command — respects provided values or live form values
function buildCommands(values?: any): string[] {
const vals = values ?? form.getFieldsValue();
@@ -128,7 +124,6 @@ export default function FirewallAddChainModal({
: 0;
const policy = vals.policy ?? '';
// chain name: prefer explicit chainName else fallback to hook (less ideal) else 'mychain'
const chain = hook;
const policyPart = policy ? ` policy ${policy} ;` : '';
@@ -136,7 +131,6 @@ export default function FirewallAddChainModal({
return [cmd];
}
// Execute commands sequentially
async function executeCommands(cmds: string[]) {
setRunning(true);
setResults([]);
@@ -156,24 +150,19 @@ export default function FirewallAddChainModal({
setResults(acc);
setRunning(false);
// refresh ruleset shown in modal
try {
await refreshRuleset();
} catch {
/* ignored, refreshRuleset already notified on failure */
}
const hadError = acc.some((r) => r.err);
if (!hadError) {
// success notification inside the modal (not global refresh notification)
notification.success({
message: 'Chain created',
description: 'Chain created and ruleset refreshed locally in the modal.',
duration: 4,
});
// Inform parent that a resource was created.
// Parent can decide whether to refresh and whether to show a notification.
onClose?.(true);
if (onSuccess) onSuccess();
} else {
@@ -219,13 +208,11 @@ export default function FirewallAddChainModal({
type="primary"
onClick={async () => {
try {
// validate name fields: chainName required; if not prefilled, tableName & family required
const requiredFields = ['chainName'];
if (!isPrefilled) requiredFields.push('tableName', 'family');
await form.validateFields(requiredFields as any);
setStep(1);
} catch {
// AntD will show validation messages; no extra handling required
}
}}
>
@@ -264,7 +251,7 @@ export default function FirewallAddChainModal({
footer={renderFooter()}
destroyOnClose
>
{/* Step 0: Form */}
{step === 0 && (
<div>
{rulesetEmpty && (
@@ -284,7 +271,7 @@ export default function FirewallAddChainModal({
}}
>
<Row gutter={12}>
{/* family & table: show inputs only when not provided via props */}
<Col span={8}>
{isPrefilled ? (
<Form.Item label="Family">
@@ -363,7 +350,7 @@ export default function FirewallAddChainModal({
</div>
)}
{/* Step 1: Preview & Results */}
{step === 1 && (
<>
<Card title="Command preview" style={{ marginBottom: 12 }}>

View File

@@ -1,4 +1,3 @@
// src/components/FirewallManager.tsx
import { Button, Card, Col, Form, Input, Modal, Row, Select, Space, Spin, Typography, notification } from 'antd';
import { ReactElement, useEffect, useState } from 'react';
import { execFirewallRaw, fetchRuleset } from '../api/apiClient';
@@ -42,7 +41,6 @@ export default function FirewallAddTableModal({ open, onClose, startOnPreview =
const [results, setResults] = useState<CmdResult[]>([]);
const [selectedFamily, setSelectedFamily] = useState<string>('bridge');
// initialize and refresh when modal opens
useEffect(() => {
form.setFieldsValue({
family: 'bridge',
@@ -70,7 +68,6 @@ export default function FirewallAddTableModal({ open, onClose, startOnPreview =
setRulesetEmpty(false);
}
} catch (err: any) {
// use notification instead of message
notification.warning({
message: 'Could not load ruleset',
description: err?.message ?? String(err),
@@ -82,14 +79,12 @@ export default function FirewallAddTableModal({ open, onClose, startOnPreview =
}
}
// Build commands: only create table
function buildCommands(values: any): string[] {
const family = (values.family ?? 'bridge').trim();
const table = (values.tableName ?? 'filter').trim();
return [`add table ${family} ${table}`];
}
// Execute commands sequentially
async function executeCommands(cmds: string[]) {
setRunning(true);
setResults([]);
@@ -109,16 +104,13 @@ export default function FirewallAddTableModal({ open, onClose, startOnPreview =
setResults(acc);
setRunning(false);
// refresh ruleset after running
try {
await refreshRuleset();
} catch {
// ignore — refreshRuleset handles notifications on error
}
const hadError = acc.some((r) => r.err);
if (!hadError) {
// success -> notify briefly and inform parent that a resource was created
notification.success({
message: 'Table created',
description: 'Table was created and ruleset has been refreshed locally in the modal.',
@@ -126,7 +118,6 @@ export default function FirewallAddTableModal({ open, onClose, startOnPreview =
});
onClose?.(true); // signal parent to refresh and close modal
} else {
// error -> open results view and show notification
notification.error({
message: 'Some commands returned errors',
description: 'See execution results below for details.',
@@ -153,7 +144,6 @@ export default function FirewallAddTableModal({ open, onClose, startOnPreview =
<Button
type="primary"
onClick={() => {
// validate form before preview
form
.validateFields()
.then(() => setStep(1))
@@ -181,7 +171,6 @@ export default function FirewallAddTableModal({ open, onClose, startOnPreview =
);
}
// show spinner while checking ruleset
if (localLoadingRuleset) {
return (
<Modal title="Create Table" open={open} onCancel={() => onClose?.(false)} footer={null} width={700}>
@@ -201,7 +190,7 @@ export default function FirewallAddTableModal({ open, onClose, startOnPreview =
footer={renderFooter()}
destroyOnClose
>
{/* Step 0: minimal form */}
{step === 0 && (
<div>
<Form
@@ -238,7 +227,7 @@ export default function FirewallAddTableModal({ open, onClose, startOnPreview =
</div>
)}
{/* Step 1: preview + results */}
{step === 1 && (
<>
<Card title="Command preview" style={{ marginBottom: 12 }}>

View File

@@ -1,14 +1,3 @@
// 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 { PlusOutlined } from '@ant-design/icons';
import {
@@ -38,9 +27,6 @@ const { Title, Text } = Typography;
type FormValues = Record<string, any>;
/* ----------------------
Token types (canonical per user)
---------------------- */
type TokenType =
| 'meta'
| 'ct'
@@ -59,642 +45,10 @@ type TokenType =
| '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) {
notification.error({
message: 'Command produced Error',
description: stderrText,
});
} else if (out && (out.rc === 0 || out.rc === -1)) {
notification.success({
message: 'Command executed successfully',
});
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';
notification.error({
message: 'Command failed',
description: info,
});
}
} catch (err: any) {
notification.error({
message: 'Execution failed',
description: 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 title="Firewall Rule Builder" extra>
<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 }]}>
@@ -717,7 +71,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
</Col>
</Row>
{/* Insert before */}
<Row gutter={16} align="middle">
<Col xs={24} sm={12}>
<Form.Item
@@ -750,7 +104,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
<Divider />
{/* Token builder header + add control */}
<Row align="middle" justify="space-between" style={{ marginBottom: 8 }}>
<Col>
<Text strong>Token builder</Text>
@@ -766,7 +120,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
<Select
placeholder="Add token..."
onSelect={(val: TokenType) => {
// sensible defaults per token type
const defaultData =
val === 'meta'
? { field: 'iifname', value: '' }
@@ -823,7 +176,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
<Divider />
{/* Tokens Form.List rendering */}
<Form.List name="tokens">
{(fields, { remove, move }) =>
fields.length === 0 ? (
@@ -842,7 +195,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
</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) => (
@@ -853,7 +206,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
</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 ||
@@ -865,7 +218,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
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%' }}>
@@ -885,7 +237,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
// LIMIT special-case
if (tokenType === 'limit') {
return (
<div style={{ display: 'flex', gap: 8, alignItems: 'center', width: '100%' }}>
@@ -903,7 +254,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
// LOG special-case
if (tokenType === 'log') {
return (
<div style={{ display: 'flex', gap: 8, alignItems: 'center', width: '100%' }}>
@@ -941,7 +291,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
// QUEUE special-case inside token list (separate from NFQUEUE action)
if (tokenType === 'queue') {
return (
<div style={{ display: 'flex', gap: 8, alignItems: 'center', width: '100%' }}>
@@ -961,7 +310,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
// NAT special-case
if (tokenType === 'nat') {
return (
<div style={{ display: 'flex', gap: 8, alignItems: 'center', width: '100%' }}>
@@ -983,7 +331,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
// Generic tokens with subfield dropdown
if (options.length > 0) {
return (
<div
@@ -1017,9 +364,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
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>
@@ -1034,7 +379,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
// L4PROTO dropdown for meta.l4proto
if (tType === 'meta' && selField === 'l4proto') {
return (
<div>
@@ -1052,7 +396,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
// Day of week (meta.day)
if (tType === 'meta' && selField === 'day') {
return (
<div>
@@ -1072,7 +415,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
// Hour range (meta.hour) — free text but show placeholder/range hint
if (tType === 'meta' && selField === 'hour') {
return (
<div>
@@ -1087,7 +429,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
// Packet type (meta.pkttype)
if (tType === 'meta' && selField === 'pkttype') {
return (
<div>
@@ -1107,7 +448,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
// Packet/conn mark
if ((tType === 'meta' || tType === 'ct') && selField === 'mark') {
return (
<div>
@@ -1121,7 +461,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
// skuid / skgid
if (tType === 'meta' && (selField === 'skuid' || selField === 'skgid')) {
return (
<div>
@@ -1140,7 +479,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
// CT state
if (tType === 'ct' && selField === 'state') {
return (
<div>
@@ -1165,7 +503,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
// CT direction
if (tType === 'ct' && selField === 'direction') {
return (
<div>
@@ -1185,8 +522,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
/* --- IP / IP6 address helpers --- */
if (
if (
(tType === 'ip' || tType === 'ip6') &&
(selField === 'saddr' || selField === 'daddr')
) {
@@ -1214,7 +550,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
// protocol / nexthdr / ip.protocol (L4 protocol): show l4proto list
if (
(tType === 'ip' && selField === 'protocol') ||
(tType === 'ip6' && selField === 'nexthdr')
@@ -1238,7 +573,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
// DSCP
if ((tType === 'ip' || tType === 'ip6') && selField === 'dscp') {
return (
<div>
@@ -1258,7 +592,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
// TTL / hoplimit numeric
if (
(tType === 'ip' && selField === 'ttl') ||
(tType === 'ip6' && selField === 'hoplimit')
@@ -1278,7 +611,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
// IP fragment bits (frag-off) — single string placeholder
if (tType === 'ip' && selField === 'frag-off') {
return (
<div>
@@ -1292,9 +624,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
/* --- Transport: TCP/UDP/ICMP --- */
// Ports: allow numeric or service name
if (
(tType === 'tcp' || tType === 'udp') &&
(selField === 'dport' || selField === 'sport')
@@ -1311,7 +641,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
// TCP flags multi-select
if (tType === 'tcp' && selField === 'flags') {
return (
<div>
@@ -1335,7 +664,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
// ICMP type/code dropdowns
if (tType === 'icmp' && selField === 'type') {
return (
<div>
@@ -1374,8 +702,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
/* --- Layer 2: Ethernet / VLAN --- */
if (tType === 'ether') {
if (selField === 'saddr' || selField === 'daddr') {
return (
@@ -1408,7 +735,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
}
}
// VLAN ID / PCP / CFI
if (tType === 'vlan') {
if (selField === 'id') {
return (
@@ -1459,8 +785,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
}
}
/* --- Payload / default free text input --- */
if (tType === 'payload' || (kind === 'string' && !selField)) {
if (tType === 'payload' || (kind === 'string' && !selField)) {
return (
<div>
<Form.Item name={[field.name, 'data', 'value']} style={{ margin: 0 }}>
@@ -1474,7 +799,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
// Default fallback free-text with helpful examples
return (
<div>
<Form.Item name={[field.name, 'data', 'value']} style={{ margin: 0 }}>
@@ -1515,8 +839,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
<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 }]}>
@@ -1528,7 +851,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
</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' ? (
@@ -1547,7 +870,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
</Select>
</Form.Item>
{/* IPv4 reject reasons */}
<Form.Item shouldUpdate={(prev, cur) => prev.rejectType !== cur.rejectType} noStyle>
{() =>
form.getFieldValue('rejectType') === 'icmp' ? (
@@ -1572,7 +895,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
}
</Form.Item>
{/* IPv6 reject reasons */}
<Form.Item shouldUpdate={(prev, cur) => prev.rejectType !== cur.rejectType} noStyle>
{() =>
form.getFieldValue('rejectType') === 'icmpv6' ? (
@@ -1597,7 +920,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
}
</Form.Item>
{/* icmpx (inet) */}
<Form.Item shouldUpdate={(prev, cur) => prev.rejectType !== cur.rejectType} noStyle>
{() =>
form.getFieldValue('rejectType') === 'icmpx' ? (
@@ -1628,7 +951,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
}
</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' ? (
@@ -1648,7 +971,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
<Form.Item name="nfqueue_bypass" valuePropName="checked">
<Checkbox
onChange={() => {
// update preview immediately
schedulePreviewUpdate();
}}
>
@@ -1665,7 +987,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
</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
@@ -1674,7 +996,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
</Col>
</Row>
{/* advanced text */}
<Row>
<Col span={24}>
<Form.Item name="advanced" label="Advanced (optional)">
@@ -1688,7 +1010,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
<Divider />
{/* preview + run */}
<Form.Item>
<div style={{ display: 'flex', gap: 12, alignItems: 'center', width: '100%' }}>
<div style={{ flex: 1 }}>

View File

@@ -1,4 +1,3 @@
// src/components/FirewallTables.tsx
import { ArrowDownOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons';
import { Alert, Button, Card, Divider, Modal, notification, Space, Spin, Table, Typography } from 'antd';
import { ColumnsType } from 'antd/lib/table';
@@ -10,7 +9,6 @@ import FirewallAddTableModal from './FireWallAddTableModal';
const { Paragraph, Text, Title } = Typography;
/* ---------- Helpers ---------- */
function renderRuleFriendly(rule: RuleOut | any): string {
if (rule?.text && typeof rule.text === 'string' && rule.text.trim() !== '') return rule.text;
@@ -103,7 +101,6 @@ function renderRuleFriendly(rule: RuleOut | any): string {
try {
return JSON.stringify(rule.expr, (_k, v) => (v === undefined ? null : v)).slice(0, 500);
} catch {
// fallthrough
}
}
@@ -120,22 +117,18 @@ function isSuccessRc(out?: ExecResult | null): boolean {
return out.rc === 0 || out.rc === -1;
}
/* ---------- Props ---------- */
type Props = {
tables: TableOut[]; // passed from parent
error?: Error | null;
refreshRules: () => Promise<void>; // trigger to re-fetch ruleset
};
/* ---------- Component ---------- */
export default function FirewallTables({ tables, error, refreshRules: refresh }: Props): ReactElement {
// local UI state, non-persistent
const [refreshing, setRefreshing] = useState(false);
const [isOpenTableCreatorModal, setIsOpenTableCreatorModal] = useState(false);
const [isOpenChainCreatorModal, setIsOpenChainCreatorModal] = useState(false);
// run raw nft commands sequentially and collect results (used for delete ops etc.)
const runCommands = useCallback(async (cmds: string[]) => {
const acc: CmdResult[] = [];
for (const cmd of cmds) {
@@ -150,7 +143,6 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }:
return acc;
}, []);
// Delete rule
const handleDeleteRule = useCallback(
async (family: string | null | undefined, table: string, chain: string, handle: number | string) => {
const cmd = `delete rule ${family ?? 'inet'} ${table} ${chain} handle ${handle}`;
@@ -175,12 +167,9 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }:
} catch (err: any) {
notification.error({ message: 'Delete failed', description: err?.message ?? String(err) });
} finally {
// auto-refresh after change (no refresh notification shown here)
try {
await refresh();
} catch {
/* ignore */
}
} catch
}
},
});
@@ -188,7 +177,6 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }:
[runCommands, refresh],
);
// Delete chain
const handleDeleteChain = useCallback(
async (family: string | null | undefined, table: string, chain: string) => {
const cmd = `delete chain ${family ?? 'inet'} ${table} ${chain}`;
@@ -215,12 +203,9 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }:
} catch (err: any) {
notification.error({ message: 'Chain deletion failed', description: err?.message ?? String(err) });
} finally {
// auto-refresh after change (no notification)
try {
await refresh();
} catch {
/* ignore */
}
} catch
}
},
});
@@ -228,7 +213,6 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }:
[runCommands, refresh],
);
// Delete table
const handleDeleteTable = useCallback(
async (family: string | null | undefined, table: string) => {
const cmd = `delete table ${family ?? 'inet'} ${table}`;
@@ -255,12 +239,9 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }:
} catch (err: any) {
notification.error({ message: 'Table deletion failed', description: err?.message ?? String(err) });
} finally {
// auto-refresh after change (no notification)
try {
await refresh();
} catch {
/* ignore */
}
} catch
}
},
});
@@ -268,12 +249,10 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }:
[runCommands, refresh],
);
// manual refresh trigger (exposed to UI)
const handleRefresh = useCallback(async () => {
setRefreshing(true);
try {
await refresh();
// Only show notification when user pressed the refresh button
notification.success({ message: 'Ruleset refreshed' });
} catch (err: any) {
notification.error({ message: 'Refresh failed', description: err?.message ?? String(err) });
@@ -289,14 +268,12 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }:
return (
<>
{/* Pass onClose that accepts optional 'created' boolean. If the modal
calls onClose(true) we will auto-refresh (no refresh notification). */}
<FirewallAddTableModal
open={isOpenTableCreatorModal}
onClose={(created?: boolean) => {
setIsOpenTableCreatorModal(false);
if (created) {
// auto-refresh after create (no notification)
void refresh().catch(() => {});
}
}}
@@ -361,7 +338,7 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }:
</div>
}
>
{/* Chain modal: same optional 'created' signal */}
<FirewallAddChainModal
open={isOpenChainCreatorModal}
onClose={(created?: boolean) => {

View File

@@ -1,4 +1,3 @@
// src/pages/ScriptsManager.tsx
import {
DeleteOutlined,
DownloadOutlined,
@@ -65,49 +64,36 @@ const { Paragraph } = Typography;
*/
export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (code: string) => void }) {
// --------------------
// State
// --------------------
const [scripts, setScripts] = useState<ScriptWithStatus[]>([]);
const [loading, setLoading] = useState(false);
// modals & editor values
const [uploadModalVisible, setUploadModalVisible] = useState(false);
const [editorModalVisible, setEditorModalVisible] = useState(false);
const [editorValue, setEditorValue] = useState<string>('');
const [currentEditingName, setCurrentEditingName] = useState<string | null>(null);
// inline requirements editor toggle + value for upload modal
const [useInlineReqEditor, setUseInlineReqEditor] = useState(false);
const [inlineReqValue, setInlineReqValue] = useState<string>('');
// pip output modal
const [pipModalVisible, setPipModalVisible] = useState(false);
const [pipOutput, setPipOutput] = useState<{ stdout?: string; stderr?: string } | null>(null);
// requirements editor modal (existing per-script editor)
const [reqModalVisible, setReqModalVisible] = useState(false);
const [reqEditorValue, setReqEditorValue] = useState<string>('');
const [reqEditingName, setReqEditingName] = useState<string | null>(null);
// add-requirements modal (for scripts that have no requirements)
const [addReqModalVisible, setAddReqModalVisible] = useState(false);
const [addReqTarget, setAddReqTarget] = useState<string | null>(null);
const [addReqUseInline, setAddReqUseInline] = useState(false);
const [addReqInlineValue, setAddReqInlineValue] = useState('');
const [addReqFile, setAddReqFile] = useState<File | null>(null);
// enable modal
const [enableModalVisible, setEnableModalVisible] = useState(false);
const [enableTarget, setEnableTarget] = useState<string | null>(null);
// forms
const [form] = Form.useForm();
const [enableForm] = Form.useForm();
// --------------------
// Data fetch
// --------------------
const refreshAll = useCallback(async () => {
setLoading(true);
try {
@@ -148,9 +134,6 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
refreshAll();
}, [refreshAll]);
// --------------------
// Helpers
// --------------------
const getMappingsForScript = useCallback(
(scriptName: string) => {
const s = scripts.find((x) => x.name === scriptName);
@@ -171,7 +154,6 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
[scripts],
);
// Focus helper: tries to find a textarea inside the modal and put caret at the end.
const focusEditorInModal = useCallback((modalSelector = '.ant-modal') => {
setTimeout(() => {
try {
@@ -184,7 +166,6 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
ta.setSelectionRange(val.length, val.length);
}
} catch {
// ignore
}
}, 80);
}, []);
@@ -205,7 +186,6 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
if (addReqModalVisible) focusEditorInModal('.ant-modal');
}, [addReqModalVisible, focusEditorInModal]);
// show pip modal if pip output is present
const showPipIfPresent = useCallback(
(resp?: ScriptUploadResponse | { pip?: { stdout?: string; stderr?: string } } | null) => {
const pip = (resp as any)?.pip;
@@ -217,9 +197,6 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
[],
);
// --------------------
// API Handlers
// --------------------
const handleDownload = useCallback(async (name: string) => {
try {
const blob = await downloadScript(name);
@@ -358,9 +335,6 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
[refreshAll],
);
// --------------------
// Upload handler (prefers file; falls back to inline editors)
// --------------------
const handleUpload = useCallback(
async (formValues: any) => {
const { name, scriptFile, requirementsFile } = formValues;
@@ -369,10 +343,8 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
return;
}
// script: prefer uploaded file; else use editorValue
const script = scriptFile || new Blob([editorValue], { type: 'text/x-python' });
// requirements: prefer uploaded file; else use inlineReqValue if toggle enabled; else null
let req: File | Blob | null = null;
if (requirementsFile) {
req = requirementsFile;
@@ -476,10 +448,6 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
[refreshAll],
);
// --------------------
// Upload form helpers
// --------------------
// Normalize Upload event to return single File object for Form storage
const normFile = (e: any) => {
if (!e) return undefined;
const list: UploadFile[] = e.fileList || [];
@@ -488,7 +456,6 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
return (last as any).originFileObj ?? last;
};
// Icon-only button wrapped in Tooltip
function IconButtonTooltip({
title,
onClick,
@@ -509,9 +476,6 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
);
}
// --------------------
// Add Requirements (for scripts missing requirements)
// --------------------
const openAddRequirements = useCallback((scriptName: string) => {
setAddReqTarget(scriptName);
setAddReqUseInline(false);
@@ -549,7 +513,6 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
}
}, [addReqFile, addReqInlineValue, addReqTarget, refreshAll, showPipIfPresent]);
// helper for Upload change inside add-req modal
const onAddReqUploadChange = useCallback((info: any) => {
const list: UploadFile[] = info.fileList || [];
if (list.length === 0) {
@@ -560,9 +523,6 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
setAddReqFile(last.originFileObj ?? last);
}, []);
// --------------------
// Table columns
// --------------------
const columns: ColumnsType<ScriptWithStatus> = useMemo(
() => [
{
@@ -715,9 +675,6 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
[getMappingsForScript, nestedColumns],
);
// --------------------
// Render
// --------------------
return (
<div>
<Row justify="space-between" style={{ marginBottom: 12 }}>
@@ -740,7 +697,7 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
expandable={expandable}
/>
{/* Upload Modal (wider) */}
<Modal
open={uploadModalVisible}
title="Upload or create script"
@@ -816,7 +773,7 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
</Form>
</Modal>
{/* Editor Modal */}
<Modal
open={editorModalVisible}
title={currentEditingName ? `Editing — ${currentEditingName}` : 'Editor'}
@@ -874,7 +831,7 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
<PythonEditor value={editorValue} onChange={setEditorValue} height={520} />
</Modal>
{/* Requirements Editor Modal (per-script) */}
<Modal
open={reqModalVisible}
title={reqEditingName ? `requirements.txt — ${reqEditingName}` : 'requirements.txt'}
@@ -913,7 +870,7 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
<PythonEditor value={reqEditorValue} onChange={setReqEditorValue} height={420} />
</Modal>
{/* Add Requirements Modal (for scripts without requirements) */}
<Modal
open={addReqModalVisible}
title={addReqTarget ? `Add requirements — ${addReqTarget}` : 'Add requirements'}
@@ -967,7 +924,7 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
</div>
</Modal>
{/* Pip output modal */}
<Modal
open={pipModalVisible}
title="pip install output"
@@ -986,7 +943,7 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
</div>
</Modal>
{/* Enable Modal */}
<Modal
open={enableModalVisible}
title={`Enable ${enableTarget ?? ''}`}

View File

@@ -1,4 +1,3 @@
// src/components/Sniffing.tsx
import {
CheckCircleOutlined,
ExclamationCircleOutlined,
@@ -23,6 +22,7 @@ import {
Typography,
} from 'antd';
import { ReactElement, useMemo, useState } from 'react';
import { startSniffer, stopSniffer, stopSnifferByInterface } from '../api/apiClient';
import { BridgeInfo, InterfaceInfo } from '../types/network';
import { InterfaceSnifferStatus } from '../types/sniffer';
@@ -41,69 +41,66 @@ interface SnifferManagerProps {
}
export default function SnifferManager(props: SnifferManagerProps): ReactElement {
// local UI state
// modal / form
const [isModalOpen, setIsModalOpen] = useState(false);
const [startMode, setStartMode] = useState<'interface' | 'bridge'>('interface');
const [form] = Form.useForm();
// derived entries
const statusEntries = useMemo(
() => Object.entries(props.statusMap) as [string, InterfaceSnifferStatus][],
[props.statusMap],
);
// open/close modal
const onOpenStartModal = () => {
form.resetFields();
setStartMode('interface');
setIsModalOpen(true);
};
const onCloseModal = () => setIsModalOpen(false);
// start submit
const handleStartSubmit = async (values: any) => {
const { target } = values;
const onCloseModal = () => {
setIsModalOpen(false);
};
const handleStartSubmit = async (values: { target?: string }) => {
const target = values.target;
if (!target) {
notification.warning({ message: 'Warning', description: 'Please select a target to start sniffing on.' });
return;
}
try {
const payload = startMode === 'interface' ? { interface: target } : { bridge: target };
const res = await startSniffer(payload);
const result = await startSniffer(payload);
notification.success({
message: 'Sniffer started',
description: `Sniffer started on ${target} (session ${res.session_id})`,
description: `Sniffer started on ${target} (session ${result.session_id})`,
});
await props.refreshAll();
setIsModalOpen(false);
} catch (err: any) {
console.error('startSniffer error', err);
notification.error(err?.message ?? 'Failed to start sniffer');
} finally {
} catch (error: any) {
console.error('startSniffer error', error);
notification.error({
message: 'Failed to start sniffer',
description: error?.message ?? 'Failed to start sniffer',
});
}
};
// stop all
const handleStopAll = async () => {
try {
await stopSniffer();
notification.success({
message: 'All sniffers stopped',
});
notification.success({ message: 'All sniffers stopped' });
await props.refreshStatus();
} catch (err: any) {
console.error('stopSniffer error', err);
notification.error(err?.message ?? 'Failed to stop sniffers');
} finally {
} catch (error: any) {
console.error('stopSniffer error', error);
notification.error({
message: 'Failed to stop sniffers',
description: error?.message ?? 'Failed to stop sniffers',
});
}
};
// stop per-interface
const handleStopFromList = async (ifaceName: string, session_id?: string | null) => {
const handleStopFromList = async (ifaceName: string, sessionId?: string | null) => {
try {
// prefer stop by interface
await stopSnifferByInterface(ifaceName);
notification.success({
message: 'Sniffer stopped',
@@ -111,28 +108,28 @@ export default function SnifferManager(props: SnifferManagerProps): ReactElement
});
await props.refreshStatus();
return;
} catch (err: any) {
console.error('stopSnifferByInterface error', err);
// try stop by session as fallback
if (session_id) {
try {
await stopSniffer({ session_id });
notification.success({
message: 'Sniffer stopped',
description: `Sniffer stopped on interface ${ifaceName} (session ${session_id})`,
});
await props.refreshStatus();
return;
} catch (err2: any) {
console.error('stopSniffer by session fallback failed', err2);
}
}
notification.error({
message: 'Failed to stop sniffer',
description: err?.message ?? 'Failed to stop sniffer',
});
} finally {
} catch (error: any) {
console.error('stopSnifferByInterface error', error);
}
if (sessionId) {
try {
await stopSniffer({ session_id: sessionId });
notification.success({
message: 'Sniffer stopped',
description: `Sniffer stopped on interface ${ifaceName} (session ${sessionId})`,
});
await props.refreshStatus();
return;
} catch (fallbackError: any) {
console.error('stopSniffer by session fallback failed', fallbackError);
}
}
notification.error({
message: 'Failed to stop sniffer',
description: 'Could not stop sniffer for this interface.',
});
};
return (
@@ -145,11 +142,9 @@ export default function SnifferManager(props: SnifferManagerProps): ReactElement
<Tooltip title="Start a new sniffer session">
<Button icon={<PlusOutlined />} onClick={onOpenStartModal} />
</Tooltip>
<Tooltip title="Refresh status">
<Button icon={<ReloadOutlined />} onClick={() => props.refreshStatus()} />
</Tooltip>
<Tooltip title="Stop all sniffers">
<Button danger icon={<StopOutlined />} onClick={handleStopAll} />
</Tooltip>
@@ -167,22 +162,19 @@ export default function SnifferManager(props: SnifferManagerProps): ReactElement
) : (
<List
dataSource={statusEntries}
renderItem={([name, st]: [string, InterfaceSnifferStatus]) => {
const running = st.running;
const exists = st.exists;
const up = st.up;
const session_id = st.session_id ?? null;
const session_label = st.session_label ?? null;
renderItem={([name, status]: [string, InterfaceSnifferStatus]) => {
const sessionId = status.session_id ?? null;
const sessionLabel = status.session_label ?? null;
return (
<List.Item
actions={[
running ? (
status.running ? (
<Button
key="stop"
size="small"
icon={<StopOutlined />}
onClick={() => handleStopFromList(name, session_id)}
onClick={() => handleStopFromList(name, sessionId)}
disabled={props.loading}
>
Stop
@@ -194,21 +186,19 @@ export default function SnifferManager(props: SnifferManagerProps): ReactElement
type="primary"
icon={<PlayCircleOutlined />}
onClick={async () => {
// start on this interface
try {
const res = await startSniffer({ interface: name });
const result = await startSniffer({ interface: name });
notification.success({
message: 'Sniffer started',
description: `Sniffer started on ${name} (session ${res.session_id})`,
description: `Sniffer started on ${name} (session ${result.session_id})`,
});
await props.refreshStatus();
} catch (err: any) {
console.error('startSniffer quick', err);
} catch (error: any) {
console.error('startSniffer quick', error);
notification.error({
message: 'Failed to start sniffer',
description: err?.message ?? 'Failed to start sniffer',
description: error?.message ?? 'Failed to start sniffer',
});
} finally {
}
}}
>
@@ -221,7 +211,7 @@ export default function SnifferManager(props: SnifferManagerProps): ReactElement
title={
<Space>
<Text strong>{name}</Text>
{running ? (
{status.running ? (
<Tag icon={<CheckCircleOutlined />} color="success">
running
</Tag>
@@ -230,16 +220,14 @@ export default function SnifferManager(props: SnifferManagerProps): ReactElement
stopped
</Tag>
)}
{!exists && <Tag color="error">missing</Tag>}
{exists && !up && <Tag color="warning">down</Tag>}
{exists && up && <Tag color="processing">up</Tag>}
{session_id && (
{!status.exists && <Tag color="error">missing</Tag>}
{status.exists && !status.up && <Tag color="warning">down</Tag>}
{status.exists && status.up && <Tag color="processing">up</Tag>}
{sessionId && (
<Tag>
{session_label ? `${session_label}` : 'session'}:{' '}
<Text code copyable={{ text: session_id }}>
{session_id.slice(0, 8)}
{sessionLabel ?? 'session'}:{' '}
<Text code copyable={{ text: sessionId }}>
{sessionId.slice(0, 8)}
</Text>
</Tag>
)}
@@ -254,7 +242,6 @@ export default function SnifferManager(props: SnifferManagerProps): ReactElement
)}
</Card>
{/* Start sniffer modal */}
<Modal
title="Start sniffer session"
open={isModalOpen}
@@ -267,8 +254,8 @@ export default function SnifferManager(props: SnifferManagerProps): ReactElement
<Form.Item label="Mode" name="mode">
<Radio.Group
value={startMode}
onChange={(e) => {
setStartMode(e.target.value);
onChange={(event) => {
setStartMode(event.target.value);
form.setFieldsValue({ target: undefined });
}}
>
@@ -291,14 +278,14 @@ export default function SnifferManager(props: SnifferManagerProps): ReactElement
}
>
{startMode === 'interface'
? props.interfaces.map((i) => (
<Option key={`if:${i.name}`} value={i.name}>
{i.name}
? props.interfaces.map((iface) => (
<Option key={`if:${iface.name}`} value={iface.name}>
{iface.name}
</Option>
))
: props.bridges.map((b) => (
<Option key={`br:${b.ifname}`} value={b.ifname}>
{b.ifname} ({b.members.map((m) => m.ifname).join(', ')})
: props.bridges.map((bridge) => (
<Option key={`br:${bridge.ifname}`} value={bridge.ifname}>
{bridge.ifname} ({bridge.members.map((member) => member.ifname).join(', ')})
</Option>
))}
</Select>