diff --git a/backend/src/network_sniffer.py b/backend/src/network_sniffer.py index 72022c3..961b427 100644 --- a/backend/src/network_sniffer.py +++ b/backend/src/network_sniffer.py @@ -129,10 +129,10 @@ def parse_packet(pkt, bridge_label: str) -> None: "src_mac": None, "dst_mac": None, "eth_type_raw": None, - "eth_type": EtherTypeEnum.UNKNOWN, + "eth_type": None, "vlan_id": None, "protocol_raw": None, - "protocol": IPProtocolEnum.UNKNOWN, + "protocol": None, "protocol_name": None, "src_ip": None, "dst_ip": None, @@ -197,7 +197,7 @@ def parse_packet(pkt, bridge_label: str) -> None: try: pkt_info["protocol"] = protocol_from_number(proto_num) except Exception: - pkt_info["protocol"] = IPProtocolEnum.UNKNOWN + pkt_info["protocol"] = proto_num if proto_num == 6 and TCP in pkt: pkt_info["protocol_name"] = "TCP" @@ -229,7 +229,7 @@ def parse_packet(pkt, bridge_label: str) -> None: try: pkt_info["protocol"] = protocol_from_number(nh) except Exception: - pkt_info["protocol"] = IPProtocolEnum.UNKNOWN + pkt_info["protocol"] = nh if nh == 6 and TCP in pkt: pkt_info["protocol_name"] = "TCP" diff --git a/frontend/src/Layout/Footer.tsx b/frontend/src/Layout/Footer.tsx index 0a59bc4..7cd3826 100644 --- a/frontend/src/Layout/Footer.tsx +++ b/frontend/src/Layout/Footer.tsx @@ -1,5 +1,5 @@ import { Footer } from 'antd/es/layout/layout'; export const AppFooter: React.FC = () => { - return ; + return ; }; diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index 4306996..d87a512 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -24,6 +24,7 @@ import { SnifferStartRequest, SnifferStartResponse, SnifferStatusResponse, + SnifferStopRequest, SnifferStopResponse, } from '../types/sniffer'; @@ -104,21 +105,50 @@ export const removeBridge = async (req: BridgeRemoveRequest) => { 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 => { const res = await api.post('/sniffer/start', payload); return res.data; }; -export const stopSniffer = async (): Promise => { - const res = await api.post('/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 => { + const res = await api.post('/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 => { + const res = await api.post(`/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 => { + const res = await api.post(`/sniffer/stop?bridge=${encodeURIComponent(bridge)}`, {}); + return res.data; +}; + +/** + * Fetch the sniffer status (per-interface). + */ export const fetchSnifferStatus = async (): Promise => { const res = await api.get('/sniffer/status'); return res.data; }; - /* ------------------------- Packets ------------------------- */ diff --git a/frontend/src/pages/Sniffing.tsx b/frontend/src/pages/Sniffing.tsx index be56386..6deb0ac 100644 --- a/frontend/src/pages/Sniffing.tsx +++ b/frontend/src/pages/Sniffing.tsx @@ -3,398 +3,346 @@ import { CheckCircleOutlined, ExclamationCircleOutlined, PlayCircleOutlined, + PlusOutlined, ReloadOutlined, StopOutlined, - SyncOutlined, } from '@ant-design/icons'; -import { Button, Card, Col, Divider, List, message, Row, Select, Space, Spin, Tag, Tooltip, Typography } from 'antd'; -import { ReactElement, useCallback, useEffect, useState } from 'react'; -import { fetchSnifferStatus as fetchSnifferStatusApi, startSniffer, stopSniffer } from '../api/apiClient'; -import { useBackendAPI } from '../hooks/useBackendAPI'; +import { + Button, + Card, + 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'; const { Title, Text } = Typography; 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 { - const { - interfacesQuery, - bridgesQuery, - fetchInterfaces, - fetchBridges, - refreshInterfaces, - refreshBridges, - snifferStatusQuery, - } = useBackendAPI(); - // local UI state - const [selectedInterface, setSelectedInterface] = useState(undefined); - const [busy, setBusy] = useState(false); + const [interfaces, setInterfaces] = useState([]); + const [bridges, setBridges] = useState([]); + const [statusMap, setStatusMap] = useState>({}); + 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(() => { - fetchInterfaces().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; - - // Explicitly type entries so renderItem knows the types - const statusEntries = Object.entries(statusMap) as [string, InterfaceSnifferStatus][]; + refreshAll().catch(() => {}); + }, []); const refreshAll = useCallback(async () => { + setLoading(true); try { - if (refreshInterfaces) await refreshInterfaces(); - if (snifferStatusQuery?.refetch) await snifferStatusQuery.refetch(); - if (refreshBridges) await refreshBridges(); - } catch { - // ignore — queries will surface errors - } - }, [refreshInterfaces, snifferStatusQuery, refreshBridges]); + const [ifs, brs] = await Promise.allSettled([fetchInterfaces(), fetchBridges()]); + if (ifs.status === 'fulfilled') setInterfaces(ifs.value); + if (brs.status === 'fulfilled') setBridges(brs.value); - // actions - const handleStart = async () => { - if (!selectedInterface) { - message.warning('Please select an interface to start sniffing on.'); + await refreshStatus(); + } catch (err) { + // ignore; errors handled in individual calls + } 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; } - setBusy(true); + setLoading(true); try { - await startSniffer({ bridge: selectedInterface }); - message.success(`Sniffer started on ${selectedInterface}`); - await refreshAll(); + const payload = startMode === 'interface' ? { interface: target } : { bridge: target }; + const res = await startSniffer(payload); + 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) { console.error('startSniffer error', err); message.error(err?.message ?? 'Failed to start sniffer'); } finally { - setBusy(false); + setLoading(false); } }; - const handleStop = async () => { - setBusy(true); + // stop all + const handleStopAll = async () => { + setLoading(true); try { await stopSniffer(); - message.success('Sniffer stopped'); - await refreshAll(); + message.success('All sniffers stopped'); + await refreshStatus(); } catch (err: any) { console.error('stopSniffer error', err); - message.error(err?.message ?? 'Failed to stop sniffer'); + message.error(err?.message ?? 'Failed to stop sniffers'); } finally { - setBusy(false); + setLoading(false); } }; - // Quick action per-interface (start/stop) - const handleStartOn = async (ifaceName: string) => { - setBusy(true); + // stop per-interface + const handleStopFromList = async (ifaceName: string, session_id?: string | null) => { + setLoading(true); try { - await startSniffer({ bridge: ifaceName }); - message.success(`Started sniffer on ${ifaceName}`); - await refreshAll(); + // prefer stop by interface + await stopSnifferByInterface(ifaceName); + message.success(`Stopped sniffing on ${ifaceName}`); + await refreshStatus(); + return; } catch (err: any) { - console.error('handleStartOn error', err); - message.error(err?.message ?? `Failed to start on ${ifaceName}`); - } finally { - setBusy(false); - } - }; - - 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); + console.error('stopSnifferByInterface error', err); + // try stop by session as fallback + if (session_id) { + 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); + } + } message.error(err?.message ?? 'Failed to stop sniffer'); } finally { - setBusy(false); + setLoading(false); } }; return (
- - - - - - - Sniffing - - Start, stop and view AF_PACKET sniffer status - - - - - - - - - - - - - - - Tip: you can also start/stop directly from the list below. - - - - - - - - - {snifferStatusQuery?.isLoading ? ( -
- -
- ) : snifferStatusQuery?.isError ? ( -
- - Failed to load sniffer status. - - {(snifferStatusQuery?.error as any)?.message ?? String(snifferStatusQuery?.error)} - - -
- ) : statusEntries.length === 0 ? ( -
- No status information available. -
- ) : ( - { - const running = st.running; - const exists = st.exists; - const up = st.up; - return ( - } - onClick={() => handleStopFromList(name)} - disabled={busy} - > - Stop - - ) : ( - - ), - ]} - > - - {name} - {running ? ( - } color="success"> - running - - ) : ( - } color="default"> - stopped - - )} - - {!exists && missing} - {exists && !up && down} - {exists && up && up} - - } - description={ - - interface: {name} - - } - /> - - ); - }} - /> - )} -
- - - - - - - - - - - - - - - - - - - {interfacesQuery?.isLoading ? ( - - ) : interfaces.length === 0 ? ( - No interfaces found. - ) : ( - {iface.name}} - /> - )} - - - - {bridgesQuery?.isLoading ? ( - - ) : bridges.length === 0 ? ( - No bridges found. - ) : ( - {b.name}} - /> - )} - + + + + Sniffing + + Start, stop and view AF_PACKET sniffer sessions + + + + + ) : ( + + ), + ]} + > + + {name} + {running ? ( + } color="success"> + running + + ) : ( + } color="default"> + stopped + + )} + + {!exists && missing} + {exists && !up && down} + {exists && up && up} + + {session_id && ( + + {session_label ? `${session_label}` : 'session'}:{' '} + + {session_id.slice(0, 8)} + + + )} + + } + description={interface: {name}} + /> + + ); + }} + /> + )} + + + {/* Start sniffer modal */} + form.submit()} + confirmLoading={loading} + okText="Start" + > +
+ + { + setStartMode(e.target.value); + form.setFieldsValue({ target: undefined }); + }} + > + Interface + Bridge + + + + + + +
+
); } diff --git a/frontend/src/types/sniffer.ts b/frontend/src/types/sniffer.ts index ea41cfa..9460b36 100644 --- a/frontend/src/types/sniffer.ts +++ b/frontend/src/types/sniffer.ts @@ -1,20 +1,49 @@ +/** + * Request to start a sniffer session. + * Exactly one of `bridge` or `interface` should be provided. + */ export interface SnifferStartRequest { - bridge: string; + bridge?: string; + interface?: string; } +/** + * Response returned when starting a sniffer session. + */ export interface SnifferStartResponse { 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 { 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 { running: boolean; exists: boolean; up: boolean; + session_id?: string | null; + session_label?: string | null; } /** @@ -23,11 +52,3 @@ export interface InterfaceSnifferStatus { export interface SnifferStatusResponse { interfaces: Record; } - -/** - * Standard API error shape we expect from FastAPI HTTPException(detail=...) - */ -export interface ApiError { - detail?: string | Record; - // FastAPI sometimes returns {"detail": "message"} or other shapes -}