Architecture - NormB/sipnab GitHub Wiki

A codemap for contributors: what lives where, how data flows, and the design decisions that explain the shape of the code. For the historical roadmap and the full design-decision catalog (D1–D21), see design/implementation-plan-v6.md. This file tracks the code as it exists.

One binary, two run modes, four answer surfaces

sipnab is a single binary (plus the sipnab-audio dlopen'd plugin crate). It runs either as a TUI (interactive, ratatui) or in batch/CLI mode (parse β†’ print/JSON/report β†’ exit). Both modes share one library (src/lib.rs). The binary (src/main.rs) wires CLI flags to library calls.

The run mode decides who drives. It does not decide who can ask: the same analysis is readable over four surfaces, all of them readers of the same two stores, none of them a second copy of the state.

Surface Flag Who asks
Terminal default A person at a TUI, or a script reading text/JSON/NDJSON
REST API --api Another program, over HTTP
MCP --mcp, plus --mcp-transport http to serve it over HTTP An AI agent, calling Model Context Protocol tools
Prometheus --metrics A scraper

MCP is not a wrapper bolted over the CLI. It is a first-class reader (src/mcp/server.rs) that takes the same read locks the TUI takes, which is why an agent can query a live capture while it is still running. No tool mutates the analysis. For the write-back question and why it stays closed, see design/mcp-write-back.md.

Two surfaces answering one question derive the answer once. GET /v1/dialogs and the MCP list_dialogs page each report which methods their match set holds, and both call method_breakdown in src/sip/dialog.rs rather than tallying separately, so the ordering rule and its tie-break cannot drift apart between them. Each tallies the whole match set rather than the page it returns, because the composition of one page answers a different question. A fact two surfaces state is a fact one function derives.

The core is synchronous. Async (tokio) exists only at the edges, on one shared runtime (src/app/servers.rs), for the optional API and MCP servers.

Data flow

capture sources                 per-packet path                    consumers
───────────────                ─────────────────                  ──────────
live device  ─┐
pcap file    ──   Packet    PacketProcessor::process      ParsedPacket
HEP listener ─┼──► (Bytes) ──► link/IP decap, IP-frag ──► (payload = Bytes ──► pipeline::process_packet
stdin        β”€β”˜    channel     + TCP reassembly,           slice, zero-copy)   β”‚  ws unwrap β†’ SIP parse
                               TCP SIP framing                                 β”‚  β†’ RTCP β†’ RTP/heuristic
                                                                               β–Ό
                                                     DialogStore ◄──── SDP links ────► StreamStore
                                                    (Arc<RwLock>)                     (Arc<RwLock>)
                                                          β–²                                β–²
                                          TUI (try_read) ─┴─ outputs / API / MCP (read) β”€β”€β”€β”˜

Parsing always happens outside the store locks. Each store is write-locked once per packet, briefly. Payloads are bytes::Bytes slices of the captured frame end to end (see docs/internals/zero-copy-payloads.md).

All four packet paths β€” live, batch, TUI file-open and the --cores sharded path (parallel.rs) β€” classify through the one pipeline::classify_packet router; only the per-path appliers differ.

For the same journey narrated packet by packet β€” every decision point, both store writes, and where each of the four paths diverges β€” see docs/internals/subsystem-guide.md.

Module map

Annotations cover only the load-bearing files. Siblings follow the same pattern.

src/
β”œβ”€β”€ lib.rs                # curated public API; #[doc(hidden)] on bin-internal modules
β”œβ”€β”€ main.rs               # binary: thin dispatcher into app/
β”œβ”€β”€ app/                  # binary orchestration as library code
β”‚   β”œβ”€β”€ bootstrap.rs      # CLI+config β†’ RunPlan (mode/source/policy) + launch
β”‚   β”œβ”€β”€ batch.rs          # BatchRunner: batch/offline receive loop, reports
β”‚   β”œβ”€β”€ servers.rs        # API + MCP servers on one shared tokio runtime
β”‚   └── tui_mode.rs       # TUI mode entry
β”œβ”€β”€ cli.rs                # clap definitions (the terminal SIP tools flag superset)
β”œβ”€β”€ config.rs             # sipnabrc parsing/merging (toml_edit for surgical writes)
β”œβ”€β”€ pipeline.rs           # THE shared per-packet protocol router (all four paths)
β”œβ”€β”€ parallel.rs           # --cores N: shard-by-host-pair offline reconstruction
β”œβ”€β”€ auth.rs / crypto.rs   # HMAC bearer tokens for api/mcp
β”œβ”€β”€ analysis.rs           # capture-level verdict behind --analyze / find_problems
β”œβ”€β”€ expect.rs             # capture thresholds as a pass/fail gate (MCP only)
β”œβ”€β”€ provenance.rs         # capture identity + store generation (frame pointers)
β”œβ”€β”€ stun.rs               # STUN/TURN parse and the request that never came back
β”œβ”€β”€ error.rs              # typed error enums: Error (config/CLI), ParseError (parse_sip/_bytes/_rtp_header/_sdp), CaptureError (parse_packet/PcapReader) β€” all re-exported at the crate root
β”œβ”€β”€ names.rs              # name resolution + [names.manual] persistence
β”œβ”€β”€ privilege.rs          # setuid drop, chroot (drop early, drop hard)
β”œβ”€β”€ process_isolation.rs  # isolated child for active responses (scanner kill)
β”œβ”€β”€ signals.rs            # signal handling
β”œβ”€β”€ capture/              # sources + L2-L4
β”‚   β”œβ”€β”€ mod.rs            # PacketProcessor: decap, IP-frag/TCP reassembly, TCP SIP framing
β”‚   β”œβ”€β”€ live.rs / file.rs / pcap_reader.rs   # libpcap live, libpcap file, pure-Rust reader
β”‚   β”œβ”€β”€ channel.rs        # capped packet channel (capture β†’ processing)
β”‚   β”œβ”€β”€ parse.rs          # link/IP/transport decap β†’ ParsedPacket
β”‚   β”œβ”€β”€ reassembly.rs     # IPv4/IPv6 fragments + TCP segments (RFC-annotated)
β”‚   β”œβ”€β”€ hep.rs            # HEP v2/v3 in/out (Homer)
β”‚   β”œβ”€β”€ tls.rs / decrypt.rs / dtls.rs / rsa_key.rs   # TLS record decryption (tls feature)
β”‚   β”œβ”€β”€ websocket.rs      # WS frame unwrap (SIP over WebSocket)
β”‚   β”œβ”€β”€ tunnel/           # MPLS/PPPoE/GTP-U/VXLAN/NSH decap β†’ one offset each
β”‚   β”œβ”€β”€ uprobe/           # eBPF uprobe TLS plaintext source (bpf feature)
β”‚   β”œβ”€β”€ merged.rs / mapped.rs / native.rs   # merged-pcapng probe, mmap reader, AF_PACKET
β”‚   └── writer.rs / atomic.rs / pcapng_meta.rs       # pcap/pcapng export
β”œβ”€β”€ sip/
β”‚   β”œβ”€β”€ parser.rs         # zero-copy-spine SIP parser (DoS caps on headers/line length)
β”‚   β”œβ”€β”€ message.rs        # SipMessage (raw/body are Bytes slices; lazy accessors)
β”‚   β”œβ”€β”€ dialog.rs         # SipDialog state machine
β”‚   β”œβ”€β”€ dialog_store.rs   # capped store; Call-ID map + endpoint index; batched eviction
β”‚   β”œβ”€β”€ sdp.rs / sdp_timeline.rs   # SDP parse; offer/answer timeline (hold/resume/T.38)
β”‚   β”œβ”€β”€ dsl.rs            # --filter expression language (nom); regexes compiled at parse
β”‚   β”œβ”€β”€ matcher.rs        # header/payload regex matching
β”‚   β”œβ”€β”€ lint/             # RFC conformance rules (message/dialog/media)
β”‚   └── method.rs / response_codes.rs / timing.rs / stir_shaken.rs / siprec.rs
β”œβ”€β”€ rtp/                  # first-class peer of sip/ (streams exist without dialogs)
β”‚   β”œβ”€β”€ stream.rs / stream_store.rs   # RtpStream; SSRC-indexed capped store
β”‚   β”œβ”€β”€ parser.rs / rtcp.rs           # RTP header, RTCP SR/RR/XR
β”‚   β”œβ”€β”€ quality.rs        # jitter/loss/MOS, burst-gap
β”‚   β”œβ”€β”€ heuristic.rs      # RTP discovery without SDP
β”‚   β”œβ”€β”€ diagnosis.rs      # one-way audio, NAT mismatch
β”‚   β”œβ”€β”€ srtp.rs           # SRTP auth+decrypt (tls feature)
β”‚   β”œβ”€β”€ dtmf.rs           # RFC 4733 events
β”‚   β”œβ”€β”€ loss_map.rs       # per-interval loss/jitter map behind the TUI's `L` view
β”‚   └── g711.rs / opus_decode.rs / wav.rs / audio_export.rs / playback.rs
β”œβ”€β”€ security/             # passive-first detection
β”‚   β”œβ”€β”€ scanner_detect.rs / fraud_detect.rs / digest_leak.rs / reg_flood.rs
β”‚   β”œβ”€β”€ scanner_kill.rs   # active response (via process_isolation)
β”‚   β”œβ”€β”€ tfps.rs           # optional TFPS peer: find tfps_ctl, ask it, read the answer
β”‚   └── alerting.rs       # rule engine + event-exec hooks
β”œβ”€β”€ output/
β”‚   β”œβ”€β”€ model.rs            # canonical DialogSummary/StreamSummary (one JSON+MCP wire shape)
β”‚   β”œβ”€β”€ cli_print.rs / json.rs / hexdump.rs / fail2ban.rs / wireshark.rs
β”‚   β”œβ”€β”€ dialog_report.rs / call_report.rs / synthetic.rs
β”‚   β”œβ”€β”€ api.rs            # axum REST (api feature)
β”‚   β”œβ”€β”€ prometheus.rs / prometheus_server.rs
β”‚   β”œβ”€β”€ vcon.rs / vcon_schema.rs   # vCon container export (vcon feature)
β”‚   β”œβ”€β”€ redact.rs         # --redact rewriting, with the reversible map
β”‚   └── event_exec.rs     # external command hooks
β”œβ”€β”€ mcp/                  # MCP server (mcp feature): server.rs, transport.rs, shape.rs
β”‚   └── tools/            # tool bodies split out of server.rs, one module per group
β”œβ”€β”€ rtpengine/            # rtpengine ng control-plane decode (bencode, ng, control)
β”œβ”€β”€ relay/                # the media-relay seam: what sipnab needs from ANY relay
β”œβ”€β”€ plugin/               # WASM plugin host (plugins feature; no interpreter without it)
β”œβ”€β”€ llmnr/                # LLMNR (RFC 4795) decode + name store
β”œβ”€β”€ tui/
β”‚   β”œβ”€β”€ mod.rs            # App state + event loop
β”‚   β”œβ”€β”€ state.rs          # per-view state structs + derived-data caches (LadderCache/DisplayedCache), refreshed by App::sync_caches()
β”‚   β”œβ”€β”€ controllers/      # key/mouse handling, one module per view/popup (KeyAction mapping layer)
β”‚   β”œβ”€β”€ render/           # frame composition: mod.rs + popups.rs + status.rs (render pass is read-only)
β”‚   β”œβ”€β”€ call_list.rs / stream_list.rs / stream_detail.rs / msg_raw.rs
β”‚   β”œβ”€β”€ call_flow/        # ladder diagram: prepare.rs (layout) + render.rs + arrows/export
β”‚   β”œβ”€β”€ save.rs / help.rs / theme.rs
β”œβ”€β”€ wasm.rs               # wasm-bindgen browser surface (wasm feature)
└── test_utils.rs         # shared SIP message builder for tests
crates/sipnab-audio/      # rodio/ALSA playback plugin, dlopen'd (no libasound NEEDED)
fuzz/                     # cargo-fuzz targets (18), out-of-workspace; weekly run via .github/workflows/fuzz.yml
tests/property_test.rs    # proptest properties (SIP/SDP round-trip, filter-DSL total-function)
harness/                  # docker-compose e2e (opensips + rtpengine + sipp)

Threading model

See docs/internals/threading.md for the full topology and lock discipline. Short version: capture threads β†’ bounded channel β†’ one processing thread that owns all store writes. TUI, API and MCP are readers. Batch --cores N shards packets by host pair to worker threads with thread-local stores, merged at EOF.

Key design decisions (abridged; full text in design/implementation-plan-v6.md)

  • D2 β€” Synchronous core, async only at the edges. The packet path is plain threads + channels; tokio appears only inside the optional API/MCP/Prometheus servers.
  • D3 β€” Zero-copy payload spine. Packet.data, ParsedPacket.payload, SipMessage.raw/body are refcounted Bytes slices of one buffer. Header parsing allocates lazily (canonical-name table + Cow unfold, WS4.1) β€” 3β†’1 allocations per header on the hot path.
  • D10 β€” Feature gates keep the binary small. native, tui, tls, hep, api, mcp, mcp-http, audio, wasm. Check Cargo.toml before assuming a build includes a module.
  • D11 β€” Key material is toxic waste. zeroize on key types, redacting Debug impls, --tls-key/keylog material never logged.
  • D13 β€” RTP is first-class. sipnab discovers streams heuristically even without SIP/SDP; rtp/ never depends on a dialog existing.
  • D15 β€” Privilege drop. sipnab sets PR_SET_NO_NEW_PRIVS at startup on every run mode β€” root or not, because that flag has no precondition and the recommended --setup-caps install has no root to drop β€” then drops root right after socket open and disables core dumps whenever decryption keys are resident (src/privilege.rs); --chroot is available for daemon deployments.
  • D16 β€” Process isolation: specified, not shipped. D16 (docs/design/implementation-plan-v6.md:624) called for scanner-kill and the REST API to run in forked children behind a Unix socket pair. Neither does. Scanner-kill runs in the scanner-kill thread (src/process_isolation.rs) and the REST/MCP servers run as tasks on a shared runtime thread (src/app/servers.rs), all in one address space alongside the parsers, the stores, TLS key material and bearer tokens. Treat the API bind address and key accordingly β€” and note that panic = "abort" (Cargo.toml:328) means a panic on any thread ends the whole process, so threads buy no fault containment either. The analysis of whether to close this gap, and why most of it should stay open, is in docs/design/process-isolation-and-hot-path-cost.md.
  • D17 β€” Warn and continue. Malformed input must never crash the process; parsers set parse_error and keep going (docs/fault-model.md).

Where to add things

You want to… Touch
Support a new SIP header accessor sip/message.rs (+ parser test)
Add a per-packet protocol behavior pipeline.rs (one classifier β€” all paths route through it)
Add an output format output/ + dispatch in app/batch.rs
Add a TUI view/keybinding tui/mod.rs (App/Popup) + tui/state.rs + tui/controllers/ + render/ + keybinding drift test
Add a detection security/ + wiring in app/batch.rs
Add an MCP tool mcp/tools/ for a new group, else mcp/server.rs (#[tool]) + mcp/shape.rs
Add a CLI flag cli.rs + flag_coverage_test.rs forces a test

This table names the files. It does not name the order, the tests each change owes, or the gates that reject it. docs/internals/walkthroughs.md has an ordered checklist for each row.

⚠️ **GitHub.com Fallback** ⚠️