Repository navigation
operations
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 0Network mode preferences survive daemon restarts and are re-applied automatically whenever a card initialises (cold start or after recovery).
Connect to the SQLite store directly:
sqlite3 /var/lib/gsm-sip-bridge/store.dbUseful 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;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;SQLite WAL files grow during writes. Force a checkpoint:
PRAGMA wal_checkpoint(TRUNCATE);sqlite3 /var/lib/gsm-sip-bridge/store.db ".backup /backup/store-$(date +%Y%m%d).db"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.
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 expiredBefore 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.
Check dmesg | grep ttyUSB. Ensure the option and qcserial kernel
modules are loaded, and see supported-hardware.md.
USB Audio Class is not enabled on the module — follow the one-time UAC setup in supported-hardware.md.
ModemManager probes ttyUSB* ports, corrupting AT sessions (the bridge
warns at startup if it is active):
sudo systemctl stop ModemManager
sudo systemctl disable ModemManagerAdd the user to the dialout and audio groups:
sudo usermod -aG dialout,audio $USERCheck:
- USB device connected:
lsusb | grep 2c7c:0125 - Serial port accessible:
ls -la /dev/ttyUSB* - ModemManager not interfering:
systemctl is-active ModemManager - Permissions: user must be in
dialoutgroup
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.
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:
- Check that the IMEI in
card_slotsmatches the re-plugged modem (sqlite3 store.db "SELECT * FROM card_slots;"). - Verify no other process holds the ttyUSB port:
fuser /dev/ttyUSB* - Force a restart:
gsm-sip-bridge --config config.toml card restart --slot <N>
error: daemon not running or socket unreachable: /tmp/gsm-sip-bridge.sock
- Verify the daemon is running:
ps aux | grep gsm-sip-bridge - Check the configured socket path matches:
[control] socket_pathinconfig.toml - Check filesystem permissions on the socket directory
Check:
- PBX reachable:
nc -zuv <server> <port> - Credentials correct in config.toml
- Transport matches PBX (udp/tcp/tls)
- If TLS: check
tls_verifysetting
Verify [bridge].sip_destination is a valid, reachable extension on the
PBX (or empty for DID passthrough, with a matching PBX inbound route).
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/*).
- Ensure no other process claims the ALSA device:
fuser /dev/snd/* - Watch for
alsa_capture_overrun/alsa_playback_underrunwarnings in the logs — raise[audio] snd_rec_latency_ms/snd_play_latency_msand consider enablingrt_audio_prio(see configuration.md).
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.
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.2succeeds while TCP to10.99.0.2:7050times 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.
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.
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; rebuiltmeans it noticed the pair was gone and repaired it, and the next bind attempt should succeed.veth missing and rebuild failedrepeats 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-sip0on the host, andip netns exec ims0 ip addr show dev veth-ims0for the namespace end (use the line's own index). Both need their/30address and theUPflag. 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.
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_periodindocker-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,
supervisecan 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=1Without 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'sims0" —ip netns pidscannot see another container's processes. Reclaiming while a sibling instance is live would delete that instance's working tunnel out from under it. A staledocker startof 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-Nfor a VoWiFi line, the carrier-side veth for a VoLTE one. An unrelated workload'sims0therefore is never deleted, opt-in or not; you'll seenetns ims0 exists but does not contain tun23-0, so it is not this deployment's and was left untouchedinstead.Reclamation also needs the per-line namespace directory visible from the host, which is what
docker-compose.yml's/var/run/netnsbind mount (rsharedpropagation) is for. If that mount is missing or does not propagate, reclamation finds nothing — confirm withip netns liston 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.
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.
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.
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-statusprints aConfigured line <id> (from config.toml): NOT RUNNING/reason: not_foundblock for it, right after the lines that did resolve. - The container's
HEALTHCHECK(anddocker 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 retriesdiscoverevery ~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-runvowifi-statusa 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_foundwell 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.
Check:
- Webhook URL valid (test with curl)
- Network connectivity from bridge host
- Check
smstable forforwarding_status = 'failed'withdiscord_status_code
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 outputCause: 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.logWhether 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>saysNetwork 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
REFUSEDfrom the carrier takes out all alerting with no second nameserver to fall back to. The assigned v4 resolver has been observed returningREFUSEDwhile 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 insteadCheck:
- Port not in use:
ss -tlnp | grep 9091 - Bridge process running:
ps aux | grep gsm-sip-bridge
- Stop the bridge
- Run:
sqlite3 /var/lib/gsm-sip-bridge/store.db "PRAGMA integrity_check;" - If corrupt, restore from backup
- Restart the bridge (it will create a fresh DB if needed)
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 bindingBoth 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.
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.
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.
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.
-
Prime. Set
[vowifi].enabled = truewith 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_pathwith its line index appended —/tmp/pcscf-0,/tmp/pcscf-1, ... (base/tmp/pcscf). Confirm it landed:docker exec <bridge-ctr> cat /tmp/pcscf-0
-
Switch. Set
[vowifi].enabled = falseand[volte].enabled = true(plusbridge_inbound = truefor inbound calls). Restart the container in place — do not recreate it.docker compose restart(or a baredocker restart <ctr>) reuses the same container filesystem, so/tmp/pcscf-0survives;docker compose up -d --force-recreate,down+up, or any path that replaces the container wipes/tmpand 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.) -
Verify.
[volte].pcscf_source_pathdefaults 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 a200 OKREGISTER 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.
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.
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-internetsidecar'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_configurepath 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.
[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.
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.
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.
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.
| 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.
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 = truesupervise::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.
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.
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.
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.
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.
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.
[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
| 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_serversip_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.
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.
-
Inbound only by default. A phone cannot dial out through the mobile
network unless
[outbound].enabledis also set — without it, the attempt is refused with403and 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).
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.
[outbound]
enabled = trueNothing 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.
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.
-
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.