try new correlation af packet bridge mode
This commit is contained in:
@@ -1,11 +1,26 @@
|
||||
"""Pydantic model for packet rows returned by the backend."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional, Union
|
||||
from typing import Literal, Optional, Union
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, IPvAnyAddress
|
||||
|
||||
|
||||
class PacketObservationModel(BaseModel):
|
||||
"""One raw-capture or telemetry observation that contributed to a packet row."""
|
||||
|
||||
observation_type: Literal["capture", "telemetry"]
|
||||
source: str
|
||||
iface: Optional[str] = None
|
||||
timestamp: Optional[str] = None
|
||||
event_type: Optional[str] = None
|
||||
capture_mode: Optional[str] = None
|
||||
capture_session_id: Optional[str] = None
|
||||
session_label: Optional[str] = None
|
||||
session_kind: Optional[str] = None
|
||||
reason: Optional[str] = None
|
||||
|
||||
|
||||
class PacketDBModel(BaseModel):
|
||||
"""Normalized packet representation used across DB and API layers."""
|
||||
|
||||
@@ -48,6 +63,18 @@ class PacketDBModel(BaseModel):
|
||||
"dpi_metadata": {"method": "GET"},
|
||||
"capture_metadata": {"capture_mode": "tc_ingress", "packet_id": "123456"},
|
||||
"telemetry_metadata": {"event_type": "egress", "iface": "eth1", "packet_id": "123456"},
|
||||
"capture_observations": [
|
||||
{
|
||||
"observation_type": "capture",
|
||||
"source": "af_packet",
|
||||
"iface": "eth0",
|
||||
"timestamp": "2026-03-05T12:34:56.789000+00:00",
|
||||
"capture_mode": "af_packet",
|
||||
"capture_session_id": "session-1",
|
||||
"session_label": "br0",
|
||||
"session_kind": "bridge",
|
||||
}
|
||||
],
|
||||
"verdict": "accept",
|
||||
"verdict_reason": "egress-observed",
|
||||
"verdict_confidence": "high",
|
||||
@@ -92,6 +119,10 @@ class PacketDBModel(BaseModel):
|
||||
dpi_metadata: Optional[dict] = Field(None, description="Raw metadata from DPI/flow enrichment.")
|
||||
capture_metadata: Optional[dict] = Field(None, description="Raw-capture metadata from the bridge tc ingress exporter.")
|
||||
telemetry_metadata: Optional[dict] = Field(None, description="Kernel telemetry details from eBPF collector.")
|
||||
capture_observations: Optional[list[PacketObservationModel]] = Field(
|
||||
None,
|
||||
description="Ordered list of raw-capture and telemetry observations merged into this packet row.",
|
||||
)
|
||||
verdict: Optional[str] = None
|
||||
verdict_reason: Optional[str] = None
|
||||
verdict_confidence: Optional[str] = None
|
||||
|
||||
@@ -98,6 +98,7 @@ class PacketInfo(TypedDict, total=False):
|
||||
app_risk_score: Optional[int]
|
||||
dpi_metadata: Optional[Dict[str, Any]]
|
||||
capture_metadata: Optional[Dict[str, Any]]
|
||||
capture_observation: Optional[Dict[str, Any]]
|
||||
ip_id: Optional[int]
|
||||
icmp_type: Optional[int]
|
||||
icmp_code: Optional[int]
|
||||
@@ -203,6 +204,34 @@ def _merge_enrichment(pkt_info: PacketInfo, enrichment: Dict[str, Any]) -> None:
|
||||
pkt_info[key] = value
|
||||
|
||||
|
||||
def _build_capture_observation(
|
||||
*,
|
||||
pkt_iface: str,
|
||||
pkt_timestamp: datetime,
|
||||
capture_source: str,
|
||||
capture_session_id: Optional[str],
|
||||
capture_metadata: Optional[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
session = sessions.get(capture_session_id) if capture_session_id else None
|
||||
session_is_bridge = bool(session and session.get("is_bridge"))
|
||||
session_label = session.get("label") if isinstance(session, dict) else None
|
||||
session_capture_mode = (
|
||||
session.get("capture_mode")
|
||||
if isinstance(session, dict) and session.get("capture_mode")
|
||||
else ("tc_ebpf" if capture_metadata else "af_packet")
|
||||
)
|
||||
return {
|
||||
"observation_type": "capture",
|
||||
"source": capture_source,
|
||||
"iface": pkt_iface,
|
||||
"timestamp": pkt_timestamp.isoformat(),
|
||||
"capture_mode": session_capture_mode,
|
||||
"capture_session_id": capture_session_id,
|
||||
"session_label": session_label,
|
||||
"session_kind": "bridge" if session_is_bridge else "interface",
|
||||
}
|
||||
|
||||
|
||||
def parse_packet(
|
||||
pkt,
|
||||
bridge_label: str,
|
||||
@@ -219,14 +248,17 @@ def parse_packet(
|
||||
|
||||
logger.debug("Packet captured on %s (bridge_label %s)", pkt_iface, bridge_label)
|
||||
|
||||
pkt_timestamp = _packet_timestamp(pkt)
|
||||
capture_source = "tc_ingress_raw" if capture_metadata else "af_packet"
|
||||
|
||||
pkt_info: PacketInfo = {
|
||||
"timestamp": _packet_timestamp(pkt),
|
||||
"timestamp": pkt_timestamp,
|
||||
"capture_session_id": capture_session_id,
|
||||
"iface": pkt_iface,
|
||||
"capture_iface": None,
|
||||
"length": len(pkt),
|
||||
"raw": bytes(pkt),
|
||||
"capture_source": "tc_ingress_raw" if capture_metadata else "af_packet",
|
||||
"capture_source": capture_source,
|
||||
"packet_id": capture_metadata.get("packet_id") if capture_metadata else None,
|
||||
"skb_mark": capture_metadata.get("skb_mark") if capture_metadata else None,
|
||||
"src_mac": None,
|
||||
@@ -250,6 +282,13 @@ def parse_packet(
|
||||
"app_risk_score": None,
|
||||
"dpi_metadata": None,
|
||||
"capture_metadata": capture_metadata,
|
||||
"capture_observation": _build_capture_observation(
|
||||
pkt_iface=pkt_iface,
|
||||
pkt_timestamp=pkt_timestamp,
|
||||
capture_source=capture_source,
|
||||
capture_session_id=capture_session_id,
|
||||
capture_metadata=capture_metadata,
|
||||
),
|
||||
"ip_id": None,
|
||||
"icmp_type": None,
|
||||
"icmp_code": None,
|
||||
|
||||
@@ -124,14 +124,14 @@ def _serialize_row_for_broadcast(row: Dict[str, Any]) -> Dict[str, Any]:
|
||||
|
||||
|
||||
def _normalize_json_fields(payload: Dict[str, Any]) -> None:
|
||||
for key in ("dpi_metadata", "capture_metadata", "telemetry_metadata"):
|
||||
for key in ("dpi_metadata", "capture_metadata", "telemetry_metadata", "capture_observations"):
|
||||
value = payload.get(key)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(parsed, dict):
|
||||
if isinstance(parsed, dict) or (key == "capture_observations" and isinstance(parsed, list)):
|
||||
payload[key] = parsed
|
||||
|
||||
|
||||
@@ -227,6 +227,13 @@ class DatabasePool:
|
||||
min_size=self._min_size,
|
||||
max_size=self._max_size,
|
||||
)
|
||||
async with self._pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
ALTER TABLE IF EXISTS packets
|
||||
ADD COLUMN IF NOT EXISTS capture_observations JSONB
|
||||
"""
|
||||
)
|
||||
logger.info("DB pool initialized")
|
||||
except Exception:
|
||||
logger.exception("Failed to create DB pool")
|
||||
@@ -259,6 +266,7 @@ class DatabasePool:
|
||||
|
||||
dpi_metadata = pkt_info.get("dpi_metadata")
|
||||
telemetry_metadata = pkt_info.get("telemetry_metadata")
|
||||
capture_observations = pkt_info.get("capture_observations")
|
||||
|
||||
try:
|
||||
async with self._pool.acquire() as conn:
|
||||
@@ -302,10 +310,11 @@ class DatabasePool:
|
||||
dpi_metadata,
|
||||
capture_metadata,
|
||||
telemetry_metadata,
|
||||
capture_observations,
|
||||
raw
|
||||
) VALUES(
|
||||
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,
|
||||
$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35::jsonb,$36::jsonb,$37::jsonb,$38
|
||||
$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35::jsonb,$36::jsonb,$37::jsonb,$38::jsonb,$39
|
||||
)
|
||||
ON CONFLICT (correlation_key) DO UPDATE SET
|
||||
updated_at = NOW(),
|
||||
@@ -357,6 +366,7 @@ class DatabasePool:
|
||||
dpi_metadata = COALESCE(EXCLUDED.dpi_metadata, packets.dpi_metadata),
|
||||
capture_metadata = COALESCE(EXCLUDED.capture_metadata, packets.capture_metadata),
|
||||
telemetry_metadata = COALESCE(EXCLUDED.telemetry_metadata, packets.telemetry_metadata),
|
||||
capture_observations = COALESCE(EXCLUDED.capture_observations, packets.capture_observations),
|
||||
raw = COALESCE(EXCLUDED.raw, packets.raw)
|
||||
RETURNING *
|
||||
""",
|
||||
@@ -397,6 +407,7 @@ class DatabasePool:
|
||||
json.dumps(dpi_metadata) if dpi_metadata is not None else None,
|
||||
json.dumps(pkt_info.get("capture_metadata")) if pkt_info.get("capture_metadata") is not None else None,
|
||||
json.dumps(telemetry_metadata) if telemetry_metadata is not None else None,
|
||||
json.dumps(capture_observations) if capture_observations is not None else None,
|
||||
pkt_info.get("raw"),
|
||||
)
|
||||
except Exception:
|
||||
|
||||
@@ -23,6 +23,21 @@ def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _observation_signature(observation: Dict[str, Any]) -> tuple[Any, ...]:
|
||||
return (
|
||||
observation.get("observation_type"),
|
||||
observation.get("source"),
|
||||
observation.get("iface"),
|
||||
observation.get("timestamp"),
|
||||
observation.get("event_type"),
|
||||
observation.get("capture_mode"),
|
||||
observation.get("capture_session_id"),
|
||||
observation.get("session_label"),
|
||||
observation.get("session_kind"),
|
||||
observation.get("reason"),
|
||||
)
|
||||
|
||||
|
||||
class PacketTracker:
|
||||
"""Deduplicate packet observations and persist one upserted row per packet."""
|
||||
|
||||
@@ -73,6 +88,11 @@ class PacketTracker:
|
||||
telemetry_metadata = payload.get("telemetry_metadata") or {}
|
||||
if isinstance(telemetry_metadata, dict):
|
||||
related_ifaces.add(telemetry_metadata.get("iface"))
|
||||
capture_observations = payload.get("capture_observations") or []
|
||||
if isinstance(capture_observations, list):
|
||||
for observation in capture_observations:
|
||||
if isinstance(observation, dict):
|
||||
related_ifaces.add(observation.get("iface"))
|
||||
|
||||
if related_ifaces & targets:
|
||||
self._entries.pop(correlation_key, None)
|
||||
@@ -124,6 +144,18 @@ class PacketTracker:
|
||||
payload["telemetry_metadata"] = event
|
||||
payload["last_observed_at"] = now_ts
|
||||
self._add_capture_source(payload, "telemetry")
|
||||
self._add_capture_observation(
|
||||
payload,
|
||||
{
|
||||
"observation_type": "telemetry",
|
||||
"source": "telemetry",
|
||||
"iface": event.get("iface"),
|
||||
"timestamp": _utcnow().isoformat(),
|
||||
"event_type": event.get("event_type"),
|
||||
"capture_mode": "tc_ebpf",
|
||||
"reason": event.get("reason"),
|
||||
},
|
||||
)
|
||||
for key, value in event.items():
|
||||
if value is None or key in {"event_type", "reason", "reason_code", "iface", "packet_uid", "correlation_key"}:
|
||||
continue
|
||||
@@ -189,6 +221,7 @@ class PacketTracker:
|
||||
"capture_sources": [],
|
||||
"capture_metadata": None,
|
||||
"telemetry_metadata": None,
|
||||
"capture_observations": [],
|
||||
},
|
||||
"persisted": False,
|
||||
"dirty": True,
|
||||
@@ -236,12 +269,33 @@ class PacketTracker:
|
||||
if source not in capture_sources:
|
||||
capture_sources.append(source)
|
||||
|
||||
def _add_capture_observation(self, payload: Dict[str, Any], observation: Optional[Dict[str, Any]]) -> bool:
|
||||
if not isinstance(observation, dict):
|
||||
return False
|
||||
|
||||
observations = payload.setdefault("capture_observations", [])
|
||||
if not isinstance(observations, list):
|
||||
observations = []
|
||||
payload["capture_observations"] = observations
|
||||
|
||||
normalized = {key: value for key, value in observation.items() if value is not None}
|
||||
signature = _observation_signature(normalized)
|
||||
for existing in observations:
|
||||
if isinstance(existing, dict) and _observation_signature(existing) == signature:
|
||||
return False
|
||||
|
||||
observations.append(normalized)
|
||||
observations.sort(key=lambda item: str(item.get("timestamp") or ""))
|
||||
return True
|
||||
|
||||
def _merge_packet_info(self, entry: Dict[str, Any], pkt_info: Dict[str, Any], now_ts: float) -> None:
|
||||
payload = entry["payload"]
|
||||
changed = False
|
||||
self._add_capture_source(payload, pkt_info.get("capture_source") or "af_packet")
|
||||
if self._add_capture_observation(payload, pkt_info.get("capture_observation")):
|
||||
changed = True
|
||||
for key, value in pkt_info.items():
|
||||
if key in {"iface", "capture_source"}:
|
||||
if key in {"iface", "capture_source", "capture_observation"}:
|
||||
continue
|
||||
if value is None:
|
||||
continue
|
||||
@@ -266,7 +320,14 @@ class PacketTracker:
|
||||
payload["capture_iface"] = iface
|
||||
changed = True
|
||||
|
||||
if iface and not pkt_info.get("capture_metadata") and not payload.get("ingress_if"):
|
||||
capture_observation = pkt_info.get("capture_observation") or {}
|
||||
is_bridge_af_packet = (
|
||||
isinstance(capture_observation, dict)
|
||||
and capture_observation.get("session_kind") == "bridge"
|
||||
and capture_observation.get("capture_mode") == "af_packet"
|
||||
)
|
||||
|
||||
if iface and not pkt_info.get("capture_metadata") and not payload.get("ingress_if") and not is_bridge_af_packet:
|
||||
payload["ingress_if"] = iface
|
||||
payload["ingress_seen_at"] = _utcnow()
|
||||
changed = True
|
||||
|
||||
Reference in New Issue
Block a user