diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml index 30c381d..783fa74 100644 --- a/.forgejo/workflows/package.yml +++ b/.forgejo/workflows/package.yml @@ -6,19 +6,18 @@ 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 — AGENTS.md §4.3's cross-compiling flow), the +# (packaging/build-package.sh — the README's "Cross-compiling" flow), the # test suite runs natively, and packaging/APKBUILD.binary wraps the result. -# 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. +# This needs no privileged runner: it runs in an alpine:edge container on +# the ordinary arch-latest runner. # # 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 instead of -# building their own, so image and 'apk upgrade' carry the same binary. +# 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. # # Publishing needs PACKAGE_TOKEN (catbot account, package:write scope) — an # org-level secret on Catcrafts since 2026-09-02, shared with fp6-img; @@ -31,7 +30,7 @@ on: jobs: package: - runs-on: flatpak + runs-on: arch-latest container: image: alpine:edge timeout-minutes: 90 diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 2a8715b..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,1024 +0,0 @@ - - - -# 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 new file mode 100644 index 0000000..a72e15e --- /dev/null +++ b/Makefile @@ -0,0 +1,157 @@ +# 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 3116d23..826eb6a 100644 --- a/README.md +++ b/README.md @@ -5,137 +5,306 @@ Userspace IMS/VoLTE daemon for mainline Linux phones. -Most Linux phones drive the modem's internal IMS engine. -imsd implements the whole IMS client in userspace instead: -SIP registration with USIM AKA authentication over kernel +Modem firmware on typical mainline-Linux phones will bring up the IMS PDN but +can't be used for actual call audio. imsd implements the whole IMS client in +userspace instead: SIP registration with USIM AKA authentication over kernel IPsec (ESP), call signalling, and the AMR-WB RTP media plane wired into -PipeWire so a stock mobile dialer like Plasma Dialer or GNOME Calls under Phosh -can place and receive real VoLTE calls on a commercial network. +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. ## Status -Call-capable, both directions: outgoing calls place, answer, carry full-duplex -AMR-WB audio and tear down. Incoming calls ring, answer and carry media the -same way, driven end-to-end from a stock dialer over a frozen D-Bus ABI. -Not there yet: DTMF (`SendDtmf` is a stub), hold/multiparty/call waiting, -USSD, P-CSCF discovery (the address is configured by hand). +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: -Verified by community members to be fully working: +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. -| Device | OS | Carrier | +## 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 |---|---|---| | The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | KPN NL | | The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | Telekom Deutschland GER | | The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | Phonero | | The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | Telia Norge | -## What it needs +## Build -imsd talks to the modem through ModemManager plus `qmicli` over QRTR, installs -its security associations with `ip xfrm` and plays audio through PipeWire, so -in practice: a Qualcomm modem on a mainline kernel with working -WWAN/ModemManager integration, kernel ESP, PipeWire, systemd, polkit and -your carrier's P-CSCF address. - -## Install and configure - -For automatic updates add the Catcrafts Alpine repository. Note this does also pull in other packages. - -Otherwise just download the latest `imsd` and `imsd-systemd` APKs and install them. - -Both can be found in the packages tab: - -``` -https://forgejo.catcrafts.net/Catcrafts/-/packages/alpine/imsd -``` - -Configuration is environment variables in `/etc/imsd.env`; the one required -setting is the P-CSCF: +[Crafter Build](https://forgejo.catcrafts.net/Catcrafts/Crafter.Build) + +gio-2.0 headers (`glib2` on Arch, `glib-dev` on Alpine). ```sh -# your carrier's P-CSCF address, capture via: `adb shell dumpsys telephony.registry` on Android +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) +``` + +### 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. + +```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. PCSCF=2001:db8::105 ``` -The apk's `imsd.service` is enabled by preset and starts at boot once that -file exists (the condition is checked when the unit starts, so after writing -the file run `systemctl start imsd`). It first brings the `ims` PDN up through ModemManager, then registers: -resuming an existing registration when it can, registering fresh otherwise, -and waiting out whatever the network tells it to wait out. Check on it with: - -```sh -journalctl -u imsd -f -busctl --system call net.catcrafts.IMS1 /net/catcrafts/IMS1 net.catcrafts.IMS1 GetStatus -``` - -## Dialers - -imsd itself is desktop-agnostic: everything a dialer needs is on the system -bus as `net.catcrafts.IMS1`. A small translation daemon per desktop speaks -the backend protocol its dialer already consumes, so dialer, shell and the -call-history/contacts stack run unmodified. - -| Desktop | Daemon | Package | Owns | Consumed by | -|---|---|---|---|---| -| Plasma Mobile | `imsd-dialerd` | `imsd` | `org.kde.telephony.*` (session bus) | plasma-dialer + kde-telephony-daemon, replacing `modem-daemon` | -| Phosh / GNOME Mobile | `imsd-ofonod` | `imsd-ofono` | `org.ofono` (system bus) | GNOME Calls' `ofono` provider plugin | - -Plasma Mobile needs nothing beyond the `imsd` package: `imsd-dialerd` is -autostarted in the session in place of plasma-dialer's `modem-daemon`, whose -own autostart must be off — the package hides it for accounts created after -the install (`/etc/skel`); an existing account hides it itself with a -`Hidden=true` copy of `org.kde.modem.daemon.desktop` in `~/.config/autostart`. - -For Phosh and GNOME Mobile, `apk add imsd-ofono` enables `imsd-ofonod` and -points GNOME Calls at its `ofono` provider (a gschema override; the per-user -equivalent is `gsettings set org.gnome.Calls autoload-plugins "['ofono']"`). -Do not run it alongside a real `ofonod`. - -Call control is polkit's `net.catcrafts.IMS1.Voice`: the active local -session and root may place, answer and end calls; an ssh login may not. - -## Full config: +### imsd | Variable | Default | Meaning | |---|---|---| -| `PCSCF` | *(none — required)* | P-CSCF address (IPv6); without it registration fails with a clear error | +| `PCSCF` | *(none — required)* | P-CSCF address; without it registration fails with a clear error | | `PCSCF_PORT` | `5060` | unprotected SIP port for the initial REGISTER | | `DEV` | `qmapmux0.0` | ims-PDN netdev; the packaged service passes the real one via `/run/imsd.env` | -| `LOCAL` | *(auto)* | UE address. 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) | +| `LOCAL` | *(auto)* | UE address; default = the global IPv6 on `DEV` | | `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 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 | +| `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 | | `RTP_PORT` | `50004` | local RTP port advertised in SDP | | `PRECOND` | `0` | `1` offers SDP QoS preconditions | | `CODECS` | *(empty — defaults)* | comma-separated codec preference list over `AMR-WB`, `AMR` (or `AMR-NB`), `PCMA`, `PCMU`: restricts and orders both the codecs offered on an outgoing call and those accepted from an inbound offer (default: offer AMR-WB + AMR, accept all four in that order). A bench knob — a network whose gateway transcodes every caller up to AMR-WB otherwise never lets the narrowband path run | -| `DUMP_SIP` | `0` | `1` writes raw REGISTER-200/SUBSCRIBE-200 dumps, the last refused REGISTER or SUBSCRIBE final (`imsd-register-fail.raw`, `imsd-subscribe-fail.raw`), the last inbound INVITE (`imsd-invite-in.raw`) and the last outgoing INVITE (`imsd-invite-out.raw`), mode 0600, for debugging — they contain your IMSI/MSISDN and addresses. A refused REGISTER or SUBSCRIBE is logged with its status line and any Retry-After / Warning / Reason / Server headers | +| `DUMP_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_DIR`, `OUT_DIR` | state dir | where dumps / per-call media stats land | | `IMSD_MEDIA` | *(auto)* | path to `imsd-media` (default: next to `imsd`, else `/usr/libexec/imsd-media`) | The protected client/server ports are fixed at 45061/45062 -(`imsd::util::PortUc`/`PortUs`) — changing them in source also requires +(`imsd::util::kPortUc`/`kPortUs`) — changing them in source also requires updating the firewall rules `ims-pdn-up.sh` installs. ### imsd-media (per-call, set through imsd's environment) | Variable | Default | Meaning | |---|---|---| -| `MIC`, `PLAY` | `1`, `1` | uplink mic capture / downlink playout via PipeWire, 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) | +| `MIC`, `PLAY` | `1`, `1` | uplink mic capture / downlink playout via PipeWire | +| `AUDIO_USER` | `user` | desktop user whose PipeWire session carries call audio (postmarketOS default account) | +| `GAIN`, `PLAY_GAIN` | `10`, `1.0` | uplink / downlink gain | +| `AMR_MODE` | `2` | AMR-WB encoder mode (0–8) | | `DTX` | `0` | encoder discontinuous transmission | | `MEDIA_TIMEOUT` | `6.0` | seconds of downlink silence before exit 3 (far-end-hangup signal) | | `RTP_DUMP` | `0` | `1` captures raw downlink RTP next to the stats file | ## D-Bus ABI -`net.catcrafts.IMS1` at `/net/catcrafts/IMS1`. The ABI is frozen. +`net.catcrafts.IMS1` at `/net/catcrafts/IMS1`. The ABI is frozen — dialer +integrations depend on it: | Member | Signature | |---|---| @@ -150,8 +319,8 @@ updating the firewall rules `ims-pdn-up.sh` installs. | `CallDeleted` | `s` | | `RegistrationChanged` | `b` | -Both `imsd-dialerd` and `imsd-ofonod` are users of this ABI. -A new desktop needs a new translation daemon, not changes here. +Both `imsd-dialerd` and `imsd-ofonod` are pure translations 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 4ef4ff3..b8145e0 100644 --- a/implementations/main.cpp +++ b/implementations/main.cpp @@ -6,15 +6,13 @@ imsd — the userspace IMS/VoLTE daemon (control plane). Owns net.catcrafts.IMS1 on the system bus with the project's frozen D-Bus ABI -(AGENTS.md §8.1) and drives it with the engine core: register on the IMS PDN (USIM AKA +(README) 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 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. +GLib/GDBus lives only here; 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 @@ -22,33 +20,28 @@ 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, no - polkit) + --session own the name on the session bus (development; no policy needed) */ #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.10"; +constexpr const char* Version = "0.3.9"; constexpr const char* BusName = "net.catcrafts.IMS1"; constexpr const char* ObjPath = "/net/catcrafts/IMS1"; constexpr const char* Iface = "net.catcrafts.IMS1"; @@ -158,112 +151,15 @@ std::optional CompletedHex(std::string_view out) { return h; } -// 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). +// global IPv6 on the ims PDN, from `ip -6 addr show dev scope global`. std::optional DetectLocal(const std::string& dev) { auto r = RunCapture({"ip", "-6", "addr", "show", "dev", dev, "scope", "global"}); - 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" : "")); + 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); } // ---- sockaddr helper ------------------------------------------------------ @@ -292,19 +188,11 @@ 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 on an address whose registration is still -// live). Handled in-process: a +// 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 // systemd restart every RestartSec would never clear it. struct ThrottledError : std::runtime_error { using std::runtime_error::runtime_error; }; -// 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) {} -}; +constexpr int ThrottleWaitMin = 21; // 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; @@ -323,30 +211,24 @@ 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 SocketFailed(); + if (fd < 0) return false; 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, la, ll, local, lport)) return false; + if (bind(fd, reinterpret_cast(&la), ll) != 0) { close(fd); return false; } sockaddr_storage pa; socklen_t pl = MakeAddr(pcscf, pport, pa); - 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; - } + if (connect(fd, reinterpret_cast(&pa), pl) != 0) { Log(std::format("UDP connect: {}", std::strerror(errno))); close(fd); 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 SocketFailed(); + if (fd < 0) return false; int one = 1; setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one); // REUSEPORT (paired with the listener's): a reconnect must bind @@ -354,7 +236,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, la, ll, local, lport)) return false; + if (bind(fd, reinterpret_cast(&la), ll) != 0) { close(fd); 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). @@ -377,47 +259,21 @@ 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) { @@ -481,13 +337,10 @@ public: int Fd() const { return fd_; } bool Alive() const { return fd_ >= 0 && alive_; } Transport CurrentTransport() const { return transport_; } - // 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_ = {}; } + void Close() { if (fd_ >= 0) { close(fd_); fd_ = -1; } alive_ = false; } private: int fd_ = -1; bool alive_ = false; - bool lastConnectLocal_ = false; Transport transport_ = Transport::Tcp; bool sizeWarned_ = false; imsd::sip::FrameBuffer fb_; @@ -614,7 +467,6 @@ 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; }; @@ -737,87 +589,6 @@ 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; @@ -927,7 +698,7 @@ public: } } - void SetLocal(std::string l) { local_ = l; pdnLocal_ = std::move(l); } + void SetLocal(std::string l) { local_ = std::move(l); } void SetDevMode(bool d) { devMode_ = d; } const std::string& Local() const { return local_; } @@ -952,31 +723,28 @@ 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) { - 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); + // 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)); + } if (quit_.load()) return; + throttleWaitMin_ = std::min(throttleWaitMin_ * 3 / 2, 60); continue; } catch (const std::exception& e) { if (devMode_) { @@ -995,9 +763,14 @@ 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"); } - OnRegistered(); + SubscribeRegEvent(); while (!quit_.load()) { if (auto cmd = PopCmd()) { HandleCmd(*cmd); continue; } @@ -1007,7 +780,6 @@ 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_) { @@ -1023,8 +795,9 @@ public: if (call_ && callid == call_->CallId() && cseq.ends_with("INVITE")) { Execute(call_->OnInviteResponse(*msg)); MaybeDropCall(); - } else { - OnSubscribeResponse(*msg, *st); + } else if (!subCallid_.empty() && callid == subCallid_ && cseq.ends_with("SUBSCRIBE")) { + Log(std::format("reg-event SUBSCRIBE -> {}", *st)); + DumpRaw("imsd-subscribe-200.raw", *msg); } } // shutdown: release an active call, keep the SA/registration for resume @@ -1035,41 +808,6 @@ 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. @@ -1082,11 +820,9 @@ 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_; @@ -1103,20 +839,14 @@ 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) - 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) + double nextReconnect_ = 0; + double reconnectDelay_ = 5; // 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; @@ -1200,33 +930,10 @@ 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") { - 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(); } + try { if (TryResume()) return; } + catch (const std::exception& e) { Log(std::format("resume failed: {} — registering fresh", e.what())); sip_.Close(); } } - // 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(); } @@ -1234,26 +941,6 @@ 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; } @@ -1313,24 +1000,14 @@ 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. - std::string contactUser = imsd::util::Uuid4(rng_); - ctx_.contactUser = contactUser; - Log(std::format("contact user-part: {}", contactUser)); + reg_.contactUser = imsd::util::Uuid4(rng_); + ctx_.contactUser = reg_.contactUser; + Log(std::format("contact user-part: {}", reg_.contactUser)); std::string callidReg = std::format("{}@{}", rng_.Token(16), local_); std::string ftag = rng_.Token(8); std::uint32_t spiUc = rng_.UInt(0x10000, 0xFFFFFFF); @@ -1345,16 +1022,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); - 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); - } + 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"); auto nonceB64 = QuotedAfter(resp, "nonce=\""); if (!nonceB64) throw std::runtime_error("no nonce in 401"); auto raw = imsd::util::FromBase64(*nonceB64); @@ -1408,7 +1085,6 @@ 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)); @@ -1441,7 +1117,7 @@ private: // pair (the SA setup flushes this one), same ports. Log("protected TCP connect unanswered; registering fresh over UDP"); SetTransport(Transport::Udp); - FreshRegisterAttempt(); + FreshRegister(); return; } throw std::runtime_error(std::format("protected {} connect failed", TransportName(transport_))); @@ -1453,13 +1129,15 @@ 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 = 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)); + 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)); } 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); @@ -1471,86 +1149,6 @@ 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_); @@ -1559,12 +1157,13 @@ 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"); - std::string m = AwaitFinal(callid, cseq, "re-REGISTER"); - auto st = imsd::sip::Status(m); - if (st == 200) return {m, cseq}; + 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}; 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); @@ -1577,8 +1176,7 @@ private: cseq++; continue; } - 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)); + throw std::runtime_error(std::format("re-REGISTER failed {}", st.value_or(0))); } } @@ -1667,21 +1265,15 @@ 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)); - 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())); - CloseFlowAfterWait(); - RecoveryFailed(imsd::engine::RegRecovery::Failure::Refused, e.retryAfter, "keepalive refresh refused", e.status); + SubscribeRegEvent(); + if (!registered_) { registered_ = true; EmitStatus(true); } + else EmitStatus(false); } 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. + // 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. Log(std::format("keepalive re-REGISTER failed: {}", e.what())); - CloseFlowAfterWait(); + sip_.Close(); } lastRefresh_ = Mono(); lastKa_ = Mono(); @@ -1691,424 +1283,78 @@ 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. 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). + // 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. void MaybeReconnect() { if (call_ || sip_.Alive() || portPs_ == 0 || reg_.callid.empty()) return; - auto plan = recovery_.Next(Boot()); - if (plan.at > Boot()) return; - if (plan.step == imsd::engine::RegRecovery::Step::Fresh) FreshRecover(); - else ReconnectCycle(); - } - - // 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; + if (Mono() < nextReconnect_) return; 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()); + 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 (!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())); + nextReconnect_ = Mono() + reconnectDelay_; + reconnectDelay_ = std::min(reconnectDelay_ * 2, 300.0); } - // 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. + // (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. 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"); 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)); - } - } + if (sip_.Send(sub)) Log("reg-event SUBSCRIBE sent"); + else { Log("reg-event SUBSCRIBE send failed"); subCallid_.clear(); } } // ---- 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 (!ClearForDial(c, emergency)) return; - // a dead client flow fails the INVITE instantly — give the - // 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; + 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(); } } + // 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(); } RefreshPani(); if (emergency) Log(std::format("EMERGENCY dial ({}): urn:service:sos over the live registration, " @@ -2153,12 +1399,7 @@ 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)); - if (reply_) reply_(imsd::msg::BuildResponse200(ctx_, msg)); - OnRegEventNotify(msg, body); - return; - } + 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)); return; } @@ -2239,13 +1480,7 @@ private: } void MaybeDropCall() { - 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(); - } + if (dropCall_) { call_.reset(); callReply_ = nullptr; dropCall_ = false; } } void StartMedia(const imsd::engine::MediaLeg& leg) { @@ -2259,18 +1494,8 @@ 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); @@ -2412,9 +1637,7 @@ 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"); } -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, {} }; +const GDBusInterfaceVTable Vtable = { HandleMethodCall, nullptr, nullptr, {} }; void OnBusAcquired(GDBusConnection* conn, const gchar*, gpointer) { DbusConn = conn; @@ -2460,8 +1683,6 @@ 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 7f37fa7..b7d5964 100644 --- a/implementations/media.cpp +++ b/implementations/media.cpp @@ -410,56 +410,41 @@ std::size_t RtpPayloadOffset(std::span pkt) { return off; } -// 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() { +// 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() { static std::once_flag once; - static AudioSession s; + static uid_t uid = 0; std::call_once(once, [] { + std::string user = EnvOr("AUDIO_USER", "user"); passwd pw{}; passwd* res = nullptr; char buf[4096]; - 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; } + if (getpwnam_r(user.c_str(), &pw, buf, sizeof buf, &res) == 0 && res) uid = res->pw_uid; else - 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); + 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); }); - return s; + return uid; } -// 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}. +// 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}. 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) { - const AudioSession& session = Session(); - if (!session.name.empty()) - argv = {"sudo", "-u", session.name, "env", - std::format("XDG_RUNTIME_DIR=/run/user/{}", session.uid)}; + std::string user = EnvOr("AUDIO_USER", "user"); + argv = {"sudo", "-u", user, "env", + std::format("XDG_RUNTIME_DIR=/run/user/{}", AudioUid())}; } 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 2122b56..a93bb15 100644 --- a/implementations/ofonod.cpp +++ b/implementations/ofonod.cpp @@ -45,12 +45,11 @@ 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. 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. +stays in 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, no polkit) + (development: no root, no policy file) IMSD_BUS=session|system override only the imsd side of that choice */ @@ -58,7 +57,6 @@ Everything happens on the GLib main loop, so there is no locking. #include import std; -import ImsdBus; namespace { @@ -317,9 +315,7 @@ 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"); } -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, {} }; +const GDBusInterfaceVTable CallVtable = { HandleCallMethod, nullptr, nullptr, {} }; guint RegisterObject(const char* xml, const char* path, const GDBusInterfaceVTable* vtable) { GDBusNodeInfo* node = g_dbus_node_info_new_for_xml(xml, nullptr); @@ -600,9 +596,9 @@ void HandleSsMethod(GDBusConnection*, const gchar*, const gchar*, const gchar*, } const GDBusInterfaceVTable ManagerVtable = { HandleManagerMethod, 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, {} }; +const GDBusInterfaceVTable ModemVtable = { HandleModemMethod, nullptr, nullptr, {} }; +const GDBusInterfaceVTable VcmVtable = { HandleVcmMethod, nullptr, nullptr, {} }; +const GDBusInterfaceVTable SsVtable = { HandleSsMethod, nullptr, nullptr, {} }; // ---- imsd events ------------------------------------------------------------ void PublishVoiceState() { @@ -731,7 +727,6 @@ 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 efb4ada..7b7805e 100644 --- a/interfaces/Imsd-Engine.cppm +++ b/interfaces/Imsd-Engine.cppm @@ -3,9 +3,7 @@ // lint-disable-file fixed-width-types /* -Imsd:Engine — the daemon's decisions as pure reducers: the call state -machine (CallMachine) and the registration recovery policy (RegRecovery, -its own section below). +Imsd:Engine — the call state machine as a pure reducer. 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 @@ -54,215 +52,6 @@ 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 611bb82..fbbd514 100644 --- a/interfaces/Imsd-Ipsec.cppm +++ b/interfaces/Imsd-Ipsec.cppm @@ -400,17 +400,4 @@ 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 306d199..bc3290c 100644 --- a/interfaces/Imsd-Sip.cppm +++ b/interfaces/Imsd-Sip.cppm @@ -121,93 +121,6 @@ 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 deleted file mode 100644 index 4156d88..0000000 --- a/interfaces/ImsdBus.cppm +++ /dev/null @@ -1,106 +0,0 @@ -// 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 new file mode 100644 index 0000000..12461fd --- /dev/null +++ b/lint-rules.h @@ -0,0 +1,858 @@ +// 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 new file mode 100644 index 0000000..b85b738 --- /dev/null +++ b/packaging/APKBUILD @@ -0,0 +1,54 @@ +# 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 4abd25e..486f4bc 100644 --- a/packaging/APKBUILD.binary +++ b/packaging/APKBUILD.binary @@ -2,12 +2,11 @@ # SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® # Maintainer: Jorijn van der Graaf # Binary packaging: wraps a crafter-build binary cross-compiled per the -# cross-compiling flow of AGENTS.md §4.3 into a proper apk — used by this repo's +# README's "Cross-compiling" section 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 and -# the install scripts (*.post-upgrade, *.post-install) 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 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 @@ -17,9 +16,8 @@ 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; -# polkit answers the daemons' call-control check (ImsdBus) -depends="modemmanager libc++ opencore-amr vo-amrwbenc pipewire-tools polkit" +# the media leg dlopen's the AMR-WB codecs; pw-record/pw-play drive PipeWire +depends="modemmanager libc++ opencore-amr vo-amrwbenc pipewire-tools" # 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 @@ -31,9 +29,6 @@ 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 @@ -47,14 +42,11 @@ 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 \ @@ -72,8 +64,6 @@ 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 \ @@ -88,7 +78,6 @@ 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 53c6153..75a0aeb 100755 --- a/packaging/build-package.sh +++ b/packaging/build-package.sh @@ -2,31 +2,47 @@ # 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 -# cross-compiling flow of AGENTS.md §4.3), run the test suite natively, and package +# README's "Cross-compiling" flow), 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 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. +# 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. # # 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 -# 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} +# 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} 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 @@ -38,49 +54,26 @@ 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: 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 +# --- 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" # --- 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, inside the Flatpak -# (same sources, host arch: no suite runs on the aarch64 binaries) +# --- 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) 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 @@ -101,7 +94,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" "$SRC"/packaging/*.post-upgrade "$SRC"/packaging/*.post-install "$PKG/" + "$SRC/packaging/80-imsd.preset" "$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 191c2b5..3ab4725 100644 --- a/packaging/ims-pdn-up.sh +++ b/packaging/ims-pdn-up.sh @@ -39,12 +39,6 @@ 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 '"' } @@ -71,49 +65,34 @@ 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; a list without the APN is final — -# the APN string is used — only once the modem is registered. +# retried after the registration wait and only a list without the APN +# falls back to the APN string. LOOKUP=pending if [ -n "$PROFILE_ID" ]; then [ "$PROFILE_ID" = none ] && PROFILE_ID= LOOKUP=done fi -lookup_profile() { # $1 = modem state, $2 = log suffix; sets PROFILE_ID, LOOKUP=done on a final answer +lookup_profile() { # $1 = log suffix; sets PROFILE_ID, LOOKUP=done on a usable list [ "$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$2" + log "ims profile lookup returned no profiles$1" 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 } }') - 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 + 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" else - log "ims profile lookup failed$2: $(squash "$OUT")" + log "ims profile lookup failed$1: $(squash "$OUT")" fi } -STATE=$(modem_state) -lookup_profile "$STATE" " (modem state: $STATE)" +lookup_profile " (modem state: $(modem_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 @@ -171,10 +150,6 @@ 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 @@ -182,14 +157,12 @@ if [ -z "$BEARER" ]; then waited=$((waited + 5)) done log "modem state: $(modem_state), packet service: $(packet_state)" - # a lookup with no final answer yet: it failed, or its list lacked the - # APN while the modem was still coming up + # a lookup that failed while the modem was still coming up n=0 while [ "$LOOKUP" != done ] && [ "$n" -lt 3 ]; do n=$((n + 1)) sleep 5 - STATE=$(modem_state) - lookup_profile "$STATE" " (retry $n, modem state: $STATE)" + lookup_profile " (retry $n)" done if [ "$LOOKUP" != done ]; then PROFILE_ID= @@ -203,7 +176,6 @@ 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 @@ -308,13 +280,9 @@ 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 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 +# ---- 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 # ---- 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 deleted file mode 100755 index 02a907d..0000000 --- a/packaging/imsd-handover.sh +++ /dev/null @@ -1,83 +0,0 @@ -#!/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 deleted file mode 100755 index a3563ed..0000000 --- a/packaging/imsd-ofono-restart-calls.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/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 deleted file mode 120000 index a5acf7b..0000000 --- a/packaging/imsd-ofono.post-install +++ /dev/null @@ -1 +0,0 @@ -imsd-ofono.post-upgrade \ No newline at end of file diff --git a/packaging/imsd-ofono.post-upgrade b/packaging/imsd-ofono.post-upgrade deleted file mode 100755 index a1edb48..0000000 --- a/packaging/imsd-ofono.post-upgrade +++ /dev/null @@ -1,21 +0,0 @@ -#!/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 5229943..be308ee 100644 --- a/packaging/imsd-ofonod.service +++ b/packaging/imsd-ofonod.service @@ -3,11 +3,8 @@ [Unit] Description=oFono-compatible GNOME Calls backend for imsd Documentation=https://forgejo.catcrafts.net/Catcrafts/imsd -# 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 +# 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 # — Plasma Mobile uses imsd-dialerd instead. Both may run at once (different # buses, different consumers), but a real ofonod must not. Conflicts=ofono.service @@ -20,9 +17,6 @@ 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 deleted file mode 120000 index 99e6bcc..0000000 --- a/packaging/imsd-systemd.post-install +++ /dev/null @@ -1 +0,0 @@ -imsd-systemd.post-upgrade \ No newline at end of file diff --git a/packaging/imsd-systemd.post-upgrade b/packaging/imsd-systemd.post-upgrade deleted file mode 100755 index 846fdc4..0000000 --- a/packaging/imsd-systemd.post-upgrade +++ /dev/null @@ -1,23 +0,0 @@ -#!/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 deleted file mode 120000 index 0a3397d..0000000 --- a/packaging/imsd.post-install +++ /dev/null @@ -1 +0,0 @@ -imsd.post-upgrade \ No newline at end of file diff --git a/packaging/imsd.post-upgrade b/packaging/imsd.post-upgrade deleted file mode 100755 index 4e295e8..0000000 --- a/packaging/imsd.post-upgrade +++ /dev/null @@ -1,48 +0,0 @@ -#!/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 13c454c..add1594 100644 --- a/packaging/imsd.service +++ b/packaging/imsd.service @@ -13,16 +13,10 @@ 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, -# LOCAL= the PDN address ModemManager assigned to it). +# /run/imsd.env: written by ims-pdn-up.sh (DEV= the connected ims netdev). # /etc/imsd.env: admin configuration — PCSCF= is REQUIRED (the carrier's -# P-CSCF address; see AGENTS.md §10, or the README) and wins over the +# P-CSCF address; see the README's Configuration section) and wins over the # runtime-derived values. EnvironmentFile=-/run/imsd.env EnvironmentFile=-/etc/imsd.env @@ -30,8 +24,7 @@ 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, as does -# a registered imsd whose PDN address left the netdev +# throttles); a failed bring-up exits nonzero and systemd retries 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 3e695ec..72934bb 100755 --- a/packaging/make-bin-tarball.sh +++ b/packaging/make-bin-tarball.sh @@ -24,12 +24,10 @@ 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-handover.sh packaging/imsd.service \ +cp packaging/ims-pdn-up.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 packaging/net.catcrafts.IMS1.policy \ - "$stage/imsd-$VER/" + packaging/net.catcrafts.IMS1.conf "$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 112b7e2..fdca8d8 100755 --- a/packaging/make-sysroot.sh +++ b/packaging/make-sysroot.sh @@ -8,8 +8,7 @@ # 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= \ -# --march=… --mtune=… # the phone's values: AGENTS.md §4.3 +# crafter-build --target=aarch64-alpine-linux-musl --sysroot= set -eu SYSROOT="${1:-$HOME/.cache/imsd/sysroot-aarch64-alpine}" @@ -17,13 +16,8 @@ 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//. 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" +# /usr/lib/gcc// +PKGS="musl-dev libc++ libc++-dev glib-dev compiler-rt llvm-libunwind-dev gcc" work=$(mktemp -d) trap 'rm -rf "$work"' EXIT @@ -46,6 +40,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 --upgrade $PKGS + add $PKGS echo ">> done: $SYSROOT" diff --git a/packaging/net.catcrafts.IMS1.conf b/packaging/net.catcrafts.IMS1.conf index cd1c18b..c365a8a 100644 --- a/packaging/net.catcrafts.IMS1.conf +++ b/packaging/net.catcrafts.IMS1.conf @@ -11,13 +11,12 @@ - - + + + + + diff --git a/packaging/net.catcrafts.IMS1.policy b/packaging/net.catcrafts.IMS1.policy deleted file mode 100644 index 181972d..0000000 --- a/packaging/net.catcrafts.IMS1.policy +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - 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 23a7cf0..0b73669 100644 --- a/packaging/org.ofono.conf +++ b/packaging/org.ofono.conf @@ -15,20 +15,20 @@ + every name the receiver owns (dbus-daemon does not): without this + line the default deny below silently drops every CallAdded / + CallStateChanged from imsd on the way to imsd-ofonod (found on the + FP6, 2026-09-20). --> - - - + + + + + + diff --git a/project.cpp b/project.cpp index 1bc9398..3507130 100644 --- a/project.cpp +++ b/project.cpp @@ -4,8 +4,7 @@ // lint-disable-file no-char-pointer import std; import Crafter.Build; -#pragma crafter dependency "https://forgejo.catcrafts.net/Catcrafts/Crafter.LintRules.git" commit "e906f797da2cd0b1776dd1b420f9b04d3cd24baf" -import Crafter.LintRules; +#include "lint-rules.h" namespace fs = std::filesystem; using namespace Crafter; @@ -58,24 +57,6 @@ 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=`: @@ -89,8 +70,7 @@ 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 (GDBus translation plus the imsd-bus - // polkit gate on its callers, no core). + // GNOME Mobile (pure GDBus translation, no core). bool wantMedia = false; bool wantDialerd = false; bool wantOfonod = false; @@ -106,15 +86,13 @@ 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); - LintRules::AddRules(ofonod); + ProjectLint::AddProjectLintRules(ofonod); return ofonod; } if (wantDialerd) { @@ -130,7 +108,7 @@ extern "C" Configuration CrafterBuildProject(std::span a dialerd.GetInterfacesAndImplementations(ifaces, impls); } ApplyGioFlags(dialerd); - LintRules::AddRules(dialerd); + ProjectLint::AddProjectLintRules(dialerd); return dialerd; } if (wantMedia) { @@ -147,7 +125,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 - LintRules::AddRules(media); + ProjectLint::AddProjectLintRules(media); return media; } @@ -183,8 +161,7 @@ extern "C" Configuration CrafterBuildProject(std::span a cfg.outputName = "imsd"; ApplyStandardArgs(cfg, args); cfg.type = ConfigurationType::Executable; - static auto Bus = BusLibrary(args); - cfg.dependencies = { Core.get(), Bus.get() }; + cfg.dependencies = { Core.get() }; { std::array ifaces = {}; std::array impls = { "implementations/main" }; @@ -202,7 +179,7 @@ extern "C" Configuration CrafterBuildProject(std::span a cfg.AddTest("Sip").Dependencies({ Core.get() }); cfg.AddTest("Sdp").Dependencies({ Core.get() }); - LintRules::AddRules(cfg); + ProjectLint::AddProjectLintRules(cfg); return cfg; } diff --git a/tests/Engine/main.cpp b/tests/Engine/main.cpp index 9ab77dd..284186e 100644 --- a/tests/Engine/main.cpp +++ b/tests/Engine/main.cpp @@ -11,12 +11,7 @@ // 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. 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. +// 480, BYE both ways. Pure reducer, no I/O. import std; import Imsd; @@ -859,248 +854,6 @@ 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 != 0; // not the count: an exit status keeps 8 bits, and this suite has more checks than that + return Failures; } diff --git a/tests/Ipsec/main.cpp b/tests/Ipsec/main.cpp index fcb1d89..4132a3d 100644 --- a/tests/Ipsec/main.cpp +++ b/tests/Ipsec/main.cpp @@ -80,14 +80,6 @@ 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 43e3454..623669f 100644 --- a/tests/Sip/main.cpp +++ b/tests/Sip/main.cpp @@ -196,98 +196,6 @@ 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; }