Skip to content

operations

github-actions[bot] edited this page Oct 6, 2026 · 13 revisions

Operations Guide

CLI Card Management

The card subcommands talk to the running daemon over its control socket (default: /tmp/gsm-sip-bridge.sock). The daemon must be running.

# Show all known slots: state, phone number, network type
gsm-sip-bridge --config config.toml card list

# Restart a slot (safe to run while other cards are active; resets give-up state)
# --mode: "full" (default, AT+CFUN=1,1 — a complete module reset, can move
# the card's ttyUSB path) or "radio" (AT+CFUN=0 -> AT+CFUN=1 — drops and
# re-acquires network registration without power-cycling the module or
# re-enumerating USB). Same two values as [scheduled_restart].restart_mode.
gsm-sip-bridge --config config.toml card restart --slot 0
gsm-sip-bridge --config config.toml card restart --slot 0 --mode radio

# Set network preference for a slot and persist it
# Valid modes: 2g, 3g, 4g, auto
gsm-sip-bridge --config config.toml card set-mode --slot 0 --mode 4g

# Query the stored network mode preference
gsm-sip-bridge --config config.toml card get-mode --slot 0

Network mode preferences survive daemon restarts and are re-applied automatically whenever a card initialises (cold start or after recovery).

Querying the Store

Connect to the SQLite store directly:

sqlite3 /var/lib/gsm-sip-bridge/store.db

Useful queries:

-- Recent calls
SELECT * FROM recent_calls;

-- Recent SMS
SELECT * FROM recent_sms;

-- Calls by module
SELECT * FROM calls WHERE module_id = 'ec20-A1B2C3' ORDER BY id DESC LIMIT 20;

-- Failed SMS forwards
SELECT * FROM sms WHERE forwarding_status = 'failed';

-- IMEI → slot assignments
SELECT slot, imei, assigned_at FROM card_slots ORDER BY slot;

-- Stored network mode preferences
SELECT slot, mode, updated_at FROM card_mode_prefs ORDER BY slot;

Manual Prune

The bridge does not auto-prune. Run periodically:

DELETE FROM calls WHERE started_at < datetime('now', '-365 days');
DELETE FROM sms WHERE received_at < datetime('now', '-365 days');
VACUUM;

WAL Checkpoint

SQLite WAL files grow during writes. Force a checkpoint:

PRAGMA wal_checkpoint(TRUNCATE);

Backup

sqlite3 /var/lib/gsm-sip-bridge/store.db ".backup /backup/store-$(date +%Y%m%d).db"

Troubleshooting

A line went unreachable and something restarted it

Look for this line in the agent's log (/tmp/ims-agent-<line>.out, also in docker logs):

watchdog: the dispatch loop has made no progress

It carries activity, phase, stalled_secs, budget_secs and — most usefully — last_at_command, the modem command the line was waiting on when it stopped moving. The agent exits 70 and the supervisor restarts that line within ~5s; a restarted line has been observed re-registering in ~150s.

This exists because of a 2026-08-16 incident in which a line was unreachable for 2h45m while every health signal reported it healthy. A modem had stopped answering, and a re-registration blocked forever in read(2) on the serial port — on the same thread that answers calls.

Repeated stalls escalate through the same ladder as a dropped USIM: three strikes trigger an AT+CFUN=0→1 SIM reset, and after five resets the line alerts once and drops to a 15-minute retry cadence rather than restarting in a tight loop. It keeps retrying deliberately — the common causes (a power blip, a USB re-enumeration, a SIM reseating itself) clear on their own, and the line then returns to service unattended.

To keep a wedged line for diagnosis, set [vowifi].watchdog_recovery_enabled = false. The stall is still detected, logged and reported — only the restart is suppressed. Do not leave this off in production: it reinstates exactly the silent outage the watchdog exists to prevent.

Is this line actually reachable right now?

Three surfaces, which must now agree:

gsm-sip-bridge -c /etc/gsm-sip-bridge/config.toml vowifi-status
#   expires_in: -9752s (LAPSED)
#   can_answer: false
#   blocked_reason: the registration has expired

wget -qO- http://127.0.0.1:9091/metrics | grep -E 'registration_expires_in_seconds|agent_up'
#   negative value = the binding has already lapsed

docker ps            # reports (unhealthy) while a resolved line's registration is expired

Before this feature, all three reported healthy for the whole outage: expires_at was recorded but read by nothing, the Prometheus heartbeat came from a thread independent of the dispatch loop, and the healthcheck only proved the metrics port accepted connections.

No /dev/ttyUSB* devices

Check dmesg | grep ttyUSB. Ensure the option and qcserial kernel modules are loaded, and see supported-hardware.md.

No audio device in arecord -l

USB Audio Class is not enabled on the module — follow the one-time UAC setup in supported-hardware.md.

ModemManager interfering with AT sessions

ModemManager probes ttyUSB* ports, corrupting AT sessions (the bridge warns at startup if it is active):

sudo systemctl stop ModemManager
sudo systemctl disable ModemManager

Permission denied on serial/audio devices

Add the user to the dialout and audio groups:

sudo usermod -aG dialout,audio $USER

Module shows FAILED at startup

Check:

  1. USB device connected: lsusb | grep 2c7c:0125
  2. Serial port accessible: ls -la /dev/ttyUSB*
  3. ModemManager not interfering: systemctl is-active ModemManager
  4. Permissions: user must be in dialout group

Card is in GivenUp state (stopped retrying)

A slot stops retrying after [resilience] max_retries consecutive failures and emits a CRITICAL log. To re-enable it:

gsm-sip-bridge --config config.toml card restart --slot <N>

This resets the give-up counter and triggers a fresh initialization attempt.

Card recovery not triggering after USB re-plug

The bridge detects USB disconnect via a serial read error on the AT port. If the device re-enumerates but the slot stays in Recovering:

  1. Check that the IMEI in card_slots matches the re-plugged modem (sqlite3 store.db "SELECT * FROM card_slots;").
  2. Verify no other process holds the ttyUSB port: fuser /dev/ttyUSB*
  3. Force a restart: gsm-sip-bridge --config config.toml card restart --slot <N>

Control socket not reachable

error: daemon not running or socket unreachable: /tmp/gsm-sip-bridge.sock
  1. Verify the daemon is running: ps aux | grep gsm-sip-bridge
  2. Check the configured socket path matches: [control] socket_path in config.toml
  3. Check filesystem permissions on the socket directory

SIP registration failing

Check:

  1. PBX reachable: nc -zuv <server> <port>
  2. Credentials correct in config.toml
  3. Transport matches PBX (udp/tcp/tls)
  4. If TLS: check tls_verify setting

SIP call fails / busy

Verify [bridge].sip_destination is a valid, reachable extension on the PBX (or empty for DID passthrough, with a matching PBX inbound route).

No audio after SIP answers

Check logs for call media active, audio connected to sound device. Verify the ALSA device is accessible and not claimed by another process (fuser /dev/snd/*).

Audio clicks / dropouts / choppy GSM audio

  1. Ensure no other process claims the ALSA device: fuser /dev/snd/*
  2. Watch for alsa_capture_overrun / alsa_playback_underrun warnings in the logs — raise [audio] snd_rec_latency_ms / snd_play_latency_ms and consider enabling rt_audio_prio (see configuration.md).

Docker container not finding USB/audio devices

The container must run with privileged and the /dev bind-mount to access USB devices and ALSA (the shipped docker/docker-compose.yml already does). Note this is not what network_mode: host is for — device and audio access work the same in any network mode.

VoWiFi: "failed to reach Agent B control channel: connection timed out"

The tunnel is up, the IMS registration succeeded and the carrier's INVITE arrived — but Agent A (inside netns ims0, ims1, ... one per line) cannot reach Agent B across the veth pair, so every inbound call fails.

Under network_mode: host (the shipped default) the veth's Agent B end — veth-sip0 for line 0, veth-sip1 for line 1, etc. ([vowifi].veth_sip_iface

  • line index, always suffixed, even for line 0) — lives in the host's network namespace, so Agent A's traffic arrives as inbound host traffic and is filtered by the host firewall. A default-deny firewall (ufw, firewalld) drops it. The giveaway is that ICMP still works — ip netns exec ims0 ping 10.99.0.2 succeeds while TCP to 10.99.0.2:7050 times out.

With ufw, one rule per line's interface:

sudo ufw allow in on veth-sip0 from 10.99.0.1 comment 'gsm-sip-bridge VoWiFi agents'

Or one rule covering every line at once with ufw's interface wildcard (+ matches any suffix):

sudo ufw allow in on veth-sip+ comment 'gsm-sip-bridge VoWiFi agents, all lines'

Allow the whole interface, not just the control port: the call's RTP audio crosses the same veth on PJSUA-allocated media ports (base 4000, incrementing per call), so a rule for TCP/7050 alone yields a connected call with no audio — a more confusing failure than no call at all. Each veth-sipN is a private /30 whose only peer is that line's own netns. The rule keys on the interface name, which survives the tunnel reconnects that delete and recreate the pair.

VoLTE's per-line carrier-agent isolation (specs/020-volte-line-netns, netns volte0/volte1/..., veth veth-volte-sip+index) hits the identical class of failure for the identical reason — same fix, different interface prefix.

Not an issue under bridge networking, where the veth's host end sits in the container's own namespace, out of the host firewall's reach.

VoWiFi: registration is granted, then torn down seconds later

Symptom: REGISTER response status=200, immediately followed by NOTIFY reports a terminated state carrying event="deactivated" and reason=noresource for our own contact — after which terminating calls never arrive.

The modem's own IMS/VoLTE stack is registered too. Our REGISTER carries +sip.instance="<urn:gsma:imei:$IMEI>" — the modem's IMEI — so a VoLTE-registered modem claims the same IMPU with the same instance-id, and per RFC 5626 the network treats one registration as a re-registration of the other and deactivates the older binding. The modem wins, and the bridge can never receive a call.

Since v6.2.0 the entrypoint reconciles this automatically on boot (AT+QCFG="ims" must be 2 when [vowifi].enabled), rebooting the module if it was wrong. If it fails, check the modem supports AT+QCFG="ims" at all — ims_conf=1 with volte_cap=1 is the state that causes this.

VoWiFi: "control channel not bindable yet" repeats and never clears

Agent B binds each line's control channel to the host end of that line's veth pair (e.g. 10.99.0.2:7050). The supervisor creates the pair once the tunnel is up, so a few of these warnings at startup are normal. If they continue:

  • The supervisor log says why. veth setup failed (<step>: <stderr>) means a command in the setup failed; veth missing; rebuilt means it noticed the pair was gone and repaired it, and the next bind attempt should succeed. veth missing and rebuild failed repeats every tick (30s, or 5s for the swu engine) until the cause is fixed.
  • After about five minutes without a bind, Agent B logs an error ("control channel still not bindable after a long wait") every five minutes.
  • Check both ends by hand: ip addr show dev veth-sip0 on the host, and ip netns exec ims0 ip addr show dev veth-ims0 for the namespace end (use the line's own index). Both need their /30 address and the UP flag. If the address is present but the bind still fails, something else may own the port; the bind error in the log says which.
  • If the tunnel itself keeps cycling, the veth is not the problem; see the re-establishes-every-~30-seconds entry below.

VoWiFi: a line re-establishes its tunnel every ~30 seconds

Symptom: one line never stays up. Its supervisor logs tunN missing from netns imsN; recreating and forcing reinitiate on every steady-state tick, swanctl --list-sas shows the IKE_SA repeatedly ESTABLISHED with a fresh CHILD_SA, and the charon log alternates installing <addr> on tunN / removing <addr> from tunN. The line's Agent A fails with Network unreachable reaching its P-CSCF, because the tunnel interface it should route over is not there.

Cause: the line's XFRM if_id is still claimed, so the interface cannot be created:

$ ip link add tun23-1 type xfrm if_id 24
RTNETLINK answers: File exists

An XFRM interface registers its if_id in the namespace it was created in, not the one it lives in. supervise creates tun23-N in the host namespace and then moves it into imsN, so the device holds the id against the host while being invisible to ip link show there. ip -d link show type xfrm run inside imsN shows it, with link-netnsid 0 pointing back at the host namespace it is registered in:

$ docker exec <container> ip netns exec ims0 ip -d link show type xfrm
tun23-0@NONE: ... link-netnsid 0
    xfrm if_id 0x17 ...

So a healthy, fully registered deployment refuses if_id 23 and 24 in the host namespace while it is running — that refusal, by itself, is not evidence of a problem.

Before specs/041-shutdown-resource-cleanup (any release before this one), this symptom after a restart was expected and self-clearing — measured 2026-07-31: 163s/195s for a container replaced immediately, against 11s for one restarted after a 3-minute stop. The stop back then only sent every process a signal and removed the name of each namespace (ip netns del); it never deleted the device itself, so the id stayed claimed until the old container's leftover mount namespace was reaped on its own, typically ~2.5 minutes later.

Since specs/041-shutdown-resource-cleanup, a graceful stop deletes the device explicitly — waiting for every child to exit, terminating each line's IKE_SA, flushing this deployment's own XFRM state, then ip link del on the tunnel interface and the veth pair before deleting the namespace (see supervise::shutdown and the log markers below). Restarting after a normal docker compose stop/restart should no longer show this at all; if it does, something in that sequence did not complete, and where to look depends on how the previous run ended:

  • The previous run's stop is still in progress. stop_grace_period in docker-compose.yml (60s by default) should be long enough; if the supervisor's log shows [supervise] teardown: out of time, skipping ..., the stop allowance ran out mid-teardown and the fallback prioritised releasing devices over waiting — check what it reported it could not release, named in a [supervise] teardown: could not release ... line.

  • The previous run was force-killed, or the machine lost power. No graceful stop ran at all, so nothing deleted the device. On start, supervise can reclaim this — but only if you opt in, because the reclamation deletes devices and a namespace on the host:

    GSM_SIP_BRIDGE_RECLAIM_LEFTOVER_NETNS=1
    

    Without it you get a log line naming the leftover and stopping there; with it, [supervise] reclaiming netns ... left by a previous run.

    Before enabling it, be sure only one instance of this deployment runs on this host. The opt-in exists because nothing the container can check from inside its own PID namespace distinguishes "my own dead run's ims0" from "a second, live instance's ims0" — ip netns pids cannot see another container's processes. Reclaiming while a sibling instance is live would delete that instance's working tunnel out from under it. A stale docker start of an older container is enough to cause this, so the default is off.

    As a second guard (independent of the flag), a namespace is only ever touched when it actually contains a device this deployment would have put there — tun23-N for a VoWiFi line, the carrier-side veth for a VoLTE one. An unrelated workload's ims0 therefore is never deleted, opt-in or not; you'll see netns ims0 exists but does not contain tun23-0, so it is not this deployment's and was left untouched instead.

    Reclamation also needs the per-line namespace directory visible from the host, which is what docker-compose.yml's /var/run/netns bind mount (rshared propagation) is for. If that mount is missing or does not propagate, reclamation finds nothing — confirm with ip netns list on the host (not inside the container): a namespace from a killed run should be visible there.

  • Something outside this deployment is holding it. Genuinely rare, but possible — see the "held open by something a process scan misses" checks below.

To confirm which case you're in, watch the id from a throwaway privileged container that can see every namespace on the host (--pid=host, which the bridge container itself cannot do):

$ docker run --rm --privileged --pid=host --net=host --entrypoint sh \
    <bridge-image> -c 'ip link add zz type xfrm if_id 24 && ip link del zz'

If it is still refused more than a few seconds after the previous run actually exited (docker inspect -f '{{.State.Status}}' <old container> — confirm it is not still stopping), look for a namespace held open by something a process scan misses, which would keep its interfaces and their if_ids alive with nothing in ip netns list to show for it:

$ find /proc/*/fd -lname 'net:*' 2>/dev/null    # netns held open by an fd
$ grep -l nsfs /proc/*/mountinfo                # netns kept by a bind mount
$ dmesg | grep unregister_netdevice             # a device stuck unregistering

A host reboot is the reliable clear if it ever comes to that.

Separately, if supervise logged that it found XFRM state which is not this deployment's and left it alone, that is a real (and different) condition — stale SAs and policies from something else on the host, which it will not flush because the flush is unfiltered and iproute2 has no ip xfrm policy deleteall if_id N. That guard applies identically at start (reclaim_stale_xfrm) and at stop (the FlushXfrm teardown step) — overriding it is yours to do, not the supervisor's:

$ docker stop <container>
$ ip xfrm policy flush && ip xfrm state flush
$ ip link del veth-sip0; ip link del veth-sip1     # any that remain
$ docker start <container>

Run that only when you know nothing else on the host uses IPsec.

VoWiFi: "no smart card reader" / vpcd connection refused

Symptom: charon logs SCardListReaders: Cannot find a smart card reader and no USIM found with quintuplets ..., while vowifi-usim-bridge restarts forever on failed to connect to vpcd ... Connection refused.

Both are the same fault: pcscd never registered the vpcd virtual reader, so nothing listens on [vowifi].vpcd_port. Check the [pcscd] lines in docker compose logs. If they say:

Address in use
ifd-vpcd.c:130:IFDHCreateChannel() Could not initialize connection to virtual ICC

then something already holds the port when pcscd starts. This bites specifically under network_mode: host, where the container shares the host's network namespace: vsmartcard's upstream default (35963) sits inside the kernel's ephemeral port range (cat /proc/sys/net/ipv4/ip_local_port_range, typically 32768-60999), so any outbound connection on the host can randomly squat it — an intermittent failure that looks like a modem or SIM problem but is not.

The default vpcd_port (15963) is below that range and is therefore safe. If you override it, keep it below the ephemeral range too. Reserving the port instead (net.ipv4.ip_local_reserved_ports) also works, but it is a host-wide kernel setting and will not evict a connection already holding the port.

VoWiFi modem line silently drops out of resolved lines after a replug

Two symptoms, usually together, after physically unplugging and replugging a modem used as a VoWiFi line:

The pinned [[vowifi.line]] modem_port stops matching anything. A USB serial device's ttyUSB* number is assigned by enumeration order, not tied to the physical device — a replug can hand it a different number than it had before. discover: LINE_COUNT=N drops by one, and the supervise log's line N (...) list is missing the modem entry, with no error explaining why: the override just never matches a probed device. Fix: drop the modem_port pin and let full auto-discovery find the modem on whichever port it lands on this boot (omit [[vowifi.line]] for that modem entirely, or keep the block for other fields but remove modem_port) — see sample_configs/vowifi-only-cs-disabled.toml. Pinning a specific ttyUSB number is only safe on hardware that's never physically replugged.

The SIM reads as unusable even though the modem answers plain AT fine. gsm-sip-bridge discover (or the daemon's own scan) logs modem's SIM is not usable ... reason=Unreadable("13") / sim_unreadable: 13 — CME ERROR: 13 is 3GPP's "SIM failure". Confirm directly against the modem's AT port with a terminal that both sends and reads back (minicom -D /dev/ttyUSBn -b 115200, screen /dev/ttyUSBn 115200, or any single-command AT-probe tool you have — a plain echo > device only writes, it won't show the reply):

AT+CPIN?
+CME ERROR: 13

This is the SIM having electrically "fallen off the bus" — common right after a replug, before the module has fully reseated it. A radio power-cycle over AT, not a container or modem restart, usually clears it:

AT+CFUN=0    -> OK
AT+CFUN=1    -> OK, wait ~10s
AT+CPIN?     -> +CPIN: READY

Once AT+CIMI returns a valid IMSI again, restart the bridge (or supervise's container) so discover re-resolves the line — it does not re-scan on its own mid-run, unless this was the only configured VoWiFi line and it was still missing when the container started — see the next section, which covers that case without a restart.

VoWiFi: a configured line was missing at startup ("NOT RUNNING" / not_found)

specs/027-discover-retry-health closes the gap the previous section still describes for the general case: discover's single startup scan can miss a configured [[vowifi.line]] modem_port/modem_serial line simply because the USB device hadn't finished enumerating yet (a real incident: an EC20 modem present on the bus the whole time, just not yet visible to /sys/bus/usb/devices at the exact moment discover ran). This used to be completely silent — no log line, vowifi-status didn't mention it, healthcheck/docker ps reported healthy regardless. It no longer is:

  • gsm-sip-bridge vowifi-status prints a Configured line <id> (from config.toml): NOT RUNNING / reason: not_found block for it, right after the lines that did resolve.
  • The container's HEALTHCHECK (and docker ps) reports unhealthy while it's in that state.
  • If [alerts.line_discovery_failed].enabled = true, a Discord notification fires once, and a paired recovery notification fires if it later self-heals.

What actually happens automatically, and what still needs a restart:

  • If this was the only configured VoWiFi line (so discover's first pass resolved zero lines at all), the bridge retries discover every ~10s for up to ~3 minutes on its own — watch for [supervise] line discovery: ... previously-missing configured line(s) now found ... in the container logs, or just re-run vowifi-status a little later. No restart needed; this is the common case for a single-modem deployment and is exactly what fixes a slow-enumerating modem.
  • If another configured line already resolved and started (a multi-line deployment where only one line was missing), this specific line will not self-heal even after the underlying hardware becomes reachable — the shared tunnel daemon's P-CSCF plugin only reads its enabled-connections list once at its own process start, and restarting it to pick up a late line would drop every other line's already-live calls. Confirm the modem now answers plain AT (see the previous section), then restart the bridge/container to pick it up.
  • If it's still NOT RUNNING/not_found well past ~3 minutes after startup with no other line configured, the retry window has already elapsed and given up for this run (that's the Discord alert firing) — confirm the hardware, fix it, and restart.

Discord forwarding failing

Check:

  1. Webhook URL valid (test with curl)
  2. Network connectivity from bridge host
  3. Check sms table for forwarding_status = 'failed' with discord_status_code

Discord alerts/forwarding fail only inside the container (host is fine)

Symptom: curl https://discord.com/ works on the host, but nothing is ever delivered and the daemon reports DNS errors. The give-away is that the container's resolver has been replaced by carrier addresses:

docker exec <bridge-ctr> cat /etc/resolv.conf
#   nameserver 2405:200:800::1   # by strongSwan   <-- carrier's, replacing yours
docker exec <bridge-ctr> getent ahostsv4 discord.com   # exits 2, no output

Cause: strongSwan's resolve plugin writing the ePDG's assigned INTERNAL_IP4_DNS/INTERNAL_IP6_DNS into /etc/resolv.conf. It replaces the resolvers Docker put there at container start rather than adding to them, so the container is left with a single carrier-controlled nameserver and no fallback. It recurs on every IKE re-auth, so restarting only helps until the next one. Confirm from the tunnel log:

docker exec <bridge-ctr> grep "installing DNS server" /tmp/charon.log

Whether this presents as a hard outage depends on whether the host has a route to the assigned server, which is not a property you control:

  • No IPv6 default route → the assigned v6 resolver is unreachable and nothing resolves (ip route get <addr> says Network unreachable).
  • A v6 default route present (e.g. the cellular bearer came up dual-stack) → it resolves, but every lookup now leaves over that bearer instead of your LAN, and one REFUSED from the carrier takes out all alerting with no second nameserver to fall back to. The assigned v4 resolver has been observed returning REFUSED while its v6 sibling answered fine.

The same host can flip between these without any config change. Fixed in the image by docker/strongswan/resolve.conf, which points the plugin at a scratch file (/run/ims-resolv.conf) and leaves the system resolver alone. If you see this, you are running an image from before that change — rebuild/pull and recreate the container. To confirm the fix took:

docker exec <bridge-ctr> cat /etc/resolv.conf        # your resolvers, no strongSwan line
docker exec <bridge-ctr> cat /run/ims-resolv.conf    # carrier IMS DNS lands here instead

Metrics endpoint returns 5xx

Check:

  1. Port not in use: ss -tlnp | grep 9091
  2. Bridge process running: ps aux | grep gsm-sip-bridge

Store.db corrupt

  1. Stop the bridge
  2. Run: sqlite3 /var/lib/gsm-sip-bridge/store.db "PRAGMA integrity_check;"
  3. If corrupt, restore from backup
  4. Restart the bridge (it will create a fresh DB if needed)

Host-side IMS over LTE (VoLTE)

specs/015-volte-host-ims. The bridge runs its own IMS registration over an LTE IMS PDN, instead of delegating to the modem's internal IMS stack and re-bridging its decoded audio. Opt in with [volte].enabled; the volte-* subcommands work as standalone diagnostics without it.

All of them need CAP_NET_ADMIN — run them inside the container.

gsm-sip-bridge volte-pdn --action up --iface <ifname>   # attach the IMS PDN
gsm-sip-bridge volte-discover --iface <ifname>          # what does the carrier publish?
gsm-sip-bridge volte-register                           # register, then keep it alive
gsm-sip-bridge volte-status --iface <ifname>            # attachment + registration state
gsm-sip-bridge volte-pdn --action down --iface <ifname> # release, restoring the previous binding

Never enable VoWiFi and VoLTE on the same SIM

Both register the same IMPU with the same IMEI-derived +sip.instance, so per RFC 5626 the network treats one registration as a re-registration of the other and deactivates the older binding — the same failure documented above for the modem's internal IMS stack. volte-register refuses to start while a vowifi-ims-agent is running (override with --force only when deliberately testing this), and supervise refuses to start at all if both sections are enabled.

The VoWiFi priming dance: capturing a P-CSCF for VoLTE

Every Indian carrier tested so far (Jio, Vodafone) publishes no P-CSCF over any host-reachable LTE mechanism — not DHCPv6, not the router advertisement, not DNS, and (on the EC200U firmware this was tested against, EC200UCNAAR03A12M08) not even AT+CGCONTRDP's own P-CSCF fields, which the firmware truncates away before they'd ever appear. volte-discover --iface <ifname> reports every method's outcome individually rather than failing opaquely — an empty result across the board is the expected outcome on these carriers, not a fault in the probe. See the "Why nothing else works" section below before re-investigating this; it's been checked thoroughly.

Crucially, this isn't just "the discovery command finds nothing" — the live registration path (volte-register / bridge_inbound) never runs that discovery chain at all. It only ever tries two things, in order: an explicit override ([[volte.line]].pcscf or --pcscf), then the file named by [volte].pcscf_source_path. volte-discover is a diagnostic for characterizing what a new carrier offers — it is not wired into anything that actually runs.

The one thing that reliably works is letting the VoWiFi/ePDG path capture the address once, from the IKEv2 config payload, and handing that to VoLTE.

It's automatic now (specs/080-volte-pcscf-auto-prime, specs/081-multi-carrier-pcscf)

As of specs/080, supervise does this for you. When [volte].enabled = true and neither an override nor a valid cache file is present for a line, it borrows discover's modem-discovery output, brings that line's ePDG tunnel up just long enough to capture the P-CSCF from the IKE_AUTH config payload, writes it to a cache, and tears the transient tunnel back down — all before attempting VoLTE registration, in the same boot, with no operator step.

specs/081 extends this from "the first discovered line only" to every line that needs it, primed concurrently, each into its own cache file keyed by that line's card_id (not its position in the discovery order — see "With several VoWiFi lines" below): [volte].pcscf_source_path -<card_id>, e.g. /tmp/pcscf-0-ec20-ABCDEF. A mixed-carrier fleet (one Jio-carrying modem, one Vodafone-carrying modem, say) now primes both lines on its own, each ending up with its own carrier's address — no manual per-carrier step, no risk of one line silently reusing another carrier's address. Watch the logs for:

[supervise] priming: no usable P-CSCF yet for 2 line(s); capturing via a transient VoWiFi tunnel before those lines register
[supervise] priming: line ec20-ABCDEF: succeeded
[supervise] priming: line ec20-123456: succeeded

If priming fails for a line (no usable modem/SIM, or the tunnel doesn't establish), that line's failure is reported distinctly, attributed to that specific line, and retried independently on the same cadence VoLTE's own startup already uses elsewhere — a failing line never blocks or delays a line that already succeeded, and no manual intervention is needed for either.

A line that already has a usable address — an explicit override, or a capture from a previous boot still sitting at its own cache path — sees none of this: priming is skipped for that line entirely, with no added startup activity, regardless of what other lines in the same deployment need. This also means the manual dance's biggest failure mode (a container recreate wiping the capture) is no longer an operator problem: the next boot just notices which lines' caches are gone and re-primes exactly those.

This only ever runs as part of supervise's own startup sequence, never for a standalone volte-register/volte-listen/volte-call invocation — those keep the manual behavior below. It also never runs when [vowifi].enabled is persistently true: the mutual-exclusion check further down this document still applies unchanged, and priming only ever acts as a transient, internal substitute for that persistent registration, never alongside it.

The manual dance (still available, and how the automatic version works internally)

Useful for troubleshooting, for a standalone CLI diagnostic session, or on a [vowifi].tunnel_engine = "swu" deployment (priming only supports the default strongswan engine): bring VoWiFi up just long enough to capture the address, then switch to VoLTE.

  1. Prime. Set [vowifi].enabled = true with a [[vowifi.line]] for the modem, and [volte].enabled = false. Start (or restart) the container and wait for the tunnel to come up — watch the logs for:

    [supervise] line 0: tunnel UP. P-CSCF: <address>
    

    Each VoWiFi line writes the address it learned to [vowifi].pcscf_source_path with its line index appended — /tmp/pcscf-0, /tmp/pcscf-1, ... (base /tmp/pcscf). Confirm it landed:

    docker exec <bridge-ctr> cat /tmp/pcscf-0
  2. Switch. Set [vowifi].enabled = false and [volte].enabled = true (plus bridge_inbound = true for inbound calls). Restart the container in place — do not recreate it. docker compose restart (or a bare docker restart <ctr>) reuses the same container filesystem, so /tmp/pcscf-0 survives; docker compose up -d --force-recreate, down + up, or any path that replaces the container wipes /tmp and destroys the capture, forcing you to redo step 1. (This is exactly the failure mode the automatic version above no longer needs an operator for.)

  3. Verify. [volte].pcscf_source_path defaults to /tmp/pcscf-0 (VoWiFi line 0), so no further config is needed for the single-line case. Confirm over the logs (connected to P-CSCF, then a 200 OK REGISTER response) or the metrics endpoint:

    curl -s localhost:9091/metrics | grep -E 'volte_(pdn_up|registered)'

With several VoWiFi lines and [volte].bridge_inbound = true (the auto-discovered, multi-line VoLTE path), each VoLTE line resolves its own address in two tiers (specs/081-multi-carrier-pcscf): an explicit [[volte.line]].pcscf override, then that line's own card_id-keyed cache (<[volte].pcscf_source_path>-<card_id>, written automatically by the multi-carrier priming pass above). The legacy, unkeyed [volte].pcscf_source_path file is not consulted here, deliberately: in a multi-line deployment that file is never safely any one line's own address — it could be another line's leftover capture, or (on a fleet upgraded from a pre-081, single-line-only deployment) a single stale capture from back when only the first discovered line was ever primed. Falling back to it would silently reintroduce the shared-address-across- carriers bug specs/081 exists to close. A line whose per-card_id cache isn't populated yet just waits for the next priming pass, same as any other unconfigured line.

For the single-line, non-bridge_inbound path, nothing has changed: it still resolves the one line's address from [volte].pcscf_source_path directly (a genuine three-tier resolution there: override, then that one file, with no card_id involved at all, since there's only one line to ever mean). --pcscf (CLI) / [[volte.line]].pcscf (config) overrides everything above and skips the file lookup (and automatic priming) entirely, for whichever line it's set on.

Making it permanent

The captured address rarely changes for a given SIM/carrier in practice, so you can pin it directly with [[volte.line]].pcscf = "<captured address>". This survives any container recreate and also opts a deployment out of automatic priming entirely (an override always wins), at the cost of needing a manual update if the carrier ever reassigns its P-CSCF.

Why nothing else works (don't re-investigate this)

Checked directly against Jio on the EC200U, EC200UCNAAR03A12M08 firmware:

  • AT+CGCONTRDP=<cid> truncates to 7 fields — the P-CSCF-discovery fields (TS 27.007's fields 8/9) are simply absent from the response, not just empty.
  • AT+CGDCONT=?'s test-command response likewise lacks the P-CSCF-discovery/IM-CN-signalling-flag parameters.
  • No QMI on this modem at all (AT-only), so the QMI/rmnet docker/cellular-internet sidecar's WDS-based approach doesn't apply here.
  • AT+QIMSCFG? returns empty.
  • Jio's own IMS DNS server (49.45.0.1) is unreachable over the IMS PDN itself.
  • The modem's DHCP (both the IPv6 RA-based path and the IPv4 dhcp_configure path for an IPv4-only PDN) offers no SIP-server option.
  • Forcing AT+QCFG="ims",1 (network-controlled → forced-on) doesn't unlock any of the above either — the gap is the firmware's AT surface, not a disabled feature flag.

NAS PCO almost certainly carries the P-CSCF in the network's own signalling (the bearer negotiates QCI 5, IMS's usual QoS class) — the firmware just doesn't expose it over any AT command this modem implements. A firmware update exposing TS 27.007's CGCONTRDP P-CSCF fields would make the existing probe_pco in volte::pcscf work completely unchanged; nothing in the discovery code itself needs to be rewritten if that ever happens.

REGISTER Request-URI form

[volte].register_request_uri mirrors [vowifi].register_request_uri ("home-domain" default, or "pcscf") — the LTE access reaches the same P-CSCF as VoWiFi, over a different bearer, so a carrier whose P-CSCF loop-detects the address form there (Jio: 483 Too Many Hops/403 Forbidden, before any challenge) needs the same fix here. Defaults to "home-domain" — TS 24.229 §5.1.1.2's mandated form, confirmed working on Jio (required) and Vodafone (verified harmless). Set "pcscf" explicitly for a deployment that needs the old literal-address form instead.

Responses on the client leg ([volte].respond_on_client)

Mirrors [vowifi].respond_on_client. The host-side LTE path installs its own Gm IPsec SAs, same as the ePDG path, so Jio's refusal of responses sent from port_us applies here too. Without it an inbound Jio call looks answered on our side but never is on Jio's: the INVITE keeps being retransmitted, the caller's phone keeps ringing after the PBX picks up, our BYE gets 481, and no RTP ever arrives (media="send-only" carrier_rx=0). Defaults to true — confirmed required on Jio and harmless on Vodafone. Set it false for a carrier proven to need RFC 3261 §18.2.2's normal behaviour instead.

Symptom: attached but nothing works

volte-pdn --action up reports routable: NO — no default route.

The carrier unicasts its router advertisements to the link-local form of the interface identifier it assigned, not to ff02::1. If the host uses its own generated link-local, every RA is silently discarded and the PDN looks dead while the RAs are arriving the whole time. The bridge handles this (addr_gen_mode=none plus the identifier from AT+CGPADDR), so seeing this means something upstream failed — check that the interface has carrier and that AT+QNETDEVCTL? reports the IMS context bound.

Note that "attached" and "usable" are different states: the assigned address is installed by the bridge regardless, so the default route — not the presence of an address — is what proves the RA was accepted.

This whole RA dance is IPv6-specific. An IPv4-only PDN (confirmed on Jio, 2026-09-16 — the same modem gave Vodafone an IPv6-only bearer) is a different code path: the ECM/RNDIS enx* adapter AT+QNETDEVCTL binds to hands out its IPv4 address through its own embedded DHCP relay, not through a static AT+CGPADDR read, so attach() runs a one-shot udhcpc instead (netcfg::dhcp_configure). No AT+CGPADDR-derived address is installed manually in this case — DHCP owns address, netmask and route together. If udhcpc fails to get a lease, the PDN is attached-but-unrouted the same way a rejected RA leaves the IPv6 path.

Symptom: general connectivity through the modem disappears

Expected. The module exposes a single host-facing data path, so binding the IMS PDN displaces whatever it carried before. volte-pdn --action down restores the previous binding, and the container does the same on shutdown.

Metrics

Metric Meaning
gsm_sip_bridge_volte_registered 1 when the host-side LTE registration is accepted
gsm_sip_bridge_volte_pdn_up 1 when the IMS PDN is attached and routable
gsm_sip_bridge_volte_registrations_total{outcome} accepted / renewed / rejected / renewal_failed

Deliberately separate from gsm_bridge_sip_registered (the PBX side) and from the VoWiFi agent's gauges — when something is down you need to know which registration, not that one of them is.

Bridging incoming calls ([volte].bridge_inbound)

specs/017-volte-inbound-bridge. With enabled alone, the LTE registration is held open and nothing more. Adding bridge_inbound = true makes the bridge answer incoming calls on it and connect them through to the PBX:

[volte]
enabled = true
bridge_inbound = true

supervise::orchestrate_volte then supervises volte-bridge in place of volte-register. Run it by hand the same way:

gsm-sip-bridge volte-bridge --iface <ifname>

This is opt-in, and unset means unchanged. A config written before this feature keeps behaving exactly as it did, with the modem-internal path still available.

What it costs: the card becomes exclusive

A card assigned here belongs to this service alone. The circuit-switched daemon will not drive it, so while this path is down, that card takes no calls at all — there is no fallback. That makes the health signals load-bearing rather than decorative:

Watch Because
gsm_sip_bridge_volte_registered 0 means calls are being missed, not merely delayed
gsm_sip_bridge_volte_pdn_up attached-but-unrouted is a real, observed state
gsm_sip_bridge_active_calls{transport="volte"} this path's calls, distinct from vowifi and cs

Call and message records carry transport="volte", a third value on the existing label rather than a new metric — existing dashboard queries keep matching unchanged. A panel that explicitly groups by transport will gain a series.

One call at a time

The bridge fronts a single subscriber line. A second concurrent call is refused as busy rather than queued, and the refusal does not disturb the call in progress.

Maintenance yields to a call

The carrier tears the LTE attachment down roughly every two hours and the service re-attaches automatically. Both that re-attachment and registration renewal are deferred while a call is in progress — either one mid-call would take the call down with it. A call is deliberately allowed to outlive its registration rather than be cut short.

Consequence worth knowing before it surprises you: a long call can leave the registration lapsing slightly late. That is the intended trade, not a fault.

Text messages still arrive

Holding this registration means the network delivers the subscriber's texts here. Both delivery routes are handled — over the registration, and via the modem's own storage — converging on the same record-and-forward path, recorded exactly once even if both routes deliver the same message.

Messages are acknowledged after being recorded, never before, so an ill-timed crash costs a retransmission (which is de-duplicated) rather than a silently lost text.

SIP server mode

specs/024-sip-server-mode. The bridge is the SIP server: IP phones REGISTER directly to it and inbound calls (from any of the three carrier paths) ring one configured account. No PBX in the deployment. Opt in with [sip_server].enabled.

Enabling it

[sip]
# The bridge's own calling port. MUST differ from listen_port below — they are
# two separate SIP endpoints and cannot share one UDP socket. Both default to
# 5060, so this line is not optional.
local_port = 5062
transport  = "udp"
# server / username / password are NOT set: there is no PBX. Leaving them in
# is a startup error, not a warning.

[sip_server]
enabled     = true
listen_port = 5060          # what the phones point at
realm       = "gsm-sip-bridge"
ring_aor    = "1001"        # which account rings; must match an account below

[[sip_server.account]]
username = "1001"
password = "env:PHONE_1001_PASSWORD"

Every mistake in that set is refused at startup with a message naming the fix — a missing account, a ring_aor matching none of them, a leftover PBX address, or both ports left at 5060. None of them fail silently at call time.

Expected on start:

INFO sip_server: registrar listening addr=0.0.0.0:5060 realm=gsm-sip-bridge accounts=1 ring_aor=1001
INFO SIP server mode active — IP phones register here; no PBX is used

Provisioning a handset

Setting Value
SIP server / registrar the bridge's LAN IP
Port [sip_server].listen_port
Username / auth ID an [[sip_server.account]].username
Password that account's password
Transport UDP

Confirm it took:

curl -s localhost:9100/metrics | grep sip_server

sip_server_bindings 1 and sip_server_ring_aor_registered 1 is a working deployment. One registrations_total{outcome="challenged"} per accepted is normal — every REGISTER is challenged once and succeeds on the retry.

Troubleshooting

The phone registers but never rings. Check the handset's "accept SIP only from the proxy" option — Accept SIP Trust Server Only on Yealink, Accept Incoming SIP from Proxy Only on Grandstream, similar on Fanvil. Turn it off. The bridge answers REGISTER on listen_port but dials from [sip].local_port, and although those handset options compare source IP (identical here) rather than port, this is the first thing to rule out. It is the one operational cost of the two-port design; see architecture.md.

no live registration for AOR "1001" in the log. A call arrived with nothing registered under the ringing account. The carrier call is deliberately left to ring out rather than answered into silence, so the existing missed-call alert still fires. Check sip_server_ring_aor_registered — if it is 0, the handset is not registered, whatever sip_server_bindings says about others.

Registrations refused. registrations_total{outcome="rejected_auth"} means wrong credentials; rejected_unknown_user means the handset is claiming an account that is not configured. The wire response is identical for both on purpose — so the registrar cannot be used to discover which account names exist — which makes these labels the only way to tell them apart.

Calls stop ringing after some minutes. The handset's registration lapsed and was not refreshed. ring_aor_registered will read 0. Check the handset's registration interval against [sip_server].min_expires; a phone asking for less than the floor is told 423 Interval Too Brief and may then give up rather than retry with a longer value.

registrar could not listen on ...: Address already in use. Something else holds the port — most often a previous run still shutting down, or an asterisk/kamailio left on 5060.

Limitations

  • Inbound only by default. A phone cannot dial out through the mobile network unless [outbound].enabled is also set — without it, the attempt is refused with 403 and logged. See Outbound calling below.
  • One phone rings. Others may register but are never called. Registering a second device on the same account replaces the first, so calls go to whichever registered most recently.
  • UDP only, and no NAT between phone and bridge — this targets a single-site LAN.
  • No message-waiting or busy-lamp subscriptions (answered 489 Bad Event).

Outbound calling

specs/025-outbound-calling. Reverses the normal direction: instead of only answering calls the mobile network delivers, the bridge places one on request — a PBX-sent INVITE to its trunk account, or (in SIP server mode) any currently-registered phone's own INVITE, redirected back to the bridge instead of refused. Opt in with [outbound].enabled; off by default. See configuration.md for the setting and architecture.md for how a request is validated and routed to a line.

Enabling it

[outbound]
enabled = true

Nothing else to configure — the destination is the Request-URI's user part, dialed verbatim on whichever line (circuit-switched, VoWiFi, or VoLTE) is idle, no path preference and no allow-list. Restricting who may reach the bridge and what they may dial is left to the PBX's dial plan and network access controls, or, in SIP server mode, [[sip_server.account]] credentials.

Troubleshooting

403 Forbidden on every dial-out attempt. Either [outbound].enabled is still false, or (SIP server mode only) the phone placing the call is not currently registered — the registrar only redirects an INVITE from a source address it already holds a live binding for, never an unauthenticated one.

503 Service Unavailable. No idle line in the process that received the INVITE. Line selection is per-process with no cross-process fallback: if the circuit-switched daemon owns the SIP side and every EC20 is busy, the call is refused even if a VoWiFi/VoLTE line happens to be idle — see architecture.md#outbound-calling.

484 Address Incomplete. The Request-URI's user part failed the destination check (empty, or otherwise not dialable). The bridge dials it verbatim, so check what the PBX or phone actually put there.

Track outcomes via gsm_sip_bridge_outbound_attempts_total{outcome=...} — placed, refused_no_idle_line, refused_invalid_destination, refused_network_failure, unanswered.

Limitations

  • Coarse progress on the circuit-switched path. ATD's own response only confirms dialing started, not that the destination answered, so a CS-originated call is accepted (200) once dialing is confirmed rather than once truly answered.
  • The VoWiFi/VoLTE path blocks its own dispatch loop for up to ~80s while a call is in flight. During that window a caller hanging up mid-ring cannot trigger a CANCEL, and an unrelated inbound call on that same process is dropped. See docs/todo.md.

Clone this wiki locally