Docs Choose an output format
Choose an output format
Machine-readable output: NDJSON, summary reports, dialog/stream JSON, and pcap/pcapng.
On this page
sipnab has four output modes:
- the interactive TUI (the default),
- per-message text with
-N, - NDJSON with
--json, - the MCP server for AI agents.
The REST API serves the same data over HTTP. This page documents the machine-readable formats the command line writes.
Which output do I want?
| You want | Run | Details |
|---|---|---|
One JSON line per SIP message, to pipe into jq | sipnab -N -I capture.pcap --json | NDJSON |
| One readable line per call | sipnab -N -I capture.pcap --report --no-cli-print | Summary-only output |
| One call explained, as text or JSON | --call-report "$CALL_ID", plus --json for JSON | Summary-only output |
| One JSON object per call | --json-dialogs --no-cli-print | One object per dialog |
| Every STUN and TURN exchange | --json-stun --no-cli-print | STUN / TURN JSON |
| One verdict for the whole capture | --json-analyze --no-cli-print | Capture analysis |
| The same verdict as YANG-modeled data | --yang-analyze --no-cli-print | YANG export |
| One call as a vCon container | --export-vcon | vCon |
| The packets, for Wireshark | -O calls.pcapng --pcapng | pcap / pcapng |
| A Wireshark display filter for the capture | -N --wireshark | CLI reference |
| Log lines fail2ban reads | -N --fail2ban with --kill-scanner | CLI reference |
| A call’s ladder as a Mermaid diagram, or its audio as WAV | the save dialog in the terminal UI (F2) | Save popup |
Prerequisites:
--jsonrequires non-interactive mode (-N/--no-tui). Without it, sipnab exits with code 2 and printsOutput flags (--json) require -N/--no-tui mode (or --call-report, or --export-vcon).--call-reportand--export-vconimply non-interactive output.- NDJSON needs no extra build feature. It is part of the default build.
- Full flag reference: CLI reference.
NDJSON (--json)
--json emits one JSON object per SIP message. The output is
newline-delimited:
- each line is independently parseable,
- the stream is pipe-friendly.
sipnab -N -I capture.pcap --json | jq .
Print the records of a capture, one per line:
sipnab -N -I tests/pcap-samples/sip-sdp-example.pcap --json
The first line is the INVITE, shown here indented with jq .. Fields with no
value drop out rather than reading null:
{
"schema_version": 1,
"timestamp": "2011-08-09T06:32:56.583165+00:00",
"src": "192.0.2.101",
"src_port": 5060,
"dst": "192.0.2.100",
"dst_port": 5060,
"transport": "UDP",
"is_request": true,
"method": "INVITE",
"call_id": "sdp-example-synth@192.0.2.101",
"from": "Caller <sip:caller@atlanta.example.com>;tag=tag-caller-sdp",
"to": "Callee <sip:callee@biloxi.example.com>",
"contact": "<sip:caller@192.0.2.101:5060>",
"ua": "SynthPhone/1.0",
"has_sdp": true,
"sdp": "v=0\r\no=caller 10003 1 IN IP4 192.0.2.101\r\ns=synthetic session\r\nc=IN IP4 192.0.2.101\r\nt=0 0\r\nm=audio 6010 RTP/AVP 8 96\r\na=rtpmap:8 PCMA/8000\r\na=rtpmap:96 telephone-event/8000\r\na=sendrecv\r\n",
"cseq": {
"number": 1,
"method": "INVITE"
},
"extension_headers": [
"Via: SIP/2.0/UDP 192.0.2.101:5060;branch=z9hG4bK-synth-sdp-example",
"Max-Forwards: 70",
"Content-Type: application/sdp",
"Content-Length: 187"
],
"frame": "tests/pcap-samples/sip-sdp-example.pcap#0@b4a34b7613500735",
"dscp": 0,
"input_origin": "wire"
}
A response record carries is_request: false plus status_code,
reason, and response_context instead of method. The third line of the
same output:
{
"schema_version": 1,
"timestamp": "2011-08-09T06:32:56.625190+00:00",
"src": "192.0.2.100",
"src_port": 5060,
"dst": "192.0.2.101",
"dst_port": 5060,
"transport": "UDP",
"is_request": false,
"status_code": 180,
"reason": "Ringing",
"call_id": "sdp-example-synth@192.0.2.101",
"from": "Caller <sip:caller@atlanta.example.com>;tag=tag-caller-sdp",
"to": "Callee <sip:callee@biloxi.example.com>;tag=tag-callee-sdp",
"contact": "<sip:callee@192.0.2.100:5060>",
"has_sdp": false,
"cseq": {
"number": 1,
"method": "INVITE"
},
"response_context": "1 INVITE",
"extension_headers": [
"Via: SIP/2.0/UDP 192.0.2.101:5060;branch=z9hG4bK-synth-sdp-example",
"Content-Length: 0"
],
"frame": "tests/pcap-samples/sip-sdp-example.pcap#2@a19501d31dc3f1c8",
"dscp": 0,
"input_origin": "wire"
}
from and to are the full From / To header values. They include
display name, URI and tags. They are not the bare user part. (The aggregated
dialog object below is the one that carries the user part.) Match them with a
regex or a substring test, not with equality.
cseq is the parsed CSeq header — { number, method }.
- It appears on every message, requests included.
- So re-requests within a dialog stay distinguishable (e.g. two REGISTERs with
CSeq1 and 2). - It drops out only when the
CSeqheader is absent or unparseable.
contact is the Contact header value when present. It is routing-critical.
The field drops out otherwise.
sdp is the raw SDP body.
- sipnab emits it only when
Content-Typeisapplication/sdp, in any case and with any parameters, and the body is valid UTF-8. - It lets a consumer verify the negotiated media (connection /
m=/a=rtpmap). Dynamic-PT decode depends on that media. - sipnab omits it for non-SDP or non-UTF-8 bodies.
has_sdp is true when the message carries SDP and false when it does
not. Every message carries it.
- It uses the rule that marks a message
(SDP)in the per-message lines, the ladders and the reports: aContent-Typeofapplication/sdpin any case, amultipart/*body with anapplication/sdppart, or a body with noContent-Typethat startsv=. An empty or blank body isfalse, whatever its type. - A
multipart/*body, such as a SIPREC INVITE, hashas_sdp: trueand nosdpfield, becausesdpholds only a wholeapplication/sdpbody.
Print the messages that carry an offer or an answer:
sipnab -N -I tests/pcap-samples/sip-sdp-example.pcap --json \
| jq -c 'select(.has_sdp) | {method, status_code, cseq}'
{"method":"INVITE","status_code":null,"cseq":{"number":1,"method":"INVITE"}}
{"method":null,"status_code":200,"cseq":{"number":1,"method":"INVITE"}}
response_context is "<num> <method>", on responses only. It says what the
response answers. It is a deprecated alias of cseq, retained for backward
compatibility under schema_version 1. Prefer cseq.
malformed is a list of structural-defect diagnostics. It is present only
when a message arrives malformed. A well-formed message omits the field.
It surfaces crafted or broken input rather than silently accepting it:
- missing mandatory headers (
Call-ID/CSeq/From/To/Via), - an unparseable
CSeq, - a
Content-Lengthlarger than the body actually present (truncated/lying length), - control/NUL bytes in a header.
Example: "malformed": ["missing mandatory header: Call-ID"].
extension_headers is every header the fields above do not already carry.
- It uses wire form (
"Name: value") and wire order, one entry per header line. Call-ID,From,To,Contact,User-AgentandCSeqnever appear. Those are fields of their own.- sipnab drops nothing else. So
Via,Max-ForwardsandContent-Lengthjoin the vendor-specific headers this exists for.
The list keeps duplicates and wire order. Three Via lines are three entries
in the order they arrived. A message whose every header is already a field
omits the key rather than sending an empty array.
frame is a frame pointer: the capture file, the frame’s index in it, and a
digest of the frame’s bytes. --show-frame takes it and prints that frame. It
drops out when the message has no frame.
dscp is the RFC 2474
Differentiated Services Code Point on the frame that carried the message, 0 to
63. 0 means sipnab observed the marking and it is best effort. The field
drops out when sipnab observed no IP header, as for a message received over
HEP.
input_origin names the capture source that delivered the message: wire,
hep or uprobe. Read it before you pass frame to --show-frame.
A uprobe read carries a pointer of the same shape as a capture offset
(uprobe:opensips/954#3 beside capture.pcap#4212). Only one of the two leads
back to bytes anyone can read:
wirecame from an IP header sipnab observed.hepcarries addressing a remote HEP sender asserted.uprobenames plaintext sipnab lifted out of a process’s TLS library. That plaintext was never on a wire at all.
Feeding a uprobe: pointer to --show-frame therefore gets a refusal that
names the process, not a frame.
The field drops out when the message came from no captured packet. There,
wire would claim an observation sipnab never made.
The -N text output and the TUI’s raw-message viewer carry the same fact. They
add a trailing origin=hep or origin=uprobe. They leave an ordinary wire
capture unmarked:
12:57:47.360 0.0.0.0:0 -> 0.0.0.0:0 REGISTER TCP origin=uprobe
A message that carries SDP has (SDP) after its method or status, as in
INVITE (SDP) or 200 OK (SDP). The -N lines, the TUI ladder and detail
title, --call-report, the Mermaid ladder, analyze_vcon and the browser
analyzer all use the same label. A message counts as carrying SDP when its
Content-Type is application/sdp in any case, when it is multipart/* with
an application/sdp part, or when it has no Content-Type and its body
starts v=. An empty or blank body is no session description, so a message
that declares application/sdp and sends no body, such as an INVITE with a
delayed offer, has no mark. A body declared as another type is that type,
whatever its first bytes:
14:52:59.666 10.0.2.20:5060 -> 10.0.2.15:5060 INVITE (SDP) UDP
schema_version increments on breaking field changes. Pin your consumers to
it.
Pretty-printed JSON (--json-pretty)
--json-pretty emits the identical message record. It indents the record
across several lines instead of packing it onto one. Reach for it when a person reads the
output:
sipnab -N -I capture.pcap --json-pretty
It costs you the NDJSON property, and that matters to anything downstream:
- A line no longer holds a whole record.
- A tolerant concatenated-JSON parser still reads the stream.
- A tool that splits on newlines no longer does.
Anything a pipeline consumes wants --json.
- Both flags need
-N/--no-tui. - Passing both gives the indented form.
--json-prettywins the branch. - Both fall silent under
--no-cli-print. It suppresses the per-message stream whatever shape it takes.
jq recipes
One recipe per question, each a complete pipeline. Run the one you want, not the whole list.
Keep only the INVITEs:
sipnab -N -I capture.pcap --json | jq 'select(.method == "INVITE")'
Find the calls placed by one user. from carries the whole From header. So
test it for a substring rather than comparing it for equality:
sipnab -N -I capture.pcap --json | jq 'select(.from // "" | test("sip:1001@"))'
Count the messages per method, to see what a capture holds before reading any of it:
sipnab -N -I capture.pcap --json \
| jq -s 'group_by(.method) | map({method: .[0].method, n: length})'
Pull the failed responses (4xx/5xx/6xx) with the reason each one carries:
sipnab -N -I capture.pcap --json \
| jq 'select(.status_code != null and .status_code >= 400)
| {ts: .timestamp, code: .status_code, reason, call_id}'
List the distinct Call-IDs seen. This is the list you feed into
--call-report:
sipnab -N -I capture.pcap --json | jq -r '.call_id' | sort -u
Build a response-code histogram, counting how many of each status code the capture holds:
sipnab -N -I capture.pcap --json \
| jq -r 'select(.is_request == false) | .status_code' \
| sort | uniq -c | sort -rn
Summary-only output
--json prints a line per message. For end-of-run summaries instead, combine
the report flags with --no-cli-print.
That flag suppresses the per-message stream but not the report.
For the aggregate report over the whole capture, and nothing else:
sipnab -N -I capture.pcap --report --no-cli-print
When the inputs include stored vCon containers (-I naming a directory of
them, for example), the report adds a vCon containers: section after the
dialog table:
- one row per container: read, or skipped with the reason;
- the totals: containers read and skipped, skips by reason, containers that fail the schema, calls by state, and the total duration.
With --json or --json-pretty, that section is one JSON object under the
key sipnab_vcon_containers instead. Read many containers at
once describes every column and field.
For a deep dive into a single call, named by its Call-ID:
sipnab -N -I capture.pcap --call-report 'abc123@192.0.2.1' --no-cli-print
Dialog / stream JSON
The richer aggregated dialog object is one shape produced by a single serializer. It holds:
state,timing(PDD / setup / ring / teardown milliseconds, retransmit counts),sdp_timeline,streamswith jitter and loss,- the four
diagnosisflags (one_way_audio,nat_mismatch,no_media,private_media_address), hints.
One place documents it, with a full worked example, under
GET /v1/dialogs/{call_id} in the REST API reference.
One object per dialog
--json is a per-message stream. --json-dialogs emits the same dialog
document the REST API returns. It writes one compact line per call, after
capture completes:
sipnab -N -I capture.pcap --json-dialogs --no-cli-print --quiet \
| jq -c 'select(.state == "Failed") | {call_id, final_status_code, final_status_reason}'
Reach for it when the question is which calls failed and why rather than what did the wire carry.
- A dialog-level filter like
state == 'Failed'selects dialogs. --jsonthen emits every message belonging to them. That includes the 100 Trying and the 401 challenge alongside the 488 that actually failed the call. All carry the samecall_id.- Joining those back together is work the per-dialog form does for you.
final_status_code is the response that decided the outcome, with auth
challenges excluded. A call challenged and then answered reports 200, not
the 401.
final_status_reason is the phrase from the wire.
RFC 3261 section 7.2
leaves it as free text. 500 Service Unavailable is legal and common, so match
on the code.
Both fields read the transaction that decides the dialog: its INVITE when it holds one, otherwise the request that opened it.
- An INVITE dialog reports its INVITE’s outcome, not the 200 to its BYE.
- So does a Call-ID that registered first and then called.
- A
REGISTERrejected403reports 403. - A
PUBLISHrefused489reports 489. - A
SUBSCRIBEreports the answer to the SUBSCRIBE, not to a later NOTIFY. - Both are absent while that request has no final response.
For registrations, signaling_diagnosis says more than the code:
final_failure.codecarries the status for any dialog.registration_failureanswers the registration question directly:
sipnab -N -I capture.pcap --json-dialogs --no-cli-print --quiet \
| jq -c 'select(.state == "Failed") |
{call_id, method,
code: (.final_status_code // .signaling_diagnosis.final_failure.code)}'
A signaling_diagnosis object sits beside diagnosis when something is wrong
with the signaling rather than the media.
- It drops out entirely when the detections found nothing.
- So a consumer that does not read the field sees a healthy dialog unchanged.
- Nine detections run over every dialog.
- Each names the messages it drew on, as indices into the dialog’s own message list:
| Field | Meaning |
|---|---|
final_failure | The dialog ended on a 4xx/5xx/6xx. Carries code, reason_phrase, and the Reason: (RFC 3326) and Warning: headers when present, which frequently hold the real cause behind a generic status code. |
auth_loop | Three or more 401/407 challenges with no 2xx. kind is credential_failure when the client answers each challenge and is re-challenged, or silent_drop when it never sends Authorization at all — different faults with different fixes. |
retransmissions | A request retransmitted with no response, identified by CSeq plus top-Via branch. Reports method, count and span_sec, because “7 INVITEs over 32 seconds” is diagnostic and “retransmissions detected” is not. icmp_cause is present only when the capture also held an ICMP error for the dialog, and carries the network’s own words for the silence — see below. |
ack_missing | A 2xx answer to an INVITE that no ACK followed, once the observation window passed RFC 3261 Timer H (32 s). Carries waited_sec and answer_transmissions — a UAS retransmits its answer until Timer H, so a count above one is the peer agreeing the ACK never arrived. |
abandoned | The dialog never reached a final response. kind separates the two cases, which are not the same claim: canceled means someone sent a CANCEL, no_final_response means the wait outlived RFC 3261 Timer C (180 s) — a statement about the capture, not a failed call. Carries elapsed_sec. |
post_dial_delay | INVITE to first provisional response over 11 s, the ITU-T E.721 Table 2 ninety-fifth-percentile target for an international connection. Carries delay_sec, the threshold_sec it exceeded, and the responded_with code that ended the wait. |
registration_failure | A REGISTER rejected (kind: rejected), or granted less time than it asked for (kind: shortened_expiry, code: 200). Carries requested_expiry_sec and granted_expiry_sec when the messages said. Kept separate from final_failure because “is this phone online?” is a different question from “why did this call fail?”. |
icmp_unreachable | An ICMP or ICMPv6 error quoting one of this dialog’s requests. Carries unreachable_endpoint (the host that did not answer), reported_by (the router or host that sent the error — a different machine), description of the ICMP cause, the raw icmp_type / icmp_code bytes, the quoted request’s method, an exact errors count, and truncated. |
source_disagreement | Present only on a run reading from TWO capture sources at once (-d with -L), and only when they told different stories about this call. A HEP mirror reports what the proxy believes it did; the local capture reports what actually left the box, which is why the pair is worth having and why the disagreement is the finding. Carries agreed (messages both witnesses carried – the denominator), mirror_only (the proxy believes it sent something that never left the box), wire_only (the box did something its own trace does not admit to) and sdp_differs (both carried it, and each advertised different media endpoints – a rewrite). Each sdp_differs entry names mirror and wire side by side and neither as the truth. |
Every finding carries an evidence array of message indices. hints carries
one plain-language line per finding.
The first seven fields are always present. null there means “checked,
nothing found”.
sipnab omits the last two when absent. Neither can run on every capture, and
a null would claim a check that never happened:
icmp_unreachableneeds a capture that holds ICMP at all.source_disagreementneeds two capture sources that both carried the call.
icmp_unreachable is worth singling out. It is the only finding that reports a
fact rather than an inference.
- The others read the SIP and reason about what the silence means.
- This one carries a router’s own statement that the far end is not reachable.
Two fields repay attention:
errorsis the exact number of ICMP errors the dialog drew. It is not the number of quotes sipnab retained.- A cap bounds retention.
evidencedraws on the retained quotes only. So on a heavily hit dialog it names fewer messages thanerrorscounts.
unreachable_endpointis the socket to go and look at.reported_byis the device that noticed and said so. That is usually a working router in the path, not the fault.
truncated is true in the ordinary case.
RFC 792 guarantees only 8 bytes
past the quoted IP header, so most quotes are a prefix of the original
datagram.
The ICMP description is the network’s wording. The hint says where each of the commonest codes sends you:
port unreachablemeans the host answered and no service holds that port. So the fault is the service.administratively prohibitedmeans a firewall or router ACL refused the packet. The peer may be perfectly healthy, and the fix is the filter.host unreachablemeans nothing reached the host at all. So the capture says nothing about its ports.
STUN evidence inside the media diagnosis
diagnosis.private_media_address is a warning. It means:
- the SDP
c=line names an RFC 1918 / RFC 4193 / link-local address, - and the peer is not itself private,
- so the far end cannot route back to it.
The SDP is correct inside one LAN, and behind an SBC, ALG or media proxy that rewrites the SDP downstream. The hint asks you to check which of those applies.
diagnosis.stun_sdp_mismatch is the evidence that settles it.
- sipnab omits it on any capture with no STUN in it.
- It is never present without
private_media_addressalso beingtrue. It corroborates that finding rather than adding a second one.
| Field | Meaning |
|---|---|
reason | ignored — STUN told the client a public address and the SDP advertised the private one anyway. relay_ignored — a TURN server allocated the client a relayed address and the SDP advertised the private one anyway. unanswered — the client’s request drew nothing, so it never learned a public address to advertise. |
client | The socket the STUN request left from, ip:port. |
server | The STUN or TURN server it went to. The box to check when the reason is unanswered. |
mapped_address / relayed_address | The reachable address the server returned and the client did not use. Absent on unanswered, where there is none. |
advertised | The unroutable address the SDP named instead. |
request_count | Requests sent for that transaction. Above one is a retransmission, which RFC 5389 section 7.2.1 sends only on timeout, so the earlier attempts drew no answer. |
observed_offset_secs | Seconds from the start of this dialog to the STUN evidence; negative when the probe came first, which is the ordinary case. Absent when either time is unknown. |
sipnab matches STUN evidence to a dialog by client IP alone, because
nothing in a Binding Request names a Call-ID. A probe from the right address
matches this dialog whether it happened during setup or an hour earlier. Read
observed_offset_secs to tell which:
- Within two minutes of the dialog, the finding is an observation of this call, and the hint adds nothing.
- Further away, the finding is an inference that the client’s NAT-discovery failure persisted, and the hint appends a note saying so.
diagnosis.media_relay is context rather than a finding. It is likewise
omitted on any capture with no TURN relay in it.
It answers “where did this call’s audio actually go”. For a relayed call nothing else in the capture answers that. Its fields:
client,server,relayed_address(the address the far end really sends to),channel,peer,lapsed.
lapsed: true is the capture-level turn_allocation_lapsed finding narrowed
to this call’s media. It is the only media_relay value that adds a
hint.
A relay_ignored or ignored finding raises private_media_address on its
own, with no observed stream needed. A public mapped or relayed address is
proof the client’s traffic reaches the internet.
unanswered does so only when the server it asked is itself public. Silence
from a STUN server on the same LAN raises nothing.
When ICMP and retransmissions both fire
retransmissions and icmp_unreachable frequently appear on the same dialog.
They are not two views of one thing.
retransmissionsmeasures how hard the sender tried before giving up.icmp_unreachablestates why nothing came back.
So the ICMP fact annotates the retransmission finding rather than replacing it.
- sipnab sets
retransmissions.icmp_causeto the ICMP description. - The hint names that cause instead of “a one-way path or an unreachable peer”.
- The
countandspan_secstay as they were.
With no ICMP in the capture, icmp_cause is absent, and the retransmission
hint names both possible causes.
The same document is what you get from:
- the REST
GET /v1/dialogs/{call_id}andGET /v1/dialogs/{call_id}/reportendpoints, - MCP tool responses (
get_dialog_reportwithformat: "json"— see the MCP tool reference), and SIPNAB_JSONin--on-dialog-exechooks.- Note the dialog hook fills
streams: []and a defaultdiagnosis. - It fires on a dialog event, when media analysis for that call may not be complete.
- So the stream and diagnosis fields are placeholders there rather than populated data.
- Note the dialog hook fills
--on-quality-exec is separate. It passes the stream object on its own, under
its own variable name: SIPNAB_STREAM_JSON, not SIPNAB_JSON.
STUN / TURN JSON (--json-stun)
NDJSON, emitted after capture:
- one object per STUN or TURN transaction,
- then one per TURN allocation.
Every line carries a record field naming its kind: transaction,
turn_allocation or ice.
sipnab -N -I capture.pcap --json-stun --no-cli-print \
| jq -c 'select(.record == "transaction" and .responded_at == null)
| {client, server, method_name, request_count}'
A transaction carries:
-
the hex
transaction_id, -
clientandserversockets, -
methodandmethod_name, -
first_request/last_request/responded_attimestamps, -
request_count, -
rtt_ms, -
and whatever the exchange produced:
mapped_address,relayed_address,peer_address,lifetime_secs,channel_number,error_code,auth_challenge,software,ice_role,use_candidateandfingerprint_valid. -
responded_at: nullis the finding: nothing answered. -
request_countabove one is a retransmission. That is itself proof the earlier attempts drew silence. -
auth_challenge: truesays the opposite. The server was reachable and asked for credentials, so nothing in the network path is at fault. -
fingerprint_validhas three values:true: present and verified,false: present and wrong,null: absent, so sipnab did not check.
A turn_allocation carries client, server, relayed_address,
lifetime_secs, allocated_at, refreshed_at, refreshes, last_activity,
released, channels, unattributed_frames, and the derived lapsed:
sipnab -N -I relay.pcap --json-stun --no-cli-print | jq 'select(.lapsed == true)'
lapsed: true means traffic was still crossing the relay after the lifetime
the server last granted had run out. The capture shows no Refresh in between.
- A TURN server tears an allocation down the moment its lifetime lapses.
- The media stops with it, mid-call, with no SIP message anywhere to explain it.
- The signaling shows a healthy call that went quiet.
A deliberate release (a Refresh with LIFETIME 0) sets released and is never
lapsed. The client asked for the teardown.
channels is what ties the relay to the media. Each entry carries:
- the
channelnumber, - the
peerthe ChannelBind named. It is absent when no bind appears in the capture. The frames still attribute, and the far side is simply not in this file. bound,frames,bytes,first_seen/last_seen,- and the
ssrcsobserved inside the frames.
Those SSRCs are the join to the stream list. sipnab unwraps ChannelData, and
the RTP inside appears in the stream list as an ordinary stream with
phone-to-relay addresses. Match its SSRC against ssrcs to see which
allocation carried it.
sipnab -N -I relay.pcap --json-stun --no-cli-print \
| jq -r 'select(.lapsed == true) | .channels[].ssrcs[]'
ssrcs counts media only. RTCP relayed on the same channel raises frames
and bytes and contributes no SSRC.
unattributed_frames counts relayed frames whose channel fell outside the
per-allocation cap.
- Non-zero means
channelsholds a sample of what crossed the allocation. - Every text surface says so when it does.
ssrcs_droppedis the same statement one level down.
An ice record appears once, and only on a capture holding ICE connectivity
checks. These are Binding Requests carrying the PRIORITY and role attributes
RFC 8445 section 7.2.1 requires.
Those attributes are what tell them from a plain server-reflexive probe to a
STUN server:
| Field | Meaning |
|---|---|
checks / checks_answered | Connectivity checks seen, and how many drew an answer of either kind. checks_answered: 0 with checks above zero means ICE never completed and the call has no media path. The individual checks are in the transaction records. |
nominated | Candidate pairs a USE-CANDIDATE check nominated and the peer confirmed. Each carries local, remote, role, priority, nominated_at and rtt_ms. This is the ICE analogue of mapped_address: it names the path the media actually took. |
role_conflicts | Pairs where both agents claimed one role, or where one answered 487 Role Conflict. Each carries a, b, the role both claimed, role_conflict_responses, and resolved. |
nominated_total / role_conflicts_total | Exact counts, which stay right past the retention cap that bounds the two lists above. |
resolved: truemeans the agents nominated a pair between them anyway.- ICE fixed the conflict itself.
- The cost was one round trip of repeated checks.
resolved: falsemeans they never nominated anything between them.- That makes the conflict a candidate cause of media that never started.
Capture analysis (--json-analyze)
One JSON object for the whole run, not a line per finding. A clean capture
still produces the object, with frames_read, dialogs_examined and
streams_examined.
The object opens with schema_version (currently 1).
tests/schemas/capture_analysis.schema.json
describes it field by field. The same object is the GET /v1/report body and
the MCP get_capture_report answer.
sipnab -N -I capture.pcap --json-analyze --no-cli-print \
| jq '.findings[] | select(.severity == "critical") | {kind, occurrences}'
findings ranks worst first. Each carries:
- a stable
kind, - a
severity, - an exact
occurrencescount in itsunit, - an
evidencearray pointing back at the capture with Call-IDs, endpoints, timestamps and counts, evidence_omitted, the rows a cap of ten kept out.
A finding has no summary field. The title and the one-sentence explanation
belong to the kind. So the text report prints them, and the JSON names the
kind.
filter appears only when --filter or a diagnostic alias narrowed the
dialogs. It holds the expression that ran, after alias expansion. So
--filter one-way records one_way == true.
The filter narrows the dialogs and nothing else.
- STUN probes, ICMP that reached no dialog, undecodable frames and retention losses are capture-level.
- The analysis always reports them whole.
complete is the field to read first. It is false when the run did not
decode everything it received:
- undecodable frames,
- SIP a port gate discarded,
- records a retention cap dropped.
Then every count below it is a floor, and the analysis gives no clean verdict. Read it first:
sipnab -N -I capture.pcap --json-analyze --no-cli-print | jq '.complete'
--analyze derives nothing new. --analyze aggregates the per-dialog diagnosis
sipnab already computes and the capture-level evidence it already holds. It
ranks and counts them, and adds no judgement of its own.
YANG export (--yang-analyze)
The capture analysis again, encoded as
RFC 7951 JSON against sipnab’s
own YANG 1.1 module, sipnab-diagnosis. It is the same value --json-analyze
prints. A tool that speaks YANG can do these things without learning sipnab’s
JSON first:
- validate it,
- draw its tree with
pyang -f tree, - generate code for it.
sipnab -N -I capture.pcap --yang-analyze --no-cli-print > analysis.json
To check it, save the module this sipnab writes against. Then hand both to
yanglint:
sipnab --print-yang-module > sipnab-diagnosis@2026-10-09.yang
yanglint -t data sipnab-diagnosis@2026-10-09.yang analysis.json
--print-yang-module prints the module this sipnab validates against. The
repository commits the same file at
yang/sipnab-diagnosis@2026-10-09.yang.
The document has one member, sipnab-diagnosis:capture-analysis. It differs
from the --json-analyze object only in how it writes the same facts:
--json-analyze | --yang-analyze | Why |
|---|---|---|
frames_read | frames-read | YANG names use hyphens. Every field follows the same rule. |
"frames_read": 1024 | "frames-read": "1024" | RFC 7951 section 6.1 writes every 64-bit integer as a string: frames-read, dialogs-examined, streams-examined, occurrences, evidence-omitted, and each count’s value. schema-version, rank and index stay numbers. |
findings in ranked order | finding, with a rank counting from 1 | YANG gives state data no order, so the position becomes a leaf. |
evidence in order | evidence, each with an index counting from 1 | The same reason. |
endpoints | endpoint | YANG names a leaf-list for one entry. |
counts: {"rtp_packets": 425} | count: [{"name": "rtp_packets", "value": "425"}] | A map becomes a list keyed by name. |
[] for no findings | no finding member | A YANG list has no empty form. |
kind keeps the same strings in both encodings, such as one_way_audio.
- Each finding kind and each count label is an identity in the module.
- Its description is the kind’s title and one-sentence explanation.
- That makes the module a catalog of everything the analysis can report.
A string from the capture may hold a character YANG cannot carry, such as a control character in a reason phrase. sipnab then writes U+FFFD in its place. That substitution is the only change to the text itself.
The module does not model the per-dialog diagnosis and
signaling_diagnosis objects. Read those from --json-dialogs.
The REST API serves the same document at GET /v1/report?format=yang-json as
application/yang-data+json. The MCP tool get_capture_report answers it for
{"format": "yang-json"}.
sipnab does not run a NETCONF or RESTCONF server. It writes documents that conform to the module.
vCon (--export-vcon)
A different kind of output from everything above.
- The formats on this page are sipnab’s own shapes. sipnab reads them, and so does whatever you write around it.
- A vCon is an interchange container other people’s systems already parse. Examples: a conversation archive, a compliance store, an agent that reasons over calls.
sipnab -N -I call.pcap --export-vcon 'CALL-ID' --vcon-out out.json
One dialog per container, never a whole capture. sipnab writes it as an OBSERVER:
- it names itself as a party that took no part in the call,
- it carries the audio inline when the run retained the RTP payload,
- it states in two places what the capture missed.
--export-vcon writes the container unsigned. A [[vcon.rule]] can sign it,
with or without encryption. See Sign without encrypting
and Encrypt what a rule writes.
It needs the non-default vcon feature, which full includes. A build
without it exits with code 2:
--export-vcon needs the 'vcon' Cargo feature, which this build does not carry. Rebuild with --features vcon (or --features full); `sipnab --version` lists the features a binary was built with
The vCon page covers what a consumer may and may not conclude from one.
pcap / pcapng
-O <file> writes captured packets, and --pcapng selects PCAP-NG.
With TLS decryption, --pcap-export-mode controls whether decryption secrets
(DSBs) travel with the file for Wireshark:
- The default
rawmode embeds no keys. encrypted+dsbopts into key-bearing PCAP-NG.decryptedwrites what sipnab decrypted as plaintext SIP and RTP, with no keys, and everything else as captured.
Examples shows it. Rotation: --split filesize:N /
--split duration:N, or SIGUSR1 on demand.
pcapng timestamps are nanosecond-resolution. sipnab declares this with
if_tsresol=9 in the Interface Description Block.
Repair a pcapng written by sipnab 0.5.0 or earlier
Files written by sipnab 0.5.0 and earlier store nanosecond timestamps without
that declaration, so other readers show every time 1000 times too large.
scripts/repair_pcapng_tsresol.py
adds the declaration in place and does not change packet data. It repairs
only sections that identify as written by sipnab, unless you pass --force,
and keeps the original as <file>.bak unless you pass --no-backup.
Check first, changing nothing:
python3 scripts/repair_pcapng_tsresol.py --dry-run calls.pcapng
A file that already declares the resolution reports:
calls.pcapng: already correct, nothing to do
Then repair it:
python3 scripts/repair_pcapng_tsresol.py calls.pcapng
Operator notes file
The notes --write-annotated writes into a capture, one JSON object per line:
{"frame":"call.pcap#0@bdb6cdb98013efe4","note":"the INVITE the SBC answered twice"}
{"frame":"call.pcap#4@0b1c8f3e5a7d2946","note":"183 here, and the SDP changed"}
framenames the frame the note is about, as a frame pointer. It is theframefield of--json, copied as it is.--write-annotatedrequires the@<digest>half. Only the digest proves a note lands on the frame it describes.- It refuses a byte-range suffix (
+start-end). A note is about a whole frame.
noteis the text.- At most 4096 bytes of UTF-8.
- No control character other than newline and tab.
- No SDES
inline:key, TLS key-log line or digestresponse=value. The capture leaves the machine, and a key pasted into a note would travel in clear.
sipnab accepts no other field. One bad line refuses the whole file. The error names the line without quoting the note.
A notes file sipnab writes has mode 0600, because it holds free text about a
call. The schema is
tests/schemas/notes.schema.json.
In the annotated pcapng, each note is a packet comment that starts
[operator note] . The section comment says how many comments are notes typed
by a person.
sipnab writes packet comments and never reads one. --json over the annotated
copy is what it is over the original.
See also
- cli-reference.md — every output flag
- examples.md — NDJSON-to-jq pipeline recipes