Cookbook - NormB/sipnab GitHub Wiki
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,
- flags common pitfalls.
Each recipe stands alone. Nothing here depends on anything above it. If you are new, 1. Triage a pcap fast is the broadest starting point.
Find out what went wrong
Chase audio and quality complaints
| I want to⦠| Recipe |
|---|---|
| Chase a "no audio" or "one-way audio" complaint | 4. Diagnose a one-way audio complaint |
| Work out why a call sounds bad in one direction | 11. Find why a call sounds bad in one direction only |
| Know whether the loss is mine or the network's | 22. Measure whether the loss is yours or the network's |
| Explain one-way findings on a VoLTE trunk | 45. Check whether comfort noise explains a one-way finding |
| Diagnose a codec mismatch to a vendor | 24. Diagnose a codec mismatch |
| Work out why DTMF never lands | 25. Find out why DTMF does not reach the IVR |
| See what NAT/STUN/TURN did | 17. Inspect what NAT did to a call |
| Know what a media-address claim is worth | 54. Find out where a stream's endpoint came from |
| Decide what counts as bad audio on my network | 31. Set the quality thresholds to your own network |
| Listen to the audio | 13. Export RTP audio as WAV |
Capture live, at scale, or through tunnels
Read encrypted traffic
| I want to⦠| Recipe |
|---|---|
| Read encrypted SIP or SRTP | 7. Decrypt SIP over TLS with a key log file |
| 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 requires | TLS-without-keys walkthrough β security implications, and whether your kernel supports it |
| Decrypt TLS from a server private key | 59. Decrypt TLS with the server's private key |
Collect from proxies, relays and recorders
| I want to⦠| Recipe |
|---|---|
| Collect traffic from proxies I cannot install on | 6. Wire HEP from your SIP stack to a central sipnab |
| Collect HEP and forward it upstream | 26. Run sipnab as a HEP relay |
| Graph traffic over time | 9. Graph call rate, response codes and PDD over time |
| See who is on a recorded call | 60. See who is on a recorded call |
| Ask a media relay whether it is dropping packets | 61. Ask a relay whether it is dropping packets |
| See if a relay is holding sessions nobody released | 62. Ask a relay what it is still holding |
| Compare a relay's view of a call with mine | 63. Compare a relay's per-call count with your capture |
| Tell a relay's reported loss from the loss I measured | 64. Read a relay's loss beside the loss you measured |
Detect abuse and fraud
| I want to⦠| Recipe |
|---|---|
| Detect and block scanners or fraud | 10. Detect SIP scanners and auto-block via fail2ban |
| Chase a device flooding REGISTER | 23. Find the device flooding REGISTER |
| Catch calls placed at three in the morning | 46. Detect fraud placed outside business hours |
| See what a STIR/SHAKEN token actually claims | 33. Check what a STIR/SHAKEN Identity header actually claims |
| Audit how the phones authenticate | 34. Detect weak digest authentication on a registrar |
| Add my own detection | 21. Detect your own fault patterns with a plugin |
Share the evidence
Let an AI agent do it
| I want to⦠| Recipe |
|---|---|
| Drive sipnab from an AI agent | 8. Let an AI agent ask sipnab about your calls |
| Expose MCP to an agent I do not host | 55. Set up the MCP server for a hosted agent |
| Let an agent read TLS with no keys | 56. Read TLS from an agent, with no keys and no restart |
| Get a count an agent cannot miscount | 57. Ask the capture how many calls failed |
| Get the facts behind a MOS score | 58. Ask why the MOS is what it is |
| Export a whole set of calls in one agent call | 53. Export a vCon per failed call in one round trip |
Just want a command to copy? Look up a one-liner by task.
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.
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.pcapThe same capture without the TUI. -N prints one line per SIP message, and then a totals line.
A message line shows time, source, destination, method or status, and transport:
sipnab -N -I capture.pcapOne line per call instead. Each line shows state, final response code, duration and PDD
(post-dial delay). --no-cli-print drops the per-message lines:
sipnab -N -I capture.pcap --report --no-cli-printA one-flag diagnostic sweep. --problems keeps only these calls:
- the calls that failed,
- the calls that show any of these:
- one-way audio,
- high loss or jitter,
- a NAT mismatch,
- retransmits,
- slow setup,
- a media asymmetry.
What to look for, below, gives the exact expression:
sipnab -N -I capture.pcap --problemsThe same selection as a per-call table, which is usually the quickest read:
sipnab -N -I capture.pcap --problems --report --no-cli-printThe same sweep in JSON, for piping into another tool:
sipnab -N -I capture.pcap --problems --jsonThe 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 problemsWithout --report, the --problems sweep prints a flagged call's messages.
It starts at the message that made the call a problem.
- For a failed call, that is the final error response and the ACK after it.
- The end-of-capture totals line follows on stderr.
tests/pcap-samples/sip-problem-call.pcap
is a sample in the repository. It holds four failed calls. Against it, the first lines read:
22:13:24.800 192.0.2.40:5060 -> 192.0.2.30:5060 486 Busy Here UDP
22:13:24.810 192.0.2.30:5060 -> 192.0.2.40:5060 ACK UDP
22:13:27.800 198.51.100.40:5060 -> 198.51.100.30:5060 603 Decline UDP
22:13:27.810 198.51.100.30:5060 -> 198.51.100.40:5060 ACK UDP
What to look for:
- The
--problemsflag and the DSL alias reached with--filter problemsexpand 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.
- For example: a different VLAN, a different host, or a different port.
Pitfalls:
- The TUI requires a tty. If you're SSH'd in without
-t, force-Nmode. - For large pcaps (>1 GB), prefer
-Nfirst. The TUI loads everything into memory.
Runnable example: Triage a capture to one verdict turns this triage into one verdict and an exit status. It also tells a capture with nothing to judge apart from a clean one.
Problem: A user reports their calls are flaky. Capture only their traffic in real time.
Commands:
Capture on eth0, and keep 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'" --jsonWhat to look for:
- The TUI's call list updates as new dialogs appear. Press
Tabto switch to the RTP stream view. PressEnteron a stream to see jitter/loss/MOS history. - In CLI mode, each completed dialog is one line of JSON. Pipe to
jqorteeto a log.
Pitfalls:
- Live capture needs
CAP_NET_RAW(Linux) or root.setcap cap_net_raw,cap_net_admin=eip $(which sipnab)lets you skipsudoafter the first run. - The filter DSL evaluates against complete dialog records.
- It evaluates once the dialog state machine has enough information.
- That is typically after the first response.
- It is earlier for fields that only depend on the request.
- For per-header regex filtering on individual messages, use the older
--from,--to,--contact,--uaflags.-
sipnab --helplists them.
-
Problem: "We had a spike in failures around 14:00. What was it?"
Commands:
sipnab -N --json emits per-message records, not per-dialog summaries.
- It writes one JSON line per SIP message.
- The
status_codefield is on response messages. -
--filterevaluates against the dialog. So all messages from matched dialogs flow through. - Combine the two, and 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 -rnA 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.mdThe 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 11. Find why a call sounds bad in one direction only.
Pitfalls:
- The histogram counts all response codes seen in messages of failed dialogs.
- So a single failed call with
100 Trying β 488contributes both 100 and 488. - For just the final response per call, use
--call-report <id>per dialog.
- So a single failed call with
- The dialog summary returned by the REST API (
/v1/dialogs) has nostatus_codefield.- That's a per-message field.
- It is only available in CLI
--jsonoutput, or via/v1/dialogs/{id}. - That endpoint includes the full message list.
Runnable example: Group failed calls by response code works over REST. It:
- groups each failed call once under its final code,
- names the calls,
- adds the ones answered and never acknowledged (recipe "Find calls that answered and were never acknowledged").
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. It is 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. It shows NAT mismatch, SDP offer/answer, and media path:
sipnab -N -I capture.pcap --call-report 'abc123@host' --markdown --no-cli-printFinally, 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, lossThe 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: trueconfirms 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.
- Under symmetric RTP (RFC 4961), each side should send from that same port.
- A hint reading
advertised 16384 but sends from 41002means the far end is replying to a port nothing is sending from. - So no NAT pinhole was ever opened there.
- Go and check that firewall rule, port-forward or RTP port range.
- When the ports agree, the hint stays quiet about them.
-
diagnosis.nat_mismatch: trueis the usual root cause.- The Contact header / Via address differs from the SDP
c=line. - This is common when the upstream SBC isn't rewriting Contact.
- The Contact header / Via address differs from the SDP
- In the TUI's RTP stream view, look for one stream with packets and one with
0 packets received.- The second one is the silenced direction.
Pitfalls:
- Both streams may show packets while the user still reports silence.
- Then the issue is downstream of sipnab (codec mismatch, jitter buffer underflow, bad headset).
- Use 11. Find why a call sounds bad in one direction only for codec asymmetry checks.
Runnable example: Diagnose one-way audio, and whose loss it is runs these for one Call-ID:
- this diagnosis,
- the asymmetry checks of recipe "Find why a call sounds bad in one direction only",
- the capture-loss check of recipe "Measure whether the loss is yours or the network's".
The filter DSL has 32 fields and 8 operators. These five cover most operational triage:
Slow setup. This is every dialog that took more than 3 seconds from INVITE to 200 OK:
sipnab -N -I capture.pcap --filter "pdd > 3.0" --jsonREGISTER dialogs that failed:
sipnab -N -I capture.pcap --filter "method == 'REGISTER' AND state == 'Failed'" --jsonShort calls. These completed, but in under 10 seconds. That usually points at a UX or cancellation problem:
sipnab -N -I capture.pcap --filter "duration < 10.0 AND state == 'Completed'" --jsonHeavy retransmits. These are the signature of packet loss on the SIP path:
sipnab -N -I capture.pcap --filter "retransmits > 5" --jsonA specific User-Agent, matched as a regex:
sipnab -N -I capture.pcap --filter "ua =~ '(?i)friendly.*scanner|sipvicious'" --jsonFor per-call asymmetry checks, see 11. Find why a call sounds bad in one direction only. Those checks cover a different codec on each leg, late media, and so on.
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 > trueis a parse error.
Problem: You want one sipnab box collecting traffic mirrors from multiple SIP servers.
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-httpRun 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 --syslogA 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.
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 = trueRestart with systemctl restart rtpengine. See the rtpengine page for:
- what sipnab does with the result,
- 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"/>On the sipnab host, tcpdump the HEP socket to see whether anything is arriving at all:
sudo tcpdump -i eth0 -n udp port 9060Confirm the HEP feed is producing dialogs:
curl -s http://localhost:9100/v1/stats | jqWatch dialogs accumulate live:
watch -n 1 'curl -s http://localhost:9100/v1/dialogs?limit=5 | jq ".dialogs[] | {call_id, state}"'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-listenrun measures no media at all. - Capturing the interface instead gets you media.
- But it 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.
- 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 narrows the capture to your media ports.
- Without one, the interface gets sipnab's auto-generated filter.
- On a run like this, that filter admits media only: RTP on any port.
- The mirror supplies the signaling.
- Up to 0.5.192 that filter was signaling-only.
- So the interface captured no media.
- And every mirrored message arrived a second time off the wire.
-
--no-rtpstill gives the interface the signaling filter. sipnab warns about that.
What you get that a single source cannot give you. The two accounts are complementary rather than redundant. Their DISAGREEMENT is the finding.
- HEP reports what the proxy believes it did.
- The wire reports what actually left the box.
- Sometimes 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:
| Finding | What it means |
|---|---|
| Seen by both | The proxy did what it says it did |
| Mirror-only | The proxy believes it sent something the wire never carried |
| Wire-only | Traffic left the box that the tracing never reported |
| Differing SDP | Both 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).
- It never pairs them by arrival order, because the mirror usually arrives FIRST.
- The proxy mirrors as it processes.
- 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. Then 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.
- At
Note also that proto_hep refuses to start without a HEP listener socket.
It refuses even when the config only sends.
Add socket=hep_udp:127.0.0.1:9061 alongside your hep_id.
Otherwise 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.
- Media may flow end-to-end and never cross that machine.
- Then no capture there can see it.
- No amount of correlation changes that.
- Put sipnab's
-dwhere the media actually is.
Pitfalls:
- HEP is UDP. It silently drops if the listener can't keep up.
- The
--hep-rate-limit 50000default lets you tune.
- The
- A routable HEP listener needs a guard.
- sipnab refuses a non-loopback
--hep-listenbind unless you pass one of these:-
--hep-allow 192.0.2.0/24(repeatable), -
--hep-auth/--hep-auth-file.
-
- A loopback bind needs neither.
- sipnab refuses a non-loopback
-
--hep-allowguards the listener and nothing else.- HEP that sipnab merely sniffs off the wire reaches no socket. That includes rtpengine's mirrored control plane.
- So no allowlist applies to it.
- See rtpengine.md for what a sniffed assertion is worth.
- Your central host may be reachable by hostname only.
- Then set
--mcp-allowed-hostfor the MCP transport too. - See 8. Let an AI agent ask sipnab about your calls.
- Then set
-
-dwith--hep-listentakes exactly one interface and one listener.- sipnab refuses
-Iwith--hep-listen.- A file's addresses are historical and belong to third parties.
- sipnab keeps them off its active-response path. It refuses to transmit for a file run at all.
- It also refuses
--multi-devicewith--hep-listen. - It also refuses
-Owith the pair.- The two sources disagree about the link layer. So there is no honest pcap to write.
- Use
--hep-sendto forward the signaling instead.
- sipnab refuses
- 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.
- A stream would then bind to whichever offer arrived last.
A collector fed by twenty proxies needs to answer three questions:
- Which of them are sending?
- Which one stopped?
- Which one sends while the listener refuses it?
The listener keeps a roster that answers all three.
At the end of a bounded headless run:
sipnab -N -L 0.0.0.0:9060 --hep-parse --hep-auth-file /etc/sipnab/hep.key --duration 600 --hep-sendersWhile a server runs, from the REST API or the MCP hep_senders tool:
curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" http://localhost:9100/v1/hep/senders \
| jq '.senders[] | {source, packets, idle_seconds, silent}'In the TUI, press h for capture health and then s.
Each row is one sender. Two things key a row:
- the capture id the sender claims:
-
--hep-idon a sipnab agent, - the capture-agent id on OpenSIPS or Kamailio;
-
- the address it sends from.
Give every sender its own id.
- Two proxies left at the default id 1 are still two rows, because their addresses differ.
- But a sipnab agent and its host's proxy share an address. Only the id tells them apart.
Read refused_sources when a sender you configured never shows up. The reason names the fix:
-
auth_mismatchis the wrong shared secret. -
auth_missingis a sender with no key. -
allowlistis an address--hep-allowdoes not cover. -
hmac_timestamp_out_of_windowis a sender whose clock drifted.
The listener also logs lines about silent senders.
- It logs
sender [email protected] silent for 30swhen one sender goes quiet while others keep sending. - It logs a
resumedline when that sender comes back. -
--hep-silence-warnsets the threshold. - The roster covers this run only. sipnab persists none of it.
Problem: TLS-encrypted SIP captures are unreadable without keys.
Build sipnab with the tls feature, or use --features full:
cargo build --release --features tls,hep,apiOn the SIP user agent, set SSLKEYLOGFILE in its environment. The user agent is a different machine from the capture host:
SSLKEYLOGFILE=/tmp/sipua.keylog /opt/myua/bin/startThen, on the capture host, start sipnab watching that keylog file for live updates:
sudo sipnab -N -d eth0 \
--keylog /tmp/sipua.keylog --keylog-watchCapture the encrypted pcap normally:
sudo sipnab -N -d eth0 -O encrypted.pcapLater, decrypt it using the keylog the UA wrote during the call. Later can be minutes or months:
sipnab -I encrypted.pcap --keylog /tmp/sipua.keylogThe default raw mode writes original packets without embedding TLS keys:
sipnab -I encrypted.pcap --keylog /tmp/sipua.keylog \
-O original.pcap --pcap-export-mode rawTo 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+dsbTo write what sipnab decrypted as plaintext, with no keys in the file, use decrypted.
- SIP over TLS and WSS comes out as plain SIP.
- SRTP comes out as plain RTP:
sipnab -I encrypted.pcap --keylog /tmp/sipua.keylog \
--pcapng -O plaintext.pcapng --pcap-export-mode decryptedThis file holds readable signaling. When sipnab has SRTP keys, it also holds listenable audio. So treat it like the call itself.
- Its PCAP-NG section comment says sipnab decrypted it.
- Each rebuilt frame names its source (TLS, WSS or SRTP).
- The run ends with one line of counts.
- The counts include what it copied as captured, because it could not decrypt it.
sipnab -I capture.pcap --dtls-keylog /tmp/dtls.keylogEverything above needs the SIP daemon to cooperate.
-
SSLKEYLOGFILEhas to be in its environment when it starts. - On a running production Kamailio, OpenSIPS or Asterisk, that means a restart.
- 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. It uses eBPF uprobes on the TLS library.
- It needs nothing from the daemon: no environment variable, no configuration, no restart.
- It writes the same NSS keylog format sipnab already consumes.
On the SIP host, as root:
ecapture tls -m keylog --keylogfile=/tmp/sip.keylogThen point sipnab at that file, exactly as in 7a. Live decryption (UA produces keys, sipnab follows):
sudo sipnab -N -d eth0 --keylog /tmp/sip.keylog --keylog-watch-m keylog is the mode for a LIVE capture.
It hands sipnab keys while sipnab reads the wire itself.
eCapture's -m text emits plaintext. That 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.
- It 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. One file, taken off the host, that decrypts itself.
- It is the easier recipe of the two when you can work from a file.
Measured, not assumed (2026-08-14):
- The traffic was a TLS 1.3
REGISTERoverTLS_AES_256_GCM_SHA384. - The keys came from a running process with no
SSLKEYLOGFILEanywhere. - sipnab decoded it 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),
- x86_64 (Debian 13, OpenSSL 3.5.6, BTF present).
Pitfalls:
-
tlsis a build-time feature, not a runtime flag.- There is no
sipnab --features tlsinvocation. Pass--featurestocargo buildand use the resulting binary. - To check what a binary carries, run
sipnab --version.- It prints the version, the commit, and the compiled feature list.
-
tlsin 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.
- If the flags appear, the build has
- There is no
- The keylog format is the standard NSS
SSLKEYLOGFILE(one line per session).- It is the same format Firefox/Chrome/curl produce.
- TLS 1.3 + ECDH ephemeral handshakes are fully supported via the
ringbackend. - eCapture is a separate program under Apache-2.0.
- sipnab neither bundles nor links it.
- It needs
CAP_BPF/CAP_PERFMONor root. - It needs 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.
7e. Decrypt traffic from a daemon you cannot restart leaves master secrets in a file.
- That file decrypts every session it covers.
- That is why the pitfall above says to treat it as a secret.
- sipnab can take the same keylog lines over a pipe instead. Then 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.keysthen 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-fdimplies--keylog-watch.- 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_PRIVSat startup, and every child inherits it. - So a process sipnab spawns can never acquire the
CAP_BPFeCapture needs. - Start the extractor from a supervisor, and hand sipnab the read end.
Pitfalls:
- sipnab opens whatever
--keylognames, a FIFO or a file, before it drops privileges.- It opens capture devices there for the same reason.
- A path under
/runis unreachable once sipnab has dropped to an unprivileged user or entered a--chroot.
- An inherited descriptor needs no privilege at all.
- So
--keylog-fdworks whatever sipnab drops to afterwards.
- So
- Core dumps are still disabled, exactly as for a keylog file.
- The secrets arrive over a pipe, but land in the same process memory.
7e. Decrypt traffic from a daemon you cannot restart and 7f. Decrypt without writing the keys to disk still need session secrets from somewhere. This recipe needs none.
- sipnab puts a kernel uprobe on the TLS library's write function.
- It reads the plaintext before encryption.
- No certificate, no private key, no keylog, and nothing restarted.
Look before you probe. This installs nothing. It answers whether the capture is worth starting:
sudo sipnab --uprobe-listFLAVOR 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.
- Probing only the one you had in mind misses the rest without saying so:
sudo sipnab -N --uprobe-tlsNarrow it if only one stack is yours to read:
sudo sipnab -N --uprobe-tls --uprobe-flavor opensslOr name a library yourself. That 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.3What this gives up.
- A uprobe sees the bytes an application handed its TLS library.
- It sees nothing about the socket beneath.
- So dialogs from this source carry no addresses and port 0.
- sipnab labels them
uprobe:<comm>/<pid>instead. That names 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 mountedtracefs.- Unprivileged,
/proc/<pid>/mapsis readable only for your own processes. - So
--uprobe-listquietly shows a fraction of the host. - Run it as root before concluding a daemon is not using TLS.
- Unprivileged,
-
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. - That is why the listing above shows such paths.
- If you pass
--uprobe-libraryby hand for a container, pass the/proc/<pid>/root/...form. - Otherwise the probe attaches to the host's copy and captures nothing.
- sipnab handles this by matching inodes and probing through
- GnuTLS is not probed.
- Its write function has a different signature.
- Attaching the OpenSSL probe shape to it would read the wrong register.
- This input can never transmit.
-
--hep-allow-killhas 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.
- sipnab marks it as such, rather than presenting a fragment as a whole message.
7g. Read TLS with no keys at all gives you the plaintext but no addresses.
- A uprobe sees the bytes an application handed its TLS library.
- It sees 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_sendmsgthat carried it: same thread, back to back. - It reports the addresses the plaintext actually went out on:
sudo sipnab -N --uprobe-tls --uprobe-backend bpf --portrange 0-65535200 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. 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.- That 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.
- That needs a nightly toolchain and
- a kernel with
CONFIG_DEBUG_INFO_BTF.- BTF is the BPF Type Format. It is 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. Read TLS with no keys at all does.
You may ask for it on a build or a kernel that cannot run it. Then sipnab refuses rather than falling back to tracefs.
- The addresses are the only reason to choose this backend.
- A silent downgrade would hand you a capture with none.
Pitfalls:
-
Widen
--portrange.- A TLS trunk on 5061 is the exception.
- The port a uprobe reports is whatever the socket actually used. That is often ephemeral.
- The default range drops them.
- A write the TLS library buffered rather than sent arrives with no addresses.
- That is exactly like a capture from 7g. Read TLS with no keys at all.
- sipnab does not guess a peer for it. 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. - Pass
--uprobe-symbolif it differs.
- OpenSSL 3 applications increasingly call
Runnable example: Read TLS without keys: the half that needs no kernel needs no root and no BTF. It shows what sipnab makes of the records the BPF program of this backend publishes.
7e. Decrypt traffic from a daemon you cannot restart and 7f. Decrypt without writing the keys to disk 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.
- 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 5061Stop 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 --reportThat is the whole recipe. There is no --keylog.
-
-m pcapputs the master secrets in the file as a pcapng Decryption Secrets Block. - 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 took the keys by uprobe.
- They came from a Python 3.13 process running OpenSSL 3.5.6.
- That process had no
SSLKEYLOGFILEand set no keylog of its own.
- That process had no
- The session was TLS 1.3
TLS_AES_256_GCM_SHA384. It carried a complete SIP dialog:-
INVITEsplit across two records, -
100,180,200with 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 want | Use |
|---|---|
| A live view, decrypted as calls happen | 7e or 7f -- sipnab runs beside the daemon |
| One artifact to take away, open later, or hand to somebody | 7i -- nothing runs on the host afterwards |
| The real wire bytes, verifiable independently | Either. Both keep the encrypted frames; neither writes plaintext |
Pitfalls, each one measured:
-
-m pcapneeds more from the kernel than-m keylogdoes. 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 and loads its bytecode. Then it 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. There,
zcat /proc/config.gz | grep NET_CLS_BPFprints# CONFIG_NET_CLS_BPF is not set. - Check that before you plan around this recipe.
-
-m keylog(7e. Decrypt traffic from a daemon you cannot restart) needs only the uprobes. It runs on that same kernel.
-
-itakes a real interface, and it is not optional.-
-m pcapcaptures through the interface, not through the process. - So use
-i lofor 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.
- The lab used port 15061, and the default
--portrangeis 5060-5061. - It said so rather than staying silent.
SIP outside --portrange 5060-5061 is being skippednames the busiest port it saw.
-
eCapture needs no BTF.
- On a kernel with none, it falls back to its own non-CO-RE bytecode.
- It logs the file it loaded (
bytecode/openssl_3_0_0_kern_noncore.o). - A missing
/sys/kernel/btf/vmlinuxis 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. Decrypt traffic from a daemon you cannot restart, and more portable.
- Treat it that way when you copy it off the host.
- Same as 7e. Decrypt traffic from a daemon you cannot restart:
- eCapture is a separate Apache-2.0 program that sipnab neither bundles nor links.
- It captures only handshakes it was running for.
Problem: You want an AI agent to query a capture without you typing CLI flags. The agent can be Claude Code, Claude Desktop, or anything that speaks MCP, the Model Context Protocol.
One-shot: the agent reads a pcap you already have.
sipnab -N --mcp -I capture.pcap --quietThe same, against a live capture instead of a file:
sudo sipnab -N --mcp -d eth0 --quietClaude 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" --quietGenerate the bearer token once, when you first set the host up.
-
openssl randoverwrites/etc/sipnab/mcp-token. - If you run it against a live server, the server starts serving a secret none of the configured agents hold.
- There is no way to recover the old one.
Steps:
-
Create the directory:
mkdir -p /etc/sipnab && chmod 0755 /etc/sipnab -
Generate the token:
openssl rand -hex 32 > /etc/sipnab/mcp-token -
Make the token readable by its owner only:
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 --quietThe agent connects to http://capture.example.com:8731/mcp with Authorization: Bearer <token>.
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 a request that does not.
- It answers HTTP 422
Unexpected message, expect initialize request:
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:
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.
This copy truncates the descriptions and input schemas:
{"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.mdis the authoritative list. -
mcp_tool_table_lists_every_registered_toolintests/docs_drift_test.rsasserts 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 that point the registry held 31. The six it had never gained were
capture_health,explain_rule,find_correlated,lint_dialog,save_findingsandvalidate_message.) -
Browse and quote the capture β
list_dialogs,get_dialog,get_dialog_report,get_message,search_messages,search_by_time,tail_dialogs,render_ladder,compare_dialogs,find_correlated,show_evidence -
Diagnose β
find_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 RFCs β
lint_dialog,validate_message,explain_rule -
Ask about the session itself β
capture_status,server_capabilities,list_captures -
Write a file or a note, swap the capture, or end the run β
export_capture,export_audio,save_findings,open_capture,shutdown_server
Only that last group reaches past the query surface. Each member needs a flag you passed at startup:
- The two exports write only under
--mcp-file-root. -
save_findingsrecords only under--mcp-allow-save-findings. -
open_captureacts only under--mcp-allow-open-capture. -
shutdown_serveracts only under--mcp-allow-shutdown.
All five still appear in tools/list when you omit those flags.
- sipnab registers the tools unconditionally, and refuses the call instead.
- Seeing
shutdown_serverlisted does not mean an agent can stop your capture.
Pitfalls:
- Stdout is the JSON-RPC wire in stdio mode.
- Use
--quiet. - Don't combine with
--json/--report/etc. sipnab refuses to start.
- Use
- sipnab refuses a non-loopback bind without a token at startup. A loopback bind needs no token.
- Pass
--mcp-allowed-hostwhen the client connects via the actual hostname.- The default Host allowlist is
localhost,127.0.0.1,::1and the bound address.
- The default Host allowlist is
Problem: You want a dashboard tracking call rate, response codes, and PDD over time.
Clone the repository and take a copy of the sample environment. Do this once per host.
-
cp .env.example .envoverwrites.env. - So on a host where you have already edited it, skip straight to starting the stack.
Steps:
-
Clone the repository:
git clone https://github.com/NormB/sipnab.git
-
Change into the observability directory:
cd sipnab/contrib/observability -
Copy the sample environment:
cp .env.example .env
sipnab may run on a different host. Then 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/observability.
docker compose reads its compose file out of the working directory. So a fresh shell needs the cd above first:
docker compose up -dThis boots:
- Prometheus (
:9090), - Grafana (
:3000, admin/admin), - an OTel Collector (
:4317/:4318), - Tempo.
The included Grafana dashboard provisions automatically. Log in and look for the sipnab folder.
Both listeners below bind a routable address. sipnab refuses either one without a credential.
- So each command supplies one.
- 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 --jsonOr 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.keyFrom the Prometheus host, confirm the target is up:
curl -s http://localhost:9090/api/v1/query?query=up{job=\"sipnab\"} | jqThen spot-check a metric value:
curl -s 'http://localhost:9090/api/v1/query?query=rate(sipnab_messages_total[1m])' | jq# 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.
-
sipnab_security_alerts_total{type}carries only the alert types that have fired.- It is absent from the scrape until the first one does.
- An absent series means no alert of that type fired since the process started.
- It does not mean that the metric is unavailable.
-
sipnab_responses_total{code}reports every response class on every scrape.- It reports
0where the capture saw none.
- It reports
- The Prometheus metrics reference describes both.
Problem: Your honeypot or edge box is getting probed by friendly-scanner, sipvicious, etc.
sudo sipnab -N -d eth0 \
--kill-scanner \
--alert syslog \
--json--kill-scanner actively responds to known scanner User-Agents.
- The responses come from a scanner-kill worker that runs as a process of its own.
- The process parsing your traffic holds none of the sockets it sends through.
- 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.)
--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-scannerforscanner_detected, -
--reg-floodforreg_flood.
On its own, it selects the format, and nothing produces lines for it. A jail reading an always-empty file never says so.
Detections carried by HEP input never reach the log without --hep-allow-kill.
The reason is the one for which the kill path has always refused them:
- The inner addresses are the sender's claim.
- 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>&1Measure 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 -rnEvery address in that list is one the jail below would ban.
Use ignoreip in the jail for the peers you already trust.
--reg-flood does two things:
- It counts refused credentialed REGISTERs per source, inside a one-second window of capture time.
- It treats a
401that arrives more than 32 seconds after its REGISTER (RFC 3261 Timer F) as a stray.
Some sites see slow guessing runs, or have phones that run a longer T1. Such a site tunes both:
sipnab -N -I registrar.pcap --reg-flood --reg-flood-threshold 20 \
--reg-flood-window 60 --reg-flood-transaction-timeout 64000Registration-flood timers explains which transaction timeout matches your proxy's fr_timer/fr_timeout and T1.
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. It prints one block per accused address. Each block carries:
- the evidence,
- the counter-evidence,
- a rule in the dialect you name (
fail2ban,nftables,iptablesorall):
sipnab -N -I trunk.pcap --kill-scanner --reg-flood --recommend-block all --quietsipnab recommends and does not apply.
- sipnab ran none of that output.
- It contacted no firewall.
- It holds no credential anywhere in sipnab.
- It prints text and stops.
The line to read in every block is COUNTER-EVIDENCE:. It 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
nftablesandiptablescommands arrive commented out. - The fail2ban jail arrives with
ignoreipalready filled in.
-
none- Nothing in this capture says the source has a relationship.
- The commands are live.
-
UNKNOWN- No scanner detector ran, so sipnab asked nothing.
- The commands are live.
- The block tells you to re-run with
--kill-scannerbefore acting on them.
The third case is the one worth pausing on.
- "Asked and it had not" and "never asked" produce the same silence.
- Only the first is a reason to feel safe.
Neither behavioral rule fires on volume. 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.
- An SBC fronting a hunt group reaches dozens of distinct extensions a second.
Both rules therefore need an OUTCOME as well as a rate:
| Signal | What it counts | What arms it |
|---|---|---|
behavioral |
More than 10 probe transactions from one source in 5s | Probing evidence |
enumeration |
More than 5 distinct target extensions in 5s | Probing evidence |
Probing evidence means one of two things inside that window:
-
5 refusals. These are final responses past
4xxthat are neither of these:- an auth challenge (
401,407), - an ordinary call outcome (
408,480,486,487,488,491,600,603). - A
5xxblames the server. So it counts for nothing. - Only a refusal on a probe transaction counts. The
Viabranch matches it. - So a
481answering a strayNOTIFYsays nothing about the OPTIONS beside it.
- an auth challenge (
-
5 probes with no answer.
- They are still unanswered half a second later.
- They outnumber the ones that drew a reply.
- Any response settles a probe, including a
100 Trying.- The question is whether anything is there.
- 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.
Some sources have completed a registration or a call: a 2xx to its REGISTER or its INVITE.
Such a source 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.
- Only the signature and refusal rules remain.
- You may take a capture upstream of whatever generates your
404s (between two proxies, say).- It 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
sipnab quotes the ua= and method= values.
- A bare
-means the request carried none. - An absent
User-Agentis itself a scanner signal. - So it is worth keeping distinct from a client that sends the string
-. That 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. - 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
# Read logpath whatever the distribution's default backend is: Ubuntu 24.04's
# is systemd, which reads the journal and never this file.
backend = auto
# Ban on every port, UDP and TCP, with nftables, as Debian 13's fail2ban does.
action = nftables[type=allports, name=sipnab, protocol="udp,tcp"]
# On a host that bans with legacy iptables instead, use:
# action = iptables-allports[name=sipnab, protocol=all]Verify the filter against a real log file before enabling the jail. 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.confSymptom: any of these:
- an unexpected spike of international or premium-rate calls,
- bursts of short calls to one number prefix (wangiri call-back bait),
- sequential dialing through a number range.
# Live fraud heuristics on the edge box (batch mode required)
sudo sipnab -N -d eth0 --fraud-detect --alert syslogWhere a call is going is a signal the shape-based heuristics cannot see. Name the destinations you never expect to dial. Then sipnab reports an INVITE to one the moment it sees it. It needs no pattern:
sipnab -d eth0 -N --fraud-detect --fraud-destination DO,VG,MAsipnab reads numbers through the common international prefixes.
- It resolves them by the longest calling code.
- So
+1 809 555 0100is the Dominican Republic and not the United States.
- So
- A number dialed with no prefix is domestic and never matches.
- 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.
- 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
Wangirialert on a premium-rate prefix (+44 9xx,+2xxIRSF ranges) is the classic revenue-fraud signature.- Block the destination prefix at the trunk, not just the source IP.
-
SequentialScanningfrom an external source usually precedes a toll-fraud attempt.- Feed the source IP to fail2ban (10b. Wire to fail2ban).
- Review outbound dial permissions.
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.
It is never interpolated into the command string.
- sipnab rewrites only the three legacy placeholders
%rule,%src, and%detail.- It rewrites them into quoted references to those variables, such as
"${SIPNAB_DETAIL}". - So each value reaches the hook as one argument.
- It rewrites them into quoted references to those variables, such as
- 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:
- Forging the source of a kill response needs a raw socket. A raw socket needs
CAP_NET_RAW.- Run sipnab as root or with capabilities.
- It opens the raw socket and starts the scanner-kill worker process before it drops privileges.
- The worker keeps that socket and gives up everything else.
- Without the capability, responses still go out, from sipnab's own port.
-
--kill-ua "<regex>"adds a custom User-Agent pattern beyond the built-in scanner list.
Runnable example: Ban a scanner through TFPS, and verify it does two things:
- It bans through
POST /v1/tfps/banonly the sources--recommend-blockfinds no counter-evidence for. - It then checks TFPS's banned list.
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.
- They surface through the filter DSL.
- They do not surface in the dialog JSON output's
diagnosisblock. -
--filteraccepts the alias name directly (codec-asym). - It 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 --jsonTargeted, 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" --jsonsipnab -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" \
--jsonFrom 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: trueon a call from PSTN to internal:- This is usually a transcoding policy that fired in one direction only.
-
ptime_asymmetry: truebetween two SIP UAs:- One is using
ptime=20, the otherptime=30. - Some downstream jitter buffers can't handle the mismatch.
- One is using
-
payload_asymmetry: true:- Same codec, but each side picked a different dynamic payload type number.
- This causes audio cut-out on RFC-strict implementations.
-
late_media: true:- Media starts noticeably after the answering 200 OK.
- This usually means an SBC is doing late-attach NAT.
- The first real RTP arrives only after media-binding.
Pitfalls:
-
sipnab -N --filter '<expr>' --jsonemits 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.
- Pipe through
- The
diagnosisblock in CLI--jsonoutput and in the REST API today only exposes these:-
one_way_audio, -
nat_mismatch, -
no_media, - 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_problemstool. - JSON
--call-reportuses the same dialog projection and does not add them.
- If you need them in your output, use the MCP
Runnable example: Diagnose one-way audio, and whose loss it is asks all five asymmetry filters about one call. It asks them beside the call's one-way diagnosis.
Problem: A support ticket needs full call details attached.
In -N (non-interactive) mode, sipnab normally prints each captured SIP message to stdout. Then it emits the report.
- Pass
--no-cli-printto suppress the per-message dump. Then only the report reaches stdout. - (
-Nis 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.mdPlain text, the default report format:
sipnab -N -I capture.pcap --call-report 'abc123@host' --no-cli-print > ticket.txtJSON, for a tool on the other end:
sipnab -N -I capture.pcap --call-report 'abc123@host' --json --no-cli-print > ticket.jsonThe report covers:
- SIP message timeline,
- SDP offers/answers,
- RTP stream stats per direction,
- computed timing (PDD, setup time, retransmits),
- the diagnosis engine's findings.
Tip: combine with the filter from 3. Find every failed call, grouped by response code.
That bulk-generates reports for every failed call.
The CLI --filter outputs per-message records. So deduplicate to call_ids first:
-
Create the reports directory:
mkdir -p /tmp/reports
-
Generate one report per failed call:
# Run all of these, in order. # 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-printarrived in v0.3.2. On older binaries, strip the leading per-message text with a pipe:
sed -n '/^# Call Report:/,$p'(markdown),awk '/^{$/{found=1} found'(JSON).
Problem: A call sounds bad. You want the actual audio to listen to or share.
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 saveA 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.
You may have built with the audio feature (in default). Then:
- Press
Enteron a stream to open Stream Detail. - Then
Pplays that stream through your local audio device. - Only Stream Detail binds that key. The stream list ignores it.
Every exported WAV carries a comment recording its origin. The comment holds:
- the sipnab version,
- the mechanism (
sipnab-capture), - 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.wavA complete file says No omissions recorded.
A partial one says INCOMPLETE and names the reason, because these are different problems:
| The note says | What happened | What to do |
|---|---|---|
the payload ring wrapped and dropped N earlier frame(s) |
The call was longer than the [limits] max_audio_frames cap. The file holds the END of it. |
Raise or remove [limits] max_audio_frames
|
the [limits] max_audio_bytes retention budget was reached and released N earlier frame(s) |
The audio retained across every call reached the budget, and the oldest stream gave up its earliest frames. The file holds the END of it. | Raise [limits] max_audio_bytes
|
N earlier frame(s) were released after this call's vCon container was written |
A live capture wrote this call's vCon, which carries the audio, and then released it | Read the audio from the vCon container |
N of M stream(s) on this call are NOT in this file |
A WAV carries two channels; a three-legged call does not fit | Export the legs separately |
(G729) beside that |
A stream's codec is not one sipnab decodes | Nothing β the audio is not recoverable from this capture |
N frame(s) failed to decode |
The decoder rejected individual Opus frames | Check for truncation (--snaplen) |
media in only ONE direction reached this capture |
sipnab saw no stream going the other way. Either the call really was one-way, or the capture point only sees one leg | Check 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, that is 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.
- It 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 one of these:
- driving the TUI under
expect/tmux, - writing a small Rust binary that links the library.
- driving the TUI under
- The library functions (
--export-vcon writes one observed dialog as a vCon container.
- vCon is the IETF interchange format.
- A conversation archive, a compliance store or an agent-facing pipeline already reads it.
- 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.vconStraight 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.
The analysis body is the JSON object itself. So jq indexes into it directly:
sipnab -N -I capture.pcap --export-vcon '[email protected]' | jq -r '.analysis[0].body.capture_completeness.note'This needs a build carrying the non-default vcon feature.
-
sipnab --versionlists what yours has. - As a library,
examples/export_vcon.rsis 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.
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 these files:
-
.pcap,.pcapng,.cap(pcap format), - their gzip-compressed variants (
.pcap.gz,.pcapng.gz).- The page decompresses these transparently, with a notice.
It gives you the same tools as the native TUI:
- call list,
- ladder diagram,
- RTP stream view,
- search,
- filter DSL.
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 -Nfor those.
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. It reports what violates it, citing the section:
sipnab -N -I capture.pcap --lintEach 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=andexpected=are what you paste into a vendor ticket. - 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 errorPitfalls:
-
--lint-fail-on errorexits non-zero when it finds something.- That is the point. But it means a shell with
set -estops there. - Exit 3 is "lint findings at or above the threshold", not a crash.
- That is the point. But it means a shell with
-
--lint-fail-on warningis stricter than most real traffic survives. Start aterror. - Findings are per dialog.
- A capture with one broken proxy repeats the same rule many times.
-
--lint-max-per-rulecaps the noise.
Problem: You have a pipeline, not a person. It needs one answer per capture, not a stream of messages.
sipnab -N -I capture.pcap --analyzesipnab -N -I capture.pcap --json-analyze--json-analyze emits one object, not one line per finding. This is deliberate.
- 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.
- That 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
--portrangeanalyzes cleanly, because it saw no SIP.- Check the packet counts in the same object before trusting a green result.
Runnable example: Triage a capture to one verdict reads this object. It exits:
-
0for clean, -
1for problems, -
2for inconclusive. That is for a capture with no SIP to judge.
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 --stunsipnab -N -I capture.pcap --json-stunYou get one record per STUN/TURN transaction and per TURN allocation.
Each carries a record field naming which it is. That tells you:
- whether the endpoint ever learned its public address,
- whether the relay allocation succeeded,
- 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.
- Capture the media range too, or use
- A missing STUN transaction is not proof of a broken client.
- An endpoint with a public address does not need one.
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-idsipnab -N -I capture.pcap --group-by src --jsonsipnab 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.
Problem: A multi-gigabyte capture takes long enough that you stop using the tool.
sipnab -N -I huge.pcap --cores 8 --reportOffline, --cores N runs N reconstruction workers.
- It shards packets by host pair.
- Each worker has private dialog and RTP-stream stores.
Pitfalls:
- It covers reconstruction and
--report/--json.- These stay on the single-threaded path regardless:
- per-message output ordering,
- the security detectors,
- SRTP decryption.
- So a run that needs those gains nothing.
- These stay on the single-threaded path regardless:
- On a live device,
--coresmeans something different: N capture sockets.- That widens capture, not analysis.
- Processing stays on one thread either way.
- See tuning.
-
--coresand--metricsdo not combine usefully offline.- The run exits before a scrape lands, and sipnab says so.
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.
- It refuses a capture that has since rotated, truncated or recompressed.
- It does not answer with whatever now sits at that position.
- Without a digest (the form a human types), sipnab prints the frame and marks it
UNVERIFIED.- It has nothing to check against.
Pitfalls:
- Quote the pointer.
#starts a comment in most shells, and everything after it disappears. -
UNVERIFIEDis not a warning about that packet.- It means you did not give sipnab a way to prove it is the same file.
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 --reportFindings 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.
- 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
Authorizationheaders andMESSAGEbodies. - 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. That is not in the default feature set.-
sipnab --versiontells you whether the binary in front of you can load one.
-
- A filter or a
jqpipeline may already answer your question.- Then use those: no build step, no trust decision.
- See plugins.
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. It reports two numbers that mean different things:
PACKETS ARE BEING DROPPED on 'eth0' (kernel buffer: 18432, interface/driver: 0).
| Counter | libpcap field | What it means | What fixes it |
|---|---|---|---|
| kernel buffer | ps_drop |
The ring was full when the packet arrived. sipnab was not draining fast enough. | a bigger -B, a narrower BPF filter, a lower --snaplen
|
| interface/driver | ps_ifdrop |
The 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 fixsipnab -N -d eth0 --metrics 127.0.0.1:9100 # sipnab_capture_kernel_dropped_packets_total
# sipnab_capture_interface_dropped_packets_totalA 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.
- It wastes memory while the real problem goes unaddressed.
- Both counters at zero does not mean the capture is complete.
- A frame can arrive intact and still be unreadable.
- Or
--snaplencan cut it short. -
--reportcounts those separately. See tuning.
Runnable example: Diagnose one-way audio, and whose loss it is ends a one-way-audio diagnosis with this answer. Either:
- the loss is the network's, or
- the capture host dropped packets, and the loss figures include them.
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'" --jsonsipnab -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.
- 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 (20. Verify the exact bytes behind a finding).
Pitfalls:
- A storm from many sources with one
Fromis credential reuse, not one broken phone.- Group by
srcbefore concluding.
- Group by
- Registration traffic is often on a different port from calls.
- If the counts look impossibly low, widen
--portrange.
- If the counts look impossibly low, widen
Runnable example: Ban a scanner through TFPS, and verify it does two things:
- It withholds a flooding device that completed a registration earlier in the capture.
- It bans the ones that never did.
Problem: Calls to one destination fail immediately. The far end says "your equipment is wrong".
sipnab -N -I capture.pcap --filter "state == 'Failed'" --jsonThen set CALL_ID to the call_id of one failed call from that output. Ask for its report:
CALL_ID='[email protected]'sipnab -N -I capture.pcap --call-report "$CALL_ID" --markdownA 488 Not Acceptable Here after an INVITE is the signature.
- The call report puts the offered and answered codec lists next to each other.
- That is the part that settles it.
- Say the offer contains only
PCMU, and the far end supports onlyG729.- Then no configuration on your side of the trunk was ever going to work.
Pitfalls:
-
488is not always codec.- It also covers unsupported transport or an unacceptable
ptime. - Read the SDP, not just the status code.
- It also covers unsupported transport or an unacceptable
- 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.
Problem: Callers press keys and the IVR does not respond. Everyone blames the IVR.
DTMF travels three different ways. The failure is nearly always that the two ends chose differently:
sipnab -N -I capture.pcap --json | jq 'select(.method == "INFO")' # SIP INFOsipnab -N -I capture.pcap --report # RFC 2833 / telephone-eventIn the SDP, look for a=rtpmap:101 telephone-event/8000 on both sides.
- One side may offer it while the other does not.
- Then that side sends in-band audio tones instead.
- Those tones survive G.711. G.729 destroys them.
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-eventpayload 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.
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 42Receiving 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. You analyze at the edge, and keep the central Homer authoritative.
Pitfalls:
- sipnab refuses a non-loopback
--hep-listenunless you pass--hep-allowor--hep-auth.- That is deliberate. An open HEP port accepts forged call records from anyone who can reach it.
-
--hep-authtravels 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 hmacsigns 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-killsafe to pair with it. - The kill response targets exactly those fields. 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. Otherwise the collector cannot tell your nodes apart.
Both halves of HEP default to UDP. That is what Homer's agents have always spoken, and what sipnab has always done.
- Homer's collectors also accept TCP and TLS.
- 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 42There is deliberately no one flag covering both.
- sipnab is both agent and collector, often in one process.
- 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. 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.pemThe receiving side needs a certificate of its own. It refuses a private key that any other user on the host can read:
-
Restrict the private key to its owner:
chmod 600 /etc/sipnab/collector.key
-
Start the listener:
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 v1 and v2 cannot travel on a stream.
- They declare no total length.
- So nothing can say where one packet ends and the next begins.
- A
tcportlslistener reads HEP v3 only. - Senders that emit v1 or 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-allowstill applies too. It 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-careplaces the trust store rather than joining it.- That is what you want for a private collector.
- 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.
-
--hep-tls-extra-cajoins the trust store instead.- It trusts the named CA in addition to the host's CA bundle.
- That is for a sender that should accept both a private collector's issuer and the public roots.
- sipnab refuses the two flags together.
-
[hep] tls_caand[hep] tls_extra_cainsipnab.tomlset the same choice. - A flag for either replaces both keys.
- A
tcportlssender rebuilds its connection when the collector restarts.- It makes one attempt per packet.
- The packet that discovers the break can vanish. Nothing after it does.
Runnable example: See who feeds a HEP collector lists each agent feeding a collector, and what the listener refused. CI runs it against a collector fed by two sipnab agents.
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.
Set CALL_ID to the call you are chasing first:
-
Set the Call-ID:
CALL_ID='[email protected]' -
Write the report from the SBC capture:
sipnab -N -I sbc.pcap --call-report "$CALL_ID" --markdown > sbc.md
-
Write the report from the PBX capture:
sipnab -N -I pbx.pcap --call-report "$CALL_ID" --markdown > pbx.md
-
Compare the two:
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.
- 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-IDbetween its legs by design.- So this recipe compares a proxy's two sides, not a B2BUA's.
- Correlate those by
From/Toand time instead.
Runnable example: one node may see the signaling and another the media, as a proxy and an rtpengine relay do. Then neither report holds the whole call. Join one call seen at two nodes joins them over REST. CI runs it against two sipnabs.
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-
Reload systemd so it reads the new unit:
sudo systemctl daemon-reload
-
Enable the service, and start it.
-
restartstarts a stopped sipnab. - It also restarts a running one on the new unit.
enable --nowwould leave that one as it is:
# Run all of these, in order. sudo systemctl enable sipnab sudo systemctl restart sipnab
-
-
Follow its log:
journalctl -u sipnab -f
Pitfalls:
-
--syslogis what makesjournalctluseful.- Without it, the interesting output goes to stdout. journald merges it there without structure.
- Granting
CAP_NET_RAWviaAmbientCapabilitiesis what lets the unit run without being root at all.- Do not add
User=rootback "to be safe". That undoes it.
- Do not add
- A live run with no
-Owrites no capture file.- If you want the packets kept, say where.
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.
- 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-printOr name the range you actually run. That keeps a busy file fast:
sipnab -N -I capture.pcap --portrange 5060-5090 --report --no-cli-printWhat 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.
- 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 (36. Read SIP over WebSocket from a WebRTC gateway).
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 that both sides' CDRs record as connected. Nobody could speak on it.
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 --analyzeWhat 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.
- That 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-timeouton a LAN is a real finding. - The same number on an international trunk is a stopwatch running against the network.
- A five-second
Pitfalls:
- Lowering
--no-final-response-timeoutreports every call still ringing when the capture stopped.- On a live tap, that 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.
Runnable example: Group failed calls by response code lists the calls answered and never acknowledged, beside the failed ones.
CI runs it against a sipnab started with --ack-timeout 5.
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-printRound-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-printThe 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-printWhat to look for:
- A MOS built on an assumed delay is only as good as the assumption.
-
media_diagnostics(58. Ask why the MOS is what it is) reportsdelay.assumed: truewhen nothing supplied one. - That is the flag to read before quoting a score to a carrier.
-
-
--pdd-thresholddefaults to ITU-T E.721's target for an international connection.- 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-delayis a declaration about the observed path.- Declaring one you have not measured trades an honest default for a confident wrong number.
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.
- That is 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
--filterdoes not reach it. - Measured: two runs produce a byte-identical file:
-
-Ounder a filter matching nothing, -
-Owith no filter at all.
-
That matters here more than anywhere else on this page. The thing you are about to do is mail the result to somebody else. Narrow with the positional BPF expression. It 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. That 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.
The subset you need may be a SIP-level one. Then you have two honest options, and neither is -O.
Export the dialogs as vCon containers. That export IS dialog-filtered:
sipnab -N -I capture.pcap --export-vcon-when "from.user == '1001'" --export-vcon-dir ./outOr export those dialogs with the identifiers replaced.
-
--redactrewrites an exported CONTAINER, not a pcap. - There is no redacted-capture path.
- The run refuses rather than pretending:
sipnab -N -I capture.pcap --export-vcon-when "from.user == '1001'" --export-vcon-dir ./out --redactWhat 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.
-
--pcapngis worth the extra bytes when the capture carried decryption secrets.- pcapng is the format that can carry them (7c. Export encrypted packets for Wireshark).
Pitfalls:
- A filter selects dialogs.
- So a call whose INVITE was outside the capture has no dialog to match.
- Its packets are not written.
- sipnab refuses to write its output over its input.
- Name a different path. Use a different directory if you are working in place.
Runnable example: Export the calls of one customer from rotated captures:
- builds the BPF expression from the customer's calls,
- reads the export back,
- removes it when another customer's call shares an address.
Problem: Somebody is spoofing a number, or a carrier says your calls arrive with a bad attestation. The argument is about what the header actually held.
--stir-shaken decodes the RFC 8224 Identity header's PASSporT. It reports what the PASSporT claims:
sipnab -N -I capture.pcap --stir-shaken --no-cli-printEach decoded token prints one line:
STIR/SHAKEN: attest=A orig=+15551234567 dest=["+15559876543"] verified=NotChecked
What to look for:
-
attest=Ameans the originator claimed full attestation.- Nothing here confirms the claim.
- A forged Identity header decodes exactly like a genuine one.
- So this is evidence about what the originator claimed. It is never grounds for trusting a calling number.
-
verified=Expiredis the one check sipnab applies locally.- The check is RFC 8224 section 6.2, Step 4,
iatfreshness. - sipnab checks it 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.
- The check is RFC 8224 section 6.2, Step 4,
- No
STIR/SHAKEN:line at all means noIdentityheader 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.
- It never checks the signature over it.
-
verified=NotCheckedis the honest answer, not a failure.
- sipnab logs the line at
info.-
--quietfloors the log atwarnand hides it. So does the TUI.
-
Problem: An audit asks whether the phones authenticate properly. 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. It reports four weaknesses:
- an MD5 algorithm,
- a nonce reused across challenges,
- a challenge with no
qop, - a response with no
cnoncewhere the challenge offeredqop.
sipnab -N -I capture.pcap --digest-leak --alert-json --no-cli-printFindings 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 --quietWhat to look for:
-
WeakAlgorithmwith noalgorithmparameter in the challenge is the same finding asalgorithm=MD5.- RFC 2617 makes MD5 the default when the parameter is absent.
- So silence is a choice.
-
NonceReuseacross challenges is the one to act on first.- A nonce that repeats is a replay window.
- 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. It does not count.
Pitfalls:
-
The detector needs an alert channel, or it prints nothing.
-
--digest-leakon 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
responsehash. That hash is an offline attack against the subscriber's password.
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. Two limits bound what that costs:
sipnab -N -I capture.pcap --max-reassembly 500 --reassembly-ttl 15 --report --no-cli-printsipnab 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-printTurn reassembly off when the capture is pure single-packet UDP. Then reassembly is only overhead:
sipnab -N -I capture.pcap --no-reassembly --report --no-cli-printWhat to look for:
-
--reportcounts 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-ttlseconds. Then it sweeps it.- On a lossy path, that sweep is where a truncated message goes.
- Its count is what tells you the path is lossy.
Pitfalls:
-
--no-reassemblyis 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
--snaplenthat cut the frame short is a different problem with the same symptom.- Reassembly cannot restore bytes the capture never took (22. Measure whether the loss is yours or the network's).
Problem: A WebRTC leg is invisible. The browser talks SIP over WebSocket (RFC 7118) to Kamailio, OpenSIPS or Janus. The capture shows TCP and no calls.
The shipped WebSocket port set is 80, 443, 8080, 8443.
- That 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-printOr name the port the gateway actually listens on:
sipnab -N -I capture.pcap --ws-portrange 5066-5066 --report --no-cli-printsipnab refuses a WebSocket frame whose payload exceeds 64 KiB (65,536 bytes, MAX_FRAME_SIZE).
It does not reassemble 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
--portrangereplaces the default signaling ports.- Naming 5066 alone stops unwrapping 443.
Pitfalls:
- WSS is TLS. Unwrapping the WebSocket framing needs the TLS decrypted first. Use one of:
- a keylog (7. Decrypt SIP over TLS when you can restart the phone or server β key log file (SSLKEYLOGFILE)),
- a uprobe (7g. Read TLS with no keys at all).
-
--ws-portrangealone does nothing for it.
- When the media goes peer to peer, a browser leg carries no RTP that a server-side tap can see.
- Put the capture where the media is.
- Or read the relay's account of it (54. Find out where a stream's endpoint came from).
Problem: On a mobile core or a data-center fabric, the SIP is real and the capture is empty. 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-tunnelsName the ports yourself for a non-standard deployment. Linux's pre-IANA VXLAN port is one example:
sudo sipnab -N -d eth0 --capture-tunnels=8472It applies to a file too, where the tunnel is already in the bytes:
sipnab -N -I capture.pcap --capture-tunnels --report --no-cli-printWhat 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.
- Those 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
-Bfor it (43. Keep a long-running capture inside a memory budget).
- sipnab ignores the flag when you supply your own BPF expression.
- Your filter is then the whole filter, tunnel ports included or not.
Problem: You know a string that identifies the traffic. You do not know which calls carry it. The string can be:
- a
P-Asserted-Identity, - an
X-header your SBC adds, - a trunk group name.
-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' -iWhole words only, with two messages of context after each hit:
sipnab -N -I capture.pcap -e 'INVITE' -w -A 2Multi-line headers folded into one line before matching. A regex spanning a wrapped Contact needs this:
sipnab -N -I capture.pcap -e 'User-Agent' -i --single-lineEverything that does not match, for finding the odd one out:
sipnab -N -I capture.pcap -v -e 'OPTIONS' --report --no-cli-printWhat to look for:
-
-ematches the message text.--filtermatches the reconstructed dialog.- Use
-efor "which calls mention this string". - Use
--filterfor "which calls behaved this way". -
payloadin the filter DSL is the bridge. It matches when any message in the dialog contains the value.
- Use
- Add
--hexdumpwhen the argument is about bytes rather than text. For example:- a header with trailing whitespace,
- a body whose line endings are wrong.
Pitfalls:
-
-eis a regex.- So
.and+in a phone number or a header name match more than you meant. - Escape them, or add
-w.
- So
- The trailing positional argument is a BPF filter, not a match expression.
-
sipnab -I capture.pcap INVITEasks libpcap to compileINVITE, and fails. - The match expression needs
-e.
-
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. It is repeatable.
- sipnab reads the files in the order their packets arrived, never by filename.
- A ring buffer wraps. So
tg.pcap7can hold older traffic thantg.pcap0:
sipnab -N -I '/var/captures/*.pcap' --report --no-cli-printDescend into subdirectories, which is off by default:
sipnab -N -I '/var/captures/*.pcap' --recursive --report --no-cli-printWhat 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. - sipnab refuses the pair when
-Inames one file directly. It does not filter nothing and say nothing.
Pitfalls:
-
-l/--limitbounds dialogs for the whole run, not per file.- A 27-file directory reaches the cap 27 times sooner than one file does.
- Eviction drops the oldest dialogs. Those are the worst ones to lose for a post-mortem.
- Raise it (43. Keep a long-running capture inside a memory budget).
-
--recursiveis off on purpose.- Descending silently can analyze several times the traffic you pointed at.
- Nothing in the output would say so.
Runnable example: Export the calls of one customer from rotated captures:
- reads a directory of rotated captures,
- exports one customer's calls,
- checks the export holds them whole.
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-printtshark -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-printWhat to look for:
- Pair it with 20. Verify the exact bytes behind a finding.
- A frame pointer names one frame.
- 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 --wiresharkis the same idea for the GUI.- It builds a display filter naming every Call-ID in the capture.
- It prints the filter for you to paste into Wireshark.
Pitfalls:
-
--wiresharkonly 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.
- None of them accepts the syntax of the other two.
Runnable example: Export the calls of one customer from rotated captures ends with the tshark command for the export.
CI runs that command and capinfos against it.
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. Otherwise they print as a one-line summary:
sipnab -N -I capture.pcap --delta-time --show-emptyGroup the messages by call first. Then the gaps you are reading belong to one ladder:
sipnab -N -I capture.pcap --group-by call-id --delta-timeWhat 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.
- It says the first copy never reached anything that would answer it.
- It does not say that the sender is chatty.
- A long gap before
180 Ringingis post-dial delay. It belongs to the far end. - A long gap before
100 Tryingbelongs to the next hop. The next hop should answer immediately.
Pitfalls:
- Without
--group-by, a busy capture interleaves twenty calls.- Then 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 (27. Compare the same call at two nodes).
Problem: A report from three weeks ago says the capture was clean. Nobody can say which file, which filter or which port range produced it.
--run-provenance-file appends one JSON line per run, at startup.
- It writes before the config takes effect.
- It writes before sipnab opens any capture device.
- sipnab reads the config file first. So
capture.nodeis the node--node-nameor[capture] node_namesets:
sipnab -N -I capture.pcap --run-provenance-file runs.jsonl --report --no-cli-printUnder systemd, point it somewhere durable: --run-provenance-file /var/log/sipnab/runs.jsonl.
Then every restart appends rather than replaces.
{"argv":["sipnab","-N","-I","capture.pcap","--run-provenance-file","runs.jsonl","--report","--no-cli-print"],
"capture":{"dialog_generation":0,"instance":"3f9b0718d158c64087d09c-1","node":"capture-01","stream_generation":0},
"cwd":"/var/captures","features":["native","tui","audio","tls","hep","api","mcp","mcp-http","metrics","plugins","vcon","archive"],
"pid":41207,"record":"run","seq":1,
"started":"2026-05-05T12:34:56.095918671+00:00","ts":"2026-05-05T12:34:56.096437745+00:00",
"uid":993,"user":"sipnab",
"version":"0.5.142 (d2965454) features: native,tui,audio,tls,hep,api,mcp,mcp-http,metrics,plugins,vcon,archive"}sipnab records a secret given on the command line as [redacted]. That covers:
- the value of
--api-key,--api-signing-key,--archive-password,--hep-auth,--mcp-signing-key,--mcp-token,--metrics-authand--vcon-forward-auth,- whether the value follows the flag after a space or after
=;
- whether the value follows the flag after a space or after
- the user name and password in a URL such as
--vcon-forward-url https://user:[email protected]/.
What to look for:
-
capture.instanceis the identifier every MCP and REST answer carries.- So it joins an artifact back to the command that made it.
-
featuresis the build, not the flags.- A report can miss a section because the binary lacked the Cargo feature.
- That report reads identically to a clean one.
- 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".
- Nobody could tell which.
- Stopping costs nothing, because sipnab has read no packet yet.
- sipnab opens the file for append, and never truncates it.
- It creates it mode 0600.
- argv holds capture paths, and a path holds a customer's name.
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-printThe in-flight queue between capture and processing has a budget of its own.
It is separate from the kernel ring -B sizes:
sudo sipnab -N -d eth0 --buffer-budget 128 -B 128What to look for:
-
-l/--limitis 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. Those are the ones a post-mortem wants.
- On a long run, prefer rotating output files (44. Run a capture that stops on its own).
- Do not rely on the store to hold a week.
-
--max-groupsbounds--group-bykeys. It is a separate map from--limit.- A run that groups a week of traffic by source address needs both raised.
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 --pcapngOr keep capturing forever inside a fixed budget. Rotate, and delete the oldest chunks:
sudo sipnab -N -d eth0 -O /var/capture/sip.pcapng --pcapng --split filesize:50 --split-keep 8What to look for:
-
filesizecounts MiB, the same unit--split filesizeand-Buse.- So
filesize:2048stops at what a file browser calls 2 GiB.
- So
-
-n/--countcounts 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-keepdeletes 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.
- The next run does not adopt it.
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 a share of a call's packets.
- That share 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-printLower 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-dialogsWhat to look for:
- A trunk with aggressive voice activity detection routinely passes 30 % comfort noise.
- 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(58. Ask why the MOS is what it is) counts the comfort-noise frames and the silence periods per stream.- That is the measurement this ratio governs.
Pitfalls:
- sipnab refuses values of 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.
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.
- That detector 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-printA wrapping range is the overnight window:
sipnab -N -I capture.pcap --fraud-detect --business-hours 22-6 --alert-json --no-cli-printWhat to look for:
- The window is whole UTC hours.
- A site that works 08:00β18:00 local is a different pair of numbers.
- 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 the block recommendation in 10. Detect SIP scanners and auto-block via fail2ban before banning it.
- Then a night-shift desk is not the thing you disconnect.
Pitfalls:
-
--business-hourswithout--fraud-detectarms 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.
- Use
--alert-json,--alert syslogor--recommend-block. - With none of them, the run is silent.
- Use
Problem: A SIPp run or a proxy under test reuses one Call-ID across hundreds of transactions.
The whole capture then 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-printWhat 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-idcount is 1 and whosebranchcount is 400 is a load generator. - One where they are close is ordinary traffic.
- A capture whose
Pitfalls:
- sipnab computes every dialog-level diagnosis per tracked unit. That includes one-way audio, codec asymmetry and PDD.
- Under
branch, those units are transactions. - So a per-call figure is not what you get.
- Under
- Switch back to
call-id(the default) before quoting durations or MOS at anyone.
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-capsThen 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/emptyWhat to look for:
-
--setup-capssetscap_net_raw,cap_net_admin+epviasetcap, and exits.- It re-invokes itself through
sudowhen it is not already root. - After it,
sudois no longer part of the daily command.
- It re-invokes itself through
- Under systemd,
AmbientCapabilities=CAP_NET_RAW CAP_NET_ADMINdoes the same job for the unit (28. Run sipnab as a service).- Do not add
User=rootback "to be safe". That undoes it.
- Do not add
Pitfalls:
-
setcapdoes not survive a new binary.- Every upgrade, every rebuild, every package update drops it.
- The failure looks like a permissions problem that appeared from nowhere.
- Re-run
--setup-capsafter an upgrade.
- sipnab opens anything it must reach by path before the drop and the chroot.
- That includes a keylog file or FIFO, an output file, a config.
- A path under
/runis unreachable afterwards (7f. Decrypt without writing the keys to disk).
-
--no-priv-dropkeeps the privileges for the rest of the run.- It is not the way to fix a permissions error.
- Forging kill responses does not need it.
- sipnab opens the raw socket before the drop, and hands it to the scanner-kill worker process.
- That process never keeps root either way.
Problem: The capture command has grown to eleven flags, three of them thresholds. It now lives in four places that disagree.
Print the configuration this invocation would actually use, and exit:
sipnab -DPoint at a file explicitly. A systemd unit should do this, rather than rely on search order:
sipnab --dump-config --config /etc/sipnab/sipnab.tomlIgnore every config file. That is how you prove a behavior comes from the flags, and not from a file you forgot:
sipnab -D --no-configWhat to look for:
- A flag beats the file.
- Several knobs deliberately carry no built-in flag default.
- That keeps "not typed" and "typed the default" distinguishable.
- It also gives the config key something to override.
- The eight quality thresholds (31. Set the quality thresholds to your own network) and
--mcp-max-rowsare among them.
-
-Dis the fastest way to settle "is this host reading the config I think it is".- It answers before any capture starts.
Pitfalls:
-
-Ddumps the effective configuration.- That includes values that came from a file you did not name.
- Add
--no-configto see the flags alone.
- A config file is per host. The tool set an MCP server registers is usually per client.
- Set the host default in
[mcp] tools. - Give each client its own list with
--mcp-tools. - That flag replaces the config list rather than adding to it.
- Set the host default in
Problem: sipnab has a long flag for nearly everything on this page, and you are typing them from memory.
sipnab --completions bashWrite it where your shell looks for it:
sipnab --completions zshWhat to look for:
- Accepted shells are
bash,elvish,fish,powershellandzsh.- The script goes to stdout. So redirect it:
sipnab --completions bash > /etc/bash_completion.d/sipnab.
- The script goes to stdout. So redirect it:
- sipnab generates the completions from the same flag definitions
--helpprints.- 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.
- That 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.
Problem: A conversation archive, a compliance store or an agent pipeline wants the calls that failed.
- It wants them as containers, not as a pcap.
- The subscriber numbers may not leave the building in the clear.
--export-vcon-when takes a filter. It writes one container per matching dialog into a directory.
--redact turns every identity into a keyed pseudonym:
sipnab -N -I capture.pcap --export-vcon-when "state == 'Failed'" --export-vcon-dir ./vcons --redact --vcon-digestThis run does three more things:
- writes the reversal table,
- keeps three leading digits of each number,
- caps 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.
- Addresses go through a prefix-preserving map.
- So both of these are still answerable on the output:
- "these forty failures came from one subscriber",
- "the media went to a subnet no SDP advertised".
- Two things go entirely, rather than becoming tokens: digest credentials and inline audio.
- No pseudonym of them carries diagnostic value.
- 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.- 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.
- Understand what that buys whoever holds it.
Pitfalls:
-
--export-vcon-whentakes a raw DSL expression, not an alias.-
--filter problemsworks. -
--export-vcon-when problemsfails at parse time, naming the position of the offending token. - Spell the expression out.
-
-
--redact-mapis the reversal of every pseudonym the run produced.- So it is exactly as sensitive as the capture.
- sipnab creates it mode 0600.
- It refuses to overwrite an existing one. That file may be the map for containers already sent somewhere.
-
--redact-keep-prefixpublishes 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-mediabounds inline audio.- The default is 5 MiB. That figure comes from a measurement against a real store that answered 204 and dropped a larger payload.
- Over the budget, sipnab refuses the media out loud.
-
capture_completeness.mediasays so. It does not let an absent recording read as a silent call.
Problem: A conserver refuses a container. It 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. Nothing on any surface said so.
Run sipnab as an MCP server over the capture. Ask validate_vcon before you hand anything to a store:
sipnab -N --mcp -I capture.pcap --quietPass a container somebody else produced as an object, rather than a Call-ID. For example, one a store already rejected:
// validate_vcon { "container": { "uuid": "018f3a2b-4c5d-8e6f-9012-3456789abcde", "created_at": "2026-09-01T12:00:00Z", "dialog": [ { "type": "transfer", "parties": [0, 1] } ] } }
{
"schema_version": 2,
"verdict": "invalid",
"errors": [
{ "instance_path": "/dialog/0", "keyword": "not",
"detail": "carries `parties`, which the schema forbids here" }
]
}What to look for:
-
There are two verdicts,
validandinvalid.- The schema is the working group's draft-ietf-vcon-vcon-core-04 schema, unchanged.
- The
transferobject above breaks a rule core-04 added. - The rule:
partiesMUST NOT be present on a transfer (section 4.3.4).
- Under core-03, sipnab wrote the empty Dialog Object
{}for an attended transfer's unseen consultation leg.- That is now an ordinary error.
- core-04 replaced it with a placeholder that names its type (section 4.3).
- sipnab writes
{"type": "recording"}there.
- 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,
- a
containerthat is not a JSON object.
- The call fails only when the request is wrong:
Pitfalls:
- The validator reads the vendored schema file, rather than a transcription of it.
- It refuses to guess.
- A keyword outside the draft-07 subset the file uses makes every validation report
invalid, naming the keyword. - So re-vendoring a richer schema fails loudly, instead of quietly certifying less than it claims.
- Needs a build carrying the non-default
vconCargo feature.- Without it, the tool refuses by name.
-
server_capabilitieslists what a given binary has.
Runnable example: validate_vcon is sipnab checking its own work.
Check a vCon against the publisher's schema checks an export with jsonschema against the working group's file itself.
sipnab's copy matches that file byte for byte.
Problem: An agent asked for "every failed call as a container" lists the dialogs. Then it issues one export per row. On a real capture, that is hundreds of round trips to do what one invocation does.
export_vcon takes a filter as well as a call_id. It 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_matchedcounts the whole store. So it is the number to page against.-
returnedis only what this call fitted underlimit.
-
- Give
call_idorfilter, never both.- A request naming a dialog and a rule for choosing dialogs has two answers, and names neither.
- The CLI refuses the same pair.
- A filter matching nothing answers with an empty set.
- "No call failed" is a finding.
- A refusal there would make a clean capture look like a broken request.
- Every container comes back with its SHA-256.
- sipnab computes it exactly as
--vcon-digestcomputes 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.
- sipnab computes it exactly as
Pitfalls:
-
--retain-audiois what puts audio inside the containers. It requires--mcp.- The MCP server is the only batch-mode consumer that can read the buffers back.
- Without it,
capture_completeness.mediareadsnone-decodable. - That 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. The agent decides what to do with it.
- There is no
formatparameter.- A vCon is a JSON container defined by the draft.
- A Markdown arm would render a document whose whole purpose is to travel between machines.
Problem: A report gives the address a call's media went through. The address can come from three places:
- SDP the two parties exchanged,
- a relay sipnab asked,
- a mirrored datagram anybody on the segment could have sent.
Which one it came from decides what the claim is worth. Every other surface renders the three identically.
Start the server with the relay's control address. 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 --quietexplain_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. It does not just count 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.
decode_ng decodes one captured relay control message, and says which path carried it.
What to look for:
-
delivery_trustvalues, strongest first:-
asked: sipnab asked the relay over its control socket, so no third party could answer. hmac-verifiedplain-secret-
port-gated-only: the source is not authenticated. -
not-relay-asserted: the parties' own SDP claim. - With no posture configured, sipnab reports the weakest reading.
- A tool whose job is telling you what a claim is worth must not round up in the absence of information.
- The posture is the HEP listener's.
- A relay datagram read off a capture interface (
input_origin: wire) isport-gated-only, whatever the listener checks. - So is a HEP datagram
--hep-parseunwraps off an interface or a file, although it reportsinput_origin: hep. -
decode_ngreads frames from a capture file, so it reportsport-gated-onlyfor every frame.
- A relay datagram read off a capture interface (
-
-
reconcile_orphanshas three verdicts. The difference is the point:-
relay-asserted-but-no-dialogmeans the signaling is missing, not the media. -
signaled-but-no-dialogmeans the capture missed the dialog, or its SDP predates the capture. -
never-namedmeans nothing in the capture named the endpoint at all.
-
-
relay_was_consulted: falsebeside anever-namedverdict means nobody asked.- That is an absence of evidence, not evidence of absence.
Pitfalls:
-
query_relayneeds three things, and says which one is missing:-
--mcp-allow-relay-query, - the relay control flag,
- a live source.
- A run reading a file can obtain no transmit permit.
-
-
There is no address parameter, deliberately.
- The destination comes from
--rtpengine-controland from nowhere else. - An address sipnab could otherwise infer is one it learned from packets.
- That is a host that was a relay during the capture, and may be somebody's laptop now.
- The destination comes from
-
--rtpengine-controltransmitslistandqueryand nothing else. This is structural.-
offer,answer,deleteandstart recordingeach change a production relay. - None is representable on that path.
-
Problem: The agent driving sipnab is not on your laptop, and not run by you. So "loopback with no token" stops being the deployment.
Every knob below is a separate decision. 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 --quietMint a short-lived token from the same signing key, rather than share a static secret:
sipnab --mint-token --token-scope read --token-id agent-a --mcp-signing-key-file /etc/sipnab/mcp.key --mcp-token-ttl 900What to look for:
-
--mcp-tools coreregisters a small set that still answers a whole call.- sipnab sends every registered tool's name, description and JSON schema on
tools/list. - 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.
- sipnab sends every registered tool's name, description and JSON schema on
-
--mcp-rate-limit-per-peerand--mcp-max-concurrentanswer different questions.- That one bounds calls in flight.
- This one bounds their arrival rate.
- Some agents never exceed the concurrency cap, and simply loop as fast as sipnab answers.
- Such an agent holds one slot at a time, and asks again the moment it frees up.
- The concurrency cap alone does not bound that.
-
--mcp-audit-fileis the durable copy of what an agent looked at.- The same record already rides the ordinary log. But that is a console view.
-
SIPNAB_LOGfilters it, and--quietsuppresses it. - 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-rootis the whole security model for the file tools. It is not negotiable.- They take a filename, never a path.
- 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. That is the address, not the socket. So reconnecting does not mint a fresh allowance.
- the pipe itself over stdio.
- The write verbs still appear in
tools/listwhen you omit their flags.- sipnab registers tools unconditionally, and refuses the call instead.
- Seeing
shutdown_serverlisted does not mean an agent can stop your capture.
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. 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. It is 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
privilegedbefore believing an empty list.- Unprivileged,
/proc/<pid>/mapsis readable only for the server's own processes. - So a short list is evidence about privilege, rather than about the host.
- The
summaryfield says which of the two produced the answer. So a relayed conclusion does not lose it.
- Unprivileged,
-
probe_path: nullis a finding, not a blank.- That library is carrying traffic sipnab cannot capture.
- Usually it is a containerized process whose
/proc/<pid>/rootthis server cannot read. - Reporting it is what keeps a capture from looking complete when it is not.
-
inodeis there becausepathis not unique.- The same string names different files in different mount namespaces.
- On an ordinary host with containers, several distinct
libssl.so.3files coexist.
-
loston the stop is the number the kernel dropped because the reader fell behind.- Those are messages that existed and are missing.
- That is a different fact from a quiet trunk.
- It is the only one you cannot discover any other way.
Pitfalls:
-
Keep calling
stop_tls_captureuntilrunningis 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. They outlive sipnab.
- Three refusals arrive before any kernel state exists. Each names itself:
- not root. A server started with
--userhas already dropped privileges, and cannot attach probes later. - a live source already running. sipnab's stores have one writer.
- a capture still loading. Poll
capture_statusuntilload.done.
- not root. A server started with
- An attach failure arrives later, not from the start call.
- A background thread installs the probes. The call returns as soon as it starts.
- Poll to see whether messages actually arrive.
- The uprobe path costs you the addresses.
- See 7g. Read TLS with no keys at all for what a dialog from this source can and cannot say.
- See 7h. Read TLS and who the peer was for the backend that pairs the write with its socket.
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.
- 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.
It is 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_countalways equaltotal_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. That includes nulls.
- Nulls become the literal
(none), because "how many dialogs carry no User-Agent" is a real question.
-
Read
completebefore the findings.- It is
falsewhen the capture:- lost packets,
- hit a retention cap,
- or held frames no decoder could read.
- A findings list from such a capture is a floor, not a total.
- It also reads
falsein two more cases:- while a load is still running,
- for a source whose read stopped before its end.
- It is
- Legal
group_byvalues arestate,response_code,method,from.user,to.user,ua,src.ip,dst.ipandrtp.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
filterinstead.
- Grouping by
from.user,to.useroruareturns fenced values.- Those are text the packet's sender wrote.
- A state name, a status code, an address or a codec is sipnab's own derivation. It comes back verbatim.
-
markdownandtextrenderings of the report have no envelope to carry the completeness flags.- So they state it in the document: an
INCOMPLETE RUNblock, the same one--reportappends. - Ask for
jsonwhen you want the booleans as fields.
- So they state it in the document: an
Problem: "The MOS is 3.6." A score is a conclusion. The argument with a carrier is about the facts underneath it.
rtp_stats gives the score. media_diagnostics gives the inputs.
Each input carries a label saying 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
applicablefirst.- It is
falsewhen no RTP stream belongs to the dialog. The response then carries almost nothing. - An empty
streamsarray would read as "sipnab checked the media and it was fine". - That is a different claim from "no media reached the capture point".
- It is
- Three things in the answer above read together:
- The media is in the default queue. So it competes with bulk traffic.
- That is the most common cause of jitter that adding bandwidth does not fix.
- The jitter figure is a measurement.
- Payload type 0 has a clock rate RFC 3551 fixes.
- The delay behind the MOS is a default, not anything this capture showed.
- So the score is only as good as that assumption (31. Set the quality thresholds to your own network).
- The media is in the default queue. So it competes with bulk traffic.
-
jitter.grounded: falsemeans 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.
- An ungrounded stream reports no
measured_msat all.
-
qos.remarked_toappears only when the stream's last packet carries a different code point from its first.- That is an SBC or a policy boundary rewriting the marking in flight.
- Its presence is the finding.
- A steady stream omits it, rather than repeating the same number.
Pitfalls:
-
endpoint_reportedis what the far end said over RTCP.- It sits apart from everything beside it, and feeds nothing.
- Nobody authenticates RTCP. Anyone can forge it.
- A report describes the path from the sender to the reporter.
- On a mid-path capture, that is a different segment from the one sipnab watches.
- The two disagreeing is normal and informative.
-
marking_observed: falseon a HEP-fed stream is not a missing marking.- sipnab saw no IP header, because a mirror carries the message and not the frame.
Problem: You hold the SIP server's TLS private key, and no keylog. Nobody was running one when the calls happened.
-k/--tls-key takes the PEM private key. It uses it to recover the pre-master secret:
sudo sipnab -N -d eth0 -k /etc/sipnab/server-key.pemThe 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. It does not capture everything and decrypt nothing.
What to look for:
- This works for TLS 1.2 RSA key exchange only.
- Those are the handshakes with no forward secrecy.
- 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 one of these:
- 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.
- 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.
Problem: Something is recording a call. You need to know what the recorder learned about it:
- which parties,
- which media stream belongs to which one.
A session recording client (an SBC, or OpenSIPS's siprec module) sends the recording server an INVITE.
The INVITE's 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": "..." }.
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.
--export-vcon on the same Call-ID writes Alice and Bob as parties, and maps each stream to its sender.
A SIPREC recording session describes the container.
What to look for:
-
labelis them=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.
- On a multi-stream call, nothing else does.
-
participant_idis who SENDS the stream.- Bob receives Alice's audio and video, and neither is his.
- Ownership comes from
<participantstreamassoc>. - Its
<send>children mean origination. Its<recv>children do not.
-
A missing
nameis missing, not empty.- An SRC that has no display name for a party sends the AOR alone.
Pitfalls:
-
recorded: falsedoes 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.
- 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.
- No later message repeats it, unless the SRC re-sends.
Problem: Audio is bad, and the media relay (rtpengine) is the prime suspect. You want the relay's OWN drop and throughput counters. You do not want a guess from the packets your capture happened to see.
A relay statistic is a claim from the relay, not something sipnab measured.
- Ask over the relay's read-only control port. sipnab prints what the relay says.
- The output carries a tag naming it 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-statsEvery 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. rtpproxy publishes none. - So a small number can mean "handled little" or "just restarted". Only the uptime tells them apart.
- A polled series (
--relay-stats-interval) can step backwards. sipnab flags that 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-statson-I file.pcap, because a file's addresses belong to third parties.
- So this needs a live source (
- 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.
Problem: You suspect leaked sessions. These are calls that ended, but whose media ports the relay never tore down.
sipnab -N -d eth0 --rtpengine-control 127.0.0.1:22222 --relay-statsRead 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 5to watch it move, rather than reading one snapshot.
- The counters are the relay's own (
relay_reported). They are cumulative, and reset on restart.- See the uptime caveat in 61. Ask a relay whether it is dropping packets.
Problem: You have a Call-ID. You want to know whether the relay's view of it matches what your capture saw. This is "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, each showing which it is:
- the relay's own RTP packet count for the call,
- the count sipnab measured from the media it captured.
It also shows 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.
- A relay that restarted mid-call zeroed its counters.
- The note names these. Read it before you escalate.
- A side can be absent. Absent is not zero.
- The relay side is absent when the relay does not hold the call.
- sipnab's side is absent when your capture saw no RTP for it.
- An absent side never renders as
0. - sipnab flags a per-call total too large to compare as suspect. It does not read it as a call the relay does not hold.
Problem: The relay reports packet loss, and so does sipnab. 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.
- Getting it wrong is how a real fault gets blamed on the wrong hop.
- There are three different "loss" numbers for one call. Each is a different claim:
| The number | Wire tier | What it actually is |
|---|---|---|
| The relay's own loss counter | the relay's own count (relay_reported) |
what the relay's box counted, on its sockets, since its last restart |
| sipnab's sequence-gap loss | what 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 lost
|
what 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-statssipnab -N -I capture.pcap --call-report 'abc123@host'- The first asks the relay for its own loss counter, live.
- The second reads two things from the capture:
- what sipnab measured on the wire,
- the far end's RTCP claim.
None is authoritative over the others.
- The relay's count and sipnab's differ whenever the capture is off the relay's path.
- 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". Then the disagreement stops being a contradiction. It 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, see recipe "Measure whether the loss is yours or the network's".
- That is a different axis: whether YOUR capture dropped packets.
Problem: You found the message that went wrong.
- The carrier's engineer opens the capture in Wireshark, not in sipnab.
- A remark in an email comes apart from the file it is about.
Take each frame's pointer from --json. Write the notes file, one JSON object per line:
sipnab -N -I capture.pcap --json | jq -c 'select(.status_code == 183) | {frame, note: "the SDP changed here, after the 180"}' > capture.notes.jsonlThen write the annotated copy:
sipnab -N -I capture.pcap --notes capture.notes.jsonl --write-annotated capture-for-carrier.pcapng- Wireshark shows each note as the packet comment on its frame.
-
tshark -r capture-for-carrier.pcapng -Y frame.commentlists the annotated frames. - The copy carries the original frames byte for byte.
- sipnab checks every note against its frame's digest.
- It refuses, writing nothing, when the capture changed after you took the pointers.
In the TUI, C on a message types the note there. A PCAP-NG save writes it.
Operator notes has the keys.
Pitfalls:
- The copy carries none of these:
- decryption secrets,
- name resolution blocks,
- the input's own comments.
- If the carrier needs to decrypt TLS, send the key log on purpose, separately.
- A note is text you typed. sipnab never reads it back.
-
--json, the REST API and MCP never show it. - That is the design (Invariant "Operator notes are output, never input"), not a gap.
-
- sipnab refuses a note holding an SDES key, a TLS key-log line or a digest
response=value.- Describe the message that carried the key, and leave the key out.
Problem: The call is no longer in any capture you hold. What you have is a vCon:
- the one a store kept,
- one sipnab exported,
- or one another producer wrote.
-I reads it the way it reads a pcap:
sipnab -N -I call.vcon.json --analyze --no-cli-print --quiettests/fixtures/vcon/sip-rtp-g711-1966.vcon.json is the container --export-vcon [email protected] --node-name tap-a writes from the sample capture.
Against it:
Capture analysis: 1 finding(s) across 1 dialog(s), 0 stream(s), 6 frame(s) read.
THIS ANALYSIS IS INCOMPLETE β sipnab did not read all of this capture. The findings below describe what it UNDERSTOOD, so their counts are floors and the ABSENCE of a finding is not evidence that the problem is absent. The blind finding(s) are listed first.
1. [BLIND] Rebuilt from a stored vCon β 1 container(s)
These dialogs were rebuilt from stored vCon containers, not read from packets. A vCon holds no RTP, so no media statistic here was computed, and whatever its producer did not store is absent. The evidence names each container, what it lacks, and the producer's own caveat.
Evidence:
- tests/fixtures/vcon/sip-rtp-g711-1966.vcon.json, vcon 0158a120-0392-8751-9a04-9decf1485b22 | messages=6 | 6 SIP message(s) rebuilt from this container. Not recomputed or not stored: media: a vCon carries no RTP, so no stream, MOS, jitter or loss was computed from it; the producer's analysis holds 1 media_quality row(s), its own numbers, not recomputed; request-URI: not stored for 3 request(s), which carry sip:request-uri-not-stored.invalid; header order: the trace keys headers by name, so their order across names is the container's, not the wire's, and headers the producer did not store (credential headers, when sipnab produced it) are absent. The producer's caveat: Produced by sipnab 0.5.208 on node tap-a. sipnab OBSERVED this dialog and took no part in it: the parties below are what the From and To headers said, not identities anyone established, and nothing here is signed. No media is in this container because this run decoded none for the dialog; the capture_completeness media_note says what was measured. That is a statement about what this run kept, not a finding that the call was silent. sipnab read 852 frame(s) for this capture. No omissions recorded: every message sipnab held for this dialog is in this container. A capture-level analysis ran and ranked no blind spots.
The other report flags read it the same way: --json-dialogs, --report, --call-report and the TUI.
-
-Ialso takes a directory of containers.- It skips one it cannot read, with the reason.
- The MCP tool
analyze_vconandPOST /v1/vcon/analyzeanalyze a container handed over inline.
Pitfalls:
- A vCon stores no RTP. So no media statistic comes back.
- The first finding says so, and quotes the producer's own caveat.
- A container that fails the vCon schema still reads.
- One example is a container vcon.store handed back with
extensionsas an object. - sipnab logs the schema finding, and carries it in the analysis.
- One example is a container vcon.store handed back with
- sipnab refuses
--coresand a BPF filter for a container.- Both work on captured frames.
- Select dialogs with
--filter.
Analyze a stored vCon walks through it in full.
Problem: The complaint says the calls failed "between ten and eleven this morning". The capture covers the whole day. Every report you run lists the calls from before and after the window too.
Give the window with --since and --until, in RFC 3339:
sipnab -N -I capture.pcap --json-dialogs --no-cli-print --quiet --since 2026-10-09T10:00:00Z --until 2026-10-09T11:00:00ZEach line is one call whose first message falls in the window.
The window joins --filter. So the failed calls in that hour are:
sipnab -N -I capture.pcap --report --filter "state == 'Failed'" --since 2026-10-09T10:00:00Z --until 2026-10-09T11:00:00ZWhat to look for:
-
The window is half-open.
- A call that started at exactly 10:00:00 is in.
- One at exactly 11:00:00 belongs to the next hour.
- Back-to-back windows count every call once.
-
The same window on every surface.
- REST's
GET /v1/dialogs?after=...&before=...and the MCPsearch_by_timetool return the same calls for the same capture.
- REST's
-
An empty answer says why. Sometimes no call in the capture started inside the window. Then:
- the run exits 0 with an empty listing,
- it prints the span the capture's calls do cover on stderr.
- So a window typed in local time instead of UTC is visible at once.
Pitfalls:
- Write the instant in UTC (
Z) or with its offset (+02:00).- sipnab refuses a time with no offset, exit 2, because it names no instant.
- The window bounds when a call started.
- A call that started at 09:59 and failed at 10:05 is outside it.
The recipes above walk through a problem end to end. This section is the other shape.
- It holds dense one-line commands to copy.
- Use it when you already know what you want, and just need the invocation.
- Every flag used here appears in cli-reference.md.
-
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
problemsDSL alias. - So it covers the whole diagnostic set: Failed state, one-way audio, loss/jitter, NAT mismatch, retransmits, PDD, asymmetry, late media.
-
sipnab -N -I capture.pcap --filter problemsreturns the same calls.
- The flag expands to the
-
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
-
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-mediais the same flag with the late-media alias.
-
-
sipnab -N -I capture.pcap --slow-setupβ slow call setup, meaning long post-dial delay
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 -uMore in output-formats.md.
Capture SIP+RTP to rotating pcapng files, 50 MiB chunks:
sudo sipnab -N -d eth0 -O /var/capture/sip.pcapng --pcapng --split filesize:50Run 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.
- It deletes only the files that running process created and named.
- So anything else in
/var/capturestays. - 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. It covers signaling only, and it does not decrypt media.
sudo sipnab -N -d eth0 --keylog /tmp/sslkeys.log --keylog-watch \
-O decrypted.pcapng --pcapngSRTP 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=cryptokeys are also learned from SDP.
- SDES
-
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
-
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 aSIPNAB_JSONpayload. - They are never shell-interpolated.
- Details arrive as
-
sudo sipnab -N -d eth0 --on-quality-exec '/usr/local/bin/page-noc'β alert when RTP quality drops
-
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). Otherwise sipnab refuses to start. -
-L/--hep-listendecodes HEP on its own. -
--hep-parseis only for unwrapping HEP that arrives inside ordinary UDP capture.
- A routable bind needs the allowlist (or
-
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.
- 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