Contributor deep-dive
How NetWatch works,
from the kernel up.
A real-time network-diagnostics TUI in Rust — capture, 15-protocol DPI, live TLS 1.3 / QUIC decryption, per-process attribution, and a Landlock sandbox, all from one tick loop.
The shape of the program
One owner, many feeders
OS/kernel sources feed collectors on background threads; each publishes into the single App state, which the loop renders immediate-mode.
Kernel / OS sources
Collectors (threads)
App state
UI (ratatui)
Where things live
The crate, by weight
src/collectors/ ~11k
13 collectors — traffic, connections, packets+DPI, health, network-intel, incident, geo, insights, and more.
src/ui/ ~10k
One render module per tab plus shared widgets.rs, help, settings, and the sort-picker overlay.
src/dpi/ ~5k
15 L7 classifiers, the JA4/JA4Q fingerprinters, and the TLS 1.3 / QUIC decryptors.
src/app.rs ~3.2k
The App state struct, the run() event loop, tick(), and every key/mouse handler.
src/platform/ ~1.5k
OS-specific interface stats & capture helpers — Linux, macOS (incl. PKTAP), Windows.
src/sandbox/ · src/ebpf/ ~1k
Landlock + capability-drop sandbox, and the optional eBPF connection tracker / RTT monitor.
main.rs → app::run
The event loop
After main puts the terminal in raw / alternate-screen mode, control hands to app::run. Its shape is the whole program in miniature:
- render — draw current state for the active tab
- wait — block on the next
AppEvent - handle — key/mouse mutate state directly; a tick refreshes collectors
The sandbox is applied here, right after App::new() — once pcap, PKTAP and the eBPF kprobe are already open, privileges can be dropped.
src/event.rs
Input on its own thread
poll() blocks, so input runs on a dedicated OS thread that funnels key/mouse events — and, on timeout, synthetic ticks — into an mpsc channel.
- The poll timeout is the tick interval
- Tick rate is an
Arc<AtomicU64>— the Settings overlay changes refresh rate live, no restart - Three event kinds, one channel:
Key,Mouse,Tick
app::tick()
One tick, staggered work
A counter per subsystem spreads expensive work across ticks, so a ~1 Hz heartbeat never hammers lsof or ICMP. (Packet capture runs off-clock, on its own thread.)
every tick
~1s
~5s
~10s
Keeping the UI smooth
Single-flight background work
Collectors do blocking I/O (lsof, syscalls, sockets). The rule: never block the render loop. Each collector spawns a thread, writes into shared state, and the UI reads the last snapshot.
- Shared state is
Arc<Mutex>/RwLock - A
busyAtomicBool+compare_exchangeis a single-flight guard — if last sample's still running, skip rather than stack threads - Capture start/stop coordinates through atomics too
Who owns this socket?
Three attribution sources, merged
The Connections tab maps each flow to a (pid, process). Userspace polling (lsof/ss) is the portable baseline — but it misses short-lived flows. Two kernel paths fill that gap.
- Lsof — portable userspace fallback
- Pktap — macOS kernel capture carries the owning pid
- Ebpf — Linux
tcp_v4_connectkprobe, drained from a ring buffer into an attribution cache
src/collectors/packets/
From wire to stream
A capture thread reads frames in batches, parses L2–L4, threads each into its Stream, runs DPI once per stream, and decrypts when keys are available — all into a bounded ring buffer.
Bounded everywhere — 5000 packets, ≤1024 streams, ≤2 MB/stream. A diagnostics tool must never OOM the box.
src/dpi/ · 15 classifiers
One trait, priority dispatch
Every protocol decoder implements one trait. classify_once runs them in priority order — cheap magic-byte checks first, parser-based ones last — and the first match wins, then it's cached on the stream.
src/dpi/tls_decrypt.rs
The Wireshark trick, in a TUI
A client launched with SSLKEYLOGFILE appends its secrets to a file. NetWatch tails it, indexes by the ClientHello random on the wire, derives AEAD keys, and reads TLS 1.3 plaintext.
Read-only by design — only when the client cooperatively exports its keys. Production traffic, malware, third parties never are.
Verified vs RFC 8448 vectors + live captures. QUIC 1-RTT reuses the same primitives — try_decrypt_quic_1rtt.
src/ebpf/ · optional feature
Kernel-level attribution
Behind --features ebpf (Linux), a kprobe on tcp_v4_connect fires at connect-entry. A background thread drains decoded events from the SDK's channel into an attribution cache the Connections collector consults.
- Off by default; stubs keep every other platform compiling
- Status is surfaced in the UI:
Active/Unavailable/NotCompiled - Phase 1 = IPv4 TCP connect only; keyed by
(daddr, dport)since the source port isn't assigned yet at connect-entry
src/sandbox/ · Landlock
Earn privilege, then drop it
NetWatch may run under sudo for capture. So the moment setup is done — pcap fds open, kprobe attached — it drops capabilities and draws a Landlock filesystem boundary. A sudo'd NetWatch still can't read your home dir.
Three modes: Disabled · BestEffort (degrade quietly on old kernels) · Strict (refuse to start if it can't enforce).
src/collectors/incident.rs
Catch the incident, export the bundle
A rolling window keeps recent connections, health, bandwidth, DNS, alerts — and packets when capture is on. A critical threat alert can auto-freeze the window so the evidence survives.
- Shift+R arm / disarm the rolling recorder
- Shift+F freeze the current incident window
- Shift+E export the bundle to your home dir
Detectors (port scan, beaconing, DNS tunnel) in network_intel.rs feed both the dashboard and the recorder.
Exported incident bundle
📄 summary.md
📄 manifest.json
📄 connections.json · health.json
📄 bandwidth.json · dns.json · alerts.json
📦 packets.pcap (when captured)
src/ui/ · ratatui
Immediate-mode, one fn per tab
There's no retained widget tree. Every frame, the active tab's render(f, &app, area) reads the current snapshot and paints from scratch — so the screen can never drift from state.
- 9 tab modules +
widgets.rsshared building blocks - Overlays (help, settings, sort-picker, memory-stats) draw on top of whichever tab is active
theme.rs+graph.rsdrive colors, sparklines, fade — the look you see in the demo
Render takes &app — reads only. Writes live in the key/tick handlers.
src/remote.rs · optional
Stream snapshots to Core
With --remote <url> --api-key <key>, NetWatch becomes an agent — each tick it builds a JSON snapshot (host, interfaces, health) and a thread POSTs it to Core.
- Entirely opt-in — zero network egress without the flags
- The publisher owns its own thread; the run loop just calls
update() - Snapshot shape mirrors the cloud dashboard's ingest contract
The seam between the OSS TUI and the cloud product — the agent half lives here.
Your first patch
Add a protocol in one file
The DPI layer is the friendliest on-ramp — self-contained, well-bounded, immediately visible in the Packets tab.
- 1. New file in
src/dpi/with a struct implementingClassifier::classify - 2. Add an
AppProtocolvariant for what you extract - 3. Wire it into
classify_oncein priority order (cheap checks first) - 4. It now renders live in the Packets tab — no UI work needed
Tests include DPI byte-vectors + RFC 8448 — add yours alongside.
The whole map, once more
What you just toured
Spine
main → run: render → wait → handle. Input on its own thread; a tick fans out staggered refreshes.
Collectors
Background threads, single-flight guards, snapshots into one App behind Arc<Mutex>.
Capture → DPI
pcap → StreamTracker reassembly → classify_once (15 protocols) → bounded ring buffer.
Decryption
SSLKEYLOGFILE → HKDF → AEAD for TLS 1.3 & QUIC 1-RTT. Read-only, cooperating-client only.
Attribution
Lsof baseline + macOS PKTAP + Linux eBPF kprobe, merged onto every flow.
Safety
Landlock + cap-drop after setup. Flight recorder for incident bundles.
Start in src/dpi/, read WIKI.md for the maintained source map, and cargo test before you push.
github.com/matthart1983/netwatch · press o to jump back to any section.