Metadata-Version: 2.4
Name: sip-doctor
Version: 0.3.2
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.
- **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 /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 -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`.

## 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              |
| 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                            |

## 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.
