Internals Subsystem Guide - NormB/sipnab GitHub Wiki
One packet's journey, from the wire to the screen.
- Read
../architecture.mdfirst 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_PRIVSimmediately after them, and before it reads any input--setup-capsrunssudo 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
CoresFileone that never reacheslaunch(). - 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
PlanErrorformain()to handle. - The result is a
RunPlan. RunPlan::modeis 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.rsorcapture/hep.rsproduce aPacket.- Its
datais abytes::Bytesslice 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_unstampedmaps the number to a transport inhep_transport.--hep-parse'sunwrap_hepinapp/batch.rscalls 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-listenreads the transport.
- A stored vCon container read by
-Iarrives pre-parsed too.capture/vcon_input.rsrebuilds 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 originInputOrigin::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:eth0selects netmap). - So which backends a binary reaches is a property of the libpcap it loads, not of sipnab.
capture/libpcap.rsasks that library at runtime throughpcap_lib_version().- It reads the banner in one pure function,
parse_banner. --version, the TUI help, MCPserver_capabilitiesand RESTGET /v1/capabilitiesall report itsrunning().- None of them may name a backend the banner does not.
- libpcap picks the capture backend from it (
- Channel.
capture::channelmoves 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 onePacketinto zero or moreParsedPackets.- 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(..)orNone. - 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
--portrangeskip counter - the LLMNR store
- the STUN tracker
- the
- Apply.
- The caller applies that action to its stores:
DialogStoreandStreamStore. - This is the only hop that differs between paths.
- The caller applies that action to its stores:
- 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 TUI renders from the stores under
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.
PacketActionis 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
--portrangeport it also checks whether the payload starts a SIP message that namesapplication/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 aPacketRxone 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_countwhere the CLI and REST API saidmsg_count. - MCP emitted Debug-formatted
Invitewhere the API emittedINVITE.
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.