Internals Subsystem Guide - NormB/sipnab GitHub Wiki

One packet's journey, from the wire to the screen.

  • Read ../architecture.md first for the one-screen map of the tree.
  • This page is the function-level trace through it.

The program spine

Startup is a straight line with exactly one branch. The shape first, then what each step does.

sequenceDiagram
    autonumber
    participant Main as main
    participant Cli
    participant Boot as bootstrap
    participant Plan as RunPlan
    participant Mode as run mode entry

    Main->>Cli: parse_args
    Main->>Boot: init_logging, run_startup_commands
    Main->>Boot: block_privilege_escalation (PR_SET_NO_NEW_PRIVS)
    Main->>Cli: validate
    Main->>Boot: read_config, declare_node_name
    Main->>Boot: apply_config
    Main->>Boot: install_panic_hook
    Main->>Boot: plan(cli, config)
    Boot-->>Plan: source, capture_config, policy, filter, mode
    alt mode == CoresFile
        Main->>Mode: run_cores_file (no capture thread)
    else Tui or Batch
        Main->>Boot: launch(source, capture_config)
        Boot-->>Main: capture handle + PacketRx
        Note over Boot,Main: chroot, privilege drop, runtime hardening
        Main->>Mode: run_tui_mode or batch::run
    end

main() is deliberately thin.

  • It has ten numbered steps plus four lettered sub-steps, across 171 lines of main.rs.
  • Each step delegates to app/bootstrap.rs.

The order matters:

  • logging before anything can log
  • immediate commands (--setup-caps, --strip-secrets) before config loading
  • PR_SET_NO_NEW_PRIVS immediately after them, and before it reads any input
    • --setup-caps runs sudo setcap.
    • sudo is a setuid binary the flag would break.
  • signal handlers and CLI validation before it acquires any resource
  • the crash hook armed before the first fallible step
  • only then the plan

The hardening step is in main() rather than in launch(). Two reasons:

  • It applies to every run mode. That includes the CoresFile one that never reaches launch().
  • It has no precondition. The privilege drop differs: it needs root to do anything at all.

plan() decides everything up front and touches nothing. It decides:

  • capture-source precedence (-I file > -d device > config device > --hep-listen > auto-detect)
  • capture config
  • port range
  • output and autostop policy
  • the compiled filter expression
  • the run mode

It starts no capture, changes no privileges, and never exits the process.

  • Every fatal misconfiguration comes back as a PlanError for main() to handle.
  • The result is a RunPlan.
  • RunPlan::mode is one of three variants:
RunMode Entry point Shape
Tui run_tui_mode() Capture thread → channel → tui-processor thread → shared stores; main thread renders.
Batch run() Capture thread → channel → the main thread's own receive loop.
CoresFile run_cores_file() No capture thread at all: the file reader shards straight to worker threads.

CoresFile dispatches before launch().

  • Multi-core file reconstruction owns its own reader.
  • So it skips the capture thread, channel, and privilege-drop handshake.

For the other two modes, launch() does these in order:

  • builds the channel
  • starts the capture thread
  • waits for the readiness handshake
  • chroots, drops privileges, and applies runtime hardening

sipnab sheds privileges after the socket exists and before it parses the first packet.

The six hops

Every mode is the same six hops. Only who performs hop 5 (Apply) differs.

  • Source.
    • capture/live.rs, capture/file.rs or capture/hep.rs produce a Packet.
    • Its data is a bytes::Bytes slice of the captured frame.
    • There is no copy, here or anywhere downstream.
    • A HEP packet arrives pre-parsed.
      • Its addresses, ports and IP protocol number come from HEP chunks.
      • parse_packet_unstamped maps the number to a transport in hep_transport.
      • --hep-parse's unwrap_hep in app/batch.rs calls it too.
      • Only for a packet a HEP wrapper delivered:
        • 22 reads as TLS and 50 as WS. These are the fake numbers OpenSIPS and Kamailio send for decrypted TLS and WebSocket legs.
        • The message's top Via narrows 22, 50 or 6 to WS or WSS.
      • A captured frame never takes that reading.
      • See How --hep-listen reads the transport.
    • A stored vCon container read by -I arrives pre-parsed too.
      • capture/vcon_input.rs rebuilds each stored SIP message as wire text.
      • It sets PreParsed::vcon. That carries the transport the container named. It also makes the parsed packet's origin InputOrigin::Vcon.
      • The one detection rule is sniffs_as_container: the first byte other than whitespace is {.
    • A live device name goes to libpcap untouched.
      • libpcap picks the capture backend from it (netmap:eth0 selects netmap).
      • So which backends a binary reaches is a property of the libpcap it loads, not of sipnab.
      • capture/libpcap.rs asks that library at runtime through pcap_lib_version().
      • It reads the banner in one pure function, parse_banner.
      • --version, the TUI help, MCP server_capabilities and REST GET /v1/capabilities all report its running().
      • None of them may name a backend the banner does not.
  • Channel.
    • capture::channel moves it to the consumer.
    • It is an unbounded crossbeam channel with a count-capped permit pool.
    • So idle memory returns to ~0.
    • A saturated pipeline still blocks the capture loop rather than growing without bound.
  • Parse.
    • PacketProcessor::process() turns one Packet into zero or more ParsedPackets.
    • It does link-layer decap, IP defragmentation, TCP reassembly and tunnel unwrapping, via parse_packet().
    • One frame can yield several messages. That is why the return type is plural.
    • A HEP message, a uprobe read and a message rebuilt from a vCon skip TCP reassembly.
      • Each is one whole message.
      • Each carries no TCP sequence number for the reassembler to order.
  • Classify.
    • classify_packet() decides what the packet means.
    • It returns a PacketAction: Sip { msg, sdp_links }, Rtp { .. }, Rtcp(..) or None.
    • All of these happen here:
      • WebSocket-SIP unwrap
      • SDP link extraction via extract_sdp_links()
      • SRTP/DTLS key learning
      • heuristic RTP discovery
    • It touches no store.
    • It is not lock-free. Three process-global side-tallies each take a mutex when they have something to record:
      • the --portrange skip counter
      • the LLMNR store
      • the STUN tracker
  • Apply.
    • The caller applies that action to its stores: DialogStore and StreamStore.
    • This is the only hop that differs between paths.
  • Present.
    • The TUI renders from the stores under try_read().
    • Every serializing surface projects through output/model.rs: CLI, NDJSON, REST, MCP, TUI save.

The four appliers

Four call sites apply a PacketAction. All four classify with the same function.

That is the whole point of the split:

  • Classification is protocol logic that must not vary.
  • Application is store access that legitimately does.
Path Applier Store access
Live (TUI) process_packet() Brief per-store write() locks, one store at a time.
Batch process_parsed_packet() Plain &mut, because the receive loop already holds BOTH store write locks for the whole of a packet's processing. That is why DeferredEffects exists.
--cores N reconstruct() Plain &mut on thread-local stores.
TUI file open run_pcap_load() write() locks on the live stores, concurrent with the render thread.

Before one pipeline replaced four (WS1), each of these carried its own copy of the classification logic.

  • They had already drifted.
  • Heuristic RTP discovery and WebSocket unwrap worked on some paths and not others.

The rule now is absolute: a new protocol behavior goes in classify_packet(), never in an applier.

  • PacketAction is what makes it enforceable.
  • An applier that ignores a variant fails to compile once the variant lands.

The live path is the one with locks. Its ordering is the deadlock-relevant detail.

sequenceDiagram
    autonumber
    participant Cap as capture::live
    participant Ch as capture::channel
    participant Proc as tui-processor
    participant PP as PacketProcessor
    participant Cls as pipeline::classify_packet
    participant DS as DialogStore
    participant SS as StreamStore

    Cap->>Ch: acquire permit (blocks when full)
    Cap->>Ch: send Packet (Bytes slice)
    Ch-->>Proc: recv Packet, release permit
    Proc->>PP: process(&Packet)
    PP-->>Proc: ParsedPackets (decap, frag + TCP reassembly)
    Proc->>Cls: classify_packet(pp, ..)
    Note over Cls: no store touched — side-tallies take a mutex when they fire
    Cls-->>Proc: PacketAction::Sip { msg, sdp_links }
    Proc->>DS: write lock
    DS-->>Proc: release
    Proc->>SS: write lock
    SS-->>Proc: release
    Note over DS,SS: dialog before stream, never both at once

The TUI tick

The render side never blocks on the stores. One iteration of the loop in run_tui_with_pause() does these in order:

  • polls background work
  • refreshes its caches under try_read()
  • draws
  • runs deferred work
  • drains every queued input event before the next redraw

The ordering is deliberate at two points:

  • Deferred saves and audio playback run after the frame that painted their status.
  • It drains input in full. So a paste burst is not metered out one keystroke per frame.
sequenceDiagram
    autonumber
    participant UI as TUI event loop
    participant Load as pcap-load worker
    participant App
    participant DS as DialogStore
    participant Term as terminal

    UI->>Load: poll_pcap_load (progress or completion)
    UI->>App: drain_async_messages
    UI->>App: sync_caches
    App->>DS: try_read
    alt guard acquired
        DS-->>App: snapshot counts and rows
    else contended
        DS-->>App: keep last frame's caches
    end
    UI->>Term: draw_frame
    Term-->>UI: drew = true or skipped
    opt drew
        UI->>App: run_pending_save, run_pending_audio
    end
    UI->>Term: poll(adaptive timeout)
    Term-->>UI: key or mouse events
    UI->>App: handle every queued event, then loop

A skipped frame is not a bug.

  • A contended try_read() means a writer holds the lock.
  • So the frame renders from the previous snapshot.
  • The adaptive cadence (10 fps active, 2 fps idle) bounds how stale that can be.
  • draw_frame() forces a blocking frame after a bounded number of skips. So the display cannot freeze.

--cores N

Offline reconstruction shards by host pair, not by packet or by call.

  • The reader peeks only the link, IP and transport headers.
    • peek_frame() walks to the outer IP header once and reads the host pair and the UDP or TCP ports there.
    • For a UDP datagram on a --portrange port it also checks whether the payload starts a SIP message that names application/sdp. That decides whether every worker receives a copy.
    • It does no SIP parse and no allocation.
  • Then shard_for() maps that pair to a worker.
  • The mapping is direction-independent.
    • So a call's request and response legs land on the same worker.
    • That worker rebuilds its bidirectional RTP without any cross-thread coordination.

Each worker owns a private PacketProcessor and thread-local stores.

  • So the hot path takes no STORE lock.
  • The stores merge only at EOF.
  • The three process-global side-tallies are the exception.
    • Every worker writes the same one.
    • So a capture that trips one of them often contends on one mutex regardless of worker count.
sequenceDiagram
    autonumber
    participant Reader as file reader
    participant Shard as shard_for
    participant W0 as worker 0
    participant W1 as worker 1
    participant Merge as merged stores

    Reader->>Shard: peek outer src/dst IP
    Shard-->>Reader: worker index (direction-independent)
    Reader->>W0: batch of packets (host pair A)
    Reader->>W1: batch of packets (host pair B)
    Note over W0,W1: thread-local DialogStore + StreamStore, no locks
    W0->>Merge: merge at EOF
    W1->>Merge: merge at EOF
    Merge->>Merge: reassociate_all
    Note over Merge: repairs SDP-to-RTP links split across workers

Two dispatch shapes exist.

  • run_offline_parallel() consumes a PacketRx one packet at a time.
  • run_offline_parallel_file() reads the file itself and sends packets in batches of 128.
    • The reason: the per-packet channel hop capped throughput past two cores.
    • The reconstruction work did not.

The merge is a union, since a host pair belongs to exactly one worker.

  • One case is the exception. It makes reassociate_all() necessary.
    • SDP can advertise a media address on a different host pair than the signaling.
    • Then one worker sees the offer and another sees the media.
  • Each merge() carries its endpoints along.
  • The re-association pass after merging links those streams to their calls.

Presentation

Everything that serializes a dialog or a stream goes through output/model.rs.

It exists because "dialog summary" was once implemented five times. Those copies had already drifted on the wire:

  • MCP said message_count where the CLI and REST API said msg_count.
  • MCP emitted Debug-formatted Invite where the API emitted INVITE.

Now one DialogSummary and one StreamSummary constructor feed all of these alike: CLI, NDJSON, REST, MCP, TUI save and reports. summary_consistency_test pins it.

These are the compact projections.

  • The full-fidelity forms live in output/json.rs: every header, SDP timelines, quality intervals.
  • Adding a field to one surface only means adding it to the model. That is the point.