test nfstream fix
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 1m49s

This commit is contained in:
2026-03-07 18:19:33 +01:00
parent 3f0331762e
commit 01f9f6b5bd
2 changed files with 206 additions and 30 deletions

View File

@@ -4,6 +4,7 @@ import asyncio
import base64 import base64
import json import json
import logging import logging
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
import asyncpg import asyncpg
@@ -266,6 +267,101 @@ class DatabasePool:
except Exception: except Exception:
logger.exception("Failed to publish pkt_info to broadcaster") logger.exception("Failed to publish pkt_info to broadcaster")
async def backfill_flow_metadata(
self,
*,
iface: str,
src_ip: str,
dst_ip: str,
src_port: int,
dst_port: int,
protocol: int,
first_seen_ms: int,
last_seen_ms: int,
enrichment: Dict[str, Any],
window_ms: int,
) -> int:
"""Update recent packet rows for a flow after enrichment arrives asynchronously."""
if self._pool is None:
await self.init_pool()
lower_bound = datetime.fromtimestamp(max(first_seen_ms - window_ms, 0) / 1000.0, tz=timezone.utc)
upper_bound = datetime.fromtimestamp(max(last_seen_ms + window_ms, 0) / 1000.0, tz=timezone.utc)
dpi_metadata = enrichment.get("dpi_metadata")
try:
async with self._pool.acquire() as conn:
rows = await conn.fetch(
"""
UPDATE packets
SET
updated_at = NOW(),
app_protocol = COALESCE(packets.app_protocol, $9),
app_master_protocol = COALESCE(packets.app_master_protocol, $10),
app_category = COALESCE(packets.app_category, $11),
app_confidence = COALESCE(packets.app_confidence, $12),
app_hostname = COALESCE(packets.app_hostname, $13),
app_is_encrypted = COALESCE(packets.app_is_encrypted, $14),
dpi_metadata = CASE
WHEN $15::jsonb IS NULL THEN packets.dpi_metadata
WHEN packets.dpi_metadata IS NULL THEN $15::jsonb
ELSE packets.dpi_metadata || $15::jsonb
END
WHERE
ip_proto_raw = $1
AND (capture_iface = $2 OR ingress_if = $2 OR egress_if = $2)
AND timestamp BETWEEN $7 AND $8
AND (
(src_ip = $3::inet AND dst_ip = $4::inet AND src_port = $5 AND dst_port = $6)
OR
(src_ip = $4::inet AND dst_ip = $3::inet AND src_port = $6 AND dst_port = $5)
)
AND (
packets.app_protocol IS NULL
OR packets.app_master_protocol IS NULL
OR packets.app_category IS NULL
OR packets.app_confidence IS NULL
OR packets.app_hostname IS NULL
OR packets.app_is_encrypted IS NULL
OR ($15::jsonb IS NOT NULL)
)
RETURNING *
""",
protocol,
iface,
src_ip,
dst_ip,
src_port,
dst_port,
lower_bound,
upper_bound,
enrichment.get("app_protocol"),
enrichment.get("app_master_protocol"),
enrichment.get("app_category"),
enrichment.get("app_confidence"),
enrichment.get("app_hostname"),
enrichment.get("app_is_encrypted"),
json.dumps(dpi_metadata) if dpi_metadata is not None else None,
)
except Exception:
logger.exception("DB flow metadata backfill failed")
return 0
if not rows:
return 0
updated_count = 0
for row in rows:
serialized = _serialize_row_for_broadcast(dict(row))
updated_count += 1
if self.broadcaster:
try:
self.broadcaster.sync_publish(serialized)
except Exception:
logger.exception("Failed to publish flow-enriched packet row")
return updated_count
async def fetch_latest(self, limit: int) -> List[PacketDBModel]: async def fetch_latest(self, limit: int) -> List[PacketDBModel]:
"""Fetch newest packet rows as validated `PacketDBModel` instances.""" """Fetch newest packet rows as validated `PacketDBModel` instances."""
if self._pool is None: if self._pool is None:

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import json import json
import logging import logging
import os import os
@@ -13,6 +14,7 @@ import time
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Iterable, Optional from typing import Any, Dict, Iterable, Optional
import src.shared_objects as shared_objects
from src.config import settings from src.config import settings
from src.utilities.flow_identity import flow_cache_key, flow_key_from_endpoints, flow_key_from_packet, packet_observed_at_ms from src.utilities.flow_identity import flow_cache_key, flow_key_from_endpoints, flow_key_from_packet, packet_observed_at_ms
@@ -36,6 +38,54 @@ def _infer_is_encrypted(payload: Dict[str, Any]) -> Optional[bool]:
return None return None
def _build_enrichment(payload: Dict[str, Any], iface: str, flow_key: str) -> Dict[str, Any]:
metadata = {
"iface": iface,
"flow_key": flow_key,
"first_seen_ms": int(payload.get("first_seen_ms") or 0) or None,
"last_seen_ms": int(payload.get("last_seen_ms") or 0) or None,
"event": payload.get("event"),
"bidirectional_packets": payload.get("bidirectional_packets"),
"bidirectional_bytes": payload.get("bidirectional_bytes"),
}
metadata = _merge_metadata(
metadata,
{
"requested_server_name": payload.get("requested_server_name"),
"client_fingerprint": payload.get("client_fingerprint"),
"server_fingerprint": payload.get("server_fingerprint"),
"user_agent": payload.get("user_agent"),
"content_type": payload.get("content_type"),
},
)
return {
"app_protocol": payload.get("application_name"),
"app_master_protocol": payload.get("application_name"),
"app_category": payload.get("application_category_name"),
"app_confidence": payload.get("application_confidence"),
"app_hostname": payload.get("requested_server_name"),
"app_is_encrypted": _infer_is_encrypted(payload),
"dpi_metadata": {"nfstream": metadata},
}
def _has_useful_metadata(payload: Dict[str, Any]) -> bool:
return any(
payload.get(key) not in (None, "", [], {})
for key in (
"application_name",
"application_category_name",
"application_confidence",
"requested_server_name",
"client_fingerprint",
"server_fingerprint",
"user_agent",
"content_type",
)
)
class NFStreamManager: class NFStreamManager:
"""Own long-lived NFStream subprocesses and a short-lived flow metadata cache.""" """Own long-lived NFStream subprocesses and a short-lived flow metadata cache."""
@@ -92,36 +142,7 @@ class NFStreamManager:
window_ms = settings.nfstream_lookup_window_ms window_ms = settings.nfstream_lookup_window_ms
if now_ms + window_ms < first_seen_ms or now_ms - window_ms > last_seen_ms: if now_ms + window_ms < first_seen_ms or now_ms - window_ms > last_seen_ms:
return {} return {}
return _build_enrichment(payload, iface, flow_key)
metadata = {
"iface": iface,
"flow_key": flow_key,
"first_seen_ms": first_seen_ms or None,
"last_seen_ms": last_seen_ms or None,
"event": payload.get("event"),
"bidirectional_packets": payload.get("bidirectional_packets"),
"bidirectional_bytes": payload.get("bidirectional_bytes"),
}
metadata = _merge_metadata(
metadata,
{
"requested_server_name": payload.get("requested_server_name"),
"client_fingerprint": payload.get("client_fingerprint"),
"server_fingerprint": payload.get("server_fingerprint"),
"user_agent": payload.get("user_agent"),
"content_type": payload.get("content_type"),
},
)
return {
"app_protocol": payload.get("application_name"),
"app_master_protocol": payload.get("application_name"),
"app_category": payload.get("application_category_name"),
"app_confidence": payload.get("application_confidence"),
"app_hostname": payload.get("requested_server_name"),
"app_is_encrypted": _infer_is_encrypted(payload),
"dpi_metadata": {"nfstream": metadata},
}
def get_debug_snapshot(self) -> Dict[str, Any]: def get_debug_snapshot(self) -> Dict[str, Any]:
"""Expose current worker/cache state for API debugging.""" """Expose current worker/cache state for API debugging."""
@@ -259,9 +280,13 @@ class NFStreamManager:
continue continue
with self._lock: with self._lock:
previous = self._cache.get(flow_cache_key(iface, flow_key))
self._cache[flow_cache_key(iface, flow_key)] = dict(payload) self._cache[flow_cache_key(iface, flow_key)] = dict(payload)
self._purge_cache_locked() self._purge_cache_locked()
if self._should_backfill(previous, payload):
self._schedule_backfill(iface, flow_key, payload)
rc = process.poll() rc = process.poll()
if rc not in (0, None): if rc not in (0, None):
logger.warning("NFStream worker for %s exited with code %s", iface, rc) logger.warning("NFStream worker for %s exited with code %s", iface, rc)
@@ -280,5 +305,60 @@ class NFStreamManager:
for key in stale_keys: for key in stale_keys:
self._cache.pop(key, None) self._cache.pop(key, None)
def _should_backfill(self, previous: Optional[Dict[str, Any]], current: Dict[str, Any]) -> bool:
if not _has_useful_metadata(current):
return False
if previous is None:
return True
interesting_keys = (
"application_name",
"application_category_name",
"application_confidence",
"requested_server_name",
"client_fingerprint",
"server_fingerprint",
"user_agent",
"content_type",
)
return any(previous.get(key) != current.get(key) for key in interesting_keys)
def _schedule_backfill(self, iface: str, flow_key: str, payload: Dict[str, Any]) -> None:
web_db = getattr(shared_objects, "db", None)
web_loop = getattr(shared_objects, "web_loop", None)
if web_db is None or web_loop is None:
return
enrichment = _build_enrichment(payload, iface, flow_key)
async def _backfill() -> None:
updated = await web_db.backfill_flow_metadata(
iface=iface,
src_ip=str(payload.get("src_ip")),
dst_ip=str(payload.get("dst_ip")),
src_port=int(payload.get("src_port") or 0),
dst_port=int(payload.get("dst_port") or 0),
protocol=int(payload.get("protocol") or 0),
first_seen_ms=int(payload.get("first_seen_ms") or 0),
last_seen_ms=int(payload.get("last_seen_ms") or 0),
enrichment=enrichment,
window_ms=settings.nfstream_lookup_window_ms,
)
if updated:
logger.debug("Backfilled NFStream metadata for %s packets on %s flow=%s", updated, iface, flow_key)
try:
future = asyncio.run_coroutine_threadsafe(_backfill(), web_loop)
future.add_done_callback(self._log_backfill_result)
except Exception:
logger.exception("Failed to schedule NFStream metadata backfill for %s", flow_key)
@staticmethod
def _log_backfill_result(future: Any) -> None:
try:
future.result()
except Exception:
logger.exception("NFStream metadata backfill task failed")
nfstream_manager = NFStreamManager() nfstream_manager = NFStreamManager()