file structure and comments unified
Some checks failed
Build and Deploy MITM Webserver / build (push) Failing after 3s
Some checks failed
Build and Deploy MITM Webserver / build (push) Failing after 3s
This commit is contained in:
@@ -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 }}>
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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 >= 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 }}>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 ?? ''}`}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user