Docs Examples & Recipes

Examples & Recipes

Worked examples with real output: triage, filtering, HEP, TLS decryption, MCP, observability, security and audio export, each one a command you can paste and the result it produces.

On this page

Recipe-style walkthroughs for the things people actually want to do. Each recipe states the problem, gives exact commands, tells you what to look for in the output, and flags common pitfalls.

Each recipe stands alone — nothing here depends on anything above it. If you are new, recipe 1 is the broadest starting point.

What do you want to do?

I want to…Recipe
Work out whether anything is wrong with a capture1. Triage a pcap fast
Watch one user’s traffic live2. Live capture, narrow to a single user
See which calls failed, and why3. Find every failed call, grouped by response code
Chase a “no audio” or “one-way audio” complaint4. Diagnose a one-way audio complaint
Work out why a call sounds bad in one direction11. Find why a call sounds bad in one direction only
Write a filter for common triage5. Filter for the five things you look for most
Collect traffic from proxies I cannot install on6. Wire HEP from your SIP stack to a central sipnab
Read encrypted SIP or SRTP7. Decrypt SIP/TLS via SSLKEYLOGFILE
Read TLS with no keys at all (eBPF)7g. Read TLS with no keys and 7h. …and who the peer was
Understand what reading TLS without keys costs and requiresTLS-without-keys walkthrough — security implications, and whether your kernel supports it
Drive sipnab from an AI agent8. Run sipnab as an MCP server
Graph traffic over time9. Graph call rate, response codes and PDD over time
Detect and block scanners or fraud10. Detect SIP scanners and auto-block via fail2ban
Hand someone a written summary of a call12. Generate a call report (text / Markdown / JSON)
Listen to the audio13. Export RTP audio as WAV
Look at a pcap with nothing installed14. Analyze a pcap without installing anything
Check signaling against the RFCs15. Check a capture against the RFCs
Get one machine-readable verdict per capture16. Analyze a capture into one machine-readable verdict
See what NAT/STUN/TURN did17. Inspect what NAT did to a call
Read a busy capture call by call18. Collect messages by call, host or method
Read a very large capture faster19. Read a very large capture faster
Prove a finding with the exact bytes20. Verify the exact bytes behind a finding
Add my own detection21. Detect your own fault patterns with a plugin
Know whether the loss is mine or the network’s22. Measure whether the loss is yours or the network’s
Chase a device flooding REGISTER23. Find the device flooding REGISTER
Diagnose a codec mismatch to a vendor24. Diagnose a codec mismatch
Work out why DTMF never lands25. Find out why DTMF does not reach the IVR
Collect HEP and forward it upstream26. Run sipnab as a HEP relay
Settle “we sent it” / “we never got it”27. Compare the same call at two nodes
Keep a capture running across reboots28. Run sipnab as a service
Work out why a capture shows almost no SIP29. Read a capture whose SIP is not on port 5060
Chase calls that answer and then drop30. Find calls that answered and were never acknowledged
Decide what counts as bad audio on my network31. Set the quality thresholds to your own network
Send a vendor only the calls that concern them32. Export one customer’s calls as a smaller capture
See what a STIR/SHAKEN token actually claims33. Check what a STIR/SHAKEN Identity header actually claims
Audit how the phones authenticate34. Detect weak digest authentication on a registrar
Read SIP split across TCP segments35. Read SIP that arrives in TCP segments or IP fragments
See the WebRTC leg of a call36. Read SIP over WebSocket from a WebRTC gateway
Capture SIP inside a mobile or fabric tunnel37. Read SIP carried inside a GTP-U or VXLAN tunnel
Find every call that mentions a string38. Search a capture for a header or a body string
Read a call that spans several capture files39. Collect a directory of rotated captures into one analysis
Hand the same packets to Wireshark40. Open the same evidence in Wireshark
Read the timers behind a slow call41. Measure the gap between consecutive messages
Prove which command produced a report42. Record which invocation produced a report
Stop a week-long capture from eating the box43. Keep a long-running capture inside a memory budget
Capture overnight without filling the disk44. Run a capture that stops on its own
Explain one-way findings on a VoLTE trunk45. Check whether comfort noise explains a one-way finding
Catch calls placed at three in the morning46. Detect fraud placed outside business hours
Read a load test that reuses one Call-ID47. Follow a load generator’s traffic by transaction, not by call
Capture without running as root48. Run a live capture without giving sipnab root
Move eleven flags into a config file49. Configure sipnab from a file instead of a long command line
Stop typing flags from memory50. Install shell completions
Ship failed calls to an archive without the numbers51. Export every failed call as a redacted vCon in one pass
Validate a vCon before a store refuses it52. Check a vCon against the schema before a store rejects it
Export a whole set of calls in one agent call53. Export a vCon per failed call in one round trip
Know what a media-address claim is worth54. Find out where a stream’s endpoint came from
Expose MCP to an agent I do not host55. Set up the MCP server for a hosted agent
Let an agent read TLS with no keys56. Read TLS from an agent, with no keys and no restart
Get a count an agent cannot miscount57. Ask the capture how many calls failed
Get the facts behind a MOS score58. Ask why the MOS is what it is
Decrypt TLS from a server private key59. Decrypt TLS with the server’s private key
Ask a media relay whether it is dropping packets61. Ask a relay whether it is dropping packets
See if a relay is holding sessions nobody released62. Ask a relay what it is still holding
Compare a relay’s view of a call with mine63. Compare a relay’s per-call count with your capture
Tell a relay’s reported loss from the loss I measured64. Read a relay’s loss beside the loss you measured
Just find a command to copyLook up a one-liner by task

Where a recipe fits

Most recipes are one of three shapes. Knowing which you are in tells you what the commands are doing:

flowchart LR
    P[pcap file] --> A[sipnab]
    L[live interface] --> A
    H[HEP from proxies] --> A
    A --> T[TUI: look at it]
    A --> J[JSON / report: pipe it]
    A --> M[metrics / alerts: watch it]
  • Look at it — the TUI, for a human working a ticket.
  • Pipe it — JSON, reports, WAV, Wireshark filters, for tooling.
  • Watch it — metrics, scanner detection, alerts, for something long-running.

1. Triage a pcap fast

Problem: Someone handed you a capture.pcap and asked “is anything wrong?”

Commands:

Open the call list and scan it visually — the interactive TUI:

sipnab -I capture.pcap

The same capture as a headless overview: dialog count, methods, average PDD.

sipnab -N -I capture.pcap

A one-flag diagnostic sweep — retransmits and failed dialogs:

sipnab -N -I capture.pcap --problems

The same sweep in JSON, for piping into another tool:

sipnab -N -I capture.pcap --problems --json

The same sweep spelled the long way. --problems expands to the problems DSL alias, so this selects exactly the same calls.

sipnab -N -I capture.pcap --filter problems

The --problems sweep prints one line per SIP message of each flagged call, then the end-of-capture summary. You should see something like (abridged):

INVITE +15551234 -> +15559876  192.0.2.6:5060 -> 192.0.2.7:5060  Failed  408 Request Timeout
...
852 packets captured, 10 SIP messages, 839 RTP packets across 2 streams

What to look for:

  • The --problems flag and the DSL alias reached with --filter problems expand to one and the same expression: state == 'Failed' OR one_way == true OR rtp.loss > 5.0 OR rtp.jitter > 50.0 OR nat_mismatch == true OR retransmits > 3 OR pdd > 11.0 OR codec_asymmetry == true OR ptime_asymmetry == true OR payload_asymmetry == true OR duration_asymmetry == true OR late_media == true. Either spelling flags the same calls, so an empty answer means the capture is probably clean.
  • The end-of-capture summary distinguishes RTP packets from RTP streams: 852 packets captured, 10 SIP messages, 839 RTP packets across 2 streams. A capture with media but no SIP usually means the SIP signaling happened off-pcap (different VLAN, different host, different port).

Pitfalls:

  • The TUI requires a tty. If you’re SSH’d in without -t, force -N mode.
  • For large pcaps (>1 GB), prefer -N first; the TUI loads everything into memory.

2. Live capture, narrow to a single user

Problem: A user reports their calls are flaky. Capture only their traffic in real time.

Commands:

Capture on eth0, keeping only this user’s calls — the filter matches From or To:

sudo sipnab -d eth0 --filter "from.user == '1001' OR to.user == '1001'"

The same capture with a CLI summary line per dialog instead of the TUI:

sudo sipnab -N -d eth0 --filter "from.user == '1001' OR to.user == '1001'" --json

What to look for:

  • The TUI’s call list updates as new dialogs appear. Press Tab to switch to the RTP stream view; press Enter on a stream to see jitter/loss/MOS history.
  • In CLI mode, each completed dialog is one line of JSON. Pipe to jq or tee to a log.

Pitfalls:

  • Live capture needs CAP_NET_RAW (Linux) or root. setcap cap_net_raw,cap_net_admin=eip $(which sipnab) lets you skip sudo after the first run.
  • The filter DSL evaluates against complete dialog records — once the dialog state machine has enough information (typically after the first response, or earlier for fields that only depend on the request). For per-header regex filtering on individual messages, use the older --from, --to, --contact, --ua flags listed in sipnab --help.

3. Find every failed call, grouped by response code

Problem: “We had a spike in failures around 14:00. What was it?”

Commands:

sipnab -N --json emits per-message records (one JSON line per SIP message), not per-dialog summaries. The status_code field is on response messages. Combined with --filter (which evaluates against the dialog so all messages from matched dialogs flow through), you get a histogram of every response code seen during failed dialogs:

Every failed call’s response messages — Call-ID, status_code and reason:

sipnab -N -I capture.pcap --filter "state == 'Failed'" --json \
  | jq 'select(.is_request == false) | {call_id, status_code, reason}'

A histogram of the response codes seen in failed dialogs:

sipnab -N -I capture.pcap --filter "state == 'Failed'" --json \
  | jq -r 'select(.is_request == false) | .status_code' \
  | sort | uniq -c | sort -rn

A detailed report for one failure, in Markdown, ready to paste into a ticket:

sipnab -N -I capture.pcap --call-report 'abc123@host' --markdown > failure-report.md

The histogram output looks like (uniq -c count, then status code):

     23 100
     14 486
      6 503
      3 488

What to look for:

  • A 401/407 spike usually means a credential-rotation push hit the wrong realm.
  • A 408 spike on outbound is upstream timeout — check rtpengine / SBC.
  • A 488 spike (Not Acceptable Here) usually means a codec mismatch — combine with Recipe 11.

Pitfalls:

  • The histogram counts all response codes seen in messages of failed dialogs (so a single failed call with 100 Trying → 488 contributes both 100 and 488). For just the final response per call, use --call-report <id> per dialog.
  • The dialog summary returned by the REST API (/v1/dialogs) has no status_code field; that’s a per-message field only available in CLI --json output or via /v1/dialogs/{id} (which includes the full message list).

4. Diagnose a one-way audio complaint

Problem: A user said “I can hear them but they can’t hear me.” There’s a Call-ID in the ticket.

Commands:

First, confirm the diagnosis engine flagged it. The diagnosis block lives on the dialog-level JSON that --call-report emits, not on per-message records.

sipnab -N -I capture.pcap --call-report 'abc123@host' --json --no-cli-print \
  | jq '{call_id, state, diagnosis}'

Then get the human-readable call report — NAT mismatch, SDP offer/answer, media path:

sipnab -N -I capture.pcap --call-report 'abc123@host' --markdown --no-cli-print

Finally, inspect the actual RTP streams for that call in the TUI:

sipnab -I capture.pcap
#   → press '/' to search, type 'abc123', Enter
#   → Tab to switch to RTP streams view
#   → Enter on each stream to see packet count, jitter, loss

The first command should print a diagnosis object like:

{
  "call_id": "abc123@host",
  "state": "Completed",
  "diagnosis": {
    "one_way_audio": true,
    "nat_mismatch": true,
    "no_media": false,
    "hints": [
      "RTP flowed 203.0.113.7:41002 -> 192.0.2.5:16386 only (SSRC 0x1a2b3c4d). No reverse media flow detected.",
      "RTP arrived from 203.0.113.7:41002 at 192.0.2.5:16386, and no SDP in this dialog advertised 203.0.113.7 (it offered 198.51.100.20:16384) — the media source was rewritten, typically by NAT, so replies sent to 198.51.100.20:16384 never reach it.",
      "One-way audio combined with NAT mismatch — media likely being sent to the wrong address."
    ]
  }
}

What to look for:

  • diagnosis.one_way_audio: true confirms the engine saw RTP in only one direction for ≥6s after call establishment.
  • The ports in the hint are where the fix goes. Each side advertises a receive port in its SDP and, under symmetric RTP (RFC 4961), should send from that same port. A hint reading advertised 16384 but sends from 41002 means the far end is replying to a port nothing is sending from, so no NAT pinhole was ever opened there — that is the firewall rule, port-forward or RTP port range to go and check. When the ports agree, the hint stays quiet about them.
  • diagnosis.nat_mismatch: true is the usual root cause — the Contact header / Via address differs from the SDP c= line. Common when the upstream SBC isn’t rewriting Contact.
  • In the TUI’s RTP stream view, look for one stream with packets and one with 0 packets received — that’s the silenced direction.

Pitfalls:

  • If both streams show packets but the user still reports silence, the issue is downstream of sipnab (codec mismatch, jitter buffer underflow, bad headset). Use Recipe 11 for codec asymmetry checks.

5. Filter for the five things you look for most

The filter DSL has 33 fields and 7 operators. These five cover most operational triage:

Slow setup — every dialog that took more than 3 seconds from INVITE to 200 OK:

sipnab -N -I capture.pcap --filter "pdd > 3.0" --json

REGISTER dialogs that failed:

sipnab -N -I capture.pcap --filter "method == 'REGISTER' AND state == 'Failed'" --json

Short calls — completed, but under 10 seconds, which usually points at a UX or cancellation problem:

sipnab -N -I capture.pcap --filter "duration < 10.0 AND state == 'Completed'" --json

Heavy retransmits, the signature of packet loss on the SIP path:

sipnab -N -I capture.pcap --filter "retransmits > 5" --json

A specific User-Agent, matched as a regex:

sipnab -N -I capture.pcap --filter "ua =~ '(?i)friendly.*scanner|sipvicious'" --json

For per-call asymmetry checks (different codec on each leg, late media, etc.), see Recipe 11.

Pitfalls:

  • String comparisons are case-sensitive. State names must match exactly ('Completed', not 'completed'). Use =~ '(?i)...' if you want case-insensitive.
  • Boolean fields only support == and !=one_way > true is a parse error.

6. Wire HEP from your SIP stack to a central sipnab

Problem: You want one sipnab box collecting traffic mirrors from multiple SIP servers.

6a. Set up the listener

Build sipnab with HEP support — skip this if you installed a package that already has it:

cargo build --release --no-default-features \
    --features native,hep,api,mcp,mcp-http

Run it as a daemon. UDP :9060 receives HEP, TCP :9100 serves REST + Prometheus. sipnab refuses a routable bind that nothing guards, so the command carries a HEP source allowlist and an API signing key:

sipnab -N --hep-listen 0.0.0.0:9060 --hep-allow 192.0.2.0/24 --api 0.0.0.0:9100 --api-signing-key-file /etc/sipnab/signing.key --no-priv-drop --syslog

A ready-to-deploy systemd unit lives at contrib/observability/sipnab-hep.service. Put sipnab on the collector host first — the install guide covers every channel.

6b. Configure the SIP server to mirror

OpenSIPS:

loadmodule "proto_hep.so"
modparam("proto_hep", "hep_id", "[hep_central]udp:capture.example.com:9060;version=3")

loadmodule "siptrace.so"
modparam("siptrace", "trace_id", "[hep_central]uri=hep:hep_central")

route {
    sip_trace("hep_central", "d", "sip");
    ...
}

Reload with opensipsctl restart (or systemctl reload opensips for graceful reload).

rtpengine:

# /etc/rtpengine/rtpengine.conf
homer = capture.example.com:9060
homer-protocol = udp
homer-id = 1
# Without this, rtpengine mirrors RTCP statistics only -- which carry no
# Call-ID and no SDP, so nothing sipnab reads can name a call or find a media
# endpoint. It is the first thing to check when a relay looks silent.
homer-enable-ng = true

Restart with systemctl restart rtpengine. See the rtpengine page for what sipnab does with the result, and for the two ways to read it that do not cost you your collector.

Kamailio:

loadmodule "siptrace.so"
modparam("siptrace", "duplicate_uri", "sip:capture.example.com:9060")
modparam("siptrace", "hep_mode_on", 1)
modparam("siptrace", "hep_version", 3)

route {
    sip_trace();
    ...
}

FreeSWITCH (mod_sofia):

<!-- conf/sip_profiles/external.xml -->
<param name="capture-server" value="udp:capture.example.com:9060;hep=3"/>
<param name="sip-capture" value="yes"/>

6c. Verify packets are arriving

On the sipnab host, tcpdump the HEP socket to see whether anything is arriving at all:

sudo tcpdump -i eth0 -n udp port 9060

Confirm the HEP feed is producing dialogs:

curl -s http://localhost:9100/v1/stats | jq

Watch dialogs accumulate live:

watch -n 1 'curl -s http://localhost:9100/v1/dialogs?limit=5 | jq ".dialogs[] | {call_id, state}"'

6d. Take signaling from HEP and media off the wire, in one process

Problem: the HEP mirror is the robust way to get decrypted SIP — it is already plaintext at the source, so there is no TLS key extraction to go wrong — but a HEP feed carries no RTP, so a --hep-listen run measures no media at all. Capturing the interface instead gets you media and puts you back on key extraction for the signaling.

Run both in one sipnab. HEP supplies the signaling, the NIC supplies the RTP for the same calls, and streams bind to dialogs by SDP media endpoint:

sipnab -N -d eth0 --hep-listen 127.0.0.1:9060 "udp portrange 10000-20000"

Raised by Dan Jenkins (@danjenkins) from OpenSIPS deployment experience.

The BPF expression is not optional decoration. Without one, the interface gets sipnab’s auto-generated signaling filter and captures no media whatsoever — while every message the mirror already sent also arrives off the wire, doubling each dialog’s message ladder. Name your media ports and nothing else, because sipnab warns if you forget.

What you get that a single source cannot give you. The two accounts are complementary rather than redundant, and their DISAGREEMENT is the finding. HEP reports what the proxy believes it did. The wire reports what actually left the box. When the question is “is the proxy misbehaving, or did I configure it to”, a mirror produced by the suspect cannot answer it — it is the same witness twice.

A composite run therefore reports, per call:

FindingWhat it means
Seen by bothThe proxy did what it says it did
Mirror-onlyThe proxy believes it sent something the wire never carried
Wire-onlyTraffic left the box that the tracing never reported
Differing SDPBoth saw the message and disagree about the media endpoint, so the report carries both accounts side by side

Neither account carries the label expected or actual. sipnab pairs copies by transaction identity (RFC 3261 section 17.1.3 and RFC 3261 section 17.2.3), never by arrival order, because the mirror usually arrives FIRST — the proxy mirrors as it processes while the wire copy takes a network hop, so any “first one wins” rule would quietly make the suspect authoritative.

A single-source run reports none of this and allocates nothing for it.

Trace both directions, or this measures nothing. In OpenSIPS, use transaction scope so the tracer mirrors the message it sent as well as the one it received:

route {
    $var(tid) = "hep_central";
    trace($var(tid), "t", "sip");   # "t", not "m"
}

At "m" (message) scope the tracer mirrors only the received copy. That matters the moment rtpengine or any other relay rewrites the SDP: the address OpenSIPS received is not the address the media flows to, so every stream comes out orphaned. Measured on OpenSIPS 3.6.7 with rtpengine 12.5.1 anchoring: at "t" every advertised media endpoint was a socket the capture actually saw RTP on (4 of 4). At "m" it observed 1 of 4.

Note also that proto_hep refuses to start without a HEP listener socket even when the config only sends — add socket=hep_udp:127.0.0.1:9061 alongside your hep_id or OpenSIPS exits with No HEP listener defined!.

Where this helps, and where it cannot. It helps where media transits the machine running the mirror — a proxy anchoring media through rtpengine on the same box is the case this composition targets. Where media flows end-to-end and never crosses that machine, no capture there can see it, and no amount of correlation changes that: put sipnab’s -d where the media actually is.

Pitfalls:

  • HEP is UDP — silently drops if the listener can’t keep up. The --hep-rate-limit 50000 default lets you tune.
  • A routable HEP listener needs a guard: sipnab refuses a non-loopback --hep-listen bind unless you pass --hep-allow 192.0.2.0/24 (repeatable) or --hep-auth/--hep-auth-file. A loopback bind needs neither.
  • --hep-allow guards the listener and nothing else. HEP that sipnab merely sniffs off the wire — including rtpengine’s mirrored control plane — reaches no socket, so no allowlist applies to it. See rtpengine.md for what a sniffed assertion is worth.
  • If your central host is reachable by hostname only, set --mcp-allowed-host for the MCP transport too (see Recipe 8).
  • -d with --hep-listen takes exactly one interface and one listener. sipnab refuses -I with --hep-listen (a file’s addresses are historical and belong to third parties, and sipnab keeps them off its active-response path by refusing to transmit for a file run at all); so are --multi-device with --hep-listen, and -O with the pair — the two sources disagree about the link layer, so there is no honest pcap to write. Use --hep-send to forward the signaling instead.
  • One sipnab, one mirroring node. The SDP endpoint index keys on address and port with no node dimension, so two nodes advertising the same RFC 1918 socket would overwrite each other and a stream would bind to whichever offer arrived last.

7. Decrypt SIP/TLS via SSLKEYLOGFILE

Problem: TLS-encrypted SIP captures are unreadable without keys.

7a. Live decryption (UA produces keys, sipnab follows)

Build sipnab with the tls feature, or use --features full:

cargo build --release --features tls,hep,api

On the SIP user agent — a different machine from the capture host — set SSLKEYLOGFILE in its environment:

SSLKEYLOGFILE=/tmp/sipua.keylog /opt/myua/bin/start

Then, on the capture host, start sipnab watching that keylog file for live updates:

sudo sipnab -N -d eth0 \
            --keylog /tmp/sipua.keylog --keylog-watch

7b. Decrypt a capture you already recorded

Capture the encrypted pcap normally:

sudo sipnab -N -d eth0 -O encrypted.pcap

Later — minutes or months — decrypt it using the keylog the UA wrote during the call:

sipnab -I encrypted.pcap --keylog /tmp/sipua.keylog

7c. Export encrypted packets for Wireshark

The default raw mode writes original packets without embedding TLS keys:

sipnab -I encrypted.pcap --keylog /tmp/sipua.keylog \
       -O original.pcap --pcap-export-mode raw

To let Wireshark decrypt the original bytes, explicitly embed a Decryption Secrets Block in PCAP-NG. This file contains TLS keys:

sipnab -I encrypted.pcap --keylog /tmp/sipua.keylog \
       --pcapng -O wireshark-friendly.pcapng --pcap-export-mode encrypted+dsb

decrypted plaintext-frame export is not supported. Requesting it exits 2.

7d. Decrypt SRTP from a DTLS keylog

sipnab -I capture.pcap --dtls-keylog /tmp/dtls.keylog

7e. Decrypt traffic from a daemon you cannot restart

Everything above needs the SIP daemon to cooperate: SSLKEYLOGFILE has to be in its environment when it starts. On a running production Kamailio, OpenSIPS or Asterisk that means a restart, and a restart is usually the reason you are looking at the capture in the first place.

eCapture reads the TLS master secrets straight out of the process with eBPF uprobes on the TLS library. It needs nothing from the daemon — no environment variable, no configuration, no restart — and it writes the same NSS keylog format sipnab already consumes.

On the SIP host, as root:

ecapture tls -m keylog --keylogfile=/tmp/sip.keylog

Then point sipnab at that file, exactly as in 7a:

sudo sipnab -N -d eth0 --keylog /tmp/sip.keylog --keylog-watch

-m keylog is the mode for a LIVE capture, because it hands sipnab keys while sipnab reads the wire itself. eCapture’s -m text emits plaintext, which is a different artifact: bytes that never existed on the wire in that form.

-m pcap is neither, and an earlier version of this page had it wrong. It writes the real encrypted frames and embeds the secrets in the file as a pcapng Decryption Secrets Block, so it produces exactly the artifact this section argues for – with no live sipnab at all. That is 7i, and it is the easier recipe of the two when you can work from a file.

Measured, not assumed (2026-08-14): a TLS 1.3 REGISTER over TLS_AES_256_GCM_SHA384, keys taken from a running process with no SSLKEYLOGFILE anywhere, decoded by sipnab as 127.0.0.1:53810 -> 127.0.0.1:15300 REGISTER TLS. Verified on both aarch64 (kernel 6.8, no BTF — eCapture falls back to its non-CO-RE bytecode automatically) and x86_64 (Debian 13, OpenSSL 3.5.6, BTF present).

Pitfalls:

  • tls is a build-time feature, not a runtime flag. There is no sipnab --features tls invocation; pass --features to cargo build and use the resulting binary. To check what a binary carries, run sipnab --version: it prints the version, the commit, and the compiled feature list, so tls in that list means the build decrypts. sipnab --help | grep -E '\-\-keylog|\-\-tls-key' answers the same question from the flag surface — if the flags appear, the build has tls.
  • The keylog format is the standard NSS SSLKEYLOGFILE (one line per session). Same format Firefox/Chrome/curl produce.
  • TLS 1.3 + ECDH ephemeral handshakes are fully supported via the ring backend.
  • eCapture is a separate program under Apache-2.0; sipnab neither bundles nor links it. It needs CAP_BPF/CAP_PERFMON or root, and Linux 4.18+ on x86_64 or 5.5+ on aarch64.
  • A keylog is key material. It decrypts every session it covers, so treat the file as a secret: sipnab disables core dumps once decryption is active for the same reason.
  • Keys only appear for handshakes eCapture was running for. Start it before the calls you care about — it cannot recover a session whose handshake it missed.

7f. Decrypt without writing the keys to disk

7e leaves master secrets in a file, and that file decrypts every session it covers — which is why the pitfall above says to treat it as a secret. sipnab can take the same keylog lines over a pipe instead, so they never reach a disk at all.

Hand sipnab the read end of a pipe with --keylog-fd:

sudo sh -c 'ecapture tls -m keylog --keylogfile=/dev/stdout | sipnab -N -d eth0 --keylog-fd 0'

Or use a named pipe, when a supervisor starts the two halves separately:

Create the pipe once:

sudo mkfifo -m 600 /run/sip.keys

then read it as a live stream:

sudo sipnab -N -d eth0 --keylog /run/sip.keys --keylog-watch

--keylog accepts a FIFO and reads it as a live stream. --keylog-fd implies --keylog-watch, since a descriptor from a running producer has nothing to read at startup and everything to read later. Pass one or the other, never both.

sipnab cannot start the extractor for you, and that is deliberate. It sets PR_SET_NO_NEW_PRIVS at startup and every child inherits it, so a process sipnab spawns can never acquire the CAP_BPF eCapture needs. Start the extractor from a supervisor and hand sipnab the read end.

Pitfalls:

  • sipnab opens a FIFO named by --keylog before it drops privileges, for the same reason it opens capture devices there. A path under /run is unreachable once sipnab has dropped to an unprivileged user or entered a --chroot.
  • An inherited descriptor needs no privilege at all, so --keylog-fd works whatever sipnab drops to afterwards.
  • Core dumps are still disabled, exactly as for a keylog file: the secrets arrive over a pipe but land in the same process memory.

7g. Read TLS with no keys at all

7e and 7f still need session secrets from somewhere. This recipe needs none: sipnab puts a kernel uprobe on the TLS library’s write function and reads the plaintext before encryption. No certificate, no private key, no keylog, and nothing restarted.

Look before you probe. This installs nothing and answers whether the capture is worth starting:

sudo sipnab --uprobe-list
FLAVOR        INODE  PIDS  LIBRARY
OpenSSL     14166752     1  /proc/982690/root/usr/lib/aarch64-linux-gnu/libssl.so.3
wolfSSL     17433084     1  /proc/982702/root/usr/lib/aarch64-linux-gnu/libwolfssl.so.42.2.0
OpenSSL        21143    12  /usr/lib/aarch64-linux-gnu/libssl.so.3

Then capture. sipnab probes every library listed, not one — a host commonly runs both OpenSSL and wolfSSL, and probing only the one you had in mind misses the rest without saying so:

sudo sipnab -N --uprobe-tls

Narrow it if only one stack is yours to read:

sudo sipnab -N --uprobe-tls --uprobe-flavor openssl

Or name a library yourself, which is the only way to attach to a daemon that has not started yet — discovery can only see what is already mapped:

sudo sipnab -N --uprobe-tls --uprobe-library /usr/lib/x86_64-linux-gnu/libssl.so.3

What this gives up. A uprobe sees the bytes an application handed its TLS library and nothing about the socket beneath, so dialogs from this source carry no addresses and port 0. sipnab labels them uprobe:<comm>/<pid> instead — the process, not a peer. It never invents an address it did not observe.

Pitfalls:

  • Needs root (or CAP_SYS_ADMIN + CAP_PERFMON) and a mounted tracefs. Unprivileged, /proc/<pid>/maps is readable only for your own processes, so --uprobe-list quietly shows a fraction of the host — run it as root before concluding a daemon is not using TLS.
  • Containers. The path a containerized process sees names a different file from sipnab’s namespace. sipnab handles this by matching inodes and probing through /proc/<pid>/root, which is why the listing above shows such paths. If you pass --uprobe-library by hand for a container, pass the /proc/<pid>/root/... form, or the probe attaches to the host’s copy and captures nothing.
  • GnuTLS is not probed. Its write function has a different signature, and attaching the OpenSSL probe shape to it would read the wrong register.
  • This input can never transmit. --hep-allow-kill has an equivalent for HEP input; there is deliberately no such flag here, because sipnab has no observed peer to answer to.
  • A write larger than 2048 bytes arrives truncated, and sipnab marks it as such rather than presenting a fragment as a whole message.

7h. Read TLS and who the peer was

7g gives you the plaintext but no addresses: a uprobe sees the bytes an application handed its TLS library and nothing about the socket beneath, so those dialogs name a process rather than a peer.

The bpf backend closes that gap. It pairs each write with the tcp_sendmsg that carried it — same thread, back to back — and reports the addresses the plaintext actually went out on:

sudo sipnab -N --uprobe-tls --uprobe-backend bpf --portrange 0-65535
200 OK     127.0.0.1:15061 -> 127.0.0.1:36160  TCP  uprobe:python3/349147#0
REGISTER   127.0.0.1:36160 -> 127.0.0.1:15061  TCP  uprobe:python3/349147#1
200 OK     127.0.0.1:15061 -> 127.0.0.1:36172  TCP  uprobe:python3/349147#2

Each request and its response share an ephemeral port, and a second connection gets a different one — the pairing binds each write to its own socket.

What it needs, and what it refuses:

  • a sipnab built with --features bpf, which needs a nightly toolchain and cargo install bpf-linker. Without them sipnab still builds and every other backend still works; this one refuses at runtime and names the missing tool;
  • a kernel with CONFIG_DEBUG_INFO_BTF — BTF is the BPF Type Format, the kernel’s description of its own structs and where each member sits. Check with ls /sys/kernel/btf/vmlinux. sipnab reads the socket layout out of that BTF at load time, so the program keeps working across kernels instead of matching only the one that compiled it;
  • root, as 7g does.

Asked for on a build or a kernel that cannot run it, sipnab refuses rather than falling back to tracefs. The addresses are the only reason to choose this backend, and a silent downgrade would hand you a capture with none.

Pitfalls:

  • Widen --portrange. A TLS trunk on 5061 is the exception, and the port a uprobe reports is whatever the socket actually used — often ephemeral. The default range drops them.
  • A write the TLS library buffered rather than sent arrives with no addresses, exactly like a 7g capture. sipnab does not guess a peer for it, because a guessed one would be indistinguishable from an observed one.
  • The kernel and the daemon may disagree about which symbol carries the plaintext: OpenSSL 3 applications increasingly call SSL_write_ex. Check with nm -D --undefined-only /path/to/app | grep SSL_write and pass --uprobe-symbol if it differs.

7i. One file, taken off the host, that decrypts itself

7e and 7f keep sipnab running beside the daemon. This recipe does not run sipnab on the SIP host at all. eCapture writes one file there, you copy it somewhere else, and sipnab reads it with no keylog, no flags and no configuration – because the secrets are inside the file.

On the SIP host, as root:

ecapture tls -m pcap -i eth0 -w /tmp/sip-tls.pcapng tcp port 5061

Stop it with Ctrl-C when you have the calls you need, copy the file off, and read it anywhere:

sipnab -N -I sip-tls.pcapng --portrange 1-65535 --report

That is the whole recipe. There is no --keylog, because -m pcap puts the master secrets in the file as a pcapng Decryption Secrets Block, and sipnab reads a DSB automatically. Wireshark reads the same file for the same reason.

Measured end to end, not assumed (2026-09-09). eCapture v2.5.2, keys taken by uprobe from a Python 3.13 process running OpenSSL 3.5.6 that had no SSLKEYLOGFILE and set no keylog of its own, over a TLS 1.3 TLS_AES_256_GCM_SHA384 session carrying a complete SIP dialog – INVITE split across two records, 100, 180, 200 with SDP, ACK, BYE, 200. sipnab read the resulting 50-packet file and reported:

TLS decryption active: 6 secret(s) from embedded DSB in sip-tls.pcapng
Call-ID                       From   To     State       Code  Msgs
[email protected]  uac    echo   Completed   200   7

Which recipe to reach for:

You wantUse
A live view, decrypted as calls happen7e or 7f – sipnab runs beside the daemon
One artifact to take away, open later, or hand to somebody7i – nothing runs on the host afterwards
The real wire bytes, verifiable independentlyEither. Both keep the encrypted frames; neither writes plaintext

Pitfalls, each one measured:

  • -m pcap needs more from the kernel than -m keylog does, and this is the one that stops most people. It attaches a TC classifier to the interface as well as the uprobes, so the kernel needs CONFIG_NET_CLS_BPF. On a kernel without it, eCapture starts, loads its bytecode, and then fails with couldn't add a ingress filter to interface 1: netlink receive: no such file or directory. Measured on Linux 6.8.12-rt-tegra, where zcat /proc/config.gz | grep NET_CLS_BPF prints # CONFIG_NET_CLS_BPF is not set. Check that before you plan around this recipe. -m keylog (7e) needs only the uprobes and runs on that same kernel.
  • -i takes a real interface, and it is not optional. -m pcap captures through the interface, not through the process, so -i lo for loopback traffic and the SIP-facing NIC otherwise.
  • The port range is yours to widen. sipnab decrypted the file above and still reported no calls on the first run, because the lab used port 15061 and the default --portrange is 5060-5061. It said so rather than staying silent – SIP outside --portrange 5060-5061 is being skipped names the busiest port it saw.
  • eCapture needs no BTF. On a kernel with none it falls back to its own non-CO-RE bytecode and logs the file it loaded (bytecode/openssl_3_0_0_kern_noncore.o). A missing /sys/kernel/btf/vmlinux is not a reason to skip this.
  • The file is key material. A DSB decrypts every session in it, so the pcapng is as sensitive as the keylog in 7e, and more portable. Treat it that way when you copy it off the host.
  • Same as 7e: eCapture is a separate Apache-2.0 program that sipnab neither bundles nor links, and it captures only handshakes it was running for.

8. Run sipnab as an MCP server

Problem: You want an AI agent (Claude Code, Claude Desktop, anything MCP-capable) to query a capture without you typing CLI flags.

8a. Drive sipnab from an agent on the same machine

One-shot: the agent reads a pcap you already have.

sipnab -N --mcp -I capture.pcap --quiet

The same, against a live capture instead of a file:

sudo sipnab -N --mcp -d eth0 --quiet

Claude Desktop config (~/Library/Application Support/Claude/claude_desktop_config.json on macOS):

{
  "mcpServers": {
    "sipnab": {
      "command": "sipnab",
      "args": ["--mcp", "-N", "-I", "/path/to/capture.pcap", "--quiet"]
    }
  }
}

Claude Code (in your project directory):

claude mcp add sipnab -- sipnab -N --mcp -I "$PWD/capture.pcap" --quiet

8b. Drive sipnab from an agent on another machine

Generate the bearer token once, when you first set the host up. openssl rand overwrites /etc/sipnab/mcp-token — run it against a live server and it starts serving a secret none of the configured agents hold, with no way to recover the old one.

# Run all of these, in order.
mkdir -p /etc/sipnab && chmod 0755 /etc/sipnab
openssl rand -hex 32 > /etc/sipnab/mcp-token
chmod 0600 /etc/sipnab/mcp-token

Then run sipnab listening on a private network interface. This is the every-boot command: it reads the token file, it does not create one.

sipnab -N --mcp --mcp-transport http \
       --mcp-bind 0.0.0.0:8731 \
       --mcp-token-file /etc/sipnab/mcp-token \
       --mcp-allowed-host capture.example.com \
       --hep-listen 0.0.0.0:9060 --hep-allow 192.0.2.0/24 --quiet

The agent connects to http://capture.example.com:8731/mcp with Authorization: Bearer <token>.

8c. Test the JSON-RPC handshake from a shell

Each probe below sends the bearer token, so read it into the shell first. The three requests are independent — none of them carries a session on to the next, so run whichever one you need.

TOKEN=$(cat /etc/sipnab/mcp-token)

Initialize, pretending to be an MCP client. Keep the session id the server returns: every later request must carry it in Mcp-Session-Id. The transport rejects one that does not, answering HTTP 422 Unexpected message, expect initialize request:

# Run all of these, in order.
SID=$(curl -sS -D - -o /dev/null http://capture.example.com:8731/mcp \
     -H "Content-Type: application/json" \
     -H "Accept: application/json, text/event-stream" \
     -H "Authorization: Bearer $TOKEN" \
     -d '{"jsonrpc":"2.0","id":1,"method":"initialize",
          "params":{"protocolVersion":"2025-06-18",
                    "capabilities":{},
                    "clientInfo":{"name":"curl","version":"0"}}}' \
     | awk 'tolower($1) == "mcp-session-id:" { print $2 }' | tr -d '\r')

Send the initialized notification the protocol requires before any tool call. It answers 202 Accepted with no body:

curl -sS http://capture.example.com:8731/mcp \
     -H "Content-Type: application/json" \
     -H "Accept: application/json, text/event-stream" \
     -H "Authorization: Bearer $TOKEN" \
     -H "Mcp-Session-Id: $SID" \
     -d '{"jsonrpc":"2.0","method":"notifications/initialized"}'

List every registered tool:

curl -sS http://capture.example.com:8731/mcp \
     -H "Content-Type: application/json" \
     -H "Accept: application/json, text/event-stream" \
     -H "Authorization: Bearer $TOKEN" \
     -H "Mcp-Session-Id: $SID" \
     -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'

Call find_problems and get JSON of the problematic dialogs:

# Run all of these, in order.
curl -sS http://capture.example.com:8731/mcp \
     -H "Content-Type: application/json" \
     -H "Accept: application/json, text/event-stream" \
     -H "Authorization: Bearer $TOKEN" \
     -H "Mcp-Session-Id: $SID" \
     -d '{"jsonrpc":"2.0","id":3,"method":"tools/call",
          "params":{"name":"find_problems",
                    "arguments":{"kinds":["one-way","nat-issues"]}}}'

The tools/list response is a standard JSON-RPC envelope with a result.tools array (descriptions and input schemas truncated here):

{"jsonrpc":"2.0","id":2,"result":{"tools":[
  {"name":"list_dialogs","description":"...","inputSchema":{"type":"object","properties":{"...":{}}}},
  {"name":"get_dialog_report","description":"...","inputSchema":{"...":"..."}},
  {"name":"find_problems","description":"...","inputSchema":{"...":"..."}}
]}}

Every registered tool appears, grouped here by what they do. The table in docs/mcp-tools.md is the authoritative list, and mcp_tool_table_lists_every_registered_tool in tests/docs_drift_test.rs asserts it against the registry — the grouping below is a reading aid, not a second source of truth. (This list said “all 25 tools” and enumerated 25 until 2026-08-05, by which point the registry held 31. The six it had never gained were capture_health, explain_rule, find_correlated, lint_dialog, save_findings and validate_message.)

  • Browse and quote the capturelist_dialogs, get_dialog, get_dialog_report, get_message, search_messages, search_by_time, tail_dialogs, render_ladder, compare_dialogs, find_correlated, show_evidence
  • Diagnosefind_problems, triage_call, diagnose_registration, check_codec_negotiation, explain_response_code, get_sdp_timeline, rtp_stats, security_findings, capture_health
  • Check a message or dialog against the RFCslint_dialog, validate_message, explain_rule
  • Ask about the session itselfcapture_status, server_capabilities, list_captures
  • Write a file or a note, swap the capture, or end the runexport_capture, export_audio, save_findings, open_capture, shutdown_server

Only that last group reaches past the query surface, and each member needs a flag you passed at startup: the two exports write only under --mcp-file-root, save_findings records only under --mcp-allow-save-findings, open_capture acts only under --mcp-allow-open-capture, and shutdown_server only under --mcp-allow-shutdown. All five still appear in tools/list when you omit those flags, because sipnab registers the tools unconditionally and refuses the call instead. Seeing shutdown_server listed does not mean an agent can stop your capture.

Pitfalls:

  • Stdout is the JSON-RPC wire in stdio mode. Use --quiet and don’t combine with --json/--report/etc. — sipnab refuses to start.
  • Non-loopback bind without a token: refused at startup. Loopback bind needs no token.
  • Pass --mcp-allowed-host when the client connects via the actual hostname (rmcp’s default Host allowlist is just localhost/127.0.0.1/::1).

9. Graph call rate, response codes and PDD over time

Problem: You want a dashboard tracking call rate, response codes, and PDD over time.

9a. Use the bundled stack

Clone the repository and take a copy of the sample environment. Do this once per host: cp .env.example .env overwrites .env, so on a host where you have already edited it, skip straight to starting the stack.

# Run all of these, in order.
git clone https://github.com/NormB/sipnab.git
cd sipnab/contrib/observability
cp .env.example .env

If sipnab runs on a different host, point the stack at it before starting: echo 'SIPNAB_HOST=192.0.2.10' >> .env, or the hostname (capture.example.com) if that is how it resolves.

Start the stack from contrib/observabilitydocker compose reads its compose file out of the working directory, so a fresh shell needs the cd above first:

docker compose up -d

This boots Prometheus (:9090), Grafana (:3000, admin/admin), an OTel Collector (:4317/:4318), and Tempo. The included Grafana dashboard provisions automatically — log in and look for the sipnab folder.

9b. Run sipnab so Prometheus can scrape it

Both listeners below bind a routable address, and sipnab refuses either one without a credential — so each command supplies one, and the scrape job has to send the matching half.

A standalone metrics endpoint, which takes HTTP Basic:

sipnab -N -d eth0 --metrics 0.0.0.0:9100 --metrics-auth-file /etc/sipnab/metrics.cred --json

Or serve the metrics from the REST API, so one port carries both. That side takes the same Bearer token as every other REST route:

sipnab -N -d eth0 --api 0.0.0.0:9100 --api-signing-key-file /etc/sipnab/signing.key

9c. Verify the scrape

From the Prometheus host, confirm the target is up:

curl -s http://localhost:9090/api/v1/query?query=up{job=\"sipnab\"} | jq

Then spot-check a metric value:

curl -s 'http://localhost:9090/api/v1/query?query=rate(sipnab_messages_total[1m])' | jq

9d. Query the metrics with PromQL

# Call rate (per method)
rate(sipnab_messages_total[5m])

# Active dialogs (in-progress)
sum(sipnab_dialogs_total{state=~"trying|ringing|incall"})

# Setup time p95
histogram_quantile(0.95, rate(sipnab_pdd_seconds_bucket[5m]))

# RTP MOS p10 (worst 10%)
histogram_quantile(0.1, rate(sipnab_mos_bucket[5m]))

Pitfalls:

  • The dashboard ships with the metric names sipnab actually emits. If you wrote a custom panel using older docs, double-check against the Prometheus metrics reference.
  • Some metrics (sipnab_responses_total, sipnab_security_alerts_total) exist in name only, with nothing wired — they’ll stay empty until upstream populates them. Don’t put alerts on them today.

10. Detect SIP scanners and auto-block via fail2ban

Problem: Your honeypot or edge box is getting probed by friendly-scanner, sipvicious, etc.

10a. Detect + log

sudo sipnab -N -d eth0 \
            --kill-scanner \
            --alert syslog \
            --json

--kill-scanner actively responds to known scanner User-Agents (uses a scanner-kill worker thread). The response code defaults to 200. Pass --kill-response 403 (or any 100–699 code) to change it. --alert syslog writes alerts to LOCAL0 so you can pick them up from /var/log/syslog (--syslog is the equivalent boolean form).

10b. Wire to fail2ban

--fail2ban is a boolean flag — it switches sipnab’s stdout to fail2ban-friendly log lines. Pipe to a file (or run under systemd and capture the unit’s stdout).

--fail2ban writes detections, so it needs a detector switched on beside it: --kill-scanner for scanner_detected, --reg-flood for reg_flood. On its own it selects the format and nothing produces lines for it, and a jail reading an always-empty file never says so. Detections carried by HEP input never reach the log without --hep-allow-kill, for the reason the kill path has always refused them: the inner addresses are the sender’s claim, and a jail line would ban whatever address the sender chose. sipnab warns once at startup when --fail2ban meets HEP input without the opt-in, so the empty file is not mistaken for an all-clear.

# Run sipnab with fail2ban-format output, write to a logfile
sudo sipnab -N -d eth0 --kill-scanner --fail2ban \
     >> /var/log/sipnab/fail2ban.log 2>&1

Measure it against your own traffic before any of it reaches fail2ban. Point sipnab at a capture of a normal hour and count who it would have banned:

sipnab -N -I trunk.pcap --kill-scanner --fail2ban | grep -oE 'src=[^ ]+' | sort | uniq -c | sort -rn

Every address in that list is one the jail below would ban. Use ignoreip in the jail for the peers you already trust.

10c. Block a scanner with a rule sipnab wrote, after reading the evidence

Counting src= fields tells you who. It does not tell you whether banning them costs you a customer. --recommend-block groups the detections by source and prints one block per accused address, carrying the evidence, the counter-evidence and a rule in the dialect you name (fail2ban, nftables, iptables or all):

sipnab -N -I trunk.pcap --kill-scanner --reg-flood --recommend-block all --quiet

sipnab recommends and does not apply. sipnab ran none of that output, contacted no firewall and holds no credential anywhere in sipnab. It prints text and stops. The line to read in every block is COUNTER-EVIDENCE:, which says one of three things:

  • also completed a registration or a call — the source is a working peer and a block disconnects it, so the nftables and iptables commands arrive commented out and the fail2ban jail arrives with ignoreip already filled in.
  • none — nothing in this capture says the source has a relationship, and the commands are live.
  • UNKNOWN — no scanner detector ran, so sipnab asked nothing. The commands are live, and the block tells you to re-run with --kill-scanner before acting on them.

The third case is the one worth pausing on: “asked and it had not” and “never asked” produce the same silence, and only the first is a reason to feel safe.

What the behavioral rules actually test

Neither behavioral rule fires on volume, because volume does not separate reconnaissance from operation. A trunk sends OPTIONS keepalives continuously by design — that is how each end learns the other is alive — and an SBC fronting a hunt group reaches dozens of distinct extensions a second. Both rules therefore need an OUTCOME as well as a rate:

SignalWhat it countsWhat arms it
behavioralMore than 10 probe transactions from one source in 5sProbing evidence
enumerationMore than 5 distinct target extensions in 5sProbing evidence

Probing evidence means one of two things inside that window:

  • 5 refusals — final responses past 4xx that are neither an auth challenge (401, 407) nor an ordinary call outcome (408, 480, 486, 487, 488, 491, 600, 603). A 5xx blames the server, so it counts for nothing. Only a refusal on a probe transaction counts, matched by the Via branch: a 481 answering a stray NOTIFY says nothing about the OPTIONS beside it.
  • 5 probes with no answer, still unanswered half a second later, and outnumbering the ones that drew a reply. Any response settles a probe, including a 100 Trying — the question is whether anything is there, and waiting for the final response would count every ringing call as a probe into a hole. Retransmissions of one request count once, so a peer resending an INVITE is one probe rather than four.

A source that has completed a registration or a call — a 2xx to its REGISTER or its INVITE — needs four times either number. Answering its OPTIONS earns it nothing, because sipnab answers anyone’s.

Two consequences follow for anyone reading a file rather than watching an interface. A capture of one direction holds no responses, so sipnab stands the unanswered test down entirely and leaves only the signature and refusal rules. And a capture taken upstream of whatever generates your 404s — between two proxies, say — never shows the refusals either.

Sample log line shape (from src/output/fail2ban.rs):

2026-05-05 12:34:56 sipnab[12345]: scanner_detected src=203.0.113.42 ua="friendly-scanner" method="OPTIONS"
2026-05-05 12:34:57 sipnab[12345]: scanner_detected src=203.0.113.43 ua=- method="REGISTER"
2026-05-05 12:34:57 sipnab[12345]: reg_flood src=203.0.113.42 count=37

The ua= and method= values are quoted, and a bare - means the request carried none — an absent User-Agent is itself a scanner signal, so it is worth keeping distinct from a client that sends the string -, which renders as "-". Both fields carry attacker-influenced text (method can be a non-standard token), so quoting is also what stops a crafted value forging a second src= field inside the line, and sipnab escapes embedded " and \. src= carries no quotes: it holds a parsed IP address, not text from the wire.

/etc/fail2ban/filter.d/sipnab.conf:

[Definition]
failregex = ^.*sipnab\[\d+\]: scanner_detected src=<HOST>.*$
            ^.*sipnab\[\d+\]: reg_flood src=<HOST>.*$
ignoreregex =

/etc/fail2ban/jail.d/sipnab.local:

[sipnab]
enabled = true
filter = sipnab
logpath = /var/log/sipnab/fail2ban.log
# Never ban the boxes the phone system needs. List every carrier SBC, every
# trunk peer and the PBX itself, before enabling the jail — these are the
# addresses that talk to you most, so they are the ones a detector tuned for a
# honeypot flags first.
ignoreip = 127.0.0.1/8 ::1 203.0.113.0/24 198.51.100.10
findtime = 600
# Several detections inside findtime, not one. A single enumeration alert is
# how a busy trunk looks, and `maxretry = 1` turns any one of them into a ban.
maxretry = 5
# An hour, not a day. Long enough to shed a scan, short enough that a wrong
# ban of your own carrier heals without an engineer.
bantime = 3600
action = iptables-allports

Verify the filter against a real log file before enabling the jail, and check the ban list afterwards — fail2ban-regex reports what it would have matched without banning anything:

fail2ban-regex /var/log/sipnab/fail2ban.log /etc/fail2ban/filter.d/sipnab.conf

10c. Detect toll fraud and wangiri call-back bait

Symptom: an unexpected spike of international or premium-rate calls, bursts of short calls to one number prefix (wangiri call-back bait), or sequential dialing through a number range.

# Live fraud heuristics on the edge box (batch mode required)
sudo sipnab -N -d eth0 --fraud-detect --alert syslog

Where a call is going is a signal the shape-based heuristics cannot see. Name the destinations you never expect to dial and sipnab reports an INVITE to one the moment it sees it, no pattern required:

sipnab -d eth0 -N --fraud-detect --fraud-destination DO,VG,MA

sipnab reads numbers through the common international prefixes and resolves them by the longest calling code, so +1 809 555 0100 is the Dominican Republic and not the United States. A number dialed with no prefix is domestic and never matches, and every other fraud finding now names the resolved destination beside its detail.

--fraud-detect runs three heuristics over INVITE traffic per source IP: VolumeSpike (call rate far above the rolling baseline), Wangiri (repeated short calls to the same number prefix), and SequentialScanning (consecutive destination numbers). Alerts fire through the same alert engine as the scanner detectors, so --alert syslog and --alert-exec both work.

You should see alert lines like:

[ALERT] fraud src=203.0.113.42 Wangiri: 4 short calls to prefix '+44900' in 60s
[ALERT] fraud src=203.0.113.42 SequentialScanning: sequential dialing detected: 3 consecutive numbers ending at 15550104
[ALERT] fraud src=203.0.113.42 VolumeSpike: 40 calls in 60s (baseline: 1.5/min)

What to look for: a Wangiri alert on a premium-rate prefix (+44 9xx, +2xx IRSF ranges) is the classic revenue-fraud signature — block the destination prefix at the trunk, not just the source IP. SequentialScanning from an external source usually precedes a toll-fraud attempt: feed the source IP to fail2ban (10b) and review outbound dial permissions.

10d. Run your own script when an alert fires

For exec hooks instead of syslog/fail2ban:

sudo sipnab -N -d eth0 --kill-scanner \
            --alert-exec '/usr/local/bin/notify-slack.sh "$SIPNAB_RULE" "$SIPNAB_SRC" "$SIPNAB_DETAIL"'

Alert data reaches the hook as the SIPNAB_RULE, SIPNAB_SRC, and SIPNAB_DETAIL environment variables — never interpolated into the command string. sipnab rewrites only the three legacy placeholders %rule, %src, and %detail into those $SIPNAB_* references for you. Anything else (%type%, %source_ip%, …) reaches the shell verbatim.

The hook is rate-limited (--exec-rate-limit 10 default) and runs in a sandboxed process.

Pitfalls:

  • The scanner-kill worker needs CAP_NET_RAW to forge SIP responses. Run sipnab as root or with capabilities — privilege drop happens after the worker opens its raw socket.
  • --kill-ua "<regex>" adds a custom User-Agent pattern beyond the built-in scanner list.

11. Find why a call sounds bad in one direction only

Problem: A call sounds bad in one direction. The codec/ptime might differ between legs.

The asymmetry signals (Phase 8.7) live on sipnab’s internal MediaDiagnosis struct and surface through the filter DSL — not the dialog JSON output’s diagnosis block. --filter accepts the alias name directly (codec-asym) and falls back to the raw DSL expression if it isn’t an alias. Both forms are equivalent.

All five asymmetry checks at once, via the problems DSL alias — the --problems flag expands to the same expression, so either spelling works:

sipnab -N -I capture.pcap --filter problems --json

Targeted, one signal at a time:

  • sipnab -N -I capture.pcap --filter codec-asym --json — different codec on each leg
  • sipnab -N -I capture.pcap --filter ptime-asym --json — different packetization interval on each leg
  • sipnab -N -I capture.pcap --filter payload-asym --json — same codec, different dynamic payload type
  • sipnab -N -I capture.pcap --filter duration-asym --json — the two streams ran for noticeably different lengths
  • sipnab -N -I capture.pcap --filter late-media --json — media started well after the answering 200 OK

The equivalent raw-DSL forms, for the two most common of those:

  • sipnab -N -I capture.pcap --filter "codec_asymmetry == true" --json
  • sipnab -N -I capture.pcap --filter "ptime_asymmetry == true" --json

Multiple signals OR’d together require raw DSL — an alias name covers only one signal each:

sipnab -N -I capture.pcap \
       --filter "codec_asymmetry == true OR ptime_asymmetry == true OR late_media == true" \
       --json

From an MCP client, multiple alias names go through find_problems instead: tools/call find_problems {"kinds": ["codec-asym", "ptime-asym", "late-media"]}. See the MCP docs for the full client-side syntax.

What to look for:

  • codec_asymmetry: true on a call from PSTN to internal: usually a transcoding policy that fired in one direction only.
  • ptime_asymmetry: true between two SIP UAs: one is using ptime=20, the other ptime=30. Some downstream jitter buffers can’t handle the mismatch.
  • payload_asymmetry: true: same codec, but each side picked a different dynamic payload type number. Causes audio cut-out on RFC-strict implementations.
  • late_media: true: media starts noticeably after the answering 200 OK. Usually means an SBC is doing late-attach NAT — first real RTP arrives only after media-binding.

Pitfalls:

  • sipnab -N --filter '<expr>' --json emits per-message records for every message of every matching dialog. Pipe through jq -s 'unique_by(.call_id)' if you want one record per affected call.
  • The diagnosis block in CLI --json output and in the REST API today only exposes one_way_audio, nat_mismatch, no_media, and free-form hints. The five asymmetry booleans are filterable via the DSL but aren’t in the JSON shape — if you need them in your output, use the MCP find_problems tool. JSON --call-report uses the same dialog projection and does not add them.

12. Generate a call report (text / Markdown / JSON)

Problem: A support ticket needs full call details attached.

In -N (non-interactive) mode, sipnab normally prints each captured SIP message to stdout and then emits the report. Pass --no-cli-print to suppress the per-message dump so only the report reaches stdout. (-N is not optional: without it sipnab tries to start the TUI and the report output never reaches stdout.)

Markdown, to paste into a ticket or a markdown editor:

sipnab -N -I capture.pcap --call-report 'abc123@host' --markdown --no-cli-print > ticket.md

Plain text, the default report format:

sipnab -N -I capture.pcap --call-report 'abc123@host' --no-cli-print > ticket.txt

JSON, for a tool on the other end:

sipnab -N -I capture.pcap --call-report 'abc123@host' --json --no-cli-print > ticket.json

The report covers: SIP message timeline, SDP offers/answers, RTP stream stats per direction, computed timing (PDD, setup time, retransmits), and the diagnosis engine’s findings.

Tip: combine with Recipe 3’s filter to bulk-generate reports for every failed call. The CLI --filter outputs per-message records, so deduplicate to call_ids first:

# Run all of these, in order.
mkdir -p /tmp/reports
# First pass: enumerate matching calls (no --no-cli-print here — we want the
# per-message JSON so jq can extract call_id).
sipnab -N -I capture.pcap --filter "state == 'Failed'" --json 2>/dev/null \
  | jq -r '.call_id' | sort -u \
  | while read cid; do
      # Second pass per call: --no-cli-print so only the report is written.
      sipnab -N -I capture.pcap --call-report "$cid" --markdown --no-cli-print \
        > "/tmp/reports/$(echo "$cid" | tr '/' '_').md"
    done

Compatibility note: --no-cli-print arrived in v0.3.2. On older binaries strip the leading per-message text by piping through sed -n '/^# Call Report:/,$p' (markdown) or awk '/^{$/{found=1} found' (JSON).


13. Export RTP audio as WAV

Problem: A call sounds bad. You want the actual audio to listen to or share.

Export a WAV from the TUI

sipnab -I capture.pcap
#   → select the call in the call list (Up/Down)
#   → press Tab to switch to the RTP stream view
#   → highlight a stream
#   → F2 to open the Save dialog
#   → cycle the format (Tab or Up/Down) until you reach "WAV — Decoded G.711 audio per RTP stream"
#   → Enter to save

A timestamped .wav lands at the path you choose. The Save dialog also exposes PCAP, PCAP-NG, TXT, JSON, NDJSON, CSV, HTML, Markdown, RTP JSON, and SIPp XML formats — WAV is the format you want for audio.

Live audio playback (TUI)

If you’ve built with the audio feature (in default), press Enter on a stream to open Stream Detail, then P plays that stream through your local audio device. Only Stream Detail binds that key — the stream list ignores it.

Check whether the file holds the whole call

Every exported WAV carries a comment recording its origin — the sipnab version, the mechanism (sipnab-capture), and whether anything is missing. Read it with any tool that shows RIFF metadata:

ffprobe -v error -show_entries format_tags -of default=noprint_wrappers=1 call.wav

A complete file says No omissions recorded. A partial one says INCOMPLETE and names the reason, because these are different problems:

The note saysWhat happenedWhat to do
the payload ring wrapped and dropped N earlier frame(s)The call was longer than the buffer. The file holds the END of it.Raise [limits] max_audio_frames
N of M stream(s) on this call are NOT in this fileA WAV carries two channels; a three-legged call does not fitExport the legs separately
(G729) beside thatA stream’s codec is not one sipnab decodesNothing — the audio is not recoverable from this capture
N frame(s) failed to decodeThe decoder rejected individual Opus framesCheck for truncation (--snaplen)
media in only ONE direction reached this capturesipnab saw no stream going the other way. Either the call really was one-way, or the capture point only sees one legCheck where the tap sits before treating it as a fault

sipnab writes the note after the audio, so the first 44 bytes stay a standard WAV header and every player reads the file normally.

Pitfalls:

  • Supported codecs for WAV decode and playback: G.711 µ-law (PT 0), G.711 A-law (PT 8), Opus (dynamic PT). sipnab matches codec names case-insensitively, as SDP allows. Other codecs (G.729, AMR, etc.) aren’t decoded today.
  • An export refusing with “No audio payload retained” is telling you about the run, not the call. It means this run kept no payload to decode — most often because retention was off. It is not a finding that the call was silent.
  • A failed audio device (headless servers, Tegra without ALSA) no longer crashes the TUI — it disables playback gracefully and surfaces a message suggesting F2 → WAV as an offline alternative.
  • A CLI batch audio-export flag does not exist today. The library functions (rtp::audio_export::export_stream_to_wav, export_dialog_to_wav) are available if you want to build it; until then, scripted batch export means driving the TUI under expect/tmux or writing a small Rust binary that links the library.

13b. Export one call to a conversation archive as a vCon

--export-vcon writes one observed dialog as a vCon container — the IETF interchange format a conversation archive, a compliance store or an agent-facing pipeline already reads. Handing one of those a pcap makes the decoding their problem. Handing them a vCon does not.

sipnab -N -I capture.pcap --export-vcon '[email protected]' --vcon-out call.vcon

Straight into a conserver, without a file in between:

sipnab -N -I capture.pcap --export-vcon '[email protected]'   | curl -fsS -X POST "$CONSERVER/vcon"       -H 'Content-Type: application/json'       -H "Authorization: Bearer $CONSERVER_TOKEN" --data-binary @-

Read the caveat before you trust the contents. Every body is a JSON-encoded string, which means jq needs a parse on the way in:

sipnab -N -I capture.pcap --export-vcon '[email protected]'   | jq -r '.analysis[0].body | fromjson | .capture_completeness.note'

Needs a build carrying the non-default vcon feature. sipnab --version lists what yours has. As a library, examples/export_vcon.rs is the same thing as a program you can run. The vCon page covers what a consumer may and may not conclude from a container sipnab wrote.

14. Analyze a pcap without installing anything

Problem: You don’t want to install anything. The pcap is on your laptop. You want to look at it.

Open https://sipnab.com/analyze/ in any modern browser. Drag-and-drop a pcap or .pcapng file. Everything runs locally via WebAssembly — the pcap never leaves your machine.

The analyze page supports .pcap, .pcapng, .cap (pcap format), and their gzip-compressed variants (.pcap.gz, .pcapng.gz — decompressed transparently, with a notice), and gives you the same call list, ladder diagram, RTP stream view, search, and filter DSL as the native TUI. Keyboard shortcuts match the TUI (? opens the help popup).

Pitfalls:

  • WASM has no network access — live capture is native-only.
  • Very large pcaps (>200 MB) may strain browser memory. Use the native sipnab -N for those.

15. Check a capture against the RFCs

Problem: A call works “most of the time”. Nothing in the ladder looks wrong, and both vendors say their side is fine.

--lint reads the signaling against RFC 3261 and reports what violates it, citing the section:

sipnab -N -I capture.pcap --lint

Each finding names what sipnab saw and what the RFC requires:

error: SIP-3261-12.1.1-CONTACT-MISSING-IN-2XX [call-id@host] §12.1.1 makes the
UAS add a Contact to the response. It is the remote target for the dialog the
2xx creates, so without it the caller has nowhere to send the ACK and nowhere
to send the BYE. The call answers and then cannot be hung up cleanly.
(RFC 3261 §12.1.1) observed=2xx to INVITE with no Contact header field
expected=Contact: <sip:user@host>
Lint: 4 finding(s) across 3 dialog(s)

That is the whole value: observed= and expected= are what you paste into a vendor ticket, and the § reference is what ends the argument.

Use it as a gate in CI, where a violation should fail the build:

sipnab -N -I regression.pcap --lint --lint-fail-on error

Pitfalls:

  • --lint-fail-on error exits non-zero when it finds something — that is the point, but it means a shell with set -e stops there. Exit 3 is “lint findings at or above the threshold”, not a crash.
  • --lint-fail-on warning is stricter than most real traffic survives. Start at error.
  • Findings are per dialog. A capture with one broken proxy repeats the same rule many times; --lint-max-per-rule caps the noise.

16. Analyze a capture into one machine-readable verdict

Problem: You have a pipeline, not a person. It needs one answer per capture, not a stream of messages.

sipnab -N -I capture.pcap --analyze
sipnab -N -I capture.pcap --json-analyze

--json-analyze emits one object, not one line per finding — deliberately. The frames read and the dialogs examined are properties of the run, not of any finding, so a clean capture still serializes to something that states them. A per-finding stream would make “no findings” and “never ran” identical, which is the distinction a pipeline most needs.

Pitfalls:

  • This is a summary, not a substitute for --report: it tells you the capture’s verdict, not each stream’s jitter.
  • A capture whose SIP sits outside --portrange analyzes cleanly because it saw no SIP. Check the packet counts in the same object before trusting a green result.

17. Inspect what NAT did to a call

Problem: One-way audio, and the SDP addresses look like private space. You suspect NAT traversal failed but cannot prove where.

sipnab -N -I capture.pcap --stun
sipnab -N -I capture.pcap --json-stun

You get one record per STUN/TURN transaction and per TURN allocation, each carrying a record field naming which it is. That is what tells you whether the endpoint ever learned its public address, whether the relay allocation succeeded, and whether the candidate it then advertised in SDP matches either.

Pitfalls:

  • STUN rides on the media ports, so a capture filtered to port 5060 contains none of it. Capture the media range too, or use --portrange.
  • A missing STUN transaction is not proof of a broken client: an endpoint with a public address does not need one.

18. Collect messages by call, host or method

Problem: A busy capture interleaves twenty calls. Reading it message by message means reconstructing each call in your head.

sipnab -N -I capture.pcap --group-by call-id
sipnab -N -I capture.pcap --group-by src --json

sipnab emits messages sharing the field together, reordered but not reformatted, so --json output stays one valid object per line and anything downstream keeps working.

Accepts call-id, from, to, method, src, dst.

Pitfalls:

  • Requires -N/--no-tui.
  • It buffers until the capture ends, so it is an offline tool. On a live device you get nothing until you stop it.
  • --max-groups (default 100000) bounds that buffer. A capture with more distinct keys drops the excess rather than growing without limit.

19. Read a very large capture faster

Problem: A multi-gigabyte capture takes long enough that you stop using the tool.

sipnab -N -I huge.pcap --cores 8 --report

Offline, --cores N runs N reconstruction workers, sharding packets by host pair, each with private dialog and RTP-stream stores.

Pitfalls:

  • It covers reconstruction and --report/--json. Per-message output ordering, the security detectors and SRTP decryption stay on the single-threaded path regardless — so a run that needs those gains nothing.
  • On a live device --cores means something different: N capture sockets, which widens capture, not analysis. Processing stays on one thread either way. See tuning.
  • --cores and --metrics do not combine usefully offline; the run exits before a scrape lands, and sipnab says so.

20. Verify the exact bytes behind a finding

Problem: You are disputing a finding with a vendor. Paraphrasing the packet is not evidence.

Every --json, --report, REST and MCP result carries a frame pointer:

"frame": "capture.pcap#3@86eadc8a324bb487"

Hand it straight back to sipnab:

sipnab --show-frame 'capture.pcap#3@86eadc8a324bb487'
VERIFIED  capture.pcap#3@86eadc8a324bb487
348 bytes, frame 3 of capture.pcap
00000030  30 20 34 30 33 20 46 6f  72 62 69 64 64 65 6e 0d  |0 403 Forbidden.|

VERIFIED is the word that matters. With the digest, sipnab checks the frame’s bytes against it and refuses a capture that has since rotated, truncated or recompressed, rather than answering with whatever now sits at that position. Without a digest — the form a human types — sipnab prints the frame and marks it UNVERIFIED, because it has nothing to check against.

Pitfalls:

  • Quote the pointer. # starts a comment in most shells and everything after it disappears.
  • UNVERIFIED is not a warning about that packet; it means you did not give sipnab a way to prove it is the same file.

21. Detect your own fault patterns with a plugin

Problem: You have a site-specific fault pattern. A filter can select the calls, but you want sipnab to diagnose it — as a finding, with evidence, everywhere findings appear.

sipnab -N -I capture.pcap --plugin ./my-detector.wasm --report

Findings appear beside the built-in ones, in the same shape, so --call-report, the TUI and the JSON all render yours with no extra work.

Before loading one someone sent you, know what you are trusting:

A plugin has no imports at all. Not a restricted set — none. No WASI, no filesystem, no network, no clock, not even logging. A module that imports anything fails to instantiate. Fuel metering bounds CPU, a 16 MiB ceiling bounds memory, 4 MiB bounds each reply, and a trap or exhausted budget fails that dialog’s plugin findings and nothing else.

What is not bounded is what it reads: a plugin sees every dialog sipnab reconstructs, including Authorization headers and MESSAGE bodies. It cannot send them anywhere, but it chooses what to report. Load one the way you would run a script — from someone you trust, or after reading it.

Pitfalls:

  • Needs a build with --features plugins, which is not in the default feature set. sipnab --version tells you whether the binary in front of you can load one.
  • If a filter or a jq pipeline already answers your question, use those — no build step, no trust decision. See plugins.

22. Measure whether the loss is yours or the network’s

Problem: RTP loss figures look terrible. Before you escalate to the carrier, you need to know the capture itself was not the thing dropping packets.

A lossy capture does not produce a smaller answer. It produces a wrong one. sipnab counts a dropped RTP packet as network loss that never happened, so MOS reads worse than the call actually was.

sipnab polls libpcap’s kernel counters once a second and reports two numbers that mean different things:

PACKETS ARE BEING DROPPED on 'eth0' (kernel buffer: 18432, interface/driver: 0).
Counterlibpcap fieldWhat it meansWhat fixes it
kernel bufferps_dropThe ring was full when the packet arrived. sipnab was not draining fast enough.a bigger -B, a narrower BPF filter, a lower --snaplen
interface/driverps_ifdropThe NIC or its driver discarded it before libpcap ever saw it.a bigger buffer cannot fix this — it is the NIC

The same two numbers appear in four places, so whichever surface you are on can answer the question:

sipnab -N -I capture.pcap --report          # named separately, each with its own fix
sipnab -N -d eth0 --metrics 127.0.0.1:9100  # sipnab_capture_kernel_dropped_packets_total
                                            # sipnab_capture_interface_dropped_packets_total

A clean run says so explicitly, so silence is never ambiguous:

Live capture on 'eth0' finished: 4821003 packets, no drops

Pitfalls:

  • Operators routinely respond to any drop by raising -B. That does nothing at all for interface drops and wastes memory while the real problem goes unaddressed.
  • Both counters zero does not mean the capture is complete: a frame can arrive intact and still be unreadable, or --snaplen can cut it short. --report counts those separately. See tuning.

23. Find the device flooding REGISTER

Problem: A device is hammering REGISTER and the proxy’s CPU shows it. You need to name the device and the reason.

sipnab -N -I capture.pcap --filter "method == 'REGISTER' AND state == 'Failed'" --json
sipnab -N -I capture.pcap --group-by src --filter "method == 'REGISTER'"

The pattern to look for is a 401/403 answered by an immediate retry with the same credentials. That is a client that treats an auth challenge as a transient error, and it does not stop on its own:

{"status_code":403,"reason":"Forbidden","cseq":{"number":2,"method":"REGISTER"},
 "ua":"SynthSwitch/1.0","from":"Alice <sip:[email protected]>"}

ua and from are what you take to the desk that owns the endpoint. The frame field in the same object is what proves it (recipe 20).

Pitfalls:

  • A storm from many sources with one From is credential reuse, not one broken phone. Group by src before concluding.
  • Registration traffic is often on a different port from calls. If the counts look impossibly low, widen --portrange.

24. Diagnose a codec mismatch

Problem: Calls to one destination fail immediately. The far end says “your equipment is wrong”.

sipnab -N -I capture.pcap --filter "state == 'Failed'" --json
sipnab -N -I capture.pcap --call-report '<call-id>' --markdown

A 488 Not Acceptable Here after an INVITE is the signature. The call report puts the offered and answered codec lists next to each other, which is the part that settles it: if the offer contains only PCMU and the far end supports only G729, no configuration on your side of the trunk was ever going to work.

Pitfalls:

  • 488 is not always codec: it also covers unsupported transport or an unacceptable ptime. Read the SDP, not just the status code.
  • A re-INVITE can renegotiate mid-call, so a call that answered can still fail this way later. Filter on the dialog, not the first transaction.

25. Find out why DTMF does not reach the IVR

Problem: Callers press keys and the IVR does not respond. Everyone blames the IVR.

DTMF travels three different ways, and the failure is nearly always that the two ends chose differently:

sipnab -N -I capture.pcap --json | jq 'select(.method == "INFO")'   # SIP INFO
sipnab -N -I capture.pcap --report                                  # RFC 2833 / telephone-event

In the SDP, look for a=rtpmap:101 telephone-event/8000 on both sides. If one side offers it and the other does not, that side sends in-band audio tones instead — which survive G.711 and which G.729 destroys.

Pitfalls:

  • In-band DTMF through a low-bitrate codec is not a bug you can fix in signaling. The codec destroys the tones before they reach the IVR.
  • A telephone-event payload type that differs between offer and answer (101 vs 96) is legal and still works. A mismatch in whether it exists at all is not.

26. Run sipnab as a HEP relay

Problem: Several edge nodes send HEP. You want them analyzed locally and forwarded to a central Homer, without installing an agent twice.

sipnab both receives and sends HEP, and the two are not the same job:

sipnab -N \
  --hep-listen 0.0.0.0:9060 --hep-allow 192.0.2.0/24 \
  --hep-send homer.example.com:9060 --hep-id 42

Receiving makes sipnab a collector: your SBC or proxy already speaks HEP, so nothing touches the capture path — no port mirror, no root. Sending makes it a capture agent: SIP goes as HEP protocol type 1 and RTCP as type 5, so the collector can report media quality and not only call setup. RTP is never forwarded.

Doing both makes it a tap in the middle: analyze at the edge, keep the central Homer authoritative.

Pitfalls:

  • sipnab refuses a non-loopback --hep-listen unless you pass --hep-allow or --hep-auth. That is deliberate: an open HEP port accepts forged call records from anyone who can reach it.
  • --hep-auth travels in cleartext inside the datagram. It defeats blind spoofing, but it does not survive an on-path sniffer. Tunnel HEP over WireGuard or IPsec on an untrusted path.
  • --hep-auth-mode hmac signs the whole datagram — every chunk, so the signature covers the source and destination the sender asserts, not only the payload. That is what makes --hep-allow-kill safe to pair with it: the kill response targets exactly those fields, and an unsigned assertion could aim it at a third party. Earlier builds signed the payload only, so anyone who observed a packet could re-send it with address chunks appended and it still verified. sipnab now refuses those version-1 tokens and logs why. There is no compatibility switch, so upgrade both ends together.
  • Give each agent its own --hep-id, or the collector cannot tell your nodes apart.

26a. Carry the feed over TCP and TLS

Both halves of HEP default to UDP, which is what Homer’s agents have always spoken and what sipnab has always done. Homer’s collectors also accept TCP and TLS, and each side of sipnab names its own transport so a relay can take one in and send another out:

sipnab -N \
  --hep-listen 0.0.0.0:9061 --hep-listen-transport tcp --hep-allow 192.0.2.0/24 \
  --hep-send homer.example.com:9060 --hep-id 42

There is deliberately no one flag covering both. sipnab is both agent and collector, often in one process, so a transport flag that named neither side would leave a reader of the command line guessing which half it meant.

Across a path you do not control, wrap the outgoing feed in TLS and name the issuer that signs the collector’s certificate:

sipnab -N -d eth0 \
  --hep-send collector.example.com:9063 --hep-send-transport tls \
  --hep-tls-ca /etc/sipnab/collector-ca.pem

The receiving side needs a certificate of its own, and refuses a private key any other user on the host can read:

# Run all of these, in order.
chmod 600 /etc/sipnab/collector.key
sipnab -N \
  --hep-listen 0.0.0.0:9063 --hep-listen-transport tls \
  --hep-tls-cert /etc/sipnab/collector.pem --hep-tls-key /etc/sipnab/collector.key \
  --hep-parse --hep-auth-file /etc/sipnab/hep.key

Pitfalls:

  • HEP v2 cannot travel on a stream. It declares no total length, so nothing can say where one packet ends and the next begins. A tcp or tls listener reads HEP v3 only; senders that emit v2 must stay on udp.
  • TLS says the path is private, not who is on it. sipnab asks connecting agents for no certificate of their own, so the identity of a peer still comes from --hep-auth-file. --hep-allow still applies too, and means the same thing it means on UDP and TCP: the source address of the connection. Keep both — TLS does not lift the bind refusal for a routable --hep-listen, for exactly this reason.
  • A named --hep-tls-ca replaces the trust store rather than joining it. That is what you want for a private collector, and it means a public certificate stops verifying the moment you name a private issuer. Omit the flag to use the host’s CA bundle instead.
  • A tcp or tls sender rebuilds its connection when the collector restarts, one attempt per packet. The packet that discovers the break can vanish; nothing after it does.

27. Compare the same call at two nodes

Problem: The SBC says it sent the call. The PBX says it never arrived. Both are looking at their own logs.

Capture at both ends and read the same Call-ID from each:

# Run all of these, in order.
sipnab -N -I sbc.pcap --call-report '<call-id>' --markdown > sbc.md
sipnab -N -I pbx.pcap --call-report '<call-id>' --markdown > pbx.md
diff sbc.md pbx.md

What the difference tells you:

  • Present at the SBC, absent at the PBX — it never crossed. Look at routing, firewall, or the network in between.
  • Present at both, different bodies — something rewrote it. An SBC that changes SDP addresses is doing its job; one that drops a header is not.
  • Present at both, different timing — the message crossed but late. Compare against the retransmission timers in each report.

Pitfalls:

  • Clocks. Two captures from two machines are only comparable if their clocks are, and a few hundred milliseconds of skew makes a normal exchange look like a retransmission. Check NTP before reading timing differences as evidence.
  • A B2BUA changes the Call-ID between its legs by design, so this recipe compares a proxy’s two sides, not a B2BUA’s. Correlate those by From/To and time instead.

28. Run sipnab as a service

Problem: The capture should survive a reboot and an SSH disconnect, and write somewhere you can find it.

# /etc/systemd/system/sipnab.service
[Unit]
Description=sipnab SIP capture
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
ExecStart=/usr/bin/sipnab -N -d eth0 --syslog \
  --metrics 127.0.0.1:9100 \
  --alert-json
Restart=on-failure
RestartSec=5
# sipnab drops privileges itself after opening the capture socket.
AmbientCapabilities=CAP_NET_RAW CAP_NET_ADMIN
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes

[Install]
WantedBy=multi-user.target
# Run all of these, in order.
sudo systemctl daemon-reload
sudo systemctl enable --now sipnab
journalctl -u sipnab -f

Pitfalls:

  • --syslog is what makes journalctl useful. Without it the interesting output goes to stdout, where journald merges it without structure.
  • Granting CAP_NET_RAW via AmbientCapabilities is what lets the unit run without being root at all. Do not add User=root back “to be safe” — that undoes it.
  • A live run with no -O writes no capture file. If you want the packets kept, say where.

29. Read a capture whose SIP is not on port 5060

Problem: A trunk capture shows almost no SIP. The calls are in the file. The signaling is on 5070, or 5080, or whatever port the SBC uses.

--portrange gates signaling only. sipnab skips SIP whose source and destination both sit outside the range, and that traffic then appears in no message count, no dialog and no output format.

Widen the gate to everything the file holds:

sipnab -N -I capture.pcap --portrange 1-65535 --report --no-cli-print

Or name the range you actually run, which keeps a busy file fast:

sipnab -N -I capture.pcap --portrange 5060-5090 --report --no-cli-print

What to look for:

  • sipnab counts the SIP the gate discarded and names the busiest ports it was on, so a default run tells you what to widen to instead of reporting a quiet capture.
  • Media is never gated by --portrange, because RTP uses SDP-negotiated dynamic ports. A too-narrow signaling range does not hide the streams; it hides the calls they belong to, and the streams then arrive as orphans.

Pitfalls:

  • On a live device the range becomes the BPF filter when you supply no filter of your own. There the kernel drops the traffic, so nothing downstream — this counter included — can see it was ever there.
  • SIP over WebSocket has a gate of its own, --ws-portrange. Widening this one does not widen that one (recipe 36).

30. Find calls that answered and were never acknowledged

Problem: Calls answer and drop a second later, or the switch logs “no ACK”. A 200 OK with no ACK behind it is a call both sides’ CDRs record as connected and nobody could speak on.

Two timers decide when sipnab treats silence as a fault rather than as a capture that stopped early:

sipnab -N -I capture.pcap --ack-timeout 5 --analyze

--no-final-response-timeout does the same for an INVITE that never drew a final response at all:

sipnab -N -I capture.pcap --no-final-response-timeout 30 --analyze

What to look for:

  • The defaults are the RFC 3261 timers: Timer H (32 s) for the missing ACK, Timer C (180 s) for the missing final response. A capture shorter than the timer cannot distinguish a fault from a truncation, which is exactly why sipnab does not call it one.
  • Lower them when the capture is short and the calls are local. A five-second --ack-timeout on a LAN is a real finding; the same number on an international trunk is a stopwatch running against the network.

Pitfalls:

  • Lowering --no-final-response-timeout reports every call still ringing when the capture stopped, which on a live tap is a normal state and not a fault. Read it against how the capture ended.
  • These change what sipnab reports, not what the capture holds. A missing ACK that arrived after the last packet in the file is missing from the file, not from the network.

31. Set the quality thresholds to your own network

Problem: Every stream shows yellow, or nothing ever does. The shipped thresholds describe a general network, and yours is not one.

The eight quality knobs carry no built-in command-line default, so a value you do not pass leaves the config file’s value in charge:

sipnab -N -I capture.pcap --mos-warn 4.0 --mos-bad 3.2 --jitter-warn-ms 20 --jitter-bad-ms 50 --loss-warn-pct 1.0 --loss-bad-pct 3.0 --report --no-cli-print

Round-trip time has its own pair, read from RTCP when the capture carries it:

sipnab -N -I capture.pcap --rtt-warn-ms 150 --rtt-bad-ms 300 --report --no-cli-print

The one MOS input a passive tap cannot measure is the one-way delay. Declare it rather than letting the default stand:

sipnab -N -I capture.pcap --one-way-delay 40 --report --no-cli-print

What to look for:

  • A MOS built on an assumed delay is only as good as the assumption. media_diagnostics (recipe 58) reports delay.assumed: true when nothing supplied one, which is the flag to read before quoting a score to a carrier.
  • --pdd-threshold defaults to ITU-T E.721’s target for an international connection, because a capture does not say what kind of call it holds. A network that knows its traffic is local or toll wants a tighter number.

Pitfalls:

  • Thresholds change which figures the output colors and which ones it reports. They do not change the measurement, so widening one to quiet a report hides the finding rather than fixing it.
  • --one-way-delay is a declaration about the observed path. Declaring one you have not measured trades an honest default for a confident wrong number.

32. Export one customer’s calls as a smaller capture

Problem: A vendor wants the packets, and the file is 4 GB of everyone’s traffic. Sending all of it is both slow and a disclosure.

Only the BPF expression narrows -O. It writes each packet as it arrives, inside the capture loop, before any dialog is complete enough to judge – nothing can tell whether a call matches from.user == '1001' until sipnab has seen and correlated its INVITE. So --filter does not reach it. Measured: -O under a filter matching nothing and -O with no filter at all produce a byte-identical file.

That matters here more than anywhere else on this page, because the thing you are about to do is mail the result to somebody else. Narrow with the positional BPF expression, which runs in the kernel before sipnab sees a packet:

sipnab -N -I capture.pcap -O one-customer.pcap --no-cli-print "host 192.0.2.10"

The same as pcapng, which is the format that can also carry decryption secrets:

sipnab -N -I capture.pcap -O one-customer.pcapng --pcapng --no-cli-print "host 192.0.2.10"

BPF is a network-level language: it selects by address, port and protocol, not by SIP user. If the subset you need is a SIP-level one, you have two honest options and neither is -O. Export the dialogs as vCon containers, which IS dialog-filtered:

sipnab -N -I capture.pcap --export-vcon-when "from.user == '1001'" --export-vcon-dir ./out

Or export those dialogs with the identifiers replaced. --redact rewrites an exported CONTAINER, not a pcap – there is no redacted-capture path, and the run refuses rather than pretending:

sipnab -N -I capture.pcap --export-vcon-when "from.user == '1001'" --export-vcon-dir ./out --redact

What to look for:

  • Read the output back before you send it. A file that reconstructs the same dialogs is one the far end can work with; one that does not means the filter cut a leg off the call.
  • --pcapng is worth the extra bytes when the capture carried decryption secrets, because pcapng is the format that can carry them (recipe 7c).

Pitfalls:

  • A filter selects dialogs, so a call whose INVITE was outside the capture has no dialog to match and its packets are not written.
  • sipnab refuses to write its output over its input. Name a different path, in a different directory if you are working in place.

33. Check what a STIR/SHAKEN Identity header actually claims

Problem: Somebody is spoofing a number, or a carrier says your calls arrive with a bad attestation, and the argument is about what the header actually held.

--stir-shaken decodes the RFC 8224 Identity header’s PASSporT and reports what it claims:

sipnab -N -I capture.pcap --stir-shaken --no-cli-print

Each decoded token prints one line:

STIR/SHAKEN: attest=A orig=+15551234567 dest=["+15559876543"] verified=NotChecked

What to look for:

  • attest=A means the originator claimed full attestation. Nothing here confirms the claim, and a forged Identity header decodes exactly like a genuine one — so this is evidence about what the originator claimed, never grounds for trusting a calling number.
  • verified=Expired is the one check sipnab applies locally: RFC 8224 section 6.2, Step 4, iat freshness, against the capture timestamp of the packet carrying the header. An old pcap therefore reports the tokens that were still fresh at the moment they crossed the tap.
  • No STIR/SHAKEN: line at all means no Identity header reached the capture point. On an inbound trunk that is itself the finding.

Pitfalls:

  • sipnab makes no outbound request to analyze a capture, so it never fetches the certificate the token references and never checks the signature over it. verified=NotChecked is the honest answer, not a failure.
  • sipnab logs the line at info. --quiet floors the log at warn and hides it, and so does the TUI.

34. Detect weak digest authentication on a registrar

Problem: An audit asks whether the phones authenticate properly, and nobody can answer from the switch’s configuration alone because the challenge is what the wire actually carried.

--digest-leak reads the 401/407 challenges and the Authorization headers answering them, and reports four weaknesses: an MD5 algorithm, a nonce reused across challenges, a challenge with no qop, and a response with no cnonce where the challenge offered qop.

sipnab -N -I capture.pcap --digest-leak --alert-json --no-cli-print

Findings arrive as one JSON object per detection:

{"alert":"digest","detail":"WeakAlgorithm: challenge uses algorithm=MD5 (should be SHA-256+)","src":"203.0.113.101","ts":"2026-05-05T12:34:56Z"}

Group the accused sources and get a firewall rule with the evidence beside it:

sipnab -N -I capture.pcap --digest-leak --recommend-block fail2ban --quiet

What to look for:

  • WeakAlgorithm with no algorithm parameter in the challenge is the same finding as algorithm=MD5: RFC 2617 makes MD5 the default when the parameter is absent, so silence is a choice.
  • NonceReuse across challenges is the one to act on first. A nonce that repeats is a replay window, and it usually means a registrar cluster whose nodes do not share nonce state. A 401 the registrar retransmitted – the same Call-ID, CSeq and Via branch – is the same challenge arriving twice, and does not count.

Pitfalls:

  • The detector needs an alert channel or it prints nothing. --digest-leak on its own arms the detection and has nowhere to put it; pass --alert-json, or --alert syslog, or read it through --recommend-block. A run with neither looks exactly like a clean capture.
  • This is a configuration audit, not a credential dump. sipnab reports the weakness in the exchange; it never prints the response hash, which is an offline attack against the subscriber’s password.

35. Read SIP that arrives in TCP segments or IP fragments

Problem: A capture full of SIP over TCP shows half the messages, or a large INVITE with many codecs and ICE candidates never parses.

sipnab reassembles IP fragments and TCP segments by default, and two limits bound what that costs:

sipnab -N -I capture.pcap --max-reassembly 500 --reassembly-ttl 15 --report --no-cli-print

sipnab drops a message larger than the TCP buffer rather than growing one into memory. Raise it for a switch that sends very large bodies:

sipnab -N -I capture.pcap --max-tcp-buffer 262144 --report --no-cli-print

Turn reassembly off when the capture is pure single-packet UDP and reassembly is only overhead:

sipnab -N -I capture.pcap --no-reassembly --report --no-cli-print

What to look for:

  • --report counts frames no decoder could read separately from frames that were never captured. A capture that lost the second half of every large INVITE reports differently from one that never saw them.
  • sipnab holds an incomplete datagram or half-read stream for --reassembly-ttl seconds and then sweeps it. On a lossy path that sweep is where a truncated message goes, and its count is what tells you the path is lossy.

Pitfalls:

  • --no-reassembly is the inverse of segment reassembly. On a TCP or TLS capture it does not make the run faster in any useful sense; it makes every multi-segment message unparseable.
  • A --snaplen that cut the frame short is a different problem with the same symptom. Reassembly cannot restore bytes the capture never took (recipe 22).

36. Read SIP over WebSocket from a WebRTC gateway

Problem: A WebRTC leg is invisible. The browser talks SIP over WebSocket (RFC 7118) to Kamailio, OpenSIPS or Janus, and the capture shows TCP and no calls.

The shipped WebSocket port set — 80, 443, 8080, 8443 — is the browser’s view of the web, not a deployment’s. Behind a reverse proxy sipnab sees whichever port the proxy forwards to:

sipnab -N -I capture.pcap --ws-portrange 1-65535 --report --no-cli-print

Or name the port the gateway actually listens on:

sipnab -N -I capture.pcap --ws-portrange 5066-5066 --report --no-cli-print

sipnab refuses a WebSocket frame whose payload exceeds 64 KiB (65,536 bytes, MAX_FRAME_SIZE) rather than reassembling it: no SIP message comes close, so a frame that large is either not SIP or not well-formed.

What to look for:

  • sipnab tallies the SIP-over-WebSocket it declined to unwrap and names the ports it was on, so a run with the default set still tells you where the traffic is.
  • A range replaces the shipped set, exactly as --portrange replaces the default signaling ports. Naming 5066 alone stops unwrapping 443.

Pitfalls:

  • WSS is TLS. Unwrapping the WebSocket framing needs the TLS decrypted first — a keylog (recipe 7) or a uprobe (recipe 7g) — and --ws-portrange alone does nothing for it.
  • A browser leg carries no RTP that a server-side tap can see when the media goes peer to peer. Put the capture where the media is, or read the relay’s account of it (recipe 54).

37. Read SIP carried inside a GTP-U or VXLAN tunnel

Problem: On a mobile core or a data-center fabric the SIP is real and the capture is empty, because every packet is an outer UDP header with the call inside it.

--capture-tunnels takes all traffic on the tunnel ports so the inner SIP reaches sipnab. With no value it covers GTP-U (2152), VXLAN (4789) and GENEVE (6081):

sudo sipnab -N -d eth0 --capture-tunnels

Name the ports yourself for a non-standard deployment — Linux’s pre-IANA VXLAN port, for instance:

sudo sipnab -N -d eth0 --capture-tunnels=8472

It applies to a file too, where the tunnel is already in the bytes:

sipnab -N -I capture.pcap --capture-tunnels --report --no-cli-print

What to look for:

  • Without the flag the auto-generated filter still sees VLAN, QinQ, PPPoE and MPLS-encapsulated SIP. Those cost nothing to add and are already on. Tunnels are the case that is off.
  • The dialogs come out with the inner addresses, which are the ones the SIP layer used and the ones a routing argument is about.

Pitfalls:

  • This is not a narrowing filter. BPF cannot walk a GTP-U extension-header chain to reach the inner port, so the only way to cover these is to take everything on the port — on a mobile core, that is the entire user plane. Size -B for it (recipe 43).
  • Ignored when you supply your own BPF expression. Your filter is then the whole filter, tunnel ports included or not.

38. Search a capture for a header or a body string

Problem: You know a string that identifies the traffic — a P-Asserted-Identity, an X- header your SBC adds, a trunk group name — and not which calls carry it.

-e is the positional match expression: a regex against the whole raw message. Once any message in a dialog matches, sipnab shows every later message of that dialog too:

sipnab -N -I capture.pcap -e 'X-Trunk-Group' -i

Whole words only, with two messages of context after each hit:

sipnab -N -I capture.pcap -e 'INVITE' -w -A 2

Multi-line headers folded into one line before matching, which is what a regex spanning a wrapped Contact needs:

sipnab -N -I capture.pcap -e 'User-Agent' -i --single-line

Everything that does not match, for finding the odd one out:

sipnab -N -I capture.pcap -v -e 'OPTIONS' --report --no-cli-print

What to look for:

  • -e matches the message text; --filter matches the reconstructed dialog. Use -e for “which calls mention this string” and --filter for “which calls behaved this way”. payload in the filter DSL is the bridge: it matches when any message in the dialog contains the value.
  • Add --hexdump when the argument is about bytes rather than text — a header with trailing whitespace, or a body whose line endings are wrong.

Pitfalls:

  • -e is a regex, so . and + in a phone number or a header name match more than you meant. Escape them or add -w.
  • The trailing positional argument is a BPF filter, not a match expression. sipnab -I capture.pcap INVITE asks libpcap to compile INVITE and fails; the match expression needs -e.

39. Collect a directory of rotated captures into one analysis

Problem: tcpdump -C -W left you 27 files and the call you want crosses three of them.

-I takes a file, a directory or a glob, and it is repeatable. sipnab reads the files in the order their packets arrived, never by filename — a ring buffer wraps, so tg.pcap7 can hold older traffic than tg.pcap0:

sipnab -N -I '/var/captures/*.pcap' --report --no-cli-print

Descend into subdirectories, which is off by default:

sipnab -N -I '/var/captures/*.pcap' --recursive --report --no-cli-print

What to look for:

  • One store serves the whole set, so a dialog whose INVITE is in one file and whose BYE is in another comes out as one call.
  • sipnab -N -I /var/captures --input-name 'tg.pcap[0-4]' narrows a directory to the files worth reading. sipnab matches the pattern against the filename alone, so it behaves the same at every depth under --recursive — and sipnab refuses the pair when -I names one file directly, rather than filtering nothing and saying nothing.

Pitfalls:

  • -l/--limit bounds dialogs for the whole run, not per file. A 27-file directory reaches the cap 27 times sooner than one file does, and eviction drops the oldest dialogs — the worst ones to lose for a post-mortem. Raise it (recipe 43).
  • --recursive is off on purpose: descending silently can analyze several times the traffic you pointed at, and nothing in the output would say so.

40. Open the same evidence in Wireshark

Problem: The argument has moved to a layer sipnab does not decode, or the person you are arguing with only trusts Wireshark.

--tshark-filter prints a ready-to-run tshark command for this capture and this display filter:

sipnab -N -I capture.pcap --tshark-filter 'sip.Method == "INVITE"' --no-cli-print
tshark -r capture.pcap -Y 'sip.Method == "INVITE"' -V

Narrow it to one call, using a Call-ID you already have:

sipnab -N -I capture.pcap --tshark-filter 'sip.Call-ID == "abc123@host"' --no-cli-print

What to look for:

  • Pair it with recipe 20. A frame pointer names one frame and a digest proves it is the same file; the tshark command puts that frame in front of somebody who wants to see the whole stack under it.
  • The printed command is text. Read it, edit the filter, and run it yourself — sipnab does not run it for you.
  • sipnab -N -I capture.pcap --wireshark is the same idea for the GUI: it builds a display filter naming every Call-ID in the capture and prints it for you to paste into Wireshark.

Pitfalls:

  • --wireshark only prints text, so it needs neither Wireshark nor a display on the capture host. Open the capture in Wireshark separately and paste the printed filter.
  • A tshark display filter is not sipnab’s filter DSL and not a BPF expression. All three languages appear in this cookbook, and none of them accepts the syntax of the other two.

41. Measure the gap between consecutive messages

Problem: The ladder looks correct and the call still feels slow, or a retransmission argument needs the actual timers rather than “it looked immediate”.

--delta-time replaces the wall clock in front of each message with the gap since the previous one:

sipnab -N -I capture.pcap --delta-time
+0.000s 192.0.2.20:5060 -> 192.0.2.15:5060 INVITE UDP
+0.512s 192.0.2.15:5060 -> 192.0.2.20:5060 100 Trying UDP

Add the full header block of the bodyless messages, which otherwise print as a one-line summary:

sipnab -N -I capture.pcap --delta-time --show-empty

Group the messages by call first, so the gaps you are reading belong to one ladder:

sipnab -N -I capture.pcap --group-by call-id --delta-time

What to look for:

  • 500 ms, then 1 s, then 2 s, then 4 s between copies of one request is RFC 3261’s Timer A backing off. That is a retransmission, and it says the first copy never reached anything that would answer it — not that the sender is chatty.
  • A long gap before 180 Ringing is post-dial delay and belongs to the far end. A long gap before 100 Trying belongs to the next hop, which should answer immediately.

Pitfalls:

  • Without --group-by, a busy capture interleaves twenty calls and the “gap” is the time to the next message of any of them.
  • sipnab computes each delta from capture timestamps. Two captures from two machines are only comparable if their clocks are (recipe 27).

42. Record which invocation produced a report

Problem: A report from three weeks ago says the capture was clean, and nobody can say which file, which filter or which port range produced it.

--run-provenance-file appends one JSON line per run, at startup — before sipnab loads the config and before it opens any capture device:

sipnab -N -I capture.pcap --run-provenance-file runs.jsonl --report --no-cli-print

Under systemd, point it somewhere durable — --run-provenance-file /var/log/sipnab/runs.jsonl — and every restart appends rather than replaces.

{"record":"run","seq":1,"argv":["sipnab","-N","-I","capture.pcap","--report"],
 "cwd":"/var/captures","user":"sipnab","uid":993,
 "version":"0.5.142 (d2965454) features: native,tui,audio,tls,hep,api,mcp,metrics,plugins,vcon",
 "capture":{"instance":"3f9b0718d158c64087d09c-1","node":"capture-01"},
 "started":"2026-05-05T12:34:56Z"}

What to look for:

  • capture.instance is the identifier every MCP and REST answer carries, so it joins an artifact back to the command that made it.
  • features is the build, not the flags. A report missing a section because the binary lacked the Cargo feature reads identically to a clean one, and this line is where that difference shows up.

Pitfalls:

  • A record sipnab cannot write stops the run. That is deliberate: a best-effort line’s absence would mean either “not enabled” or “the disk was full”, and nobody could tell which. Stopping costs nothing, because sipnab has read no packet yet.
  • sipnab opens the file for append and never truncates it, and creates it mode 0600 — argv holds capture paths, and a path holds a customer’s name.

43. Keep a long-running capture inside a memory budget

Problem: A capture that should run for a week dies on day three under the out-of-memory killer.

Five caps bound what a run retains, and each bounds a different thing:

sipnab -N -I capture.pcap --limit 20000 --max-streams 4000 --max-lost-sequences 4000 --findings-history 500 --report --no-cli-print

The in-flight queue between capture and processing has a budget of its own, separate from the kernel ring -B sizes:

sudo sipnab -N -d eth0 --buffer-budget 128 -B 128

What to look for:

  • -l/--limit is not a concurrency limit. Nothing removes a completed dialog, so the bound scales with uptime rather than with load: a box carrying five concurrent calls still evicts once 100,000 calls have completed.
  • The eviction count appears wherever a dialog count appears, so a run that hit a cap says so rather than quietly answering from a truncated store.

Pitfalls:

  • Eviction drops the oldest dialogs, which are the ones a post-mortem wants. On a long run, prefer rotating output files (recipe 44) over relying on the store to hold a week.
  • --max-groups bounds --group-by keys and is a separate map from --limit. A run that groups a week of traffic by source address needs both raised.

44. Run a capture that stops on its own

Problem: A capture running over a weekend either fills the disk or waits for somebody to stop it at the wrong moment.

Stop after a packet count, a wall-clock duration, or a size:

sudo sipnab -N -d eth0 -n 500000 --duration 8h --autostop filesize:2048 -O /var/capture/sip.pcapng --pcapng

Or keep capturing forever inside a fixed budget, by rotating and deleting the oldest chunks:

sudo sipnab -N -d eth0 -O /var/capture/sip.pcapng --pcapng --split filesize:50 --split-keep 8

What to look for:

  • filesize counts MiB, the same unit --split filesize and -B use, so filesize:2048 stops at what a file browser calls 2 GiB.
  • -n/--count counts every packet received from the capture source. On a HEP listener that includes packets later dropped by the allowlist, the rate limiter or authentication.

Pitfalls:

  • --split-keep deletes capture files. It touches only the files that running process created and named, so anything else in the directory stays — but leave it off whenever the capture is evidence you cannot retake.
  • A run killed mid-capture leaves behind whatever it had not yet deleted, and the next run does not adopt it.

45. Check whether comfort noise explains a one-way finding

Problem: A mobile or VoLTE trunk reports one-way audio on calls that were fine, or a trunk that really is one-way reports nothing.

--cn-suppression-ratio is the share of a call’s packets that must be comfort noise before sipnab accepts comfort noise as the explanation for media in one direction. It is the one threshold that suppresses a finding, so getting it wrong fails silently:

sipnab -N -I capture.pcap --cn-suppression-ratio 0.6 --report --no-cli-print

Lower it where a call carrying any comfort noise at all is still expected to be bidirectional:

sipnab -N -I capture.pcap --cn-suppression-ratio 0.1 --one-way --json-dialogs

What to look for:

  • A trunk with aggressive voice activity detection routinely passes 30 % comfort noise, and above the ratio one-way audio is never reported on it. If a trunk has stopped producing one-way findings entirely, this is the first knob to check.
  • media_diagnostics (recipe 58) counts the comfort-noise frames and the silence periods per stream, which is the measurement this ratio governs.

Pitfalls:

  • Refused at 0 and above 1 by name, in the flag and in the config file, so a config file is not the lenient way in.
  • Raising it is a statement about the trunk, not about the call. Set it per capture source rather than globally.

46. Detect fraud placed outside business hours

Problem: The international bill jumped and the calls all happened at 03:00.

--fraud-detect runs the volume, wangiri and sequential-dialing heuristics. --business-hours adds the off-hours detector, which is otherwise unreachable — with no window declared there is no “outside” for a call to fall in:

sipnab -N -I capture.pcap --fraud-detect --business-hours 8-18 --alert-json --no-cli-print

A wrapping range is the overnight window:

sipnab -N -I capture.pcap --fraud-detect --business-hours 22-6 --alert-json --no-cli-print

What to look for:

  • The window is whole UTC hours. A site that works 08:00–18:00 local is a different pair of numbers, and getting it wrong moves the alert rather than silencing it.
  • Off-hours volume from a single extension is the classic compromised-handset signature. Feed the source to recipe 10’s block recommendation before banning it, so a night-shift desk is not the thing you disconnect.

Pitfalls:

  • --business-hours without --fraud-detect arms nothing: the second flag is what runs the detector.
  • As with every detector on this page, an alert channel is what makes the findings visible. --alert-json, --alert syslog or --recommend-block — with none of them the run is silent.

47. Follow a load generator’s traffic by transaction, not by call

Problem: A SIPp run or a proxy under test reuses one Call-ID across hundreds of transactions, and the whole capture reconstructs as a single enormous dialog.

--dialog-track branch groups by SIP transaction — the Via branch — instead of by Call-ID:

sipnab -N -I capture.pcap --dialog-track branch --report --no-cli-print

What to look for:

  • A single ordinary call yields several units under branch: RFC 3261 gives the ACK to a 2xx a new branch and the BYE another. That is the transaction view, not a miscount.
  • Compare the two counts. A capture whose call-id count is 1 and whose branch count is 400 is a load generator; one where they are close is ordinary traffic.

Pitfalls:

  • sipnab computes every dialog-level diagnosis — one-way audio, codec asymmetry, PDD — per tracked unit. Under branch those units are transactions, so a per-call figure is not what you get.
  • Switch back to call-id (the default) before quoting durations or MOS at anyone.

48. Run a live capture without giving sipnab root

Problem: Security refuses to sign off on a packet capture running as root, and sudo sipnab on every invocation is what the procedure currently says.

Grant the binary the two capabilities live capture needs, once:

sipnab --setup-caps

Then run it as an ordinary user. sipnab opens its capture devices first and drops privileges afterwards, so name the user and a directory to confine it to:

sudo sipnab -N -d eth0 --user sipnab --chroot /var/empty

What to look for:

  • --setup-caps sets cap_net_raw,cap_net_admin+ep via setcap and exits, re-invoking itself through sudo when it is not already root. After it, sudo is no longer part of the daily command.
  • Under systemd, AmbientCapabilities=CAP_NET_RAW CAP_NET_ADMIN does the same job for the unit (recipe 28). Do not add User=root back “to be safe” — that undoes it.

Pitfalls:

  • setcap does not survive a new binary. Every upgrade, every rebuild, every package update drops it, and the failure looks like a permissions problem that appeared from nowhere. Re-run --setup-caps after an upgrade.
  • sipnab opens anything it must reach by path — a keylog FIFO, an output file, a config — before the drop and the chroot. A path under /run is unreachable afterwards (recipe 7f).
  • --no-priv-drop keeps the privileges. It exists for the cases that genuinely need them, such as the scanner-kill worker forging responses, and it is not the way to fix a permissions error.

49. Configure sipnab from a file instead of a long command line

Problem: The capture command has grown to eleven flags, three of them thresholds, and it now lives in four places that disagree.

Print the configuration this invocation would actually use, and exit:

sipnab -D

Point at a file explicitly, which is what a systemd unit should do rather than relying on search order:

sipnab --dump-config --config /etc/sipnab/sipnab.toml

Ignore every config file, which is how you prove a behavior comes from the flags and not from a file you forgot:

sipnab -D --no-config

What to look for:

  • A flag beats the file. Several knobs deliberately carry no built-in flag default precisely so that “not typed” and “typed the default” stay distinguishable and the config key has something to override — the eight quality thresholds (recipe 31) and --mcp-max-rows among them.
  • -D is the fastest way to settle “is this host reading the config I think it is”. It answers before any capture starts.

Pitfalls:

  • -D dumps the effective configuration, which includes values that came from a file you did not name. Add --no-config to see the flags alone.
  • A config file is per host; the tool set an MCP server registers is per client. That is why --mcp-tools has an ordinary flag default and the row caps do not.

50. Install shell completions

Problem: sipnab has a long flag for nearly everything on this page, and you are typing them from memory.

sipnab --completions bash

Write it where your shell looks for it:

sipnab --completions zsh

What to look for:

  • Accepted shells are bash, elvish, fish, powershell and zsh. The script goes to stdout, so redirect it: sipnab --completions bash > /etc/bash_completion.d/sipnab.
  • sipnab generates the completions from the same flag definitions --help prints, so they cannot drift from the binary that produced them.

Pitfalls:

  • Regenerate after an upgrade. A completion file from an older build offers flags the new binary may have renamed, which is the one failure mode a completion has.
  • Completions know the flags, not your captures. They do not complete a Call-ID or a filter expression.

51. Export every failed call as a redacted vCon in one pass

Problem: A conversation archive, a compliance store or an agent pipeline wants the calls that failed — as containers, not as a pcap — and the subscriber numbers may not leave the building in the clear.

--export-vcon-when takes a filter and writes one container per matching dialog into a directory, with --redact turning every identity into a keyed pseudonym:

sipnab -N -I capture.pcap --export-vcon-when "state == 'Failed'" --export-vcon-dir ./vcons --redact --vcon-digest

Write the reversal table, keep three leading digits of each number, and cap what a container may inline:

sipnab -N -I capture.pcap --export-vcon-when "response_code >= 400" --export-vcon-dir ./vcons --redact --redact-map ./redact-map.json --redact-keep-prefix 3 --vcon-max-inline-media 2

--vcon-digest prints a sha256sum-format line per container, so sipnab ... --vcon-digest > SHA256SUMS and a later sha256sum -c SHA256SUMS both work with no glue:

fcce469e74ae2242e892faaf1952cb3f13764dde0280366e3edc2fd43a30bfd3  1-1966_192.0.2.20-1cc03f18.vcon.json

What to look for:

  • Redaction is not masking. Every identity becomes a token that is equal exactly when the original was equal, and addresses go through a prefix-preserving map — so “these forty failures came from one subscriber” and “the media went to a subnet no SDP advertised” are both still answerable on the output.
  • Two things go entirely rather than becoming tokens, because no pseudonym of them carries diagnostic value: digest credentials and inline audio.
  • Redaction affects the serialized container only. The TUI, the reports and every in-process analysis keep the real values, so a redacted export and a live triage session read the same capture.
  • Every run draws a fresh redaction key unless you pass --redact-key-file, and that default is the safe one: the tokens join against no other export and nothing anywhere can reverse them. Supply a file when tokens must stay stable across captures or across hosts — the whole file is the secret, trailing newline included — and understand what that buys whoever holds it.

Pitfalls:

  • --export-vcon-when takes a raw DSL expression, not an alias. --filter problems works; --export-vcon-when problems fails at parse time, naming the position of the offending token. Spell the expression out.
  • --redact-map is the reversal of every pseudonym the run produced, so it is exactly as sensitive as the capture. sipnab creates it mode 0600 and refuses to overwrite an existing one, because that file may be the map for containers already sent somewhere.
  • --redact-keep-prefix publishes that many leading digits of a real subscriber number in the clear. Zero is the default for that reason; three buys you NANP area-code analysis and costs three digits.
  • --vcon-max-inline-media bounds inline audio (5 MiB by default, a figure measured against a real store that answered 204 and dropped a larger payload). Over the budget sipnab refuses the media out loud, and capture_completeness.media says so rather than letting an absent recording read as a silent call.

52. Check a vCon against the schema before a store rejects it

Problem: A conserver refuses a container and reports the refusal to whoever POSTed it — never to whoever built it. A validation pass over 4,216 real containers found 2 the schema rejects, with nothing on any surface saying so.

Run sipnab as an MCP server over the capture, and ask validate_vcon before you hand anything to a store:

sipnab -N --mcp -I capture.pcap --quiet
// validate_vcon { "call_id": "[email protected]" }
{
  "verdict": "valid",
  "schema_path": "tests/schemas/vcon.schema.json",
  "errors": [],
  "deviations": [],
  "explanations": []
}

Pass a container somebody else produced — one a store already rejected — as an object rather than a Call-ID:

// validate_vcon { "container": { "vcon": "0.4.0", "dialog": [ { "type": "transfer" } ] } }
{
  "verdict": "invalid",
  "errors": [
    { "instance_path": "/dialog/0", "keyword": "required",
      "detail": "missing required properties: start" }
  ],
  "deviations": []
}

What to look for:

  • There are three verdicts, and the middle one carries the point. valid-except-documented-deviation means every finding is a shape sipnab emits deliberately that the schema rejects: section 4.3 of draft-ietf-vcon-vcon-core-03 says a Dialog Object with no parameters is possible, the working group agreed that shape in issue #20 after IETF 124, and the draft’s own Appendix B schema forbids it because every Dialog Object requires a start. sipnab emits one — the consultative leg of an attended transfer, which the observed leg never saw.
  • The exemption is narrow. Only a Dialog Object with no members at all counts. A typed object missing start is an ordinary error, and it is exactly the defect the corpus pass found; folding the two together would teach a producer that a missing start is fine.
  • A container that disagrees with the schema is an answer, not a tool error. The call fails only when the request is wrong: neither argument, both, an unknown Call-ID, or a container that is not a JSON object.

Pitfalls:

  • The validator reads the vendored schema file rather than a transcription of it, and it refuses to guess: a keyword outside the draft-07 subset the file uses makes every validation report invalid naming the keyword. Re-vendoring a richer schema fails loudly instead of quietly certifying less than it claims.
  • Needs a build carrying the non-default vcon Cargo feature. Without it the tool refuses by name; server_capabilities lists what a given binary has.

53. Export a vCon per failed call in one round trip

Problem: An agent asked for “every failed call as a container” lists the dialogs and then issues one export per row — on a real capture, hundreds of round trips to do what one invocation does.

export_vcon takes a filter as well as a call_id, and returns the whole matching set inline:

sipnab -N --mcp -I capture.pcap --retain-audio --mcp-max-rows 200 --quiet
// export_vcon { "filter": "state == 'Failed'", "limit": 50 }
{
  "returned": 2,
  "total_matched": 7,
  "truncated": true,
  "containers": [
    { "call_id": "[email protected]",
      "digest": "b6f0a1c9d84e2f7a3b5c6d0e1f2a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c",
      "completeness": { "media": "carried", "complete": true, "omissions": [] },
      "container": {} }
  ]
}

What to look for:

  • total_matched counts the whole store, so it is the number to page against — returned is only what this call fitted under limit.
  • Give call_id or filter, never both. A request naming a dialog and a rule for choosing dialogs has two answers and names neither, and the CLI refuses the same pair.
  • A filter matching nothing answers with an empty set. “No call failed” is a finding, and a refusal there would make a clean capture look like a broken request.
  • Every container comes back with its SHA-256, computed exactly as --vcon-digest computes it — one function — so a store’s ledger entry compares against this value directly. The digest identifies the document: re-exporting one call produces a second document with a new digest and the same uuid, so deduplicate on the uuid.

Pitfalls:

  • --retain-audio is what puts audio inside the containers, and it requires --mcp: the MCP server is the only batch-mode consumer that can read the buffers back. Without it capture_completeness.media reads none-decodable, which reports the run and never claims the call was silent.
  • Unlike its file-writing neighbors this tool writes nothing and needs no --mcp-file-root. It returns the container inline and the agent decides what to do with it.
  • There is no format parameter. A vCon is a JSON container defined by the draft, and a Markdown arm would render a document whose whole purpose is to travel between machines.

54. Find out where a stream’s endpoint came from

Problem: A report gives the address a call’s media went through. Whether that came from SDP the two parties exchanged, from a relay sipnab asked, or from a mirrored datagram anybody on the segment could have sent decides what the claim is worth — and every other surface renders the three identically.

Start the server with the relay’s control address, and opt in to the one tool that transmits:

sudo sipnab -N --mcp -d eth0 --rtpengine-control 127.0.0.1:22222 --mcp-allow-relay-query --quiet

explain_attribution answers for one call, endpoint by endpoint:

// explain_attribution { "call_id": "[email protected]" }
{
  "endpoints": [
    { "address": "192.0.2.10", "port": 10000,
      "asserted_by": "signaled",
      "delivery_trust": "not-relay-asserted",
      "delivery_note": "the parties' own claim in SDP, not a relay's statement about its allocation" }
  ],
  "unauthenticated_endpoints": 0
}

reconcile_orphans says why a stream has no dialog, rather than counting the ones that do not:

// reconcile_orphans { "limit": 2 }
{ "orphans": [ { "ssrc": 305419896, "reason": "never-named" } ],
  "total_orphans": 4, "relay_was_consulted": false }

query_relay asks the relay itself about a call that was already up when the capture started, and decode_ng decodes one captured relay control message and says which path carried it.

What to look for:

  • delivery_trust, strongest first: asked (sipnab asked the relay over its control socket, so no third party could answer), hmac-verified, plain-secret, port-gated-onlythe source is not authenticated — and not-relay-asserted, the parties’ own SDP claim. With no posture configured sipnab reports the weakest reading, because a tool whose job is telling you what a claim is worth must not round up in the absence of information.
  • reconcile_orphans has three verdicts and the difference is the point: relay-asserted-but-no-dialog means the signaling is missing, not the media; signaled-but-no-dialog means the capture missed the dialog or its SDP predates the capture; never-named means nothing in the capture named the endpoint at all.
  • relay_was_consulted: false beside a never-named verdict means nobody asked — an absence of evidence, not evidence of absence.

Pitfalls:

  • query_relay needs three things and says which one is missing: --mcp-allow-relay-query, the relay control flag, and a live source. A run reading a file can obtain no transmit permit.
  • There is no address parameter, deliberately. The destination comes from --rtpengine-control and from nowhere else. An address sipnab could otherwise infer is one it learned from packets — a host that was a relay during the capture and may be somebody’s laptop now.
  • --rtpengine-control transmits list and query and nothing else, structurally so: offer, answer, delete and start recording each change a production relay, and none is representable on that path.

55. Set up the MCP server for a hosted agent

Problem: The agent driving sipnab is not on your laptop and not run by you, and “loopback with no token” stops being the deployment.

Every knob below is a separate decision, and the defaults are the conservative half of each:

sipnab -N --mcp --mcp-transport http --mcp-bind 0.0.0.0:8731 --mcp-signing-key-file /etc/sipnab/mcp.key --mcp-token-ttl 900 --mcp-rate-limit-per-peer 20 --mcp-max-rows 200 --mcp-max-body-bytes 8192 --mcp-audit-file /var/log/sipnab/mcp-audit.jsonl --mcp-tools core --mcp-file-root /var/lib/sipnab/captures --quiet

Mint a short-lived token from the same signing key, rather than sharing a static secret:

sipnab --mint-token --token-scope read --token-id agent-a --mcp-signing-key-file /etc/sipnab/mcp.key --mcp-token-ttl 900

What to look for:

  • --mcp-tools core registers a small set that still answers a whole call. sipnab sends every registered tool’s name, description and JSON schema on tools/list, and the model then carries them in context for the whole session, before the agent has asked anything — on a client with a small context window that fixed cost is worth cutting.
  • --mcp-rate-limit-per-peer and --mcp-max-concurrent answer different questions. That one bounds calls in flight; this one bounds their arrival rate. An agent that never exceeds the concurrency cap and simply loops as fast as sipnab answers holds one slot at a time and asks again the moment it frees up, which the concurrency cap alone does not bound.
  • --mcp-audit-file is the durable copy of what an agent looked at. The same record already rides the ordinary log, but that is a console view: SIPNAB_LOG filters it and --quiet suppresses it, and the question comes later, from somebody who did not choose the log level.

Pitfalls:

  • sipnab refuses a tool call it cannot write to the audit file. An audit trail that silently skipped what it could not record would be worse than none, so a full disk stops the answers rather than the recording.
  • --mcp-file-root is the whole security model for the file tools, and it is not negotiable: they take a filename, never a path, and sipnab rejects anything containing a separator, a .. or an absolute prefix before touching the filesystem. An agent-supplied path is an arbitrary file write wearing a feature’s clothes.
  • A peer is the source IP over HTTP — the address, not the socket, so reconnecting does not mint a fresh allowance — and the pipe itself over stdio.
  • The write verbs still appear in tools/list when you omit their flags, because sipnab registers tools unconditionally and refuses the call instead. Seeing shutdown_server listed does not mean an agent can stop your capture.

56. Read TLS from an agent, with no keys and no restart

Problem: Somebody wants an agent to look at SIP over TLS on a host you cannot restart. Whether that is even possible is a property of the host, and asking it is a smaller act than doing it.

list_tls_libraries stays available whatever else is off, so an agent can always report what a capture would see:

sudo sipnab -N --mcp -d eth0 --quiet
// list_tls_libraries { }
{
  "supported": true,
  "privileged": true,
  "libraries": [
    { "flavor": "OpenSSL", "path": "/usr/lib/libssl.so.3", "inode": 21143,
      "process_count": 12, "symbol": "SSL_write",
      "probe_path": "/proc/954/root/usr/lib/libssl.so.3" },
    { "flavor": "wolfSSL", "path": "/usr/lib/libwolfssl.so.42.2.0", "inode": 17433084,
      "process_count": 1, "symbol": "wolfSSL_write", "probe_path": null }
  ],
  "unreachable_count": 1
}

Actually installing the probes is a separate opt-in, and the most consequential one on this surface:

sudo sipnab -N --mcp -d eth0 --mcp-allow-tls-capture --quiet
// start_tls_capture { "flavors": ["openssl"] }
// stop_tls_capture  { }
{ "running": false, "messages": 412, "lost": 0, "uptime_sec": 96 }

What to look for:

  • Read privileged before believing an empty list. Unprivileged, /proc/<pid>/maps is readable only for the server’s own processes, so a short list is evidence about privilege rather than about the host. The summary field says which of the two produced the answer, so a relayed conclusion does not lose it.
  • probe_path: null is a finding, not a blank. That library is carrying traffic sipnab cannot capture — usually a containerized process whose /proc/<pid>/root this server cannot read. Reporting it is what keeps a capture from looking complete when it is not.
  • inode is there because path is not unique. The same string names different files in different mount namespaces, and on an ordinary host with containers several distinct libssl.so.3 files coexist.
  • lost on the stop is the number the kernel dropped because the reader fell behind — messages that existed and are missing, which is a different fact from a quiet trunk and the only one you cannot discover any other way.

Pitfalls:

  • Keep calling stop_tls_capture until running is false. The stop is a request: the worker owns the probes and removes them on its way out, a kernel round trip per probe. Probes left installed cost every process that maps the library, and they outlive sipnab.
  • Three refusals arrive before any kernel state exists, each naming itself: not root (a server started with --user has already dropped privileges and cannot attach probes later), a live source already running (sipnab’s stores have one writer), and a capture still loading (poll capture_status until load.done).
  • An attach failure arrives later, not from the start call: a background thread installs the probes and the call returns as soon as it starts. Poll to see whether messages actually arrive.
  • The uprobe path costs you the addresses. See recipe 7g for what a dialog from this source can and cannot say, and 7h for the backend that pairs the write with its socket.

57. Ask the capture how many calls failed

Problem: An agent asked “how many calls failed?” fetches rows and tallies them in its head. Counting is the operation a language model gets wrong most reliably, and a truncated page makes a confident wrong total.

aggregate_dialogs counts inside the store and returns the buckets:

sipnab -N --mcp -I capture.pcap --mcp-max-rows 200 --quiet
// aggregate_dialogs { "group_by": "response_code", "filter": "state == 'Failed'" }
{
  "group_by": "response_code",
  "buckets": [ { "value": "503", "count": 412 }, { "value": "486", "count": 77 } ],
  "other_count": 11,
  "distinct_values": 6,
  "total_matched": 500
}

get_capture_report is the whole-capture analysis — the one --report prints — for the questions that are about the capture rather than about one call:

// get_capture_report { "format": "json" }
{ "findings": [], "dialogs_examined": 2, "streams_examined": 2,
  "frames_read": 852, "complete": true }

What to look for:

  • The buckets plus other_count always equal total_matched. A truncated aggregate that does not say what it left out is a wrong total rather than a partial one, so nothing is silently dropped — including nulls, which become the literal (none), because “how many dialogs carry no User-Agent” is a real question.
  • Read complete before the findings. It is false when the capture lost packets, hit a retention cap, or held frames no decoder could read, and a findings list from such a capture is a floor, not a total. It also reads false while a load is still running and for a source whose read stopped before its end.
  • Legal group_by values are state, response_code, method, from.user, to.user, ua, src.ip, dst.ip and rtp.codec. Anything else fails naming the legal set.

Pitfalls:

  • One dimension, and no time bucketing. That is a deliberate cap: two dimensions is a pivot table, and a pivot table wants a UI. Narrow the window with filter instead.
  • Grouping by from.user, to.user or ua returns fenced values, because those are text the packet’s sender wrote. A state name, a status code, an address or a codec is sipnab’s own derivation and comes back verbatim.
  • markdown and text renderings of the report have no envelope to carry the completeness flags, so they state it in the document — an INCOMPLETE RUN block, the same one --report appends. Ask for json when you want the booleans as fields.

58. Ask why the MOS is what it is

Problem: “The MOS is 3.6.” A score is a conclusion, and the argument with a carrier is about the facts underneath it.

rtp_stats gives the score. media_diagnostics gives the inputs, each labeled with what kind of number it is:

sipnab -N --mcp -I capture.pcap --quiet
// media_diagnostics { "call_id": "[email protected]" }
{
  "applicable": true,
  "streams": [
    { "ssrc": "0x343da99b", "codec": "PCMU", "packets": 425,
      "qos": { "marking_observed": true, "dscp": 0, "name": "CS0 / default (best effort)" },
      "jitter": { "grounded": true, "clock_basis": "rfc3551", "clock_rate_hz": 8000,
                  "measured_ms": 0.0054 },
      "delay": { "source": "assumed", "assumed": true, "one_way_ms": 100.0 },
      "silence": { "cn_frames": 0, "periods": 0, "total_ms": 0 } }
  ]
}

What to look for:

  • Read applicable first. It is false when no RTP stream belongs to the dialog, and the response then carries almost nothing. An empty streams array would read as “sipnab checked the media and it was fine”, which is a different claim from “no media reached the capture point”.
  • Three things in the answer above read together. The media is in the default queue, so it competes with bulk traffic — the most common cause of jitter that adding bandwidth does not fix. The jitter figure is a measurement, because payload type 0 has a clock rate RFC 3551 fixes. And the delay behind the MOS is a default, not anything this capture showed, so the score is only as good as that assumption (recipe 31).
  • jitter.grounded: false means the stream supplied no clock rate and sipnab fell back to a default. Jitter is an RTP-timestamp difference divided by that rate, so a wrong divisor gives a different quantity, not a rough one — and an ungrounded stream reports no measured_ms at all.
  • qos.remarked_to appears only when the stream’s last packet carries a different code point from its first: an SBC or a policy boundary rewriting the marking in flight. Its presence is the finding, and a steady stream omits it rather than repeating the same number.

Pitfalls:

  • endpoint_reported — what the far end said over RTCP — sits apart from everything beside it and feeds nothing. Nobody authenticates RTCP, anyone can forge it, and a report describes the path from the sender to the reporter, which on a mid-path capture is a different segment from the one sipnab watches. The two disagreeing is normal and informative.
  • marking_observed: false on a HEP-fed stream is not a missing marking. sipnab saw no IP header, because a mirror carries the message and not the frame.

59. Decrypt TLS with the server’s private key

Problem: You hold the SIP server’s TLS private key and no keylog, because nobody was running one when the calls happened.

-k/--tls-key takes the PEM private key and uses it to recover the pre-master secret:

sudo sipnab -N -d eth0 -k /etc/sipnab/server-key.pem

The same key reads a capture you already recorded: sipnab -N -I capture.pcap -k /etc/sipnab/server-key.pem --report. sipnab refuses to start when it cannot read the key, rather than capturing everything and decrypting nothing.

What to look for:

  • This works for TLS 1.2 RSA key exchange only — the handshakes with no forward secrecy, where the client encrypts the pre-master secret to the server’s key, so that key recovers it afterwards.
  • If the decryption produces nothing, look at the cipher suite in the handshake before suspecting the key. An ECDHE or DHE suite is doing exactly the job it exists to do.

Pitfalls:

  • Forward secrecy is the normal case now, and it defeats this. ECDHE and DHE handshakes need --keylog (recipe 7) or a uprobe (recipe 7g); a server key cannot recover them, and no flag changes that.
  • TLS 1.3 removed RSA key exchange entirely. On a TLS 1.3 capture this flag has nothing to do.
  • A server private key is a larger secret than a keylog: a keylog decrypts the sessions it covers, and this decrypts every session that key ever protected. sipnab disables core dumps once decryption is active, for the same reason it does with a keylog.

60. See who is on a recorded call

Problem: Something is recording a call and you need to know what the recorder learned about it — which parties, and which media stream belongs to which one.

A session recording client (an SBC, or OpenSIPS’s siprec module) sends the recording server an INVITE whose body carries an application/rs-metadata+xml part. sipnab reads that part off the wire like any other body:

sipnab -N -I recorded.pcap --call-report "[email protected]"
SIPREC recording ([email protected]): session 4f1c0a2e, complete
  participant sip:[email protected] (Alice)
  participant sip:[email protected]
  stream label 0 -> sip:[email protected]
  stream label 1 -> sip:[email protected]
  stream label 2 -> sip:[email protected]

The same facts reach an agent through MCP as siprec_metadata { "call_id": "..." }, and the REST API serves them on GET /v1/dialogs/{call_id} as a siprec block:

"siprec": {
  "session_id": "4f1c0a2e",
  "mode": "complete",
  "participants": [
    { "participant_id": "9b2d1f00", "aor": "sip:[email protected]", "name": "Alice" },
    { "participant_id": "c7e40a13", "aor": "sip:[email protected]" }
  ],
  "streams": [
    { "stream_id": "1a2b3c4d", "label": "0", "participant_id": "9b2d1f00" },
    { "stream_id": "2b3c4d5e", "label": "1", "participant_id": "9b2d1f00" },
    { "stream_id": "3c4d5e6f", "label": "2", "participant_id": "c7e40a13" }
  ]
}

In the TUI the ladder annotates the recording INVITE in place, so you see it without leaving the call flow.

What to look for:

  • label is the m= line. Alice has two streams here, labels 0 and 1 — the audio and the video of one call. The label ties a recorded stream back to the media description it came from, and on a multi-stream call nothing else does.
  • participant_id is who SENDS the stream. Bob receives Alice’s audio and video, and neither is his. Ownership comes from <participantstreamassoc>, whose <send> children mean origination and whose <recv> children do not.
  • A missing name is missing, not empty. An SRC that has no display name for a party sends the AOR alone.

Pitfalls:

  • recorded: false does not mean the call went unrecorded. It means no SIPREC metadata reached the capture point. A recorder signaling on a path sipnab cannot see looks exactly the same from here, and the tool says so rather than implying the stronger claim.
  • sipnab reads SIPREC and does not speak it. It is not a session recording client and not a recording server; putting it in the recording path is not what it is for.
  • Recording metadata rides in the INVITE that opens the recording dialog. A capture that starts mid-call has missed it, and no later message repeats it unless the SRC re-sends.

Look up a one-liner by task

The recipes above walk through a problem end to end. This section is the other shape: dense one-line commands to copy when you already know what you want and just need the invocation. Every flag used here appears in cli-reference.md.

Triage a capture fast

  • sudo sipnab -d eth0 — watch SIP interactively on an interface (TUI)
  • sipnab -N -I capture.pcap --problems — show only problem calls from a pcap. The flag expands to the problems DSL alias, so it covers the whole diagnostic set (Failed state, one-way audio, loss/jitter, NAT mismatch, retransmits, PDD, asymmetry, late media) and sipnab -N -I capture.pcap --filter problems returns the same calls
  • sipnab -N -I capture.pcap --call-report '[email protected]' — deep-dive one call: ladder, timing, SDP, RTP quality, diagnosis
  • sipnab -N -I capture.pcap --call-report '[email protected]' --markdown > call.md — the same as a Markdown report for a ticket
  • sipnab -N -I capture.pcap --report --no-cli-print — post-capture aggregate summary only, no per-message noise

Narrow a capture to the calls you care about

  • sudo sipnab -N -d eth0 --from '^1001@' --to '^18005551212' — calls from/to specific users, matched as regexes
  • sipnab -N -I capture.pcap --filter "method == 'INVITE' and rtp.mos < 3.5" — filter DSL: INVITE dialogs that ended with bad audio quality
  • sipnab -N -I capture.pcap --filter codec-asym — diagnostic aliases go through the same flag (see docs/filter-dsl.md); sipnab -N -I capture.pcap --filter late-media is the same flag with the late-media alias
  • sipnab -N -I capture.pcap --slow-setup — slow call setup, meaning long post-dial delay

Feed NDJSON into jq and other tools

NDJSON to jq, counting failures by status code:

sipnab -N -I capture.pcap --json | jq -s 'map(select(.status_code >= 400)) | group_by(.status_code) | map({code: .[0].status_code, n: length})'

Every Call-ID seen on the wire, ready to feed back into --call-report:

sipnab -N -I capture.pcap --json | jq -r '.call_id // empty' | sort -u

More in output-formats.md.

Record traffic to disk, encrypted or not

Capture SIP+RTP to rotating pcapng files, 50 MiB chunks:

sudo sipnab -N -d eth0 -O /var/capture/sip.pcapng --pcapng --split filesize:50

Run that capture forever inside 400 MiB, keeping the newest eight chunks:

sudo sipnab -N -d eth0 -O /var/capture/sip.pcapng --pcapng \
     --split filesize:50 --split-keep 8

--split-keep deletes capture files — the older chunks, as rotation creates new ones. sipnab deletes nothing without the flag, and deletes only the files that running process created and named, so anything else in /var/capture stays. Leave it off whenever the capture is evidence you cannot retake.

Decrypt SIPS signaling with a TLS key log and export decryptable pcapng. --keylog is the SIP/TLS NSS keylog — signaling only, and it does not decrypt media.

sudo sipnab -N -d eth0 --keylog /tmp/sslkeys.log --keylog-watch \
     -O decrypted.pcapng --pcapng

SRTP needs media keys instead:

  • sipnab -N -I capture.pcap --dtls-keylog /tmp/dtls.keylog — keys recovered from DTLS-SRTP handshakes
  • sipnab -N -I capture.pcap --srtp-keys /tmp/srtp-keys.txt — AES-CM master keys; SDES a=crypto keys are also learned from SDP

Detect scanners and block abuse

  • sudo sipnab -N -d eth0 --kill-scanner --alert syslog — detect SIP scanners and answer them, rate-limited
  • sudo sipnab -N -d eth0 --fail2ban — emit fail2ban-compatible lines for scanner/flood sources

Run a command when a call or its quality changes

  • sudo sipnab -N -d eth0 --on-dialog-exec '/usr/local/bin/call-logger' — run a command on every dialog state change; details arrive as SIPNAB_* env vars plus a SIPNAB_JSON payload, never shell-interpolated
  • sudo sipnab -N -d eth0 --on-quality-exec '/usr/local/bin/page-noc' — alert when RTP quality drops

Exchange HEP with Kamailio, OpenSIPS or Homer

  • sipnab -N -L 0.0.0.0:9060 --hep-allow 192.0.2.0/24 — receive HEP from Kamailio/OpenSIPS/Asterisk and analyze live. A routable bind needs the allowlist (or --hep-auth), or sipnab refuses to start. -L/--hep-listen decodes HEP on its own; --hep-parse is only for unwrapping HEP that arrives inside ordinary UDP capture
  • sudo sipnab -N -d eth0 -H homer.example.net:9060 — mirror captured traffic to Homer
  • sudo sipnab -N -d eth0 -H collector:9060 --hep-send-transport tcp — mirror over TCP instead of UDP, for a collector that wants a stream
  • sipnab -N -L 0.0.0.0:9060 --hep-listen-transport tls --hep-tls-cert cert.pem --hep-tls-key key.pem --hep-allow 192.0.2.0/24 — receive HEP inside TLS. Each side names its own transport, so a relay can take TCP in and send UDP out

61. Ask a relay whether it is dropping packets

Problem: Audio is bad and the media relay (rtpengine) is the prime suspect. You want the relay’s OWN drop and throughput counters, not a guess from the packets your capture happened to see.

A relay statistic is a claim from the relay, not something sipnab measured. Asking over the relay’s read-only control port prints what it says, tagged as the relay’s own count so you never mistake it for sipnab’s:

sipnab -N -d eth0 --rtpengine-control 127.0.0.1:22222 --relay-stats

Every figure names the relay as its source (wire tier relay_reported). Reading it as “sipnab saw this” is the one mistake this whole view exists to stop.

Pitfalls:

  • A relay that restarted a second ago reads as a quiet one. rtpengine’s counters are cumulative and reset to zero on restart. Where the relay publishes uptime, sipnab shows it beside them, and rtpproxy publishes none — so a small number can mean “handled little” or “just restarted”, and only the uptime tells them apart. sipnab flags a polled series (--relay-stats-interval) that steps backwards as a probable restart rather than a drop in traffic.
  • Asking transmits to the relay, so this needs a live source (-d), never a capture file. sipnab refuses --relay-stats on -I file.pcap, because a file’s addresses belong to third parties.
  • A statistic missing from the reply was not asked for. A zero means the relay counted zero. They are different answers and never render the same.

62. Ask a relay what it is still holding

Problem: You suspect leaked sessions — calls that ended but whose media ports the relay never tore down.

sipnab -N -d eth0 --rtpengine-control 127.0.0.1:22222 --relay-stats

Read the active-session count beside the created and destroyed totals: a created count far above destroyed, with a high active count, is the shape of sessions nobody released.

Pitfalls:

  • “Active” is this instant, not a window. A session torn down a second ago is already gone from the count. Poll on a timer with --relay-stats-interval 5 to watch it move rather than reading one snapshot.
  • The counters are the relay’s own (relay_reported), cumulative, and reset on restart — see the uptime caveat in recipe 61.

63. Compare a relay’s per-call count with your capture

Problem: You have a Call-ID and want to know whether the relay’s view of it matches what your capture saw — “we sent it” / “we never got it”, one hop up.

sipnab -N -d eth0 --rtpengine-control 127.0.0.1:22222 --relay-compare 'abc123@host'

This shows two figures side by side — the relay’s own RTP packet count for the call, and the count sipnab measured from the media it captured — each showing which it is, and a one-word verdict (match or differ) with a plain-language note.

Pitfalls:

  • A difference is not automatically a fault. The two count different sockets over different windows with different start times. Three ordinary causes: a capture off a mirror port under load misses packets, a capture that sees BOTH sides of the relay’s hairpin counts each packet twice, and a relay that restarted mid-call zeroed its counters. The note names these — read it before you escalate.
  • The relay side is absent, not zero, when the relay does not hold the call, and sipnab’s side is absent, not zero, when your capture saw no RTP for it. An absent side never renders as 0, and sipnab flags a per-call total too large to compare as suspect rather than reading it as a call the relay does not hold.

64. Read a relay’s loss beside the loss you measured

Problem: The relay reports packet loss and so does sipnab, and the two numbers disagree. You need to know which to believe.

Neither — because they are not the same measurement. This is the recipe most easily got wrong, and getting it wrong is how a real fault gets blamed on the wrong hop. There are three different “loss” numbers for one call, and each is a different claim:

The numberWire tierWhat it actually is
The relay’s own loss counterthe relay’s own count (relay_reported)what the relay’s box counted, on its sockets, since its last restart
sipnab’s sequence-gap losswhat sipnab saw on the wire (sipnab_measured)gaps in the RTP sequence numbers that reached the capture point — bounded by what your capture saw
the far end’s RTCP fraction lostwhat the far end claims (endpoint_reported)the remote endpoint’s own assertion, never checkable
sipnab -N -d eth0 --rtpengine-control 127.0.0.1:22222 --relay-stats
sipnab -N -I capture.pcap --call-report 'abc123@host'

The first asks the relay for its own loss counter, live. The second reads what sipnab measured on the wire and the far end’s RTCP claim, from the capture. None is authoritative over the others: the relay’s count and sipnab’s differ whenever the capture is off the relay’s path, and the far end’s claim is about a third socket again. Cite the tier when you quote the number — “the relay reports 40 lost, our capture measured 3 gaps” — and the disagreement stops being a contradiction and becomes three facts about three points on the path.

Pitfalls:

  • Do not subtract one tier from another. The relay’s count minus sipnab’s is not “packets sipnab missed” — the two count different sockets over different windows, so the difference describes nothing.
  • For sipnab’s own capture loss versus the network’s (a different axis — whether YOUR capture dropped packets), see recipe 22.

Next steps

  • keybindings.md — the interactive TUI these captures feed
  • filter-dsl.md — the full filter language behind --filter
  • mcp.md — drive the same analysis from an AI agent