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,5 +1,5 @@
// src/apiClient.ts
import axios from 'axios';
import { CreateRuleRequest, ExecResult, RulesetModel } from '../types/firewall';
import {
BridgeCreateRequest,
@@ -36,22 +36,14 @@ export const api = axios.create({
timeout: 20000,
});
// Normalize FastAPI errors here
api.interceptors.response.use(
(response) => response,
(error) => {
// FastAPI HTTPException format
const detail = error?.response?.data?.detail ?? error?.response?.data?.message ?? error.message ?? 'Unknown error';
// Always reject with a standard Error
return Promise.reject(new Error(detail));
},
);
/* -------------------------
Basic endpoints
------------------------- */
export const fetchHello = async (): Promise<any> => {
const res = await api.get('/hello');
return res.data;
@@ -62,10 +54,6 @@ export const fetchVersions = async (): Promise<any> => {
return res.data;
};
/* -------------------------
Network queries (existing)
------------------------- */
export const fetchInterfaces = async (): Promise<InterfaceInfo[]> => {
const res = await api.get<InterfaceInfo[]>('/network/interfaces');
return res.data;
@@ -101,150 +89,84 @@ export const removeBridge = async (req: BridgeRemoveRequest) => {
return res.data;
};
/* -------------------------
Sniffer
------------------------- */
/**
* Start a sniffer session. Provide exactly one of { bridge, interface }.
* Returns a session_id that you can use to stop the session later.
*/
export const startSniffer = async (payload: SnifferStartRequest): Promise<SnifferStartResponse> => {
const res = await api.post<SnifferStartResponse>('/sniffer/start', payload);
return res.data;
};
/**
* Stop sniffer(s).
* - If you pass a body with { session_id }, it will stop that specific session.
* - If you call without body and without query params, it will stop all sessions.
*/
export const stopSniffer = async (body?: SnifferStopRequest): Promise<SnifferStopResponse> => {
const res = await api.post<SnifferStopResponse>('/sniffer/stop', body ?? {});
return res.data;
};
/**
* Stop sniffing for a specific interface across sessions.
* Calls: POST /sniffer/stop?interface=eth0 (empty body)
*/
export const stopSnifferByInterface = async (iface: string): Promise<SnifferStopResponse> => {
const res = await api.post<SnifferStopResponse>(`/sniffer/stop?interface=${encodeURIComponent(iface)}`, {});
return res.data;
};
/**
* Stop sniffing for a specific bridge across sessions.
* Calls: POST /sniffer/stop?bridge=br0 (empty body)
*/
export const stopSnifferByBridge = async (bridge: string): Promise<SnifferStopResponse> => {
const res = await api.post<SnifferStopResponse>(`/sniffer/stop?bridge=${encodeURIComponent(bridge)}`, {});
return res.data;
};
/**
* Fetch the sniffer status (per-interface).
*/
export const fetchSnifferStatus = async (): Promise<SnifferStatusResponse> => {
const res = await api.get<SnifferStatusResponse>('/sniffer/status');
return res.data;
};
/* -------------------------
Packets
------------------------- */
export const fetchPackets = async (limit = 100): Promise<any> => {
// limit default mirrors OpenAPI default
const res = await api.get('/packets/packets', { params: { limit } });
return res.data;
};
export const clearPackets = async (): Promise<any> => {
// limit default mirrors OpenAPI default
const res = await api.delete('/packets/packets');
return res.data;
};
/* -------------------------
Firewall
------------------------- */
/**
* GET /firewall/rules
* Returns: { ruleset: RulesetModel | string | null }
* - If the server returns a raw textual fallback (string), the caller should handle it.
*/
export const fetchRuleset = async (): Promise<{ ruleset: RulesetModel }> => {
const res = await api.get<{ ruleset: RulesetModel }>('/firewall/rules');
return res.data;
};
/**
* DELETE /firewall/rules/{handle}?family=...&table=...&chain=...
* On success the backend returns 204 No Content. This function resolves to void.
*/
export const deleteRule = async (handle: number, family: string, table: string, chain: string): Promise<void> => {
const res = await api.delete(`/firewall/rules/${encodeURIComponent(String(handle))}`, {
params: { family, table, chain },
});
// axios resolves non-2xx as reject; server uses 204 No Content so nothing to return
return res.data;
};
/**
* createRuleJson - POST /firewall/rules
* Body: CreateRuleRequest (must include expr)
*/
export const createRuleJson = async (req: CreateRuleRequest): Promise<ExecResult> => {
const res = await api.post<ExecResult>('/firewall/rules', req);
return res.data;
};
export const execFirewallRaw = async (cmd: string): Promise<ExecResult> => {
const res = await api.post<ExecResult>('/firewall/raw', { cmd: cmd });
const res = await api.post<ExecResult>('/firewall/raw', { cmd });
return res.data;
};
/* -------------------------
Scripts
------------------------- */
/**
* Fetch the combined scripts + status endpoint.
* Returns a list of ScriptWithStatus entries.
*/
export const fetchScriptsAll = async (): Promise<ScriptWithStatus[]> => {
const res = await api.get<ScriptWithStatus[]>('/scripts/scripts');
return res.data;
};
/**
* Convenience: list only the basic ScriptInfo items (no mappings).
* This uses the combined endpoint and maps to ScriptInfo[].
*/
export const listScripts = async (): Promise<ScriptInfo[]> => {
const all = await fetchScriptsAll();
return all.map((s) => ({ name: s.name, path: s.path }));
return all.map((script) => ({ name: script.name, path: script.path }));
};
/**
* Get status/mappings for a single script by name.
* Because the backend merged status into /scripts, we fetch that and filter.
*/
export const fetchScriptStatusForName = async (name: string): Promise<StatusForNameResponse> => {
const all = await fetchScriptsAll();
const found = all.find((s) => s.name === name);
const found = all.find((script) => script.name === name);
if (!found) {
// If script not found we still return an empty mapping structure
return { name, mappings: [] };
}
return { name: found.name, mappings: found.mappings || [] };
};
/**
* Upload a script (multipart). If a requirements file is provided the backend
* will run pip install and return pip output in the response (mandatory install).
*/
export const uploadScript = async (opts: {
name: string;
script: File | Blob;
@@ -253,7 +175,10 @@ export const uploadScript = async (opts: {
const fd = new FormData();
fd.append('name', opts.name);
fd.append('script', opts.script);
if (opts.requirements) fd.append('requirements', opts.requirements as Blob);
if (opts.requirements) {
fd.append('requirements', opts.requirements as Blob);
}
const res = await api.post<ScriptUploadResponse>('/scripts/scripts', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
@@ -261,71 +186,49 @@ export const uploadScript = async (opts: {
return res.data;
};
/**
* Download the script file as a Blob. Use this blob to create an object URL or read text.
*/
export const downloadScript = async (name: string): Promise<Blob> => {
const res = await api.get(`/scripts/scripts/${encodeURIComponent(name)}`, { responseType: 'blob' });
return res.data as Blob;
};
/**
* Download the requirements.txt for a script as a Blob.
* Returns 404 if not present (axios will throw).
*/
export const downloadRequirements = async (name: string): Promise<Blob> => {
const res = await api.get(`/scripts/scripts/${encodeURIComponent(name)}/requirements`, { responseType: 'blob' });
return res.data as Blob;
};
/**
* Replace / upload requirements for a script. This endpoint ALWAYS runs pip install
* and will return pip stdout/stderr on success. On pip failure the backend returns 500.
*
* Use multipart/form-data so Swagger UI shows a file picker (backend expects UploadFile).
*/
export const uploadRequirements = async (
name: string,
requirements: File | Blob,
): Promise<RequirementsUploadResult> => {
const fd = new FormData();
fd.append('requirements', requirements);
const res = await api.put<RequirementsUploadResult>(`/scripts/scripts/${encodeURIComponent(name)}/requirements`, fd, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return res.data;
};
/**
* Delete only the requirements file and attempt to clean up the venv.
* Returns summary `{ removed: { requirements_removed, venv_removed }, errors? }`.
*/
export const deleteRequirements = async (name: string): Promise<RequirementsDeleteResult> => {
const res = await api.delete<RequirementsDeleteResult>(`/scripts/scripts/${encodeURIComponent(name)}/requirements`);
return res.data;
};
/**
* Delete a script (and its files / venv). If qnum provided only remove that unit.
*/
export const deleteScript = async (name: string, qnum?: number | null): Promise<DeleteResult> => {
const params: Record<string, any> = {};
if (typeof qnum !== 'undefined' && qnum !== null) params.qnum = qnum;
if (typeof qnum !== 'undefined' && qnum !== null) {
params.qnum = qnum;
}
const res = await api.delete<DeleteResult>(`/scripts/scripts/${encodeURIComponent(name)}`, { params });
return res.data;
};
/**
* Enable a script (creates + starts a systemd unit). Returns OperationResult including service name.
*/
export const enableScript = async (name: string, req: EnableRequest): Promise<OperationResult> => {
const res = await api.post<OperationResult>(`/scripts/scripts/${encodeURIComponent(name)}/enable`, req);
return res.data;
};
/**
* Disable a script unit (POST with qnum as query param). Returns OperationResult.
*/
export const disableScript = async (name: string, qnum: number): Promise<OperationResult> => {
const res = await api.post<OperationResult>(`/scripts/scripts/${encodeURIComponent(name)}/disable`, null, {
params: { qnum },

View File

@@ -1,6 +1,6 @@
// src/AppRouter.tsx
import { Navigate, Route, Routes } from 'react-router-dom';
import App from './App'; // your layout component (has <Outlet />)
import App from './App';
import { Firewall } from './pages/Firewall';
import Home from './pages/Home';
import Network from './pages/Network';
@@ -8,34 +8,27 @@ import Scripting from './pages/Scripting';
import Sniffing from './pages/Sniffing';
import { PATHS } from './routes';
function NotFound() {
return (
<div style={{ padding: 16 }}>
<h2>404 - Not Found</h2>
<p>The requested page does not exist.</p>
</div>
);
}
export default function AppRouter() {
return (
<Routes>
{/* App is the top-level layout; Outlet renders the active child route */}
<Route path={PATHS.ROOT} element={<App />}>
{/* When the user hits '/', redirect to '/home' */}
<Route index element={<Navigate to={PATHS.HOME} replace />} />
{/* Child routes - these render inside App's <Outlet /> */}
<Route path={PATHS.HOME.slice(1)} element={<Home />} />
<Route path={PATHS.NETWORK.slice(1)} element={<Network />} />
<Route path={PATHS.SNIFFING.slice(1)} element={<Sniffing />} />
<Route path={PATHS.SCRIPTING.slice(1)} element={<Scripting />} />
<Route path={PATHS.FIREWALL.slice(1)} element={<Firewall />} />
{/* Fallback (renders inside layout too) */}
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
);
}
/** simple 404 rendered inside the layout */
function NotFound() {
return (
<div style={{ padding: 16 }}>
<h2>404 – Not Found</h2>
<p>The requested page does not exist.</p>
</div>
);
}

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>

View File

@@ -1,7 +1,7 @@
// src/hooks/useNetwork.ts
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useState } from "react";
import * as api from "../api/apiClient";
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useCallback, useState } from 'react';
import * as api from '../api/apiClient';
import type {
BridgeCreateRequest,
BridgeInfo,
@@ -9,24 +9,15 @@ import type {
FullState,
InterfaceInfo,
RouteInfo,
} from "../types/network";
import { SnifferStatusResponse } from "../types/sniffer";
} from '../types/network';
import { SnifferStatusResponse } from '../types/sniffer';
const TEN_SECONDS = 1000 * 10;
const FIVE_SECONDS = 1000 * 5;
/**
* useNetwork
*
* - queries start disabled (no automatic network calls)
* - calling fetchInterfaces()/fetchBridges()/... will:
* 1) fetch and cache the data right away (queryClient.fetchQuery)
* 2) enable the corresponding useQuery so it becomes "active" and will
* auto-refetch based on the query options
*
* This gives "no initial auto-fetch" but "once fetched, auto-updates".
*/
export function useBackendAPI() {
const qc = useQueryClient();
const queryClient = useQueryClient();
// per-query enabled flags (start false => no automatic fetch)
const [interfacesEnabled, setInterfacesEnabled] = useState(false);
const [linksEnabled, setLinksEnabled] = useState(false);
const [routesEnabled, setRoutesEnabled] = useState(false);
@@ -34,132 +25,126 @@ export function useBackendAPI() {
const [fullStateEnabled, setFullStateEnabled] = useState(false);
const [snifferStatusEnabled, setSnifferStatusEnabled] = useState(false);
// common query options once enabled
const commonOptions = {
refetchOnWindowFocus: true,
staleTime: 1000 * 10, // 10s
staleTime: TEN_SECONDS,
};
// Queries (disabled initially)
const interfacesQuery = useQuery<InterfaceInfo[]>({
queryKey: ["interfaces"],
queryKey: ['interfaces'],
queryFn: api.fetchInterfaces,
enabled: interfacesEnabled,
...commonOptions,
});
const linksQuery = useQuery<InterfaceInfo[]>({
queryKey: ["links"],
queryKey: ['links'],
queryFn: api.fetchLinks,
enabled: linksEnabled,
...commonOptions,
});
const routesQuery = useQuery<RouteInfo[]>({
queryKey: ["routes"],
queryKey: ['routes'],
queryFn: api.fetchRoutes,
enabled: routesEnabled,
...commonOptions,
});
const bridgesQuery = useQuery<BridgeInfo[]>({
queryKey: ["bridges"],
queryKey: ['bridges'],
queryFn: api.fetchBridges,
enabled: bridgesEnabled,
...commonOptions,
});
const fullStateQuery = useQuery<FullState>({
queryKey: ["full-state"],
queryKey: ['full-state'],
queryFn: api.fetchFullState,
enabled: fullStateEnabled,
...commonOptions,
});
const snifferStatusQuery = useQuery<SnifferStatusResponse>({
queryKey: ["sniffer-status"],
queryKey: ['sniffer-status'],
queryFn: api.fetchSnifferStatus,
enabled: fullStateEnabled,
enabled: snifferStatusEnabled,
...commonOptions,
});
// Imperative fetch helpers that also enable auto-refetch behavior
const fetchInterfaces = useCallback(async () => {
const res = await qc.fetchQuery<InterfaceInfo[]>({
queryKey: ["interfaces"],
const result = await queryClient.fetchQuery<InterfaceInfo[]>({
queryKey: ['interfaces'],
queryFn: api.fetchInterfaces,
staleTime: 1000 * 10
staleTime: TEN_SECONDS,
});
setInterfacesEnabled(true);
return res;
}, [qc]);
return result;
}, [queryClient]);
const fetchLinks = useCallback(async () => {
const res = await qc.fetchQuery<InterfaceInfo[]>({
queryKey: ["links"],
const result = await queryClient.fetchQuery<InterfaceInfo[]>({
queryKey: ['links'],
queryFn: api.fetchLinks,
staleTime: 1000 * 10
staleTime: TEN_SECONDS,
});
setLinksEnabled(true);
return res;
}, [qc]);
return result;
}, [queryClient]);
const fetchRoutes = useCallback(async () => {
const res = await qc.fetchQuery<RouteInfo[]>({
queryKey: ["routes"],
const result = await queryClient.fetchQuery<RouteInfo[]>({
queryKey: ['routes'],
queryFn: api.fetchRoutes,
staleTime: 1000 * 10
staleTime: TEN_SECONDS,
});
setRoutesEnabled(true);
return res;
}, [qc]);
return result;
}, [queryClient]);
const fetchBridges = useCallback(async () => {
const res = await qc.fetchQuery<BridgeInfo[]>({
queryKey: ["bridges"],
const result = await queryClient.fetchQuery<BridgeInfo[]>({
queryKey: ['bridges'],
queryFn: api.fetchBridges,
staleTime: 1000 * 10
staleTime: TEN_SECONDS,
});
setBridgesEnabled(true);
return res;
}, [qc]);
return result;
}, [queryClient]);
const fetchFullState = useCallback(async () => {
const res = await qc.fetchQuery<FullState>({
queryKey: ["full-state"],
const result = await queryClient.fetchQuery<FullState>({
queryKey: ['full-state'],
queryFn: api.fetchFullState,
staleTime: 1000 * 5
staleTime: FIVE_SECONDS,
});
setFullStateEnabled(true);
return res;
}, [qc]);
return result;
}, [queryClient]);
const fetchSnifferStatus = useCallback(async () => {
const res = await qc.fetchQuery<SnifferStatusResponse>({
queryKey: ["sniffer-status"],
const result = await queryClient.fetchQuery<SnifferStatusResponse>({
queryKey: ['sniffer-status'],
queryFn: api.fetchSnifferStatus,
staleTime: 1000 * 5
staleTime: FIVE_SECONDS,
});
setFullStateEnabled(true);
return res;
}, [qc]);
setSnifferStatusEnabled(true);
return result;
}, [queryClient]);
// Local loading state for simple UI feedback
const [isCreating, setIsCreating] = useState(false);
const [isRemoving, setIsRemoving] = useState(false);
// Simple imperative functions that call the API and invalidate queries
async function createBridge(payload: BridgeCreateRequest) {
setIsCreating(true);
try {
await api.createBridge(payload);
// If the query is enabled it will refetch automatically after invalidation.
await qc.invalidateQueries({ queryKey: ["bridges"] });
await qc.invalidateQueries({ queryKey: ["full-state"] });
await qc.invalidateQueries({ queryKey: ["interfaces"] });
} catch (err) {
await queryClient.invalidateQueries({ queryKey: ['bridges'] });
await queryClient.invalidateQueries({ queryKey: ['full-state'] });
await queryClient.invalidateQueries({ queryKey: ['interfaces'] });
} catch (error) {
const message =
err instanceof Error ? err.message : typeof err === "string" ? err : "Create bridge failed";
error instanceof Error ? error.message : typeof error === 'string' ? error : 'Create bridge failed';
throw new Error(message);
} finally {
setIsCreating(false);
@@ -170,45 +155,43 @@ export function useBackendAPI() {
setIsRemoving(true);
try {
await api.removeBridge(payload);
await qc.invalidateQueries({ queryKey: ["bridges"] });
await qc.invalidateQueries({ queryKey: ["full-state"] });
await qc.invalidateQueries({ queryKey: ["interfaces"] });
await qc.invalidateQueries({ queryKey: ["sniffer-status"] });
} catch (err) {
await queryClient.invalidateQueries({ queryKey: ['bridges'] });
await queryClient.invalidateQueries({ queryKey: ['full-state'] });
await queryClient.invalidateQueries({ queryKey: ['interfaces'] });
await queryClient.invalidateQueries({ queryKey: ['sniffer-status'] });
} catch (error) {
const message =
err instanceof Error ? err.message : typeof err === "string" ? err : "Remove bridge failed";
error instanceof Error ? error.message : typeof error === 'string' ? error : 'Remove bridge failed';
throw new Error(message);
} finally {
setIsRemoving(false);
}
}
// Convenience: invalidate helpers
function refreshInterfaces() {
return qc.invalidateQueries({ queryKey: ["interfaces"] });
return queryClient.invalidateQueries({ queryKey: ['interfaces'] });
}
function refreshLinks() {
return qc.invalidateQueries({ queryKey: ["links"] });
return queryClient.invalidateQueries({ queryKey: ['links'] });
}
function refreshRoutes() {
return qc.invalidateQueries({ queryKey: ["routes"] });
return queryClient.invalidateQueries({ queryKey: ['routes'] });
}
function refreshBridges() {
return qc.invalidateQueries({ queryKey: ["bridges"] });
return queryClient.invalidateQueries({ queryKey: ['bridges'] });
}
function refreshFullState() {
return qc.invalidateQueries({ queryKey: ["full-state"] });
return queryClient.invalidateQueries({ queryKey: ['full-state'] });
}
function refreshSnifferStatus() {
return qc.invalidateQueries({ queryKey: ["sniffer-status"] });
return queryClient.invalidateQueries({ queryKey: ['sniffer-status'] });
}
// Convenience: refresh all queries
function refreshAll() {
refreshInterfaces();
refreshLinks();
@@ -219,39 +202,28 @@ export function useBackendAPI() {
}
return {
// queries
interfacesQuery,
linksQuery,
routesQuery,
bridgesQuery,
fullStateQuery,
snifferStatusQuery,
// manual fetchers (fetch+enable auto-updates)
fetchInterfaces,
fetchLinks,
fetchRoutes,
fetchBridges,
fetchFullState,
fetchSnifferStatus,
// simple mutation functions (imperative)
createBridge,
removeBridge,
// local loading flags
isCreating,
isRemoving,
// invalidate helpers
refreshInterfaces,
refreshLinks,
refreshRoutes,
refreshBridges,
refreshFullState,
refreshSnifferStatus,
// refresh all
refreshAll,
};
}

View File

@@ -1,4 +1,3 @@
// src/pages/BridgesManager.tsx
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import {
Button,
@@ -18,9 +17,9 @@ import {
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { useEffect, useMemo, useState } from 'react';
import { createBridge, fetchFullState, removeBridge } from '../api/apiClient';
import type { BridgeInfo, InterfaceInfo } from '../types/network';
import { FullState } from '../types/network';
import type { BridgeInfo, FullState, InterfaceInfo } from '../types/network';
const { Title, Paragraph } = Typography;
@@ -29,19 +28,19 @@ export default function Network() {
const [bridgeForm] = Form.useForm();
const [networkState, setNetworkState] = useState<FullState>();
const getFullState = (auto: boolean = false) => {
const getFullState = (silent = false) => {
fetchFullState()
.then((interfaces) => {
setNetworkState(interfaces);
if (!auto) {
.then((state) => {
setNetworkState(state);
if (!silent) {
notification.success({
message: 'Success',
description: 'Network state updated.',
});
}
})
.catch((err) => {
console.error('Failed to fetch interfaces:', err);
.catch((error) => {
console.error('Failed to fetch network state:', error);
notification.error({
message: 'Error',
description: 'Failed to fetch network state.',
@@ -49,39 +48,36 @@ export default function Network() {
});
};
// fetch on mount (explicit, since queries are disabled by default)
useEffect(() => {
getFullState(true);
}, []);
// build select options from interfaces list
const interfaceOptions = useMemo(
() =>
networkState?.interfaces.map((it: InterfaceInfo) => ({
label: it.name,
value: it.name,
networkState?.interfaces.map((iface: InterfaceInfo) => ({
label: iface.name,
value: iface.name,
})) ?? [],
[networkState],
);
// Columns for interfaces table (read-only)
const interfaceColumns: ColumnsType<InterfaceInfo> = useMemo(
() => [
{ title: 'IfIndex', dataIndex: 'ifindex', key: 'ifindex', width: 90 },
{ title: 'Name', dataIndex: 'name', key: 'name' },
{ title: 'State', dataIndex: 'state', key: 'state', render: (s) => <Tag>{s}</Tag> },
{ title: 'MAC', dataIndex: 'mac', key: 'mac', render: (m) => m ?? '—' },
{ title: 'State', dataIndex: 'state', key: 'state', render: (state) => <Tag>{state}</Tag> },
{ title: 'MAC', dataIndex: 'mac', key: 'mac', render: (mac) => mac ?? '—' },
{ title: 'MTU', dataIndex: 'mtu', key: 'mtu', width: 90 },
{
title: 'Addresses',
dataIndex: 'addresses',
key: 'addresses',
render: (addrs: any[]) =>
addrs?.length ? (
<Space orientation="vertical">
{addrs.map((a) => (
<span key={`${a.address}/${a.prefixlen}`}>
{a.address}/{a.prefixlen} ({a.family})
render: (addresses: any[]) =>
addresses?.length ? (
<Space direction="vertical">
{addresses.map((address) => (
<span key={`${address.address}/${address.prefixlen}`}>
{address.address}/{address.prefixlen} ({address.family})
</span>
))}
</Space>
@@ -93,23 +89,22 @@ export default function Network() {
[],
);
// Columns for bridges table (with remove action)
const bridgeColumns: ColumnsType<BridgeInfo> = useMemo(
() => [
{ title: 'IfIndex', dataIndex: 'ifindex', key: 'ifindex', width: 90 },
{ title: 'Name', dataIndex: 'ifname', key: 'ifname' },
{ title: 'State', dataIndex: 'state', key: 'state', render: (s) => <Tag>{s ?? '—'}</Tag> },
{ title: 'State', dataIndex: 'state', key: 'state', render: (state) => <Tag>{state ?? '—'}</Tag> },
{
title: 'Members',
dataIndex: 'members',
key: 'members',
render: (members: any[]) => (members?.length ? members.map((m) => m.ifname).join(', ') : '—'),
render: (members: any[]) => (members?.length ? members.map((member) => member.ifname).join(', ') : '—'),
},
{
title: 'Actions',
key: 'actions',
width: 140,
render: (_: any, record: BridgeInfo) => (
render: (_, record: BridgeInfo) => (
<Popconfirm
title={`Remove bridge ${record.ifname}?`}
onConfirm={() => handleRemoveBridge(record.ifname)}
@@ -121,11 +116,12 @@ export default function Network() {
),
},
],
[networkState],
[],
);
async function handleCreateBridge(values: { name: string; interfaces?: string[] }) {
const ifaceList = values.interfaces ?? [];
createBridge({ name: values.name, interfaces: ifaceList })
function handleCreateBridge(values: { name: string; interfaces?: string[] }) {
const interfaces = values.interfaces ?? [];
createBridge({ name: values.name, interfaces })
.then(() => {
notification.success({
message: 'Success',
@@ -135,11 +131,11 @@ export default function Network() {
getFullState(true);
bridgeForm.resetFields();
})
.catch((err) => {
console.error(err);
.catch((error) => {
console.error(error);
notification.error({
message: 'Error',
description: (err as Error).message ?? 'Failed to create bridge',
description: (error as Error).message ?? 'Failed to create bridge',
});
});
}
@@ -153,11 +149,11 @@ export default function Network() {
});
getFullState(true);
})
.catch((err) => {
console.error(err);
.catch((error) => {
console.error(error);
notification.error({
message: 'Error',
description: (err as Error).message ?? 'Failed to remove bridge',
description: (error as Error).message ?? 'Failed to remove bridge',
});
});
}
@@ -166,28 +162,21 @@ export default function Network() {
<div style={{ padding: 16 }}>
<Row justify="space-between" align="middle" style={{ marginBottom: 12 }}>
<Col>
<Title level={2}> Network Management</Title>
<Title level={2}>Network Management</Title>
<Paragraph type="secondary">View system interfaces and manage network bridges.</Paragraph>
</Col>
<Col>
<Space>
<Button
icon={<ReloadOutlined />}
onClick={() => {
getFullState();
}}
>
Refresh
</Button>
</Space>
<Button icon={<ReloadOutlined />} onClick={() => getFullState()}>
Refresh
</Button>
</Col>
</Row>
<Row gutter={16}>
<Col span={14}>
<Card title={`Interfaces (${networkState?.interfaces.length})`} style={{ overflow: 'auto' }}>
<Card title={`Interfaces (${networkState?.interfaces.length ?? 0})`} style={{ overflow: 'auto' }}>
<Table
rowKey={(r: InterfaceInfo) => r.ifindex}
rowKey={(row: InterfaceInfo) => row.ifindex}
dataSource={networkState?.interfaces ?? []}
columns={interfaceColumns}
pagination={{ pageSize: 8 }}
@@ -198,14 +187,13 @@ export default function Network() {
<Col span={10}>
<Card
title={`Bridges (${networkState?.bridges.length})`}
title={`Bridges (${networkState?.bridges.length ?? 0})`}
style={{ overflow: 'auto' }}
extra={
<Button
icon={<PlusOutlined />}
type="primary"
onClick={() => {
// ensure up-to-date interface list when opening modal
getFullState(true);
setBridgeModalVisible(true);
}}
@@ -213,7 +201,7 @@ export default function Network() {
}
>
<Table
rowKey={(r: BridgeInfo) => String(r.ifindex)}
rowKey={(row: BridgeInfo) => String(row.ifindex)}
dataSource={networkState?.bridges ?? []}
columns={bridgeColumns}
pagination={{ pageSize: 6 }}
@@ -223,7 +211,6 @@ export default function Network() {
</Col>
</Row>
{/* Create Bridge Modal */}
<Modal
title="Create Bridge"
open={bridgeModalVisible}
@@ -238,7 +225,6 @@ export default function Network() {
<Input placeholder="e.g. br0" />
</Form.Item>
{/* Select field populated from interfaces endpoint */}
<Form.Item name="interfaces" label="Interfaces (select one or more)">
<Select
mode="multiple"

View File

@@ -1,6 +1,6 @@
// src/components/Sniffing.tsx
import { Col, message, Row, Select, Typography } from 'antd';
import { Col, message, Row, Typography } from 'antd';
import { ReactElement, useCallback, useEffect, useState } from 'react';
import { fetchBridges, fetchInterfaces, fetchSnifferStatus } from '../api/apiClient';
import PacketViewer from '../components/PacketViewer';
import SnifferManager from '../components/SnifferManager';
@@ -8,48 +8,48 @@ import { BridgeInfo, InterfaceInfo } from '../types/network';
import { InterfaceSnifferStatus } from '../types/sniffer';
const { Title, Text } = Typography;
const { Option } = Select;
export default function Sniffing(): ReactElement {
const [interfaces, setInterfaces] = useState<InterfaceInfo[]>([]);
const [bridges, setBridges] = useState<BridgeInfo[]>([]);
const [statusMap, setStatusMap] = useState<Record<string, InterfaceSnifferStatus>>({});
const [loading, setLoading] = useState(false);
const [statusLoading, setStatusLoading] = useState(false);
// initial load
useEffect(() => {
refreshAll().catch(() => {});
const refreshStatus = useCallback(async () => {
setStatusLoading(true);
try {
const status = await fetchSnifferStatus();
setStatusMap(status.interfaces ?? {});
} catch (error: any) {
console.error('fetchSnifferStatus error', error);
message.error(error?.message ?? 'Failed to fetch sniffer status');
} finally {
setStatusLoading(false);
}
}, []);
const refreshAll = useCallback(async () => {
setLoading(true);
try {
const [ifs, brs] = await Promise.allSettled([fetchInterfaces(), fetchBridges()]);
if (ifs.status === 'fulfilled') setInterfaces(ifs.value);
if (brs.status === 'fulfilled') setBridges(brs.value);
const [ifaces, bridgesResult] = await Promise.allSettled([fetchInterfaces(), fetchBridges()]);
if (ifaces.status === 'fulfilled') {
setInterfaces(ifaces.value);
}
if (bridgesResult.status === 'fulfilled') {
setBridges(bridgesResult.value);
}
await refreshStatus();
} catch (err) {
// ignore; errors handled in individual calls
} finally {
setLoading(false);
}
}, []);
}, [refreshStatus]);
useEffect(() => {
refreshAll().catch(() => undefined);
}, [refreshAll]);
const refreshStatus = useCallback(async () => {
setStatusLoading(true);
try {
const st = await fetchSnifferStatus();
setStatusMap(st.interfaces ?? {});
} catch (err: any) {
console.error('fetchSnifferStatus error', err);
message.error(err?.message ?? 'Failed to fetch sniffer status');
} finally {
setStatusLoading(false);
}
}, []);
return (
<div className="sniffing-page" style={{ padding: 16 }}>
<Row justify="space-between" align="middle" style={{ marginBottom: 12 }}>
@@ -57,9 +57,10 @@ export default function Sniffing(): ReactElement {
<Title level={2} style={{ margin: 0 }}>
Sniffing
</Title>
<Text type="secondary">Start, stop and view AF_PACKET sniffer sessions</Text>
<Text type="secondary">Start, stop and view AF_PACKET sniffer sessions.</Text>
</Col>
</Row>
<Row>
<Col span={24}>
<SnifferManager
@@ -73,9 +74,10 @@ export default function Sniffing(): ReactElement {
/>
</Col>
</Row>
<Row>
<Col span={24} style={{ marginTop: 24 }}>
<PacketViewer interfaces={interfaces.map((i) => i.name)} />
<PacketViewer interfaces={interfaces.map((iface) => iface.name)} />
</Col>
</Row>
</div>