A resume compared PCSCF= against the kernel's SAs and, when they did not match, registered fresh without a word — a second initial REGISTER from an address whose registration is live; on KPN a fresh pair to the same P-CSCF meets the 21-minute refusal that costs the live registration (§13), what another P-CSCF does with it is unmeasured. The registration was made to whatever P-CSCF the SAs name (the SA setup takes imsd's previous pair out first), so that is where a resume belongs: SaPeer reads it from the dir-out transport policies — the one address a reqid-1 and a reqid-2 policy both name, each with imsd's fixed protected port as its selector's sport (reqid and mode alone are anyone's): the state text cannot tell the UE from the P-CSCF, a lone policy is not ours and a VPN's beside the pair changes nothing — TryResume resumes there, routes it, and says so when PCSCF= differs; the next fresh registration moves to PCSCF= and moves back when it fails before the SA setup and the old registration still stands (its address still on the link), as it already did between addresses, and its fallback Route is the P-CSCF it registers at, set before the 200's Service-Route is looked for — an empty Service-Route value counts as none — so a move never keeps the previous node's route. A file left by an attempt that got through the SA setup and did not live to record — to this or another P-CSCF — is recognised by its SA pair: the file's spi_uc/spi_us (a 0.3.x file carries them too) against the kernel's (FileNamesAnotherPair, Imsd:Ipsec, pinned in tests/Ipsec), instead of resumed with the wrong dialog; the state file gains pcscf, named in that line. Both fresh paths — the bring-up's and recovery's — now set their target themselves: with no PCSCF=, a bring-up that did not resume fails with its own message, and since no fresh one follows: a resumable pair whose attempt failed stays for the restart to resume, a pair nothing can resume — one the engine's rule wrote off during the attempt among them, a refresh refused with a 403 that carries a Retry-After or a binding a NOTIFY terminated under the re-REGISTER wait, decided once after both catch arms by its verdict (Reconnectable(), and with no fresh target Fresh(): a 401, a refusal with a Retry-After, the second refusal — with no fresh target every refusal reaches the reducer and its count travels in the holds, so the restart's refusal is the second, while with a target only a Retry-After does, as before — a refusal without one is not counted and the fresh registration follows at once; without the count a refused no-target resume would re-REGISTER on the same Call-ID at every RestartSec, the F16 loop) — is removed so the next bring-up finds nothing — and a recovery step that needs one restarts the daemon; the "PCSCF not set" check at the top of BringUp is gone, so a registration can be resumed with no setting at all. Three journal lines of the fresh-target path change with it: `resume connect failed; falling back to fresh` and `state file transport differs from SIP_TRANSPORT; fresh` end in `the next registration is a fresh one`, and a resume the engine writes off (a 403 with a Retry-After, a binding a NOTIFY terminated under the wait) logs `resume failed: … — written off by the network: the next registration is a fresh one` (§7.8). `no reg state file; resume falls back to fresh` keeps its text with a fresh target and ends in `nothing can resume it: the pair is removed` without one; the new `the state file names another SA pair (0x…/0x…[ to X]) than the kernel's (0x…/0x… to Y): another attempt's; …` line takes the same two tails. Tested: crafter-build test, 7 passed (tests/Engine: the refusal count's travel through the holds); the resume on the SA peer with the fresh target absent in builds of this series on the dev phone (KPN), and the no-target bring-up there with a resume that failed on a transport mismatch (the pair kept, the restart resumed) and with the state file removed (the pair removed, the next bring-up found nothing); the two dead-resume lines' no-target tail — a missing file, a file with another pair's SPIs — and the pair's removal on the tip's host build (bench 23f: a fake modem on PATH, a real xfrm in a namespace; a file with the pair's SPIs but another P-CSCF's address passes the check and the resume runs — `RESUME via existing SA`, the connect then failing in the namespace); on the dev phone (bench 41, the tip's build) a resume on a file without its pcscf key, the live pair's SPIs.
163 lines
12 KiB
Markdown
163 lines
12 KiB
Markdown
<!-- SPDX-License-Identifier: GPL-3.0-only -->
|
|
<!-- SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® -->
|
|
|
|
# imsd
|
|
|
|
Userspace IMS/VoLTE daemon for mainline Linux phones.
|
|
|
|
Most Linux phones drive the modem's internal IMS engine.
|
|
imsd implements the whole IMS client in userspace instead:
|
|
SIP registration with USIM AKA authentication over kernel
|
|
IPsec (ESP), call signalling, and the AMR-WB RTP media plane wired into
|
|
PipeWire so a stock mobile dialer like Plasma Dialer or GNOME Calls under Phosh
|
|
can place and receive real VoLTE calls on a commercial network.
|
|
|
|
## Status
|
|
|
|
Call-capable, both directions: outgoing calls place, answer, carry full-duplex
|
|
AMR-WB audio and tear down. Incoming calls ring, answer and carry media the
|
|
same way, driven end-to-end from a stock dialer over a frozen D-Bus ABI.
|
|
Not there yet: DTMF (`SendDtmf` is a stub), hold/multiparty/call waiting,
|
|
USSD, P-CSCF discovery (the address is configured by hand).
|
|
|
|
Verified by community members to be fully working:
|
|
|
|
| Device | OS | Carrier |
|
|
|---|---|---|
|
|
| The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | KPN NL |
|
|
| The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | Telekom Deutschland GER |
|
|
| The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | Phonero |
|
|
| The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | Telia Norge |
|
|
|
|
## What it needs
|
|
|
|
imsd talks to the modem through ModemManager plus `qmicli` over QRTR, installs
|
|
its security associations with `ip xfrm` and plays audio through PipeWire, so
|
|
in practice: a Qualcomm modem on a mainline kernel with working
|
|
WWAN/ModemManager integration, kernel ESP, PipeWire, systemd, polkit and
|
|
your carrier's P-CSCF address.
|
|
|
|
## Install and configure
|
|
|
|
For automatic updates add the Catcrafts Alpine repository. Note this does also pull in other packages.
|
|
|
|
Otherwise just download the latest `imsd` and `imsd-systemd` APKs and install them.
|
|
|
|
Both can be found in the packages tab:
|
|
|
|
```
|
|
https://forgejo.catcrafts.net/Catcrafts/-/packages/alpine/imsd
|
|
```
|
|
|
|
Configuration is environment variables in `/etc/imsd.env`; the one setting a fresh registration
|
|
needs is the P-CSCF:
|
|
|
|
```sh
|
|
# your carrier's P-CSCF address, capture via: `adb shell dumpsys telephony.registry` on Android
|
|
PCSCF=2001:db8::105
|
|
```
|
|
|
|
The apk's `imsd.service` is enabled by preset and starts at boot once that
|
|
file exists (the condition is checked when the unit starts, so after writing
|
|
the file run `systemctl start imsd`). It first brings the `ims` PDN up through ModemManager, then registers:
|
|
resuming an existing registration when it can, registering fresh otherwise,
|
|
and waiting out whatever the network tells it to wait out. Check on it with:
|
|
|
|
```sh
|
|
journalctl -u imsd -f
|
|
busctl --system call net.catcrafts.IMS1 /net/catcrafts/IMS1 net.catcrafts.IMS1 GetStatus
|
|
```
|
|
|
|
## Dialers
|
|
|
|
imsd itself is desktop-agnostic: everything a dialer needs is on the system
|
|
bus as `net.catcrafts.IMS1`. A small translation daemon per desktop speaks
|
|
the backend protocol its dialer already consumes, so dialer, shell and the
|
|
call-history/contacts stack run unmodified.
|
|
|
|
| Desktop | Daemon | Package | Owns | Consumed by |
|
|
|---|---|---|---|---|
|
|
| Plasma Mobile | `imsd-dialerd` | `imsd` | `org.kde.telephony.*` (session bus) | plasma-dialer + kde-telephony-daemon, replacing `modem-daemon` |
|
|
| Phosh / GNOME Mobile | `imsd-ofonod` | `imsd-ofono` | `org.ofono` (system bus) | GNOME Calls' `ofono` provider plugin |
|
|
|
|
Plasma Mobile needs nothing beyond the `imsd` package: `imsd-dialerd` is
|
|
autostarted in the session in place of plasma-dialer's `modem-daemon`, whose
|
|
own autostart must be off — the package hides it for accounts created after
|
|
the install (`/etc/skel`); an existing account hides it itself with a
|
|
`Hidden=true` copy of `org.kde.modem.daemon.desktop` in `~/.config/autostart`.
|
|
|
|
For Phosh and GNOME Mobile, `apk add imsd-ofono` enables `imsd-ofonod` and
|
|
points GNOME Calls at its `ofono` provider (a gschema override; the per-user
|
|
equivalent is `gsettings set org.gnome.Calls autoload-plugins "['ofono']"`).
|
|
Do not run it alongside a real `ofonod`.
|
|
|
|
Call control is polkit's `net.catcrafts.IMS1.Voice`: the active local
|
|
session and root may place, answer and end calls; an ssh login may not.
|
|
|
|
## Full config:
|
|
|
|
| Variable | Default | Meaning |
|
|
|---|---|---|
|
|
| `PCSCF` | *(none)* | P-CSCF address (IPv6), exactly one — not a comma-separated list — in RFC 5952 §4 form (lowercase, leading zeros dropped, the longest zero run compressed, no brackets — what `ip` prints for a plain IPv6 address; an IPv4-mapped address only as hex, `::ffff:a00:1` — the dotted spelling RFC 5952 §5 and `ip` use for it is refused); upper case, leading zeros, an uncompressed run or brackets are refused at start with the spelling to use (in the packaged unit the bring-up script refuses brackets first, with the rule), a dotted-quad tail with the rule; before 1.0.0 an upper-case, zero-padded or uncompressed `PCSCF=` matched no SA in `ip xfrm`'s text and registered fresh at every restart — an upgraded phone with such a setting stops at start with the spelling to use. It is where a fresh registration goes; a registration resumed from the kernel's SAs stays on the P-CSCF it was made to until its next fresh registration. Without it a fresh registration fails with a clear error |
|
|
| `PCSCF_PORT` | `5060` | unprotected SIP port for the initial REGISTER |
|
|
| `DEV` | `qmapmux0.0` | ims-PDN netdev; the packaged service passes the real one via `/run/imsd.env` |
|
|
| `LOCAL` | *(auto)* | UE address (compared in RFC 5952 form). The packaged service gets the PDN address ModemManager assigned, via `/run/imsd.env` from `ims-pdn-up.sh`; a manual run prefers a global IPv6 on `DEV` that is neither temporary nor `dynamic` (RA-derived), then any non-temporary one, then the first (the kernel's temporary SLAAC address is listed first, and a registration bound to it is lost when the kernel retires it) |
|
|
| `USER_AGENT` | `imsd/<version>` | REGISTER User-Agent. Some networks fingerprint UAs; setting your device's stock build string reproduces the stock modem's registration exactly. Empty omits the header |
|
|
| `STATE_FILE` | `/var/lib/imsd/imsreg.state` (root) / `$XDG_STATE_HOME/imsd/imsreg.state` | persisted registration context for warm resume |
|
|
| `HOLDS_FILE` | `/run/imsd.holds` (root) / `$XDG_RUNTIME_DIR/imsd.holds` (`/tmp/imsd.holds` without a runtime dir) | the waits the network imposed (a `Retry-After`, the refusal schedule), where that schedule stands whether the old registration is gone (`old_gone`) and the refusal count (`refusals`: a resume refused without a Retry-After at a start with no fresh target is one, the restart's the second), kept across a restart of imsd for the PDN they were set on — not across a reboot; `/dev/null` switches the file off (only a regular file is ever removed; anything else is emptied). `imsd-handover.sh` honours it too |
|
|
| `RESUME` | `auto` | `0` forces a fresh registration (ignores a warm SA) where there is a fresh target; with none the untried pair is removed and the bring-up ends as AGENTS §7.2 (the daemon's own bring-up) step 1 says |
|
|
| `REFRESH_INTERVAL` | *(auto)* | keepalive re-REGISTER period in s; default = half the granted expiry, clamped to [120, 1800] |
|
|
| `EALG` | `aes-cbc` | offered ESP cipher (`aes-cbc`, `des-ede3-cbc`, `null`). From the 401's `Security-Server` list imsd selects the highest-preference `ipsec-3gpp` mechanism that matches this offer (RFC 3329 §2.3.1) and takes the SPIs/ports from the entry carrying them (some cores list every mechanism they support and put the SPIs on the one they applied); when no entry matches, the P-CSCF's SPI-carrying entry is installed as listed (`hmac-md5-96`, an absent `ealg` = null) and the journal says so. The line, the selection and the SA parameters are logged at every fresh registration |
|
|
| `SIP_TRANSPORT` | `auto` | transport of the protected leg (the second REGISTER and everything after it): `tcp`, `udp`, or `auto` = TCP, falling back to a fresh registration over UDP when the P-CSCF leaves two TCP connects unanswered — but only on a phone that has never registered over TCP: once the state file records a TCP registration, a silent connect is treated as an outage, not a policy (delete the file or set `udp` to override, e.g. after a SIM change). The challenge (first REGISTER) is always UDP. Over UDP a REGISTER or INVITE larger than ~1.2 KB leaves as IPv6 fragments at the ims PDN's 1280-byte MTU (the journal says so once per flow); there are no SIP retransmission timers over UDP yet, so a lost datagram costs a timeout. The SAs, the listener and the firewall rule cover both transports |
|
|
| `EMERGENCY_NUMBERS` | *(empty)* | comma-separated additions to the builtin 112/911 emergency numbers (e.g. a private test core's short code). SIM `EF_ECC` is not read yet. `imsd-ofonod` reads it too, for the oFono `EmergencyNumbers` property |
|
|
| `RTP_PORT` | `50004` | local RTP port advertised in SDP |
|
|
| `PRECOND` | `0` | `1` offers SDP QoS preconditions |
|
|
| `CODECS` | *(empty — defaults)* | comma-separated codec preference list over `AMR-WB`, `AMR` (or `AMR-NB`), `PCMA`, `PCMU`: restricts and orders both the codecs offered on an outgoing call and those accepted from an inbound offer (default: offer AMR-WB + AMR, accept all four in that order). A bench knob — a network whose gateway transcodes every caller up to AMR-WB otherwise never lets the narrowband path run |
|
|
| `DUMP_SIP` | `0` | `1` writes raw REGISTER-200/SUBSCRIBE-200 dumps, the last refused REGISTER or SUBSCRIBE final (`imsd-register-fail.raw`, `imsd-subscribe-fail.raw`), the last inbound INVITE (`imsd-invite-in.raw`) and the last outgoing INVITE (`imsd-invite-out.raw`), mode 0600, for debugging — they contain your IMSI/MSISDN and addresses. A refused REGISTER or SUBSCRIBE is logged with its status line and any Retry-After / Warning / Reason / Server headers |
|
|
| `DUMP_DIR`, `OUT_DIR` | state dir | where dumps / per-call media stats land |
|
|
| `IMSD_MEDIA` | *(auto)* | path to `imsd-media` (default: next to `imsd`, else `/usr/libexec/imsd-media`) |
|
|
|
|
The protected client/server ports are fixed at 45061/45062
|
|
(`imsd::util::PortUc`/`PortUs`) — changing them in source also requires
|
|
updating the firewall rules `ims-pdn-up.sh` installs.
|
|
|
|
### imsd-media (per-call, set through imsd's environment)
|
|
|
|
| Variable | Default | Meaning |
|
|
|---|---|---|
|
|
| `MIC`, `PLAY` | `1`, `1` | uplink mic capture / downlink playout via PipeWire, as imsd passes them (`imsd-media` started by hand defaults both to off) |
|
|
| `AUDIO_USER` | *(auto)* | account whose PipeWire session carries call audio; imsd sets it per call from logind's seat0 session. Set in imsd's own environment it pins an account instead |
|
|
| `GAIN`, `PLAY_GAIN` | `10`, `1.0` | uplink / downlink gain, as imsd passes them (`imsd-media` started by hand defaults `GAIN` to `1.0`) |
|
|
| `AMR_MODE` | `2` | AMR encoder mode of whichever AMR codec is active, as imsd passes it (12.65 kbit/s for AMR-WB; `imsd-media` started by hand defaults to 2 for AMR-WB and 7 for AMR) |
|
|
| `DTX` | `0` | encoder discontinuous transmission |
|
|
| `MEDIA_TIMEOUT` | `6.0` | seconds of downlink silence before exit 3 (far-end-hangup signal) |
|
|
| `RTP_DUMP` | `0` | `1` captures raw downlink RTP next to the stats file |
|
|
|
|
## D-Bus ABI
|
|
|
|
`net.catcrafts.IMS1` at `/net/catcrafts/IMS1`. The ABI is frozen.
|
|
|
|
| Member | Signature |
|
|
|---|---|
|
|
| `Dial(number)` | `s → s` (callUni) |
|
|
| `HangUp(callUni)` | `s` |
|
|
| `Accept(callUni)` | `s` |
|
|
| `SendDtmf(callUni, tones)` | `ss` |
|
|
| `GetCalls()` | `→ aa{sv}` |
|
|
| `GetStatus()` | `→ a{sv}` |
|
|
| `CallAdded` | `s a{sv}` |
|
|
| `CallStateChanged` | `s s s` (callUni, state, reason) |
|
|
| `CallDeleted` | `s` |
|
|
| `RegistrationChanged` | `b` |
|
|
|
|
Both `imsd-dialerd` and `imsd-ofonod` are users of this ABI.
|
|
A new desktop needs a new translation daemon, not changes here.
|
|
|
|
## License
|
|
|
|
GPL-3.0-only — see [LICENSE](LICENSE).
|
|
|
|
## Copyright
|
|
|
|
Copyright (C) 2026 Catcrafts®
|
|
catcrafts.net
|