Metadata-Version: 2.4
Name: sip-doctor
Version: 0.8.0
Summary: Trace SIP calls (UDP, TCP, TLS, proxies, HEP) live on a Linux server or from pcap files, and explain failures
Author: Ramesh Audireddy
Keywords: sip,voip,pcap,tcpdump,kamailio,asterisk,freeswitch,hep
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.6
Classifier: Programming Language :: Python :: 3.7
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Operating System :: POSIX :: Linux
Classifier: Environment :: Console
Classifier: Topic :: Communications :: Telephony
Classifier: Topic :: System :: Networking :: Monitoring
Requires-Python: >=3.6
Description-Content-Type: text/markdown
License-File: LICENSE
Dynamic: license-file

# sip-doctor

Trace SIP calls on a Linux server, live or from pcap files, and get a plain-English explanation of what went wrong
and how to fix it.

- **Transports:** SIP over UDP (including IP-fragmented messages), TCP (stream reassembly, several messages per
  segment, lost or out-of-order segments) and TLS. For TLS, the handshake, certificate and alerts are inspected. The
  SIP inside TLS is encrypted, so to see those calls, receive them decrypted through HEP.
- **Proxies and B2BUAs:** every hop of a proxied call is shown (caller → proxy → callee, including failover), so you
  can see which hop failed. B2BUA legs with different Call-IDs (Asterisk, FreeSWITCH, SBCs) are linked together.
- **Audio:** every RTP stream is matched to its call through the SDP, with packet loss, jitter, dropouts, an
  estimated MOS and DTMF digits. It detects one-way and missing audio, audio arriving from an address the SDP didn't
  announce (NAT), and audio that starts late or stops early.
- **Attacks:** the report and live alerts flag:
  - scanners such as SIPVicious
  - floods
  - extension scanning
  - password guessing
  - **compromised accounts**: logins that succeed after many failures
  - **toll fraud**: international and high-risk calls from suspicious sources
- **Proxy behaviour:** for every proxy in the call path it shows:
  - whether the proxy is stateful (sends 100 Trying)
  - Record-Route, and in-dialog requests that bypass the proxy
  - Max-Forwards handling and signs of a routing loop
  - internal addresses visible to the next network (topology hiding)
- **STIR/SHAKEN:** for proxies that sign and verify calls. It decodes the Identity header (PASSporT) on every
  hop and checks freshness, number matching and token parameters. It traces the REST calls to the signing,
  verification and CPS services (content over HTTP, timing over HTTPS), shows the verstat passed to customers, and
  explains failures.
- **Mid-call events:** hold and resume, codec changes, transfers (REFER and their result), DTMF over SIP INFO, and
  rejected re-INVITEs.
- **Live:** capture from any interface with tcpdump (the kernel filters the traffic) or a raw socket. You can also
  receive HEP from Kamailio, OpenSIPS, FreeSWITCH or Asterisk.
- **Reports:** a call table, a ranked problem list, per-peer statistics, a time-window filter, JSON, and an HTML
  report to attach to tickets.
- **Files:** pcap and pcapng files from tcpdump, Wireshark or `tcpdump -i any`, with Ethernet, VLAN, Linux cooked
  capture, IPv4 or IPv6. You can also pipe a capture in on stdin.
- **No dependencies:** only the Python standard library is needed. It works on **Python 3.6 to 3.14**, from CentOS 7 /
  FreePBX SNG7 and Ubuntu 18.04 (3.6) to current releases, and each version is tested to produce identical reports.

```
━━ Call #1  ANSWERED  2001 → 3001  03:55:00  UDP  203.0.113.10 → 198.51.100.20
   ringing after 50ms · answered after 1.0s · talk 32.0s · callee hung up · codecs PCMU, PCMA, telephone-event
   Call-ID missing-ack@203.0.113.10

                     203.0.113.10:5060                  198.51.100.20:5060
                          caller                              callee
      +0.000                 |---------- INVITE (SDP) ---------->|
      +0.050                 |<---------- 180 Ringing -----------|   ringing after 50ms
      +1.000                 |<--------- 200 OK (SDP) -----------|   answered after 1.0s
      +1.500                 |<------- 200 OK ×7 retrans --------|   until +20.500 · ✖ no ACK came back
     +33.000                 |<-------------- BYE ---------------|   callee hung up · talk 32.0s
     +33.020                 |---------- 200 OK (BYE) ---------->|

   ✖ Missing ACK: the call dropped after ~32 seconds
     The 200 OK was sent 8 times but no ACK came back to it. The answering side gives up and hangs up after 32 seconds.
     Fix: The ACK is sent to the 200 OK's Contact (<sip:3001@192.168.1.50:5060>) via any Record-Route proxies. Make sure that address is reachable from the caller (NAT: set the external/public address on the callee/PBX, or enable Record-Route on the proxy).
   ▲ NAT problem: 198.51.100.20 advertises a private Contact (192.168.1.50)
     Packets come from public 198.51.100.20 but its 200 OK Contact says 192.168.1.50. In-dialog requests (ACK, BYE, re-INVITE) will be sent to an unreachable address.
     Fix: Set the external/public IP on that device (or enable NAT handling / fix_nated_contact on the proxy).
```

## Install on a Linux server

Pick one of these methods.

**Single file:** no pip and no internet needed on the server.

```bash
./deploy/build-pyz.sh                      # on your machine: builds dist/sip-doctor (~45 KB)
scp dist/sip-doctor root@server:/usr/local/bin/
ssh root@server sip-doctor --version
```

**pipx:**

```bash
scp -r sip-doctor server:   # or git clone
sudo pipx install ./sip-doctor --global    # pipx >= 1.5; older pipx: sudo PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx install ./sip-doctor
```

**Virtualenv:** this works on Debian 12 and Ubuntu 24, where system-wide `pip install` is blocked.

```bash
sudo python3 -m venv /opt/sip-doctor
sudo /opt/sip-doctor/bin/pip install ./sip-doctor
sudo ln -s /opt/sip-doctor/bin/sip-doctor /usr/local/bin/sip-doctor
```

**Without installing:** run `sudo python3 -m sipdoctor ...` from inside the project folder.

**Which method suits your server's Python:**

| Server (default `python3`)                                  | Recommended                                                 |
|-------------------------------------------------------------|-------------------------------------------------------------|
| CentOS 7, FreePBX SNG7, Ubuntu 18.04, RHEL 8 (3.6)          | the single file, or `pip3 install sip_doctor-*.whl` (build it with `python -m build -w` on another machine) |
| Debian 10 (3.7), RHEL 8 with `python38`, Ubuntu 20.04 (3.8) | any method                                                  |
| Debian 11/12, Ubuntu 22.04/24.04, RHEL 9, Rocky/Alma 9 (3.9-3.12) | any method (virtualenv or pipx on Debian 12 / Ubuntu 24.04) |

`pip install` from the source folder needs Python 3.7 or newer, because the packaging tools no longer run on 3.6.
The single file and the wheel work on 3.6. Check the version with `python3 --version`.

Install `tcpdump` as well (`apt install tcpdump` or `dnf install tcpdump`). When tcpdump is available, sip-doctor uses
it so the kernel filters packets before Python sees them, which matters on busy servers. Without tcpdump, sip-doctor
falls back to a raw socket. Live capture needs root (or `CAP_NET_RAW`); reading files does not.

## Live tracing

```bash
sudo sip-doctor live                        # all interfaces, ports 5060,5061,5080,5081
sudo sip-doctor live -i eth0 -p 5060-5070   # one interface, a port range
sudo sip-doctor live --number 1001          # only calls from/to 1001
sudo sip-doctor live --ip 203.0.113.50      # only traffic to/from one peer (e.g. a trunk)
sudo sip-doctor live -q                     # no per-message lines; a call flow per finished call
sudo sip-doctor live -q -v                  # call flows with headers and SDP under each message
sudo sip-doctor live -w /var/tmp/sip.pcap --max-mb 200   # also save the SIP packets (rotates to .1)
sudo sip-doctor live --json-log /var/log/sip-doctor/calls.jsonl   # one JSON line per finished call
sudo sip-doctor live --rtp                  # also analyse the audio: one-way audio, loss, jitter, MOS
sudo sip-doctor live --home-cc 1            # flag international calls in +CC format too (toll fraud)
sudo sip-doctor live -p any                 # find SIP on any port (slower: every TCP/UDP packet is checked)
```

Each SIP message prints as it arrives. When a call ends, its call flow and findings follow:

```
20:09:06.064 #2          127.0.0.1:48548 → 127.0.0.9:5080        TCP  INVITE sip:1002@127.0.0.9;transport=tcp [SDP]
20:09:06.118 #2           127.0.0.9:5080 → 127.0.0.1:48548       TCP  100 Trying (INVITE)
20:09:06.118 #2           127.0.0.9:5080 → 127.0.0.1:48548       TCP  486 Busy Here (INVITE)
```

OPTIONS keep-alives and successful registrations are hidden unless they fail. Add `-a` to show everything. Press
Ctrl-C to stop; any calls still in progress are then summarised.

### Run it permanently (systemd)

```bash
sudo cp deploy/sip-doctor.service /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now sip-doctor
journalctl -u sip-doctor -f                    # call summaries
tail -f /var/log/sip-doctor/calls.jsonl | jq . # structured log
```

## Reading capture files

```bash
sip-doctor capture.pcap                        # overview, call table, call flow + findings per call
sip-doctor capture.pcap -v                     # call flows with key headers and SDP under each message
sip-doctor capture.pcap -vv                    # ... and the complete SIP messages
sip-doctor capture.pcap --no-flow              # summaries and findings only
sip-doctor capture.pcap --all                  # also OPTIONS and REGISTER transactions
sip-doctor capture.pcap --call 7               # one call (number or part of its Call-ID)
sip-doctor capture.pcap --call 7 -w call7.pcap # save that call's SIP and RTP, for Wireshark (Telephony › VoIP Calls › Play)
sip-doctor capture.pcap --from 14:00 --to 14:30   # only calls in that window (or 'YYYY-MM-DD HH:MM')
sip-doctor capture.pcap --html report.html     # also save the report as a web page, to attach to a ticket
sip-doctor capture.pcap --no-rtp               # skip the audio analysis
sip-doctor capture.pcap --json                 # machine-readable (includes audio streams and events)
sudo tcpdump -i any -w - port 5060 | sip-doctor read -    # analyse a live tcpdump stream
```

Call flow through a proxy that asks for authentication (in a capture with more than 20 calls, flows are drawn
only for calls with problems unless you add `-l`):

```
                    10.0.0.5:5060                          10.0.0.1:5060                          10.0.0.9:5060
                       caller                                  proxy                                 callee
   +0.000                 |------------ INVITE (SDP) ----------->|                                      |
   +0.010                 |< 407 Proxy Authentication Required --|                                      |
   +0.020                 |---------------- ACK ---------------->|                                      |
   +0.030                 |------------ INVITE (SDP) ----------->|                                      |
   +0.040                 |<------------ 100 Trying -------------|                                      |
   +0.050                 |                                      |------------ INVITE (SDP) ----------->|
   +0.060                 |                                      |<------------ 100 Trying -------------|
   +0.800                 |                                      |<----------- 180 Ringing -------------|
   +0.810                 |<----------- 180 Ringing -------------|                                      |
   +3.000                 |                                      |<----------- 200 OK (SDP) ------------|
```

## What a call report contains

Each call shows its result and timings, who called whom (display name, P-Asserted-Identity, forwarding), the software
on each side, every proxy hop with its response time, a timeline of mid-call events, an audio quality table, and the
call flow with RTP streams and notes on each step:

```
━━ Call #1  ANSWERED  alice → 1012  04:15:00  UDP  10.0.0.5 → 10.0.0.9
   ringing after 300ms · answered after 1.0s · talk 18.2s · caller hung up · codecs PCMU, PCMA, telephone-event
   caller name "Alice Smith" · asserted identity +14155550123 · forwarded from 1000 · setup 1.0s
   Call-ID holdxfer@10.0.0.5
   Software: caller Yealink T54W 96.86 · callee Asterisk PBX 20.5.0
   Events:
        +5.000  caller put the call on hold → 200 OK
        +9.000  caller resumed the call → 200 OK
       +10.000  re-INVITE from caller: media update (G729) → 488 Not Acceptable Here
       +14.000  caller transferred the call to 1099 → 202 Accepted
       +15.100  transfer result: 404 Not Found
   Audio:
     → callee  10.0.0.5:20030 → 10.0.0.9:30030  PCMU      900 pkts  18.0s  loss 0.0%  jitter 0ms  MOS 4.4
     → caller  10.0.0.9:30030 → 10.0.0.5:20030  PCMU      900 pkts  18.0s  loss 0.0%  jitter 0ms  MOS 4.4

                       10.0.0.5:5060                       10.0.0.9:5060
                          caller                              callee
      +0.000                 |---------- INVITE (SDP) ---------->|
      +0.300                 |<---------- 180 Ringing -----------|   ringing after 300ms
      +1.000                 |<--------- 200 OK (SDP) -----------|   answered after 1.0s
      +1.020                 |--------------- ACK -------------->|
      +1.100                 |======== RTP PCMU 900 pkts =======>|   18.0s · loss 0.0% · jitter 0ms · MOS 4.4
      +1.100                 |<======= RTP PCMU 900 pkts ========|   18.0s · loss 0.0% · jitter 0ms · MOS 4.4
      +5.000                 |---------- INVITE (SDP) ---------->|   caller put the call on hold → 200 OK
      +5.050                 |<--------- 200 OK (SDP) -----------|
      +5.060                 |--------------- ACK -------------->|
      +9.000                 |---------- INVITE (SDP) ---------->|   caller resumed the call → 200 OK
      +9.050                 |<--------- 200 OK (SDP) -----------|
      +9.060                 |--------------- ACK -------------->|
     +10.000                 |---------- INVITE (SDP) ---------->|   re-INVITE from caller: media update (G729) → 488 Not Acceptable Here
     +10.050                 |<---- 488 Not Acceptable Here -----|
     +10.060                 |--------------- ACK -------------->|
     +11.000                 |-------------- INFO -------------->|   DTMF 4
     +11.010                 |<--------- 200 OK (INFO) ----------|
     +12.000                 |-------------- INFO -------------->|   DTMF 2
     +12.010                 |<--------- 200 OK (INFO) ----------|
     +14.000                 |-------------- REFER ------------->|   caller transferred the call to 1099 → 202 Accepted
     +14.020                 |<----- 202 Accepted (REFER) -------|
   ...
```

The audio table flags bad streams:

```
   Audio:
     → caller  10.0.0.99:41000 → 10.0.0.5:20020  PCMU      848 pkts  20.0s  loss 15.2%  jitter 25ms  MOS 2.9  1 dropout(s) ≤1.5s
     → callee  10.0.0.5:20020 → 10.0.0.9:30020  PCMU     1000 pkts  20.0s  loss 0.0%  jitter 0ms  MOS 4.4

   ✖ Poor audio callee → caller: estimated MOS 2.9
   ▲ Audio callee → caller comes from 10.0.0.99:41000, not from the SDP address
```

With many calls, the report first prints a call table, a **Problems** list ranked across the whole capture, and a
**Peers** table: for every trunk, gateway or phone that INVITEs were sent to, it shows attempts, answer rate (ASR),
calls with no reply, average reply time and post-dial delay, and failure codes. RTP that no captured SIP set up (for
example because the SIP was encrypted) is listed separately.

## Troubleshooting explanations

The first time each kind of problem appears in a report, sip-doctor prints a full troubleshooting entry under it:

- **What it means:** the problem in plain words.
- **User impact:** what callers notice.
- **Likely causes.**
- **How to confirm:** numbered checks with concrete commands.
- **How to fix.**
- **Platform settings:** the relevant setting or command for Asterisk (PJSIP and chan_sip), FreeSWITCH, Kamailio and
  OpenSIPS.
- **The RFC.**

Later occurrences of the same problem point back to it. Add `--brief` to hide the explanations.

```
   ✖ Missing ACK: the call dropped after ~32 seconds
     ...
     ┌─ Troubleshooting: Missing ACK
     │ What it means:  When a call is answered, the callee sends 200 OK and the caller must confirm it with an ACK...
     │ User impact:    The call connects and audio may work, but it always drops after about 32 seconds.
     │ Likely causes:
     │   • NAT: the callee's 200 OK has a private Contact (192.168.x.x / 10.x.x.x), so the ACK goes nowhere
     │   ...
     │ How to confirm:
     │   1. Look at the 200 OK's Contact and Record-Route headers (run with -v)
     │   ...
     │ Platform settings:
     │   Asterisk (PJSIP): ... rtp_symmetric=yes, force_rport=yes, rewrite_contact=yes, direct_media=no
     │   Kamailio: record_route() for initial INVITEs; loose_route() + handle_ruri_alias() for in-dialog requests ...
     └─
```

You can also look anything up directly:

```bash
sip-doctor explain                 # list every topic
sip-doctor explain one-way-audio   # a problem
sip-doctor explain 488             # a SIP status code: meaning, causes, checks, fixes, platform commands
sip-doctor explain q850-34         # a Q.850 cause from a Reason header
sip-doctor explain unknown_ca      # a TLS alert
sip-doctor explain mos jitter asr  # terms
```

`--json` output includes each finding's `topic` and, once per topic, the full explanation under `explanations`.

## Attacks on your SIP server

Every SIP server reachable from the Internet is scanned within minutes. sip-doctor watches every source address
and reports attacks in a **Security** section of the report. In `live` mode, an alert is printed the moment each one
happens and is written to the `--json-log`.

```
Security  (attacks and suspicious sources)
  ✖ Account 1005 logged in from 203.0.113.66 after 17 failed attempts
  ✖ International calls answered for 203.0.113.66: 0118821234567890
  ▲ Password guessing from 203.0.113.66: 17 rejected logins
  ▲ SIP flood from 203.0.113.66: up to 120 requests per second
  ▲ Extension scan from 203.0.113.66: 12 users / numbers probed
  ▲ SIP scanner at 203.0.113.66 (User-Agent "friendly-scanner")
```

| Detection | When |
|---|---|
| SIP scanner | The User-Agent of a known attack tool (friendly-scanner / SIPVicious, sipcli, VaxSIPUserAgent, …) |
| SIP flood | 50 or more requests per second from one address (error at 500) |
| Extension scan | 10 or more different users / extensions rejected (401 / 403 / 404) for one address |
| Password guessing | 10 or more logins with wrong credentials from one address |
| **Account compromised** | A successful login from an address that had many failed attempts first |
| **Toll fraud** | International calls from a suspicious source: answered (error), or several attempts / high-risk ranges such as +870, +881, +882, +883 (warning) |

International means dialled with `00` or `011`. Add `--home-cc 1` (your country code) so that `+CC` numbers outside
your country count too. Normal digest logins (a 401 challenge followed by a successful login) and ordinary call
failures are not treated as attacks. Each detection comes with the full explanation and the platform settings that
stop it (fail2ban, Kamailio pike / secfilter, OpenSIPS fraud_detection, Asterisk dialplan limits, …).

## Proxy behaviour

For each proxy a call passes through (a host that received the INVITE and forwarded it with the same Call-ID), the
call report shows one line:

```
   Proxy 10.0.0.1: stateful: 100 Trying after 10ms · Record-Route yes · Max-Forwards 70→69
```

and reports:
- **In-dialog requests that bypass the proxy** (`record-route`): the ACK, BYE or re-INVITE went straight between
  the endpoints. This is informational when the proxy doesn't Record-Route, and a warning when it does (a user agent
  ignoring the route set, or an unusable Record-Route address).
- **Max-Forwards not decremented**, or **nearly used up** when arriving (`max-forwards`): routing loops.
- **Internal addresses sent to an external next hop** (`topology-hiding`, informational): private addresses in
  Via, Contact, Record-Route, SDP or Call-ID.

`sip-doctor explain sip-proxy stateful-proxy registrar redirect-server sbc b2bua record-route digest` explains
the concepts.

## STIR/SHAKEN on a SIP proxy

**Worked example:** [docs/stir-shaken.md](docs/stir-shaken.md) walks through a proxy's signing, CPS deposit and
verification traffic, and five troubleshooting cases, using real messages and sip-doctor output from
`samples/stir_shaken_demo.pcap`.

A typical setup: customers send calls to your proxy, and the proxy calls a REST signing service (STI-AS) to get a
PASSporT, then forwards the call with an `Identity` header. Optionally it also deposits the token with a Call
Placement Service for out-of-band SHAKEN. For incoming calls, the proxy sends the Identity header to a verification
service (STI-VS) and passes the result to the customer as `verstat` in `P-Asserted-Identity` / `From`.

Many deployments use the **SIP redirect interface** instead. The customer's SBC sends the INVITE to an AS or VS
proxy, which calls its REST service and *answers* the INVITE, usually with `302 Moved Temporarily`. The AS response
carries the `Identity` header; the VS response carries `verstat` in `P-Asserted-Identity` (or a rejection such as
`438`). sip-doctor recognises this:

- Such calls show as **SIGNED** / **NOT SIGNED** or **VERIFIED** / **NOT VERIFIED** / **NO VALIDATION**, not as
  failed 302s.
- The proxies are labelled `STI-AS` / `STI-VS` in the call flow.
- The token in the response is checked against the customer's INVITE (numbers, freshness, signature).
- It reports an AS that returned no Identity, a VS that returned no verstat, "passed" for a bad token, and slow
  answers.

Plain (non-STIR) 302 redirects are reported as information, not as failed calls.

Run sip-doctor on the proxy and point it at the services:

```bash
sudo sip-doctor live -i any --stir 10.0.2.50:8080                       # REST over HTTP: full content
sudo sip-doctor live -i any --stir sti.example.com:443,cps.example.com:443   # HTTPS: timing only
sip-doctor capture.pcap --stir 10.0.2.50:8080
```

Plain-HTTP calls to a STIR service are recognised even without `--stir`, by their path or JSON content (ATIS-1000082
`signingRequest` / `verificationRequest`, CPS `/passports/{dest}/{orig}`, or vendor APIs using fields like `identity`,
`attest`, `verstat`). Encrypted (HTTPS) services must be named with `--stir`, and only their timing is visible.

Each call shows the token on every hop, the REST calls with their latency and result, and the REST calls inside the
call flow:

```
   STIR/SHAKEN:
       198.51.100.150:5060 → 10.0.2.1:5060         no Identity header
             10.0.2.1:5060 → 203.0.113.200:5060    no Identity header
                  10.0.2.1 → 10.0.2.50:8080        POST /stir/v1/signing → 500 Internal Server Error · SVC4001 Signing key unavailable  (20ms)

                    198.51.100.150:5060           10.0.2.1:5060            203.0.113.200:5060           10.0.2.50:8080
                          caller                      proxy                       callee                    STIR svc
      +0.000                 |------ INVITE (SDP) ----->|                            |                            |
      +0.006                 |                          |.......... POST /stir/v1/signing ...................>|
      +0.026                 |                          |<........ 500 Internal Server Error ..................|   20ms · SVC4001 ...
      +0.040                 |                          |------ INVITE (SDP) ------->|                            |
      +0.120                 |                          |<-- 428 Use Identity Header |                            |

   ✖ Signing request failed: SVC4001 Signing key unavailable
   ▲ Call sent to 203.0.113.200 without an Identity header
   ✖ Call failed: 428 Use Identity Header
```

What it checks:

| Check | Finding |
|---|---|
| Signing / verification / CPS request failed, timed out, or returned a service / policy exception | `stir-sign-failed`, `stir-service-error`, `stir-service-down` |
| REST answer slower than 500 ms (added to every call's post-dial delay) | `stir-slow` |
| Call forwarded to the next network without an Identity header | `stir-unsigned` |
| Token older than 60 s, or `iat` in the future (clock problems) | `stir-stale` |
| `orig.tn` / `dest.tn` don't match the calling / called number actually sent (numbers rewritten after signing) | `stir-tn-mismatch` |
| `alg` not ES256, `ppt` not shaken, missing or non-https `x5u`, `info` ≠ `x5u`, bad attestation, missing `origid` / `iat` | `stir-bad-passport` |
| Verification result other than TN-Validation-Passed, with the service's reason code | `stir-verify-failed` |
| Carrier responses 428, 436, 437, 438, 608 | status explanations |

The overview line sums it up across the capture, e.g.
`STIR/SHAKEN: 6 signed INVITE(s) (A 5, B 1) · verstat TN-Validation-Passed 1, TN-Validation-Failed 1 · 9 REST call(s), avg 210ms, max 1.4s, 1 failed`.
`--json` includes, per call, the decoded token on each hop, the problems found, and every REST call.

### Verifying signatures

Give sip-doctor the certificates and it verifies each PASSporT the way a verification service does:

1. The ES256 signature, using the public key of the certificate named by `x5u`.
2. The certificate: valid at the time of the call, and a SHAKEN certificate (TNAuthList with an SPC).
3. The chain of trust to the STI-CA roots you trust.

```bash
sip-doctor capture.pcap --stir-certs /etc/sip-doctor/certs --stir-ca sti-pa-roots.pem
sip-doctor capture.pcap --fetch-certs --stir-ca sti-pa-roots.pem   # download certificates from x5u
sip-doctor samples/stir_shaken_demo.pcap --stir-certs samples/certs --stir-ca samples/certs/sti-ca.pem
```

- `--stir-certs DIR`: PEM or DER files named like the last part of the x5u URL (`sp-cert.pem` for
  `https://…/sp-cert.pem`). Each file may hold the leaf certificate plus intermediates.
- `--stir-ca FILE`: the trusted roots, for example the STI-PA's trusted CA list.
- `--fetch-certs`: downloads certificates that aren't found locally. It's off by default, because `x5u` comes from
  whoever sent the call. Downloads are limited to https on port 443, hosts that resolve to public addresses, no
  redirects, at most 64 KB, and a 3 s timeout.

Each token then shows `signature valid · chain trusted (to <root>)`, or a finding:

| Check | Finding |
|---|---|
| Signature does not match the token (altered after signing, or wrong key) | `stir-signature-invalid` |
| Certificate expired / not yet valid, no TNAuthList, untrusted issuer, or not available | `stir-cert-problem` |

The signature code is pure Python (no dependencies). It is checked against the RFC 6979 P-256 test vectors and
cross-checked with OpenSSL.

### Other checks

- **verstat you can't trust** (`stir-verstat-untrusted`): a `verstat` received from another network and passed on
  unchanged, `TN-Validation-Passed` on a call that had no Identity header, or "passed" for a token that fails the
  checks. Each of these lets spoofed calls appear verified.
- **Several Identity headers:** `div` PASSporTs (RFC 8946) for forwarded calls are understood. A token for the
  original number plus a `div` token for the new one is not a mismatch. `rcd` caller names (branded calling) are
  shown.
- **Header syntax:** for example a missing `;` between `info=<…>` and `alg=`, which appears in published examples
  and is a real interoperability problem.

`sip-doctor explain stir-shaken passport attestation verstat es256 tnauthlist spc sti-pa div rcd` explains the
terms. A note on wording you may read elsewhere: the token is *signed*, not encrypted. Anyone can read it; the
signature proves who issued it and that it wasn't changed.

## SIP over TLS: what you can see

On the wire, sip-doctor shows each TLS connection's SNI, TLS version, server certificate (TLS 1.2 and older), alerts,
and whether the handshake completed. It reports:

- a certificate rejected by the client (unknown CA, self-signed), an expired certificate, or a name mismatch
- version or cipher mismatches, and a client certificate required (mutual TLS)
- a refused or timed-out TCP connection to 5061, and plain SIP sent to a TLS port

The SIP messages inside TLS are encrypted. To trace those calls, have the proxy or PBX send a decrypted copy over
HEP and run:

```bash
sudo sip-doctor live --hep 9060              # HEP plus the normal capture
sip-doctor live -i none --hep 9060           # HEP only (no root needed)
```

| Server     | HEP configuration                                                                                                                                                           |
|------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Kamailio   | `loadmodule "siptrace.so"` · `modparam("siptrace", "duplicate_uri", "sip:127.0.0.1:9060")` · `modparam("siptrace", "hep_mode_on", 1)` · `modparam("siptrace", "hep_version", 3)`, then call `sip_trace()` in your routes (or set `trace_mode`) |
| OpenSIPS   | `loadmodule "proto_hep.so"` · `modparam("proto_hep", "hep_id", "[hep_dst] 127.0.0.1:9060;transport=udp;version=3")` · `loadmodule "tracer.so"` · `modparam("tracer", "trace_id", "[tid]uri=hep:hep_dst")`, then `trace("tid", "d", "sip");` |
| FreeSWITCH | `sofia.conf.xml`: `<param name="capture-server" value="udp:127.0.0.1:9060"/>`; profile: `<param name="sip-capture" value="yes"/>` (or `sofia global capture on`)              |
| Asterisk   | load `res_hep` and `res_hep_pjsip`; `hep.conf`: `enabled = yes`, `capture_address = 127.0.0.1:9060`                                                                          |

HEP received on UDP 9060 inside a pcap file is decoded automatically.

## What it diagnoses

| Area          | Findings                                                                                                                       |
|---------------|--------------------------------------------------------------------------------------------------------------------------------|
| Reachability  | no response from a hop (with retransmission count), INVITE retransmissions, TCP connection refused or timed out, TCP reset       |
| Call failures | every 4xx/5xx/6xx with its meaning and fix, which hop sent it, per-hop results through proxies, `Reason:` (Q.850) and `Warning:` headers |
| Auth          | challenge with no credentials sent, and wrong username/password (401/407/403 after credentials) for calls and registrations      |
| Dialog        | missing ACK (the call drops after 32 s), BYE rejected (481), session-timer expiry, slow post-dial delay                          |
| NAT           | private Contact or SDP audio address sent from a public IP (no ACK/BYE, one-way audio)                                          |
| Media (SDP)   | 488/606 with the offered codecs, audio rejected (port 0), no common codec, SRTP                                                  |
| Audio (RTP)   | one-way or no audio, packet loss, jitter, dropouts, MOS, RTCP loss reports, NAT source mismatch, unexpected codec, late or early-stopping audio, DTMF (RFC 4733) |
| Mid-call      | hold/resume, re-INVITE or UPDATE rejected (488, 491 glare), transfer (REFER) and its result, DTMF via SIP INFO, PRACK              |
| Attacks       | scanners, floods, extension scans, password guessing, compromised accounts, toll fraud |
| Proxies       | Record-Route bypass, Max-Forwards not decremented / nearly exhausted, internal addresses leaked |
| STIR/SHAKEN   | signing / verification / CPS REST failures and latency, unsigned calls, stale tokens, number mismatches, invalid PASSporT parameters, failed verification, invalid signatures, certificate and chain problems, untrusted verstat |
| Peers         | per destination: attempts, ASR, no-reply count, reply time, post-dial delay, failure codes                                        |
| Transport     | SIP over UDP above ~1300 bytes or IP-fragmented, TLS failures (see above)                                                        |
| Other         | REGISTER results, unanswered OPTIONS pings (repeats collapsed as ×N), failed MESSAGE/SUBSCRIBE/REFER                            |

## Security

sip-doctor parses traffic sent by anyone who can reach your SIP ports, including the scanners that probe every
public SIP server, and capture files people send you. It is built for that:

- **Hostile input:** malformed packets and files are skipped (and counted), never fatal. Numbers are parsed
  strictly, and no pattern can be made to backtrack catastrophically.
- **Bounded resources:** every buffer and table has a limit, so floods cannot exhaust memory: open calls (50,000),
  messages per call, TCP connections (20,000), out-of-order TCP data, IP fragments, TLS handshake data, RTP streams,
  HEP decompression and capture-file records. At the limit, the oldest entries are reported and released.
- **Terminal escape sequences:** control and bidirectional-override characters from packets are shown as visible
  escapes (`\x1b`), so a packet cannot rewrite your terminal, set its title or write to your clipboard. The HTML
  report escapes everything as well.
- **Files written safely:** sip-doctor refuses to write through a symbolic or hard link, or into another user's file
  (no symlink attacks in `/tmp` when run as root). New files are private (`0600`): captures and reports contain
  phone numbers, call content and credentials.
- **Credentials:** digest `response` and `cnonce` values are redacted in `-vv` and HTML output. Files saved with
  `-w` are raw captures and contain everything.
- **HEP:** the listener uses 127.0.0.1 unless you choose another address. If you open it to the network, restrict
  the senders with `--hep-allow` (and a firewall), otherwise anyone who can reach the port can inject fake calls.
- **Least privilege:** capturing needs only `CAP_NET_RAW` and `CAP_NET_ADMIN`, not root. The systemd unit in
  `deploy/` runs as an unprivileged dynamic user with just those two capabilities and a read-only system. For
  one-off use, `sudo sip-doctor live` is fine; to avoid full root:
  `sudo setpriv --reuid=$USER --regid=$(id -g) --init-groups --inh-caps=+net_raw,+net_admin --ambient-caps=+net_raw,+net_admin sip-doctor live`.
- **External tools:** `tcpdump` and `ip` are run from the system directories (`/usr/sbin`, ...) without a shell, so
  a directory early in `PATH` cannot substitute them.

## Development

```bash
python3 -m unittest discover -s tests       # tests (no pytest needed)
tests/run-matrix.sh                         # the tests on Python 3.6 ... 3.14 in Docker
python3 tests/samplegen.py samples          # regenerate the example captures in samples/
sip-doctor samples/all_scenarios.pcap       # every scenario in one file (see samples/README.md for the list)
```

`tests/live_traffic.py` sends real SIP over loopback (UDP, TCP, HEP), for testing `sip-doctor live` on Linux.
