NetWatch architecture tour
1 / 0
next · o overview · ? help

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.

~33k LOC Rust ratatui · 9 tabs tokio + threads pcap · eBPF · Landlock

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

libpcap capture
eBPF kprobe · PKTAP
ss · lsof · /proc
SSLKEYLOGFILE

Collectors (threads)

traffic · connections
packets + DPI
health · network-intel
incident recorder

App state

single owner
Arc<Mutex> / RwLock
tick-driven refresh

UI (ratatui)

9 tab renderers
help / settings overlays
immediate-mode draw

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/app.rs — run()

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
src/event.rs

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.)

traffic
every tick
connections
~1s
health probes
~5s
interfaces+config
~10s
src/app.rs — tick()

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 busy AtomicBool + compare_exchange is a single-flight guard — if last sample's still running, skip rather than stack threads
  • Capture start/stop coordinates through atomics too
src/collectors/traffic.rs — update()

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_connect kprobe, drained from a ring buffer into an attribution cache
src/collectors/connections.rs

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.

libpcap thread · snaplen 65535 · 64-packet batches
parse Ethernet / IP / TCP·UDP
StreamTracker — reassembly · ≤1024 flows (LRU)
classify_once() — DPI, cached per stream
try_decrypt_tls_record / _quic_1rtt
ring buffer — newest 5000 · Packets tab + PCAP

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.

TLSHTTPQUICDNSSSHDHCPNTPMQTTSTUNSNMPSSDPLLMNRNetBIOSFTPBitTorrentHTTP/3
src/dpi/mod.rs

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.

SSLKEYLOGFILE → secret (by client_random)
HKDF-Expand-Label → key + iv
AEAD-open(nonce = iv ⊕ seq) → plaintext

Read-only by design — only when the client cooperatively exports its keys. Production traffic, malware, third parties never are.

src/dpi/tls_decrypt path — StreamTracker

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/ebpf/mod.rs

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.

App::new() — open pcap · PKTAP · eBPF (needs caps)
sandbox::apply() — drop CAP_NET_RAW/BPF/PERFMON · Landlock
run loop — restricted: read /proc + dbs, write cache/exports only

Three modes: Disabled · BestEffort (degrade quietly on old kernels) · Strict (refuse to start if it can't enforce).

src/sandbox/mod.rs

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.rs shared building blocks
  • Overlays (help, settings, sort-picker, memory-stats) draw on top of whichever tab is active
  • theme.rs + graph.rs drive colors, sparklines, fade — the look you see in the demo
src/app.rs — terminal.draw()

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
src/remote.rs

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 implementing Classifier::classify
  • 2. Add an AppProtocol variant for what you extract
  • 3. Wire it into classify_once in priority order (cheap checks first)
  • 4. It now renders live in the Packets tab — no UI work needed
build & verify (CONTRIBUTING.md)

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.

Sections — click to jump

Navigation

Space j lnext slide
k hprevious slide
Home / Endfirst / last
19jump to slide
o / Escsection overview
ftoggle fullscreen
?this help

swipe on touch · click left / right edges · Esc to close