MCP Tools - NormB/sipnab GitHub Wiki
Every tool the MCP server exposes, what it answers, and what it returns.
This is lookup material, not reading material.
- MCP server β what MCP is, and a first working example.
- MCP deployment β the deployment shapes.
- MCP protocol β the wire contract, security model and error semantics.
The v0.5 sipnab MCP tool surface. No tool edits the analysis in place, and
every response carries a ceiling. That ceiling defaults to 1000 rows and is an
operator setting, not a build-time fact: --mcp-max-rows N, or [limits] mcp_max_rows in the config file, with the flag winning. The right value
belongs to the consumer β a model with a small context window wants far fewer,
a batch client piping to a file wants far more. Note this is a DIFFERENT limit
from dialog_limit, which bounds dialogs tracked over the whole run and
defaults 100x higher. One tool replaces
the analysis outright β open_capture, off unless you enable it β and it mints
a new capture identity so the replacement cannot reach a consumer as an
ordinary update.
Survey β what is in this capture
| Tool | Parameters | Returns |
|---|---|---|
capture_status |
-- | What this server captures: live or file, uptime, and whether stopping loses unsaved packets |
runtime_stats |
sample_seconds? |
What sipnab is doing and what it costs the host: its own memory, threads, descriptors and CPU; the host's totals and the basis they came from; its share of them and whether that share is load-bearing; per-interface counters; store occupancy against the caps |
capture_health |
sample_seconds |
Capture-path counters read twice: run totals, deltas across the window, undecoded_fraction, and undecodable frames by reason |
reconcile_orphans |
limit? |
Why each RTP stream with no dialog lacks one: a relay named the endpoint but no signaling arrived, SDP named it but no dialog claims it, or nothing named it at all |
get_capture_report |
format? |
Whole-capture analysis: findings, orphaned media, STUN/ICMP evidence, what the caps shed |
list_captures |
-- | Capture files in --mcp-file-root, with sizes |
find_in_captures |
filter, max_files?, deadline_ms?
|
Which capture files hold dialogs matching a filter, without opening any of them |
list_dialogs |
filter?, limit?, cursor?
|
A page of dialog summaries, with the total behind it |
timeline |
bucket_seconds? |
Call volume per fixed-width interval, so a gap or a spike is visible without reading every dialog |
top_talkers |
by, limit?, filter?, prefix_digits?
|
The busiest IPs, user agents or dialled prefixes, ranked, each share stated against the population behind it |
aggregate_dialogs |
group_by, filter?, top_n?
|
Counts dialogs grouped by ONE field, in the store rather than in the model |
group_dialogs |
by, metrics?, filter?, top_n?
|
Carrier metrics per group β ASR, NER, ACD, post-dial delay, MOS, retransmissions β each beside the population it rests on |
server_capabilities |
-- | sipnab version and the optional features this binary carries |
Find β narrow to the calls that matter
| Tool | Parameters | Returns |
|---|---|---|
find_problems |
kinds?, filter?, limit?, cursor?
|
A page of dialogs matching one or more diagnostic alias names |
search_messages |
query, limit?, cursor?
|
A page of substring matches across method/From/To/UA/body, with the total behind it |
search_by_time |
start, end?, filter?, limit?, cursor?
|
Dialogs whose first message falls in an RFC 3339 window |
validate_filter |
expr |
Whether a filter DSL expression parses, the parser's message when it does not, and how many dialogs it selects β with no rows |
describe_endpoint |
ip?, user?, limit?
|
Everything one participant did: dialogs by method and state, INVITE outcomes, REGISTER state, banners, streams, findings |
tail_dialogs |
cursor?, limit?
|
Cursor-based incremental dialog fetch |
await_condition |
filter, timeout_seconds?, poll_interval_ms?
|
Waits until a filter matches or a deadline passes, whichever is first, so a live capture costs one call instead of a polling loop |
find_correlated |
call_id, limit?
|
The other legs of the same call across a B2BUA, each with a score AND the strategy that matched it |
get_call_tree |
call_id, limit?
|
Every leg of one call as a tree, each edge carrying its parent, depth, score, strategy and whether the walk went through it |
compare_dialogs |
call_id_a, call_id_b
|
Two calls side by side, with the differences named |
compare_captures |
a, b, dimensions?, top_n?
|
Diffs two capture files in --mcp-file-root by aggregate, ranked by how far each bucket MOVED. Neither becomes the loaded capture |
| Tool | Parameters | Returns |
|---|---|---|
triage_call |
call_id |
First-pass verdict: signaling problem, media problem, both, or none, with evidence |
get_dialog |
call_id, max_messages?, cursor?
|
Paginated dialog with full SIP messages |
get_dialog_report |
call_id, format?
|
Structured per-call report (JSON / Markdown / text) |
get_message |
call_id, index
|
Single SIP message at a given index |
render_ladder |
call_id, format?
|
Call-flow ladder (Markdown / text) |
get_sdp_timeline |
call_id |
SDP offer/answer exchanges in order: codecs, ptime, direction |
check_codec_negotiation |
call_id |
Codecs offered vs answered and whether they intersect β for 488s |
diagnose_registration |
call_id |
Whether an endpoint registered, hit a rejection, is looping on auth, or got a short expiry |
media_diagnostics |
call_id |
The facts under the MOS: QoS marking, jitter grounding, delay provenance, silence, and what the far end reported |
siprec_metadata |
call_id |
The SIPREC (RFC 7866) recording metadata a call carried: session id, mode, participants with AOR and display name, and the recorded streams, each naming its m= line label and the participant that sends it |
rtp_stats |
call_id?, min_mos?, max_mos?, limit?, cursor?
|
One call's RTP quality and diagnosis, or a capture-wide stream sweep |
| Tool | Parameters | Returns |
|---|---|---|
lint_dialog |
call_id, rulesets?, severity_min?, suppression_file?
|
Conformance findings for one call, declaration against observation included, each with its RFC and section |
validate_message |
call_id, index, suppression_file?
|
Conformance findings for one message, read alone |
explain_rule |
rule_id |
The catalog entry behind one rule identifier: citation, basis, scope, selectors |
explain_response_code |
code |
IANA registry meaning and class for a SIP status code |
evaluate_expectations |
rules?, rules_toml?, suppression_file?
|
A pass/fail verdict per rule plus an exit code for a build. A rule whose population is empty fails rather than passing quietly |
The peer this needs. These read TFPS through its
tfps_ctlprogram in JSON mode, the--jsonflag. TFPS gained that mode in sippulse/tfps#6, merged on 2026-09-18, and no tagged release carries it yet: v0.2.1, the newest, rejects--json. Until the next release, build TFPS from itsmasterbranch. To check thetfps_ctlyou have, runtfps_ctl status --json. One line of JSON means it is ready, andunknown option: --jsonmeans it predates the mode. Against an oldertfps_ctlthese answer with that error and name what to install.
tfps_droppedis the exception: it needs adroppedsubcommand that no TFPS build has, released or onmaster, so it answers with TFPS's ownunknown command: droppedand says so.
| Tool | Parameters | Returns |
|---|---|---|
security_findings |
kinds?, since?, limit?
|
Recent scanner / fraud / digest / reg_flood findings, plus the detectors this server runs |
generate_fail2ban_rule |
finding_id |
A fail2ban filter and jail derived from ONE recorded finding, with that finding attached as the evidence |
tfps_status |
-- | Whether the toll-fraud prevention peer (TFPS) runs on this host, and what it reports: enforcement, mode, interface, sources blocked now, database, version |
tfps_banned |
-- | Every source TFPS holds condemned right now, with the rule, what it saw, and when the ban began and lapses |
tfps_dropped |
-- | Per condemned source, what the enforcement dropped and the last request line the source sent |
tfps_labels |
limit? |
The TFPS verdict log -- blocked, would-block, exempt -- the export the label corpus harness scores the scanner detector against |
tfps_ban |
ip, ttl_secs?
|
Write. Relays an OPERATOR's decision to condemn a source and reports the TFPS answer as given, refusal included |
tfps_unban |
ip |
Write. Relays an operator's decision to release a source |
| Tool | Parameters | Returns |
|---|---|---|
explain_attribution |
call_id |
Where each of a call's media endpoints came from and how much that path is worth β asked directly, HMAC-verified, plain secret, port-gated only, or the parties' own SDP |
show_evidence |
refs, max_bytes?
|
Follows frame pointers back to the captured bytes: verified, unverified, or unresolvable with a reason |
decode_evidence |
frame_ref, field?
|
Decodes the frame one pointer names: link type, addressing, and each SIP header's byte range inside the message and the frame |
decode_ng |
frame_ref |
Decodes the relay control message one pointer names: the command, the call, whether it carries SDP, and which delivery path carried it |
build_evidence_package |
call_ids, filename
|
Write. One directory of escalation artifacts in --mcp-file-root: pcapng, ladder and RTP stats per call, manifest, and the rebuilt-frames README |
save_findings |
summary, call_id?, detail?
|
Write. Records the agent's conclusion to sipnab's log. Needs --mcp-allow-save-findings; no tool reads it back |
| Tool | Parameters | Returns |
|---|---|---|
export_capture |
filename |
Writes held SIP signaling to a pcap in --mcp-file-root (re-synthesized frames, no RTP) |
export_audio |
call_id, filename
|
Writes a call's RTP audio to a WAV in --mcp-file-root; needs the server started with --retain-audio
|
export_vcon |
call_id?, filter?, limit?
|
Dialogs as vCon conversation containers, structured JSON, each with its SHA-256. One call_id or a whole filtered set. Unsigned, retained audio inline, and every omission stated in the response |
validate_vcon |
call_id?, container?
|
Checks a container against the schema sipnab vendors and names the one documented deviation instead of passing it |
generate_repro |
call_id, format?, pin?, vary?, filename?
|
A SIPp scenario replaying one call, with the hypothesis as an input: pin holds the suspected cause fixed, vary regenerates identity |
generate_wireshark_filter |
call_id, include_media?
|
A Wireshark display filter selecting one call's signaling and its RTP by SSRC, plus the tshark line that applies it |
Capture control (opt-in, off by default)
| Tool | Parameters | Returns |
|---|---|---|
query_relay |
call_id?, max_calls?
|
Transmits. Asks the configured relay what it holds right now. Needs --mcp-allow-relay-query and a live source; the destination comes from operator configuration only |
relay_stats |
call_id?, names_only?
|
Transmits. Asks the configured relay for its own counters (global, per-call, or the names it knows), tiered relay_reported. Same opt-in and live-source gate as query_relay
|
relay_compare |
call_id |
Transmits. Sets the relay's per-call RTP count beside sipnab's measured count, both tiers named and a word verdict, never a sum. Same gate as query_relay
|
open_capture |
filename |
Destructive. Replaces every dialog and stream with another capture from --mcp-file-root. Needs --mcp-allow-open-capture; loads in the background |
shutdown_server |
dry_run?, save_to?, discard_unsaved?
|
Destructive. Stops the process. Needs --mcp-allow-shutdown; dry-run by default |
start_tls_capture |
flavors, libraries
|
installs kernel uprobes and reads SIP plaintext with no key; needs --mcp-allow-tls-capture
|
stop_tls_capture |
-- | stops that capture and removes its kernel probes |
list_tls_libraries |
-- | which TLS libraries this host runs, and whether sipnab could read their plaintext without keys |
Six rules hold across the whole surface. Each tool section below states only what it does differently, so read these once and skip them afterwards.
A required parameter has no default. Leave one out, or send the wrong JSON
type, and the call fails with invalid_params (-32602) before the tool runs.
Optional parameters carry a ? in every table on this page, and each row says
what the tool does when you omit that one.
A limit of 0 means the default, not "no rows". limit defaults to 50
everywhere it appears. The server clamps anything larger to --mcp-max-rows
(1000 unless the operator lowered it) and returns the clamped page without an
error. Both edges bite the same caller: a client that computes limit from
"rows I still want" asks for 50 when it meant to ask for none, and a client
that asks for 5000 to "get everything" receives 1000 and no complaint. Read
returned rather than the length you asked for.
Pass a cursor back exactly as it arrived. next_cursor pairs a timestamp
with an identity β <RFC 3339>|<Call-ID> for dialogs, and
<RFC 3339>|0xSSRC@src>dst for the rtp_stats sweep. A hand-rebuilt bare
timestamp still parses, so rebuilding one costs rows instead of raising an
error. next_cursor: null marks the final page.
Every list-style tool answers with a page object, never a bare array. The
rows sit under a named key β dialogs, hits, streams, findings β beside
returned and total_matched, so counting the rows is never necessary and
never right. search_messages and security_findings carry the
page fields too. tail_dialogs is the one page object with no
total, because a tail cannot have one. Response
bounding tabulates it.
Every answer says how much of the capture is behind it. Two booleans ride
on every response object: source_exhausted, true once sipnab has read the
source to its end, and source_stopped_early, true when a read ended before the
source did β a truncated dump file, a file that would not open, a read error
part-way through. The answer is the WHOLE answer only when source_exhausted is
true and source_stopped_early is false, and one response tells you that much,
so nothing has to call capture_status to interpret it.
Until then the response omits truncated: false entirely. That value claims
outright that the page holds every match, and a caller reading only that field
deserves no claim rather than a wrong one β JSON absence and false are
different values. The response still says truncated: true whenever the row cap
really did keep matches out, because that fact holds whatever the load state.
complete, where a tool has one, likewise never reads true over a partial
read.
A file source loads on a background thread, so an agent's first call lands
inside a window a human client never sees: on a 921 MB capture, list_dialogs
answered with 6 of 18,241 dialogs. tail_dialogs and capture_status have
always carried source_exhausted. Now every tool that answers from the capture
does. Seventeen tools are the exception, because their answer cannot move with
the load. Nine read something other than the capture store β
explain_response_code, explain_rule, decode_evidence, decode_ng,
show_evidence, list_captures, list_tls_libraries, server_capabilities
and compare_captures, which reads two files and never the loaded capture.
The other eight ask another process: query_relay and relay_stats put the
question to the media relay, and tfps_status, tfps_banned, tfps_dropped,
tfps_labels, tfps_ban and tfps_unban put it to the toll-fraud prevention
peer. How much
of the capture sipnab has read says nothing about what those hold. The
tools that answer with a rendered document β render_ladder, and
get_capture_report / get_dialog_report in markdown and text β have no
object to put a field in, so they say it in prose instead: a document drawn
over a capture that is still loading, or over one whose read stopped before its
end, ends with the same INCOMPLETE RUN block --report appends, naming each
reason. A document drawn over a capture read in full says nothing extra,
because a caveat on every answer is a caveat nobody reads.
Every other tool answers with a JSON object, so the two booleans sit in the
object beside the tool's own fields β timeline was the last one
returning a bare array, and it now wraps its rows in an object for exactly this
reason.
Capture text arrives fenced, and identifiers do not. Free text an endpoint
wrote β display names, User-Agent, SDP, whole messages β comes wrapped in
β¦untrusted-capture-dataβ§ β¦ β¦/untrusted-capture-dataβ§, and the tools that
emit it append a provenance note as the LAST content block. Call-IDs, cursors,
addresses and timestamps stay verbatim so they pass straight into the next
call. Untrusted capture text gives the per-tool
breakdown. Every JSON sample below shows the markers where the server really
emits them.
Each tool section names the capture its example ran against. Start a server on that capture, then call the tool. These two cover most of the page:
# The paging examples: 1334 dialogs, no RTP.
sipnab -N --mcp --mcp-transport http --mcp-bind 127.0.0.1:8731 \
--mcp-file-root tests/pcap-samples \
-I tests/pcap-samples/sipp-branch-scenario.pcapng# The media examples: one completed call, two RTP streams.
sipnab -N --mcp --mcp-transport http --mcp-bind 127.0.0.1:8731 \
--mcp-file-root tests/pcap-samples \
-I tests/pcap-samples/sip-rtp-g711.pcapDrive either one with the raw HTTP test on the deployment page, or point
a client at http://127.0.0.1:8731/mcp. A loopback bind needs no token.
Numbers in the samples are what those captures produce. Jitter and MOS come out byte-identical run to run, because a file replay reads packet timestamps rather than arrival times, so a value that fails to match points at a real change rather than at timing noise.
Start here when you have a capture and no particular suspect. These answer "what is in it", "how much", and "is the server even seeing traffic".
Ask this first. It answers what the server is actually attached to β a live interface or a replayed file β which nothing else on this surface reveals.
No parameters. Returns:
unsaved is the field that matters. It is true only for a live capture
with no output file β packets held in memory and nowhere else. A file replay is
already on disk, so it is never unsaved.
source: "unknown" means nobody gave the server capture context. It
reports that rather than guessing, because a wrong "live" would be worse than
an admission of ignorance.
capture_identity says which capture this is and how many times its stores have
changed. Compare it across calls: a higher generation on the same instance means
the capture grew, and a different instance means open_capture loaded a
different file and every cursor you hold is void. The same object appears on
capture_status, list_dialogs, find_problems, tail_dialogs, find_correlated,
save_findings, open_capture and the capture-wide rtp_stats sweep. node names
the box that answered, which decides whose capture a fact came from once an agent
holds several servers at once.
load is null except while an open_capture read runs. During one:
"load": {
"filename": "outage-0722.pcap",
"instance": "1f4a17c8e2b91d40-2",
"packets": 184320, // climbing
"elapsed_sec": 4,
"done": false,
"error": null // set when a load stopped early
}The stores fill as the read goes, so dialogs appear before done. Wait for
done before concluding anything about how many calls the capture holds β a
partial answer looks exactly like a complete one.
Read the two unanalysed_ pairs before reading anything into dialog_count.
They report SIP that sipnab recognized and did not analyze, and they are the
only way to tell a capture that holds no calls from one whose calls fell
outside a port setting. They count different losses with different
remedies, so a non-zero figure names its own flag:
-
unanalysed_sip_messages/unanalysed_busiest_portsβ plain SIP signaling with both ports outside--portrange. Re-run with a range that covers the ports listed. -
unanalysed_websocket_messages/unanalysed_websocket_portsβ SIP-over-WebSocket (RFC 7118) on a port outside the WebSocket set. Re-run with--ws-portrangecovering the ports listed; widening--portrangerecovers none of it. This is the common case on a WSS listener behind a reverse proxy, and on Kamailio, OpenSIPS and Janus, which all default outside sipnab's shipped 80/443/8080/8443.
Both are zero on a live capture, where BPF filtered before the pipeline saw anything and there is nothing to under-report.
Asterisk's Manager Interface speaks a line protocol on TCP/5038, and a login is
four lines of plain text: Action: login, Username:, Secret:. AMI can
originate calls, read configuration and run shell commands, so the credential
is worth strictly more than the SIP digest --digest-leak already reports.
Reported here because this is where the misleading zero was.
unanalysed_sip_messages: 0 was the complete answer for a capture full of AMI,
and the zero is honest β AMI is not SIP, so there was no unparsed SIP message
to count. Every detector sipnab had ran on parsed SIP messages, and this
traffic never became one.
| Field | Type | Description |
|---|---|---|
observation |
object |
banner (the server greeted a client) or cleartext-login
|
observation.username |
string? | The account named, bounded to MAX_AMI_VALUE_CHARS (64). Echoed on purpose: an operator can act on a finding that names the exposed account, and cannot act on one saying "an account" |
observation.secret_present |
bool | Whether a Secret: line was there. A boolean, never the value
|
src / dst / dst_port
|
string / string / u16 | Who, to whom, on what port |
default_port |
bool | Whether dst_port is 5038 |
count |
u64 | Payloads carrying this observation between this pair |
The secret is never recorded. sipnab writes findings to logs, exports
them in containers and hands them to an agent's context, so the Secret: line
becomes the fact that a credential crossed the wire and nothing more.
Not a decoder. It matches a banner and a login line and records where they were. No session tracking, no response following, nothing else the protocol carries.
The port is an observation, not a filter. An interface moved off 5038 is
still reported, with default_port: false β a deployment that moved the
service is the one most likely to think nobody can find it, and a rule keyed on
the default would agree.
No arming needed, unlike the detectors behind
security_findings. It costs two literal prefix tests on
payloads that have already failed the SIP check, so there is no cost to gate
against. A login with an AuthType: MD5 challenge and no Secret: line
reports secret_present: false, so it never counts as a cleartext password.
Sightings aggregate per address pair, so one session reconnecting in a loop is one row with a count. The account is deliberately outside that key: this path runs before authentication, so the field is attacker-chosen.
Always present, and empty when there is nothing to report. sipnab omits
every other optional block on this surface because it may never have run the
check at all. It runs this one on every capture, so [] means "looked, found
none" β a stronger answer than a missing key.
capture_quality above counts what the capture lost. caveats counts what
sipnab chose not to do, which is a different thing and is not a defect.
| Key | What it counts |
|---|---|
media_creating_commands |
rtpengine subscribe, publish and start recording commands seen and deliberately not attributed |
tls |
TLS decryption state β absent unless a decryptor ran |
Those commands create media belonging to a call without being one of its two legs. Decoding one as an ordinary leg would make a two-party call report three streams, after which the analysis that judges one-way audio and asymmetry answers a question nobody asked β so sipnab counts them instead.
Read this before reasoning about a stream count. A call with a recording
fork has media on the wire that no rtp_stats row explains, and this number is
how you know that is a decision rather than a gap. Zero is a real
answer and the key is always present.
Absent when nobody supplied keys, which is not a decryption failure. When a
decryptor ran, the block carries keylog_entries, sessions_with_keys,
app_data_records, decrypted_records, undecrypted_records,
late_recovered, late_evicted and read_nothing.
Act on read_nothing, never on the arithmetic. A TLS handshake carries
records sipnab never loads keys for, so app_data_records > decrypted_records
is not by itself a failure β an agent deriving a verdict from the two counts
reports a working capture as broken and sends an operator after keys that
already work. read_nothing is true only when application data arrived and
none of it opened.
That case is why the block exists. A capture holding ciphertext nobody can open
produces a dialog listing identical to one from a quiet network. If you are
about to answer "there was no SIP on this capture", check this field first:
server_capabilities.can_decrypt says whether the BUILD can decrypt, and this
says whether this RUN did.
GET /v1/stats carries the same block under the same name.
What sipnab is doing, and what it is costing the host it runs on.
sipnab exports 32 Prometheus metrics, and the listener that serves them is off by default β so on most deployments those numbers exist inside the process and nothing can read them. An agent asked "is this server healthy" could not enable a listener to find out. This answers without one.
Starts no capture. Every field below answers instantly. The one optional
parameter, sample_seconds, buys a rate at the cost of a wait that long.
// runtime_stats {}
{
"schema_version": 1,
"process": {
"rss_bytes": 36831232, "virtual_bytes": 1276391424,
"threads": 3, "open_fds": 10, "cpu_seconds": 0.01
},
"host": {
"memory_total_bytes": 131881889792,
"memory_available_bytes": 121499242496,
"cpus": 14, "basis": "host"
},
"impact": {
"memory_pct": 0.0279, "significant": false,
"note": "sipnab holds 0.0% of the host memory total; the threshold for load-bearing is 10.0%"
},
"interfaces": [],
"dialogs": { "used": 1, "capacity": 100000, "pct": 0.001 },
"streams": { "used": 2, "capacity": 10000, "pct": 0.02 },
"capture_packets_total": 852,
"capture_queue_depth_packets": 0,
"capture_backpressure_blocks_total": 0,
"uptime_seconds": 41
}An absent field means "not readable here", never zero. Every value under
process and host is optional, because the sources are platform-specific. A
field reported as 0 on a platform where it was never read is worse than one
that says it does not know.
host.basis names the denominator. Inside a container the real limits are
the control group's limits rather than the machine's, so the basis reads
cgroup and the
totals come from memory.max. A percentage against the wrong total is worse
than no percentage, because a reader believes it.
impact.significant is a verdict. A capture that is itself the reason a
proxy started dropping calls is the worst failure this tool can have, and it
used to be invisible. note carries the threshold, so you can disagree with the setting rather
than with the finding.
interfaces reads the interface, not the capture handle. sipnab's own
counters describe what reached sipnab. These describe what reached the NIC. The
pair separates the two remedies β ps_ifdrop climbing with rx_missed_errors
is hardware that cannot keep up, and the fix is ring size, coalescing or RSS.
ps_drop climbing alone is sipnab's read loop falling behind, and the fix is
--buffer or a tighter filter. The handle counter alone cannot tell you
which.
The capture-queue fields are absent without a capture. A run replaying a
file through the API owns no capture meter, so capture_queue_depth_packets
and capture_backpressure_blocks_total do not appear at all, rather than
appearing as 0.
Zero there would read as "the queue is clear", which is the one thing a
saturated pipeline must never say.
Occupancy, not just counts. dialogs.used alone is a number. Beside
capacity it is a decision. An operator who cannot see occupancy learns about
eviction by noticing that calls have gone missing.
Rates cost a wait, so ask for one explicitly. Every counter above is
cumulative, and "1,284,301 messages" answers a different question from "312
messages/second, of which 190 are OPTIONS". Send sample_seconds to read the
counters twice across that window:
runtime_stats { "sample_seconds": 5 }
// ... the same envelope, plus:
{
"rates": {
"window_seconds": 5,
"packets_per_second": 412.6,
"calls_per_second": 3.4,
"calls_per_second_by_method": [["OPTIONS", 2.8], ["INVITE", 0.6]]
}
}The breakdown is the point: a rate that does not separate INVITE from OPTIONS describes whatever the deployment does most, which in the field is the keepalive plane rather than the calls.
window_seconds is the window sipnab applied. sipnab caps a long window at
30 seconds so one tool call cannot outlive the client's own deadline, and
rejects sample_seconds: 0 rather than answering it β an empty window returns
zero deltas, and zero deltas is exactly what a healthy quiet capture reports.
The REST endpoint GET /v1/runtime returns the same envelope from the same
derivation β including the same refusal and the same cap β so the two surfaces
cannot disagree about one process.
Reads the capture counters, waits, and reads them again. The response carries the run totals and the change across that window, which turns a pile of monotonic counters into a rate.
sample_seconds is required β this tool has no default window, because a
rate without a stated interval is not a rate. The call blocks for that long:
capture_health { "sample_seconds": 1 }capture_status tells you what the counters say right now. capture_health tells you
what they did over a window you chose, which is the difference between "this
process has dropped 4 million packets since Tuesday" and "this process is
dropping packets now".
Three questions it answers on a busy production server:
-
Does the capture path drop packets under load? Read
in_window.kernel_droppedandin_window.interface_dropped. They stay apart because their fixes disagree β a bigger ring buffer cures the first and does nothing for the second. -
What is on this wire that sipnab cannot decode? Read
undecodable_by_reason. Each entry names the reason as a code and carries the number that identifies it: the link type, the EtherType, or the IP protocol. -
What does the encapsulation-aware capture filter cost? Run the same
window twice, once with
--capture-tunnelsand once without, and comparein_window.packetsagainst the two drop counters.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
sample_seconds |
u32 | 1 to 30. A larger value clamps to 30, and window.requested_seconds beside window.applied_seconds reports both. Zero fails with sample_seconds must be at least 1. |
Required β the call fails. This tool is the one place with no default, because a rate without a stated interval is not a rate. |
The call blocks for the whole window, so a client with a short request timeout should ask for a few seconds rather than 30.
Returns:
{
"schema_version": 1,
"attachment": 2,
"window": {
"requested_seconds": 10,
"applied_seconds": 10,
"observed_ms": 10003
},
"totals": {
"packets": 8412990,
"kernel_dropped": 1204,
"interface_dropped": 0,
"invalid_timestamps": 0,
"undecodable_frames": 2103247
},
"in_window": {
"packets": 94318,
"kernel_dropped": 17,
"interface_dropped": 0,
"invalid_timestamps": 0,
"undecodable_frames": 23610
},
"undecoded_fraction": 0.24999999,
"undecoded_fraction_in_window": 0.2503,
"undecodable_by_reason": [
{ "reason": 2, "number": 34887, "frames": 2061109, "frames_in_window": 23140 },
{ "reason": 3, "number": 47, "frames": 41022, "frames_in_window": 465 },
{ "reason": 4, "number": null, "frames": 1116, "frames_in_window": 5 }
],
"undecodable_reasons_dropped": 0,
"dialogs_tracked": 2411,
"streams_tracked": 4802,
"clock": {
"synchronized": true,
"max_error_us": 238000,
"est_error_us": 0,
"available": true
},
"source_exhausted": true,
"source_stopped_early": false
}source_stopped_early answers the question this tool usually gets: did the
whole capture arrive? It reads true when a file's read ended before the file
did β libpcap error: truncated dump file, a file that would not open, a read
that hit an error part-way through β which is the normal state of a ring
buffer's newest member and otherwise stays invisible here. Until this field
landed, that condition reached stderr as 0 of 1 file(s) read in full, 1 stopped early and reached this response not at all, so an agent asking whether a
capture was sound got no answer either way. Both fields are booleans, so the response type stays
free of strings.
This tool starts no capture. With
--mcpattached to a live interface, the counters already accumulate, so a rate costs two reads and a wait. That is not only the cheap design, it is the safe one: the handler opens no device, names no interface, and writes no file, so no path leads from an MCP call to a capture that transmits or records anything.
No value in this response is a string. The response type holds integers, codes, two proportions and the clock's booleans, and it has no string field anywhere in it or in anything nested inside it. A type that cannot represent packet content cannot leak packet content, which is why the reasons below travel as codes and their labels live on this page instead of on the wire. The test
a_populated_capture_health_response_carries_no_string_value_anywhereserializes a full response and fails on any string value at any depth.
| Code | Meaning |
|---|---|
1 |
Nothing attached. No capture context reached this server. |
2 |
A live interface. |
3 |
A capture file replaying. |
Code 1 exists so that a server with nothing to read says so. A row of zeros
from a tool that never had a capture looks exactly like a healthy quiet wire,
and no code is 0, so a defaulted or truncated response can never pass for a
real answer.
reason |
Meaning | What number carries |
Where to start |
|---|---|---|---|
1 |
The pcap link type has no decoder here | The DLT number |
editcap -T ether converts a DLT_NULL (0) or DLT_LINUX_SLL (113) file |
2 |
The link layer named a payload that is not IP | The EtherType, or null when the link layer records none |
34887 is 0x8847, so the mirror carries MPLS. 2054 is ARP, which every Ethernet capture carries and nothing needs to decode |
3 |
An IP header decoded, and its payload is no transport sipnab handles | The outermost IP protocol, or null when the decoder recorded none |
47 is GRE and 4/41 are IP-in-IP. --capture-tunnels widens the filter to reach inside them |
4 |
The frame is shorter than a header it claims | null |
Raise --snaplen. A cut frame is a capture setting, not a parser gap |
5 |
A decoder rejected the bytes | null |
Save a sample and open an issue |
The number is the whole point of the entry. "Unsupported link type" names no
action, and "unsupported link type 0" names three. frames counts the whole
run, frames_in_window counts the sample, and an entry whose frames_in_window
is 0 describes a problem that has already stopped.
undecodable_reasons_dropped counts frames whose specific number did not fit
the fixed-slot tables behind these counters. A non-zero value means the
breakdown adds up to less than totals.undecodable_frames, and the field
exists so that nobody has to discover the shortfall by subtracting.
An MCP tool call blocks the agent that made it. The handler holds a request slot for the whole window, and clients cancel a call that has not answered β 60 seconds is the common default. A window that can run for minutes turns a diagnostic into a denial of service against the agent that asked for it.
Thirty seconds keeps the whole call inside half of that budget and still buys
a window worth having. A trunk at 10,000 packets per second puts 300,000
packets through it, which is enough for a drop rate to mean something. For a
longer view, call the tool repeatedly and read totals.
Divide by window.observed_ms, never by sample_seconds. A loaded runtime
wakes the handler late, and the response reports the wall clock precisely so
that a rate does not inherit that error.
Clock discipline. The response carries a clock object β synchronized,
max_error_us, est_error_us, available β read from adjtimex(2) at report
time rather than cached at startup, since a host can lose its time source while
sipnab runs.
It is irrelevant to a single capture, where one clock stamped every packet and a
constant offset cancels out of every interval. It matters the moment you
correlate across NODES: find_correlated's timing_heuristic matches dialogs
that started within the leg-correlation window of each other β two seconds
unless --leg-correlation-window says otherwise β and that is smaller than the
skew an undisciplined host accumulates in a day. A clock three seconds fast
fails to correlate legs that belong together, and a slow one pulls unrelated
legs inside the window. Widening the window to reach a B2BUA that dips a
database before placing the outbound leg widens this exposure with it. Read clock from both servers before trusting a time-based
match, and prefer any of the six identifier strategies β session_id,
x_call_id, charging_vector_related_icid, sdp_origin,
charging_vector_icid or via_branch β none of which care what time anyone
thinks it is.
available: false means the platform gave no answer β NOT that the clock is
bad. The two are different facts and only one of them is a problem.
The whole-capture analysis, the one --report prints. Backed by
analysis::analyze
and rendered by output::analysis_report.
get_dialog_report and render_ladder
both answer for ONE Call-ID, so everything the report says about the capture as
a whole had no MCP path: orphaned media, STUN, ICMP errors quoting SIP or RTP,
and what the retention caps shed. capture_status could hand an agent a count, and no tool
could expand it.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
format |
string? |
"json", "markdown" or "text". Anything else fails with unknown format 'x', expected json|markdown|text. |
"json". |
Frames read comes from the same process-global counter the Prometheus scrape
reports (sipnab_capture_packets_total), so the denominator here is the one
every other number in the run measures against.
A capture with no findings still answers with the clean line rather than an empty body, because silence is indistinguishable from the tool not having run:
// get_capture_report { "format": "text" }
// A capture with findings names each one; a clean capture says so outright.
"Capture analysis: 2 finding(s) across 1 dialog(s), 2 stream(s), 535000 frame(s) read."json is the default and returns an OBJECT, serialized from the analysis
itself β findings, dialogs_examined, streams_examined, frames_read and
complete.
Each findings row is one ranked problem, worst first:
| Field | Type | Description |
|---|---|---|
kind |
string | Which problem this is, such as one_way_audio. |
severity |
string | How bad it is, carried on the row so a consumer sorts without a second table. |
occurrences |
u64 | How many times sipnab saw it, in units. Exact, and not the length of evidence, which a cap bounds. |
unit |
string | What one occurrence is β call, stream, frame. |
evidence |
object[] | Up to a fixed cap of verifiable instances. |
evidence_omitted |
u64 | Evidence rows the cap kept out. sipnab counts these while accumulating rather than subtracting, because one row often stands for many occurrences and the subtraction reported omissions that never existed. |
An evidence row carries call_id, endpoints (most specific first), at,
counts (named integers such as rtp_packets and streams) and note.
sipnab leaves out any of the five that does not apply rather than sending
null. A capture-level finding that belongs to no call, such as a STUN probe
or an undecodable frame, simply has no call_id.
// get_capture_report { "format": "json" } β one findings row, against
// tests/pcap-samples/sip-rtp-g711.pcap
{
"kind": "one_way_audio",
"severity": "critical",
"occurrences": 2,
"unit": "call",
"evidence": [
{
"call_id": "[email protected]",
"endpoints": ["10.0.2.20:5060 -> 10.0.2.15", "SDP 10.0.2.20"],
"at": "2016-11-26T14:52:59.666393Z",
"counts": { "rtp_packets": 425, "streams": 1 },
"note": "10.0.2.15:27942 -> 10.0.2.20:6000 carried 425 packet(s); nothing came back the other way"
}
],
"evidence_omitted": 0
}Read complete before the findings: it is false when the capture
lost packets, hit a retention cap, or held frames no decoder could read, and a
findings list from such a capture is a floor, not a total.
complete also reads false while the load runs, and for a source whose read
stopped before its end. It answers "did sipnab read all of its input", so a file
still arriving cannot satisfy it. Until this gate landed it ignored the load
entirely and read backwards during one: on a 100 MB capture the same tool in the same
session answered complete: true at frames_read: 312 and complete: false at
frames_read: 365747 β true over 0.09% of the file, false once the whole
file had arrived. The field keeps its name and its meaning. What changed is that
the two facts under it, source_exhausted and source_stopped_early, now gate
it and travel beside it.
markdown and text answer with a rendered document, which has no envelope to
carry either flag, so the document states the fact itself. A report drawn while
the capture is still loading, or over one whose read stopped before its end,
ends with an INCOMPLETE RUN block naming each reason β the same block
--report appends, and for the same reason: the reader of a rendered report has
no $? and no JSON in front of them, and a partial report looks exactly like a
whole one. A capture read in full adds nothing and the report ends where it
always did. Ask for json when you want the two booleans as fields.
Before 0.5.125 this default returned the TEXT rendering.
formatchooses between markdown headings and plain text inside the renderer and never had a JSON arm, so the tool asked for JSON, got prose, and fell back to a text block. An agent that called it with no argument received prose and nothing to say the structure it asked for was never there.
GET /v1/report answers the same question over REST.
Capture files in the configured root, with their sizes and the instant each one's first packet arrived.
Parameters: none.
It lists .pcap and .pcapng only, matched case-insensitively, and skips
directories. That is narrower than what open_capture accepts,
which is any readable capture the name resolves to β a .cap file sitting in
the root opens fine and never appears here, so an agent that treats this listing
as the whole set it may open misses it. Ask the operator, or try the name.
list_captures {}Without --mcp-file-root the whole file-tool group is off, and this answers
with a refusal rather than an empty list β "no directory configured" and "the
directory is empty" are different facts:
{ "code": -32602,
"message": "file tools are disabled: start sipnab with --mcp-file-root <DIR>" }Otherwise it answers with captures sorted by filename, plus
schema_version. Running it against tests/pcap-samples returns 30 of the
directory's 36 entries. The six it leaves out are five .cap captures and one
file carrying no extension at all:
// list_captures {}
{
"schema_version": 1,
"captures": [
{ "filename": "Asterisk_ZFONE_XLITE.pcap", "bytes": 255581,
"first_packet": "2010-09-27T07:12:49+00:00" },
{ "filename": "DTMFsipinfo.pcap", "bytes": 25429,
"first_packet": "2011-04-27T08:14:29+00:00" },
{ "filename": "b2bua-asterisk.pcapng", "bytes": 114952,
"first_packet": "2016-05-18T18:23:03+00:00" }
// ... 27 more
]
}first_packet is what turns a listing into a choice. It is the timestamp
on the first record of the file, read whole seconds, so forty rotated captures
narrow to the two that could hold a call from 14:02 without opening each one in
turn. Opening one is not free: open_capture replaces the
loaded capture and voids every cursor and Call-ID an agent is holding. The key
reads null whenever sipnab got no first packet out of the file β a rotated
capture that never received one, or a file it could not open β so treat null
as "unknown", not as "empty". sipnab reports neither a dialog count nor a
last-packet time here, because both need the whole file parsed and a listing
that costs a full read of every capture in the root is a listing nobody runs.
Which of these files holds the call, asked without giving up the one you are working in.
list_captures narrows forty rotated files to the two that
could hold a call, by time. That is a filter, not an answer. The question an
operator actually has β "which of these holds Call-ID X" β had no tool at all:
the only way inside another file is open_capture, documented
Destructive, which replaces every dialog and stream and mints a new
capture_identity that voids every cursor you hold.
This sweeps instead: a scratch store per file, the filter applied, the loaded capture untouched.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
filter |
string | A filter DSL expression, the same vocabulary every other filtering tool takes. Unparseable fails with invalid_params before the sweep opens anything |
Required |
max_files |
u32? | Files to open before stopping. Clamped to DEFAULT_MAX_FILES (20); 0 means the default |
20 |
deadline_ms |
u64? | Wall-clock the sweep may spend. Clamped to DEFAULT_DEADLINE_MS (30000); 0 means the default |
30000 |
// find_in_captures { "filter": "call_id == \"[email protected]\"" }
{
"schema_version": 1,
"sweep": {
"matches": [
{ "filename": "rotated-03.pcap", "dialogs_matched": 1,
"first_call_id": "[email protected]" }
],
"files_examined": 12,
"files_total": 12,
"unreadable": [],
"complete": true
}
}A sweep runs under bounds, and one that reports no matches has not
shown the call is absent β only that it did not find it in what it managed to
read. complete is true in exactly one case: every candidate examined, and
every one of them readable.
It is false when the sweep stopped early β stopped_because says
max-files or deadline β and false when the sweep could not open some
file. unreadable names each of those, with its reason, and never skips one
silently: the file nobody could look in is exactly the one that might hold
the call. A reason comes from the OS and from libpcap, so sipnab strips control
characters and keeps the first MAX_REASON_CHARS (200).
A match does not excuse an incomplete sweep either. On a rotated spool a call that spans a rotation is in two files, so finding it in one says nothing about the other.
max_files makes the cost predictable. deadline_ms is the one that matters:
a file's cost is its size, which the caller cannot see, so twenty small files
and twenty 2 GB files are the same max_files and a very different wait. The
sweep tests the deadline before each file rather than after: a deadline
tested only afterwards is one the last file can overrun by its whole read.
There is no cancel. A tool call has no channel to interrupt it, so the sweep bounds itself and always returns with an account of what it covered.
Returns one page of dialog summaries from the live capture store.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
filter |
string? | A diagnostic alias name β problems, slow-setup, short-calls, one-way, nat-issues, codec-asym, ptime-asym, payload-asym, duration-asym, late-media β or a raw filter DSL expression. Anything else fails with invalid_params naming the position it stopped parsing at. |
Every dialog in the store matches. |
limit |
u32? | Ceiling is --mcp-max-rows (1000 unless the operator changed it). Higher clamps to it, 0 means the default. |
50 rows. |
cursor |
string? | The previous response's next_cursor, verbatim (<RFC 3339 created_at>|<Call-ID>). A malformed timestamp half fails with invalid_params. |
Starts at the oldest dialog. |
fields |
string[]? | Field names to keep on each row, plus call_id, which survives whether or not you list it. An unknown name fails with invalid_params naming both the typo and the fields a row carries. See Narrowing a row with fields. |
The whole row. |
Returns β a page object, not a bare array:
| Field | Type | Description |
|---|---|---|
dialogs |
DialogSummary[] |
This page, oldest first (ties broken by Call-ID). |
returned |
usize | Rows in dialogs, so counting the array is never necessary. |
total_matched |
usize | Dialogs matching the filter across the whole store, whatever limit and cursor say. This is the number that answers "how many". |
by_method |
{method, count}[] |
total_matched split by the method that opened each dialog, dominant class first. Covers the whole match set, not this page. |
truncated |
bool |
true when matches remain after this page. Absent while the answer is not whole β see the sixth rule above. |
source_exhausted |
bool |
true once sipnab has read the capture source to its end. |
source_stopped_early |
bool |
true when a source's read ended before the source did. |
next_cursor |
string? | Pass back to continue. null on the final page. |
schema_version |
u32 |
1 for this shape. |
capture_identity |
object | Which capture answered β see capture_status. A changed instance voids every cursor you hold. |
Each DialogSummary row carries call_id, state, method, from_user,
to_user, msg_count, duration_sec, created_at, updated_at, a timing
object (pdd_ms, setup_ms, retransmits, duration_ms, each null when the
capture never showed it) and a frame pointer for
show_evidence. from_user and to_user arrive fenced,
because an endpoint chose them.
Two more keys appear when sipnab knows them, and are left out rather than sent as null when it does not, so an absent key means "not known here":
-
final_status_codeβ the final response the call reached.statecollapses every release cause into one word, so 403, 404, 408, 486, 503 and 603 all read asFailed, and this is the field that tells them apart. Absent while a call is still in progress, never0. -
input_originβ which capture source delivered the message that OPENED the dialog:wire,heporuprobe. It is what keepsframehonest, because a uprobe pointer (uprobe:opensips/954#3) has the same shape as a capture offset and only one of the two leads to bytes on disk.
The example below runs against tests/pcap-samples/sipp-branch-scenario.pcapng,
which holds 1334 dialogs. limit: 2 therefore reports 2 of 1334 β and says so:
// list_dialogs { "limit": 2 }
{
"schema_version": 1,
"dialogs": [
{
"call_id": "[email protected]",
"state": "Registered",
"method": "REGISTER",
"from_user": "β¦untrusted-capture-dataβ§ua-aβ¦/untrusted-capture-dataβ§",
"to_user": "β¦untrusted-capture-dataβ§ua-aβ¦/untrusted-capture-dataβ§",
"msg_count": 7,
"final_status_code": 403,
"duration_sec": 0.036,
"created_at": "2016-11-17T21:52:35.303349+00:00",
"updated_at": "2016-11-17T21:52:35.339349+00:00",
"timing": {
"pdd_ms": null,
"setup_ms": null,
"retransmits": 0,
"duration_ms": null
},
"frame": "tests/pcap-samples/sipp-branch-scenario.pcapng#0@0f039ad14545671e",
"input_origin": "wire"
},
{
"call_id": "[email protected]",
"state": "Registered",
"method": "REGISTER",
"from_user": "β¦untrusted-capture-dataβ§ua-aβ¦/untrusted-capture-dataβ§",
"to_user": "β¦untrusted-capture-dataβ§ua-aβ¦/untrusted-capture-dataβ§",
"msg_count": 7,
"final_status_code": 403,
"duration_sec": 0.036,
"created_at": "2016-11-17T21:52:35.403349+00:00",
"updated_at": "2016-11-17T21:52:35.439349+00:00",
"timing": {
"pdd_ms": null,
"setup_ms": null,
"retransmits": 0,
"duration_ms": null
},
"frame": "tests/pcap-samples/sipp-branch-scenario.pcapng#7@f7951dd197e419b9",
"input_origin": "wire"
}
],
"returned": 2,
"total_matched": 1334,
"by_method": [
{ "method": "REGISTER", "count": 1334 }
],
"truncated": true,
"next_cursor": "2016-11-17T21:52:35.403349+00:00|[email protected]",
"capture_identity": {
"node": "capture-01",
"instance": "1ae7318cb5c11b1a306dd-1",
"dialog_generation": 9015,
"stream_generation": 0
}
}by_method is why this fixture is a fair example rather than a flattering one:
the page shows two dialogs and the breakdown shows that all 1334 are REGISTER.
Nothing in the rows could have told a caller that, and a client that reasoned
about "the calls in this capture" from the page would have been reasoning about
a registration flood.
Two fields in there catch people writing a client from this page. from_user
reads β¦untrusted-capture-dataβ§ua-aβ¦/untrusted-capture-dataβ§, not ua-a, so a
comparison against the bare name never matches β strip the markers, or match
inside them. And capture_identity.node names the box that answered, which
decides whose capture a fact came from once an agent holds several servers.
A bare array hides its own size. This tool returned 50 of 2311 dialogs on a
production capture with nothing in the reply to mark the cut, and limit alone
could not close the gap: requests above 1000 clamp to the hard cap, leaving 1311
dialogs no call could reach. An agent asked "how many calls failed?" counts the
rows it holds and answers with that number, so a short list does not read as an
incomplete answer β it reads as a confident wrong one. total_matched and
truncated name the shortfall. cursor closes it.
next_cursor pairs a timestamp with a Call-ID for the same reason
tail_dialogs does. Dialogs share a created_at routinely β a
burst of registrations lands on one millisecond β and a bare timestamp forces a
choice between dropping the rest of that group and serving it twice. Resuming
after the (created_at, Call-ID) pair splits the group exactly where the page
ended. Pass the value back unmodified. A bare RFC 3339 timestamp still
parses, so rebuilding one by hand fails silently rather than erroring.
The two tools page on different clocks, deliberately. tail_dialogs follows
updated_at, because reporting change is its job. list_dialogs pages on
created_at, which never moves: a dialog that gains one more message mid-sweep
would jump forward in an updated_at ordering, past a cursor that had already
gone by, and vanish from the listing.
list_dialogs, find_problems, tail_dialogs and search_by_time take an
optional fields array. This surface already bounds rows three ways -- limit, --mcp-max-rows, and
cursors -- and it bounded columns not at all, so asking for a page to read two
values off it still paid for every other value on every row.
// list_dialogs { "limit": 200, "fields": ["state", "final_status_code"] }
{ "call_id": "[email protected]", "state": "Completed", "final_status_code": 200 }Three rules, each there for a reason:
-
call_idalways survives, listed or not. Every follow-up tool here takes a Call-ID, so a row an agent cannot address is a row it can do nothing with. - An unknown name fails, naming both the typo and the fields the row actually carries. Silently returning rows without the field asked for reads as "no such data", which is a wrong answer rather than an error.
-
The envelope is never projected.
total_matched,truncated,next_cursorandcapture_identityare how a caller knows what it did NOT get, and dropping them to save bytes would trade away the very thing the bounds exist to report.
A field already absent from a row stays absent rather than reappearing as
null: the projection runs on the serialized page, so skip_serializing_if
still decides what exists.
Call volume over time, in fixed-width buckets.
aggregate_dialogs answers "how many, grouped by what" and is blind to WHEN.
A trunk that failed for ninety seconds and a trunk that fails one call in
forty produce the same bucket counts, and only the shape over time separates
them. This returns one row per interval so the shape is readable without
fetching every dialog and bucketing them in the model's head -- the counting
task language models get wrong most reliably.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
bucket_seconds |
integer? | Greater than zero. A zero-width bucket describes no interval and every dialog would fall into all of them at once, so it fails with invalid_params rather than dividing by zero. |
60. |
Returns β an object wrapping the series, not a bare array:
| Field | Type | Description |
|---|---|---|
buckets |
object[] | One row per interval, oldest first. Each carries start (inclusive, RFC 3339), bucket_seconds and dialogs. |
returned |
u64 | Rows in buckets, so counting the array is never necessary. |
bucket_seconds |
u64 | The width actually used, echoed because you may have omitted it. |
source_exhausted |
bool |
true once sipnab has read the capture source to its end. |
source_stopped_early |
bool |
true when a source's read ended before the source did. |
schema_version |
u32 |
1 for this shape. |
There is no truncated and no cursor: this tool takes no limit and returns
every bucket in the capture.
// timeline { "bucket_seconds": 60 }
{
"schema_version": 1,
"buckets": [
{ "start": "2026-08-28T04:00:00Z", "bucket_seconds": 60, "dialogs": 412 },
{ "start": "2026-08-28T04:01:00Z", "bucket_seconds": 60, "dialogs": 0 },
{ "start": "2026-08-28T04:02:00Z", "bucket_seconds": 60, "dialogs": 377 }
],
"returned": 3,
"bucket_seconds": 60
}The object is the reason to read this tool's answer rather than count it. A
bare array has no key to carry source_exhausted, so a series drawn while the
capture was still loading looked exactly like one drawn over the whole file.
Wrapping the rows gives that fact somewhere to land.
Two properties are deliberate. Buckets align to the epoch, not to the first dialog, so two captures of the same window produce identical boundaries and line up against each other. Aligning to the earliest call would instead shift every boundary whenever that call changed. The series also keeps empty buckets, as the middle row above shows -- a gap is a finding, and a series that silently drops it renders as continuous traffic on a shorter axis.
Ranks the busiest participants: IPs, user agents, or dialled prefixes.
aggregate_dialogs and group_dialogs group DIALOGS, and a dialog has two
ends. Asking "who is busiest" of a dialog-shaped answer forces the caller to
pick one end and ignore the other, which reports a trunk's traffic as belonging
to whichever side the schema happened to name first. This counts PARTICIPANTS
instead.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
by |
string |
ip, ua or prefix. Anything else fails with invalid_params naming the legal set. |
Required. |
limit |
integer? | Bounded by --mcp-max-rows. |
The usual row default. |
filter |
string? | Alias or DSL, the same vocabulary every other tool takes. | The whole store. |
prefix_digits |
integer? | How many leading digits make a prefix bucket. | 4. |
Returns:
| Field | Type | Description |
|---|---|---|
by |
string | Echo of the dimension ranked on, so an answer is self-describing. |
talkers |
object[] | Busiest first, bounded by limit. |
distinct_talkers |
usize | Talkers seen across the whole match, whether or not each got a row. |
total_matched |
usize | Dialogs the filter matched across the whole store. |
truncated |
bool |
true when distinct_talkers exceeds the rows returned. |
capture_identity |
object | Which capture answered β see capture_status. |
schema_version |
u32 |
1 for this shape. |
Each talkers row carries key (the address, banner or prefix), dialogs,
messages, invites, answered, failed, and share_pct. messages counts
what sipnab credits to the row: messages SENT for an ip, messages carrying
the banner for a ua, every message of the dialog for a prefix. answered
and failed are subsets of invites, so a dimension carrying no INVITE dialogs
reports zero for all three. share_pct is null β never 0 β when nothing
matched, because a zero there would read as a talker measured to be idle.
The example runs against tests/pcap-samples/sipp-branch-scenario.pcapng,
whose 1334 REGISTER dialogs run between two addresses:
// top_talkers { "by": "ip", "limit": 3 }
{
"schema_version": 1,
"by": "ip",
"talkers": [
{ "key": "192.0.2.20", "dialogs": 1334, "messages": 5082,
"invites": 0, "answered": 0, "failed": 0, "share_pct": 100.0 },
{ "key": "192.0.2.10", "dialogs": 1334, "messages": 3907,
"invites": 0, "answered": 0, "failed": 0, "share_pct": 100.0 }
],
"distinct_talkers": 2,
"total_matched": 1334,
"truncated": false,
"capture_identity": {
"node": "capture-01",
"instance": "eb70c18d2bf4734944323-2",
"dialog_generation": 9015,
"stream_generation": 0
}
}The shares deliberately sum above 100%. One dialog counts for every talker
in it, so a two-ended call adds to two rows β here both ends took part in all
1334 dialogs and both read 100%. total_matched is the number that partitions:
compare a row's dialogs against it rather than against the other rows.
by: "prefix" is the exception and does partition, because sipnab credits one
bucket per dialog and a call has one dialled number.
sipnab orders rows by dialogs, then by messages, then by key, so two
runs over one capture cannot disagree. key arrives fenced for by: "ua"
alone, because a banner is a string a stranger typed. An address and a prefix
bucket are values sipnab derived, and come back verbatim.
ip reads senders off the MESSAGES, not off the dialog's opening addresses: a
proxy that re-originates mid-dialog is invisible in the dialog record and would
otherwise never appear. ua reads User-Agent from requests and Server from
responses, both sender-written and therefore fenced. prefix strips a leading
+ before taking digits, so +15551234 and 15551234 are one destination
rather than two.
Counts dialogs grouped by one field, inside the store.
Counting is the operation a language model gets wrong most reliably, and this
page documents that failure elsewhere: an agent asked "how many calls failed?"
counts the rows it is holding and answers with that number. The page object
fixed exactly one count β total_matched, for one filter. Every other count
still meant fetching pages and tallying them in the model's head, or issuing N
filtered queries with the buckets guessed in advance.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
group_by |
string | One of state, response_code, method, from.user, to.user, ua, src.ip, dst.ip, rtp.codec. Anything else fails with invalid_params naming the legal set. |
Required. |
filter |
string? | Alias or DSL, applied before grouping. | No filter β the whole store. |
top_n |
integer? | Bounded by --mcp-max-rows, the same knob that bounds dialog rows β not a second ceiling of sipnab's own. Anything past it lands in other_count rather than disappearing. |
The same default as any row limit. |
Returns group_by (echoed), buckets (each { value, count }, largest
first), other_count, distinct_values, total_matched, capture_identity
and schema_version.
The example runs against tests/pcap-samples/sipp-branch-scenario.pcapng,
whose 1334 dialogs land in two states, with top_n: 1 asking for only the
larger one:
// aggregate_dialogs { "group_by": "state", "top_n": 1 }
{
"schema_version": 1,
"group_by": "state",
"buckets": [ { "value": "Registered", "count": 1207 } ],
"other_count": 127,
"distinct_values": 2,
"total_matched": 1334,
"capture_identity": {
"node": "capture-01",
"instance": "f078218d2bfca5995cf37-2",
"dialog_generation": 9015,
"stream_generation": 0
}
}The buckets plus other_count always equal total_matched. A truncated
aggregate that does not say what it left out is a wrong total rather than a
partial one, so nothing is silently dropped β including nulls, which become the
literal (none): "how many dialogs carry no User-Agent" is a real question.
One dimension, and no time bucketing. That is a deliberate cap, not an
unfinished feature. Two dimensions is a pivot table, a pivot table wants a UI,
and positioning
puts that outside what sipnab is. Narrow the window with filter instead.
Grouping by from.user, to.user or ua returns fenced values. Those are
text the packet's sender wrote, and they reach a model here exactly as they
would in a row. A state name, a status code, an IP or a codec is sipnab's own
derivation and comes back verbatim β fencing those would tell the agent to
distrust the analysis.
Carrier metrics per group, computed inside the store.
aggregate_dialogs answers "how many" and stops.
Every question beginning with WHICH β which trunk is failing, which User-Agent
has the worst audio, which hour it started β wants a rate, and a rate is the one
thing a language model cannot recover from a page of rows: it would have to hold
every dialog, classify each outcome and divide. Agents stop early and answer
from the rows they happen to hold, which is how a truncated page becomes a
confident verdict about a carrier. So this tool computes the rate where the
dialogs live and reports the population underneath it.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
by |
string | ONE of state, response_code, method, from.user, to.user, ua, src.ip, dst.ip, rtp.codec, to_domain, hour, next_hop. Anything else β including two names in one string, such as "src.ip,hour" β fails with invalid_params naming all twelve. |
Required. |
metrics |
string[]? | Any of count, asr, ner, acd, pdd_p50, pdd_p95, mos_p10, retransmit_rate. The tool sorts and de-duplicates the list, so the key order in the answer never depends on the order you wrote. An unknown name fails naming all eight. |
All eight. |
filter |
string? | Alias or DSL, applied before grouping β the same vocabulary list_dialogs takes. |
No filter β the whole store. |
top_n |
integer? | Bounded by --mcp-max-rows, the same knob that bounds dialog rows. Everything past it lands in other_count rather than disappearing. |
50, the same default as any row limit. |
// group_dialogs { "by": "next_hop", "metrics": ["count", "asr", "ner"], "top_n": 2 }
{
"schema_version": 1,
"group_by": "next_hop",
"metrics": ["asr", "count", "ner"],
"units": { "asr": "percent", "count": "dialogs", "ner": "percent" },
"groups": [
{
"value": "203.0.113.9:5060",
"count": 412,
"metrics": { "asr": 71.6, "count": 412.0, "ner": 98.27 },
"not_grounded": {},
"population": {
"dialogs": 412, "seizures": 405, "answered": 290, "delivered": 398,
"completed_calls": 288, "pdd_measured": 401,
"mos_grounded_dialogs": 260, "retransmits": 14
}
},
{
"value": "203.0.113.10:5060",
"count": 77,
"metrics": { "asr": null, "count": 77.0, "ner": null },
"not_grounded": {
"asr": "no INVITE in this group reached a final response, so nothing in it was a decided call attempt",
"ner": "no INVITE in this group reached a final response, so nothing in it was a decided call attempt"
},
"population": {
"dialogs": 77, "seizures": 0, "answered": 0, "delivered": 0,
"completed_calls": 0, "pdd_measured": 0,
"mos_grounded_dialogs": 0, "retransmits": 0
}
}
],
"other_count": 11,
"distinct_values": 6,
"total_matched": 500,
"capture_identity": { "node": "capture-01", "instance": "1d1a718cb5c33b7c52754-1",
"dialog_generation": 4113, "stream_generation": 902 }
}A null metric is an answer, and not_grounded says which population went
missing. The second group above holds 77 REGISTER dialogs. An ASR of 0 there
would name a working registrar a dead trunk, which is a wrong answer rather than
a missing one. That is the discipline mos_grounded already applies to a MOS on
an unpublished codec, extended to the other seven metrics.
Every figure names its unit in the answer. An ASR of 0.65 and an ASR of
65 describe the same trunk and differ by a factor of a hundred, so units
travels with the numbers rather than living on this page. asr and ner are
PERCENTS here and in evaluate_expectations, which
takes its thresholds in the same unit: a rule reads "value": 99, matching the
number you read in a report. The two tools disagreed on this before 0.5.130 --
one percent, one a ratio -- and a threshold copied between them was wrong by a
hundredfold in the direction that always passes.
The eight metrics, and what each one measures:
| Metric | Unit | Definition | Population it rests on |
|---|---|---|---|
count |
dialogs | Dialogs in the group. | dialogs |
asr |
percent | Answer-seizure ratio: 2xx answers over seizures. | seizures |
ner |
percent | Network effectiveness ratio (ITU-T E.411): seizures the network delivered over seizures. | seizures |
acd |
seconds | Average call duration: mean CONVERSATION time, not dialog span. | completed_calls |
pdd_p50, pdd_p95
|
milliseconds | Post-dial delay percentiles, nearest rank. | pdd_measured |
mos_p10 |
mos | Tenth-percentile MOS across dialogs holding a stream sipnab can score, worst stream per dialog. | mos_grounded_dialogs |
retransmit_rate |
retransmissions per dialog |
retransmits over dialogs. Never refused β a group exists because a dialog fell into it. |
dialogs |
A seizure is an INVITE dialog that reached a final response. Both halves carry weight. A REGISTER is not a call attempt, and an INVITE still ringing when the capture ended has not failed β counting it as a failed seizure reports a live capture as an outage that looks worse the earlier you look.
NER credits the far end where ASR does not. A trunk full of 486s has an ASR
of zero and works perfectly: the network delivered every call and the callee was
busy, so reading the ASR alone escalates a healthy carrier. The five codes that
count as delivered are 480, 486, 487, 600 and 603 β the callee
unavailable, busy, busy everywhere, declining, or the CALLER hanging up on a
call that had already reached the far end. 408 Request Timeout is
deliberately absent. A proxy emits it both for a silent phone and for an
unreachable next hop, so crediting it would credit the network for calls that
may never have arrived, which is the exact misattribution NER exists to prevent.
ACD times the conversation, not the dialog. A call that rang for five seconds, talked for sixty and took one more on the BYE handshake spans 66 seconds and contributes 60. Averaging the span bills the caller for the ring-back, which is not what any carrier means by average call duration.
Percentiles use nearest rank, with no interpolation. An interpolated p95 returns a number no call experienced, and these figures get quoted back to a carrier as evidence about real calls. Every metric rounds to two decimal places, because a ratio over three dialogs is not accurate to fourteen β and the population sits beside it, so anyone wanting the exact ratio divides the two integers.
Groups rank by dialog count, largest first, ties broken by value. Ranking by
the metric would answer a different question per call and could not page against
other_count. That remainder carries a count and nothing else, deliberately: an
ASR averaged over the groups that fell off the end is an average of averages
across the smallest groups, which is the arithmetic that turns one busy trunk
into a capture-wide verdict. The groups plus other_count always equal
total_matched.
Three dimensions live here and not in aggregate_dialogs, and the set is a
strict SUPERSET rather than an overlapping one, so an agent never has to
discover per tool which vocabulary it is talking to:
| Dimension | Value | Fenced? |
|---|---|---|
to_domain |
The host half of the To URI, port stripped, IPv6 brackets kept. | Yes β the sender wrote it. |
hour |
The calendar hour the dialog opened in, aligned to the epoch like timeline buckets, RFC 3339. |
No |
next_hop |
host:port the opening message went to, read off the IP and transport headers. |
No |
Fencing follows the same rule as everywhere else. to_domain, to.user,
from.user and ua arrive wrapped in β¦untrusted-capture-dataβ§ because a
sender chose them. A state name, a status code, an address, a codec and
next_hop come back verbatim, because they are sipnab's own derivation and
telling an agent to distrust them would tell it to distrust the analysis.
Note next_hop names the peer only from the vantage point of a capture taken
beside the sender, which is where a proxy's own trunk traffic gets captured.
One dimension, for the reason aggregate_dialogs gives:
two is a pivot table, and a pivot table wants a UI. Narrow with filter
instead. Reach for aggregate_dialogs when a bare count answers the question β
it does the same walk without computing seven metrics.
What this binary can do and what this server permits. Ask before requesting decryption, HEP, a file export or a capture swap: a build without the feature, or a server without the flag, fails confusingly otherwise.
No parameters. Returns:
{
"schema_version": 1,
"version": "0.5.182",
"features": ["api", "hep", "mcp", "native", "tls", "tui"],
"can_decrypt": true, // tls
"can_hep": true, // hep
"can_plugins": false, // plugins
"runtime": {
"mcp_file_root": "/var/spool/sipnab-captures", // null when unset
"mcp_allow_shutdown": false,
"mcp_allow_open_capture": true,
"mcp_allow_save_findings": false
}
}features comes from cfg! at compile time, so it cannot claim a feature the
binary does not have. runtime is a different question β what the operator
turned on β and no compile-time check can answer it. Without it an agent
discovers the setup by calling a tool and collecting a refusal, and a refusal
mid-investigation reads as a dead end rather than as a server it was never
allowed to use that way.
An orphaned stream is media with no dialog. rtp_stats names which streams
have none. This tool says why each one has none, which is the part an incident
needs.
Parameters: limit (u32?) β maximum rows to return, bounded by
--mcp-max-rows and defaulting to 50. There is no cursor: read total_orphans
against the rows you received, and raise limit to reach the rest.
Returns orphans, total_orphans (the count before limit bit),
truncated, relay_was_consulted, capture_identity and schema_version.
Each orphans row carries ssrc, src, dst, named_endpoint, asserted_by,
reason and note.
Three verdicts, and the difference between them is the point:
| verdict | what it means |
|---|---|
relay-asserted-but-no-dialog |
a relay named this endpoint and no captured dialog claims it β the signaling is missing, not the media |
signaled-but-no-dialog |
SDP named it and no dialog holds it β either the capture missed the dialog, or its SDP arrived before capture started |
never-named |
nothing in this capture named this endpoint at all |
relay_was_consulted rides alongside the verdict and matters more than it looks. A
never-named verdict with relay_was_consulted: false means nobody asked β
an absence of evidence, not evidence of absence. It is deliberately not
reported using the Unattributed vocabulary, which answers "sipnab asked the
relay and it said X": this server holds no live reconciler, and claiming an answer
nobody received is the failure that vocabulary exists to prevent.
The example runs against tests/pcap-samples/codec-negotiation.pcap,
which carries four streams and no dialogs at all:
// reconcile_orphans { "limit": 2 }
{
"schema_version": 1,
"orphans": [
{
"ssrc": 238226163,
"src": "127.0.0.1:5094",
"dst": "127.0.0.1:5084",
"named_endpoint": null,
"asserted_by": null,
"reason": "never-named",
"note": "nothing in this capture named this endpoint. A relay could answer it and none was asked: that is an absence of evidence, not evidence of absence"
},
{
"ssrc": 238226163,
"src": "127.0.0.1:5084",
"dst": "127.0.0.1:5094",
"named_endpoint": null,
"asserted_by": null,
"reason": "never-named",
"note": "nothing in this capture named this endpoint. A relay could answer it and none was asked: that is an absence of evidence, not evidence of absence"
}
],
"total_orphans": 4,
"truncated": true,
"relay_was_consulted": false,
"capture_identity": {
"node": "capture-01",
"instance": "ece9518d2bf67680750b4-2",
"dialog_generation": 1,
"stream_generation": 4
}
}You know something is wrong but not which call. These cut a capture down to the dialogs worth reading: by symptom, by text, by time, or by relationship to a call you already have.
Convenience wrapper over list_dialogs that ORs each named alias, then ANDs
the optional filter.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
kinds |
string[]? | One or more of the ten diagnostic aliases listed under list_dialogs, OR-ed together. An unknown name fails with invalid_params. An empty array behaves as omitted. |
["problems"]. |
filter |
string? | An alias name or a raw DSL expression, ANDed with the alias match. | The alias match alone decides the page. |
limit |
u32? | Ceiling is --mcp-max-rows (1000 by default). Higher clamps to it, 0 means the default. |
50 rows. |
cursor |
string? | The previous response's next_cursor, verbatim. |
Starts at the oldest match. |
fields |
string[]? | Field names to keep on each row, plus call_id. Same rules as list_dialogs β see Narrowing a row with fields. |
The whole row. |
Returns the same page object as list_dialogs β dialogs,
returned, total_matched, by_method, truncated, next_cursor,
schema_version and capture_identity β with the same meanings and the same
fenced from_user and to_user.
Read by_method first on this tool especially. Whatever the deployment does
most dominates a problems page, and that is rarely the calls: on one real
capture 98 of 110 rows carried OPTIONS, and 89 of those tripped an alias solely
on retransmits > 3 β dead qualify peers, not call faults. Without the
breakdown an agent had to already suspect the keepalive plane to exclude it.
filter is what makes this the triage entry point rather than a firehose. The
aliases answer "is this call interesting". The filter answers "is it one of
mine", so {"kinds": ["problems"], "filter": "dst.ip == '203.0.113.9'"} asks a
question that previously needed a client-side join. The two AND together β
ORing them would widen the sweep instead of narrowing it.
An unknown alias, or a filter that neither names an alias nor parses, returns invalid_params (-32602) naming the offending value.
// find_problems { "limit": 1, "filter": "msg_count > 5" }
{
"schema_version": 1,
"dialogs": [
{
"call_id": "[email protected]",
"state": "Failed",
"method": "REGISTER",
"from_user": "β¦untrusted-capture-dataβ§ua-aβ¦/untrusted-capture-dataβ§",
"to_user": "β¦untrusted-capture-dataβ§ua-aβ¦/untrusted-capture-dataβ§",
"msg_count": 6,
"final_status_code": 403,
"duration_sec": 0.03,
"created_at": "2016-11-17T21:54:34.903349+00:00",
"updated_at": "2016-11-17T21:54:34.933349+00:00",
"timing": {
"pdd_ms": null,
"setup_ms": null,
"retransmits": 0,
"duration_ms": null
},
"frame": "tests/pcap-samples/sipp-branch-scenario.pcapng#8028@42ba0f02341ca2f7",
"input_origin": "wire"
}
],
"returned": 1,
"total_matched": 6,
"by_method": [
{ "method": "REGISTER", "count": 6 }
],
"truncated": true,
"next_cursor": "2016-11-17T21:54:34.903349+00:00|[email protected]",
"capture_identity": {
"node": "capture-01",
"instance": "1ae7318cb5c11b1a306dd-1",
"dialog_generation": 9015,
"stream_generation": 0
}
}The same capture answers find_problems {} with total_matched: 127. Six of
those 127 carry more than five messages, which is what the filter selects.
Case-insensitive substring search over method, status, From, To, User-Agent, and body across all dialogs.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
query |
string | Any substring. Matching ignores case. | Required β the call fails. |
limit |
u32? | Ceiling is --mcp-max-rows (1000 by default). Higher clamps to it, 0 means the default. |
50 hits. |
cursor |
string? | The previous response's next_cursor, verbatim (<RFC 3339 created_at>|<Call-ID>#<zero-padded message index>). A malformed timestamp half fails with invalid_params. |
Starts at the oldest match. |
Returns β the same page shape list_dialogs returns, not
a bare array, plus the provenance note as a second content block:
| Field | Type | Description |
|---|---|---|
hits |
object[] | This page of { call_id, message_index, snippet }, ordered by the dialog's created_at, then Call-ID, then message index. |
returned |
usize | Rows in hits. |
total_matched |
usize | Messages matching the query across the whole store, whatever limit and cursor say. This is the number that answers "how many". |
truncated |
bool |
true when matches remain after this page. Absent while the answer is not whole β see the sixth rule above. |
source_exhausted |
bool |
true once sipnab has read the capture source to its end. |
source_stopped_early |
bool |
true when a source's read ended before the source did. |
next_cursor |
string? | Pass back to continue. null on the final page. |
schema_version |
u32 |
1 for this shape. |
capture_identity |
object | Which capture answered. A changed instance voids the cursor. |
snippet holds the whole raw message, fenced, and stops at 4 KB. Pass call_id
and message_index straight to get_message for the parsed
form.
Count from the fields, never from the rows. A capped result and a complete one hold the same shape, so the row count cannot tell them apart: on the sample capture below,
{ "query": "REGISTER" }returns 50 rows andlimit: 1000returns 1000, while 1334 messages match.total_matchedis the number that answers "how many are there",truncatedsays whether you are seeing all of them, andnext_cursorreaches the rest.
The example runs against tests/pcap-samples/sipp-branch-scenario.pcapng:
// search_messages { "query": "REGISTER", "limit": 1 }
{
"schema_version": 1,
"hits": [
{
"call_id": "[email protected]",
"message_index": 0,
"snippet": "β¦untrusted-capture-dataβ§REGISTER sip:example.net SIP/2.0\r\nβ¦β¦/untrusted-capture-dataβ§"
}
],
"returned": 1,
"total_matched": 1334,
"truncated": true,
"next_cursor": "2016-11-17T21:52:35.303349+00:00|[email protected]#0000000000",
"capture_identity": {
"node": "capture-01",
"instance": "12100b18cb6971cf461cff-1",
"dialog_generation": 9015,
"stream_generation": 0
}
}Passing that next_cursor back returns [email protected] and reports
the same total_matched: 1334: the total describes the store, not the page.
The index half of the cursor carries leading zeros because the server compares
the cursor as text β rebuild one by hand and #10 sorts before #2, which
silently skips eight messages of a dialog. Pass it back exactly as it arrived.
Returns dialogs whose first message falls in the window, oldest first.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
start |
string | An inclusive RFC 3339 instant, such as "2026-07-31T14:00:00Z". A malformed one fails with start 'x' is not RFC 3339. |
Required β the call fails. |
end |
string? | An exclusive RFC 3339 instant after start. At or before start fails with invalid_params (-32602). |
Everything from start onward. |
filter |
string? | An alias name or a raw DSL expression, ANDed with the window. | The window alone decides the page. |
limit |
u32? | Ceiling is --mcp-max-rows (1000 by default). Higher clamps to it, 0 means the default. |
50 rows. |
cursor |
string? | The previous response's next_cursor, verbatim (<RFC 3339 created_at>|<Call-ID>). A malformed timestamp half fails with invalid_params. |
Starts at the oldest dialog in the window. |
fields |
string[]? | Field names to keep on each row, plus call_id. The row here is already narrow, so name one of the four below β see Narrowing a row with fields. |
The whole row. |
Returns { dialogs, returned, total_matched, truncated, next_cursor, capture_identity, schema_version }.
Each row carries call_id, created_at, state and final_status_code β
a narrower row than list_dialogs returns, with no
msg_count, no from_user, no timing and no frame. No markers appear
here either, because none of those four fields is free text. Feed a call_id to another
tool when you need the rest.
total_matched counts every dialog in the window before limit applies, so a
small answer from a quiet window reads differently from a truncated one.
filter turns "failed calls between 14:00 and 14:05" into a single call. The
window narrows first and the filter runs over what survives.
The example runs against tests/pcap-samples/sipp-branch-scenario.pcapng. The
same window without a filter answers total_matched: 247:
// search_by_time { "start": "2016-11-17T21:52:35Z", "end": "2016-11-17T21:53:00Z",
// "filter": "problems", "limit": 2 }
{
"schema_version": 1,
"dialogs": [
{
"call_id": "[email protected]",
"created_at": "2016-11-17T21:52:36.203349+00:00",
"final_status_code": 403,
"state": "Failed"
},
{
"call_id": "[email protected]",
"created_at": "2016-11-17T21:52:37.703349+00:00",
"final_status_code": 403,
"state": "Failed"
}
],
"returned": 2,
"total_matched": 16,
"truncated": true,
"next_cursor": "2016-11-17T21:52:37.703349+00:00|[email protected]",
"capture_identity": {
"node": "capture-01",
"instance": "12100b18cb6971cf461cff-1",
"dialog_generation": 9015,
"stream_generation": 0
}
}truncated: true is not a dead end. Pass next_cursor back to
continue through the window. total_matched keeps counting the whole window
rather than the remainder, so it does not shrink as you page β without a
cursor the only way past a truncated window would be to narrow it, putting
every row past the 1000-row ceiling out of reach. The cursor is
the same compound form list_dialogs issues, and it is opaque:
pass it back exactly as it arrived.
Compiles a Filter DSL expression, counts what it selects, and returns no rows.
The DSL narrows list_dialogs, find_problems,
search_by_time, aggregate_dialogs
and group_dialogs, and before this tool every way of trying
an expression cost a page. An agent converging on a working filter β widen it,
narrow it, try the other field name β paid for a page of fenced summaries per
attempt and discarded every row. The two failure modes also look alike from
outside: a malformed expression comes back as invalid_params, which lands in
the error channel where a model acts on it least, and an expression that parses
but selects nothing returns the same empty page as an empty capture.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
expr |
string | A raw filter DSL expression, or one of the diagnostic aliases list_dialogs takes. Nothing you can put here fails the call. |
Required β the call fails. |
A parse failure is a successful call. Every other tool rejects a bad filter
with invalid_params, which is right there and wrong here: learning what is
wrong with an expression is the entire point of this tool, and an error object
carries that message where a model is least likely to act on it. So a bad
expression answers valid: false with the parser's own text β position, caret
and hint included β and the only error this tool raises is one it did not
expect.
// validate_filter { "expr": "state = Failed" }
{
"schema_version": 1,
"expr": "state = Failed",
"valid": false,
"error": "invalid filter 'state = Failed': unexpected input at position 6: '='\n state = Failed\n ^\nvalid operators: ==, !=, <, <=, >, >=, =~ (regex)\nsee docs/filter-dsl.md for fields, values, and diagnostic aliases",
"total_matched": null,
"total_dialogs": 2
}total_matched stays null on a parse failure rather than dropping to 0,
because a zero there reads as "parsed, matched nothing" β the opposite of what
happened. Fix the expression and the count arrives, measured against the whole
store with no cursor and no truncation. The example runs against
tests/pcap-samples/sip-rtp-g711.pcap:
// validate_filter { "expr": "state == 'Completed' and rtp.mos < 4.5" }
{
"schema_version": 1,
"expr": "state == 'Completed' and rtp.mos < 4.5",
"valid": true,
"error": null,
"total_matched": 1,
"total_dialogs": 2
}Read total_dialogs beside total_matched. It is the denominator, and it
comes back even on a parse failure: 0 matches out of 0 dialogs is an empty
capture, 0 out of 4000 is an expression that selects nothing, and no single
number separates them.
Aliases expand here exactly as they do everywhere else, because this tool calls
the same compiler the other tools call. A second parser would eventually accept
an expression list_dialogs then rejects, which is worse than no tool at all.
This response carries your own expression and two integers and nothing read off
the wire, so it appends no provenance note.
Everything the capture holds about one participant.
The rest of this surface keys on Call-ID, which is the right shape for "why did this call fail" and the wrong shape for the question that comes first. An operator handed a complaint holds a phone, a trunk or a subscriber β an address or a name β and wants to know what that thing has been doing. Agents reason in entities too. Answering from a dialog-centric surface meant listing every dialog, filtering client-side, and re-deriving per-entity facts the stores already hold.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
ip |
string? | The endpoint's address, v4 or v6. Mutually exclusive with user. An unparseable value fails with ip '...' is not an address. |
Give user instead. |
user |
string? | The user part of a SIP URI β alice from sip:[email protected]. Mutually exclusive with ip. |
Give ip instead. |
limit |
u32? | Bounds recent_dialogs, problem_call_ids and findings only. Ceiling is --mcp-max-rows (1000 by default), 0 means the default. |
50. |
Exactly one selector, and the tool refuses both. A caller passing an address and a name means either their intersection or their union, the two give different answers, and picking one silently would answer a question nobody asked:
{ "code": -32602,
"message": "give exactly one of ip or user, not both: the two select different sets, and whether you meant their intersection or their union changes the answer" }The example runs against
tests/pcap-samples/sip-rtp-g711.pcap:
// describe_endpoint { "ip": "10.0.2.20", "limit": 1 }
{
"schema_version": 1,
"endpoint_kind": "ip",
"endpoint": "10.0.2.20",
"dialogs": 2,
"by_method": { "INVITE": 2 },
"by_state": { "Completed": 1, "InCall": 1 },
"messages_sent": 5,
"messages_received": 5,
"calls": {
"invites": 2, "with_final_status": 2, "failed": 0,
"failure_rate_pct": 0.0, "by_final_status": { "200": 2 }
},
"registration": {
"applicable": false, "dialogs": 0, "succeeded": 0, "failed": 0,
"auth_loops": 0, "problem_call_ids": []
},
"user_agents": [],
"stack": {
"branch_cookie": "z9hG4bK", "branch_shape": "hex",
"tag_shape": "other", "callid_has_host": true,
"confidence": 0, "requests_read": 5, "mixed": false
},
"streams": {
"count": 2, "orphaned": 0, "packets": 839, "lost_packets": 0,
"max_jitter_ms": 0.0061987502747274615,
"codecs": ["β¦untrusted-capture-dataβ§PCMAβ¦/untrusted-capture-dataβ§",
"β¦untrusted-capture-dataβ§PCMUβ¦/untrusted-capture-dataβ§"]
},
"findings": {
"selectable": true, "findings": [], "total_matched": 0, "armed_kinds": [],
"note": "No detection rule is armed on this server, so no finding could have been recorded. ..."
},
"recent_dialogs": [ /* one fenced dialog summary */ ],
"truncated": true
}A phone behind NAT puts its own private address in the Contact of a REGISTER.
The registrar must notice that the packet arrived from a public address the
header does not name, and route later requests to where it came from. When it
does, everything works. When it does not, every inbound call goes to an
unroutable host and the phone never rings.
The observation is not the finding. Measured against the private corpus on 2026-09-08, 1,660 of 2,226 REGISTER contacts carry a private host β 74.6% β and that estate works. A rule firing on the observation alone would report three quarters of a healthy fleet.
| Field | Type | Description |
|---|---|---|
observation.contact_host |
string? | The host the Contact named, as written. Absent for Contact: *, which RFC 3261 section 10.2.2 defines as every binding and which names no host |
observation.contact_host_private |
bool? | Whether that host is one the public internet does not route to. Absent when the Contact names a domain: a name resolves somewhere the capture cannot see, and judging it either way would be a guess |
observation.source_public |
bool | Whether the REGISTER arrived from a public address |
observation.rewrite_required |
bool | The two together β the shape that calls for a NAT rewrite |
verdict |
string |
rewritten, not-rewritten or no-later-requests
|
requests_to_contact |
usize | Later requests addressed to the private Contact host |
requests_to_source |
usize | Later requests addressed to where the REGISTER came from |
is_finding |
bool | The conjunction: the shape called for a rewrite and no rewrite happened. This is the field to alert on |
Any request to the private host is a finding, not a majority of them. A proxy that sends some requests to the source and some to the header still sends some nobody receives, and 99 deliverable requests do not make the hundredth deliverable.
Silence is not success. A capture that ended before the first inbound call
reports no-later-requests, which is distinct from rewritten on purpose:
reading it as success is how a capture that stopped too early becomes a clean
bill of health.
Corroborated against the registered AoR, not the address. A request the
registrar sent to the private Contact carries no address belonging to the
endpoint β that is exactly what going to the wrong host means β so nothing
address-based can find it. The tie is the user part in the request URI and
To, matched case-sensitively per
RFC 3261 section 19.1.4.
diagnose_registration reports the observation for a
single call. The corroborated finding lives here, because settling it needs
what happened across the endpoint's other dialogs.
A banner names a PRODUCT and one product ships more than one stack.
top_talkers by=ua reports Asterisk PBX 20.15.2 as a single
row at share_pct: 100.0 across 507 dialogs, and tshark finds 18 distinct
source addresses behind that one label β running two different stacks, which
need different configuration, different NAT handling and different re-INVITE
handling. The banner cannot tell them apart. The syntax can.
| Field | Type | Description |
|---|---|---|
branch_cookie |
string? | The Via branch's cookie: the mandatory z9hG4bK (RFC 3261 section 8.1.1.7) plus any vendor extension before the per-transaction part. Echoed from the wire, bounded to MAX_FINGERPRINT_CHARS (32) |
branch_shape |
string? | What follows the cookie: uuid, as-hex, hex or other
|
tag_shape |
string? | The same vocabulary, for the From tag |
callid_has_host |
bool? | Whether the Call-ID carries an @host part |
inference |
string? | The library the observations point at β pjproject or chan_sip. Absent when they point nowhere |
confidence |
u8 | How many of the three observations agree with inference, out of three. 0 when sipnab inferred nothing |
requests_read |
usize | The denominator. A fingerprint from one request and one from four hundred are different claims |
mixed |
bool | True when this endpoint's own requests disagreed. A shared address, or a proxy relaying for others, carries both populations |
It names a library, never a product. z9hG4bKPj is pjproject's, and
pjproject ships inside Asterisk's res_pjsip, Grandstream UCM and FreePBX
alike β so naming a vendor from the shape would be a guess wearing the shape of
a measurement. The same discipline find_correlated applies
with identifier_match.
Two observations are the floor for an inference. The magic cookie alone is
mandatory for every compliant sender, so it distinguishes nothing, and
callid_has_host alone splits the world roughly in half. One agreeing
observation infers nothing, and an unrecognized stack reports its shapes with no
inference at all. sipnab does not force it into the nearest label.
Read from requests only. A response echoes the request's branch, From tag
and Call-ID verbatim, so reading one would fingerprint the party that SENT the
request as though it were the party that answered β and on any dialog those are
two different stacks. An endpoint that only answers reports requests_read: 0
and infers nothing.
limit bounds the page and never the counts. dialogs, by_method,
by_state, calls and streams describe every match, and only
recent_dialogs shortens β with truncated saying so. recent_dialogs runs
newest first, because an operator chasing a complaint wants what just happened,
and list_dialogs already pages the whole history oldest-first
for anyone sweeping it.
An ip matches on MESSAGES, not on the dialog's opening addresses. A dialog
records the socket pair its first message arrived on, so a proxy re-originating
mid-dialog, a re-INVITE from a second interface, and a BYE from the far side all
belong to the call while carrying different addresses. Matching the dialog
record alone would silently drop them, and the dropped ones are exactly the
transfers and hand-offs an operator is looking for.
A user matches the From or To user part EXACTLY. RFC 3261
section 19.1.4 makes the user
part case-sensitive, so Alice and alice name two URIs, and folding the case
here would file one endpoint's traffic under the name of a second.
Three fields answer differently for a user, and each says so rather than
inventing a number:
| Field | For a user
|
Why |
|---|---|---|
messages_sent, messages_received
|
Always 0
|
A URI user part names a party, not a socket. Which party sent a given message does not follow from it, and a count derived from something else under this name would be worse than zero. |
streams |
The streams linked to that user's dialogs | A user has no media identity of its own, so the Call-IDs are the only bridge. An ip matches the media 5-tuple directly and therefore sees orphans too. |
findings.selectable |
false, with a note
|
The alert engine files every finding against a source IP, so no key selects a user. Re-ask with the address. |
That selectable flag matters because an empty list has three readings, not
one. selectable: false means nobody could ask. An empty armed_kinds means
nothing was watching, and the note says so in the same words
security_findings uses. Only on a server with a detector
armed does an empty list mean the endpoint stayed clean.
failure_rate_pct guards its denominator: it comes back null, not 0.0, when
nothing reached a final status. A zero there hands a clean bill of health to an
endpoint nobody has measured. registration.applicable does the same job for
REGISTER β false means the endpoint sent none, so the four counts under it
read as "no input" rather than "no failures". A registration counts by the
REQUEST rather than by the dialog's method, because a REGISTER can arrive inside
a dialog something else opened once Call-ID reuse is in play, and a registration
nobody examined reports as a healthy one. The Call-IDs in problem_call_ids go
straight into diagnose_registration.
user_agents attributes a banner to whoever wrote it: User-Agent off requests
the address SENT and Server off responses it sent, per RFC 3261
section 20.41 and
RFC 3261 section 20.35. Reading them off
received messages would file the far end's software under this endpoint. For a
user only User-Agent on requests whose From user matches counts, the one
case where the URI identifies the party that wrote the header. Values arrive
fenced, and so do the summaries in recent_dialogs.
Incremental fetch of the dialogs updated after a cursor position.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
cursor |
string? | The previous response's next_cursor, verbatim (<RFC 3339>|<Call-ID>). A bare RFC 3339 timestamp also parses, and filters strictly after it. |
Starts from the beginning of the store. |
limit |
u32? | Ceiling is --mcp-max-rows (1000 by default). Higher clamps to it, 0 means the default. |
50 rows. |
fields |
string[]? | Field names to keep on each row, plus call_id. Same rules as list_dialogs β see Narrowing a row with fields. |
The whole row. |
Returns { dialogs, next_cursor, source_exhausted, source_stopped_early, capture_identity }, where
dialogs holds the same summary rows list_dialogs returns,
fencing included. This page object carries no returned, total_matched or
truncated β "how many are there" is not a question a tail can answer, because
the store keeps changing underneath it. Poll until dialogs comes back empty
and source_exhausted is true.
next_cursor is compound β <RFC 3339>|<Call-ID> β not a bare
timestamp. Dialogs can share an updated_at, so resuming from the
(updated_at, Call-ID) pair is what keeps a tie group split across a
page boundary from vanishing or arriving twice. Pass it back
unmodified. A client that rebuilds a bare timestamp from a dialog's
updated_at instead falls back to the pre-compound strictly after
filter and loses or repeats the tied dialogs β that bare-timestamp
form is still accepted, so the mistake is silent rather than an error.
| occurs in neither an RFC 3339 timestamp nor a valid Call-ID
(RFC 3261 word), so the split is unambiguous.
source_exhausted is false while more dialog updates can still
arrive and true once the capture source is fully drained β the end of
an -I pcap replay, or a live capture that has hit its
--count/--duration/--autostop stop condition. sipnab keeps
serving MCP after that, so this is the flag to poll to learn a replay
has finished: stop when it turns true instead of polling forever.
// tail_dialogs { "limit": 1 }
{
"dialogs": [
{
"call_id": "[email protected]",
"state": "Completed",
"method": "INVITE",
"from_user": "β¦untrusted-capture-dataβ§sippβ¦/untrusted-capture-dataβ§",
"to_user": "β¦untrusted-capture-dataβ§testβ¦/untrusted-capture-dataβ§",
"msg_count": 6,
"final_status_code": 200,
"duration_sec": 8.504,
"created_at": "2016-11-26T14:52:59.666393+00:00",
"updated_at": "2016-11-26T14:53:08.170676+00:00",
"timing": {
"pdd_ms": null,
"setup_ms": 4,
"retransmits": 0,
"duration_ms": 8499
},
"frame": "tests/pcap-samples/sip-rtp-g711.pcap#0@db88659b94678546",
"input_origin": "wire"
}
],
"next_cursor": "2016-11-26T14:53:08.170676+00:00|[email protected]",
"source_exhausted": true,
"source_stopped_early": false,
"capture_identity": {
"node": "capture-01",
"instance": "1d1a718cb5c33b7c52754-1",
"dialog_generation": 13,
"stream_generation": 2
}
}Waits until a filter selects at least one dialog, or until a deadline passes β
whichever happens first. One call replaces a tail_dialogs
loop in which every turn that finds nothing still costs a model call.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
filter |
string | A named alias or a filter DSL expression, exactly what list_dialogs accepts. validate_filter checks one without waiting. |
Required β the call fails. |
timeout_seconds |
u32? | Ceiling is --mcp-max-wait-seconds (60 by default). Higher clamps to it and sets timeout_clamped. 0 means "look once and answer". |
30 seconds. |
poll_interval_ms |
u32? | Floor is 100 ms. sipnab raises anything smaller and returns the effective value. | 500 ms. |
Returns { matched, stopped_because, dialogs, returned, total_matched, truncated, elapsed_ms, timeout_seconds, timeout_clamped, poll_interval_ms, polls, scans, source_exhausted, capture_identity }, where dialogs holds the
same summary rows list_dialogs returns, fencing included,
and is empty unless matched is true.
There is no page-size argument. This tool answers did it happen, and the rows
are evidence that it did rather than a page to work through β so it returns
what any list tool returns to a caller that named no limit: the default fifty
rows, cut to --mcp-max-rows when the operator set that lower.
list_dialogs with the same filter is the paging surface,
with the cursors and field projection that belong there. The response carries
reported either way, so a bounded page never reads as a smaller event than it
was.
A deadline that passes is an answer, not an error. It comes back as a
successful call carrying matched: false β because "the fault has not
reproduced" and "the tool broke" are different findings, and an agent has to
be able to tell them apart.
stopped_because names which of three endings this was, and the two negative
ones differ in whether asking again could ever help:
| Value | matched |
Meaning |
|---|---|---|
condition_met |
true |
The filter selected at least one dialog |
deadline |
false |
The wait ran out. Not yet β asking again may still find it |
source_exhausted |
false |
The capture source drained. Not ever, from this capture: the wait returns early rather than holding a slot to learn nothing |
The numbers this tool applies, and what moves each one:
-
Deadline default: 30 seconds (
DEFAULT_TIMEOUT_SECONDS). Used when the call names notimeout_seconds. -
Deadline ceiling: 60 seconds (
DEFAULT_MCP_MAX_WAIT_SECONDS, whichawait_conditionreads asDEFAULT_MAX_WAIT_SECONDS). The shipped value of--mcp-max-wait-seconds; the operator moves it, and a request above it is clamped rather than refused. -
Poll default: 500 milliseconds (
DEFAULT_POLL_INTERVAL_MS). Used when the call names nopoll_interval_ms. -
Poll floor: 100 milliseconds (
MIN_POLL_INTERVAL_MS). A smaller request meets that floor, so no caller can turn this into a spin loop, and the response carries the value actually used.
polls counts the looks and scans counts the looks that ran the filter. They
differ because both stores number their revisions, so a look at a capture that
has not moved resolves by comparing two integers rather than by re-running
the filter β an idle capture costs almost nothing to watch.
Nothing this call allocates outlives it. There is no subscription registry, no
per-client filter and no background task, which is why the deadline is not a
detail: it is what ends the server's obligation. --mcp-max-wait-seconds
bounds that obligation for the same reason --mcp-max-rows bounds a response,
and for a reason of its own β a waiting call occupies one of
--mcp-max-concurrent slots while producing nothing.
// await_condition { "filter": "state == Failed", "timeout_seconds": 20 }
{
"schema_version": 1,
"filter": "state == Failed",
"matched": true,
"stopped_because": "condition_met",
"dialogs": [
{
"call_id": "[email protected]",
"state": "Failed",
"method": "INVITE",
"from_user": "β¦untrusted-capture-dataβ§sippβ¦/untrusted-capture-dataβ§",
"to_user": "β¦untrusted-capture-dataβ§testβ¦/untrusted-capture-dataβ§",
"msg_count": 4,
"duration_sec": 1.204,
"created_at": "2016-11-26T14:52:59.666393+00:00",
"updated_at": "2016-11-26T14:53:00.870676+00:00",
"frame": "tests/pcap-samples/sip-rtp-g711.pcap#0@db88659b94678546"
}
],
"returned": 1,
"total_matched": 1,
"truncated": false,
"elapsed_ms": 4512,
"timeout_seconds": 20,
"timeout_clamped": false,
"poll_interval_ms": 500,
"polls": 10,
"scans": 3,
"source_exhausted": false,
"capture_identity": {
"node": "capture-01",
"instance": "1d1a718cb5c33b7c52754-1",
"dialog_generation": 41,
"stream_generation": 7
}
}Finds the other legs of one call β the far side of a B2BUA, SBC or PBX hop.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
call_id |
string | A Call-ID the store holds β the leg to correlate from. | Required β the call fails. |
limit |
u32? | Ceiling is --mcp-max-rows (1000 by default). Higher clamps to it, 0 means the default. |
50 legs. |
Returns source_call_id, legs, total_matched, heuristic_only,
capture_identity, timing_clock and schema_version. A leg the source has no
relationship with answers with an empty legs array and total_matched: 0,
never an error. There is no cursor β raise limit to reach past a truncated
answer, and a call with more than 1000 correlated legs is a capture problem
rather than a paging one.
The example runs against
tests/pcap-samples/sip-rtp-g711.pcap,
whose two calls share an SDP origin:
// find_correlated { "call_id": "[email protected]" }
{
"schema_version": 1,
"source_call_id": "[email protected]",
"legs": [
{ "call_id": "[email protected]", "score": 90, "strategy": "sdp_origin",
"identifier_match": true, "observed_gap_ms": null }
],
"total_matched": 1,
"heuristic_only": false,
"capture_identity": {
"node": "capture-01",
"instance": "1d1a718cb5c33b7c52754-1",
"dialog_generation": 13,
"stream_generation": 2
},
"timing_clock": null // non-null only when a timing_heuristic leg is returned
}Read strategy, not just score. Two strategies score 100 and they are not
the same claim:
strategy |
What it means | Survives a B2BUA? |
|---|---|---|
session_id |
RFC 7989 Session-ID matched |
Yes, by design |
x_call_id |
A configured header matched (X-Call-ID by default) |
Only if the SBC inserts it |
charging_vector_related_icid |
One leg's RFC 7315 related-icid names the other's icid-value
|
Yes β but only when the B2BUA chose to emit it (MAY) |
sdp_origin |
The RFC 8866 SDP origin tuple matched | Only if the SBC forwards SDP untouched |
charging_vector_icid |
Both legs carry the same RFC 7315 icid-value
|
Not by design: an ICID identifies one dialog, and a B2BUA is two |
via_branch |
Two INVITEs shared a Via branch | No: a new transaction gets a new branch |
timing_heuristic |
Same endpoint, close in time | Not an identifier at all |
The two P-Charging-Vector rows are one header and two different claims.
RFC 7315 section 4.6 says the ICID identifies a dialog, so a conformant B2BUA emits
a different icid-value on each side and charging_vector_icid is silent
across it β a match there means some intermediary copied a per-dialog
identifier onto a second dialog, which no RFC grants. The parameter that
addresses the hop is related-icid
(RFC 7315 section 4.6.4.1), and it is optional. Two limits
worth knowing before you rely on either: the first proxy generates the icid
(RFC 7315 section 5.6), so a leg arriving from an endpoint carries none and this is useless at
the access edge. And RFC 7315 section 4.6.2.2 lets the next hop "modify the contents", which
RFC 7315 section 6.6 calls normal behavior, so unlike Session-ID there is no end-to-end
constancy requirement at all. Full argument:
docs/design/icid-correlation.md.
Neither strategy puts the matched value in the response. RFC 7315 section 4.6's own
suggested construction embeds the generating proxy's hostname or address in the
icid, so it is operator-internal rather than opaque, and strategy names the
strategy and nothing else.
identifier_match carries that distinction as a boolean, so a caller can filter
on it without knowing which names mean what. heuristic_only says whether
every returned leg came from a guess β a call tree built only from timing is a
hypothesis, and an agent that cannot tell presents it as a finding.
observed_gap_ms appears only for timing_heuristic, because there it is
the evidence: a 15 ms gap on a quiet box and a 1,900 ms gap on a busy SBC score
identically and mean very different things. On an identifier match it is null,
since the elapsed time is not why they matched.
A caveat worth stating plainly: most deployments emit no correlation header at all. Where none is present, the only strategy left is the bottom row, and on a busy SBC unrelated calls routinely share an endpoint inside its window.
Every leg of one call, as a tree.
The TUI has stitched B2BUA legs together since the x key, and nothing on the
MCP surface reached it. find_correlated answers one hop
from one Call-ID, so an agent facing a carrier call across an SBC and a PBX β
three or four dialogs deep β had to call it on each result and assemble the
graph itself. Multi-leg is the normal case in carrier work, and
get_dialog hands back a quarter of the call without saying so.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
call_id |
string | Any leg of the call. The walk is symmetric, so naming the B-leg returns the same set rooted differently. A Call-ID the store does not hold fails with call_id '...' not found. |
Required β the call fails. |
limit |
u32? | Maximum legs including the root. Ceiling is --mcp-max-rows (1000 by default), higher clamps to it, 0 means the default. |
50 legs. |
The example runs against
tests/pcap-samples/sip-rtp-g711.pcap,
whose two calls share an SDP origin, with each leg's dialog summary trimmed to
its first three keys:
// get_call_tree { "call_id": "[email protected]" }
{
"schema_version": 1,
"root_call_id": "[email protected]",
"legs": [
{
"call_id": "[email protected]",
"depth": 0,
"parent_call_id": null,
"score": null,
"strategy": null,
"identifier_match": null,
"followed": true,
"dialog": { "call_id": "[email protected]", "state": "Completed", "method": "INVITE" }
},
{
"call_id": "[email protected]",
"depth": 1,
"parent_call_id": "[email protected]",
"score": 90,
"strategy": "sdp_origin",
"identifier_match": true,
"followed": true,
"dialog": { "call_id": "[email protected]", "state": "InCall", "method": "INVITE" }
}
],
"total_legs": 2,
"max_depth": 1,
"truncated": false,
"heuristic_edges": 0,
"total_messages": 10,
"first_activity": "2016-11-26T14:52:59.666393+00:00",
"last_activity": "2016-11-26T14:53:08.290927+00:00"
}Every leg past the root names the leg it came from (parent_call_id), how far
out it sits (depth), and the strategy and score of that one edge β the same
strategy vocabulary find_correlated tabulates. The root
carries null for all four, because the caller named it rather than any
strategy matching it.
The walk stops at a timing guess, and says so. Six of the seven correlation
strategies compare a value both legs carry, and the walk expands those
transitively. timing_heuristic links two INVITE dialogs that share an endpoint
address and started inside the leg-correlation window, so on a proxy carrying
ten calls a second every dialog sits within a guess of every other. Expanded
transitively that is not a call tree, it is the capture. Such an edge still
appears in legs with followed: false, so an agent that wants the next hop
calls get_call_tree again rooted there and sees what it costs.
Read followed before you read the shape. A leaf appears for two opposite
reasons β a timing_heuristic edge the walk declined to expand, or a leg the
row cap cut the walk short of β and followed reflects what the walk actually
visited rather than what it intended to visit. A leg still sitting in the queue
when limit ended the walk reports followed: false, because claiming it
had searched that subtree would describe work nobody did. truncated says
the cap fired at all.
heuristic_edges counts the guesses in the whole tree. Zero means every link is
an identifier both legs carried. total_messages is the size of the merged
ladder the TUI's extended flow renders, and first_activity and
last_activity bracket the call rather than the root leg.
An unknown call_id fails with invalid_params rather than answering with an
empty tree. A call with no other legs and a Call-ID nobody holds are different
facts, and one empty answer for both collapses them β a lone dialog comes back
as a one-leg tree with total_legs: 1 and max_depth: 0.
"Why did this one work and that one not?"
Parameters: call_id_a and call_id_b (both string, both required) β two
Call-IDs the store holds. Either one unknown fails with invalid_params naming
it. No optional parameters, and passing the same Call-ID twice is legal and
answers with an empty differences.
Each side reports call_id, state, final_status_code, msg_count,
methods (sorted) and hints. differences names the fields that differ, so
you are not diffing two objects by eye. The example compares the two calls in
tests/pcap-samples/sip-rtp-g711.pcap:
// compare_dialogs { "call_id_a": "[email protected]", "call_id_b": "[email protected]" }
{
"schema_version": 1,
"a": { "call_id": "[email protected]", "state": "Completed", "final_status_code": 200,
"msg_count": 6, "methods": ["ACK", "BYE", "INVITE"], "hints": [] },
"b": { "call_id": "[email protected]", "state": "InCall", "final_status_code": 200,
"msg_count": 4, "methods": ["ACK", "INVITE"], "hints": [] },
"differences": ["state", "msg_count", "methods"]
}final_status_code matches on both sides here and so stays out of
differences β the second call simply never sent a BYE.
Is today worse than yesterday, and where.
Baseline comparison is what turns a capture tool into an operations tool, and
nothing else on this surface reaches past the loaded capture. The answer has to
be a diff of AGGREGATES rather than of dialog lists β two captures never share a
Call-ID, so comparing rows finds every dialog different and says nothing. This
tool reads both files, groups each one the way
aggregate_dialogs groups, and ranks the buckets by how
far they MOVED.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
a |
string | The baseline: a bare filename inside --mcp-file-root. |
Required β the call fails. |
b |
string | The capture to hold against it, in the same root. | Required β the call fails. |
dimensions |
string[]? | From the aggregate_dialogs vocabulary: state, response_code, method, from.user, to.user, ua, src.ip, dst.ip, rtp.codec. A repeated name de-duplicates rather than failing. Anything else fails naming the nine. |
["state", "response_code"] β how many calls reached each state, and which codes they ended on. |
top_n |
integer? | Rows per dimension. Everything past it sums into other. Ceiling is --mcp-max-rows. |
50. |
The example diffs two files in tests/pcap-samples:
// compare_captures { "a": "sip-rtp-g711.pcap", "b": "sip-auth-failure.pcapng",
// "dimensions": ["state"], "top_n": 4 }
{
"schema_version": 1,
"a": { "filename": "sip-rtp-g711.pcap", "packets": 852, "dialogs": 2,
"streams": 2, "dialogs_dropped": 0, "read_error": null },
"b": { "filename": "sip-auth-failure.pcapng", "packets": 8, "dialogs": 2,
"streams": 0, "dialogs_dropped": 0, "read_error": null },
"dimensions": [
{
"dimension": "state",
"buckets": [
{ "value": "Completed", "a": 1, "b": 0, "delta": -1 },
{ "value": "Failed", "a": 0, "b": 1, "delta": 1 },
{ "value": "InCall", "a": 1, "b": 0, "delta": -1 },
{ "value": "Terminated", "a": 0, "b": 1, "delta": 1 }
],
"other": { "value": "(other)", "a": 0, "b": 0, "delta": 0 },
"distinct_values": 4
}
],
"summary": "'sip-rtp-g711.pcap' (2 dialogs) is the baseline; 'sip-auth-failure.pcapng' (2 dialogs) is held against it, so delta is b minus a. Neither is the capture this server has loaded."
}Buckets rank by absolute movement, not by size. The question is what
changed, and the largest bucket is usually the one that changed least, so "today
is worse than yesterday, and here is where" reads off the first row. delta is
b - a throughout. A value absent from one side counts as zero there rather
than as missing, because a response code appearing only in b IS the finding.
Neither file becomes the loaded capture. Both reads land in private stores that the call drops before it builds the answer, so what an agent asked a minute ago stays true afterwards. The read runs on a blocking thread, since two whole captures inside a tool handler would otherwise hold the single runtime thread the MCP server and the REST API share.
One thing does escape that isolation, which is why this tool carries
readOnlyHint: false: reading a file through the shared pipeline bumps the
PROCESS-WIDE undecodable-frame tallies and the ICMP evidence store that
get_capture_report reports. It destroys nothing, and
something observable moves. This reuses
open_capture's existing behavior rather than inventing a new
one.
Read the two CaptureSide blocks before the deltas. dialogs_dropped above
zero means that side hit the dialog ceiling and every count under it is a FLOOR
rather than a total, and read_error names a read that stopped early. A
truncated pcap is the normal state of a rotating capture's newest member, so a
partial read reports rather than fails β and summary restates both conditions
in words, because a comparison against a partial population is a wrong answer
wearing the shape of a right one.
Four refusals, each cheaper than the mistake it prevents:
| Situation | Answer |
|---|---|
No --mcp-file-root
|
file tools are disabled: start sipnab with --mcp-file-root <DIR> to enable them |
| A path rather than a bare name | '../../etc/passwd' is not a bare filename. These tools take a name, not a path, ... |
a and b resolving to one file |
'x.pcap' and 'x.pcap' are the same file (...); a capture compared with itself differs from itself nowhere |
| A dimension outside the vocabulary | cannot compare on 'hour'; one of: state, response_code, ... |
The vocabulary check runs BEFORE either file opens, because reading two captures
is the most expensive thing this surface does and a mistyped dimension must not
cost it. The same-file check runs after the resolver fully resolves both names,
so it catches two spellings of one file as well as the same name twice. And a
capture that yielded no dialogs AND reported a read error fails with
internal_error rather than diffing: every bucket would appear to collapse to
zero, which is a finding that is not there.
Note the dimension list here is the plain
aggregate_dialogs nine.
group_dialogs's three extras β to_domain, hour,
next_hop β do not apply, and hour in particular would compare two captures
of different days bucket by bucket.
You have a call-id. These explain what happened to that one call -- its signaling, its media, and the specific negotiations that tend to break.
Start here. The first question in VoIP triage is which half of the stack failed. Signaling decides whether a call connects. RTP decides whether you can hear it. They have different causes and different fixes, and confusing them is the most common wrong turn β so ask this before anything else.
Parameters: call_id (string, required) β a Call-ID the store holds, as
returned by list_dialogs. An unknown one fails with
invalid_params (-32602) naming the value. There are no optional parameters.
// triage_call { "call_id": "[email protected]" }
{
"schema_version": 1,
"call_id": "[email protected]",
"verdict": "media", // "signaling" | "media" | "both" | "none"
"state": "Completed",
"final_status_code": 200,
"signaling": { "problem": false, "hints": [] },
"media": {
"problem": true,
"one_way_audio": true,
"nat_mismatch": false,
"no_media": false,
"stream_count": 1,
"hints": ["RTP flowed 10.0.2.15:27942 -> 10.0.2.20:6000 only (SSRC 0x343da99b). No reverse media flow detected."]
},
"termination": { // absent when nothing said why the call ended
"cause_code": 38,
"cause_text": "Network out of order",
"protocol": "Q.850",
"source_header": "Reason",
"frame_ref": 7
}
}A clean 200 OK with one-way audio is a media problem. Nothing in the SIP
exchange is wrong, and time spent reading it is time lost.
Where to go next, by verdict:
| Verdict | Next tool |
|---|---|
signaling |
explain_response_code on the final code, then get_dialog
|
media |
rtp_stats, and check_codec_negotiation if the call failed |
both |
Signaling first β media symptoms are often downstream of a failed negotiation |
none |
The call is fine. Check you have the right Call-ID |
termination is a fact about the call, not a fault, so it never moves
verdict. A call that cleared normally reports verdict: "none" and a
Q.850 cause of 16 at the same time, and both are true.
It answers the question the verdict cannot: "the call ended" and "the call ended because the far end was out of order" are different answers, and only the second closes a ticket.
| Field | Type | Description |
|---|---|---|
cause_code |
u16? | The cause number, on the scale protocol names. Absent when the message named a protocol and no cause β never zero for a missing value, because 0 is itself a Q.850 cause |
cause_text |
string? | The human-readable cause. sipnab strips control characters and keeps the first MAX_CAUSE_TEXT_CHARS (200) β far past every value a gateway produces, and short enough that a crafted header cannot spend a context window |
protocol |
string? | Which scale cause_code is on. 16 is normal clearing in Q.850 and is not a SIP status code at all, so a code without this is not interpretable |
source_header |
string | Which header carried the cause: Reason (RFC 3326) or X-Asterisk-HangupCauseCode
|
frame_ref |
usize | Index into the dialog's message list of the message that carried it |
sipnab reads the last message that names a cause β a call that went through
a challenge, a retry and a clear carries more than one, and the one that says
why it ended is the last thing said. Reason on a BYE or a CANCEL counts,
which is the case this exists for: a call that clears normally has no failure
response to hang a cause on.
Where one message carries both protocols, sipnab picks the non-SIP one,
because a SIP cause restates final_status_code and that is already its own
field. A Q.850 cause is the gateway's, and the fact nothing else recovers.
The same block appears on get_dialog_report and on
GET /v1/dialogs/{call_id}/report, from one assembler.
Paginated dialog with full SIP messages.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
call_id |
string | A Call-ID the store holds. An unknown one fails with call_id 'x' not found. |
Required β the call fails. |
max_messages |
u32? | Ceiling is --mcp-max-rows (1000 by default). Higher clamps to it, 0 means the default. |
100 messages. |
cursor |
u32? | A message index, counting from 0. Unlike the dialog cursors elsewhere, this one is a plain integer, and past-the-end returns an empty page rather than an error. | Starts at message 0. |
Returns:
| Field | Type | Description |
|---|---|---|
dialog |
object | The same summary list_dialogs returns, with from_user and to_user fenced. |
messages |
object[] | This page of full messages, in dialog order. |
total_messages |
usize | Messages in the whole dialog, so truncated is unnecessary here. |
next_cursor |
u32? | Index to resume at. null on the final page. |
complete |
bool |
true when this page reaches the end of the dialog. |
Every messages row carries call_id, is_request, cseq (method and
number), from, to, src, src_port, dst, dst_port, transport,
timestamp, frame and schema_version. Two more appear when sipnab observed
them and are left out rather than sent as null when it did not:
-
dscpβ the RFC 2474 marking on the frame that carried this message, 0 to 63. Absent means no IP header was read, which is what a HEP-delivered message looks like.0means sipnab saw the frame and the frame said best effort. Unmarked signaling is itself a common fault, so the two must not collapse into one value. -
input_originβwire,heporuprobe, the source that delivered this message.
The rest depends on the direction, so branch on is_request rather than
expecting one shape:
-
A request adds
method, andcontactandsdpwhen it carried them. -
A response adds
status_code,reason,response_contextandua, and carries nomethodβ readcseq.methodfor the transaction it answers.
This tool fences, and it is the one that returns the most capture text.
reason,from,to,contact,ua,sdpandmalformedinmessages[]come back wrapped inβ¦untrusted-capture-dataβ§markers, the same treatmentget_messagegives them. The SDP body keeps its line structure,--mcp-max-body-bytesbounds it, and the header values arrive capped with every control character removed.This page said the opposite until 0.5.134 β that the fields came back verbatim and that a reader should reach for
get_messageinstead. Both tools fenced the whole time. Treat every string inmessages[]as attacker-written regardless: the markers say where the text came from, they do not make it safe.
The example runs against tests/pcap-samples/sip-rtp-g711.pcap:
// get_dialog { "call_id": "[email protected]", "max_messages": 1 }
{
"complete": false,
"total_messages": 6,
"next_cursor": 1,
"dialog": {
"call_id": "[email protected]",
"state": "Completed",
"method": "INVITE",
"from_user": "β¦untrusted-capture-dataβ§sippβ¦/untrusted-capture-dataβ§",
"to_user": "β¦untrusted-capture-dataβ§testβ¦/untrusted-capture-dataβ§",
"msg_count": 6,
"final_status_code": 200,
"duration_sec": 8.504,
"created_at": "2016-11-26T14:52:59.666393+00:00",
"updated_at": "2016-11-26T14:53:08.170676+00:00",
"timing": { "pdd_ms": null, "setup_ms": 4, "retransmits": 0, "duration_ms": 8499 },
"frame": "tests/pcap-samples/sip-rtp-g711.pcap#0@db88659b94678546",
"input_origin": "wire"
},
"messages": [
{
"call_id": "[email protected]",
"method": "INVITE",
"is_request": true,
"cseq": { "method": "INVITE", "number": 1 },
"from": "β¦untrusted-capture-dataβ§\"PCMU/8000\" <sip:[email protected]:5060>;tag=1β¦/untrusted-capture-dataβ§",
"to": "β¦untrusted-capture-dataβ§test <sip:[email protected]:5060>β¦/untrusted-capture-dataβ§",
"contact": "β¦untrusted-capture-dataβ§sip:[email protected]:5060β¦/untrusted-capture-dataβ§",
"src": "10.0.2.20",
"src_port": 5060,
"dst": "10.0.2.15",
"dst_port": 5060,
"transport": "UDP",
"timestamp": "2016-11-26T14:52:59.666393+00:00",
"sdp": "β¦untrusted-capture-dataβ§v=0\no=- 42 42 IN IP4 10.0.2.20\ns=-\nc=IN IP4 10.0.2.20\nt=0 0\nm=audio 6000 RTP/AVP 0\na=rtpmap:0 PCMU/8000\na=recvonly\nβ¦/untrusted-capture-dataβ§",
"frame": "tests/pcap-samples/sip-rtp-g711.pcap#0@db88659b94678546",
"dscp": 0,
"input_origin": "wire",
"schema_version": 1
}
]
}The SDP above arrives with \n line endings, not the \r\n the wire carried.
Fencing strips every control character a quoting document could act on and keeps
only \n and \t, so a client that reassembles the body and compares it byte
for byte against the packet finds a difference that is sipnab's, not the
endpoint's. Read it as text, not as the original octets β reach for
show_evidence when you need those.
total_messages is 6 and next_cursor is 1, so five messages remain. Call
again with cursor: 1 to continue, and stop when complete turns true.
Per-call diagnostic report for one Call-ID. Backed by
output::generate_call_report β same content as --call-report.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
call_id |
string | A Call-ID the store holds. An unknown one fails with invalid_params (-32602) naming the value. |
Required β the call fails. |
format |
string? |
"json", "markdown" or "text". Anything else fails with unknown format 'x', expected json|markdown|text. |
"json". |
"json" answers with the structured object below. "markdown" and "text"
answer with one text block holding the rendered report, byte-identical to what
render_ladder produces for the same dialog. All three append
the provenance note as a second content block. The two rendered formats also
carry the INCOMPLETE RUN block described under
get_capture_report when the capture was not read in
full. "json" carries source_exhausted and source_stopped_early as fields
instead.
The example runs against tests/pcap-samples/sip-rtp-g711.pcap:
// get_dialog_report { "call_id": "[email protected]" }
{
"schema_version": 1,
"call_id": "[email protected]",
"state": "Completed",
"method": "INVITE",
"final_status_code": 200,
"final_status_reason": "OK",
"from": "sipp",
"from_display": "PCMU/8000",
"to": "test",
"to_display": "test",
"msg_count": 6,
"duration_sec": 8.504,
"frame": "tests/pcap-samples/sip-rtp-g711.pcap#0@db88659b94678546",
"timing": {
"retransmits": 0,
"setup_ms": 4,
"teardown_ms": 0,
"trying_delay_ms": 0
},
"diagnosis": {
"hints": [
"RTP flowed 10.0.2.15:27942 -> 10.0.2.20:6000 only (SSRC 0x343da99b). No reverse media flow detected."
],
"nat_mismatch": false,
"no_media": false,
"one_way_audio": true
},
"sdp_timeline": [ /* the same rows get_sdp_timeline returns */ ],
"streams": [ /* the same rows rtp_stats returns, without mos */ ]
}A termination block appears beside these fields on a call that said why it
ended β the same object, from the same assembler, that
triage_call documents above. It is absent from the example
because this sample's BYE carries no Reason header and no vendor
equivalent, which is the honest rendering: sipnab drops the block, and never
sends null, when nothing on the wire named a cause.
This report bundles what triage_call,
get_sdp_timeline and rtp_stats answer
separately, so one call replaces three when you already know which call to read.
Its streams rows omit mos and mos_grounded β ask rtp_stats when the
question is audio quality rather than what the call did.
Unlike the summary rows elsewhere, from and to here arrive unfenced, and
the trailing provenance note explains why: a rendered report interleaves
sipnab's diagnosis with header values the sender wrote, and fencing the whole
document would tell an agent to distrust the analysis as well.
Single SIP message at a given zero-based index.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
call_id |
string | A Call-ID the store holds. | Required β the call fails. |
index |
u32 |
0 to msg_count - 1 for that dialog. |
Required β the call fails. |
Where an index comes from, because the number means nothing on its own:
msg_count on any list_dialogs row is the count, so the last
valid index is msg_count - 1. get_dialog pages messages from
its cursor, so the Nth row of that page sits at cursor + N, and every
lint_dialog finding reports the message_index it fired on.
An index at or past the end fails with index 999 out of range for dialog with 7 messages β the count is in the message, so a caller that guessed corrects
itself without another round trip.
Returns one message in the same shape get_dialog uses for a
messages row, plus the provenance note as a second content block. The
difference is the fencing: this tool wraps from, to, contact, sdp, ua,
reason, malformed and extension_headers, and leaves call_id, addresses,
ports, method, status_code, cseq and timestamps verbatim so they pass into
the next call.
// get_message { "call_id": "[email protected]", "index": 0 }
{
"schema_version": 1,
"call_id": "[email protected]",
"method": "INVITE",
"is_request": true,
"cseq": { "method": "INVITE", "number": 1 },
"from": "β¦untrusted-capture-dataβ§\"PCMU/8000\" <sip:[email protected]:5060>;tag=1β¦/untrusted-capture-dataβ§",
"to": "β¦untrusted-capture-dataβ§test <sip:[email protected]:5060>β¦/untrusted-capture-dataβ§",
"contact": "β¦untrusted-capture-dataβ§sip:[email protected]:5060β¦/untrusted-capture-dataβ§",
"sdp": "β¦untrusted-capture-dataβ§v=0\no=- 42 42 IN IP4 10.0.2.20\ns=-\nc=IN IP4 10.0.2.20\nt=0 0\nm=audio 6000 RTP/AVP 0\na=rtpmap:0 PCMU/8000\na=recvonly\nβ¦/untrusted-capture-dataβ§",
"src": "10.0.2.20",
"src_port": 5060,
"dst": "10.0.2.15",
"dst_port": 5060,
"transport": "UDP",
"timestamp": "2016-11-26T14:52:59.666393+00:00",
"frame": "tests/pcap-samples/sip-rtp-g711.pcap#0@db88659b94678546",
"dscp": 0,
"input_origin": "wire",
"extension_headers": [
"β¦untrusted-capture-dataβ§Via: SIP/2.0/UDP 10.0.2.20:5060;branch=z9hG4bK-1966-1-0β¦/untrusted-capture-dataβ§",
"β¦untrusted-capture-dataβ§Max-Forwards: 70β¦/untrusted-capture-dataβ§",
"β¦untrusted-capture-dataβ§Content-Type: application/sdpβ¦/untrusted-capture-dataβ§",
"β¦untrusted-capture-dataβ§Content-Length: 123β¦/untrusted-capture-dataβ§"
]
}dscp and input_origin carry the meanings get_dialog gives
them. sipnab omits either key rather than sending null when it observed
neither.
Every header the fields above do NOT already carry, in wire form
("Name: value") and in wire order β one entry per header line.
The named fields are a closed list, and every vendor-, carrier- and
SBC-specific fact lives outside it: X-Asterisk-*, Reason, Diversion,
P-Asserted-Identity, Remote-Party-ID, Require, RSeq. The parser has
always retained them, so this was a projection gap rather than a parsing one.
Before it existed the only route to one of those values was
decode_evidence, which needs a server-side
--mcp-file-root opt-in, a reachable original capture, and one call per frame.
Six header names never appear here, because they are already fields of their
own: Call-ID, From, To, Contact, User-Agent and CSeq. sipnab drops
nothing else β Via and Content-Length are in the list above because the
sample carries them and nothing pretends otherwise.
The list keeps duplicates and wire order. Via is a stack whose order is
the route the request took, so three Via lines are three entries in the order
they arrived.
Fenced whole, name included. For an extension header the NAME is the
sender's choice as much as the value is, so the marker pair wraps the entire
Name: value string. This widens the prompt-injection surface the threat model
already tracks β see deferred-and-declined.md
β it does not introduce a new one: the same bytes were already reachable
through search_messages, which returns the raw message.
No new ceiling. The parser refuses a message with more than 200 headers or a header line longer than 8 KiB, so what reaches this field is already bounded by the operator's own limits.
Call-flow ladder for one Call-ID.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
call_id |
string | A Call-ID the store holds. | Required β the call fails. |
format |
string? |
"markdown" or "text". Anything else, "json" included, fails with unknown format 'x', expected markdown|text. |
"markdown". |
Returns one text content block holding the rendered report, and the provenance
note as a second block. There is no JSON shape here β ask
get_dialog_report with format: "json" for fields a
program can read.
render_ladder { "call_id": "[email protected]" }Output is byte-identical to the report
sipnab -N --call-report <id> --markdown --no-cli-print /
sipnab -N --call-report <id> --no-cli-print writes for the same dialog.
--no-cli-print matters for the comparison: without it the CLI writes the whole
capture's per-message dump ahead of the report, and the tool never does.
Over a capture that was not read in full β still loading, or a read that
stopped before the file's end β the tool appends an INCOMPLETE RUN block
after the ladder, which --call-report does not. The ladder itself does not
change: the block goes after it, so no line of the drawing moves.
# Call Report: [email protected]
## Summary
| Field | Value |
|-------|-------|
| Time | 2016-11-26 14:52:59 -> 14:53:08 (8s) |
| From | sipp |
| To | test |
| State | Completed |
## Timing
| Metric | Value |
|--------|-------|
| PDD | - |
| Setup | 0.00s |
| Ring | - |
| Teardown | 0.00s |
| Retransmits | 0 |
## Media Streams
| SSRC | Codec | Source | Destination | Packets | Jitter | Loss |
|------|-------|--------|-------------|---------|--------|------|
| 0x343da99b | PCMU | 10.0.2.15:27942 | 10.0.2.20:6000 | 425 | 0ms | 0.0% |
## Issues
- RTP flowed 10.0.2.15:27942 -> 10.0.2.20:6000 only (SSRC 0x343da99b). No reverse media flow detected.
The offer/answer exchanges in order β codecs, media address, port and mode per negotiation, including re-INVITEs. Use it when audio changed mid-call, or when the two ends disagree about the codec.
Parameters: call_id (string, required) β a Call-ID the store holds. No
optional parameters.
Every exchange carries direction (offer or answer), codecs,
media_addr, media_port, mode (sendrecv, sendonly, recvonly or
inactive), timestamp, and event β null unless sipnab classified the
exchange, as with the MediaAnchorChange below. A call carrying no SDP answers
with an empty exchanges array rather than an error.
// get_sdp_timeline { "call_id": "[email protected]" }
{
"call_id": "[email protected]",
"exchanges": [
{
"codecs": [
"PCMU"
],
"direction": "offer",
"event": null,
"media_addr": "10.0.2.20",
"media_port": 6000,
"mode": "recvonly",
"timestamp": "2016-11-26T14:52:59.666393+00:00"
},
{
"codecs": [
"PCMU",
"telephone-event"
],
"direction": "answer",
"event": "MediaAnchorChange",
"media_addr": "10.0.2.15",
"media_port": 27942,
"mode": "sendonly",
"timestamp": "2016-11-26T14:52:59.670743+00:00"
}
],
"schema_version": 1
}The offer promises recvonly on 10.0.2.20:6000 and the answer replies
sendonly from 10.0.2.15:27942, which is why
triage_call calls this capture one-way media rather than a
fault.
For 488 Not Acceptable Here, which usually means nobody offered the far end a
codec it accepts.
Parameters: call_id (string, required) β a Call-ID the store holds. No
optional parameters.
Returns offered, answered, common, result, sdp_exchange_count,
final_status_code, call_id and schema_version. Codec names come from the
SDP unfenced, because they are tokens from a registry rather than free text.
// check_codec_negotiation { "call_id": "[email protected]" }
{
"schema_version": 1,
"call_id": "[email protected]",
"offered": ["PCMU"],
"answered": ["PCMU", "telephone-event"],
"common": ["PCMU"],
"result": "ok",
"final_status_code": 200,
"sdp_exchange_count": 2
}result has five values, and the distinction matters:
| Result | Meaning | What to do |
|---|---|---|
ok |
The two sides agreed | Codecs are not your problem |
no_common_codec |
Both offered codecs, none shared | A codec policy problem β compare the lists |
no_answer |
An offer went out, nothing came back | The call did not get far enough to negotiate |
sdp_present_but_no_codecs |
Both sides exchanged SDP, but neither listed a codec | Look at the SDP itself β a malformed or media-less m= line |
no_sdp_in_capture |
No SDP at all | Not a codec problem. Hold with inactive media, or a reject before any offer |
no_answer and no_sdp_in_capture are deliberately separate: reporting the
first for the second sends you hunting a reply that was never expected.
"Is this phone online?" β a different question from "why did this call fail?".
Parameters: call_id (string, required) β a Call-ID the store holds. No
optional parameters. The call need not carry a REGISTER, and the answer says
so when it does not.
Read applicable first. It is false for a dialog carrying no REGISTER,
and the response then holds only call_id, reason and schema_version β no
hints, no registration_failure. Reporting a healthy registration for a call
that never attempted one would be worse than admitting the question does not
apply:
// diagnose_registration { "call_id": "[email protected]" } β an INVITE dialog
{
"schema_version": 1,
"call_id": "[email protected]",
"applicable": false,
"reason": "this dialog carries no REGISTER request"
}When it does apply, registration_failure is null for a registration that
worked, and otherwise names the kind β rejected, shortened_expiry or an
auth loop reported through auth_loop. evidence lists the message indexes
behind the verdict, ready for get_message. The example runs
against
tests/pcap-samples/sip-auth-failure.pcapng:
// diagnose_registration { "call_id": "[email protected]" }
{
"schema_version": 1,
"call_id": "[email protected]",
"applicable": true,
"final_status_code": null,
"auth_loop": null,
"registration_failure": {
"kind": "rejected",
"code": 403,
"evidence": [0, 3],
"requested_expiry_sec": null,
"granted_expiry_sec": null
},
"contact_rewrite": {
"contact_host": "203.0.113.1:42952",
"contact_host_private": false,
"source_public": true,
"rewrite_required": false
},
"hints": [
"Call failed: 403 Forbidden.",
"Registration rejected: 403 Forbidden. The endpoint answered an authentication challenge and the registrar refused the credentials it offered, so the fault is in the account, its password or its permission to register β none of which is a reachability problem."
]
}final_status_code is null here even though the registrar answered 403,
because the dialog never reached a state that records one. Read
registration_failure.code for the status that decided the verdict.
A phone behind NAT registers its own private address, and the registrar must notice the packet came from a public address the header does not name and route later requests there instead. When it does not, every inbound call goes to an unroutable host and the phone never rings.
rewrite_required is the conjunction that shape needs:
contact_host_private && source_public. The sample above reports false β a
public Contact from a public source needs nothing rewritten, which is the
honest reading and not a fault ruled out.
| Field | Type | Description |
|---|---|---|
contact_host |
string? | The host the Contact named, as written. Absent for Contact: *, which RFC 3261 section 10.2.2 defines as every binding and which names no host |
contact_host_private |
bool? | Whether that host is one the public internet does not route to. Absent when the Contact names a domain, which resolves somewhere the capture cannot see |
source_public |
bool | Whether the REGISTER arrived from a public address |
rewrite_required |
bool | The two together |
This is deliberately not a finding. 1,660 of 2,226 REGISTER contacts in the
private corpus carry a private host β 74.6%, measured 2026-09-08 β and that
estate works. What separates the working case from the broken one is which
address later requests actually used, and one dialog cannot see that. Ask
describe_endpoint, which crosses an endpoint's dialogs
and reports verdict and is_finding.
The block is absent when the dialog carries no REGISTER β the same
applicable: false case the top of this section describes.
"Was this call recorded, and who is on the recording?" A session recording
client (SRC) β OpenSIPS's siprec module, an SBC β sends the recording server
an INVITE whose body carries an application/rs-metadata+xml part describing
what it is about to record. sipnab reads that part off the wire and returns it.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
call_id |
string | A Call-ID the store holds. | Required β the call fails. |
Read recorded first. It is false when the dialog carried no SIPREC
metadata, and the response then holds only call_id, reason,
capture_identity and schema_version. The reason says what that does and
does not mean: sipnab reports what reached the capture point, so a recorder
signaling on a path it cannot see is indistinguishable here from a call nobody
recorded. It is not a statement that the call went unrecorded.
Otherwise siprec carries:
| Field | Answers |
|---|---|
session_id |
The recording session the SRC opened, which ties two legs of one recording together |
mode |
What the SRC declared it is recording, from <datamode> β complete for the whole session |
participants |
Each party, with participant_id, aor and name when the SRC sent one. A missing name is absent, not empty: an SRC that has no display name writes a self-closing <nameID>
|
streams |
Each recorded stream, with stream_id, label and participant_id
|
label and participant_id are the two that matter. The label is the SDP
a=label naming the m= line the SRC cut the recorded stream from, so on a
call with audio and video it says which is which. The participant_id is the party
that sends that stream, which is the only route from a recorded stream back
to a person β nothing inside the stream element names one. sipnab reads it from
<participantstreamassoc>, whose <send> children mean ownership and whose
<recv> children do not. The party at the other end hears a stream without
owning it.
// siprec_metadata { "call_id": "[email protected]" }
{
"schema_version": 1,
"call_id": "[email protected]",
"recorded": true,
"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" }
]
}
}sipnab reads SIPREC and does not speak it. It is not a session recording client and not a recording server β it observes the signaling between them, which is the same posture it takes toward every other protocol it reads.
"The MOS is 3.6 β why?" rtp_stats gives the score. This gives the facts
underneath it, and each one says what kind of number it is.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
call_id |
string | A Call-ID the store holds. | Required β the call fails. |
Read applicable first. It is false when no RTP stream belongs to the
dialog, and the response then holds only call_id, reason,
capture_identity and schema_version. An empty streams array would read as
"sipnab checked the media and it was fine", which is a different claim from "no
media reached the capture point".
Otherwise streams carries one entry per stream, each with five blocks:
| Block | Answers | The honesty flag |
|---|---|---|
qos |
Which queue the sender asked the network to put this media in |
marking_observed β false for a HEP-fed stream, where sipnab saw no IP header. dscp: 0 means observed best effort, a real and frequently wrong marking |
jitter |
The interarrival jitter, and whether it is a measurement |
grounded β false when 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_ms at all |
delay |
The one-way delay behind the published MOS |
assumed β true when neither the operator nor any RTCP supplied one |
silence |
Comfort-noise frames and detected silence periods | -- (counts, not estimates) |
endpoint_reported |
What the far end said over RTCP | The whole block is the flag. Omitted entirely when no RTCP arrived |
endpoint_reported sits apart from everything beside it on purpose. Nobody
authenticates RTCP and anyone can forge it, and a report describes the path
from the sender to the reporter β on a mid-path capture, a different segment
from the one sipnab watches. The two disagreeing is normal and informative, and
merging them would destroy exactly that signal. Nothing under this key feeds the
MOS.
qos.remarked_to appears only when the stream's last packet carries a different
code point from its first β an SBC or a policy boundary rewriting the marking in
flight. Its presence is the finding, and a steady stream omits it rather than
repeating the same number.
The example runs against
tests/pcap-samples/sip-rtp-g711.pcap:
// media_diagnostics { "call_id": "[email protected]" }
{
"schema_version": 1,
"call_id": "[email protected]",
"applicable": true,
"streams": [
{
"ssrc": "0x343da99b",
"src": "10.0.2.15:27942",
"dst": "10.0.2.20:6000",
"codec": "PCMU",
"packets": 425,
"qos": {
"marking_observed": true,
"dscp": 0,
"name": "CS0 / default (best effort)",
"expedited": false
},
"jitter": {
"grounded": true,
"clock_basis": "rfc3551",
"clock_rate_hz": 8000,
"measured_ms": 0.0054046519599899685
},
"delay": { "source": "assumed", "assumed": true, "one_way_ms": 100.0 },
"silence": { "cn_frames": 0, "periods": 0, "total_ms": 0 }
}
],
"capture_identity": {
"instance": "6dac718cb96d767d0f490-1",
"node": "sbc-1",
"dialog_generation": 13,
"stream_generation": 2
}
}Three things in that answer are worth reading together. The media is in the default queue, so it competes with bulk traffic β the most common cause of jitter that adding bandwidth does not fix. The jitter figure IS a measurement, because payload type 0 has a clock rate RFC 3551 Table 4 fixes. And the delay behind the MOS is a default, not anything this capture showed, so a MOS built on it is only as good as that assumption.
clock_basis has three values: rfc3551 (a static payload type), rtpmap (a
dynamic one an SDP named), and assumed (neither, and the reason grounded is
false).
Per-stream RTP quality for one call, or across the whole capture.
This tool has two modes, and call_id is the switch. Pass it for one
dialog's streams. Omit it to sweep the whole capture. Five parameters belong to
the sweep alone:
| Name | Type | Legal values | If omitted |
|---|---|---|---|
call_id |
string? | A Call-ID the store holds. | Switches modes β the tool sweeps every stream in the capture, orphans included. |
min_mos |
f64? | Sweep only. Keeps streams scoring at or above this. Rejected alongside call_id. |
No lower bound. |
max_mos |
f64? | Sweep only. Keeps streams scoring strictly below this. Rejected alongside call_id. |
No upper bound. |
orphaned |
bool? | Sweep only. true keeps only streams no dialog claims, false keeps only streams a dialog claimed. Independent of the MOS bounds β an orphan usually has no dialog to ground its clock, so requiring a bound alongside would filter out most of what this exists to find. |
Both kinds sweep. |
limit |
u32? | Sweep only. Ceiling is --mcp-max-rows (1000 by default), higher clamps to it, 0 means the default. |
50 streams. |
cursor |
string? | Sweep only. The previous response's next_cursor, verbatim (<RFC 3339>|0xSSRC@src>dst). |
Starts at the earliest stream. |
Only the two MOS bounds fail alongside a call_id. They ask a question
about the whole capture, so sending one with a Call-ID is a misunderstanding of
the tool, and sipnab answers invalid_params (-32602) rather than quietly
doing nothing. Per-call mode simply skips orphaned, limit and cursor: a
call holds every stream it holds, so there is nothing there to page or to
filter.
With call_id the answer keeps its existing shape β { call_id, streams, diagnosis },
where streams is an array of stream JSON objects (codec, MOS, jitter, loss%,
packets, SSRC, quality intervals) and diagnosis carries the standard one-way /
NAT-mismatch flags plus the asymmetry signals (codec_asymmetry,
ptime_asymmetry, payload_type_asymmetry, duration_asymmetry,
late_media). A MOS bound alongside a call_id returns invalid_params
(-32602) rather than quietly doing nothing.
// rtp_stats { "call_id": "[email protected]" }
{
"call_id": "[email protected]",
"streams": [
{
"associated_dialog": "[email protected]",
"dialog_assertion": "signaled",
"codec": "PCMU",
"src": "10.0.2.15:27942",
"dst": "10.0.2.20:6000",
"ssrc": "0x343da99b",
"payload_type": 0,
"packets": 425,
"octets": 68000,
"loss_pct": 0.0,
"jitter_ms": 0.0054046519599899685,
"mos": 4.358100599599484,
"mos_grounded": true,
"mos_grounding": "published",
"orphaned": false,
"dscp": 0,
"input_origin": "wire",
"first_seen": "2016-11-26T14:52:59.689083+00:00",
"last_seen": "2016-11-26T14:53:08.169060+00:00",
"round_trip_note": "Not measured. No endpoint reported a round trip for this stream, so latency is unknown rather than good β a stream with clean jitter and no loss can still be unusable on delay alone (ITU-T G.114).",
"quality_intervals": [
{
"jitter_ms": 0.0063823922199494915,
"loss_pct": 0.0,
"mos": 4.358100053908192,
"mos_grounded": true,
"packets": 252,
"r_factor": 90.79984682258672,
"timestamp": "2016-11-26T14:53:04.709076+00:00",
"verdict": "acceptable"
}
],
"frame": "tests/pcap-samples/sip-rtp-g711.pcap#5@ae02f78d2d48b4f0",
"schema_version": 1
}
],
"diagnosis": {
"actual_media": null,
"hints": [
"RTP flowed 10.0.2.15:27942 -> 10.0.2.20:6000 only (SSRC 0x343da99b). No reverse media flow detected."
],
"nat_mismatch": false,
"no_media": false,
"one_way_audio": true,
"private_media_address": false,
"sdp_media": "10.0.2.20"
}
}diagnosis always carries one_way_audio, nat_mismatch, no_media,
private_media_address, sdp_media, actual_media and hints. The asymmetry
signals named above appear only when sipnab found one, so an absent
codec_asymmetry means the legs agreed rather than that the check did not run.
Per-call mode returns no total_matched, truncated or next_cursor β a call
holds every stream it holds, so there is nothing to page. quality_intervals
holds one entry per completed sampling window, so a short call legitimately
returns an empty array while this eight-second one returns a single row. The
window is five seconds by default. --quality-interval narrows it, down to one
second, without shortening the hour of call time the trend covers.
Each interval carries its own mos, r_factor and verdict. The
stream-level MOS is a mean over the whole call, and a mean hides a burst
shorter than itself: one 90 % loss figure this project had to explain turned
out to be three bursts of half a second. sipnab scores an interval on its own
jitter and loss and on the STREAM's resolved path delay, so a number in the
trend and the headline beside it rest on one basis.
verdict has three states and the third is the point:
verdict |
What it means |
|---|---|
acceptable |
R at or above 70, where ITU-T G.107's worst user-satisfaction category is still "some users dissatisfied". |
degraded |
R below 70, where that category becomes "many users dissatisfied". |
not_scorable |
The codec has no published or declared impairment value, so sipnab refuses to judge the interval. The mos beside it is a placeholder, and mos_grounded is false. |
not_scorable is the only answer for an ungrounded codec, and it is not a
shade of good or bad. Anything that COUNTS degraded intervals has to read the
verdict first: a refusal to judge is not the absence of a problem, and reading
it as one reports a healthy call on a codec nobody can score.
Each stream carries mos_grounded and mos_grounding. estimate_mos
returns the same number β 4.216 at 10 ms jitter β for AMR, AMR-WB, EVS, G.722
and for a stream whose codec was never identified, because sipnab only has
published ITU-T G.113 impairment values for G.711, G.729 and Opus. When
mos_grounded is false the MOS means unknown, not "about 4.2", and a
mos_note says so.
mos_grounding names which basis the number rests on, because "grounded" now
covers two of them:
mos_grounding |
mos_grounded |
What the number rests on |
|---|---|---|
published |
true |
An ITU-T G.113 impairment value sipnab implements. |
operator_declared |
true |
An Ie this deployment supplied in [media.codec_ie]. A mos_note says so, so an agent citing the figure cites the operator rather than a standard. |
unpublished |
false |
Nothing published and nothing declared. Placeholder. |
For AMR-WB specifically the placeholder is wrong by roughly a full MOS point in either direction: its nine modes genuinely span about 4.49 down to 3.51. Do not report a MOS to a human without checking this field. See MOS and codecs for the full picture.
Each stream also says who asserted the SDP media endpoint that tied it to its Call-ID:
dialog_assertion |
Who said it | What the address is |
|---|---|---|
signaled |
a negotiating party, in its own SDP | that party's endpoint |
media-relay |
an rtpengine relay, about a port it allocated | the leg's midpoint |
A relay cannot be wrong about which socket it opened, so the address is
authoritative β and it is still not an endpoint. An agent reasoning about where
media went must not report a media-relay address as the far party's, because
it names the box in the middle and the two lead to opposite conclusions.
sipnab emits the key whenever it knows the answer, signaled included. Absent
means nobody recorded who asserted it, never "a party did". Do not default it.
Distinct from dialog_origin, which names the capture source that delivered
the assertion rather than its author. See
rtpengine relay attribution.
"Every stream with a MOS below 3.5" is one call rather than a listing plus one
rtp_stats per dialog, which costs thousands of round trips on a real capture.
The sweep also reaches streams the per-call mode cannot: a stream that never
linked to a dialog has no Call-ID to ask about, and an orphan is not an oddity β
it is what a NAT or one-way-audio fault looks like from the media side.
| Field | Type | Description |
|---|---|---|
streams |
object[] | This page, oldest first_seen first. |
returned |
usize | Rows in streams. |
total_matched |
usize | Streams matching across the whole store. |
ungrounded_excluded |
usize | Streams a MOS bound could not judge. |
truncated |
bool |
true when matches remain after this page. |
next_cursor |
string? | Pass back to continue. null on the final page. |
capture_identity |
object | Which capture answered β see capture_status. A changed instance voids every cursor you hold. |
schema_version |
u32 |
1 for this shape. |
A MOS bound only judges codecs sipnab has a real impairment value for β
one G.113 publishes, or one this deployment declared in [media.codec_ie].
min_mos and max_mos skip every ungrounded stream and count it in
ungrounded_excluded,
because a bound on a placeholder picks calls out of a guess β and it goes
wrong in both directions. A healthy AMR-WB stream never appears in a max_mos
sweep, while a degraded one turns up on a figure that never described it.
Reporting the skipped count keeps the difference visible: "2 streams below 3.5"
and "2 streams below 3.5, plus 200 I cannot score" describe different captures,
and on any network carrying AMR-WB, EVS or G.722 the second one is the truth.
Omit both bounds and the sweep lists every stream, including the codecs with no
published value, each still carrying mos_grounded.
The example runs against tests/pcap-samples/codec-negotiation.pcap, which
carries four streams β two PCMU, two G722 β and no dialogs at all:
// rtp_stats { "max_mos": 4.5, "limit": 1 }
{
"schema_version": 1,
"streams": [
{
"codec": "PCMU",
"dscp": 0,
"dst": "127.0.0.1:5084",
"first_seen": "2026-07-08T18:35:27.407583+00:00",
"frame": "tests/pcap-samples/codec-negotiation.pcap#5@5d4c3e3d970a836b",
"input_origin": "wire",
"jitter_ms": 0.26954164761616456,
"last_seen": "2026-07-08T18:35:30.407077+00:00",
"loss_pct": 0.0,
"mos": 4.357953149337916,
"mos_grounded": true,
"mos_grounding": "published",
"octets": 24160,
"orphaned": true,
"packets": 151,
"payload_type": 0,
"quality_intervals": [],
"round_trip_note": "Not measured. No endpoint reported a round trip for this stream, so latency is unknown rather than good β a stream with clean jitter and no loss can still be unusable on delay alone (ITU-T G.114).",
"schema_version": 1,
"src": "127.0.0.1:5094",
"ssrc": "0x0e330af3"
}
],
"returned": 1,
"total_matched": 2,
"ungrounded_excluded": 2,
"truncated": true,
"next_cursor": "2026-07-08T18:35:27.407583+00:00|[email protected]:5094>127.0.0.1:5084",
"capture_identity": {
"node": "capture-01",
"instance": "22fa418cb5c799c57abef-1",
"dialog_generation": 1,
"stream_generation": 4
}
}The sweep adds frame, capture_identity and the page fields the per-call mode
omits.
orphaned means "no dialog claims this stream", and nothing else. It is
associated_dialog.is_none(), which sipnab computes while building the
response, so the two fields can never disagree: every stream above reports orphaned: true, which
is the truth about a capture holding four streams and no dialogs at all.
sipnab computes orphaned per response as associated_dialog.is_none(), so a
stream reports it from its FIRST packet. That matters because a short unclaimed
stream is exactly what a NAT or one-way-audio fault looks like from the media
side: an agent filtering for orphans to find one must not have to wait for a
sweep to notice. Reading associated_dialog directly means the same thing.
total_matched: 2 and ungrounded_excluded: 2 account for all four streams.
The two G722 streams score 4.22 from the placeholder arm, which would have put
them under a 4.5 bound on a number that means nothing.
Whether the traffic obeys the RFCs, and which clause it broke. These turn "the call failed" into "12.1.1 requires a Contact and there is none".
Conformance, which is not the question triage_call answers. That tool asks
why a call failed. This one asks whether the traffic obeys the specification. A
call can complete over messages that break four MUSTs, and a fully conformant
call can hit a busy signal.
The rules that earn this tool its place compare the declaration against the
observation. sipnab holds the signaling and the RTP in one process, so it can
report that the SDP declared PCMU on payload type 0 while the wire carried
payload type 8, that RTP arrived on a port no m= line advertised, that
sendrecv promised media in both directions and the capture holds it in one,
or that the packet spacing contradicts a=ptime. A linter reading message text
reaches none of that, because the defect sits in neither message.
The RFC 3261 syntax rules and the RFC 3264 offer/answer rules run alongside them. Everything citing RFC 4566, 3551 or 5761 belongs to the observation half. SIP conformance rules lists every rule, the section behind it, and the suppression syntax.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
call_id |
string | A Call-ID the store holds. | Required β the call fails. |
rulesets |
string[]? | The 15 selectors below, OR-ed together. An unknown one fails with invalid_params listing all 15. |
The whole catalog, reported back as rulesets: ["all"]. |
severity_min |
string? |
info, notice, warning or error. Anything else fails with unknown severity 'x'. Valid values: info, notice, warning, error. |
info, so the floor drops nothing. |
suppression_file |
string? | A bare filename inside --mcp-file-root. A file sipnab cannot open fails with invalid_params rather than linting with every rule on. |
sipnab walks for a .sipnablint beside the capture and upward to the project root. |
Selectors take two forms β the catalog's own names, and one per RFC the rules cite:
-
By category:
all,must,rfc(MUST and SHOULD together),interop,observation(observedalso works) andsyntax. -
By RFC:
rfc3261,rfc3262,rfc3264,rfc3551,rfc4028,rfc4566,rfc5761,rfc7989.
An unknown selector fails with invalid_params (-32602) naming the whole
vocabulary, so a typo such as rfc3621 cannot quietly select nothing and hand
back an empty list that reads as a clean call. Passing an empty array behaves
as omitting the parameter.
The example runs against tests/pcap-samples/b2bua-asterisk.pcapng. Its SDP
negotiates sendrecv in both directions, and the capture carries 355 RTP
packets in one:
// lint_dialog { "call_id": "[email protected]:5060",
// "rulesets": ["observed"] }
{
"schema_version": 1,
"call_id": "[email protected]:5060",
"rulesets": ["observed"],
"severity_min": "info",
"message_count": 15,
"rtp_streams_observed": 1,
"finding_count": 1,
"severity_counts": { "error": 0, "warning": 1, "notice": 0, "info": 0 },
"findings": [
{
"rule_id": "OBS-3264-6.1-DIRECTION-UNMET",
"severity": "warning",
"basis": "observation",
"rfc": 3264,
"section": "6.1",
"message_index": 0,
"observed": "β¦untrusted-capture-dataβ§355 RTP packets observed, none of them toward one negotiated endpointβ¦/untrusted-capture-dataβ§",
"expected": "media in both directions, as a=sendrecv promised",
"explanation": "Β§6.1 makes sendrecv a promise to send as well as receive. ...",
"frame_ref": "tests/pcap-samples/b2bua-asterisk.pcapng#23@08cb2267de5498ce"
}
],
"rules_not_evaluated": [
{
"reason": "needs the endpoint pairs RTCP arrived on. ...",
"rule_ids": ["OBS-5761-5.1.1-RTCP-MUX-UNANSWERED"]
}
],
"findings_withheld": { "suppressed": 0, "below_severity": 0, "capped": 0 },
"suppressions": { "file": null, "patterns": [], "findings_suppressed": 0 },
"rule_catalog": "docs/sip-lint-rules.md"
}rfc and section stay separate fields rather than prose inside the
explanation, and that is the whole point of the shape. An agent quotes
RFC 3264 section 6.1 out of the data instead of inventing a section number that reads
plausibly, and explain_rule turns the identifier back into the citation and
the link.
frame_ref on each finding points at the frame the rule fired on. Hand it to
show_evidence and the claim becomes bytes a reader can
check, which is the difference between a finding and an assertion. observed
quotes the capture and therefore arrives fenced. expected and explanation
are sipnab's own words, so they do not.
rules_not_evaluated names what the run could not settle, grouped by reason. A
rule that found nothing and a rule that never ran leave the same empty finding
list behind, and only this field separates them. Two reasons appear on this
tool:
- No RTP reached the call, so the
OBS-rules had nothing to compare the declaration against. -
OBS-5761-5.1.1-RTCP-MUX-UNANSWEREDneeds the endpoint pairs RTCP arrived on. The stream store folds an RTCP report into the stream it describes and keeps no record of where it landed, so no MCP tool raises this rule.
A project silences rules with a .sipnablint β one identifier per line, or a
prefix ending *, with # starting a comment. SIP conformance
rules documents the pattern syntax.
sipnab looks for one beside the capture, then climbs toward the project root
and stops there. A capture that sits outside any project β a corpus mount, a
share, /tmp β adopts nothing from above itself, because inheriting a
stranger's suppression list would switch off rules nobody here turned off.
suppression_file overrides the search outright, and a file it names that
sipnab cannot open returns invalid_params rather than quietly linting with every
rule on.
Two response fields carry the consequences, and every call includes both, even when every number is zero:
"suppressions": {
"file": "/srv/captures/.sipnablint", // null when none applied
"patterns": ["OBS-*", "SIP-3261-8.1.1.6-MAX-FORWARDS-MISSING"],
"findings_suppressed": 4
},
"findings_withheld": { "suppressed": 4, "below_severity": 2, "capped": 0 }A response carrying no field and a response carrying zero must not be the same bytes. The first says nothing about whether the run hid findings. The second says it hid none.
The three counts stay apart because they send you to three different places:
something you wrote down silenced it, your severity floor dropped it, or there
was simply too much of it and the per-rule cap stopped after 25.
capped is the only lower bound of the three β a rule may stop evaluating once
it hits the cap, and nothing can count what it then never raises. suppressed
and below_severity are exact, which is why suppression deliberately does not
short-circuit the rule.
The response names the file rather than merely acknowledging one. "4 findings suppressed" leaves you nothing to act on when the search walked up three directories to find the file that did it.
The same rules against one message, named by its zero-based index β the shape a
CI job or a header-level argument with a vendor wants. lint_dialog reports
message_index on every finding, so this tool narrows a hit rather than
finding new ones.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
call_id |
string | A Call-ID the store holds. | Required β the call fails. |
index |
u32 |
0 to msg_count - 1. Out of range fails with invalid_params (-32602) naming the message count. |
Required β the call fails. |
suppression_file |
string? | A bare filename inside --mcp-file-root, exactly as lint_dialog takes it. Wins outright over the discovery walk. |
sipnab walks for a .sipnablint beside the capture and upward. |
There is no rulesets or severity_min here. Every rule that can run on one
message runs, and the response reports the rest under rules_not_evaluated.
// validate_message { "call_id": "[email protected]", "index": 0 }
{
"schema_version": 1,
"call_id": "[email protected]",
"message_index": 0,
"message_count": 2,
"finding_count": 2,
"severity_counts": { "error": 0, "warning": 2, "notice": 0, "info": 0 },
"findings": [
{
"rule_id": "SIP-3261-8.1.1.6-MAX-FORWARDS-MISSING",
"severity": "warning", "basis": "must", "rfc": 3261, "section": "8.1.1.6",
"message_index": 0,
"observed": "β¦untrusted-capture-dataβ§no Max-Forwards header fieldβ¦/untrusted-capture-dataβ§",
"expected": "Max-Forwards: 70",
"explanation": "Β§8.1.1.6 makes a UAC insert one into every request it originates. ...",
"frame_ref": "tests/pcap-samples/sip-488-codec-reject.pcapng#4@b2b8ff6b3eeddf54"
},
{
"rule_id": "SIP-3261-8.1.1.7-BRANCH-COOKIE",
"severity": "warning", "basis": "must", "rfc": 3261, "section": "8.1.1.7",
"message_index": 0,
"observed": "β¦untrusted-capture-dataβ§top Via branch without the z9hG4bK prefixβ¦/untrusted-capture-dataβ§",
"expected": "branch=z9hG4bK...",
"explanation": "Β§8.1.1.7 makes every compliant branch begin with z9hG4bK. ...",
"frame_ref": "tests/pcap-samples/sip-488-codec-reject.pcapng#4@b2b8ff6b3eeddf54"
}
],
"findings_withheld": { "suppressed": 0, "below_severity": 0, "capped": 0 },
"suppressions": { "file": null, "patterns": [], "findings_suppressed": 0 },
"rules_not_evaluated": [
{ "reason": "reads a dialog's messages against each other, and this tool reads one message alone. Call lint_dialog.",
"rule_ids": ["SIP-3261-8.1.1.2-TO-TAG-IN-INITIAL-REQUEST", "..."] },
{ "reason": "compares the declaration against the observed media, and this tool reads one message alone. Call lint_dialog.",
"rule_ids": ["OBS-3264-6.1-PT-UNDECLARED", "..."] },
{ "reason": "needs the endpoint pairs RTCP arrived on. ...",
"rule_ids": ["OBS-5761-5.1.1-RTCP-MUX-UNANSWERED"] }
],
"rule_catalog": "docs/sip-lint-rules.md"
}That example runs against tests/pcap-samples/sip-488-codec-reject.pcapng,
whose first OPTIONS ping carries neither a Max-Forwards header field nor
the RFC 3261 branch cookie.
observed quotes the message, so it arrives fenced. expected is sipnab's own
words, so it does not. frame_ref points at the frame the finding fired on β hand
it to show_evidence to read the bytes behind the claim.
findings_withheld counts what the response left out and why, and
suppressions names the .sipnablint file that took part, both of them exactly
as lint_dialog reports them.
Nineteen of the forty-one rules skip on a one-message run, which is why the
response names them. Reach for lint_dialog first and use this to confirm one
message.
Turns a rule identifier back into its catalog entry, so an identifier lifted out of a finding, a CI log or a suppression file resolves without a round trip to the source.
Parameters: rule_id (string, required) β one of the 41 catalog
identifiers, matched exactly, such as OBS-3264-6.1-PT-UNDECLARED. No optional
parameters, and the tool reads no capture, so it answers the same on any
server. An unknown identifier fails with invalid_params (-32602) listing all
41, which doubles as the way to enumerate them.
// explain_rule { "rule_id": "OBS-3264-6.1-DIRECTION-UNMET" }
{
"schema_version": 1,
"rule_id": "OBS-3264-6.1-DIRECTION-UNMET",
"title": "sendrecv negotiated, media observed one way",
"severity": "warning",
"basis": "observation",
"rfc": 3264,
"section": "6.1",
"citation": "RFC 3264 Β§6.1",
"url": "https://www.rfc-editor.org/rfc/rfc3264#section-6.1",
"scope": "media",
"rulesets": ["all", "observation", "observed", "rfc3264"],
"rule_catalog": "docs/sip-lint-rules.md"
}rulesets lists every selector that reaches this rule, so any entry passes
straight back as a lint_dialog rulesets value. scope says what the rule
has to read before it can run: message, dialog or media.
An unknown identifier returns invalid_params (-32602) listing all forty-one, because an empty answer would read as "that rule found nothing".
The IANA registry, not an agent's recollection.
Parameters: code (integer, required) β a SIP status code from 100 to 699.
Outside that range fails with 999 is not a SIP response code (100-699). No
optional parameters, and this tool reads no capture either, so it answers on a
server holding nothing.
// explain_response_code { "code": 488 }
{
"schema_version": 1,
"code": 488,
"class": "failure", // provisional|success|redirect|challenge|canceled|declined|failure
"explanation": "488 Not Acceptable Here β Codec negotiation failed. Compare the SDP offer against the callee's supported codecs and ptime values.",
"registered": true
}class distinguishes a challenge from a failure: 401 is challenge, not
failure, because a challenged call has not failed β it is mid-handshake.
registered: false means the code is outside the registry, usually a vendor
extension. The tool says so rather than inventing a meaning.
A pass/fail verdict on the loaded capture, and an exit code for a build.
Every other tool here makes a bad day shorter. This one prevents it, and it
moves sipnab from something an operator reaches for during an incident to
something that runs on every commit. The rules live in a checked-in
sipnab.expect.toml beside the SBC config: that file is the UX, and this tool
is how an agent reasons about it.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
rules |
object[]? | The suite inline. Each rule is {metric, op, value} plus optional name, scope, min_sample and grounded_only. Mutually exclusive with rules_toml. |
Give rules_toml instead. |
rules_toml |
string? | The verbatim text of a sipnab.expect.toml. Mutually exclusive with rules. |
Give rules instead. |
suppression_file |
string? | A lint suppression file in --mcp-file-root, applied to the lint_errors rules. |
Discovers a .sipnablint beside the capture, exactly as lint_dialog does. |
Passing the file's own bytes is the point of rules_toml: the agent then judges
the capture against exactly what CI judges it against, rather than against its
own transcription. Passing both, or neither, fails β two suites in one call have
no defined order, and a call with no suite would report a verdict on nothing.
One rule is a metric, a comparison, a threshold, and what it applies to:
| Field | Legal values |
|---|---|
metric |
count (dialogs matching the scope), asr (answered over seized, as a PERCENT from 0 to 100, the same unit group_dialogs reports), lint_errors (conformance findings at or above a severity floor), mos_p<N> (a MOS percentile, mos_p0 worst to mos_p100 best). |
op |
>=, >, <=, <, ==, !=
|
value |
The threshold, in the metric's own unit. |
scope |
filter:<alias-or-DSL> to narrow by dialog, or severity:<info|notice|warning|error> to set the floor a lint_errors rule counts from. Omitted means the whole capture. |
min_sample |
Observations below which the rule reports skipped rather than a verdict. |
grounded_only |
MOS metrics only. Defaults to true. |
// evaluate_expectations { "rules": [
// { "name": "no codec rejections", "metric": "count", "op": "==", "value": 0,
// "scope": "filter:response_code == 488" },
// { "name": "audio floor", "metric": "mos_p10", "op": ">=", "value": 4.0 },
// { "name": "answer rate", "metric": "asr", "op": ">=", "value": 99, "min_sample": 50 } ] }
{
"schema_version": 1,
"verdict": "pass",
"exit_code": 0,
"rules_total": 3, "evaluated": 2, "passed": 2, "failed": 0, "skipped": 1,
"dialogs_in_capture": 2,
"streams_in_capture": 2,
"suppressions_applied": false,
"results": [
{ "index": 0, "name": "no codec rejections", "metric": "count",
"unit": "dialogs", "op": "==", "threshold": 0.0,
"scope": "filter:response_code == 488", "observed": 0.0, "sample": 2,
"verdict": "pass", "reason": "0 is == 0 over 2 dialog(s)" },
{ "index": 1, "name": "audio floor", "metric": "mos_p10",
"unit": "mos (1.0-5.0)", "op": ">=", "threshold": 4.0,
"observed": 4.358100156401542, "sample": 2, "ungrounded_excluded": 0,
"verdict": "pass", "reason": "4.3581 is >= 4 over 2 scored stream(s)" },
{ "index": 2, "name": "answer rate", "metric": "asr",
"unit": "ratio (0.0-1.0)", "op": ">=", "threshold": 0.99,
"observed": 1.0, "sample": 2, "min_sample": 50,
"verdict": "skipped",
"reason": "skipped: 2 seizure(s) is below the declared min_sample of 50" }
],
"capture_identity": { "node": "capture-01", "instance": "1d1a718cb5c33b7c52754-1",
"dialog_generation": 13, "stream_generation": 2 }
}unit rides on every outcome, and reading it is not optional. asr here is
a RATIO from 0.0 to 1.0, and the same name in
group_dialogs is a PERCENT. A threshold of 0.99 means 99%
under one reading and 1% under the other, so the answer names its unit rather
than leaving it to this page.
An empty population FAILS, and the message says why. Three failure modes get a gate deleted rather than fixed, and each one has an answer here:
| Failure mode | What this tool does |
|---|---|
| Passing on data it never judged | A rule whose population is empty reports fail with reason beginning unevaluable:. A MOS threshold on a capture full of AMR-WB would otherwise score every stream off a placeholder and report green having measured nothing. |
| Failing on a sample too small to mean anything |
min_sample is a declared floor. Below it a rule reports skipped, so a three-call smoke test never trips an ASR threshold on a Friday afternoon. |
| Lying about coverage | A suite where every rule skipped reports not_evaluated with exit code 2, distinct from a pass. A file of rules that never run is exactly the thing that stays in a repository claiming to check something. |
Declaring min_sample of 1 or more is the ONLY way to have an empty population
tolerated, and that asymmetry carries weight: a gate goes quiet only where its
author wrote down that it may. min_sample: 0 declares no floor at all, so it
leaves the empty case failing exactly as an absent one does.
// evaluate_expectations { "rules": [{ "metric": "asr", "op": ">=", "value": 95 }] }
// ... on a capture holding two REGISTER dialogs and no call attempt:
{ "verdict": "fail", "exit_code": 1, "evaluated": 1, "failed": 1,
"results": [ { "index": 0, "metric": "asr", "observed": null, "sample": 0,
"verdict": "fail",
"reason": "unevaluable: no seizure was in scope, so 'asr >= 95' rests on nothing. Declare a min_sample of 1 or more to accept an empty population in writing; a gate does not pass on data it never judged" } ] }The verdict tests the SAMPLE rather than whether a value came back. count
always produces a value β zero matches is zero β so checking the value alone let
count == 0 pass on a capture holding no dialogs at all, which is the exact
"green on data it never judged" this design refuses.
exit_code is what a command line reports: 0 every rule that ran passed, 1
at least one failed, 2 nothing ran. CI has to tell a green run from a run that
judged nothing, and a shell script comparing against 0 cannot.
Three more things the report says out loud. sample names how many observations
each figure rests on. ungrounded_excluded appears on MOS rules and counts the
streams whose codec has no published impairment value β with grounded_only
off, notes says instead that placeholder scores entered the percentile.
suppressions_applied says whether a lint suppression file was in force, so a
lint_errors count of zero cannot pass for one taken with every rule armed.
A defect in the RULES is a hard error rather than a per-rule failure: an unknown
metric, an unparseable filter, a severity: scope on a metric that reads no
findings, grounded_only on a metric that reads no MOS, or a percentile outside
0 to 100 all fail the whole call with invalid_params naming the rule's index.
A misspelled metric evaluating to "fail" would look identical to traffic that
genuinely broke the threshold, and one evaluating to "pass" would be a gate that
checks nothing while looking green. Half a gate reporting green is the outcome
this refuses to produce.
Nothing on the CLI reaches this evaluator yet. It and
exit_codeboth ship and both carry tests, and no flag runs them, so a checked-insipnab.expect.tomlcannot fail a build on its own today.
Scanner and abuse signals derived from the capture, and the one tool that turns them into something a firewall can act on.
Recent findings from active detection rules (scanner, fraud, digest, reg-flood, etc.). Backed by the AlertEngine's bounded ring buffer (default 1000 entries, kept in memory only).
| Name | Type | Legal values | If omitted |
|---|---|---|---|
kinds |
string[]? | Exactly four names: scanner, fraud, digest, reg_flood. Anything else fails with invalid_params naming all four β including reg-flood with a hyphen, which suggests the underscore spelling. |
Findings of every kind. |
since |
string? | RFC 3339. Keeps findings recorded strictly after it. A malformed value fails with since must be RFC 3339. |
The whole retained history. |
limit |
u32? | Ceiling is --mcp-max-rows (1000 by default). Higher clamps to it, 0 means the default. |
50 findings. |
Returns β a page object, not a bare array:
| Field | Type | Description |
|---|---|---|
findings |
object[] | This page of { rule_name, src_ip, detail, timestamp }, newest first. |
returned |
usize | Rows in findings. |
total_matched |
usize | Findings matching kinds and since across the whole retained ring buffer. |
truncated |
bool |
true when matches remain after this page. There is no cursor β narrow with since, or raise limit. |
armed_kinds |
string[] | The detectors this server runs. Empty means it runs none, so findings could only ever be empty. |
detection_armed |
bool |
false when armed_kinds is empty, stated separately so a caller can branch on one field. |
note |
string? | Present only when no detector runs, saying so in words. |
schema_version |
u32 |
1 for this shape. |
Read
armed_kindsbefore you readfindings. An empty findings list means "nothing tripped" only on a server that armed something; on any other it means nothing was watching, and the two are opposite operational states. Akindsvalue outside the vocabulary fails rather than answering with an empty list, which would be a third way to read[]. Cross-checkingserver_capabilitiesis not necessary for this question: the answer is in the response.
The page fields carry the meanings they do everywhere else on this surface β
search_messages documents the same four. armed_kinds is
per detector, which is what makes it worth reading rather than a bare
"detection is on": a server started with --kill-scanner alone answers
"no fraud findings" for a capture full of toll fraud, and armed_kinds: ["scanner"] is what tells an agent that the question was never asked.
Arming a rule takes a flag on the server command line, such as --kill-scanner
or --digest-leak. Findings then land in a bounded in-memory ring buffer
(1000 entries by default, --findings-history to change it) and go nowhere
else β stopping the process discards them.
timestamp records when the rule fired during analysis, not when the packet
arrived. On a replayed pcap those differ by years, so a since value copied
from a dialog's created_at returns everything.
The example runs against
tests/pcap-samples/sip-auth-failure.pcapng
on a server started with --digest-leak:
// security_findings {}
{
"schema_version": 1,
"findings": [
{
"rule_name": "digest",
"src_ip": "203.0.113.101",
"detail": "WeakAlgorithm: challenge uses algorithm=MD5 (should be SHA-256+)",
"timestamp": "2026-08-13T16:16:02.880725566+00:00"
}
],
"returned": 1,
"total_matched": 1,
"truncated": false,
"armed_kinds": ["digest"],
"detection_armed": true
}The same tool on a server started without a detection flag answers with an
empty findings list, armed_kinds: [], detection_armed: false and a note
saying that nothing was watching. armed_kinds: ["digest"] above says the
opposite in the same field: this server watched for digest weaknesses and for
nothing else, so it answers nothing about scanners either way.
A fail2ban filter and jail derived from ONE recorded security finding.
Scoped to one finding, with that finding attached as the evidence. A rule generated from a class of findings would be a policy this tool has no standing to write. A rule generated from one, with the alert line it matches printed beside it, is something an operator can read and decide on.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
finding_id |
string |
<rule_name>@<src_ip>@<timestamp> β the three fields security_findings already returns for every finding. |
Required β the call fails. |
The id derives from the finding rather than coming from a counter, because an assigned id would have to survive eviction, restart and a second server reading the same capture, and none of those hold for a position in a bounded ring buffer. An agent holding "id 7" across a restart would act on a different finding.
// generate_fail2ban_rule { "finding_id": "[email protected]@2026-08-28T11:31:03.859034298+00:00" }
{
"schema_version": 1,
"finding_id": "[email protected]@2026-08-28T11:31:03.859034298+00:00",
"evidence": {
"rule_name": "digest",
"src_ip": "203.0.113.101",
"detail": "β¦untrusted-capture-dataβ§WeakAlgorithm: challenge uses algorithm=MD5 (should be SHA-256+)β¦/untrusted-capture-dataβ§",
"timestamp": "2026-08-28T11:31:03.859034298+00:00"
},
"filter_name": "sipnab-digest",
"filter_path": "/etc/fail2ban/filter.d/sipnab-digest.conf",
"filter_conf": "# Generated by sipnab from finding [email protected]@...\n[Definition]\nfailregex = ^.*\\[ALERT\\] digest src=<HOST> .*$\nignoreregex =\n",
"jail_path": "/etc/fail2ban/jail.d/sipnab-digest.conf",
"jail_conf": "[sipnab-digest]\nenabled = true\nfilter = sipnab-digest\nlogpath = /var/log/syslog\nport = 5060,5061\nprotocol = udp\nmaxretry = 3\nfindtime = 600\nbantime = 3600\n",
"log_line_format": "[ALERT] <rule_name> src=<ip> <detail>",
"caveats": [ /* three, below */ ]
}The failregex matches sipnab's own alert line, the [ALERT] <rule> src=<ip> <detail> form sipnab writes. So the jail sees nothing until sipnab runs with
--alert syslog, or something captures its stderr to the logpath. That is the
first caveat, and it is the one that makes an otherwise-correct jail inert.
The other two caveats say what the numbers are not. maxretry, findtime and
bantime are fail2ban's conventional starting values rather than figures sipnab
measured on this traffic β choose them from your own false-positive tolerance.
And one finding is evidence that this detector fired, not that every future
match is an attacker: the jail bans any source the detector reports, this one
included. A loopback src_ip adds a fourth caveat, because a jail acting on it
would ban the host from itself.
Two refusals. A server with no detector armed holds no findings at all, and says so rather than reporting an unknown id:
{ "code": -32602,
"message": "this server holds no findings: no detection rule was armed, so nothing could have been recorded. Arm one with --kill-scanner, --fraud-detect, --digest-leak or --reg-flood and re-run the capture." }An id the server does not hold comes back with the ids it does hold, up to the
row cap, so a caller that mistyped a timestamp does not have to go back to
security_findings. The scan walks the whole ring buffer
for that list, because a truncated scan cannot report what it truncated β and
here the truncated part is the vocabulary the caller gets offered.
detail arrives fenced: it quotes headers a sender wrote. rule_name does not,
because sipnab chose it β and the tool checks that name is a bare identifier
before writing it into a regular expression, since a name carrying a
regular-expression operator would produce a pattern the author never wrote.
TFPS is the toll-fraud prevention system an operator may run on the same host
as sipnab: it condemns sources and enforces that decision in the firewall.
sipnab never bans anything, so the question an agent reading
security_findings asks next is what TFPS did with the evidence. This tool
answers whether TFPS is there at all, and what it reports about itself.
No parameters.
TFPS is optional peer software. On a machine without it every tfps_* tool
answers the same two fields and nothing else, and that is a result rather than
an error, because a bare machine is the ordinary case:
// tfps_status {}
{
"installed": false,
"reason": "tfps_ctl not found on PATH; pass --tfps-ctl or [tfps] ctl"
}With TFPS installed (--tfps-ctl /usr/local/bin/tfps_ctl, or tfps_ctl on
PATH), the answer names the executable that answered and carries its status:
// tfps_status {}
{
"installed": true,
"tfps_ctl": "/usr/local/bin/tfps_ctl",
"status": {
"enforcement": "active",
"mode": null,
"interface": null,
"map": "own map id 7",
"blocked_now": 2,
"pairs": 4,
"peers": 4,
"last_checkpoint": 1758200500,
"db": "/var/lib/tfps/tfps.db",
"version": "0.2.1"
}
}Every field is present on every answer, null when unknown. map names the
block map TFPS read, own map id N or pinned map <path>. It is null,
with enforcement inactive, when TFPS could open none. mode and
interface are always null: TFPS's JSON mode does not fill them yet.
pairs and peers count what TFPS has learned, and TFPS currently reports
the same number for both. last_checkpoint is Unix seconds. sipnab looks for tfps_ctl only when a
tfps_* tool runs: it probes nothing at startup, and a machine without TFPS
logs nothing about it. A peer that exits non-zero, hangs past ten
seconds, or prints something other than the agreed JSON is an internal_error
(-32603) whose message carries tfps_ctl's standard error verbatim.
Every source TFPS holds condemned right now: the address, the rule that condemned it, what that rule saw, when the ban began and lapses, and whether the firewall holds it or TFPS is observing only.
No parameters. --mcp-max-rows bounds the page. total is how many rows
TFPS returned, and truncated says whether the cap withheld any.
// tfps_banned {}
{
"installed": true,
"tfps_ctl": "/usr/local/bin/tfps_ctl",
"rows": [
{
"ip": "198.51.100.10",
"reason": "user-agent",
"detail": "β¦untrusted-capture-dataβ§pplsipβ¦/untrusted-capture-dataβ§",
"first_seen": 1758200000,
"expires": 1789748119,
"enforced": true
},
{
"ip": "198.51.100.12",
"reason": null,
"detail": null,
"first_seen": null,
"expires": null,
"enforced": true
}
],
"total": 2,
"returned": 2,
"truncated": false
}detail is the sender's own text β here the User-Agent a scanner chose β
and arrives fenced the way security_findings fences its detail. Addresses,
rules and timestamps are verbatim. reason is the rule that condemned the
source. reason, detail and first_seen are null for a block no audit
row explains, such as one an operator made by hand. first_seen and
expires are Unix seconds, and expires is null for a ban that does not
lapse.
Per condemned source, what the enforcement has done: packets dropped, events TFPS recorded, when it last saw the source, the rule behind the block, and the last request line the source sent.
No parameters. Bounded by --mcp-max-rows like tfps_banned.
// tfps_dropped {}
{
"installed": true,
"tfps_ctl": "/usr/local/bin/tfps_ctl",
"rows": [
{
"ip": "198.51.100.10",
"dropped": 30,
"events": 4,
"last_seen": "2026-09-03T16:41:00Z",
"rule": "user-agent",
"last_request": "β¦untrusted-capture-dataβ§OPTIONS sip:[email protected] SIP/2.0β¦/untrusted-capture-dataβ§"
}
],
"total": 1,
"returned": 1,
"truncated": false
}last_request is a request line the source wrote, and arrives fenced. It and
rule are null when TFPS recorded none.
The TFPS verdict log: one row per decision about a source. This is the export the label corpus harness scores sipnab's scanner detector against, reachable without a shell on the box.
| Parameter | Type | Legal values | If omitted |
|---|---|---|---|
limit |
integer | a positive count of rows, newest first. 0 means the default |
one page of --mcp-max-rows
|
A limit that fits in a page passes through to TFPS as --limit N. 0,
absent or more than a page asks TFPS for the page and one row more, which is
how truncated knows TFPS held more. So total here is how many TFPS
returned, at most a page and one row, not the size of the log. For the whole
log, run tfps_ctl log --json --limit N on the TFPS host.
// tfps_labels { "limit": 250 }
{
"installed": true,
"tfps_ctl": "/usr/local/bin/tfps_ctl",
"rows": [
{
"ip": "198.51.100.11",
"reason": "scanner",
"detail": "β¦untrusted-capture-dataβ§sipviciousβ¦/untrusted-capture-dataβ§",
"first_seen": 1758200100,
"expires": null,
"unbanned_at": null,
"enforced": true,
"disposition": "block"
}
],
"total": 1,
"returned": 1,
"truncated": false
}TFPS's audit log records only the blocks it enforced, so today every row has
disposition block and enforced true. The log has no columns for a
ban's end yet, so expires and unbanned_at are always null. first_seen
is Unix seconds. detail arrives fenced.
Ask TFPS to condemn one source. An operator action relayed through sipnab,
not a decision sipnab makes: the address and the duration are the caller's,
TFPS refuses its host's own addresses and anything in its ignoreip, and
sipnab reports the answer as given, refusal included. The automated path β sipnab's
own findings reaching TFPS as they happen β is a separate channel, and nothing
sipnab detects ever comes through this tool.
| Parameter | Type | Legal values | If omitted |
|---|---|---|---|
ip |
string | an IPv4 address. The TFPS block map is IPv4, and tfps_ctl fails outright on an IPv6 address, so sipnab refuses one itself |
required. Anything that is not an IPv4 address is invalid_params (-32602) before sipnab asks TFPS |
ttl_secs |
integer | seconds the ban lasts. 0 is forever |
the TFPS default of an hour |
The TFPS ban command records no free-text reason, so this tool takes none. The
address and the duration reach tfps_ctl as arguments and never through a
shell.
// tfps_ban { "ip": "198.51.100.20", "ttl_secs": 3600 }
{
"installed": true,
"tfps_ctl": "/usr/local/bin/tfps_ctl",
"action": {
"ip": "198.51.100.20",
"action": "ban",
"applied": true,
"refused": null,
"expires": 1789746010,
"source": "operator"
}
}A ban TFPS refuses is not an error, even though tfps_ctl signals it with
exit 1: applied is false and refused says why in the words TFPS uses β
local for one of the host's own addresses, declared for one its
ignoreip exempts, and kernel when it could not write the block map:
// tfps_ban { "ip": "127.0.0.1" }
{
"installed": true,
"tfps_ctl": "/usr/local/bin/tfps_ctl",
"action": {
"ip": "127.0.0.1",
"action": "ban",
"applied": false,
"refused": "local",
"expires": null,
"source": "operator"
}
}The tool is readOnlyHint: false and destructiveHint: true, so a host that
confirms destructive calls asks before this one. idempotentHint: true,
because banning a banned source changes nothing.
Ask TFPS to release one condemned source. The same operator action in the other direction, reported as given.
| Parameter | Type | Legal values | If omitted |
|---|---|---|---|
ip |
string | an IPv4 address | required; anything else, an IPv6 address included, is invalid_params (-32602) |
// tfps_unban { "ip": "198.51.100.20" }
{
"installed": true,
"tfps_ctl": "/usr/local/bin/tfps_ctl",
"action": {
"ip": "198.51.100.20",
"action": "unban",
"applied": true,
"refused": null,
"expires": null,
"source": "operator"
}
}A source that was not blocked comes back applied: false, refused: "not-blocked" β the answer TFPS gave, not an error. readOnlyHint: false,
destructiveHint: false β a release restores rather than destroys β and
idempotentHint: true.
Every claim above points back at bytes. These retrieve those bytes, prove which frame they came from, and package them so someone else can check the work.
Follows a frame pointer back to the bytes it names, turning one from a string into something a reader can check without reopening the capture.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
refs |
string[] | At least one pointer in <source>#<ordinal>@<digest> form. An empty array fails with refs must name at least one frame pointer, because an empty result would read as "nothing resolved". |
Required β the call fails. |
max_bytes |
u32? | 1 to 4096 bytes of hex per frame. Higher clamps to 4096, and to the frame length when the frame is shorter. 0 means zero bytes here, not the default β this is the one parameter on the surface that does not read 0 as "unset". |
256 bytes, enough for a SIP start line and its headers. |
A pointer whose @digest half is missing still resolves, and comes back
unverified rather than verified. Batching is the normal use β one bad
pointer never discards the rest, so each entry reports its own status.
Not every tool returns a pointer, and the two that do use different key
names. A caller planning around "every tool returns frame_ref" would look
for a key most responses do not carry:
-
frame_refβ the findingslint_dialogandvalidate_messagereturn. Named apart because a finding cites a message index, and the pointer is what makes it checkable without the list that index counts within. -
frameβlist_dialogs,find_problems,tail_dialogs,get_dialog(its dialog and its messages),get_message, the JSONget_dialog_report, and the streams inrtp_stats. -
No pointer at all β
search_messages,search_by_time,find_correlated,triage_call,check_codec_negotiation,diagnose_registration,compare_dialogs,get_sdp_timeline, the RTCP remote reports, and the capture-level counters.
A fact with no pointer omits the key entirely β never "", never frame 0, both
of which read as a real pointer.
The example follows the frame pointer that
list_dialogs returned for the first call in
tests/pcap-samples/sip-rtp-g711.pcap,
with max_bytes cut to 64 to keep the hex short:
// show_evidence { "refs": ["tests/pcap-samples/sip-rtp-g711.pcap#0@db88659b94678546"],
// "max_bytes": 64 }
{
"schema_version": 1,
"requested": 1,
"resolved": 1,
"verified": 1,
"summary": "1 of 1 pointer(s) resolved; 1 verified against a recorded digest",
"frames": [
{
"pointer": "tests/pcap-samples/sip-rtp-g711.pcap#0@db88659b94678546",
"status": "verified",
"source": "sip-rtp-g711.pcap",
"ordinal": 0,
"frame_bytes": 500,
"hex_bytes_shown": 64,
"truncated": true,
"hex": "00 00 00 00 00 00 00 00 00 00 00 00 08 00 45 00 01 e6 fe 17 40 00 40 11 22 cd 0a 00 02 14 0a 00 02 0f 13 c4 13 c4 01 d2 1a 06 49 4e 56 49 54 45 20 73 69 70 3a 74 65 73 74 40 31 30 2e 30 2e 32"
}
]
}source reports the bare filename, not the path the pointer carried β the tool
keeps only the final component and pushes it through the file-root guard. A
pointer that resolves to nothing keeps its entry and gains a reason instead of
the byte fields:
// show_evidence { "refs": ["bogus#1@deadbeef"] }
{
"schema_version": 1,
"requested": 1,
"resolved": 0,
"verified": 0,
"summary": "0 of 1 pointer(s) resolved; 0 verified against a recorded digest",
"frames": [
{
"pointer": "bogus#1@deadbeef",
"status": "unresolvable",
"reason": "cannot open '<file-root>/bogus': Failed to open pcap file ..."
}
]
}status has three values and they are deliberately not interchangeable:
| Status | Means |
|---|---|
verified |
The frame is there and its bytes hash to what the pointer recorded. The capture has not changed under the claim. |
unverified |
The frame is there, the pointer carried no @digest, so this checked nothing. The bytes could come from a rotated capture. |
unresolvable |
No bytes. reason says why β a malformed pointer, a source outside the file root, a frame past the end, or a digest mismatch. |
A digest mismatch is unresolvable, not a resolved frame with a warning.
Returning bytes from a capture that changed after someone made the pointer
would manufacture exactly the confidence this feature exists to provide.
The file root confines every source. A pointer carries whatever path the
producing run read, which usually sits outside the server's reach. The tool
therefore takes only the final component and pushes it through the same guard
the file tools use.
A pointer naming a live device or a HEP listener is unresolvable: sipnab
retains parsed messages, not frames, so there is nothing on disk to seek to.
One bad pointer never discards the rest of a batch β each gets its own entry, so
a caller can tell which one failed. max_bytes caps the hex per frame (default
256, maximum 4096) and truncated says when a frame was longer.
Follows one frame pointer down to the byte range of a single header.
show_evidence answers half the provenance question β are
these the bytes behind the claim β and hands back a hexdump. That
leaves a reader holding 500 bytes of Ethernet, IP, UDP and SIP when one header
line provoked the finding. This tool follows the same pointer through the same
resolver and reports what the frame CONTAINS: the link type, the innermost
addressing, and one row per SIP header with where that header sits.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
frame_ref |
string | ONE pointer in <source>#<ordinal>@<digest> form β the frame key on a dialog, message or stream, or the frame_ref key on a lint_dialog or validate_message finding. Both names carry the same text. A blank string fails with invalid_params. |
Required β the call fails. |
field |
string? | Keep only headers with this name, matched case-insensitively against the canonical long form (Contact, never m). |
Every header in the message. |
One pointer rather than a batch, unlike show_evidence: a status line per
pointer is small, and a whole packet's structure per pointer fills a context
window with frames nobody asked to read. field is the compact form and the one
an agent chasing a finding wants β a REGISTER carries a dozen headers and the
malformed one is a single line.
The example follows the pointer
list_dialogs returns for the first call in
tests/pcap-samples/sip-rtp-g711.pcap:
// decode_evidence { "frame_ref": "tests/pcap-samples/sip-rtp-g711.pcap#0@db88659b94678546",
// "field": "Contact" }
{
"frame_bytes": 500,
"link_type": 1,
"network": { "src_addr": "10.0.2.20", "src_port": 5060,
"dst_addr": "10.0.2.15", "dst_port": 5060, "transport": "UDP" },
"ordinal": 0,
"payload_bytes": 458,
"payload_offset": 42,
"pointer": "tests/pcap-samples/sip-rtp-g711.pcap#0@db88659b94678546",
"schema_version": 1,
"sip": {
"header_count": 9,
"headers_returned": 1,
"headers": [
{ "name": "Contact", "value": "sip:[email protected]:5060", "index": 5,
"message_byte_start": 227, "message_byte_end": 259,
"frame_byte_start": 269, "frame_byte_end": 301 }
],
"is_request": true,
"method": "INVITE",
"start_line": "INVITE sip:[email protected]:5060 SIP/2.0"
},
"source": "sip-rtp-g711.pcap",
"status": "verified"
}header_count counts the message and headers_returned counts this answer, so
field never hides how much it filtered out. index is the ordinal a lint
finding cites, so a caller holding one pairs the two without matching on header
names. The message-relative pair locates the header inside the SIP bytes and the
frame-relative pair locates it inside the whole frame, which is what turns
show_evidence's hexdump into a quotation.
The ranges come from the parse itself. SipHeader carries the byte range
of its own logical line, recorded by the parser as it walks, so a range cannot
disagree with the header beside it. A folded header spans every continuation
line the parser unfolded into it, so the range covers bytes the unfolded value
does not reproduce verbatim.
This replaced a second walk of the header grammar, paired with the parse positionally. Two walks of one grammar part company β over a line with no colon, a non-UTF-8 line, an over-long one, or the per-message header cap β and a range pinned one header early still resolves, so it reads as evidence. The old design handled that by dropping the WHOLE set on any disagreement, which meant one junk line cost every other header its range. There is one walk now, so a line the parser drops costs nothing.
A header that came from no bytes carries no range, and ranges_unavailable
counts them. Headers are also built by hand β synthesized in a test, or
rewritten by a transform β and a plausible range invented for one of those would
point at bytes that never said it:
{ "sip": { "header_count": 7, "headers_returned": 7, "headers": [ /* one row with no byte keys */ ],
"ranges_unavailable": "1 header(s) carry no byte range, because they came from no bytes in this message -- synthesized, or rewritten after the parse. A plausible range for one of those would point at bytes that never said it." } }Citing bytes that never carried the value would be worse than citing none,
precisely because it resolves. The frame-relative pair also drops on its own when the transport
payload does not sit at exactly one place in the frame β a decapsulated or
reassembled payload need not be a contiguous slice at all, and two candidate
offsets make frame[start..end] a coin toss. Keys stay absent rather than
empty throughout, the rule frame_ref already follows: 0 and "" both read
as real values.
status carries the same three words show_evidence uses, with the same
meanings. An unresolvable pointer keeps its own compact shape β no addressing,
no headers, no byte counts β so a thin decode can never pass for a partial one:
// decode_evidence { "frame_ref": "uprobe:opensips/1234#7" }
{
"schema_version": 1,
"pointer": "uprobe:opensips/1234#7",
"status": "unresolvable",
"reason": "read 7 came from the TLS library inside opensips (pid 1234); it was never a frame on any wire, so there is no capture to seek into and no bytes to verify it against. This is not a missing file"
}Two more refusals sit between "resolved" and "decoded", and each keeps its own
key rather than pretending to a decode. decode_unavailable appears when the
frame resolves and the capture does not reopen for its link-layer type β
decoding an SLL or PPPoE capture as Ethernet produces addressing that looks
decoded and is wrong β and when the frame carries no SIP-bearing transport.
not_sip appears when the transport payload does not parse as SIP.
The file root confines every source. A pointer carries whatever path the
producing run read, usually an absolute path far outside this server's reach.
This tool never opens that path: it keeps the final component and pushes it
through resolve_in_root, the same guard the file tools use, which is why
source above reads sip-rtp-g711.pcap and not the path in the pointer. Without
that step, a tool taking a caller-supplied path and returning the decoded
contents of the file there is an arbitrary-file-read primitive wearing a
readOnlyHint. A pointer naming a file outside the root comes back
unresolvable with the confined path in its reason.
No timestamp, deliberately. The resolver returns the frame's bytes without
the capture record that framed them, so any time here would be an invention β
and a manufactured capture time on an evidence surface is the exact failure this
mechanism exists to prevent. get_message carries the message's
time alongside the same pointer. For the same honesty, a frame carrying two
SIP messages packed into one TCP segment decodes as the FIRST one: the
pointer is frame-granular, so it cannot name the second.
One directory holding everything an escalation needs for a set of calls.
Analysis ending in a paragraph creates work: somebody has to translate it into an attachment, a ticket or a message to a carrier. This tool ends the analysis in an artifact instead β a pcapng of the signaling, a ladder and an RTP-stats file per call, a manifest, and a README carrying the rebuilt-frames disclaimer. The disclaimer lives INSIDE the directory on purpose. The directory is what gets forwarded, and whoever opens it at the carrier never saw the tool that made it.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
call_ids |
string[] | At least one Call-ID the store holds, in the order they should appear. A repeat packages once. More than --mcp-max-rows of them fails, asking for batches. |
Required β the call fails. |
filename |
string | A bare directory name inside --mcp-file-root, and it must not already exist. |
Required β the call fails. |
// build_evidence_package { "call_ids": ["[email protected]", "[email protected]"],
// "filename": "escalation-4417" }
{
"schema_version": 1,
"path": "/var/spool/sipnab-exports/escalation-4417",
"calls": 2,
"messages": 10,
"files": [
"README.md", "manifest.json",
"call-01-ladder.md", "call-01-rtp.json",
"call-02-ladder.md", "call-02-rtp.json",
"signaling.pcapng"
],
"summary": "2 call(s) packaged in /var/spool/sipnab-exports/escalation-4417. README.md inside it states that the frames in signaling.pcapng were rebuilt from parsed messages rather than copied, which is the claim the recipient has to see."
}manifest.json maps each ordinal back to its Call-ID and repeats the
disclaimer as a field:
{
"schema_version": 1,
"sipnab_version": "0.5.129",
"package": "escalation-4417",
"signaling": "signaling.pcapng",
"signaling_frames_rebuilt": true,
"calls": [
{ "index": 1, "call_id": "[email protected]",
"ladder": "call-01-ladder.md", "rtp_stats": "call-01-rtp.json" },
{ "index": 2, "call_id": "[email protected]",
"ladder": "call-02-ladder.md", "rtp_stats": "call-02-rtp.json" }
]
}Filenames are ordinals, never the Call-ID. A Call-ID is arbitrary text a
peer chose: it can hold a separator, a leading dash, four hundred characters, or
bytes no filesystem wants. None of that belongs in a name this tool constructs,
so the manifest carries the correlation and call-01-ladder.md carries the
content.
The pcapng holds every named call's messages in one chronological run, not
grouped by leg. A multi-leg escalation reads as one timeline, and splitting it
by leg hides the interleaving that is usually the finding. Its frames come from
export_capture's writer and carry that tool's whole
asterisk: the SIP layer is faithful, everything under it comes rebuilt from
recorded addresses and ports, a SIP-over-TCP message writes as UDP, and RTP,
RTCP, DNS and ICMP are absent. The RTP measurements in call-NN-rtp.json come
from the ORIGINAL capture and nobody can reproduce them from the pcapng alone,
which is exactly why the README says so.
The ladder and the stats come from render_ladder and
rtp_stats rather than from a second rendering path, so a package
can never disagree with what the same agent saw over the wire.
Nothing gets created until every Call-ID checks out. The tool collects the
messages first, so an unknown id in the middle of the list fails with
invalid_params and leaves no directory and no claimed name behind. Any write
that fails afterwards removes the part-built directory too: a half-written
package would hold the name against a retry and read as complete evidence.
The shared file rule applies in full β bare names only, no symlink out of the root, and the tool declines a name already taken, including by a directory:
{ "code": -32602,
"message": "the requested filename '/var/spool/sipnab-exports/escalation-4417' already exists. sipnab will not write over it, because that file may be the only copy of a capture β choose a name that is not taken." }An empty call_ids array fails too, with an empty package is a directory of disclaimers.
The only write verb on sipnab's network surface. Requires
--mcp-allow-save-findings, which is off by default.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
summary |
string | A one-line conclusion. sipnab clips it at 500 characters and reports the original length. | Required β the call fails. |
call_id |
string? | Any string. Not checked against the store, deliberately: a note about a call the store has since dropped is still the note that mattered. | The finding records no call. |
detail |
string? | Supporting text, clipped at 4096 characters. | The finding carries a summary alone. |
// save_findings { "summary": "audit probe", "call_id": "[email protected]", "detail": "x" }
{
"schema_version": 1,
"seq": 0,
"written_at": "2026-08-13T12:12:17.976455577+00:00",
"summary_chars_submitted": 11,
"detail_chars_submitted": 1,
"truncated": false,
"recorded_total": 1,
"remaining": 999,
"readable_over_mcp": false,
"delivered_to": "sipnab log (tracing/journald/stderr)",
"capture_identity": {
"node": "capture-01",
"instance": "1d1a718cb5c33b7c52754-1",
"dialog_generation": 13,
"stream_generation": 2
}
}readable_over_mcp: false is a constant, not a state. It appears on every
response so a client never has to infer the dead end from the absence of a
reader.
It records what the agent concluded, and that is all it does. The finding goes
to sipnab's log and nowhere else: no tool reads it back, it appears in no query
result, and no analysis consumes it. There is no list_findings, deliberately.
That dead end is the entire safety argument. Every response on this surface
carries attacker-controlled text β From display names, User-Agent strings,
raw message snippets β so a write verb reachable from that text must not be able
to change what an operator is reading, or to come back later as evidence the
agent then cites. The compiler enforces this rather than convention: the
annotation types stay private to the MCP module, so no analysis code can name
them.
Read your findings where operational facts already live β journalctl -u sipnab,
syslog, or stderr. Each line records the agent's claim as the agent's claim,
never as a measurement of sipnab's own.
Two bounds, both reported rather than silent. sipnab clips text at 500
characters of summary and 4096 of detail, setting truncated and
*_chars_submitted giving the original length. And one process accepts 1000
findings, after which writes are refused with an error naming the limit β
never accepted and discarded, because an agent told "recorded" about something
the server threw away is worse off than one told plainly that it was not.
remaining counts down so the bound is visible before it bites.
An agent that reports "media anchored at <addr>" cannot otherwise say whether
that came from SDP the two parties exchanged, from a relay sipnab asked, or
from a mirrored datagram anybody could have sent. Those carry very different
weight in an incident review and every other surface renders them identically.
delivery_trust, strongest first:
| value | meaning |
|---|---|
asked |
sipnab asked the relay over its control socket; no third party could answer |
hmac-verified |
delivered over HEP, authenticated with a token covering the datagram |
plain-secret |
delivered over HEP with a shared secret; the token does not cover the datagram, so anyone who captures one can replay it |
port-gated-only |
accepted because it arrived on the expected port and for no other reason β the source is not authenticated |
not-relay-asserted |
the parties' own claim in SDP |
The authentication answer comes from the run's configuration rather than a per-packet record, and that is correct rather than a shortcut: ingest rejects any datagram that fails authentication, so everything still in the store arrived under whatever posture the run configured. With no posture configured it 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.
// explain_attribution { "call_id": "[email protected]" }
{
"call_id": "[email protected]",
"endpoints": [
{
"address": "192.0.2.10",
"port": 10000,
"asserted_by": "signaled",
"input_origin": "wire",
"observed_at": "2023-11-14T22:13:25+00:00",
"delivery_trust": "not-relay-asserted",
"delivery_note": "the parties' own claim in SDP, not a relay's statement about its allocation"
}
],
"unauthenticated_endpoints": 0,
"schema_version": 1
}decode_evidence decodes a SIP frame. This decodes a relay control message,
and adds the question no other surface answers: which path carried it, and did
that path authenticate whoever sent it.
That distinction decides what the message is worth. Anything on the segment can put a control message on the HEP port, and landing there is the whole reason sipnab credits it -- whereupon it names a call and binds media to an address its sender chose. One delivered to an HMAC-authenticated listener is a different claim entirely. Both decode to the same bytes.
delivery is hep or sniffed-udp. delivery_trust uses the same scale as
explain_attribution. on_believed_mirror_port stands
apart because it is the ONLY reason anything believes a sniffed message at all,
and a reader should see the whole of that reason rather than its conclusion.
Status is verified, unverified or unresolvable, as in
decode_evidence, and a pointer leading nowhere returns
a reason rather than failing the call.
// decode_ng { "frame_ref": "relay.pcap#412@9f2c1a" }
{
"pointer": "relay.pcap#412@9f2c1a",
"status": "verified",
"source": "relay.pcap",
"ordinal": 412,
"delivery": "hep",
"delivery_trust": "port-gated-only",
"delivery_note": "accepted because it arrived on the expected port and for no other reason: the source is not authenticated",
"on_believed_mirror_port": true,
"command": "offer",
"call_id": "[email protected]",
"has_sdp": true,
"sdp_bytes": 231,
"schema_version": 1
}Getting the result out of sipnab and into the next tool, the next team, or a bug report someone else can reproduce.
list_captures, export_capture, export_audio,
compare_captures and
build_evidence_package all require
--mcp-file-root <DIR> and refuse to run without it. They take a bare
filename, never a path. generate_repro joins them the
moment you give it a filename, and
decode_evidence pushes the source half of a frame pointer
through the same guard.
sipnab refuses ../x, /etc/passwd and sub/dir.pcap before any filesystem
call. That is the whole security model and it is deliberately absolute: a tool
accepting an agent-supplied path is an arbitrary file write, not an export.
Name checking alone does not finish the job, so sipnab does one more thing. A symlink already sitting in the root is a single bare component β it passes every check above, and the kernel follows it when the file opens. sipnab therefore compares the resolved path against the root in its fully resolved form and refuses a name by where it points rather than by how someone spelled it. Each tool returns the resolved path, so a caller learns where the bytes actually went.
That escape needed prior write access inside the root, so it never amounted to a remote break. sipnab closes it because this page calls the boundary absolute, and a boundary described that way ought to be.
The boundary stops an escape, and an overwrite. Inside the root,
export_capture,export_audio,build_evidence_package,generate_repro'sfilenameandshutdown_server'ssave_torefuse a filename that already exists, name the file, and ask for one that is free. sipnab declines to write over a file it did not create, because that file may hold the only copy of a capture. Calllist_capturesto see which names a directory already uses.The guard covers every capture in the root, not only the one the run is reading. Narrowed to the current capture it would miss the others staged there for
open_capture, and every earlier export, which then fall to a name collision on a call that reports success.
Writes the SIP signaling sipnab is holding to a pcap. Use it to preserve signaling before stopping a live capture β otherwise the messages end with the process.
The file is not a copy of the capture. sipnab keeps parsed messages, not the frames that arrived, so the export rebuilds one Ethernet/IP/UDP frame around each message. The SIP layer is faithful. Everything under it is reconstructed from the addresses and ports sipnab recorded.
Concretely, the file holds no RTP, no RTCP and no non-SIP traffic, and writes a SIP-over-TCP message as UDP. On one measured export, 4,875 of the 5,000 packets that had been on the wire were absent.
That matters beyond the analysis, because the output is a pcap and people forward pcaps. If the file is going to a carrier, a regulator or a court, say what it is β nothing inside it announces that the frames were rebuilt.
Parameters: filename (string, required) β a bare filename inside
--mcp-file-root. A path component of any kind fails with '../escape.pcap' is not a bare filename. sipnab does not require a .pcap extension and does
not add one, so notes.txt writes a pcap under that name, which
list_captures then never lists. No optional parameters.
Returns path (the resolved absolute path, so you learn where the bytes went),
messages, bytes and schema_version:
// export_capture { "filename": "sig.pcap" }
{
"schema_version": 1,
"path": "/var/spool/sipnab-exports/sig.pcap",
"messages": 10,
"bytes": 5673
}Writes one call's RTP audio to a WAV in the configured root. Fails when the call carries no audio it can decode, rather than writing an empty file.
Requires --retain-audio on the server command line: call audio is
content, not signaling, so holding it in memory is an operator decision
rather than a side effect of enabling MCP. Without the flag the tool refuses,
and its refusal reports the media it measured and names the flag β a capture
setting, not a finding that the call was silent.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
call_id |
string | A Call-ID whose streams carry audio sipnab can decode. | Required β the call fails. |
filename |
string | A bare filename inside --mcp-file-root, under the same rule export_capture applies. sipnab writes a WAV whatever extension you give it. |
Required β the call fails. |
Returns path, summary and schema_version:
// export_audio { "call_id": "[email protected]", "filename": "call.wav" }
{
"schema_version": 1,
"path": "/var/spool/sipnab-exports/call.wav",
"summary": "Exported 8.5s of mu-law audio (425 frames, PCMU/8000Hz) to /var/spool/sipnab-exports/call.wav"
}Without --retain-audio the refusal arrives as internal_error (-32603) rather
than invalid_params, and it reports what sipnab measured so the answer cannot
read as a silent call:
No audio payload retained: sipnab measured 425 RTP packet(s) of PCMU on 1
decodable stream, but kept none of their payload, so there is nothing to
decode. Audio payload retention was off for this run β that is a capture
setting, not a finding that the call was silent. Start the server with
--retain-audio to hold payload for export.
One observed dialog as a vCon conversation container
(draft-ietf-vcon-vcon-core,
syntax 0.4.0) β the interchange format a conversation travels in once it
leaves the system that captured it. Backed by
output::vcon.
--export-vcon writes the same container from the CLI.
Unlike its file-writing neighbors this tool writes nothing and needs no
--mcp-file-root. It returns the container inline, so read_only_hint is
true and the agent decides what to do with it.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
call_id |
string | A Call-ID the store holds. An unknown one fails with invalid_params (-32602) naming the value. |
Give filter instead. |
filter |
string | A filter alias or a raw DSL expression, the vocabulary list_dialogs takes. The --export-vcon-when flag answers the same question but accepts the DSL expression ONLY -- it rejects an alias, which is FLT1. Every matching dialog comes back. |
Give call_id instead. |
limit |
integer | 1 to --mcp-max-rows (1000 by default). |
50. |
Give call_id or filter, never both. The CLI refuses the same pair, because
a request naming a dialog AND a rule for choosing dialogs has two answers and
names neither.
Export a set in one call. An agent asked for every failed call used to list
dialogs and then issue one export_vcon per row β on a real capture, hundreds
of round trips to do what --export-vcon-when does in one invocation. Pass the
filter here instead. A container is a large answer, so limit bounds the set.
total_matched counts the whole store, so it is the number to page against.
A filter matching nothing answers with an empty set. "No call failed" is a finding, and a refusal there would make a clean capture look like a broken request.
There is no format. A vCon IS a JSON container defined by the draft, so a
markdown arm would render a document whose whole purpose is to travel between
machines β and an agent offered one would eventually hand the prose to
something expecting a container.
What sipnab claims, and what it refuses to claim. The container is an observer vCon and says so in its own parties. sipnab read a mirror port: it did not place the call, record it, or obtain anyone's permission to keep it. So four things are absent by design, and their absence is the message:
- No signature and no encryption. A JWS over this container reads as the domain that constructed this vouches for it. What sipnab could truthfully sign is these bytes are what sipnab observed, and no signature algorithm carries that distinction. A verifier would check it, get back "authentic", and draw the wrong conclusion.
-
No party name sipnab vouches for, and
validationis always"none".FromandToare a claim the sender made about itself. sipnab EMITS anamewhen the wire carried a display name -- besidesip_display_name, not instead of it -- so a generic vCon reader shows a named party. What sipnab refuses is the claim, not the field. Treat the name as personal data: a redaction step must covernameas well assip_display_name. - No consent and no lawful-basis attachment. An empty consent field reads as "nobody recorded consent", a statement about the CALL. The truth is a statement about the producer: sipnab was never in a position to record one.
-
No
url, ever. Media travels INLINE or not at all, because sipnab hosts nothing a URL could reach and a dead link inside a record is indistinguishable from evidence somebody removed.
What happens to the audio. dialog.type: "recording" is a FORMAT term for
a Dialog Object carrying media. A consumer's recordings table is a PROVENANCE
term for containers from an in-path recorder. sipnab emits the first and is
never the second: it reconstructed the audio from a mirror port, and every WAV
it writes says "not a recording made by the endpoints".
So audio the run RETAINED travels inline -- base64url, mediatype: "audio/x-wav", and a content_hash of sha512- plus the base64url SHA-512 of
the DECODED bytes, which sha512sum on the exported .wav reproduces. The
duration on that object is the FILE's, never the call's. When a payload ring
dropped frames, a recording-set wraps it carrying the CALL's media window, so
the two clocks stand side by side -- that is the only way the format can say
"this file is a fragment of that call".
capture_completeness.media says which of four things happened, so nobody has
to read a missing recording object as an answer:
media |
What it means |
|---|---|
carried |
The audio is inline in a recording Dialog Object |
refused-over-budget |
sipnab decoded audio and REFUSED to inline it. One probed store answers 204 and drops a payload over 10485760 bytes without telling the producer, so the emitter enforces a 5 MiB budget itself. The audio exists and was not truncated |
none-decodable |
The run decoded no audio. media_note reports the measurement -- never that the call was silent |
not-considered |
Nobody asked this export for media. A fact about the export, not about the call |
What the capture MISSED travels with the container, in two places. vCon has
no field meaning "this record is incomplete" β dialog.type: "incomplete" says
the CALL did not complete, which accuses the traffic rather than the tap. So the
caveat rides in the analysis object AND in a sipnab-capture-completeness
attachment, both built from ONE value so the two cannot contradict each other.
Read capture_completeness.note before the contents: every clause in it is a
measurement of what this run READ and dropped.
Every container comes back with its SHA-256. sipnab computes it exactly as
--vcon-digest does β one function, over the container's own serialized bytes
β so a SHA256SUMS line or a store's ledger entry compares against this value
directly. That is what binds one emission to one ledger entry.
The digest identifies the DOCUMENT, not the dialog. created_at records the
moment sipnab wrote the container, so re-exporting one call produces a second
document with a new digest and the same uuid. Deduplicate on the uuid.
What the container omits reaches you in the response. A container over the
size budget carries no audio, which looks exactly like a conversation that had
none, and the caveat explaining the difference lives inside an attachment whose
body is JSON text. So the response repeats it where a caller reads it.
completeness.note is the container's own caveat, verbatim.
completeness.max_inline_media_bytes is the budget this run ENFORCED, never the
compiled-in default. completeness.omissions carries one row per loss, each
naming the counter it came from, how many, and in what unit. An empty list and
complete: true say the container lost nothing.
The response is an envelope, so one dialog and a filtered set arrive in one shape:
// export_vcon { "filter": "response_code >= 400", "limit": 2 }
{
"schema_version": 1,
"returned": 2,
"total_matched": 7,
"truncated": true,
"capture_identity": { /* which capture, and which revision of its stores */ },
"containers": [
{
"call_id": "[email protected]",
"digest": "b6f0a1c9d84e2f7a3b5c6d0e1f2a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c",
"completeness": {
"note": "Produced by sipnab 0.5.141 on node capture-01. ... 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.",
"media": "none-decodable",
"media_note": "No audio payload retained: ...",
"max_inline_media_bytes": 5242880,
"complete": true,
"omissions": []
},
"container": { /* the vCon itself, shown in full below */ }
}
]
}A run that lost something fills the same block instead:
"completeness": {
"note": "Produced by sipnab 0.5.141 on node capture-01. ... β INCOMPLETE: 3 header line(s) exceeded the parser's length cap and were dropped, ...",
"media": "refused-over-budget",
"media_note": "...",
"max_inline_media_bytes": 1048576,
"complete": false,
"omissions": [
{ "kind": "media_refused_over_budget", "count": 1, "unit": "recording" },
{ "kind": "headers_dropped_oversize", "count": 3, "unit": "header" }
]
}One row per β INCOMPLETE: clause in the note, always. One set of counters
produces both the rows and the prose, so a reader holding either has the whole
account.
The container example runs against tests/pcap-samples/sip-rtp-g711.pcap:
// the "container" member of one entry above
{
"vcon": "0.4.0",
"uuid": "0158a120-0392-86a4-a667-78f1c5213800",
"created_at": "2026-08-24T22:13:31.238368261+00:00",
"extensions": ["sip-signaling", "CC"],
"parties": [
{
"sip": "sip:[email protected]:5060",
"sip_contact": "sip:[email protected]:5060",
"sip_display_name": "PCMU/8000",
"validation": "none"
},
{
"sip": "sip:[email protected]:5060",
"sip_display_name": "test",
"sip_user_agent": "FreeSWITCH-mod_sofia/1.6.12-20-b91a0a6~64bit",
"validation": "none"
},
{
"role": "observer",
"sip_user_agent": "sipnab/0.5.124 (observer; node capture-01)",
"validation": "none"
}
],
"dialog": [ { "sip_call_id": "[email protected]" } ],
"attachments": [
{
"purpose": "sip-message-trace",
"party": 2,
"mediatype": "application/json",
"encoding": "json",
"body": { "schema_version": 1, "sip_call_id": "[email protected]", "messages": [ /* the same objects --json emits, one per message */ ] }
},
{
"purpose": "sipnab-capture-completeness",
"party": 2,
"mediatype": "application/json",
"encoding": "json",
"body": {
"note": "Produced by sipnab 0.5.127 on node capture-01. sipnab OBSERVED this dialog and took no part in it: ... 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.",
"node": "capture-01",
"sipnab_version": "0.5.124",
"frames_read": 852,
"undecodable_frames": 0,
"sip_discarded_by_port_gate": 0,
"sip_discarded_by_websocket_gate": 0,
"messages_evicted": 0,
"dialogs_refused": 0,
"dialogs_rotated": 0,
"blind_spots": [],
"media": "none-decodable",
"media_note": "No audio payload retained: sipnab measured 425 RTP packet(s) of PCMU on 1 decodable stream, but kept none of their payload, so there is nothing to decode. This is a statement about what this run kept, not a finding that the call was silent. ..."
}
}
],
"analysis": [
{
"type": "report",
"dialog": 0,
"vendor": "sipnab",
"product": "sipnab 0.5.175 (passive observer; not a recording system)",
"schema": "sipnab-dialog-diagnosis/1",
"mediatype": "application/json",
"encoding": "json",
"body": { "schema_version": 1, "sip_call_id": "[email protected]", "final_status_code": 200, "capture_completeness": { /* the same object the attachment carries */ } }
}
]
}An unknown Call-ID answers with an error, never an empty container:
// export_vcon { "call_id": "no-such@nowhere" }
{ "code": -32602, "message": "call_id 'no-such@nowhere' not found" }A filter that selects nothing answers with an empty set instead, because "nothing matched" is a finding about the capture and "that call is not here" is a mistake in the request.
Three things a reader must not conclude.
blind_spots: [] is not the same answer as an absent blind_spots. The empty
array means a capture analysis ran and ranked nothing. The absent field means
nobody looked. Both doors here always run the analysis, so an absent field on a
container from this tool would be a defect rather than a clean bill.
The observer party is always the LAST entry, and every attachment's party
index points at it. Do not hard-code 2: the count changes the day a container
carries a party the two observed headers did not name.
uuid is a UUIDv8 whose timestamp half is the DIALOG's, not the export's, so
re-exporting one dialog out of one capture keeps one identifier and a consumer
can deduplicate on it. Two dialogs that opened in the same millisecond on the
same node have only 12 bits separating them β that is inherent in the draft's
layout, which spends a v7's entropy on a host-derived rand_b.
A binary built without the vcon Cargo feature refuses this tool with
invalid_params and names the feature, rather than the tool being absent.
server_capabilities lists what a given binary carries.
Checks a vCon container against the working group's schema as sipnab vendors it
(tests/schemas/vcon.schema.json). Backed by
output::vcon_schema.
Run it before handing containers to a conserver. A store that refuses one reports the refusal to whoever POSTed it, never to whoever built it β and a validation pass over 4,216 real containers found 2 the schema rejects, with nothing on any surface saying so.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
call_id |
string | A Call-ID the store holds. sipnab exports its container, then checks it. | Give container instead. |
container |
object | A container you already hold β one sipnab wrote, one another producer wrote, one a store rejected. It must be the object itself, not a string holding it. | Give call_id instead. |
Give one, never both.
Three verdicts, not two.
verdict |
What it means |
|---|---|
valid |
Nothing disagrees with the schema |
valid-except-documented-deviation |
Every finding is a shape sipnab emits on purpose that the schema rejects. deviations names each one and explanations says why |
invalid |
At least one finding is an ordinary defect. errors carries it |
The middle verdict carries the whole point.
Section 4.3 of draft-ietf-vcon-vcon-core-03
says "it is
possible to have a Dialog Object with no parameters in it", the working group
agreed that shape in issue #20 after IETF 124, and the draft's own Appendix B
schema forbids it, because every Dialog Object requires a start. sipnab emits
one: the consultative call of an attended transfer, which the observed leg
never saw. A validator that folded that into a clean pass would teach a
producer that a missing start is fine β and a missing start on a transfer
object is exactly the defect the corpus pass found.
So the exemption is narrow. ONLY a Dialog Object with no members at all counts
as the documented deviation. A typed object missing start is an error, and
the two never merge.
// validate_vcon { "call_id": "[email protected]" }
{
"schema_version": 1,
"verdict": "valid",
"schema_path": "tests/schemas/vcon.schema.json",
"schema_id": "https://ietf.org/vcon/schemas/unsigned-vcon.json",
"errors": [],
"deviations": [],
"explanations": [],
"call_id": "[email protected]"
}A container carrying an attended transfer's consultation object:
{
"verdict": "valid-except-documented-deviation",
"errors": [],
"deviations": [
{
"instance_path": "/dialog/2",
"keyword": "required",
"detail": "missing required properties: start",
"deviation": "empty-dialog-object"
}
],
"explanations": [
{
"name": "empty-dialog-object",
"explanation": "The empty Dialog Object `{}` of section 4.3: ... reported here rather than passed silently ..."
}
]
}And the defect the corpus pass found, which is an error rather than an exemption:
// validate_vcon { "container": { ..., "dialog": [ { "type": "transfer" } ] } }
{
"verdict": "invalid",
"errors": [
{
"instance_path": "/dialog/0",
"keyword": "required",
"detail": "missing required properties: start"
}
],
"deviations": []
}A container that disagrees with the schema is an ANSWER, not a tool error. The
call fails with invalid_params only when the REQUEST is wrong: neither
argument, both arguments, an unknown call_id, or a container that is not a
JSON object.
The validator reads the vendored file rather than a transcription of it.
jsonschema is a dev-dependency, so a validator built on it would ship only in
the test tree β which is where the gap already was. This walks the schema
itself, implementing the draft-07 subset the file uses, and it refuses to guess:
a keyword outside that subset makes every validation report invalid naming
the keyword. Re-vendoring a richer schema therefore fails loudly instead of
quietly certifying less than it claims.
A binary built without the vcon Cargo feature refuses this tool with
invalid_params and names the feature.
server_capabilities lists what a given binary carries.
A SIPp scenario that replays one call, with your hypothesis as an input.
The novel part is the second half of that sentence. pin names the aspects of
the captured request you believe caused the outcome and copies them
byte-for-byte. vary names the identity fields SIPp regenerates per run, so the
replay is a new call rather than a retransmission of the captured one. The
artifact then encodes the theory, and running it tests THAT rather than
replaying generically. Getting the split wrong emits a scenario that
"reproduces" for unrelated reasons, which is worse than emitting nothing.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
call_id |
string | A Call-ID the store holds, carrying at least one request. | Required β the call fails. |
format |
string? |
"sipp", the only one. Anything else fails with unknown format 'x'; 'sipp' is the only one. |
"sipp". |
pin |
string[]? | Any of branch, call_id, cseq, headers, request_uri, sdp, tags, user_agent. An explicit [] means "nothing", not "the default". |
Nothing pinned β the scenario tests no theory and the response says so. |
vary |
string[]? |
branch, call_id, tags β the three SIPp can regenerate. Anything else from the pin vocabulary fails, naming the three. |
All three. |
filename |
string? | A bare name in --mcp-file-root to write the scenario to as well. The text comes back either way. |
The response carries the scenario and nothing touches the disk. |
// generate_repro { "call_id": "[email protected]", "pin": ["sdp", "user_agent"] }
{
"schema_version": 1,
"call_id": "[email protected]",
"format": "sipp",
"hypothesis": {
"pinned": ["sdp", "user_agent"],
"varied": ["branch", "call_id", "tags"],
"generated": ["contact", "content_length", "cseq", "max_forwards", "request_uri", "via"],
"omitted": ["headers"]
},
"asserted": { "provisional": [100], "final": 200 },
"scenario": "<?xml version=\"1.0\" encoding=\"UTF-8\" ?>\n<!DOCTYPE scenario SYSTEM \"sipp.dtd\">\n...",
"run": "sipp -sf <scenario.xml> -m 1 <proxy-host>:<port>",
"caveats": [
"user_agent was pinned and the captured request carries no User-Agent header, so none is sent",
"the pinned body names the media endpoint the ORIGINAL caller advertised, so any RTP the far end sends goes there and not to the machine running SIPp",
"SIPp sends from the host you run it on. Source address, routing and any IP access list differ from the capture, and a reproduction depends on those matching."
]
}Read the hypothesis block as a four-way split of the original request.
pinned came across byte-for-byte, varied is what SIPp regenerates,
generated is what the template supplies instead of the capture, and omitted
is what the capture held and the scenario leaves out. Nothing falls outside
those four, so no aspect of the original quietly survives or quietly vanishes.
Only three aspects can vary, because varying needs a generator rather than an opinion. SIPp supplies a fresh Call-ID, tag and branch for every call it places. It has no defined way to invent a plausible SDP body or User-Agent, and a scenario built on an invented one would reproduce β or fail to β for a reason nobody chose. Asking for anything else fails:
{ "code": -32602,
"message": "'sdp' cannot be varied: SIPp has no generator for it. Variable aspects are branch, call_id, tags. Pin it instead if it is part of your hypothesis." }An aspect in both pin and vary fails outright. Held fixed and
regenerated are opposite instructions, and a scenario that silently picked one
would encode a theory nobody stated. All three identity fields vary by default
for a reason worth stating: replaying a captured Call-ID, tag and branch at a
proxy is not a repro at all β to the transaction layer it is a retransmission of
a call that proxy already answered, so the response says more about its
transaction state than about the theory under test.
Anything unpinned takes a generic value, and caveats names the cost. With
no sdp pin the offer is a stock PCMU one, so a codec or attribute the far end
rejected never appears. With an empty pin list the response says outright that
the scenario encodes no hypothesis. A pinned body brings the captured
Content-Type with it, because the aspect pins BYTES and the label has to
describe them β a multipart body announced as application/sdp gets rejected
for a reason that has nothing to do with the theory.
The recv sequence asserts what the capture actually held: every provisional
code seen, plus 100 whether or not the capture held one, and the final status.
A call with no final response in the capture asserts nothing about the outcome β
the scenario sends the request and waits. For an INVITE the scenario sends an ACK either
way, with the Via that RFC 3261 calls for in each case: a fresh branch on a 2xx,
because that ACK is a new transaction, and [last_Via:] on a non-2xx, because
RFC 3261 section 17.1.1.3 makes it
part of the INVITE transaction.
Twelve headers the scenario always owns come from the template whatever the
capture held, pin: ["headers"] included: Via, Contact, Content-Length,
Content-Type, CSeq, From, To, Call-ID, Max-Forwards, Record-Route,
Route and User-Agent. A replay runs from a different host, so a captured
Via or Contact would send the responses to a machine that is not the one
running the test.
Two escaping rules protect the artifact, because an operator runs it against
their own proxy. A CR or LF inside a captured header value would end that header
and start another, so this tool strips control characters rather than escaping
them β a SIP header has no escape for one. And ]]> inside a header value or
SDP attribute would close the scenario's CDATA section early, so it splits
across two sections, which preserves the bytes exactly.
Everything in scenario beyond the template β the Request-URI, header values,
SDP β came from the packet's sender, so the response carries the provenance note
rather than fencing the document field by field.
A display filter selecting one call, and the tshark line that applies it.
This is a deliberate small thing. Handing off cleanly to the human's preferred tool signals that sipnab is not trying to own the workflow, and the person who takes the escalation usually opens Wireshark next.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
call_id |
string | A Call-ID the store holds. | Required β the call fails. |
include_media |
bool? |
true adds one rtp.ssrc term per stream attributed to the call. |
true. |
// generate_wireshark_filter { "call_id": "[email protected]" }
{
"schema_version": 1,
"call_id": "[email protected]",
"display_filter": "sip.Call-ID == \"[email protected]\" || rtp.ssrc == 0x343da99b",
"tshark": "tshark -r 'tests/pcap-samples/sip-rtp-g711.pcap' -Y 'sip.Call-ID == \"[email protected]\" || rtp.ssrc == 0x343da99b'",
"streams_included": 1,
"notes": [
"rtp.ssrc matches only once Wireshark is decoding those packets as RTP. Enable 'Try to decode RTP outside of conversations', or use Decode As on the media ports, if the SSRC terms select nothing."
]
}The tshark line names this run's capture file when the server reads one, and
falls back to <capture.pcap> on a live capture. Both the filename and the
filter go through POSIX shell quoting, so an embedded quote in a Call-ID closes
and reopens the quoted word rather than ending it.
notes earns its place twice. With include_media: true and no stream
attributed to the call, it says the filter selects signaling only β otherwise a
filter that quietly lost half its intent looks the same as one that never had
it. With SSRC terms present, it warns that rtp.ssrc matches nothing until
Wireshark decodes those packets as RTP, which is the first thing that goes wrong
when someone pastes this filter into a fresh window.
A Call-ID carrying a control character fails. The display-filter grammar
escapes \ and " and nothing else, so a filter built around a control
character either fails to compile or selects something other than what it names.
A filter that quietly selects the wrong packets is worse than no filter:
{ "code": -32602,
"message": "this Call-ID carries the control character U+0009, which a Wireshark display-filter string literal cannot represent. A filter written around it would not mean what the Call-ID says." }The tools that change server state rather than read it. Every one stays off until you turn it on server-side.
This is the only tool that transmits. Every other one answers from bytes sipnab already holds. This one puts a packet on the network.
It closes a gap a passive decoder cannot. A call already in progress when sipnab started has no control exchange left to read, so the relay's own view is the only way to learn which ports belong to it -- and incident response usually begins mid-call, which is exactly when that gap is worst.
The tool needs three things, and says which one is missing:
| requirement | why |
|---|---|
--mcp-allow-relay-query |
transmitting is a larger act than reading, so an operator opts in |
| the relay control flag | names which relay to ask |
| a live source | a run reading a file can obtain no transmit permit |
There is no address parameter, deliberately. The destination comes from operator configuration and from nowhere else. A tool argument naming the destination would turn this surface into a way to make sipnab send packets to a host the caller chose, and an address sipnab could otherwise infer is one it learned from packets -- a host that served as a relay during the capture, and may be somebody's laptop now.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
call_id |
string? | One Call-ID to ask the relay about. | Switches modes β the tool enumerates what the relay holds. |
max_calls |
u32? | Enumeration only. A cap on the Call-IDs the relay returns. | The relay's own default. |
max_calls takes its name from what it bounds rather than from limit,
because it is the relay's own list argument traveling to another process
rather than a page over data sipnab holds. rtpengine warns that raising it may push the answer past
one UDP datagram, and sipnab reports a truncated answer rather than padding it.
Omit call_id to enumerate. truncated reports that the relay held more than
it returned, because "the relay holds these 32 calls" and "the relay returned
the first 32 of an unknown number" are different statements.
// query_relay { "call_id": "[email protected]" }
{
"asked": "query",
"relay_address": "127.0.0.1:22222",
"outcome": "call",
"call_id": "[email protected]",
"tags": [
{
"tag": "from-tag-9a1c",
"in_dialogue_with": ["to-tag-4b7e"],
"codec": "PCMU",
"streams": [
{
"local_address": "192.0.2.10",
"local_port": 30000,
"endpoint": "198.51.100.4:16388",
"is_rtcp": false,
"ssrcs": [305419896]
}
]
}
],
"delivery_trust": "asked",
"delivery_note": "sipnab asked the relay over its control socket; no third party could answer",
"schema_version": 1
}Transmits, like query_relay. It asks the configured relay
for the statistics it keeps about ITSELF -- never a figure sipnab derived -- so
each value carries the tier relay_reported and never blends with what sipnab
measured. That comparison is relay_compare.
Three modes from two parameters:
| Name | Type | Legal values | If omitted |
|---|---|---|---|
call_id |
string? | One Call-ID, to scope the counters to that call. | The relay's own global counters. |
names_only |
bool? |
true returns the NAMES the relay knows, not their values -- "what can I even ask for", since the key set is version-specific. |
false. A call_id overrides it. |
It needs the same three things query_relay does, and refuses identically when
one is missing: --mcp-allow-relay-query, a configured relay control address,
and a live source.
A relay that answers but declines -- a Call-ID it does not hold -- is a
success carrying outcome: "refused" and the relay's own words, not an
error: the relay answered, it just said no. An unreachable relay is the error,
because nothing is then known.
// relay_stats { "call_id": "[email protected]" }
{
"relay_address": "127.0.0.1:22222",
"tier": "relay_reported",
"outcome": "ok",
"statistics": [
{ "name": "totals.RTP.packets", "value": "9000" },
{ "name": "totals.RTP.bytes", "value": "1440000" }
],
"delivery_trust": "asked",
"delivery_note": "sipnab asked the relay over its control socket; no third party could answer",
"schema_version": 1
}A names_only answer carries names, a names_source (listed when the relay
enumerated them, probed when they are the names that did not refuse) and a
names_note stating how sipnab determined the set -- so a probed set is never
read as a definitive enumeration.
Transmits, like query_relay. It sets the relay's own RTP
packet count for one call beside the count sipnab measured from the packets it
captured. A comparison, never a sum: both figures travel with their tiers
(relay_reported and sipnab_measured), the verdict is a word, and it never
computes relay - sipnab as a value -- the two count different sockets over
different windows, so an ordinary gap is not a relay fault.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
call_id |
string | The Call-ID to compare. | Required -- the call fails. |
Same opt-in and live-source gate as relay_stats. It reports an absent side as
absent, never coercing it to zero: a call the relay does not hold carries sipnab's
count and no relay figure (outcome: "relay_does_not_hold_call"), and a call
sipnab never captured carries the relay's figure and no measured one
(outcome: "sipnab_has_no_rtp"). Only when both sides produced a count -- a
real zero included -- are they compared.
// relay_compare { "call_id": "[email protected]" }
{
"relay_address": "127.0.0.1:22222",
"call_id": "[email protected]",
"outcome": "compared",
"relay_reported": { "value": 9000, "name": "totals.RTP.packets" },
"sipnab_measured": 9000,
"verdict": "match",
"note": "the relay and sipnab agree at 9000 RTP packets",
"delivery_trust": "asked",
"delivery_note": "sipnab asked the relay over its control socket; no third party could answer",
"schema_version": 1
}Destructive. Requires --mcp-allow-open-capture, which is off by default.
It loads another capture from --mcp-file-root and throws away every dialog and
stream the server holds.
Use it on a long-lived HTTP server working through a corpus, where a restart
costs an operator their session. In either stdio shape, starting sipnab again
with a different -I does the same job and leaves a clean store behind, so
prefer that.
Parameters: filename (string, required) β a bare filename inside
--mcp-file-root, under the rule every file tool applies. Unlike
list_captures, any capture format libpcap reads is fine,
.cap included. No optional parameters, and no way to ask for a merge: this
replaces the stores rather than adding to them.
Returns status ("loading"), filename, path, the new
capture_identity, discarded_dialogs, note and schema_version.
// open_capture { "filename": "outage-0722.pcap" }
{
"schema_version": 1,
"status": "loading",
"filename": "outage-0722.pcap",
"path": "/var/spool/sipnab-captures/outage-0722.pcap",
"capture_identity": {
"node": "capture01",
"instance": "1f4a17c8e2b91d40-2",
"dialog_generation": 1,
"stream_generation": 1
},
"discarded_dialogs": 128,
"note": "the previous capture is gone; poll capture_status until load.done is true, and treat every answer carrying a different capture_identity.instance as a different capture"
}The call returns as soon as the background read starts, not when it finishes.
That is deliberate: the REST API and the MCP server share one runtime thread, so
a multi-gigabyte read inside the handler stops every other client for its
duration. Poll capture_status and watch load.packets climb until
load.done turns true.
Every answer afterwards carries a new capture_identity.instance. Treat any
cursor, message index or Call-ID from before the swap as void β they addressed a
different capture. discarded_dialogs says how much analysis the call threw
away, so an agent can report the cost rather than discover it.
Four refusals, each naming what to do instead:
| Refusal | Why |
|---|---|
--mcp-allow-open-capture missing |
The operator did not enable it. The tool is still listed, because "not permitted here" and "this build cannot" are different answers |
| The source is a live interface | A live capture's writer never stops, so a second writer would race it for the life of the process. No opt-out |
| The source has not drained | The original reader is still filling the stores. Poll capture_status until source_exhausted is true |
| A load is already running | Poll capture_status until load.done is true |
A fifth, where the client can carry the question. If your client declared
MCP's elicitation capability, a swap that would discard dialogs asks the
person driving it to confirm first, and a decline refuses the call:
refusing to open 'outage-0722.pcap': the confirmation was declined; nothing
was done. The loaded capture is untouched and its 13 dialog(s) are still
addressable.
The four refusals above resolve before the question, so sipnab asks nobody
to approve a swap sipnab was going to refuse anyway. A swap that would discard
nothing does not ask, and a client that declared no elicitation capability is
not asked and is not treated as having refused β --mcp-allow-open-capture
remains the guard it always was. See
the protocol page.
A filename must be a bare name inside the root, under the same rule every file
tool applies. sipnab also refuses a capture that belongs to this run's own -I
set, with the output guard's wording about overwriting β that file is already
loaded, and reading it again under a new identity would duplicate what the store
holds.
Destructive. Requires --mcp-allow-shutdown, which is off by default.
Without it every call fails, whatever the arguments say:
shutdown is disabled: start sipnab with --mcp-allow-shutdown to permit it.
A stock server cannot be stopped by an agent.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
dry_run |
bool? |
true reports what would happen. false stops the process. |
true β the safe value is the default, so stopping takes a deliberate second call. |
save_to |
string? | A bare filename inside --mcp-file-root to write the capture to before stopping. |
sipnab writes nothing. On a live capture holding unsaved packets, the call then refuses unless discard_unsaved is true. |
discard_unsaved |
bool? |
true accepts losing the packets a live capture holds in memory. |
false β a live capture with unsaved packets refuses to stop. |
Returns dry_run, would_stop, live, unsaved, dialogs, streams,
saved_to, confirmed_by_operator, note and schema_version. Read
would_stop rather than assuming: it is false on a dry run, on a refusal and
on a declined confirmation alike, and note says which.
// shutdown_server {} β no arguments means DRY RUN
{
"schema_version": 1,
"dry_run": true,
"would_stop": false,
"live": false,
"unsaved": false,
"dialogs": 13,
"streams": 2,
"saved_to": null,
"confirmed_by_operator": null,
"note": "dry run β nothing stopped. Call again with dry_run=false to stop."
}Stopping takes a deliberate second call with dry_run: false. On a live
capture holding packets written nowhere, it refuses outright unless you pass
save_to or discard_unsaved: true β losing a capture to a misread sentence
is the failure worth engineering against.
And, where the client can carry the question, sipnab asks a person. If your
client declared MCP's elicitation capability, a dry_run: false call sends a
real elicitation/create request and waits for the answer. Only accept
carrying confirm: true stops the process:
// shutdown_server { "dry_run": false }, confirmation declined
{
"schema_version": 1,
"dry_run": false,
"would_stop": false,
"confirmed_by_operator": false,
"note": "the confirmation was declined; nothing was done"
// ...
}confirmed_by_operator is null where sipnab asked nobody β a dry run, or a
client that declared no elicitation capability. That case is not a refusal:
dry_run remains the guard it always was, so the tool behaves exactly as it
did on any client that cannot answer. save_to writes only on the path that
actually stops, so a declined confirmation leaves no file behind. See
the protocol page.
Installs kernel uprobes on this host's TLS libraries and reads SIP plaintext from them β no key, no certificate, and no restart of the process observed.
Needs --mcp-allow-tls-capture. It is off by default and separate from
--mcp-allow-open-capture, because it is a different act: that one reads a
file an operator placed in a directory, this one attaches probes to a running
process's TLS library and reads its plaintext.
Call list_tls_libraries first.
| Parameter | Type | Default | Meaning |
|---|---|---|---|
flavors |
array of string | every one found |
openssl, wolfssl
|
libraries |
array of string | discover | probe these paths instead of discovering |
// start_tls_capture { } // every library found
// start_tls_capture { "flavors": ["openssl"] } // one flavor only
// start_tls_capture { "libraries": ["/proc/954/root/usr/lib/libssl.so.3"] }
{
"schema_version": 1,
"running": true,
"targets": ["/proc/954/root/usr/lib/libssl.so.3:SSL_write"],
"messages": 0,
"lost": null,
"uptime_sec": 0,
"error": null,
"summary": "Probing 1 TLS library. ..."
}Three refusals, each before any kernel state exists, and each says which one it is rather than a bare failure:
-
not root β sipnab drops privileges after opening its capture devices, so
a server started with
--usercannot attach probes later; - a live source is already running β sipnab's stores have one writer, so a uprobe capture cannot run beside one;
-
a capture is still loading β poll
capture_statusuntilload.done.
An attach failure arrives later, not here. A background thread installs
the probes, so the call returns as soon as that thread starts. Poll
stop_tls_capture or capture_status to see whether
messages actually arrive.
Stops the running capture and removes its kernel probes. Safe to call when nothing is running β it says so rather than failing.
No parameters. Returns:
// stop_tls_capture { }
{
"schema_version": 1,
"running": false,
"targets": ["/proc/954/root/usr/lib/libssl.so.3:SSL_write"],
"messages": 412,
"lost": 0, // records the kernel DROPPED: messages that existed and are missing
"uptime_sec": 96,
"error": null,
"summary": "The TLS capture has stopped and its probes are removed."
}Keep calling until running is false. The stop is a request: the worker
owns the probes and removes them on its way out, which is a kernel round trip
per probe. Probes left installed cost every process that maps the library, and
they outlive sipnab.
lost is worth reading. It counts records the kernel dropped because the
reader fell behind β messages that existed and are missing, which is a
different fact from a quiet trunk and the only one you cannot discover any
other way. The dialogs already collected stay in the store after the stop.
Which TLS libraries processes on this host are actually mapping, and
whether sipnab could attach a uprobe to read their plaintext. Ask before
concluding that reading SIP over TLS needs keys β and read
privileged and probe_path before concluding it can.
No parameters. Returns:
{
"schema_version": 1,
"supported": true, // false off Linux, or without the `native` feature
"privileged": true, // running as root
"libraries": [
{
"flavor": "OpenSSL",
"path": "/usr/lib/aarch64-linux-gnu/libssl.so.3",
"inode": 21143, // the identity; the PATH is not unique
"process_count": 12,
"symbol": "SSL_write",
"probe_path": "/proc/954/root/usr/lib/aarch64-linux-gnu/libssl.so.3"
},
{
"flavor": "wolfSSL",
"path": "/usr/lib/aarch64-linux-gnu/libwolfssl.so.42.2.0",
"inode": 17433084,
"process_count": 1,
"symbol": "wolfSSL_write",
"probe_path": null // in use, but NOT capturable from here
}
],
"unreachable_count": 1,
"summary": "1 of 2 TLS libraries could be read with `sipnab --uprobe-tls`, without any key or certificate. 1 cannot be reached from this server's mount namespace and would be missed."
}Read privileged before believing an empty list. Unprivileged,
/proc/<pid>/maps is readable only for the server's own processes, so a short
list is evidence about privilege rather than about the host. summary says
which of the two situations produced the answer, so a relayed conclusion does
not lose it.
probe_path: null is a finding, not a blank. That library is carrying
traffic sipnab cannot capture β usually a containerized process whose
/proc/<pid>/root this server cannot read. sipnab reports it rather than
dropping it, because the alternative is a capture that looks complete and is
not.
inode is there because path is not unique: the same string names
different files in different mount namespaces, and on an ordinary host with
containers several distinct libssl.so.3 files coexist.
find_problems.kinds β diagnostic alias names, OR-ed together.
Defaults to ["problems"]. The full vocabulary:
problems Β· slow-setup Β· short-calls Β· one-way Β· nat-issues Β·
codec-asym Β· ptime-asym Β· payload-asym Β· duration-asym Β·
late-media
An unknown alias returns a JSON-RPC invalid params error naming the
bad alias. The same names work as the list_dialogs filter aliases
(and as sipnab --filter aliases on the CLI).
security_findings.kinds β matches the rule names sipnab records
findings under: scanner, fraud, digest, reg_flood (note the
underscore β the --alert rule grammar spells it reg-flood, but
sipnab records and filters findings as reg_flood). Omitted or empty
kinds returns findings of every kind.
Both enums refuse a name they do not know, and name the vocabulary in the
refusal. security_findings.kinds used to accept anything and match nothing,
so a name outside those four answered [] β the same bytes a quiet capture
returns. reg-flood was the case that bit, and its refusal now suggests the
underscore spelling by name.
All tools return MCP errors via the JSON-RPC error object. The codes
sipnab uses:
| Code | Meaning |
|---|---|
-32602 (invalid_params) |
Unknown Call-ID, out-of-range index, malformed filter, unknown format, unknown alias, a path where a bare filename belongs, and every "this tool is not enabled" refusal. The common case by far. |
-32603 (internal_error) |
A read or write that reached the filesystem or the decoder and failed there β export_audio with no retained payload, a capture file sipnab cannot open. Not only for bugs. |
| -32000 (server error) | Capacity, not correctness: --mcp-max-concurrent or --mcp-rate-limit-per-peer turned this call away, and the same call succeeds once the server has room. The only code here worth a retry β treat -32602 as a bug in the request. |
Tools never panic. An unknown Call-ID always produces a structured error rather than an empty result.
| Limit | Default | Set by |
|---|---|---|
Default limit for list-style tools |
50 | the per-call limit
|
Ceiling on limit (clamps higher requests) |
1000 | --mcp-max-rows |
| SIP body / snippet bytes | 4096 | --mcp-max-body-bytes |
Messages per get_dialog page |
1000 | --mcp-max-rows |
These are defaults, not laws. The right ceiling belongs to the CONSUMER
rather than to sipnab: an agent with a small context window wants far fewer
than a thousand rows and a batch consumer piping to a file wants far more, so
no single number serves both. Raise or lower them with --mcp-max-rows and
--mcp-max-body-bytes, or the matching [limits] keys in the config file.
The per-call limit narrows an answer below the ceiling, and cannot exceed
it.
A bound is not a loss. list_dialogs, find_problems, search_by_time,
search_messages, security_findings and the capture-wide rtp_stats sweep
each report total_matched beside their page, so a caller sees how much of the
answer it holds. That number describes the STORE, not the file: while the
source is still loading it counts what had arrived by then, which is why every
one of them also carries source_exhausted and why the response omits
truncated: false until the answer is whole. All of those except security_findings carry a cursor to the
rest, as do tail_dialogs and get_dialog. Asking for a limit above the
ceiling does nothing β the ceiling clamps it β so either raise the ceiling with
--mcp-max-rows or page.
Two tools remain exceptions, and a caller has to know which:
| Tool | Reports a total | Carries a cursor | How to reach the rest |
|---|---|---|---|
security_findings |
Yes | No | Raise limit, and narrow with since
|
tail_dialogs |
No | Yes | Follow next_cursor until the page comes back empty |
security_findings has no cursor because its source is a ring buffer whose
default depth is 1000 β the same as the maximum limit, so one call reaches
all of it. Raise --findings-history above 1000 and total_matched is what
tells you a page is short, and since is the way through. tail_dialogs reports
no total because a tail cannot have one: the store keeps changing underneath
it, so any number it gave would describe a moment that has passed.
{ "schema_version": 2, // 2 absorbed the counters the old stats tool returned "source": "live", // "live" | "file" | "unknown" "name": "eth0", // interface, or file path "uptime_sec": 3612, "dialog_count": 128, "stream_count": 64, "orphaned_stream_count": 3, // streams no dialog claims "active_dialog_count": 12, // any non-terminal state "active_call_count": 9, // InCall only β narrower, hence the version bump "capture_quality": { "kernel_dropped_packets": 0, "interface_dropped_packets": 0, "invalid_timestamps": 0, "undecodable_frames": 0, "degraded": false }, "caveats": { "media_creating_commands": 0 // relay commands seen and NOT attributed }, "source_exhausted": false, // true once a file is read to the end "source_stopped_early": false, // true when a read ended before the file did "writing_to": null, // path packets are being saved to, if any "unsaved": true, // stopping now would lose packets "capture_identity": { "node": "capture01", "instance": "1f4a17c8e2b91d40-1", "dialog_generation": 412, "stream_generation": 96 }, "unanalysed_sip_messages": 0, // SIP that --portrange excluded "unanalysed_busiest_ports": [], "unanalysed_websocket_messages": 0, // SIP-over-WebSocket the WS port set excluded "unanalysed_websocket_ports": [], // pass these to --ws-portrange "load": null // an open_capture load in flight, if any }