Repository navigation
configuration
An unrecognised key fails startup. Every key below is checked against what the parser accepts; a typo is reported by name rather than silently ignored. Upgrading from a version that only warned? See migrating-config-to-strict-parsing.md.
The bridge reads a single TOML configuration file specified via --config.
| Key | Type | Default | Description |
|---|---|---|---|
level |
enum | info |
trace, debug, info, warn, or error. Applies to the daemon and both vowifi-*-agent subcommands. Overridden by the RUST_LOG env var if set, or by -v/--verbose on the CLI (always forces trace). |
| Key | Type | Default | Description |
|---|---|---|---|
server |
string | (required) | PBX hostname or IP |
port |
integer | 5060 | SIP port |
username |
string | (required) | SIP account |
password |
string | (required) | Supports env:VAR syntax |
transport |
enum | udp |
udp, tcp, or tls
|
local_port |
integer | 5060 | Fixed local port |
display_name |
string | username | Callee display |
tls_verify |
enum | strict |
strict or skip
|
public_addr |
string | (unset) | IPv4 literal or hostname to advertise in SIP signaling (Contact/Via) and SDP, instead of letting PJSIP's own address discovery pick one — needed when the PBX/phones reach this bridge only over a routed network (e.g. Tailscale) under network_mode: host, where the discovered address is otherwise a private, unroutable one and calls connect with no audio in either direction. A hostname is resolved once, at startup, to its first IPv4 result; an IPv6 value or an unresolvable/IPv6-only hostname fails startup rather than starting with broken audio (this bridge's SIP transport is IPv4-only). |
codecs |
array of strings | (unset) | VoWiFi/VoLTE only: the codecs the bridge may use toward the PBX or phone, most preferred first; the circuit-switched path ignores it. Accepted names (case-insensitive): G722, PCMU, PCMA, AMR-WB, AMR-NB, OPUS. PCMU is required, because the bridge's internal link between its two agents falls back to it. Codecs not listed are neither offered nor accepted. Unset keeps the existing order (G.722, then PCMU) with AMR-WB, AMR-NB and Opus at the lowest priority, so a phone or PBX that prefers them can still negotiate them. An unknown, empty or duplicate entry, or a list without PCMU, fails startup; so does naming a codec this build lacks. Audio is still decoded and re-encoded; this saves bandwidth toward the phone, it is not passthrough. |
amr_octet_align |
bool | true |
VoWiFi/VoLTE only: how AMR (AMR-NB and AMR-WB) is packed when this bridge makes the offer to the phone or PBX, which is inbound calls. true offers octet-aligned (octet-align=1), which most softphones (baresip, Linphone, many desk phones) require and refuse otherwise; false offers bandwidth-efficient, a few bytes smaller per packet. A phone that makes the offer (outbound calls) is followed whichever you choose. Only matters when AMR is offered; see codecs. |
| Key | Type | Default | Description |
|---|---|---|---|
sip_destination |
string | "" |
Empty = DID passthrough |
sip_dial_timeout_sec |
integer | 30 | Range: 5-120 |
| Key | Type | Default | Description |
|---|---|---|---|
enabled |
boolean | true | Disable SMS monitoring |
discord_webhook_url |
string | "" |
Supports env:VAR syntax |
db_path |
string | /var/lib/gsm-sip-bridge/store.db |
Store path |
Discord alerts for critical operational events, generalizing the SMS
forwarding above into five categories. [sms] continues to work unchanged
even if this whole section is omitted — [alerts.sms] is seeded from
[sms] above when absent. Each category alerts only once its own built-in
recovery is exhausted, then sends a single "recovered" notice when it
clears — never a repeating stream while a condition stays unhealthy.
| Key | Type | Default | Description |
|---|---|---|---|
discord_webhook_url |
string | "" |
Shared default webhook for any category below with no override of its own. Supports env:VAR syntax |
instance_name |
string | (host hostname) | Name identifying this deployment, shown in every alert footer as gsm-sip-bridge · <instance_name>. Falls back to the system hostname when unset — set it explicitly under Docker, where the hostname is a random container id |
| Sub-table | Key | Type | Default | Description |
|---|---|---|---|---|
[alerts.sms] |
enabled |
boolean | true | Matches [sms].enabled unless overridden |
discord_webhook_url |
string | (none) | Optional per-category override | |
[alerts.module_lifecycle] |
enabled |
boolean | false | SIM absent/unreadable, discovery/init failure, or an AT command worker unresponsive |
at_worker_unresponsive_sec |
integer | 60 | Range: 5-600 | |
[alerts.registration_loss] |
enabled |
boolean | false | A VoLTE/VoWiFi line's SIP registration lost continuously past unhealthy_sec
|
unhealthy_sec |
integer | 300 | Range: 30-3600 | |
[alerts.tunnel_failure] |
enabled |
boolean | false | A VoWiFi line's ePDG/IPsec tunnel non-established, or its IPsec CHILD_SA missing, continuously past unhealthy_sec
|
unhealthy_sec |
integer | 300 | Range: 30-3600 | |
[alerts.missed_call] |
enabled |
boolean | false | An inbound call that rang out unanswered (never bridged) |
[alerts.line_discovery_failed] |
enabled |
boolean | false | An explicitly configured VoWiFi line (modem_port/modem_serial) never resolved even after retrying discovery for it |
[alerts.gm_connection_lost] |
enabled |
boolean | true | A registered line's Gm signaling connection down and unrecoverable continuously past unhealthy_sec, despite automatic reconnects and a re-registration. Defaults enabled (an incident, not an opt-in) |
unhealthy_sec |
integer | 300 | Range: 30-3600 |
Every sub-table also accepts its own discord_webhook_url override; a
category without one falls back to [alerts].discord_webhook_url. See
config.toml.example for a fully-commented sample.
| Key | Type | Default | Description |
|---|---|---|---|
port |
integer | 9091 | Metrics HTTP server port |
agent_report_interval_seconds |
integer | 10 | How often each VoWiFi agent re-reports its call/SMS/health state over the control socket. Also sets the staleness threshold (3x this) after which an agent that stopped reporting is marked down. Ignored when [vowifi].enabled is false. |
The opt-out switch for the circuit-switched call path (specs/026-disable-
circuit-switched). Circuit-switched tuning itself lives in [modules] below
— this section exists solely to hold the on/off flag.
Turning it off stops all circuit-switched modem discovery, the periodic rescan, AT traffic, and circuit-switched call handling. The metrics endpoint, control socket, and message store keep running unchanged, and VoWiFi/VoLTE are unaffected.
Two consequences are not predictable from the flag's name alone:
-
No telephone-facing registration of the circuit-switched host's own.
With the path off there is nothing behind a trunk registration, so this
process establishes none, and starts no registrar of its own either. If
this deployment previously registered a trunk with a PBX, that
registration stops — the PBX will mark it down. The startup log names
[cs].enabledas the reason. This has no effect when VoWiFi or VoLTE is enabled: that subsystem already owns the telephone-facing side and hosts its own registration independently. -
Voice-capable modems become available to VoWiFi. While the flag is
on, a voice-capable modem with no explicit
[[vowifi.line]]override is reserved for circuit-switched use. With the flag off nothing is reserved, so VoWiFi may pick it up instead, subject to the usual SIM-readiness check andmax_linesbound.
If neither VoWiFi nor VoLTE is enabled either, the process still starts — serving metrics and stored history only — with a startup warning that no call path is active.
Recommended false on any deployment where every call is carried over
VoWiFi or VoLTE, to stop the circuit-switched daemon probing modems in the
background for a path that will never carry a call.
Metrics: the endpoint always exports gsm_sip_bridge_cs_enabled (1
enabled, 0 disabled), so a scrape can tell "deliberately disabled" apart
from "process down or scrape broken". With the flag off,
gsm_sip_bridge_modules_active, gsm_sip_bridge_modules_failed,
gsm_sip_bridge_module_init_total, gsm_sip_bridge_module_retries_total,
and gsm_sip_bridge_scheduled_restart_total are absent from the scrape
entirely — not reported as zero — so an existing "no cards ready" style
alert rule goes quiet instead of firing continuously against a path that
was switched off on purpose. Review any dashboard panel or alert rule built
on those five series before flipping the flag; every other series
(VoWiFi/VoLTE, SMS, store, build info) is unaffected.
| Key | Type | Default | Description |
|---|---|---|---|
enabled |
boolean | true |
Master switch for the circuit-switched call path. [modules] below tunes it when on. |
Circuit-switched card-pool tuning, gated by [cs].enabled above — these
keys stay valid and simply have no effect while [cs].enabled = false.
| Key | Type | Default | Description |
|---|---|---|---|
retry_interval_sec |
integer | 30 | Range: 5-600 |
max_concurrent |
integer | 8 | Range: 1-8 |
Latency and audio-quality tuning shared by every call path — circuit-switched
(EC20 USB audio) AND VoWiFi/VoLTE IMS calls all run through the same jitter-buffer
pipeline. All keys are optional. The section is read at startup; changes require a
process restart. See docs/audio-tuning-log.md for the empirical history behind
the defaults.
| Key | Type | Default | Description |
|---|---|---|---|
profile |
enum | lan |
Latency preset: lan or wan (see below) |
vad |
boolean | true |
PJMEDIA voice activity detection / noise suppression on the capture path. Set false only to diagnose audio issues (raw passthrough). |
snd_rec_latency_ms |
integer | 150 |
Capture ring-buffer depth (caller → SIP), 20–2000 ms. Raise if logs report alsa_capture_overrun. |
snd_play_latency_ms |
integer | 150 |
Playback ring-buffer depth (SIP → caller), 20–2000 ms. Raise if logs report alsa_playback_underrun. |
EC20 USB sound-device tuning for the circuit-switched path only — VoWiFi/VoLTE never touch this modem's ALSA device (VoWiFi/VoLTE hard-codes its own gain and never applies these knobs at all). All keys are optional.
| Key | Type | Default | Description |
|---|---|---|---|
rx_gain |
integer | unset (firmware default) | EC20 downlink digital gain (AT+QRXGAIN, 0–65535), applied at module init. Controls how loud SIP audio sounds to the GSM caller. |
eec_mode |
integer | unset (firmware default, 12543) | EC20 echo-canceller mode word (AT+QEEC=2,<val>). 0 disables all EC — recommended for USB audio bridges, which have no acoustic echo path. |
tx_level |
float | 1.0 |
PJSUA conference-bridge software gain on the GSM → SIP path (1.0 = unity, 0.5 ≈ −6 dB, 2.0 = +6 dB). |
rt_audio_prio |
integer |
0 (off) |
SCHED_FIFO priority (1–99) for PJMEDIA's sound-device threads; prevents XRUNs/choppy audio under load. Requires CAP_SYS_NICE; best-effort. |
The profile preset tunes two independent latency contributors:
-
Audio ring buffer (
ring_capacity) — the queue between the ALSA capture thread and the PJSIP media thread. Oversizing this lets stale audio queue up invisibly, adding hundreds of milliseconds of delay. -
PJSIP jitter buffer (
jb_init_ms,jb_min_pre,jb_max_ms) — PJMEDIA's adaptive jitter buffer. Without a hard ceiling it ratchets upward on any CPU spike and never recovers.
| Setting |
lan (default) |
wan |
|---|---|---|
ring_capacity |
4 frames (80 ms slack) | 16 frames (320 ms slack) |
jb_init_ms |
20 ms | 60 ms |
jb_min_pre |
1 frame | 2 frames |
jb_max_ms |
40 ms (hard cap) | 200 ms (hard cap) |
Use lan when the SIP server is on the same machine or local network.
There is no packet jitter on this path, so the smallest ring and tightest
jitter buffer caps give the best end-to-end latency. Expected one-way delay
through the bridge: ~120–150 ms (dominated by the GSM air interface, which
is fixed at ~80–110 ms).
Use wan when pointing sip.server at an internet SIP trunk. The
wider ring and larger jitter buffer absorb burst packet loss and higher RTT
without causing audible glitches. Expected one-way delay: ~180–340 ms
depending on trunk latency.
Preventive nightly card-restart cycle: the daemon walks every known slot in
ascending order and restarts each modem with a randomized gap between
cards. Cards on an active call are deferred to the end of the cycle; a
manual card restart issued during a cycle takes precedence (and always
does a full reset, regardless of restart_mode). See
specs/010-scheduled-card-restart/quickstart.md for full details.
| Key | Type | Default | Description |
|---|---|---|---|
enabled |
boolean | true |
Master switch |
cron |
string | 0 1 * * * |
Standard 5-field cron expression in system local time. Invalid expressions disable the scheduler without aborting the daemon. |
start_jitter_seconds |
integer | 600 |
Symmetric jitter on the cycle start time. Range 0–86400; 0 disables. |
inter_card_gap_seconds |
integer | 30 |
Base wait between consecutive per-card restarts. Range 0–3600. |
inter_card_gap_jitter_seconds |
integer | 15 |
Symmetric jitter on the inter-card gap; must be ≤ inter_card_gap_seconds. |
restart_mode |
string | full |
full sends AT+CFUN=1,1 — a complete module reset that can move the card's ttyUSB path (the original, still-default behavior). radio sends AT+CFUN=0 -> AT+CFUN=1 instead — a soft cycle that drops and re-acquires network registration without power-cycling the module or re-enumerating USB. Any other value disables the scheduler for this run (lenient policy, like the other keys in this section). |
Controls automatic card recovery behavior. All keys are optional; defaults cover typical homelab use.
| Key | Type | Default | Description |
|---|---|---|---|
initial_backoff_sec |
integer | 5 | Delay before the first recovery retry (seconds). Range: 1-600 |
max_backoff_sec |
integer | 120 | Maximum backoff delay after repeated failures (seconds). Range: 1-3600 |
max_retries |
integer | 10 | Give-up threshold: stop retrying a slot after this many consecutive failures. Range: 1-1000 |
network_loss_timeout_sec |
integer | 60 | Seconds of failed network registration before recovery is triggered. Range: 10-600 |
network_poll_interval_sec |
integer | 30 | How often to poll the modem for network registration status (seconds). Range: 5-300 |
Configures the Unix domain socket used by card CLI subcommands to communicate with the running daemon.
| Key | Type | Default | Description |
|---|---|---|---|
socket_path |
string | /tmp/gsm-sip-bridge.sock |
Filesystem path for the control socket. Must be writable by the bridge process and readable by CLI users. |
The inbound VoWiFi-to-SIP bridge — a second, independent inbound call path
alongside [sip]/[bridge]. Read by the vowifi-*-agent/discover/supervise/healthcheck
subcommands, never by the normal daemon path. All ePDG-tunnel
configuration lives here — none of it is read from environment variables;
.env holds secrets only (specs/012-strongswan-epdg config consolidation).
Multi-line (specs/013-multi-card-vowifi): VoWiFi auto-discovers every
attached VoWiFi-capable modem by default. Each discovered SIM becomes its
own line: its own tunnel, IMS registration, network namespace, and
inbound call path, running concurrently, up to max_lines. Every per-line
resource (netns, XFRM if_id/interface name, veth interface names/
addresses) is derived from each line's position in the discovered line
table — there is no config knob for any of it. Line-specific settings
(which modem, its home network identity) live only in [[vowifi.line]]
below; everything else in this section is a genuinely global/shared value.
Run gsm-sip-bridge --config config.toml discover --shell-env to see what
gets discovered/resolved.
| Key | Type | Default | Description |
|---|---|---|---|
enabled |
boolean | false |
Master switch |
use_tcp |
boolean | true |
SIP transport to the P-CSCF |
sec_agree |
boolean | true |
Advertise Require: sec-agree / negotiate Gm IPsec |
register_request_uri |
enum | home-domain |
What goes in the REGISTER request line: home-domain (the ims.mnc<MNC>.mcc<MCC>.3gppnetwork.org realm that TS 24.229 §5.1.1.2 mandates, and what every real handset sends) or pcscf (the literal P-CSCF address, the historical default). Jio requires home-domain — its P-CSCF finds its own address in the request line, routes the REGISTER to itself and answers 483 Too Many Hops (or a blanket 403 Forbidden) before any challenge, which reads exactly like an unprovisioned subscriber; measured across four P-CSCFs and two SIMs. Vodafone was verified on the realm form too. Airtel is recorded as answering 406 User Unknown to the realm form — that predates the default change and was never reproduced, so if an Airtel deployment regresses, set pcscf and capture the exchange |
gm_auth_alg |
enum | (empty) | Pin the Gm IPsec integrity algorithm (hmac-sha-1-96 / hmac-md5-96) instead of taking the P-CSCF's highest-q offer. Empty follows the network's stated preference |
gm_cipher_alg |
enum | (empty) | Pin the Gm IPsec cipher (aes-cbc / null). Needed when a P-CSCF advertises a preference it does not actually use: our ESP goes out fine, its replies fail integrity, and the protected connection just times out — XfrmInStateProtoError in /proc/net/xfrm_stat is the only direct symptom |
respond_on_client |
boolean | false |
Answer network-initiated requests (INVITE, BYE, NOTIFY, MESSAGE) over the Gm client leg — port_uc → port_ps, the connection we registered over — instead of the socket they arrived on. Jio needs this: it ignores every response sent from port_us, verified on the wire to both of its protected ports with the correct SPI, monotonic ESP sequence and well-formed SIP, while validating our ESP on the client SA continuously. The symptom is call-independent — Jio just retransmits its INVITE every 2 s, so not even our 100 Trying lands. Leave it off elsewhere: it contradicts RFC 3261 §18.2.2, and Airtel and Vi answer correctly on the arrival socket |
sms_delivery_report |
boolean | true |
Acknowledge an SMS delivered over IMS at the 3GPP layer as well as the SIP one — TS 24.341 §5.3.2.4's delivery report, an RP-ACK sent back to the IP-SM-GW in a MESSAGE request of our own. §5.3.2.3 asks for both answers (the 200 OK says the request arrived, the report says the text was taken) and every real handset sends both. [volte] has its own settable copy of this switch below, which inherits this value when not itself set — so a config that already relies on this switch governing VoLTE keeps doing so on upgrade. Jio requires it — measured by A/B on a live line: with the report off a submitted SMS is never delivered (the message centre sends only Type 0 reachability probes and holds the text, and [cs].enabled does not rescue it), and turning it on released a message stranded 12 minutes earlier within 40 s. Both stranded messages did eventually arrive once acknowledgement resumed (the second ~42 min after it was sent, on the message centre's own retry schedule), so the failure mode is indefinite delay rather than immediate loss — how long the centre holds them before giving up was not measured. Vi delivers real SMS having never received a report, so this is not a precondition everywhere. Turn it off only to isolate a carrier that dislikes the report, and capture the exchange if you do — no such carrier is known |
respect_caller_privacy |
boolean | true |
Whether an inbound Privacy: id/user withholds the caller's CNAP name (from From/P-Asserted-Identity's display name) from the PBX/SIP-server leg — RFC 3325 §9.1's withholding obligation for onward signaling. The caller's number itself is unaffected either way. [volte] has its own settable copy of this switch below, which inherits this value when not itself set. Turn off only for a deployment where the onward leg is already fully trusted (e.g. an internal PBX no caller-facing party ever sees) and always showing the real name is preferred over honouring a carrier's withholding request |
originating_headers |
array of strings | [] |
Optional originating (INVITE) header groups, by name. Empty is the minimal header set every carrier in production here places calls on — except P-Access-Network-Info, unconditionally IEEE-802.11 (live-verified 2026-09-15: fixes Jio's outbound (MO) 480/MSML intercept; a no-op on Vodafone vs. the previous pinned 3GPP-WLAN — see docs/plans/jio-vowifi-outbound-480.md). "icsi" adds Accept-Contact, P-Preferred-Service and a Contact feature tag naming the MMTel voice ICSI (TS 24.173 §5.2) — for a network whose S-CSCF keys its initial filter criteria on the service identifier; "preferred-identity" adds P-Preferred-Identity carrying the same identity as From (TS 24.229 §5.1.2A.1); "supported" adds Supported: 100rel, timer, which is not free — it invites the network to Require them, and session-timer refresh (RFC 4028 §7.4) is not implemented; "allow" sends the full MTSI method list instead of the five-method minimum. An unrecognised entry fails startup rather than silently doing nothing. These exist so a carrier that intercepts a minimal INVITE can be tested against a TS 24.229-shaped one without a rebuild — see docs/plans/jio-vowifi-outbound-480.md. Leave empty unless a capture says otherwise |
pcscf_source_path |
string | /tmp/pcscf |
Base path Agent A reads its tunnel-assigned P-CSCF from; the line index is appended, so this becomes /tmp/pcscf-0, /tmp/pcscf-1, ... Each line's carrier assigns its own P-CSCF, so one shared file would have the lines overwriting each other. Point [volte].pcscf_source_path at a specific line's file to reuse that address over LTE |
control_port |
integer | 7050 | Agent A↔B control channel TCP port — shared across every line; lines are told apart by their (internally derived) veth address, not this port |
wideband |
boolean | true |
Carry AMR-WB/G.722 end-to-end instead of narrowing to 8 kHz |
watchdog_recovery_enabled |
boolean | true |
Restart a line whose work has demonstrably stalled. Turn off only to preserve a wedged line for diagnosis — the stall is still detected and reported either way, but with this off nothing recovers the line |
apn |
string | ims |
APN used by the swu engine's dialer — shared across every line |
epdg_fqdn |
string | unset (derived per line from that line's mcc/mnc) |
Override the derived 3GPP ePDG FQDN — shared across every line if set |
epdg_ip |
string | unset (resolve epdg_fqdn) |
Skip DNS and dial this ePDG IP directly — shared across every line if set |
src_addr |
string | unset (auto-select) | Force the tunnel's local source address — shared across every line if set |
keepalive_interval_sec |
integer | 20 | Idle-tunnel TCP keepalive interval |
tunnel_engine |
enum | strongswan |
strongswan or swu (specs/012-strongswan-epdg). swu requires the full image (ghcr.io/selvakn/gsm-sip-bridge:<version>-swu): the default slim image ships without the SWu/Python engine and fails fast at startup if this is set to swu (specs/033-slim-optional-swu-image) |
vpcd_host |
string | 127.0.0.1 |
pcscd's vpcd virtual reader host (strongswan engine) |
vpcd_port |
integer | 15963 | Base TCP port pcscd's shared vpcd reader listens on — one reader serves every line, at base + line-index. Unlike the other per-line fields, this is a genuine config key: keep the base below the kernel's ephemeral range (net.ipv4.ip_local_port_range, 32768-60999 by default) — see operations.md
|
max_lines |
integer | 8 | Upper bound on concurrently supported VoWiFi lines (specs/013-multi-card-vowifi FR-016); modems discovered beyond this count are reported and skipped |
Optional array of tables — omit entirely for full auto-discovery (every SIM-ready modem becomes its own line, network identity auto-derived from the SIM). Add one entry per line to pin it to a specific modem and/or fix its network identity. Every field is optional except the matcher.
| Key | Type | Default | Description |
|---|---|---|---|
modem_serial |
string | none | Match a modem by its USB hardware serial (preferred — survives device-path changes) |
modem_port |
string | none | Match (or pin) a modem by its AT serial device path directly |
mcc |
string | unset (auto-derive) | This line's home network MCC. Must be set together with mnc
|
mnc |
string | unset (auto-derive) | This line's home network MNC, zero-padded to 3 digits |
imsi_override |
string | unset (read via AT+CIMI) | Diagnostic escape hatch: use this IMSI instead of reading it from the SIM |
imei_override |
string | unset (read via AT+CGSN, or auto-generated for a pcsc_reader line) |
Diagnostic escape hatch: use this IMEI instead of reading it from the modem |
msisdn |
string | none | This line's phone number, shown in its alert notifications (specs/034-alert-identity) |
pcsc_reader |
bool | false |
This line's SIM comes from a physical PC/SC reader (e.g. OmniKey AG 3x21) instead of a modem (specs/023-omnikey-pcsc-vowifi) — see docs/omnikey-pcsc-vowifi.md. imsi_override becomes mandatory — not because the IMSI is unreadable (it is read from EF_IMSI over PC/SC) but because it names which reader's card this line owns, which must be known before any card session exists; run gsm-sip-bridge pcsc-list to see every attached reader's card (IMSI, MCC/MNC, carrier) before writing this in. mcc/mnc stay optional and auto-derive from the card's own EF_IMSI/EF_AD via vowifi-plmn --pcsc-imsi; pin them only for a card whose EF_AD omits the MNC-length byte, which fails at startup saying so. imei_override stays optional — left unset, a stable, Luhn-valid IMEI is auto-generated from the line's IMSI (an IMEI is a device identity, genuinely not on the card). Requires [vowifi].tunnel_engine = "strongswan" (the default); the swu engine has no PC/SC support and refuses to start with this set |
SMS delivery (specs/038-reliable-sms-delivery): a VoWiFi registration
advertises voice capability, not messaging — the carrier can and does deliver
some texts into the line's own modem storage instead of as an IMS MESSAGE.
For any line with a real modem (pcsc_reader = false), that storage is now
swept every ~20s regardless of [cs].enabled, so nothing sent through the
classic cellular bearer is left unread. A pcsc_reader line has no modem to
sweep — its SIM sits in the reader with no cellular attach at all, so its SMS
delivery is over the IMS registration only.
Host-side IMS over LTE (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. Do not enable this alongside [vowifi] on the same SIM — both
register the same IMPU with the same IMEI-derived +sip.instance, so the network
tears one binding down (volte-register refuses to start while a VoWiFi agent is
running unless --force is given).
Like [vowifi], auto-discovers every SIM-ready modem as its own line by default
(specs/018-volte-multi-modem), each with sane defaults — cid 3, apn "ims",
P-CSCF auto-discovered. Line-specific settings live only in [[volte.line]].
With bridge_inbound, each line also runs in its own network namespace/veth
pair (specs/020-volte-line-netns) — the same isolation [vowifi] lines get,
and just as with [vowifi]'s equivalent fields, this is pure internal
infrastructure with no config knob at all.
| Key | Type | Default | Description |
|---|---|---|---|
enabled |
boolean | false |
Master switch. Off by default; the volte-* subcommands work without it as diagnostics |
bridge_inbound |
boolean | false |
Answer incoming calls over this registration and bridge them to the PBX (specs/017-volte-inbound-bridge), instead of only holding the registration open. Turning this on makes every bridged card EXCLUSIVE to this service — the circuit-switched daemon will not drive it |
max_lines |
integer | 8 | Upper bound on auto-discovered LTE lines (specs/018-volte-multi-modem). Only meaningful with bridge_inbound — a single PBX registration then serves every line, exactly as the VoWiFi path does |
pcscf_source_path |
string | /tmp/pcscf-0 |
Which VoWiFi line's captured P-CSCF to borrow over LTE. Names one specific line — [vowifi].pcscf_source_path is a base with the line index appended, and each line's address comes from its own carrier, so there is no single "the" address to pick up. Change the suffix to use a different line |
status_path |
string | /tmp/volte-registration-status |
Where volte-register publishes registration state for volte-status
|
lock_path |
string | /tmp/volte-registration.lock |
Lock file preventing two concurrent VoLTE registrations on one SIM |
register_request_uri |
enum | home-domain |
Same meaning and accepted values as [vowifi]'s field above — the LTE access reaches the same P-CSCF as VoWiFi, over a different bearer, so the same Jio loop-detection rejection applies here too. Confirmed working on Jio (required) and Vodafone (verified harmless). Set pcscf for a deployment that needs the old literal-address form |
respond_on_client |
boolean | true |
Same meaning as [vowifi]'s field above — this path installs its own Gm IPsec SAs just like the ePDG one, so Jio's refusal of responses sent from port_us applies here too. Unlike [vowifi], defaults on: confirmed required on Jio and harmless on Vodafone. Set false for a carrier proven to need RFC 3261 §18.2.2's normal behaviour instead |
sms_delivery_report |
boolean | inherits [vowifi].sms_delivery_report
|
Same meaning as [vowifi]'s field above — SMS-over-IP's RP-layer acknowledgement is the same TS 24.341 procedure regardless of access. Settable independently, but when this key is absent it inherits [vowifi]'s value rather than defaulting to true on its own — a config that set [vowifi].sms_delivery_report before [volte] had this key already relied on it governing VoLTE too (both paths share one dispatcher), so an upgrade must not silently flip VoLTE's behaviour back. A VoLTE-only deployment with no [vowifi] section gets true, [vowifi]'s own default |
respect_caller_privacy |
boolean | inherits [vowifi].respect_caller_privacy
|
Same meaning as [vowifi]'s field above — the RFC 3325 Privacy header is a SIP/IMS-layer signal, not an access one. Inherits [vowifi]'s value when absent, settable independently, for the same upgrade-compatibility reason as sms_delivery_report above |
Optional array of tables — omit entirely for full auto-discovery. Add one entry per line to pin it to a specific modem and/or override its PDN/P-CSCF settings. Every field is optional except the matcher.
| Key | Type | Default | Description |
|---|---|---|---|
modem_serial |
string | none | Match a modem by its USB hardware serial (preferred — survives device-path changes) |
modem_port |
string | none | Match (or pin) a modem by its AT serial device path directly |
cid |
integer | 3 | This line's PDP context id. Must not collide with the contexts the modem uses for general internet access |
apn |
string | ims |
This line's APN |
pcscf |
string | unset (auto-discover) | This line's explicit P-CSCF address. Unset falls back to pcscf_source_path, then on-modem discovery — which does not work on every carrier |
iface |
string | unset (auto-detect) | Host data interface bound to this line's IMS PDN. Unset auto-detects from the modem's own USB device |
msisdn |
string | none | This line's own MSISDN, advertised in the P-Preferred-Identity |
The bridge acts as the SIP server — IP phones REGISTER directly to it and inbound calls ring a registered phone — instead of registering to an external PBX (specs/024-sip-server-mode). For small deployments with no telephone system to point at. Off by default.
Inbound only by default: a phone cannot dial out through the mobile network,
and such an attempt is refused with 403, unless [outbound] below is also
enabled. Exactly one account rings inbound calls; others may register but are
never called for those.
Enabling this changes what [sip] means. server, username and password
describe a PBX that does not exist in this mode, so they become errors
rather than requirements — as does [bridge].sip_destination, since the
destination is ring_aor. [sip].transport must be udp.
listen_port and [sip].local_port are two separate SIP endpoints and cannot
share one UDP port. Both default to 5060, so enabling this mode without moving
[sip].local_port is a startup error naming the fix — leave listen_port at
5060 for the phones and set [sip].local_port = 5062.
listen_port also cannot be one the VoWiFi or VoLTE telephony side already
holds — 5072 with [vowifi].enabled, or 5073 and the per-line loopback span
5074..5074+4×[volte].max_lines with [volte].enabled + bridge_inbound.
Those are fixed internal constants an operator cannot move, so the error says to
move listen_port instead. They are only reserved when the subsystem that owns
them is actually running, so a deployment with neither may use them freely.
Enabling [vowifi] and VoLTE inbound bridging together with this mode is
refused: both telephony sides would host a registrar on one port and only one
could bind it, leaving the other's calls nowhere to go.
| Key | Type | Default | Description |
|---|---|---|---|
enabled |
boolean | false |
Master switch. Off by default |
listen_addr |
string | 0.0.0.0 |
Address the registrar binds. Must be an IP address, not a hostname |
listen_port |
integer | 5060 | Port IP phones register to. Must differ from [sip].local_port
|
realm |
string | gsm-sip-bridge |
Digest authentication realm, shown in the handset's credential prompt |
ring_aor |
string | none (required) | Which account inbound calls ring. Must match one [[sip_server.account]] username, or startup fails — otherwise the mode would start cleanly and silently never ring |
min_expires |
integer | 60 | Shortest registration lifetime granted, in seconds. A phone asking for less gets 423 Interval Too Brief
|
max_expires |
integer | 3600 | Longest registration lifetime granted, in seconds. A longer request is clamped, and the granted value reported back |
nonce_lifetime_sec |
integer | 120 | How long an authentication challenge stays valid. After this a phone is asked to retry, silently, without prompting a human |
Array of tables — one per IP phone the registrar will accept. At least one entry is required when the mode is enabled. Usernames must be unique.
| Key | Type | Default | Description |
|---|---|---|---|
username |
string | none (required) | The account name the phone authenticates as |
password |
string | none (required) | The account's password. Supports env:VAR indirection and is redacted from logs |
Lets a call originating on the SIP side — a PBX-sent INVITE, or a phone
registered in [sip_server] mode — be placed out over the mobile network on
whichever configured SIM is idle (specs/025-outbound-calling). Off by
default. Reverses [sip_server]'s inbound-only restriction: with this
enabled, any currently registered phone may also dial out, not only
ring_aor.
The destination number is dialed exactly as given, on any idle line
(circuit-switched, VoWiFi, or VoLTE), with no preference between them and no
allow-list — restricting who may reach the bridge and what they may dial is
left entirely to the PBX's dial plan, network access controls, and
[sip_server] account credentials.
| Key | Type | Default | Description |
|---|---|---|---|
enabled |
boolean | false |
Master switch. Off by default |
sip_server_dial_mode |
string | "proxy" |
How a phone registered in [sip_server] mode reaches the dial-out account. "proxy": the registrar relays the phone's INVITE itself (no Record-Route — once answered, BYE and media go directly between phone and bridge); works with every handset, including PJSIP-based softphones such as Telephone.app that treat a 302 as a failed call. "redirect": the registrar answers 302 Moved Temporarily and the phone must re-INVITE the dial-out port ([sip].local_port) — only for handsets known to follow redirects. Ignored in PBX trunk mode |
Serial-port discovery controls (specs/030-bad-port-isolation). A specific
misbehaving USB serial interface can hang the kernel option driver on any
operation — even a bare open — in a way no userspace read-timeout can break.
Left unhandled, probing such a port during the startup/rescan scan wedges the
whole daemon. Discovery bounds each individual port probe: a port that does
not respond within probe_timeout_ms is abandoned and the scan moves on, and a
port that times out three times in a row is quarantined in memory for the rest
of the process's life (logged once, at WARN, when it crosses that threshold).
Both apply with the defaults below; the section is optional and, when absent,
discovery behaves exactly as before this feature.
excluded_ports is the operator escape hatch for a known-bad port: a listed
port is never opened or probed at all. Each entry is either an exact device
path (/dev/ttyUSB1) or a USB-topology fragment (5-1.2.1.2:1.1). Topology
fragments are matched by exact-equality or a boundary-aligned leading prefix —
so a full interface fragment excludes just that interface, while a coarser
device fragment (5-1.2.1.2) excludes every interface on that device — and,
unlike device paths, they survive replug/reboot (where ttyUSB numbering
changes). Prefer the topology form. The abandon-on-timeout log line prints the
port's interface path so it can be copied straight into this list.
| Key | Type | Default | Description |
|---|---|---|---|
excluded_ports |
array of strings | [] |
Ports never to open/probe. Exact /dev/ttyUSB* paths or USB-topology fragments (exact or whole-device prefix) |
probe_timeout_ms |
integer (ms) | 5000 |
Per-port probe abandon budget. Covers the SIM read (open + AT+CPIN? + AT+CIMI), so it is 5s, not just an open's worth. Values below 1000 are clamped up (a lower value would abandon every port and quarantine all modems) |
For deployments where one cellular card serves both internet and VoWiFi
calls, internet is provided by a separate, opt-in sidecar container
(specs/032-cellular-internet-sidecar), not by the bridge. It is not
configured in config.toml — these are container environment variables set in
docker/.env (see .env.example) and consumed only when you start the stack
with the docker/docker-compose.cellular-internet.yml overlay. The bridge waits
for the sidecar's internet-reachable healthcheck before it starts. Full runbook:
docs/ec20-internet-plus-vowifi.md.
| Env var | Default | Description |
|---|---|---|
INTERNET_APN |
(required) | Carrier internet APN (never an IMS APN); no default is dialed |
INTERNET_QMI_DEV |
/dev/cdc-wdm0 |
QMI control device; QMI-only keeps the modem's AT port free for the bridge's AT+CSIM
|
INTERNET_PROBE_HOST |
one.one.one.one |
Hostname the DNS readiness probe resolves |
INTERNET_PROBE_RESOLVER |
1.1.1.1 |
Resolver queried; override where the carrier blocks the default |
[sip]
server = "127.0.0.1"
port = 5060
username = "test"
password = "test"
transport = "udp"
[sms]
enabled = false[sip]
server = "pbx.example.com"
port = 5061
username = "gsm-bridge"
password = "env:SIP_PASSWORD"
transport = "tls"
tls_verify = "strict"
display_name = "GSM Bridge"
# Only needed when phones/PBX reach this bridge over a routed network
# (e.g. Tailscale) rather than the same LAN segment:
# public_addr = "100.111.26.23"
# codecs = ["AMR-WB", "OPUS", "G722", "PCMU"] # VoWiFi/VoLTE phone-facing order
# amr_octet_align = true # offer AMR octet-aligned (most phones need it)
[bridge]
sip_destination = ""
sip_dial_timeout_sec = 30
[sms]
enabled = true
discord_webhook_url = "env:DISCORD_WEBHOOK_URL"
db_path = "/data/store.db"
[metrics]
port = 9091
[modules]
retry_interval_sec = 30
max_concurrent = 8
[resilience]
initial_backoff_sec = 5
max_backoff_sec = 120
max_retries = 10
network_loss_timeout_sec = 60
network_poll_interval_sec = 30
[control]
socket_path = "/run/gsm-sip-bridge/control.sock"