refactor(tests): move flat tests/*.py into per-subsystem subfolders

Groups every flat test_*.py under the module it exercises, matching the
existing tests/{profiler,sniffer,prober,collector,correlation,cli,web,
topology,swarm,bus,updater,api,docker,geoip,...} layout. New folders:
services/, fleet/, config/, logging/, db/ (+ db/mysql/), telemetry/,
mutator/, core/.

Path-dependent __file__ references bumped an extra .parent in three
files that moved one level deeper:
- tests/sniffer/test_sniffer_ja3.py   (template path)
- tests/services/test_ssh_capture_emit.py (template path)
- tests/cli/test_mode_gating.py  (REPO root)
- tests/web/test_env_lazy_jwt.py (repo var)

Also drops two SQLite runtime artifacts (test_decnet.db-{shm,wal}) that
were leaking into the repo from a previous test run.

Fixes two test_service_isolation cases that patched asyncio.sleep (no
longer on the profiler main-loop hot path — same pre-existing bug I
fixed earlier in test_attacker_worker.py) by patching asyncio.wait_for
and passing interval=0.
This commit is contained in:
2026-04-23 21:34:25 -04:00
parent 21e6820714
commit ea95a009df
78 changed files with 18 additions and 10 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,117 @@
"""
Unit tests for the passive p0f-lite OS fingerprint lookup.
Covers:
- initial_ttl() TTL → bucket rounding
- hop_distance() upper-bound clamping
- guess_os() signature matching for Linux, Windows, macOS, nmap,
embedded, and the unknown fallback
"""
from __future__ import annotations
from decnet.sniffer.p0f import guess_os, hop_distance, initial_ttl
# ─── initial_ttl ────────────────────────────────────────────────────────────
class TestInitialTtl:
def test_linux_bsd(self):
assert initial_ttl(64) == 64
assert initial_ttl(59) == 64
assert initial_ttl(33) == 64
def test_windows(self):
assert initial_ttl(128) == 128
assert initial_ttl(120) == 128
assert initial_ttl(65) == 128
def test_embedded(self):
assert initial_ttl(255) == 255
assert initial_ttl(254) == 255
assert initial_ttl(200) == 255
def test_very_short(self):
# anything <= 32 rounds to 32
assert initial_ttl(32) == 32
assert initial_ttl(1) == 32
def test_out_of_range(self):
# Packets with TTL > 255 (should never happen) still bucket.
assert initial_ttl(300) == 255
# ─── hop_distance ───────────────────────────────────────────────────────────
class TestHopDistance:
def test_zero_when_local(self):
assert hop_distance(64) == 0
assert hop_distance(128) == 0
assert hop_distance(255) == 0
def test_typical(self):
assert hop_distance(60) == 4 # 4 hops from Linux
assert hop_distance(120) == 8 # 8 hops from Windows
def test_negative_or_weird_still_bucketed(self):
# TTL=0 is anomalous but we still return a non-negative distance.
# TTL 0 bucket is 32 → distance = 32 - 0 = 32.
assert hop_distance(0) == 32
# ─── guess_os ───────────────────────────────────────────────────────────────
class TestGuessOs:
def test_linux_default(self):
# Modern Linux: TTL 64, window 29200+, WScale 7, full options
result = guess_os(
ttl=64, window=29200, mss=1460, wscale=7,
options_sig="M,S,T,N,W",
)
assert result == "linux"
def test_windows_default(self):
# Windows 10: TTL 128, window 64240, WScale 8, MSS 1460
result = guess_os(
ttl=128, window=64240, mss=1460, wscale=8,
options_sig="M,N,W,N,N,T,S",
)
assert result == "windows"
def test_macos_ios(self):
# macOS default: TTL 64, window 65535, WScale 6, ends with EOL
result = guess_os(
ttl=64, window=65535, mss=1460, wscale=6,
options_sig="M,N,W,N,N,T,S,E",
)
assert result == "macos_ios"
def test_nmap_sYn(self):
# nmap -sS uses tiny/distinctive windows like 1024 or 4096
result = guess_os(
ttl=64, window=1024, mss=1460, wscale=10,
options_sig="M,W,T,S,S",
)
assert result == "nmap"
def test_nmap_alt_window(self):
result = guess_os(
ttl=64, window=31337, mss=1460, wscale=10,
options_sig="M,W,T,S,S",
)
assert result == "nmap"
def test_embedded_ttl255(self):
# Any TTL bucket 255 → embedded
result = guess_os(
ttl=250, window=4128, mss=536, wscale=None,
options_sig="M",
)
assert result == "embedded"
def test_unknown(self):
# Bizarre combo nothing matches
result = guess_os(
ttl=50, window=100, mss=0, wscale=None, options_sig="",
)
assert result == "unknown"

View File

@@ -0,0 +1,108 @@
"""
Unit tests for TCP retransmit detection in the SnifferEngine flow aggregator.
A retransmit is defined as a *forward-direction* (attacker → decky) TCP
segment carrying payload whose sequence number has already been seen on
this flow. Empty SYN/ACKs that share seq legitimately are excluded.
"""
from __future__ import annotations
from scapy.layers.inet import IP, TCP
from decnet.sniffer.fingerprint import SnifferEngine
_DECKY_IP = "192.168.1.10"
_DECKY = "decky-01"
_ATTACKER_IP = "10.0.0.7"
def _mk_engine() -> tuple[SnifferEngine, list[str]]:
captured: list[str] = []
engine = SnifferEngine(
ip_to_decky={_DECKY_IP: _DECKY},
write_fn=captured.append,
dedup_ttl=0, # disable dedup for easier assertion
)
return engine, captured
def _data_pkt(seq: int, payload: bytes = b"data", sport: int = 55555):
return IP(src=_ATTACKER_IP, dst=_DECKY_IP, ttl=64) / TCP(
sport=sport, dport=22, flags="A", seq=seq, window=29200,
) / payload
def _rst(sport: int = 55555):
return IP(src=_DECKY_IP, dst=_ATTACKER_IP, ttl=64) / TCP(
sport=22, dport=sport, flags="R",
)
def _extract_retransmits(lines: list[str]) -> int:
"""Pull `retransmits=` from the last tcp_flow_timing line."""
import re
for line in reversed(lines):
if "tcp_flow_timing" not in line:
continue
m = re.search(r'retransmits="(\d+)"', line)
if m:
return int(m.group(1))
return -1
class TestRetransmitDetection:
def test_no_retransmits_when_seqs_unique(self):
engine, captured = _mk_engine()
engine.on_packet(_data_pkt(seq=1000))
engine.on_packet(_data_pkt(seq=1004))
engine.on_packet(_data_pkt(seq=1008))
engine.on_packet(_rst())
assert _extract_retransmits(captured) == 0
def test_single_retransmit(self):
engine, captured = _mk_engine()
engine.on_packet(_data_pkt(seq=2000))
engine.on_packet(_data_pkt(seq=2004))
engine.on_packet(_data_pkt(seq=2000)) # retransmitted
engine.on_packet(_rst())
assert _extract_retransmits(captured) == 1
def test_multiple_retransmits(self):
engine, captured = _mk_engine()
engine.on_packet(_data_pkt(seq=3000))
engine.on_packet(_data_pkt(seq=3000))
engine.on_packet(_data_pkt(seq=3000))
engine.on_packet(_data_pkt(seq=3004))
engine.on_packet(_rst())
# Two retransmits (original + 2 dupes of seq=3000)
assert _extract_retransmits(captured) == 2
def test_reverse_direction_not_counted(self):
"""Packets from decky → attacker sharing seq should NOT count."""
engine, captured = _mk_engine()
# Forward data
engine.on_packet(_data_pkt(seq=4000))
engine.on_packet(_data_pkt(seq=4004))
engine.on_packet(_data_pkt(seq=4008))
# Reverse response (decky → attacker) with same seq as a forward
# packet — different flow direction, must not count as retransmit.
reverse = IP(src=_DECKY_IP, dst=_ATTACKER_IP, ttl=64) / TCP(
sport=22, dport=55555, flags="A", seq=4000, window=29200,
) / b"resp"
engine.on_packet(reverse)
engine.on_packet(_rst())
assert _extract_retransmits(captured) == 0
def test_empty_segments_not_counted(self):
"""Pure ACKs (no payload) are not retransmits even if seqs repeat."""
engine, captured = _mk_engine()
# Three pure-ACKs with identical seq
for _ in range(3):
pkt = IP(src=_ATTACKER_IP, dst=_DECKY_IP, ttl=64) / TCP(
sport=55555, dport=22, flags="A", seq=5000, window=29200,
)
engine.on_packet(pkt)
engine.on_packet(_rst())
assert _extract_retransmits(captured) == 0

View File

@@ -0,0 +1,232 @@
"""
Integration tests for TCP-level passive fingerprinting in the SnifferEngine.
Covers end-to-end flow from a scapy packet through `on_packet()` to:
- tcp_syn_fingerprint event emission (OS guess, options, hop distance)
- tcp_flow_timing event emission (packet count, duration, retransmits)
- dedup behavior (one event per unique fingerprint per window)
- flow flush on FIN/RST
"""
from __future__ import annotations
from scapy.layers.inet import IP, TCP
from decnet.sniffer.fingerprint import SnifferEngine
# ─── Helpers ────────────────────────────────────────────────────────────────
_DECKY_IP = "192.168.1.10"
_DECKY = "decky-01"
_ATTACKER_IP = "10.0.0.7"
def _make_engine() -> tuple[SnifferEngine, list[str]]:
"""Return (engine, captured_syslog_lines)."""
captured: list[str] = []
engine = SnifferEngine(
ip_to_decky={_DECKY_IP: _DECKY},
write_fn=captured.append,
dedup_ttl=300.0,
)
return engine, captured
def _linux_syn(src_port: int = 45000, dst_port: int = 22, seq: int = 1000):
"""Build a synthetic SYN that should fingerprint as Linux."""
return IP(src=_ATTACKER_IP, dst=_DECKY_IP, ttl=64) / TCP(
sport=src_port,
dport=dst_port,
flags="S",
seq=seq,
window=29200,
options=[
("MSS", 1460),
("SAckOK", b""),
("Timestamp", (123, 0)),
("NOP", None),
("WScale", 7),
],
)
def _windows_syn(src_port: int = 45001):
"""Build a synthetic SYN that should fingerprint as Windows."""
return IP(src=_ATTACKER_IP, dst=_DECKY_IP, ttl=128) / TCP(
sport=src_port,
dport=3389,
flags="S",
window=64240,
options=[
("MSS", 1460),
("NOP", None),
("WScale", 8),
("NOP", None),
("NOP", None),
("SAckOK", b""),
],
)
def _fields_from_line(line: str) -> dict[str, str]:
"""Parse the SD-params section of an RFC 5424 syslog line into a dict."""
import re
m = re.search(r"\[relay@55555 (.*?)\]", line)
if not m:
return {}
body = m.group(1)
out: dict[str, str] = {}
for k, v in re.findall(r'(\w+)="((?:[^"\\]|\\.)*)"', body):
out[k] = v
return out
def _msgid(line: str) -> str:
"""Extract MSGID from RFC 5424 line."""
parts = line.split(" ", 6)
return parts[5] if len(parts) > 5 else ""
# ─── tcp_syn_fingerprint emission ──────────────────────────────────────────
class TestSynFingerprintEmission:
def test_linux_syn_emits_fingerprint(self):
engine, captured = _make_engine()
engine.on_packet(_linux_syn())
fp_lines = [ln for ln in captured if _msgid(ln) == "tcp_syn_fingerprint"]
assert len(fp_lines) == 1
f = _fields_from_line(fp_lines[0])
assert f["src_ip"] == _ATTACKER_IP
assert f["dst_ip"] == _DECKY_IP
assert f["os_guess"] == "linux"
assert f["ttl"] == "64"
assert f["initial_ttl"] == "64"
assert f["hop_distance"] == "0"
assert f["window"] == "29200"
assert f["wscale"] == "7"
assert f["mss"] == "1460"
assert f["has_sack"] == "true"
assert f["has_timestamps"] == "true"
def test_windows_syn_emits_windows_guess(self):
engine, captured = _make_engine()
engine.on_packet(_windows_syn())
fp_lines = [ln for ln in captured if _msgid(ln) == "tcp_syn_fingerprint"]
assert len(fp_lines) == 1
f = _fields_from_line(fp_lines[0])
assert f["os_guess"] == "windows"
assert f["ttl"] == "128"
assert f["initial_ttl"] == "128"
def test_hop_distance_inferred_from_ttl(self):
engine, captured = _make_engine()
pkt = IP(src=_ATTACKER_IP, dst=_DECKY_IP, ttl=58) / TCP(
sport=40000, dport=22, flags="S", window=29200,
options=[("MSS", 1460), ("SAckOK", b""), ("Timestamp", (0, 0)),
("NOP", None), ("WScale", 7)],
)
engine.on_packet(pkt)
fp_lines = [ln for ln in captured if _msgid(ln) == "tcp_syn_fingerprint"]
f = _fields_from_line(fp_lines[0])
assert f["initial_ttl"] == "64"
assert f["hop_distance"] == "6"
def test_dedup_suppresses_repeated_fingerprints(self):
engine, captured = _make_engine()
engine.on_packet(_linux_syn(src_port=40001))
engine.on_packet(_linux_syn(src_port=40002))
engine.on_packet(_linux_syn(src_port=40003))
fp_lines = [ln for ln in captured if _msgid(ln) == "tcp_syn_fingerprint"]
assert len(fp_lines) == 1 # same OS + options_sig deduped
def test_different_os_not_deduped(self):
engine, captured = _make_engine()
engine.on_packet(_linux_syn(src_port=40001))
engine.on_packet(_windows_syn(src_port=40002))
fp_lines = [ln for ln in captured if _msgid(ln) == "tcp_syn_fingerprint"]
assert len(fp_lines) == 2
def test_decky_source_does_not_emit(self):
"""Packets originating from a decky (outbound reply) should NOT
be classified as an attacker fingerprint."""
engine, captured = _make_engine()
pkt = IP(src=_DECKY_IP, dst=_ATTACKER_IP, ttl=64) / TCP(
sport=22, dport=40000, flags="S", window=29200,
options=[("MSS", 1460)],
)
engine.on_packet(pkt)
fp_lines = [ln for ln in captured if _msgid(ln) == "tcp_syn_fingerprint"]
assert fp_lines == []
# ─── tcp_flow_timing emission ───────────────────────────────────────────────
class TestFlowTiming:
def test_flow_flushed_on_fin_if_non_trivial(self):
"""A session with ≥4 packets triggers a tcp_flow_timing event on FIN."""
engine, captured = _make_engine()
# SYN + 3 data ACKs + FIN = 5 packets → passes the trivial-flow filter
pkts = [_linux_syn(src_port=50000, seq=100)]
for i, seq in enumerate((101, 200, 300)):
pkts.append(
IP(src=_ATTACKER_IP, dst=_DECKY_IP, ttl=64) / TCP(
sport=50000, dport=22, flags="A", seq=seq, window=29200,
) / b"hello-data-here"
)
pkts.append(
IP(src=_ATTACKER_IP, dst=_DECKY_IP, ttl=64) / TCP(
sport=50000, dport=22, flags="FA", seq=400, window=29200,
)
)
for p in pkts:
engine.on_packet(p)
flow_lines = [ln for ln in captured if _msgid(ln) == "tcp_flow_timing"]
assert len(flow_lines) == 1
f = _fields_from_line(flow_lines[0])
assert f["src_ip"] == _ATTACKER_IP
assert f["dst_ip"] == _DECKY_IP
assert int(f["packets"]) == 5
assert int(f["retransmits"]) == 0
def test_trivial_flow_dropped(self):
"""A 2-packet scan probe (SYN + RST) must NOT emit a timing event."""
engine, captured = _make_engine()
engine.on_packet(_linux_syn(src_port=50001, seq=200))
engine.on_packet(
IP(src=_DECKY_IP, dst=_ATTACKER_IP, ttl=64) / TCP(
sport=22, dport=50001, flags="R", window=0,
)
)
flow_lines = [ln for ln in captured if _msgid(ln) == "tcp_flow_timing"]
assert flow_lines == [] # trivial: packets<4, no retransmits, dur<1s
def test_retransmit_forces_emission_on_short_flow(self):
"""Even a 3-packet flow must emit if it contains a retransmit."""
engine, captured = _make_engine()
engine.on_packet(_linux_syn(src_port=50002, seq=300))
# Repeat a forward data seq → retransmit
for _ in range(2):
engine.on_packet(
IP(src=_ATTACKER_IP, dst=_DECKY_IP, ttl=64) / TCP(
sport=50002, dport=22, flags="A", seq=301, window=29200,
) / b"payload"
)
engine.on_packet(
IP(src=_DECKY_IP, dst=_ATTACKER_IP, ttl=64) / TCP(
sport=22, dport=50002, flags="R", window=0,
)
)
flow_lines = [ln for ln in captured if _msgid(ln) == "tcp_flow_timing"]
assert len(flow_lines) == 1
f = _fields_from_line(flow_lines[0])
assert int(f["retransmits"]) == 1
def test_flush_all_flows_helper_drops_trivial(self):
"""flush_all_flows still filters trivial flows."""
engine, captured = _make_engine()
engine.on_packet(_linux_syn(src_port=50003, seq=400))
engine.flush_all_flows()
flow_lines = [ln for ln in captured if _msgid(ln) == "tcp_flow_timing"]
assert flow_lines == [] # single packet = trivial

View File

@@ -0,0 +1,308 @@
"""
Tests for the fleet-wide sniffer worker and fingerprinting engine.
Tests the IP-to-decky mapping, packet callback routing, syslog output
format, dedup logic, and worker fault isolation.
"""
import struct
import time
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from decnet.sniffer.fingerprint import (
SnifferEngine,
_ja3,
_ja4,
_ja4_alpn_tag,
_ja4_version,
_ja4s,
_ja3s,
_parse_client_hello,
_parse_server_hello,
_parse_ssh_banner,
_session_resumption_info,
_tls_version_str,
)
from decnet.sniffer.syslog import syslog_line, write_event
from decnet.sniffer.worker import _load_ip_to_decky
# ─── Helpers ─────────────────────────────────────────────────────────────────
def _build_tls_client_hello(
tls_version: int = 0x0303,
cipher_suites: list[int] | None = None,
sni: str = "example.com",
) -> bytes:
"""Build a minimal TLS ClientHello payload for testing."""
if cipher_suites is None:
cipher_suites = [0x1301, 0x1302, 0x1303]
body = b""
body += struct.pack("!H", tls_version) # ClientHello version
body += b"\x00" * 32 # Random
body += b"\x00" # Session ID length = 0
# Cipher suites
cs_data = b"".join(struct.pack("!H", cs) for cs in cipher_suites)
body += struct.pack("!H", len(cs_data)) + cs_data
# Compression methods
body += b"\x01\x00" # 1 method, null
# Extensions
ext_data = b""
if sni:
sni_bytes = sni.encode("ascii")
sni_ext = struct.pack("!HBH", len(sni_bytes) + 3, 0, len(sni_bytes)) + sni_bytes
ext_data += struct.pack("!HH", 0x0000, len(sni_ext)) + sni_ext
body += struct.pack("!H", len(ext_data)) + ext_data
# Handshake header
hs = struct.pack("!B", 0x01) + struct.pack("!I", len(body))[1:] # type + 3-byte length
hs_with_body = hs + body
# TLS record header
record = struct.pack("!BHH", 0x16, 0x0301, len(hs_with_body)) + hs_with_body
return record
def _build_tls_server_hello(
tls_version: int = 0x0303,
cipher_suite: int = 0x1301,
) -> bytes:
"""Build a minimal TLS ServerHello payload for testing."""
body = b""
body += struct.pack("!H", tls_version)
body += b"\x00" * 32 # Random
body += b"\x00" # Session ID length = 0
body += struct.pack("!H", cipher_suite)
body += b"\x00" # Compression method
# No extensions
body += struct.pack("!H", 0)
hs = struct.pack("!B", 0x02) + struct.pack("!I", len(body))[1:]
hs_with_body = hs + body
record = struct.pack("!BHH", 0x16, 0x0301, len(hs_with_body)) + hs_with_body
return record
# ─── TLS parser tests ───────────────────────────────────────────────────────
class TestTlsParsers:
def test_parse_client_hello_valid(self):
data = _build_tls_client_hello()
result = _parse_client_hello(data)
assert result is not None
assert result["tls_version"] == 0x0303
assert result["cipher_suites"] == [0x1301, 0x1302, 0x1303]
assert result["sni"] == "example.com"
def test_parse_client_hello_no_sni(self):
data = _build_tls_client_hello(sni="")
result = _parse_client_hello(data)
assert result is not None
assert result["sni"] == ""
def test_parse_client_hello_invalid_data(self):
assert _parse_client_hello(b"\x00\x01") is None
assert _parse_client_hello(b"") is None
assert _parse_client_hello(b"\x16\x03\x01\x00\x00") is None
def test_parse_server_hello_valid(self):
data = _build_tls_server_hello()
result = _parse_server_hello(data)
assert result is not None
assert result["tls_version"] == 0x0303
assert result["cipher_suite"] == 0x1301
def test_parse_server_hello_invalid(self):
assert _parse_server_hello(b"garbage") is None
# ─── SSH banner parser tests ────────────────────────────────────────────────
class TestSshBannerParser:
def test_openssh_banner_crlf(self):
data = b"SSH-2.0-OpenSSH_9.2p1 Debian-2\r\nkex-init..."
assert _parse_ssh_banner(data) == "SSH-2.0-OpenSSH_9.2p1 Debian-2"
def test_banner_lf_only(self):
data = b"SSH-2.0-libssh2_1.10.0\n"
assert _parse_ssh_banner(data) == "SSH-2.0-libssh2_1.10.0"
def test_non_ssh_payload(self):
assert _parse_ssh_banner(b"GET / HTTP/1.1\r\n") is None
assert _parse_ssh_banner(b"") is None
assert _parse_ssh_banner(b"\x16\x03\x01\x00") is None
def test_missing_terminator(self):
# No CR/LF within the 255-byte RFC window → not a complete banner yet.
assert _parse_ssh_banner(b"SSH-2.0-OpenSSH_9.2p1" + b" " * 300) is None
def test_banner_too_short(self):
assert _parse_ssh_banner(b"SSH-\r\n") is None
def test_non_ascii_rejected(self):
assert _parse_ssh_banner(b"SSH-2.0-\xff\xfe\r\n") is None
# ─── Fingerprint computation tests ──────────────────────────────────────────
class TestFingerprints:
def test_ja3_deterministic(self):
data = _build_tls_client_hello()
ch = _parse_client_hello(data)
ja3_str1, hash1 = _ja3(ch)
ja3_str2, hash2 = _ja3(ch)
assert hash1 == hash2
assert len(hash1) == 32 # MD5 hex
def test_ja4_format(self):
data = _build_tls_client_hello()
ch = _parse_client_hello(data)
ja4 = _ja4(ch)
parts = ja4.split("_")
assert len(parts) == 3
assert parts[0].startswith("t") # TCP
def test_ja3s_deterministic(self):
data = _build_tls_server_hello()
sh = _parse_server_hello(data)
_, hash1 = _ja3s(sh)
_, hash2 = _ja3s(sh)
assert hash1 == hash2
def test_ja4s_format(self):
data = _build_tls_server_hello()
sh = _parse_server_hello(data)
ja4s = _ja4s(sh)
parts = ja4s.split("_")
assert len(parts) == 2
assert parts[0].startswith("t")
def test_tls_version_str(self):
assert _tls_version_str(0x0303) == "TLS 1.2"
assert _tls_version_str(0x0304) == "TLS 1.3"
assert "0x" in _tls_version_str(0x9999)
def test_ja4_version_with_supported_versions(self):
ch = {"tls_version": 0x0303, "supported_versions": [0x0303, 0x0304]}
assert _ja4_version(ch) == "13"
def test_ja4_alpn_tag(self):
assert _ja4_alpn_tag([]) == "00"
assert _ja4_alpn_tag(["h2"]) == "h2"
assert _ja4_alpn_tag(["http/1.1"]) == "h1"
def test_session_resumption_info(self):
ch = {"has_session_ticket_data": True, "has_pre_shared_key": False,
"has_early_data": False, "session_id": b""}
info = _session_resumption_info(ch)
assert info["resumption_attempted"] is True
assert "session_ticket" in info["mechanisms"]
# ─── Syslog format tests ────────────────────────────────────────────────────
class TestSyslog:
def test_syslog_line_format(self):
line = syslog_line("sniffer", "decky-01", "tls_client_hello", src_ip="10.0.0.1")
assert "<134>" in line # PRI for local0 + INFO
assert "decky-01" in line
assert "sniffer" in line
assert "tls_client_hello" in line
assert 'src_ip="10.0.0.1"' in line
def test_write_event_creates_files(self, tmp_path):
log_path = tmp_path / "test.log"
json_path = tmp_path / "test.json"
line = syslog_line("sniffer", "decky-01", "tls_client_hello", src_ip="10.0.0.1")
write_event(line, log_path, json_path)
assert log_path.exists()
assert json_path.exists()
assert "decky-01" in log_path.read_text()
# ─── SnifferEngine tests ────────────────────────────────────────────────────
class TestSnifferEngine:
def test_resolve_decky_by_dst(self):
engine = SnifferEngine(
ip_to_decky={"192.168.1.10": "decky-01"},
write_fn=lambda _: None,
)
assert engine._resolve_decky("10.0.0.1", "192.168.1.10") == "decky-01"
def test_resolve_decky_by_src(self):
engine = SnifferEngine(
ip_to_decky={"192.168.1.10": "decky-01"},
write_fn=lambda _: None,
)
assert engine._resolve_decky("192.168.1.10", "10.0.0.1") == "decky-01"
def test_resolve_decky_unknown(self):
engine = SnifferEngine(
ip_to_decky={"192.168.1.10": "decky-01"},
write_fn=lambda _: None,
)
assert engine._resolve_decky("10.0.0.1", "10.0.0.2") is None
def test_update_ip_map(self):
engine = SnifferEngine(
ip_to_decky={"192.168.1.10": "decky-01"},
write_fn=lambda _: None,
)
engine.update_ip_map({"192.168.1.20": "decky-02"})
assert engine._resolve_decky("10.0.0.1", "192.168.1.20") == "decky-02"
assert engine._resolve_decky("10.0.0.1", "192.168.1.10") is None
def test_dedup_suppresses_identical_events(self):
written: list[str] = []
engine = SnifferEngine(
ip_to_decky={},
write_fn=written.append,
dedup_ttl=300.0,
)
fields = {"src_ip": "10.0.0.1", "ja3": "abc", "ja4": "def"}
engine._log("decky-01", "tls_client_hello", **fields)
engine._log("decky-01", "tls_client_hello", **fields)
assert len(written) == 1
def test_dedup_allows_different_fingerprints(self):
written: list[str] = []
engine = SnifferEngine(
ip_to_decky={},
write_fn=written.append,
dedup_ttl=300.0,
)
engine._log("decky-01", "tls_client_hello", src_ip="10.0.0.1", ja3="abc", ja4="def")
engine._log("decky-01", "tls_client_hello", src_ip="10.0.0.1", ja3="xyz", ja4="uvw")
assert len(written) == 2
def test_dedup_disabled_when_ttl_zero(self):
written: list[str] = []
engine = SnifferEngine(
ip_to_decky={},
write_fn=written.append,
dedup_ttl=0,
)
fields = {"src_ip": "10.0.0.1", "ja3": "abc", "ja4": "def"}
engine._log("decky-01", "tls_client_hello", **fields)
engine._log("decky-01", "tls_client_hello", **fields)
assert len(written) == 2
# ─── Worker IP map loading ──────────────────────────────────────────────────
class TestWorkerIpMap:
def test_load_ip_to_decky_no_state(self):
with patch("decnet.config.load_state", return_value=None):
result = _load_ip_to_decky()
assert result == {}