frontedn updates and backend test without unknown in packet parser
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 10s
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 10s
This commit is contained in:
@@ -129,10 +129,10 @@ def parse_packet(pkt, bridge_label: str) -> None:
|
|||||||
"src_mac": None,
|
"src_mac": None,
|
||||||
"dst_mac": None,
|
"dst_mac": None,
|
||||||
"eth_type_raw": None,
|
"eth_type_raw": None,
|
||||||
"eth_type": EtherTypeEnum.UNKNOWN,
|
"eth_type": None,
|
||||||
"vlan_id": None,
|
"vlan_id": None,
|
||||||
"protocol_raw": None,
|
"protocol_raw": None,
|
||||||
"protocol": IPProtocolEnum.UNKNOWN,
|
"protocol": None,
|
||||||
"protocol_name": None,
|
"protocol_name": None,
|
||||||
"src_ip": None,
|
"src_ip": None,
|
||||||
"dst_ip": None,
|
"dst_ip": None,
|
||||||
@@ -197,7 +197,7 @@ def parse_packet(pkt, bridge_label: str) -> None:
|
|||||||
try:
|
try:
|
||||||
pkt_info["protocol"] = protocol_from_number(proto_num)
|
pkt_info["protocol"] = protocol_from_number(proto_num)
|
||||||
except Exception:
|
except Exception:
|
||||||
pkt_info["protocol"] = IPProtocolEnum.UNKNOWN
|
pkt_info["protocol"] = proto_num
|
||||||
|
|
||||||
if proto_num == 6 and TCP in pkt:
|
if proto_num == 6 and TCP in pkt:
|
||||||
pkt_info["protocol_name"] = "TCP"
|
pkt_info["protocol_name"] = "TCP"
|
||||||
@@ -229,7 +229,7 @@ def parse_packet(pkt, bridge_label: str) -> None:
|
|||||||
try:
|
try:
|
||||||
pkt_info["protocol"] = protocol_from_number(nh)
|
pkt_info["protocol"] = protocol_from_number(nh)
|
||||||
except Exception:
|
except Exception:
|
||||||
pkt_info["protocol"] = IPProtocolEnum.UNKNOWN
|
pkt_info["protocol"] = nh
|
||||||
|
|
||||||
if nh == 6 and TCP in pkt:
|
if nh == 6 and TCP in pkt:
|
||||||
pkt_info["protocol_name"] = "TCP"
|
pkt_info["protocol_name"] = "TCP"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Footer } from 'antd/es/layout/layout';
|
import { Footer } from 'antd/es/layout/layout';
|
||||||
|
|
||||||
export const AppFooter: React.FC = () => {
|
export const AppFooter: React.FC = () => {
|
||||||
return <Footer>MitM Webserver App by Marcus Almert ©2025</Footer>;
|
return <Footer>MitM Webserver App by Marcus Almert ©2025-2026</Footer>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
SnifferStartRequest,
|
SnifferStartRequest,
|
||||||
SnifferStartResponse,
|
SnifferStartResponse,
|
||||||
SnifferStatusResponse,
|
SnifferStatusResponse,
|
||||||
|
SnifferStopRequest,
|
||||||
SnifferStopResponse,
|
SnifferStopResponse,
|
||||||
} from '../types/sniffer';
|
} from '../types/sniffer';
|
||||||
|
|
||||||
@@ -104,21 +105,50 @@ export const removeBridge = async (req: BridgeRemoveRequest) => {
|
|||||||
Sniffer
|
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> => {
|
export const startSniffer = async (payload: SnifferStartRequest): Promise<SnifferStartResponse> => {
|
||||||
const res = await api.post<SnifferStartResponse>('/sniffer/start', payload);
|
const res = await api.post<SnifferStartResponse>('/sniffer/start', payload);
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const stopSniffer = async (): Promise<SnifferStopResponse> => {
|
/**
|
||||||
const res = await api.post<SnifferStopResponse>('/sniffer/stop');
|
* 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;
|
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> => {
|
export const fetchSnifferStatus = async (): Promise<SnifferStatusResponse> => {
|
||||||
const res = await api.get<SnifferStatusResponse>('/sniffer/status');
|
const res = await api.get<SnifferStatusResponse>('/sniffer/status');
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
/* -------------------------
|
/* -------------------------
|
||||||
Packets
|
Packets
|
||||||
------------------------- */
|
------------------------- */
|
||||||
|
|||||||
@@ -3,261 +3,204 @@ import {
|
|||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
ExclamationCircleOutlined,
|
ExclamationCircleOutlined,
|
||||||
PlayCircleOutlined,
|
PlayCircleOutlined,
|
||||||
|
PlusOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
StopOutlined,
|
StopOutlined,
|
||||||
SyncOutlined,
|
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { Button, Card, Col, Divider, List, message, Row, Select, Space, Spin, Tag, Tooltip, Typography } from 'antd';
|
import {
|
||||||
import { ReactElement, useCallback, useEffect, useState } from 'react';
|
Button,
|
||||||
import { fetchSnifferStatus as fetchSnifferStatusApi, startSniffer, stopSniffer } from '../api/apiClient';
|
Card,
|
||||||
import { useBackendAPI } from '../hooks/useBackendAPI';
|
Col,
|
||||||
|
Form,
|
||||||
|
List,
|
||||||
|
message,
|
||||||
|
Modal,
|
||||||
|
Radio,
|
||||||
|
Row,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
Tag,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from 'antd';
|
||||||
|
import { ReactElement, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
fetchBridges,
|
||||||
|
fetchInterfaces,
|
||||||
|
fetchSnifferStatus,
|
||||||
|
startSniffer,
|
||||||
|
stopSniffer,
|
||||||
|
stopSnifferByInterface,
|
||||||
|
} from '../api/apiClient';
|
||||||
|
import { BridgeInfo, InterfaceInfo } from '../types/network';
|
||||||
import { InterfaceSnifferStatus } from '../types/sniffer';
|
import { InterfaceSnifferStatus } from '../types/sniffer';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
const { Option } = Select;
|
const { Option } = Select;
|
||||||
|
|
||||||
/** Local type for the interface objects returned by your backend/hook.
|
|
||||||
* Adjust fields if your backend has different shape.
|
|
||||||
*/
|
|
||||||
type NetworkInterface = {
|
|
||||||
ifindex: number;
|
|
||||||
name: string;
|
|
||||||
state?: string;
|
|
||||||
mac?: string;
|
|
||||||
mtu?: number;
|
|
||||||
flags?: string[];
|
|
||||||
addresses?: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Sniffing(): ReactElement {
|
export default function Sniffing(): ReactElement {
|
||||||
const {
|
|
||||||
interfacesQuery,
|
|
||||||
bridgesQuery,
|
|
||||||
fetchInterfaces,
|
|
||||||
fetchBridges,
|
|
||||||
refreshInterfaces,
|
|
||||||
refreshBridges,
|
|
||||||
snifferStatusQuery,
|
|
||||||
} = useBackendAPI();
|
|
||||||
|
|
||||||
// local UI state
|
// local UI state
|
||||||
const [selectedInterface, setSelectedInterface] = useState<string | undefined>(undefined);
|
const [interfaces, setInterfaces] = useState<InterfaceInfo[]>([]);
|
||||||
const [busy, setBusy] = useState(false);
|
const [bridges, setBridges] = useState<BridgeInfo[]>([]);
|
||||||
|
const [statusMap, setStatusMap] = useState<Record<string, InterfaceSnifferStatus>>({});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [statusLoading, setStatusLoading] = useState(false);
|
||||||
|
|
||||||
// ensure data is fetched on mount
|
// modal / form
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [startMode, setStartMode] = useState<'interface' | 'bridge'>('interface');
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
// initial load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchInterfaces().catch(() => {});
|
refreshAll().catch(() => {});
|
||||||
fetchSnifferStatusApi().catch(() => {});
|
}, []);
|
||||||
if (fetchBridges) fetchBridges().catch(() => {});
|
|
||||||
}, [fetchInterfaces, fetchBridges]);
|
|
||||||
|
|
||||||
// Properly type interfaces as array of objects (not string[])
|
|
||||||
const interfaces = (interfacesQuery?.data ?? []) as NetworkInterface[];
|
|
||||||
const bridges = (bridgesQuery?.data ?? []) as Array<{ name: string }>;
|
|
||||||
|
|
||||||
const statusMap = (snifferStatusQuery?.data?.interfaces ?? {}) as Record<string, InterfaceSnifferStatus>;
|
|
||||||
|
|
||||||
// Explicitly type entries so renderItem knows the types
|
|
||||||
const statusEntries = Object.entries(statusMap) as [string, InterfaceSnifferStatus][];
|
|
||||||
|
|
||||||
const refreshAll = useCallback(async () => {
|
const refreshAll = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
if (refreshInterfaces) await refreshInterfaces();
|
const [ifs, brs] = await Promise.allSettled([fetchInterfaces(), fetchBridges()]);
|
||||||
if (snifferStatusQuery?.refetch) await snifferStatusQuery.refetch();
|
if (ifs.status === 'fulfilled') setInterfaces(ifs.value);
|
||||||
if (refreshBridges) await refreshBridges();
|
if (brs.status === 'fulfilled') setBridges(brs.value);
|
||||||
} catch {
|
|
||||||
// ignore — queries will surface errors
|
|
||||||
}
|
|
||||||
}, [refreshInterfaces, snifferStatusQuery, refreshBridges]);
|
|
||||||
|
|
||||||
// actions
|
await refreshStatus();
|
||||||
const handleStart = async () => {
|
} catch (err) {
|
||||||
if (!selectedInterface) {
|
// ignore; errors handled in individual calls
|
||||||
message.warning('Please select an interface to start sniffing on.');
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// derived entries
|
||||||
|
const statusEntries = useMemo(() => Object.entries(statusMap) as [string, InterfaceSnifferStatus][], [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;
|
||||||
|
if (!target) {
|
||||||
|
message.warning('Please select a target to start sniffing on.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBusy(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await startSniffer({ bridge: selectedInterface });
|
const payload = startMode === 'interface' ? { interface: target } : { bridge: target };
|
||||||
message.success(`Sniffer started on ${selectedInterface}`);
|
const res = await startSniffer(payload);
|
||||||
await refreshAll();
|
message.success(`Sniffer started on ${target} (session ${res.session_id})`);
|
||||||
|
await refreshStatus();
|
||||||
|
// if interface/bridge lists may have changed, refresh them too
|
||||||
|
try {
|
||||||
|
const [ifs, brs] = await Promise.allSettled([fetchInterfaces(), fetchBridges()]);
|
||||||
|
if (ifs.status === 'fulfilled') setInterfaces(ifs.value);
|
||||||
|
if (brs.status === 'fulfilled') setBridges(brs.value);
|
||||||
|
} catch {}
|
||||||
|
setIsModalOpen(false);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('startSniffer error', err);
|
console.error('startSniffer error', err);
|
||||||
message.error(err?.message ?? 'Failed to start sniffer');
|
message.error(err?.message ?? 'Failed to start sniffer');
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStop = async () => {
|
// stop all
|
||||||
setBusy(true);
|
const handleStopAll = async () => {
|
||||||
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await stopSniffer();
|
await stopSniffer();
|
||||||
message.success('Sniffer stopped');
|
message.success('All sniffers stopped');
|
||||||
await refreshAll();
|
await refreshStatus();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('stopSniffer error', err);
|
console.error('stopSniffer error', err);
|
||||||
message.error(err?.message ?? 'Failed to stop sniffer');
|
message.error(err?.message ?? 'Failed to stop sniffers');
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Quick action per-interface (start/stop)
|
// stop per-interface
|
||||||
const handleStartOn = async (ifaceName: string) => {
|
const handleStopFromList = async (ifaceName: string, session_id?: string | null) => {
|
||||||
setBusy(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await startSniffer({ bridge: ifaceName });
|
// prefer stop by interface
|
||||||
message.success(`Started sniffer on ${ifaceName}`);
|
await stopSnifferByInterface(ifaceName);
|
||||||
await refreshAll();
|
message.success(`Stopped sniffing on ${ifaceName}`);
|
||||||
|
await refreshStatus();
|
||||||
|
return;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('handleStartOn error', err);
|
console.error('stopSnifferByInterface error', err);
|
||||||
message.error(err?.message ?? `Failed to start on ${ifaceName}`);
|
// try stop by session as fallback
|
||||||
} finally {
|
if (session_id) {
|
||||||
setBusy(false);
|
try {
|
||||||
|
await stopSniffer({ session_id });
|
||||||
|
message.success(`Stopped session ${session_id}`);
|
||||||
|
await refreshStatus();
|
||||||
|
return;
|
||||||
|
} catch (err2: any) {
|
||||||
|
console.error('stopSniffer by session fallback failed', err2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const handleStopFromList = async (ifaceName?: string) => {
|
|
||||||
setBusy(true);
|
|
||||||
try {
|
|
||||||
await stopSniffer();
|
|
||||||
message.success(`Stopped sniffer${ifaceName ? ` (requested from ${ifaceName})` : ''}`);
|
|
||||||
await refreshAll();
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('handleStopFromList error', err);
|
|
||||||
message.error(err?.message ?? 'Failed to stop sniffer');
|
message.error(err?.message ?? 'Failed to stop sniffer');
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 16 }}>
|
<div style={{ padding: 16 }}>
|
||||||
<Row gutter={[16, 16]}>
|
<Row justify="space-between" align="middle" style={{ marginBottom: 12 }}>
|
||||||
<Col span={16}>
|
|
||||||
<Card>
|
|
||||||
<Row justify="space-between" align="middle">
|
|
||||||
<Col>
|
<Col>
|
||||||
<Title level={4} style={{ margin: 0 }}>
|
<Title level={2} style={{ margin: 0 }}>
|
||||||
Sniffing
|
Sniffing
|
||||||
</Title>
|
</Title>
|
||||||
<Text type="secondary">Start, stop and view AF_PACKET sniffer status</Text>
|
<Text type="secondary">Start, stop and view AF_PACKET sniffer sessions</Text>
|
||||||
</Col>
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
<Col>
|
<Card
|
||||||
|
title="Sniffer sessions"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
extra={
|
||||||
<Space>
|
<Space>
|
||||||
<Tooltip title="Refresh interfaces & status">
|
<Tooltip title="Start a new sniffer session">
|
||||||
<Button icon={<ReloadOutlined />} onClick={() => refreshAll()} disabled={busy} />
|
<Button icon={<PlusOutlined />} onClick={onOpenStartModal} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip title="Refresh sniffer status">
|
<Tooltip title="Refresh status">
|
||||||
<Button
|
<Button icon={<ReloadOutlined />} onClick={() => refreshStatus()} />
|
||||||
icon={<SyncOutlined spin={snifferStatusQuery?.isFetching} />}
|
</Tooltip>
|
||||||
onClick={() => snifferStatusQuery?.refetch?.()}
|
|
||||||
disabled={busy}
|
<Tooltip title="Stop all sniffers">
|
||||||
/>
|
<Button danger icon={<StopOutlined />} onClick={handleStopAll} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Space>
|
</Space>
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
|
||||||
<Row gutter={[12, 12]} align="middle">
|
|
||||||
<Col>
|
|
||||||
<Text strong>Select interface / bridge</Text>
|
|
||||||
</Col>
|
|
||||||
|
|
||||||
<Col flex="1">
|
|
||||||
<Select
|
|
||||||
showSearch
|
|
||||||
placeholder="Choose interface or bridge"
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
value={selectedInterface}
|
|
||||||
onChange={(val) => setSelectedInterface(val as string | undefined)}
|
|
||||||
loading={interfacesQuery?.isLoading}
|
|
||||||
allowClear
|
|
||||||
optionFilterProp="children"
|
|
||||||
filterOption={(input, option) =>
|
|
||||||
(option?.children as unknown as string)?.toLowerCase().includes(input.toLowerCase())
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* interfaces from the system: use iface.name */}
|
{statusLoading ? (
|
||||||
{interfaces.map((iface) => (
|
|
||||||
<Option key={`if:${iface.name}`} value={iface.name}>
|
|
||||||
{iface.name}
|
|
||||||
</Option>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* optional bridges (if present) */}
|
|
||||||
{bridges && bridges.length > 0 && (
|
|
||||||
<>
|
|
||||||
<Option key="__divider__" value="__divider__" disabled>
|
|
||||||
────────── bridges ──────────
|
|
||||||
</Option>
|
|
||||||
{bridges.map((b) => (
|
|
||||||
<Option key={`br:${b.name}`} value={b.name}>
|
|
||||||
{b.name}
|
|
||||||
</Option>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Select>
|
|
||||||
</Col>
|
|
||||||
|
|
||||||
<Col>
|
|
||||||
<Space>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
icon={<PlayCircleOutlined />}
|
|
||||||
onClick={handleStart}
|
|
||||||
loading={busy && !snifferStatusQuery?.isFetching}
|
|
||||||
disabled={
|
|
||||||
!selectedInterface || busy || (selectedInterface && statusMap[selectedInterface]?.running)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Start
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
danger
|
|
||||||
icon={<StopOutlined />}
|
|
||||||
onClick={handleStop}
|
|
||||||
loading={busy && !snifferStatusQuery?.isFetching}
|
|
||||||
disabled={busy}
|
|
||||||
>
|
|
||||||
Stop
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
<Row>
|
|
||||||
<Col span={24}>
|
|
||||||
<Text type="secondary">Tip: you can also start/stop directly from the list below.</Text>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</Space>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
<Card title="Sniffer status" bordered>
|
|
||||||
{snifferStatusQuery?.isLoading ? (
|
|
||||||
<div style={{ textAlign: 'center', padding: 24 }}>
|
<div style={{ textAlign: 'center', padding: 24 }}>
|
||||||
<Spin />
|
<Spin />
|
||||||
</div>
|
</div>
|
||||||
) : snifferStatusQuery?.isError ? (
|
|
||||||
<div style={{ padding: 12 }}>
|
|
||||||
<Space direction="vertical">
|
|
||||||
<Text type="danger">Failed to load sniffer status.</Text>
|
|
||||||
<Text type="secondary">
|
|
||||||
{(snifferStatusQuery?.error as any)?.message ?? String(snifferStatusQuery?.error)}
|
|
||||||
</Text>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
) : statusEntries.length === 0 ? (
|
) : statusEntries.length === 0 ? (
|
||||||
<div style={{ padding: 12 }}>
|
<div style={{ padding: 12 }}>
|
||||||
<Text type="secondary">No status information available.</Text>
|
<Text type="secondary">No status information available.</Text>
|
||||||
@@ -269,6 +212,9 @@ export default function Sniffing(): ReactElement {
|
|||||||
const running = st.running;
|
const running = st.running;
|
||||||
const exists = st.exists;
|
const exists = st.exists;
|
||||||
const up = st.up;
|
const up = st.up;
|
||||||
|
const session_id = st.session_id ?? null;
|
||||||
|
const session_label = st.session_label ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List.Item
|
<List.Item
|
||||||
actions={[
|
actions={[
|
||||||
@@ -277,8 +223,8 @@ export default function Sniffing(): ReactElement {
|
|||||||
key="stop"
|
key="stop"
|
||||||
size="small"
|
size="small"
|
||||||
icon={<StopOutlined />}
|
icon={<StopOutlined />}
|
||||||
onClick={() => handleStopFromList(name)}
|
onClick={() => handleStopFromList(name, session_id)}
|
||||||
disabled={busy}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
Stop
|
Stop
|
||||||
</Button>
|
</Button>
|
||||||
@@ -288,8 +234,21 @@ export default function Sniffing(): ReactElement {
|
|||||||
size="small"
|
size="small"
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<PlayCircleOutlined />}
|
icon={<PlayCircleOutlined />}
|
||||||
onClick={() => handleStartOn(name)}
|
onClick={async () => {
|
||||||
disabled={busy}
|
// start on this interface
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await startSniffer({ interface: name });
|
||||||
|
message.success(`Started sniffer on ${name} (session ${res.session_id})`);
|
||||||
|
await refreshStatus();
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('startSniffer quick', err);
|
||||||
|
message.error(err?.message ?? 'Failed to start sniffer');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
>
|
>
|
||||||
Start
|
Start
|
||||||
</Button>
|
</Button>
|
||||||
@@ -313,13 +272,18 @@ export default function Sniffing(): ReactElement {
|
|||||||
{!exists && <Tag color="error">missing</Tag>}
|
{!exists && <Tag color="error">missing</Tag>}
|
||||||
{exists && !up && <Tag color="warning">down</Tag>}
|
{exists && !up && <Tag color="warning">down</Tag>}
|
||||||
{exists && up && <Tag color="processing">up</Tag>}
|
{exists && up && <Tag color="processing">up</Tag>}
|
||||||
|
|
||||||
|
{session_id && (
|
||||||
|
<Tag>
|
||||||
|
{session_label ? `${session_label}` : 'session'}:{' '}
|
||||||
|
<Text code copyable={{ text: session_id }}>
|
||||||
|
{session_id.slice(0, 8)}
|
||||||
|
</Text>
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
description={
|
description={<Text type="secondary">interface: {name}</Text>}
|
||||||
<Space size="small">
|
|
||||||
<Text type="secondary">interface: {name}</Text>
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
);
|
);
|
||||||
@@ -327,74 +291,58 @@ export default function Sniffing(): ReactElement {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
|
||||||
|
|
||||||
<Col span={8}>
|
{/* Start sniffer modal */}
|
||||||
<Card title="Quick actions" size="small" bordered>
|
<Modal
|
||||||
<Space direction="vertical" style={{ width: '100%' }}>
|
title="Start sniffer session"
|
||||||
<Button icon={<ReloadOutlined />} onClick={() => refreshAll()} block disabled={busy}>
|
open={isModalOpen}
|
||||||
Refresh all
|
onCancel={onCloseModal}
|
||||||
</Button>
|
onOk={() => form.submit()}
|
||||||
|
confirmLoading={loading}
|
||||||
<Button icon={<SyncOutlined />} onClick={() => snifferStatusQuery?.refetch?.()} block disabled={busy}>
|
okText="Start"
|
||||||
Refresh status
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
<Button
|
|
||||||
icon={<PlayCircleOutlined />}
|
|
||||||
onClick={() => {
|
|
||||||
// start on selected, fallback to first available interface
|
|
||||||
const target = selectedInterface ?? interfaces[0]?.name;
|
|
||||||
if (!target) {
|
|
||||||
message.warning('No interface selected / available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSelectedInterface(target);
|
|
||||||
handleStartOn(target);
|
|
||||||
}}
|
|
||||||
block
|
|
||||||
disabled={busy || interfaces.length === 0}
|
|
||||||
>
|
>
|
||||||
Start on selected / first
|
<Form form={form} layout="vertical" onFinish={handleStartSubmit} initialValues={{ target: undefined }}>
|
||||||
</Button>
|
<Form.Item label="Mode" name="mode">
|
||||||
|
<Radio.Group
|
||||||
|
value={startMode}
|
||||||
|
onChange={(e) => {
|
||||||
|
setStartMode(e.target.value);
|
||||||
|
form.setFieldsValue({ target: undefined });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Radio value="interface">Interface</Radio>
|
||||||
|
<Radio value="bridge">Bridge</Radio>
|
||||||
|
</Radio.Group>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
<Button danger icon={<StopOutlined />} onClick={() => handleStop()} block disabled={busy}>
|
<Form.Item
|
||||||
Stop sniffer
|
label={startMode === 'interface' ? 'Interface' : 'Bridge'}
|
||||||
</Button>
|
name="target"
|
||||||
</Space>
|
rules={[{ required: true, message: 'Please select a target' }]}
|
||||||
</Card>
|
>
|
||||||
|
<Select
|
||||||
<Card title="Available interfaces" size="small" style={{ marginTop: 12 }}>
|
showSearch
|
||||||
{interfacesQuery?.isLoading ? (
|
placeholder={startMode === 'interface' ? 'Select interface' : 'Select bridge'}
|
||||||
<Spin />
|
optionFilterProp="children"
|
||||||
) : interfaces.length === 0 ? (
|
filterOption={(input, option) =>
|
||||||
<Text type="secondary">No interfaces found.</Text>
|
(option?.children as unknown as string)?.toLowerCase().includes(input.toLowerCase())
|
||||||
) : (
|
}
|
||||||
<List
|
>
|
||||||
size="small"
|
{startMode === 'interface'
|
||||||
dataSource={interfaces}
|
? interfaces.map((i) => (
|
||||||
renderItem={(iface: NetworkInterface) => <List.Item>{iface.name}</List.Item>}
|
<Option key={`if:${i.name}`} value={i.name}>
|
||||||
/>
|
{i.name}
|
||||||
)}
|
</Option>
|
||||||
</Card>
|
))
|
||||||
|
: bridges.map((b) => (
|
||||||
<Card title="Bridges" size="small" style={{ marginTop: 12 }}>
|
<Option key={`br:${b.ifname}`} value={b.ifname}>
|
||||||
{bridgesQuery?.isLoading ? (
|
{b.ifname} ({b.members.map((m) => m.ifname).join(', ')})
|
||||||
<Spin />
|
</Option>
|
||||||
) : bridges.length === 0 ? (
|
))}
|
||||||
<Text type="secondary">No bridges found.</Text>
|
</Select>
|
||||||
) : (
|
</Form.Item>
|
||||||
<List
|
</Form>
|
||||||
size="small"
|
</Modal>
|
||||||
dataSource={bridges}
|
|
||||||
renderItem={(b: { name: string }) => <List.Item>{b.name}</List.Item>}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* Request to start a sniffer session.
|
||||||
|
* Exactly one of `bridge` or `interface` should be provided.
|
||||||
|
*/
|
||||||
export interface SnifferStartRequest {
|
export interface SnifferStartRequest {
|
||||||
bridge: string;
|
bridge?: string;
|
||||||
|
interface?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response returned when starting a sniffer session.
|
||||||
|
*/
|
||||||
export interface SnifferStartResponse {
|
export interface SnifferStartResponse {
|
||||||
started: boolean;
|
started: boolean;
|
||||||
bridge: string;
|
session_id: string;
|
||||||
|
target: string;
|
||||||
|
target_type: 'bridge' | 'interface';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional body when stopping a sniffer session.
|
||||||
|
* Prefer providing session_id to stop a specific session.
|
||||||
|
*/
|
||||||
|
export interface SnifferStopRequest {
|
||||||
|
session_id?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response returned when stopping sniffer(s).
|
||||||
|
*/
|
||||||
export interface SnifferStopResponse {
|
export interface SnifferStopResponse {
|
||||||
stopped: boolean;
|
stopped: boolean;
|
||||||
|
session_id?: string | null;
|
||||||
|
target?: string | null;
|
||||||
|
target_type?: 'bridge' | 'interface' | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-interface sniffer status (now includes owning session info).
|
||||||
|
*/
|
||||||
export interface InterfaceSnifferStatus {
|
export interface InterfaceSnifferStatus {
|
||||||
running: boolean;
|
running: boolean;
|
||||||
exists: boolean;
|
exists: boolean;
|
||||||
up: boolean;
|
up: boolean;
|
||||||
|
session_id?: string | null;
|
||||||
|
session_label?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,11 +52,3 @@ export interface InterfaceSnifferStatus {
|
|||||||
export interface SnifferStatusResponse {
|
export interface SnifferStatusResponse {
|
||||||
interfaces: Record<string, InterfaceSnifferStatus>;
|
interfaces: Record<string, InterfaceSnifferStatus>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Standard API error shape we expect from FastAPI HTTPException(detail=...)
|
|
||||||
*/
|
|
||||||
export interface ApiError {
|
|
||||||
detail?: string | Record<string, any>;
|
|
||||||
// FastAPI sometimes returns {"detail": "message"} or other shapes
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user