diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml index 783fa74..30c381d 100644 --- a/.forgejo/workflows/package.yml +++ b/.forgejo/workflows/package.yml @@ -6,18 +6,19 @@ name: package # plain 'apk upgrade' without an fp6-img image run. # # Build: crafter-build cross-compiles against an Alpine aarch64 sysroot -# (packaging/build-package.sh — the README's "Cross-compiling" flow), the +# (packaging/build-package.sh — AGENTS.md §4.3's cross-compiling flow), the # test suite runs natively, and packaging/APKBUILD.binary wraps the result. -# This needs no privileged runner: it runs in an alpine:edge container on -# the ordinary arch-latest runner. +# crafter-build is the Crafter.Build Flatpak, and bubblewrap inside the job +# container needs a privileged runner: the job runs on the `flatpak` label +# (a privileged runner registered to this repository), in an alpine:edge +# container. # # Release gating is the version: pkgver comes from implementations/main.cpp, # the registry answers 409 for an already-published version, and the publish # step treats that as "nothing to do" — so pushes only release when the # Version constant bumps. This is the only producer of the imsd apk: fp6-img -# images install the published package from the registry (pinned version, -# pinned sha256) instead of building their own, so image and 'apk upgrade' -# carry the same binary. +# images install the published package from the registry instead of +# building their own, so image and 'apk upgrade' carry the same binary. # # Publishing needs PACKAGE_TOKEN (catbot account, package:write scope) — an # org-level secret on Catcrafts since 2026-09-02, shared with fp6-img; @@ -30,7 +31,7 @@ on: jobs: package: - runs-on: arch-latest + runs-on: flatpak container: image: alpine:edge timeout-minutes: 90 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2a8715b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,1024 @@ + + + +# AGENTS.md — working on imsd + +This file is for coding agents and for anyone changing imsd: what the +repository contains, how it is built, tested and packaged, how the daemon +behaves at runtime, and the conventions a change has to follow. The README +is for users: what imsd is, how to install and configure it. The +configuration variables are documented there, not here (§10). +Where this file and the code disagree, the code is right and this file has a +bug — fix it in the same change. + +## 1. What imsd is + +imsd is a userspace IMS/VoLTE client for mainline Linux phones. The modem +firmware on such phones brings up the IMS PDN but cannot carry call audio, so +imsd does the whole IMS client itself: SIP registration with USIM AKA +authentication over kernel IPsec (ESP), call signalling for outgoing and +incoming calls, and the RTP media plane (AMR-WB, AMR, G.711) wired into +PipeWire. A stock mobile dialer — Plasma Dialer, or GNOME Calls under Phosh +and GNOME Mobile — places and receives real VoLTE calls on a commercial +network through it. + +It talks to the modem through ModemManager (`mmcli`) and `qmicli` over QRTR +(the USIM AKA run goes through a UIM logical channel), installs its security +associations with `ip xfrm`, and plays and records audio with the PipeWire +tools (`pw-play`, `pw-record`). In practice that means a Qualcomm modem on a +mainline kernel with working WWAN/ModemManager integration, kernel ESP, and +PipeWire. The verified device/carrier combinations are in the README. + +## 2. Repository layout + +| Path | What it is | +|---|---| +| `project.cpp` | The canonical build description, for [Crafter Build](https://forgejo.catcrafts.net/Catcrafts/Crafter.Build). Chooses the product by `--product=`. | +| `interfaces/Imsd.cppm` | The primary module interface of `imsd-core`: `export import`s the seven partitions below. | +| `interfaces/Imsd-Util.cppm` | `imsd::util` — SIP tokens, IPv6 host/port formatting, hex/base64, MD5 (RFC 1321), the fixed protected ports `PortUc`/`PortUs` (45061/45062). | +| `interfaces/Imsd-Sip.cppm` | `imsd::sip` — SIP as a text layer plus TCP framing: header extraction, status codes, the granted lifetime, `ResponseOf` transaction matching. Not a full RFC 3261 parser, deliberately. | +| `interfaces/Imsd-Sdp.cppm` | `imsd::sdp` — SDP offer/answer: the AMR-WB + AMR + telephone-event offer (optional IR.92 preconditions), the parser the media plane needs, `BuildAnswer` for the terminating side, the `CODECS` override. | +| `interfaces/Imsd-Aka.cppm` | `imsd::aka` — USIM AKA glue: the AKAv1-MD5 digest (RFC 3310), IMPI/IMPU/realm from IMSI + MCC/MNC, parsers for `qmicli --uim-get-card-status` and `mmcli` output, the AUTHENTICATE APDU and its response. | +| `interfaces/Imsd-Ipsec.cppm` | `imsd::ipsec` — the two ESP SA pairs (TS 33.203) as `ip xfrm` argv lists, the Security-Server mechanism selection, and the reader that reconstructs SPIs/ports from `ip xfrm state`/`policy` text for a warm resume. | +| `interfaces/Imsd-Messages.cppm` | `imsd::msg` — every SIP request and response imsd sends, built from an explicit `Context` and per-request variables so the builders are pure functions; byte-pinned by `tests/Messages`. Also `IsEmergency`. | +| `interfaces/Imsd-Engine.cppm` | `imsd::engine` — the decisions as pure reducers: `CallMachine` (one call's dialog, returning `Action`s for the shell) and `RegRecovery` (the registration recovery policy, §7.4). | +| `interfaces/ImsdBus.cppm` | `imsd::bus` — the polkit gate in front of call control, shared by imsd and imsd-ofonod. GLib, so outside `imsd-core`. | +| `implementations/main.cpp` | The `imsd` daemon shell: GLib main loop, GDBus service, sockets, subprocesses, the registration sequence and the run loop that pumps the core. | +| `implementations/media.cpp` | `imsd-media`, the per-call RTP leg. Pure std + POSIX, dlopen's the AMR codecs. | +| `implementations/dialerd.cpp` | `imsd-dialerd`, the Plasma Mobile session-bus backend (`org.kde.telephony.*`). | +| `implementations/ofonod.cpp` | `imsd-ofonod`, the GNOME Calls system-bus backend (`org.ofono`). | +| `tests//main.cpp` | The seven unit-test suites: `Util`, `Aka`, `Ipsec`, `Messages`, `Engine`, `Sip`, `Sdp`. | +| `packaging/` | systemd units, the bring-up and upgrade scripts, D-Bus and polkit policies, presets, the gschema override, `APKBUILD.binary` and the CI helpers (§9). | +| `.forgejo/workflows/package.yml` | CI: builds the aarch64 apks on every push to `main` and publishes them to the Forgejo Alpine registry (§9.4). | +| `bin/`, `build/` | crafter-build's outputs and intermediates. Ignored. | + +Also ignored by `.gitignore`, and never to be committed: `*.raw`, `*.rtp`, +`*.stats`, `imsreg.state`, `imsd-*.tar.gz` — captures and runtime state carry +the subscriber's IMSI/MSISDN and network addresses. + +## 3. Build products + +Six products come out of the tree. C++26 modules throughout, `import std;` +in every source but the re-exporting `Imsd.cppm`, clang + libc++ + lld. + +| Product | Kind | Built from | Links | Notes | +|---|---|---|---|---| +| `imsd-core` | static library | the `Imsd` module and its seven partitions | — | Pure `import std` C++, no GLib, no I/O side effects: every message, digest, SA command and call-state decision is here and unit-testable on any dev box without a bus, a modem or a phone. | +| `imsd` | executable | `implementations/main.cpp` | `imsd-core`, `imsd-bus`, gio-2.0, pthread | The daemon. Default product. | +| `imsd-bus` | static library | `interfaces/ImsdBus.cppm` | gio-2.0 | The polkit gate (`net.catcrafts.IMS1.Voice`) imsd and imsd-ofonod both link. A translation daemon that runs as root on the system bus reaches imsd as root, which polkit always allows, so it gates its own callers through this or it is a bypass. | +| `imsd-media` | executable | `implementations/media.cpp` | `-ldl -lpthread` | The RTP/AMR data plane, spawned once per call. No GLib, no core. A separate process for crash isolation and for the far-end-hangup exit-code contract (§7.6). `-- --product=media`. | +| `imsd-dialerd` | executable | `implementations/dialerd.cpp` | gio-2.0 | Plasma Mobile integration: a session daemon owning `org.kde.telephony.{CallUtils,DeviceUtils,UssdUtils}`, bridging to imsd. GDBus translation only. A separate process because those are per-user session-bus names a root system daemon cannot own. `-- --product=dialerd`. | +| `imsd-ofonod` | executable | `implementations/ofonod.cpp` | `imsd-bus`, gio-2.0 | GNOME Calls integration: a system daemon owning `org.ofono`, presenting imsd as an oFono modem. GDBus translation plus the polkit gate on its own callers. `-- --product=ofonod`. | + +The rule behind the split: **`imsd-core` stays GLib-free and free of I/O so +that every decision in it has a unit test.** New protocol logic goes into a +partition with a test. The shell (`main.cpp`) holds protocol decisions no +test reaches — some older than 0.3.10, some added by it: the reg-event +subscription schedule (§7.4: its retry +ladder, Retry-After clamp, Timer F, renewal point and RFC 6665 reasons); the +registration sequence and its transactions (`AwaitFinal`/`AwaitInitialFinal`: +which message is a REGISTER's final, the dispatch of requests arriving +meanwhile and the routing of our reg-event SUBSCRIBE's final read there, the +12 s wait restarted on a provisional; `Reregister`'s one re-challenge); the +inputs to `RegRecovery` — `SipFlow::Connect`'s split of failures into local +refusals and network silence, and the catch sites that sort a failed refresh +(the keepalive's, `ReconnectCycle`'s) and a failed fresh attempt +(`FreshRecover`, `DeferBringUp`) into Refused, Silent or Throttled with the +status, the Retry-After and `saReplaced_`, the bring-up resume's catch (a +refusal's Retry-After fed as a hold, the old registration written off after +any failed resume), the `OnLink` read that writes the old registration off +when its address has left the netdev (`FreshRecover` after a failed move, +`AddressGone`), and `PacketServiceUp`'s read of +ModemManager before a silent cycle is counted; the shell's reaction to a NOTIFY +terminating our binding (the flow closed at once, or kept under a call and +closed when the call ends, a reconnect cycle's or the bring-up resume's wait +ended by it — the reducer's verdict is pinned, the reaction is not); and the +holds file's codec (`PersistHolds`/`LoadHolds`: aging by boot seconds, the +pdn/boot match, defaults for absent keys — proven on the phone only, captures +45- and 70- of 2026-09-25-polkit-voice). For the schedule, a reducer next to +`RegRecovery`, with a Subscription-State parser in `Imsd:Sip` pinned by +`tests/Sip`, is the follow-up that moves it; a pure format/parse pair in +`Imsd:Engine` is the holds file's. + +## 4. Building + +### 4.1 Native, with crafter-build + +crafter-build is the Crafter.Build Flatpak. It carries its own clang, lld and +libc++, and its SDK (org.freedesktop.Sdk, from Flathub) has the gio-2.0 +headers, so the host needs Flatpak and nothing else: + +```sh +flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo +flatpak remote-add --if-not-exists catcrafts https://flatpak.catcrafts.net/catcrafts.flatpakrepo +flatpak install catcrafts net.catcrafts.CrafterBuild//stable +mkdir -p ~/.local/bin +printf '#!/bin/sh\nexec flatpak run net.catcrafts.CrafterBuild//stable "$@"\n' > ~/.local/bin/crafter-build +chmod +x ~/.local/bin/crafter-build +``` + +`crafter-build` is a script and not an alias so that the name also resolves in +a shell that has not defined the alias (a fresh `bash -c`, a CI step); +`~/.local/bin` has to be on `PATH` (any directory on it will do). The +sandbox does not see the host's `/tmp`: keep the checkout under `/home`, or +grant its directory with `flatpak run --filesystem=`. + +```sh +crafter-build # bin/imsd----/imsd +crafter-build -- --product=media # bin/imsd-media-…/imsd-media +crafter-build -- --product=dialerd # bin/imsd-dialerd-…/imsd-dialerd +crafter-build -- --product=ofonod # bin/imsd-ofonod-…/imsd-ofonod +crafter-build test # the seven unit-test suites +crafter-build lint # the house-style rules (§4.2, §6) +``` + +Output directories under `bin/` are named `----`; +the `./bin/imsd-*/imsd` globs in §8.3 rely on that. + +A host-native binary built this way links the Flatpak's libc++ through an +rpath into `/app/lib`: run it inside the sandbox (`flatpak run --command=sh +net.catcrafts.CrafterBuild//stable`, with `--socket=session-bus` for §8.3's +daemons — the sandbox's own session bus is a filter that refuses their names; +`crafter-build test` runs the suites there +anyway). A sysroot +build (§4.3) gets no such rpath — it links the sysroot's libc++. + +### 4.2 The lint rules, a dependency of project.cpp + +`project.cpp` imports the house-style rules (§6) from their own repository: + +```cpp +#pragma crafter dependency "https://forgejo.catcrafts.net/Catcrafts/Crafter.LintRules.git" commit "" +import Crafter.LintRules; +``` + +crafter-build clones that repository — and updates the clone on later loads — +and builds it for the host whenever it loads `project.cpp`: every build does +this, CI's included, not only `lint`. The commit is pinned, so a change to +the rules reaches imsd when the pin moves. `LintRules::AddRules` registers +the rules on each product's configuration. + +```sh +crafter-build lint # report every finding; exit 1 on any +crafter-build lint 'naming' # one rule (the argument is a glob) +crafter-build format # apply the transform rules in place +``` + +Six rules read the AST (`naming`, `no-char-pointer`, `const-local`, +`constexpr-constant`, `fixed-width-types`, `single-declaration`) and need the +module PCMs: build the four programs and the tests first. With crafter-build +2.1.0 they still do not run on 2 of the 18 files, `implementations/main.cpp` +among them (`'type_traits' file not found`). + +### 4.3 Cross-compiling for a phone (aarch64 Alpine / postmarketOS) + +```sh +packaging/make-sysroot.sh # one-time: Alpine aarch64 sysroot from the CDN (edge) +crafter-build -- --target=aarch64-alpine-linux-musl \ + --sysroot=$HOME/.cache/imsd/sysroot-aarch64-alpine \ + --march=armv8.6-a+fp16fml+aes+sha3+sm4 --mtune=cortex-a720 +# `crafter-build test` with the same flags fails: its runner is qemu-aarch64, +# which the Flatpak's sandbox does not have. Test host-native (§4.1). +``` + +`make-sysroot.sh` needs no root, qemu or device: it downloads +`apk-tools-static` and the Alpine keys and populates a minimal rootfs (musl, +libc++ with its std module source, glib and their headers, compiler-rt, +libunwind, gcc for its +`crtbeginS.o`/libgcc, which clang's `*-alpine-linux-musl` driver links +against). In a cross build `project.cpp` does not ask the host's pkg-config +(it would answer for the wrong architecture); it adds `-I=/usr/include/glib-2.0` +and `-I=/usr/lib/glib-2.0/include`, which clang resolves inside the sysroot. +crafter-build compiles the std module against the sysroot's libc++ headers +from the sysroot's own source — Alpine's `llvm-runtimes`, the same release +as its libc++, which `make-sysroot.sh` installs (`libc++-dev` ships none); +run over an existing sysroot the script upgrades its packages (`apk add +--upgrade`). +Without that source it falls back to the toolchain's source for the same libc++ +major release, then to the sources Crafter.Build carries for other +releases. This is the flow CI runs (§9.4). + +## 5. Tests + +`crafter-build test` runs the seven suites. Each suite +is a plain `main()` with a `Check(bool, message)` counter: failures print +`FAIL: …`, success prints `: all tests passed`, and the exit status is +the failure count for six suites — non-zero short of a multiple of 256 — and +1 for `Engine`. `crafter-build test` ending in `7 passed`, on every +commit of a range (§11), is the project's definition of green. + +What the suites pin: + +- `Util` — tokens, IPv6 detection and host/port formatting, hex/base64, MD5. +- `Aka` — the AKAv1-MD5 digest, identity derivation, the `qmicli`/`mmcli` + text parsers, the AUTHENTICATE APDU and its response parser. +- `Ipsec` — the exact `ip xfrm` argv lists for the SA pairs, the + Security-Server mechanism selection, the SA reader. +- `Messages` — every outgoing SIP message byte-for-byte, cross-generated from + the Python reference stack imsd replaced. +- `Sip` — the text layer's edge cases (empty header values, line anchoring, + keepalive discards), `ResponseOf`, refused finals with reason headers, + reginfo documents. A change that flips one of those tests is a behaviour + change, not a cleanup. +- `Sdp` — the offer's exact bytes, the parser, `BuildAnswer` at the offer's + own payload types and octet-align mode, the `CODECS` override. +- `Engine` — the call state machine driven through recorded network dialogs + (reliable 18x/PRACK, 2xx ACK + media start, far-end hangup, CANCEL/answer + races, busy/error finals, the terminating side's 100/180/200/486/487/488, + G.711-only and AMR-NB gateway offers, ring timeout, BYE both ways, the + emergency chain) and `RegRecovery` (thresholds, backoffs, the throttle + schedule, Retry-After, which waits a Dial may cut short, when the old + registration counts as gone, the holds a restart takes over, a binding the + network terminated). + +Rules: + +- A decision in `imsd-core` gets a test in the suite that owns the partition. + Fixtures are recorded network traffic where any exists, inline fixtures + only where no recording exists. +- Behaviour that lives in the shell (`main.cpp`: timers, the reg-event + subscription schedule, the audio-session resolution, the hand-over scripts) + is proven on a phone where a bench can reach it; the commit message says + in one sentence that it was tested, and the arms no bench has reached are + named here. The known gaps: the reg-event subscription schedule (the + arming and KPN's + `noresource` closure have run on hardware, the 60 s retry only on a + pre-fix build that renewed that closure; the retry on this code, the + renewal firing, Timer F, the doubling, a SUBSCRIBE `Retry-After`, the + renew arm and the back-off's survival across a refresh have run nowhere — + §3, §7.4); a `Retry-After` on a REGISTER and a `Dial` bringing a recovery + step forward (KPN's refusals, all 403, carry none; imsd-ofonod refuses the + `Dial` itself and imsd-dialerd forwards it — pinned in `tests/Engine` + only); a refused initial or fresh + REGISTER (403, 5xx) at bring-up or in recovery (KPN has only throttled or + stayed silent — pinned in `tests/Engine` only); the write-off of the old + registration by a recovery's fresh attempt that fails past the SA setup + (such attempts are on record only as a bring-up's fatal exit, which does + not pass that arm — pinned in `tests/Engine` only); a refused + refresh counted + towards the escalation, and the escalation by two of them (KPN refuses a + refresh only with the 403 that says the registration is gone — pinned in + `tests/Engine` only); of the throttled-attempt chain in recovery (a + throttled fresh attempt, the old SA retried once meanwhile with the stored + binding's Contact and refused 403, the deferred attempt accepted at the + hold's end — run 2026-10-03 on the series as it then stood) the deferred + attempt made by the + same process, since a restart inside the hold made it from the holds file + instead (the same process made it on 2026-09-24, on the build before the + policy's rework); the address moves + writing `old_gone` to the holds file (the move benches predate that + field); a SUBSCRIBE final read inside a REGISTER wait (KPN answers within + the second); the shell's handling of a NOTIFY that terminates our own + binding (§3; every captured termination is a previous contact's O2 + `deactivated`, and a deregistration during a call cannot be staged); + `SipFlow::Connect`'s errno arms — socket() failing, a UDP connect() + error, a bind() refused while the address is still on the link — which + no bench reaches without fault injection (§3); a coverage gap on the air + (no bench stages a radio gap without an RF shield) — the packet-service + read's rule has run through a fake `mmcli` only (2026-10-03: `searching` + and `detached` not counted, `connecting` with `unknown` counted), its + other states, ModemManager not answering and its second site (a refresh + unanswered over a reconnected flow) not at all, and `tests/Engine` pin + nothing of it; the REGISTER wait's diverting arms — a datagram skipped on + the init port, a response to another transaction ignored, a provisional + restarting the wait, a request (an INVITE among them) dispatched and + answered under the wait; an incoming call answered from a session's + dialer since the polkit gate — imsd-dialerd's `Accept` by the active + session, or GNOME Calls' `Answer` through imsd-ofonod's gate (imsd's own + `Accept` gate has run: refused for an account without a seat session and + for nobody, accepted for root, 2026-10-04); a refused + final carrying reason headers (KPN's 403s carry none — the rendering is pinned + in `tests/Sip` only); an `AUDIO_USER` pin in imsd's own environment that + names no account (the logind fallback) or an account without a PipeWire + socket (a silent call) — a pin naming an account with one ran, 2026-10-04. + `DetectLocal`'s three-tier address ranking for a run without `LOCAL` + (§10.1) ran through a fake `ip` (2026-10-05: the phone's listing of the + netdev and two cut-down ones). +- `imsd-media --selftest` round-trips the RFC 4867 payload packing (both + formats, both AMR codecs) and the G.711 tables. `MIC_SRC=` (raw s16 + PCM at the codec rate, paced in real time) and `PCM_DUMP=1` are test seams + for driving the leg against a synthetic RTP peer without a phone. +- `imsd --session` and `imsd-ofonod --session` own their names on the session + bus for development without root, polkit or a phone (§8.3). + +## 6. Code style and lint + +The rules are the Catcrafts house style, +[Crafter.LintRules](https://forgejo.catcrafts.net/Catcrafts/Crafter.LintRules), +pulled in by `project.cpp` (§4.2); that repository's README lists the +seventeen of them. `// lint-disable-file ` (by convention on line 4, +after the SPDX header and a blank line) and `// lint-disable-next-line ` +suppress a rule where a file needs it; nearly every source file disables +`fixed-width-types`. + +The tree was written under an earlier, project-local copy of the rules and +does not pass the shared ones yet: `crafter-build lint` reports 72 findings +(2026-10-06) — `naming` 44 (constants and statics are PascalCase here, the +shared rule wants camelCase), `const-local` 10 (a rule the copy did not +have), `single-declaration` 2 (not reported under the copy), `format-concat` +13 and `wrap-join` 3 (as under the copy). CI does not run lint. + +Beyond the rules: + +- Comments explain constraints the code cannot show — a network's observed + behaviour, a spec clause, the reason a shape was chosen — and name their + evidence (a dated bench, a capture) when they rest on one. They do not + narrate what the code does. +- Log lines are the operator's interface (§7.8). A new state the daemon can + be in gets a journal line that names it; existing lines are grepped for by + scripts (`imsd-handover.sh` reads a pre-0.3.10 daemon's `bring-up deferred: … + next fresh registration in N min`), so changing one is an interface change. +- GLib only in `main.cpp`, `dialerd.cpp`, `ofonod.cpp` and `ImsdBus.cppm`. + `media.cpp` is std + POSIX. The partitions are std only. + +## 7. How the daemon works + +### 7.1 Processes and buses + +`imsd` runs as root on the system bus as `net.catcrafts.IMS1` (object +`/net/catcrafts/IMS1`, interface `net.catcrafts.IMS1`; the frozen ABI, §8). +The bus policy `packaging/net.catcrafts.IMS1.conf` lets any local account +reach it; placing, answering and ending a call is polkit's +`net.catcrafts.IMS1.Voice` (`packaging/net.catcrafts.IMS1.policy`, shaped like +ModemManager's Voice action: the active local session and root — not an ssh +login, an inactive session or a system service account). Reads (`GetStatus`, +`GetCalls`) stay open, as ModemManager's are to every local account: the call +signals reach them all anyway, the status (the address, the P-CSCF, the +phone number) travels in no signal. + +Two threads: a dedicated engine thread runs the SIP loop (blocking framed +receive on the protected client flow, the protected-port listeners for +terminating requests, media reaping, the keepalive refresh); the GLib main +loop owns the bus. D-Bus method calls push commands to the engine queue; +engine events reach the bus through `g_idle_add`, where the calls table and +the status snapshot live (main thread only). + +Per answered call imsd forks `imsd-media` (`IMSD_MEDIA`, default next to the +`imsd` binary, else `/usr/libexec/imsd-media`) with the addresses, ports and +payload type as arguments and the codec and audio knobs in its environment +(§7.6, §10.2). + +The desktop daemons are translation layers: `imsd-dialerd` runs as the +session user (autostarted from `/etc/xdg/autostart/imsd-dialerd.desktop` in +place of plasma-dialer's `modem-daemon`, whose autostart the package hides +through `/etc/skel/.config/autostart/org.kde.modem.daemon.desktop`); +`imsd-ofonod` runs as root on the system bus (`imsd-ofonod.service`, +`Conflicts=ofono.service`). Both consume the frozen ABI and add nothing to it. + +### 7.2 Bring-up + +`imsd.service` runs two `ExecStartPre=` steps before the daemon: + +1. `/usr/libexec/ims-pdn-up.sh` brings the `ims` PDN up through ModemManager + (§7.3) and writes `/run/imsd.env` with `DEV=` (the connected netdev) and + `LOCAL=` (the PDN address ModemManager assigned). +2. `/usr/libexec/imsd-handover.sh` (`ExecStartPre=-`, failure-tolerant) hands + a pre-0.3.10 daemon's throttle wait over (§7.5). + +The unit then reads `EnvironmentFile=-/run/imsd.env` and +`EnvironmentFile=-/etc/imsd.env` (the admin's file wins) and starts +`/usr/bin/imsd`. systemd reads those files anew for every command it spawns, +so each `ExecStartPre=` already sees the values the previous one wrote. +`TimeoutStartSec=900` covers the bring-up's worst case (a 2-minute modem wait, +a 5-minute registration gate, connect retries). `Restart=on-failure`, +`RestartSec=120`; `KillMode=mixed` with `TimeoutStopSec=10`; `StateDirectory=imsd` +(`/var/lib/imsd`). + +The daemon's own bring-up (`BringUp()` in `main.cpp`): + +1. `PCSCF` must be set (no P-CSCF discovery from the PDN's PCO yet). +2. `qmicli -d qrtr://0 --uim-get-card-status` finds the ready USIM (slot, AID); + `mmcli -m any` gives the primary SIM path and the IMEI (for the + `+sip.instance` URN); `mmcli -i ` gives IMSI and MCC/MNC, from which + the IMPI/IMPU/realm are derived. `mmcli --location-get` fills + `P-Access-Network-Info`. +3. **Warm resume** when an SA pair is already in the kernel and the state + file (`STATE_FILE`, default `/var/lib/imsd/imsreg.state`) describes the + registration: reconnect through the existing SA and send a refresh + re-REGISTER (`RESUME via existing SA`, then `REGISTERED (resumed + + true-refreshed)`). This avoids the network's fresh-SA throttle. `RESUME=0` + forces a fresh registration. A resume the network refuses is not fatal: the + fresh registration replaces its SAs. +4. **Fresh registration** otherwise (`FRESH registration`): an unprotected + REGISTER on `PCSCF_PORT` (UDP, always), the 401 with the AKA challenge and + the `Security-Server` list, the AKA run over a UIM logical channel + (`--uim-open-logical-channel`, `--uim-send-apdu`), the SA pair installed + with `ip xfrm`, the protected REGISTER over the negotiated transport + (§10.1, `SIP_TRANSPORT`), `REGISTERED (fresh)`. Then the reg-event + SUBSCRIBE (§7.4). +5. A keepalive re-REGISTER refresh at half the granted lifetime, within + 120–1800 s (`REFRESH_INTERVAL`), only while idle. + +The state file is JSON: `callid`, `ftag`, `cseq`, `spi_uc`, `spi_us`, +`expiry`, `route`, `ppi`, `aor`, `contact_user`, `transport`. + +A failed fresh bring-up exits non-zero and systemd retries after +`RestartSec` — except a bring-up the network refused or throttled, which +waits in process on the refusal schedule (§7.4) with nothing to reconnect +over, then tries again (`bring-up deferred: …`). A registered daemon whose +address leaves the netdev with no PDN address there to move to also exits +(`engine fatal: … is no longer on qmapmux0.0 — exiting for systemd +restart`) — not at once: the loss is +noticed at the next recovery step, the refresh (every 1800 s on KPN's +7200 s grant), whose +re-REGISTER gets no reply and whose reconnect cannot bind the address (dev +phone 2026-10-02, the ims bearer disconnected under a registered daemon: +14 min from the disconnect to the exit). After `RestartSec` (120 s) +`ims-pdn-up.sh` runs again and imsd registers on the PDN's address (the +same bench: a new prefix, `REGISTERED (fresh)` six seconds into the new +start). + +### 7.3 The ims PDN (`packaging/ims-pdn-up.sh`) + +Finds or creates the `ims` bearer through ModemManager, connects it and +configures the muxed netdev. Idempotent; a non-zero exit fails the unit. + +- Waits up to 120 s for a modem, then gates the connect attempts on network + registration (`IMS_REG_TIMEOUT`, default 300 s): some carriers attach ~2 min + after boot. +- Never creates or connects a bearer while the modem is `disabled` — its + radio is off (airplane mode): a modem still disabled after the wait, or + disabled when an attempt is due, makes the script exit non-zero (`modem … + disabled … (radio off): not connecting`) and systemd retries. A bearer + connect on a disabled modem fails with `no-service` and leaves + ModemManager's state `enabled` with the modem in low power; switching the + radio back on then no longer brings it up (dev phone 2026-10-06: not + registered after 150 s, against 5 s without the attempt; a ModemManager + disable/enable recovered it). +- Connects by **3GPP profile index** (`IMS_PROFILE_ID`, default: looked up by + APN in `qmicli --wds-get-profile-list=3gpp`), not by APN string: a + profile-indexed request is exempt from the modem's attach-family forcing + that refuses an APN-string IPv6 request when the attach PDN was granted + IPv4-only (Odido, O2 UK, Swisscom). An empty profile list is the modem still + coming up; a list without the APN is final only once the modem is + registered (the list can be partial while it enables). A leftover + APN-string bearer is never reused and is deleted when connecting by index. +- Sets `accept_ra_defrtr=0` on the netdev before link-up: the PDN's router + advertisement must never make the ims PDN the phone's IPv6 default route (on + IPv4-only wifi every AAAA-first client would send its SYNs into the IMS + walled garden and hang). The RA's prefix and MTU still apply. An RA default + route already present (an imsd restart) is flushed. +- Sets the PDN MTU ModemManager reports on the link (1280 on the FP6), adds + the address, waits out DAD, and installs a host route to `PCSCF` + (`dev `, no gateway — a raw-IP netdev without header_ops). The PDN + is IPv6-only, so `PCSCF` must be an IPv6 address; anything else on the IMS + network (its DNS servers, a traceroute target) needs its own host route + (`ip -6 route add dev qmapmux0.0`). imsd installs a host route per + media peer itself. +- Opens imsd's protected ports 45061–45062 (TCP and UDP) on `qmapmux*` in + nftables when a `inet filter` table exists (pmOS's default INPUT chain drops + inbound on `qmapmux*`, which silently killed every network-initiated + request after ESP decapsulation). Best-effort. +- Writes `/run/imsd.env`: `DEV=`, `LOCAL=`. imsd + registers on the address the network assigned, not on the kernel's + temporary SLAAC address from the RA, which the netdev lists first and + retires after about two days (the SAs and the Contact would go with it). + +Its own settings (read from the environment, else from `/etc/imsd.env` for +manual runs): `IMS_APN` (default `ims`), `IMS_IP_TYPE` (`ipv6`), +`IMS_PROFILE_ID` (empty = look up; `none` = connect by APN string), +`IMS_REG_TIMEOUT` (300), `PCSCF`. + +### 7.4 Registration recovery (`RegRecovery`, `Imsd-Engine.cppm`) + +What the daemon does when the registration stops working after bring-up, as +a pure reducer the shell feeds with the clock and the network's answers. Two +kinds of failure reach it: a **refusal** (the network answered a refresh or a +fresh REGISTER with a final other than 200/401) and **silence** (the flow died +and reconnecting through the SA, or the refresh over it, got no answer). +Neither is retried for long on the same registration (TS 24.229 5.1.1.4 and +RFC 5626 have the UE register anew). Before this reducer existed the daemon +looped reconnect + re-REGISTER on one Call-ID and SA forever (O2 UK, +2026-09-24: 21 × 503 over 70 min, then 33 unanswered reconnect cycles over +2 h 39 min). + +The policy, with its constants: + +| Step | Rule | +|---|---| +| Reconnect | Through the existing SA, then refresh. Back-off 5 s → 5 min (`ReconnectDelayMin`/`Max`). | +| Escalate to a fresh registration | After two refused refreshes (`RefusalsBeforeFresh`) or two reconnect cycles that end without an answer (`SilentCyclesBeforeFresh`) while the modem reports packet service (`PacketServiceUp`: ModemManager's registration and packet-service state, as `ims-pdn-up.sh` reads them). A cycle that fails during a coverage gap is the phone's silence, not the network's: not counted, retried on the local-refusal pace, so a radio gap across a refresh does not escalate into a fresh registration, which KPN refuses while the registration is live and drops it for (§13). New Call-ID, new challenge, new SA pair (`registering fresh (recovery attempt N)`). | +| A refused or throttled fresh attempt | Waits on the **throttle schedule**: 21 min, growing ×1.5 to 60 (`ThrottleMinutesMin`/`Max`). KPN refuses a fresh SA pair on an address whose registration is live (§13), and drops that registration the moment an initial REGISTER arrives. | +| A silent fresh attempt | Backs off 30 s → 10 min (`FreshDelayMin`/`Max`). | +| `Retry-After` | Always wins over a shorter wait, honoured up to an hour (`RetryAfterMax`, the schedule's longest wait); a longer one is cut to an hour and the refusal heard again then. On a refused fresh attempt it replaces the schedule's wait, shorter or longer, never below 30 s (`FreshDelayMin`). | +| A 403 | A refusal like the others: never hammered (the schedule), never abandoned (a SIM that is unbarred registers within the hour). | +| The old registration is gone | A 403 once a fresh registration is due, a fresh attempt that got past the SA setup, a binding the network terminated (reg-event), an older address that left the netdev, or a bring-up whose resume failed: no reconnect over the old SA from then on. Otherwise the existing SA is still retried on the reconnect back-off while a fresh registration is deferred. | +| A `Dial` while unregistered | Brings the next recovery step forward (the reconnect, or the fresh attempt once it is due) — never a wait the network imposed (a `Retry-After`, or the schedule after a refused or throttled fresh attempt). imsd-ofonod refuses a `Dial` then and imsd-dialerd lists no modem. | +| Success | Resets everything. | + +The network's waits are **holds**, and a restart of imsd (an upgrade restarts +it) does not cut them short: `PersistHolds` writes them to the holds file +(`HOLDS_FILE`, default `/run/imsd.holds`) as JSON — `pdn` (the PDN's own +address), `boot` (the boot id), `written_at` (seconds of uptime), `hold`, +`fresh_hold`, `throttle_minutes` (the schedule's place), `old_gone` (the old +registration is gone: a 403 once a fresh registration is due, an attempt past +the SA setup, a binding the network terminated, an older address that left +the netdev, and every bring-up that goes on to register fresh — its resume +failed or there was nothing to resume) — and `LoadHolds` takes them +over at the next start for the same `pdn` and boot id, waiting them out +before the resume (a refresh's `Retry-After`) or before the fresh +registration (a fresh attempt's schedule or `Retry-After`, a terminated +binding's wait); with `old_gone` set the start does not resume over the old SAs at +all, it waits the fresh hold out and registers fresh — also after a start +during an outage, whose resume and fresh attempt both fail without an answer: +the next start then registers fresh while the network still holds the old +registration, which KPN refuses (§13; dev phone 2026-10-04: 21 min where +0.3.9's retried resume would have recovered at once). A file without the key +reads as not gone; the 0.3.9 hand-over (`imsd-handover.sh`) writes the key as +true, since the wait it hands over follows a throttled initial REGISTER, at +which KPN drops the old registration (§13). A reboot or another PDN address +starts clean. The schedule's place is kept +even after the holds run out, so a restart does not start it over at 21 min. +When nothing is held the file is removed — when it is a regular file; +`/dev/null` switches the knob off and anything that is not a regular file is +emptied, never unlinked. The recovery policy's waits and the holds are +measured on the boot clock (`CLOCK_BOOTTIME`), so a suspend counts; the +keepalive refresh gate, the CRLF keepalive and the reg-event retry and +renewal timers run on the steady clock and count awake seconds only. + +The **reg-event subscription** (RFC 3680; the network's own view of the +binding) is one live dialog per registration, subscribed anew ahead of the +lifetime the S-CSCF granted. Refused or unanswered, it is retried after 60 s +doubling to 10 min (a `Retry-After` honoured up to an hour; a registration +refresh neither brings a scheduled retry forward nor starts its back-off +over — the back-off resets when a subscription starts afresh or is +accepted). When the S-CSCF ends it +for a reason that allows renewal (RFC 6665: `deactivated`, `timeout`, +`giveup`, `probation`) it is renewed; after any other reason — `noresource`, +`rejected`, a reason-less end — the next registration refresh subscribes anew. An +earlier subscription's NOTIFY is still read for the binding's state, but its +end is not ours to renew (KPN closes a registration's previous subscription +with `noresource` when a new one replaces it). A NOTIFY that **terminates the +binding** triggers a fresh registration — at once, or after the network's +`retry-after` (up to an hour); after the call, when one is up (one exception: +a REGISTER wait that still gets its 200 after such a NOTIFY, a call having +kept the flow, leaves that registration standing). After +`rejected` and `unregistered` it waits on the refusal schedule, a wait no +`Dial` cuts short: the binding is gone, so nothing is retried meanwhile. One +termination counts once, however many NOTIFYs report it (a repeated +`probation` report may extend its hold from its own `retry-after`). The schedule is +shell code in `main.cpp`, not a reducer (§3); its hardware coverage per arm +is in §5. + +A registration resumed on an older address (a pre-0.3.10 temporary one) +moves to the PDN's own address in place: with its next fresh registration, or +when the older address leaves the netdev first, which takes that registration +with it. + +### 7.5 Upgrades and the 0.3.9 hand-over + +An apk upgrade (or an `apk add` of a package whose daemon an `apk del` left +running) restarts `imsd` and `imsd-ofonod` once the transaction is done: the +packages' `post-upgrade`/`post-install` scripts mark the units +`needs-restart` (`systemctl set-property … Markers=+needs-restart`, after a +`daemon-reload` when the unit is not-found because its file was just +reinstalled) and pmOS's systemd trigger runs `enqueue-marked`. A call in +progress ends. The restart resumes the registration over its existing SAs, +or goes on waiting out a hold the network had set (§7.4). A stopped unit +keeps no marker and `enqueue-marked` leaves it stopped (dev phone, systemd +262~rc3, 2026-10-03). + +A daemon older than 0.3.10 kept its bring-up throttle wait in memory only, so +the restart registered fresh at once instead of waiting the schedule out. +For that one hop: `imsd.post-upgrade` records the running +daemon's `InvocationID` in `/run/imsd.handover` (when no holds file exists); +at the next start `imsd-handover.sh` reads that finished invocation's last +bring-up line from the journal and, when it is the throttle's (`bring-up +deferred: fresh-SA throttle active (spi-s=0) — … next fresh registration in +N min`; the old daemon's other deferrals, after a 401 it could not use, are +not handed over), ages it on the monotonic clock (`__MONOTONIC_TIMESTAMP` against +`/proc/timer_list`; the phone's wall clock can read 1970 until its first NTP +sync, and the monotonic clock stops in a suspend, so the wait can only come +out long, never short of the network's window), and writes what is left to +the holds file as 0.3.10 keeps it — for the address this start registers on +when that is the address the wait was earned on (the old daemon logged its +`LOCAL=` once at start); on another address — the normal hop, 0.3.9 on the +kernel's temporary address and 0.3.10 on the PDN's own, which holds no +registration (§13) — only the verdict that the old registration is gone is +written and the new start registers fresh at once (dev phone 2026-10-03: +accepted, registered 16 s after 0.3.9's refused attempt) — +`LOCAL` as systemd hands it to the unit's commands (the `/etc/imsd.env` pin +if there is one, else what `ims-pdn-up.sh` just wrote), unquoted the way imsd +itself gets it. Reading at the start rather than at the upgrade means a wait +that ended in between — the old daemon making another attempt — is +read as it ended. When the old address cannot be read the wait is handed +over anyway; when the wait cannot be timed, or this start has no `LOCAL`, the +journal says it was not handed over. + +### 7.6 Calls and the media plane + +`CallMachine` owns one call's SIP dialog, outgoing or incoming, and never +touches a socket, a clock or a subprocess: each event returns `Action`s for +the shell (send this on the client flow, answer that request on the channel +it came in on, start/stop the media leg, emit this D-Bus state change, arm +this deadline). States and reasons are the frozen D-Bus vocabulary: +`dialing` / `ringing` / `incoming` / `active` / `terminated`, with reasons +`outgoing` / `incoming` / `accepted` / `local-hangup` / `remote-hangup` / +`refused-or-busy` / `error`. + +- Outgoing: INVITE with the SDP offer (AMR-WB + AMR + telephone-event by + default; `CODECS` restricts and orders; `PRECOND=1` adds IR.92 + preconditions), reliable 18x with PRACK, 2xx ACK and media start, CANCEL + before answer, BYE after. Busy and error finals end the call with + `refused-or-busy` or `error`. +- Incoming: an INVITE on a protected-port listener is validated before it is + announced to the UI; 100 and 180 (reliable when the caller requires + 100rel); `Accept` sends the 200 with the SDP answer at the offer's own + payload types and octet-align mode; a remote CANCEL gets 200 + 487; an + unusable offer 488 naming what was offered; ring timeout 480. A landline + gateway's G.711-only or AMR-NB offer rings and is answered at its own + payload types. +- Emergency: a number `IsEmergency` classifies (112, 911, plus + `EMERGENCY_NUMBERS`; SIM `EF_ECC` is not read yet) is tried as + `urn:service:sos` first, with one fallback to a plain INVITE of the digits; + a plain INVITE answered `380 Alternative Service` with an emergency body is + re-attempted as `urn:service:sos`. +- `SendDtmf` is a stub (RFC 4733 not implemented). +- Incoming calls reach imsd only when the network's T-ADS routes the call to + the IMS domain; the REGISTER Contact advertises the MMTEL ICSI and + `+sip.instance` for that. When T-ADS still picks CS, no INVITE arrives — + a network/modem-integration matter, not a daemon defect. + +`imsd-media` is one process per call: `imsd-media + `. It binds the advertised +RTP port (`RTP_PORT`, default 50004), sends uplink frames from `pw-record` +through the encoder, captures the downlink, and with `PLAY=1` reconstructs +the media clock from RTP timestamps so `pw-play` stays real-time-paced +through far-end DTX silence. Codecs: AMR-WB (16 kHz, the default), AMR +narrowband and G.711 PCMA/PCMU (8 kHz, what a PSTN gateway offers); AMR +frames ride RFC 4867 payloads, octet-aligned or bandwidth-efficient +(`OCTET_ALIGN`, mirrored from the SDP). The AMR codecs are dlopen'd +(`libvo-amrwbenc` + `libopencore-amrwb` for WB, `libopencore-amrnb` for NB); +G.711 is a table. **Exit code 3 means the downlink dried up** for +`MEDIA_TIMEOUT` seconds (default 6.0) — the far-end-hangup signal, which on +carriers whose BYE never reaches our SAs is the reliable teardown trigger. +Per-call stats land in `OUT_DIR` (default the state dir); `RTP_DUMP=1` +captures the raw downlink next to them. + +**Audio session.** Call audio goes through the PipeWire of one account. +imsd resolves it for each call from logind: the user session active on +seat0 (`loginctl show-seat seat0`; a greeter does not count) when it has a +`/run/user//pipewire-0` socket, and passes it to `imsd-media` as +`AUDIO_USER`. `AUDIO_USER` set in imsd's own environment pins an account +instead; one that is not an account falls back to logind. imsd as packaged +(root, on the system bus) logs the resolution at start and when a call's +media starts (`audio session: …`), `imsd-media` once per call. An `imsd-media` started without the variable at all (by hand, or +by an imsd older than 0.3.10 while an upgrade replaces it) tries the +account `user`, its old default. + +### 7.7 Runtime files + +| Path | Written by | Content | +|---|---|---| +| `/etc/imsd.env` | the admin | configuration (§10); `PCSCF=` is required. The apk adds a drop-in with `ConditionPathExists=/etc/imsd.env` (§9.2), so an unconfigured phone does not boot into a failing unit | +| `/run/imsd.env` | `ims-pdn-up.sh` | `DEV=`, `LOCAL=` of the connected PDN | +| `/run/imsd.holds` (`HOLDS_FILE`) | imsd, `imsd-handover.sh` | the network's holds and the schedule's place (§7.4) | +| `/run/imsd.handover` | `imsd.post-upgrade` | the InvocationID of the daemon an upgrade replaces, written when no holds file exists (§7.5); consumed by `imsd-handover.sh` | +| `/run/imsd-ofonod.started` | `imsd-ofono-restart-calls.sh` | marker: imsd-ofonod has started once since boot | +| `/var/lib/imsd/imsreg.state` (`STATE_FILE`) | imsd | the registration context for a warm resume | +| `/var/lib/imsd/*.stats`, `*.rtp`, `*.raw` | imsd, imsd-media | per-call media stats, raw RTP (`RTP_DUMP`), SIP dumps (`DUMP_SIP`; mode 0600 — they carry the IMSI/MSISDN) | +| `$XDG_STATE_HOME/imsd/` (else `~/.local/state/imsd/`), `$XDG_RUNTIME_DIR/imsd.holds` (else `/tmp/imsd.holds`) | imsd | the same files for a non-root (`--session`) run | + +### 7.8 Journal vocabulary + +Lines the operator (and the scripts) look for, all prefixed `imsd: `: + +- `LOCAL=… P-CSCF=…`, `SIP transport: …`, `SIM slot=… IMSI=… domain=…` at start. +- `RESUME via existing SA (cseq N)`, `REGISTERED (resumed + true-refreshed)`, + `FRESH registration`, `Security-Server: …`, `REGISTERED (fresh)`. +- `bring-up deferred: …; next: fresh registration in N s` — one line; `in N + min` from a minute (the pre-0.3.10 form `bring-up deferred: … next fresh + registration in N min` is what `imsd-handover.sh` reads from an older + daemon's journal), `a hold the network set before this start stands (…)`, + `the refusal schedule stands at N min from before this start: …`. +- `…; next: fresh registration in N s` (`in N min` from a minute, `now`), + `registering fresh (recovery attempt N)`, + `fresh registration deferred/refused/failed: …`, `reconnected + re-registered`, + `recovered by a fresh registration`. +- `the registration's SAs are on X, not the PDN's own address Y: resuming + there until the next fresh registration`, `the fresh registration moves + from X to the PDN's own address Y`, `back on X: the old registration stands + until the next fresh attempt`, `X has left DEV: the registration on it is + gone; staying on Y; next: …`, `X is no longer on DEV: the registration on + it is gone; on the PDN's own address Y now; next: …`. +- `engine fatal: … — exiting for systemd restart`. +- `reg-event SUBSCRIBE sent`, and the binding's state from NOTIFY. +- `audio session: …` at start and at each call's media start; `CODECS override active: …`. +- A refused REGISTER or SUBSCRIBE is logged with its status line and + any `Retry-After` / `Warning` / `Reason` / `Server` headers. +- `connect: no answer from the P-CSCF N times (…)` when the protected + connect series ends unanswered. + +From the scripts: `ims-pdn-up: …`, `imsd-handover: …`, +`imsd-ofono-restart-calls: …`. + +## 8. D-Bus ABI and desktop integration + +### 8.1 `net.catcrafts.IMS1` (frozen) + +Object `/net/catcrafts/IMS1`, interface `net.catcrafts.IMS1`. The ABI is +frozen — the two translation daemons depend on it, and a new desktop needs a +new translation daemon, not a change here. + +| Member | Signature | +|---|---| +| `Dial(number)` | `s → s` (callUni) | +| `HangUp(callUni)` | `s` | +| `Accept(callUni)` | `s` | +| `SendDtmf(callUni, tones)` | `ss` | +| `GetCalls()` | `→ aa{sv}` | +| `GetStatus()` | `→ a{sv}`: `registered` (b), `local` (s), `pcscf` (s), `expiry` (x), `cseq` (x), `ppi` (s) | +| `CallAdded` | `s a{sv}` | +| `CallStateChanged` | `s s s` (callUni, state, reason) | +| `CallDeleted` | `s` | +| `RegistrationChanged` | `b` | + +`Dial`, `HangUp`, `Accept` and `SendDtmf` are gated by polkit (§7.1); a +refused caller gets `org.freedesktop.DBus.Error.AccessDenied`, and polkit not +answering is logged with the sender. A translation daemon that runs as root +on the system bus must gate its own callers through `imsd-bus`, or it is a +bypass of `net.catcrafts.IMS1.Voice`. + +### 8.2 Plasma Mobile (`imsd-dialerd`) + +A drop-in for plasma-dialer's `modem-daemon`, owning the same session-bus +names and objects (`org.kde.telephony.CallUtils` at +`/org/kde/telephony/CallUtils/tel/mm`, likewise `DeviceUtils` and +`UssdUtils`; the `/tel/mm` suffix is hard-coded in plasma-dialer's QML plugin +and kde-telephony-daemon — keep it). plasma-dialer, kde-telephony-daemon and +spacebar run unmodified. ABI details verified against plasma-dialer 6.7.3 are +in the header of `implementations/dialerd.cpp` (`CallData` marshalled as a +struct wrapping `a{sv}`, struct-wrapped int32 enums in MMCallState numbering, +an empty callUni meaning the active call, `callsChanged` with the full vector +on every add/delete). It lists no modem while imsd is unregistered. + +### 8.3 GNOME Calls, Phosh, GNOME Mobile (`imsd-ofonod`) + +Presents imsd as an oFono modem: `org.ofono` with a `Manager` at `/`, one +`Modem` + `VoiceCallManager` at `/imsd`, and a `VoiceCall` object per live +call — what GNOME Calls' bundled `ofono` provider plugin drives; Calls, Phosh +and GNOME Shell run unmodified. `org.ofono` is the seam because it is the +only one a third party can implement: `org.gnome.Calls.Call` is what Calls +*exports* so a shell can observe and control a call that already exists +(Accept, Hangup, SendDtmf, Silence — no Dial), and Phosh consumes it from +Calls — its call UI needs nothing extra once Calls has a working origin; +Calls' own backends are libpeas provider plugins (`mm`, `ofono`, `sip`, +`dummy`) against private headers no distro installs. +Consumer-shaped details verified against calls 50.0 are in the header of +`implementations/ofonod.cpp`. Verified on a Fairphone 6 (postmarketOS, +dbus-broker) with GNOME Calls 50.0: outgoing and incoming calls with audio +both ways, answered and hung up through `org.gnome.Calls.Call` — the surface +Phosh drives. The answered calls predate the polkit gate (§5). + +Setup: `apk add imsd-ofono` enables `imsd-ofonod.service` by preset and ships +a gschema override setting `org.gnome.Calls autoload-plugins` to `['ofono']` +(the per-user equivalent is `gsettings set org.gnome.Calls autoload-plugins +"['ofono']"`). Drop `mm` rather than adding `ofono` alongside it: the +ModemManager provider would offer the modem's CS voice path as a second +origin, and on a phone whose voice path is imsd that origin carries no audio. +Never run it beside a real `ofonod` (one name, one owner; +the unit has `Conflicts=ofono.service` and the daemon exits if it loses the +name). Running `imsd-dialerd` at the same time is fine (different bus, +different consumer). One dbus-broker +detail is load-bearing and lives in `packaging/org.ofono.conf`: dbus-broker +checks a broadcast against the sender's send policy for every name the +receiver owns, so root (imsd) must be allowed `send_destination="org.ofono"` +or imsd's call signals never reach the daemon. + +Known gaps, all inherited rather than introduced: + +- USSD is refused with `org.ofono.Error.NotSupported` and not advertised in + the modem's `Interfaces`: the IMS stack has no USSD path. +- No hold, multiparty, transfer or call waiting (`org.ofono.Error.NotImplemented`): + imsd is a single-call stack. +- Per-call caller-ID withholding is refused rather than silently ignored: + imsd cannot honour a CLIR request, and failing is safer than dialling with + the caller ID exposed. +- Phosh's emergency-call screen lists nothing: `imsd-ofonod` publishes + `org.ofono.VoiceCallManager.EmergencyNumbers` (112, 911, plus + `EMERGENCY_NUMBERS`), oFono's standard place for it, but Calls' ofono + provider never reads it: it makes no `GetProperties` call on the VoiceCallManager + and returns NULL for the origin's `emergency-numbers`, which is what + `org.gnome.Calls.EmergencyCalls.GetEmergencyContacts` is built from (the + ModemManager provider fills its equivalent via + `mm_sim_dup_emergency_numbers()`). An upstream fix would close this. + Dialling an emergency number from the dialpad is unaffected — that reaches + imsd's own classifier. +- DTMF is forwarded to imsd, where `SendDtmf` is a stub — the same + limitation Plasma has. +- Restarting `imsd-ofonod` restarts GNOME Calls with it: when the modem's + `VoiceCallManager` interface goes away and comes back, Calls 50.0 adds a + new origin but leaves the old origin's `CallAdded` handler connected, so + after one such cycle every call is added twice, and after a few the stale handler runs on + freed memory and Calls dies with SIGBUS in `g_hash_table_lookup` (measured + on the FP6). Calls-side; an upstream fix belongs in + `plugins/provider/ofono/calls-ofono-provider.c`. To keep imsd's own + restarts and registration drops from triggering it, the + `VoiceCallManager` interface is listed permanently (`Online` + tracks registration; `Dial` fails with "imsd is not registered" / "imsd is + not running"), and only this daemon's own restart cycles the origin — after + which Calls dials nothing until it restarts, so the unit's `ExecStartPost` + (`imsd-ofono-restart-calls.sh`) restarts the Calls daemon of every user + running one. The first start since boot leaves a running Calls alone. + +Development without a phone or root (crafter-build's binaries run inside the +sandbox: §4.1): + +```sh +./bin/imsd-*/imsd --session & # imsd's ABI on the session bus +./bin/imsd-ofonod-*/imsd-ofonod --session +busctl --user call org.ofono / org.ofono.Manager GetModems +``` + +## 9. Packaging, CI and releases + +### 9.1 The APKBUILD + +`packaging/APKBUILD.binary` is **the** packaging since 2026-09-02: it wraps +a crafter-build binary cross-compiled per §4.3 (bundled by +`packaging/make-bin-tarball.sh` together with the units, scripts and +policies) into the apks CI publishes. `pkgver` is taken from the `Version` +constant in `implementations/main.cpp` by `build-package.sh`. +Subpackages: `imsd-ofono` (the GNOME Calls backend, opt-in) and +`imsd-systemd` (the unit files; `install_if` systemd — ordered after +`imsd-ofono` because `systemd()` amoves all of `usr/lib/systemd/system`). +There is no from-source recipe: a build needs crafter-build (§4.1). + +It declares `provides="81voltd=$pkgver-r$pkgrel"`: this satisfies +`soc-qcom-modem`'s dependency and excludes the real package, because 81voltd +serves the modem's own ims-PDN requests, races imsd for the PDN and flaps it +with a new prefix every ~2.5 min — two IMS stacks cannot share one PDN. +`depends` lists `modemmanager`, the AMR codec libraries, `pipewire-tools` and +`polkit` (the gate fails closed without it). There is no OpenRC service: the +unit's sequencing is only tested under systemd. + +### 9.2 What the package installs and enables + +`imsd.service` is enabled by preset (`80-imsd.preset`, plus the +`multi-user.target.wants` link) and gated by a drop-in +`ConditionPathExists=/etc/imsd.env`, so VoLTE survives reboots without a +manual `systemctl enable` and an unconfigured phone does not boot into a +failing unit. `imsd-ofonod.service` is enabled the same way by `imsd-ofono`. +All of that is the apk's doing (`APKBUILD.binary`'s `package()`). +The main package hides kde-telephony's `modem-daemon` autostart for accounts +created after it is installed (`/etc/skel/.config/autostart/org.kde.modem.daemon.desktop`) +because `imsd-dialerd` owns those names. + +### 9.3 Install scripts + +`imsd.post-upgrade`, `imsd-ofono.post-upgrade` and `imsd-systemd.post-upgrade` +mark their units `needs-restart` (§7.5); `imsd.post-upgrade` also records the +old invocation for the 0.3.9 hand-over. Each `*.post-install` is a symlink to +its `post-upgrade`: `apk del` leaves the daemon running, so a later `apk add` +is an upgrade for that daemon and apk runs post-install for it. A first +install finds no unit active and marks nothing. `imsd-systemd` marks +`imsd.service` too, since it ships the unit file and apk installs it after +`imsd`. + +### 9.4 CI and what a push means + +`.forgejo/workflows/package.yml` runs on every push to `main` (and on +dispatch): on the `flatpak` runner label (a privileged runner registered to +this repository — bubblewrap inside the job container needs the privilege), in an +`alpine:edge` container, `packaging/build-package.sh` installs the +Crafter.Build Flatpak (`net.catcrafts.CrafterBuild//stable` from +flatpak.catcrafts.net — the branch Crafter.Build publishes from its release +tags, where `beta` follows every push to its master; the build log names its +commit) and llvm/abuild, builds +the sysroot, cross-compiles the four products, runs the suite natively inside +the Flatpak, wraps the result with `APKBUILD.binary`, and the publish step +uploads the apks to the Forgejo Alpine registry +(`…/api/packages/Catcrafts/alpine/edge/fp6`) with `PACKAGE_TOKEN`. The +registry answers 409 for an already-published version and the step treats +that as "nothing to do", so **a push releases exactly when the `Version` +constant changes**. This is the only producer of the imsd apk: fp6-img +images install the published package from the registry, and +installed phones upgrade with plain `apk upgrade`. **A push to `main` is a +release to real users' phones.** A release is a range of commits ending in a +bump commit titled `imsd X.Y.Z` that changes only the `Version` constant. + +## 10. Configuration reference + +Everything is environment variables. The packaged unit reads +`/run/imsd.env` (from `ims-pdn-up.sh`) and then `/etc/imsd.env` (the user's, +wins on conflict). The variables are user documentation: the tables and the +minimum `/etc/imsd.env` are in the README. + +### 10.1 imsd + +The README's first table. 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. + +### 10.2 imsd-media (per call, set through imsd's environment) + +The README's second table. Two pairs are not configuration and are left out +there: + +| Variable | Default | Meaning | +|---|---|---| +| `CODEC`, `OCTET_ALIGN` | *(from the SDP)* | set by imsd from the negotiated answer; not configuration | +| `MIC_SRC`, `PCM_DUMP` | *(unset)* | test seams (§5) | + +### 10.3 ims-pdn-up.sh and imsd-ofonod + +`IMS_APN`, `IMS_IP_TYPE`, `IMS_PROFILE_ID`, `IMS_REG_TIMEOUT` and `PCSCF` for +the bring-up script (§7.3). `imsd-ofonod` reads `EMERGENCY_NUMBERS` (from +`/etc/imsd.env` through its unit) and `IMSD_BUS=session|system` to override +only the imsd side of its bus choice. + +## 11. Making changes + +- **Commit messages.** Subject `area: what changes` — the area is the module + partition, program, script or `packaging`/`ci`/`tests`/`README` the change + lives in (several joined by commas), the rest a short lowercase clause + with no trailing period, imperative or declarative. The body says why — + the field incident or the spec clause — and, where the change alters + behaviour, in one sentence how it was tested. A version bump is a commit + titled `imsd X.Y.Z` that changes only the `Version` constant and has no + body. +- **No AI attribution trailers.** Commits carry no `Assisted-by:`, + `Co-Authored-By:` or similar lines, whoever or whatever drafted them. +- **Signed commits.** Every commit is GPG-signed with the repository's + configured identity (`git config user.signingkey`, `commit.gpgsign`). Do + not override the identity from the environment. +- **One change, one commit; a fix goes into the commit that introduced the + problem** while a range is unpublished (rebase), never as a follow-up on + top. Every commit of a range builds and passes the seven suites on its own. +- **Keep `make-bin-tarball.sh`'s file list and `APKBUILD.binary`'s + `package()` in sync** when a packaged file is added. +- **The README and this file follow the code in the same commit.** A new + knob gets its row in the README's configuration tables; a new state its journal line in §7.8; a new file + under `/run` or `/var/lib` its row in §7.7. +- **Compatibility notes are kept where the mechanism lives**, not in a + changelog the repository does not have: the 0.3.9 hand-over is documented + in §7.5 because `imsd.post-upgrade` ships in every later package and the + path stays live for any direct upgrade from an older daemon. +- **Never commit captures or state** (`*.raw`, `*.rtp`, `*.stats`, + `imsreg.state`): they carry the subscriber's identity. + +## 12. Security considerations + +- imsd runs as root (xfrm, qmicli). Its attack surface on the bus is the + frozen ABI; the writes are polkit-gated (§7.1, §8.1). Do not add an + ungated write member, and do not add a root translation daemon that + forwards calls without gating its own callers through `imsd-bus`. +- The bus policies deliberately let any local account *reach* both names; + authorization is polkit's, not the policy file's. Keep it that way: a + policy that names an account breaks the moment the account is called + something else. +- SIP dumps and the state file carry the IMSI, the MSISDN, SPIs and + addresses. Dumps are mode 0600 under the state dir, off by default + (`DUMP_SIP=0`), and never leave the phone in a commit. +- The ims PDN is a walled garden: `ims-pdn-up.sh` keeps it from becoming + the default route and gives the P-CSCF a host route; imsd adds one per + call for the media peer (§7.3). Nothing else is routed over it. + A change that routes anything else over it needs a reason and a bench. +- The fixed protected ports (45061/45062) are opened in nftables for + `qmapmux*` only, by the bring-up script. + +## 13. Carrier and platform facts the code relies on + +Observed on real networks and encoded in the policy; cite them when touching +the related code. + +- **KPN's fresh-SA throttle** — the `bring-up deferred: fresh-SA throttle + active (spi-s=0)` case — refuses a fresh SA pair on an address while the + registration on that address is live, whatever its age or last refresh: + refused 11 s, 23 min and 2 h 58 min into a registration, 4 and 6.6 min + after its last refresh (2026-09-19, -20, -24, 2026-10-03). KPN drops that + registration the moment the initial REGISTER arrives, throttled or not, + and with it gone accepts a fresh pair there: 5 and 21 min after the + throttled attempt (2026-10-03). An address that holds no registration is + accepted while another address's is live (2026-09-26, 2026-10-03), and a + new prefix's PDN address was accepted 15 s after a throttled attempt on + the temporary one (the 0.3.9 → 0.3.10 hop, 2026-10-03). An address whose + registration another address's had replaced was refused 26 s after, + accepted 141 s and 10 min after (2026-10-03): how soon KPN accepts where a + registration has just gone is not measured more closely. The schedule's + 21 min is the wait that has never been refused, not a measured minimum; + imsd resumes rather than registers fresh wherever it can, since on a live + registration's address the attempt is refused and costs the registration. +- **O2 UK, 2026-09-24:** a refresh refused 21 × 503 over 70 min, then 33 + unanswered reconnect cycles; a fresh registration was accepted in one + second. The origin of `RegRecovery`. +- **KPN closes a registration's previous reg-event subscription with + `noresource`** when a new one replaces it: that end is not renewed. +- **Modem attach-family forcing** (Odido, O2 UK, Swisscom): an APN-string + IPv6 bearer request is refused when the attach PDN was granted IPv4-only; + a profile-indexed request is not. +- **pmOS's default nftables** drops inbound on `qmapmux*`; without the + protected-port rule every network-initiated request (NOTIFY, MT INVITE) + dies after ESP decapsulation and MT calls fall back to CS. +- **The kernel's temporary SLAAC address** on the ims netdev is listed first + and retired after about two days; a registration bound to it dies with it. +- **systemd reads `EnvironmentFile=` per spawned command** (systemd 262~rc3 + on the FP6, 2026-10-03: an `ExecStart` saw what its `ExecStartPre` had + just written), and unquotes a quoted value — which is why the hand-over script + takes `LOCAL` from its environment rather than parsing the files itself. +- **GNOME Calls 50.0** crashes after an oFono origin withdraw/re-add cycle + and dials nothing after `org.ofono` restarts until it restarts itself. +- **dbus-broker** checks broadcasts against the sender's send policy for + every name the receiver owns (the `org.ofono.conf` detail). +- **The phone's wall clock reads 1970 until the first NTP sync**; anything + that ages a journal line uses the monotonic or boot clock. diff --git a/Makefile b/Makefile deleted file mode 100644 index a72e15e..0000000 --- a/Makefile +++ /dev/null @@ -1,157 +0,0 @@ -# SPDX-License-Identifier: GPL-3.0-only -# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® -# -# Distro-packaging build path. project.cpp (crafter-build) remains the -# canonical build description; this Makefile mirrors it for environments -# where crafter-build isn't packaged — same clang module pipeline, same -# flags. Keep the two in sync when products, module partitions or link -# flags change. -# -# make -> build/make/{imsd,imsd-media,imsd-dialerd,imsd-ofonod} -# make check -> build + run the 7 unit-test suites -# make install -> DESTDIR/PREFIX staged install (binaries + systemd -# units, D-Bus policies, dialerd autostart, the GNOME -# Calls plugin override, ims-pdn-up.sh) -# -# Requires clang++ with libc++ and the libc++ std module sources -# (std.cppm — Alpine: llvm-runtimes, Arch: libc++). Override STD_CPPM if -# yours lives elsewhere. - -# make's builtin default is CXX=g++, and distro build environments (abuild) -# export CXX=c++ — both gcc, which has no --precompile. This build requires -# clang: the file assignment deliberately ignores the environment while still -# yielding to an explicit `make CXX=...`. -CXX = clang++ -AR ?= ar -PKG_CONFIG ?= pkg-config -STD_CPPM ?= /usr/share/libc++/v1/std.cppm - -O ?= build/make -PCM = $(O)/pcm -OBJ = $(O)/obj - -OPT ?= -O3 -# One flag set for every TU: BMIs are rejected on compiler-option mismatch, -# so per-target extras are limited to -I/-D (compile) and -l/-L (link). -ALL_CXXFLAGS = -std=c++26 -stdlib=libc++ $(OPT) $(CXXFLAGS) -ALL_LDFLAGS = -fuse-ld=lld $(LDFLAGS) - -# pkg-config gio-2.0: only -I/-D pass through — it also emits -pthread, -# which would config-mismatch the shared std BMI (project.cpp filters -# identically). pthread lives in libc on every platform we target. -GIO_CFLAGS := $(filter -I% -D%,$(shell $(PKG_CONFIG) --cflags gio-2.0)) -GIO_LIBS := $(filter -l% -L%,$(shell $(PKG_CONFIG) --libs gio-2.0)) -ifeq ($(strip $(GIO_LIBS)),) -GIO_LIBS := -lgio-2.0 -lgobject-2.0 -lglib-2.0 -endif - -# Imsd module partitions, plus the primary interface last. The BMI file -# names must stay -.pcm — that is how clang resolves -# `import :Part;` under -fprebuilt-module-path. -PARTITIONS = Imsd-Util Imsd-Sip Imsd-Sdp Imsd-Aka Imsd-Ipsec \ - Imsd-Messages Imsd-Engine -CORE_MODULES = $(PARTITIONS) Imsd -CORE_OBJS = $(CORE_MODULES:%=$(OBJ)/%.o) - -TESTS = Util Aka Ipsec Messages Engine Sip Sdp - -all: $(O)/imsd $(O)/imsd-media $(O)/imsd-dialerd $(O)/imsd-ofonod - -# ---- module BMIs ---------------------------------------------------------- - -$(PCM)/std.pcm: $(STD_CPPM) | $(PCM) - $(CXX) $(ALL_CXXFLAGS) -Wno-reserved-identifier \ - -Wno-reserved-module-identifier --precompile $< -o $@ - -$(PCM)/%.pcm: interfaces/%.cppm $(PCM)/std.pcm | $(PCM) - $(CXX) $(ALL_CXXFLAGS) -fprebuilt-module-path=$(PCM) --precompile $< -o $@ - -# partition import graph (see the `import :X;` lines in interfaces/) -$(PCM)/Imsd-Aka.pcm $(PCM)/Imsd-Ipsec.pcm: $(PCM)/Imsd-Util.pcm -$(PCM)/Imsd-Messages.pcm: $(PCM)/Imsd-Util.pcm $(PCM)/Imsd-Aka.pcm \ - $(PCM)/Imsd-Sip.pcm -$(PCM)/Imsd-Engine.pcm: $(PCM)/Imsd-Util.pcm $(PCM)/Imsd-Sip.pcm \ - $(PCM)/Imsd-Sdp.pcm $(PCM)/Imsd-Messages.pcm -$(PCM)/Imsd.pcm: $(PARTITIONS:%=$(PCM)/%.pcm) - -# ---- imsd-core (static lib of the pure-C++ engine modules) ---------------- - -$(OBJ)/%.o: $(PCM)/%.pcm | $(OBJ) - $(CXX) $(ALL_CXXFLAGS) -fprebuilt-module-path=$(PCM) -c $< -o $@ - -$(O)/libimsd-core.a: $(CORE_OBJS) - $(AR) rcs $@ $^ - -# ---- executables ----------------------------------------------------------- - -$(OBJ)/main.o: implementations/main.cpp $(PCM)/Imsd.pcm | $(OBJ) - $(CXX) $(ALL_CXXFLAGS) $(GIO_CFLAGS) -fprebuilt-module-path=$(PCM) \ - -c $< -o $@ - -$(O)/imsd: $(OBJ)/main.o $(O)/libimsd-core.a - $(CXX) $(ALL_CXXFLAGS) $(ALL_LDFLAGS) $^ $(GIO_LIBS) -lpthread -o $@ - -$(OBJ)/media.o: implementations/media.cpp $(PCM)/std.pcm | $(OBJ) - $(CXX) $(ALL_CXXFLAGS) -fprebuilt-module-path=$(PCM) -c $< -o $@ - -$(O)/imsd-media: $(OBJ)/media.o - $(CXX) $(ALL_CXXFLAGS) $(ALL_LDFLAGS) $^ -ldl -lpthread -o $@ - -$(OBJ)/dialerd.o: implementations/dialerd.cpp $(PCM)/std.pcm | $(OBJ) - $(CXX) $(ALL_CXXFLAGS) $(GIO_CFLAGS) -fprebuilt-module-path=$(PCM) \ - -c $< -o $@ - -$(O)/imsd-dialerd: $(OBJ)/dialerd.o - $(CXX) $(ALL_CXXFLAGS) $(ALL_LDFLAGS) $^ $(GIO_LIBS) -o $@ - -$(OBJ)/ofonod.o: implementations/ofonod.cpp $(PCM)/std.pcm | $(OBJ) - $(CXX) $(ALL_CXXFLAGS) $(GIO_CFLAGS) -fprebuilt-module-path=$(PCM) \ - -c $< -o $@ - -$(O)/imsd-ofonod: $(OBJ)/ofonod.o - $(CXX) $(ALL_CXXFLAGS) $(ALL_LDFLAGS) $^ $(GIO_LIBS) -o $@ - -# ---- tests ----------------------------------------------------------------- - -$(O)/tests/%: tests/%/main.cpp $(PCM)/Imsd.pcm $(O)/libimsd-core.a | $(O)/tests - $(CXX) $(ALL_CXXFLAGS) $(ALL_LDFLAGS) -fprebuilt-module-path=$(PCM) \ - $< $(O)/libimsd-core.a -lpthread -o $@ - -check: $(TESTS:%=$(O)/tests/%) - @fail=0; for t in $(TESTS); do \ - echo "== $$t"; $(O)/tests/$$t || fail=1; \ - done; exit $$fail - -# ---- install ---------------------------------------------------------------- - -PREFIX ?= /usr - -install: all - install -Dm755 $(O)/imsd $(DESTDIR)$(PREFIX)/bin/imsd - install -Dm755 $(O)/imsd-dialerd $(DESTDIR)$(PREFIX)/bin/imsd-dialerd - install -Dm755 $(O)/imsd-ofonod $(DESTDIR)$(PREFIX)/bin/imsd-ofonod - install -Dm755 $(O)/imsd-media $(DESTDIR)$(PREFIX)/libexec/imsd-media - install -Dm755 packaging/ims-pdn-up.sh \ - $(DESTDIR)$(PREFIX)/libexec/ims-pdn-up.sh - install -Dm644 packaging/imsd.service \ - $(DESTDIR)$(PREFIX)/lib/systemd/system/imsd.service - install -Dm644 packaging/imsd-dialerd.desktop \ - $(DESTDIR)/etc/xdg/autostart/imsd-dialerd.desktop - install -Dm644 packaging/net.catcrafts.IMS1.conf \ - $(DESTDIR)$(PREFIX)/share/dbus-1/system.d/net.catcrafts.IMS1.conf - install -Dm644 packaging/imsd-ofonod.service \ - $(DESTDIR)$(PREFIX)/lib/systemd/system/imsd-ofonod.service - install -Dm644 packaging/org.ofono.conf \ - $(DESTDIR)$(PREFIX)/share/dbus-1/system.d/imsd-ofono.conf - install -Dm644 packaging/90_imsd-ofono.gschema.override \ - $(DESTDIR)$(PREFIX)/share/glib-2.0/schemas/90_imsd-ofono.gschema.override - install -Dm644 packaging/80-imsd-ofono.preset \ - $(DESTDIR)$(PREFIX)/lib/systemd/system-preset/80-imsd-ofono.preset - -$(PCM) $(OBJ) $(O)/tests: - mkdir -p $@ - -clean: - rm -rf $(O) - -.PHONY: all check install clean diff --git a/README.md b/README.md index 826eb6a..3116d23 100644 --- a/README.md +++ b/README.md @@ -5,306 +5,137 @@ Userspace IMS/VoLTE daemon for mainline Linux phones. -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 +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 — Plasma Dialer, or GNOME Calls under Phosh -and GNOME Mobile — can place and receive real VoLTE calls on a commercial -network. +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 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: +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). -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. +Verified by community members to be fully working: -## Architecture - -C++26 modules, built with Crafter Build. Five 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). -- **imsd-ofonod** (executable) — the GNOME Calls integration, for Phosh and - GNOME Mobile: a system daemon owning `org.ofono` and presenting imsd as an - oFono modem, which is the seam GNOME Calls' bundled `ofono` provider plugin - drives. GDBus translation only, no core; built with `-- --product=ofonod`. - See [GNOME Calls (Phosh, GNOME Mobile)](#gnome-calls-phosh-gnome-mobile). - -### Desktop integration - -imsd itself is desktop-agnostic: everything a dialer needs is on the system -bus as `net.catcrafts.IMS1`. Each desktop gets a small translation daemon -speaking whatever backend protocol its dialer already consumes, so the dialer, -the shell and the call-history/contacts stack all stay unmodified. - -| Desktop | Daemon | Owns | Consumed by | -|---|---|---|---| -| Plasma Mobile | `imsd-dialerd` | `org.kde.telephony.*` (session bus) | plasma-dialer + kde-telephony-daemon, replacing `modem-daemon` | -| Phosh / GNOME Mobile | `imsd-ofonod` | `org.ofono` (system bus) | GNOME Calls' `ofono` provider plugin | - -Phosh's own call UI needs nothing extra: it consumes `org.gnome.Calls`, which -GNOME Calls exports once it has a working origin. - -### 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. - -## 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 by community members to be fully workking: - -| Device | OS | Carrier +| 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 | -## Build +## What it needs -[Crafter Build](https://forgejo.catcrafts.net/Catcrafts/Crafter.Build) + -gio-2.0 headers (`glib2` on Arch, `glib-dev` on Alpine). +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. -```sh -crafter-build # bin/imsd--/imsd (the daemon) -crafter-build -- --product=media # bin/imsd-media-.../imsd-media (the media leg) -crafter-build -- --product=dialerd # bin/imsd-dialerd-.../imsd-dialerd (Plasma backend) -crafter-build -- --product=ofonod # bin/imsd-ofonod-.../imsd-ofonod (GNOME Calls backend) -crafter-build test # unit tests (Util, Aka, Ipsec, Messages, Engine, Sip, Sdp) +## 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 ``` -### Building with make (distro packaging) - -For environments without crafter-build, the Makefile mirrors the same clang -module pipeline: GNU make, clang++ with libc++ and the libc++ std module -sources (`std.cppm` — package `llvm-runtimes` on Alpine, `libc++` on Arch), -lld, and gio-2.0 headers. +Configuration is environment variables in `/etc/imsd.env`; the one required +setting is the P-CSCF: ```sh -make # build/make/{imsd,imsd-media,imsd-dialerd,imsd-ofonod} -make check # the same 7 unit-test suites -make install # DESTDIR/PREFIX staged install incl. the packaging/ files -``` - -`project.cpp` stays the canonical build description; the Makefile tracks it. - -### 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.6-a+fp16fml+aes+sha3+sm4 --mtune=cortex-a720 -# 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. The bring-up never lets the ims PDN become the phone's IPv6 -default route: the PDN's router advertisement is not accepted as a default -router (`accept_ra_defrtr=0` on the netdev; its prefix and MTU still apply) -and the P-CSCF gets a host route instead, so on IPv4-only wifi the phone's -other IPv6 traffic (apk, browsers) is not sent into the IMS walled garden, -where it would hang. Media peers get a host route per call; anything else -on the IMS network (its DNS servers, a traceroute target) needs its own -(`ip -6 route add dev qmapmux0.0`). The PDN MTU ModemManager reports -is set on the link. 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` builds the apk from source via -the Makefile; `packaging/APKBUILD.binary` + `make-bin-tarball.sh` wrap a -cross-compiled build into an apk instead. - -## GNOME Calls (Phosh, GNOME Mobile) - -`imsd-ofonod` presents imsd on the system bus as an oFono modem — a Manager at -`/`, one Modem + VoiceCallManager at `/imsd`, and a VoiceCall object per live -call — which is what GNOME Calls' bundled `ofono` provider plugin drives. -Calls, Phosh and GNOME Shell run unmodified. - -`org.ofono` is the seam because it is the only one a third party can -implement. `org.gnome.Calls.Call` is what Calls *exports* so a shell can -observe and control a call that already exists (Accept, Hangup, SendDtmf, -Silence — there is no Dial); Phosh consumes it from Calls. Calls' own backends -are libpeas provider plugins (`mm`, `ofono`, `sip`, `dummy`) linked against -private headers that no distro installs. - -Setup: - -```sh -apk add imsd-ofono # enables imsd-ofonod via a systemd preset -systemctl enable --now imsd-ofonod # hand installs: system service, org.ofono is a system name -# point Calls at the ofono provider (the package ships this as a gschema -# override; this is the per-user equivalent) -gsettings set org.gnome.Calls autoload-plugins "['ofono']" -``` - -Drop `mm` from `autoload-plugins` rather than adding `ofono` alongside it: the -ModemManager provider would offer the modem's own CS voice path as a second -origin, and on a phone whose voice path is imsd that origin carries no audio. - -Do not run this alongside a real `ofonod` — there is one `org.ofono` name and -one owner; the unit declares `Conflicts=ofono.service` and the daemon exits if -it loses the name. Running `imsd-dialerd` at the same time is fine (different -bus, different consumer). - -Verified on a Fairphone 6 (postmarketOS, dbus-broker) with GNOME Calls 50.0: -outgoing and incoming calls with audio both ways, answered and hung up through -`org.gnome.Calls.Call` — the surface Phosh drives. One dbus-broker detail is -load-bearing and lives in the policy file: dbus-broker checks a broadcast -against the sender's send policy for every name the receiver owns, so root -(imsd) must be allowed `send_destination="org.ofono"` or imsd's call signals -never reach the daemon. - -Known gaps, all inherited rather than introduced: - -- **USSD is refused** with `org.ofono.Error.NotSupported`; the IMS stack has no - USSD path. It is deliberately not advertised in the modem's `Interfaces`. -- **No hold, multiparty, transfer or call waiting** — imsd is a single-call - stack, so those methods return `org.ofono.Error.NotImplemented`. -- **Per-call caller-ID withholding is refused** rather than silently ignored: - imsd cannot honour a CLIR request, and failing is safer than dialling with - the caller ID exposed. -- **Phosh's emergency-call screen lists nothing.** Not for want of the data: - `imsd-ofonod` publishes `org.ofono.VoiceCallManager.EmergencyNumbers` (112, - 911, plus `EMERGENCY_NUMBERS`), which is oFono's standard place for it. - Calls' ofono provider simply never reads it — it makes no `GetProperties` - call on the VoiceCallManager, and returns NULL for the origin's - `emergency-numbers`, which is what - `org.gnome.Calls.EmergencyCalls.GetEmergencyContacts` is built from. (The - ModemManager provider does read it, via `mm_sim_dup_emergency_numbers()`.) - An upstream fix would close this. Dialling an emergency number from the - normal dialpad is unaffected — that reaches imsd's own classifier. -- DTMF is forwarded to imsd, where `SendDtmf` is still a stub (RFC 4733 is not - implemented yet) — the same limitation Plasma has. -- **Restart GNOME Calls after restarting `imsd-ofonod` itself.** When the - modem's `VoiceCallManager` interface goes away and comes back, Calls 50.0 - adds a new origin but leaves the old origin's `CallAdded` handler connected: - after one such cycle every call is added twice, after a few the stale handler - runs on freed memory and Calls dies with SIGBUS in `g_hash_table_lookup` - (measured on the FP6). Calls-side; an upstream fix belongs in - `plugins/provider/ofono/calls-ofono-provider.c`. To keep imsd's own - restarts and registration drops from triggering it, `imsd-ofonod` lists the - interface permanently: `Online` tracks registration, and `Dial` fails with - "imsd is not registered" / "imsd is not running" instead. Only `org.ofono` - vanishing — this daemon restarting — still cycles the origin. - -Development, without a phone or root: - -```sh -./bin/imsd-*/imsd --session & # imsd's ABI on the session bus -./bin/imsd-ofonod-*/imsd-ofonod --session -busctl --user call org.ofono / org.ofono.Manager GetModems -# or keep org.ofono on the system bus and imsd on the session bus: -IMSD_BUS=session imsd-ofonod -``` - -## 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. +# your carrier's P-CSCF address, capture via: `adb shell dumpsys telephony.registry` on Android PCSCF=2001:db8::105 ``` -### imsd +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 — required)* | P-CSCF address; without it registration fails with a clear error | +| `PCSCF` | *(none — required)* | P-CSCF address (IPv6); 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` | +| `LOCAL` | *(auto)* | UE address. 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/` | 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 and whether the old registration is gone (`old_gone`), 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) | | `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 (`/var/lib/imsd/imsreg.state`) 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 | +| `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 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 | +| `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::kPortUc`/`kPortUs`) — changing them in source also requires +(`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 | -| `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) | +| `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 — dialer -integrations depend on it: +`net.catcrafts.IMS1` at `/net/catcrafts/IMS1`. The ABI is frozen. | Member | Signature | |---|---| @@ -319,8 +150,8 @@ integrations depend on it: | `CallDeleted` | `s` | | `RegistrationChanged` | `b` | -Both `imsd-dialerd` and `imsd-ofonod` are pure translations of this ABI; a new -desktop needs a new translation daemon, not changes here. +Both `imsd-dialerd` and `imsd-ofonod` are users of this ABI. +A new desktop needs a new translation daemon, not changes here. ## License diff --git a/implementations/main.cpp b/implementations/main.cpp index b8145e0..4ef4ff3 100644 --- a/implementations/main.cpp +++ b/implementations/main.cpp @@ -6,13 +6,15 @@ imsd — the userspace IMS/VoLTE daemon (control plane). Owns net.catcrafts.IMS1 on the system bus with the project's frozen D-Bus ABI -(README) and drives it with the engine core: register on the IMS PDN (USIM AKA +(AGENTS.md §8.1) and drives it with the engine core: register on the IMS PDN (USIM AKA + IPsec sec-agree, fresh or warm-resume, with a keepalive re-REGISTER refresh), run outgoing and incoming calls through the Imsd:Engine call machine (inbound INVITEs on the protected-port listeners become ringing calls a dialer answers via Accept), and spawn one imsd-media data-plane process per answered call. -GLib/GDBus lives only here; every message, digest, SA command, and call-state -decision comes from the GLib-free core. +GLib/GDBus lives only here and in ImsdBus (the polkit gate in front of call +control, shared with imsd-ofonod) — the integration daemons imsd-dialerd and +imsd-ofonod carry their own; every message, digest, SA command, and +call-state decision comes from the GLib-free core. Threads: a dedicated engine thread runs the SIP loop (blocking framed recv, protected-port listeners, media reaping, keepalive); the GLib main loop owns @@ -20,28 +22,33 @@ the bus. D-Bus method calls push commands to the engine queue; engine events reach the bus via g_idle_add, where the calls table + status snapshot live (touched only on the main thread). - --session own the name on the session bus (development; no policy needed) + --session own the name on the session bus (development; no policy, no + polkit) */ #include #include #include #include +#include #include #include #include +#include #include #include #include +#include #include import std; import Imsd; +import ImsdBus; namespace { // ---- static config (env-overridable, same knobs as imsd.py) --------------- -constexpr const char* Version = "0.3.9"; +constexpr const char* Version = "0.3.10"; constexpr const char* BusName = "net.catcrafts.IMS1"; constexpr const char* ObjPath = "/net/catcrafts/IMS1"; constexpr const char* Iface = "net.catcrafts.IMS1"; @@ -151,15 +158,112 @@ std::optional CompletedHex(std::string_view out) { return h; } -// global IPv6 on the ims PDN, from `ip -6 addr show dev scope global`. +// The UE address on the ims PDN when LOCAL is not given: a global IPv6 on +// that is neither temporary nor RA-derived (`dynamic`) — the one +// ims-pdn-up.sh assigned from ModemManager — else the first non-temporary +// one, else the first. The kernel lists the temporary (privacy) address +// first, and registering on it binds Contact, Via and the SAs to an address +// that is deprecated after a day and removed after two (ims ledger F18). std::optional DetectLocal(const std::string& dev) { auto r = RunCapture({"ip", "-6", "addr", "show", "dev", dev, "scope", "global"}); - std::size_t at = r.out.find("inet6 "); - if (at == std::string_view::npos) return std::nullopt; - std::size_t start = at + 6; - std::size_t end = r.out.find('/', start); - if (end == std::string_view::npos) return std::nullopt; - return r.out.substr(start, end - start); + std::optional first, nonTemporary, assigned; + std::size_t pos = 0; + while ((pos = r.out.find("inet6 ", pos)) != std::string::npos) { + std::size_t start = pos + 6; + pos = start; + std::size_t eol = r.out.find('\n', start); + if (eol == std::string::npos) eol = r.out.size(); + std::size_t slash = r.out.find('/', start); + if (slash == std::string::npos || slash > eol) continue; + std::string addr = r.out.substr(start, slash - start); + std::string_view flags = std::string_view(r.out).substr(slash, eol - slash); + bool temporary = flags.contains(" temporary"); + bool dynamic = flags.contains(" dynamic"); + if (!first) first = addr; + if (!temporary && !nonTemporary) nonTemporary = addr; + if (!temporary && !dynamic && !assigned) assigned = addr; + } + if (assigned) return assigned; + if (nonTemporary) return nonTemporary; + return first; +} + +// Whether `addr` is configured on `dev` now. Compared as bytes, so any +// spelling of the address matches; when the interface list cannot be read +// the answer is yes — nothing here acts on a guess that it is gone. +bool OnLink(const std::string& dev, const std::string& addr) { + int fam = Is6(addr) ? AF_INET6 : AF_INET; + unsigned char want[16] = {}; + if (inet_pton(fam, addr.c_str(), want) != 1) return false; + ifaddrs* ifs = nullptr; + if (getifaddrs(&ifs) != 0) return true; + bool found = false; + for (ifaddrs* i = ifs; i && !found; i = i->ifa_next) { + if (!i->ifa_addr || i->ifa_addr->sa_family != fam || dev != i->ifa_name) continue; + const void* have = fam == AF_INET6 ? static_cast(&reinterpret_cast(i->ifa_addr)->sin6_addr) + : static_cast(&reinterpret_cast(i->ifa_addr)->sin_addr); + found = std::memcmp(have, want, fam == AF_INET6 ? 16 : 4) == 0; + } + freeifaddrs(ifs); + return found; +} + +// The account whose PipeWire session carries call audio: AUDIO_USER when +// it names an account, else logind's seat0 session (`loginctl show-seat +// seat0` -> `show-session`) when that is a user session — not phrog's +// greeter — with a PipeWire socket. imsd-media gets the answer as +// AUDIO_USER. As packaged (root, on the system bus) imsd resolves it at +// start, so a wrong name or a missing session is in the journal before the +// first call rather than after two silent rounds of trial (alyx report 14, +// 2026-09-20), and again when a call's media starts, because the answer +// changes when someone logs in. +struct AudioSession { std::string name; unsigned uid = 0; std::string how; }; + +std::string LoginctlProp(std::string_view text, std::string_view key) { + for (auto line : std::views::split(text, '\n')) { + std::string_view l(line); + if (l.size() > key.size() && l.starts_with(key) && l[key.size()] == '=') return std::string(l.substr(key.size() + 1)); + } + return {}; +} + +AudioSession ResolveAudioSession() { + AudioSession s; + passwd pw{}; + passwd* res = nullptr; + char buf[4096]; + std::string want = EnvOr("AUDIO_USER", ""); + if (!want.empty()) { + if (getpwnam_r(want.c_str(), &pw, buf, sizeof buf, &res) == 0 && res) { + s.name = want; s.uid = res->pw_uid; s.how = "AUDIO_USER"; + return s; + } + Log(std::format("AUDIO_USER '{}' is not an account; using logind's seat0 session instead", want)); + } + auto seat = RunCapture({"loginctl", "show-seat", "seat0", "-p", "ActiveSession", "--value"}); + std::string id = seat.out; + while (!id.empty() && (id.back() == '\n' || id.back() == ' ')) id.pop_back(); + if (seat.rc != 0 || id.empty()) { s.how = "no session on seat0"; return s; } + auto sess = RunCapture({"loginctl", "show-session", id, "-p", "Name", "-p", "Class", "-p", "User"}); + std::string name = LoginctlProp(sess.out, "Name"), cls = LoginctlProp(sess.out, "Class"), uid = LoginctlProp(sess.out, "User"); + unsigned u = 0; + auto [end, err] = std::from_chars(uid.data(), uid.data() + uid.size(), u); + if (sess.rc != 0 || name.empty() || err != std::errc{} || end != uid.data() + uid.size()) { s.how = std::format("seat0 session {} not readable", id); return s; } + if (!cls.starts_with("user")) { s.how = std::format("seat0 session {} is {}'s {} session, nobody logged in", id, name, cls); return s; } + std::error_code ec; + if (!std::filesystem::exists(std::format("/run/user/{}/pipewire-0", u), ec)) { s.how = std::format("seat0 session {} ({}) has no PipeWire socket in /run/user/{}", id, name, u); return s; } + s.name = name; s.uid = u; s.how = std::format("logind seat0 session {}", id); + return s; +} + +std::string AudioSessionLine(const AudioSession& s) { + if (!s.name.empty()) return std::format("audio session: {} (uid {}, {})", s.name, s.uid, s.how); + return std::format("audio session: none — {}", s.how); +} + +void LogAudioSession() { + AudioSession s = ResolveAudioSession(); + Log(AudioSessionLine(s) + (s.name.empty() ? "; looked up again at each call, set AUDIO_USER to pin one" : "")); } // ---- sockaddr helper ------------------------------------------------------ @@ -188,11 +292,19 @@ constexpr std::string_view TransportName(Transport t) { return t == Transport::U // connect (O2 UK, 2026-09-17). Both run through the same SA pair — the xfrm // policies select on ports only — and the same Via port. // A P-CSCF refusing a fresh security association (KPN: Security-Server -// spi-s=0 to a fresh REGISTER made too soon after another; the window is -// ~20 min and every fresh attempt re-arms it). Handled in-process: a +// spi-s=0 to a fresh REGISTER on an address whose registration is still +// live). Handled in-process: a // systemd restart every RestartSec would never clear it. struct ThrottledError : std::runtime_error { using std::runtime_error::runtime_error; }; -constexpr int ThrottleWaitMin = 21; +// A REGISTER the network refused: the protected one, or a refresh, answered +// with a final other than 200 (a refresh's first 401 is its challenge); the +// unprotected initial one answered with a final from 300 up (its 401 is the +// challenge). The status and its Retry-After feed imsd::engine::RegRecovery. +struct RefusedError : std::runtime_error { + int status; + long retryAfter; + RefusedError(const std::string& what, int st, long ra) : std::runtime_error(what), status(st), retryAfter(ra) {} +}; // Bytes of SIP that fit one ESP-protected IPv6 packet at the ims PDN's // 1280-byte MTU (40 IPv6 + 8 ESP + 16 IV + 8 UDP + padding + 12 ICV). constexpr std::size_t UdpSinglePacketBudget = 1190; @@ -211,24 +323,30 @@ public: // one attempt. bool Connect(const std::string& local, int lport, const std::string& pcscf, int pport, Transport t, int tries = 10, int silentLimit = 0) { transport_ = t; + lastConnectLocal_ = false; int fam = Is6(local) ? AF_INET6 : AF_INET; if (t == Transport::Udp) { int fd = socket(fam, SOCK_DGRAM, 0); - if (fd < 0) return false; + if (fd < 0) return SocketFailed(); int one = 1; setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one); setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &one, sizeof one); sockaddr_storage la; socklen_t ll = MakeAddr(local, lport, la); - if (bind(fd, reinterpret_cast(&la), ll) != 0) { close(fd); return false; } + if (!Bind(fd, la, ll, local, lport)) return false; sockaddr_storage pa; socklen_t pl = MakeAddr(pcscf, pport, pa); - if (connect(fd, reinterpret_cast(&pa), pl) != 0) { Log(std::format("UDP connect: {}", std::strerror(errno))); close(fd); return false; } + if (connect(fd, reinterpret_cast(&pa), pl) != 0) { + // a UDP connect() cannot be refused by the peer: no route + // yet, and the like — local, like the TCP path's ENETUNREACH + Log(std::format("UDP connect: {}", std::strerror(errno))); + close(fd); lastConnectLocal_ = true; return false; + } fd_ = fd; alive_ = true; fb_ = {}; sizeWarned_ = false; return true; } int silent = 0; for (int attempt = 0; attempt < tries; attempt++) { int fd = socket(fam, SOCK_STREAM, 0); - if (fd < 0) return false; + if (fd < 0) return SocketFailed(); int one = 1; setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one); // REUSEPORT (paired with the listener's): a reconnect must bind @@ -236,7 +354,7 @@ public: // it in LISTEN state — REUSEADDR alone only covers TIME_WAIT. setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &one, sizeof one); sockaddr_storage la; socklen_t ll = MakeAddr(local, lport, la); - if (bind(fd, reinterpret_cast(&la), ll) != 0) { close(fd); return false; } + if (!Bind(fd, la, ll, local, lport)) return false; sockaddr_storage pa; socklen_t pl = MakeAddr(pcscf, pport, pa); // non-blocking connect + poll: Linux connect() ignores SO_SNDTIMEO, // so bound it ourselves (a silent P-CSCF must not hang the engine). @@ -259,21 +377,47 @@ public: return true; } close(fd); + lastConnectLocal_ = localRefusal; if (!localRefusal) silent++; - if (attempt == tries - 1) return false; - if (silentLimit > 0 && silent >= silentLimit) { - Log(std::format("connect: no answer from the P-CSCF {} times", silent)); - return false; - } // An answer (RST → ECONNREFUSED, ICMP → EHOSTUNREACH/ENETUNREACH) // counts towards the silent limit like a timeout: either way the // P-CSCF does not serve TCP on that port. The log says which. const char* why = err == ETIMEDOUT ? "no answer" : (err == EADDRNOTAVAIL ? "TIME_WAIT?" : std::strerror(err)); + if (attempt == tries - 1) { + Log(std::format("connect failed ({}) [{}/{}]", why, attempt + 1, tries)); + return false; + } + if (silentLimit > 0 && silent >= silentLimit) { + Log(std::format("connect: no answer from the P-CSCF {} times ({})", silent, why)); + return false; + } Log(std::format("connect failed; retry in 10s ({}) [{}/{}]", why, attempt + 1, tries)); sleep(10); } return false; } + // socket() failing is ours, not the network's: logged, and a local + // refusal like a connect() the kernel turns down. + bool SocketFailed() { + Log(std::format("connect failed: socket: {}", std::strerror(errno))); + lastConnectLocal_ = true; + return false; + } + // A bind the kernel refuses (the address is not on the link: + // EADDRNOTAVAIL) is logged and counts like the network's silence — it is + // not a transient refusal to retry every 5 s. Closes fd on failure. + bool Bind(int fd, const sockaddr_storage& la, socklen_t ll, const std::string& local, int lport) { + if (bind(fd, reinterpret_cast(&la), ll) == 0) return true; + int err = errno; + close(fd); + lastConnectLocal_ = false; + Log(std::format("connect failed: cannot bind {} ({})", imsd::util::HostPort(local, lport), std::strerror(err))); + return false; + } + // Whether the last failed Connect() ended on a local refusal (our own + // 4-tuple still in TIME_WAIT, no route yet) rather than on the network's + // silence or answer — the recovery policy counts only the latter. + bool LastConnectLocal() const { return lastConnectLocal_; } // MSG_NOSIGNAL: a flow the network killed (CSFB excursion, P-CSCF idle // drop) must surface as a send error, not a SIGPIPE. bool Send(std::string_view msg) { @@ -337,10 +481,13 @@ public: int Fd() const { return fd_; } bool Alive() const { return fd_ >= 0 && alive_; } Transport CurrentTransport() const { return transport_; } - void Close() { if (fd_ >= 0) { close(fd_); fd_ = -1; } alive_ = false; } + // What was read and not yet dispatched goes with the flow: nothing + // read from it can be answered once it is closed. + void Close() { if (fd_ >= 0) { close(fd_); fd_ = -1; } alive_ = false; fb_ = {}; } private: int fd_ = -1; bool alive_ = false; + bool lastConnectLocal_ = false; Transport transport_ = Transport::Tcp; bool sizeWarned_ = false; imsd::sip::FrameBuffer fb_; @@ -467,6 +614,7 @@ public: for (int f : udp_) close(f); for (auto& c : conns_) close(c.fd); listeners_.clear(); udp_.clear(); conns_.clear(); + udpUc_ = -1; } private: struct Conn { int fd; imsd::sip::FrameBuffer fb; }; @@ -589,6 +737,87 @@ std::int64_t NowEpoch() { return std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); } +// The recovery policy's holds, kept for the next process: the network's +// word (a Retry-After, the schedule after a refused or throttled fresh +// attempt, a terminated binding's wait) outlives a restart of imsd, and +// an upgrade restarts it. They are filed under the PDN's address — +// another one, or a reboot, starts clean — so the file names that +// address and the boot, and lives in /run. Seconds left at +// `written_at`, which is on the boot clock: no clock step moves it, and +// it runs through a suspend as the network's timers do. Whether the old +// registration is gone travels with them: a restart inside a hold must +// not resume over SAs the network has written off. +std::int64_t BootSeconds() { + timespec ts{}; + clock_gettime(CLOCK_BOOTTIME, &ts); + return ts.tv_sec; +} + +std::string BootId() { + std::string id; + if (std::FILE* f = std::fopen("/proc/sys/kernel/random/boot_id", "r")) { + char buf[64]; + id.assign(buf, std::fread(buf, 1, sizeof buf, f)); + std::fclose(f); + } + while (!id.empty() && std::isspace(static_cast(id.back()))) id.pop_back(); + return id; +} + +// The recovery policy runs on the same clock: a hold in the process and +// its copy in the file age alike, through a suspend as the network's +// timers do. +double Boot() { + timespec ts{}; + clock_gettime(CLOCK_BOOTTIME, &ts); + return static_cast(ts.tv_sec) + static_cast(ts.tv_nsec) / 1e9; +} + +std::string HoldsPath() { + if (const char* o = std::getenv("HOLDS_FILE")) return o; + if (geteuid() == 0) return "/run/imsd.holds"; + return std::format("{}/imsd.holds", EnvOr("XDG_RUNTIME_DIR", "/tmp")); +} + +void PersistHolds(const imsd::engine::RegRecovery::Held& h, const std::string& pdn) { + std::string path = HoldsPath(); + if (!h.Any()) { + // nothing to keep: the file goes — when it is ours. A device node or a + // link the HOLDS_FILE knob points at (/dev/null switches the file off) is + // emptied instead, never unlinked. + std::error_code ec; + auto st = std::filesystem::symlink_status(path, ec); + if (std::filesystem::is_regular_file(st)) std::remove(path.c_str()); + else if (std::filesystem::exists(st)) { if (std::FILE* f = std::fopen(path.c_str(), "w")) std::fclose(f); } + return; + } + std::string j = std::format("{{\"pdn\": \"{}\", \"boot\": \"{}\", \"written_at\": {}, \"hold\": {}, \"fresh_hold\": {}, \"throttle_minutes\": {}, \"old_gone\": {}}}\n", + pdn, BootId(), BootSeconds(), static_cast(std::ceil(h.hold)), static_cast(std::ceil(h.freshHold)), h.throttleMinutes, h.oldGone ? "true" : "false"); + if (std::FILE* f = std::fopen(path.c_str(), "w")) { + std::fwrite(j.data(), 1, j.size(), f); + std::fclose(f); + } +} + +std::optional LoadHolds(const std::string& pdn) { + std::FILE* f = std::fopen(HoldsPath().c_str(), "r"); + if (!f) return std::nullopt; + std::string s; + char buf[512]; std::size_t n; + while ((n = std::fread(buf, 1, sizeof buf, f)) > 0) s.append(buf, n); + std::fclose(f); + if (QuotedAfter(s, "\"pdn\": \"").value_or("") != pdn) return std::nullopt; + if (QuotedAfter(s, "\"boot\": \"").value_or("") != BootId()) return std::nullopt; + long elapsed = BootSeconds() - IntAfter(s, "\"written_at\": ").value_or(0); + imsd::engine::RegRecovery::Held h; + h.hold = std::max(0, IntAfter(s, "\"hold\": ").value_or(0) - elapsed); + h.freshHold = std::max(0, IntAfter(s, "\"fresh_hold\": ").value_or(0) - elapsed); + h.throttleMinutes = static_cast(IntAfter(s, "\"throttle_minutes\": ").value_or(h.throttleMinutes)); + h.oldGone = s.find("\"old_gone\": true") != std::string::npos; + if (!h.Any()) return std::nullopt; + return h; +} + // build an a{sv} from a CallInfo (matches imsd.py _d typing) GVariant* CallVariant(const CallInfo& c) { GVariantBuilder b; @@ -698,7 +927,7 @@ public: } } - void SetLocal(std::string l) { local_ = std::move(l); } + void SetLocal(std::string l) { local_ = l; pdnLocal_ = std::move(l); } void SetDevMode(bool d) { devMode_ = d; } const std::string& Local() const { return local_; } @@ -723,28 +952,31 @@ public: } void Run() { + if (auto h = LoadHolds(pdnLocal_)) { + recovery_.Restore(Boot(), *h); + auto kept = recovery_.Holds(Boot()); // what the policy took over: the file's values after the clamp + if (kept.hold > 0 || kept.freshHold > 0) + Log(std::format("a hold the network set before this start stands ({:.0f} s for any attempt, {:.0f} s for a fresh registration; the refusal schedule at {} min)", kept.hold, kept.freshHold, kept.throttleMinutes)); + else if (kept.throttleMinutes > imsd::engine::RegRecovery::ThrottleMinutesMin) + Log(std::format("the refusal schedule stands at {} min from before this start: a refused or throttled fresh registration waits that long", kept.throttleMinutes)); + if (kept.oldGone) Log("the old registration was gone before this start: no resume over its SAs, the fresh registration once its wait has run"); + } for (;;) { try { BringUp(); break; + } catch (const Stopping&) { + return; } catch (const ThrottledError& e) { - // The window grows on repeats (a carrier's window may exceed the - // first guess and every throttled attempt re-arms it); it resets - // after a successful bring-up. - int waitMin = throttleWaitMin_; - throttleCount_++; - Log(std::format("bring-up deferred: {} — attempt {}, next fresh registration in {} min", e.what(), throttleCount_, waitMin)); - registered_ = false; - EmitStatus(true); - for (int i = 0; i < waitMin * 60 && !quit_.load(); i++) { - // A Dial/Accept/HangUp arriving now must not wait in the - // queue and run stale once registration succeeds; with no - // flow the call collapses to terminated at once (DevLoop's rule). - if (auto cmd = PopCmd()) { HandleCmd(*cmd); MaybeDropCall(); continue; } - std::this_thread::sleep_for(std::chrono::seconds(1)); - } + DeferBringUp(e.what(), imsd::engine::RegRecovery::Failure::Throttled, 0); + if (quit_.load()) return; + continue; + } catch (const RefusedError& e) { + // A refused initial registration (403, 5xx) waits like a + // throttle: a fatal would restart imsd every RestartSec with an + // initial REGISTER each time. + DeferBringUp(e.what(), imsd::engine::RegRecovery::Failure::Refused, e.retryAfter); if (quit_.load()) return; - throttleWaitMin_ = std::min(throttleWaitMin_ * 3 / 2, 60); continue; } catch (const std::exception& e) { if (devMode_) { @@ -763,14 +995,9 @@ public: return; } } - throttleWaitMin_ = ThrottleWaitMin; throttleCount_ = 0; - registered_ = true; - EmitStatus(true); - lastRefresh_ = Mono(); - lastKa_ = Mono(); try { server_.Open(local_); } catch (...) { Log("server ports unavailable; remote hangup/incoming not seen"); } - SubscribeRegEvent(); + OnRegistered(); while (!quit_.load()) { if (auto cmd = PopCmd()) { HandleCmd(*cmd); continue; } @@ -780,6 +1007,7 @@ public: } MaybeRefresh(); MaybeReconnect(); + MaybeResubscribe(); if (!call_ && sip_.Alive() && Mono() - lastKa_ > 30) { sip_.SendKeepalive(); lastKa_ = Mono(); } ReapMedia(); if (call_ && call_->State() != imsd::engine::CallState::Active && Mono() > deadline_) { @@ -795,9 +1023,8 @@ public: if (call_ && callid == call_->CallId() && cseq.ends_with("INVITE")) { Execute(call_->OnInviteResponse(*msg)); MaybeDropCall(); - } else if (!subCallid_.empty() && callid == subCallid_ && cseq.ends_with("SUBSCRIBE")) { - Log(std::format("reg-event SUBSCRIBE -> {}", *st)); - DumpRaw("imsd-subscribe-200.raw", *msg); + } else { + OnSubscribeResponse(*msg, *st); } } // shutdown: release an active call, keep the SA/registration for resume @@ -808,6 +1035,41 @@ public: private: struct Cmd { enum class Kind { Dial, HangUp, Accept, Stop } kind; std::string uni, number; }; + struct Stopping {}; // the engine was told to stop while a bring-up waited + + // A bring-up the network deferred (a fresh-SA throttle) or refused: wait + // it out here, in process, on the recovery policy's schedule, then try + // the bring-up again. There is nothing to reconnect over meanwhile (the + // resume just found the stored SAs unusable, or there are none) and no + // recovery step runs from inside the wait — portPs_ says so to + // MaybeReconnect, whatever the attempt left in it — so a Dial + // meanwhile collapses at once, as in dev mode. + void DeferBringUp(std::string_view why, imsd::engine::RegRecovery::Failure f, long retryAfter) { + FreshAttemptFailed(f, retryAfter, std::format("bring-up deferred: {}", why)); + portPs_ = 0; + registered_ = false; + EmitStatus(true); + WaitForPolicy(); + } + + // Wait until the policy's next step is due. A Dial/Accept/HangUp + // arriving meanwhile must not wait in the queue and run stale once + // registration succeeds; with no flow the call collapses to terminated + // at once (DevLoop's rule). A wait can be long (an hour on the + // schedule), so the REGISTER after one names the access network read + // after it, not before. False when the engine is stopping. + bool WaitForPolicy() { + bool waited = false; + while (!quit_.load() && recovery_.Next(Boot()).at > Boot()) { + waited = true; + if (auto cmd = PopCmd()) { HandleCmd(*cmd); MaybeDropCall(); continue; } + std::this_thread::sleep_for(std::chrono::seconds(1)); + } + if (waited && !quit_.load()) RefreshPani(); + return !quit_.load(); + } + + void KeepHolds() { PersistHolds(recovery_.Holds(Boot()), pdnLocal_); } // dev/session mode without a modem: process commands so the ABI answers, // but there is no SIP socket — a Dial collapses to terminated/error. @@ -820,9 +1082,11 @@ private: // config std::string pcscf_, dev_, outDir_, local_, ealgOffer_, mediaBin_; + // The PDN's own address (LOCAL, else DetectLocal) — every fresh + // registration's. local_ differs from it only while a resume runs on the + // address an older registration was made on (TryResume). + std::string pdnLocal_; std::string sipPolicy_; // SIP_TRANSPORT: auto | tcp | udp - int throttleWaitMin_ = ThrottleWaitMin; // grows on consecutive throttles, resets on success - int throttleCount_ = 0; Transport transport_ = Transport::Tcp; // the protected leg's transport in effect void SetTransport(Transport t) { transport_ = t; ctx_.transport = std::string(TransportName(t)); } std::vector emergencyExtra_; @@ -839,14 +1103,20 @@ private: SipFlow sip_; ServerPorts server_; std::string subCallid_; // live reg-event subscription dialog (empty: none) + bool subPending_ = false; // SUBSCRIBE sent, no final yet + double subSentAt_ = 0; + double subRetryAt_ = 0; // a failed/ended subscription is renewed at this time (0: none due) + double subRenewAt_ = 0; // the live subscription is subscribed anew at this time, ahead of its granted expiry (0: none) + double subRetryDelay_ = 60; // 60 s doubling to 10 min between failed SUBSCRIBEs int portPs_ = 0; // P-CSCF protected server port (reconnect target) - double nextReconnect_ = 0; - double reconnectDelay_ = 5; + imsd::engine::RegRecovery recovery_; // what to do when the registration stops working + bool saReplaced_ = false; // the running fresh attempt got past the SA setup (the old registration's SAs are gone) // call imsd::util::Rng rng_; std::optional call_; bool dropCall_ = false; + bool closeFlowWhenIdle_ = false; // a binding was terminated under a call: its flow closes with the call pid_t mediaPid_ = -1; double deadline_ = 0; // reply_ answers the inbound request being dispatched right now; @@ -930,10 +1200,33 @@ private: // registered fresh for the same IMPI, a stale binding, …) is not // fatal: the fresh registration below replaces its SAs. Only a // failed FRESH registration is. + // The network's word holds at bring-up as it does in recovery: a + // hold from before this start is waited out before the resume (a + // refresh's Retry-After) or the fresh registration (a fresh attempt's + // schedule or Retry-After, a terminated binding's wait), and so is + // one the resume itself earns — a refusal's Retry-After, a binding + // a NOTIFY terminated under it. if (resume != "0") { - try { if (TryResume()) return; } - catch (const std::exception& e) { Log(std::format("resume failed: {} — registering fresh", e.what())); sip_.Close(); } + if (!WaitForPolicy()) throw Stopping{}; + // a registration the network wrote off before this start (the + // holds carry it) is not resumed: the wait above was the fresh one + try { if (recovery_.Reconnectable() && TryResume()) return; } + catch (const RefusedError& e) { + Log(std::format("resume failed: {} — the next registration is a fresh one", e.what())); + CloseFlowAfterWait(); + if (e.retryAfter > 0) recovery_.OnReconnectFailure(Boot(), imsd::engine::RegRecovery::Failure::Refused, e.retryAfter, e.status); + } + catch (const std::exception& e) { Log(std::format("resume failed: {} — the next registration is a fresh one", e.what())); CloseFlowAfterWait(); } } + // a resume that failed found the stored SAs unusable (and after a + // reboot there are none): nothing to reconnect over while a deferred + // fresh registration waits + recovery_.OldGone(); + portPs_ = 0; + local_ = pdnLocal_; ctx_.local = local_; + KeepHolds(); + if (recovery_.Next(Boot()).at > Boot()) Log(std::format("the network's hold stands; {}", NextStep())); + if (!WaitForPolicy()) throw Stopping{}; FreshRegister(); } @@ -941,6 +1234,26 @@ private: auto state = RunCapture({"ip", "xfrm", "state"}); auto policy = RunCapture({"ip", "xfrm", "policy"}); auto sa = imsd::ipsec::ParseExistingSa(state.out, policy.out, pcscf_, local_); + // The registration may sit on another address of the link: before + // 0.3.10 imsd registered on the kernel's temporary one (ims ledger + // F18). Resume there while it is still up — a restart, an upgrade + // among them, keeps the registration for the price of a re-REGISTER, + // where a fresh one is an AKA run, a new SA pair and, when KPN + // refuses it, the old registration and a 21-minute wait (KPN + // refuses a fresh pair on an address whose registration is live and + // has accepted one on another address of the link meanwhile; other + // carriers' rules are unknown) — and move at the next fresh + // registration. + if (!sa) { + for (const std::string& other : imsd::ipsec::SaLocals(state.out, pcscf_)) { + if (other == local_ || !OnLink(dev_, other)) continue; + sa = imsd::ipsec::ParseExistingSa(state.out, policy.out, pcscf_, other); + if (!sa) continue; + Log(std::format("the registration's SAs are on {}, not the PDN's own address {}: resuming there until the next fresh registration", other, local_)); + local_ = other; ctx_.local = other; + break; + } + } if (!sa) return false; auto ps = LoadState(); if (!ps || !ps->callid) { Log("no reg state file; resume falls back to fresh"); return false; } @@ -1000,14 +1313,24 @@ private: Log(std::format("binding: {}", ct)); } + // A fresh registration. The attempt mints its own Contact user-part into + // ctx_ for its REGISTERs; on any failure the stored binding's comes back, + // so requests over the existing SA meanwhile (a reconnect, a refresh) + // carry the binding the network knows. reg_ takes the new one on the 200. void FreshRegister() { + saReplaced_ = false; + try { FreshRegisterAttempt(); } + catch (...) { ctx_.contactUser = reg_.contactUser; throw; } + } + + void FreshRegisterAttempt() { Log("FRESH registration"); // Oracle diff (journal/ims.md s53): stock's Contact user-part is a // fresh UUID per registration, not the IMSI. Minted before reg1 so // both REGISTERs and the stored binding carry the same URI. - reg_.contactUser = imsd::util::Uuid4(rng_); - ctx_.contactUser = reg_.contactUser; - Log(std::format("contact user-part: {}", reg_.contactUser)); + std::string contactUser = imsd::util::Uuid4(rng_); + ctx_.contactUser = contactUser; + Log(std::format("contact user-part: {}", contactUser)); std::string callidReg = std::format("{}@{}", rng_.Token(16), local_); std::string ftag = rng_.Token(8); std::uint32_t spiUc = rng_.UInt(0x10000, 0xFFFFFFF); @@ -1022,16 +1345,16 @@ private: if (bind(us, reinterpret_cast(&la), ll) != 0) { close(us); throw std::runtime_error("bind init port failed"); } - timeval tv{8, 0}; setsockopt(us, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv); std::string reg1 = imsd::msg::BuildRegisterInitial(ctx_, callidReg, ftag, rng_.Token(16), spiUc, spiUs); sockaddr_storage pa; socklen_t pl = MakeAddr(pcscf_, pcscfPort_, pa); sendto(us, reg1.data(), reg1.size(), 0, reinterpret_cast(&pa), pl); - char buf[65535]; - ssize_t n = recv(us, buf, sizeof buf, 0); - close(us); - if (n <= 0) throw std::runtime_error("no 401 to unprotected REGISTER"); - std::string resp(buf, static_cast(n)); - if (imsd::sip::Status(resp) != 401) throw std::runtime_error("expected 401"); + std::string resp = AwaitInitialFinal(us, callidReg); + if (auto st1 = imsd::sip::Status(resp); st1 != 401) { + DumpRaw("imsd-register-fail.raw", resp); + std::string what = std::format("expected 401, got {}", imsd::sip::RefusalSummary(resp)); + if (st1 && *st1 >= 300) throw RefusedError(what, *st1, imsd::sip::RetryAfter(resp).value_or(0)); + throw std::runtime_error(what); + } auto nonceB64 = QuotedAfter(resp, "nonce=\""); if (!nonceB64) throw std::runtime_error("no nonce in 401"); auto raw = imsd::util::FromBase64(*nonceB64); @@ -1085,6 +1408,7 @@ private: sp.portUc = PortUc; sp.portUs = PortUs; sp.portPs = portPs; sp.portPc = portPc; sp.alg = alg; sp.ealg = ealg; + saReplaced_ = true; // from here on the old registration's SAs are gone for (auto& cmd : imsd::ipsec::BuildSetupCommands(sp)) { int rc = RunCmd(cmd); if (rc != 0) Log(std::format("{} -> rc {}", RedactKeys(cmd), rc)); @@ -1117,7 +1441,7 @@ private: // pair (the SA setup flushes this one), same ports. Log("protected TCP connect unanswered; registering fresh over UDP"); SetTransport(Transport::Udp); - FreshRegister(); + FreshRegisterAttempt(); return; } throw std::runtime_error(std::format("protected {} connect failed", TransportName(transport_))); @@ -1129,15 +1453,13 @@ private: std::string auth = imsd::msg::AuthAka(ctx_, *nonceB64, cnonce, response); std::string reg2 = imsd::msg::BuildRegisterProtected(ctx_, callidReg, ftag, rng_.Token(16), 2, spiUc, spiUs, auth); if (!sip_.Send(reg2)) throw std::runtime_error("send protected REGISTER failed"); - std::string ok; - for (;;) { - auto m = sip_.RecvMsg(12.0); - if (!m) throw std::runtime_error("no reply to protected REGISTER"); - auto st = imsd::sip::Status(*m); - if (st == 200) { ok = *m; break; } - if (st && *st >= 300) throw std::runtime_error(std::format("REGISTER failed {}", *st)); + std::string ok = AwaitFinal(callidReg, 2, "protected REGISTER"); + if (auto st = imsd::sip::Status(ok); st != 200) { + DumpRaw("imsd-register-fail.raw", ok); + throw RefusedError(std::format("REGISTER refused: {}", imsd::sip::RefusalSummary(ok)), *st, imsd::sip::RetryAfter(ok).value_or(0)); } reg_.callid = callidReg; reg_.ftag = ftag; reg_.cseq = 2; + reg_.contactUser = contactUser; reg_.spiUc = spiUc; reg_.spiUs = spiUs; reg_.expiry = imsd::sip::GrantedExpires(ok).value_or(0); DumpRaw("imsd-register-200.raw", ok); @@ -1149,6 +1471,86 @@ private: Log("REGISTERED (fresh)"); } + // Closes the flow of a REGISTER wait that failed. A call that arrived + // under the wait (AwaitFinal dispatches requests) ends with it: a 486 + // while the flow still carries one, and the bus told. + void CloseFlowAfterWait() { + if (call_) { + Log(std::format("call {} arrived under a registration that failed; ending it", call_->Uni())); + Execute(call_->OnHangup()); + MaybeDropCall(); + if (call_) { Execute(call_->Abandon("registration failed")); MaybeDropCall(); } + } + sip_.Close(); + } + + // The final of the REGISTER (callid, cseq) just sent, within 12 s of + // the send or of its last provisional. A request arriving on the client + // flow meanwhile (a NOTIFY, an OPTIONS) is dispatched and answered, not + // read as the reply; a provisional of our own transaction keeps the + // wait going; a response to our reg-event SUBSCRIBE is handled as the + // run loop handles it, any other transaction's is ignored. Throws + // when nothing answers or the flow dies under a dispatched request. + std::string AwaitFinal(const std::string& callid, long cseq, std::string_view what) { + std::string want = std::format("{} REGISTER", cseq); + double deadline = Mono() + 12.0; + for (;;) { + double left = deadline - Mono(); + auto m = left > 0 ? sip_.RecvMsg(left) : std::nullopt; + if (!m) throw std::runtime_error(std::format("no reply to {}", what)); + auto st = imsd::sip::Status(*m); + if (!st) { + reply_ = [this](const std::string& r) { sip_.Send(r); }; + DispatchRequest(*m); + if (!sip_.Alive()) throw std::runtime_error(std::format("flow closed while awaiting the {} reply", what)); + continue; + } + if (!imsd::sip::ResponseOf(*m, callid, want)) { + if (!OnSubscribeResponse(*m, *st)) Log(std::format("ignoring a response to another transaction while awaiting the {} reply", what)); + continue; + } + if (*st >= 200) return *m; + Log(std::format("{} -> {} (provisional); waiting for the final", what, *st)); + deadline = Mono() + 12.0; + } + } + + // The final of the unprotected REGISTER (callid, CSeq 1) on the init + // socket, within 8 s of the send or of its last provisional. The socket + // is bound to the init port, not connected: what else it delivers — a + // late final of an earlier attempt, a stray datagram — is logged with + // its source port and skipped, never judged as the reply. Closes the + // socket; throws when nothing answers. + std::string AwaitInitialFinal(int us, const std::string& callid) { + double deadline = Mono() + 8.0; + char buf[65535]; + for (;;) { + double left = deadline - Mono(); + if (left <= 0) break; + long sec = static_cast(left); + long usec = static_cast((left - static_cast(sec)) * 1e6); + if (sec == 0 && usec == 0) usec = 1000; + timeval tv{sec, usec}; + setsockopt(us, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv); + sockaddr_storage src; socklen_t sl = sizeof src; + ssize_t n = recvfrom(us, buf, sizeof buf, 0, reinterpret_cast(&src), &sl); + if (n <= 0) break; + std::string m(buf, static_cast(n)); + if (!imsd::sip::ResponseOf(m, callid, "1 REGISTER")) { + int sp = src.ss_family == AF_INET6 ? ntohs(reinterpret_cast(&src)->sin6_port) : ntohs(reinterpret_cast(&src)->sin_port); + std::string_view sv = m; + Log(std::format("ignoring a datagram on the init port from port {} that is not the unprotected REGISTER's reply: {}", sp, sv.substr(0, sv.find("\r\n")))); + continue; + } + int st = *imsd::sip::Status(m); + if (st >= 200) { close(us); return m; } + Log(std::format("unprotected REGISTER -> {} (provisional); waiting for the final", st)); + deadline = Mono() + 8.0; + } + close(us); + throw std::runtime_error("no 401 to unprotected REGISTER"); + } + // re-REGISTER over the (resumed or live) SA. Returns (200 msg, used cseq). std::pair Reregister(const std::string& callid, const std::string& ftag, long startCseq, std::uint32_t spiUc, std::uint32_t spiUs) { std::string auth = imsd::msg::AuthEmpty(ctx_); @@ -1157,13 +1559,12 @@ private: for (;;) { std::string reg = imsd::msg::BuildRegisterProtected(ctx_, callid, ftag, rng_.Token(16), cseq, spiUc, spiUs, auth); if (!sip_.Send(reg)) throw std::runtime_error("send re-REGISTER failed"); - auto m = sip_.RecvMsg(12.0); - if (!m) throw std::runtime_error("no reply to re-REGISTER"); - auto st = imsd::sip::Status(*m); - if (st == 200) return {*m, cseq}; + std::string m = AwaitFinal(callid, cseq, "re-REGISTER"); + auto st = imsd::sip::Status(m); + if (st == 200) return {m, cseq}; if (st == 401 && !challenged) { challenged = true; - auto nonce = QuotedAfter(*m, "nonce=\""); + auto nonce = QuotedAfter(m, "nonce=\""); auto raw = nonce ? imsd::util::FromBase64(*nonce) : std::nullopt; if (!raw || raw->size() < 32) throw std::runtime_error("bad 401 nonce on refresh"); std::vector rnd(raw->begin(), raw->begin() + 16); @@ -1176,7 +1577,8 @@ private: cseq++; continue; } - throw std::runtime_error(std::format("re-REGISTER failed {}", st.value_or(0))); + DumpRaw("imsd-register-fail.raw", m); + throw RefusedError(std::format("re-REGISTER refused: {}", imsd::sip::RefusalSummary(m)), *st, imsd::sip::RetryAfter(m).value_or(0)); } } @@ -1265,15 +1667,21 @@ private: if (auto e = imsd::sip::GrantedExpires(msg)) reg_.expiry = *e; PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport); Log(std::format("keepalive re-REGISTER ok (cseq {})", used)); - SubscribeRegEvent(); - if (!registered_) { registered_ = true; EmitStatus(true); } - else EmitStatus(false); - } catch (const std::exception& e) { - // Whatever failed (dead flow, silent P-CSCF, error status), the - // recovery is the same as a daemon restart: drop the flow and - // let MaybeReconnect re-run the connect + true-refresh sequence. + OnRegistered(); + } catch (const RefusedError& e) { + // The network refused the refresh: the recovery policy counts it + // (a second refusal means a fresh registration) and the flow is + // dropped so the reconnect step retries the refresh after the + // wait (Retry-After when given). Log(std::format("keepalive re-REGISTER failed: {}", e.what())); - sip_.Close(); + CloseFlowAfterWait(); + RecoveryFailed(imsd::engine::RegRecovery::Failure::Refused, e.retryAfter, "keepalive refresh refused", e.status); + } catch (const std::exception& e) { + // A dead flow or a silent P-CSCF: the recovery is the same as a + // daemon restart's — drop the flow and let MaybeReconnect re-run + // the connect + true-refresh sequence at once. + Log(std::format("keepalive re-REGISTER failed: {}", e.what())); + CloseFlowAfterWait(); } lastRefresh_ = Mono(); lastKa_ = Mono(); @@ -1283,78 +1691,424 @@ private: // registration stay valid — a CSFB excursion resets it, the P-CSCF drops // idle flows — which is exactly the state a daemon restart resumes from // (s51: "restart-to-recover"). Do what the restart does, in place: - // reconnect the TCP leg over the existing SA and true-refresh. Backoff - // doubles 5 s → 5 min so a dead PDN doesn't turn this into a hot loop. + // reconnect the TCP leg over the existing SA and true-refresh. When that + // keeps failing — the network refuses the refresh, or nothing answers — + // do the rest of what a restart does: register fresh. RegRecovery holds + // the thresholds and the pacing (a dead PDN must not turn this into a + // hot loop; a carrier that refuses fresh SAs must not be hammered). void MaybeReconnect() { if (call_ || sip_.Alive() || portPs_ == 0 || reg_.callid.empty()) return; - if (Mono() < nextReconnect_) return; - Log("client flow dead; reconnecting"); - sip_.Close(); - bool ok = sip_.Connect(local_, PortUc, pcscf_, portPs_, transport_, /*tries=*/2); - if (ok) { - try { - RefreshPani(); - auto [msg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1, reg_.spiUc, reg_.spiUs); - reg_.cseq = used; - if (auto e = imsd::sip::GrantedExpires(msg)) reg_.expiry = *e; - UpdateRouteAndPpi(msg); - LogBindings(msg); - PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport); - Log("reconnected + re-registered"); - SubscribeRegEvent(); - if (!registered_) { registered_ = true; EmitStatus(true); } - else EmitStatus(false); - lastRefresh_ = Mono(); lastKa_ = Mono(); - reconnectDelay_ = 5; - nextReconnect_ = 0; - return; - } catch (const std::exception& e) { - Log(std::format("reconnect re-REGISTER failed: {}", e.what())); - sip_.Close(); - } - } - if (registered_) { registered_ = false; EmitStatus(true); } - nextReconnect_ = Mono() + reconnectDelay_; - reconnectDelay_ = std::min(reconnectDelay_ * 2, 300.0); + auto plan = recovery_.Next(Boot()); + if (plan.at > Boot()) return; + if (plan.step == imsd::engine::RegRecovery::Step::Fresh) FreshRecover(); + else ReconnectCycle(); } - // (Re-)subscribe to the reg event package (TS 24.229 5.1.1.3): a fresh - // dialog each time, sent over the registration flow; the S-CSCF's - // immediate NOTIFY (logged in DispatchRequest) is the network's own view - // of our bindings. Failure is non-fatal — registration is unaffected. + // The policy's next step, as the journal line: what, and when. + std::string NextStep() { + auto plan = recovery_.Next(Boot()); + double in = plan.at - Boot(); + std::string what = plan.step == imsd::engine::RegRecovery::Step::Fresh ? "fresh registration" : "reconnect over the existing SA"; + if (in <= 0) return std::format("next: {} now", what); + if (in >= 60) return std::format("next: {} in {:.0f} min", what, in / 60); + return std::format("next: {} in {:.0f} s", what, in); + } + + // ModemManager's word on the phone's own service, as ims-pdn-up.sh reads + // it (`mmcli -K`). False only when the modem clearly has none: a state + // short of registered, or packet service detached. Anything else — + // ModemManager not answering, a state or packet-service value this + // code does not know — counts as service: the network's silence, not + // the phone's, is the default. + bool PacketServiceUp() { + auto r = RunCapture({"mmcli", "-m", "any", "-K"}); + auto field = [&](std::string_view key) -> std::string { + std::size_t at = r.out.find(key); + if (at == std::string::npos) return {}; + std::size_t end = r.out.find('\n', at); + if (end == std::string::npos) end = r.out.size(); + std::size_t colon = r.out.find(':', at); + if (colon == std::string::npos || colon > end) return {}; + std::string v = r.out.substr(colon + 1, end - colon - 1); + while (!v.empty() && v.front() == ' ') v.erase(v.begin()); + while (!v.empty() && (v.back() == ' ' || v.back() == '\r')) v.pop_back(); + return v; + }; + std::string state = field("modem.generic.state "), ps = field("modem.3gpp.packet-service-state "); + for (const char* down : {"failed", "initializing", "locked", "disabled", "disabling", "enabling", "enabled", "searching"}) + if (state == down) return false; + return ps != "detached"; + } + + // A cycle that failed while the modem reports no packet service is the + // phone's silence, not the network's: not counted, retried on the + // local-refusal pace like a connect() the kernel refused — a radio gap + // across a refresh must not escalate to a fresh registration, which KPN + // refuses while the old one is live, and drops it for. + void CoverageGap(std::string_view what) { + if (registered_) { registered_ = false; EmitStatus(true); } + recovery_.OnLocalRefusal(Boot()); + Log(std::format("{} while the modem reports no packet service: not counted; {}", what, NextStep())); + } + + void ReconnectCycle() { + using F = imsd::engine::RegRecovery::Failure; + Log("client flow dead; reconnecting"); + sip_.Close(); + if (!sip_.Connect(local_, PortUc, pcscf_, portPs_, transport_, /*tries=*/2)) { + if (AddressGone()) return; + if (sip_.LastConnectLocal()) { + if (registered_) { registered_ = false; EmitStatus(true); } + recovery_.OnLocalRefusal(Boot()); + return; + } + if (!PacketServiceUp()) { CoverageGap("P-CSCF silent"); return; } + RecoveryFailed(F::Silent, 0, "P-CSCF silent"); + return; + } + try { + RefreshPani(); + auto [msg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1, reg_.spiUc, reg_.spiUs); + reg_.cseq = used; + if (auto e = imsd::sip::GrantedExpires(msg)) reg_.expiry = *e; + UpdateRouteAndPpi(msg); + LogBindings(msg); + PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport); + Log("reconnected + re-registered"); + OnRegistered(); + } catch (const RefusedError& e) { + Log(std::format("reconnect re-REGISTER failed: {}", e.what())); + CloseFlowAfterWait(); + RecoveryFailed(F::Refused, e.retryAfter, "refresh refused", e.status); + } catch (const std::exception& e) { + Log(std::format("reconnect re-REGISTER failed: {}", e.what())); + CloseFlowAfterWait(); + // the binding terminated under the wait (the flow closed, or + // under a call the wait timed out): that verdict named the next + // step, this is no silence + if (!recovery_.Reconnectable()) return; + if (!PacketServiceUp()) { CoverageGap("refresh unanswered"); return; } + RecoveryFailed(F::Silent, 0, "refresh unanswered"); + } + } + + // A failed reconnect + refresh cycle: the status goes unregistered, the + // policy decides whether the next step is another cycle or a fresh + // registration, and when. + void RecoveryFailed(imsd::engine::RegRecovery::Failure f, long retryAfter, std::string_view what, int status = 0) { + if (registered_) { registered_ = false; EmitStatus(true); } + bool wasReconnectable = recovery_.Reconnectable(); + recovery_.OnReconnectFailure(Boot(), f, retryAfter, status); + KeepHolds(); + Log(std::format("{} ({} refused, {} silent cycles){}; {}", what, recovery_.refusals, recovery_.silentCycles, + wasReconnectable && !recovery_.Reconnectable() ? std::format(" — the old registration is gone ({})", status) : "", NextStep())); + } + + // The rest of what a restart does, in place: a new Call-ID, a new + // challenge, a new SA pair (FreshRegister flushes the old ones), then + // the post-registration steps. A refused or throttled attempt waits on + // the throttle schedule (a wait no Dial cuts short — on KPN a refused + // attempt costs the old registration) and a silent one on the + // short backoff. The attempt's flow is closed whichever way it failed; + // past the SA setup the old registration's SAs are gone with it, so + // nothing is retried until the next fresh attempt. + void FreshRecover() { + using F = imsd::engine::RegRecovery::Failure; + Log(std::format("registering fresh (recovery attempt {})", recovery_.freshAttempts + 1)); + sip_.Close(); + saReplaced_ = false; // this attempt's, whatever fails first + // the subscription dialog is the registration's and goes with the + // attempt; a retry the network scheduled (its Retry-After) stands + // until a fresh registration replaces the old one; an attempt that + // fails before the SA setup leaves the old registration and that + // retry standing + subCallid_.clear(); subPending_ = false; subRenewAt_ = 0; + // a registration resumed on an older address (a pre-0.3.10 temporary + // one) moves to the PDN's own address here, server ports included; + // an attempt that fails before the SA setup leaves that registration + // standing, so it moves back to it + std::string from = local_; + if (local_ != pdnLocal_) { + Log(std::format("the fresh registration moves from {} to the PDN's own address {}", local_, pdnLocal_)); + MoveTo(pdnLocal_); + } + bool ok = false; + try { + RefreshPani(); + FreshRegister(); + subRetryAt_ = 0; // a new registration: its subscription starts afresh + OnRegistered(); + ok = true; + Log("recovered by a fresh registration"); + } catch (const ThrottledError& e) { + FreshAttemptFailed(F::Throttled, 0, std::format("fresh registration deferred: {}", e.what())); + } catch (const RefusedError& e) { + FreshAttemptFailed(F::Refused, e.retryAfter, std::format("fresh registration refused: {}", e.what())); + } catch (const std::exception& e) { + FreshAttemptFailed(F::Silent, 0, std::format("fresh registration failed: {}", e.what())); + } + if (!ok && !quit_.load() && recovery_.Reconnectable() && local_ != from) { + if (OnLink(dev_, from)) { + Log(std::format("back on {}: the old registration stands until the next fresh attempt", from)); + MoveTo(from); + } else { + recovery_.OldGone(); + KeepHolds(); // the verdict outlives a restart like the hold it goes with + Log(std::format("{} has left {}: the registration on it is gone; staying on {}; {}", from, dev_, local_, NextStep())); + } + } + } + + // Our address for everything from here on: the flow, the server ports, + // the requests' Via and Contact, the media leg. + void MoveTo(const std::string& addr) { + server_.CloseAll(); + local_ = addr; ctx_.local = addr; + server_.Open(local_); + } + + // After a failed fresh attempt, in recovery or at bring-up: the + // attempt's flow closed (a refused protected REGISTER leaves it open), + // the policy told, and its next step logged. + void FreshAttemptFailed(imsd::engine::RegRecovery::Failure f, long retryAfter, const std::string& what) { + CloseFlowAfterWait(); + if (!OnLink(dev_, local_)) Log(what); // said before the address's own line: a refusal is always in the journal + if (AddressGone()) return; + bool wasReconnectable = recovery_.Reconnectable(); + recovery_.OnFreshFailure(Boot(), f, retryAfter, saReplaced_); + KeepHolds(); + Log(std::format("{}{}; {}", what, wasReconnectable && !recovery_.Reconnectable() ? " — the old registration is gone" : "", NextStep())); + } + + // After a failed recovery step: has our address left the link? An + // older registration's address (a pre-0.3.10 temporary one that + // expired) takes that registration with it: imsd moves to the PDN's + // own address and the policy's fresh step runs there, every wait it + // holds included. The PDN's own address leaving means the PDN went + // away — no retry brings it back; a restart runs the PDN bring-up + // again. True when the step that failed has nothing left to do. + bool AddressGone() { + if (OnLink(dev_, local_)) return false; + if (local_ != pdnLocal_ && OnLink(dev_, pdnLocal_)) { + if (registered_) { registered_ = false; EmitStatus(true); } + std::string from = local_; + recovery_.OldGone(); + MoveTo(pdnLocal_); + KeepHolds(); // the caller returns before its own write + Log(std::format("{} is no longer on {}: the registration on it is gone; on the PDN's own address {} now; {}", from, dev_, local_, NextStep())); + return true; + } + RequestRestart(std::format("{} is no longer on {}", local_, dev_)); + return true; + } + + // Leave through systemd (Restart=on-failure), which runs ims-pdn-up.sh + // again and registers on the PDN's own address. + void RequestRestart(const std::string& why) { + quit_.store(true); + auto ev = std::make_unique(); + ev->type = Event::Type::Fatal; ev->text = why; + PostEvent(std::move(ev)); + } + + // After a registration succeeds — bring-up, a keepalive refresh, a + // reconnect + refresh, or a fresh recovery: the status (a signal only + // when it changes), the timers, the recovery policy, the reg-event + // subscription. + void OnRegistered() { + recovery_.OnSuccess(); + closeFlowWhenIdle_ = false; // this registration's flow stands, whatever a verdict said of the one before + KeepHolds(); + bool was = registered_; + registered_ = true; + EmitStatus(!was); + lastRefresh_ = Mono(); lastKa_ = Mono(); + // a refresh or a reconnect opens no subscription dialog while imsd + // holds one for the registration or has a retry scheduled; a fresh + // registration (or a start) has none yet, and a fresh attempt drops + // the one it holds before it sends. A retry already scheduled (the + // network's Retry-After, the backoff) is MaybeResubscribe's to send, + // not a registration's to bring forward — nor to start its backoff + // over: that resets with a subscription that starts afresh here, or + // is accepted + if (subCallid_.empty() && !subPending_ && subRetryAt_ == 0) { subRetryDelay_ = 60; SubscribeRegEvent(); } + } + + // Subscribe to the reg event package (TS 24.229 5.1.1.3): one dialog per + // registration, sent over the registration flow; the S-CSCF's immediate + // NOTIFY (logged in DispatchRequest) is the network's own view of our + // bindings. Failure is non-fatal — registration is unaffected. void SubscribeRegEvent() { + subRetryAt_ = 0; subRenewAt_ = 0; subCallid_ = std::format("{}@{}", rng_.Token(16), local_); std::string sub = imsd::msg::BuildSubscribeReg(ctx_, subCallid_, rng_.Token(8), rng_.Token(16)); - if (sip_.Send(sub)) Log("reg-event SUBSCRIBE sent"); - else { Log("reg-event SUBSCRIBE send failed"); subCallid_.clear(); } + if (sip_.Send(sub)) { Log("reg-event SUBSCRIBE sent"); subPending_ = true; subSentAt_ = Mono(); } + else { Log("reg-event SUBSCRIBE send failed"); subCallid_.clear(); ScheduleResubscribe(0); } + } + + // A response on the client flow that belongs to our reg-event + // SUBSCRIBE, wherever the flow is read (the run loop, a REGISTER + // wait). False when it is not ours. + bool OnSubscribeResponse(const std::string& msg, int st) { + if (subCallid_.empty() || imsd::sip::Header(msg, "Call-ID") != subCallid_) return false; + if (!imsd::sip::Header(msg, "CSeq").value_or("").ends_with("SUBSCRIBE")) return false; + if (st >= 300) { + Log(std::format("reg-event SUBSCRIBE refused: {}", imsd::sip::RefusalSummary(msg))); + DumpRaw("imsd-subscribe-fail.raw", msg); + subPending_ = false; subCallid_.clear(); + ScheduleResubscribe(imsd::sip::RetryAfter(msg).value_or(0)); + } else { + Log(std::format("reg-event SUBSCRIBE -> {}", st)); + if (st >= 200) { + DumpRaw("imsd-subscribe-200.raw", msg); subPending_ = false; subRetryDelay_ = 60; + if (auto e = imsd::sip::GrantedExpires(msg)) ArmSubscriptionRenewal(*e); + } + } + return true; + } + + // The subscription is part of being registered (TS 24.229 5.1.1.3): a + // refused or unanswered SUBSCRIBE is retried, 60 s doubling to 10 min + // (a Retry-After wins), and a subscription the S-CSCF ends is renewed. + // Until now one failure left the daemon deaf to the network's view of + // its binding — O2 UK 2026-09-24: SUBSCRIBE 500, the registration + // killed 8 s later, nothing heard. + void ScheduleResubscribe(long retryAfter) { + double wait = std::max(subRetryDelay_, static_cast(std::min(retryAfter, imsd::engine::RegRecovery::RetryAfterMax))); + subRetryDelay_ = std::min(subRetryDelay_ * 2, 600.0); + subRetryAt_ = Mono() + wait; + Log(std::format("reg-event: next SUBSCRIBE in {:.0f} s", wait)); + } + void MaybeResubscribe() { + if (subPending_ && Mono() - subSentAt_ > 32) { // Timer F: no final at all + Log("reg-event SUBSCRIBE unanswered"); + subPending_ = false; subCallid_.clear(); + ScheduleResubscribe(0); + } + if (call_ || !registered_ || !sip_.Alive()) return; + if (!subCallid_.empty() && !subPending_ && subRenewAt_ > 0 && Mono() >= subRenewAt_) { + // the granted lifetime is running out: a new dialog (the S-CSCF + // closes the previous one — KPN with noresource), on the same + // registration + Log("reg-event: the subscription nears its expiry; subscribing anew"); + subCallid_.clear(); subRenewAt_ = 0; + SubscribeRegEvent(); + return; + } + if (subRetryAt_ == 0 || Mono() < subRetryAt_) return; + SubscribeRegEvent(); + } + + // The network granted the subscription `expires` seconds (a SUBSCRIBE + // 2xx, a NOTIFY's Subscription-State): subscribe anew 10 min before the + // end, or halfway through a shorter grant. + void ArmSubscriptionRenewal(long expires) { + double ahead = expires > 1200 ? expires - 600.0 : expires / 2.0; + subRenewAt_ = Mono() + std::max(ahead, 30.0); + Log(std::format("reg-event: subscription granted {} s; renewed in {:.0f} s", expires, subRenewAt_ - Mono())); + } + + // The network's view of our binding (RFC 3680 reginfo), read from any + // reg-event NOTIFY — a subscription an earlier process left behind + // carries the same account (O2, 2026-09-24: the previous dialog's NOTIFY + // reached a restarted daemon whose own SUBSCRIBE had just got 500). A + // binding the registrar terminated needs a new registration: the + // policy decides when (TS 24.229 5.4.1.5, 5.1.1.7 — now for deactivated + // and expired, after retry-after for probation, on the refusal schedule + // for rejected and unregistered). Active or not listed: nothing to do. + // Subscription state is per dialog: only our live dialog's end is ours + // to renew, unless the binding went with it. + void OnRegEventNotify(const std::string& msg, std::string_view body) { + using S = imsd::sip::RegInfoContact::State; + auto c = imsd::sip::RegInfoContactState(body, imsd::msg::ContactUser(ctx_)); + bool bindingGone = false; + if (c.state == S::Terminated) { + bindingGone = true; + if (registered_) { registered_ = false; EmitStatus(true); } + // a call lives on the client flow: the flow closes when the + // call is dropped, not under it — the call goes on, or the + // network releases it, and the fresh step follows the call as + // every other recovery step does + if (call_) closeFlowWhenIdle_ = true; + else + sip_.Close(); + subCallid_.clear(); subPending_ = false; subRetryAt_ = 0; subRenewAt_ = 0; + recovery_.OnBindingTerminated(Boot(), c.event, c.retryAfter); + KeepHolds(); + Log(std::format("reg-event: our binding is terminated ({}); {}", c.event, NextStep())); + } else if (c.state == S::Active) { + Log("reg-event: our binding is active"); + } + auto ss = imsd::sip::Header(msg, "Subscription-State"); + std::string_view callid = imsd::sip::Header(msg, "Call-ID").value_or(""); + bool ours = !subCallid_.empty() && callid == subCallid_; + if (ss && ss->starts_with("active") && ours) + if (auto e = IntAfter(*ss, "expires=")) ArmSubscriptionRenewal(*e); + if (ss && ss->starts_with("terminated") && !ours && !bindingGone) { + Log(std::format("reg-event: an earlier subscription ({}) ended ({}); ours ({}) stands", callid, *ss, subCallid_.empty() ? "none" : subCallid_)); + } else if (ss && ss->starts_with("terminated") && !bindingGone) { + // RFC 6665 §4.1.3: renew after deactivated, timeout, giveup and + // probation (after its retry-after, on the 60 s retry floor); not after rejected + // or noresource. KPN's S-CSCF ends a registration's previous + // subscription with noresource when a new one replaces it (dev + // phone 2026-09-25, Call-IDs compared); the 2026-09-24 reading + // that it ends every subscription came from opening a new one + // at every refresh and taking that closure for our own + std::string reason; + if (auto r = ss->find("reason="); r != std::string_view::npos) { + std::string_view v = ss->substr(r + 7); + reason = std::string(v.substr(0, v.find_first_of(";, \t\r"))); + } + subCallid_.clear(); subPending_ = false; subRenewAt_ = 0; + if (reason == "deactivated" || reason == "timeout" || reason == "giveup" || reason == "probation") { + Log(std::format("reg-event: subscription ended ({}); renewing", *ss)); + ScheduleResubscribe(IntAfter(*ss, "retry-after=").value_or(0)); + } else { + Log(std::format("reg-event: subscription ended ({}); the next refresh subscribes anew", *ss)); + } + } } // ---- command handling ------------------------------------------------- + // A Dial while a call exists: refused, unless it is an emergency dial, + // which preempts whatever exists (TS 22.101) — released, and abandoned + // if it lingers awaiting a CANCEL's 487 (the late final is ignored by + // Call-ID mismatch). False when the dial was refused. + bool ClearForDial(const Cmd& c, bool emergency) { + if (!call_) return true; + if (!emergency) { + Log(std::format("dial({}) refused: call in progress", c.number)); + CallInfo bad{c.uni, c.number, "terminated", "error", NowEpoch(), 0}; + PostAdded(bad); + PostState(c.uni, "terminated", "error"); + PostDeleted(c.uni); + return false; + } + Log(std::format("emergency dial: preempting call {}", call_->Uni())); + Execute(call_->OnHangup()); + MaybeDropCall(); + if (call_) { Execute(call_->Abandon("preempted by emergency dial")); MaybeDropCall(); } + return true; + } + void HandleCmd(const Cmd& c) { if (c.kind == Cmd::Kind::Stop) { quit_.store(true); return; } if (c.kind == Cmd::Kind::Dial) { bool emergency = imsd::msg::IsEmergency(c.number, emergencyExtra_); - if (call_) { - if (!emergency) { - Log(std::format("dial({}) refused: call in progress", c.number)); - CallInfo bad{c.uni, c.number, "terminated", "error", NowEpoch(), 0}; - PostAdded(bad); - PostState(c.uni, "terminated", "error"); - PostDeleted(c.uni); - return; - } - // an emergency dial preempts whatever exists (TS 22.101): - // release it; if it lingers awaiting a CANCEL's 487, abandon - // it — the late final is ignored by Call-ID mismatch - Log(std::format("emergency dial: preempting call {}", call_->Uni())); - Execute(call_->OnHangup()); - MaybeDropCall(); - if (call_) { Execute(call_->Abandon("preempted by emergency dial")); MaybeDropCall(); } - } + if (!ClearForDial(c, emergency)) return; // a dead client flow fails the INVITE instantly — give the - // reconnect path one immediate shot first (user action beats - // the backoff timer) - if (!sip_.Alive()) { nextReconnect_ = 0; MaybeReconnect(); } + // recovery path one immediate shot first: user action cuts the + // backoff short, never a wait the network imposed (a + // Retry-After, the schedule after a refused or throttled fresh + // attempt, the one after a binding the network rejected or + // unregistered, probation's retry-after — the policy keeps those). + // The shot's REGISTER wait dispatches + // requests, so an incoming call may have arrived under it: the + // same rule again. + if (!sip_.Alive()) { + recovery_.OnDial(); MaybeReconnect(); + if (!ClearForDial(c, emergency)) return; + } RefreshPani(); if (emergency) Log(std::format("EMERGENCY dial ({}): urn:service:sos over the live registration, " @@ -1399,7 +2153,12 @@ private: ? std::string_view{} : std::string_view(msg).substr(at + 4); if (method == "MESSAGE") Log(std::format("MESSAGE from {} ({} bytes):\n{}", imsd::sip::CallerId(msg), body.size(), body)); - else if (auto ev = imsd::sip::Header(msg, "Event"); ev && ev->starts_with("reg")) Log(std::format("reg-event NOTIFY ({} bytes):\n{}", body.size(), body)); + else if (auto ev = imsd::sip::Header(msg, "Event"); ev && ev->starts_with("reg")) { + Log(std::format("reg-event NOTIFY ({} bytes):\n{}", body.size(), body)); + if (reply_) reply_(imsd::msg::BuildResponse200(ctx_, msg)); + OnRegEventNotify(msg, body); + return; + } if (reply_) reply_(imsd::msg::BuildResponse200(ctx_, msg)); return; } @@ -1480,7 +2239,13 @@ private: } void MaybeDropCall() { - if (dropCall_) { call_.reset(); callReply_ = nullptr; dropCall_ = false; } + if (!dropCall_) return; + call_.reset(); callReply_ = nullptr; dropCall_ = false; + if (closeFlowWhenIdle_) { + closeFlowWhenIdle_ = false; + Log("the call is over: closing the flow of the binding the network terminated under it"); + sip_.Close(); + } } void StartMedia(const imsd::engine::MediaLeg& leg) { @@ -1494,8 +2259,18 @@ private: mediaBin_, local_, std::to_string(rtpPort_), leg.remoteIp, std::to_string(leg.remotePort), std::to_string(leg.payloadType), "86400", outBase}; + AudioSession session; + if (geteuid() == 0) { + session = ResolveAudioSession(); + Log(AudioSessionLine(session) + (session.name.empty() ? "; this call has no audio" : "")); + } pid_t pid = fork(); if (pid == 0) { + if (geteuid() == 0) { + // no session is said with an empty value: imsd-media reads a + // missing variable as a caller older than 0.3.10 + setenv("AUDIO_USER", session.name.c_str(), 1); + } setenv("MIC", EnvOr("MIC", "1").c_str(), 1); setenv("PLAY", EnvOr("PLAY", "1").c_str(), 1); setenv("GAIN", EnvOr("GAIN", "10").c_str(), 1); @@ -1637,7 +2412,9 @@ void HandleMethodCall(GDBusConnection*, const gchar*, const gchar*, const gchar* g_dbus_method_invocation_return_dbus_error(inv, "org.freedesktop.DBus.Error.UnknownMethod", "no such method"); } -const GDBusInterfaceVTable Vtable = { HandleMethodCall, nullptr, nullptr, {} }; +bool IsRead(std::string_view method) { return method == "GetStatus" || method == "GetCalls"; } +constexpr char ErrAccessDenied[] = "org.freedesktop.DBus.Error.AccessDenied"; +const GDBusInterfaceVTable Vtable = { imsd::bus::Gated, nullptr, nullptr, {} }; void OnBusAcquired(GDBusConnection* conn, const gchar*, gpointer) { DbusConn = conn; @@ -1683,6 +2460,8 @@ int main(int argc, char** argv) { return 1; } Log(std::format("LOCAL={} P-CSCF=[{}]:{}", local, EnvOr("PCSCF", "(unset)"), EnvOr("PCSCF_PORT", "5060"))); + if (!session) LogAudioSession(); + imsd::bus::AuthorizeCalls = !session; Engine engine; engine.SetLocal(local); diff --git a/implementations/media.cpp b/implementations/media.cpp index b7d5964..7f37fa7 100644 --- a/implementations/media.cpp +++ b/implementations/media.cpp @@ -410,41 +410,56 @@ std::size_t RtpPayloadOffset(std::span pkt) { return off; } -// The desktop user's uid, resolved ONCE and thread-safely. getpwnam() hands -// out one static buffer, and the mic and playout threads spawn their pw -// helpers at the same instant — the race sent both into /run/user/0, where -// no PipeWire session lives, and a call was answered into static (dev phone, -// 2026-09-08 20:01: tx_mic=0 rx_played=0 qdrop=883). Logged per call so a -// journal shows which session the helpers were aimed at. -uid_t AudioUid() { +// The desktop session whose PipeWire instance carries call audio, resolved +// ONCE and thread-safely: getpwnam() hands out one static buffer, and the +// mic and playout threads spawn their pw helpers at the same instant — the +// race sent both into /run/user/0, where no PipeWire session lives, and a +// call was answered into static (dev phone, 2026-09-08 20:01: tx_mic=0 +// rx_played=0 qdrop=883). AUDIO_USER names the account: imsd sets it per +// call from logind's seat0 session (or its own AUDIO_USER), and sets it +// empty when nobody is logged in. The packaged default used to be +// postmarketOS's account name `user`: a unit whose account was called +// otherwise got no audio and no message (alyx report 14, 2026-09-20). +// That default stays for a caller that does not set the variable at all — +// an imsd older than 0.3.10, still running while an upgrade replaces this +// binary, or a hand run — so its calls keep their audio. +// Logged per call so a journal shows which session the helpers were aimed at. +struct AudioSession { uid_t uid = 0; std::string name; }; + +const AudioSession& Session() { static std::once_flag once; - static uid_t uid = 0; + static AudioSession s; std::call_once(once, [] { - std::string user = EnvOr("AUDIO_USER", "user"); passwd pw{}; passwd* res = nullptr; char buf[4096]; - if (getpwnam_r(user.c_str(), &pw, buf, sizeof buf, &res) == 0 && res) uid = res->pw_uid; + const char* given = std::getenv("AUDIO_USER"); + std::string want = given ? given : "user"; + if (!given) std::println(std::cerr, "imsd-media: no AUDIO_USER in the environment (a caller older than 0.3.10, or a hand run); trying the account `user`"); + if (want.empty()) + std::println(std::cerr, "imsd-media: AUDIO_USER empty (no session to aim at); the helpers run as uid 0 (no audio)"); + else if (getpwnam_r(want.c_str(), &pw, buf, sizeof buf, &res) == 0 && res) { s.uid = res->pw_uid; s.name = want; } else - std::println(std::cerr, "imsd-media: AUDIO_USER '{}' not found; no PipeWire session for uid 0", user); - std::println(std::cerr, "imsd-media: audio session: {} -> /run/user/{}", user, uid); + std::println(std::cerr, "imsd-media: AUDIO_USER '{}' is not an account; the helpers run as uid 0 (no audio)", want); + std::println(std::cerr, "imsd-media: audio session: {} -> /run/user/{}", s.name.empty() ? std::string("(none)") : s.name, s.uid); }); - return uid; + return s; } -// Spawn pw-record/pw-play in the desktop user's PipeWire session (sudo -u -// when run as root). AUDIO_USER names the session owner; the default "user" -// is postmarketOS's standard account. `toChild` true = we write the child's -// stdin (pw-play); false = we read its stdout (pw-record). Returns {pid, fd}. +// Spawn pw-record/pw-play in the desktop session's PipeWire instance (sudo -u +// when run as root; as ourselves when there is no session to aim at). +// `toChild` true = we write the child's stdin (pw-play); false = we read its +// stdout (pw-record). Returns {pid, fd}. struct Child { pid_t pid = -1; int fd = -1; }; Child SpawnPw(bool play, bool toChild, int rate) { int pipefd[2]; if (pipe(pipefd) != 0) return {}; std::vector argv; if (geteuid() == 0) { - std::string user = EnvOr("AUDIO_USER", "user"); - argv = {"sudo", "-u", user, "env", - std::format("XDG_RUNTIME_DIR=/run/user/{}", AudioUid())}; + const AudioSession& session = Session(); + if (!session.name.empty()) + argv = {"sudo", "-u", session.name, "env", + std::format("XDG_RUNTIME_DIR=/run/user/{}", session.uid)}; } const char* tool = play ? "pw-play" : "pw-record"; const char* lat = play ? "40ms" : "20ms"; diff --git a/implementations/ofonod.cpp b/implementations/ofonod.cpp index a93bb15..2122b56 100644 --- a/implementations/ofonod.cpp +++ b/implementations/ofonod.cpp @@ -45,11 +45,12 @@ plugins/provider/ofono/ — get these wrong and the plugin silently ignores us: Runs as root because `org.ofono` is a system-bus name (policy in packaging/org.ofono.conf). It does no I/O beyond D-Bus: every call decision -stays in imsd. Everything happens on the GLib main loop, so there is no -locking. +stays in imsd. Its calls reach imsd as root, so it asks polkit about its own +callers (ImsdBus): call control is net.catcrafts.IMS1.Voice, as on imsd. +Everything happens on the GLib main loop, so there is no locking. --session own org.ofono on the session bus and talk to imsd there - (development: no root, no policy file) + (development: no root, no policy file, no polkit) IMSD_BUS=session|system override only the imsd side of that choice */ @@ -57,6 +58,7 @@ locking. #include import std; +import ImsdBus; namespace { @@ -315,7 +317,9 @@ void HandleCallMethod(GDBusConnection*, const gchar*, const gchar* objectPath, c g_dbus_method_invocation_return_dbus_error(inv, "org.freedesktop.DBus.Error.UnknownMethod", "no such method"); } -const GDBusInterfaceVTable CallVtable = { HandleCallMethod, nullptr, nullptr, {} }; +bool IsRead(std::string_view method) { return method == "GetProperties" || method == "GetCalls" || method == "GetModems"; } +constexpr char ErrAccessDenied[] = "org.ofono.Error.AccessDenied"; +const GDBusInterfaceVTable CallVtable = { imsd::bus::Gated, nullptr, nullptr, {} }; guint RegisterObject(const char* xml, const char* path, const GDBusInterfaceVTable* vtable) { GDBusNodeInfo* node = g_dbus_node_info_new_for_xml(xml, nullptr); @@ -596,9 +600,9 @@ void HandleSsMethod(GDBusConnection*, const gchar*, const gchar*, const gchar*, } const GDBusInterfaceVTable ManagerVtable = { HandleManagerMethod, nullptr, nullptr, {} }; -const GDBusInterfaceVTable ModemVtable = { HandleModemMethod, nullptr, nullptr, {} }; -const GDBusInterfaceVTable VcmVtable = { HandleVcmMethod, nullptr, nullptr, {} }; -const GDBusInterfaceVTable SsVtable = { HandleSsMethod, nullptr, nullptr, {} }; +const GDBusInterfaceVTable ModemVtable = { imsd::bus::Gated, nullptr, nullptr, {} }; +const GDBusInterfaceVTable VcmVtable = { imsd::bus::Gated, nullptr, nullptr, {} }; +const GDBusInterfaceVTable SsVtable = { imsd::bus::Gated, nullptr, nullptr, {} }; // ---- imsd events ------------------------------------------------------------ void PublishVoiceState() { @@ -727,6 +731,7 @@ int main(int argc, char** argv) { if (std::string_view(argv[i]) == "--session") session = true; GBusType ofonoBus = session ? G_BUS_TYPE_SESSION : G_BUS_TYPE_SYSTEM; + imsd::bus::AuthorizeCalls = !session; std::string imsdBusName = EnvOr("IMSD_BUS", session ? "session" : "system"); GBusType imsdBus = imsdBusName == "session" ? G_BUS_TYPE_SESSION : G_BUS_TYPE_SYSTEM; diff --git a/interfaces/Imsd-Engine.cppm b/interfaces/Imsd-Engine.cppm index 7b7805e..efb4ada 100644 --- a/interfaces/Imsd-Engine.cppm +++ b/interfaces/Imsd-Engine.cppm @@ -3,7 +3,9 @@ // lint-disable-file fixed-width-types /* -Imsd:Engine — the call state machine as a pure reducer. +Imsd:Engine — the daemon's decisions as pure reducers: the call state +machine (CallMachine) and the registration recovery policy (RegRecovery, +its own section below). A CallMachine owns one call's SIP dialog, outgoing or incoming. It never touches a socket, a clock, or a subprocess: each event (the INVITE's @@ -52,6 +54,215 @@ export namespace imsd::engine { return d == Direction::Incoming ? "incoming" : "outgoing"; } + // ---- registration recovery policy --------------------------------- + // + // What the daemon does when the registration stops working after + // bring-up, as a pure reducer: the shell supplies the clock and the + // network, this decides the next step and how long to wait for it. Two + // kinds of failure reach it. A REFUSAL: the network answered a refresh + // (or a fresh REGISTER) with a final other than 200/401. SILENCE: the + // flow died and reconnecting through the SA, or the refresh over it, + // got no answer. Neither is retried for long on the same registration. + // TS 24.229 5.1.1.4.1 has the UE perform an initial registration after + // a 408, 500, 504 or 403 on a re-REGISTER (5.1.1.2.1: not before a + // Retry-After has passed), and RFC 5626 has it register again when its + // flow cannot be recovered. imsd does so after any refusal: the 503s + // below are not in that list. Before this reducer the daemon looped + // reconnect + re-REGISTER on the same Call-ID and SA forever — O2 UK, + // 2026-09-24: 21 × 503 over 70 min, then 33 unanswered reconnect + // cycles over 2 h 39 min, each ended only by a manual restart whose + // fresh registration the network accepted within two seconds (ims + // ledger F16). + // + // Pacing. Reconnect cycles back off 5 s → 5 min. A refusal escalates + // to a fresh registration after two (one 503 may be a momentary + // hiccup a retried refresh clears), silence after two reconnect cycles + // that end without an answer — a TCP connect series whose last attempt + // was not a local refusal, a bind() the kernel refused, or a refresh + // that fails short of a refusal: unanswered, an unusable challenge, a + // failed send (the resume at start gives up after two unanswered + // connects). + // A fresh registration is the expensive step: an AKA run, a + // new SA pair, and on KPN a refusal (spi-s=0) while the registration on + // the address is live, which costs that registration. So a refused or + // throttled fresh attempt + // waits on the throttle schedule (21 min, growing ×1.5 to 60) and a + // silent one backs off 30 s → 10 min. Retry-After (RFC 3261 20.33) + // always wins over a shorter wait. A 403 is a refusal like the others: + // never hammered (the schedule), never abandoned (a SIM that is + // unbarred registers within the hour); once a fresh registration is + // due it also says the old registration is gone (KPN drops it the + // moment an initial REGISTER arrives, throttled or not), as does a + // fresh attempt that got past the SA setup, a binding the network + // terminated, and a resume that failed at bring-up (OldGone) — no + // reconnect over the old SA from then on. Every wait + // is either the self-imposed backoff, which a user's Dial cuts short, + // or the network's own word (a Retry-After, the schedule after a + // refused or throttled fresh attempt, a terminated binding's probation + // or its refusal schedule), which nothing cuts short — a + // restart of the process neither: the shell keeps the holds (Holds) + // and hands them to the next one (Restore). The shell supplies the + // clock and the network; this holds the deadlines and names the next + // step (Next). Success resets everything. + struct RegRecovery { + enum class Failure { Refused, Silent, Throttled }; + enum class Step { Reconnect, Fresh }; + // What comes next: the step, and the earliest time it may run (the + // shell's clock; at <= now means now). + struct Plan { Step step; double at; }; + + static constexpr int RefusalsBeforeFresh = 2; + static constexpr int SilentCyclesBeforeFresh = 2; + static constexpr double ReconnectDelayMin = 5; + static constexpr double ReconnectDelayMax = 300; + static constexpr double FreshDelayMin = 30; + static constexpr double FreshDelayMax = 600; + static constexpr int ThrottleMinutesMin = 21; + static constexpr int ThrottleMinutesMax = 60; + // The network's word is honoured up to the schedule's own longest + // wait: a Retry-After beyond an hour is cut to it, and the refusal + // heard again then — one REGISTER an hour at most, never a day + // unregistered on one answer. + static constexpr long RetryAfterMax = ThrottleMinutesMax * 60; + // A connect refused locally — our own 4-tuple still in TIME_WAIT + // (up to 60 s after we closed it), no route yet — is not the + // network's answer: retried soon, never counted, the backoff + // untouched. Dev phone 2026-09-24: four such refusals in 26 s read + // as two silent cycles cost a fresh registration into the throttle. + static constexpr double LocalRetry = 5; + + int refusals = 0; + int silentCycles = 0; + int freshAttempts = 0; + double reconnectDelay = ReconnectDelayMin; + double freshDelay = FreshDelayMin; + int throttleMinutes = ThrottleMinutesMin; + bool fresh = false; // the next registration attempt is a fresh one + bool oldGone = false; // the old registration is gone: nothing to reconnect over + bool terminated = false; // the network terminated our binding, and the policy has counted it + // the deadlines, on the shell's clock + double backoffUntil = 0; // self-imposed: no step before this; a Dial clears it + double holdUntil = 0; // the network's word for any attempt (a Retry-After on a refresh): nothing clears it + double freshHoldUntil = 0; // no fresh attempt before this (the schedule after a refused or throttled fresh attempt, a fresh Retry-After, probation, rejected/unregistered) + + bool Fresh() const { return fresh; } + bool Reconnectable() const { return !oldGone; } + + // The network's holds as seconds still to run, and where the + // schedule stands: what a restart of the process must not forget. + struct Held { + double hold = 0; + double freshHold = 0; + int throttleMinutes = ThrottleMinutesMin; + bool oldGone = false; // the old registration is gone: the next process has nothing to reconnect over + // a schedule that has advanced is kept too, until a success resets it, and so is the gone verdict + bool Any() const { return hold > 0 || freshHold > 0 || throttleMinutes > ThrottleMinutesMin || oldGone; } + }; + Held Holds(double now) const { return {std::max(0.0, holdUntil - now), std::max(0.0, freshHoldUntil - now), throttleMinutes, oldGone}; } + // A restarted process takes them over; nothing here shortens a + // hold it already has, or lengthens one past RetryAfterMax. + void Restore(double now, const Held& h) { + if (h.hold > 0) holdUntil = std::max(holdUntil, now + std::min(h.hold, static_cast(RetryAfterMax))); + if (h.freshHold > 0) freshHoldUntil = std::max(freshHoldUntil, now + std::min(h.freshHold, static_cast(RetryAfterMax))); + throttleMinutes = std::clamp(std::max(throttleMinutes, h.throttleMinutes), ThrottleMinutesMin, ThrottleMinutesMax); + if (h.oldGone) OldGone(); // no reconnect over SAs the network has written off + } + + // The next step and when. Not escalated: a reconnect after the + // backoff and any hold. Escalated: the fresh attempt once the + // backoff, any hold and the fresh hold have passed — and while a + // fresh hold still runs, a reconnect over the old SA meanwhile, the + // cheap thing, unless that registration is gone. + Plan Next(double now) const { + double any = std::max(backoffUntil, holdUntil); + double freshAt = std::max(any, freshHoldUntil); + if (!fresh) return {Step::Reconnect, any}; + if (!oldGone && freshHoldUntil > any && now < freshHoldUntil) return {Step::Reconnect, any}; + return {Step::Fresh, freshAt}; + } + + void OnLocalRefusal(double now) { backoffUntil = now + LocalRetry; } + + // A refused keepalive refresh, or a failed reconnect + refresh + // cycle; `status` is the refusal's. The backoff paces the next step; + // a Retry-After holds every attempt for its own length (up to + // RetryAfterMax), whichever is longer. A 403 once a fresh + // registration is due says the old registration is gone (KPN drops + // it the moment an initial REGISTER arrives, throttled or not). + void OnReconnectFailure(double now, Failure f, long retryAfter = 0, int status = 0) { + if (retryAfter > 0) holdUntil = std::max(holdUntil, now + static_cast(std::min(retryAfter, RetryAfterMax))); + // A cycle over a registration already known gone — the network + // terminated the binding while the cycle waited for its final — + // fails by definition: not a silent cycle, not a refusal, and no + // backoff before the fresh registration that verdict scheduled. + if (oldGone) return; + if (f == Failure::Refused) refusals++; + else silentCycles++; + if (refusals >= RefusalsBeforeFresh || silentCycles >= SilentCyclesBeforeFresh) fresh = true; + if (status == 403 && fresh) oldGone = true; + backoffUntil = now + reconnectDelay; + reconnectDelay = std::min(reconnectDelay * 2, ReconnectDelayMax); + } + + // A failed fresh registration; `saReplaced`: it got past the SA + // setup, so the old registration's SAs went with it. Refusals and + // throttles hold the next fresh attempt on the schedule (a Retry-After + // replaces it, never below the short backoff's floor nor above + // RetryAfterMax); silence backs + // it off 30 s → 10 min. A reconnect over the old SA meanwhile waits + // at least the reconnect backoff. + void OnFreshFailure(double now, Failure f, long retryAfter = 0, bool saReplaced = false) { + freshAttempts++; + fresh = true; + if (saReplaced) oldGone = true; + backoffUntil = std::max(backoffUntil, now + reconnectDelay); + if (f == Failure::Silent) { + backoffUntil = std::max(backoffUntil, now + freshDelay); + freshDelay = std::min(freshDelay * 2, FreshDelayMax); + return; + } + double wait = throttleMinutes * 60.0; + throttleMinutes = std::min(throttleMinutes * 3 / 2, ThrottleMinutesMax); + if (retryAfter > 0) wait = std::max(static_cast(std::min(retryAfter, RetryAfterMax)), FreshDelayMin); + freshHoldUntil = now + wait; + } + + // The network terminated our binding (a reg-event NOTIFY, RFC + // 3680): the old registration is gone. deactivated and expired + // mean a fresh registration now; probation after the network's + // retry-after (up to RetryAfterMax); rejected and unregistered on the + // refusal schedule — + // the network's word is neither hammered nor taken as final. One + // termination counts once, however often it is reported (every + // reg-event dialog of the account carries it, a NOTIFY may be + // retransmitted), and never shortens a hold already running. + void OnBindingTerminated(double now, std::string_view event, long retryAfter = 0) { + bool reported = terminated; + terminated = true; + oldGone = true; + fresh = true; + if (event == "probation") { + freshHoldUntil = std::max(freshHoldUntil, now + static_cast(std::clamp(retryAfter, 1L, RetryAfterMax))); + } else if (reported) { + return; + } else if (event == "rejected" || event == "unregistered") { + freshHoldUntil = std::max(freshHoldUntil, now + throttleMinutes * 60.0); + throttleMinutes = std::min(throttleMinutes * 3 / 2, ThrottleMinutesMax); + } else { + backoffUntil = 0; + } + } + + // The stored registration is not there to reconnect over (a resume + // that failed at bring-up found its SAs unusable, or there are none). + void OldGone() { oldGone = true; fresh = true; } + + // A user's Dial cuts the backoff short, never the network's word. + void OnDial() { backoffUntil = 0; } + + void OnSuccess() { *this = RegRecovery{}; } + }; + // Parsed SDP answer needed to bring up the media leg. `codec` is one of // imsd-media's: "AMR-WB", "AMR", "PCMA", "PCMU" (empty = none we play). struct MediaLeg { diff --git a/interfaces/Imsd-Ipsec.cppm b/interfaces/Imsd-Ipsec.cppm index fbbd514..611bb82 100644 --- a/interfaces/Imsd-Ipsec.cppm +++ b/interfaces/Imsd-Ipsec.cppm @@ -400,4 +400,17 @@ export namespace imsd::ipsec { sa.securityServer = std::format("ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=aes-cbc; " "spi-c={}; spi-s={}; port-c={}; port-s={}", sa.spiPc, sa.spiPs, sa.portPc, sa.portPs); return sa; } + + // Every local address holding an SA to the P-CSCF, once each, in kernel + // order: where a registration made on another address of the link (the + // kernel's temporary one, before 0.3.10 — ims ledger F18) still lives. + std::vector SaLocals(std::string_view stateText, std::string_view pcscf) { + std::vector locals; + for (std::string_view b : detail::SrcBlocks(stateText)) { + std::string_view src = detail::TokenAfter(b, "src "); + if (src.empty() || detail::TokenAfter(b, "dst ") != pcscf) continue; + if (std::ranges::find(locals, src) == locals.end()) locals.emplace_back(src); + } + return locals; + } } diff --git a/interfaces/Imsd-Sip.cppm b/interfaces/Imsd-Sip.cppm index bc3290c..306d199 100644 --- a/interfaces/Imsd-Sip.cppm +++ b/interfaces/Imsd-Sip.cppm @@ -121,6 +121,93 @@ export namespace imsd::sip { return v; } + // True for a response of the transaction (callid, cseq) — the reply a + // wait for that request may judge. A request, another transaction's + // response or anything else a socket delivers is not. + bool ResponseOf(std::string_view msg, std::string_view callid, std::string_view cseq) { + return Status(msg).has_value() && Header(msg, "Call-ID") == callid && Header(msg, "CSeq") == cseq; + } + + // Retry-After of a response (RFC 3261 20.33), in seconds; nullopt when + // absent or not a number. + std::optional RetryAfter(std::string_view msg) { + auto v = Header(msg, "Retry-After"); + if (!v) return std::nullopt; + return LeadingDigits(*v); + } + + // A refused final for the journal: its status line, then the headers + // that say who refused and for how long — Retry-After (RFC 3261 20.33), + // Warning (20.43: the node's own words), Reason (RFC 3326), Server + // (20.35). "503 Service Unavailable; Retry-After: 60; Warning: 399 + // pcscf1.example \"...\"". A bare status code says neither which node + // nor why: O2 UK answered a refresh 503 twenty-one times and a + // subscription 500 (alyx, 2026-09-24) and the journal could not tell + // the P-CSCF from the S-CSCF (ims ledger F17). + std::string RefusalSummary(std::string_view msg) { + std::string_view line = msg.substr(0, msg.find_first_of("\r\n")); + if (line.starts_with("SIP/2.0 ")) line.remove_prefix(8); + std::string s(Trim(line)); + for (std::string_view name : {"Retry-After", "Warning", "Reason", "Server"}) + for (std::string_view v : Headers(msg, name)) + s += std::format("; {}: {}", name, v); + return s; + } + + // Our binding in a reg-event NOTIFY body (RFC 3680 reginfo): the + // whose carries `user` as its SIP user part, across every + // of the implicit set (the sip: and the tel: identity + // list the same contact). Active anywhere = Active; otherwise the first + // terminated occurrence's event and retry-after attribute; NotListed + // when no contact of ours is in the document — a partial update about + // other bindings, or a previous registration's binding (the contact our + // own fresh registration deactivated, O2 UK 2026-09-24). + struct RegInfoContact { + enum class State { NotListed, Active, Terminated }; + State state = State::NotListed; + std::string event; // terminated: deactivated | probation | rejected | unregistered | expired | … + long retryAfter = 0; // terminated: the retry-after attribute (probation), seconds + }; + RegInfoContact RegInfoContactState(std::string_view reginfo, std::string_view user) { + constexpr std::size_t npos = std::string_view::npos; + RegInfoContact out; + std::string needle = std::format(":{}@", user); // sip:@… / sips:@… + std::size_t pos = 0; + while ((pos = reginfo.find("', pos); + if (tagEnd == npos) break; + std::string_view tag = reginfo.substr(pos, tagEnd - pos); + std::size_t end = reginfo.find("", tagEnd); + std::string_view body = reginfo.substr(tagEnd + 1, end == npos ? npos : end - tagEnd - 1); + pos = end == npos ? reginfo.size() : end + 10; + std::size_t u = body.find(""); + if (u == npos) continue; + std::size_t ue = body.find("", u); + std::string_view uri = Trim(body.substr(u + 5, ue == npos ? npos : ue - u - 5)); + if (!uri.contains(needle)) continue; + auto attr = [&](std::string_view name) -> std::string_view { + std::string key = std::format(" {}=\"", name); + std::size_t a = tag.find(key); + if (a == npos) return {}; + std::size_t b = tag.find('"', a + key.size()); + return tag.substr(a + key.size(), b == npos ? npos : b - a - key.size()); + }; + std::string_view state = attr("state"); + if (state == "active") { + out.state = RegInfoContact::State::Active; + out.event.clear(); + out.retryAfter = 0; + return out; + } + if (state == "terminated" && out.state == RegInfoContact::State::NotListed) { + out.state = RegInfoContact::State::Terminated; + out.event = std::string(attr("event")); + out.retryAfter = LeadingDigits(attr("retry-after")).value_or(0); + } + } + return out; + } + // Where a UDP response to this request must go (RFC 3261 18.2.2): the // top Via's sent-by port — unless the Via carries rport, in which case // the caller should reply to the request's source port instead diff --git a/interfaces/ImsdBus.cppm b/interfaces/ImsdBus.cppm new file mode 100644 index 0000000..4156d88 --- /dev/null +++ b/interfaces/ImsdBus.cppm @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: GPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// lint-disable-file fixed-width-types no-char-pointer +/* +ImsdBus — what the two system-bus daemons (imsd, imsd-ofonod) share on the +bus side: the polkit gate in front of call control. + +The bus policies let any local account reach both names, so the session +account can dial whatever it is called; whether a caller may place, answer +or end a call is polkit's net.catcrafts.IMS1.Voice +(packaging/net.catcrafts.IMS1.policy), shaped like ModemManager's Voice +action: the active local session and root, not an ssh login, an inactive +session or a system service account. Reads (status, the call list) stay +open, as ModemManager's are to every local account; the call signals reach +them all anyway. + +GLib, so not part of the GLib-free imsd-core. +*/ + +module; +#include +export module ImsdBus; +import std; + +namespace imsd::bus { + +constexpr const char* VoiceAction = "net.catcrafts.IMS1.Voice"; + +// CheckAuthorization's AllowUserInteraction, as ModemManager passes it: a +// rule an administrator sets to auth_self prompts through the session's +// polkit agent. +constexpr guint32 AllowUserInteraction = 1; + +struct Pending { + GDBusMethodInvocation* inv; + GDBusInterfaceMethodCallFunc body; + const char* deniedError; + void (*log)(std::string_view); +}; + +void OnCheck(GObject* src, GAsyncResult* res, gpointer data) { + std::unique_ptr p(static_cast(data)); + GDBusMethodInvocation* inv = p->inv; + GError* err = nullptr; + GVariant* r = g_dbus_connection_call_finish(G_DBUS_CONNECTION(src), res, &err); + gboolean authorized = FALSE; + gboolean challenge = FALSE; + if (r) { + GVariant* details = nullptr; + g_variant_get(r, "((bb@a{ss}))", &authorized, &challenge, &details); + g_variant_unref(details); + g_variant_unref(r); + } + if (authorized) { + p->body(g_dbus_method_invocation_get_connection(inv), g_dbus_method_invocation_get_sender(inv), + g_dbus_method_invocation_get_object_path(inv), g_dbus_method_invocation_get_interface_name(inv), + g_dbus_method_invocation_get_method_name(inv), g_dbus_method_invocation_get_parameters(inv), inv, + g_dbus_method_invocation_get_user_data(inv)); + return; + } + std::string why = err ? std::format("{}: polkit did not answer: {}", g_dbus_method_invocation_get_sender(inv), err->message) + : std::format("{} is not authorized for {}", g_dbus_method_invocation_get_sender(inv), VoiceAction); + p->log(std::format("{} refused: {}", g_dbus_method_invocation_get_method_name(inv), why)); + g_dbus_method_invocation_return_dbus_error(inv, p->deniedError, why.c_str()); + if (err) g_error_free(err); +} + +} // namespace imsd::bus + +export namespace imsd::bus { + +// Off for a --session run: the session bus has no polkit, and its callers +// are the owner's own processes. +inline bool AuthorizeCalls = true; + +// Run `body` for the call `inv` once polkit allows its sender +// net.catcrafts.IMS1.Voice; otherwise answer `deniedError` and log why. The +// check is asynchronous — nothing else on the main loop waits for it. +void Authorize(GDBusMethodInvocation* inv, GDBusInterfaceMethodCallFunc body, const char* deniedError, void (*log)(std::string_view)) { + GVariantBuilder subject; + g_variant_builder_init(&subject, G_VARIANT_TYPE("a{sv}")); + g_variant_builder_add(&subject, "{sv}", "name", g_variant_new_string(g_dbus_method_invocation_get_sender(inv))); + GVariantBuilder details; + g_variant_builder_init(&details, G_VARIANT_TYPE("a{ss}")); + g_dbus_connection_call(g_dbus_method_invocation_get_connection(inv), "org.freedesktop.PolicyKit1", + "/org/freedesktop/PolicyKit1/Authority", "org.freedesktop.PolicyKit1.Authority", + "CheckAuthorization", + g_variant_new("((sa{sv})sa{ss}us)", "system-bus-name", &subject, VoiceAction, &details, AllowUserInteraction, ""), + G_VARIANT_TYPE("((bba{ss}))"), G_DBUS_CALL_FLAGS_NONE, G_MAXINT, nullptr, OnCheck, + new Pending{ inv, body, deniedError, log }); +} + +// A vtable method handler for `Body` that lets `IsRead` methods through and +// puts every other method — call control, and anything added later — behind +// Authorize. +template +void Gated(GDBusConnection* conn, const gchar* sender, const gchar* path, const gchar* iface, const gchar* method, GVariant* params, GDBusMethodInvocation* inv, gpointer user) { + if (!AuthorizeCalls || IsRead(method)) { + Body(conn, sender, path, iface, method, params, inv, user); + return; + } + Authorize(inv, Body, DeniedError, Log); +} + +} // namespace imsd::bus diff --git a/lint-rules.h b/lint-rules.h deleted file mode 100644 index 12461fd..0000000 --- a/lint-rules.h +++ /dev/null @@ -1,858 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-only -// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® -#pragma once - -// This repo's house-style lint rules. Deliberately project-local — the rule -// set is NOT part of the crafter-build library, so downstream consumers are -// never nudged toward one team's style. Include from project.cpp AFTER -// `import std;` and `import Crafter.Build;` (the header uses both and has no -// includes of its own), then call AddProjectLintRules(cfg). -// -// Report-only rules are registered first so their file:line numbers always -// match the on-disk file; transforms run last because each one sees the -// previous transform's output, which can shift line numbers. -// -// All detection runs on ctx.CommentStripped() — comments and string/char -// literal bodies are blanked to spaces with newlines preserved, so byte -// offsets and line numbers in the stripped text are valid in ctx.content. -// Transforms use that property directly: find in stripped, edit in content. - -namespace ProjectLint { - -inline bool IsCppFile(const Crafter::LintContext& ctx) { - std::string ext = ctx.file.extension().string(); - return ext == ".cpp" || ext == ".cppm" || ext == ".h"; -} - -inline std::vector Lines(std::string_view text) { - std::vector lines; - std::size_t start = 0; - while (start <= text.size()) { - std::size_t end = text.find('\n', start); - if (end == std::string_view::npos) { - if (start < text.size()) lines.push_back(text.substr(start)); - break; - } - lines.push_back(text.substr(start, end - start)); - start = end + 1; - } - return lines; -} - -inline std::string_view Trim(std::string_view s) { - while (!s.empty() && (s.front() == ' ' || s.front() == '\t' || s.front() == '\r')) s.remove_prefix(1); - while (!s.empty() && (s.back() == ' ' || s.back() == '\t' || s.back() == '\r')) s.remove_suffix(1); - return s; -} - -inline bool IsWordChar(char c) { - return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'; -} - -// Net '(' minus ')' on one stripped line. -inline std::int64_t ParenDelta(std::string_view s) { - std::int64_t d = 0; - for (char c : s) { - if (c == '(') ++d; - if (c == ')') --d; - } - return d; -} - -inline bool IsPascalCase(std::string_view name) { - return !name.empty() && name[0] >= 'A' && name[0] <= 'Z' && !name.contains('_'); -} - -inline bool IsCamelCase(std::string_view name) { - // One trailing underscore is the member-shadowing-a-keyword convention - // (requires_, label_) — allowed. - if (name.ends_with('_')) name.remove_suffix(1); - return !name.empty() && ((name[0] >= 'a' && name[0] <= 'z') || name[0] == '_') && name.find('_', 1) == std::string_view::npos; -} - -// The identifier ending right before position `pos` (exclusive), or empty. -inline std::string_view WordBefore(std::string_view s, std::size_t pos) { - std::size_t end = pos; - while (end > 0 && (s[end - 1] == ' ' || s[end - 1] == '\t')) --end; - std::size_t begin = end; - while (begin > 0 && (IsWordChar(s[begin - 1]) || s[begin - 1] == '~')) --begin; - return s.substr(begin, end - begin); -} - -inline void AddProjectLintRules(Crafter::Configuration& cfg) { - using Crafter::LintContext; - - // ---------------- report-only rules ---------------- - - // License header: SPDX identifier on line 1, copyright on line 2, blank - // line 3. - cfg.AddLintRule("spdx-header", [](LintContext& ctx) { - if (!IsCppFile(ctx)) return; - if (!ctx.Line(1).starts_with("// SPDX-License-Identifier:")) { - ctx.Report(1, "first line must be // SPDX-License-Identifier: ..."); - } - if (!ctx.Line(2).starts_with("// SPDX-FileCopyrightText:")) { - ctx.Report(2, "second line must be // SPDX-FileCopyrightText: ..."); - } - if (ctx.lines.size() >= 3 && !Trim(ctx.Line(3)).empty()) { - ctx.Report(3, "third line must be blank (separates the license header)"); - } - }); - - cfg.AddLintRule("no-tabs", [](LintContext& ctx) { - if (!IsCppFile(ctx)) return; - for (std::size_t n = 1; n <= ctx.lines.size(); ++n) { - if (ctx.Line(n).contains('\t')) ctx.Report(n, "tab character (use spaces)"); - } - }); - - // Naming: functions/types PascalCase, variables camelCase, statics and - // namespace-scope globals PascalCase. A line-based scope tracker decides - // whether a declaration sits at namespace scope (global) or inside a - // function/type (local/member). Heuristic by nature — report-only. - cfg.AddLintRule("naming", [](LintContext& ctx) { - if (!IsCppFile(ctx)) return; - std::vector lines = Lines(ctx.CommentStripped()); - - enum class Scope { Namespace, Type, Function, Other }; - std::vector stack; - auto currentScope = [&]() { return stack.empty() ? Scope::Namespace : stack.back(); }; - - static const std::regex typeDecl(R"(\b(?:class|struct|union)\s+(?:CRAFTER_API\s+)?([A-Za-z_]\w*))"); - static const std::regex enumDecl(R"(\benum\s+(?:class\s+|struct\s+)?([A-Za-z_]\w*))"); - static const std::regex usingDecl(R"(^\s*using\s+([A-Za-z_]\w*)\s*=)"); - static const std::regex varDecl( - R"(^\s*((?:static|constexpr|const|inline|mutable|thread_local|export|CRAFTER_API)\s+)*)" - R"((?:std::)?[A-Za-z_][\w:]*(?:<[^;={]*>)?(?:\s*[&*])*\s+([A-Za-z_]\w*)\s*(=|;|\{))"); - static const std::unordered_set keywords = { - "if", "for", "while", "switch", "catch", "return", "else", "do", "case", "goto", - "new", "delete", "throw", "using", "namespace", "template", "typedef", "friend", - "public", "private", "protected", "class", "struct", "enum", "union", "import", - "module", "export", "break", "continue", "co_return", "co_await", "co_yield", - "sizeof", "alignof", "decltype", "static_assert", "operator", "try", "requires", - }; - - // Cumulative paren depth at the start of each line: declaration and - // function checks only run at depth 0, so wrapped parameter lists and - // continuation lines (`) {` closers) never look like declarations. - std::int64_t parenDepth = 0; - for (std::size_t i = 0; i < lines.size(); ++i) { - std::string_view trimmed = Trim(lines[i]); - std::string lineStr(lines[i]); - std::smatch m; - bool atDepth0 = parenDepth == 0; - parenDepth = std::max(0, parenDepth + ParenDelta(lines[i])); - - if (!trimmed.starts_with('#') && atDepth0) { - // Type / alias names must be PascalCase. - if (std::regex_search(lineStr, m, typeDecl) || std::regex_search(lineStr, m, enumDecl)) { - std::string name = m[1].str(); - if (!keywords.contains(name) && !IsPascalCase(name)) { - ctx.Report(i + 1, std::format("type '{}' should be PascalCase", name)); - } - } - if (std::regex_search(lineStr, m, usingDecl) && !IsPascalCase(m[1].str())) { - ctx.Report(i + 1, std::format("type alias '{}' should be PascalCase", m[1].str())); - } - - // Function definitions: identifier before the first '(' on a - // line that ends where a body opens or closes — a multi-line - // def's trailing '{' or a one-liner's trailing '}'. Statement - // calls with inline lambda arguments look similar - // (`bool x = std::any_of(..., [](T v) { ... });`) but end in - // ';' and/or carry '=' before the name — both excluded. - // Lambdas ("](") and control keywords are skipped; ctors/ - // dtors pass the Pascal check by construction; `main` and - // operators exempt. - std::size_t bodyBrace = trimmed.find('{'); - bool functionShaped = trimmed.ends_with('{') || trimmed.ends_with('}'); - if (functionShaped && currentScope() != Scope::Function && !trimmed.starts_with("return")) { - std::size_t paren = trimmed.find('('); - if (paren != std::string_view::npos && paren > 0 && paren < bodyBrace) { - std::string_view name = WordBefore(trimmed, paren); - char before = trimmed[paren - 1]; - bool looksLikeDef = !name.empty() && IsWordChar(before) - && !keywords.contains(name) && name != "main" - && !name.starts_with('~'); - // Require a type token before the name (or a - // qualified Class::Name), with no '=' in front — - // plain calls and initializations don't match. - if (looksLikeDef) { - std::size_t nameStart = trimmed.rfind(name, paren); - std::string_view prefix = Trim(trimmed.substr(0, nameStart)); - looksLikeDef = !prefix.empty() && !prefix.contains('=') - && (IsWordChar(prefix.back()) || prefix.back() == '>' - || prefix.back() == '*' || prefix.back() == '&' - || prefix.ends_with("::")); - } - if (looksLikeDef && !IsPascalCase(name)) { - ctx.Report(i + 1, std::format("function '{}' should be PascalCase", name)); - } - } - } - - // Variable declarations: camelCase locally, PascalCase for - // statics and namespace-scope globals. - if (std::regex_search(lineStr, m, varDecl)) { - std::string name = m[2].str(); - std::string_view typeToken = Trim(std::string_view(lineStr).substr(0, static_cast(m.position(2)))); - std::string_view firstWord = typeToken.substr(0, typeToken.find_first_of(" \t<")); - if (!keywords.contains(firstWord) && !keywords.contains(name)) { - bool isStatic = lineStr.contains("static "); - // constexpr variables are compile-time constants — - // constant naming (PascalCase) like statics/globals. - bool isConstexpr = lineStr.contains("constexpr "); - bool global = currentScope() == Scope::Namespace; - if ((isStatic || global || isConstexpr) && !IsPascalCase(name)) { - ctx.Report(i + 1, std::format("{} '{}' should be PascalCase", - isStatic ? "static variable" - : isConstexpr ? "constexpr constant" - : "global variable", name)); - } else if (!isStatic && !global && !isConstexpr && !IsCamelCase(name)) { - ctx.Report(i + 1, std::format("variable '{}' should be camelCase", name)); - } - } - } - } - - // Scope tracking: classify each '{' opened on this line; pop on '}'. - for (std::size_t c = 0; c < lines[i].size(); ++c) { - if (lines[i][c] == '{') { - Scope kind = Scope::Other; - std::string_view upTo = Trim(lines[i].substr(0, c)); - if (trimmed.starts_with("namespace") || upTo.contains("namespace ")) { - kind = Scope::Namespace; - } else if (std::regex_search(lineStr, typeDecl) || std::regex_search(lineStr, enumDecl)) { - kind = Scope::Type; - } else if (upTo.ends_with(')') || upTo.ends_with("const") || upTo.ends_with("noexcept") - || upTo.ends_with("->") || trimmed.starts_with("extern")) { - kind = Scope::Function; // function/lambda/control body — all non-global - } - stack.push_back(kind); - } else if (lines[i][c] == '}') { - if (!stack.empty()) stack.pop_back(); - } - } - } - }); - - cfg.AddLintRule("enum-class", [](LintContext& ctx) { - if (!IsCppFile(ctx)) return; - std::vector lines = Lines(ctx.CommentStripped()); - static const std::regex plainEnum(R"(\benum\s+(?!class\b|struct\b)[A-Za-z_])"); - for (std::size_t i = 0; i < lines.size(); ++i) { - std::string lineStr(lines[i]); - if (std::regex_search(lineStr, plainEnum)) { - ctx.Report(i + 1, "use enum class instead of plain enum"); - } - } - }); - - cfg.AddLintRule("no-iostream-print", [](LintContext& ctx) { - if (!IsCppFile(ctx)) return; - std::vector lines = Lines(ctx.CommentStripped()); - for (std::size_t i = 0; i < lines.size(); ++i) { - if (lines[i].contains("std::cout")) { - ctx.Report(i + 1, "use std::println instead of std::cout"); - } else if (lines[i].contains("std::cerr") && lines[i].contains("<<")) { - ctx.Report(i + 1, "use std::println(std::cerr, ...) instead of streaming to std::cerr"); - } - } - }); - - // Prefer std::string / std::string_view over char*. OS interop stays: - // getenv returns char*, argv is char**, extern "C" prototypes mirror C. - cfg.AddLintRule("no-char-pointer", [](LintContext& ctx) { - if (!IsCppFile(ctx)) return; - std::vector lines = Lines(ctx.CommentStripped()); - static const std::regex charPtr(R"(\bchar\s*\*)"); - for (std::size_t i = 0; i < lines.size(); ++i) { - std::string lineStr(lines[i]); - // C-interop stays char*: argv, getenv/setenv, dlerror, C APIs fed - // by c_str()/data(), binary IO reinterpret_casts, extern "C" - // prototypes. (extern " matches with the literal body blanked.) - if (lineStr.contains("argv") || lineStr.contains("getenv") || lineStr.contains("setenv") - || lineStr.contains("dlerror") || lineStr.contains("c_str") || lineStr.contains(".data(") - || lineStr.contains("reinterpret_cast") || lineStr.contains("extern \"")) continue; - if (std::regex_search(lineStr, charPtr)) { - ctx.Report(i + 1, "prefer std::string / std::string_view over char*"); - } - } - }); - - // String building via `+` with a literal operand → std::format. Plain - // `a += b` accumulation (builder pattern) stays legal. Single-line chains - // of simple operands (identifier / member / call chains, parenthesized - // groups, literals) are REWRITTEN automatically; anything the operand - // scanner can't prove safe — raw-string lines, ternaries, mixed - // operators, multi-line expressions — is reported for a human instead. - cfg.AddLintRule("format-concat", [](LintContext& ctx) { - if (!IsCppFile(ctx)) return; - const std::string& code = ctx.CommentStripped(); - std::vector stripped = Lines(code); - - // Walk one operand leftwards from `from` (exclusive). Returns the - // operand's begin, or npos when the shape isn't a simple postfix - // chain (then the whole chain bails to a report). - auto operandLeft = [](std::string_view s, std::size_t from) -> std::size_t { - std::size_t i = from; - while (i > 0 && (s[i - 1] == ' ' || s[i - 1] == '\t')) --i; - bool any = false; - for (;;) { - if (i == 0) break; - char c = s[i - 1]; - if (c == ')' || c == ']') { - char open = c == ')' ? '(' : '['; - std::size_t depth = 0; - do { - --i; - if (s[i] == c) ++depth; - if (s[i] == open) --depth; - if (depth == 0) break; - } while (i > 0); - if (depth != 0) return std::string_view::npos; - any = true; - // A group directly preceded by an identifier (or another - // group) is a call/index postfix — keep consuming the - // callee: `path.string()` is ONE operand, not `()`. - if (i > 0 && (IsWordChar(s[i - 1]) || s[i - 1] == ')' || s[i - 1] == ']')) continue; - } else if (c == '"') { - --i; // closing quote; interior is blanked, find the opener - while (i > 0 && s[i - 1] != '"') --i; - if (i == 0) return std::string_view::npos; - --i; - any = true; - } else if (IsWordChar(c)) { - while (i > 0 && IsWordChar(s[i - 1])) --i; - any = true; - } else if (c == '.' && any) { - --i; - continue; - } else if (c == ':' && i > 1 && s[i - 2] == ':' && any) { - i -= 2; - continue; - } else if (c == '>' && i > 1 && s[i - 2] == '-' && any) { - i -= 2; - continue; - } else { - break; - } - // After a primary, only connectors continue the operand. - if (i > 0 && (s[i - 1] == '.' || (s[i - 1] == ':' && i > 1 && s[i - 2] == ':') - || (s[i - 1] == '>' && i > 1 && s[i - 2] == '-'))) continue; - break; - } - if (!any) return std::string_view::npos; - // A unary operator, ternary, or other-precedence operator in - // front means expression structure we don't reason about — bail. - std::size_t b = i; - while (b > 0 && (s[b - 1] == ' ' || s[b - 1] == '\t')) --b; - if (b > 0 && std::string_view("!*&~-?:<>/%^|").contains(s[b - 1])) return std::string_view::npos; - return i; - }; - - // Walk one operand rightwards from `from` (inclusive). Returns one - // past the operand's end, or npos on bail. - auto operandRight = [](std::string_view s, std::size_t from) -> std::size_t { - std::size_t i = from; - while (i < s.size() && (s[i] == ' ' || s[i] == '\t')) ++i; - bool any = false; - for (;;) { - if (i >= s.size()) break; - char c = s[i]; - if (c == '(' || c == '[') { - char close = c == '(' ? ')' : ']'; - std::size_t depth = 0; - while (i < s.size()) { - if (s[i] == c) ++depth; - if (s[i] == close && --depth == 0) { ++i; break; } - ++i; - } - if (depth != 0) return std::string_view::npos; - any = true; - } else if (c == '"') { - ++i; - while (i < s.size() && s[i] != '"') ++i; - if (i >= s.size()) return std::string_view::npos; - ++i; - any = true; - } else if (IsWordChar(c)) { - while (i < s.size() && IsWordChar(s[i])) ++i; - any = true; - } else if (any && c == '.') { - ++i; - continue; - } else if (any && c == ':' && i + 1 < s.size() && s[i + 1] == ':') { - i += 2; - continue; - } else if (any && c == '-' && i + 1 < s.size() && s[i + 1] == '>') { - i += 2; - continue; - } else { - break; - } - if (i < s.size() && (s[i] == '(' || s[i] == '[' || s[i] == '.' - || (s[i] == ':' && i + 1 < s.size() && s[i + 1] == ':') - || (s[i] == '-' && i + 1 < s.size() && s[i + 1] == '>'))) continue; - break; - } - return any ? i : std::string_view::npos; - }; - - struct Edit { std::size_t begin; std::size_t end; std::string replacement; }; - std::vector edits; // offsets into ctx.content - std::size_t lineStart = 0; - for (std::size_t li = 0; li < stripped.size(); ++li) { - std::string_view line = stripped[li]; - std::size_t lineOff = lineStart; - lineStart += line.size() + 1; - std::string_view trimmed = Trim(line); - if (trimmed.starts_with('#')) continue; - // Raw strings defeat the comment stripper's quote tracking; any - // literal-+ pattern on such a line is a manual fix. - bool hasRaw = std::string_view(ctx.content).substr(lineOff, line.size()).contains("R\""); - - std::size_t searchFrom = 0; - while (searchFrom < line.size()) { - // A candidate `+` that isn't ++ / += and touches a literal. - std::size_t plus = line.find('+', searchFrom); - if (plus == std::string_view::npos) break; - searchFrom = plus + 1; - if (plus + 1 < line.size() && (line[plus + 1] == '+' || line[plus + 1] == '=')) { ++searchFrom; continue; } - if (plus > 0 && line[plus - 1] == '+') continue; - std::size_t leftEnd = plus; - while (leftEnd > 0 && (line[leftEnd - 1] == ' ' || line[leftEnd - 1] == '\t')) --leftEnd; - std::size_t rightBegin = plus + 1; - while (rightBegin < line.size() && (line[rightBegin] == ' ' || line[rightBegin] == '\t')) ++rightBegin; - bool literalAdjacent = (leftEnd > 0 && line[leftEnd - 1] == '"') - || (rightBegin < line.size() && line[rightBegin] == '"'); - if (!literalAdjacent) continue; - if (hasRaw) { - ctx.Report(li + 1, "use std::format instead of string concatenation with + (raw-string line, fix manually)"); - break; - } - - // Expand to the full chain: operands joined by `+`. - std::size_t chainBegin = operandLeft(line, plus); - std::size_t chainEnd = operandRight(line, plus + 1); - bool bail = chainBegin == std::string_view::npos || chainEnd == std::string_view::npos; - while (!bail) { - std::size_t b = chainBegin; - while (b > 0 && (line[b - 1] == ' ' || line[b - 1] == '\t')) --b; - if (b > 0 && line[b - 1] == '+' && !(b > 1 && line[b - 2] == '+')) { - std::size_t prev = operandLeft(line, b - 1); - if (prev == std::string_view::npos) { bail = true; break; } - chainBegin = prev; - } else break; - } - while (!bail) { - std::size_t e = chainEnd; - while (e < line.size() && (line[e] == ' ' || line[e] == '\t')) ++e; - if (e < line.size() && line[e] == '+' && !(e + 1 < line.size() && (line[e + 1] == '+' || line[e + 1] == '='))) { - std::size_t next = operandRight(line, e + 1); - if (next == std::string_view::npos) { bail = true; break; } - chainEnd = next; - } else break; - } - if (bail) { - ctx.Report(li + 1, "use std::format instead of string concatenation with + (not auto-fixable)"); - break; - } - - // Split the chain at depth-0 '+' into parts; literals feed the - // format string, everything else becomes an argument. - std::string_view chain = line.substr(chainBegin, chainEnd - chainBegin); - std::string_view rawChain = std::string_view(ctx.content).substr(lineOff + chainBegin, chainEnd - chainBegin); - std::string fmt; - std::vector args; - std::size_t partBegin = 0; - std::int64_t depth = 0; - bool inLit = false; - for (std::size_t p = 0; p <= chain.size(); ++p) { - if (p < chain.size()) { - char c = chain[p]; - if (c == '"') inLit = !inLit; - if (inLit) continue; - if (c == '(' || c == '[') ++depth; - if (c == ')' || c == ']') --depth; - if (!(c == '+' && depth == 0)) continue; - } - std::string_view part = Trim(chain.substr(partBegin, p - partBegin)); - std::string_view rawPart = Trim(rawChain.substr(partBegin, p - partBegin)); - if (part.starts_with('"') && part.ends_with('"') && part.size() >= 2 - && std::count(part.begin(), part.end(), '"') == 2) { - for (char c : rawPart.substr(1, rawPart.size() - 2)) { - fmt += c; - if (c == '{') fmt += '{'; - if (c == '}') fmt += '}'; - } - } else if (part.contains('"') && part.contains('+')) { - bail = true; // nested concat inside an operand — human territory - break; - } else { - fmt += "{}"; - args.push_back(rawPart); - } - partBegin = p + 1; - } - if (bail || args.empty()) { - ctx.Report(li + 1, "use std::format instead of string concatenation with + (not auto-fixable)"); - break; - } - std::string replacement = std::format("std::format(\"{}\"", fmt); - for (std::string_view a : args) replacement += std::format(", {}", a); - replacement += ")"; - edits.push_back({lineOff + chainBegin, lineOff + chainEnd, std::move(replacement)}); - searchFrom = chainEnd; - } - } - if (edits.empty()) return; - std::string out = ctx.content; - std::sort(edits.begin(), edits.end(), [](const Edit& a, const Edit& b) { return a.begin > b.begin; }); - for (const Edit& e : edits) out.replace(e.begin, e.end - e.begin, e.replacement); - ctx.SetContent(std::move(out)); - }); - - // ---------------- transforms (auto-fixed by `crafter-build format`) ---------------- - - // short/int/long → fixed-width types. Lines mentioning main/argc/argv or - // extern "C" keep their C-conventional ints. - cfg.AddLintRule("fixed-width-types", [](LintContext& ctx) { - if (!IsCppFile(ctx)) return; - const std::string& code = ctx.CommentStripped(); - struct Rep { std::size_t pos; std::size_t len; std::string to; }; - std::vector reps; - // Builtin integer type specifiers combine in any order (`unsigned - // long`, `long unsigned int`, ...), so match whole RUNS of these - // keywords and classify the run, rather than the words one by one. - static const std::unordered_set IntWords = { - "unsigned", "signed", "short", "long", "int", "char", - }; - std::size_t lineStart = 0; - while (lineStart <= code.size()) { - std::size_t lineEnd = code.find('\n', lineStart); - if (lineEnd == std::string::npos) lineEnd = code.size(); - std::string_view line(code.data() + lineStart, lineEnd - lineStart); - // `int main` / argc / argv keep their C-conventional type; so do - // extern "C" prototypes (the literal body is blanked in the - // stripped text, so match `extern "`). - bool exempt = line.contains("int main") || line.contains("argc") || line.contains("argv") - || line.contains("extern \""); - std::size_t pos = 0; - while (!exempt && pos < line.size()) { - if (!IsWordChar(line[pos])) { ++pos; continue; } - std::size_t wordEnd = pos; - while (wordEnd < line.size() && IsWordChar(line[wordEnd])) ++wordEnd; - if (!IntWords.contains(line.substr(pos, wordEnd - pos))) { pos = wordEnd; continue; } - - // Extend the run over consecutive specifier keywords. - std::size_t runBegin = pos; - std::size_t runEnd = wordEnd; - bool hasUnsigned = false; - bool hasSigned = false; - bool hasShort = false; - bool hasChar = false; - bool hasLong = false; - std::string_view nextWord; - for (;;) { - std::string_view word = line.substr(pos, wordEnd - pos); - if (word == "unsigned") hasUnsigned = true; - else if (word == "signed") hasSigned = true; - else if (word == "short") hasShort = true; - else if (word == "char") hasChar = true; - else if (word == "long") hasLong = true; - runEnd = wordEnd; - pos = wordEnd; - while (pos < line.size() && (line[pos] == ' ' || line[pos] == '\t')) ++pos; - wordEnd = pos; - while (wordEnd < line.size() && IsWordChar(line[wordEnd])) ++wordEnd; - nextWord = line.substr(pos, wordEnd - pos); - if (!IntWords.contains(nextWord)) break; - } - pos = runEnd; - - // Bare `char` is text, not an integer — only signed/unsigned - // char is byte arithmetic. `long double` is a floating type. - if (hasChar && !hasUnsigned && !hasSigned) continue; - if (nextWord == "double") continue; - - std::string_view width = hasChar ? "8" : hasShort ? "16" : hasLong ? "64" : "32"; - reps.push_back({lineStart + runBegin, runEnd - runBegin, - std::format("std::{}int{}_t", hasUnsigned ? "u" : "", width)}); - } - lineStart = lineEnd + 1; - } - if (reps.empty()) return; - std::string out = ctx.content; - std::sort(reps.begin(), reps.end(), [](const Rep& a, const Rep& b) { return a.pos > b.pos; }); - for (const Rep& r : reps) out.replace(r.pos, r.len, r.to); - ctx.SetContent(std::move(out)); - }); - - // One declaration per statement. Auto-splits the simple initialized form - // `Type a = x, b = y;`; anything with pointers/references, parens, or - // templates in the declarators is only reported (splitting `int* a, b;` - // would change b's type). - cfg.AddLintRule("single-declaration", [](LintContext& ctx) { - if (!IsCppFile(ctx)) return; - std::vector stripped = Lines(ctx.CommentStripped()); - // The declarator char class excludes quotes: string-literal bodies are - // blanked in the stripped text, so reconstructing them would corrupt - // the file — those lines are left alone. - static const std::regex simpleMulti( - R"(^(\s*)((?:std::)?[A-Za-z_][\w:]*)\s+([A-Za-z_]\w*\s*=\s*[^,;()<>*&"]+(?:,\s*[A-Za-z_]\w*\s*=\s*[^,;()<>*&"]+)+);\s*$)"); - std::string out; - bool changed = false; - for (std::size_t i = 0; i < stripped.size(); ++i) { - std::string lineStr(stripped[i]); - std::smatch m; - std::string_view raw = i + 1 <= ctx.lines.size() ? ctx.Line(i + 1) : std::string_view{}; - if (!raw.contains("//") && !ctx.Suppressed("single-declaration", i + 1) - && std::regex_match(lineStr, m, simpleMulti)) { - std::string indent = m[1].str(), type = m[2].str(), decls = m[3].str(); - std::size_t start = 0; - bool first = true; - while (start < decls.size()) { - std::size_t comma = decls.find(',', start); - std::string_view d = Trim(std::string_view(decls).substr(start, comma == std::string::npos ? std::string::npos : comma - start)); - if (!first) out += '\n'; - out += std::format("{}{} {};", indent, type, d); - first = false; - if (comma == std::string::npos) break; - start = comma + 1; - } - changed = true; - } else { - out += raw; - } - if (i + 1 < stripped.size() || ctx.content.ends_with('\n')) out += '\n'; - } - if (changed) ctx.SetContent(std::move(out)); - }); - - // K&R braces: `{` on its own line after a `)`/else/do/try header joins - // onto the header line. Standalone scope blocks (previous line ends with - // `;`, `{`, a comment, …) are intentional and stay. - cfg.AddLintRule("brace-style", [](LintContext& ctx) { - if (!IsCppFile(ctx)) return; - std::vector stripped = Lines(ctx.CommentStripped()); - std::vector outLines; - outLines.reserve(ctx.lines.size()); - bool changed = false; - for (std::size_t i = 0; i < ctx.lines.size(); ++i) { - std::string_view trimmed = Trim(stripped[i]); - if (trimmed == "{" && !outLines.empty()) { - std::string_view prevTrim = i > 0 ? Trim(stripped[i - 1]) : std::string_view{}; - bool headerBefore = prevTrim.ends_with(')') || prevTrim == "else" || prevTrim == "do" || prevTrim == "try"; - bool hasComment = ctx.Line(i + 1).contains("//") || (i > 0 && ctx.Line(i).contains("//")); - bool suppressed = ctx.Suppressed("brace-style", i) || ctx.Suppressed("brace-style", i + 1); - if (headerBefore && !hasComment && !suppressed) { - std::string& prev = outLines.back(); - while (!prev.empty() && (prev.back() == ' ' || prev.back() == '\t')) prev.pop_back(); - prev += " {"; - changed = true; - continue; - } - } - outLines.emplace_back(ctx.Line(i + 1)); - } - if (!changed) return; - std::string out; - for (std::size_t i = 0; i < outLines.size(); ++i) { - out += outLines[i]; - if (i + 1 < outLines.size() || ctx.content.ends_with('\n')) out += '\n'; - } - ctx.SetContent(std::move(out)); - }); - - // Single-statement if bodies join onto the if line: `if (x)\n y;` → - // `if (x) y;` — when the condition's parens balance on one line, the body - // is one `;`-terminated statement, neither line carries a comment, and - // the joined line stays ≤ 250 columns. - cfg.AddLintRule("if-single-line", [](LintContext& ctx) { - if (!IsCppFile(ctx)) return; - std::vector stripped = Lines(ctx.CommentStripped()); - static const std::regex ifHeader(R"(^\s*(?:\}?\s*else\s+)?if\s*\(.*\)\s*$)"); - std::vector outLines; - bool changed = false; - for (std::size_t i = 0; i < ctx.lines.size(); ++i) { - std::string lineStr(stripped[i]); - if (i + 1 < ctx.lines.size() && std::regex_match(lineStr, ifHeader) && ParenDelta(stripped[i]) == 0) { - std::string_view body = Trim(stripped[i + 1]); - bool joinable = !body.empty() && body != "{" && !body.starts_with("if") && body.ends_with(';') - && !ctx.Line(i + 1).contains("//") && !ctx.Line(i + 2).contains("//") - && !ctx.Suppressed("if-single-line", i + 1) && !ctx.Suppressed("if-single-line", i + 2); - std::string joined = std::string(ctx.Line(i + 1)); - while (!joined.empty() && (joined.back() == ' ' || joined.back() == '\t')) joined.pop_back(); - joined += " "; - joined += Trim(ctx.Line(i + 2)); - if (joinable && joined.size() <= 250) { - outLines.push_back(std::move(joined)); - ++i; // consume the body line - changed = true; - continue; - } - } - outLines.emplace_back(ctx.Line(i + 1)); - } - if (!changed) return; - std::string out; - for (std::size_t i = 0; i < outLines.size(); ++i) { - out += outLines[i]; - if (i + 1 < outLines.size() || ctx.content.ends_with('\n')) out += '\n'; - } - ctx.SetContent(std::move(out)); - }); - - // Wrapped call arguments join back onto one line when the whole - // expression fits in 250 columns. Lambda bodies (lines ending `{`), - // comments, raw strings, and preprocessor lines are left alone. - // Operator-style continuations (next line starts with && / || / |) join - // under the same length limit. - cfg.AddLintRule("wrap-join", [](LintContext& ctx) { - if (!IsCppFile(ctx)) return; - // Fixpoint loop: a join can enable another (joining an inner paren - // wrap balances the line an operator continuation hangs off), so one - // pass is not idempotent. Iterate until a pass changes nothing; the - // cap is a safety net — joins strictly reduce the line count, so - // termination is guaranteed anyway. - for (std::int32_t pass = 0; pass < 16; ++pass) { - std::vector stripped = Lines(ctx.CommentStripped()); - std::vector outLines; - bool changed = false; - for (std::size_t i = 0; i < ctx.lines.size(); ++i) { - std::string_view trimmed = Trim(stripped[i]); - std::int64_t delta = ParenDelta(stripped[i]); - bool candidate = delta > 0 && !trimmed.empty() && !trimmed.starts_with('#') - && !trimmed.ends_with('{') && !ctx.Line(i + 1).contains("//") && !ctx.Line(i + 1).contains("R\""); - if (candidate) { - std::string joined(ctx.Line(i + 1)); - std::size_t j = i + 1; - bool ok = true; - while (delta > 0 && j < ctx.lines.size() && j - i <= 4) { - std::string_view next = Trim(stripped[j]); - // A `{`-ending line is fine when it closes the expression - // (a control header's `) {`); mid-expression it means a - // lambda body starts — leave those wrapped. - bool closes = delta + ParenDelta(stripped[j]) <= 0; - if (next.empty() || (next.ends_with('{') && !closes) - || ctx.Line(j + 1).contains("//") || ctx.Line(j + 1).contains("R\"")) { - ok = false; - break; - } - while (!joined.empty() && (joined.back() == ' ' || joined.back() == '\t')) joined.pop_back(); - std::string_view fragment = Trim(ctx.Line(j + 1)); - // No separator right after an opening paren or before a - // closing one — joining must not manufacture `( x` / `x )`. - if (!joined.ends_with('(') && !fragment.starts_with(')') && !fragment.starts_with(',')) joined += " "; - joined += fragment; - delta += ParenDelta(stripped[j]); - ++j; - } - for (std::size_t l = i + 1; ok && l <= j; ++l) ok = !ctx.Suppressed("wrap-join", l); - if (ok && delta <= 0 && joined.size() <= 250) { - outLines.push_back(std::move(joined)); - i = j - 1; // consumed through line j-1 (0-based i) - changed = true; - continue; - } - } else if (delta == 0 && i + 1 < ctx.lines.size() && !trimmed.starts_with('#') - && !ctx.Line(i + 1).contains("//") && !ctx.Line(i + 1).contains("R\"")) { - auto isOpStart = [](std::string_view s) { - return s.starts_with("&&") || s.starts_with("||") || s.starts_with("| "); - }; - // Operator-style continuation chain (`a\n && b\n && c`). - // Joined when the WHOLE chain stays within 250 columns — - // longer conditions legitimately wrap. Same mechanics as the - // paren join: chain lines must be comment-free, raw-string - // free, and paren-balanced. - if (isOpStart(Trim(stripped[i + 1])) && !isOpStart(trimmed)) { - std::string joined(ctx.Line(i + 1)); - std::size_t j = i + 1; - bool ok = true; - while (j < ctx.lines.size() && isOpStart(Trim(stripped[j]))) { - if (ParenDelta(stripped[j]) != 0 || ctx.Line(j + 1).contains("//") || ctx.Line(j + 1).contains("R\"")) { - ok = false; - break; - } - while (!joined.empty() && (joined.back() == ' ' || joined.back() == '\t')) joined.pop_back(); - joined += " "; - joined += Trim(ctx.Line(j + 1)); - ++j; - } - for (std::size_t l = i + 1; ok && l <= j; ++l) ok = !ctx.Suppressed("wrap-join", l); - if (ok && joined.size() <= 250) { - outLines.push_back(std::move(joined)); - i = j - 1; // consumed the chain - changed = true; - continue; - } - } - } - outLines.emplace_back(ctx.Line(i + 1)); - } - if (!changed) return; - std::string out; - for (std::size_t i = 0; i < outLines.size(); ++i) { - out += outLines[i]; - if (i + 1 < outLines.size() || ctx.content.ends_with('\n')) out += '\n'; - } - ctx.SetContent(std::move(out)); - } - }); - - // No space padding inside parens: `( x` / `x )` → `(x` / `x)`. Only - // intra-line (a line legitimately ends with '(' when a call wraps); - // string/comment interiors are excluded via the stripped text. - cfg.AddLintRule("paren-spacing", [](LintContext& ctx) { - if (!IsCppFile(ctx)) return; - const std::string& code = ctx.CommentStripped(); - std::vector> cuts; // [begin, end) spans of spaces to delete - for (std::size_t i = 0; i < code.size(); ++i) { - if (code[i] == '(' ) { - std::size_t j = i + 1; - while (j < code.size() && code[j] == ' ') ++j; - if (j > i + 1 && j < code.size() && code[j] != '\n' && code[j] != '\r') cuts.push_back({i + 1, j}); - } else if (code[i] == ')') { - std::size_t j = i; - while (j > 0 && code[j - 1] == ' ') --j; - // Only single-space padding: multi-space runs are usually - // deliberate alignment columns. - if (j == i - 1 && j > 0 && code[j - 1] != '\n' && code[j - 1] != ',') cuts.push_back({j, i}); - } - } - if (cuts.empty()) return; - std::string out = ctx.content; - for (auto it = cuts.rbegin(); it != cuts.rend(); ++it) out.erase(it->first, it->second - it->first); - ctx.SetContent(std::move(out)); - }); - - cfg.AddLintRule("trim-trailing-ws", [](LintContext& ctx) { - if (!IsCppFile(ctx)) return; - std::string out; - out.reserve(ctx.content.size()); - for (std::size_t n = 1; n <= ctx.lines.size(); ++n) { - std::string_view line = ctx.Line(n); - // Byte fidelity on CRLF files: peel the \r, trim, put it back. - bool crlf = line.ends_with('\r'); - if (crlf) line.remove_suffix(1); - while (line.ends_with(' ') || line.ends_with('\t')) line.remove_suffix(1); - out += line; - if (crlf) out += '\r'; - // The last line only had a newline if the file ended with one — - // preserve that byte exactly so this rule doesn't shadow - // final-newline. - if (n < ctx.lines.size() || ctx.content.ends_with('\n')) out += '\n'; - } - ctx.SetContent(std::move(out)); - }); - - cfg.AddLintRule("final-newline", [](LintContext& ctx) { - if (!IsCppFile(ctx)) return; - if (!ctx.content.empty() && !ctx.content.ends_with('\n')) { - ctx.SetContent(ctx.content + '\n'); - } - }); -} - -} // namespace ProjectLint diff --git a/packaging/APKBUILD b/packaging/APKBUILD deleted file mode 100644 index b85b738..0000000 --- a/packaging/APKBUILD +++ /dev/null @@ -1,54 +0,0 @@ -# SPDX-License-Identifier: GPL-3.0-only -# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® -# Maintainer: Jorijn van der Graaf -pkgname=imsd -pkgver=0.3.0 -pkgrel=0 -pkgdesc="Userspace IMS/VoLTE daemon for mainline Linux phones" -url="https://forgejo.catcrafts.net/Catcrafts/imsd" -arch="aarch64 x86_64" -license="GPL-3.0-only" -# the media leg dlopen's the AMR-WB codecs; pw-record/pw-play drive PipeWire — -# none of which abuild's .so auto-scan can see -depends="modemmanager opencore-amr vo-amrwbenc pipewire-tools" -# clang/libc++ C++26 modules build (see Makefile); llvm-runtimes ships the -# libc++ std module sources (/usr/share/libc++/v1/std.cppm) it precompiles -makedepends="clang lld libc++-dev llvm-libunwind-dev llvm-runtimes glib-dev pkgconf" -# the versioned provides both satisfies soc-qcom-modem's 81voltd dependency -# and excludes the real package: 81voltd serves the modem's own ims-PDN -# requests, which races imsd for the PDN and flaps it with a new prefix every -# ~2.5 min — the two IMS stacks cannot share one PDN -provides="81voltd=$pkgver-r$pkgrel" -# imsd-ofono is opt-in: it carries the org.ofono shim that lets GNOME Calls -# (Phosh, GNOME Mobile) drive imsd, plus the gschema override that repoints -# Calls at the ofono provider. A Plasma Mobile phone wants imsd-dialerd, which -# is in the main package, and must NOT get that override. -subpackages="$pkgname-ofono:ofono" -source="$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz" -builddir="$srcdir/$pkgname" - -build() { - make -} - -check() { - make check -} - -package() { - make install DESTDIR="$pkgdir" -} - -ofono() { - pkgdesc="GNOME Calls (Phosh, GNOME Mobile) backend for imsd" - depends="$pkgname=$pkgver-r$pkgrel calls" - amove usr/bin/imsd-ofonod - amove usr/lib/systemd/system/imsd-ofonod.service - amove usr/share/dbus-1/system.d/imsd-ofono.conf - amove usr/share/glib-2.0/schemas/90_imsd-ofono.gschema.override - amove usr/lib/systemd/system-preset/80-imsd-ofono.preset - # enabled on install, like imsd.service in the main package - mkdir -p "$subpkgdir"/etc/systemd/system/multi-user.target.wants - ln -s /usr/lib/systemd/system/imsd-ofonod.service \ - "$subpkgdir"/etc/systemd/system/multi-user.target.wants/imsd-ofonod.service -} diff --git a/packaging/APKBUILD.binary b/packaging/APKBUILD.binary index 486f4bc..4abd25e 100644 --- a/packaging/APKBUILD.binary +++ b/packaging/APKBUILD.binary @@ -2,11 +2,12 @@ # SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® # Maintainer: Jorijn van der Graaf # Binary packaging: wraps a crafter-build binary cross-compiled per the -# README's "Cross-compiling" section into a proper apk — used by this repo's +# cross-compiling flow of AGENTS.md §4.3 into a proper apk — used by this repo's # package CI (packaging/build-package.sh, which also seds pkgver from # implementations/main.cpp) and runnable by hand. The source tarball is -# produced by packaging/make-bin-tarball.sh; the desktop/preset files live -# next to it in packaging/ (copy them beside this file). Since 2026-09-02 +# produced by packaging/make-bin-tarball.sh; the desktop/preset files and +# the install scripts (*.post-upgrade, *.post-install) live next to it in +# packaging/ (copy them beside this file). Since 2026-09-02 # this is the only imsd packaging: fp6-img images install the apk this # builds from the registry rather than building their own. pkgname=imsd @@ -16,8 +17,9 @@ pkgdesc="Userspace IMS/VoLTE daemon for mainline Linux phones" url="https://forgejo.catcrafts.net/Catcrafts/imsd" arch="aarch64" license="GPL-3.0-only" -# the media leg dlopen's the AMR-WB codecs; pw-record/pw-play drive PipeWire -depends="modemmanager libc++ opencore-amr vo-amrwbenc pipewire-tools" +# the media leg dlopen's the AMR-WB codecs; pw-record/pw-play drive PipeWire; +# polkit answers the daemons' call-control check (ImsdBus) +depends="modemmanager libc++ opencore-amr vo-amrwbenc pipewire-tools polkit" # versioned provides = imsd satisfies soc-qcom-modem's 81voltd dependency AND # excludes the real package: 81voltd serves the modem's own ims-PDN requests, # which races imsd for the PDN and flaps it with a new prefix every ~2.5 min @@ -29,6 +31,9 @@ provides="81voltd=$pkgver-r$pkgrel" # (ofono before systemd: systemd() amoves all of usr/lib/systemd/system, # so ofono() must take imsd-ofonod.service first) subpackages="$pkgname-ofono:ofono $pkgname-systemd" +# restart the running daemons onto an upgrade (systemd's enqueue-marked); the +# systemd subpackage marks imsd.service too, since it brings the unit file +install="$pkgname.post-upgrade $pkgname.post-install $pkgname-ofono.post-upgrade $pkgname-ofono.post-install $pkgname-systemd.post-upgrade $pkgname-systemd.post-install" options="!check !tracedeps" source=" imsd-$pkgver.tar.gz @@ -42,11 +47,14 @@ package() { install -Dm755 imsd-media "$pkgdir"/usr/libexec/imsd-media install -Dm755 imsd-dialerd "$pkgdir"/usr/bin/imsd-dialerd install -Dm755 ims-pdn-up.sh "$pkgdir"/usr/libexec/ims-pdn-up.sh + install -Dm755 imsd-handover.sh "$pkgdir"/usr/libexec/imsd-handover.sh install -Dm644 imsd.service "$pkgdir"/usr/lib/systemd/system/imsd.service install -Dm644 imsd-dialerd.desktop \ "$pkgdir"/etc/xdg/autostart/imsd-dialerd.desktop install -Dm644 net.catcrafts.IMS1.conf \ "$pkgdir"/usr/share/dbus-1/system.d/net.catcrafts.IMS1.conf + install -Dm644 net.catcrafts.IMS1.policy \ + "$pkgdir"/usr/share/polkit-1/actions/net.catcrafts.IMS1.policy # the skel override hides kde-telephony's modem daemon autostart for the # account created at install — imsd-dialerd owns those session bus names install -Dm644 "$srcdir"/org.kde.modem.daemon.desktop \ @@ -64,6 +72,8 @@ package() { printf '[Unit]\nConditionPathExists=/etc/imsd.env\n' \ > "$pkgdir"/usr/lib/systemd/system/imsd.service.d/10-require-config.conf install -Dm755 imsd-ofonod "$pkgdir"/usr/bin/imsd-ofonod + install -Dm755 imsd-ofono-restart-calls.sh \ + "$pkgdir"/usr/libexec/imsd-ofono-restart-calls.sh install -Dm644 imsd-ofonod.service \ "$pkgdir"/usr/lib/systemd/system/imsd-ofonod.service install -Dm644 org.ofono.conf \ @@ -78,6 +88,7 @@ ofono() { pkgdesc="GNOME Calls (Phosh, GNOME Mobile) backend for imsd" depends="$pkgname=$pkgver-r$pkgrel calls" amove usr/bin/imsd-ofonod + amove usr/libexec/imsd-ofono-restart-calls.sh amove usr/lib/systemd/system/imsd-ofonod.service amove usr/share/dbus-1/system.d/imsd-ofono.conf amove usr/share/glib-2.0/schemas/90_imsd-ofono.gschema.override diff --git a/packaging/build-package.sh b/packaging/build-package.sh index 75a0aeb..53c6153 100755 --- a/packaging/build-package.sh +++ b/packaging/build-package.sh @@ -2,47 +2,31 @@ # SPDX-License-Identifier: GPL-3.0-only # SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® # CI package build: cross-compile imsd for aarch64 with crafter-build (the -# README's "Cross-compiling" flow), run the test suite natively, and package +# cross-compiling flow of AGENTS.md §4.3), run the test suite natively, and package # the result via packaging/APKBUILD.binary. Expects an x86_64 Alpine # environment with root — the workflow runs it in an alpine:edge job -# container on an ordinary runner. Root only installs packages and hands off -# to a scratch user: the sysroot is built with apk.static --usermode (which -# refuses root) and abuild wants a user too. +# container on the privileged `flatpak` runner: crafter-build is the +# Crafter.Build Flatpak, and bubblewrap inside the container needs the +# privilege. Root only installs packages and hands off to a scratch user: the +# sysroot is built with apk.static --usermode (which refuses root) and abuild +# wants a user too. # # Built packages land in /home/build/.local/share/abuild/*/aarch64/imsd*.apk; # the workflow's publish step uploads them to the Forgejo Alpine registry. set -eu -# The musl build of crafter-build (Crafter.Build CI's release-musl job): -# this container is Alpine, and the glibc launcher cannot run on musl. v2 = -# SSE4.2 baseline: the CI box is an Intel N5105 (no AVX). Overridable for -# local rehearsals (file:// works). -CRAFTER_URL=${CRAFTER_URL:-https://forgejo.catcrafts.net/Catcrafts/Crafter.Build/releases/download/latest/crafter-build-linux-x86_64-musl-v2.tar.gz} +# crafter-build is the Crafter.Build Flatpak: it carries its own clang, lld +# and libc++, and its SDK has glib, so nothing that compiles is installed +# here. `stable` is the branch Crafter.Build publishes from its release tags +# (`beta` follows every push to its master), so the toolchain of a release +# moves only with a release of the toolchain. The branch is overridable +# for a rehearsal against another one. +CRAFTER_REMOTE=${CRAFTER_REMOTE:-https://flatpak.catcrafts.net/catcrafts.flatpakrepo} +CRAFTER_REF=${CRAFTER_REF:-net.catcrafts.CrafterBuild//stable} SRC=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) -# clang cross-targets aarch64 natively and the target's libc++/glib come from -# the sysroot; llvm-runtimes/libc++-dev/glib-dev here serve the NATIVE -# test-suite run. build-base = Alpine's standard build environment (the one -# abuild implies): binutils' ld/ar for clang's default link driver, gcc's -# libgcc_s/crt objects the musl clang driver links against. -if [ "$(id -u)" = 0 ]; then - apk add -q git curl tar clang lld llvm llvm-runtimes libc++-dev llvm-libunwind-dev glib-dev \ - build-base abuild sudo - id build >/dev/null 2>&1 || adduser -D build - addgroup build abuild 2>/dev/null || true - echo 'build ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/build - # abuild in cross mode strips with $CHOST-strip; llvm-strip handles any - # ELF arch, so give it that name - ln -sf "$(command -v llvm-strip)" /usr/local/bin/aarch64-alpine-linux-musl-strip - # the CI checkout arrives root-owned; crafter-build writes bin/ into it - chown -R build "$SRC" - # -l: a login shell, so HOME really is /home/build (abuild keys + output); - # it scrubs the environment, so carry the one knob that matters across - exec su -l build -c "CRAFTER_URL='${CRAFTER_URL:-}' sh -eu '$SRC/packaging/build-package.sh'" -fi - retry() { # retry _desc=$1; shift for _i in 1 2 3; do @@ -54,26 +38,49 @@ retry() { # retry return 1 } +# llvm for llvm-strip (abuild in cross mode strips with $CHOST-strip); the +# compiler is the Flatpak's. +if [ "$(id -u)" = 0 ]; then + apk add -q git curl tar flatpak llvm abuild sudo + # the Flatpak as a system installation the build user runs; its SDK + # (org.freedesktop.Sdk) comes from Flathub + flatpak remote-add --if-not-exists catcrafts "$CRAFTER_REMOTE" + flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo + retry "install crafter-build" flatpak install -y --noninteractive catcrafts "$CRAFTER_REF" + id build >/dev/null 2>&1 || adduser -D build + addgroup build abuild 2>/dev/null || true + echo 'build ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/build + # abuild in cross mode strips with $CHOST-strip; llvm-strip handles any + # ELF arch, so give it that name + ln -sf "$(command -v llvm-strip)" /usr/local/bin/aarch64-alpine-linux-musl-strip + # the CI checkout arrives root-owned; crafter-build writes bin/ into it + chown -R build "$SRC" + # -l: a login shell, so HOME really is /home/build (abuild keys + output); + # it scrubs the environment, so carry the one knob that matters across + exec su -l build -c "CRAFTER_REF='$CRAFTER_REF' sh -eu '$SRC/packaging/build-package.sh'" +fi + # implementations/main.cpp is the version's single source of truth (same # derivation as make-bin-tarball.sh) VER=$(sed -n 's/.*char\* Version = "\(.*\)".*/\1/p' "$SRC/implementations/main.cpp") [ -n "$VER" ] || { echo "cannot read Version from implementations/main.cpp" >&2; exit 1; } echo ">> packaging imsd $VER" -# --- crafter-build: static launcher from the rolling release -mkdir -p "$HOME/crafter-build" -retry "fetch crafter-build" \ - sh -c "curl -fsSL '$CRAFTER_URL' | tar -xz -C '$HOME/crafter-build'" -PATH="$HOME/crafter-build/bin:$PATH" -export CRAFTER_BUILD_HOME="$HOME/crafter-build/share/crafter-build" +# --- crafter-build: the Flatpak, under the name the rest of this file uses. +# The build log names the build it ran with. +mkdir -p "$HOME/bin" +printf '#!/bin/sh\nexec flatpak run %s "$@"\n' "$CRAFTER_REF" > "$HOME/bin/crafter-build" +chmod +x "$HOME/bin/crafter-build" +PATH="$HOME/bin:$PATH" +flatpak info "$CRAFTER_REF" | sed -n 's/^ *\(Version\|Commit\|Date\): */crafter-build \1: /p' +crafter-build --version # --- aarch64 Alpine sysroot (unprivileged: apk.static --usermode) SYSROOT="$HOME/.cache/imsd/sysroot-aarch64-alpine" retry "make sysroot" "$SRC/packaging/make-sysroot.sh" "$SYSROOT" -# --- cross-compile the products; run the suites natively (same sources, -# host arch — the aarch64 binaries are additionally exercised by fp6-img's -# chroot `make check` whenever an image builds) +# --- cross-compile the products; run the suites natively, inside the Flatpak +# (same sources, host arch: no suite runs on the aarch64 binaries) cd "$SRC" # The phone is one SoC, 4x Cortex-A520 + 4x Cortex-A720, so build for it. # armv8.6-a is the highest level whose mandatory set it exposes (8.7 would @@ -94,7 +101,7 @@ rm -rf "$PKG" mkdir -p "$PKG" cp "$SRC/packaging/APKBUILD.binary" "$PKG/APKBUILD" cp "$SRC/packaging/org.kde.modem.daemon.desktop" \ - "$SRC/packaging/80-imsd.preset" "$PKG/" + "$SRC/packaging/80-imsd.preset" "$SRC"/packaging/*.post-upgrade "$SRC"/packaging/*.post-install "$PKG/" mv "imsd-$VER.tar.gz" "$PKG/" sed -i "s/^pkgver=.*/pkgver=$VER/" "$PKG/APKBUILD" # a throwaway signing key: phones trust the registry-signed APKINDEX, not diff --git a/packaging/ims-pdn-up.sh b/packaging/ims-pdn-up.sh index 3ab4725..191c2b5 100644 --- a/packaging/ims-pdn-up.sh +++ b/packaging/ims-pdn-up.sh @@ -39,6 +39,12 @@ bearer_paths() { kv -m "$MODEM" | sed -n 's/^modem\.generic\.bearers\.value\[[0-9]*\] *: *//p' } +# A disabled modem has its radio off (airplane mode): leave it alone. A bearer +# connect on it fails with no-service and leaves ModemManager's state +# `enabled` with the modem still in low power; switching the radio back on +# then no longer brings the modem up (dev phone 2026-10-06). +radio_off() { [ "$(modem_state)" = disabled ]; } + envval() { # $1 = key — for manual runs; under systemd the vars are inherited sed -n "s/^$1=//p" /etc/imsd.env 2>/dev/null | tail -n1 | tr -d '"' } @@ -65,34 +71,49 @@ log "modem $MODEM" # lookup itself can fail while ModemManager is still enabling the modem; a # failed lookup is not "no profile" (the APN-string request it would fall # back to is the one the modem refuses under an IPv4 attach), so it is -# retried after the registration wait and only a list without the APN -# falls back to the APN string. +# retried after the registration wait; a list without the APN is final — +# the APN string is used — only once the modem is registered. LOOKUP=pending if [ -n "$PROFILE_ID" ]; then [ "$PROFILE_ID" = none ] && PROFILE_ID= LOOKUP=done fi -lookup_profile() { # $1 = log suffix; sets PROFILE_ID, LOOKUP=done on a usable list +lookup_profile() { # $1 = modem state, $2 = log suffix; sets PROFILE_ID, LOOKUP=done on a final answer [ "$LOOKUP" = done ] && return 0 if OUT=$(qmicli -d qrtr://0 --wds-get-profile-list=3gpp 2>&1); then # a list with no profiles at all is the modem still coming up, not # a modem without an ims profile (dev phone cold boot 2026-09-20 # 16:47: exit 0, no entries, index 2 present a minute later) if ! printf '%s\n' "$OUT" | grep -q -E '^[[:space:]]*\[[0-9]+\] 3gpp'; then - log "ims profile lookup returned no profiles$1" + log "ims profile lookup returned no profiles$2" return 0 fi PROFILE_ID=$(printf '%s\n' "$OUT" | awk -v apn="$IMS_APN" ' /^[ \t]*\[[0-9]+\] 3gpp/ { idx = $1; gsub(/[^0-9]/, "", idx) } /APN:/ { a = $0; sub(/.*APN: '"'"'/, "", a); sub(/'"'"'.*/, "", a); if (tolower(a) == tolower(apn) && idx != "") { print idx; exit } }') - LOOKUP=done - [ -n "$PROFILE_ID" ] && log "ims profile: index $PROFILE_ID (apn $IMS_APN)" || log "ims profile: none for apn $IMS_APN, connecting by APN string" + if [ -n "$PROFILE_ID" ]; then + LOOKUP=done + log "ims profile: index $PROFILE_ID (apn $IMS_APN)" + return 0 + fi + # a list without the APN is final only once the modem is registered: + # while it enables, the list can be partial (dev phone cold boot + # 2026-09-24 18:18: entries but no ims at `enabling`, ims listed when + # read minutes later), and on a walled carrier the APN-string fallback + # that would follow is the request the modem refuses + case "$1" in + registered|connecting|connected) + LOOKUP=done + log "ims profile: none for apn $IMS_APN, connecting by APN string" ;; + *) log "ims profile: not in the list yet$2" ;; + esac else - log "ims profile lookup failed$1: $(squash "$OUT")" + log "ims profile lookup failed$2: $(squash "$OUT")" fi } -lookup_profile " (modem state: $(modem_state))" +STATE=$(modem_state) +lookup_profile "$STATE" " (modem state: $STATE)" # ---- find a connected ims bearer; else find-or-create one and connect it. # APN names are case-insensitive. A DISCONNECTED bearer is reused only when @@ -150,6 +171,10 @@ if [ -z "$BEARER" ]; then [ "$STATE" != "$last" ] && log "waiting for registration (state: ${STATE:-unknown})" last=$STATE if [ "$waited" -ge "$REG_TIMEOUT" ]; then + if [ "$STATE" = disabled ]; then + log "modem still disabled after $REG_TIMEOUT s (radio off): not connecting" + exit 1 + fi log "not registered after $REG_TIMEOUT s — attempting anyway" break fi @@ -157,12 +182,14 @@ if [ -z "$BEARER" ]; then waited=$((waited + 5)) done log "modem state: $(modem_state), packet service: $(packet_state)" - # a lookup that failed while the modem was still coming up + # a lookup with no final answer yet: it failed, or its list lacked the + # APN while the modem was still coming up n=0 while [ "$LOOKUP" != done ] && [ "$n" -lt 3 ]; do n=$((n + 1)) sleep 5 - lookup_profile " (retry $n)" + STATE=$(modem_state) + lookup_profile "$STATE" " (retry $n, modem state: $STATE)" done if [ "$LOOKUP" != done ]; then PROFILE_ID= @@ -176,6 +203,7 @@ n=0 while [ -z "$BEARER" ]; do n=$((n + 1)) [ "$n" -gt 10 ] && { log "bearer connect failed after 10 attempts"; exit 1; } + radio_off && { log "modem disabled (radio off): not connecting"; exit 1; } B=$(find_ims_bearer no) # reuse a stale disconnected ims bearer of this run's shape if [ -z "$B" ]; then if [ -n "$PROFILE_ID" ]; then @@ -280,9 +308,13 @@ case "$PCSCF" in *) log "PCSCF '$PCSCF' is not an IPv6 address; the ims PDN is brought up IPv6-only"; exit 1 ;; esac -# ---- hand the connected ims netdev to imsd (imsd.service reads -# /run/imsd.env) so the daemon carries no baked-in interface name -echo "DEV=$IFACE" > /run/imsd.env +# ---- hand the connected ims netdev and the PDN's own address to imsd +# (imsd.service reads /run/imsd.env): the daemon carries no baked-in +# interface name and registers on the address the network assigned — not +# on the kernel's temporary SLAAC address from the RA, which the netdev +# lists first and which is deprecated after a day and removed after two +# (ims ledger F18; the SAs and the Contact would go with it) +printf 'DEV=%s\nLOCAL=%s\n' "$IFACE" "$ADDR" > /run/imsd.env # ---- open the IMS protected ports in the firewall (rung 5b root cause, # journal/ims.md s56): pmOS's default nftables INPUT chain is policy-drop and diff --git a/packaging/imsd-handover.sh b/packaging/imsd-handover.sh new file mode 100755 index 0000000..02a907d --- /dev/null +++ b/packaging/imsd-handover.sh @@ -0,0 +1,83 @@ +#!/bin/sh +# SPDX-License-Identifier: GPL-3.0-only +# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +# +# ExecStartPre of imsd.service, after ims-pdn-up.sh: hand a daemon older +# than 0.3.10 its bring-up throttle wait over. Such a daemon kept the wait +# in memory only, so the restart an upgrade makes registered fresh at once +# instead of waiting the schedule out. imsd.post-upgrade +# records the running daemon's invocation in /run/imsd.handover; here, with +# that daemon stopped and its journal final, the invocation's last bring-up +# line is read and, when it is the old throttle's `bring-up deferred: +# fresh-SA throttle active (spi-s=0) — … next fresh registration in +# N min`, what is left of the N min is written to +# the holds file (/run/imsd.holds, or HOLDS_FILE) the way 0.3.10 keeps +# it — with the verdict that the old registration is gone: that line +# follows a throttled initial REGISTER, at which KPN drops the +# registration, so the new start does not resume over the written-off SAs. +# The old daemon deferred the same way after a 401 it could not use (no +# ipsec-3gpp mechanism, an unsupported algorithm); nothing says the +# network dropped the registration then, and that wait is not handed over. +# The wait itself is handed over only for the address it was earned on +# (the old daemon logged its LOCAL once at start): KPN's refusal is the +# address's — a fresh pair is refused where a registration is live or has +# just gone — and on the normal hop the old daemon sat on the kernel's +# temporary address while this start registers on the PDN's own, which +# holds none: then only the verdict is written and the new start registers +# fresh at once (should the network refuse it there, the daemon's own +# schedule takes over from its start: the old daemon's place in it was +# the other address's). +# When the old address cannot be read the wait is handed over anyway. +# Reading at the start, not at the upgrade, means a wait that +# ended between the two — the old daemon making another attempt — is +# read as it ended. +set -u +marker=/run/imsd.handover +[ -r "$marker" ] || exit 0 +inv=$(cat "$marker"); rm -f "$marker" +[ -n "$inv" ] || exit 0 +holds=${HOLDS_FILE:-/run/imsd.holds} # the holds file as imsd reads it (HoldsPath) +[ -e "$holds" ] && exit 0 # a 0.3.10 or later daemon kept its own holds +line=$(journalctl -u imsd.service "_SYSTEMD_INVOCATION_ID=$inv" -o json --no-pager 2>/dev/null \ + | grep -E '"MESSAGE" *: *"imsd: (bring-up deferred: |REGISTERED|FRESH registration|a hold the network)' | tail -n 1) +case "$line" in *'imsd: bring-up deferred: fresh-SA throttle active (spi-s=0)'*) ;; *) exit 0 ;; esac +mins=$(printf '%s\n' "$line" | sed -n 's/.*next fresh registration in \([0-9][0-9]*\) min.*/\1/p') +# the line's age on the monotonic clock (journald stamps every entry with it; +# /proc/timer_list reads it now), which no clock step moves — this phone's +# wall clock reads 1970 until its first NTP sync. It stops in a suspend, so +# the age can only come out short and the wait handed over long, never +# short of the network's window. +at=$(printf '%s\n' "$line" | sed -n 's/.*"__MONOTONIC_TIMESTAMP" *: *"\([0-9][0-9]*\)".*/\1/p') +now=$(sed -n 's/^now at \([0-9][0-9]*\) nsecs$/\1/p' /proc/timer_list 2>/dev/null | head -n 1) +if [ -z "$mins" ] || [ -z "$at" ] || [ -z "$now" ]; then + echo "imsd-handover: the previous daemon waited for a fresh registration, but its wait could not be timed: NOT handed over" + exit 0 +fi +left=$((mins * 60 - (now / 1000000000 - at / 1000000))) +[ "$left" -gt 0 ] || exit 0 +# the address this start registers on, as systemd hands it to imsd: the unit +# reads its EnvironmentFile= lines anew for every command it spawns, so LOCAL +# here is the /etc/imsd.env pin when there is one, else the one ims-pdn-up.sh +# just wrote to /run/imsd.env — unquoted by systemd's parser, the same bytes +# imsd compares the holds file's pdn with. The file read is for a run outside +# the unit. +pdn=${LOCAL:-$(sed -n 's/^LOCAL=//p' /run/imsd.env 2>/dev/null | tail -n 1)} +if [ -z "$pdn" ]; then + echo "imsd-handover: the previous daemon waited $mins min for a fresh registration, but this start has no LOCAL: NOT handed over" + exit 0 +fi +# the address the wait was earned on: the old daemon's own start line +old=$(journalctl -u imsd.service "_SYSTEMD_INVOCATION_ID=$inv" -o cat --no-pager 2>/dev/null \ + | sed -n 's/^imsd: LOCAL=\([^ ]*\) P-CSCF=.*/\1/p' | tail -n 1) +boot=$(cat /proc/sys/kernel/random/boot_id); now_s=$(cut -d. -f1 /proc/uptime) +if [ -n "$old" ] && [ "$old" != "$pdn" ]; then + printf '{"pdn": "%s", "boot": "%s", "written_at": %s, "hold": 0, "fresh_hold": 0, "old_gone": true}\n' \ + "$pdn" "$boot" "$now_s" > "$holds" \ + && echo "imsd-handover: the previous daemon's fresh-registration wait ($left s of $mins min left) was earned on $old, not this start's $pdn: only the verdict that its registration is gone is handed over" + exit 0 +fi +tm=$((mins * 3 / 2)); [ "$tm" -gt 60 ] && tm=60 +printf '{"pdn": "%s", "boot": "%s", "written_at": %s, "hold": 0, "fresh_hold": %s, "throttle_minutes": %s, "old_gone": true}\n' \ + "$pdn" "$boot" "$now_s" "$left" "$tm" > "$holds" \ + && echo "imsd-handover: the previous daemon's fresh-registration wait ($left s of $mins min left) handed over for $pdn$([ -n "$old" ] && echo " (its own address)" || echo "; its address could not be read")" +exit 0 diff --git a/packaging/imsd-ofono-restart-calls.sh b/packaging/imsd-ofono-restart-calls.sh new file mode 100755 index 0000000..a3563ed --- /dev/null +++ b/packaging/imsd-ofono-restart-calls.sh @@ -0,0 +1,24 @@ +#!/bin/sh +# SPDX-License-Identifier: GPL-3.0-only +# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +# +# ExecStartPost of imsd-ofonod.service. GNOME Calls 50.0 keeps a stale modem +# when org.ofono vanishes and dials nothing until it is restarted (AGENTS.md +# §8.3, Known gaps), so every start of imsd-ofonod after org.ofono went away +# restarts the Calls daemon of each user running one. Its first start since +# boot is not one: no Calls has seen org.ofono go, and a Calls that runs +# already (a login during the bring-up) is left alone. The unit's own +# timestamp tells the first start from a restart (an upgrade from 0.3.9 is +# a restart); the marker covers a stop and a later start. +marker=/run/imsd-ofonod.started +if [ ! -e "$marker" ] && [ "$(systemctl show -P ActiveExitTimestampMonotonic imsd-ofonod.service)" = 0 ]; then + : > "$marker" + echo "imsd-ofono-restart-calls: first start since boot, Calls left alone" + exit 0 +fi +: > "$marker" +for uid in $(systemctl list-units 'user@*.service' --legend=no --plain | sed -n 's/^user@\([0-9]*\)\.service.*/\1/p'); do + echo "imsd-ofono-restart-calls: restarting the Calls daemon of uid $uid, if it runs" + systemctl --user -M "$uid@" try-restart --no-block calls-daemon.service || : +done +exit 0 diff --git a/packaging/imsd-ofono.post-install b/packaging/imsd-ofono.post-install new file mode 120000 index 0000000..a5acf7b --- /dev/null +++ b/packaging/imsd-ofono.post-install @@ -0,0 +1 @@ +imsd-ofono.post-upgrade \ No newline at end of file diff --git a/packaging/imsd-ofono.post-upgrade b/packaging/imsd-ofono.post-upgrade new file mode 100755 index 0000000..a1edb48 --- /dev/null +++ b/packaging/imsd-ofono.post-upgrade @@ -0,0 +1,21 @@ +#!/bin/sh +# SPDX-License-Identifier: GPL-3.0-only +# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +# +# As imsd.post-upgrade, for imsd-ofonod: restarted once apk is done, and +# its ExecStartPost restarts GNOME Calls with it. Also installed as +# imsd-ofono.post-install, for a package added again over the daemon an +# `apk del` left running. + +# A unit whose file an apk del removed is not-found to systemd until a +# reload and refuses properties ("failed to load properly"); its file is +# back on disk by the time post-install runs, so reload and mark again. +mark() { + systemctl set-property "$1" Markers=+needs-restart 2>/dev/null && return 0 + systemctl daemon-reload && systemctl set-property "$1" Markers=+needs-restart || : +} + +if [ -d /run/systemd/system ] && systemctl is-active -q imsd-ofonod.service; then + mark imsd-ofonod.service +fi +exit 0 diff --git a/packaging/imsd-ofonod.service b/packaging/imsd-ofonod.service index be308ee..5229943 100644 --- a/packaging/imsd-ofonod.service +++ b/packaging/imsd-ofonod.service @@ -3,8 +3,11 @@ [Unit] Description=oFono-compatible GNOME Calls backend for imsd Documentation=https://forgejo.catcrafts.net/Catcrafts/imsd -# org.ofono is a system-bus name, so this is a system service; it needs no -# privileges beyond owning that name. Only enable it on a GNOME/Phosh session +# org.ofono is a system-bus name only root may own (org.ofono.conf), so this +# is a system service run as root: imsd admits the calls it forwards as +# root's (it has asked polkit about its own caller), and its start hook +# restarts GNOME Calls in the users' own managers. Only enable it on a +# GNOME/Phosh session # — Plasma Mobile uses imsd-dialerd instead. Both may run at once (different # buses, different consumers), but a real ofonod must not. Conflicts=ofono.service @@ -17,6 +20,9 @@ Type=simple # matches imsd's configuration; imsd itself owns emergency classification. EnvironmentFile=-/etc/imsd.env ExecStart=/usr/bin/imsd-ofonod +# GNOME Calls keeps a stale modem when org.ofono restarts and dials nothing +# until it restarts too: restart the Calls daemon of each user running one +ExecStartPost=-/usr/libexec/imsd-ofono-restart-calls.sh # it holds no call state of its own — imsd is re-queried on every reconnect Restart=on-failure RestartSec=5 diff --git a/packaging/imsd-systemd.post-install b/packaging/imsd-systemd.post-install new file mode 120000 index 0000000..99e6bcc --- /dev/null +++ b/packaging/imsd-systemd.post-install @@ -0,0 +1 @@ +imsd-systemd.post-upgrade \ No newline at end of file diff --git a/packaging/imsd-systemd.post-upgrade b/packaging/imsd-systemd.post-upgrade new file mode 100755 index 0000000..846fdc4 --- /dev/null +++ b/packaging/imsd-systemd.post-upgrade @@ -0,0 +1,23 @@ +#!/bin/sh +# SPDX-License-Identifier: GPL-3.0-only +# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +# +# imsd.service ships in this subpackage, which apk installs after imsd. +# When imsd is added back over a daemon an `apk del` left running, +# imsd.post-install runs before this unit file is on disk and cannot mark +# the unit (not-found to systemd), so the package that brings the file +# marks it too; on an upgrade both mark it, which is one marker. +# Also installed as imsd-systemd.post-install. + +# A unit whose file an apk del removed is not-found to systemd until a +# reload and refuses properties ("failed to load properly"); the file is +# on disk by now, so reload and mark again. +mark() { + systemctl set-property "$1" Markers=+needs-restart 2>/dev/null && return 0 + systemctl daemon-reload && systemctl set-property "$1" Markers=+needs-restart || : +} + +if [ -d /run/systemd/system ] && systemctl is-active -q imsd.service; then + mark imsd.service +fi +exit 0 diff --git a/packaging/imsd.post-install b/packaging/imsd.post-install new file mode 120000 index 0000000..0a3397d --- /dev/null +++ b/packaging/imsd.post-install @@ -0,0 +1 @@ +imsd.post-upgrade \ No newline at end of file diff --git a/packaging/imsd.post-upgrade b/packaging/imsd.post-upgrade new file mode 100755 index 0000000..4e295e8 --- /dev/null +++ b/packaging/imsd.post-upgrade @@ -0,0 +1,48 @@ +#!/bin/sh +# SPDX-License-Identifier: GPL-3.0-only +# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +# +# Restart imsd onto the upgraded binary once apk is done: the systemd +# trigger restarts every unit marked needs-restart (enqueue-marked). A +# changed bus policy is live the moment apk writes it (dbus-broker-launch +# reloads on its own), so it must not be left facing the old daemon. The +# restart resumes the registration over its existing SAs, or goes on +# waiting when the network had imsd wait; a call in progress ends. +# +# Also installed as imsd.post-install: `apk del imsd` removes the files +# and leaves the daemon running, so a later `apk add` of the package is +# this upgrade for that daemon, and apk runs post-install for it. A first +# install finds no unit active and marks nothing. On the binary route +# (APKBUILD.binary) the unit file ships in imsd-systemd, installed after +# this package; its imsd-systemd.post-upgrade marks the unit then. +# +# A daemon older than 0.3.10 kept its bring-up throttle wait in memory +# only, so a restart inside it registered fresh at once instead of +# waiting the schedule out. Before marking, record the running +# invocation in /run/imsd.handover: the next start (imsd-handover.sh, an +# ExecStartPre after ims-pdn-up.sh) reads that invocation's last bring-up +# line once the daemon has stopped and hands what is left of its wait over +# as imsd's holds file (on another address only the verdict that its +# registration is gone). Read then, not here: a wait that ends between this +# script and the restart at the end of the transaction — the old daemon +# making another attempt — is read as it ended. + +# A unit whose file an apk del removed is not-found to systemd until a +# reload and refuses properties ("failed to load properly"); its file is +# back on disk by the time post-install runs, so reload and mark again. +mark() { + systemctl set-property "$1" Markers=+needs-restart 2>/dev/null && return 0 + systemctl daemon-reload && systemctl set-property "$1" Markers=+needs-restart || : +} + +record_old_invocation() { + [ -e /run/imsd.holds ] && return 0 # a 0.3.10 or later daemon keeps its own holds + inv=$(systemctl show -P InvocationID imsd.service 2>/dev/null) + [ -n "$inv" ] && echo "$inv" > /run/imsd.handover +} + +if [ -d /run/systemd/system ] && systemctl is-active -q imsd.service; then + record_old_invocation + mark imsd.service +fi +exit 0 diff --git a/packaging/imsd.service b/packaging/imsd.service index add1594..13c454c 100644 --- a/packaging/imsd.service +++ b/packaging/imsd.service @@ -13,10 +13,16 @@ Type=simple # idempotent. worst case ~14 min (2 min modem wait + 5 min registration # gate + connect retries), hence TimeoutStartSec ExecStartPre=/usr/libexec/ims-pdn-up.sh +# a daemon older than 0.3.10 that an upgrade restarted inside its bring-up +# throttle wait: the wait — on another address only the verdict that its +# registration is gone — is handed over from its journal now that it has +# stopped (imsd.post-upgrade recorded the invocation in /run/imsd.handover) +ExecStartPre=-/usr/libexec/imsd-handover.sh TimeoutStartSec=900 -# /run/imsd.env: written by ims-pdn-up.sh (DEV= the connected ims netdev). +# /run/imsd.env: written by ims-pdn-up.sh (DEV= the connected ims netdev, +# LOCAL= the PDN address ModemManager assigned to it). # /etc/imsd.env: admin configuration — PCSCF= is REQUIRED (the carrier's -# P-CSCF address; see the README's Configuration section) and wins over the +# P-CSCF address; see AGENTS.md §10, or the README) and wins over the # runtime-derived values. EnvironmentFile=-/run/imsd.env EnvironmentFile=-/etc/imsd.env @@ -24,7 +30,8 @@ EnvironmentFile=-/etc/imsd.env StateDirectory=imsd ExecStart=/usr/bin/imsd # registration: resume an existing SA when possible (avoids fresh-SA -# throttles); a failed bring-up exits nonzero and systemd retries +# throttles); a failed bring-up exits nonzero and systemd retries, as does +# a registered imsd whose PDN address left the netdev Restart=on-failure RestartSec=120 # SIGTERM: engine keeps the SA + registration state for the next resume diff --git a/packaging/make-bin-tarball.sh b/packaging/make-bin-tarball.sh index 72934bb..3e695ec 100755 --- a/packaging/make-bin-tarball.sh +++ b/packaging/make-bin-tarball.sh @@ -24,10 +24,12 @@ cp "$BIN" "$stage/imsd-$VER/imsd" cp "$MEDIA" "$stage/imsd-$VER/imsd-media" cp "$DIALERD" "$stage/imsd-$VER/imsd-dialerd" cp "$OFONOD" "$stage/imsd-$VER/imsd-ofonod" -cp packaging/ims-pdn-up.sh packaging/imsd.service \ +cp packaging/ims-pdn-up.sh packaging/imsd-handover.sh packaging/imsd.service \ packaging/imsd-dialerd.desktop packaging/imsd-ofonod.service \ + packaging/imsd-ofono-restart-calls.sh \ packaging/org.ofono.conf packaging/90_imsd-ofono.gschema.override \ packaging/80-imsd-ofono.preset \ - packaging/net.catcrafts.IMS1.conf "$stage/imsd-$VER/" + packaging/net.catcrafts.IMS1.conf packaging/net.catcrafts.IMS1.policy \ + "$stage/imsd-$VER/" tar -C "$stage" -czf "imsd-$VER.tar.gz" "imsd-$VER" echo "wrote imsd-$VER.tar.gz ($(du -h "imsd-$VER.tar.gz" | cut -f1))" diff --git a/packaging/make-sysroot.sh b/packaging/make-sysroot.sh index fdca8d8..112b7e2 100755 --- a/packaging/make-sysroot.sh +++ b/packaging/make-sysroot.sh @@ -8,7 +8,8 @@ # clang can use via --sysroot. No root, no qemu, no device needed: # # packaging/make-sysroot.sh [dir] # default: ~/.cache/imsd/sysroot-aarch64-alpine -# crafter-build --target=aarch64-alpine-linux-musl --sysroot= +# crafter-build -- --target=aarch64-alpine-linux-musl --sysroot= \ +# --march=… --mtune=… # the phone's values: AGENTS.md §4.3 set -eu SYSROOT="${1:-$HOME/.cache/imsd/sysroot-aarch64-alpine}" @@ -16,8 +17,13 @@ MIRROR="${MIRROR:-https://dl-cdn.alpinelinux.org/alpine/edge}" ARCH=aarch64 # gcc is in the list for its crtbeginS.o/libgcc: clang's driver for # *-alpine-linux-musl links against the GCC runtime it detects under -# /usr/lib/gcc// -PKGS="musl-dev libc++ libc++-dev glib-dev compiler-rt llvm-libunwind-dev gcc" +# /usr/lib/gcc//. llvm-runtimes is in it for the std module +# source of this very libc++ (usr/share/libc++/v1/std.cppm; libc++-dev ships +# none): crafter-build compiles the sysroot's own copy first. --upgrade below +# is for a run over an existing sysroot: added alone beside libc++ 22, +# llvm-runtimes 23's source did not compile against its headers; upgraded +# with it, it did. +PKGS="musl-dev libc++ libc++-dev llvm-runtimes glib-dev compiler-rt llvm-libunwind-dev gcc" work=$(mktemp -d) trap 'rm -rf "$work"' EXIT @@ -40,6 +46,6 @@ mkdir -p "$SYSROOT" --arch "$ARCH" --root "$SYSROOT" --initdb --no-scripts --no-cache \ --usermode --keys-dir "$work/usr/share/apk/keys/$ARCH" \ -X "$MIRROR/main" -X "$MIRROR/community" \ - add $PKGS + add --upgrade $PKGS echo ">> done: $SYSROOT" diff --git a/packaging/net.catcrafts.IMS1.conf b/packaging/net.catcrafts.IMS1.conf index c365a8a..cd1c18b 100644 --- a/packaging/net.catcrafts.IMS1.conf +++ b/packaging/net.catcrafts.IMS1.conf @@ -11,12 +11,13 @@ - - + + - - - diff --git a/packaging/net.catcrafts.IMS1.policy b/packaging/net.catcrafts.IMS1.policy new file mode 100644 index 0000000..181972d --- /dev/null +++ b/packaging/net.catcrafts.IMS1.policy @@ -0,0 +1,26 @@ + + + + + + + Catcrafts + https://forgejo.catcrafts.net/Catcrafts/imsd + + Place, answer and end voice calls over IMS + System policy prevents voice calls. + + no + no + yes + + + diff --git a/packaging/org.ofono.conf b/packaging/org.ofono.conf index 0b73669..23a7cf0 100644 --- a/packaging/org.ofono.conf +++ b/packaging/org.ofono.conf @@ -15,20 +15,20 @@ + every name the receiver owns (dbus-daemon does not). The default + below allows sending too since 0.3.10; this line stays so that a + tighter default can never again silently drop every CallAdded / + CallStateChanged from imsd on the way to imsd-ofonod, as the old + default deny did (found on the FP6, 2026-09-20). --> - - + + + - - - - diff --git a/project.cpp b/project.cpp index 3507130..1bc9398 100644 --- a/project.cpp +++ b/project.cpp @@ -4,7 +4,8 @@ // lint-disable-file no-char-pointer import std; import Crafter.Build; -#include "lint-rules.h" +#pragma crafter dependency "https://forgejo.catcrafts.net/Catcrafts/Crafter.LintRules.git" commit "e906f797da2cd0b1776dd1b420f9b04d3cd24baf" +import Crafter.LintRules; namespace fs = std::filesystem; using namespace Crafter; @@ -57,6 +58,24 @@ static void ApplyGioFlags(Configuration& cfg) { } } +// ImsdBus — the polkit gate imsd and imsd-ofonod put in front of call +// control. A static library of its own: GLib, so outside imsd-core. +static std::unique_ptr BusLibrary(std::span args) { + auto bus = std::make_unique(); + bus->path = "./"; + bus->name = "imsd-bus"; + bus->outputName = "imsd-bus"; + ApplyStandardArgs(*bus, args); + bus->type = ConfigurationType::LibraryStatic; + { + std::array ifaces = { "interfaces/ImsdBus" }; + std::array impls = {}; + bus->GetInterfacesAndImplementations(ifaces, impls); + } + ApplyGioFlags(*bus); + return bus; +} + extern "C" Configuration CrafterBuildProject(std::span args) { // Four executables come out of this repo; which one a build produces is // chosen by `--product=`: @@ -70,7 +89,8 @@ extern "C" Configuration CrafterBuildProject(std::span a // (pure GDBus translation, no core). // ofonod — imsd-ofonod, the system-bus GNOME Calls backend // (org.ofono.*) bridging to imsd, for Phosh and - // GNOME Mobile (pure GDBus translation, no core). + // GNOME Mobile (GDBus translation plus the imsd-bus + // polkit gate on its callers, no core). bool wantMedia = false; bool wantDialerd = false; bool wantOfonod = false; @@ -86,13 +106,15 @@ extern "C" Configuration CrafterBuildProject(std::span a ofonod.outputName = "imsd-ofonod"; ApplyStandardArgs(ofonod, args); ofonod.type = ConfigurationType::Executable; + static auto Bus = BusLibrary(args); + ofonod.dependencies = { Bus.get() }; { std::array ifaces = {}; std::array impls = { "implementations/ofonod" }; ofonod.GetInterfacesAndImplementations(ifaces, impls); } ApplyGioFlags(ofonod); - ProjectLint::AddProjectLintRules(ofonod); + LintRules::AddRules(ofonod); return ofonod; } if (wantDialerd) { @@ -108,7 +130,7 @@ extern "C" Configuration CrafterBuildProject(std::span a dialerd.GetInterfacesAndImplementations(ifaces, impls); } ApplyGioFlags(dialerd); - ProjectLint::AddProjectLintRules(dialerd); + LintRules::AddRules(dialerd); return dialerd; } if (wantMedia) { @@ -125,7 +147,7 @@ extern "C" Configuration CrafterBuildProject(std::span a } media.linkFlags.push_back("-ldl"); // dlopen the AMR-WB codecs media.linkFlags.push_back("-lpthread"); // std::jthread playout/mic - ProjectLint::AddProjectLintRules(media); + LintRules::AddRules(media); return media; } @@ -161,7 +183,8 @@ extern "C" Configuration CrafterBuildProject(std::span a cfg.outputName = "imsd"; ApplyStandardArgs(cfg, args); cfg.type = ConfigurationType::Executable; - cfg.dependencies = { Core.get() }; + static auto Bus = BusLibrary(args); + cfg.dependencies = { Core.get(), Bus.get() }; { std::array ifaces = {}; std::array impls = { "implementations/main" }; @@ -179,7 +202,7 @@ extern "C" Configuration CrafterBuildProject(std::span a cfg.AddTest("Sip").Dependencies({ Core.get() }); cfg.AddTest("Sdp").Dependencies({ Core.get() }); - ProjectLint::AddProjectLintRules(cfg); + LintRules::AddRules(cfg); return cfg; } diff --git a/tests/Engine/main.cpp b/tests/Engine/main.cpp index 284186e..9ab77dd 100644 --- a/tests/Engine/main.cpp +++ b/tests/Engine/main.cpp @@ -11,7 +11,12 @@ // reject -> 486, remote CANCEL -> 200+487, unusable offer -> 488 (naming // what was offered), a landline gateway's G.711-only and AMR-NB offers // ring and are answered at the offer's own payload types, ring timeout -> -// 480, BYE both ways. Pure reducer, no I/O. +// 480, BYE both ways. Then RegRecovery, the registration recovery policy: +// the refusal and silence thresholds, local refusals, the reconnect and +// fresh backoffs, the throttle schedule, Retry-After, which waits a Dial +// may cut short, when the old registration counts as gone, the holds a +// restart takes over (Holds/Restore), and a binding the network terminated +// (OnBindingTerminated). Pure reducers, no I/O. import std; import Imsd; @@ -854,6 +859,248 @@ int main() { Check(can && !can->text.contains("001010123456789@ims"), "CANCEL carries no IMSI"); } + // ---- RegRecovery: when a registration stops working after bring-up. + // The clock is ours: seconds, starting at 1000. + { + using F = RegRecovery::Failure; + using S = RegRecovery::Step; + auto reconnectAt = [](const RegRecovery& r, double now, double at) { auto p = r.Next(now); return p.step == S::Reconnect && p.at == at; }; + auto freshAt = [](const RegRecovery& r, double now, double at) { auto p = r.Next(now); return p.step == S::Fresh && p.at == at; }; + RegRecovery r; + Check(!r.Fresh() && r.Reconnectable() && reconnectAt(r, 1000, 0), "the first step is a reconnect, at once"); + r.OnLocalRefusal(1000); + Check(r.refusals == 0 && r.silentCycles == 0 && r.reconnectDelay == 5 && reconnectAt(r, 1000, 1005), "a local connect refusal: retried in 5 s, not counted, backoff untouched"); + // O2 2026-09-24, 09:29: the keepalive refresh refused (503), then a + // reconnect's refresh refused again -> fresh, not a third refresh + r.OnReconnectFailure(1000, F::Refused); + Check(!r.Fresh() && reconnectAt(r, 1000, 1005), "first refusal: reconnect after 5 s, not yet an escalation"); + r.OnReconnectFailure(1005, F::Refused); + Check(r.Fresh() && r.refusals == 2 && freshAt(r, 1005, 1015), "second refusal: escalated, the fresh attempt after the doubled backoff"); + Check(r.Reconnectable(), "…and the old SA is still there"); + r.OnSuccess(); + Check(!r.Fresh() && r.refusals == 0 && r.reconnectDelay == 5, "success resets the policy"); + // Retry-After is the network's word: it holds every attempt for its + // own length, next to the backoff, and a Dial cuts only the backoff + r.OnReconnectFailure(1000, F::Refused, 90); + Check(reconnectAt(r, 1000, 1090), "Retry-After 90 beats the 5-s backoff"); + r.OnDial(); + Check(reconnectAt(r, 1000, 1090), "…and a Dial does not cut it"); + r.OnReconnectFailure(1090, F::Refused, 3); + Check(r.Fresh() && freshAt(r, 1090, 1100), "a Retry-After shorter than the backoff does not shorten it"); + r.OnDial(); + Check(freshAt(r, 1090, 1093), "…but a Dial cuts the backoff down to it, no further"); + r.OnSuccess(); + // a 403 once a fresh registration is due: the old registration is gone + { + RegRecovery k; + k.OnReconnectFailure(1000, F::Refused, 0, 403); + Check(!k.Fresh() && k.Reconnectable(), "a first 403 is a refusal like the others"); + k.OnReconnectFailure(1005, F::Refused, 0, 403); + Check(k.Fresh() && !k.Reconnectable() && freshAt(k, 1005, 1015), "a 403 once fresh is due: gone, the fresh attempt after the backoff"); + k.OnSuccess(); + Check(k.Reconnectable(), "success forgets it"); + } + // O2 10:43: the flow killed 8 s after the 200, every SYN unanswered + r.OnReconnectFailure(1000, F::Silent); + Check(!r.Fresh() && reconnectAt(r, 1000, 1005), "first silent cycle: retry in 5 s"); + r.OnReconnectFailure(1005, F::Silent); + Check(r.Fresh() && r.silentCycles == 2, "two silent cycles escalate"); + // a mix: one refusal + one silence keeps reconnecting (neither + // threshold reached) + RegRecovery m; + m.OnReconnectFailure(1000, F::Refused); + m.OnReconnectFailure(1000, F::Silent); + Check(!m.Fresh(), "one refusal + one silence: still reconnecting"); + m.OnReconnectFailure(1000, F::Silent); + Check(m.Fresh(), "…until a threshold is reached"); + // the reconnect backoff caps at 5 min + RegRecovery b; + for (int i = 0; i < 10; i++) b.OnReconnectFailure(1000, F::Silent); + Check(b.reconnectDelay == 300 && b.Next(1000).at == 1300, "reconnect backoff caps at 300 s"); + // fresh attempts: silence backs off 30 s -> 10 min (a Dial may cut + // it); a refusal or a throttle holds on the throttle schedule (21 + // min growing to 60), the network's word, and the old SA is + // reconnected meanwhile + RegRecovery f; + f.OldGone(); + Check(f.Fresh() && !f.Reconnectable() && freshAt(f, 1000, 0), "the old registration gone: fresh now, nothing to reconnect to"); + f.OnFreshFailure(1000, F::Silent); + Check(freshAt(f, 1000, 1030), "silent fresh attempt: 30 s"); + f.OnDial(); + Check(freshAt(f, 1000, 0), "…which a Dial cuts"); + f.OnFreshFailure(1030, F::Silent); + Check(freshAt(f, 1030, 1090), "…then 60 s"); + double last = 0; + for (int i = 0; i < 8; i++) { f.OnFreshFailure(2000, F::Silent); last = f.Next(2000).at - 2000; } + Check(last == 600 && f.freshAttempts == 10, "silent fresh backoff caps at 600 s; attempts counted"); + RegRecovery g; + g.OnReconnectFailure(1000, F::Refused); g.OnReconnectFailure(1000, F::Refused); // escalated, backoff 5 then 10 + g.OnFreshFailure(1010, F::Refused); + Check(g.Reconnectable() && reconnectAt(g, 1010, 1030) && g.freshHoldUntil == 1010 + 21 * 60, "refused fresh attempt: the old SA is reconnected after the backoff, the next fresh attempt held 21 min"); + Check(freshAt(g, 1010 + 21 * 60, 1010 + 21 * 60), "…and runs once the hold ends"); + g.OnDial(); + Check(g.Next(1010).step == S::Reconnect && g.Next(1010).at <= 1010, "a Dial meanwhile: the reconnect at once, never the held fresh attempt"); + g.OnFreshFailure(2000, F::Throttled); + Check(g.freshHoldUntil == 2000 + 31 * 60, "throttled next: 31 min (×1.5)"); + g.OnFreshFailure(3000, F::Refused); + Check(g.freshHoldUntil == 3000 + 46 * 60, "then 46 min"); + g.OnFreshFailure(4000, F::Refused); + Check(g.freshHoldUntil == 4000 + 60 * 60, "capped at 60 min"); + g.OnFreshFailure(5000, F::Refused); + Check(g.freshHoldUntil == 5000 + 60 * 60, "stays at 60 min"); + g.OnFreshFailure(6000, F::Refused, 7200); + Check(g.freshHoldUntil == 6000 + 3600, "a Retry-After beyond an hour is cut to the hour, the schedule's own ceiling"); + RegRecovery ra; + ra.OnFreshFailure(1000, F::Refused, 2700); + Check(ra.freshHoldUntil == 3700, "a Retry-After longer than the schedule's wait holds for its own length"); + ra.OnFreshFailure(5000, F::Refused, 86400); + Check(ra.freshHoldUntil == 5000 + 3600, "…a day's for an hour: the refusal is heard again then"); + RegRecovery rb; + rb.OnReconnectFailure(1000, F::Refused, 86400); + Check(reconnectAt(rb, 1000, 1000 + 3600), "a refresh's Retry-After is cut to the hour the same"); + RegRecovery h; + h.OnFreshFailure(1000, F::Refused, 60); + Check(h.freshHoldUntil == 1060, "a refused fresh attempt with Retry-After 60 waits 60 s, not the schedule"); + h.OnFreshFailure(1000, F::Refused, 5); + Check(h.freshHoldUntil == 1030, "…but never under the 30-s floor"); + Check(h.throttleMinutes == 46, "the schedule still advances underneath (21 -> 31 -> 46)"); + h.OnFreshFailure(1000, F::Silent, 600); + Check(h.freshHoldUntil == 1030 && h.Next(1000).at == 1030, "Retry-After is a refusal's word; silence keeps its own backoff"); + Check(g.Fresh(), "a failed fresh attempt stays on the fresh step"); + g.OnSuccess(); + Check(!g.Fresh() && g.throttleMinutes == 21 && g.freshDelay == 30, "success resets the fresh schedule too"); + // an attempt that got past the SA setup took the old SAs with it + { + RegRecovery s; + s.OnFreshFailure(1000, F::Silent, 0, /*saReplaced=*/true); + Check(!s.Reconnectable() && freshAt(s, 1000, 1030), "no reply past the SA setup: the old registration is gone, the next fresh attempt on the short backoff"); + RegRecovery t; + t.OnFreshFailure(1000, F::Refused, 0, /*saReplaced=*/true); + Check(!t.Reconnectable() && freshAt(t, 1000, 1000 + 21 * 60), "refused past the SA setup: gone, and on the schedule"); + } + // the network terminated our binding + { + RegRecovery n; + n.OnBindingTerminated(1000, "expired"); + Check(n.Fresh() && !n.Reconnectable() && freshAt(n, 1000, 0), "expired: fresh now, nothing to reconnect to"); + RegRecovery o; + o.OnBindingTerminated(1000, "probation", 300); + Check(freshAt(o, 1000, 1300), "probation: after the network's retry-after"); + o.OnDial(); + Check(freshAt(o, 1000, 1300), "…which a Dial does not cut"); + RegRecovery p; + p.OnBindingTerminated(1000, "probation"); + Check(freshAt(p, 1000, 1001), "probation without a retry-after: at least 1 s"); + RegRecovery q; + q.OnBindingTerminated(1000, "rejected"); + Check(freshAt(q, 1000, 1000 + 21 * 60) && !q.Reconnectable(), "rejected: on the refusal schedule, a hold, the old SA not retried"); + q.OnBindingTerminated(1001, "rejected"); + q.OnBindingTerminated(2000, "unregistered"); + Check(q.freshHoldUntil == 1000 + 21 * 60 && q.throttleMinutes == 31, "the same termination reported again (another dialog, a retransmission): counted once"); + RegRecovery u; + u.OnBindingTerminated(1000, "deactivated"); + u.OnFreshFailure(1000, F::Silent); + u.OnBindingTerminated(1005, "deactivated"); + Check(freshAt(u, 1005, 1030), "…nor does a repeat cut the backoff of the attempt made since"); + RegRecovery v; + v.OnFreshFailure(1000, F::Refused, 3000); + v.OnBindingTerminated(1010, "rejected"); + Check(!v.Reconnectable() && freshAt(v, 1010, 4000) && v.throttleMinutes == 46, "a rejected binding never shortens the network's Retry-After"); + RegRecovery w; + w.OnBindingTerminated(1000, "probation", 3000); + w.OnBindingTerminated(1010, "probation", 60); + Check(freshAt(w, 1010, 4000), "…nor a second probation report the first one's retry-after"); + RegRecovery x; + x.OnBindingTerminated(1000, "probation", 86400); + Check(freshAt(x, 1000, 1000 + 3600), "probation's retry-after is cut to the hour like a Retry-After"); + // terminated while a reconnect cycle waited for its re-REGISTER final: the cycle's failure is no news + RegRecovery y; + y.OnReconnectFailure(1000, F::Silent); + y.OnBindingTerminated(1010, "expired"); + y.OnReconnectFailure(1011, F::Silent); + Check(y.silentCycles == 1 && freshAt(y, 1011, 0), "the flow closed under the cycle: not a silent cycle, the fresh registration now"); + y.OnReconnectFailure(1012, F::Refused, 90, 503); + Check(y.refusals == 0 && freshAt(y, 1012, 1102), "…a refusal with a Retry-After on it: not counted, the network's hold kept"); + } + // a terminated binding's verdict outlives the process with its hold + { + RegRecovery tg; + tg.OnBindingTerminated(1000, "rejected"); + auto htg = tg.Holds(1100); + Check(htg.Any() && htg.oldGone && htg.freshHold == 1000 + 21 * 60 - 1100, "a terminated binding's holds carry that the old registration is gone"); + RegRecovery th; + th.Restore(50, htg); + Check(!th.Reconnectable() && freshAt(th, 50, 50 + htg.freshHold), "…and the restart does not reconnect over its SAs: the fresh attempt waits what was left"); + RegRecovery ti; + ti.OnBindingTerminated(1000, "probation", 1800); + RegRecovery tj; + tj.Restore(0, ti.Holds(1300)); + Check(!tj.Reconnectable() && freshAt(tj, 0, 1500), "probation's retry-after survives a restart the same way"); + } + // the network's holds outlive the process + { + RegRecovery a; + a.OnReconnectFailure(1000, F::Refused); a.OnReconnectFailure(1000, F::Refused); + a.OnFreshFailure(1010, F::Throttled); + auto h = a.Holds(1100); + Check(h.Any() && h.hold == 0 && h.freshHold == 1010 + 21 * 60 - 1100 && h.throttleMinutes == 31, "holds: the seconds still to run and the schedule's place"); + RegRecovery b; + b.Restore(50, h); + Check(reconnectAt(b, 50, 0) && b.throttleMinutes == 31, "restored fresh hold: the resume (a reconnect) is free at once"); + b.OldGone(); + Check(freshAt(b, 50, 50 + h.freshHold), "…and the fresh step waits what was left of it"); + b.OnDial(); + Check(freshAt(b, 50, 50 + h.freshHold), "…which a Dial does not cut"); + RegRecovery c; + c.OnReconnectFailure(1000, F::Refused, 600); + RegRecovery d; + d.Restore(10, c.Holds(1200)); + Check(reconnectAt(d, 10, 10 + 400), "restored Retry-After: no step before it ends"); + d.Restore(20, RegRecovery::Held{30, 0, 21}); + Check(reconnectAt(d, 20, 10 + 400), "a restore never shortens a hold"); + Check(!RegRecovery{}.Holds(5).Any(), "no hold, nothing to keep"); + auto k = a.Holds(1010 + 21 * 60 + 5); + Check(k.Any() && k.hold == 0 && k.freshHold == 0 && k.throttleMinutes == 31, "the hold run out, the schedule's place is still kept"); + RegRecovery e; + e.Restore(10, k); + Check(reconnectAt(e, 10, 0) && e.throttleMinutes == 31, "…and a restart takes it over: no wait, the next refused or throttled fresh attempt on the advanced schedule"); + e.OldGone(); e.OnFreshFailure(20, F::Throttled); + Check(freshAt(e, 20, 20 + 31 * 60), "…31 min, not 21"); + RegRecovery g; + g.OnReconnectFailure(1000, F::Refused); g.OnReconnectFailure(1000, F::Refused, 0, 403); + auto hg = g.Holds(1100); + Check(hg.Any() && hg.oldGone, "a 403 once fresh is due: the holds carry that the old registration is gone"); + RegRecovery h2; + h2.Restore(50, hg); + Check(!h2.Reconnectable() && h2.Fresh() && freshAt(h2, 50, 0), "…and the restart does not reconnect over its SAs: fresh at once"); + RegRecovery i; + i.OnFreshFailure(1000, F::Throttled, 0, true); + auto hi = i.Holds(1100); + RegRecovery j; + j.Restore(50, hi); + Check(hi.oldGone && !j.Reconnectable() && freshAt(j, 50, 50 + hi.freshHold), "an attempt past the SA setup: gone travels with the hold, the restart waits it out without resuming"); + RegRecovery m; + m.OldGone(); + Check(m.Holds(5).Any() && m.Holds(5).oldGone && m.Holds(5).freshHold == 0, "gone with no hold left is still kept"); + RegRecovery n; + n.Restore(5, m.Holds(5)); + Check(!n.Reconnectable() && freshAt(n, 5, 0), "…so the next start registers fresh at once instead of resuming"); + Check(!RegRecovery::Held{0, 0, 21, false}.Any(), "nothing gone, nothing held: nothing to keep"); + RegRecovery x; + x.Restore(2000, RegRecovery::Held{86400, 86400, 21}); + Check(x.holdUntil == 2000 + 3600 && x.freshHoldUntil == 2000 + 3600, "a restored hold is cut to the hour like the Retry-After it came from"); + } + // bring-up: a resume that failed leaves nothing to reconnect over + { + RegRecovery u; + u.OldGone(); + u.OnFreshFailure(1000, F::Throttled); + Check(!u.Reconnectable() && freshAt(u, 1000, 1000 + 21 * 60), "a throttled bring-up: the next fresh attempt in 21 min, nothing meanwhile"); + u.OnDial(); + Check(freshAt(u, 1000, 1000 + 21 * 60), "…a Dial changes nothing"); + } + } + if (Failures == 0) std::println("Engine: all tests passed"); - return Failures; + return Failures != 0; // not the count: an exit status keeps 8 bits, and this suite has more checks than that } diff --git a/tests/Ipsec/main.cpp b/tests/Ipsec/main.cpp index 4132a3d..fcb1d89 100644 --- a/tests/Ipsec/main.cpp +++ b/tests/Ipsec/main.cpp @@ -80,6 +80,14 @@ int main() { } // incomplete input -> nullopt Check(!ParseExistingSa("src a dst b\n\tspi 0x1 reqid 1 mode transport\n", "", "b", "a").has_value(), "missing pieces -> nullopt"); + { + // a registration still on another address of the link, plus an SA to + // another P-CSCF that is none of ours + std::string two = std::format("{}src 2001:db8:1111:2222:aaaa:bbbb:cccc:dddd dst 2001:db8::105\n\tproto esp spi 0x00000001 reqid 1 mode transport\nsrc 2001:db8:1111:2222:eeee:ffff:0:1 dst 2001:db8::999\n\tproto esp spi 0x00000002 reqid 1 mode transport\n", State); + auto locals = SaLocals(two, "2001:db8::105"); + Check(locals.size() == 2 && locals[0] == "2001:db8:1111:2222:3333:4444:5555:db43" && locals[1] == "2001:db8:1111:2222:aaaa:bbbb:cccc:dddd", "SaLocals: every local address with an SA to the P-CSCF, once each, in kernel order"); + Check(SaLocals(State, "2001:db8::999").empty(), "SaLocals: no SA to that P-CSCF -> none"); + } // ---- fresh setup command sequence { diff --git a/tests/Sip/main.cpp b/tests/Sip/main.cpp index 623669f..43e3454 100644 --- a/tests/Sip/main.cpp +++ b/tests/Sip/main.cpp @@ -196,6 +196,98 @@ int main() { Check(!m4.has_value(), "absurd Content-Length: discarded, no overflow"); } + // ---- RetryAfter / RefusalSummary (what a refused final is logged as) + { + std::string r503 = + "SIP/2.0 503 Service Unavailable\r\n" + "Via: SIP/2.0/TCP [2001:db8::db43]:45062;branch=z9hG4bKx\r\n" + "Retry-After: 120\r\n" + "Warning: 399 pcscf1.example \"S-CSCF unreachable\"\r\n" + "Server: Mavenir-P-CSCF/4.2\r\n" + "Content-Length: 0\r\n\r\n"; + Check(RetryAfter(r503) == 120, "Retry-After parsed"); + Check(RefusalSummary(r503) == "503 Service Unavailable; Retry-After: 120; Warning: 399 pcscf1.example \"S-CSCF unreachable\"; Server: Mavenir-P-CSCF/4.2", "summary: status line + reason headers in order"); + std::string r500 = + "SIP/2.0 500 Server Internal Error\r\n" + "Via: SIP/2.0/TCP [2001:db8::db43]:45062;branch=z9hG4bKx\r\n" + "Content-Length: 0\r\n\r\n"; + Check(!RetryAfter(r500).has_value(), "no Retry-After -> nullopt"); + Check(RefusalSummary(r500) == "500 Server Internal Error", "summary without reason headers is the status line"); + std::string two = + "SIP/2.0 403 Forbidden\r\n" + "Reason: Q.850;cause=21;text=\"Call rejected\"\r\n" + "Reason: SIP;cause=403\r\n" + "Retry-After: soon\r\n" + "Content-Length: 0\r\n\r\n"; + Check(!RetryAfter(two).has_value(), "non-numeric Retry-After -> nullopt"); + Check(RefusalSummary(two) == "403 Forbidden; Retry-After: soon; Reason: Q.850;cause=21;text=\"Call rejected\"; Reason: SIP;cause=403", "every instance of a reason header is kept"); + Check(RefusalSummary("NOTIFY sip:x SIP/2.0\r\nEvent: reg\r\n\r\n") == "NOTIFY sip:x SIP/2.0", "a request summarises to its request line"); + Check(RefusalSummary("") == "", "empty message -> empty summary"); + } + + // ---- ResponseOf (which datagram a REGISTER wait may judge) + { + Check(ResponseOf(Resp200, "12345@host", "2 REGISTER"), "the transaction's own final"); + Check(!ResponseOf(Resp200, "12345@host", "1 REGISTER"), "another CSeq is not it"); + std::string stale = + "SIP/2.0 408 Request Timeout\r\n" + "Via: SIP/2.0/UDP [2001:db8::db43]:5060;branch=z9hG4bKa;rport=5060\r\n" + "Call-ID: earlier@[2001:db8::db43]\r\n" + "CSeq: 1 REGISTER\r\n" + "Content-Length: 0\r\n\r\n"; + Check(!ResponseOf(stale, "12345@host", "1 REGISTER"), "an earlier attempt's late final is not it"); + Check(ResponseOf(stale, "earlier@[2001:db8::db43]", "1 REGISTER"), "matched by Call-ID and CSeq"); + Check(!ResponseOf("NOTIFY sip:x SIP/2.0\r\nCall-ID: 12345@host\r\nCSeq: 2 REGISTER\r\n\r\n", "12345@host", "2 REGISTER"), "a request is never a response"); + Check(!ResponseOf("", "12345@host", "2 REGISTER"), "nothing is not it"); + } + + // ---- RegInfoContactState: our binding in a reg-event NOTIFY (RFC 3680) + { + using S = RegInfoContact::State; + auto contact = [](std::string_view id, std::string_view state, std::string_view extra, std::string_view user) { + return std::format( + "\t\t\n" + "\t\t\tsip:{}@[2a03:db8::1]:45062\n" + "\t\t\t"<urn:gsma:imei:35901961-000000-0>" \n" + "\t\t\n", id, state, extra, user); + }; + auto reginfo = [](std::string_view contacts) { + return std::format( + "\n" + "\n" + "\t\n{0}\t\n" + "\t\n{0}\t\n" + "\n", contacts); + }; + const std::string me = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + const std::string old = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + // the first NOTIFY of a registration: our contact active in both registrations + auto r0 = RegInfoContactState(reginfo(contact("3", "active", " event=\"created\" expires=\"10800\"", me)), me); + Check(r0.state == S::Active, "our contact active -> Active"); + // O2 2026-09-24 10:43: the previous registration's contact deactivated by ours, ours created + std::string both = contact("3", "terminated", " event=\"deactivated\"", old) + contact("4", "active", " event=\"created\" expires=\"10800\"", me); + auto r1 = RegInfoContactState(reginfo(both), me); + Check(r1.state == S::Active, "old contact terminated + ours active -> Active (the old one is not ours)"); + auto r1o = RegInfoContactState(reginfo(both), old); + Check(r1o.state == S::Terminated && r1o.event == "deactivated" && r1o.retryAfter == 0, "seen from the old contact's side: Terminated/deactivated"); + // a network-initiated deregistration of our own binding + auto r2 = RegInfoContactState(reginfo(contact("4", "terminated", " event=\"probation\" retry-after=\"120\"", me)), me); + Check(r2.state == S::Terminated && r2.event == "probation" && r2.retryAfter == 120, "probation with retry-after"); + auto r3 = RegInfoContactState(reginfo(contact("4", "terminated", " event=\"rejected\"", me)), me); + Check(r3.state == S::Terminated && r3.event == "rejected", "rejected"); + // active in one registration of the set beats terminated in another + std::string mixed = + "\n\n" + contact("4", "terminated", " event=\"expired\"", me) + + "\n\n" + contact("4", "active", "", me) + "\n"; + Check(RegInfoContactState(mixed, me).state == S::Active, "active anywhere in the set -> Active"); + // not ours at all + auto r4 = RegInfoContactState(reginfo(contact("9", "terminated", " event=\"unregistered\"", old)), me); + Check(r4.state == S::NotListed && r4.event.empty(), "a document without our contact -> NotListed"); + Check(RegInfoContactState("", me).state == S::NotListed, "empty body -> NotListed"); + Check(RegInfoContactState("sip:aaaaaaaa@x", me).state == S::NotListed, "a user part that is only a prefix of ours does not match"); + Check(RegInfoContactState("sip:" + me + "@h", me).state == S::Terminated, "an unterminated element still parses"); + } + if (Failures == 0) std::println("Sip: all tests passed"); return Failures; }