Docs Real-World Captures
Real-World Captures
Twelve findings read out of live carrier and PBX traffic, with the command, the output and what an operator does next -- the retransmit storms, false-positive detections and misleading aggregates that a curated fixture cannot show.
On this page
- What do you want to learn?
- About the captures and the addresses
- 1. Take the census before you take the ticket
- 2. The default port range hid most of the capture
- 3. Read the whole ring, not one file of it
- 4. A retransmit storm that ICMP explains
- 5. The scanner detector flagged the PBX itself
- 6. NAT mismatch is what an SBC looks like from outside
- 7. Loss and jitter figures that no interval saw
- 8. Findings that are ordinary behavior
- 9. Registration churn worth chasing, and churn that is a rounding error
- 10. Conformance findings move with the vantage point
- 11. Reading a toll-fraud probe line by line
- 12. One call, two carrier edge nodes
- Where to go next
Every other page on this site demonstrates sipnab against a fixture: a capture built to hold exactly one fault, with two parties, one codec and nothing else happening. Production traffic looks nothing like that. This page works through twelve findings taken from live carrier and PBX captures — 4.5 million frames across two sites — and each one exists because the fixture version of it teaches the wrong lesson.
The recurring theme is that the loudest finding is rarely the fault. On these captures the retransmit storm came from keepalives to peers that no longer existed, the scanner detector flagged the operator’s own PBX, and 58 of the 59 NAT mismatches were an SBC doing its job. Reading real traffic is mostly the work of separating those from the one that matters.
What do you want to learn?
| Question | Example |
|---|---|
| What does a busy trunk actually contain? | 1. Take the census before you take the ticket |
| Why does my capture hold fewer messages than I expected? | 2. The default port range hid most of the capture |
| Should I read one rotated file or all of them? | 3. Read the whole ring, not one file of it |
| Why is everything retransmitting? | 4. A retransmit storm that ICMP explains |
| Is it safe to wire scanner detection to a jail? | 5. The scanner detector flagged the PBX itself |
| Every second call reports a NAT mismatch. Is that bad? | 6. NAT mismatch is what an SBC looks like from outside |
| The loss figure says 90%. Do I believe it? | 7. Loss and jitter figures that no interval saw |
| One-way audio and codec asymmetry on healthy calls? | 8. Findings that are ordinary behavior |
| Which registration churn is worth chasing? | 9. Registration churn worth chasing, and churn that is a rounding error |
| Why does the linter fire on a conformant proxy? | 10. Conformance findings move with the vantage point |
| What does toll fraud look like on the wire? | 11. Reading a toll-fraud probe line by line |
| A duplicate INVITE arrived three minutes late? | 12. One call, two carrier edge nodes |
About the captures and the addresses
Two capture sets run through this page, both taken on 2026-07-31 with
tcpdump -C -W writing a rotating ring:
| Placeholder | What it holds |
|---|---|
trunk.pcap0 … trunk.pcap9 | A carrier access edge and its media relays. 3,406,114 frames, 121,841 SIP messages, 2,846,169 RTP packets. |
pbx.pcap0 … pbx.pcap4 | One PBX and its trunk peers. 1,126,158 frames, 2,077 dialogs. |
inbound.pcap | A single inbound call from a wholesale carrier, 60 frames. |
Substitute your own paths. Every command below runs against a capture file and exits, so none of them needs an interface or root.
Addresses in this page are documentation addresses, not the ones on the wire. Every routable address from the corpus maps to RFC 5737 documentation space, and every Call-ID, tag, branch, user and number is synthetic — rewritten value by value, keeping the structure two of the examples read. Ports, counts, timings, packet counts and loss figures are the measured values, because those are the teaching content:
| Role in the examples | Address |
|---|---|
| Access edge proxy | 192.0.2.10 |
| Media relay, outside interface | 192.0.2.20 |
| The PBX | 192.0.2.30 |
| Core network behind the edge | 198.51.100.0/24 |
| Carrier peers and subscribers | 203.0.113.0/24 |
Private RFC 1918 addresses appear unchanged, because a private address inside an SDP body is the whole point of several of these examples.
1. Take the census before you take the ticket
Situation: someone hands you a trunk capture and a complaint. Before diagnosing anything, find out what the capture holds — the ratio decides which findings deserve attention.
Command:
sipnab -N -I /var/captures --input-name 'trunk.pcap[0-9]' --portrange 1-65535 \
--json-dialogs --no-cli-print --quiet \
| jq -r '.method' | sort | uniq -c | sort -rn
Output:
11887 OPTIONS
11623 KDMQ
2888 REGISTER
2171 SUBSCRIBE
1659 NOTIFY
442 INVITE
120 BYE
13 CANCEL
1 UPDATE
1 REFER
1 INFO
The same run, grouped by dialog state instead:
sipnab -N -I /var/captures --input-name 'trunk.pcap[0-9]' --portrange 1-65535 \
--json-dialogs --no-cli-print --quiet \
| jq -r '.state' | sort | uniq -c | sort -rn
25262 Completed
2746 Registered
2098 Active
231 Trying
144 InCall
138 Canceled
73 Failed
72 Terminated
37 Ringing
3 Redirected
1 Transferring
1 Pending
What it means. Calls are 1.4% of this capture. Of 30,806 dialogs, 442 carry
an INVITE, and 76% of everything sipnab tracked is either an OPTIONS keepalive
or KDMQ — the Kamailio distributed-message method that cluster members use to
replicate user location and hash-table state to each other. Neither method
belongs to a phone call, and neither appears in any synthetic fixture.
Two consequences follow immediately:
- A count of dialogs is not a count of calls. A dashboard reporting “30,806
sessions” from this hour describes a keepalive mesh. Filter to
method == 'INVITE'before any figure reaches a report. - A diagnostic sweep inherits the same ratio.
--problemson this capture returns mostly OPTIONS transactions, and example 4 is what they turn out to be.
Redirected, Transferring and Pending appear once or three times each. A
state that occurs three times in 30,806 dialogs is worth reading individually
rather than aggregating, because it is either a genuine oddity or the one
feature nobody tested.
What to do next. Re-run every later command with
--filter "method == 'INVITE'" when the question is about calls. The
filter DSL reference lists the aliases that
combine method with a diagnosis.
2. The default port range hid most of the capture
Situation: the capture is 100 MB and sipnab reports a few hundred SIP messages. Nothing looks wrong, and that is the problem.
Command:
sipnab -N -I pbx.pcap0 --no-cli-print
Output:
sipnab: 235762 packets captured, 456 SIP messages, 158223 RTP packets across 99 streams
NOT ANALYZED: 1446 further SIP message(s) were seen on ports outside --portrange
and are in none of the totals above. Busiest: 5080 (1065), 2397 (22), 11470 (20),
46429 (19), 7374 (18). Re-run with --portrange 1-65535 to include them.
Now the same file with the gate opened:
sipnab -N -I pbx.pcap0 --portrange 1-65535 --no-cli-print
sipnab: 235762 packets captured, 1902 SIP messages, 158223 RTP packets across 99 streams
What it means. The default --portrange 5060-5061 discarded 1,446 of 1,902
messages — 76% of the SIP in the file. Port 5080 alone carried 1,065 of them,
which on this PBX is the trunk side: internal phones register on 5060 and every
carrier peer talks on 5080. Reading this capture with the default range answers
questions about the phones and reports silence about the trunk.
The trunk capture shows the same effect at a different scale. There the default range skipped 4,249 of 13,460 messages, and the busiest hidden port was 8090 — the Kamailio cluster mesh from example 1, which never touches 5060 at all.
Notice what the counts do and do not move. Packets captured stays at 235,762 and RTP stays at 158,223 across 99 streams, because the port gate covers signaling only and sipnab never gates media on a port. So a capture read this way reports media it cannot attribute to a call, which reads as orphaned streams rather than as missing signaling.
What to do next. Put --portrange 1-65535 on every command when reading a
file — the cost is one extra pass over frames that fail the SIP parse. On a
live capture the range becomes the kernel BPF filter instead, so the traffic
never reaches sipnab and no counter can report the loss. Set the range before
you capture, and see the port-gate warning in
Troubleshooting.
3. Read the whole ring, not one file of it
Situation: tcpdump -C 100 -W 10 left ten files. Reading the newest one is
the obvious move, and it distorts every duration and stream count in the answer.
Command:
One file:
sipnab -N -I trunk.pcap0 --portrange 1-65535 --no-cli-print
sipnab: 369892 packets captured, 13460 SIP messages, 310249 RTP packets across 648 streams
All ten, in capture order:
sipnab -N -I /var/captures --input-name 'trunk.pcap[0-9]' --portrange 1-65535 --no-cli-print
Read 3406114 packets: 10 of 10 file(s) read in full
sipnab: 3406114 packets captured, 121841 SIP messages, 2846169 RTP packets across 1185 streams
What it means. Nine times the packets, nine times the messages — and fewer than twice the streams. One file holds 648 streams. All ten hold 1,185. The arithmetic only works if most streams span several files, which is exactly what a 100 MB rotation does to a five-minute call: reading the files one at a time counts one stream ten times, each with a truncated duration.
The diagnosis engine reports that truncation as a fault. trunk.pcap0 alone
produces 12 duration-asymmetry hints of this shape:
Duration asymmetry: A leg lasted 0.2s, B leg 7.9s (Δ 7.7s) — one side may have
hung up or dropped media early.
A 7.7-second delta on a file that covers 11 seconds is the file boundary, not a one-sided hangup. Read together, the ring reports 6 hints at 107.1 s and a spread of genuine deltas above 40 s — figures that describe calls rather than files.
-I reads files in capture order, never by filename,
which matters because a -C -W ring wraps: trunk.pcap7 can hold older traffic
than trunk.pcap0. Sorting by name would interleave two time windows and
produce stream durations that are worse than truncated.
What to do next. Point -I at the directory and narrow with
--input-name, as above. The whole 921 MB ring took 3.3 seconds of wall clock
on one aarch64 host, so the cost of reading everything is not the reason to read
one file. See Cookbook recipe 19
for the flags that matter on captures too large to hold in memory.
4. A retransmit storm that ICMP explains
Situation: --problems on the PBX capture returns 376 dialogs with
retransmits above 3. That reads like a lossy link.
Command:
sipnab -N -I /var/captures --input-name 'pbx.pcap[0-4]' --portrange 1-65535 \
--json-dialogs --no-cli-print --quiet \
| jq -r 'select(.timing.retransmits > 3) | "\(.method) \(.state) rt=\(.timing.retransmits)"' \
| sort | uniq -c | sort -rn
Output:
331 OPTIONS Trying rt=10
13 OPTIONS Trying rt=6
10 OPTIONS Trying rt=8
7 OPTIONS Trying rt=5
7 OPTIONS Trying rt=4
5 OPTIONS Trying rt=7
3 OPTIONS Trying rt=9
Not one INVITE, and 331 of the 376 sit on the same figure. Pull the report for a single one of them — substitute a Call-ID from your own run:
sipnab -N -I pbx.pcap0 --portrange 1-65535 --no-cli-print \
--call-report '3b1f0c7a-9d21-4e08-b6ad-2ad4e1f90c11'
Call Report: 3b1f0c7a-9d21-4e08-b6ad-2ad4e1f90c11
════════════════════════════════════════
Time: 2026-07-31 17:35:51 -> 17:36:23 (31s)
From: <sip:redacted-user@...>
To: -
Result: Trying
Frame: pbx.pcap0#7498@a1b2c3d4e5f60718
Timing:
PDD: -
Setup: -
Ring: -
Teardown: -
Retransmits: 10
SIP Transactions:
OPTIONS
OPTIONS
OPTIONS
OPTIONS
OPTIONS
OPTIONS
OPTIONS
OPTIONS
OPTIONS
OPTIONS
OPTIONS
Media Streams:
None
Issues Detected: None
Signaling Issues:
- No response to OPTIONS: 11 transmissions over 31.5s
evidence: #0 OPTIONS, #1 OPTIONS, #2 OPTIONS, #3 OPTIONS, #4 OPTIONS, #5 OPTIONS, #6 OPTIONS, #7 OPTIONS, #8 OPTIONS, #9 OPTIONS, #10 OPTIONS
- ICMP host unreachable: 203.0.113.75:5080 unreachable (11 times), reported by 203.0.113.181
evidence: #0 OPTIONS, #1 OPTIONS, #2 OPTIONS, #3 OPTIONS, #4 OPTIONS, #5 OPTIONS, #6 OPTIONS, #7 OPTIONS, #8 OPTIONS, #9 OPTIONS, #10 OPTIONS
What it means. retransmits: 10 is not a measure of loss. Eleven
transmissions over 31.5 s is the complete non-INVITE client transaction ladder
of RFC 3261 §17.1.2.2:
T1, 2×T1, 4×T1, then T2 (4 s) repeatedly until Timer F fires at 64×T1 = 32 s. A
transaction that reaches that count did not lose packets — it ran to completion
against a peer that answered nothing. The number tells you the timer expired,
and nothing else.
The ICMP line is what turns the inference into a cause, and which address reported it is the diagnosis. sipnab prints the tally at the end of any run over the same file:
sipnab -N -I pbx.pcap0 --portrange 1-65535 --no-cli-print
ICMP: 696 error(s) quoting a SIP request, naming 12 unreachable endpoint(s).
Busiest: 203.0.113.74:5080 (72, host unreachable), 203.0.113.41:5080 (71, host
unreachable), 203.0.113.42:5080 (71, host unreachable), 203.0.113.72:5080 (71,
host unreachable), 203.0.113.71:5080 (69, host unreachable)
Across the whole PBX capture, --analyze separates two shapes. Two evidence
rows out of the 338 under its ICMP: SIP request undeliverable finding:
sipnab -N -I /var/captures --input-name 'pbx.pcap[0-4]' --portrange 1-65535 \
--analyze --no-cli-print
- 192.0.2.30:5080 -> 203.0.113.72, unreachable 203.0.113.72:5080,
reported by 203.0.113.181 | host unreachable (type 3, code 1), quoting a OPTIONS
- 192.0.2.30:5080 -> 203.0.113.73, unreachable 203.0.113.73:5080,
reported by 203.0.113.73 | communication administratively prohibited
(type 3, code 13), quoting a OPTIONS
A router answered the first one, and the destination host answered the second. Type 3 code 1 from a third address means the peer no longer exists on that network — a decommissioned gateway, or a route that went away. Type 3 code 13 from the peer’s own address means the peer is alive and its firewall refuses you. Same symptom, same retransmit count, two different teams to call.
What to do next. Take the 12 unreachable endpoints to the trunk configuration and remove the ones that no longer exist. Until then each of them burns eleven transmissions every keepalive interval, and — as example 5 shows — makes the PBX look like a scanner to its own monitoring.
5. The scanner detector flagged the PBX itself
Situation: you are about to wire --fail2ban to a jail. The
cookbook says
to measure first against a capture of a normal hour. Here is why.
Command:
sipnab -N -I /var/captures --input-name 'pbx.pcap[0-4]' --portrange 1-65535 \
--kill-scanner --fail2ban --no-cli-print \
| grep -oE 'src=[^ ]+ ua="[^"]*"' | sort | uniq -c
Output:
2864 src=192.0.2.30 ua="Asterisk PBX 20.15.2"
The trunk capture, same command against the other corpus:
sipnab -N -I /var/captures --input-name 'trunk.pcap[0-9]' --portrange 1-65535 \
--kill-scanner --fail2ban --no-cli-print \
| grep -oE 'src=[^ ]+ ua="[^"]*"' | sort | uniq -c
21 src=198.51.100.12 ua="sipsak 0.9.8.1"
What it means. Across 1.1 million frames of PBX traffic, the detector fired
2,864 times, every one of them naming the PBX itself. Across 3.4 million frames
of carrier traffic it fired 21 times, every one naming the operator’s own
monitoring probe. Not one detection in either capture belongs to an outside
party. A jail reading this file with maxretry = 5 bans the phone system inside
the first second.
Neither detection came from a User-Agent signature — Asterisk and sipsak are
not on any scanner list. Both came from the behavioral rule, which counts
probe transactions from one source and arms on evidence of probing. The evidence
here is probes that drew no answer, and the source of those is
example 4: a PBX sending OPTIONS to
twelve peers that no longer answer looks, to a rule counting unanswered probes,
exactly like a sweep. The dead trunk peers manufactured the detection.
What to do next.
- Run the measurement above against a capture of a normal hour on your
traffic before the jail exists, and put every address it returns in
ignoreip. The cookbook recipe carries the jail configuration with theignoreip,maxretryandbantimevalues that survive this. - Fix the unanswered probes first. The PBX-side detections rest entirely on keepalives to peers that no longer answer, so removing those peers from the trunk configuration removes the reason the rule arms.
- Read the
src=values, never the count. A count of 2,864 says “act now” and a single address says “this is your own box”.
6. NAT mismatch is what an SBC looks like from outside
Situation: --problems flags nat_mismatch on a large share of calls, and
the calls sound fine. The fixture version of this finding is always a fault.
Command:
sipnab -N -I /var/captures --input-name 'trunk.pcap[0-9]' --portrange 1-65535 \
--json-dialogs --no-cli-print --quiet \
| jq -r 'select(.method == "INVITE")
| "nat=\(.diagnosis.nat_mismatch) private=\(.diagnosis.private_media_address) oneway=\(.diagnosis.one_way_audio) nomedia=\(.diagnosis.no_media)"' \
| sort | uniq -c | sort -rn
Output:
266 nat=false private=false oneway=false nomedia=false
114 nat=false private=false oneway=false nomedia=true
45 nat=true private=true oneway=false nomedia=false
13 nat=true private=false oneway=false nomedia=false
2 nat=false private=false oneway=true nomedia=false
1 nat=true private=false oneway=true nomedia=false
1 nat=false private=true oneway=true nomedia=false
What it means. Of 442 INVITE dialogs, 59 carry nat_mismatch: true. One
of those 59 also carries one_way_audio: true. The other 58 carried audio in
both directions for the whole call.
The two hints sipnab attached to one of those 59 dialogs say why, and the two halves say different things:
RTP arrived from 203.0.113.50:11814 at 192.0.2.20:21274, and no SDP in this
dialog advertised 203.0.113.50 (it offered 10.15.1.147:11814) — the media source
was rewritten, typically by NAT, so replies sent to 10.15.1.147:11814 never
reach it.
SDP offered the private media address 10.15.1.147:11814 to a peer on the public
internet, which cannot route back to it. This is correct only if something
downstream rewrites the SDP (an SBC, an ALG, or a media proxy); if nothing does,
the far end sends audio to an address that does not exist and the call is
one-way while signaling looks healthy.
That is a phone behind NAT advertising 10.15.1.147 and an SBC rewriting the media path — the deployment working as designed. sipnab reports the observation and refuses to guess whether something downstream fixes it, because from the capture point both cases look identical up to the moment media flows.
The port is the tell. Here the advertised port (11814) and the observed port (11814) match, so the NAT preserved the port: a static 1:1 mapping, and symmetric RTP still works. Compare a case in the same capture where the offer named 10.240.91.12:11948 and media arrived from 203.0.113.183:11948 — again the same port, again benign. A different port on each side means port-address translation, and that is where a return pinhole can fail to open.
One row of that table has nothing to do with NAT. 114 answered calls report
no_media: true — a quarter of every call in the capture, and none of them a
fault. Compare the media addresses those calls negotiated against the ones that
did carry audio:
sipnab -N -I /var/captures --input-name 'trunk.pcap[0-9]' --portrange 1-65535 \
--json-dialogs --no-cli-print --quiet \
| jq -r 'select(.diagnosis.no_media == true) | .sdp_timeline[]?.media_addr' \
| sort | uniq -c | sort -rn | head -4
166 198.51.100.13
137 192.0.2.21
25 198.51.100.27
24 198.51.100.21
sipnab -N -I /var/captures --input-name 'trunk.pcap[0-9]' --portrange 1-65535 \
--json-dialogs --no-cli-print --quiet \
| jq -r 'select((.streams | length) > 0) | .sdp_timeline[]?.media_addr' \
| sort | uniq -c | sort -rn | head -4
188 198.51.100.12
165 192.0.2.20
27 198.51.100.23
25 198.51.100.27
Two media relays, and the lists share nothing at the top. Every call that
carried audio anchored on 198.51.100.12 / 192.0.2.20, and every call reporting
no media anchored on 198.51.100.13 / 192.0.2.21. The port mirror feeding this
capture covers one relay of the pair. no_media here is a statement about the
capture point, which is why the finding text tells you to check that the capture
sees the media path at all before reading it as a fault.
What to do next. Never read nat_mismatch alone. Combine it with the
outcome:
sipnab -N -I /var/captures --input-name 'trunk.pcap[0-9]' --portrange 1-65535 \
--filter "nat_mismatch == true AND one_way == true" --json-dialogs \
--no-cli-print --quiet
On this capture that returns one dialog out of 442, and that one is the ticket. Troubleshooting covers what to change once you have it.
7. Loss and jitter figures that no interval saw
Situation: a stream reports 90.33% packet loss and another reports 13,514 ms of jitter. Both figures would justify replacing a circuit, and neither describes what the caller heard.
Command:
sipnab -N -I /var/captures --input-name 'trunk.pcap[0-9]' --portrange 1-65535 \
--json-dialogs --no-cli-print --quiet \
| jq -c '.streams[]? | select(.loss_pct > 70)
| {packets, loss_pct, jitter_ms, bursts: .burst_gap.burst_count,
burst_ms: .burst_gap.burst_duration_ms,
gap_loss: .burst_gap.gap_loss_rate, bursty: .burst_gap.is_bursty}'
Output:
{"packets":2775,"loss_pct":78.7486598253944,"jitter_ms":0.07164038823094283,"bursts":2,"burst_ms":530.0,"gap_loss":0.0,"bursty":true}
{"packets":2775,"loss_pct":78.7486598253944,"jitter_ms":0.07322643861696838,"bursts":2,"burst_ms":530.0,"gap_loss":0.0,"bursty":true}
{"packets":3310,"loss_pct":90.33124963486593,"jitter_ms":0.3000290927994878,"bursts":3,"burst_ms":540.0,"gap_loss":0.0,"bursty":true}
{"packets":3310,"loss_pct":90.33124963486593,"jitter_ms":0.3017346830911323,"bursts":3,"burst_ms":540.0,"gap_loss":0.0,"bursty":true}
What it means. Two streams, each listed twice — a relay capture sees the same audio arriving and leaving, so every stream has a near-identical twin. Read one of each pair.
The aggregate on the second says 90.33% loss. The burst-gap model on the same stream says three bursts totalling 540 ms, with a gap loss rate of exactly 0.0. A 68-second call lost half a second of audio in three pieces and delivered everything else. A ticket saying “90% packet loss” sends an engineer to the wrong layer.
The interval series settles it:
sipnab -N -I /var/captures --input-name 'trunk.pcap[0-9]' --portrange 1-65535 \
--json-dialogs --no-cli-print --quiet \
| jq -r '.streams[]? | select(.loss_pct > 90) | .quality_intervals[]
| "\(.timestamp) loss=\(.loss_pct) packets=\(.packets)"' \
| head -13
2026-07-31T15:44:38.644134+00:00 loss=99.19282222793927 packets=251
2026-07-31T15:44:43.664087+00:00 loss=19.12350597609562 packets=203
2026-07-31T15:44:48.664102+00:00 loss=0.0 packets=250
2026-07-31T15:44:53.684110+00:00 loss=1.593625498007968 packets=247
2026-07-31T15:44:58.704101+00:00 loss=0.0 packets=251
2026-07-31T15:45:03.724093+00:00 loss=0.0 packets=251
2026-07-31T15:45:08.725258+00:00 loss=10.8 packets=223
2026-07-31T15:45:13.744111+00:00 loss=0.0 packets=251
2026-07-31T15:45:18.764113+00:00 loss=0.0 packets=251
2026-07-31T15:45:23.784098+00:00 loss=0.0 packets=251
2026-07-31T15:45:28.804124+00:00 loss=0.0 packets=251
2026-07-31T15:45:33.824091+00:00 loss=0.0 packets=251
2026-07-31T15:45:38.844109+00:00 loss=0.0 packets=251
Ten of the thirteen five-second intervals lost nothing. The first one is the figure to read carefully: 99.19% loss while receiving 251 packets, which is a full interval of 20 ms audio. Receiving everything and reporting 99% loss means the sequence space jumped, not that audio vanished. The SDP timeline for the same call says what jumped it:
sipnab -N -I /var/captures --input-name 'trunk.pcap[0-9]' --portrange 1-65535 \
--json-dialogs --no-cli-print --quiet \
| jq -c 'select([.streams[]? | select(.loss_pct > 90)] | length > 0)
| .sdp_timeline[] | {timestamp, direction, event, media_addr, media_port}'
{"timestamp":"2026-07-31T15:44:30.532163+00:00","direction":"offer","event":null,"media_addr":"198.51.100.27","media_port":10084}
{"timestamp":"2026-07-31T15:44:30.538475+00:00","direction":"offer","event":"MediaAnchorChange","media_addr":"192.0.2.20","media_port":21284}
{"timestamp":"2026-07-31T15:44:33.608214+00:00","direction":"answer","event":"MediaAnchorChange","media_addr":"203.0.113.148","media_port":26408}
{"timestamp":"2026-07-31T15:44:33.609530+00:00","direction":"answer","event":"MediaAnchorChange","media_addr":"198.51.100.12","media_port":34450}
{"timestamp":"2026-07-31T15:44:34.404015+00:00","direction":"answer","event":"MediaAnchorChange","media_addr":"203.0.113.148","media_port":26408}
{"timestamp":"2026-07-31T15:44:34.406111+00:00","direction":"answer","event":"MediaAnchorChange","media_addr":"198.51.100.12","media_port":34450}
Four MediaAnchorChange events inside four seconds, and the stream’s first
packet lands 20 ms after the third of them. The relay re-anchored the media
while the call was answering. The sequence discontinuity belongs to that
hand-off, not to the network.
Jitter behaves the same way. Ask the ring for every stream whose aggregate jitter passes three seconds, and print the worst interval beside it:
sipnab -N -I /var/captures --input-name 'trunk.pcap[0-9]' --portrange 1-65535 \
--json-dialogs --no-cli-print --quiet \
| jq -c '.streams[]? | select(.jitter_ms > 3000)
| {packets, jitter_ms, loss_pct, intervals: (.quality_intervals | length),
worst_interval_jitter_ms: ([.quality_intervals[].jitter_ms] | max)}'
{"packets":1893,"jitter_ms":3333.136390820093,"loss_pct":0.0,"intervals":7,"worst_interval_jitter_ms":0.16320738588116435}
{"packets":1893,"jitter_ms":3333.138510299535,"loss_pct":0.0,"intervals":7,"worst_interval_jitter_ms":0.1834566583384629}
{"packets":4153,"jitter_ms":13514.316250662869,"loss_pct":1.8667296786389416,"intervals":16,"worst_interval_jitter_ms":0.6413230501234052}
{"packets":4153,"jitter_ms":13514.321777099512,"loss_pct":1.8667296786389416,"intervals":16,"worst_interval_jitter_ms":0.6143676470639956}
{"packets":4306,"jitter_ms":8236.74992001001,"loss_pct":1.8015963511972637,"intervals":17,"worst_interval_jitter_ms":0.8262424991207276}
{"packets":4306,"jitter_ms":8236.753047991591,"loss_pct":1.8015963511972637,"intervals":17,"worst_interval_jitter_ms":0.8307203392990805}
{"packets":4659,"jitter_ms":9330.191903142577,"loss_pct":1.6673701983959477,"intervals":18,"worst_interval_jitter_ms":0.6767651051659166}
{"packets":4659,"jitter_ms":9330.201241363062,"loss_pct":1.6673701983959477,"intervals":18,"worst_interval_jitter_ms":0.682117122331886}
Four streams and their twins. Aggregates from 3,333 ms to 13,514 ms, and not one five-second interval anywhere above 0.83 ms — four orders of magnitude apart.
What to do next. Read burst_gap and quality_intervals before the
aggregate on any stream whose headline figure looks impossible. The aggregate is
one running number over the whole stream, so a single discontinuity — a
re-anchor, a hold and resume, a hand-off that preserves the SSRC — pins it high
for the rest of the call. is_bursty: true beside a gap_loss_rate of 0.0 is
that signature exactly. The schema for both blocks is in
Output formats, and
MOS and codecs
covers what the quality score does with them.
8. Findings that are ordinary behavior
Situation: the whole trunk ring produces four one-way-audio findings and one codec asymmetry. Chasing all five wastes a day.
Command:
sipnab -N -I /var/captures --input-name 'trunk.pcap[0-9]' --portrange 1-65535 \
--json-dialogs --no-cli-print --quiet \
| jq -r 'select((.diagnosis.hints | join(" ")) | test("RTP flowed|Codec asymmetry"))
| "\(.state) \(.duration_sec)s \(.diagnosis.hints | join(" / "))"'
Output:
Completed 3.062s RTP flowed 198.51.100.24:10192 -> 198.51.100.12:31934 only (SSRC 0x7a30ae18). No reverse media flow detected. / SDP offered the private media address 192.168.15.44:12320 to a peer on the public internet, which cannot route back to it. This is correct only if something downstream rewrites the SDP (an SBC, an ALG, or a media proxy); if nothing does, the far end sends audio to an address that does not exist and the call is one-way while signaling looks healthy.
Completed 0.173s RTP flowed 203.0.113.82:43880 -> 192.0.2.20:38646 only (SSRC 0x797d72f7). No reverse media flow detected. / RTP arrived from 203.0.113.82:43880 at 192.0.2.20:38646, and no SDP in this dialog advertised 203.0.113.82 (it offered 198.51.100.24:19262) — the media source was rewritten, typically by NAT, so replies sent to 198.51.100.24:19262 never reach it. / One-way audio combined with NAT mismatch — media likely being sent to the wrong address.
Canceled 23.517s RTP flowed 203.0.113.145:11048 -> 192.0.2.20:25400 only (SSRC 0x62aaf71c). No reverse media flow detected.
Completed 11.528s Codec asymmetry: A leg uses PCMU, B leg uses CN — likely a transcoding B2BUA on the path. / Duration asymmetry: A leg lasted 10.4s, B leg 0.0s (Δ 10.4s) — one side may have hung up or dropped media early.
Ringing 1.748s RTP flowed 203.0.113.146:56998 -> 192.0.2.20:27736 only (SSRC 0xf5620096). No reverse media flow detected.
What it means. Read the state column first.
- The
CanceledandRingingrows are early media. Audio flowing from the callee toward the caller before answer, with nothing coming back, carries the ringing tone or a network announcement, working exactly as it should. Nobody has answered yet, so there is no reverse direction to have. TheCanceledone ran 23.5 s of that tone before the caller gave up. - The first two rows are the ones to open, because a call that answered and carried audio one way is a complaint waiting to arrive. The second names its own cause: one-way audio and a NAT mismatch on the same dialog, which is the combination example 6 exists to isolate. The first carries a private media address with no NAT rewrite beside it, so the question there is whether anything downstream rewrites that SDP.
- The fourth row names CN on the B leg. CN is comfort noise (RFC 3389, payload type 13) — the packet an endpoint sends instead of silence during a pause. It is not a codec a transcoder produced. The finding fires because the two legs carried different payload types, and the reason they did is that one side stopped talking.
What to do next. Filter one-way audio by state before it reaches anyone:
sipnab -N -I /var/captures --input-name 'trunk.pcap[0-9]' --portrange 1-65535 \
--filter "one_way == true AND state == 'Completed'" --json-dialogs \
--no-cli-print --quiet
For codec findings, check the payload types the streams actually carried before believing “transcoding”. Across the whole ring the codec census is 692 streams of PCMU, 2 of CN and 2 on a dynamic payload type — a single-codec trunk, so any report of transcoding on it deserves a second look. Recipe 11 covers the asymmetry aliases and what each one compares.
9. Registration churn worth chasing, and churn that is a rounding error
Situation: --analyze reports 144 findings under its
REGISTER failed or was cut short heading on a PBX with 177 REGISTER dialogs.
That reads like a registrar in trouble.
Command:
sipnab -N -I /var/captures --input-name 'pbx.pcap[0-4]' --portrange 1-65535 \
--json-dialogs --no-cli-print --quiet \
| jq -r 'select(.method == "REGISTER") | .signaling_diagnosis.registration_failure // empty
| if .kind == "rejected" then "rejected \(.code)"
elif (.requested_expiry_sec - .granted_expiry_sec) <= 1 then "off-by-one"
else "shortened to \(((.granted_expiry_sec / .requested_expiry_sec) * 100 | floor))pct"
end' \
| sort | uniq -c | sort -rn
Output:
124 off-by-one
5 rejected 403
1 shortened to 90pct
1 shortened to 85pct
1 shortened to 80pct
1 shortened to 79pct
1 shortened to 77pct
1 shortened to 75pct
1 shortened to 74pct
1 shortened to 72pct
1 shortened to 70pct
1 shortened to 69pct
1 shortened to 64pct
1 shortened to 58pct
1 shortened to 49pct
1 shortened to 27pct
1 shortened to 20pct
What it means. 124 of the 144 findings are a registrar granting 479 s against a requested 480, or 359 against 360, or 239 against 240. That is a registrar computing the expiry from a second-granularity clock, and it changes a phone’s re-registration interval by one part in 480. It is noise, and it is 86% of the finding.
Underneath it sit two things that matter:
- 5 rejections with 403 Forbidden. Those phones are not reachable for
inbound calls at all.
--analyzepairs four of them with an authentication loop:challenges=3 | the UAC answers each challenge and is challenged again — wrong credentials, which points at provisioning rather than at the registrar. - 15 materially shortened grants, down to 20% of what the endpoint asked for. A phone that asked for 480 s and got 98 re-registers five times as often as it planned. Fifteen phones doing that is a measurable load, and the ratio — not the count — is what identifies them.
What to do next. Treat “shorter than asked” as two findings and separate
them by ratio, as the jq filter above does. Chase the rejections first, the
sub-50% grants second, and never open a ticket for the off-by-one class.
Troubleshooting covers the
challenge-loop shapes.
10. Conformance findings move with the vantage point
Situation: --lint reports 22 errors of one rule on a proxy capture. The
proxy is a stock build of well-tested software, and 22 conformance errors in one
100 MB slice is implausible.
Command:
sipnab -N -I trunk.pcap0 --portrange 1-65535 --lint --no-cli-print \
| grep -oE '^(error|warning|note): [A-Z0-9.-]+' | sort | uniq -c | sort -rn
Output:
22 error: SIP-3261-17.1.1.3-ACK-BRANCH-MISMATCH
12 warning: SIP-3261-8.1.1.6-MAX-FORWARDS-MISSING
2 error: SDP-7587-7-OPUS-RTPMAP-RATE
Now scope the same file to a single hop with a BPF expression:
sipnab -N -I trunk.pcap0 --portrange 1-65535 --lint --no-cli-print 'host 192.0.2.10' \
| grep -oE '^(error|warning|note): [A-Z0-9.-]+' | sort | uniq -c | sort -rn
38 error: SIP-3261-12.1.1-RECORD-ROUTE-NOT-COPIED
12 warning: SIP-3261-8.1.1.6-MAX-FORWARDS-MISSING
2 error: SIP-3261-17.1.1.3-ACK-BRANCH-MISMATCH
1 error: SDP-7587-7-OPUS-RTPMAP-RATE
And to the other hop:
sipnab -N -I trunk.pcap0 --portrange 1-65535 --lint --no-cli-print 'host 198.51.100.14' \
| grep -oE '^(error|warning|note): [A-Z0-9.-]+' | sort | uniq -c | sort -rn
1 error: SDP-7587-7-OPUS-RTPMAP-RATE
What it means. One rule went from 22 to 2 to 0. Another went from 0 to 38. Nothing on the wire changed.
The message ladder for one flagged dialog shows why. Substitute a Call-ID the lint output named:
sipnab -N -I trunk.pcap0 --portrange 1-65535 --quiet \
-e '4c8e17b0-2fd5-4a91-9c33-6081ea5f7d24' \
| grep -E '^[0-9]{2}:|^(Via|CSeq):'
15:44:29.029 203.0.113.180:37402 -> 192.0.2.10:5080 INVITE UDP
Via: SIP/2.0/UDP 192.168.40.35:5060;branch=z9hG4bK5a0c71e2b8934fd10cae4
CSeq: 1 INVITE
15:44:29.035 192.0.2.10:5080 -> 203.0.113.180:37402 407 Proxy Authentication Required UDP
15:44:29.047 203.0.113.180:37402 -> 192.0.2.10:5080 ACK UDP
15:44:29.048 203.0.113.180:37402 -> 192.0.2.10:5080 INVITE UDP
Via: SIP/2.0/UDP 192.168.40.35:5060;branch=z9hG4bK4071938265
CSeq: 2 INVITE
15:44:29.082 192.0.2.10:5080 -> 203.0.113.180:37402 100 trying -- your call is important to us UDP
15:44:29.084 198.51.100.12:8090 -> 198.51.100.13:8090 KDMQ UDP
Via: SIP/2.0/UDP 198.51.100.12:8090;branch=z9hG4bKb809.7c41ad9e000000000000000000000000.0
CSeq: 10 KDMQ
15:44:29.084 198.51.100.14:5060 -> 198.51.100.24:5080 INVITE UDP
Via: SIP/2.0/UDP 198.51.100.14;branch=z9hG4bK4fb4.e08d5c1a7b264f39a1c8074e2dd6b953.0
Via: SIP/2.0/UDP 192.168.40.35:5060;rport=37402;received=203.0.113.180;branch=z9hG4bK4071938265
CSeq: 2 INVITE
15:44:29.085 198.51.100.13:8090 -> 198.51.100.12:8090 200 OK UDP
15:44:29.104 198.51.100.24:5080 -> 198.51.100.14:5060 401 Unauthorized UDP
15:44:29.105 198.51.100.14:5060 -> 198.51.100.24:5080 ACK UDP
15:44:29.127 198.51.100.14:5060 -> 198.51.100.24:5080 INVITE UDP
Via: SIP/2.0/UDP 198.51.100.14;branch=z9hG4bK4fb4.e08d5c1a7b264f39a1c8074e2dd6b953.1.cs2
Via: SIP/2.0/UDP 192.168.40.35:5060;rport=37402;received=203.0.113.180;branch=z9hG4bK4071938265
CSeq: 3 INVITE
15:44:29.141 198.51.100.24:5080 -> 198.51.100.14:5060 100 Trying UDP
15:44:31.805 198.51.100.24:5080 -> 198.51.100.14:5060 183 Session Progress UDP
CSeq: 3 INVITE
15:44:31.808 192.0.2.10:5080 -> 203.0.113.180:37402 183 Session Progress UDP
CSeq: 2 INVITE
15:44:31.995 198.51.100.24:5080 -> 198.51.100.14:5060 183 Session Progress UDP
CSeq: 3 INVITE
15:44:31.998 192.0.2.10:5080 -> 203.0.113.180:37402 183 Session Progress UDP
CSeq: 2 INVITE
One Call-ID, two hops, and nothing in the record separates them. The access hop
runs a 407 challenge and re-sends at CSeq: 2. The core hop runs its own 401
challenge and re-sends at CSeq: 3, under a top Via the edge proxy added. The
ACK at 29.105 answers the 401 on the core hop and carries that hop’s
branch, while the INVITE branch sipnab recorded came from the access hop.
Each branch is correct on the hop that carried it, and the finding names a real
disagreement inside one dialog record. The disagreement comes from the vantage
point.
The KDMQ pair at 29.084 belongs to no call at all — it is the cluster
replication from example 1,
interleaved into the ladder because -e follows a dialog and the mesh shares
the capture.
The reverse effect is just as instructive. Narrowing to the access hop hides the
core-side messages, and 38 RECORD-ROUTE-NOT-COPIED errors appear that the
two-hop reading never produced. A conformance verdict is a statement about where
you stood as much as about what crossed the wire.
Not everything moved. MAX-FORWARDS-MISSING held at 12 across the whole-file
run and the access-hop run, because it tests one message against itself and needs no
second hop. The access hop carries all twelve offending requests, so narrowing
to it changes nothing. Those 12 are real: twelve requests entered the network with
no Max-Forwards header
at all. SDP-7587-7-OPUS-RTPMAP-RATE is real too — a device declaring
opus/48000/1 where RFC 7587 §7
requires opus/48000/2 — and the whole ring holds 24 of them.
What to do next. Lint one hop at a time. Add a BPF expression naming the element you want to judge, and read the rules that move between one scope and the next as questions about the capture rather than as defects. The conformance rule reference explains what each transaction rule compares, and Cookbook recipe 15 covers running the linter in CI.
11. Reading a toll-fraud probe line by line
Situation: --analyze reports an authentication loop from an address you do
not recognize. The whole probe fits on one screen, and every line of it is a
signature.
Command:
sipnab -N -I pbx.pcap4 --portrange 1-65535 --quiet \
-e '9f4c2a71-3e08-4b6d-a520-71c0d9e4f832'
Output (numbers, addresses and the Call-ID replaced, everything else verbatim):
17:40:41.924 203.0.113.66:53168 -> 192.0.2.30:5060 INVITE UDP
INVITE sip:<redacted-e164>@192.0.2.30 SIP/2.0
Via: SIP/2.0/UDP 0.0.0.0:53168;branch=z9hG4bK704192338
Max-Forwards: 70
From: <sip:<redacted-e164>@192.0.2.30>;tag=51720483
To: <sip:<redacted-e164>@192.0.2.30>
Call-ID: 9f4c2a71-3e08-4b6d-a520-71c0d9e4f832
CSeq: 1 INVITE
Contact: <sip:<redacted-e164>@203.0.113.77:53168>
User-Agent: pplsip
Content-Type: application/sdp
Content-Length: 206
v=0
o=<redacted-e164> 16264 18299 IN IP4 0.0.0.0
s=pplsip
c=IN IP4 0.0.0.0
t=0 0
m=audio 25282 RTP/AVP 100 6 0 8 3 18 5 101
a=rtpmap:0 pcmu/8000
a=rtpmap:101 telephone-event/8000
a=fmtp:101 0-11
17:40:41.968 192.0.2.30:5060 -> 203.0.113.66:53168 401 Unauthorized UDP
17:40:42.467 192.0.2.30:5060 -> 203.0.113.66:53168 401 Unauthorized UDP
17:40:43.468 192.0.2.30:5060 -> 203.0.113.66:53168 401 Unauthorized UDP
17:40:45.468 192.0.2.30:5060 -> 203.0.113.66:53168 401 Unauthorized UDP
What it means. Six things in that message identify it, and none of them needs a threat feed:
c=IN IP4 0.0.0.0ando=on 0.0.0.0. The sender wants no audio. It is asking one question — does this PBX route the call — and a real media address would cost it a socket it has no use for.Viaon 0.0.0.0 as well, so no response can ever route back by the Via. A tool that has given up on receiving replies is not placing a call.Contactnames 203.0.113.77 while the packet came from 203.0.113.66. Two different hosts. The generator hardcodes a Contact and sprays from whatever address it happens to hold.a=rtpmap:0 pcmu/8000in lowercase. Every real stack writesPCMU. The codec list also declares eight payload types and provides anrtpmapfor two.User-Agent: pplsip, echoed ins=pplsip. A session name matching the User-Agent is a template, not a session.- The dialled digits. Both
FromandTocarry a long digit string that opens with the same fixed prefix and ends in an international destination — the shape of a prefix probe looking for a route that bills somewhere expensive.
Now read the four 401s, because sipnab’s own summary needs the correction:
sipnab -N -I pbx.pcap4 --portrange 1-65535 --json-dialogs --no-cli-print --quiet \
| jq -c 'select(.call_id == "9f4c2a71-3e08-4b6d-a520-71c0d9e4f832")
| {auth_loop: .signaling_diagnosis.auth_loop,
hints: .signaling_diagnosis.hints, timing}'
{"auth_loop":{"challenges":4,"evidence":[1,2,3,4],"kind":"silent_drop"},"hints":["Authentication loop: 4 challenges and no Authorization header ever sent — the client is not attempting the challenge, or a proxy is stripping it."],"timing":{"retransmits":3}}
Four challenges, and the attacker sent one INVITE. The intervals between the
401s are 0.499 s, 1.001 s and 2.000 s — the INVITE server transaction’s Timer G
ladder from RFC 3261 §17.2.1,
doubling from T1 up to T2. The PBX retransmitted its own final response because
nothing ever acknowledged it. timing.retransmits: 3 says so in the same
record: 4 challenges minus 3 retransmissions is one challenge.
What to do next. Match on the properties, not the address — a single source
address is one rented host. c=IN IP4 0.0.0.0 on an INVITE from outside your
network has no legitimate reading, and neither does a Contact whose host differs
from the source. Both survive the attacker changing address, User-Agent and
prefix. Block the destination prefix at the trunk as well as the source, because
the source is disposable and the prefix is what pays.
Cookbook recipe 10c
covers the fraud heuristics that run over this pattern live.
12. One call, two carrier edge nodes
Situation: a wholesale carrier’s inbound call rings, gets no answer, and
then a duplicate INVITE for the same call arrives almost three minutes later
from a different address. --analyze calls the capture clean.
Command:
sipnab -N -I inbound.pcap --portrange 1-65535 --analyze --no-cli-print
No problems found in 1 dialog(s) and 0 stream(s) across 60 frame(s). Every frame
decoded, no port gate discarded SIP, and no retention cap was reached — so this
is a statement about the capture, not only about what sipnab could read of it.
One dialog, and the ladder shows two sources:
sipnab -N -I inbound.pcap --portrange 1-65535 --quiet \
-e '[email protected]' \
| grep -E '^[0-9]{2}:|^(Via|Call-ID|CSeq):'
-e prints each matching message in full, so the grep keeps the timestamp
line and the three header fields that carry the finding. Header lines belong to
the message above them:
15:27:14.693 203.0.113.41:5060 -> 192.0.2.10:5060 INVITE UDP
Via: SIP/2.0/UDP 203.0.113.41:5060;branch=z9hG4bK4f3e.b7e94c1052d8a3f6417c02be95d3ff81.0
Via: SIP/2.0/UDP 203.0.113.51:5060;branch=z9hG4bK4f3e.9c0a44b7e1d2650fa839cb7f21e408d6.2
Via: SIP/2.0/UDP 203.0.113.52:5060;branch=z9hG4bK4f3e.2fb18e05c3947da6b0e572139c4a86ef.0
Via: SIP/2.0/UDP 203.0.113.53:5060;branch=z9hG4bK00B1a97f38e6dc410b2
Call-ID: [email protected]
CSeq: 543439 INVITE
15:27:14.698 192.0.2.10:5060 -> 203.0.113.41:5060 100 trying UDP
15:27:15.614 192.0.2.10:5060 -> 203.0.113.41:5060 180 Ringing UDP
15:27:49.170 192.0.2.10:5060 -> 203.0.113.41:5060 BYE UDP
15:27:49.327 203.0.113.41:5060 -> 192.0.2.10:5060 200 OK UDP
15:30:14.459 203.0.113.41:5060 -> 192.0.2.10:5060 CANCEL UDP
15:30:14.460 203.0.113.42:5060 -> 192.0.2.10:5060 INVITE UDP
Via: SIP/2.0/UDP 203.0.113.42:5060;branch=z9hG4bK4f3e.5d3ab8146fc70e29d5b1938ae0c6274b.0
Via: SIP/2.0/UDP 203.0.113.51:5060;branch=z9hG4bK4f3e.9c0a44b7e1d2650fa839cb7f21e408d6.3
Via: SIP/2.0/UDP 203.0.113.52:5060;branch=z9hG4bK4f3e.2fb18e05c3947da6b0e572139c4a86ef.0
Via: SIP/2.0/UDP 203.0.113.53:5060;branch=z9hG4bK00B1a97f38e6dc410b2
Call-ID: [email protected]
CSeq: 543439 INVITE
15:30:14.465 192.0.2.10:5060 -> 203.0.113.42:5060 100 trying UDP
15:30:14.490 203.0.113.42:5060 -> 192.0.2.10:5060 CANCEL UDP
15:30:14.493 192.0.2.10:5060 -> 203.0.113.42:5060 200 canceling UDP
15:30:14.612 192.0.2.10:5060 -> 203.0.113.42:5060 487 Request Terminated UDP
15:30:14.619 203.0.113.42:5060 -> 192.0.2.10:5060 ACK UDP
What it means. Same Call-ID, same CSeq (543439 INVITE), same bottom three
Via entries, three minutes apart, from two different carrier edge nodes. Read
the Via stack from the bottom up and it names every hop the carrier put the call
through — and the branch on the second-from-top entry moves from
z9hG4bK4f3e.9c0a44b7e1d2650fa839cb7f21e408d6.2 to z9hG4bK4f3e.9c0a44b7e1d2650fa839cb7f21e408d6.3. That suffix is the
carrier’s own retry counter. Its core re-fired the same call through a second
edge node, and neither the Call-ID nor the CSeq changed to say so.
Everything downstream of that reads badly without it. The edge sent BYE at
15:27:49 after 34.5 s of ringing. The carrier’s CANCEL for the first attempt
arrived at 15:30:14, two minutes and twenty-five seconds after the edge had
already given up. The second node’s attempt lasted 30 ms before its own CANCEL.
--lint names the fork mechanically. Two findings, with the Call-ID and the
rule’s own RFC explanation elided to leave the evidence:
sipnab -N -I inbound.pcap --portrange 1-65535 --lint --no-cli-print
error: SIP-3261-12.1.1-RECORD-ROUTE-NOT-COPIED [...] observed=request recorded
["sip:203.0.113.41;lr", "sip:203.0.113.52;lr"], the 2xx returned [] expected=the
2xx ending with ["sip:203.0.113.41;lr", "sip:203.0.113.52;lr"], in that order
error: SIP-3261-17.1.1.3-ACK-BRANCH-MISMATCH [...] observed=ACK
branch=z9hG4bK4f3e.5d3ab8146fc70e29d5b1938ae0c6274b.0, INVITE branch=z9hG4bK4f3e.b7e94c1052d8a3f6417c02be95d3ff81.0
The ACK carries the second node’s branch while the recorded INVITE branch is
the first node’s — the same cross-hop shape as
example 10, except that
here the two branches come from two boxes rather than two hops, which is the
finding rather than an artifact. The Record-Route error is separate and real: a
2xx that returns an empty Record-Route set when the request recorded two
entries leaves the caller with no route set, so any in-dialog request afterwards
has nowhere to go.
What to do next. When a duplicate INVITE arrives on an established Call-ID,
compare the top Via host and its branch suffix against the first attempt before
treating it as a retransmission. A retransmission repeats the branch exactly. A
re-fork increments a suffix and changes the sending node. Take the pair to the
carrier with both timestamps, because the gap between your BYE and their
CANCEL is the number that identifies whose timer is wrong.
Where to go next
- Troubleshooting — symptom to command, when you have a complaint rather than a capture.
- Cookbook — the recipes these examples apply, including the fail2ban wiring, the audio export and the MCP surface.
- Tuning capture — the BPF expressions that scope a capture to one hop, as example 10 needs.
- Output formats — every field these
examples read out of
--json-dialogs, with its type and meaning. - Attribute media on an rtpengine relay — what to do when the media in a capture like these arrives with no call attached to it.