306 lines
16 KiB
Markdown
306 lines
16 KiB
Markdown
|
|
<!-- SPDX-License-Identifier: GPL-3.0-only -->
|
|||
|
|
<!-- SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® -->
|
|||
|
|
|
|||
|
|
# imsd
|
|||
|
|
|
|||
|
|
Userspace IMS/VoLTE daemon for mainline Linux phones.
|
|||
|
|
|
|||
|
|
> ## ⚠️ No emergency calls
|
|||
|
|
>
|
|||
|
|
> **imsd cannot make emergency calls (112, 911, 999, …).** Emergency calling
|
|||
|
|
> needs dedicated IMS emergency-registration procedures and CS-fallback paths
|
|||
|
|
> that imsd does not implement — and on a phone whose modem voice stack is
|
|||
|
|
> bypassed like this, there may be no other working call path either. **Do
|
|||
|
|
> not rely on a device running imsd to reach emergency services.**
|
|||
|
|
|
|||
|
|
Modem firmware on typical mainline-Linux phones will bring up the IMS PDN but
|
|||
|
|
can't be used for actual call audio. 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 Plasma Mobile dialer can place and receive real VoLTE
|
|||
|
|
calls on a commercial network.
|
|||
|
|
|
|||
|
|
## Status
|
|||
|
|
|
|||
|
|
Call-capable, both directions. Outgoing VoLTE calls place, answer, carry
|
|||
|
|
full-duplex AMR-WB audio, and tear down; incoming calls ring, answer via
|
|||
|
|
`Accept`, and carry media the same way — driven end-to-end from a stock
|
|||
|
|
dialer over the frozen D-Bus ABI. Current state:
|
|||
|
|
|
|||
|
|
| Piece | State |
|
|||
|
|
|---|---|
|
|||
|
|
| SIP message layer + TCP framing (`Imsd:Sip`) | done, tested |
|
|||
|
|
| SDP offer/answer for AMR-WB (`Imsd:Sdp`) | done, tested |
|
|||
|
|
| USIM AKA + AKAv1-MD5 digest, SIM parsers (`Imsd:Aka`) | done, tested |
|
|||
|
|
| IPsec SA commands + warm-SA reader (`Imsd:Ipsec`) | done, tested |
|
|||
|
|
| SIP request/response builders (`Imsd:Messages`) | done, byte-pinned |
|
|||
|
|
| Outgoing + incoming call state machine (`Imsd:Engine`) | done, tested |
|
|||
|
|
| Registration (fresh + warm resume + keepalive refresh, MMTEL feature tags) | done |
|
|||
|
|
| RTP + AMR-WB media plane into PipeWire (`imsd-media`) | done |
|
|||
|
|
| D-Bus service `net.catcrafts.IMS1` (register, Dial, Accept, HangUp, live media) | done |
|
|||
|
|
| Plasma Dialer session backend (`imsd-dialerd`, org.kde.telephony.*) | done |
|
|||
|
|
| DTMF (`SendDtmf`, RFC 4733) | not yet implemented |
|
|||
|
|
|
|||
|
|
The engine core (`imsd-core`) is a GLib-free, pure-`std` static library whose
|
|||
|
|
every decision — SIP framing, SDP selection, the AKAv1-MD5 digest, the
|
|||
|
|
`ip xfrm` command sequence, and the whole call FSM — is unit-tested on a dev
|
|||
|
|
box with no bus, modem, or phone; several suites pin output byte-for-byte
|
|||
|
|
against recorded network traffic. GLib/GDBus, sockets, and subprocess control
|
|||
|
|
live only in the `imsd` daemon shell.
|
|||
|
|
|
|||
|
|
## Architecture
|
|||
|
|
|
|||
|
|
C++26 modules, built with Crafter Build. Four build products:
|
|||
|
|
|
|||
|
|
- **imsd-core** (static library) — the engine, pure `import std` C++ with no
|
|||
|
|
GLib and no I/O side effects. Everything here is unit-testable on any dev
|
|||
|
|
box without a modem, a bus, or a phone; several suites pin output
|
|||
|
|
byte-for-byte against recorded network traffic.
|
|||
|
|
- **imsd** (executable) — the daemon shell: GLib main loop, GDBus service,
|
|||
|
|
sockets, subprocess control (`qmicli`/`mmcli`/`ip`), and the registration
|
|||
|
|
sequence + run loop that pumps the core. All GLib and I/O live here.
|
|||
|
|
- **imsd-media** (executable) — the RTP/AMR-WB data plane, spawned once per
|
|||
|
|
call. Pure `import std` + POSIX, no GLib; links `-ldl` and dlopen's the
|
|||
|
|
codecs. A separate process for crash isolation and the far-end-hangup
|
|||
|
|
exit-code contract.
|
|||
|
|
- **imsd-dialerd** (executable) — the Plasma Mobile integration: a session
|
|||
|
|
daemon owning `org.kde.telephony.{CallUtils,DeviceUtils,UssdUtils}`,
|
|||
|
|
bridging them to imsd on the system bus. GDBus translation only, no core.
|
|||
|
|
It is a separate process because those are per-user *session*-bus names
|
|||
|
|
that the root system daemon cannot own; built with `-- --product=dialerd`,
|
|||
|
|
autostarted from an `.desktop` file in place of plasma-dialer's
|
|||
|
|
`modem-daemon` (whose autostart must be disabled).
|
|||
|
|
|
|||
|
|
### Modules (imsd-core)
|
|||
|
|
|
|||
|
|
| Module | Responsibility |
|
|||
|
|
|---|---|
|
|||
|
|
| `Imsd:Util` | random SIP tokens, host/port formatting, hex/base64 decode, MD5 (RFC 1321) |
|
|||
|
|
| `Imsd:Sip` | SIP message text layer: header access, status lines, registration lifetime, UDP response routing (RFC 3261 18.2.2), TCP stream framing (Content-Length, RFC 5626 keepalives) |
|
|||
|
|
| `Imsd:Sdp` | AMR-WB SDP: offer building (octet-aligned, IR.92 QoS preconditions), answer parsing (payload-type selection, octet-align detection) |
|
|||
|
|
| `Imsd:Aka` | AKAv1-MD5 digest (RFC 3310), home-network identity, `qmicli`/`mmcli` text parsers, AUTHENTICATE APDU build/parse |
|
|||
|
|
| `Imsd:Ipsec` | IMS security association: the `ip xfrm` setup command sequence + a reader that reconstructs SPIs/ports/Security-Server from a warm SA |
|
|||
|
|
| `Imsd:Messages` | Every SIP request/response the daemon sends (REGISTER fresh/protected/refresh, INVITE, PRACK, ACK 2xx/non-2xx, CANCEL, BYE, 200) |
|
|||
|
|
| `Imsd:Engine` | Call state machine as a pure reducer, both directions: each event returns a list of Actions for the shell. Outgoing — PRACK on reliable 18x, ACK of 2xx/non-2xx, CANCEL/answer races. Incoming (UAS) — 100/180 on the INVITE (reliable when the caller requires 100rel), 200-with-answer on Accept, 486/480/488 rejects, the remote-CANCEL 200+487 pair. Both — session-timer refreshes, remote BYE, media-plane far-end-hangup, teardown-cause logging |
|
|||
|
|
|
|||
|
|
The **daemon shell** (`implementations/main.cpp`) owns the GDBus service, the
|
|||
|
|
protected SIP/TCP flow + protected-port listeners, the SIM/AKA and `ip xfrm`
|
|||
|
|
subprocess calls, the fresh/resume/keepalive registration sequence, and the
|
|||
|
|
engine run loop that executes the call machine's Actions and spawns the media
|
|||
|
|
leg. An out-of-dialog INVITE arriving on a protected-port listener becomes a
|
|||
|
|
new (incoming) call machine; the connection it arrived on is remembered as
|
|||
|
|
the call's durable reply channel so the 200 OK that a later `Accept` produces
|
|||
|
|
still reaches the caller. The **media leg** (`implementations/media.cpp`) is
|
|||
|
|
the RTP socket, the dlopen'd AMR-WB encode/decode, the pw-record/pw-play
|
|||
|
|
subprocesses, and the timestamp-driven playout-clock reconstruction (CNG
|
|||
|
|
gap-fill for far-end DTX). The **dialer daemon**
|
|||
|
|
(`implementations/dialerd.cpp`) maps imsd's `CallAdded`/`CallStateChanged`/
|
|||
|
|
`CallDeleted`/`RegistrationChanged` signals to the kde-telephony CallData
|
|||
|
|
vocabulary (incoming calls surface as `direction=incoming`, state
|
|||
|
|
`RingingIn`, which is what triggers the answer UI and ringtone).
|
|||
|
|
|
|||
|
|
### Roadmap
|
|||
|
|
|
|||
|
|
Ordered so every phase yielded something independently testable; 1–7 done.
|
|||
|
|
|
|||
|
|
1. **Message plane** (done): SIP text + SDP, unit tests.
|
|||
|
|
2. **Engine** (done): the call state machine as a pure Action-returning
|
|||
|
|
reducer, tested against recorded SIP dialogs before touching a socket.
|
|||
|
|
3. **AKA + IPsec** (done): by shelling out to `qmicli` (UIM APDUs) and
|
|||
|
|
`ip xfrm`, with the digest, parsers, and command sequence all in the pure
|
|||
|
|
core. A future native swap (libqmi-glib, XFRM netlink) can be bisected
|
|||
|
|
against the subprocess version.
|
|||
|
|
4. **Transport** (done): TCP client flow + protected-port TCP/UDP listeners.
|
|||
|
|
5. **Media** (done): RTP + AMR-WB (libopencore-amrwb / libvo-amrwbenc) with
|
|||
|
|
pw-record/pw-play. The receive side reconstructs the media clock from RTP
|
|||
|
|
timestamps and decodes NO_DATA frames for DTX gaps, so playback stays
|
|||
|
|
real-time-paced through far-end silence; the uplink is paced by the audio
|
|||
|
|
clock. (Native PipeWire `pw_stream` is a possible later refinement.)
|
|||
|
|
6. **Call capability end-to-end** (done): validated live on a commercial
|
|||
|
|
network — an answered full-duplex AMR-WB call driven from a stock dialer.
|
|||
|
|
7. **Incoming calls** (done): the UAS side of the engine + `Accept`, the
|
|||
|
|
session-bus dialer backend, and the REGISTER MMTEL feature tags. The
|
|||
|
|
daemon-side path (INVITE on the listener → ring → `Accept` → 200 with an
|
|||
|
|
AMR-WB answer at the caller's payload types → ACK → media → BYE) is
|
|||
|
|
validated on-device. Note the terminating-network dependency below.
|
|||
|
|
|
|||
|
|
Not yet implemented: DTMF (`SendDtmf`, RFC 4733).
|
|||
|
|
|
|||
|
|
### Terminating-network dependency (incoming)
|
|||
|
|
|
|||
|
|
A UE only receives a VoLTE call as a SIP INVITE if the network's Terminating
|
|||
|
|
Access Domain Selection (T-ADS) routes it to the PS/IMS domain rather than
|
|||
|
|
paging the modem over CS. That requires the IMS registration to advertise
|
|||
|
|
voice capability — the MMTEL ICSI (`+g.3gpp.icsi-ref=...mmtel`) and
|
|||
|
|
`+sip.instance` media-feature tags in the REGISTER Contact, which
|
|||
|
|
`Imsd:Messages` emits. T-ADS may still select CS for reasons outside the
|
|||
|
|
client's control (operator policy, the modem's own radio-capability
|
|||
|
|
signalling, a competing registration). When it does, no INVITE reaches imsd;
|
|||
|
|
that is a network/modem-integration matter, not a daemon defect.
|
|||
|
|
|
|||
|
|
### Design rules
|
|||
|
|
|
|||
|
|
- **The D-Bus ABI is frozen.** Dialer integrations must never need to know
|
|||
|
|
which daemon version owns the name.
|
|||
|
|
- **Engine core stays GLib-free.** GLib types stop at the daemon shell;
|
|||
|
|
core modules take `std::string_view` in and give values out. (GDBus was
|
|||
|
|
chosen over sd-bus because the surrounding telephony stack — ModemManager,
|
|||
|
|
libqmi — is GLib; but that's a shell concern.)
|
|||
|
|
- **Edge-case behavior is part of the spec.** Header matching, framing, and
|
|||
|
|
SDP selection rules are pinned by unit tests, including deliberately odd
|
|||
|
|
cases (empty header values, keepalive discards, payload-type preference
|
|||
|
|
order). A change that flips one of those tests is an ABI change, not a
|
|||
|
|
cleanup.
|
|||
|
|
|
|||
|
|
### History
|
|||
|
|
|
|||
|
|
imsd comes out of the Fairphone 6 mainline bring-up, where this design —
|
|||
|
|
userspace SIP/AKA/IPsec plus an RTP media plane into PipeWire, driving an
|
|||
|
|
unmodified Plasma Mobile dialer — was validated end-to-end on a commercial
|
|||
|
|
network (KPN, NL) with a Python prototype before this implementation was
|
|||
|
|
started. Design decisions that look oddly specific (protected-port listener
|
|||
|
|
pairs, media-inactivity call teardown, DTX playout reconstruction) are
|
|||
|
|
lessons from that bring-up, and the recorded dialogs used in tests come
|
|||
|
|
from it.
|
|||
|
|
|
|||
|
|
## Supported hardware & carriers
|
|||
|
|
|
|||
|
|
imsd talks to the modem through ModemManager plus `qmicli` over QRTR (USIM
|
|||
|
|
AKA via a UIM logical channel), so in practice it currently needs a Qualcomm
|
|||
|
|
modem on a mainline kernel with working WWAN/MM integration, kernel ESP
|
|||
|
|
(`ip xfrm`), and PipeWire for audio.
|
|||
|
|
|
|||
|
|
Everything below has been verified on exactly **one device / one carrier**
|
|||
|
|
(n=1) — reports from other combinations are very welcome:
|
|||
|
|
|
|||
|
|
| Device | OS | Modem | Carrier | Status |
|
|||
|
|
|---|---|---|---|---|
|
|||
|
|
| Fairphone 6 (SM7635 "milos") | postmarketOS, mainline kernel | Qualcomm (QRTR/QMI) | KPN NL (eSIM) | MO + MT calls, full-duplex AMR-WB |
|
|||
|
|
|
|||
|
|
Carrier-side assumptions that held on this network but may differ elsewhere:
|
|||
|
|
IPsec sec-agree with AKAv1-MD5 over IPv6, AMR-WB audio, TCP for the
|
|||
|
|
protected flows. The SIP/SDP/AKA layers are carrier-agnostic and unit-tested;
|
|||
|
|
the bring-up sequencing is where new networks will differ.
|
|||
|
|
|
|||
|
|
## Build
|
|||
|
|
|
|||
|
|
[Crafter Build](https://forgejo.catcrafts.net/Catcrafts/Crafter.Build) +
|
|||
|
|
gio-2.0 headers (`glib2` on Arch, `glib-dev` on Alpine).
|
|||
|
|
|
|||
|
|
```sh
|
|||
|
|
crafter-build # bin/imsd-<target>-<march>/imsd (the daemon)
|
|||
|
|
crafter-build -- --product=media # bin/imsd-media-.../imsd-media (the media leg)
|
|||
|
|
crafter-build -- --product=dialerd # bin/imsd-dialerd-.../imsd-dialerd (dialer backend)
|
|||
|
|
crafter-build test # unit tests (Util, Aka, Ipsec, Messages, Engine, Sip, Sdp)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
What each product is and why it is a separate process is covered under
|
|||
|
|
[Architecture](#architecture) above.
|
|||
|
|
|
|||
|
|
### Cross-compiling for a phone (aarch64 Alpine/postmarketOS)
|
|||
|
|
|
|||
|
|
```sh
|
|||
|
|
packaging/make-sysroot.sh # one-time: Alpine aarch64 sysroot from the CDN
|
|||
|
|
crafter-build -- --target=aarch64-alpine-linux-musl \
|
|||
|
|
--sysroot=$HOME/.cache/imsd/sysroot-aarch64-alpine \
|
|||
|
|
--march=armv8-a --mtune=generic
|
|||
|
|
# same flags after `crafter-build test --target=aarch64-alpine-linux-musl`
|
|||
|
|
# run the suite under qemu-aarch64 against the sysroot
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## Run (development)
|
|||
|
|
|
|||
|
|
```sh
|
|||
|
|
./bin/imsd-*/imsd --session # own net.catcrafts.IMS1 on the session bus
|
|||
|
|
busctl --user call net.catcrafts.IMS1 /net/catcrafts/IMS1 net.catcrafts.IMS1 GetStatus
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
On a phone the daemon runs as root on the system bus (policy in
|
|||
|
|
`packaging/net.catcrafts.IMS1.conf` — it grants the postmarketOS default
|
|||
|
|
account `user` access; adjust it alongside `AUDIO_USER` if your session
|
|||
|
|
account is named differently), started by `packaging/imsd.service`
|
|||
|
|
after `packaging/ims-pdn-up.sh` has brought up the ims PDN through
|
|||
|
|
ModemManager. It spawns `/usr/libexec/imsd-media` per call. Registration
|
|||
|
|
resumes an in-kernel IPsec SA when one is present (avoiding the network's
|
|||
|
|
fresh-SA throttle) and keeps itself alive with a re-REGISTER refresh at half
|
|||
|
|
the granted lifetime. `packaging/APKBUILD.binary` + `make-bin-tarball.sh`
|
|||
|
|
wrap a cross-compiled build into an apk.
|
|||
|
|
|
|||
|
|
## Configuration
|
|||
|
|
|
|||
|
|
Everything is environment variables. The packaged unit reads
|
|||
|
|
`/run/imsd.env` (written by `ims-pdn-up.sh`: the connected ims netdev) and
|
|||
|
|
then `/etc/imsd.env` (your configuration, wins on conflict). Minimum viable
|
|||
|
|
`/etc/imsd.env`:
|
|||
|
|
|
|||
|
|
```sh
|
|||
|
|
# REQUIRED until P-CSCF discovery from the PDN's PCO is implemented: your
|
|||
|
|
# carrier's P-CSCF address. Find it in a stock-firmware capture or your
|
|||
|
|
# carrier's IMS documentation.
|
|||
|
|
PCSCF=2001:db8::105
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### imsd
|
|||
|
|
|
|||
|
|
| Variable | Default | Meaning |
|
|||
|
|
|---|---|---|
|
|||
|
|
| `PCSCF` | *(none — required)* | P-CSCF address; without it 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; default = the global IPv6 on `DEV` |
|
|||
|
|
| `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 |
|
|||
|
|
| `RESUME` | `auto` | `0` forces a fresh registration (ignores a warm SA) |
|
|||
|
|
| `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`) |
|
|||
|
|
| `RTP_PORT` | `50004` | local RTP port advertised in SDP |
|
|||
|
|
| `PRECOND` | `0` | `1` offers SDP QoS preconditions |
|
|||
|
|
| `DUMP_SIP` | `0` | `1` writes raw REGISTER-200/SUBSCRIBE-200/INVITE dumps (mode 0600) for debugging — they contain your IMSI/MSISDN and addresses |
|
|||
|
|
| `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::kPortUc`/`kPortUs`) — 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 |
|
|||
|
|
| `AUDIO_USER` | `user` | desktop user whose PipeWire session carries call audio (postmarketOS default account) |
|
|||
|
|
| `GAIN`, `PLAY_GAIN` | `10`, `1.0` | uplink / downlink gain |
|
|||
|
|
| `AMR_MODE` | `2` | AMR-WB encoder mode (0–8) |
|
|||
|
|
| `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 — dialer
|
|||
|
|
integrations depend on it:
|
|||
|
|
|
|||
|
|
| 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` |
|
|||
|
|
|
|||
|
|
## Development disclosure
|
|||
|
|
|
|||
|
|
imsd was developed with substantial use of a generative-AI coding assistant
|
|||
|
|
(Anthropic's Claude), driven and reviewed by the maintainer; commits carry
|
|||
|
|
`Co-Authored-By: Claude` trailers. Every wire-format decision is pinned by
|
|||
|
|
the unit tests, and the call paths were validated end-to-end on a live
|
|||
|
|
commercial network.
|
|||
|
|
|
|||
|
|
## License
|
|||
|
|
|
|||
|
|
GPL-3.0-only — see [LICENSE](LICENSE).
|