Compare commits

...
Author SHA1 Message Date
7583ee3606
imsd 0.3.10
All checks were successful
package / package (push) Successful in 1m57s
2026-10-06 17:54:10 +02:00
7d38846bb6
ims-pdn-up: do not connect a bearer while the modem is disabled
With the modem's radio off (airplane mode) the bring-up waited five
minutes for registration and then tried to connect the ims bearer
anyway. That attempt fails with no-service and leaves ModemManager's
modem state `enabled` while the modem stays in low power; switching the
radio back on then no longer brings the modem up, and the phone has no
mobile service until the modem is disabled and enabled again (dev phone
2026-10-06: not registered 150 s after the radio came on, against 5 s
without the attempt).

0.3.9's bring-up made the same attempts, but it only ran at boot or at
a manual restart. Since this series a registered imsd exits when its
address leaves the netdev and systemd runs the bring-up again, and an
upgrade restarts it: the bring-up now also runs during a radio-off
period.

A modem still disabled after the registration wait, or disabled when a
connect attempt is due, now makes the script exit non-zero without
touching the modem; systemd retries after RestartSec.

Tested on the dev phone (KPN) with this script: a registered imsd, the
radio switched off — imsd exited at its next refresh, the bring-up gave
up after the wait with the modem still disabled, and when the radio came
back the modem registered in 5 s and imsd registered by itself two
minutes later; the same with an upgrade's restart during the radio-off
period. The twelve mock scenarios of the bring-up are unchanged.
2026-10-06 17:54:09 +02:00
af5b98ec8b
project.cpp: take the lint rules from Crafter.LintRules
lint-rules.h was a project-local copy of the Catcrafts house-style
rules. The rules have their own repository now, and project.cpp imports
them from it (`#pragma crafter dependency`), as Crafter.Build's own
project.cpp does. The dependency is pinned to a commit; crafter-build
clones it whenever it loads project.cpp, so every build fetches it,
CI's included.

The tree does not pass the shared rules yet: `crafter-build lint`
reports 72 findings where the copy reported 20. The shared `naming`
wants camelCase constants and statics (44), `const-local` is new (10),
and `single-declaration` reports two files the copy did not. They are
left for a later change; CI does not run lint.

Tested: build-package.sh run end to end on this commit in a privileged
alpine:edge container (podman) on the workstation, and `crafter-build
lint` on a built copy of this commit.
2026-10-06 16:45:44 +02:00
f089c8dd71
packaging: drop the make route and the from-source APKBUILD
imsd had two build descriptions to keep in sync: project.cpp for
crafter-build, and a Makefile mirroring it for distros that build from
source, used by packaging/APKBUILD. CI has not used that route since
2026-09-02 — its apks are a crafter-build binary wrapped by
APKBUILD.binary — and the recipe had not followed the releases: its
pkgver was still 0.3.0.

crafter-build is now the only build. AGENTS.md loses the make route
(§4.2) and describes one recipe (§9.1).

Tested: build-package.sh run end to end on this commit in a privileged
alpine:edge container (podman) on the workstation.
2026-10-06 16:45:44 +02:00
19346908f9
README, AGENTS.md: the README documents imsd for users, configuration included
The README had been cut down to an introduction and the configuration
variables moved to AGENTS.md, a file for coding agents. A user who has to
set a variable should not need that file.

The README is the user documentation again: installing from the
Catcrafts Alpine repository or the apks, the configuration tables for
imsd and imsd-media, the dialers and the D-Bus ABI. Its build
instructions go: building is in AGENTS.md (§4), with the tests, the
packaging and how the daemon works. AGENTS.md §10 points to the README
for the tables and keeps the two imsd-media pairs that are not
configuration.
2026-10-06 16:45:44 +02:00
a625248e18
packaging, ci: build the apks with the Crafter.Build Flatpak
Crafter.Build's CI publishes a Flatpak and nothing else since
2026-10-02: the rolling `latest` release's
`crafter-build-linux-x86_64-musl-v2.tar.gz` that build-package.sh
fetched is gone (404 since 2026-10-03), so a push of a release would
fail at `fetch crafter-build` and publish nothing.

Install the Flatpak (`net.catcrafts.CrafterBuild//stable` from
flatpak.catcrafts.net) in the job container and run it as crafter-build.
bubblewrap inside the container needs a privileged runner, so the job
runs on the `flatpak` label. The sysroot stays Alpine edge and gains
llvm-runtimes, the package with the std module source of its own libc++
(libc++-dev ships none). make-sysroot.sh adds its list with --upgrade:
run over a sysroot from before this change (libc++ 22.1.8, no std module
source) a plain add installed llvm-runtimes 23.1.2 alone, and its
std.cppm did not compile against the 22.1.8 headers (`'text_encoding'
file not found`); with --upgrade libc++, libc++-dev and llvm-runtimes
came out at 23.1.2 and it compiled.

AGENTS.md §4.1, §4.3, §8.3 and §9.4 describe the route as it runs, and
the README's Building section carries the install lines. Three comments
in the files this touches said what is not so and are corrected:
package.yml had fp6-img install the package by a pinned version and
sha256 (it takes the newest in the registry), build-package.sh had
fp6-img's image build run `make check` on the aarch64 binaries (it
builds no imsd: no suite runs on them), and make-sysroot.sh's usage line
lacked the --march without which the cross build fails on an x86 host.

Tested: build-package.sh run end to end on this commit in a privileged
alpine:edge container (podman) on the workstation.
2026-10-06 14:01:33 +02:00
828b0af95b
README, AGENTS.md: a short README for people, the detail in AGENTS.md
The README had grown to 422 lines: the device and carrier table came
after 61 lines of architecture, and the configuration section after 237
lines of architecture, that table, build, run and packaging notes and
GNOME Calls detail.

AGENTS.md, a file for coding agents, takes that detail and adds to it: a
reference to the tree at this commit, written from the code and the
bench record. The README is now what a person needs: what imsd is and
its status, the verified device/carrier table, what it needs, how to
install and enable it, the two dialers, a status check for the installed
daemon, a `Not there yet` list, and where the rest is.

In-tree comments that sent readers to README sections which moved — the
cross-compiling flow (the CI workflow, build-package.sh,
APKBUILD.binary), GNOME Calls' known gaps (imsd-ofono-restart-calls.sh),
the D-Bus ABI (main.cpp) and the Configuration section (imsd.service) —
point at AGENTS.md's sections now.
2026-10-06 14:01:33 +02:00
f18baf2790
imsd, imsd-ofonod: let polkit decide call control, not the account name
The system-bus policies granted net.catcrafts.IMS1 and org.ofono to root
and to the account `user`, postmarketOS's default name, so on a unit
whose account is called something else dbus denied every Dial from the
dialer — nothing in imsd's journal, nothing on screen. A field user
found it by trial (alyx report 14, 2026-09-20; ims ledger F14).

Let any local account reach both names and have the daemons decide:
placing, answering or ending a call needs polkit's
net.catcrafts.IMS1.Voice (the active local session; not an ssh login, an
inactive session or a system service account). imsd-ofonod asks about
its own callers, since its calls reach imsd as root. Reads such as the
status and the call list are not gated, and a refusal is logged with the
sender; a `--session` run, the development mode without root or polkit,
leaves the gate off. The check is ImsdBus, a small GLib module both
daemons link; the package depends on polkit.

An upgrade restarts both daemons (the previous commit), so the open
policy faces the old, ungated daemons only until the apk transaction
ends.

Tested on the dev phone (KPN) in builds of this series; not every path
has run there.
2026-10-06 14:01:33 +02:00
2fe991a14e
packaging: restart imsd and imsd-ofonod on upgrade
An upgrade left the old imsd and imsd-ofonod running until they were
next restarted, while a changed bus policy is live the moment apk writes
it: dbus-broker-launch reloads on its own. A policy that relies on a
check inside the daemons would face the old daemons in between.

Mark imsd.service and imsd-ofonod.service needs-restart in post-upgrade
scripts; the systemd trigger restarts them once the transaction is done
(enqueue-marked). A call in progress ends. The same scripts run as
post-install: `apk del` removes a package's files and leaves its daemon
running, so a later `apk add` of the package is this upgrade for that
daemon — and apk runs the post-install script, not post-upgrade, for a
package it adds. GNOME Calls 50.0 dials nothing after imsd-ofonod
restarts under it until it restarts too, so imsd-ofonod's ExecStartPost
restarts the Calls daemon of each user running one — not at the unit's
first start since boot, when a Calls that runs already is left alone.

A daemon older than 0.3.10 kept its bring-up throttle wait in memory
only, so a restart lost it. imsd's post-upgrade records that daemon's
invocation, and imsd-handover.sh, an ExecStartPre after ims-pdn-up.sh,
reads the invocation's journal and, while the fresh-SA throttle's wait
it finds there still runs (`spi-s=0`; the old daemon's other deferrals
are not handed over), writes what is left of it to the holds file when
this start's LOCAL is the address the wait was earned on, or when that
address cannot be read; when LOCAL is another address, only the verdict
that the old registration is gone.

Tested on the dev phone (KPN) in builds of this series; not every path
has run there.
2026-10-06 14:01:33 +02:00
0a91050635
imsd, media: aim call audio at logind's seat0 session, not an account named user
imsd-media defaulted AUDIO_USER to `user`, postmarketOS's default
account name, and with no such account a call had no audio on the phone
and noise at the far end, the message only in imsd-media's stderr, at
each call. A field user found it by trial (alyx report 14, 2026-09-20;
ims ledger F14).

Have imsd, as packaged (root, on the system bus), resolve the session
from logind when a call's media starts — the user session active on
seat0 (a greeter is not one) when it has a PipeWire socket — and hand it
to imsd-media as AUDIO_USER; set in imsd's own environment to an
account's name, AUDIO_USER pins that account instead. imsd as packaged
(root, on the system bus) logs the resolution at start, before the first
call, and when a call's media starts.

No session is an empty AUDIO_USER. imsd-media started without the
variable at all keeps `user`: that is an imsd older than 0.3.10, still
running while an upgrade replaces the helper under it, whose calls would
otherwise be silent until the restart, or a hand run.

Tested on the dev phone (KPN) in builds of this series; not every path
has run there.
2026-10-06 14:01:33 +02:00
7eb2deae0c
ims-pdn-up, imsd: register on the PDN's own address, not the kernel's temporary one
imsd took the first global IPv6 on the ims netdev as its address, and
the kernel lists the temporary (privacy) SLAAC address first: `temporary
dynamic`, preferred for a day and valid for two by its lifetimes (read
2026-09-24). Contact, Via and the four SAs were bound to it, so once the
kernel removes it the registration has lost its address. The address
ModemManager assigned — the one the script installs — sat third on the
list. Seen on the dev phone; a field unit's journal shows the split too
— imsd registered on an address other than the one the script installed
(ims ledger F18).

Hand the assigned address to the daemon as LOCAL through /run/imsd.env,
next to DEV. For a manual run without it, prefer a global address that
is neither `temporary` nor `dynamic`, then any non-temporary one.

A restart after the upgrade finds the registration's SAs on the old
address. It resumes there while that address is still on the link, and
moves to the PDN's address in place: with its next fresh registration,
or when the old address leaves the link first. When the address it is
registered on leaves the netdev and the PDN's own address is not there
to move to, a registered imsd exits at its next recovery step, non-zero,
for a systemd restart, which runs the PDN bring-up again.

Tested on the dev phone (KPN) in builds of this series; not every path
has run there.
2026-10-06 14:01:33 +02:00
f8fd5a3aa8
ims-pdn-up: a profile list without the ims APN is not final before the modem is registered
The profile-list query answered while ModemManager is still enabling the
modem can be partial: entries, but no ims yet. The lookup took that as
"no ims profile", fell back to the APN-string request — the one a walled
carrier refuses — and never looked again (dev phone cold boot 2026-09-24
18:18:59: "none for apn ims" at `enabling`, the ims profile listed when
read minutes later (18:19–18:25); harmless on KPN, a refused boot on O2
UK). The 0.3.8 fix covered only an empty list.

Treat a list without the APN as final only when the modem is registered
(or already connecting/connected); before that, retry it after the
registration wait like a failed lookup. A retry that still has no final
answer logs the modem state.

Tested on the dev phone (KPN) in builds of this series; not every path
has run there.
2026-10-06 14:01:33 +02:00
db848c6490
engine: keep the reg-event subscription alive and act on a terminated binding
The reg-event subscription is the network's own account of our binding
(TS 24.229 5.1.1.3), and the daemon opened a new dialog for it at every
refresh and reconnect and otherwise never looked back: a refused
SUBSCRIBE was forgotten, an unanswered one had no timeout, an ended
subscription got nothing until the next refresh's new dialog, and a
NOTIFY was logged whatever it said. O2 UK, 2026-09-24: a fresh
registration's SUBSCRIBE got 500, a NOTIFY arrived all the same and
showed our contact active, the flow died eight seconds after the 200 and
no connect over the SA succeeded after that; the 500 was logged and
nothing was done about it.

Keep the subscription alive: retry a refused or unanswered SUBSCRIBE on
a backoff, renew one the S-CSCF ends with deactivated, timeout, giveup
or probation (RFC 6665 4.1.3; after any other reason, or none, the next
refresh subscribes anew), and subscribe anew ahead of the lifetime the
S-CSCF granted. A refresh or a reconnect opens no dialog while imsd
holds one for the registration or has a retry scheduled.

Read the reginfo for our own contact and act on its state: a NOTIFY that
terminates our binding schedules a fresh registration — at once for
deactivated or expired, after the network's retry-after (up to an hour)
for probation, on imsd's own refusal schedule (21 min, growing to 60)
for rejected or unregistered (RFC 3680). A hold already running stands,
and a REGISTER wait a call kept open that still gets its 200 after such
a NOTIFY leaves that registration standing. The parser is in Imsd:Sip
and pinned by tests/Sip with the O2 document shapes; the policy is
RegRecovery's (OnBindingTerminated, added here with its tests in
tests/Engine).

Tested on the dev phone (KPN) in builds of this series; not every path
has run there.
2026-10-06 14:01:33 +02:00
142adf057c
engine: register fresh when the refresh is refused or nothing answers
After bring-up the daemon recovered a dead protected flow by
reconnecting through the existing SA and refreshing, on a 5 s → 5 min
backoff, forever. That was the whole recovery: a refresh the network
refused, or a P-CSCF that stopped answering, kept it in that loop on the
same Call-ID and SA until someone restarted the service. O2 UK,
2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min;
after a manual restart the new registration's flow died 8 s in and no
connect over its SA succeeded for 2 h 39 min; a second restart
registered within two seconds (13:27:55 → 13:27:57 in the journal; ims
ledger F16).

Do in place the rest of what a restart does: when the refresh keeps
being refused, or reconnect cycles keep ending without an answer unless
the modem reports no packet service, register fresh. The fresh
registration is the expensive step, so a failed one waits before the
next: on a schedule (21 min, growing to 60) after a refusal or a
throttle, on a backoff after silence, and a Retry-After from the network
is honoured up to an hour. What the network set — a Retry-After, the
schedule after a refused or throttled attempt — is kept across a restart
in /run/imsd.holds, for the address it was set on. A refused initial
registration at bring-up — a final from 300 up to the unprotected
REGISTER (its 401 is the challenge), any final but 200 to the protected
one, a second 401 (rejected credentials) included — now waits on the
same schedule instead of exiting for a systemd restart every two minutes.

The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like
the call machine: the daemon supplies the clock and the network, the
reducer holds the deadlines and names the next step, and tests/Engine
pin it. tests/Engine now exits 0 or 1 instead of its failure count: with
more than 255 checks a count of 256 would exit 0, and crafter-build
reads 77 as a skipped suite. The bring-up's deferral is logged in the
policy's form: `bring-up deferred: <why> — attempt N, next fresh
registration in M min` becomes `bring-up deferred: <why>; next: fresh
registration in M min`.

A bring-up writes the verdict that the old registration is gone as well:
a start that goes on to register fresh — its resume failed, or there was
nothing to resume over — records it before the attempt, and when the
attempt then fails without an answer the daemon exits with the file in
place. The next start on the same address and boot does not resume,
where 0.3.9 retried the resume: it registers fresh at once. On KPN, with
the old registration in fact still live — a start during an outage,
staged on the dev phone — that attempt was throttled, and the 21-min
schedule began. A bring-up does not retry its resume through an outage
the way recovery reconnects.

Tested on the dev phone (KPN) in builds of this series; not every path
has run there.
2026-10-06 14:01:33 +02:00
46bf363915
sip: log a refused REGISTER or SUBSCRIBE with its status line and reason headers
A refused final was logged as its bare status code ("re-REGISTER failed
503", "reg-event SUBSCRIBE -> 500") — the unprotected REGISTER's as
"expected 401", without even the code — so a field journal could not say
which node refused or what it said. O2 UK answered a phone's 30-minute
refresh with 503 twenty-one times over seventy minutes and then refused
a fresh registration's reg-event SUBSCRIBE with 500 (alyx, 2026-09-24);
the journal holds nothing but the numbers.

Log the status line with the headers that carry the reason —
Retry-After, Warning, Reason, Server — for a REGISTER's refused final
(initial, protected, refresh) and for a refused SUBSCRIBE the run loop
reads, and dump the refused message under DUMP_SIP as its own fail dump
next to the 200s.

To tell a REGISTER's final from what else arrives, the final is now
matched by Call-ID and CSeq, and a request that arrives on the client
flow while one is awaited is dispatched: a refresh no longer reads such
a request as a failed REGISTER. An INVITE among those requests is a call
like any other, announced on the bus: a Dial whose reconnect it arrived
under meets the call-in-progress rule again before it places its own,
and when a resume, a refresh or a reconnect fails the call that arrived
under it ends with the flow it came in on (486 while the flow still
carries one, and the bus told). The unprotected REGISTER's reply is
matched the same way on the init port.

Tested on the dev phone (KPN) in builds of this series; not every path
has run there.
2026-10-06 14:01:33 +02:00
35 changed files with 3252 additions and 1579 deletions

View file

@ -6,18 +6,19 @@ name: package
# plain 'apk upgrade' without an fp6-img image run. # plain 'apk upgrade' without an fp6-img image run.
# #
# Build: crafter-build cross-compiles against an Alpine aarch64 sysroot # Build: crafter-build cross-compiles against an Alpine aarch64 sysroot
# (packaging/build-package.sh — the README's "Cross-compiling" flow), the # (packaging/build-package.sh — AGENTS.md §4.3's cross-compiling flow), the
# test suite runs natively, and packaging/APKBUILD.binary wraps the result. # test suite runs natively, and packaging/APKBUILD.binary wraps the result.
# This needs no privileged runner: it runs in an alpine:edge container on # crafter-build is the Crafter.Build Flatpak, and bubblewrap inside the job
# the ordinary arch-latest runner. # container needs a privileged runner: the job runs on the `flatpak` label
# (a privileged runner registered to this repository), in an alpine:edge
# container.
# #
# Release gating is the version: pkgver comes from implementations/main.cpp, # Release gating is the version: pkgver comes from implementations/main.cpp,
# the registry answers 409 for an already-published version, and the publish # 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 # 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 # Version constant bumps. This is the only producer of the imsd apk: fp6-img
# images install the published package from the registry (pinned version, # images install the published package from the registry instead of
# pinned sha256) instead of building their own, so image and 'apk upgrade' # building their own, so image and 'apk upgrade' carry the same binary.
# carry the same binary.
# #
# Publishing needs PACKAGE_TOKEN (catbot account, package:write scope) — an # Publishing needs PACKAGE_TOKEN (catbot account, package:write scope) — an
# org-level secret on Catcrafts since 2026-09-02, shared with fp6-img; # org-level secret on Catcrafts since 2026-09-02, shared with fp6-img;
@ -30,7 +31,7 @@ on:
jobs: jobs:
package: package:
runs-on: arch-latest runs-on: flatpak
container: container:
image: alpine:edge image: alpine:edge
timeout-minutes: 90 timeout-minutes: 90

1024
AGENTS.md Normal file

File diff suppressed because it is too large Load diff

157
Makefile
View file

@ -1,157 +0,0 @@
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
#
# Distro-packaging build path. project.cpp (crafter-build) remains the
# canonical build description; this Makefile mirrors it for environments
# where crafter-build isn't packaged — same clang module pipeline, same
# flags. Keep the two in sync when products, module partitions or link
# flags change.
#
# make -> build/make/{imsd,imsd-media,imsd-dialerd,imsd-ofonod}
# make check -> build + run the 7 unit-test suites
# make install -> DESTDIR/PREFIX staged install (binaries + systemd
# units, D-Bus policies, dialerd autostart, the GNOME
# Calls plugin override, ims-pdn-up.sh)
#
# Requires clang++ with libc++ and the libc++ std module sources
# (std.cppm — Alpine: llvm-runtimes, Arch: libc++). Override STD_CPPM if
# yours lives elsewhere.
# make's builtin default is CXX=g++, and distro build environments (abuild)
# export CXX=c++ — both gcc, which has no --precompile. This build requires
# clang: the file assignment deliberately ignores the environment while still
# yielding to an explicit `make CXX=...`.
CXX = clang++
AR ?= ar
PKG_CONFIG ?= pkg-config
STD_CPPM ?= /usr/share/libc++/v1/std.cppm
O ?= build/make
PCM = $(O)/pcm
OBJ = $(O)/obj
OPT ?= -O3
# One flag set for every TU: BMIs are rejected on compiler-option mismatch,
# so per-target extras are limited to -I/-D (compile) and -l/-L (link).
ALL_CXXFLAGS = -std=c++26 -stdlib=libc++ $(OPT) $(CXXFLAGS)
ALL_LDFLAGS = -fuse-ld=lld $(LDFLAGS)
# pkg-config gio-2.0: only -I/-D pass through — it also emits -pthread,
# which would config-mismatch the shared std BMI (project.cpp filters
# identically). pthread lives in libc on every platform we target.
GIO_CFLAGS := $(filter -I% -D%,$(shell $(PKG_CONFIG) --cflags gio-2.0))
GIO_LIBS := $(filter -l% -L%,$(shell $(PKG_CONFIG) --libs gio-2.0))
ifeq ($(strip $(GIO_LIBS)),)
GIO_LIBS := -lgio-2.0 -lgobject-2.0 -lglib-2.0
endif
# Imsd module partitions, plus the primary interface last. The BMI file
# names must stay <module>-<partition>.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

335
README.md
View file

@ -5,306 +5,137 @@
Userspace IMS/VoLTE daemon for mainline Linux phones. Userspace IMS/VoLTE daemon for mainline Linux phones.
Modem firmware on typical mainline-Linux phones will bring up the IMS PDN but Most Linux phones drive the modem's internal IMS engine.
can't be used for actual call audio. imsd implements the whole IMS client in imsd implements the whole IMS client in userspace instead:
userspace instead: SIP registration with USIM AKA authentication over kernel SIP registration with USIM AKA authentication over kernel
IPsec (ESP), call signalling, and the AMR-WB RTP media plane wired into IPsec (ESP), call signalling, and the AMR-WB RTP media plane wired into
PipeWire so a stock mobile dialer — Plasma Dialer, or GNOME Calls under Phosh PipeWire so a stock mobile dialer like Plasma Dialer or GNOME Calls under Phosh
and GNOME Mobile — can place and receive real VoLTE calls on a commercial can place and receive real VoLTE calls on a commercial network.
network.
## Status ## Status
Call-capable, both directions. Outgoing VoLTE calls place, answer, carry Call-capable, both directions: outgoing calls place, answer, carry full-duplex
full-duplex AMR-WB audio, and tear down; incoming calls ring, answer via AMR-WB audio and tear down. Incoming calls ring, answer and carry media the
`Accept`, and carry media the same way — driven end-to-end from a stock same way, driven end-to-end from a stock dialer over a frozen D-Bus ABI.
dialer over the frozen D-Bus ABI. Current state: Not there yet: DTMF (`SendDtmf` is a stub), hold/multiparty/call waiting,
USSD, P-CSCF discovery (the address is configured by hand).
The engine core (`imsd-core`) is a GLib-free, pure-`std` static library whose Verified by community members to be fully working:
every decision — SIP framing, SDP selection, the AKAv1-MD5 digest, the
`ip xfrm` command sequence, and the whole call FSM — is unit-tested on a dev
box with no bus, modem, or phone; several suites pin output byte-for-byte
against recorded network traffic. GLib/GDBus, sockets, and subprocess control
live only in the `imsd` daemon shell.
## Architecture | Device | OS | Carrier |
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 | KPN NL |
| The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | Telekom Deutschland GER | | 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 | Phonero |
| The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | Telia Norge | | The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | Telia Norge |
## Build ## What it needs
[Crafter Build](https://forgejo.catcrafts.net/Catcrafts/Crafter.Build) + imsd talks to the modem through ModemManager plus `qmicli` over QRTR, installs
gio-2.0 headers (`glib2` on Arch, `glib-dev` on Alpine). its security associations with `ip xfrm` and plays audio through PipeWire, so
in practice: a Qualcomm modem on a mainline kernel with working
WWAN/ModemManager integration, kernel ESP, PipeWire, systemd, polkit and
your carrier's P-CSCF address.
```sh ## Install and configure
crafter-build # bin/imsd-<target>-<march>/imsd (the daemon)
crafter-build -- --product=media # bin/imsd-media-.../imsd-media (the media leg) For automatic updates add the Catcrafts Alpine repository. Note this does also pull in other packages.
crafter-build -- --product=dialerd # bin/imsd-dialerd-.../imsd-dialerd (Plasma backend)
crafter-build -- --product=ofonod # bin/imsd-ofonod-.../imsd-ofonod (GNOME Calls backend) Otherwise just download the latest `imsd` and `imsd-systemd` APKs and install them.
crafter-build test # unit tests (Util, Aka, Ipsec, Messages, Engine, Sip, Sdp)
Both can be found in the packages tab:
```
https://forgejo.catcrafts.net/Catcrafts/-/packages/alpine/imsd
``` ```
### Building with make (distro packaging) Configuration is environment variables in `/etc/imsd.env`; the one required
setting is the P-CSCF:
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 ```sh
make # build/make/{imsd,imsd-media,imsd-dialerd,imsd-ofonod} # your carrier's P-CSCF address, capture via: `adb shell dumpsys telephony.registry` on Android
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 <addr> 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 PCSCF=2001:db8::105
``` ```
### imsd The apk's `imsd.service` is enabled by preset and starts at boot once that
file exists (the condition is checked when the unit starts, so after writing
the file run `systemctl start imsd`). It first brings the `ims` PDN up through ModemManager, then registers:
resuming an existing registration when it can, registering fresh otherwise,
and waiting out whatever the network tells it to wait out. Check on it with:
```sh
journalctl -u imsd -f
busctl --system call net.catcrafts.IMS1 /net/catcrafts/IMS1 net.catcrafts.IMS1 GetStatus
```
## Dialers
imsd itself is desktop-agnostic: everything a dialer needs is on the system
bus as `net.catcrafts.IMS1`. A small translation daemon per desktop speaks
the backend protocol its dialer already consumes, so dialer, shell and the
call-history/contacts stack run unmodified.
| Desktop | Daemon | Package | Owns | Consumed by |
|---|---|---|---|---|
| Plasma Mobile | `imsd-dialerd` | `imsd` | `org.kde.telephony.*` (session bus) | plasma-dialer + kde-telephony-daemon, replacing `modem-daemon` |
| Phosh / GNOME Mobile | `imsd-ofonod` | `imsd-ofono` | `org.ofono` (system bus) | GNOME Calls' `ofono` provider plugin |
Plasma Mobile needs nothing beyond the `imsd` package: `imsd-dialerd` is
autostarted in the session in place of plasma-dialer's `modem-daemon`, whose
own autostart must be off — the package hides it for accounts created after
the install (`/etc/skel`); an existing account hides it itself with a
`Hidden=true` copy of `org.kde.modem.daemon.desktop` in `~/.config/autostart`.
For Phosh and GNOME Mobile, `apk add imsd-ofono` enables `imsd-ofonod` and
points GNOME Calls at its `ofono` provider (a gschema override; the per-user
equivalent is `gsettings set org.gnome.Calls autoload-plugins "['ofono']"`).
Do not run it alongside a real `ofonod`.
Call control is polkit's `net.catcrafts.IMS1.Voice`: the active local
session and root may place, answer and end calls; an ssh login may not.
## Full config:
| Variable | Default | Meaning | | Variable | Default | Meaning |
|---|---|---| |---|---|---|
| `PCSCF` | *(none — required)* | P-CSCF address; without it registration fails with a clear error | | `PCSCF` | *(none — required)* | P-CSCF address (IPv6); without it registration fails with a clear error |
| `PCSCF_PORT` | `5060` | unprotected SIP port for the initial REGISTER | | `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` | | `DEV` | `qmapmux0.0` | ims-PDN netdev; the packaged service passes the real one via `/run/imsd.env` |
| `LOCAL` | *(auto)* | UE address; default = the global IPv6 on `DEV` | | `LOCAL` | *(auto)* | UE address. The packaged service gets the PDN address ModemManager assigned, via `/run/imsd.env` from `ims-pdn-up.sh`; a manual run prefers a global IPv6 on `DEV` that is neither temporary nor `dynamic` (RA-derived), then any non-temporary one, then the first (the kernel's temporary SLAAC address is listed first, and a registration bound to it is lost when the kernel retires it) |
| `USER_AGENT` | `imsd/<version>` | REGISTER User-Agent. Some networks fingerprint UAs; setting your device's stock build string reproduces the stock modem's registration exactly. Empty omits the header | | `USER_AGENT` | `imsd/<version>` | REGISTER User-Agent. Some networks fingerprint UAs; setting your device's stock build string reproduces the stock modem's registration exactly. Empty omits the header |
| `STATE_FILE` | `/var/lib/imsd/imsreg.state` (root) / `$XDG_STATE_HOME/imsd/imsreg.state` | persisted registration context for warm resume | | `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) | | `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] | | `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 | | `EALG` | `aes-cbc` | offered ESP cipher (`aes-cbc`, `des-ede3-cbc`, `null`). From the 401's `Security-Server` list imsd selects the highest-preference `ipsec-3gpp` mechanism that matches this offer (RFC 3329 §2.3.1) and takes the SPIs/ports from the entry carrying them (some cores list every mechanism they support and put the SPIs on the one they applied); when no entry matches, the P-CSCF's SPI-carrying entry is installed as listed (`hmac-md5-96`, an absent `ealg` = null) and the journal says so. The line, the selection and the SA parameters are logged at every fresh registration |
| `SIP_TRANSPORT` | `auto` | transport of the protected leg (the second REGISTER and everything after it): `tcp`, `udp`, or `auto` = TCP, falling back to a fresh registration over UDP when the P-CSCF leaves two TCP connects unanswered — but only on a phone that has never registered over TCP: once the state file (`/var/lib/imsd/imsreg.state`) records a TCP registration, a silent connect is treated as an outage, not a policy (delete the file or set `udp` to override, e.g. after a SIM change). The challenge (first REGISTER) is always UDP. Over UDP a REGISTER or INVITE larger than ~1.2 KB leaves as IPv6 fragments at the ims PDN's 1280-byte MTU (the journal says so once per flow); there are no SIP retransmission timers over UDP yet, so a lost datagram costs a timeout. The SAs, the listener and the firewall rule cover both transports | | `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 | | `EMERGENCY_NUMBERS` | *(empty)* | comma-separated additions to the builtin 112/911 emergency numbers (e.g. a private test core's short code). SIM `EF_ECC` is not read yet. `imsd-ofonod` reads it too, for the oFono `EmergencyNumbers` property |
| `RTP_PORT` | `50004` | local RTP port advertised in SDP | | `RTP_PORT` | `50004` | local RTP port advertised in SDP |
| `PRECOND` | `0` | `1` offers SDP QoS preconditions | | `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 | | `CODECS` | *(empty — defaults)* | comma-separated codec preference list over `AMR-WB`, `AMR` (or `AMR-NB`), `PCMA`, `PCMU`: restricts and orders both the codecs offered on an outgoing call and those accepted from an inbound offer (default: offer AMR-WB + AMR, accept all four in that order). A bench knob — a network whose gateway transcodes every caller up to AMR-WB otherwise never lets the narrowband path run |
| `DUMP_SIP` | `0` | `1` writes raw REGISTER-200/SUBSCRIBE-200 dumps, the last inbound INVITE (`imsd-invite-in.raw`) and the last outgoing INVITE (`imsd-invite-out.raw`), mode 0600, for debugging — they contain your IMSI/MSISDN and addresses | | `DUMP_SIP` | `0` | `1` writes raw REGISTER-200/SUBSCRIBE-200 dumps, the last refused REGISTER or SUBSCRIBE final (`imsd-register-fail.raw`, `imsd-subscribe-fail.raw`), the last inbound INVITE (`imsd-invite-in.raw`) and the last outgoing INVITE (`imsd-invite-out.raw`), mode 0600, for debugging — they contain your IMSI/MSISDN and addresses. A refused REGISTER or SUBSCRIBE is logged with its status line and any Retry-After / Warning / Reason / Server headers |
| `DUMP_DIR`, `OUT_DIR` | state dir | where dumps / per-call media stats land | | `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`) | | `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 The protected client/server ports are fixed at 45061/45062
(`imsd::util::kPortUc`/`kPortUs`) — changing them in source also requires (`imsd::util::PortUc`/`PortUs`) — changing them in source also requires
updating the firewall rules `ims-pdn-up.sh` installs. updating the firewall rules `ims-pdn-up.sh` installs.
### imsd-media (per-call, set through imsd's environment) ### imsd-media (per-call, set through imsd's environment)
| Variable | Default | Meaning | | Variable | Default | Meaning |
|---|---|---| |---|---|---|
| `MIC`, `PLAY` | `1`, `1` | uplink mic capture / downlink playout via PipeWire | | `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` | `user` | desktop user whose PipeWire session carries call audio (postmarketOS default account) | | `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 | | `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-WB encoder mode (0–8) | | `AMR_MODE` | `2` | AMR encoder mode of whichever AMR codec is active, as imsd passes it (12.65 kbit/s for AMR-WB; `imsd-media` started by hand defaults to 2 for AMR-WB and 7 for AMR) |
| `DTX` | `0` | encoder discontinuous transmission | | `DTX` | `0` | encoder discontinuous transmission |
| `MEDIA_TIMEOUT` | `6.0` | seconds of downlink silence before exit 3 (far-end-hangup signal) | | `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 | | `RTP_DUMP` | `0` | `1` captures raw downlink RTP next to the stats file |
## D-Bus ABI ## D-Bus ABI
`net.catcrafts.IMS1` at `/net/catcrafts/IMS1`. The ABI is frozen — dialer `net.catcrafts.IMS1` at `/net/catcrafts/IMS1`. The ABI is frozen.
integrations depend on it:
| Member | Signature | | Member | Signature |
|---|---| |---|---|
@ -319,8 +150,8 @@ integrations depend on it:
| `CallDeleted` | `s` | | `CallDeleted` | `s` |
| `RegistrationChanged` | `b` | | `RegistrationChanged` | `b` |
Both `imsd-dialerd` and `imsd-ofonod` are pure translations of this ABI; a new Both `imsd-dialerd` and `imsd-ofonod` are users of this ABI.
desktop needs a new translation daemon, not changes here. A new desktop needs a new translation daemon, not changes here.
## License ## License

File diff suppressed because it is too large Load diff

View file

@ -410,41 +410,56 @@ std::size_t RtpPayloadOffset(std::span<const std::uint8_t> pkt) {
return off; return off;
} }
// The desktop user's uid, resolved ONCE and thread-safely. getpwnam() hands // The desktop session whose PipeWire instance carries call audio, resolved
// out one static buffer, and the mic and playout threads spawn their pw // ONCE and thread-safely: getpwnam() hands out one static buffer, and the
// helpers at the same instant — the race sent both into /run/user/0, where // mic and playout threads spawn their pw helpers at the same instant — the
// no PipeWire session lives, and a call was answered into static (dev phone, // race sent both into /run/user/0, where no PipeWire session lives, and a
// 2026-09-08 20:01: tx_mic=0 rx_played=0 qdrop=883). Logged per call so a // call was answered into static (dev phone, 2026-09-08 20:01: tx_mic=0
// journal shows which session the helpers were aimed at. // rx_played=0 qdrop=883). AUDIO_USER names the account: imsd sets it per
uid_t AudioUid() { // call from logind's seat0 session (or its own AUDIO_USER), and sets it
// empty when nobody is logged in. The packaged default used to be
// postmarketOS's account name `user`: a unit whose account was called
// otherwise got no audio and no message (alyx report 14, 2026-09-20).
// That default stays for a caller that does not set the variable at all —
// an imsd older than 0.3.10, still running while an upgrade replaces this
// binary, or a hand run — so its calls keep their audio.
// Logged per call so a journal shows which session the helpers were aimed at.
struct AudioSession { uid_t uid = 0; std::string name; };
const AudioSession& Session() {
static std::once_flag once; static std::once_flag once;
static uid_t uid = 0; static AudioSession s;
std::call_once(once, [] { std::call_once(once, [] {
std::string user = EnvOr("AUDIO_USER", "user");
passwd pw{}; passwd pw{};
passwd* res = nullptr; passwd* res = nullptr;
char buf[4096]; char buf[4096];
if (getpwnam_r(user.c_str(), &pw, buf, sizeof buf, &res) == 0 && res) uid = res->pw_uid; const char* given = std::getenv("AUDIO_USER");
std::string want = given ? given : "user";
if (!given) std::println(std::cerr, "imsd-media: no AUDIO_USER in the environment (a caller older than 0.3.10, or a hand run); trying the account `user`");
if (want.empty())
std::println(std::cerr, "imsd-media: AUDIO_USER empty (no session to aim at); the helpers run as uid 0 (no audio)");
else if (getpwnam_r(want.c_str(), &pw, buf, sizeof buf, &res) == 0 && res) { s.uid = res->pw_uid; s.name = want; }
else else
std::println(std::cerr, "imsd-media: AUDIO_USER '{}' not found; no PipeWire session for uid 0", user); std::println(std::cerr, "imsd-media: AUDIO_USER '{}' is not an account; the helpers run as uid 0 (no audio)", want);
std::println(std::cerr, "imsd-media: audio session: {} -> /run/user/{}", user, uid); std::println(std::cerr, "imsd-media: audio session: {} -> /run/user/{}", s.name.empty() ? std::string("(none)") : s.name, s.uid);
}); });
return uid; return s;
} }
// Spawn pw-record/pw-play in the desktop user's PipeWire session (sudo -u // Spawn pw-record/pw-play in the desktop session's PipeWire instance (sudo -u
// when run as root). AUDIO_USER names the session owner; the default "user" // when run as root; as ourselves when there is no session to aim at).
// is postmarketOS's standard account. `toChild` true = we write the child's // `toChild` true = we write the child's stdin (pw-play); false = we read its
// stdin (pw-play); false = we read its stdout (pw-record). Returns {pid, fd}. // stdout (pw-record). Returns {pid, fd}.
struct Child { pid_t pid = -1; int fd = -1; }; struct Child { pid_t pid = -1; int fd = -1; };
Child SpawnPw(bool play, bool toChild, int rate) { Child SpawnPw(bool play, bool toChild, int rate) {
int pipefd[2]; int pipefd[2];
if (pipe(pipefd) != 0) return {}; if (pipe(pipefd) != 0) return {};
std::vector<std::string> argv; std::vector<std::string> argv;
if (geteuid() == 0) { if (geteuid() == 0) {
std::string user = EnvOr("AUDIO_USER", "user"); const AudioSession& session = Session();
argv = {"sudo", "-u", user, "env", if (!session.name.empty())
std::format("XDG_RUNTIME_DIR=/run/user/{}", AudioUid())}; argv = {"sudo", "-u", session.name, "env",
std::format("XDG_RUNTIME_DIR=/run/user/{}", session.uid)};
} }
const char* tool = play ? "pw-play" : "pw-record"; const char* tool = play ? "pw-play" : "pw-record";
const char* lat = play ? "40ms" : "20ms"; const char* lat = play ? "40ms" : "20ms";

View file

@ -45,11 +45,12 @@ plugins/provider/ofono/ — get these wrong and the plugin silently ignores us:
Runs as root because `org.ofono` is a system-bus name (policy in 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 packaging/org.ofono.conf). It does no I/O beyond D-Bus: every call decision
stays in imsd. Everything happens on the GLib main loop, so there is no stays in imsd. Its calls reach imsd as root, so it asks polkit about its own
locking. callers (ImsdBus): call control is net.catcrafts.IMS1.Voice, as on imsd.
Everything happens on the GLib main loop, so there is no locking.
--session own org.ofono on the session bus and talk to imsd there --session own org.ofono on the session bus and talk to imsd there
(development: no root, no policy file) (development: no root, no policy file, no polkit)
IMSD_BUS=session|system override only the imsd side of that choice IMSD_BUS=session|system override only the imsd side of that choice
*/ */
@ -57,6 +58,7 @@ locking.
#include <glib-unix.h> #include <glib-unix.h>
import std; import std;
import ImsdBus;
namespace { namespace {
@ -315,7 +317,9 @@ void HandleCallMethod(GDBusConnection*, const gchar*, const gchar* objectPath, c
g_dbus_method_invocation_return_dbus_error(inv, "org.freedesktop.DBus.Error.UnknownMethod", "no such method"); g_dbus_method_invocation_return_dbus_error(inv, "org.freedesktop.DBus.Error.UnknownMethod", "no such method");
} }
const GDBusInterfaceVTable CallVtable = { HandleCallMethod, nullptr, nullptr, {} }; bool IsRead(std::string_view method) { return method == "GetProperties" || method == "GetCalls" || method == "GetModems"; }
constexpr char ErrAccessDenied[] = "org.ofono.Error.AccessDenied";
const GDBusInterfaceVTable CallVtable = { imsd::bus::Gated<HandleCallMethod, IsRead, ErrAccessDenied, Log>, nullptr, nullptr, {} };
guint RegisterObject(const char* xml, const char* path, const GDBusInterfaceVTable* vtable) { guint RegisterObject(const char* xml, const char* path, const GDBusInterfaceVTable* vtable) {
GDBusNodeInfo* node = g_dbus_node_info_new_for_xml(xml, nullptr); GDBusNodeInfo* node = g_dbus_node_info_new_for_xml(xml, nullptr);
@ -596,9 +600,9 @@ void HandleSsMethod(GDBusConnection*, const gchar*, const gchar*, const gchar*,
} }
const GDBusInterfaceVTable ManagerVtable = { HandleManagerMethod, nullptr, nullptr, {} }; const GDBusInterfaceVTable ManagerVtable = { HandleManagerMethod, nullptr, nullptr, {} };
const GDBusInterfaceVTable ModemVtable = { HandleModemMethod, nullptr, nullptr, {} }; const GDBusInterfaceVTable ModemVtable = { imsd::bus::Gated<HandleModemMethod, IsRead, ErrAccessDenied, Log>, nullptr, nullptr, {} };
const GDBusInterfaceVTable VcmVtable = { HandleVcmMethod, nullptr, nullptr, {} }; const GDBusInterfaceVTable VcmVtable = { imsd::bus::Gated<HandleVcmMethod, IsRead, ErrAccessDenied, Log>, nullptr, nullptr, {} };
const GDBusInterfaceVTable SsVtable = { HandleSsMethod, nullptr, nullptr, {} }; const GDBusInterfaceVTable SsVtable = { imsd::bus::Gated<HandleSsMethod, IsRead, ErrAccessDenied, Log>, nullptr, nullptr, {} };
// ---- imsd events ------------------------------------------------------------ // ---- imsd events ------------------------------------------------------------
void PublishVoiceState() { void PublishVoiceState() {
@ -727,6 +731,7 @@ int main(int argc, char** argv) {
if (std::string_view(argv[i]) == "--session") session = true; if (std::string_view(argv[i]) == "--session") session = true;
GBusType ofonoBus = session ? G_BUS_TYPE_SESSION : G_BUS_TYPE_SYSTEM; GBusType ofonoBus = session ? G_BUS_TYPE_SESSION : G_BUS_TYPE_SYSTEM;
imsd::bus::AuthorizeCalls = !session;
std::string imsdBusName = EnvOr("IMSD_BUS", session ? "session" : "system"); std::string imsdBusName = EnvOr("IMSD_BUS", session ? "session" : "system");
GBusType imsdBus = imsdBusName == "session" ? G_BUS_TYPE_SESSION : G_BUS_TYPE_SYSTEM; GBusType imsdBus = imsdBusName == "session" ? G_BUS_TYPE_SESSION : G_BUS_TYPE_SYSTEM;

View file

@ -3,7 +3,9 @@
// lint-disable-file fixed-width-types // lint-disable-file fixed-width-types
/* /*
Imsd:Engine — the call state machine as a pure reducer. Imsd:Engine — the daemon's decisions as pure reducers: the call state
machine (CallMachine) and the registration recovery policy (RegRecovery,
its own section below).
A CallMachine owns one call's SIP dialog, outgoing or incoming. It never 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 touches a socket, a clock, or a subprocess: each event (the INVITE's
@ -52,6 +54,215 @@ export namespace imsd::engine {
return d == Direction::Incoming ? "incoming" : "outgoing"; 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<double>(RetryAfterMax)));
if (h.freshHold > 0) freshHoldUntil = std::max(freshHoldUntil, now + std::min(h.freshHold, static_cast<double>(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<double>(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<double>(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<double>(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 // 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). // imsd-media's: "AMR-WB", "AMR", "PCMA", "PCMU" (empty = none we play).
struct MediaLeg { struct MediaLeg {

View file

@ -400,4 +400,17 @@ export namespace imsd::ipsec {
sa.securityServer = std::format("ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=aes-cbc; " "spi-c={}; spi-s={}; port-c={}; port-s={}", sa.spiPc, sa.spiPs, sa.portPc, sa.portPs); 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; 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<std::string> SaLocals(std::string_view stateText, std::string_view pcscf) {
std::vector<std::string> 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;
}
} }

View file

@ -121,6 +121,93 @@ export namespace imsd::sip {
return v; 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<long> 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
// <contact> whose <uri> carries `user` as its SIP user part, across every
// <registration> 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:<user>@… / sips:<user>@…
std::size_t pos = 0;
while ((pos = reginfo.find("<contact", pos)) != npos) {
std::size_t tagEnd = reginfo.find('>', pos);
if (tagEnd == npos) break;
std::string_view tag = reginfo.substr(pos, tagEnd - pos);
std::size_t end = reginfo.find("</contact>", 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("<uri>");
if (u == npos) continue;
std::size_t ue = body.find("</uri>", 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 // 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 // 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 // the caller should reply to the request's source port instead

106
interfaces/ImsdBus.cppm Normal file
View file

@ -0,0 +1,106 @@
// SPDX-License-Identifier: GPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// lint-disable-file fixed-width-types no-char-pointer
/*
ImsdBus — what the two system-bus daemons (imsd, imsd-ofonod) share on the
bus side: the polkit gate in front of call control.
The bus policies let any local account reach both names, so the session
account can dial whatever it is called; whether a caller may place, answer
or end a call is polkit's net.catcrafts.IMS1.Voice
(packaging/net.catcrafts.IMS1.policy), shaped like ModemManager's Voice
action: the active local session and root, not an ssh login, an inactive
session or a system service account. Reads (status, the call list) stay
open, as ModemManager's are to every local account; the call signals reach
them all anyway.
GLib, so not part of the GLib-free imsd-core.
*/
module;
#include <gio/gio.h>
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<Pending> p(static_cast<Pending*>(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 <GDBusInterfaceMethodCallFunc Body, bool (*IsRead)(std::string_view), const char* DeniedError, void (*Log)(std::string_view)>
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

View file

@ -1,858 +0,0 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
#pragma once
// This repo's house-style lint rules. Deliberately project-local — the rule
// set is NOT part of the crafter-build library, so downstream consumers are
// never nudged toward one team's style. Include from project.cpp AFTER
// `import std;` and `import Crafter.Build;` (the header uses both and has no
// includes of its own), then call AddProjectLintRules(cfg).
//
// Report-only rules are registered first so their file:line numbers always
// match the on-disk file; transforms run last because each one sees the
// previous transform's output, which can shift line numbers.
//
// All detection runs on ctx.CommentStripped() — comments and string/char
// literal bodies are blanked to spaces with newlines preserved, so byte
// offsets and line numbers in the stripped text are valid in ctx.content.
// Transforms use that property directly: find in stripped, edit in content.
namespace ProjectLint {
inline bool IsCppFile(const Crafter::LintContext& ctx) {
std::string ext = ctx.file.extension().string();
return ext == ".cpp" || ext == ".cppm" || ext == ".h";
}
inline std::vector<std::string_view> Lines(std::string_view text) {
std::vector<std::string_view> 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<std::string_view> lines = Lines(ctx.CommentStripped());
enum class Scope { Namespace, Type, Function, Other };
std::vector<Scope> 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<std::string_view> 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<std::int64_t>(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<std::size_t>(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<std::string_view> 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<std::string_view> 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<std::string_view> 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<std::string_view> 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<Edit> 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<std::string_view> 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<Rep> 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<std::string_view> 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<std::string_view> 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<std::string_view> stripped = Lines(ctx.CommentStripped());
std::vector<std::string> 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<std::string_view> stripped = Lines(ctx.CommentStripped());
static const std::regex ifHeader(R"(^\s*(?:\}?\s*else\s+)?if\s*\(.*\)\s*$)");
std::vector<std::string> 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<std::string_view> stripped = Lines(ctx.CommentStripped());
std::vector<std::string> 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<std::pair<std::size_t, std::size_t>> 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

View file

@ -1,54 +0,0 @@
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
# Maintainer: Jorijn van der Graaf <jorijnvdgraaf@catcrafts.net>
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
}

View file

@ -2,11 +2,12 @@
# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® # SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
# Maintainer: Jorijn van der Graaf <jorijnvdgraaf@catcrafts.net> # Maintainer: Jorijn van der Graaf <jorijnvdgraaf@catcrafts.net>
# Binary packaging: wraps a crafter-build binary cross-compiled per the # Binary packaging: wraps a crafter-build binary cross-compiled per the
# README's "Cross-compiling" section into a proper apk — used by this repo's # cross-compiling flow of AGENTS.md §4.3 into a proper apk — used by this repo's
# package CI (packaging/build-package.sh, which also seds pkgver from # package CI (packaging/build-package.sh, which also seds pkgver from
# implementations/main.cpp) and runnable by hand. The source tarball is # implementations/main.cpp) and runnable by hand. The source tarball is
# produced by packaging/make-bin-tarball.sh; the desktop/preset files live # produced by packaging/make-bin-tarball.sh; the desktop/preset files and
# next to it in packaging/ (copy them beside this file). Since 2026-09-02 # the install scripts (*.post-upgrade, *.post-install) live next to it in
# packaging/ (copy them beside this file). Since 2026-09-02
# this is the only imsd packaging: fp6-img images install the apk this # this is the only imsd packaging: fp6-img images install the apk this
# builds from the registry rather than building their own. # builds from the registry rather than building their own.
pkgname=imsd pkgname=imsd
@ -16,8 +17,9 @@ pkgdesc="Userspace IMS/VoLTE daemon for mainline Linux phones"
url="https://forgejo.catcrafts.net/Catcrafts/imsd" url="https://forgejo.catcrafts.net/Catcrafts/imsd"
arch="aarch64" arch="aarch64"
license="GPL-3.0-only" license="GPL-3.0-only"
# the media leg dlopen's the AMR-WB codecs; pw-record/pw-play drive PipeWire # the media leg dlopen's the AMR-WB codecs; pw-record/pw-play drive PipeWire;
depends="modemmanager libc++ opencore-amr vo-amrwbenc pipewire-tools" # polkit answers the daemons' call-control check (ImsdBus)
depends="modemmanager libc++ opencore-amr vo-amrwbenc pipewire-tools polkit"
# versioned provides = imsd satisfies soc-qcom-modem's 81voltd dependency AND # versioned provides = imsd satisfies soc-qcom-modem's 81voltd dependency AND
# excludes the real package: 81voltd serves the modem's own ims-PDN requests, # 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 # which races imsd for the PDN and flaps it with a new prefix every ~2.5 min
@ -29,6 +31,9 @@ provides="81voltd=$pkgver-r$pkgrel"
# (ofono before systemd: systemd() amoves all of usr/lib/systemd/system, # (ofono before systemd: systemd() amoves all of usr/lib/systemd/system,
# so ofono() must take imsd-ofonod.service first) # so ofono() must take imsd-ofonod.service first)
subpackages="$pkgname-ofono:ofono $pkgname-systemd" 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" options="!check !tracedeps"
source=" source="
imsd-$pkgver.tar.gz imsd-$pkgver.tar.gz
@ -42,11 +47,14 @@ package() {
install -Dm755 imsd-media "$pkgdir"/usr/libexec/imsd-media install -Dm755 imsd-media "$pkgdir"/usr/libexec/imsd-media
install -Dm755 imsd-dialerd "$pkgdir"/usr/bin/imsd-dialerd install -Dm755 imsd-dialerd "$pkgdir"/usr/bin/imsd-dialerd
install -Dm755 ims-pdn-up.sh "$pkgdir"/usr/libexec/ims-pdn-up.sh 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.service "$pkgdir"/usr/lib/systemd/system/imsd.service
install -Dm644 imsd-dialerd.desktop \ install -Dm644 imsd-dialerd.desktop \
"$pkgdir"/etc/xdg/autostart/imsd-dialerd.desktop "$pkgdir"/etc/xdg/autostart/imsd-dialerd.desktop
install -Dm644 net.catcrafts.IMS1.conf \ install -Dm644 net.catcrafts.IMS1.conf \
"$pkgdir"/usr/share/dbus-1/system.d/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 # the skel override hides kde-telephony's modem daemon autostart for the
# account created at install — imsd-dialerd owns those session bus names # account created at install — imsd-dialerd owns those session bus names
install -Dm644 "$srcdir"/org.kde.modem.daemon.desktop \ install -Dm644 "$srcdir"/org.kde.modem.daemon.desktop \
@ -64,6 +72,8 @@ package() {
printf '[Unit]\nConditionPathExists=/etc/imsd.env\n' \ printf '[Unit]\nConditionPathExists=/etc/imsd.env\n' \
> "$pkgdir"/usr/lib/systemd/system/imsd.service.d/10-require-config.conf > "$pkgdir"/usr/lib/systemd/system/imsd.service.d/10-require-config.conf
install -Dm755 imsd-ofonod "$pkgdir"/usr/bin/imsd-ofonod 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 \ install -Dm644 imsd-ofonod.service \
"$pkgdir"/usr/lib/systemd/system/imsd-ofonod.service "$pkgdir"/usr/lib/systemd/system/imsd-ofonod.service
install -Dm644 org.ofono.conf \ install -Dm644 org.ofono.conf \
@ -78,6 +88,7 @@ ofono() {
pkgdesc="GNOME Calls (Phosh, GNOME Mobile) backend for imsd" pkgdesc="GNOME Calls (Phosh, GNOME Mobile) backend for imsd"
depends="$pkgname=$pkgver-r$pkgrel calls" depends="$pkgname=$pkgver-r$pkgrel calls"
amove usr/bin/imsd-ofonod amove usr/bin/imsd-ofonod
amove usr/libexec/imsd-ofono-restart-calls.sh
amove usr/lib/systemd/system/imsd-ofonod.service amove usr/lib/systemd/system/imsd-ofonod.service
amove usr/share/dbus-1/system.d/imsd-ofono.conf amove usr/share/dbus-1/system.d/imsd-ofono.conf
amove usr/share/glib-2.0/schemas/90_imsd-ofono.gschema.override amove usr/share/glib-2.0/schemas/90_imsd-ofono.gschema.override

View file

@ -2,47 +2,31 @@
# SPDX-License-Identifier: GPL-3.0-only # SPDX-License-Identifier: GPL-3.0-only
# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® # SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
# CI package build: cross-compile imsd for aarch64 with crafter-build (the # CI package build: cross-compile imsd for aarch64 with crafter-build (the
# README's "Cross-compiling" flow), run the test suite natively, and package # cross-compiling flow of AGENTS.md §4.3), run the test suite natively, and package
# the result via packaging/APKBUILD.binary. Expects an x86_64 Alpine # the result via packaging/APKBUILD.binary. Expects an x86_64 Alpine
# environment with root — the workflow runs it in an alpine:edge job # environment with root — the workflow runs it in an alpine:edge job
# container on an ordinary runner. Root only installs packages and hands off # container on the privileged `flatpak` runner: crafter-build is the
# to a scratch user: the sysroot is built with apk.static --usermode (which # Crafter.Build Flatpak, and bubblewrap inside the container needs the
# refuses root) and abuild wants a user too. # privilege. Root only installs packages and hands off to a scratch user: the
# sysroot is built with apk.static --usermode (which refuses root) and abuild
# wants a user too.
# #
# Built packages land in /home/build/.local/share/abuild/*/aarch64/imsd*.apk; # Built packages land in /home/build/.local/share/abuild/*/aarch64/imsd*.apk;
# the workflow's publish step uploads them to the Forgejo Alpine registry. # the workflow's publish step uploads them to the Forgejo Alpine registry.
set -eu set -eu
# The musl build of crafter-build (Crafter.Build CI's release-musl job): # crafter-build is the Crafter.Build Flatpak: it carries its own clang, lld
# this container is Alpine, and the glibc launcher cannot run on musl. v2 = # and libc++, and its SDK has glib, so nothing that compiles is installed
# SSE4.2 baseline: the CI box is an Intel N5105 (no AVX). Overridable for # here. `stable` is the branch Crafter.Build publishes from its release tags
# local rehearsals (file:// works). # (`beta` follows every push to its master), so the toolchain of a release
CRAFTER_URL=${CRAFTER_URL:-https://forgejo.catcrafts.net/Catcrafts/Crafter.Build/releases/download/latest/crafter-build-linux-x86_64-musl-v2.tar.gz} # moves only with a release of the toolchain. The branch is overridable
# for a rehearsal against another one.
CRAFTER_REMOTE=${CRAFTER_REMOTE:-https://flatpak.catcrafts.net/catcrafts.flatpakrepo}
CRAFTER_REF=${CRAFTER_REF:-net.catcrafts.CrafterBuild//stable}
SRC=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) 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 <description> <cmd...> retry() { # retry <description> <cmd...>
_desc=$1; shift _desc=$1; shift
for _i in 1 2 3; do for _i in 1 2 3; do
@ -54,26 +38,49 @@ retry() { # retry <description> <cmd...>
return 1 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 # implementations/main.cpp is the version's single source of truth (same
# derivation as make-bin-tarball.sh) # derivation as make-bin-tarball.sh)
VER=$(sed -n 's/.*char\* Version = "\(.*\)".*/\1/p' "$SRC/implementations/main.cpp") 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; } [ -n "$VER" ] || { echo "cannot read Version from implementations/main.cpp" >&2; exit 1; }
echo ">> packaging imsd $VER" echo ">> packaging imsd $VER"
# --- crafter-build: static launcher from the rolling release # --- crafter-build: the Flatpak, under the name the rest of this file uses.
mkdir -p "$HOME/crafter-build" # The build log names the build it ran with.
retry "fetch crafter-build" \ mkdir -p "$HOME/bin"
sh -c "curl -fsSL '$CRAFTER_URL' | tar -xz -C '$HOME/crafter-build'" printf '#!/bin/sh\nexec flatpak run %s "$@"\n' "$CRAFTER_REF" > "$HOME/bin/crafter-build"
PATH="$HOME/crafter-build/bin:$PATH" chmod +x "$HOME/bin/crafter-build"
export CRAFTER_BUILD_HOME="$HOME/crafter-build/share/crafter-build" PATH="$HOME/bin:$PATH"
flatpak info "$CRAFTER_REF" | sed -n 's/^ *\(Version\|Commit\|Date\): */crafter-build \1: /p'
crafter-build --version
# --- aarch64 Alpine sysroot (unprivileged: apk.static --usermode) # --- aarch64 Alpine sysroot (unprivileged: apk.static --usermode)
SYSROOT="$HOME/.cache/imsd/sysroot-aarch64-alpine" SYSROOT="$HOME/.cache/imsd/sysroot-aarch64-alpine"
retry "make sysroot" "$SRC/packaging/make-sysroot.sh" "$SYSROOT" retry "make sysroot" "$SRC/packaging/make-sysroot.sh" "$SYSROOT"
# --- cross-compile the products; run the suites natively (same sources, # --- cross-compile the products; run the suites natively, inside the Flatpak
# host arch — the aarch64 binaries are additionally exercised by fp6-img's # (same sources, host arch: no suite runs on the aarch64 binaries)
# chroot `make check` whenever an image builds)
cd "$SRC" cd "$SRC"
# The phone is one SoC, 4x Cortex-A520 + 4x Cortex-A720, so build for it. # 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 # armv8.6-a is the highest level whose mandatory set it exposes (8.7 would
@ -94,7 +101,7 @@ rm -rf "$PKG"
mkdir -p "$PKG" mkdir -p "$PKG"
cp "$SRC/packaging/APKBUILD.binary" "$PKG/APKBUILD" cp "$SRC/packaging/APKBUILD.binary" "$PKG/APKBUILD"
cp "$SRC/packaging/org.kde.modem.daemon.desktop" \ cp "$SRC/packaging/org.kde.modem.daemon.desktop" \
"$SRC/packaging/80-imsd.preset" "$PKG/" "$SRC/packaging/80-imsd.preset" "$SRC"/packaging/*.post-upgrade "$SRC"/packaging/*.post-install "$PKG/"
mv "imsd-$VER.tar.gz" "$PKG/" mv "imsd-$VER.tar.gz" "$PKG/"
sed -i "s/^pkgver=.*/pkgver=$VER/" "$PKG/APKBUILD" sed -i "s/^pkgver=.*/pkgver=$VER/" "$PKG/APKBUILD"
# a throwaway signing key: phones trust the registry-signed APKINDEX, not # a throwaway signing key: phones trust the registry-signed APKINDEX, not

View file

@ -39,6 +39,12 @@ bearer_paths() {
kv -m "$MODEM" | sed -n 's/^modem\.generic\.bearers\.value\[[0-9]*\] *: *//p' 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 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 '"' sed -n "s/^$1=//p" /etc/imsd.env 2>/dev/null | tail -n1 | tr -d '"'
} }
@ -65,34 +71,49 @@ log "modem $MODEM"
# lookup itself can fail while ModemManager is still enabling the modem; a # 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 # 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 # back to is the one the modem refuses under an IPv4 attach), so it is
# retried after the registration wait and only a list without the APN # retried after the registration wait; a list without the APN is final —
# falls back to the APN string. # the APN string is used — only once the modem is registered.
LOOKUP=pending LOOKUP=pending
if [ -n "$PROFILE_ID" ]; then if [ -n "$PROFILE_ID" ]; then
[ "$PROFILE_ID" = none ] && PROFILE_ID= [ "$PROFILE_ID" = none ] && PROFILE_ID=
LOOKUP=done LOOKUP=done
fi fi
lookup_profile() { # $1 = log suffix; sets PROFILE_ID, LOOKUP=done on a usable list lookup_profile() { # $1 = modem state, $2 = log suffix; sets PROFILE_ID, LOOKUP=done on a final answer
[ "$LOOKUP" = done ] && return 0 [ "$LOOKUP" = done ] && return 0
if OUT=$(qmicli -d qrtr://0 --wds-get-profile-list=3gpp 2>&1); then 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 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 # 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) # 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 if ! printf '%s\n' "$OUT" | grep -q -E '^[[:space:]]*\[[0-9]+\] 3gpp'; then
log "ims profile lookup returned no profiles$1" log "ims profile lookup returned no profiles$2"
return 0 return 0
fi fi
PROFILE_ID=$(printf '%s\n' "$OUT" | awk -v apn="$IMS_APN" ' PROFILE_ID=$(printf '%s\n' "$OUT" | awk -v apn="$IMS_APN" '
/^[ \t]*\[[0-9]+\] 3gpp/ { idx = $1; gsub(/[^0-9]/, "", idx) } /^[ \t]*\[[0-9]+\] 3gpp/ { idx = $1; gsub(/[^0-9]/, "", idx) }
/APN:/ { a = $0; sub(/.*APN: '"'"'/, "", a); sub(/'"'"'.*/, "", a); /APN:/ { a = $0; sub(/.*APN: '"'"'/, "", a); sub(/'"'"'.*/, "", a);
if (tolower(a) == tolower(apn) && idx != "") { print idx; exit } }') if (tolower(a) == tolower(apn) && idx != "") { print idx; exit } }')
LOOKUP=done if [ -n "$PROFILE_ID" ]; then
[ -n "$PROFILE_ID" ] && log "ims profile: index $PROFILE_ID (apn $IMS_APN)" || log "ims profile: none for apn $IMS_APN, connecting by APN string" LOOKUP=done
log "ims profile: index $PROFILE_ID (apn $IMS_APN)"
return 0
fi
# a list without the APN is final only once the modem is registered:
# while it enables, the list can be partial (dev phone cold boot
# 2026-09-24 18:18: entries but no ims at `enabling`, ims listed when
# read minutes later), and on a walled carrier the APN-string fallback
# that would follow is the request the modem refuses
case "$1" in
registered|connecting|connected)
LOOKUP=done
log "ims profile: none for apn $IMS_APN, connecting by APN string" ;;
*) log "ims profile: not in the list yet$2" ;;
esac
else else
log "ims profile lookup failed$1: $(squash "$OUT")" log "ims profile lookup failed$2: $(squash "$OUT")"
fi fi
} }
lookup_profile " (modem state: $(modem_state))" STATE=$(modem_state)
lookup_profile "$STATE" " (modem state: $STATE)"
# ---- find a connected ims bearer; else find-or-create one and connect it. # ---- 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 # APN names are case-insensitive. A DISCONNECTED bearer is reused only when
@ -150,6 +171,10 @@ if [ -z "$BEARER" ]; then
[ "$STATE" != "$last" ] && log "waiting for registration (state: ${STATE:-unknown})" [ "$STATE" != "$last" ] && log "waiting for registration (state: ${STATE:-unknown})"
last=$STATE last=$STATE
if [ "$waited" -ge "$REG_TIMEOUT" ]; then 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" log "not registered after $REG_TIMEOUT s — attempting anyway"
break break
fi fi
@ -157,12 +182,14 @@ if [ -z "$BEARER" ]; then
waited=$((waited + 5)) waited=$((waited + 5))
done done
log "modem state: $(modem_state), packet service: $(packet_state)" log "modem state: $(modem_state), packet service: $(packet_state)"
# a lookup that failed while the modem was still coming up # a lookup with no final answer yet: it failed, or its list lacked the
# APN while the modem was still coming up
n=0 n=0
while [ "$LOOKUP" != done ] && [ "$n" -lt 3 ]; do while [ "$LOOKUP" != done ] && [ "$n" -lt 3 ]; do
n=$((n + 1)) n=$((n + 1))
sleep 5 sleep 5
lookup_profile " (retry $n)" STATE=$(modem_state)
lookup_profile "$STATE" " (retry $n, modem state: $STATE)"
done done
if [ "$LOOKUP" != done ]; then if [ "$LOOKUP" != done ]; then
PROFILE_ID= PROFILE_ID=
@ -176,6 +203,7 @@ n=0
while [ -z "$BEARER" ]; do while [ -z "$BEARER" ]; do
n=$((n + 1)) n=$((n + 1))
[ "$n" -gt 10 ] && { log "bearer connect failed after 10 attempts"; exit 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 B=$(find_ims_bearer no) # reuse a stale disconnected ims bearer of this run's shape
if [ -z "$B" ]; then if [ -z "$B" ]; then
if [ -n "$PROFILE_ID" ]; then if [ -n "$PROFILE_ID" ]; then
@ -280,9 +308,13 @@ case "$PCSCF" in
*) log "PCSCF '$PCSCF' is not an IPv6 address; the ims PDN is brought up IPv6-only"; exit 1 ;; *) log "PCSCF '$PCSCF' is not an IPv6 address; the ims PDN is brought up IPv6-only"; exit 1 ;;
esac esac
# ---- hand the connected ims netdev to imsd (imsd.service reads # ---- hand the connected ims netdev and the PDN's own address to imsd
# /run/imsd.env) so the daemon carries no baked-in interface name # (imsd.service reads /run/imsd.env): the daemon carries no baked-in
echo "DEV=$IFACE" > /run/imsd.env # interface name and registers on the address the network assigned — not
# on the kernel's temporary SLAAC address from the RA, which the netdev
# lists first and which is deprecated after a day and removed after two
# (ims ledger F18; the SAs and the Contact would go with it)
printf 'DEV=%s\nLOCAL=%s\n' "$IFACE" "$ADDR" > /run/imsd.env
# ---- open the IMS protected ports in the firewall (rung 5b root cause, # ---- 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 # journal/ims.md s56): pmOS's default nftables INPUT chain is policy-drop and

83
packaging/imsd-handover.sh Executable file
View file

@ -0,0 +1,83 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
#
# ExecStartPre of imsd.service, after ims-pdn-up.sh: hand a daemon older
# than 0.3.10 its bring-up throttle wait over. Such a daemon kept the wait
# in memory only, so the restart an upgrade makes registered fresh at once
# instead of waiting the schedule out. imsd.post-upgrade
# records the running daemon's invocation in /run/imsd.handover; here, with
# that daemon stopped and its journal final, the invocation's last bring-up
# line is read and, when it is the old throttle's `bring-up deferred:
# fresh-SA throttle active (spi-s=0) — … next fresh registration in
# N min`, what is left of the N min is written to
# the holds file (/run/imsd.holds, or HOLDS_FILE) the way 0.3.10 keeps
# it — with the verdict that the old registration is gone: that line
# follows a throttled initial REGISTER, at which KPN drops the
# registration, so the new start does not resume over the written-off SAs.
# The old daemon deferred the same way after a 401 it could not use (no
# ipsec-3gpp mechanism, an unsupported algorithm); nothing says the
# network dropped the registration then, and that wait is not handed over.
# The wait itself is handed over only for the address it was earned on
# (the old daemon logged its LOCAL once at start): KPN's refusal is the
# address's — a fresh pair is refused where a registration is live or has
# just gone — and on the normal hop the old daemon sat on the kernel's
# temporary address while this start registers on the PDN's own, which
# holds none: then only the verdict is written and the new start registers
# fresh at once (should the network refuse it there, the daemon's own
# schedule takes over from its start: the old daemon's place in it was
# the other address's).
# When the old address cannot be read the wait is handed over anyway.
# Reading at the start, not at the upgrade, means a wait that
# ended between the two — the old daemon making another attempt — is
# read as it ended.
set -u
marker=/run/imsd.handover
[ -r "$marker" ] || exit 0
inv=$(cat "$marker"); rm -f "$marker"
[ -n "$inv" ] || exit 0
holds=${HOLDS_FILE:-/run/imsd.holds} # the holds file as imsd reads it (HoldsPath)
[ -e "$holds" ] && exit 0 # a 0.3.10 or later daemon kept its own holds
line=$(journalctl -u imsd.service "_SYSTEMD_INVOCATION_ID=$inv" -o json --no-pager 2>/dev/null \
| grep -E '"MESSAGE" *: *"imsd: (bring-up deferred: |REGISTERED|FRESH registration|a hold the network)' | tail -n 1)
case "$line" in *'imsd: bring-up deferred: fresh-SA throttle active (spi-s=0)'*) ;; *) exit 0 ;; esac
mins=$(printf '%s\n' "$line" | sed -n 's/.*next fresh registration in \([0-9][0-9]*\) min.*/\1/p')
# the line's age on the monotonic clock (journald stamps every entry with it;
# /proc/timer_list reads it now), which no clock step moves — this phone's
# wall clock reads 1970 until its first NTP sync. It stops in a suspend, so
# the age can only come out short and the wait handed over long, never
# short of the network's window.
at=$(printf '%s\n' "$line" | sed -n 's/.*"__MONOTONIC_TIMESTAMP" *: *"\([0-9][0-9]*\)".*/\1/p')
now=$(sed -n 's/^now at \([0-9][0-9]*\) nsecs$/\1/p' /proc/timer_list 2>/dev/null | head -n 1)
if [ -z "$mins" ] || [ -z "$at" ] || [ -z "$now" ]; then
echo "imsd-handover: the previous daemon waited for a fresh registration, but its wait could not be timed: NOT handed over"
exit 0
fi
left=$((mins * 60 - (now / 1000000000 - at / 1000000)))
[ "$left" -gt 0 ] || exit 0
# the address this start registers on, as systemd hands it to imsd: the unit
# reads its EnvironmentFile= lines anew for every command it spawns, so LOCAL
# here is the /etc/imsd.env pin when there is one, else the one ims-pdn-up.sh
# just wrote to /run/imsd.env — unquoted by systemd's parser, the same bytes
# imsd compares the holds file's pdn with. The file read is for a run outside
# the unit.
pdn=${LOCAL:-$(sed -n 's/^LOCAL=//p' /run/imsd.env 2>/dev/null | tail -n 1)}
if [ -z "$pdn" ]; then
echo "imsd-handover: the previous daemon waited $mins min for a fresh registration, but this start has no LOCAL: NOT handed over"
exit 0
fi
# the address the wait was earned on: the old daemon's own start line
old=$(journalctl -u imsd.service "_SYSTEMD_INVOCATION_ID=$inv" -o cat --no-pager 2>/dev/null \
| sed -n 's/^imsd: LOCAL=\([^ ]*\) P-CSCF=.*/\1/p' | tail -n 1)
boot=$(cat /proc/sys/kernel/random/boot_id); now_s=$(cut -d. -f1 /proc/uptime)
if [ -n "$old" ] && [ "$old" != "$pdn" ]; then
printf '{"pdn": "%s", "boot": "%s", "written_at": %s, "hold": 0, "fresh_hold": 0, "old_gone": true}\n' \
"$pdn" "$boot" "$now_s" > "$holds" \
&& echo "imsd-handover: the previous daemon's fresh-registration wait ($left s of $mins min left) was earned on $old, not this start's $pdn: only the verdict that its registration is gone is handed over"
exit 0
fi
tm=$((mins * 3 / 2)); [ "$tm" -gt 60 ] && tm=60
printf '{"pdn": "%s", "boot": "%s", "written_at": %s, "hold": 0, "fresh_hold": %s, "throttle_minutes": %s, "old_gone": true}\n' \
"$pdn" "$boot" "$now_s" "$left" "$tm" > "$holds" \
&& echo "imsd-handover: the previous daemon's fresh-registration wait ($left s of $mins min left) handed over for $pdn$([ -n "$old" ] && echo " (its own address)" || echo "; its address could not be read")"
exit 0

View file

@ -0,0 +1,24 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
#
# ExecStartPost of imsd-ofonod.service. GNOME Calls 50.0 keeps a stale modem
# when org.ofono vanishes and dials nothing until it is restarted (AGENTS.md
# §8.3, Known gaps), so every start of imsd-ofonod after org.ofono went away
# restarts the Calls daemon of each user running one. Its first start since
# boot is not one: no Calls has seen org.ofono go, and a Calls that runs
# already (a login during the bring-up) is left alone. The unit's own
# timestamp tells the first start from a restart (an upgrade from 0.3.9 is
# a restart); the marker covers a stop and a later start.
marker=/run/imsd-ofonod.started
if [ ! -e "$marker" ] && [ "$(systemctl show -P ActiveExitTimestampMonotonic imsd-ofonod.service)" = 0 ]; then
: > "$marker"
echo "imsd-ofono-restart-calls: first start since boot, Calls left alone"
exit 0
fi
: > "$marker"
for uid in $(systemctl list-units 'user@*.service' --legend=no --plain | sed -n 's/^user@\([0-9]*\)\.service.*/\1/p'); do
echo "imsd-ofono-restart-calls: restarting the Calls daemon of uid $uid, if it runs"
systemctl --user -M "$uid@" try-restart --no-block calls-daemon.service || :
done
exit 0

View file

@ -0,0 +1 @@
imsd-ofono.post-upgrade

View file

@ -0,0 +1,21 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
#
# As imsd.post-upgrade, for imsd-ofonod: restarted once apk is done, and
# its ExecStartPost restarts GNOME Calls with it. Also installed as
# imsd-ofono.post-install, for a package added again over the daemon an
# `apk del` left running.
# A unit whose file an apk del removed is not-found to systemd until a
# reload and refuses properties ("failed to load properly"); its file is
# back on disk by the time post-install runs, so reload and mark again.
mark() {
systemctl set-property "$1" Markers=+needs-restart 2>/dev/null && return 0
systemctl daemon-reload && systemctl set-property "$1" Markers=+needs-restart || :
}
if [ -d /run/systemd/system ] && systemctl is-active -q imsd-ofonod.service; then
mark imsd-ofonod.service
fi
exit 0

View file

@ -3,8 +3,11 @@
[Unit] [Unit]
Description=oFono-compatible GNOME Calls backend for imsd Description=oFono-compatible GNOME Calls backend for imsd
Documentation=https://forgejo.catcrafts.net/Catcrafts/imsd Documentation=https://forgejo.catcrafts.net/Catcrafts/imsd
# org.ofono is a system-bus name, so this is a system service; it needs no # org.ofono is a system-bus name only root may own (org.ofono.conf), so this
# privileges beyond owning that name. Only enable it on a GNOME/Phosh session # is a system service run as root: imsd admits the calls it forwards as
# root's (it has asked polkit about its own caller), and its start hook
# restarts GNOME Calls in the users' own managers. Only enable it on a
# GNOME/Phosh session
# — Plasma Mobile uses imsd-dialerd instead. Both may run at once (different # — Plasma Mobile uses imsd-dialerd instead. Both may run at once (different
# buses, different consumers), but a real ofonod must not. # buses, different consumers), but a real ofonod must not.
Conflicts=ofono.service Conflicts=ofono.service
@ -17,6 +20,9 @@ Type=simple
# matches imsd's configuration; imsd itself owns emergency classification. # matches imsd's configuration; imsd itself owns emergency classification.
EnvironmentFile=-/etc/imsd.env EnvironmentFile=-/etc/imsd.env
ExecStart=/usr/bin/imsd-ofonod 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 # it holds no call state of its own — imsd is re-queried on every reconnect
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5

View file

@ -0,0 +1 @@
imsd-systemd.post-upgrade

View file

@ -0,0 +1,23 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
#
# imsd.service ships in this subpackage, which apk installs after imsd.
# When imsd is added back over a daemon an `apk del` left running,
# imsd.post-install runs before this unit file is on disk and cannot mark
# the unit (not-found to systemd), so the package that brings the file
# marks it too; on an upgrade both mark it, which is one marker.
# Also installed as imsd-systemd.post-install.
# A unit whose file an apk del removed is not-found to systemd until a
# reload and refuses properties ("failed to load properly"); the file is
# on disk by now, so reload and mark again.
mark() {
systemctl set-property "$1" Markers=+needs-restart 2>/dev/null && return 0
systemctl daemon-reload && systemctl set-property "$1" Markers=+needs-restart || :
}
if [ -d /run/systemd/system ] && systemctl is-active -q imsd.service; then
mark imsd.service
fi
exit 0

1
packaging/imsd.post-install Symbolic link
View file

@ -0,0 +1 @@
imsd.post-upgrade

48
packaging/imsd.post-upgrade Executable file
View file

@ -0,0 +1,48 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
#
# Restart imsd onto the upgraded binary once apk is done: the systemd
# trigger restarts every unit marked needs-restart (enqueue-marked). A
# changed bus policy is live the moment apk writes it (dbus-broker-launch
# reloads on its own), so it must not be left facing the old daemon. The
# restart resumes the registration over its existing SAs, or goes on
# waiting when the network had imsd wait; a call in progress ends.
#
# Also installed as imsd.post-install: `apk del imsd` removes the files
# and leaves the daemon running, so a later `apk add` of the package is
# this upgrade for that daemon, and apk runs post-install for it. A first
# install finds no unit active and marks nothing. On the binary route
# (APKBUILD.binary) the unit file ships in imsd-systemd, installed after
# this package; its imsd-systemd.post-upgrade marks the unit then.
#
# A daemon older than 0.3.10 kept its bring-up throttle wait in memory
# only, so a restart inside it registered fresh at once instead of
# waiting the schedule out. Before marking, record the running
# invocation in /run/imsd.handover: the next start (imsd-handover.sh, an
# ExecStartPre after ims-pdn-up.sh) reads that invocation's last bring-up
# line once the daemon has stopped and hands what is left of its wait over
# as imsd's holds file (on another address only the verdict that its
# registration is gone). Read then, not here: a wait that ends between this
# script and the restart at the end of the transaction — the old daemon
# making another attempt — is read as it ended.
# A unit whose file an apk del removed is not-found to systemd until a
# reload and refuses properties ("failed to load properly"); its file is
# back on disk by the time post-install runs, so reload and mark again.
mark() {
systemctl set-property "$1" Markers=+needs-restart 2>/dev/null && return 0
systemctl daemon-reload && systemctl set-property "$1" Markers=+needs-restart || :
}
record_old_invocation() {
[ -e /run/imsd.holds ] && return 0 # a 0.3.10 or later daemon keeps its own holds
inv=$(systemctl show -P InvocationID imsd.service 2>/dev/null)
[ -n "$inv" ] && echo "$inv" > /run/imsd.handover
}
if [ -d /run/systemd/system ] && systemctl is-active -q imsd.service; then
record_old_invocation
mark imsd.service
fi
exit 0

View file

@ -13,10 +13,16 @@ Type=simple
# idempotent. worst case ~14 min (2 min modem wait + 5 min registration # idempotent. worst case ~14 min (2 min modem wait + 5 min registration
# gate + connect retries), hence TimeoutStartSec # gate + connect retries), hence TimeoutStartSec
ExecStartPre=/usr/libexec/ims-pdn-up.sh 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 TimeoutStartSec=900
# /run/imsd.env: written by ims-pdn-up.sh (DEV= the connected ims netdev). # /run/imsd.env: written by ims-pdn-up.sh (DEV= the connected ims netdev,
# LOCAL= the PDN address ModemManager assigned to it).
# /etc/imsd.env: admin configuration — PCSCF= is REQUIRED (the carrier's # /etc/imsd.env: admin configuration — PCSCF= is REQUIRED (the carrier's
# P-CSCF address; see the README's Configuration section) and wins over the # P-CSCF address; see AGENTS.md §10, or the README) and wins over the
# runtime-derived values. # runtime-derived values.
EnvironmentFile=-/run/imsd.env EnvironmentFile=-/run/imsd.env
EnvironmentFile=-/etc/imsd.env EnvironmentFile=-/etc/imsd.env
@ -24,7 +30,8 @@ EnvironmentFile=-/etc/imsd.env
StateDirectory=imsd StateDirectory=imsd
ExecStart=/usr/bin/imsd ExecStart=/usr/bin/imsd
# registration: resume an existing SA when possible (avoids fresh-SA # registration: resume an existing SA when possible (avoids fresh-SA
# throttles); a failed bring-up exits nonzero and systemd retries # throttles); a failed bring-up exits nonzero and systemd retries, as does
# a registered imsd whose PDN address left the netdev
Restart=on-failure Restart=on-failure
RestartSec=120 RestartSec=120
# SIGTERM: engine keeps the SA + registration state for the next resume # SIGTERM: engine keeps the SA + registration state for the next resume

View file

@ -24,10 +24,12 @@ cp "$BIN" "$stage/imsd-$VER/imsd"
cp "$MEDIA" "$stage/imsd-$VER/imsd-media" cp "$MEDIA" "$stage/imsd-$VER/imsd-media"
cp "$DIALERD" "$stage/imsd-$VER/imsd-dialerd" cp "$DIALERD" "$stage/imsd-$VER/imsd-dialerd"
cp "$OFONOD" "$stage/imsd-$VER/imsd-ofonod" cp "$OFONOD" "$stage/imsd-$VER/imsd-ofonod"
cp packaging/ims-pdn-up.sh packaging/imsd.service \ cp packaging/ims-pdn-up.sh packaging/imsd-handover.sh packaging/imsd.service \
packaging/imsd-dialerd.desktop packaging/imsd-ofonod.service \ packaging/imsd-dialerd.desktop packaging/imsd-ofonod.service \
packaging/imsd-ofono-restart-calls.sh \
packaging/org.ofono.conf packaging/90_imsd-ofono.gschema.override \ packaging/org.ofono.conf packaging/90_imsd-ofono.gschema.override \
packaging/80-imsd-ofono.preset \ packaging/80-imsd-ofono.preset \
packaging/net.catcrafts.IMS1.conf "$stage/imsd-$VER/" packaging/net.catcrafts.IMS1.conf packaging/net.catcrafts.IMS1.policy \
"$stage/imsd-$VER/"
tar -C "$stage" -czf "imsd-$VER.tar.gz" "imsd-$VER" tar -C "$stage" -czf "imsd-$VER.tar.gz" "imsd-$VER"
echo "wrote imsd-$VER.tar.gz ($(du -h "imsd-$VER.tar.gz" | cut -f1))" echo "wrote imsd-$VER.tar.gz ($(du -h "imsd-$VER.tar.gz" | cut -f1))"

View file

@ -8,7 +8,8 @@
# clang can use via --sysroot. No root, no qemu, no device needed: # clang can use via --sysroot. No root, no qemu, no device needed:
# #
# packaging/make-sysroot.sh [dir] # default: ~/.cache/imsd/sysroot-aarch64-alpine # packaging/make-sysroot.sh [dir] # default: ~/.cache/imsd/sysroot-aarch64-alpine
# crafter-build --target=aarch64-alpine-linux-musl --sysroot=<dir> # crafter-build -- --target=aarch64-alpine-linux-musl --sysroot=<dir> \
# --march=… --mtune=… # the phone's values: AGENTS.md §4.3
set -eu set -eu
SYSROOT="${1:-$HOME/.cache/imsd/sysroot-aarch64-alpine}" SYSROOT="${1:-$HOME/.cache/imsd/sysroot-aarch64-alpine}"
@ -16,8 +17,13 @@ MIRROR="${MIRROR:-https://dl-cdn.alpinelinux.org/alpine/edge}"
ARCH=aarch64 ARCH=aarch64
# gcc is in the list for its crtbeginS.o/libgcc: clang's driver for # 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 # *-alpine-linux-musl links against the GCC runtime it detects under
# <sysroot>/usr/lib/gcc/<triple>/ # <sysroot>/usr/lib/gcc/<triple>/. llvm-runtimes is in it for the std module
PKGS="musl-dev libc++ libc++-dev glib-dev compiler-rt llvm-libunwind-dev gcc" # source of this very libc++ (usr/share/libc++/v1/std.cppm; libc++-dev ships
# none): crafter-build compiles the sysroot's own copy first. --upgrade below
# is for a run over an existing sysroot: added alone beside libc++ 22,
# llvm-runtimes 23's source did not compile against its headers; upgraded
# with it, it did.
PKGS="musl-dev libc++ libc++-dev llvm-runtimes glib-dev compiler-rt llvm-libunwind-dev gcc"
work=$(mktemp -d) work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT trap 'rm -rf "$work"' EXIT
@ -40,6 +46,6 @@ mkdir -p "$SYSROOT"
--arch "$ARCH" --root "$SYSROOT" --initdb --no-scripts --no-cache \ --arch "$ARCH" --root "$SYSROOT" --initdb --no-scripts --no-cache \
--usermode --keys-dir "$work/usr/share/apk/keys/$ARCH" \ --usermode --keys-dir "$work/usr/share/apk/keys/$ARCH" \
-X "$MIRROR/main" -X "$MIRROR/community" \ -X "$MIRROR/main" -X "$MIRROR/community" \
add $PKGS add --upgrade $PKGS
echo ">> done: $SYSROOT" echo ">> done: $SYSROOT"

View file

@ -11,12 +11,13 @@
<allow send_destination="net.catcrafts.IMS1"/> <allow send_destination="net.catcrafts.IMS1"/>
<allow receive_sender="net.catcrafts.IMS1"/> <allow receive_sender="net.catcrafts.IMS1"/>
</policy> </policy>
<!-- the phone session user drives calls via the imsdialerd shim --> <!-- Any local account may reach imsd: the dialer shim runs as the session
<policy user="user"> account, whatever it is called. imsd itself decides who may place,
answer or end a call — polkit's net.catcrafts.IMS1.Voice
(net.catcrafts.IMS1.policy: the active local session). Owning the name
stays root's. -->
<policy context="default">
<allow send_destination="net.catcrafts.IMS1"/> <allow send_destination="net.catcrafts.IMS1"/>
<allow receive_sender="net.catcrafts.IMS1"/> <allow receive_sender="net.catcrafts.IMS1"/>
</policy> </policy>
<policy context="default">
<deny send_destination="net.catcrafts.IMS1"/>
</policy>
</busconfig> </busconfig>

View file

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?> <!--*-nxml-*-->
<!-- SPDX-License-Identifier: GPL-3.0-only -->
<!-- SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® -->
<!DOCTYPE policyconfig PUBLIC "-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
"http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
<!-- Call control on imsd (net.catcrafts.IMS1) and imsd-ofonod (org.ofono):
both daemons ask polkit about this action before they place, answer or
end a call for a caller. The defaults are ModemManager's Voice action as
the permissive polkit build ships it (the strict build has
allow_active auth_self_keep): the active local session, not an ssh
login, an inactive session or a system service account. root is always
allowed.
Install: /usr/share/polkit-1/actions/net.catcrafts.IMS1.policy -->
<policyconfig>
<vendor>Catcrafts</vendor>
<vendor_url>https://forgejo.catcrafts.net/Catcrafts/imsd</vendor_url>
<action id="net.catcrafts.IMS1.Voice">
<description>Place, answer and end voice calls over IMS</description>
<message>System policy prevents voice calls.</message>
<defaults>
<allow_any>no</allow_any>
<allow_inactive>no</allow_inactive>
<allow_active>yes</allow_active>
</defaults>
</action>
</policyconfig>

View file

@ -15,20 +15,20 @@
<allow own="org.ofono"/> <allow own="org.ofono"/>
<!-- imsd (root) reaches this daemon only by broadcast signals, and <!-- imsd (root) reaches this daemon only by broadcast signals, and
dbus-broker checks a broadcast against the sender's send policy for dbus-broker checks a broadcast against the sender's send policy for
every name the receiver owns (dbus-daemon does not): without this every name the receiver owns (dbus-daemon does not). The default
line the default deny below silently drops every CallAdded / below allows sending too since 0.3.10; this line stays so that a
CallStateChanged from imsd on the way to imsd-ofonod (found on the tighter default can never again silently drop every CallAdded /
FP6, 2026-09-20). --> CallStateChanged from imsd on the way to imsd-ofonod, as the old
default deny did (found on the FP6, 2026-09-20). -->
<allow send_destination="org.ofono"/> <allow send_destination="org.ofono"/>
</policy> </policy>
<!-- the session user's GNOME Calls drives the modem; call control is <!-- Any local account's GNOME Calls may reach imsd-ofonod, whatever the
exactly what net.catcrafts.IMS1 already grants it through the dialerd --> session account is called; imsd-ofonod asks polkit before call control
<policy user="user"> (net.catcrafts.IMS1.Voice, as imsd does). Owning the name stays
root's. -->
<policy context="default">
<deny own="org.ofono"/>
<allow send_destination="org.ofono"/> <allow send_destination="org.ofono"/>
<allow receive_sender="org.ofono"/> <allow receive_sender="org.ofono"/>
</policy> </policy>
<policy context="default">
<deny own="org.ofono"/>
<deny send_destination="org.ofono"/>
</policy>
</busconfig> </busconfig>

View file

@ -4,7 +4,8 @@
// lint-disable-file no-char-pointer // lint-disable-file no-char-pointer
import std; import std;
import Crafter.Build; import Crafter.Build;
#include "lint-rules.h" #pragma crafter dependency "https://forgejo.catcrafts.net/Catcrafts/Crafter.LintRules.git" commit "e906f797da2cd0b1776dd1b420f9b04d3cd24baf"
import Crafter.LintRules;
namespace fs = std::filesystem; namespace fs = std::filesystem;
using namespace Crafter; using namespace Crafter;
@ -57,6 +58,24 @@ static void ApplyGioFlags(Configuration& cfg) {
} }
} }
// ImsdBus — the polkit gate imsd and imsd-ofonod put in front of call
// control. A static library of its own: GLib, so outside imsd-core.
static std::unique_ptr<Configuration> BusLibrary(std::span<const std::string_view> args) {
auto bus = std::make_unique<Configuration>();
bus->path = "./";
bus->name = "imsd-bus";
bus->outputName = "imsd-bus";
ApplyStandardArgs(*bus, args);
bus->type = ConfigurationType::LibraryStatic;
{
std::array<fs::path, 1> ifaces = { "interfaces/ImsdBus" };
std::array<fs::path, 0> impls = {};
bus->GetInterfacesAndImplementations(ifaces, impls);
}
ApplyGioFlags(*bus);
return bus;
}
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) { extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
// Four executables come out of this repo; which one a build produces is // Four executables come out of this repo; which one a build produces is
// chosen by `--product=`: // chosen by `--product=`:
@ -70,7 +89,8 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
// (pure GDBus translation, no core). // (pure GDBus translation, no core).
// ofonod — imsd-ofonod, the system-bus GNOME Calls backend // ofonod — imsd-ofonod, the system-bus GNOME Calls backend
// (org.ofono.*) bridging to imsd, for Phosh and // (org.ofono.*) bridging to imsd, for Phosh and
// GNOME Mobile (pure GDBus translation, no core). // GNOME Mobile (GDBus translation plus the imsd-bus
// polkit gate on its callers, no core).
bool wantMedia = false; bool wantMedia = false;
bool wantDialerd = false; bool wantDialerd = false;
bool wantOfonod = false; bool wantOfonod = false;
@ -86,13 +106,15 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
ofonod.outputName = "imsd-ofonod"; ofonod.outputName = "imsd-ofonod";
ApplyStandardArgs(ofonod, args); ApplyStandardArgs(ofonod, args);
ofonod.type = ConfigurationType::Executable; ofonod.type = ConfigurationType::Executable;
static auto Bus = BusLibrary(args);
ofonod.dependencies = { Bus.get() };
{ {
std::array<fs::path, 0> ifaces = {}; std::array<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "implementations/ofonod" }; std::array<fs::path, 1> impls = { "implementations/ofonod" };
ofonod.GetInterfacesAndImplementations(ifaces, impls); ofonod.GetInterfacesAndImplementations(ifaces, impls);
} }
ApplyGioFlags(ofonod); ApplyGioFlags(ofonod);
ProjectLint::AddProjectLintRules(ofonod); LintRules::AddRules(ofonod);
return ofonod; return ofonod;
} }
if (wantDialerd) { if (wantDialerd) {
@ -108,7 +130,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
dialerd.GetInterfacesAndImplementations(ifaces, impls); dialerd.GetInterfacesAndImplementations(ifaces, impls);
} }
ApplyGioFlags(dialerd); ApplyGioFlags(dialerd);
ProjectLint::AddProjectLintRules(dialerd); LintRules::AddRules(dialerd);
return dialerd; return dialerd;
} }
if (wantMedia) { if (wantMedia) {
@ -125,7 +147,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
} }
media.linkFlags.push_back("-ldl"); // dlopen the AMR-WB codecs media.linkFlags.push_back("-ldl"); // dlopen the AMR-WB codecs
media.linkFlags.push_back("-lpthread"); // std::jthread playout/mic media.linkFlags.push_back("-lpthread"); // std::jthread playout/mic
ProjectLint::AddProjectLintRules(media); LintRules::AddRules(media);
return media; return media;
} }
@ -161,7 +183,8 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
cfg.outputName = "imsd"; cfg.outputName = "imsd";
ApplyStandardArgs(cfg, args); ApplyStandardArgs(cfg, args);
cfg.type = ConfigurationType::Executable; cfg.type = ConfigurationType::Executable;
cfg.dependencies = { Core.get() }; static auto Bus = BusLibrary(args);
cfg.dependencies = { Core.get(), Bus.get() };
{ {
std::array<fs::path, 0> ifaces = {}; std::array<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "implementations/main" }; std::array<fs::path, 1> impls = { "implementations/main" };
@ -179,7 +202,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
cfg.AddTest("Sip").Dependencies({ Core.get() }); cfg.AddTest("Sip").Dependencies({ Core.get() });
cfg.AddTest("Sdp").Dependencies({ Core.get() }); cfg.AddTest("Sdp").Dependencies({ Core.get() });
ProjectLint::AddProjectLintRules(cfg); LintRules::AddRules(cfg);
return cfg; return cfg;
} }

View file

@ -11,7 +11,12 @@
// reject -> 486, remote CANCEL -> 200+487, unusable offer -> 488 (naming // reject -> 486, remote CANCEL -> 200+487, unusable offer -> 488 (naming
// what was offered), a landline gateway's G.711-only and AMR-NB offers // 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 -> // ring and are answered at the offer's own payload types, ring timeout ->
// 480, BYE both ways. Pure reducer, no I/O. // 480, BYE both ways. Then RegRecovery, the registration recovery policy:
// the refusal and silence thresholds, local refusals, the reconnect and
// fresh backoffs, the throttle schedule, Retry-After, which waits a Dial
// may cut short, when the old registration counts as gone, the holds a
// restart takes over (Holds/Restore), and a binding the network terminated
// (OnBindingTerminated). Pure reducers, no I/O.
import std; import std;
import Imsd; import Imsd;
@ -854,6 +859,248 @@ int main() {
Check(can && !can->text.contains("001010123456789@ims"), "CANCEL carries no IMSI"); 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"); if (Failures == 0) std::println("Engine: all tests passed");
return Failures; return Failures != 0; // not the count: an exit status keeps 8 bits, and this suite has more checks than that
} }

View file

@ -80,6 +80,14 @@ int main() {
} }
// incomplete input -> nullopt // incomplete input -> nullopt
Check(!ParseExistingSa("src a dst b\n\tspi 0x1 reqid 1 mode transport\n", "", "b", "a").has_value(), "missing pieces -> 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 // ---- fresh setup command sequence
{ {

View file

@ -196,6 +196,98 @@ int main() {
Check(!m4.has_value(), "absurd Content-Length: discarded, no overflow"); 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<contact id=\"{}\" state=\"{}\"{}>\n"
"\t\t\t<uri>sip:{}@[2a03:db8::1]:45062</uri>\n"
"\t\t\t<unknown-param name=\"+sip.instance\">&quot;&lt;urn:gsma:imei:35901961-000000-0&gt;&quot; </unknown-param>\n"
"\t\t</contact>\n", id, state, extra, user);
};
auto reginfo = [](std::string_view contacts) {
return std::format(
"<?xml version=\"1.0\"?>\n"
"<reginfo xmlns=\"urn:ietf:params:xml:ns:reginfo\" version=\"1\" state=\"full\">\n"
"\t<registration aor=\"sip:+447700900000@example.net\" id=\"2\" state=\"active\">\n{0}\t</registration>\n"
"\t<registration aor=\"tel:+447700900000\" id=\"3\" state=\"active\">\n{0}\t</registration>\n"
"</reginfo>\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 =
"<reginfo version=\"2\" state=\"partial\">\n<registration aor=\"tel:+1\" id=\"3\" state=\"active\">\n" + contact("4", "terminated", " event=\"expired\"", me) +
"</registration>\n<registration aor=\"sip:+1@x\" id=\"2\" state=\"active\">\n" + contact("4", "active", "", me) + "</registration>\n</reginfo>";
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("<reginfo><registration><contact id=\"1\" state=\"active\"><uri>sip:aaaaaaaa@x</uri></contact>", me).state == S::NotListed, "a user part that is only a prefix of ours does not match");
Check(RegInfoContactState("<contact state=\"terminated\" event=\"expired\"><uri>sip:" + me + "@h</uri>", me).state == S::Terminated, "an unterminated element still parses");
}
if (Failures == 0) std::println("Sip: all tests passed"); if (Failures == 0) std::println("Sip: all tests passed");
return Failures; return Failures;
} }