flow id sessions prefix
This commit is contained in:
@@ -63,6 +63,7 @@ class PacketInfo(TypedDict, total=False):
|
|||||||
"""TypedDict for parsed packet data used by persistence and telemetry."""
|
"""TypedDict for parsed packet data used by persistence and telemetry."""
|
||||||
|
|
||||||
timestamp: datetime
|
timestamp: datetime
|
||||||
|
capture_session_id: Optional[str]
|
||||||
correlation_key: str
|
correlation_key: str
|
||||||
correlation_source: str
|
correlation_source: str
|
||||||
packet_id: Optional[str]
|
packet_id: Optional[str]
|
||||||
@@ -199,7 +200,12 @@ def _merge_enrichment(pkt_info: PacketInfo, enrichment: Dict[str, Any]) -> None:
|
|||||||
pkt_info[key] = value
|
pkt_info[key] = value
|
||||||
|
|
||||||
|
|
||||||
def parse_packet(pkt, bridge_label: str, capture_metadata: Optional[Dict[str, Any]] = None) -> None:
|
def parse_packet(
|
||||||
|
pkt,
|
||||||
|
bridge_label: str,
|
||||||
|
capture_metadata: Optional[Dict[str, Any]] = None,
|
||||||
|
capture_session_id: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Parse a scapy Packet object into a normalized PacketInfo and schedule DB insert.
|
Parse a scapy Packet object into a normalized PacketInfo and schedule DB insert.
|
||||||
bridge_label indicates whether the packet was captured as part of a bridge-snapshot or single-interface.
|
bridge_label indicates whether the packet was captured as part of a bridge-snapshot or single-interface.
|
||||||
@@ -212,6 +218,7 @@ def parse_packet(pkt, bridge_label: str, capture_metadata: Optional[Dict[str, An
|
|||||||
|
|
||||||
pkt_info: PacketInfo = {
|
pkt_info: PacketInfo = {
|
||||||
"timestamp": _packet_timestamp(pkt),
|
"timestamp": _packet_timestamp(pkt),
|
||||||
|
"capture_session_id": capture_session_id,
|
||||||
"iface": pkt_iface,
|
"iface": pkt_iface,
|
||||||
"capture_iface": None,
|
"capture_iface": None,
|
||||||
"length": len(pkt),
|
"length": len(pkt),
|
||||||
@@ -454,11 +461,12 @@ def parse_packet_bytes(
|
|||||||
packet_bytes: bytes,
|
packet_bytes: bytes,
|
||||||
iface: str,
|
iface: str,
|
||||||
capture_metadata: Optional[Dict[str, Any]] = None,
|
capture_metadata: Optional[Dict[str, Any]] = None,
|
||||||
|
capture_session_id: Optional[str] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Parse one raw Ethernet frame using the shared Scapy packet path."""
|
"""Parse one raw Ethernet frame using the shared Scapy packet path."""
|
||||||
pkt = Ether(packet_bytes)
|
pkt = Ether(packet_bytes)
|
||||||
pkt.sniffed_on = iface
|
pkt.sniffed_on = iface
|
||||||
parse_packet(pkt, iface, capture_metadata=capture_metadata)
|
parse_packet(pkt, iface, capture_metadata=capture_metadata, capture_session_id=capture_session_id)
|
||||||
|
|
||||||
|
|
||||||
# -------------------------
|
# -------------------------
|
||||||
@@ -640,7 +648,7 @@ def _session_reader_loop(session_id: str) -> None:
|
|||||||
try:
|
try:
|
||||||
pkt = Ether(raw)
|
pkt = Ether(raw)
|
||||||
pkt.sniffed_on = iface
|
pkt.sniffed_on = iface
|
||||||
parse_packet(pkt, label)
|
parse_packet(pkt, label, capture_session_id=session_id)
|
||||||
logger.debug("Captured packet on %s in session %s (len=%d)", iface, session_id, len(raw))
|
logger.debug("Captured packet on %s in session %s (len=%d)", iface, session_id, len(raw))
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to parse/process packet from %s in session %s", iface, session_id)
|
logger.exception("Failed to parse/process packet from %s in session %s", iface, session_id)
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ def _normalize_json_fields(payload: Dict[str, Any]) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _derive_flow_id(payload: Dict[str, Any]) -> Optional[str]:
|
def _derive_flow_id(payload: Dict[str, Any]) -> Optional[str]:
|
||||||
|
session_id = payload.get("capture_session_id")
|
||||||
|
session_prefix = f"{session_id}:" if session_id not in (None, "") else ""
|
||||||
|
|
||||||
dpi_metadata = payload.get("dpi_metadata")
|
dpi_metadata = payload.get("dpi_metadata")
|
||||||
if not isinstance(dpi_metadata, dict):
|
if not isinstance(dpi_metadata, dict):
|
||||||
return None
|
return None
|
||||||
@@ -64,22 +67,22 @@ def _derive_flow_id(payload: Dict[str, Any]) -> Optional[str]:
|
|||||||
if isinstance(tcp_meta, dict):
|
if isinstance(tcp_meta, dict):
|
||||||
stream = tcp_meta.get("stream")
|
stream = tcp_meta.get("stream")
|
||||||
if stream not in (None, "", []):
|
if stream not in (None, "", []):
|
||||||
return f"tcp:{stream}"
|
return f"{session_prefix}tcp:{stream}"
|
||||||
|
|
||||||
udp_meta = dpi_metadata.get("udp")
|
udp_meta = dpi_metadata.get("udp")
|
||||||
if isinstance(udp_meta, dict):
|
if isinstance(udp_meta, dict):
|
||||||
stream = udp_meta.get("stream")
|
stream = udp_meta.get("stream")
|
||||||
if stream not in (None, "", []):
|
if stream not in (None, "", []):
|
||||||
return f"udp:{stream}"
|
return f"{session_prefix}udp:{stream}"
|
||||||
|
|
||||||
tshark_meta = dpi_metadata.get("tshark")
|
tshark_meta = dpi_metadata.get("tshark")
|
||||||
if isinstance(tshark_meta, dict):
|
if isinstance(tshark_meta, dict):
|
||||||
tcp_stream = tshark_meta.get("tcp_stream")
|
tcp_stream = tshark_meta.get("tcp_stream")
|
||||||
if tcp_stream not in (None, "", []):
|
if tcp_stream not in (None, "", []):
|
||||||
return f"tcp:{tcp_stream}"
|
return f"{session_prefix}tcp:{tcp_stream}"
|
||||||
udp_stream = tshark_meta.get("udp_stream")
|
udp_stream = tshark_meta.get("udp_stream")
|
||||||
if udp_stream not in (None, "", []):
|
if udp_stream not in (None, "", []):
|
||||||
return f"udp:{udp_stream}"
|
return f"{session_prefix}udp:{udp_stream}"
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -157,6 +157,16 @@ export const fetchPackets = async (limit = 100): Promise<FetchPacketsResponse> =
|
|||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getPacketsWebSocketUrl = (subscribeRecent = 0): string => {
|
||||||
|
const url = new URL(BASE);
|
||||||
|
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
url.pathname = `${url.pathname.replace(/\/$/, '')}/packets/ws/packets`;
|
||||||
|
if (subscribeRecent > 0) {
|
||||||
|
url.searchParams.set('subscribe_recent', String(subscribeRecent));
|
||||||
|
}
|
||||||
|
return url.toString();
|
||||||
|
};
|
||||||
|
|
||||||
export const clearPackets = async (): Promise<any> => {
|
export const clearPackets = async (): Promise<any> => {
|
||||||
const res = await api.delete('/packets/packets');
|
const res = await api.delete('/packets/packets');
|
||||||
return res.data;
|
return res.data;
|
||||||
|
|||||||
@@ -7,25 +7,27 @@ import {
|
|||||||
Descriptions,
|
Descriptions,
|
||||||
Modal,
|
Modal,
|
||||||
Row,
|
Row,
|
||||||
|
Segmented,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Spin,
|
Spin,
|
||||||
Switch,
|
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
Tabs,
|
Tabs,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
message,
|
message,
|
||||||
|
theme,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { ReactElement, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { ReactElement, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { clearPackets, fetchPackets } from '../api/apiClient';
|
import { clearPackets, fetchPackets, getPacketsWebSocketUrl } from '../api/apiClient';
|
||||||
import type { PacketRow } from '../types/packets';
|
import type { PacketRow } from '../types/packets';
|
||||||
|
|
||||||
const { Text, Title } = Typography;
|
const { Text, Title } = Typography;
|
||||||
const { Option } = Select;
|
const { Option } = Select;
|
||||||
|
|
||||||
const DEFAULT_LIMIT = 200;
|
const DEFAULT_LIMIT = 200;
|
||||||
|
const DEFAULT_PAGE_SIZE = 25;
|
||||||
const MAX_PACKETS = 2000; // in-memory cap
|
const MAX_PACKETS = 2000; // in-memory cap
|
||||||
|
|
||||||
const ARP_OPCODE_LABELS: Record<number, string> = {
|
const ARP_OPCODE_LABELS: Record<number, string> = {
|
||||||
@@ -915,10 +917,13 @@ function formatTimestamp(ts?: string) {
|
|||||||
* PacketViewer component
|
* PacketViewer component
|
||||||
*/
|
*/
|
||||||
export default function PacketViewer(): ReactElement {
|
export default function PacketViewer(): ReactElement {
|
||||||
|
const { token } = theme.useToken();
|
||||||
const [packets, setPackets] = useState<PacketRow[]>([]);
|
const [packets, setPackets] = useState<PacketRow[]>([]);
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [statusLoading, setStatusLoading] = useState<boolean>(false);
|
const [statusLoading, setStatusLoading] = useState<boolean>(false);
|
||||||
const [limit, setLimit] = useState<number>(DEFAULT_LIMIT);
|
const [limit, setLimit] = useState<number>(DEFAULT_LIMIT);
|
||||||
|
const [mode, setMode] = useState<'live' | 'history'>('live');
|
||||||
|
const [pageSize, setPageSize] = useState<number>(DEFAULT_PAGE_SIZE);
|
||||||
const [paused, setPaused] = useState<boolean>(false);
|
const [paused, setPaused] = useState<boolean>(false);
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const [hexModalOpen, setHexModalOpen] = useState(false);
|
const [hexModalOpen, setHexModalOpen] = useState(false);
|
||||||
@@ -967,18 +972,20 @@ export default function PacketViewer(): ReactElement {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// open websocket
|
const closeWs = useCallback(() => {
|
||||||
const openWs = useCallback(() => {
|
|
||||||
if (wsRef.current) {
|
if (wsRef.current) {
|
||||||
try {
|
try {
|
||||||
wsRef.current.close();
|
wsRef.current.close();
|
||||||
} catch {}
|
} catch {}
|
||||||
wsRef.current = null;
|
wsRef.current = null;
|
||||||
}
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loc = window.location;
|
// open websocket
|
||||||
const protocol = loc.protocol === 'https:' ? 'wss' : 'ws';
|
const openWs = useCallback((subscribeRecent: number) => {
|
||||||
const wsUrl = `ws://mitm.lan/api/packets/ws/packets?subscribe_recent=20`;
|
closeWs();
|
||||||
|
|
||||||
|
const wsUrl = getPacketsWebSocketUrl(subscribeRecent);
|
||||||
const ws = new WebSocket(wsUrl);
|
const ws = new WebSocket(wsUrl);
|
||||||
wsRef.current = ws;
|
wsRef.current = ws;
|
||||||
|
|
||||||
@@ -1014,7 +1021,7 @@ export default function PacketViewer(): ReactElement {
|
|||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
wsRef.current = null;
|
wsRef.current = null;
|
||||||
};
|
};
|
||||||
}, [paused, pushNew]);
|
}, [closeWs, paused, pushNew]);
|
||||||
|
|
||||||
// pause handling: when unpausing, flush queuedDuringPause into list
|
// pause handling: when unpausing, flush queuedDuringPause into list
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1027,26 +1034,28 @@ export default function PacketViewer(): ReactElement {
|
|||||||
}
|
}
|
||||||
}, [paused, pushNew]);
|
}, [paused, pushNew]);
|
||||||
|
|
||||||
// start up: fetch history and open ws
|
// start up / mode change: fetch history, optionally attach to live websocket
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
fetchHistory(limit).then(() => {
|
fetchHistory(limit).then(() => {
|
||||||
openWs();
|
if (mode === 'live') {
|
||||||
|
openWs(limit);
|
||||||
|
} else {
|
||||||
|
closeWs();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
if (wsRef.current) {
|
closeWs();
|
||||||
try {
|
|
||||||
wsRef.current.close();
|
|
||||||
} catch {}
|
|
||||||
wsRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [fetchHistory, limit, openWs]);
|
}, [closeWs, fetchHistory, limit, mode, openWs]);
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await fetchHistory(limit);
|
await fetchHistory(limit);
|
||||||
|
if (mode === 'live') {
|
||||||
|
openWs(limit);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -1365,19 +1374,44 @@ export default function PacketViewer(): ReactElement {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.packet-viewer .packet-mode-toggle.ant-segmented {
|
||||||
|
background: ${token.colorFillTertiary};
|
||||||
|
}
|
||||||
|
|
||||||
|
.packet-viewer .packet-mode-toggle .ant-segmented-item-selected {
|
||||||
|
background: ${token.colorPrimary};
|
||||||
|
color: ${token.colorTextLightSolid};
|
||||||
|
}
|
||||||
|
|
||||||
|
.packet-viewer .packet-mode-toggle .ant-segmented-item-selected:hover {
|
||||||
|
color: ${token.colorTextLightSolid};
|
||||||
|
}
|
||||||
`}
|
`}
|
||||||
</style>
|
</style>
|
||||||
<Row justify="space-between" align="middle" style={{ marginBottom: 12 }}>
|
<Row justify="space-between" align="middle" style={{ marginBottom: 12 }}>
|
||||||
<Col>
|
<Col>
|
||||||
<Title level={3}>Packets</Title>
|
<Title level={3}>Packets</Title>
|
||||||
<div>
|
<div>
|
||||||
<Text type="secondary">Live packet viewer — history + live stream</Text>
|
<Text type="secondary">
|
||||||
|
{mode === 'live' ? 'Live packet viewer via websocket stream' : 'Historical packet viewer from database'}
|
||||||
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
<Col>
|
<Col>
|
||||||
<Space>
|
<Space>
|
||||||
<Text>History</Text>
|
<Segmented
|
||||||
|
className="packet-mode-toggle"
|
||||||
|
value={mode}
|
||||||
|
onChange={(value) => setMode(value as 'live' | 'history')}
|
||||||
|
options={[
|
||||||
|
{ label: 'Live packets', value: 'live' },
|
||||||
|
{ label: 'History', value: 'history' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text>{mode === 'live' ? 'Recent buffer' : 'History size'}</Text>
|
||||||
<Select value={limit} onChange={(v) => setLimit(Number(v))} style={{ width: 120 }}>
|
<Select value={limit} onChange={(v) => setLimit(Number(v))} style={{ width: 120 }}>
|
||||||
<Option value={50}>50</Option>
|
<Option value={50}>50</Option>
|
||||||
<Option value={100}>100</Option>
|
<Option value={100}>100</Option>
|
||||||
@@ -1385,8 +1419,19 @@ export default function PacketViewer(): ReactElement {
|
|||||||
<Option value={500}>500</Option>
|
<Option value={500}>500</Option>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Text>Live</Text>
|
<Text>Page size</Text>
|
||||||
<Switch checked={!paused} onChange={(checked) => handlePauseToggle(!checked ? true : false)} />
|
<Select value={pageSize} onChange={(v) => setPageSize(Number(v))} style={{ width: 120 }}>
|
||||||
|
<Option value={25}>25</Option>
|
||||||
|
<Option value={50}>50</Option>
|
||||||
|
<Option value={100}>100</Option>
|
||||||
|
<Option value={200}>200</Option>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{mode === 'live' ? (
|
||||||
|
<Button onClick={() => handlePauseToggle(!paused)}>
|
||||||
|
{paused ? 'Resume live' : 'Pause live'}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Button icon={<SyncOutlined />} onClick={handleRefresh} loading={statusLoading} />
|
<Button icon={<SyncOutlined />} onClick={handleRefresh} loading={statusLoading} />
|
||||||
<Button
|
<Button
|
||||||
@@ -1415,7 +1460,10 @@ export default function PacketViewer(): ReactElement {
|
|||||||
rowKey={(rec: PacketTableRow) => rec.key}
|
rowKey={(rec: PacketTableRow) => rec.key}
|
||||||
dataSource={tableRows}
|
dataSource={tableRows}
|
||||||
columns={columns as any}
|
columns={columns as any}
|
||||||
pagination={{ pageSize: 25 }}
|
pagination={{
|
||||||
|
pageSize,
|
||||||
|
showSizeChanger: false,
|
||||||
|
}}
|
||||||
size="small"
|
size="small"
|
||||||
bordered
|
bordered
|
||||||
expandable={{
|
expandable={{
|
||||||
|
|||||||
Reference in New Issue
Block a user