Linux userspace IMS daemon
  • C++ 96.3%
  • Shell 2.3%
  • Makefile 1.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jorijn van der Graaf 86217f11be imsd 0.2.7 — initial public snapshot
Userspace VoLTE/IMS daemon for mainline Linux phones (developed on the
Fairphone 6): a GLib-free core (SIP, SDP, USIM AKA, IPsec SA setup, RTP
media, call engine) behind a GDBus control daemon, a standalone AMR-WB
media leg, and a Plasma Dialer backend. C++26 modules built with Crafter
Build; the tree is clean under the project's house-style linter
(crafter-build lint) across all three build products.

Assisted-by: Claude:claude-fable-5
Signed-off-by: Jorijn van der Graaf <jorijnvdgraaf@catcrafts.net>
2026-07-22 22:53:28 +02:00
implementations imsd 0.2.7 — initial public snapshot 2026-07-22 22:53:28 +02:00
interfaces imsd 0.2.7 — initial public snapshot 2026-07-22 22:53:28 +02:00
packaging imsd 0.2.7 — initial public snapshot 2026-07-22 22:53:28 +02:00
tests imsd 0.2.7 — initial public snapshot 2026-07-22 22:53:28 +02:00
.gitignore imsd 0.2.7 — initial public snapshot 2026-07-22 22:53:28 +02:00
LICENSE imsd 0.2.7 — initial public snapshot 2026-07-22 22:53:28 +02:00
lint-rules.h imsd 0.2.7 — initial public snapshot 2026-07-22 22:53:28 +02:00
project.cpp imsd 0.2.7 — initial public snapshot 2026-07-22 22:53:28 +02:00
README.md imsd 0.2.7 — initial public snapshot 2026-07-22 22:53:28 +02:00

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; 17 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 + gio-2.0 headers (glib2 on Arch, glib-dev on Alpine).

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 above.

Cross-compiling for a phone (aarch64 Alpine/postmarketOS)

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)

./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:

# 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 (08)
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.