manag veth directly with sniffer
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 10s

This commit is contained in:
2026-03-07 15:38:56 +01:00
parent a282b89d45
commit 264da68f11
7 changed files with 257 additions and 56 deletions

View File

@@ -17,6 +17,10 @@ BACKEND_SNIFFER_BUFFER_DRAIN_INTERVAL_SECONDS=5.0
BACKEND_SNIFFER_THREAD_JOIN_TIMEOUT_SECONDS=2.0 BACKEND_SNIFFER_THREAD_JOIN_TIMEOUT_SECONDS=2.0
BACKEND_CAPTURE_INTERFACE= BACKEND_CAPTURE_INTERFACE=
BACKEND_CAPTURE_HEADER_ENABLED=true BACKEND_CAPTURE_HEADER_ENABLED=true
BACKEND_CAPTURE_AUTOMANAGE=true
BACKEND_CAPTURE_MIRROR_INTERFACE=mitmcap0
BACKEND_CAPTURE_AUTOMANAGED_INTERFACE=mitmcap1
BACKEND_CAPTURE_BUILD_DIR=/tmp/mitm-bpf
BACKEND_TELEMETRY_PROCESS_STOP_TIMEOUT_SECONDS=3.0 BACKEND_TELEMETRY_PROCESS_STOP_TIMEOUT_SECONDS=3.0
BACKEND_TELEMETRY_READER_JOIN_TIMEOUT_SECONDS=2.0 BACKEND_TELEMETRY_READER_JOIN_TIMEOUT_SECONDS=2.0
BACKEND_NDPI_MAX_FLOWS=200000 BACKEND_NDPI_MAX_FLOWS=200000

View File

@@ -3,12 +3,58 @@
from datetime import datetime from datetime import datetime
from typing import Optional, Union from typing import Optional, Union
from pydantic import BaseModel, Field, IPvAnyAddress from pydantic import BaseModel, ConfigDict, Field, IPvAnyAddress
class PacketDBModel(BaseModel): class PacketDBModel(BaseModel):
"""Normalized packet representation used across DB and API layers.""" """Normalized packet representation used across DB and API layers."""
model_config = ConfigDict(
json_schema_extra={
"example": {
"id": 123,
"timestamp": "2026-03-05T12:34:56.789Z",
"updated_at": "2026-03-05T12:34:56.900Z",
"correlation_key": "pid:123456",
"correlation_source": "kernel_mark",
"packet_id": "123456",
"packet_uid": "9f6d3af0d3c81cb20ee8e7d32df7c56414460542",
"skb_mark": 123456,
"ingress_if": "eth0",
"egress_if": "eth1",
"capture_iface": "mitmcap0",
"src_mac": "aa:bb:cc:dd:ee:ff",
"dst_mac": "11:22:33:44:55:66",
"eth_type_raw": 2048,
"eth_type": "IPv4",
"ip_proto_raw": 6,
"ip_proto": "TCP",
"src_ip": "192.168.1.10",
"dst_ip": "192.168.1.1",
"src_port": 54321,
"dst_port": 80,
"vlan_id": None,
"length": 128,
"raw_present": True,
"capture_sources": ["af_packet", "telemetry"],
"raw_b64": "BASE64...",
"app_protocol": "HTTP",
"app_master_protocol": "HTTP",
"app_category": "Web",
"app_confidence": "high",
"app_hostname": "example.org",
"app_is_encrypted": False,
"app_risk_score": 0,
"dpi_metadata": {"method": "GET"},
"capture_metadata": {"header_magic": "MTCP", "header_version": 1},
"telemetry_metadata": {"event_type": "egress", "iface": "eth1"},
"verdict": "accept",
"verdict_reason": "egress-observed",
"verdict_confidence": "high",
}
}
)
id: Union[int, str] id: Union[int, str]
timestamp: datetime = Field(..., description="Packet timestamp in ISO format.") timestamp: datetime = Field(..., description="Packet timestamp in ISO format.")
updated_at: Optional[datetime] = Field(None, description="Last DB update time for this row.") updated_at: Optional[datetime] = Field(None, description="Last DB update time for this row.")
@@ -52,48 +98,3 @@ class PacketDBModel(BaseModel):
egress_seen_at: Optional[datetime] = None egress_seen_at: Optional[datetime] = None
verdict_seen_at: Optional[datetime] = None verdict_seen_at: Optional[datetime] = None
packets: Optional[int] = None packets: Optional[int] = None
class Config:
schema_extra = {
"example": {
"id": 123,
"timestamp": "2026-03-05T12:34:56.789Z",
"updated_at": "2026-03-05T12:34:56.900Z",
"correlation_key": "pid:123456",
"correlation_source": "kernel_mark",
"packet_id": "123456",
"packet_uid": "9f6d3af0d3c81cb20ee8e7d32df7c56414460542",
"skb_mark": 123456,
"ingress_if": "eth0",
"egress_if": "eth1",
"capture_iface": "mitmcap0",
"src_mac": "aa:bb:cc:dd:ee:ff",
"dst_mac": "11:22:33:44:55:66",
"eth_type_raw": 2048,
"eth_type": "IPv4",
"ip_proto_raw": 6,
"ip_proto": "TCP",
"src_ip": "192.168.1.10",
"dst_ip": "192.168.1.1",
"src_port": 54321,
"dst_port": 80,
"vlan_id": None,
"length": 128,
"raw_present": True,
"capture_sources": ["af_packet", "telemetry"],
"raw_b64": "BASE64...",
"app_protocol": "HTTP",
"app_master_protocol": "HTTP",
"app_category": "Web",
"app_confidence": "high",
"app_hostname": "example.org",
"app_is_encrypted": False,
"app_risk_score": 0,
"dpi_metadata": {"method": "GET"},
"capture_metadata": {"header_magic": "MTCP", "header_version": 1},
"telemetry_metadata": {"event_type": "egress", "iface": "eth1"},
"verdict": "accept",
"verdict_reason": "egress-observed",
"verdict_confidence": "high",
}
}

View File

@@ -53,6 +53,10 @@ class BackendSettings:
sniffer_thread_join_timeout_seconds: float sniffer_thread_join_timeout_seconds: float
capture_interface: str capture_interface: str
capture_header_enabled: bool capture_header_enabled: bool
capture_automanage: bool
capture_automanaged_mirror_if: str
capture_automanaged_capture_if: str
capture_build_dir: str
telemetry_process_stop_timeout_seconds: float telemetry_process_stop_timeout_seconds: float
telemetry_reader_join_timeout_seconds: float telemetry_reader_join_timeout_seconds: float
ndpi_max_flows: int ndpi_max_flows: int
@@ -84,6 +88,10 @@ def load_settings() -> BackendSettings:
sniffer_thread_join_timeout_seconds=_env_float("BACKEND_SNIFFER_THREAD_JOIN_TIMEOUT_SECONDS", 2.0), sniffer_thread_join_timeout_seconds=_env_float("BACKEND_SNIFFER_THREAD_JOIN_TIMEOUT_SECONDS", 2.0),
capture_interface=_env_str("BACKEND_CAPTURE_INTERFACE", ""), capture_interface=_env_str("BACKEND_CAPTURE_INTERFACE", ""),
capture_header_enabled=_env_bool("BACKEND_CAPTURE_HEADER_ENABLED", True), capture_header_enabled=_env_bool("BACKEND_CAPTURE_HEADER_ENABLED", True),
capture_automanage=_env_bool("BACKEND_CAPTURE_AUTOMANAGE", True),
capture_automanaged_mirror_if=_env_str("BACKEND_CAPTURE_MIRROR_INTERFACE", "mitmcap0"),
capture_automanaged_capture_if=_env_str("BACKEND_CAPTURE_AUTOMANAGED_INTERFACE", "mitmcap1"),
capture_build_dir=_env_str("BACKEND_CAPTURE_BUILD_DIR", "/tmp/mitm-bpf"),
telemetry_process_stop_timeout_seconds=_env_float("BACKEND_TELEMETRY_PROCESS_STOP_TIMEOUT_SECONDS", 3.0), telemetry_process_stop_timeout_seconds=_env_float("BACKEND_TELEMETRY_PROCESS_STOP_TIMEOUT_SECONDS", 3.0),
telemetry_reader_join_timeout_seconds=_env_float("BACKEND_TELEMETRY_READER_JOIN_TIMEOUT_SECONDS", 2.0), telemetry_reader_join_timeout_seconds=_env_float("BACKEND_TELEMETRY_READER_JOIN_TIMEOUT_SECONDS", 2.0),
ndpi_max_flows=_env_int("BACKEND_NDPI_MAX_FLOWS", 200_000), ndpi_max_flows=_env_int("BACKEND_NDPI_MAX_FLOWS", 200_000),

View File

@@ -75,6 +75,13 @@ async def on_startup() -> None:
@app.on_event("shutdown") @app.on_event("shutdown")
async def shutdown_event() -> None: async def shutdown_event() -> None:
"""Stop network resources and release shared runtime objects.""" """Stop network resources and release shared runtime objects."""
try:
from src.network_sniffer import stop_afpacket_sniffer
stop_afpacket_sniffer()
except Exception:
logging.exception("Failed to stop sniffer sessions during shutdown")
try: try:
network_api.shutdown_network_api() network_api.shutdown_network_api()
except Exception: except Exception:
@@ -87,6 +94,13 @@ async def shutdown_event() -> None:
except Exception: except Exception:
logging.exception("Failed to stop bridge telemetry collector") logging.exception("Failed to stop bridge telemetry collector")
try:
from src.utilities.capture_pipeline import capture_pipeline_manager
capture_pipeline_manager.stop_all()
except Exception:
logging.exception("Failed to stop capture pipelines")
try: try:
from src.utilities.packet_tracker import packet_tracker from src.utilities.packet_tracker import packet_tracker

View File

@@ -34,6 +34,7 @@ from src.utilities.interface_bridge_helpers import (
from src.config import settings from src.config import settings
from src.utilities.bridge_telemetry import bridge_telemetry_manager from src.utilities.bridge_telemetry import bridge_telemetry_manager
from src.utilities.capture_header import parse_capture_header from src.utilities.capture_header import parse_capture_header
from src.utilities.capture_pipeline import capture_pipeline_manager
from src.utilities.ndpi_classifier import ndpi_classifier from src.utilities.ndpi_classifier import ndpi_classifier
from src.utilities.packet_identity import build_packet_uid from src.utilities.packet_identity import build_packet_uid
from src.utilities.packet_tracker import packet_tracker from src.utilities.packet_tracker import packet_tracker
@@ -567,7 +568,7 @@ def _session_reader_loop(session_id: str) -> None:
try: try:
capture_meta = None capture_meta = None
packet_bytes = raw packet_bytes = raw
if settings.capture_header_enabled and settings.capture_interface and iface == settings.capture_interface: if settings.capture_header_enabled:
capture_meta, packet_bytes = parse_capture_header(raw) capture_meta, packet_bytes = parse_capture_header(raw)
pkt = Ether(packet_bytes) pkt = Ether(packet_bytes)
pkt.sniffed_on = iface pkt.sniffed_on = iface
@@ -617,8 +618,8 @@ def start_afpacket_sniffer(target: str, target_is_interface: bool = False) -> st
"is_bridge": not target_is_interface, "is_bridge": not target_is_interface,
"ports": [], "ports": [],
"capture_ifaces": [], "capture_ifaces": [],
"capture_bridge": None,
} }
sessions[session_id] = session
# determine ports for this session # determine ports for this session
if target_is_interface: if target_is_interface:
@@ -626,19 +627,35 @@ def start_afpacket_sniffer(target: str, target_is_interface: bool = False) -> st
capture_ifaces = [target] capture_ifaces = [target]
else: else:
ports = get_bridge_ports_once(target) ports = get_bridge_ports_once(target)
capture_ifaces = [settings.capture_interface] if settings.capture_interface else list(ports) capture_iface = None
if settings.capture_automanage:
capture_iface = capture_pipeline_manager.acquire(target)
session["capture_bridge"] = target
elif settings.capture_interface:
capture_iface = settings.capture_interface
capture_ifaces = [capture_iface] if capture_iface else list(ports)
session["ports"] = ports session["ports"] = ports
session["capture_ifaces"] = capture_ifaces session["capture_ifaces"] = capture_ifaces
sessions[session_id] = session
# create sockets for this session only # create sockets for this session only
for iface in capture_ifaces: try:
if not check_interface_exists(iface): for iface in capture_ifaces:
logger.warning("Snapshot port %s missing for session %s, skipping", iface, session_id) if not check_interface_exists(iface):
continue logger.warning("Snapshot port %s missing for session %s, skipping", iface, session_id)
s = _create_af_packet_socket(iface) continue
if s: s = _create_af_packet_socket(iface)
session["sockets"][iface] = s if s:
session["sockets"][iface] = s
except Exception:
sessions.pop(session_id, None)
if session.get("capture_bridge"):
try:
capture_pipeline_manager.release(session["capture_bridge"])
except Exception:
logger.exception("Failed to release capture pipeline after startup error")
raise
# start dedicated thread # start dedicated thread
t = threading.Thread(target=_session_reader_loop, args=(session_id,), daemon=True) t = threading.Thread(target=_session_reader_loop, args=(session_id,), daemon=True)
@@ -672,6 +689,12 @@ def stop_afpacket_sniffer(session_id: Optional[str] = None, target: Optional[str
t = s.get("thread") t = s.get("thread")
if t and isinstance(t, threading.Thread): if t and isinstance(t, threading.Thread):
t.join(timeout=settings.sniffer_thread_join_timeout_seconds) t.join(timeout=settings.sniffer_thread_join_timeout_seconds)
capture_bridge = s.get("capture_bridge")
if capture_bridge:
try:
capture_pipeline_manager.release(capture_bridge)
except Exception:
logger.exception("Failed to release capture pipeline for bridge %s", capture_bridge)
_sync_bridge_telemetry() _sync_bridge_telemetry()
logger.info("Stopped session %s", session_id) logger.info("Stopped session %s", session_id)
return return
@@ -681,6 +704,9 @@ def stop_afpacket_sniffer(session_id: Optional[str] = None, target: Optional[str
# For interface-mode: close that iface in any session that has it # For interface-mode: close that iface in any session that has it
for sid, s in list(sessions.items()): for sid, s in list(sessions.items()):
if target in s.get("ports", []): if target in s.get("ports", []):
if s.get("capture_bridge") == target:
stop_afpacket_sniffer(session_id=sid)
continue
# close only that iface socket in that session # close only that iface socket in that session
sock = s["sockets"].pop(target, None) sock = s["sockets"].pop(target, None)
if sock: if sock:
@@ -748,6 +774,7 @@ def get_internal_debug_state() -> dict:
sid: { sid: {
"label": s.get("label"), "label": s.get("label"),
"is_bridge": s.get("is_bridge"), "is_bridge": s.get("is_bridge"),
"capture_bridge": s.get("capture_bridge"),
"ports": list(s.get("ports", [])), "ports": list(s.get("ports", [])),
"capture_ifaces": list(s.get("capture_ifaces", [])), "capture_ifaces": list(s.get("capture_ifaces", [])),
"sockets": list(s.get("sockets", {}).keys()), "sockets": list(s.get("sockets", {}).keys()),
@@ -757,5 +784,6 @@ def get_internal_debug_state() -> dict:
}, },
"buffer_len": len(_PACKET_BUFFER), "buffer_len": len(_PACKET_BUFFER),
"telemetry_ports": sorted({iface for session in sessions.values() for iface in session.get("ports", [])}), "telemetry_ports": sorted({iface for session in sessions.values() for iface in session.get("ports", [])}),
"capture_pipeline": capture_pipeline_manager.get_debug_snapshot(),
"packet_tracker": packet_tracker.get_debug_snapshot(), "packet_tracker": packet_tracker.get_debug_snapshot(),
} }

View File

@@ -54,6 +54,9 @@ class BridgeTelemetryManager:
env = os.environ.copy() env = os.environ.copy()
env["PYTHONUNBUFFERED"] = "1" env["PYTHONUNBUFFERED"] = "1"
backend_root = str(helper.parents[2])
existing_pythonpath = env.get("PYTHONPATH", "")
env["PYTHONPATH"] = backend_root if not existing_pythonpath else f"{backend_root}:{existing_pythonpath}"
cmd = [python_bin, str(helper), "--ifaces", ",".join(sorted(self._interfaces))] cmd = [python_bin, str(helper), "--ifaces", ",".join(sorted(self._interfaces))]
logger.info("Starting bridge telemetry collector for interfaces=%s", sorted(self._interfaces)) logger.info("Starting bridge telemetry collector for interfaces=%s", sorted(self._interfaces))

View File

@@ -0,0 +1,143 @@
"""Manage the optional tc-based bridge capture pipeline for mirrored packet storage."""
from __future__ import annotations
import logging
import subprocess
import threading
from pathlib import Path
from typing import Dict, Optional
from src.config import settings
logger = logging.getLogger("capture_pipeline")
class CapturePipelineManager:
"""Reference-count mirrored capture pipelines per bridge."""
def __init__(self) -> None:
self._lock = threading.Lock()
self._refs: Dict[str, int] = {}
self._active: Dict[str, Dict[str, str]] = {}
def acquire(self, bridge: str) -> Optional[str]:
"""Ensure a bridge capture pipeline exists and return the capture interface name."""
if not settings.capture_automanage:
return settings.capture_interface or None
bridge = bridge.strip()
if not bridge:
raise ValueError("Bridge name is required for capture pipeline setup")
with self._lock:
refcount = self._refs.get(bridge, 0)
if refcount > 0:
self._refs[bridge] = refcount + 1
active = self._active.get(bridge, {})
return active.get("capture_if") or settings.capture_interface or settings.capture_automanaged_capture_if
self._setup_bridge_locked(bridge)
self._refs[bridge] = 1
active = self._active.get(bridge, {})
return active.get("capture_if") or settings.capture_interface or settings.capture_automanaged_capture_if
def release(self, bridge: str) -> None:
"""Tear down a bridge capture pipeline when the last session releases it."""
if not settings.capture_automanage:
return
bridge = bridge.strip()
if not bridge:
return
with self._lock:
refcount = self._refs.get(bridge, 0)
if refcount <= 1:
self._refs.pop(bridge, None)
try:
self._teardown_bridge_locked(bridge)
finally:
self._active.pop(bridge, None)
return
self._refs[bridge] = refcount - 1
def stop_all(self) -> None:
"""Remove all managed capture pipelines."""
if not settings.capture_automanage:
return
with self._lock:
bridges = list(self._active.keys())
self._refs.clear()
for bridge in bridges:
try:
self._teardown_bridge_locked(bridge)
except Exception:
logger.exception("Failed to tear down capture pipeline for bridge %s", bridge)
self._active.clear()
def get_debug_snapshot(self) -> Dict[str, object]:
with self._lock:
return {
"automanage_enabled": settings.capture_automanage,
"refs": dict(self._refs),
"active": dict(self._active),
}
def _setup_bridge_locked(self, bridge: str) -> None:
script = self._repo_root() / "tools" / "setup_bridge_capture.sh"
mirror_if = settings.capture_automanaged_mirror_if
capture_if = settings.capture_interface or settings.capture_automanaged_capture_if
cmd = [
str(script),
"--bridge",
bridge,
"--mirror-if",
mirror_if,
"--capture-if",
capture_if,
"--build-dir",
settings.capture_build_dir,
]
logger.info(
"Setting up capture pipeline for bridge=%s mirror_if=%s capture_if=%s",
bridge,
mirror_if,
capture_if,
)
subprocess.run(cmd, check=True, capture_output=True, text=True)
self._active[bridge] = {
"mirror_if": mirror_if,
"capture_if": capture_if,
"build_dir": settings.capture_build_dir,
}
def _teardown_bridge_locked(self, bridge: str) -> None:
script = self._repo_root() / "tools" / "teardown_bridge_capture.sh"
active = self._active.get(bridge, {})
mirror_if = active.get("mirror_if") or settings.capture_automanaged_mirror_if
capture_if = active.get("capture_if") or settings.capture_interface or settings.capture_automanaged_capture_if
cmd = [
str(script),
"--bridge",
bridge,
"--mirror-if",
mirror_if,
"--capture-if",
capture_if,
]
logger.info(
"Tearing down capture pipeline for bridge=%s mirror_if=%s capture_if=%s",
bridge,
mirror_if,
capture_if,
)
subprocess.run(cmd, check=True, capture_output=True, text=True)
def _repo_root(self) -> Path:
return Path(__file__).resolve().parents[3]
capture_pipeline_manager = CapturePipelineManager()