diff --git a/AGENTS.md b/AGENTS.md index a01b265..2a8715b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,10 +23,7 @@ and GNOME Mobile — places and receives real VoLTE calls on a commercial network through it. It talks to the modem through ModemManager (`mmcli`) and `qmicli` over QRTR -(the USIM AKA run goes through a UIM logical channel) and, for the P-CSCF list -the network put in the ims PDN's PCO, through its own QMI WDS client -(`imsd-pcscf`, a raw QRTR socket bound to the bearer's mux data port — §13), -installs its security +(the USIM AKA run goes through a UIM logical channel), installs its security associations with `ip xfrm`, and plays and records audio with the PipeWire tools (`pw-play`, `pw-record`). In practice that means a Qualcomm modem on a mainline kernel with working WWAN/ModemManager integration, kernel ESP, and @@ -37,22 +34,20 @@ PipeWire. The verified device/carrier combinations are in the README. | Path | What it is | |---|---| | `project.cpp` | The canonical build description, for [Crafter Build](https://forgejo.catcrafts.net/Catcrafts/Crafter.Build). Chooses the product by `--product=`. | -| `interfaces/Imsd.cppm` | The primary module interface of `imsd-core`: `export import`s the eight partitions below. | -| `interfaces/Imsd-Util.cppm` | `imsd::util` — SIP tokens, IPv6 host/port formatting, the RFC 5952 §4 IPv6 text (`CanonicalIp6`/`CanonicalAddr`; an IPv4-mapped address spelled in hex stays hex, where §5 and `inet_ntop` write it dotted) and the comma-separated address-list parser (`ParseAddressList`), hex/base64, MD5 (RFC 1321), the fixed protected ports `PortUc`/`PortUs` (45061/45062). | +| `interfaces/Imsd.cppm` | The primary module interface of `imsd-core`: `export import`s the seven partitions below. | +| `interfaces/Imsd-Util.cppm` | `imsd::util` — SIP tokens, IPv6 host/port formatting, hex/base64, MD5 (RFC 1321), the fixed protected ports `PortUc`/`PortUs` (45061/45062). | | `interfaces/Imsd-Sip.cppm` | `imsd::sip` — SIP as a text layer plus TCP framing: header extraction, status codes, the granted lifetime, `ResponseOf` transaction matching. Not a full RFC 3261 parser, deliberately. | -| `interfaces/Imsd-Sdp.cppm` | `imsd::sdp` — SDP offer/answer: the AMR-WB + AMR + telephone-event offer (optional IR.92 preconditions; `OfferedTelephoneEventPt` names its telephone-event numbers), the parser the media plane needs (the codec, its payload type and mode, and the telephone-event payload type the m-line lists at that codec's clock), `BuildAnswer` for the terminating side, the `CODECS` override. | +| `interfaces/Imsd-Sdp.cppm` | `imsd::sdp` — SDP offer/answer: the AMR-WB + AMR + telephone-event offer (optional IR.92 preconditions), the parser the media plane needs, `BuildAnswer` for the terminating side, the `CODECS` override. | | `interfaces/Imsd-Aka.cppm` | `imsd::aka` — USIM AKA glue: the AKAv1-MD5 digest (RFC 3310), IMPI/IMPU/realm from IMSI + MCC/MNC, parsers for `qmicli --uim-get-card-status` and `mmcli` output, the AUTHENTICATE APDU and its response. | -| `interfaces/Imsd-Ipsec.cppm` | `imsd::ipsec` — the two ESP SA pairs (TS 33.203) as `ip xfrm` argv lists, the Security-Server mechanism selection, and the reader that reconstructs SPIs/ports from `ip xfrm state`/`policy` text for a warm resume, and (1.0.0) `SaPeer`, the P-CSCF the kernel's pair is to, read from the `dir out` policies that carry imsd's fingerprint, `OwnXfrmDeleteCommands`, the deletes by exact key of what two rules select — the policies carrying imsd's fingerprint and the reqid-1/2 transport-mode ESP states in their direction or on the UE's ims-PDN address, whoever installed them (§12), and `FileNamesAnotherPair`, whether a state file's `spi_uc`/`spi_us` name the kernel's pair (unknown on either side decides nothing). | +| `interfaces/Imsd-Ipsec.cppm` | `imsd::ipsec` — the two ESP SA pairs (TS 33.203) as `ip xfrm` argv lists, the Security-Server mechanism selection, and the reader that reconstructs SPIs/ports from `ip xfrm state`/`policy` text for a warm resume. | | `interfaces/Imsd-Messages.cppm` | `imsd::msg` — every SIP request and response imsd sends, built from an explicit `Context` and per-request variables so the builders are pure functions; byte-pinned by `tests/Messages`. Also `IsEmergency`. | | `interfaces/Imsd-Engine.cppm` | `imsd::engine` — the decisions as pure reducers: `CallMachine` (one call's dialog, returning `Action`s for the shell) and `RegRecovery` (the registration recovery policy, §7.4). | -| `interfaces/Imsd-Qmi.cppm` | `imsd::qmi` — the QMI WDS request bytes `imsd-pcscf` sends (Bind Mux Data Port, Set IP Family, Get Packet Service Status, Get Current Settings, Get Profile Settings), the message/TLV parser, the P-CSCF list readers (0x22 flag, 0x23 IPv4, 0x24 FQDN, 0x2E IPv6 — the last one libqmi does not model). | | `interfaces/ImsdBus.cppm` | `imsd::bus` — the polkit gate in front of call control, shared by imsd and imsd-ofonod. GLib, so outside `imsd-core`. | | `implementations/main.cpp` | The `imsd` daemon shell: GLib main loop, GDBus service, sockets, subprocesses, the registration sequence and the run loop that pumps the core. | -| `implementations/media.cpp` | `imsd-media`, the per-call RTP leg, DTMF included (RFC 4733 events out, queued by `dtmf ` lines on its stdin; the far end's events in, played as tones). Pure std + POSIX, dlopen's the AMR codecs. | -| `implementations/pcscf.cpp` | `imsd-pcscf`, the P-CSCF reader: the AF_QIPCRTR socket, the WDS service lookup and the Bind Mux Data Port / Set IP Family / Get Current Settings sequence around `Imsd:Qmi` (plus Get Profile Settings with `--profile`, Get Packet Service Status with `-v`). std + POSIX + the Linux QRTR UAPI (``), no GLib; never Start/Stop Network. | +| `implementations/media.cpp` | `imsd-media`, the per-call RTP leg. Pure std + POSIX, dlopen's the AMR codecs. | | `implementations/dialerd.cpp` | `imsd-dialerd`, the Plasma Mobile session-bus backend (`org.kde.telephony.*`). | | `implementations/ofonod.cpp` | `imsd-ofonod`, the GNOME Calls system-bus backend (`org.ofono`). | -| `tests//main.cpp` | The eight unit-test suites: `Util`, `Aka`, `Ipsec`, `Qmi`, `Messages`, `Engine`, `Sip`, `Sdp`. | +| `tests//main.cpp` | The seven unit-test suites: `Util`, `Aka`, `Ipsec`, `Messages`, `Engine`, `Sip`, `Sdp`. | | `packaging/` | systemd units, the bring-up and upgrade scripts, D-Bus and polkit policies, presets, the gschema override, `APKBUILD.binary` and the CI helpers (§9). | | `.forgejo/workflows/package.yml` | CI: builds the aarch64 apks on every push to `main` and publishes them to the Forgejo Alpine registry (§9.4). | | `bin/`, `build/` | crafter-build's outputs and intermediates. Ignored. | @@ -63,75 +58,44 @@ the subscriber's IMSI/MSISDN and network addresses. ## 3. Build products -Seven products come out of the tree. C++26 modules throughout, `import std;` +Six products come out of the tree. C++26 modules throughout, `import std;` in every source but the re-exporting `Imsd.cppm`, clang + libc++ + lld. | Product | Kind | Built from | Links | Notes | |---|---|---|---|---| -| `imsd-core` | static library | the `Imsd` module and its eight partitions | — | Pure `import std` C++, no GLib, no I/O side effects: every message, digest, SA command and call-state decision is here and unit-testable on any dev box without a bus, a modem or a phone. | +| `imsd-core` | static library | the `Imsd` module and its seven partitions | — | Pure `import std` C++, no GLib, no I/O side effects: every message, digest, SA command and call-state decision is here and unit-testable on any dev box without a bus, a modem or a phone. | | `imsd` | executable | `implementations/main.cpp` | `imsd-core`, `imsd-bus`, gio-2.0, pthread | The daemon. Default product. | | `imsd-bus` | static library | `interfaces/ImsdBus.cppm` | gio-2.0 | The polkit gate (`net.catcrafts.IMS1.Voice`) imsd and imsd-ofonod both link. A translation daemon that runs as root on the system bus reaches imsd as root, which polkit always allows, so it gates its own callers through this or it is a bypass. | | `imsd-media` | executable | `implementations/media.cpp` | `-ldl -lpthread` | The RTP/AMR data plane, spawned once per call. No GLib, no core. A separate process for crash isolation and for the far-end-hangup exit-code contract (§7.6). `-- --product=media`. | | `imsd-dialerd` | executable | `implementations/dialerd.cpp` | gio-2.0 | Plasma Mobile integration: a session daemon owning `org.kde.telephony.{CallUtils,DeviceUtils,UssdUtils}`, bridging to imsd. GDBus translation only. A separate process because those are per-user session-bus names a root system daemon cannot own. `-- --product=dialerd`. | | `imsd-ofonod` | executable | `implementations/ofonod.cpp` | `imsd-bus`, gio-2.0 | GNOME Calls integration: a system daemon owning `org.ofono`, presenting imsd as an oFono modem. GDBus translation plus the polkit gate on its own callers. `-- --product=ofonod`. | -| `imsd-pcscf` | executable | `implementations/pcscf.cpp` | `imsd-core` | The P-CSCF reader `ims-pdn-up.sh` runs once the ims PDN is up (§7.3): one QMI WDS client over a raw QRTR socket, bound to the bearer's mux data port, asking for the call's runtime settings. Prints the PCO's P-CSCF list; never sends Start Network or Stop Network (its writes are its own client's mux binding and IP family, §12), and the call stayed connected and the registration stood after it (§13). Built with `-- --product=pcscf`. | The rule behind the split: **`imsd-core` stays GLib-free and free of I/O so that every decision in it has a unit test.** New protocol logic goes into a -partition with a test. The shell (`main.cpp`; from 1.0.0 also `pcscf.cpp`'s -QRTR control lookup and its matching of answers by type, transaction and -message id) holds protocol decisions no -test reaches — some older than 0.3.10, some added by 0.3.10, 0.3.11, 0.3.12 or 1.0.0: the P-CSCF in -use (`pcscf_`: the kernel's SA peer at a resume, `PCSCF=` or the first of -`PCSCF_DISCOVERED=` at a fresh registration, the move between them and back — -§7.2); the reg-event +partition with a test. The shell (`main.cpp`) holds protocol decisions no +test reaches — some older than 0.3.10, some added by it: the reg-event subscription schedule (§7.4: its retry ladder, Retry-After clamp, Timer F, renewal point and RFC 6665 reasons); the registration sequence and its transactions (`AwaitFinal`/`AwaitInitialFinal`: which message is a REGISTER's final, the dispatch of requests arriving meanwhile and the routing of our reg-event SUBSCRIBE's final read there, the -12 s wait restarted on a provisional); the reregistration's headers and its -answer to a challenge (`Reregister`: the stored challenge answer, or the empty -digest for a pre-0.3.11 state file, the new SPIs, `PortUcNext`; a 401 thrown -as a refusal carrying its status); the state-file round trip -(`PersistState`/`LoadState`: the `security_server` key, written empty for a -value with a quote, a backslash or a newline — the choice between that list -and the entry rebuilt from the SA is `Imsd:Ipsec`'s, with a test); the +12 s wait restarted on a provisional; `Reregister`'s one re-challenge); the inputs to `RegRecovery` — `SipFlow::Connect`'s split of failures into local refusals and network silence, and the catch sites that sort a failed refresh (the keepalive's, `ReconnectCycle`'s) and a failed fresh attempt (`FreshRecover`, `DeferBringUp`) into Refused, Silent or Throttled with the status, the Retry-After and `saReplaced_`, the bring-up resume's catch (a refusal's Retry-After fed as a hold, the old registration written off after -a failed resume that a fresh registration follows; with no fresh target only -a pair nothing can resume is written off — a 403 once fresh is due or a -binding a NOTIFY terminated under the attempt counts as one by the engine's -rule, and so does a resume it gives up: a 401, a refusal with a Retry-After, -the second refusal, the count travelling in the holds (`Reconnectable()` and -`Fresh()`, decided once after both catch arms) — and taken out of the kernel, -§7.2 step 1), the `OnLink` read that writes the old registration off +any failed resume), the `OnLink` read that writes the old registration off when its address has left the netdev (`FreshRecover` after a failed move, `AddressGone`), and `PacketServiceUp`'s read of -ModemManager before a silent cycle is counted; the no-target reads and -reactions (1.0.0): the kernel text behind `SaPeer`, the holds' `old_gone` -and the script's `PCSCF_DISCOVERY=` — whether a pair is resumable (`SaPeer` -finds one and the holds' `old_gone` does not write it off: `!peer || -writtenOff` in `main()`) and the choice of the idle or the fatal are shell -code — at `main()`'s idle before the bus name (a pair the holds wrote off -taken out of the kernel on the way) and at the no-target bring-up's write-off -of a dead pair, its removal and the idle or the fatal that follows (§7.3); -and the xfrm deletes the SA setup and the dead pair's teardown run — -`OwnXfrmDeleteCommands` builds them from the kernel text, with a test; the -shell reads that text and runs them (§12); -the shell's reaction to a NOTIFY +ModemManager before a silent cycle is counted; the shell's reaction to a NOTIFY terminating our binding (the flow closed at once, or kept under a call and closed when the call ends, a reconnect cycle's or the bring-up resume's wait ended by it — the reducer's verdict is pinned, the reaction is not); and the holds file's codec (`PersistHolds`/`LoadHolds`: aging by boot seconds, the pdn/boot match, defaults for absent keys — proven on the phone only, captures -45- and 70- of 2026-09-25-polkit-voice; the `pdn` compare in canonical form on -both sides (1.0.0) is unbenched: no capture stages a holds file whose `pdn` is -spelled unlike `LOCAL`). For the schedule, a reducer next to +45- and 70- of 2026-09-25-polkit-voice). For the schedule, a reducer next to `RegRecovery`, with a Subscription-State parser in `Imsd:Sip` pinned by `tests/Sip`, is the follow-up that moves it; a pure format/parse pair in `Imsd:Engine` is the holds file's. @@ -164,8 +128,7 @@ crafter-build # bin/imsd----/ims crafter-build -- --product=media # bin/imsd-media-…/imsd-media crafter-build -- --product=dialerd # bin/imsd-dialerd-…/imsd-dialerd crafter-build -- --product=ofonod # bin/imsd-ofonod-…/imsd-ofonod -crafter-build -- --product=pcscf # bin/imsd-pcscf-…/imsd-pcscf -crafter-build test # the eight unit-test suites +crafter-build test # the seven unit-test suites crafter-build lint # the house-style rules (§4.2, §6) ``` @@ -203,9 +166,9 @@ crafter-build format # apply the transform rules in place Six rules read the AST (`naming`, `no-char-pointer`, `const-local`, `constexpr-constant`, `fixed-width-types`, `single-declaration`) and need the -module PCMs: build the five programs and the tests first. With crafter-build -2.1.0 they still do not run on every file — `implementations/main.cpp` is -among the misses (`'type_traits' file not found`). +module PCMs: build the four programs and the tests first. With crafter-build +2.1.0 they still do not run on 2 of the 18 files, `implementations/main.cpp` +among them (`'type_traits' file not found`). ### 4.3 Cross-compiling for a phone (aarch64 Alpine / postmarketOS) @@ -221,7 +184,7 @@ crafter-build -- --target=aarch64-alpine-linux-musl \ `make-sysroot.sh` needs no root, qemu or device: it downloads `apk-tools-static` and the Alpine keys and populates a minimal rootfs (musl, libc++ with its std module source, glib and their headers, compiler-rt, -libunwind, linux-headers for `imsd-pcscf`'s ``, gcc for its +libunwind, gcc for its `crtbeginS.o`/libgcc, which clang's `*-alpine-linux-musl` driver links against). In a cross build `project.cpp` does not ask the host's pkg-config (it would answer for the wrong architecture); it adds `-I=/usr/include/glib-2.0` @@ -237,65 +200,33 @@ releases. This is the flow CI runs (§9.4). ## 5. Tests -`crafter-build test` runs the eight suites. Each suite +`crafter-build test` runs the seven suites. Each suite is a plain `main()` with a `Check(bool, message)` counter: failures print `FAIL: …`, success prints `: all tests passed`, and the exit status is -the failure count for seven suites — non-zero short of a multiple of 256 — and -1 for `Engine`. `crafter-build test` ending in `8 passed`, on every +the failure count for six suites — non-zero short of a multiple of 256 — and +1 for `Engine`. `crafter-build test` ending in `7 passed`, on every commit of a range (§11), is the project's definition of green. What the suites pin: -- `Util` — tokens, IPv6 detection and host/port formatting, the RFC 5952 §4 - form (the RFC's §4.1–4.2.3 examples, idempotence on `inet_ntop`-shaped - text, what is refused), the address-list parser, hex/base64, MD5. +- `Util` — tokens, IPv6 detection and host/port formatting, hex/base64, MD5. - `Aka` — the AKAv1-MD5 digest, identity derivation, the `qmicli`/`mmcli` text parsers, the AUTHENTICATE APDU and its response parser. - `Ipsec` — the exact `ip xfrm` argv lists for the SA pairs, the - Security-Server mechanism selection, the SA reader, the SA peer - (`SaPeer`: the one address a reqid-1 and a reqid-2 `dir out` transport - policy both name with imsd's protected port as the selector's sport — the - fingerprint —, the pair kept beside a VPN's policies; - a lone or disagreeing policy names no - peer), `FileNamesAnotherPair` (the pair's own SPIs, other SPIs, one - differing, no keys, a 0 from a missing inbound state, zeros in the file), - and `OwnXfrmDeleteCommands` (our pair's eight deletes byte-exact on - the fixture in the registered FP6's shape (synthetic addresses and keys), a VPN's tunnel SA and policies beside it - untouched, another transport-mode user's pair on other ports and addresses - untouched — its reqid-1 ESP state on the UE's address deleted, its AH state - left —, a policy-less state on the UE's address deleted, one between - other addresses left). -- `Qmi` — the request bytes `imsd-pcscf` sends (four of the five the probe's, - byte for byte; Get Profile Settings is the helper's own, answered by the - modem on the dev phone in the probe capture's `--profile 2 -v` run and on the dev phone's bring-up - discoveries (the 2026-10-11/12 KPN bench), which pass `--profile` when the - ims profile index is known — the `profile-pcscf-via-pco` flag line), - the parser on the modem's 163-byte Get Current Settings answer in its exact - TLV shape (addresses re-encoded to `2001:db8::/32`), the OutOfCall answer, - the IPv4/FQDN list readers on synthetic lists, the tolerance of a count - that disagrees with a length, short and overrunning buffers, the - packet-service-status and profile PCSCF-via-PCO readers (present and absent - TLVs) and the error names (OutOfCall, NoEffect, an unknown code as `?`). + Security-Server mechanism selection, the SA reader. - `Messages` — every outgoing SIP message byte-for-byte, cross-generated from the Python reference stack imsd replaced. - `Sip` — the text layer's edge cases (empty header values, line anchoring, keepalive discards), `ResponseOf`, refused finals with reason headers, reginfo documents. A change that flips one of those tests is a behaviour change, not a cleanup. -- `Sdp` — the offer's exact bytes, the parser (the telephone-event payload - type at the chosen codec's clock among it), `BuildAnswer` at the offer's +- `Sdp` — the offer's exact bytes, the parser, `BuildAnswer` at the offer's own payload types and octet-align mode, the `CODECS` override. - `Engine` — the call state machine driven through recorded network dialogs (reliable 18x/PRACK, 2xx ACK + media start, far-end hangup, CANCEL/answer races, busy/error finals, the terminating side's 100/180/200/486/487/488, G.711-only and AMR-NB gateway offers, ring timeout, BYE both ways, the - emergency chain, DTMF — sent to the leg on an active call that negotiated - telephone-event, dropped with the reason before the answer, after the - call, without the negotiation, for a character that is no tone or for a - string past `DtmfMaxTones`; the receive payload type: our offer's on a - renumbered answer, the answer's when the audio took ours, the echoed - offer's on an incoming call) and - `RegRecovery` (thresholds, backoffs, the throttle + emergency chain) and `RegRecovery` (thresholds, backoffs, the throttle schedule, Retry-After, which waits a Dial may cut short, when the old registration counts as gone, the holds a restart takes over, a binding the network terminated). @@ -315,43 +246,18 @@ Rules: pre-fix build that renewed that closure; the retry on this code, the renewal firing, Timer F, the doubling, a SUBSCRIBE `Retry-After`, the renew arm and the back-off's survival across a refresh have run nowhere — - §3, §7.4); a `Retry-After` on a REGISTER has run in the field (one - report, a screenshot of 2026-10-08, O2, 0.3.10: a refresh's Retry-After - set the next step's time — `in 7 min`, then `in 13 min` — and the - refusals counted; the wait's length went unmeasured, the journal lines - on the screenshot carry no timestamps; KPN's refusals, all 403, carry - none) and a `Dial` bringing a recovery step - forward (imsd-ofonod refuses the `Dial` itself and imsd-dialerd forwards - it) is pinned in `tests/Engine` only; a 401 to a reregistration (the - refreshes KPN accepted were never challenged — consecutive CSeqs on every - bench — and O2 has not run this shape) and - the escalation by one refused refresh that carries a `Retry-After` — the - escalation to a fresh registration is pinned in `tests/Engine` only; a - resume echoing a multi-mechanism Security-Server list on the wire (the - choice of the stored list is pinned in `tests/Ipsec` with O2's recorded - list; KPN's has one mechanism, so on the bench the stored value and the - rebuilt one are the same bytes; O2's six-entry list has not resumed on - this code, and the state-file round trip is shell code no test - reaches); a Security-Server list `PersistState` writes empty (one with - a quote, a backslash or a newline — neither KPN's nor O2's list has - one; the resume of such a file ran with a hand-edited empty key, - 2026-10-11); a state write of a standing registration after a fresh - attempt that aborted before its SA setup (`RegState::securityServer`, - not the attempt's `ss_` — on KPN the old SA's retry after a throttled - attempt is refused 403, the throttled-attempt chain below, so no such - write follows here); the window that leaves a state file naming another - SA pair than the kernel's (a stop or a crash between a fresh attempt's - SA setup and its 200's state write — the check is pinned in `tests/Ipsec` - and ran on a hand-edited list, 2026-10-11; the window itself is not - staged); a - refused initial or fresh + §3, §7.4); a `Retry-After` on a REGISTER and a `Dial` bringing a recovery + step forward (KPN's refusals, all 403, carry none; imsd-ofonod refuses the + `Dial` itself and imsd-dialerd forwards it — pinned in `tests/Engine` + only); a refused initial or fresh REGISTER (403, 5xx) at bring-up or in recovery (KPN has only throttled or stayed silent — pinned in `tests/Engine` only); the write-off of the old registration by a recovery's fresh attempt that fails past the SA setup (such attempts are on record only as a bring-up's fatal exit, which does - not pass that arm — pinned in `tests/Engine` only); the escalation by - two refused refreshes that carry no `Retry-After` (KPN refuses a refresh - only with the 403 that says the registration is gone — pinned in + not pass that arm — pinned in `tests/Engine` only); a refused + refresh counted + towards the escalation, and the escalation by two of them (KPN refuses a + refresh only with the 403 that says the registration is gone — pinned in `tests/Engine` only); of the throttled-attempt chain in recovery (a throttled fresh attempt, the old SA retried once meanwhile with the stored binding's Contact and refused 403, the deferred attempt accepted at the @@ -387,132 +293,11 @@ Rules: socket (a silent call) — a pin naming an account with one ran, 2026-10-04. `DetectLocal`'s three-tier address ranking for a run without `LOCAL` (§10.1) ran through a fake `ip` (2026-10-05: the phone's listing of the - netdev and two cut-down ones). The 1.0.0 arms no bench has reached: a - fresh registration moving from the resumed SA peer to `PCSCF=` and back - when the attempt fails before the SA setup (`FreshRecover`); the - fresh-target tail of a state file another attempt left behind (its no-target - tail ran on the tip's host build, bench 23f, with a file naming another SA - pair; the check itself, `FileNamesAnotherPair`, is pinned in `tests/Ipsec`; - the window itself is not staged on the phone); another transport-mode reqid 1 + 2 pair - beside ours on other ports, and two addresses each carrying imsd's - fingerprint (`tests/Ipsec` only — a VPN's tunnel policy and a transport - reqid-1 policy beside the live pair, resumed and kept, and a lone one with - no pair of ours, none found, ran on the dev phone, 2026-10-11/12); the SA - setup's and the dead pair's deletes with - another transport-mode user's pair in the kernel (`tests/Ipsec` only — with - a VPN's tunnel policy and tunnel state there, both left in place through a - fresh registration and a dead pair's removal, they ran on the dev phone, - 2026-10-12); a no-target resume the network refuses (the reducer's side of - the count's travel, `Held` → `Restore`, is pinned in `tests/Engine`; the - holds file's `refusals` key — `PersistHolds`/`LoadHolds`, shell code — and - the shell's giving the pair up on the engine's verdict are unbenched); - `FreshRecover`'s restart for want of a fresh target (a resumed no-target - registration whose recovery needs a fresh one); an empty `Service-Route` - value in the 200, ignored (KPN's 200 carries none); a no-target resume - whose refresh goes unanswered (the connect case ran on the tip's host - build, bench 23f: the pair kept for the restart, `the restart tries the - resume again`); `RESUME=0` at a no-target start - (BringUp's removal of the untried pair — the same deletes ran before the - bus name on the host's kernel, a holds write-off in front of imsd's pair, - 2026-10-13); `RemoveDeadPair`'s no-peer line (`N xfrm entries of imsd's own - with no pair to resume: removing them`); `RunOwnXfrmDeletes`' - line for a delete that failed (`… -> rc N`: an entry gone between the read - and the delete); the holds file's `pdn` compare in canonical form (no - capture stages a holds file whose `pdn` is spelled unlike `LOCAL`); the - discovery arms beyond the plain found one — the v4/FQDN `also gave` line, - the eight-address cap, the IPv4-only and no-mux-id skips, the comma-list - `PCSCF=` refusal, the reader's rc-0 answer without an address — on the mock - bench only (S13, S19–S22, S55): KPN's PCO carries two IPv6 addresses and nothing else, so on the - real PCO the dev phone reached the found arm alone; the configured skip, the - bracketed refusal and, through the `IMSD_PCSCF` seam, the reader's failure - and its exit 2 ran on the dev phone too (2026-10-11/12); the script's idle arms (no SIM, a refused ims PDN) with the - refusal count's reset by a coverage gap, a timeout and a transient cause, - the no-coverage and locked-SIM retries and the APN-string connect of a - modem without the profile, on the mock bench only (a SIM-out boot would - reach the no-SIM idle alone; the others need a SIM-in run against a refusing - network, an outage, a locked SIM, a modem without the profile) — the daemon's `idle:` exit for - the script's keys ran on the host build (2026-10-12, exit 0), its own idle - (nothing to register to), the discovery-failed exit 1, `main()`'s removal - of a pair the holds wrote off and `imsd-ofonod` starting without waiting - for imsd on the dev phone (2026-10-12). DTMF ran on the dev phone (KPN, - 2026-10-14 to 16, the fp6 journal's `2026-10-14-dtmf-bench-1` to - `2026-10-16-dtmf-bench-17-heard`): outgoing calls to voicemail over the bus - (AMR-WB, telephone-event 98 — three tones, a string with a character - that is no tone, a wrong call uni: the two drops' lines; benches 1, 5 - to 9 and 11 to 16), 13 keys from GNOME Calls' keypad on a call to a stock phone the - network answered narrowband (AMR 99, telephone-event 100), heard there - (bench 2, the first sender; 28 keys from Plasma Dialer's keypad on two - such calls heard there on the one-sender shape, bench 17); on incoming - calls from that phone (the offer's 111 at 16 kHz) - 10 and 4 keys sent from the keypad (benches 3 and 4); on Plasma Mobile, - 11 keys from Plasma Dialer's keypad on an incoming call and 3 on an - outgoing narrowband one, through `imsd-dialerd` (bench 10); the stock - phone's key presses heard as tones (on GNOME Calls: bench 3's twelve on - the renderer before the gap fill, bench 4's four on it with the gap - fill; on Plasma Mobile: bench 10's ten on an incoming wideband call and - two on an outgoing narrowband one, every received event rendered by the - leg's own count). Every event of - ours in benches 1 to 3 and 5 to 17 is in the RFC 4733 shape on the wire - with the clock shared with the audio, and so are three of bench 4's - four: the third carried a mic frame after its first packet (seq 1761 - between 1760 and 1762) — the race of the sender's first shape, a thread - of its own beside the mic's, which the sender's commit closes by having - the mic thread send the event packets in its own frames' slots (one - event of bench 4's four raced, none of the 23 live-mic events of benches - 2 and 3; the loopback's mic is a file, paced like a microphone, and has - not raced it). That first shape also stamped the audio a slot early or - late per event against the capture clock on the AMR-WB calls measured — - there pw-record delivers frames in bursts of two (§13) and the thread's - 140 ms window took six to eight of them: the two tones of `2#`, 10 slots - apart by construction, are 11, 9 and 10 slots apart in benches 5, 12 and - 13, AMR-WB voicemail calls (`analysis-v2-bench-{5,12,13}.txt` beside - bench 14) — which the one-sender shape of bench 14 cannot (10); on a - narrowband call the first shape's stamping was not measured. - Not reached on hardware: - a network answering without telephone-event (KPN echoes it; an answer that - keeps the rtpmap off its m-line is pinned in `tests/Sdp`), the drops - before the answer and after the call and the `DtmfMaxTones` bound - (these three pinned in `tests/Engine`), the letters A–D and lower case - (pinned in `tests/Engine` and the selftest), an inbound offer without - telephone-event at the codec's clock (pinned in `tests/Sdp`), the shell's - refusal of a line over PIPE_BUF and its `not sent: no control pipe` line - on a call with no media leg (nothing pins these two), a `pipe()` failure, a write to a - dead leg's pipe (`DTMF '…' not sent: …`), a full pipe's EAGAIN, G.711 - (the loopback only), a received event with a code past D (dropped - unrendered) or one whose packet reports more than two seconds the clock - has not covered (that packet skipped — a long key press renders slot by - slot), a lost first packet of an event (the renderer starts where the - clock stands), and an answer that renumbers telephone-event or gives the - audio our number (`DTMF_RX_PT` — KPN echoes our numbers; pinned in - `tests/Engine` and by the loopback's collision arm). -- `imsd-pcscf` ran on the dev phone (KPN) on 2026-10-11 against the live ims - bearer: the `--mux 1 --profile 2 -v` sequence, a plain run, and `OutOfCall` - on an unbound mux (the fp6 journal's `2026-10-11-pcscf-pco-probe` capture, - `04-…`). Not reached on hardware: exit 2 (a PCO without an IPv6 list), an - error answer to Bind Mux Data Port or Set IP Family, a lookup that finds no - WDS service, the answer timeout, skipped strays and indications, the - lookup's second send to a local node other than 1, and the `v4`/`fqdn` - output lines (`tests/Qmi` pins their parsing on synthetic TLVs only). The - QRTR control lookup and the matching of answers by type, transaction and - message id are shell code (`pcscf.cpp`) no test reaches. + netdev and two cut-down ones). - `imsd-media --selftest` round-trips the RFC 4867 payload packing (both - formats, both AMR codecs), the G.711 tables, and pins the RFC 4733 event - payload bytes, the keypad-to-event codes and every event's Q.23 - frequency pair. `MIC_SRC=` (raw s16 + formats, both AMR codecs) and the G.711 tables. `MIC_SRC=` (raw s16 PCM at the codec rate, paced in real time) and `PCM_DUMP=1` are test seams - for driving the leg against a synthetic RTP peer without a phone; the fp6 - journal's `utilities/media_loopback.py` is that peer, and its `--dtmf` - arm writes a `dtmf ` line to the leg's stdin and checks the events - on the wire (one timestamp per event, the marker, the growing duration, - the end bit three times, the shared sequence and timestamp space with the - audio; with the mic file, or without a mic — `--no-mic`, the silence tick - carrying the events), and its `--dtmf-rx` arm has the peer send events and checks the - decoded downlink for each tone's two frequencies where the event sat, the - stream intact around it — the sender's shape is proven there and on the - dev phone's wire (the fp6 journal's dtmf benches: their pcaps go through - the same checks), the renderer's there — and on the dev phone by ear and - by the leg's count of events rendered (bench 10); what a gateway does - with the events is proven only by a call to one. + for driving the leg against a synthetic RTP peer without a phone. - `imsd --session` and `imsd-ofonod --session` own their names on the session bus for development without root, polkit or a phone (§8.3). @@ -527,16 +312,11 @@ suppress a rule where a file needs it; nearly every source file disables `fixed-width-types`. The tree was written under an earlier, project-local copy of the rules and -does not pass the shared ones yet: `crafter-build lint` reports findings in -most files — `naming` first (constants and statics are PascalCase here, the -shared rule wants camelCase), then `format-concat`, `const-local`, -`wrap-join`, `single-declaration` and `no-char-pointer` (`const-local` and -`single-declaration` the copy did not report) — and the count moves (72 on -2026-10-06, before the Qmi files; 108 in 16 of 20 files on the tree that -added them, 2026-10-13; 109 on the 1.0.0 tree, 2026-10-13: `naming` 69, -`format-concat` 23, `const-local` 10, `wrap-join` 3, `single-declaration` 2, -`no-char-pointer` 1 and `if-single-line` 1, the last new since). CI does not -run lint. +does not pass the shared ones yet: `crafter-build lint` reports 72 findings +(2026-10-06) — `naming` 44 (constants and statics are PascalCase here, the +shared rule wants camelCase), `const-local` 10 (a rule the copy did not +have), `single-declaration` 2 (not reported under the copy), `format-concat` +13 and `wrap-join` 3 (as under the copy). CI does not run lint. Beyond the rules: @@ -575,9 +355,8 @@ the status snapshot live (main thread only). Per answered call imsd forks `imsd-media` (`IMSD_MEDIA`, default next to the `imsd` binary, else `/usr/libexec/imsd-media`) with the addresses, ports and -payload type as arguments, the codec and audio knobs in its environment -(§7.6, §10.2) and its stdin on a pipe from imsd, the control pipe the DTMF -tones travel down (§7.6). +payload type as arguments and the codec and audio knobs in its environment +(§7.6, §10.2). The desktop daemons are translation layers: `imsd-dialerd` runs as the session user (autostarted from `/etc/xdg/autostart/imsd-dialerd.desktop` in @@ -590,15 +369,9 @@ through `/etc/skel/.config/autostart/org.kde.modem.daemon.desktop`); `imsd.service` runs two `ExecStartPre=` steps before the daemon: -1. `/usr/libexec/ims-pdn-up.sh` brings the `ims` PDN up through ModemManager, - reads the P-CSCF list the network put in the PDN's PCO (`imsd-pcscf`; - skipped when `PCSCF=` is set), gives the first a host route (§7.3) and - writes `/run/imsd.env` with `DEV=` (the connected netdev), `LOCAL=` (the - PDN address ModemManager assigned), `PCSCF_DISCOVERED=` (the IPv6 addresses of - that list, at most eight, comma-separated in the modem's order; IPv4 entries - and names dropped) and `PCSCF_DISCOVERY=` (how it came to be: - found, none, failed or skipped, §7.3) — or `IMSD_IDLE=` alone when - the bring-up found nothing to do (§7.3). +1. `/usr/libexec/ims-pdn-up.sh` brings the `ims` PDN up through ModemManager + (§7.3) and writes `/run/imsd.env` with `DEV=` (the connected netdev) and + `LOCAL=` (the PDN address ModemManager assigned). 2. `/usr/libexec/imsd-handover.sh` (`ExecStartPre=-`, failure-tolerant) hands a pre-0.3.10 daemon's throttle wait over (§7.5). @@ -613,30 +386,7 @@ a 5-minute registration gate, connect retries). `Restart=on-failure`, The daemon's own bring-up (`BringUp()` in `main.cpp`): -1. The P-CSCF a fresh registration goes to is `PCSCF=` when set, else the - first address of `PCSCF_DISCOVERED=` (the bring-up's reading of the PDN's - PCO, §7.3); the journal says which (`pcscf: configured …` / `pcscf: - discovered … (using …)`). A resume needs neither: the kernel's SAs name - its P-CSCF (step 3). With neither, a bring-up that did not resume ends in - the fatal `no P-CSCF for a fresh registration: …` and the restart's retry - when the resume merely failed or the discovery did (`PCSCF_DISCOVERY=failed`, - §7.3), else in the clean `idle: no P-CSCF …` exit (§7.3) — and since no - fresh one follows, a resumable pair whose attempt failed (a silent - connect, an unanswered refresh, a first refusal without a Retry-After — - the refusal count goes to the holds) is not written off and stays for the - restart to resume, unless the - engine's rule ends it: a 401, a refusal with a Retry-After or the second - refusal (`RefusalsBeforeFresh`; the count travels in the holds, so the - restart's refusal is the second) give the resume up, a 403 once a fresh - registration is due or a binding a NOTIFY terminated under the attempt - write the registration off — either way the pair counts as one nothing - can resume. A pair nothing can resume (its UE address left the link, no - state file, a file another attempt left behind, written off before this start or - given up by the engine during the attempt) is written off (`old_gone` in the holds — written as - well for a pair `RESUME=0` leaves untried) and taken - out of the kernel first (`the SA pair to … is nothing to resume: removing - it` — by exact key, what two rules select — the fingerprinted policies and the reqid-1/2 transport-mode ESP states in their direction or on the UE's ims-PDN address, §12), so the next bring-up - finds nothing to resume. +1. `PCSCF` must be set (no P-CSCF discovery from the PDN's PCO yet). 2. `qmicli -d qrtr://0 --uim-get-card-status` finds the ready USIM (slot, AID); `mmcli -m any` gives the primary SIM path and the IMEI (for the `+sip.instance` URN); `mmcli -i ` gives IMSI and MCC/MNC, from which @@ -647,106 +397,20 @@ The daemon's own bring-up (`BringUp()` in `main.cpp`): registration: reconnect through the existing SA and send a refresh re-REGISTER (`RESUME via existing SA`, then `REGISTERED (resumed + true-refreshed)`). This avoids the network's fresh-SA throttle. `RESUME=0` - forces a fresh registration where there is a fresh target; with none the - untried pair is taken out and the bring-up ends as step 1 says. A resume the network refuses is not fatal while there is a fresh target: - the fresh registration follows and replaces its SAs — at once, or after - the hold the reducer keeps (a Retry-After the refusal carries, the one - refusal that reaches the reducer with a target; a binding a NOTIFY - terminated under the resume; a fresh hold kept from before this start); - with no target every refusal reaches the reducer, - §7.4; with none it is the fatal and the - restart, or the idle (step 1). The SA reader, the holds file's `pdn` - key, `LOCAL` and `PCSCF` compare addresses in RFC 5952 §4 form - (`imsd::util::CanonicalAddr`: lower-case hex, leading zeros dropped, the - longest zero run compressed — what `ip xfrm` prints for a plain IPv6 - address; an IPv4-mapped address is read in that hex form only, never with - the dotted tail `ip` prints), and `PCSCF=` is accepted in that one spelling - only: upper case, leading zeros or an uncompressed run are refused at - bring-up with the one to use (`PCSCF='…' is not written as ip prints it; - use PCSCF=…`), a dotted-quad tail with the rule but no spelling, a - bracketed value by the bring-up script with the rule (`write the bare - address, as ip prints it (no brackets)`), a comma-separated `PCSCF=` (a - list) with its own message (`must be one address`; in the packaged unit the - bring-up script, which runs first, fails a list before the daemon sees it) - — one spelling, so the - bring-up script, which routes the value as typed, and the daemon agree on - it. Before 1.0.0 an upper-case, zero-padded or uncompressed `PCSCF=` was - accepted and, compared as text against `ip xfrm`, matched no SA and - registered fresh at every restart; an upgraded phone with such a setting - stops at bring-up with the spelling to use. A resume follows the kernel's SA peer (`SaPeer`), not - `PCSCF=`: a registration made to another P-CSCF than today's setting is - resumed there (`pcscf: resuming on …`; imsd routes that peer itself once - `ParseExistingSa` confirms the SAs) and moves at the next fresh - registration (`the fresh registration moves from P-CSCF X to Y`; an - attempt that fails before the SA setup goes back while the old registration - still stands — not written off by its address leaving the link, a NOTIFY - that terminated the binding or a 403 once a fresh registration was due: - `back to P-CSCF X: …`). - A state file written for another SA pair than the kernel's — its - `spi_uc`/`spi_us` against the pair's (`FileNamesAnotherPair`), a 0.3.x - file's too: an attempt that got through the SA setup and did not live to - record, to this or another P-CSCF — is not used; the - bring-up registers fresh (with no fresh target: the fatal or the idle, step 1). Every fresh registration goes to `PCSCF=`, else the first - entry of `PCSCF_DISCOVERED=` that `CanonicalIp6` accepts; with neither, a bring-up that cannot resume ends as - step 1 says (the fatal after a resumable pair's failed attempt or a failed - discovery, else the clean `idle: no P-CSCF …` exit — a resume the engine - gave up or wrote off included) and a recovery step that needs one - restarts the daemon through systemd. -4. **Fresh registration** otherwise (`FRESH registration`): the target's host - route (imsd's own `ip -6 route replace`; step 3 says which target), an - unprotected REGISTER on `PCSCF_PORT` (UDP, always), the 401 with the AKA challenge and + forces a fresh registration. A resume the network refuses is not fatal: the + fresh registration replaces its SAs. +4. **Fresh registration** otherwise (`FRESH registration`): an unprotected + REGISTER on `PCSCF_PORT` (UDP, always), the 401 with the AKA challenge and the `Security-Server` list, the AKA run over a UIM logical channel (`--uim-open-logical-channel`, `--uim-send-apdu`), the SA pair installed with `ip xfrm`, the protected REGISTER over the negotiated transport (§10.1, `SIP_TRANSPORT`), `REGISTERED (fresh)`. Then the reg-event SUBSCRIBE (§7.4). 5. A keepalive re-REGISTER refresh at half the granted lifetime, within - 120–1800 s (`REFRESH_INTERVAL`), only while idle. The refresh of a - registration this process made fresh is a reregistration as TS 24.229 - 5.1.1.4.2 has it: the same Call-ID and Contact, CSeq+1, the - Authorization of the last successful authentication verbatim (the 401's - nonce, our cnonce and response, kept in the state file), a - Security-Client with new SPIs and a new client port (`PortUcNext`, - 45063) for the SA pairs a re-authentication would set up — the server - port stays (TS 33.203 7.4.0) — and the Security-Verify (the 401's list). - A registration resumed from the kernel's SAs sends the same - Security-Client as that refresh, the Authorization the state file holds - (a file from before 0.3.11: the empty digest) and the Security-Verify - from the state file (since 0.3.12); resumed from a file written before - 0.3.12, from one whose list `PersistState` wrote empty, or from one - whose list names another SA pair than the kernel holds (an attempt the - process did not live to record: its SAs installed, its 200 not - written — since 1.0.0 such a file is not resumed at all when its own - `spi_uc`/`spi_us` are not the pair's, step 3), it sends — - on the resume's re-REGISTER, the keepalive re-REGISTERs, the reg-event - SUBSCRIBE and INVITEs, until its next fresh registration — the one - entry rebuilt from the SA - (`Imsd:Ipsec`, which makes the choice): KPN's one-entry answer to the - default offer in the same bytes, not a list such as O2's six, which RFC 3329 §2.3.1 has the server answer with a - 494 when it does not mirror its own; a refused resume registers fresh (with - no fresh target: the fatal and the restart, or the idle, step 1). - A 401 to a - reregistration asks for re-authentication over temporary SAs on the - proposed ports, which imsd does not implement: it is a refusal and the - recovery is a fresh registration (§7.4). Every refresh of the pre-0.3.11 - shape on record from O2 UK (an initial registration's empty digest, the - in-use SPIs) was refused with 503 (§13). + 120–1800 s (`REFRESH_INTERVAL`), only while idle. The state file is JSON: `callid`, `ftag`, `cseq`, `spi_uc`, `spi_us`, -`expiry`, `route`, `ppi`, `aor`, `contact_user`, `transport`, since 1.0.0 -`pcscf` (the P-CSCF the registration was made to; named in the line that -reports a file another attempt left behind — the check is the SPIs', -`FileNamesAnotherPair`), since 0.3.12 -`security_server` (the registration's Security-Verify: the 401's list, -written empty when it holds a quote, a backslash or a newline; absent, -empty or naming another SA pair than the kernel's — a file from before -0.3.12, such a list, or a file an unrecorded attempt left behind — the -resume rebuilds one mechanism from the SA, logs which, and stores that), -and since -0.3.11 `auth_nonce`, `auth_cnonce`, `auth_response` (a registration resumed -from an older file has none: its reregistrations — the resume's and the -keepalives', across restarts — carry the empty digest, as before 0.3.11, -until its next fresh registration, and each such resume logs it). +`expiry`, `route`, `ppi`, `aor`, `contact_user`, `transport`. A failed fresh bring-up exits non-zero and systemd retries after `RestartSec` — except a bring-up the network refused or throttled, which @@ -772,59 +436,6 @@ configures the muxed netdev. Idempotent; a non-zero exit fails the unit. - Waits up to 120 s for a modem, then gates the connect attempts on network registration (`IMS_REG_TIMEOUT`, default 300 s): some carriers attach ~2 min after boot. -- Idles instead of retrying when there is nothing to do: a modem in the - `failed` state with a SIM reason (`sim-missing`, `esim-without-profiles`, - `sim-error`), checked once the modem appears and again while waiting for - registration, writes `IMSD_IDLE=no-sim` to `/run/imsd.env` and exits 0; a - profile-indexed connect series refused ten times in a row — each a - persistent 3GPP cause in ModemManager's typed error - (`ServiceOptionNotSubscribed`, `MissingOrUnknownApn`, - `OperatorDeterminedBarring`, `ServiceOptionNotAuthorizedInPlmn`, - `UserAuthenticationFailed`, `ServiceOptionNotSupported`, `Ipv4OnlyAllowed`, - `Ipv6OnlyAllowed`) with packet service at the time — writes - `IMSD_IDLE=no-ims-pdn` (the line carrying the last error). The script's own - rule, stricter than the daemon's `PacketServiceUp`: a refusal counts only - while the modem reads `registered`, `connecting` or `connected` with packet - service not `detached`; any other read (`searching`, `disconnecting`, - `unknown`, an unanswered `mmcli`), a D-Bus timeout, `esm-proc-timeout`, a - transient cause (`InsufficientResources`, `ServiceOptionOutOfOrder`, - `NetworkFailureActivation`) or any other error starts the count again, as a - coverage gap does — an outage must not idle VoLTE until the next boot. Installing the package and inserting a - SIM is the opt-in: `/etc/imsd.env` holds overrides only and its existence - changes nothing here (before 1.0.0 the apk's drop-in made it the switch for - the whole unit, §9.2). imsd reads the key, logs `idle: …` and exits 0; - `Restart=on-failure` leaves a clean exit alone, so the unit stays inactive - until the next boot, a `systemctl start imsd` or a start of `imsd-ofonod`, - whose `Wants=imsd.service` starts an idle imsd again (the Phosh image: an - `imsd-ofono` upgrade, its own restart after a crash) — one attempt per - boot, or per such start. What is not a fact keeps the retry: ten failures - without coverage — the network found only at the end or lost on the way — - exit 1 like a disabled radio, as do a SIM still waiting for its PIN (the - modem `locked`), a modem whose final profile list has no ims APN (the - APN-string connect is tried; a connected APN-string bearer — a run whose - lookup failed at boot — is reused and its registration resumed) and an - APN-string series, which a walled carrier's modem refuses locally. - The dead end after the PDN is up is the daemon's own, decided in `main()` - before it takes the bus name with the same clean exit: nothing to register - to — no `PCSCF=` and nothing discovered: `PCSCF_DISCOVERY=none` (no IPv6 - P-CSCF read from the PCO — the reader's exit 2: the list absent, empty, - unreadable, or IPv4/FQDN entries only), `skipped` (a netdev without an rmnet - mux id) or no `PCSCF_DISCOVERY=` at all (a manual run) — - and no registration of its own to resume (`SaPeer` finds no pair, or the - holds for this PDN and boot say `old_gone`): - `idle: no P-CSCF — none configured (PCSCF=), none discovered (see the - ims-pdn-up lines) — and nothing to resume; set PCSCF= in /etc/imsd.env to - name one`; a pair the holds wrote off is taken out of the - kernel by exact key on the way (`the SA pair to … is nothing to resume: - removing it`, §12), so no start of this boot finds it. The no-target - bring-up decides the same after its resume attempt: a resumable pair whose attempt failed keeps the fatal - and the restart; a dead pair is written off, taken out of the kernel (§12) - and the daemon idles (`RequestIdle`). A reader that did not answer - (`PCSCF_DISCOVERY=failed`) is no fact: the daemon exits 1 instead — before - the bus name with `no P-CSCF: discovery failed … — exiting for systemd - restart`, from the no-target bring-up with `bring-up FAILED: no P-CSCF for a - fresh registration: …` and `engine fatal: … — exiting for systemd restart` — - and the unit's restart runs discovery again. - Never creates or connects a bearer while the modem is `disabled` — its radio is off (airplane mode): a modem still disabled after the wait, or disabled when an attempt is due, makes the script exit non-zero (`modem … @@ -848,69 +459,32 @@ configures the muxed netdev. Idempotent; a non-zero exit fails the unit. walled garden and hang). The RA's prefix and MTU still apply. An RA default route already present (an imsd restart) is flushed. - Sets the PDN MTU ModemManager reports on the link (1280 on the FP6), adds - the address and waits out DAD. -- Resolves the P-CSCF: `PCSCF` when set (one IPv6 address; it wins and - discovery is skipped — a list or a non-IPv6 value exits non-zero), else - `imsd-pcscf --mux [--profile ]` reads - the list the network put in the PDN's PCO from the modem (§13; the mux id - from `ip -d link show `), not for `IMS_IP_TYPE=ipv4`, which the bring-up does not support (it - configures only the PDN's IPv6 address). The usable - addresses — IPv6; IPv4 ones and names are logged and dropped — are handed - over (at most 8) and the first, the daemon's fresh target, gets a host route - (`dev `, no gateway — a raw-IP netdev without header_ops); imsd - routes the P-CSCF it uses itself as well — the fresh target before a fresh - registration, the resumed SA peer once its SAs are confirmed (§7.2 steps 3 and - 4) —, as each call's media peer is. Nothing usable is logged with - its cause (`nothing usable (…)`), is not the script's failure, and is told - apart in the hand-off: `PCSCF_DISCOVERY=none` when the reader found no IPv6 - P-CSCF in the PCO (its exit 2: the list absent, empty or unreadable — - `tests/Qmi` pins the tolerant read) — a fact of this PDN: a registration of imsd's own that - can be resumed is resumed and stays up (a fresh registration later restarts - the daemon for want of a target, and that start runs discovery again), a - bring-up that did not resume ends in the daemon's clean idle (`idle: no - P-CSCF …`, the idle rule above) or, after a resumable pair's failed - attempt, in the fatal and the restart; `PCSCF_DISCOVERY=failed` when the - reader did not answer (an error, a timeout, nothing printed) — no fact: a - daemon with nothing to resume exits 1 and the unit's restart runs - discovery again. Anything else on - the IMS network (its DNS servers, a traceroute target) needs its own host - route (`ip -6 route add dev qmapmux0.0`). imsd installs a host route - per media peer itself, and one to the P-CSCF it registers to or resumes on - (§7.2 step 3). + the address, waits out DAD, and installs a host route to `PCSCF` + (`dev `, no gateway — a raw-IP netdev without header_ops). The PDN + is IPv6-only, so `PCSCF` must be an IPv6 address; anything else on the IMS + network (its DNS servers, a traceroute target) needs its own host route + (`ip -6 route add dev qmapmux0.0`). imsd installs a host route per + media peer itself. - Opens imsd's protected ports 45061–45062 (TCP and UDP) on `qmapmux*` in nftables when a `inet filter` table exists (pmOS's default INPUT chain drops inbound on `qmapmux*`, which silently killed every network-initiated request after ESP decapsulation). Best-effort. -- Writes `/run/imsd.env`: `DEV=`, `LOCAL=`, - `PCSCF_DISCOVERED=` (comma-separated in the modem's order; empty when - nothing was discovered or `PCSCF` is set) and `PCSCF_DISCOVERY=found|none|failed|skipped` - (how the list came to be: the PCO's answer, the reader's failure, or - discovery not run — `PCSCF=` set, `IMS_IP_TYPE=ipv4`, no rmnet mux id), and - logs the line it wrote. imsd +- Writes `/run/imsd.env`: `DEV=`, `LOCAL=`. imsd registers on the address the network assigned, not on the kernel's temporary SLAAC address from the RA, which the netdev lists first and retires after about two days (the SAs and the Contact would go with it). - The script never reads `PCSCF_DISCOVERED` from its environment: the unit - hands it the previous run's file, and a stale discovered value must not - pass for the admin's `PCSCF=`. -Its own settings (read from the environment, else from the file `IMSD_ENV` -names, `/etc/imsd.env` by default, for manual runs): `IMS_APN` (default -`ims`), `IMS_IP_TYPE` (`ipv6`), `IMS_PROFILE_ID` (empty = look up; `none` = -connect by APN string), `IMS_REG_TIMEOUT` (300) and `PCSCF`; from the -environment only, `IMSD_PCSCF` (the reader's path, default -`/usr/libexec/imsd-pcscf` — the mock bench's seam) and `IMSD_ENV` (the -configuration file's path — the bench's seam too). +Its own settings (read from the environment, else from `/etc/imsd.env` for +manual runs): `IMS_APN` (default `ims`), `IMS_IP_TYPE` (`ipv6`), +`IMS_PROFILE_ID` (empty = look up; `none` = connect by APN string), +`IMS_REG_TIMEOUT` (300), `PCSCF`. ### 7.4 Registration recovery (`RegRecovery`, `Imsd-Engine.cppm`) What the daemon does when the registration stops working after bring-up, as a pure reducer the shell feeds with the clock and the network's answers. Two -kinds of failure reach it: a **refusal** (the network answered a refresh with -a final other than 200 — a 401, re-authentication, included — or a fresh -registration's REGISTER with a final other than the expected one: from 300 -up on the unprotected one, whose 401 is the challenge, anything but 200 on -the protected one) and **silence** (the flow died +kinds of failure reach it: a **refusal** (the network answered a refresh or a +fresh REGISTER with a final other than 200/401) and **silence** (the flow died and reconnecting through the SA, or the refresh over it, got no answer). Neither is retried for long on the same registration (TS 24.229 5.1.1.4 and RFC 5626 have the UE register anew). Before this reducer existed the daemon @@ -923,12 +497,12 @@ The policy, with its constants: | Step | Rule | |---|---| | Reconnect | Through the existing SA, then refresh. Back-off 5 s → 5 min (`ReconnectDelayMin`/`Max`). | -| Escalate to a fresh registration | After one refused refresh whose `Retry-After` is at least a second (the network's considered answer; a `Retry-After` of 0 or one that does not parse counts as none; O2 2026-10-08: the refresh refused 503 with Retry-After 414 s, the refresh after that wait 503 with Retry-After 772 s) or a 401 (re-authentication, which imsd answers with a fresh registration rather than over temporary SAs), after two refused refreshes without one (`RefusalsBeforeFresh`), or two reconnect cycles that end without an answer (`SilentCyclesBeforeFresh`) while the modem reports packet service (`PacketServiceUp`: ModemManager's registration and packet-service state, as `ims-pdn-up.sh` reads them). A cycle that fails during a coverage gap is the phone's silence, not the network's: not counted, retried on the local-refusal pace, so a radio gap across a refresh does not escalate into a fresh registration, which KPN refuses while the registration is live and drops it for (§13). New Call-ID, new challenge, new SA pair (`registering fresh (recovery attempt N)`; with no fresh target the daemon restarts instead, §7.2 step 3). | +| Escalate to a fresh registration | After two refused refreshes (`RefusalsBeforeFresh`) or two reconnect cycles that end without an answer (`SilentCyclesBeforeFresh`) while the modem reports packet service (`PacketServiceUp`: ModemManager's registration and packet-service state, as `ims-pdn-up.sh` reads them). A cycle that fails during a coverage gap is the phone's silence, not the network's: not counted, retried on the local-refusal pace, so a radio gap across a refresh does not escalate into a fresh registration, which KPN refuses while the registration is live and drops it for (§13). New Call-ID, new challenge, new SA pair (`registering fresh (recovery attempt N)`). | | A refused or throttled fresh attempt | Waits on the **throttle schedule**: 21 min, growing ×1.5 to 60 (`ThrottleMinutesMin`/`Max`). KPN refuses a fresh SA pair on an address whose registration is live (§13), and drops that registration the moment an initial REGISTER arrives. | | A silent fresh attempt | Backs off 30 s → 10 min (`FreshDelayMin`/`Max`). | | `Retry-After` | Always wins over a shorter wait, honoured up to an hour (`RetryAfterMax`, the schedule's longest wait); a longer one is cut to an hour and the refusal heard again then. On a refused fresh attempt it replaces the schedule's wait, shorter or longer, never below 30 s (`FreshDelayMin`). | | A 403 | A refusal like the others: never hammered (the schedule), never abandoned (a SIM that is unbarred registers within the hour). | -| The old registration is gone | A 403 once a fresh registration is due, a fresh attempt that got past the SA setup, a binding the network terminated (reg-event), an older address that left the netdev, or a bring-up whose resume failed while a fresh registration follows (with no fresh target a resumable pair whose attempt failed is kept for the restart; a pair nothing can resume — given up or written off by the engine, its address off the link, no state file, a file another attempt left behind, left untried by `RESUME=0` — is written off and taken out of the kernel, §7.2 step 1): no reconnect over the old SA from then on. Otherwise the existing SA is still retried on the reconnect back-off while a fresh registration is deferred. | +| The old registration is gone | A 403 once a fresh registration is due, a fresh attempt that got past the SA setup, a binding the network terminated (reg-event), an older address that left the netdev, or a bring-up whose resume failed: no reconnect over the old SA from then on. Otherwise the existing SA is still retried on the reconnect back-off while a fresh registration is deferred. | | A `Dial` while unregistered | Brings the next recovery step forward (the reconnect, or the fresh attempt once it is due) — never a wait the network imposed (a `Retry-After`, or the schedule after a refused or throttled fresh attempt). imsd-ofonod refuses a `Dial` then and imsd-dialerd lists no modem. | | Success | Resets everything. | @@ -940,23 +514,12 @@ address), `boot` (the boot id), `written_at` (seconds of uptime), `hold`, registration is gone: a 403 once a fresh registration is due, an attempt past the SA setup, a binding the network terminated, an older address that left the netdev, and every bring-up that goes on to register fresh — its resume -failed or there was nothing to resume; a bring-up with no fresh target -writes `old_gone` when nothing can be resumed — a pair nothing can resume: its UE -address left the link, no state file, a file another attempt left behind, written -off before this start, given up or written off by the engine during the -attempt, left untried by `RESUME=0` — never for a pair kept for the restart, -§7.2 step 1); the reducer's refusal count travels too (`refusals`): a resume -refused without a Retry-After at a start with no fresh target is one -refusal, the restart's is the second and `RefusalsBeforeFresh` gives the -resume up — without it the restart loop would re-REGISTER on the same -Call-ID every `RestartSec`, the F16 shape — and -`LoadHolds` takes them +failed or there was nothing to resume) — and `LoadHolds` takes them over at the next start for the same `pdn` and boot id, waiting them out before the resume (a refresh's `Retry-After`) or before the fresh registration (a fresh attempt's schedule or `Retry-After`, a terminated binding's wait); with `old_gone` set the start does not resume over the old SAs at -all: with a fresh target it waits the fresh hold out and registers fresh, -with none it removes the pair and ends as §7.2 step 1 says — also after a start +all, it waits the fresh hold out and registers fresh — also after a start during an outage, whose resume and fresh attempt both fail without an answer: the next start then registers fresh while the network still holds the old registration, which KPN refuses (§13; dev phone 2026-10-04: 21 min where @@ -986,10 +549,8 @@ for a reason that allows renewal (RFC 6665: `deactivated`, `timeout`, earlier subscription's NOTIFY is still read for the binding's state, but its end is not ours to renew (KPN closes a registration's previous subscription with `noresource` when a new one replaces it). A NOTIFY that **terminates the -binding** triggers a fresh registration where there is a fresh target (with none a -running daemon restarts, and a bring-up whose resume the NOTIFY ended idles -— exit 1 instead when `PCSCF_DISCOVERY=failed` — §7.2 steps 1 and 3) — at -once, or after the network's `retry-after` (up to an hour); after the call, when one is up (one exception: +binding** triggers a fresh registration — at once, or after the network's +`retry-after` (up to an hour); after the call, when one is up (one exception: a REGISTER wait that still gets its 200 after such a NOTIFY, a call having kept the flow, leaves that registration standing). After `rejected` and `unregistered` it waits on the refusal schedule, a wait no @@ -1013,25 +574,9 @@ packages' `post-upgrade`/`post-install` scripts mark the units `daemon-reload` when the unit is not-found because its file was just reinstalled) and pmOS's systemd trigger runs `enqueue-marked`. A call in progress ends. The restart resumes the registration over its existing SAs, -or goes on waiting out a hold the network had set (§7.4) — or, with an -upper-case, zero-padded or uncompressed `PCSCF=` (accepted until 0.3.12), -stops at bring-up with the spelling to use (§7.2 step 3). A stopped unit +or goes on waiting out a hold the network had set (§7.4). A stopped unit keeps no marker and `enqueue-marked` leaves it stopped (dev phone, systemd -262~rc3, 2026-10-03) — so a phone that never had `/etc/imsd.env` (the unit -never started before 1.0.0) keeps its stopped unit through the upgrade and -starts at its next boot, registering where §7.3's rule lets it — unless `imsd-ofonod` -runs there (the Phosh image, `imsd-ofono`): the upgrade restarts that daemon, -and its `Wants=imsd.service` starts imsd at once (`enqueue-marked` queues the -marked unit's try-restart, systemd collapses it to a restart for a running -unit, and a restart job pulls in a start job for each `Wants=` — systemd -262~rc3, `job_type_collapse` and `transaction_add_job_and_dependencies`; dev -phone 2026-10-11: a stopped imsd was started in the same second an -`imsd-ofonod` restart began, two seconds after it had been stopped, and -resumed) — without waiting for it: -since 1.0.0 `imsd-ofonod.service` has no `After=imsd.service` (that restart -would otherwise wait behind the ims PDN bring-up — the whole `ExecStartPre`, up to `TimeoutStartSec=900`), so GNOME -Calls' backend starts at once and republishes when imsd's name appears (`OnImsdAppeared`; dev -phone 2026-10-12, bench 30). +262~rc3, 2026-10-03). A daemon older than 0.3.10 kept its bring-up throttle wait in memory only, so the restart registered fresh at once instead of waiting the schedule out. @@ -1065,8 +610,8 @@ journal says it was not handed over. `CallMachine` owns one call's SIP dialog, outgoing or incoming, and never touches a socket, a clock or a subprocess: each event returns `Action`s for the shell (send this on the client flow, answer that request on the channel -it came in on, start/stop the media leg, hand it the tones a key press -sent, emit this D-Bus state change, arm this deadline). States and reasons are the frozen D-Bus vocabulary: +it came in on, start/stop the media leg, emit this D-Bus state change, arm +this deadline). States and reasons are the frozen D-Bus vocabulary: `dialing` / `ringing` / `incoming` / `active` / `terminated`, with reasons `outgoing` / `incoming` / `accepted` / `local-hangup` / `remote-hangup` / `refused-or-busy` / `error`. @@ -1079,8 +624,7 @@ sent, emit this D-Bus state change, arm this deadline). States and reasons are t - Incoming: an INVITE on a protected-port listener is validated before it is announced to the UI; 100 and 180 (reliable when the caller requires 100rel); `Accept` sends the 200 with the SDP answer at the offer's own - payload types and octet-align mode, its telephone-event echoed when the - offer's m-line lists one at the codec's clock; a remote CANCEL gets 200 + 487; an + payload types and octet-align mode; a remote CANCEL gets 200 + 487; an unusable offer 488 naming what was offered; ring timeout 480. A landline gateway's G.711-only or AMR-NB offer rings and is answered at its own payload types. @@ -1089,26 +633,7 @@ sent, emit this D-Bus state change, arm this deadline). States and reasons are t `urn:service:sos` first, with one fallback to a plain INVITE of the digits; a plain INVITE answered `380 Alternative Service` with an emergency body is re-attempted as `urn:service:sos`. -- DTMF (`SendDtmf`): the tones go out as RFC 4733 telephone-event packets - on the call's RTP stream, at the payload type the SDP negotiated for the - codec's clock (`MediaLeg::dtmfPt`: the answer's for an outgoing call, the - offer's for an incoming one; the leg takes the far end's events at that - number and at `MediaLeg::dtmfRxPt`, `DTMF_RX_PT` — our own SDP's at that - clock: our offer's on a call we placed (the answerer sends with the - offer's numbers, RFC 3264 §6.1), the echoed offer's on one we answered - (the offerer with the answer's, §5.1); the answer's instead when - the answer gave the audio our offer's number, which is then the leg's - audio number — never at the audio's). The - engine's `OnDtmf` lets them through on an active call that negotiated - telephone-event and drops them otherwise, with a journal line that says - why (before the answer, after the call, no telephone-event at the codec's - clock in the SDP, a character that is no tone or a string past - `DtmfMaxTones` (128) — then - the whole string, never part of what was pressed; letters are - upper-cased, `a` and `A` are one event). There - is no second method: no SIP INFO, and no tone played into the microphone - path. The shell writes one `dtmf - ` line per `SendDtmf` down the leg's control pipe. +- `SendDtmf` is a stub (RFC 4733 not implemented). - Incoming calls reach imsd only when the network's T-ADS routes the call to the IMS domain; the REGISTER Contact advertises the MMTEL ICSI and `+sip.instance` for that. When T-ADS still picks CS, no INVITE arrives — @@ -1124,35 +649,7 @@ narrowband and G.711 PCMA/PCMU (8 kHz, what a PSTN gateway offers); AMR frames ride RFC 4867 payloads, octet-aligned or bandwidth-efficient (`OCTET_ALIGN`, mirrored from the SDP). The AMR codecs are dlopen'd (`libvo-amrwbenc` + `libopencore-amrwb` for WB, `libopencore-amrnb` for NB); -G.711 is a table. DTMF (RFC 4733 telephone-events; `DTMF_PT` names their -payload type, unset = none; imsd sets it from the SDP and writes the lines -down the control pipe, §7.1): each `dtmf -` line on its stdin queues the tones, and a tone takes seven 20 ms -slots of the uplink in place of the audio frames — the event's five -(100 ms at -10 dBm0: the marker bit on the first packet, the duration -growing a slot at a time, the end bit on the fifth packet) and two in -which the end packet goes out again (RFC 4733 §2.5.1.4) — then three -slots of audio before the next tone; the audio's own sender carries the -events — per captured frame (a silence tick without a mic) the event -packet goes in the frame's place, one packet per slot — so events and -audio share the stream's sequence and timestamp space (RFC 4733 §2.1), the -clock stays the capture's, and the audio after an event is stamped as if -it had run through it; a line of more than 4096 bytes is -dropped through its end and the next line goes. The far end's events — -taken at `DTMF_RX_PT` (our offer's 98 or 100 on a call we placed, the -number an answerer sends with, RFC 3264 §6.1, whatever the answer -renumbered ours to; the offer's number we echoed on a call we answered; -the answer's telephone-event number instead when the answer gave the -audio our offer's number, which is then the leg's audio number) and at -`DTMF_PT`, never at the audio's — are played as the tones they stand for -(ITU-T Q.23's frequency pairs, at the packet's volume), in the playout, in -step with the audio clock: the gap before the event's start (a stock -sender's DTX silence often precedes a key press — 0 to 7 slots on the dev -phone, 2026-10-15) is filled as before an audio frame, each packet reports -the event's duration so far and what the clock has not covered is -synthesized, so a retransmitted end packet adds nothing and a lost update -is caught up by the next; a far end whose audio resumes past the event -(the stock phone's does, §13) meets the clock with the usual fill. **Exit code 3 means the downlink dried up** for +G.711 is a table. **Exit code 3 means the downlink dried up** for `MEDIA_TIMEOUT` seconds (default 6.0) — the far-end-hangup signal, which on carriers whose BYE never reaches our SAs is the reliable teardown trigger. Per-call stats land in `OUT_DIR` (default the state dir); `RTP_DUMP=1` @@ -1173,9 +670,9 @@ account `user`, its old default. | Path | Written by | Content | |---|---|---| -| `/etc/imsd.env` | the admin | configuration (§10), none required since 1.0.0 where discovery finds a P-CSCF (§7.3): `PCSCF=` overrides what discovery found, and names the P-CSCF where discovery is skipped or finds none; the file's existence changes nothing. Before 1.0.0 the apk's drop-in `ConditionPathExists=/etc/imsd.env` made it the switch for the whole unit (§9.2) | -| `/run/imsd.env` | `ims-pdn-up.sh` | `DEV=`, `LOCAL=` of the connected PDN, `PCSCF_DISCOVERED=` the IPv6 addresses of the PCO's P-CSCF list in the modem's order, at most eight (IPv4 entries and names are dropped; empty when the PCO gave no IPv6 P-CSCF, when discovery did not run or failed, or when `PCSCF=` is set), `PCSCF_DISCOVERY=found|none|failed|skipped` (how the list came to be: the PCO's answer, the reader's failure, or discovery not run); or `IMSD_IDLE=no-sim` / `no-ims-pdn` alone when the bring-up found nothing to do (§7.3; the daemon decides the dead end after the PDN is up itself) | -| `/run/imsd.holds` (`HOLDS_FILE`) | imsd, `imsd-handover.sh` | the network's holds, the schedule's place and the reducer's refusal count (§7.4) | +| `/etc/imsd.env` | the admin | configuration (§10); `PCSCF=` is required. The apk adds a drop-in with `ConditionPathExists=/etc/imsd.env` (§9.2), so an unconfigured phone does not boot into a failing unit | +| `/run/imsd.env` | `ims-pdn-up.sh` | `DEV=`, `LOCAL=` of the connected PDN | +| `/run/imsd.holds` (`HOLDS_FILE`) | imsd, `imsd-handover.sh` | the network's holds and the schedule's place (§7.4) | | `/run/imsd.handover` | `imsd.post-upgrade` | the InvocationID of the daemon an upgrade replaces, written when no holds file exists (§7.5); consumed by `imsd-handover.sh` | | `/run/imsd-ofonod.started` | `imsd-ofono-restart-calls.sh` | marker: imsd-ofonod has started once since boot | | `/var/lib/imsd/imsreg.state` (`STATE_FILE`) | imsd | the registration context for a warm resume | @@ -1204,100 +701,16 @@ Lines the operator (and the scripts) look for, all prefixed `imsd: `: until the next fresh attempt`, `X has left DEV: the registration on it is gone; staying on Y; next: …`, `X is no longer on DEV: the registration on it is gone; on the PDN's own address Y now; next: …`. -- `pcscf: configured X` / `pcscf: configured X; discovery gave Y, Z (ignored: - PCSCF= wins)` / `pcscf: discovered Y, Z (using Y)` at start, `pcscf: - PCSCF_DISCOVERED entry '…' is not an IPv6 address; ignored`; `pcscf: - resuming on X, the registration's SA peer; the next fresh registration - goes to Y` (+ ` (the peer is not in today's list)`, or `… (no P-CSCF - configured or discovered)`), `the fresh registration moves from P-CSCF X - to Y`, `back to P-CSCF X: the old registration stands until the next fresh - attempt`, `no reg state file; resume falls back to fresh` and `the state file names - another SA pair (0x…/0x…[ to X]) than the kernel's (0x…/0x… to Y): another - attempt's; resume falls back to fresh` (` to X` only when the file records a - P-CSCF — a pre-1.0.0 file does not; with no fresh target both end in - `nothing can resume it: the pair is removed` instead), `no P-CSCF for a - fresh registration: none configured (PCSCF=) and none discovered - (PCSCF_DISCOVERED empty — see the ims-pdn-up lines)`. -- `PCSCF='…' is not written as ip prints it; use PCSCF=…` (under `bring-up - FAILED:`) — one accepted spelling of the setting (§7.2 step 3); the - bring-up script's `ims-pdn-up: PCSCF '…': write the bare address, as ip - prints it (no brackets)` (its own prefix: a script line) is the same rule - for a bracketed value. - `engine fatal: … — exiting for systemd restart`. - `reg-event SUBSCRIBE sent`, and the binding's state from NOTIFY. -- `the SA pair to X is nothing to resume: removing it` — a bring-up with no - fresh target that found a pair nothing can resume (§7.2 step 1; `N xfrm - entries of imsd's own with no pair to resume: removing them` when the - removal finds entries of imsd's own but no pair `SaPeer` can name — - policy-less states on the UE's address, a lone or disagreeing fingerprinted - policy —, §12); a - resumable pair whose attempt failed is kept for the restart (`resume - failed: … — the restart tries the resume again`, `resume connect failed; - the restart tries the resume again`, `state file transport differs from - SIP_TRANSPORT; the restart tries the resume again` — the last at every - restart while the forced transport stands; with a fresh target each ends in - `the next registration is a fresh one`); a registration the engine's rule - wrote off — a 403 once a fresh registration is due, a binding a NOTIFY - terminated under the re-REGISTER wait — is removed instead (`resume failed: - … — written off by the network: the pair is removed`; with a fresh target - `… — written off by the network: the next registration is a fresh one`), - and so is a resume - the engine gives up with no fresh target — a 401, a refusal with a - Retry-After, the second refusal, the count travelling in the holds (`resume - failed: … — the resume is given up (the engine's rule): the pair is - removed`). - `audio session: …` at start and at each call's media start; `CODECS override active: …`. -- `media pt=N octet=true|false -> [X]:P, telephone-event pt N|none` at a - call's media start (`… pt N (theirs M)` when `DTMF_RX_PT` differs from - the one we send with); `call X: DTMF '…' -> media leg (telephone-event pt - N)` for tones the engine lets through, `call X: DTMF '…' dropped: the call is ` / - `… dropped: no telephone-event negotiated for this call` / `… dropped: - 'c' is not a tone` / `call X: DTMF of N tones dropped: more than 128` for - tones not sent, `SendDtmf(X, '…') ignored: no such call`; `DTMF '…' not - sent: no control pipe to the media leg` on a call whose control pipe - could not be made or that has no media leg, `DTMF '…' not sent: ` - when the control pipe's write fails (`EPIPE`: the leg died under the - action; `EAGAIN`: it is alive, stopped reading, its pipe full), - `DTMF of N tones not sent: longer than the control pipe's atomic - write` (a line the engine's bound should have stopped), `media control - pipe: …; this call has no DTMF` when the pipe could not be made. - A refused REGISTER or SUBSCRIBE is logged with its status line and - any `Retry-After` / `Warning` / `Reason` / `Server` headers; - `re-REGISTER challenged: 401 …; re-authentication asked, the next - registration is a fresh one` for a challenged refresh; `state file holds - no challenge answer (pre-0.3.11): the refresh carries an empty digest` - at a resume of a registration made before 0.3.11 — every reregistration - of it carries the empty digest until its next fresh registration, and - the line repeats at each later resume of it; `Security-Verify from the - state file (1 mechanism)` / `(N mechanisms)`, `state file holds no Security-Server (a - pre-0.3.12 file, or a list the file cannot hold): Security-Verify - rebuilt from the SA as one mechanism` or `state file's Security-Server - names another SA pair than the one in the kernel (spi-s N vs M): - Security-Verify rebuilt from the SA as one mechanism` at every resume. + any `Retry-After` / `Warning` / `Reason` / `Server` headers. - `connect: no answer from the P-CSCF N times (…)` when the protected connect series ends unanswered. -- `idle: no SIM — nothing to do; systemctl start imsd when that changes` - (or `idle: no ims PDN from the network — …` from `IMSD_IDLE`; `idle: no - P-CSCF — none configured (PCSCF=), none discovered (see the ims-pdn-up - lines) — and nothing to resume; set PCSCF= in /etc/imsd.env to name one — - …` the daemon's own, before the bus name or - from the no-target bring-up), then exit 0; `no P-CSCF: discovery failed - (see the ims-pdn-up lines) and nothing to resume — exiting for systemd - restart`, exit 1, when the reader did not answer (`PCSCF_DISCOVERY=failed`). - -From the scripts: `ims-pdn-up: …` — among them `no SIM (sim-missing): not -bringing the ims PDN up — systemctl start imsd once one is in`, `bearer -connect refused 10 times in a row by profile index with packet service and a -persistent cause (last: …): no ims PDN for this SIM here — systemctl start -imsd to try again`, `wrote -/run/imsd.env: IMSD_IDLE=…`, `P-CSCF discovered: a, b -(pco-requested 1, profile-pcscf-via-pco 1)`, `P-CSCF discovery also gave v4 … -(not usable on the IPv6-only ims PDN)`, `P-CSCF discovery: nothing usable -(…)`, `P-CSCF X configured: discovery skipped`, `host route to P-CSCF X dev -Y` (the configured address or the first discovered), `wrote -/run/imsd.env: DEV=… LOCAL=… PCSCF_DISCOVERED=… PCSCF_DISCOVERY=…` —, -`imsd-handover: …`, `imsd-ofono-restart-calls: …`. +From the scripts: `ims-pdn-up: …`, `imsd-handover: …`, +`imsd-ofono-restart-calls: …`. ## 8. D-Bus ABI and desktop integration @@ -1391,6 +804,8 @@ Known gaps, all inherited rather than introduced: `mm_sim_dup_emergency_numbers()`). An upstream fix would close this. Dialling an emergency number from the dialpad is unaffected — that reaches imsd's own classifier. +- DTMF is forwarded to imsd, where `SendDtmf` is a stub — the same + limitation Plasma has. - Restarting `imsd-ofonod` restarts GNOME Calls with it: when the modem's `VoiceCallManager` interface goes away and comes back, Calls 50.0 adds a new origin but leaves the old origin's `CallAdded` handler connected, so @@ -1440,31 +855,11 @@ unit's sequencing is only tested under systemd. ### 9.2 What the package installs and enables `imsd.service` is enabled by preset (`80-imsd.preset`, plus the -`multi-user.target.wants` link), so VoLTE survives reboots without a manual -`systemctl enable`. Since 1.0.0 there is no condition on `/etc/imsd.env`: -the P-CSCF comes from the PDN's PCO, so the unit starts on every phone with -the package — installing it and inserting a SIM is the opt-in; the file holds -overrides only — and a phone with nothing to do (no SIM, no ims PDN from the -network, no P-CSCF in the PCO and nothing to resume) exits cleanly and stays -inactive (§7.3) -rather than failing and restarting. `systemctl mask --now imsd` opts out: a -plain `disable` removes only the [Install] links, and `imsd-ofonod`'s static -`Wants=imsd.service` (the Phosh image) pulls a disabled imsd back in at every -boot and at its every restart (dev phone 2026-10-11, the fp6 journal's -`2026-10-11-0400-bench-kpn` capture: an `imsd-ofonod` restart started a -stopped and, in the control run, a disabled imsd); the mask -held through an `imsd-ofonod` restart and a reboot on the dev phone -(2026-10-11). `systemctl unmask imsd` opts back in. `imsd-ofonod` does not -wait for imsd (no `After=imsd.service` since 1.0.0, §7.5). -Until 0.3.12 the apk shipped a drop-in `ConditionPathExists=/etc/imsd.env`; -the upgrade removes it, and an upgraded phone that never had the file starts -imsd at its next boot — or during the upgrade itself where `imsd-ofonod` -runs (§7.5) — and registers where §7.3's rule lets it. `imsd-ofonod.service` is enabled the same way by -`imsd-ofono`. -The preset, the `multi-user.target.wants` link and the drop-in no longer -shipped are the apk's doing (`APKBUILD.binary`'s `package()`); the idle, the -`Wants=` pull-in and the opt-out are the bring-up script's and the daemon's, -systemd's and the admin's. +`multi-user.target.wants` link) and gated by a drop-in +`ConditionPathExists=/etc/imsd.env`, so VoLTE survives reboots without a +manual `systemctl enable` and an unconfigured phone does not boot into a +failing unit. `imsd-ofonod.service` is enabled the same way by `imsd-ofono`. +All of that is the apk's doing (`APKBUILD.binary`'s `package()`). The main package hides kde-telephony's `modem-daemon` autostart for accounts created after it is installed (`/etc/skel/.config/autostart/org.kde.modem.daemon.desktop`) because `imsd-dialerd` owns those names. @@ -1490,7 +885,7 @@ Crafter.Build Flatpak (`net.catcrafts.CrafterBuild//stable` from flatpak.catcrafts.net — the branch Crafter.Build publishes from its release tags, where `beta` follows every push to its master; the build log names its commit) and llvm/abuild, builds -the sysroot, cross-compiles the five products, runs the suite natively inside +the sysroot, cross-compiles the four products, runs the suite natively inside the Flatpak, wraps the result with `APKBUILD.binary`, and the publish step uploads the apks to the Forgejo Alpine registry (`…/api/packages/Catcrafts/alpine/edge/fp6`) with `PACKAGE_TOKEN`. The @@ -1506,44 +901,29 @@ bump commit titled `imsd X.Y.Z` that changes only the `Version` constant. Everything is environment variables. The packaged unit reads `/run/imsd.env` (from `ims-pdn-up.sh`) and then `/etc/imsd.env` (the user's, -wins on conflict; optional since 1.0.0). The variables are user -documentation: the tables are in the README. +wins on conflict). The variables are user documentation: the tables and the +minimum `/etc/imsd.env` are in the README. ### 10.1 imsd The README's first table. The protected client/server ports are fixed at 45061/45062 (`imsd::util::PortUc`/`PortUs`) — changing them in source also -requires updating the firewall rules `ims-pdn-up.sh` installs. `PCSCF` and -`PCSCF_DISCOVERED` are read at start (`FreshPcscfFromEnv`, for the start line -and again by the engine): the first is one address in RFC 5952 form (§7.2 step 3: another spelling is -refused at bring-up), and wins, the second a comma-separated IPv6 list whose -first entry `CanonicalIp6` accepts is the fresh target otherwise (an IPv4 -address, a name, a literal with a zone or a dotted-quad tail is skipped — logged by the -engine when the daemon goes on to register, not by the idle before the bus -name). A resume goes to the kernel's SA -peer whatever they say (and routes it itself); beyond the resume's `pcscf:` -line, having a fresh target decides what a refused or failed resume -leads to — the fresh registration (a refusal without a Retry-After is not -counted) or, with none, the pair kept for the restart, removed, or the idle -(§7.2 step 1) — and a comma-separated `PCSCF=` stops the bring-up before the -resume. +requires updating the firewall rules `ims-pdn-up.sh` installs. ### 10.2 imsd-media (per call, set through imsd's environment) -The README's second table. These are not configuration and are left out +The README's second table. Two pairs are not configuration and are left out there: | Variable | Default | Meaning | |---|---|---| -| `CODEC`, `OCTET_ALIGN`, `DTMF_PT`, `DTMF_RX_PT` | *(from the SDP)* | set by imsd from the SDP (`DTMF_PT`: the telephone-event payload type at the codec's clock the leg sends with, empty when none was negotiated; `DTMF_RX_PT`: a second number the leg takes events at — our own SDP's at that clock, or the answer's when the answer gave the audio our offer's number, §7.6); not configuration | +| `CODEC`, `OCTET_ALIGN` | *(from the SDP)* | set by imsd from the negotiated answer; not configuration | | `MIC_SRC`, `PCM_DUMP` | *(unset)* | test seams (§5) | ### 10.3 ims-pdn-up.sh and imsd-ofonod -`IMS_APN`, `IMS_IP_TYPE`, `IMS_PROFILE_ID`, `IMS_REG_TIMEOUT`, `PCSCF`, -`IMSD_PCSCF` and `IMSD_ENV` (the configuration file's path, a bench seam) for -the bring-up script (§7.3); `IMSD_IDLE` and `PCSCF_DISCOVERY` are the -script's word to the daemon, not settings. `imsd-ofonod` reads `EMERGENCY_NUMBERS` (from +`IMS_APN`, `IMS_IP_TYPE`, `IMS_PROFILE_ID`, `IMS_REG_TIMEOUT` and `PCSCF` for +the bring-up script (§7.3). `imsd-ofonod` reads `EMERGENCY_NUMBERS` (from `/etc/imsd.env` through its unit) and `IMSD_BUS=session|system` to override only the imsd side of its bus choice. @@ -1557,16 +937,6 @@ only the imsd side of its bus choice. behaviour, in one sentence how it was tested. A version bump is a commit titled `imsd X.Y.Z` that changes only the `Version` constant and has no body. -- **Versioning.** `MAJOR.MINOR.PATCH`: the patch number counts releases, - fixes and features alike; the minor number moves when a release changes - what a user can rely on the daemon for (0.3 added emergency calling), not - with the size of a release; the major number moves when a release changes - what an installed phone does without being asked, and it is not an ABI - change (§8.1: the ABI is frozen). 1.0.0 is the first to move it: the unit - starts on every phone with the package, with no file to write, and - registers where `PCSCF=` names the P-CSCF or, on the FP6's `qmapmux0.0` - (KPN, the one device and network it ran on), where the reader gets the - PCO's IPv6 list. - **No AI attribution trailers.** Commits carry no `Assisted-by:`, `Co-Authored-By:` or similar lines, whoever or whatever drafted them. - **Signed commits.** Every commit is GPG-signed with the repository's @@ -1574,7 +944,7 @@ only the imsd side of its bus choice. not override the identity from the environment. - **One change, one commit; a fix goes into the commit that introduced the problem** while a range is unpublished (rebase), never as a follow-up on - top. Every commit of a range builds and passes the eight suites on its own. + top. Every commit of a range builds and passes the seven suites on its own. - **Keep `make-bin-tarball.sh`'s file list and `APKBUILD.binary`'s `package()` in sync** when a packaged file is added. - **The README and this file follow the code in the same commit.** A new @@ -1593,48 +963,20 @@ only the imsd side of its bus choice. frozen ABI; the writes are polkit-gated (§7.1, §8.1). Do not add an ungated write member, and do not add a root translation daemon that forwards calls without gating its own callers through `imsd-bus`. -- imsd deletes by exact key what two rules select, whoever installed it. A - fresh registration's SA setup and the dead pair's teardown (`RemoveDeadPair`) - delete — `ip xfrm state delete src dst proto esp spi`, `ip xfrm policy - delete dir` — what two rules select (`OwnXfrmDeleteCommands`): a transport-mode policy with reqid 1 or 2 whose - selector carries the protected port 45061/45062 on the UE side (imsd's - fingerprint), and a transport-mode reqid-1/2 ESP state running from such a - policy's source to its destination or anchored on the UE's ims-PDN address - (a state whose policy add failed goes too; an AH or IPComp state is not - selected). Never `ip xfrm state|policy flush`: a policy-based VPN's - (strongSwan's, libreswan's) SAs and policies, or another transport-mode - user's pair on other ports and addresses, stay. Before 1.0.0 the setup - flushed the host's whole xfrm state and policy. Residual: a transport-mode - policy with reqid 1 and the UE-side port 45061, or reqid 2 and 45062 — alone - or paired, between any addresses — goes, with the reqid-1/2 transport-mode - ESP states in its direction, and a reqid-1/2 transport-mode ESP state on the - UE's ims-PDN address goes whoever made it (`tests/Ipsec` pins both). - The bus policies deliberately let any local account *reach* both names; authorization is polkit's, not the policy file's. Keep it that way: a policy that names an account breaks the moment the account is called something else. - SIP dumps and the state file carry the IMSI, the MSISDN, SPIs and - addresses, and the state file (since 0.3.11) the last challenge answer — - nonce, cnonce and response — and (since 0.3.12) the P-CSCF's - Security-Server list. Dumps are mode 0600 under the state dir, off by default + addresses. Dumps are mode 0600 under the state dir, off by default (`DUMP_SIP=0`), and never leave the phone in a commit. - The ims PDN is a walled garden: `ims-pdn-up.sh` keeps it from becoming - the default route and gives the P-CSCF a host route — the configured one, - or the first IPv6 address the modem reports from the PCO (the fresh target; at most eight - are handed over, IPv4 entries and names dropped); imsd adds one per call for - the media peer (§7.3) and one for the P-CSCF it registers to or resumes on - (§7.2 step 3). Nothing else is routed over it. + the default route and gives the P-CSCF a host route; imsd adds one per + call for the media peer (§7.3). Nothing else is routed over it. A change that routes anything else over it needs a reason and a bench. - The fixed protected ports (45061/45062) are opened in nftables for `qmapmux*` only, by the bring-up script. -- **`imsd-pcscf` holds a QMI WDS client of its own on the modem, as root, - from `ims-pdn-up.sh`.** It binds to the bearer's mux data port and reads - the call's runtime settings; it never sends Start Network or Stop Network. Its only writes - are its own client's mux binding and IP family; the call stayed connected - and the registration stood after it (§13). One client socket, closed on - exit (the service lookup's own socket closes when the lookup ends). - ## 13. Carrier and platform facts the code relies on Observed on real networks and encoded in the policy; cite them when touching @@ -1659,56 +1001,12 @@ the related code. registration's address the attempt is refused and costs the registration. - **O2 UK, 2026-09-24:** a refresh refused 21 × 503 over 70 min, then 33 unanswered reconnect cycles; a fresh registration was accepted in one - second. The origin of `RegRecovery`. On 2026-10-08, under 0.3.10 (the - first release to log a Retry-After), a refresh was refused 503 with - `Retry-After` 414 s and the refresh after that wait 503 with `Retry-After` - 772 s: the origin of the `Retry-After` escalation. Every refresh of the - pre-0.3.11 shape — an initial registration's empty digest and the in-use - SPIs in Security-Client — on record from O2 was refused with 503: 21 on - 2026-09-24 under 0.3.9 and the same loop again that afternoon on a - stationary phone, two on 2026-10-08 under 0.3.10, and the user never saw - one succeed, while - the fresh registrations that followed were accepted: the origin of the - 5.1.1.4.2 refresh shape. KPN accepts both shapes without a challenge. + second. The origin of `RegRecovery`. - **KPN closes a registration's previous reg-event subscription with `noresource`** when a new one replaces it: that end is not renewed. - **Modem attach-family forcing** (Odido, O2 UK, Swisscom): an APN-string IPv6 bearer request is refused when the attach PDN was granted IPv4-only; a profile-indexed request is not. -- **pw-record delivers 20 ms frames in bursts** — of two on an AMR-WB - call (1.8 frames per burst on average, the bursts ~37 ms apart; the dev - phone, 2026-10-15/16: of the uplink's inter-packet gaps 42–45 % are - under 4 ms and 50–52 % are 30–40 ms, 3–8 % lie between and under 1 % - above — the fp6 journal's `2026-10-16-dtmf-bench-14/uplink-cadence.txt` - over benches 4, 5, 13 and 14, counted by the `cadence.py` beside it), - of three or four on a narrowband one (3.3 on average, ~68 ms apart; - 70 % under 4 ms, 28 % over 40 ms — `2026-10-16-dtmf-bench-17-heard/uplink-cadence-narrowband.txt`, - bench 17's two calls; the burst sizes of all six legs in `burst-sizes.txt` - beside it). A sender on a clock of its own cannot count the frames a - 140 ms window holds, so the events ride the frames' own slots (§7.6), - and an event's packets leave in those bursts: the audio's own spacing, - which RFC 4733 §2.5.1.2 calls the natural interval for event updates - (its 50 ms recommendation is for a spacing of the sender's own). -- **DTMF on KPN (2026-10-14 to 16):** the answer to our offer echoes - telephone-event at our payload types — 98 at 16 kHz on an AMR-WB call to - voicemail, 100 at 8 kHz on a phone-to-phone call the network answered - narrowband AMR at 99 — and a stock phone's terminating INVITE offers - AMR-WB 107 with telephone-event 111; events of the sender's first shape - (100 ms, a packet every 20 ms, at least 100 ms apart) were heard on that - phone (bench 2 the first shape; bench 17 the one-sender shape — 28 keys - on two narrowband calls, every one heard there). On - its own AMR-WB calls to us (telephone-event 111 at 16 kHz) that phone - sends one event of 20 packets per key press (340 ms, -10 dBm0, an update - every 20 ms, the end packet three times), its audio resuming one to eight - slots past the event's end and the event preceded by a DTX gap of up to - seven slots (the fp6 journal's benches 2 to 4 and bench 10's incoming - call, their `far-end-events-*` files); on a call we placed that the - network answered narrowband (telephone-event 100 at 8 kHz) its two key - presses arrived as events of 22 and 19 packets (400 and 340 ms), the - audio resuming 15.8 and 19 slots (316 and 380 ms) past their ends, the - first off the stream's 20 ms timestamp grid (bench 10's outgoing call) — - whether that shape is the phone's or the network's interworking, - nothing on disk says. - **pmOS's default nftables** drops inbound on `qmapmux*`; without the protected-port rule every network-initiated request (NOTIFY, MT INVITE) dies after ESP decapsulation and MT calls fall back to CS. @@ -1724,17 +1022,3 @@ the related code. every name the receiver owns (the `org.ofono.conf` detail). - **The phone's wall clock reads 1970 until the first NTP sync**; anything that ages a journal line uses the monotonic or boot clock. -- **A WDS client bound to the bearer's mux data port, with IP family 6 set, - reads the call's runtime settings; one that sent neither the binding nor Set IP Family got - `OutOfCall`.** ModemManager owns the ims - bearer's call and never asks the modem for the P-CSCF fields; a second - client that does Bind Mux Data Port (the netdev's `rmnet mux_id`, endpoint - embedded/1 for the `ipa` driver — ModemManager's own binding), Set IP - Family 6 and Get Current Settings gets them: TLV 0x22 = 1, the IPv6 list - in TLV 0x2E (two addresses on KPN), no IPv4 list, an empty FQDN list. Get Current Settings from a client that sent neither - Bind Mux Data Port nor Set IP Family answered `OutOfCall` (the control run), - as did a client bound to a mux with no call (`--mux 7`). The bearer stayed - connected and the registration stood afterwards (dev phone, - 2026-10-11; the fp6 journal's `2026-10-11-pcscf-pco-probe` capture). - libqmi does not model 0x2E and qmicli cannot print any of it, hence - `imsd-pcscf`. diff --git a/README.md b/README.md index b2f35de..3116d23 100644 --- a/README.md +++ b/README.md @@ -6,26 +6,36 @@ Userspace IMS/VoLTE daemon for mainline Linux phones. Most Linux phones drive the modem's internal IMS engine. -imsd implements the whole IMS client in userspace instead wired into +imsd implements the whole IMS client in userspace instead: +SIP registration with USIM AKA authentication over kernel +IPsec (ESP), call signalling, and the AMR-WB RTP media plane wired into PipeWire so a stock mobile dialer like Plasma Dialer or GNOME Calls under Phosh can place and receive real VoLTE calls on a commercial network. -Calls verified by community members to be fully working: +## Status + +Call-capable, both directions: outgoing calls place, answer, carry full-duplex +AMR-WB audio and tear down. Incoming calls ring, answer and carry media the +same way, driven end-to-end from a stock dialer over a frozen D-Bus ABI. +Not there yet: DTMF (`SendDtmf` is a stub), hold/multiparty/call waiting, +USSD, P-CSCF discovery (the address is configured by hand). + +Verified by community members to be fully working: | Device | OS | Carrier | |---|---|---| -| The Fairphone (Gen. 6) | Nura, Linux 7.1.2 | KPN NL | -| The Fairphone (Gen. 6) | Nura, Linux 7.1.2 | Telekom Deutschland GER | -| The Fairphone (Gen. 6) | Nura, Linux 7.1.2 | Phonero | -| The Fairphone (Gen. 6) | Nura, Linux 7.1.2 | Telia Norge | -| The Fairphone (Gen. 6) | Nura, Linux 7.2.0 | o2-uk | +| The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | KPN NL | +| The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | Telekom Deutschland GER | +| The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | Phonero | +| The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | Telia Norge | ## What it needs imsd talks to the modem through ModemManager plus `qmicli` over QRTR, installs its security associations with `ip xfrm` and plays audio through PipeWire, so in practice: a Qualcomm modem on a mainline kernel with working -WWAN/ModemManager integration, kernel ESP, PipeWire, systemd and polkit. +WWAN/ModemManager integration, kernel ESP, PipeWire, systemd, polkit and +your carrier's P-CSCF address. ## Install and configure @@ -39,6 +49,25 @@ Both can be found in the packages tab: https://forgejo.catcrafts.net/Catcrafts/-/packages/alpine/imsd ``` +Configuration is environment variables in `/etc/imsd.env`; the one required +setting is the P-CSCF: + +```sh +# your carrier's P-CSCF address, capture via: `adb shell dumpsys telephony.registry` on Android +PCSCF=2001:db8::105 +``` + +The apk's `imsd.service` is enabled by preset and starts at boot once that +file exists (the condition is checked when the unit starts, so after writing +the file run `systemctl start imsd`). It first brings the `ims` PDN up through ModemManager, then registers: +resuming an existing registration when it can, registering fresh otherwise, +and waiting out whatever the network tells it to wait out. Check on it with: + +```sh +journalctl -u imsd -f +busctl --system call net.catcrafts.IMS1 /net/catcrafts/IMS1 net.catcrafts.IMS1 GetStatus +``` + ## Dialers imsd itself is desktop-agnostic: everything a dialer needs is on the system @@ -67,20 +96,16 @@ session and root may place, answer and end calls; an ssh login may not. ## Full config: -Configuration is environment variables in `/etc/imsd.env`, none are required. - | Variable | Default | Meaning | |---|---|---| -| `PCSCF` | *(the first of `PCSCF_DISCOVERED`)* | P-CSCF address (IPv6), exactly one — not a comma-separated list — in RFC 5952 §4 form (lowercase, leading zeros dropped, the longest zero run compressed, no brackets — what `ip` prints for a plain IPv6 address; an IPv4-mapped address only as hex, `::ffff:a00:1` — the dotted spelling RFC 5952 §5 and `ip` use for it is refused); upper case, leading zeros, an uncompressed run or brackets are refused at start with the spelling to use (in the packaged unit the bring-up script refuses brackets first, with the rule), a dotted-quad tail with the rule; before 1.0.0 an upper-case, zero-padded or uncompressed `PCSCF=` matched no SA in `ip xfrm`'s text and registered fresh at every restart — an upgraded phone with such a setting stops at start with the spelling to use. Set, it wins over `PCSCF_DISCOVERED`. It is where a fresh registration goes; a registration resumed from the kernel's SAs stays on the P-CSCF it was made to until its next fresh registration. With neither this nor `PCSCF_DISCOVERED` there is no fresh registration: a phone with nothing to resume idles (`idle: no P-CSCF …`), unless a resume failed with its pair still resumable, or the reader did not answer (`PCSCF_DISCOVERY=failed`): those exit non-zero for systemd's restart | -| `PCSCF_DISCOVERED` | *(from the PDN's PCO)* | the IPv6 P-CSCF list the network put in the ims PDN's PCO, comma-separated in the order the modem reports it; the first entry `CanonicalIp6` accepts is used when `PCSCF` is unset. The packaged service gets it via `/run/imsd.env` from `ims-pdn-up.sh`, which reads it from the modem with `imsd-pcscf` (at most eight; it routes the first; imsd routes the P-CSCF it uses — the fresh target, the resumed SA peer — itself as well); entries `CanonicalIp6` does not read — an IPv4 address, a name, an IPv6 literal with a zone or a dotted-quad tail (an IPv4-mapped entry in hex only) — are ignored (logged by the engine when the daemon goes on to register); `PCSCF_DISCOVERY=found|none|failed|skipped` beside it says how the list came to be — `failed` (the reader did not answer) makes a daemon with nothing to resume exit 1 for the restart instead of idling. In `/etc/imsd.env` it is a bench seam | -| `IMSD_PCSCF` | `/usr/libexec/imsd-pcscf` | the P-CSCF reader `ims-pdn-up.sh` runs — a bench seam (the mock bench's fake reader; a stub through a unit drop-in on the dev phone), from the environment | +| `PCSCF` | *(none — required)* | P-CSCF address (IPv6); without it registration fails with a clear error | | `PCSCF_PORT` | `5060` | unprotected SIP port for the initial REGISTER | | `DEV` | `qmapmux0.0` | ims-PDN netdev; the packaged service passes the real one via `/run/imsd.env` | -| `LOCAL` | *(auto)* | UE address (compared in RFC 5952 form). The packaged service gets the PDN address ModemManager assigned, via `/run/imsd.env` from `ims-pdn-up.sh`; a manual run prefers a global IPv6 on `DEV` that is neither temporary nor `dynamic` (RA-derived), then any non-temporary one, then the first (the kernel's temporary SLAAC address is listed first, and a registration bound to it is lost when the kernel retires it) | +| `LOCAL` | *(auto)* | UE address. The packaged service gets the PDN address ModemManager assigned, via `/run/imsd.env` from `ims-pdn-up.sh`; a manual run prefers a global IPv6 on `DEV` that is neither temporary nor `dynamic` (RA-derived), then any non-temporary one, then the first (the kernel's temporary SLAAC address is listed first, and a registration bound to it is lost when the kernel retires it) | | `USER_AGENT` | `imsd/` | REGISTER User-Agent. Some networks fingerprint UAs; setting your device's stock build string reproduces the stock modem's registration exactly. Empty omits the header | | `STATE_FILE` | `/var/lib/imsd/imsreg.state` (root) / `$XDG_STATE_HOME/imsd/imsreg.state` | persisted registration context for warm resume | -| `HOLDS_FILE` | `/run/imsd.holds` (root) / `$XDG_RUNTIME_DIR/imsd.holds` (`/tmp/imsd.holds` without a runtime dir) | the waits the network imposed (a `Retry-After`, the refusal schedule), where that schedule stands whether the old registration is gone (`old_gone`) and the refusal count (`refusals`: a resume refused without a Retry-After at a start with no fresh target is one, the restart's the second), 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) where there is a fresh target; with none the untried pair is removed and the bring-up ends as AGENTS §7.2 (the daemon's own bring-up) step 1 says | +| `HOLDS_FILE` | `/run/imsd.holds` (root) / `$XDG_RUNTIME_DIR/imsd.holds` (`/tmp/imsd.holds` without a runtime dir) | the waits the network imposed (a `Retry-After`, the refusal schedule), where that schedule stands and whether the old registration is gone (`old_gone`), kept across a restart of imsd for the PDN they were set on — not across a reboot; `/dev/null` switches the file off (only a regular file is ever removed; anything else is emptied). `imsd-handover.sh` honours it too | +| `RESUME` | `auto` | `0` forces a fresh registration (ignores a warm SA) | | `REFRESH_INTERVAL` | *(auto)* | keepalive re-REGISTER period in s; default = half the granted expiry, clamped to [120, 1800] | | `EALG` | `aes-cbc` | offered ESP cipher (`aes-cbc`, `des-ede3-cbc`, `null`). From the 401's `Security-Server` list imsd selects the highest-preference `ipsec-3gpp` mechanism that matches this offer (RFC 3329 §2.3.1) and takes the SPIs/ports from the entry carrying them (some cores list every mechanism they support and put the SPIs on the one they applied); when no entry matches, the P-CSCF's SPI-carrying entry is installed as listed (`hmac-md5-96`, an absent `ealg` = null) and the journal says so. The line, the selection and the SA parameters are logged at every fresh registration | | `SIP_TRANSPORT` | `auto` | transport of the protected leg (the second REGISTER and everything after it): `tcp`, `udp`, or `auto` = TCP, falling back to a fresh registration over UDP when the P-CSCF leaves two TCP connects unanswered — but only on a phone that has never registered over TCP: once the state file records a TCP registration, a silent connect is treated as an outage, not a policy (delete the file or set `udp` to override, e.g. after a SIM change). The challenge (first REGISTER) is always UDP. Over UDP a REGISTER or INVITE larger than ~1.2 KB leaves as IPv6 fragments at the ims PDN's 1280-byte MTU (the journal says so once per flow); there are no SIP retransmission timers over UDP yet, so a lost datagram costs a timeout. The SAs, the listener and the firewall rule cover both transports | @@ -91,7 +116,6 @@ Configuration is environment variables in `/etc/imsd.env`, none are required. | `DUMP_SIP` | `0` | `1` writes raw REGISTER-200/SUBSCRIBE-200 dumps, the last refused REGISTER or SUBSCRIBE final (`imsd-register-fail.raw`, `imsd-subscribe-fail.raw`), the last inbound INVITE (`imsd-invite-in.raw`) and the last outgoing INVITE (`imsd-invite-out.raw`), mode 0600, for debugging — they contain your IMSI/MSISDN and addresses. A refused REGISTER or SUBSCRIBE is logged with its status line and any Retry-After / Warning / Reason / Server headers | | `DUMP_DIR`, `OUT_DIR` | state dir | where dumps / per-call media stats land | | `IMSD_MEDIA` | *(auto)* | path to `imsd-media` (default: next to `imsd`, else `/usr/libexec/imsd-media`) | -| `IMSD_ENV` | `/etc/imsd.env` | the configuration file's path — a bench seam for manual runs of `ims-pdn-up.sh` (the packaged unit reads `/etc/imsd.env` through `EnvironmentFile=`). The file's existence changes nothing | The protected client/server ports are fixed at 45061/45062 (`imsd::util::PortUc`/`PortUs`) — changing them in source also requires diff --git a/implementations/main.cpp b/implementations/main.cpp index 6af25b2..4ef4ff3 100644 --- a/implementations/main.cpp +++ b/implementations/main.cpp @@ -31,7 +31,6 @@ reach the bus via g_idle_add, where the calls table + status snapshot live #include #include #include -#include #include #include #include @@ -49,13 +48,12 @@ import ImsdBus; namespace { // ---- static config (env-overridable, same knobs as imsd.py) --------------- -constexpr const char* Version = "1.1.0"; +constexpr const char* Version = "0.3.10"; constexpr const char* BusName = "net.catcrafts.IMS1"; constexpr const char* ObjPath = "/net/catcrafts/IMS1"; constexpr const char* Iface = "net.catcrafts.IMS1"; constexpr int PortUc = imsd::util::PortUc; constexpr int PortUs = imsd::util::PortUs; -constexpr int PortUcNext = imsd::util::PortUcNext; constexpr int InitPort = 5060; std::string EnvOr(const char* k, std::string d) { @@ -115,35 +113,6 @@ std::string RedactKeys(const std::vector& argv) { return s; } -// imsd's own xfrm entries out of the kernel, each by its key (the -// OwnXfrmDeleteCommands list — never `ip xfrm … flush`: another IPsec user's -// entries, a policy-based VPN's, stay, §12); a delete that fails (an entry -// gone between the read and the delete) is logged and the rest go on. At -// file scope, as LoadHolds and PersistHolds are: the Engine and main() both -// run it (main() through RemoveDeadPair). -void RunOwnXfrmDeletes(const std::vector>& cmds) { - for (auto& cmd : cmds) { - int rc = RunCmd(cmd); - if (rc != 0) Log(std::format("{} -> rc {}", RedactKeys(cmd), rc)); - } -} - -// The kernel's SA pair when nothing can resume it and no fresh registration -// replaces it, and whatever else of imsd's own is left (a policy-less state -// on the UE's address): taken out by exact key, as the SA setup takes imsd's -// previous pair out (RunOwnXfrmDeletes — nothing else in the kernel is -// touched, §12), so the next bring-up finds nothing to resume. Run by the -// no-target bring-up and by main()'s idle before the bus name. -void RemoveDeadPair(std::string_view local) { - auto state = RunCapture({"ip", "xfrm", "state"}); - auto policy = RunCapture({"ip", "xfrm", "policy"}); - auto cmds = imsd::ipsec::OwnXfrmDeleteCommands(state.out, policy.out, local); - if (cmds.empty()) return; - if (auto peer = imsd::ipsec::SaPeer(policy.out)) Log(std::format("the SA pair to {} is nothing to resume: removing it", *peer)); - else Log(std::format("{} xfrm entr{} of imsd's own with no pair to resume: removing them", cmds.size(), cmds.size() == 1 ? "y" : "ies")); - RunOwnXfrmDeletes(cmds); -} - // ---- small text extractors (401 header params, qmicli "completed:") ------- std::optional QuotedAfter(std::string_view text, std::string_view key) { @@ -297,31 +266,6 @@ void LogAudioSession() { Log(AudioSessionLine(s) + (s.name.empty() ? "; looked up again at each call, set AUDIO_USER to pin one" : "")); } -// The P-CSCF a fresh registration goes to, from the environment: PCSCF= -// when set (one address, any spelling — a list is left as it came and -// refused at bring-up), else the first address of PCSCF_DISCOVERED=, the -// list the bring-up read from the PDN's PCO (comma-separated IPv6, PCO -// order). `discovered` and `rejected` get the list and what was not an -// IPv6 literal, for the caller's log lines. -std::string FreshPcscfFromEnv(std::vector* discovered = nullptr, std::vector* rejected = nullptr) { - std::string configured = imsd::util::CanonicalAddr(EnvOr("PCSCF", "")); - std::vector list = imsd::util::ParseAddressList(EnvOr("PCSCF_DISCOVERED", ""), rejected); - if (discovered) *discovered = list; - if (!configured.empty()) return configured; - return list.empty() ? std::string() : list.front(); -} - -// The no-target bring-up's two outcomes, told apart by the script's word in -// /run/imsd.env (§7.3). The PCO naming no P-CSCF is a fact -// (PCSCF_DISCOVERY=none): a daemon with nothing to resume idles on it until -// a boot or a start. A reader that did not answer is not -// (PCSCF_DISCOVERY=failed): exit 1, and the unit's restart runs discovery -// again. Nothing here depends on a configuration file existing — installing -// the package and inserting a SIM is the opt-in. -bool DiscoveryFailed() { return EnvOr("PCSCF_DISCOVERY", "") == "failed"; } -constexpr std::string_view NoPcscfIdleText = "no P-CSCF — none configured (PCSCF=), none discovered (see the ims-pdn-up lines) — and nothing to resume; set PCSCF= in /etc/imsd.env to name one"; -constexpr std::string_view NoPcscfDiscoveryFailed = "no P-CSCF: discovery failed (see the ims-pdn-up lines) and nothing to resume"; - // ---- sockaddr helper ------------------------------------------------------ socklen_t MakeAddr(std::string_view ip, int port, sockaddr_storage& ss) { std::memset(&ss, 0, sizeof ss); @@ -352,12 +296,10 @@ constexpr std::string_view TransportName(Transport t) { return t == Transport::U // live). Handled in-process: a // systemd restart every RestartSec would never clear it. struct ThrottledError : std::runtime_error { using std::runtime_error::runtime_error; }; -// A REGISTER the network refused: the protected one, or a reregistration, -// answered with a final other than 200 (a reregistration's 401 asks for a -// re-authentication imsd does not do over the old SAs: a refusal carrying -// 401); the unprotected initial one answered with a final from 300 up (its -// 401 is the challenge). The status and its Retry-After feed -// imsd::engine::RegRecovery. +// A REGISTER the network refused: the protected one, or a refresh, answered +// with a final other than 200 (a refresh's first 401 is its challenge); the +// unprotected initial one answered with a final from 300 up (its 401 is the +// challenge). The status and its Retry-After feed imsd::engine::RegRecovery. struct RefusedError : std::runtime_error { int status; long retryAfter; @@ -693,20 +635,6 @@ struct RegState { // Contact user-part of the stored binding (UUID since the s53 oracle // diff; empty = legacy IMSI binding from an older state file). std::string contactUser; - // The answer to the last challenge — the 401's nonce, our cnonce and - // the response — which every reregistration carries verbatim (TS - // 24.229 5.1.1.4.2 a). Empty = a registration resumed from a pre-0.3.11 - // state file; its reregistrations carry an empty digest, the pre-0.3.11 - // shape, until the next fresh registration. - std::string authNonce, authCnonce, authResponse; - // The Security-Verify this registration's protected REGISTERs, its - // reg-event SUBSCRIBE and its INVITEs echo: the 401's whole - // Security-Server list (RFC 3329 §2.3.1). Set on the fresh 200 and at - // the resume, never from an attempt in flight: - // the member ss_ holds a fresh attempt's 401 before that attempt can - // still abort (the throttle, an unsupported mechanism), and the - // standing registration's state writes must not store it. - std::string securityServer; }; // Writable directory for the daemon's own files: the persisted registration @@ -721,21 +649,13 @@ std::string StateDir() { // tiny JSON reader/writer for the persisted registration context. std::string StatePath() { return EnvOr("STATE_FILE", std::format("{}/imsreg.state", StateDir())); } -// `security_server` is the registration's Security-Verify (RegState's -// securityServer: the 401's joined list); a resume takes it from here -// since 0.3.12. The file's values are quoted without escaping; a value -// with a quote, a backslash or a newline in it (none RFC 3329's token -// syntax produces) is written empty, and the resume falls back to the SA. -void PersistState(const RegState& r, const std::string& route, const std::string& ppi, const std::string& aor, const std::string& transport, const std::string& pcscf) { - std::string ss = r.securityServer.find_first_of("\"\\\n") == std::string::npos ? r.securityServer : std::string(); +void PersistState(const RegState& r, const std::string& route, const std::string& ppi, const std::string& aor, const std::string& transport) { std::string j = std::format( "{{\"callid\": \"{}\", \"ftag\": \"{}\", \"cseq\": {}, \"spi_uc\": {}, " "\"spi_us\": {}, \"expiry\": {}, \"route\": \"{}\", \"ppi\": \"{}\", " - "\"aor\": \"{}\", \"contact_user\": \"{}\", \"transport\": \"{}\", " - "\"auth_nonce\": \"{}\", \"auth_cnonce\": \"{}\", \"auth_response\": \"{}\", " - "\"security_server\": \"{}\", \"pcscf\": \"{}\"}}", + "\"aor\": \"{}\", \"contact_user\": \"{}\", \"transport\": \"{}\"}}", r.callid, r.ftag, r.cseq, r.spiUc, r.spiUs, r.expiry, route, ppi, aor, - r.contactUser, transport, r.authNonce, r.authCnonce, r.authResponse, ss, pcscf); + r.contactUser, transport); std::string path = StatePath(); std::error_code ec; std::filesystem::create_directories(std::filesystem::path(path).parent_path(), ec); @@ -759,7 +679,7 @@ void DumpRaw(std::string_view name, std::string_view msg) { } struct PersistedState { - std::optional callid, ftag, route, ppi, aor, contactUser, transport, authNonce, authCnonce, authResponse, securityServer, pcscf; + std::optional callid, ftag, route, ppi, aor, contactUser, transport; std::optional cseq, spiUc, spiUs, expiry; }; std::optional LoadState() { @@ -777,11 +697,6 @@ std::optional LoadState() { ps.aor = QuotedAfter(s, "\"aor\": \""); ps.contactUser = QuotedAfter(s, "\"contact_user\": \""); ps.transport = QuotedAfter(s, "\"transport\": \""); // absent in pre-0.3.5 files: TCP - ps.authNonce = QuotedAfter(s, "\"auth_nonce\": \""); // the three absent in pre-0.3.11 files - ps.authCnonce = QuotedAfter(s, "\"auth_cnonce\": \""); - ps.authResponse = QuotedAfter(s, "\"auth_response\": \""); - ps.securityServer = QuotedAfter(s, "\"security_server\": \""); // absent in pre-0.3.12 files - ps.pcscf = QuotedAfter(s, "\"pcscf\": \""); // absent in pre-1.0.0 files ps.cseq = IntAfter(s, "\"cseq\": "); ps.spiUc = IntAfter(s, "\"spi_uc\": "); ps.spiUs = IntAfter(s, "\"spi_us\": "); @@ -805,11 +720,11 @@ struct StatusSnapshot { // engine -> main-loop event struct Event { - enum class Type { Added, State, Deleted, Registration, Status, Fatal, Idle } type; + enum class Type { Added, State, Deleted, Registration, Status, Fatal } type; CallInfo info; // Added std::string uni, state, reason; // State/Deleted StatusSnapshot status; // Registration/Status - std::string text; // Fatal, Idle + std::string text; // Fatal }; GDBusConnection* DbusConn = nullptr; @@ -876,8 +791,8 @@ void PersistHolds(const imsd::engine::RegRecovery::Held& h, const std::string& p else if (std::filesystem::exists(st)) { if (std::FILE* f = std::fopen(path.c_str(), "w")) std::fclose(f); } return; } - std::string j = std::format("{{\"pdn\": \"{}\", \"boot\": \"{}\", \"written_at\": {}, \"hold\": {}, \"fresh_hold\": {}, \"throttle_minutes\": {}, \"old_gone\": {}, \"refusals\": {}}}\n", - pdn, BootId(), BootSeconds(), static_cast(std::ceil(h.hold)), static_cast(std::ceil(h.freshHold)), h.throttleMinutes, h.oldGone ? "true" : "false", h.refusals); + std::string j = std::format("{{\"pdn\": \"{}\", \"boot\": \"{}\", \"written_at\": {}, \"hold\": {}, \"fresh_hold\": {}, \"throttle_minutes\": {}, \"old_gone\": {}}}\n", + pdn, BootId(), BootSeconds(), static_cast(std::ceil(h.hold)), static_cast(std::ceil(h.freshHold)), h.throttleMinutes, h.oldGone ? "true" : "false"); if (std::FILE* f = std::fopen(path.c_str(), "w")) { std::fwrite(j.data(), 1, j.size(), f); std::fclose(f); @@ -891,7 +806,7 @@ std::optional LoadHolds(const std::string& pdn) char buf[512]; std::size_t n; while ((n = std::fread(buf, 1, sizeof buf, f)) > 0) s.append(buf, n); std::fclose(f); - if (imsd::util::CanonicalAddr(QuotedAfter(s, "\"pdn\": \"").value_or("")) != imsd::util::CanonicalAddr(pdn)) return std::nullopt; + if (QuotedAfter(s, "\"pdn\": \"").value_or("") != pdn) return std::nullopt; if (QuotedAfter(s, "\"boot\": \"").value_or("") != BootId()) return std::nullopt; long elapsed = BootSeconds() - IntAfter(s, "\"written_at\": ").value_or(0); imsd::engine::RegRecovery::Held h; @@ -899,7 +814,6 @@ std::optional LoadHolds(const std::string& pdn) h.freshHold = std::max(0, IntAfter(s, "\"fresh_hold\": ").value_or(0) - elapsed); h.throttleMinutes = static_cast(IntAfter(s, "\"throttle_minutes\": ").value_or(h.throttleMinutes)); h.oldGone = s.find("\"old_gone\": true") != std::string::npos; - h.refusals = static_cast(IntAfter(s, "\"refusals\": ").value_or(0)); if (!h.Any()) return std::nullopt; return h; } @@ -957,12 +871,6 @@ gboolean OnIdleEvent(gpointer data) { FatalExit = true; // non-zero exit, or Restart=on-failure never fires g_main_loop_quit(MainLoop); break; - case Event::Type::Idle: - // nothing to do (§7.3): a clean exit, which Restart=on-failure - // leaves alone - Log(std::format("idle: {} — nothing to do; systemctl start imsd when that changes", ev->text)); - g_main_loop_quit(MainLoop); - break; } return G_SOURCE_REMOVE; } @@ -975,25 +883,10 @@ void PostEvent(std::unique_ptr ev) { class Engine { public: Engine() { - // The P-CSCF a fresh registration goes to: PCSCF= when set, else the - // first address the bring-up read from the PDN's PCO - // (PCSCF_DISCOVERED=); no baked-in default — carrier infrastructure. - // A resume follows the kernel's SAs instead (TryResume), whatever - // this says today. - { - std::vector rejected; - pcscfTarget_ = FreshPcscfFromEnv(&pcscfCandidates_, &rejected); - pcscf_ = pcscfTarget_; - std::string configured = imsd::util::CanonicalAddr(EnvOr("PCSCF", "")); - std::string list; - for (const std::string& c : pcscfCandidates_) list += (list.empty() ? "" : ", ") + c; - if (!configured.empty()) - Log(pcscfCandidates_.empty() ? std::format("pcscf: configured {}", configured) - : std::format("pcscf: configured {}; discovery gave {} (ignored: PCSCF= wins)", configured, list)); - else if (!pcscfCandidates_.empty()) - Log(std::format("pcscf: discovered {} (using {})", list, pcscfTarget_)); - for (const std::string& r : rejected) Log(std::format("pcscf: PCSCF_DISCOVERED entry '{}' is not an IPv6 address; ignored", r)); - } + // No baked-in default: the P-CSCF is carrier infrastructure, learned + // from the IMS PDN's PCO on a stock stack. Until PCO discovery is + // implemented, PCSCF= must be configured (see README). + pcscf_ = EnvOr("PCSCF", ""); pcscfPort_ = std::atoi(EnvOr("PCSCF_PORT", "5060").c_str()); dev_ = EnvOr("DEV", "qmapmux0.0"); outDir_ = EnvOr("OUT_DIR", StateDir()); @@ -1034,7 +927,7 @@ public: } } - void SetLocal(std::string l) { local_ = imsd::util::CanonicalAddr(l); pdnLocal_ = local_; } + void SetLocal(std::string l) { local_ = l; pdnLocal_ = std::move(l); } void SetDevMode(bool d) { devMode_ = d; } const std::string& Local() const { return local_; } @@ -1053,10 +946,6 @@ public: std::scoped_lock g(cmdLock_); cmds_.push_back({Cmd::Kind::Accept, uni, {}}); } - void SendDtmf(const std::string& uni, const std::string& tones) { - std::scoped_lock g(cmdLock_); - cmds_.push_back({Cmd::Kind::Dtmf, uni, tones}); - } void Stop() { { std::scoped_lock g(cmdLock_); cmds_.push_back({Cmd::Kind::Stop, {}, {}}); } quit_.store(true); @@ -1145,7 +1034,7 @@ public: } private: - struct Cmd { enum class Kind { Dial, HangUp, Accept, Dtmf, Stop } kind; std::string uni, arg; }; // arg: the number to dial, or the tones + struct Cmd { enum class Kind { Dial, HangUp, Accept, Stop } kind; std::string uni, number; }; struct Stopping {}; // the engine was told to stop while a bring-up waited // A bring-up the network deferred (a fresh-SA throttle) or refused: wait @@ -1181,20 +1070,6 @@ private: } void KeepHolds() { PersistHolds(recovery_.Holds(Boot()), pdnLocal_); } - // what follows a resume that failed: with a fresh target the fresh - // registration, without one the restart's new resume attempt - std::string ResumeFailedNext() const { return pcscfTarget_.empty() ? "the restart tries the resume again" : "the next registration is a fresh one"; } - // a pair nothing can resume (no state file, the file another attempt's): with a fresh target the fresh registration replaces it; with none BringUp writes it off and takes it out of the kernel - std::string ResumeDeadNext() const { return pcscfTarget_.empty() ? "nothing can resume it: the pair is removed" : "resume falls back to fresh"; } - // the failed resume's line tail by the engine's verdict: a registration it - // wrote off is removed (no fresh target) or replaced by the fresh one; with - // no fresh target a resume it gives up (a 401, a refusal with a Retry-After, - // the second refusal) is removed too; else what ResumeFailedNext says - std::string ResumeFailedTail(const std::string& next) { - if (!recovery_.Reconnectable()) return pcscfTarget_.empty() ? "written off by the network: the pair is removed" : "written off by the network: the next registration is a fresh one"; - if (pcscfTarget_.empty() && recovery_.Fresh()) return "the resume is given up (the engine's rule): the pair is removed"; - return next; - } // dev/session mode without a modem: process commands so the ABI answers, // but there is no SIP socket — a Dial collapses to terminated/error. @@ -1207,15 +1082,6 @@ private: // config std::string pcscf_, dev_, outDir_, local_, ealgOffer_, mediaBin_; - // The P-CSCF a fresh registration goes to (PCSCF=, else the first of - // PCSCF_DISCOVERED=) and the whole discovered list. pcscf_ is the one in - // use: the same, or the kernel's SA peer while a resumed registration - // made to another one lives (TryResume) — the next fresh registration - // moves to the target, and back when it fails before the SA setup - // (FreshRecover), as local_ does against pdnLocal_. - std::string pcscfTarget_; - std::vector pcscfCandidates_; - static constexpr std::string_view NoFreshPcscf = "no P-CSCF for a fresh registration: none configured (PCSCF=) and none discovered (PCSCF_DISCOVERED empty — see the ims-pdn-up lines)"; // The PDN's own address (LOCAL, else DetectLocal) — every fresh // registration's. local_ differs from it only while a resume runs on the // address an older registration was made on (TryResume). @@ -1252,7 +1118,6 @@ private: bool dropCall_ = false; bool closeFlowWhenIdle_ = false; // a binding was terminated under a call: its flow closes with the call pid_t mediaPid_ = -1; - int mediaCtl_ = -1; // our end of the leg's control pipe (its stdin) double deadline_ = 0; // reply_ answers the inbound request being dispatched right now; // callReply_ is the call's durable response channel — refreshed from @@ -1304,15 +1169,7 @@ private: // ---- bring-up: warm resume if an SA + state file exist, else fresh ---- void BringUp() { - if (pcscfTarget_.contains(',')) throw std::runtime_error(std::format("PCSCF='{}' must be one address", pcscfTarget_)); - // PCSCF= is accepted in one spelling only: the one `ip` prints (RFC - // 5952 — what the journal and `ip xfrm` show). Another spelling is - // refused with the one to use rather than normalised, so the bring-up - // script, which routes the value as typed, and the daemon agree on it. - if (std::string raw = EnvOr("PCSCF", ""); !raw.empty()) { - auto canon = imsd::util::CanonicalIp6(raw); - if (!canon || *canon != raw) throw std::runtime_error(std::format("PCSCF='{}' is not written as ip prints it; use PCSCF={}", raw, canon.value_or(""))); - } + if (pcscf_.empty()) throw std::runtime_error("PCSCF not set — configure the carrier's P-CSCF address " "(P-CSCF discovery from the PDN's PCO is not implemented yet)"); auto card = RunCapture({"qmicli", "-d", "qrtr://0", "--uim-get-card-status"}); auto sel = imsd::aka::ParseCardStatus(card.out); if (!sel) throw std::runtime_error("no ready USIM found"); @@ -1341,67 +1198,25 @@ private: std::string resume = EnvOr("RESUME", "auto"); // A resume that the network refuses (403 after another process // registered fresh for the same IMPI, a stale binding, …) is not - // fatal while there is a fresh target: the fresh registration below - // replaces its SAs, and only a failed FRESH registration is. With no - // fresh target the bring-up ends as §7.2 step 1 says. + // fatal: the fresh registration below replaces its SAs. Only a + // failed FRESH registration is. // The network's word holds at bring-up as it does in recovery: a // hold from before this start is waited out before the resume (a // refresh's Retry-After) or the fresh registration (a fresh attempt's // schedule or Retry-After, a terminated binding's wait), and so is // one the resume itself earns — a refusal's Retry-After, a binding // a NOTIFY terminated under it. - ResumeOutcome outcome = ResumeOutcome::Dead; // not tried (RESUME=0, or written off before this start): a pair nothing resumes if (resume != "0") { if (!WaitForPolicy()) throw Stopping{}; // a registration the network wrote off before this start (the // holds carry it) is not resumed: the wait above was the fresh one - const std::string next = ResumeFailedNext(); - try { if (recovery_.Reconnectable()) { outcome = TryResume(); if (outcome == ResumeOutcome::Done) return; } } + try { if (recovery_.Reconnectable() && TryResume()) return; } catch (const RefusedError& e) { - outcome = ResumeOutcome::Failed; + Log(std::format("resume failed: {} — the next registration is a fresh one", e.what())); CloseFlowAfterWait(); - // the network's word (a Retry-After) reaches the reducer as before; - // with no fresh target every refusal does — its count travels in - // the holds, so a restart's refusal is the second (§7.4). With a - // target a refusal without one feeds nothing: the fresh - // registration follows at once, not after a self-imposed backoff - // the fresh path would log as the network's hold - if (e.retryAfter > 0 || pcscfTarget_.empty()) recovery_.OnReconnectFailure(Boot(), imsd::engine::RegRecovery::Failure::Refused, e.retryAfter, e.status); - Log(std::format("resume failed: {} — {}", e.what(), ResumeFailedTail(next))); + if (e.retryAfter > 0) recovery_.OnReconnectFailure(Boot(), imsd::engine::RegRecovery::Failure::Refused, e.retryAfter, e.status); } - catch (const std::exception& e) { outcome = ResumeOutcome::Failed; CloseFlowAfterWait(); Log(std::format("resume failed: {} — {}", e.what(), ResumeFailedTail(next))); } - // the engine's verdict decides, in one place for both arms: a - // registration it wrote off (a 403 once a fresh registration is - // due; a binding a NOTIFY terminated under the re-REGISTER wait, - // Imsd-Engine.cppm) is nothing to resume any more, and so, with no - // fresh target, is one whose resume it gives up (a 401, a refusal - // with a Retry-After, the second refusal — RefusalsBeforeFresh, - // the count surviving the restart): Dead, taken out below. With a - // target the fresh registration follows either way. - if (outcome == ResumeOutcome::Failed && (!recovery_.Reconnectable() || (pcscfTarget_.empty() && recovery_.Fresh()))) outcome = ResumeOutcome::Dead; - } - if (pcscfTarget_.empty()) { - // No fresh registration follows, so a resumable pair is not written - // off: one whose attempt failed (a silent connect, an - // unanswered or refused refresh — its Retry-After is in the - // holds) stays for the restart to resume (the fatal and the - // retry) — unless the engine's rule wrote the registration off or - // gave the resume up (a 403 once fresh is due, a NOTIFY-terminated - // binding; a 401, a refusal with a Retry-After, the second refusal — - // the count travels in the holds; Dead above); a pair nothing can - // resume (its UE address left the link, no - // state file, the file another attempt's, written off before this - // start) is written off and taken out of the kernel by exact key - // — the fresh registration that would have replaced it does not - // come — so the next bring-up finds nothing to resume, and the - // daemon idles (RequestIdle: a clean exit, §7.3): the PCO named no - // P-CSCF and nothing is configured, a fact until a boot or a - // start. A discovery that FAILED (the reader did not answer) is - // no fact: the fatal, and the restart runs it again. - if (outcome == ResumeOutcome::Dead) { recovery_.OldGone(); RemoveDeadPair(pdnLocal_); } - KeepHolds(); - if (outcome == ResumeOutcome::Failed || DiscoveryFailed()) throw std::runtime_error(std::string(NoFreshPcscf)); - RequestIdle(std::string(NoPcscfIdleText)); throw Stopping{}; + catch (const std::exception& e) { Log(std::format("resume failed: {} — the next registration is a fresh one", e.what())); CloseFlowAfterWait(); } } // a resume that failed found the stored SAs unusable (and after a // reboot there are none): nothing to reconnect over while a deferred @@ -1410,39 +1225,14 @@ private: portPs_ = 0; local_ = pdnLocal_; ctx_.local = local_; KeepHolds(); - pcscf_ = pcscfTarget_; - // the target's host route is imsd's own: the bring-up script routes the - // first address of its own read, not a list set in /etc/imsd.env nor a - // route lost with the PDN — the same idempotent replace as a media peer's - RunCmd({"ip", Is6(pcscf_) ? "-6" : "-4", "route", "replace", pcscf_, "dev", dev_}); if (recovery_.Next(Boot()).at > Boot()) Log(std::format("the network's hold stands; {}", NextStep())); if (!WaitForPolicy()) throw Stopping{}; FreshRegister(); } - // What a bring-up's resume attempt found: the registration (resumed), - // no pair of ours in the kernel, a pair nothing can resume, or a - // resumable pair whose attempt failed — worth a retry. - enum class ResumeOutcome { Done, None, Dead, Failed }; - - ResumeOutcome TryResume() { + bool TryResume() { auto state = RunCapture({"ip", "xfrm", "state"}); auto policy = RunCapture({"ip", "xfrm", "policy"}); - // The registration was made to whatever P-CSCF the kernel's SAs name - // (the SA setup takes imsd's previous pair out), whatever PCSCF= says - // today: resume there and move at the next fresh registration — a - // fresh one now would cost the registration, on KPN with the - // 21-minute refusal. A route to it exists while the netdev lives; - // the replace covers a target that changed under a running phone. - auto peer = imsd::ipsec::SaPeer(policy.out); - if (!peer) return ResumeOutcome::None; - if (*peer != pcscf_) { - bool listed = *peer == pcscfTarget_ || std::ranges::find(pcscfCandidates_, *peer) != pcscfCandidates_.end(); - Log(pcscfTarget_.empty() - ? std::format("pcscf: resuming on {}, the registration's SA peer (no P-CSCF configured or discovered)", *peer) - : std::format("pcscf: resuming on {}, the registration's SA peer; the next fresh registration goes to {}{}", *peer, pcscfTarget_, listed ? "" : " (the peer is not in today's list)")); - pcscf_ = *peer; - } auto sa = imsd::ipsec::ParseExistingSa(state.out, policy.out, pcscf_, local_); // The registration may sit on another address of the link: before // 0.3.10 imsd registered on the kernel's temporary one (ims ledger @@ -1464,59 +1254,20 @@ private: break; } } - if (!sa) { Log(std::format("the SA pair to {} has no SA on an address of {}: nothing to resume", pcscf_, dev_)); return ResumeOutcome::Dead; } - // the peer's host route is imsd's own once the SAs are confirmed (a peer - // named by a pair that is not ours gets no route into the PDN), whether - // or not the bring-up script routed the same address — idempotent - RunCmd({"ip", Is6(pcscf_) ? "-6" : "-4", "route", "replace", pcscf_, "dev", dev_}); + if (!sa) return false; auto ps = LoadState(); - if (!ps || !ps->callid) { Log(std::format("no reg state file; {}", ResumeDeadNext())); return ResumeOutcome::Dead; } - // A file another attempt left behind — one that got through the SA - // setup and did not live to record, to this or another P-CSCF — names - // another SA pair than the kernel's (its spi_uc/spi_us; a 0.3.x file - // carries them too): its dialog is that attempt's, and a Call-ID - // cannot be rebuilt from an SA (the Security-Verify can — - // ChooseResumeSecurityVerify). - if (imsd::ipsec::FileNamesAnotherPair(ps->spiUc, ps->spiUs, sa->spiUc, sa->spiUs)) { - Log(std::format("the state file names another SA pair ({:#x}/{:#x}{}) than the kernel's ({:#x}/{:#x} to {}): another attempt's; {}", - static_cast(*ps->spiUc), static_cast(*ps->spiUs), - ps->pcscf && !ps->pcscf->empty() ? std::format(" to {}", imsd::util::CanonicalAddr(*ps->pcscf)) : std::string(), - sa->spiUc, sa->spiUs, pcscf_, ResumeDeadNext())); - return ResumeOutcome::Dead; - } + if (!ps || !ps->callid) { Log("no reg state file; resume falls back to fresh"); return false; } // The registration's transport is what the P-CSCF knows; a resume // must reuse it. A forced policy that disagrees means a fresh start // — decided before anything from the file is adopted. Transport t = ps->transport.value_or("TCP") == "UDP" ? Transport::Udp : Transport::Tcp; - if (sipPolicy_ != "auto" && t != transport_) { Log(std::format("state file transport differs from SIP_TRANSPORT; {}", ResumeFailedNext())); return ResumeOutcome::Failed; } + if (sipPolicy_ != "auto" && t != transport_) { Log("state file transport differs from SIP_TRANSPORT; fresh"); return false; } route_ = ps->route.value_or(std::format("", imsd::util::HostPort(pcscf_, sa->portPs))); // No fallback identity: an empty ppi omits P-Preferred-Identity and // is re-learned from the next 200's P-Associated-URI. ppi_ = ps->ppi.value_or(""); - // The Security-Verify the protected REGISTERs, the reg-event - // SUBSCRIBE and the INVITEs echo: the 401's whole Security-Server - // list (RFC 3329 §2.3.1), from the state file since 0.3.12, or the - // one-mechanism entry rebuilt from the SA — Imsd:Ipsec chooses - // (ChooseResumeSecurityVerify). The rebuilt entry is KPN's answer to - // the default offer in the same bytes, not a list such as O2's six, - // which the RFC has the server answer with a 494; a refused resume - // registers fresh where there is a fresh target (§7.2 step 1 otherwise). - auto rsv = imsd::ipsec::ChooseResumeSecurityVerify(ps->securityServer.value_or(""), *sa, ealgOffer_); - ss_ = rsv.value; - using Source = imsd::ipsec::ResumeSecurityVerify::Source; - switch (rsv.source) { - case Source::Stored: - Log(std::format("Security-Verify from the state file ({} mechanism{})", rsv.mechanisms, rsv.mechanisms == 1 ? "" : "s")); - break; - case Source::NoneStored: - Log("state file holds no Security-Server (a pre-0.3.12 file, or a list the file cannot hold): Security-Verify rebuilt from the SA as one mechanism"); - break; - case Source::OtherSa: - Log(std::format("state file's Security-Server names another SA pair than the one in the kernel (spi-s {} vs {}): Security-Verify rebuilt from the SA as one mechanism", - rsv.storedSpiPs ? std::to_string(*rsv.storedSpiPs) : "none", sa->spiPs)); - break; - } + ss_ = sa->securityServer; // The public identity our requests are signed with (From) — restored // here so a call placed before the refresh 200 re-learns it does not // fall back to the barred temporary IMPU. @@ -1530,8 +1281,8 @@ private: // Local refusals (TIME_WAIT after a restart) keep retrying; two // silences mean the flow is not coming back — fresh instead. if (!sip_.Connect(local_, PortUc, pcscf_, sa->portPs, transport_, 10, 2)) { - Log(std::format("resume connect failed; {}", ResumeFailedNext())); - return ResumeOutcome::Failed; + Log("resume connect failed; falling back to fresh"); + return false; } reg_.callid = *ps->callid; reg_.ftag = ps->ftag.value_or(""); @@ -1544,17 +1295,14 @@ private: reg_.spiUc = static_cast(ps->spiUc.value_or(sa->spiUc)); reg_.spiUs = static_cast(ps->spiUs.value_or(sa->spiUs)); reg_.expiry = ps->expiry.value_or(0); - reg_.securityServer = ss_; - reg_.authNonce = ps->authNonce.value_or(""); reg_.authCnonce = ps->authCnonce.value_or(""); reg_.authResponse = ps->authResponse.value_or(""); - if (reg_.authNonce.empty()) Log("state file holds no challenge answer (pre-0.3.11): the refresh carries an empty digest"); - auto [okMsg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1); + auto [okMsg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1, reg_.spiUc, reg_.spiUs); reg_.cseq = used; if (auto e = imsd::sip::GrantedExpires(okMsg)) reg_.expiry = *e; UpdateRouteAndPpi(okMsg); LogBindings(okMsg); - PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport, pcscf_); + PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport); Log("REGISTERED (resumed + true-refreshed)"); - return ResumeOutcome::Done; + return true; } // The registrar's 200 OK echoes EVERY current binding for the implicit @@ -1660,13 +1408,6 @@ private: sp.portUc = PortUc; sp.portUs = PortUs; sp.portPs = portPs; sp.portPc = portPc; sp.alg = alg; sp.ealg = ealg; - // imsd's previous pair out first, each entry by its key — not `ip xfrm … - // flush`: another IPsec user's entries (a policy-based VPN's) stay (§12) - { - auto state = RunCapture({"ip", "xfrm", "state"}); - auto policy = RunCapture({"ip", "xfrm", "policy"}); - RunOwnXfrmDeletes(imsd::ipsec::OwnXfrmDeleteCommands(state.out, policy.out, local_)); - } saReplaced_ = true; // from here on the old registration's SAs are gone for (auto& cmd : imsd::ipsec::BuildSetupCommands(sp)) { int rc = RunCmd(cmd); @@ -1697,7 +1438,7 @@ private: if (autoTcp) { // Two unanswered TCP connects: treat the P-CSCF as UDP-only // and register again over UDP — a new challenge and a new SA - // pair (the SA setup takes this one out), same ports. + // pair (the SA setup flushes this one), same ports. Log("protected TCP connect unanswered; registering fresh over UDP"); SetTransport(Transport::Udp); FreshRegisterAttempt(); @@ -1720,19 +1461,13 @@ private: reg_.callid = callidReg; reg_.ftag = ftag; reg_.cseq = 2; reg_.contactUser = contactUser; reg_.spiUc = spiUc; reg_.spiUs = spiUs; - reg_.authNonce = *nonceB64; reg_.authCnonce = cnonce; reg_.authResponse = response; - reg_.securityServer = ss_; reg_.expiry = imsd::sip::GrantedExpires(ok).value_or(0); DumpRaw("imsd-register-200.raw", ok); - // The Route for everything over this registration is the P-CSCF it - // was made at — never a previous one's, after a move from the - // resumed peer — unless the 200 carries a Service-Route, which - // UpdateRouteAndPpi puts in its place. - route_ = std::format("", imsd::util::HostPort(pcscf_, portPs)); UpdateRouteAndPpi(ok); LogBindings(ok); + if (route_.empty()) route_ = std::format("", imsd::util::HostPort(pcscf_, portPs)); ctx_.route = route_; ctx_.ppi = ppi_; - PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport, pcscf_); + PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport); Log("REGISTERED (fresh)"); } @@ -1816,43 +1551,39 @@ private: throw std::runtime_error("no 401 to unprotected REGISTER"); } - // A reregistration over the (resumed or live) SA, TS 24.229 5.1.1.4.2: - // the same Call-ID and Contact, CSeq+1, the Authorization of the last - // successful authentication, a Security-Client with new SPIs and a new - // client port for the SA pairs a re-authentication would set up (the - // server port stays, TS 33.203 7.4.0), the Security-Verify (the 401's - // list, kept in the state file since 0.3.12 so a resumed registration - // echoes it too; a resume from an older file sends the one entry - // rebuilt from the kernel's SA). Returns (200 msg, used cseq). A 401 - // asks for re-authentication over temporary SAs on the proposed ports, - // which imsd does not do: it is thrown as a refusal whose recovery is a - // fresh registration (RegRecovery's 401 rule). Every refresh of the - // pre-0.3.11 shape on record from O2 UK — an initial registration's - // empty digest, the in-use SPIs — was refused 503 (2026-09-24 under - // 0.3.9, 2026-10-08 under 0.3.10) while the fresh registrations that - // followed were accepted; KPN accepted both shapes without a challenge. - std::pair Reregister(const std::string& callid, const std::string& ftag, long cseq) { - std::string auth = reg_.authNonce.empty() ? imsd::msg::AuthEmpty(ctx_) - : imsd::msg::AuthAka(ctx_, reg_.authNonce, reg_.authCnonce, reg_.authResponse); - std::uint32_t spiUc = 0, spiUs = 0; - do spiUc = rng_.UInt(0x10000, 0xFFFFFFF); while (spiUc == reg_.spiUc || spiUc == reg_.spiUs); - do spiUs = rng_.UInt(0x10000, 0xFFFFFFF); while (spiUs == reg_.spiUc || spiUs == reg_.spiUs || spiUs == spiUc); - std::string reg = imsd::msg::BuildRegisterProtected(ctx_, callid, ftag, rng_.Token(16), cseq, spiUc, spiUs, auth, PortUcNext); - if (!sip_.Send(reg)) throw std::runtime_error("send re-REGISTER failed"); - std::string m = AwaitFinal(callid, cseq, "re-REGISTER"); - auto st = imsd::sip::Status(m); - if (st == 200) return {m, cseq}; - DumpRaw("imsd-register-fail.raw", m); - if (st == 401) throw RefusedError(std::format("re-REGISTER challenged: {}; re-authentication asked, the next registration is a fresh one", imsd::sip::RefusalSummary(m)), 401, imsd::sip::RetryAfter(m).value_or(0)); - throw RefusedError(std::format("re-REGISTER refused: {}", imsd::sip::RefusalSummary(m)), *st, imsd::sip::RetryAfter(m).value_or(0)); + // re-REGISTER over the (resumed or live) SA. Returns (200 msg, used cseq). + std::pair Reregister(const std::string& callid, const std::string& ftag, long startCseq, std::uint32_t spiUc, std::uint32_t spiUs) { + std::string auth = imsd::msg::AuthEmpty(ctx_); + long cseq = startCseq; + bool challenged = false; + for (;;) { + std::string reg = imsd::msg::BuildRegisterProtected(ctx_, callid, ftag, rng_.Token(16), cseq, spiUc, spiUs, auth); + if (!sip_.Send(reg)) throw std::runtime_error("send re-REGISTER failed"); + std::string m = AwaitFinal(callid, cseq, "re-REGISTER"); + auto st = imsd::sip::Status(m); + if (st == 200) return {m, cseq}; + if (st == 401 && !challenged) { + challenged = true; + auto nonce = QuotedAfter(m, "nonce=\""); + auto raw = nonce ? imsd::util::FromBase64(*nonce) : std::nullopt; + if (!raw || raw->size() < 32) throw std::runtime_error("bad 401 nonce on refresh"); + std::vector rnd(raw->begin(), raw->begin() + 16); + std::vector autn(raw->begin() + 16, raw->begin() + 32); + auto aka = Authenticate(rnd, autn); + if (!aka) throw std::runtime_error("AKA failed on refresh"); + std::string cnonce = rng_.Token(16); + std::string response = imsd::aka::DigestAkav1(*nonce, aka->res, cnonce, ctx_.id.regUri, ctx_.id.impi, ctx_.id.domain); + auth = imsd::msg::AuthAka(ctx_, *nonce, cnonce, response); + cseq++; + continue; + } + DumpRaw("imsd-register-fail.raw", m); + throw RefusedError(std::format("re-REGISTER refused: {}", imsd::sip::RefusalSummary(m)), *st, imsd::sip::RetryAfter(m).value_or(0)); + } } void UpdateRouteAndPpi(const std::string& okMsg) { - // an empty Service-Route value carries no route: ignored like a - // missing header, so the fallback Route the caller set stands - auto svcAll = imsd::sip::Headers(okMsg, "Service-Route"); - decltype(svcAll) svc; - for (auto& v : svcAll) if (!v.empty()) svc.push_back(v); + auto svc = imsd::sip::Headers(okMsg, "Service-Route"); if (svc.empty()) Log("200: no Service-Route (route stays P-CSCF)"); if (!svc.empty()) { std::string r; @@ -1931,20 +1662,17 @@ private: if (Mono() - lastRefresh_ < interval) return; try { RefreshPani(); - auto [msg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1); + auto [msg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1, reg_.spiUc, reg_.spiUs); reg_.cseq = used; if (auto e = imsd::sip::GrantedExpires(msg)) reg_.expiry = *e; - PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport, pcscf_); + PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport); Log(std::format("keepalive re-REGISTER ok (cseq {})", used)); OnRegistered(); } catch (const RefusedError& e) { // The network refused the refresh: the recovery policy counts it - // — a refusal with a Retry-After of at least a second, or a 401 - // (the refresh throws a challenge as a refusal), makes the fresh - // registration the next step, behind any wait the network set; a - // refusal with neither gets one more refresh first, a second - // refusal then escalates — and the flow is dropped so the next - // step runs after the wait. + // (a second refusal means a fresh registration) and the flow is + // dropped so the reconnect step retries the refresh after the + // wait (Retry-After when given). Log(std::format("keepalive re-REGISTER failed: {}", e.what())); CloseFlowAfterWait(); RecoveryFailed(imsd::engine::RegRecovery::Failure::Refused, e.retryAfter, "keepalive refresh refused", e.status); @@ -2040,12 +1768,12 @@ private: } try { RefreshPani(); - auto [msg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1); + auto [msg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1, reg_.spiUc, reg_.spiUs); reg_.cseq = used; if (auto e = imsd::sip::GrantedExpires(msg)) reg_.expiry = *e; UpdateRouteAndPpi(msg); LogBindings(msg); - PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport, pcscf_); + PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport); Log("reconnected + re-registered"); OnRegistered(); } catch (const RefusedError& e) { @@ -2077,7 +1805,7 @@ private: } // The rest of what a restart does, in place: a new Call-ID, a new - // challenge, a new SA pair (FreshRegister takes the old ones out), then + // challenge, a new SA pair (FreshRegister flushes the old ones), then // the post-registration steps. A refused or throttled attempt waits on // the throttle schedule (a wait no Dial cuts short — on KPN a refused // attempt costs the old registration) and a silent one on the @@ -2098,21 +1826,12 @@ private: // a registration resumed on an older address (a pre-0.3.10 temporary // one) moves to the PDN's own address here, server ports included; // an attempt that fails before the SA setup leaves that registration - // standing, so it moves back to it — the P-CSCF too, once the address - // verdict below says the registration still stands - if (pcscfTarget_.empty()) { RequestRestart(std::string(NoFreshPcscf)); return; } - std::string fromPcscf = pcscf_; - if (pcscf_ != pcscfTarget_) { - Log(std::format("the fresh registration moves from P-CSCF {} to {}", pcscf_, pcscfTarget_)); - pcscf_ = pcscfTarget_; - } + // standing, so it moves back to it std::string from = local_; if (local_ != pdnLocal_) { Log(std::format("the fresh registration moves from {} to the PDN's own address {}", local_, pdnLocal_)); MoveTo(pdnLocal_); } - // the fresh target's host route, imsd's own (BringUp's fresh path does the same) - RunCmd({"ip", Is6(pcscf_) ? "-6" : "-4", "route", "replace", pcscf_, "dev", dev_}); bool ok = false; try { RefreshPani(); @@ -2138,12 +1857,6 @@ private: Log(std::format("{} has left {}: the registration on it is gone; staying on {}; {}", from, dev_, local_, NextStep())); } } - // after the address verdict: a registration just declared gone has - // no P-CSCF to go back to - if (!ok && !quit_.load() && recovery_.Reconnectable() && pcscf_ != fromPcscf) { - Log(std::format("back to P-CSCF {}: the old registration stands until the next fresh attempt", fromPcscf)); - pcscf_ = fromPcscf; - } } // Our address for everything from here on: the flow, the server ports, @@ -2198,16 +1911,6 @@ private: PostEvent(std::move(ev)); } - // Leave cleanly: nothing to do (§7.3); the - // unit stays inactive until the next boot, a `systemctl start imsd` or a - // start of imsd-ofonod (its Wants=imsd.service, the Phosh image). - void RequestIdle(const std::string& why) { - quit_.store(true); - auto ev = std::make_unique(); - ev->type = Event::Type::Idle; ev->text = why; - PostEvent(std::move(ev)); - } - // After a registration succeeds — bring-up, a keepalive refresh, a // reconnect + refresh, or a fresh recovery: the status (a signal only // when it changes), the timers, the recovery policy, the reg-event @@ -2374,8 +2077,8 @@ private: bool ClearForDial(const Cmd& c, bool emergency) { if (!call_) return true; if (!emergency) { - Log(std::format("dial({}) refused: call in progress", c.arg)); - CallInfo bad{c.uni, c.arg, "terminated", "error", NowEpoch(), 0}; + Log(std::format("dial({}) refused: call in progress", c.number)); + CallInfo bad{c.uni, c.number, "terminated", "error", NowEpoch(), 0}; PostAdded(bad); PostState(c.uni, "terminated", "error"); PostDeleted(c.uni); @@ -2391,7 +2094,7 @@ private: void HandleCmd(const Cmd& c) { if (c.kind == Cmd::Kind::Stop) { quit_.store(true); return; } if (c.kind == Cmd::Kind::Dial) { - bool emergency = imsd::msg::IsEmergency(c.arg, emergencyExtra_); + bool emergency = imsd::msg::IsEmergency(c.number, emergencyExtra_); if (!ClearForDial(c, emergency)) return; // a dead client flow fails the INVITE instantly — give the // recovery path one immediate shot first: user action cuts the @@ -2410,9 +2113,9 @@ private: if (emergency) Log(std::format("EMERGENCY dial ({}): urn:service:sos over the live registration, " "fallback plain INVITE — stage 1, no emergency registration/PDN; " - "carrier-side behavior UNVERIFIED", c.arg)); - call_.emplace(ctx_, rng_, c.uni, c.arg, rtpPort_, precond_, emergency, codecs_); - CallInfo info{c.uni, c.arg, "dialing", "outgoing", NowEpoch(), 0}; + "carrier-side behavior UNVERIFIED", c.number)); + call_.emplace(ctx_, rng_, c.uni, c.number, rtpPort_, precond_, emergency, codecs_); + CallInfo info{c.uni, c.number, "dialing", "outgoing", NowEpoch(), 0}; PostAdded(info); bool ok = Execute(call_->Start()); if (!ok && call_ && !call_->Terminated()) { Execute(call_->Fail("error")); } @@ -2422,9 +2125,6 @@ private: } else if (c.kind == Cmd::Kind::Accept) { if (call_ && call_->Uni() == c.uni) { Execute(call_->OnAccept()); MaybeDropCall(); } else Log(std::format("Accept({}) ignored: no such ringing call", c.uni)); - } else if (c.kind == Cmd::Kind::Dtmf) { - if (call_ && call_->Uni() == c.uni) Execute(call_->OnDtmf(c.arg)); - else Log(std::format("SendDtmf({}, '{}') ignored: no such call", c.uni, c.arg)); } } @@ -2524,7 +2224,6 @@ private: break; case T::StartMedia: StartMedia(a.media); break; case T::StopMedia: StopMedia(); break; - case T::SendDtmf: SendDtmfToMedia(a.text); break; case T::State: if (announce) PostState(call_->Uni(), a.state, a.reason); break; @@ -2551,9 +2250,7 @@ private: void StartMedia(const imsd::engine::MediaLeg& leg) { if (!leg.Valid()) { Log("SDP answer missing media endpoint; no media leg"); return; } - Log(std::format("media {} pt={} octet={} -> [{}]:{}, telephone-event pt {}{}", leg.codec, leg.payloadType, leg.octetAlign, leg.remoteIp, leg.remotePort, - leg.dtmfPt ? std::to_string(*leg.dtmfPt) : std::string("none"), - leg.dtmfRxPt && leg.dtmfRxPt != leg.dtmfPt ? std::format(" (theirs {})", *leg.dtmfRxPt) : std::string())); + Log(std::format("media {} pt={} octet={} -> [{}]:{}", leg.codec, leg.payloadType, leg.octetAlign, leg.remoteIp, leg.remotePort)); if (Is6(leg.remoteIp)) RunCmd({"ip", "-6", "route", "replace", leg.remoteIp, "dev", dev_}); else RunCmd({"ip", "route", "replace", leg.remoteIp, "dev", dev_}); @@ -2567,21 +2264,8 @@ private: session = ResolveAudioSession(); Log(AudioSessionLine(session) + (session.name.empty() ? "; this call has no audio" : "")); } - // the control pipe: imsd-media reads `dtmf ` lines on its - // stdin (§7.6); our end closes with the leg, no other child inherits - // it, and it never blocks the engine thread — a leg that stopped - // reading fails the write (EAGAIN once the pipe is full) instead - int ctl[2] = {-1, -1}; - if (pipe(ctl) != 0) { - Log(std::format("media control pipe: {}; this call has no DTMF", std::strerror(errno))); - } else { - fcntl(ctl[0], F_SETFD, FD_CLOEXEC); - fcntl(ctl[1], F_SETFD, FD_CLOEXEC); - fcntl(ctl[1], F_SETFL, O_NONBLOCK); - } pid_t pid = fork(); if (pid == 0) { - if (ctl[0] >= 0) dup2(ctl[0], STDIN_FILENO); // dup2 clears the close-on-exec if (geteuid() == 0) { // no session is said with an empty value: imsd-media reads a // missing variable as a caller older than 0.3.10 @@ -2595,43 +2279,16 @@ private: setenv("DTX", EnvOr("DTX", "0").c_str(), 1); setenv("OCTET_ALIGN", leg.octetAlign ? "1" : "0", 1); setenv("CODEC", leg.codec.c_str(), 1); - setenv("DTMF_PT", leg.dtmfPt ? std::to_string(*leg.dtmfPt).c_str() : "", 1); - setenv("DTMF_RX_PT", leg.dtmfRxPt ? std::to_string(*leg.dtmfRxPt).c_str() : "", 1); std::vector c; for (auto& s : argv) c.push_back(const_cast(s.c_str())); c.push_back(nullptr); execvp(c[0], c.data()); _exit(127); } - if (ctl[0] >= 0) close(ctl[0]); - if (pid < 0 && ctl[1] >= 0) { close(ctl[1]); ctl[1] = -1; } mediaPid_ = pid; - mediaCtl_ = ctl[1]; - } - - // The engine's SendDtmf action: one `dtmf ` line down the - // control pipe, whole or not at all — a write of at most PIPE_BUF bytes - // to a pipe is atomic, and the engine's DtmfMaxTones keeps the line - // under it (a longer line is refused here too, never half-written to - // be joined with the next). The leg may have died since the action was - // decided (its exit is reaped on this thread, after the actions ran): - // then the write fails with EPIPE — SIGPIPE is ignored in main() — and - // the tones are lost with the call, which the reap ends. - void SendDtmfToMedia(const std::string& tones) { - if (mediaCtl_ < 0) { Log(std::format("DTMF '{}' not sent: no control pipe to the media leg", tones)); return; } - const std::string line = std::format("dtmf {}\n", tones); - if (line.size() > PIPE_BUF) { Log(std::format("DTMF of {} tones not sent: longer than the control pipe's atomic write", tones.size())); return; } - const ssize_t n = write(mediaCtl_, line.data(), line.size()); - if (n != static_cast(line.size())) Log(std::format("DTMF '{}' not sent: {}", tones, n < 0 ? std::strerror(errno) : "short write to the media leg")); - } - - void CloseMediaCtl() { - if (mediaCtl_ >= 0) close(mediaCtl_); - mediaCtl_ = -1; } void StopMedia() { - CloseMediaCtl(); if (mediaPid_ <= 0) return; kill(mediaPid_, SIGTERM); for (int i = 0; i < 100; i++) { @@ -2651,7 +2308,6 @@ private: if (r == 0) return; // still running int code = WIFEXITED(status) ? WEXITSTATUS(status) : -1; mediaPid_ = -1; // reaped; don't re-terminate - CloseMediaCtl(); Execute(call_->OnMediaExit(code)); MaybeDropCall(); } @@ -2729,10 +2385,7 @@ void HandleMethodCall(GDBusConnection*, const gchar*, const gchar*, const gchar* return; } if (m == "SendDtmf") { - const gchar* uni = nullptr; - const gchar* tones = nullptr; - g_variant_get(params, "(&s&s)", &uni, &tones); - TheEngine->SendDtmf(uni ? uni : "", tones ? tones : ""); + Log("SendDtmf accepted but not yet sent (TODO RFC4733)"); g_dbus_method_invocation_return_value(inv, nullptr); return; } @@ -2796,22 +2449,6 @@ int main(int argc, char** argv) { std::println(std::cerr, "imsd: must run as root (xfrm / qmicli)"); return 1; } - // A write to the media leg's control pipe after the leg died must fail - // with EPIPE, not end the daemon (the client flow's sends carry - // MSG_NOSIGNAL; an inbound connection's reply is a plain write and is - // covered by this too). - signal(SIGPIPE, SIG_IGN); - - // The bring-up found nothing to do (IMSD_IDLE= in /run/imsd.env — the - // script's arms: no SIM, a refused ims PDN): say so and - // leave cleanly. Restart=on-failure leaves a clean exit alone, so the - // unit stays inactive until the next boot, a manual start or a start of - // imsd-ofonod (its Wants=imsd.service). - if (std::string idle = EnvOr("IMSD_IDLE", ""); !idle.empty()) { - Log(std::format("idle: {} — nothing to do; systemctl start imsd when that changes", - idle == "no-sim" ? "no SIM" : idle == "no-ims-pdn" ? "no ims PDN from the network" : idle)); - return 0; - } std::string dev = EnvOr("DEV", "qmapmux0.0"); std::string local = EnvOr("LOCAL", ""); @@ -2822,31 +2459,7 @@ int main(int argc, char** argv) { std::println(std::cerr, "imsd: no global IPv6 on {} — is the ims PDN up?", dev); return 1; } - local = imsd::util::CanonicalAddr(local); - std::string freshPcscf = FreshPcscfFromEnv(); - Log(std::format("LOCAL={} P-CSCF=[{}]:{}", local, freshPcscf.empty() ? "(none)" : freshPcscf, EnvOr("PCSCF_PORT", "5060"))); - // The daemon's own dead end, decided here, before the bus name, so the - // exit is clean and the unit stays inactive (§7.3): nothing to register - // to — no PCSCF=, nothing discovered — and no registration of its own to - // resume (SaPeer finds no pair, or the holds' write-off for this PDN and - // boot says it cannot). The script decides the dead ends only it can see - // (no SIM, a refused ims PDN). A discovery that failed is no dead end: - // exit 1, and the unit's restart runs it again. - if (!session && freshPcscf.empty()) { - auto policy = RunCapture({"ip", "xfrm", "policy"}); - auto holds = LoadHolds(local); - auto peer = imsd::ipsec::SaPeer(policy.out); - const bool writtenOff = holds && holds->oldGone; - if (!peer || writtenOff) { - // a pair the holds wrote off before this start is nothing to - // resume: taken out of the kernel here, as the no-target bring-up - // takes a dead pair out (§7.2 step 1), so no start of this boot - // finds it — the holds keep the write-off - if (peer && writtenOff) RemoveDeadPair(local); - if (DiscoveryFailed()) { Log(std::format("{} — exiting for systemd restart", NoPcscfDiscoveryFailed)); return 1; } - Log(std::format("idle: {} — nothing to do; systemctl start imsd when that changes", NoPcscfIdleText)); return 0; - } - } + Log(std::format("LOCAL={} P-CSCF=[{}]:{}", local, EnvOr("PCSCF", "(unset)"), EnvOr("PCSCF_PORT", "5060"))); if (!session) LogAudioSession(); imsd::bus::AuthorizeCalls = !session; diff --git a/implementations/media.cpp b/implementations/media.cpp index 3d24966..7f37fa7 100644 --- a/implementations/media.cpp +++ b/implementations/media.cpp @@ -26,23 +26,11 @@ DTX silence (every missing 20 ms slot is decoded as a NO_DATA frame for CNG/PLC; zeros for G.711). MIC=1 feeds live pw-record audio through the encoder. -DTMF goes out as RFC 4733 telephone-event packets at the payload type the -SDP negotiated for the codec's clock (DTMF_PT; unset = none): stdin is the -daemon's control pipe, and each `dtmf ` line queues its tones (0-9, -*, #, A-D). A tone takes seven 20 ms slots of the uplink in place of the -audio frames — the event's five (100 ms at -10 dBm0) and two in which its -end packet goes out again — then three slots of audio run before the next -tone. The far end's events — taken at DTMF_RX_PT (default DTMF_PT) and -at DTMF_PT, never at the audio's payload type — are played as the tones -they stand for, in step with the audio clock. - Usage: imsd-media imsd-media --selftest (payload pack/depay roundtrip, both formats, - both AMR codecs; G.711 table roundtrip; the - telephone-event payload, codes and Q.23 - frequency pairs) + both AMR codecs; G.711 table roundtrip) Env: CODEC MIC PLAY GAIN PLAY_GAIN AMR_MODE DTX MEDIA_TIMEOUT RTP_DUMP -OCTET_ALIGN AUDIO_USER MIC_SRC PCM_DUMP DTMF_PT DTMF_RX_PT +OCTET_ALIGN AUDIO_USER MIC_SRC PCM_DUMP (OCTET_ALIGN=0 selects RFC 4867 bandwidth-efficient payloads both ways; the daemon sets it from the negotiated SDP — 1 is the default/legacy behavior. AMR_MODE is the encoder mode of whichever AMR codec is active; default 2 @@ -57,7 +45,6 @@ with no PipeWire and no network.) #include #include #include -#include #include #include #include @@ -484,13 +471,8 @@ Child SpawnPw(bool play, bool toChild, int rate) { if (toChild) { dup2(pipefd[0], STDIN_FILENO); } else { dup2(pipefd[1], STDOUT_FILENO); } close(pipefd[0]); close(pipefd[1]); - int devnull = open("/dev/null", O_RDWR); - if (devnull >= 0) { - dup2(devnull, STDERR_FILENO); - // our stdin is the daemon's control pipe: not pw-record's to hold - if (!toChild) dup2(devnull, STDIN_FILENO); - close(devnull); - } + int devnull = open("/dev/null", O_WRONLY); + if (devnull >= 0) { dup2(devnull, STDERR_FILENO); close(devnull); } std::vector cargv; for (auto& s : argv) cargv.push_back(const_cast(s.c_str())); cargv.push_back(nullptr); @@ -525,32 +507,19 @@ struct TxState { sockaddr_storage latched{}; socklen_t latchedLen = 0; std::uint64_t tx = 0; std::uint64_t txMic = 0; - std::uint64_t txDtmf = 0; std::atomic micOn{false}; }; -// One packet on the uplink, audio or event. Every packet stands in one -// 20 ms slot of the stream's single timestamp base (RFC 4733 §2.1: events -// share the audio's sequence and timestamp space), so the next slot's -// timestamp advances whichever kind went out; an audio frame is stamped -// with its slot (`ts` empty), an event packet with the event's start -// (`ts` given — constant over the event, §2.5.1.2). Returns the timestamp -// used. -std::uint32_t RtpSendPacket(int sock, TxState& st, int pt, bool marker, std::optional ts, std::span payload) { +void RtpSend(int sock, TxState& st, std::span payload) { std::uint8_t hdr[12]; - sockaddr_storage a1; - sockaddr_storage a2; - socklen_t l1; - socklen_t l2; - std::uint32_t stamp; + sockaddr_storage a1, a2; socklen_t l1, l2; { std::scoped_lock g(st.lock); - stamp = ts.value_or(st.ts); hdr[0] = 0x80; - hdr[1] = static_cast((marker ? 0x80 : 0) | (pt & 0x7F)); + hdr[1] = static_cast(st.pt & 0x7F); hdr[2] = (st.seq >> 8) & 0xFF; hdr[3] = st.seq & 0xFF; - hdr[4] = (stamp >> 24) & 0xFF; hdr[5] = (stamp >> 16) & 0xFF; - hdr[6] = (stamp >> 8) & 0xFF; hdr[7] = stamp & 0xFF; + hdr[4] = (st.ts >> 24) & 0xFF; hdr[5] = (st.ts >> 16) & 0xFF; + hdr[6] = (st.ts >> 8) & 0xFF; hdr[7] = st.ts & 0xFF; hdr[8] = (st.ssrc >> 24) & 0xFF; hdr[9] = (st.ssrc >> 16) & 0xFF; hdr[10] = (st.ssrc >> 8) & 0xFF; hdr[11] = st.ssrc & 0xFF; st.seq = (st.seq + 1) & 0xFFFF; @@ -568,126 +537,8 @@ std::uint32_t RtpSendPacket(int sock, TxState& st, int pt, bool marker, std::opt sendTo(a1, l1); // avoid double-send when latched == dst if (l2 && (l2 != l1 || std::memcmp(&a1, &a2, l1) != 0)) sendTo(a2, l2); - return stamp; } -// an audio frame: the stream's payload type, stamped with its slot -void RtpSend(int sock, TxState& st, std::span payload) { - RtpSendPacket(sock, st, st.pt, false, std::nullopt, payload); -} - -// ---- DTMF: RFC 4733 telephone-event packets on the uplink ----------------- -// A tone is one event of five slots (100 ms): an event packet in each in -// place of the audio frame, the duration growing by a slot, the marker bit -// on the first, the end bit on the fifth, which goes out three times, the -// repeats in two slots of their own (§2.5.1.4) — then three slots of audio -// before the next tone. The gateway's detector sees a 100 ms tone and a -// 100 ms gap: what a keypad press produces, and Q.24's 40 ms minimums -// with margin. The audio's own senders carry the events — per slot, a -// captured frame in the mic thread or a silence tick in the main loop -// without a mic (and the latch burst), `DtmfSender::SendSlot` sends -// either the event packet the slot carries or the frame, under one lock — -// so one packet leaves per slot, no frame can land inside an event -// whichever thread holds the slot, and the stream's clock stays the -// capture's; the slots an event took advanced it like frames -// (RtpSendPacket), so the audio that follows is stamped as if it had run -// through it. -constexpr int DtmfToneSlots = 5; -constexpr int DtmfEndRepeats = 2; -constexpr int DtmfGapSlots = 3; -constexpr std::uint8_t DtmfVolume = 10; // -10 dBm0 (§2.3.4: 0 to -63) - -// The event code of a keypad character (§3.2: 0-9, * = 10, # = 11, A-D = -// 12-15, either case); nullopt for anything else. -std::optional DtmfEvent(char c) { - if (c >= '0' && c <= '9') return c - '0'; - if (c == '*') return 10; - if (c == '#') return 11; - if (c >= 'A' && c <= 'D') return 12 + (c - 'A'); - if (c >= 'a' && c <= 'd') return 12 + (c - 'a'); - return std::nullopt; -} - -// §2.3: event, E | R | volume, the duration in timestamp units, big-endian. -std::array DtmfPayload(int event, bool end, std::uint16_t duration) { - return {static_cast(event), - static_cast((end ? 0x80 : 0) | DtmfVolume), - static_cast(duration >> 8), - static_cast(duration & 0xFF)}; -} - -// The tone an event stands for (ITU-T Q.23 / RFC 4733 §3.2): the keypad's -// row and column frequencies of events 0-15; nullopt for any other event. -struct DtmfPair { int low; int high; }; -std::optional DtmfFrequencies(int event) { - static constexpr std::array lows = {697, 770, 852, 941}; - static constexpr std::array highs = {1209, 1336, 1477, 1633}; - // keypad rows 1 2 3 A / 4 5 6 B / 7 8 9 C / * 0 # D, indexed by event - static constexpr std::array, 16> cell = {{ - {3, 1}, {0, 0}, {0, 1}, {0, 2}, {1, 0}, {1, 1}, {1, 2}, {2, 0}, - {2, 1}, {2, 2}, {3, 0}, {3, 2}, {0, 3}, {1, 3}, {2, 3}, {3, 3}}}; - if (event < 0 || event > 15) return std::nullopt; - const auto [row, col] = cell[static_cast(event)]; - return DtmfPair{lows[static_cast(row)], highs[static_cast(col)]}; -} - -// Sample `k` of a tone at `rate`: the two sines at `amp` each. -std::int16_t DtmfSample(DtmfPair f, std::uint32_t k, int rate, double amp) { - const double t = static_cast(k) / rate; - const double v = amp * (std::sin(2 * std::numbers::pi * f.low * t) + std::sin(2 * std::numbers::pi * f.high * t)); - return static_cast(std::lround(std::clamp(v, -32768.0, 32767.0))); -} - -// The tones the control pipe queued, paid out one slot at a time by the -// audio's own senders: one sender per slot, so no frame can land inside an -// event and the stream's clock is the capture's. -class DtmfSender { -public: - void Queue(std::string_view tones) { - std::scoped_lock g(lock_); - for (char c : tones) - if (auto ev = DtmfEvent(c)) queue_.push_back(*ev); - } - // Called by the audio's sender for every uplink slot, with the frame - // it would send: under one lock the slot is decided and sent — the - // event packet the slot carries (returns true: the frame stays unsent), - // or the frame (false; an empty frame, a DTX gap, sends nothing). One - // decision and one send per slot, so no frame can land inside an event - // whichever thread holds the slot. - bool SendSlot(int sock, TxState& st, int pt, std::span frame) { - std::scoped_lock g(lock_); - if (gap_ > 0) { - gap_--; - } else if (event_ >= 0 || !queue_.empty()) { - if (event_ < 0) { - event_ = queue_.front(); - queue_.pop_front(); - slot_ = 0; - start_.reset(); - } - slot_++; - const int reported = std::min(slot_, DtmfToneSlots); - const auto payload = DtmfPayload(event_, slot_ >= DtmfToneSlots, static_cast(reported * st.tsStep)); - start_ = RtpSendPacket(sock, st, pt, slot_ == 1, start_, payload); - { std::scoped_lock s(st.lock); st.txDtmf++; } - if (slot_ == DtmfToneSlots + DtmfEndRepeats) { - event_ = -1; - gap_ = DtmfGapSlots; - } - return true; - } - if (!frame.empty()) RtpSend(sock, st, frame); - return false; - } -private: - std::mutex lock_; - std::deque queue_; - int event_ = -1; // the event on the wire; -1 = none - int slot_ = 0; // its packets sent so far - int gap_ = 0; // slots of audio owed before the next event - std::optional start_; // the event's timestamp -}; - std::vector>> Depay(Codec c, std::span pl, bool octetAlign) { return octetAlign ? DepayOctet(c, pl) : DepayBe(c, pl); @@ -722,14 +573,12 @@ std::vector G711DecodeFrame(Codec c, std::span std::atomic Quit{false}; void OnSig(int) { Quit.store(true); } -// playout queue item: an audio packet, or (`event`) a telephone-event one -struct PktItem { std::uint32_t ts; std::vector payload; bool event = false; }; +// playout queue item +struct PktItem { std::uint32_t ts; std::vector payload; }; // --selftest: pack->depay roundtrip of every frame type, both payload // formats, both AMR codecs; G.711 digital zero, idempotence over the whole -// 16-bit range, and a 1 kHz sine surviving with the codec's nominal SNR; -// the RFC 4733 telephone-event payload bytes, every keypad character's -// event code, every event's Q.23 frequency pair and no tone past D. +// 16-bit range, and a 1 kHz sine surviving with the codec's nominal SNR. int SelfTest() { for (Codec c : {Codec::AmrWb, Codec::AmrNb}) { for (int ft = 0; ft < 16; ft++) { @@ -754,42 +603,6 @@ int SelfTest() { std::println(std::cerr, "selftest FAIL G.711 digital zero"); return 1; } - // RFC 4733 §2.3: event, E | R | volume (-10 dBm0), duration big-endian - if (DtmfPayload(1, false, 160) != std::array{0x01, 0x0A, 0x00, 0xA0} - || DtmfPayload(11, true, 1600) != std::array{0x0B, 0x8A, 0x06, 0x40}) { - std::println(std::cerr, "selftest FAIL telephone-event payload"); - return 1; - } - // §3.2: every keypad character's event code, the letters in either case, - // and no code for anything else - for (const char* keys : {"0123456789*#ABCD", "0123456789*#abcd"}) { - for (int ev = 0; ev < 16; ev++) { - if (DtmfEvent(keys[ev]) != ev) { - std::println(std::cerr, "selftest FAIL telephone-event code of '{}'", keys[ev]); - return 1; - } - } - } - if (DtmfEvent('E') || DtmfEvent('e') || DtmfEvent(' ') || DtmfEvent('\n')) { - std::println(std::cerr, "selftest FAIL telephone-event codes"); - return 1; - } - // Q.23: every event's pair — rows 697/770/852/941 Hz for 1 2 3 A, 4 5 6 B, - // 7 8 9 C, * 0 # D; columns 1209/1336/1477/1633 Hz — and no tone past D - static constexpr std::array, 16> q23 = {{ - {941, 1336}, {697, 1209}, {697, 1336}, {697, 1477}, {770, 1209}, {770, 1336}, {770, 1477}, {852, 1209}, - {852, 1336}, {852, 1477}, {941, 1209}, {941, 1477}, {697, 1633}, {770, 1633}, {852, 1633}, {941, 1633}}}; - for (int ev = 0; ev < 16; ev++) { - const auto f = DtmfFrequencies(ev); - if (!f || f->low != q23[static_cast(ev)].first || f->high != q23[static_cast(ev)].second) { - std::println(std::cerr, "selftest FAIL telephone-event tone {}", ev); - return 1; - } - } - if (DtmfFrequencies(16) || DtmfFrequencies(-1)) { - std::println(std::cerr, "selftest FAIL telephone-event tones past D"); - return 1; - } for (Codec c : {Codec::Pcma, Codec::Pcmu}) { for (int v = -32768; v <= 32767; v++) { std::uint8_t b = G711Encode(c, static_cast(v)); @@ -848,8 +661,6 @@ int main(int argc, char** argv) { bool rtpDump = EnvBool("RTP_DUMP", false); std::string micSrc = EnvOr("MIC_SRC", ""); bool pcmDump = EnvBool("PCM_DUMP", false); - const int dtmfPt = std::atoi(EnvOr("DTMF_PT", "0").c_str()); - const int dtmfRxPt = std::atoi(EnvOr("DTMF_RX_PT", EnvOr("DTMF_PT", "0").c_str()).c_str()); constexpr int ExitMediaTimeout = 3; constexpr int PrimeFrames = 8; constexpr int MaxFill = 25; @@ -882,13 +693,8 @@ int main(int argc, char** argv) { signal(SIGINT, OnSig); signal(SIGPIPE, SIG_IGN); - // ---- DTMF: the tones the control pipe (below) queues; the audio's - // senders pay them out slot by slot - DtmfSender dtmf; - // ---- mic uplink thread: pw-record (or the MIC_SRC file, real-time paced) - // through the codec's encoder, one RTP packet per 20 ms frame — or the - // event packet whose slot it is + // through the codec's encoder, one RTP packet per 20 ms frame std::atomic stop{false}; std::jthread micThread; if (mic) { @@ -925,23 +731,23 @@ int main(int argc, char** argv) { next += std::chrono::milliseconds(20); std::this_thread::sleep_until(next); } - st.micOn.store(true); std::memcpy(samples.data(), raw.data(), raw.size()); if (gain != 1.0) for (auto& sample : samples) { int v = static_cast(sample * gain); sample = static_cast(v < -32768 ? -32768 : (v > 32767 ? 32767 : v)); } - std::vector payload; // empty: a DTX gap, nothing to send in the slot + std::vector payload; if (amr) { std::vector frame = enc.Encode(samples.data(), amrMode, dtx); - if (!frame.empty()) payload = PayloadFromFrame(codec, frame, octetAlign); + if (frame.empty()) continue; + payload = PayloadFromFrame(codec, frame, octetAlign); } else { payload = G711Payload(codec, samples); } - // the slot's packet: the event's, or this frame - if (dtmf.SendSlot(sock, st, dtmfPt, payload) || payload.empty()) continue; + st.micOn.store(true); { std::scoped_lock g(st.lock); st.txMic++; } + RtpSend(sock, st, payload); } st.micOn.store(false); close(fd); @@ -977,8 +783,6 @@ int main(int argc, char** argv) { std::uint64_t cng = 0; std::uint64_t late = 0; std::uint64_t qdrop = 0; - std::uint64_t rxDtmf = 0; // telephone-event packets from the far end - std::uint64_t tones = 0; // events of theirs rendered as a tone std::jthread playThread; if (decodeOn) { playThread = std::jthread([&] { @@ -1010,7 +814,6 @@ int main(int argc, char** argv) { }; std::vector zero(frameSamples, 0); std::optional expect; - std::optional toneTs; // the event being rendered, by its start stamp for (;;) { PktItem item; { @@ -1020,55 +823,6 @@ int main(int argc, char** argv) { item = std::move(playq.front()); playq.pop_front(); } - if (item.event) { - // The far end's key press, played as the tone it stands - // for (§2.5.2.2 leaves rendering to the receiver). The - // gap before the event's start — a stock sender's DTX - // silence often precedes a key press (dev phone, - // 2026-10-15: 0 to 7 slots) — is filled as before an - // audio frame, so the tone starts where the event does. - // Each packet reports the event's duration so far from - // its start stamp; what the clock has not covered yet is - // synthesized — a retransmitted end packet adds nothing, - // a lost update is caught up by the next. A far end - // whose audio resumes past the event (the stock phone's - // does) meets the clock with the usual fill. - if (item.payload.size() < 4) continue; - const auto freqs = DtmfFrequencies(item.payload[0]); - if (!freqs) continue; - const std::uint32_t dur = (static_cast(item.payload[2]) << 8) | item.payload[3]; - const double amp = 16383.0 * std::pow(10.0, -(item.payload[1] & 0x3F) / 20.0); - if (!expect) { - for (int i = 0; i < PrimeFrames; i++) - if (!writePcm(zero)) return; - expect = item.ts; - } - const std::uint32_t before = (item.ts - *expect) & 0xFFFFFFFF; - if (before < 0x80000000u) { - int fill = static_cast(before / frameSamples); - if (fill > MaxFill) fill = 0; - for (int i = 0; i < fill; i++) { - cng++; - std::uint8_t nodata = 0x7C; - std::vector pcm = amr ? dec.Decode(&nodata, 1) : zero; - applyGain(pcm); - if (!writePcm(pcm)) return; - } - expect = item.ts; - } - const std::uint32_t end = (item.ts + dur) & 0xFFFFFFFF; - const std::uint32_t ahead = (end - *expect) & 0xFFFFFFFF; - if (ahead == 0 || ahead > static_cast(2 * rate)) continue; // covered, or not a tone's length - if (!toneTs || *toneTs != item.ts) { toneTs = item.ts; tones++; } - const std::uint32_t from = (*expect - item.ts) & 0xFFFFFFFF; - std::vector pcm(ahead); - for (std::uint32_t i = 0; i < ahead; i++) - pcm[i] = DtmfSample(*freqs, from + i, rate, amp); - applyGain(pcm); - if (!writePcm(pcm)) return; - expect = end; - continue; - } std::vector> frames; if (amr) { for (auto& [hdr, speech] : Depay(codec, item.payload, octetAlign)) { @@ -1113,65 +867,9 @@ int main(int argc, char** argv) { }); } - // ---- the control pipe that queues the tones: the daemon's - // `dtmf ` lines on stdin. No pipe (a hand run, /dev/null) ends - // the reader at once; the leg runs on. - std::jthread ctlThread([&] { - // a hand run in the background of a job-control shell: a read of - // its terminal would stop the whole process (SIGTTIN); blocked for - // this thread, the read fails with EIO and the reader ends - sigset_t ttin; - sigemptyset(&ttin); - sigaddset(&ttin, SIGTTIN); - pthread_sigmask(SIG_BLOCK, &ttin, nullptr); - std::string pending; - bool discarding = false; // an overlong line is dropped through its end - char buf[256]; - while (!stop.load()) { - pollfd pfd{STDIN_FILENO, POLLIN, 0}; - if (poll(&pfd, 1, 100) <= 0) continue; - const ssize_t n = read(STDIN_FILENO, buf, sizeof buf); - if (n <= 0) return; - std::string_view in(buf, static_cast(n)); - if (discarding) { - const std::size_t end = in.find('\n'); - if (end == std::string_view::npos) continue; - in.remove_prefix(end + 1); - discarding = false; - } - pending.append(in); - // a line of more than 4096 bytes with no end in sight: dropped - // through its end; the next line goes - if (pending.size() > 4096 && pending.find('\n') == std::string::npos) { - std::println(std::cerr, "imsd-media: control line of {} bytes without an end dropped", pending.size()); - pending.clear(); - discarding = true; - continue; - } - std::size_t nl; - while ((nl = pending.find('\n')) != std::string::npos) { - const std::string line = pending.substr(0, nl); - pending.erase(0, nl + 1); - // the same bound on a line whose end came in the read that - // took it past 4096 bytes - if (nl > 4096) { - std::println(std::cerr, "imsd-media: control line of {} bytes dropped", nl); - continue; - } - if (!line.starts_with("dtmf ")) { - std::println(std::cerr, "imsd-media: control line ignored: {}", line); - } else if (dtmfPt > 0) { - dtmf.Queue(std::string_view(line).substr(5)); - } else { - std::println(std::cerr, "imsd-media: {} dropped: no telephone-event payload type (DTMF_PT)", line); - } - } - } - }); - // ---- main recv loop std::vector silence = SilenceFrame(codec, 0, octetAlign); - for (int i = 0; i < 5; i++) dtmf.SendSlot(sock, st, dtmfPt, silence); // latch burst, the one path + for (int i = 0; i < 5; i++) RtpSend(sock, st, silence); // latch burst std::FILE* dump = rtpDump ? std::fopen((std::format("{}.rtp", out)).c_str(), "wb") : nullptr; double t0 = Now(); @@ -1190,8 +888,7 @@ int main(int argc, char** argv) { break; } if (now - lastTx >= 0.02 && !st.micOn.load()) { - // no mic: the slot's packet is the event's, or a silence frame - dtmf.SendSlot(sock, st, dtmfPt, silence); + RtpSend(sock, st, silence); lastTx = now; } std::uint8_t buf[65535]; @@ -1229,14 +926,7 @@ int main(int argc, char** argv) { std::fwrite(lb, 1, 4, dump); std::fwrite(buf, 1, static_cast(n), dump); } - // the far end's events arrive at the payload type our own SDP gave - // telephone-event (DTMF_RX_PT), or at the one we send with when the - // answerer numbers for itself — never at the audio's: a frame is - // audio first - const int rxPt = n >= 12 ? buf[1] & 0x7F : -1; - const bool isEvent = n >= 12 && rxPt != pt && ((dtmfRxPt > 0 && rxPt == dtmfRxPt) || (dtmfPt > 0 && rxPt == dtmfPt)); - if (isEvent) rxDtmf++; - if (playThread.joinable() && n >= 12 && (rxPt == pt || isEvent)) { + if (playThread.joinable() && n >= 12 && (buf[1] & 0x7F) == pt) { std::size_t off = RtpPayloadOffset(std::span(buf, static_cast(n))); std::uint32_t pktTs = (static_cast(buf[4]) << 24) | (buf[5] << 16) | (buf[6] << 8) | buf[7]; @@ -1245,14 +935,13 @@ int main(int argc, char** argv) { playq.pop_front(); qdrop++; } - playq.push_back({pktTs, std::vector(buf + off, buf + n), isEvent}); + playq.push_back({pktTs, std::vector(buf + off, buf + n)}); qcv.notify_one(); } } stop.store(true); qcv.notify_all(); - if (ctlThread.joinable()) ctlThread.join(); if (micThread.joinable()) micThread.join(); if (playThread.joinable()) playThread.join(); if (dump) std::fclose(dump); @@ -1272,14 +961,14 @@ int main(int argc, char** argv) { // .stats sidecar (tiny; always written) if (std::FILE* sf = std::fopen((std::format("{}.stats", out)).c_str(), "w")) { std::print(sf, - "{{\"codec\": \"{}\", \"tx\": {}, \"tx_mic\": {}, \"tx_dtmf\": {}, \"rx\": {}, \"rx_bytes\": {}, " - "\"rx_played\": {}, \"rx_dtmf\": {}, \"tones\": {}, \"cng\": {}, \"late\": {}, \"qdrop\": {}, " - "\"first_src\": \"{}\", \"dst\": \"{}:{}\", \"pt\": {}, \"dtmf_pt\": {}, \"dtmf_rx_pt\": {}, \"mic\": {}, " + "{{\"codec\": \"{}\", \"tx\": {}, \"tx_mic\": {}, \"rx\": {}, \"rx_bytes\": {}, " + "\"rx_played\": {}, \"cng\": {}, \"late\": {}, \"qdrop\": {}, " + "\"first_src\": \"{}\", \"dst\": \"{}:{}\", \"pt\": {}, \"mic\": {}, " "\"play\": {}, \"amr_mode\": {}, \"media_ended\": {}}}", - CodecName(codec), st.tx, st.txMic, st.txDtmf, rx, rxBytes, rxPlayed, rxDtmf, tones, cng, late, qdrop, firstSrc, - rIp, rPort, pt, dtmfPt, dtmfRxPt, mic, play, amrMode, mediaEnded); + CodecName(codec), st.tx, st.txMic, rx, rxBytes, rxPlayed, cng, late, qdrop, firstSrc, + rIp, rPort, pt, mic, play, amrMode, mediaEnded); std::fclose(sf); } - std::println("imsd-media: codec={} tx={} tx_mic={} tx_dtmf={} rx={} rx_played={} rx_dtmf={} tones={} cng={} late={} " "qdrop={} rx_bytes={} first_src={} media_ended={}", CodecName(codec), st.tx, st.txMic, st.txDtmf, rx, rxPlayed, rxDtmf, tones, cng, late, qdrop, rxBytes, firstSrc, mediaEnded); + std::println("imsd-media: codec={} tx={} tx_mic={} rx={} rx_played={} cng={} late={} " "qdrop={} rx_bytes={} first_src={} media_ended={}", CodecName(codec), st.tx, st.txMic, rx, rxPlayed, cng, late, qdrop, rxBytes, firstSrc, mediaEnded); return mediaEnded ? ExitMediaTimeout : 0; } diff --git a/implementations/pcscf.cpp b/implementations/pcscf.cpp deleted file mode 100644 index 7282421..0000000 --- a/implementations/pcscf.cpp +++ /dev/null @@ -1,239 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® - -// lint-disable-file fixed-width-types no-char-pointer -/* -imsd-pcscf — prints the P-CSCF list the network delivered in the ims PDN's -Protocol Configuration Options, read from the modem over QMI. - -ModemManager owns the data call and never asks the modem for these fields; -a second WDS client gets them once it is bound to the same mux data port -(the binding ModemManager gives its own clients), so this is a reader on -the side: Bind Mux Data Port, Set IP Family 6, Get Current Settings, over -one AF_QIPCRTR socket, which is its own QMI client as far as the modem is -concerned — no proxy, no interference with ModemManager's. It never sends -Start Network or Stop Network: it changes nothing about the call. The -bytes are Imsd:Qmi's; this file is the socket, the service lookup and the -sequence. - -Usage: imsd-pcscf --mux [--ep-type 4] [--ep-iface 1] [--node 0] - [--profile ] [--timeout ] [-v] - --mux the rmnet mux id of the ims netdev (`ip -d link show `) - --ep-type, --ep-iface - the modem's data endpoint: embedded (4), interface 1 for an - IPA-attached modem (what ModemManager binds for the `ipa` - driver) - --node the QRTR node of the modem's WDS service (0) - --profile the ims profile's 3GPP index: adds the profile's "PCSCF Address - Using PCO" flag to the output (did the modem ask the network at - all?) - --timeout the whole run's budget in seconds (10) - -v every request and answer in hex, on stderr -Output, one item per line: - the P-CSCF list, PCO order - v4
an IPv4 P-CSCF (unusable on an IPv6 PDN) - fqdn a P-CSCF name - flag pco-requested <0|1> the call's TLV 0x22 - flag profile-pcscf-via-pco <0|1> the profile's flag (with --profile) -Exit: 0 at least one IPv6 address; 2 none; 1 an error (text on stderr — - `OutOfCall` means the bound client does not see a call on that mux). -*/ - -#include -#include -#include -#include -#include - -import std; -import Imsd; - -namespace { - bool Verbose = false; - - void Err(std::string_view s) { std::println(std::cerr, "imsd-pcscf: {}", s); } - - std::string Hex(std::span b) { - std::string s; - for (std::uint8_t x : b) s += std::format("{}{:02x}", s.empty() ? "" : " ", x); - return s; - } - - long NowMs() { - return std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()).count(); - } - - // Ask the control port for the WDS servers; the first one on `node`. - // (The lookup is sent to node 1's control port — the local node on this - // platform — and to our own node's when that differs, as the modem's - // control port answers for the whole bus.) - std::optional FindWds(std::uint32_t node, long deadlineMs) { - int s = socket(AF_QIPCRTR, SOCK_DGRAM, 0); - if (s < 0) { Err(std::format("socket(AF_QIPCRTR): {}", std::strerror(errno))); return std::nullopt; } - qrtr_ctrl_pkt pkt{}; - pkt.cmd = QRTR_TYPE_NEW_LOOKUP; - pkt.server.service = imsd::qmi::WdsService; - sockaddr_qrtr ctrl{}; - ctrl.sq_family = AF_QIPCRTR; - ctrl.sq_node = 1; - ctrl.sq_port = QRTR_PORT_CTRL; - (void)!sendto(s, &pkt, sizeof pkt, 0, reinterpret_cast(&ctrl), sizeof ctrl); - sockaddr_qrtr me{}; - socklen_t ml = sizeof me; - if (getsockname(s, reinterpret_cast(&me), &ml) == 0 && me.sq_node != 1) { - ctrl.sq_node = me.sq_node; - (void)!sendto(s, &pkt, sizeof pkt, 0, reinterpret_cast(&ctrl), sizeof ctrl); - } - std::optional found; - while (true) { - long left = deadlineMs - NowMs(); - if (left <= 0) break; - pollfd p{s, POLLIN, 0}; - if (poll(&p, 1, static_cast(std::min(left, 2000L))) <= 0) break; - qrtr_ctrl_pkt in{}; - sockaddr_qrtr from{}; - socklen_t fl = sizeof from; - ssize_t n = recvfrom(s, &in, sizeof in, 0, reinterpret_cast(&from), &fl); - if (n < static_cast(sizeof in)) continue; - if (in.cmd != QRTR_TYPE_NEW_SERVER) continue; - if (in.server.service == 0) break; // end of the dump - if (Verbose) Err(std::format("WDS server: instance {} at {}:{}", static_cast(in.server.instance), static_cast(in.server.node), static_cast(in.server.port))); - if (in.server.service == imsd::qmi::WdsService && in.server.node == node && !found) { - sockaddr_qrtr a{}; - a.sq_family = AF_QIPCRTR; - a.sq_node = in.server.node; - a.sq_port = in.server.port; - found = a; - } - } - close(s); - return found; - } - - // One QMI client: one socket, one transaction at a time. - class Wds { - public: - Wds(sockaddr_qrtr peer, long deadlineMs) : peer_(peer), deadline_(deadlineMs) { fd_ = socket(AF_QIPCRTR, SOCK_DGRAM, 0); } - ~Wds() { if (fd_ >= 0) close(fd_); } - bool Open() const { return fd_ >= 0; } - std::uint16_t NextTxn() { return ++txn_; } - - // Send a request and wait for its response (same transaction and - // message id); indications and strays are skipped. - std::optional Call(std::span req, std::uint16_t msg, std::string_view name) { - if (Verbose) Err(std::format("--> {}: {}", name, Hex(req))); - if (sendto(fd_, req.data(), req.size(), 0, reinterpret_cast(&peer_), sizeof peer_) < 0) { - Err(std::format("{}: sendto: {}", name, std::strerror(errno))); - return std::nullopt; - } - std::vector buf(65536); - while (true) { - long left = deadline_ - NowMs(); - if (left <= 0) { Err(std::format("{}: no answer in time", name)); return std::nullopt; } - pollfd p{fd_, POLLIN, 0}; - int pr = poll(&p, 1, static_cast(left)); - if (pr <= 0) { Err(std::format("{}: no answer in time", name)); return std::nullopt; } - ssize_t n = recv(fd_, buf.data(), buf.size(), 0); - if (n <= 0) continue; - auto r = imsd::qmi::ParseMessage(std::span(buf.data(), static_cast(n))); - if (!r) continue; - if (r->type != imsd::qmi::TypeResponse || r->txn != txn_ || r->msg != msg) { - if (Verbose) Err(std::format(" (skipped type {} txn {} msg 0x{:04x})", r->type, r->txn, r->msg)); - continue; - } - if (Verbose) Err(std::format("<-- {}: {}", name, Hex(std::span(buf.data(), static_cast(n))))); - return r; - } - } - - private: - int fd_ = -1; - sockaddr_qrtr peer_; - long deadline_; - std::uint16_t txn_ = 0; - }; - - void Usage() { - std::println(std::cerr, "usage: imsd-pcscf --mux [--ep-type 4] [--ep-iface 1] [--node 0] [--profile ] [--timeout ] [-v]"); - } -} - -int main(int argc, char** argv) { - std::optional mux; - std::optional profile; - std::uint32_t epType = imsd::qmi::EndpointEmbedded; - std::uint32_t epIface = imsd::qmi::EndpointInterfaceIpa; - std::uint32_t node = 0; - double timeout = 10.0; - auto num = [&](int& i, const char* what) -> std::optional { - if (i + 1 >= argc) { Err(std::format("{} needs a value", what)); return std::nullopt; } - std::string_view v(argv[++i]); - long out = 0; - auto [p, ec] = std::from_chars(v.data(), v.data() + v.size(), out); - if (ec != std::errc{} || p != v.data() + v.size()) { Err(std::format("{}: not a number: {}", what, v)); return std::nullopt; } - return out; - }; - for (int i = 1; i < argc; i++) { - std::string_view a(argv[i]); - std::optional v; - if (a == "--mux") { if (!(v = num(i, "--mux"))) return 1; mux = static_cast(*v); } - else if (a == "--profile") { if (!(v = num(i, "--profile"))) return 1; profile = static_cast(*v); } - else if (a == "--ep-type") { if (!(v = num(i, "--ep-type"))) return 1; epType = static_cast(*v); } - else if (a == "--ep-iface") { if (!(v = num(i, "--ep-iface"))) return 1; epIface = static_cast(*v); } - else if (a == "--node") { if (!(v = num(i, "--node"))) return 1; node = static_cast(*v); } - else if (a == "--timeout") { - if (i + 1 >= argc) { Err("--timeout needs a value"); return 1; } - timeout = std::atof(argv[++i]); - if (timeout <= 0) { Err("--timeout: not a positive number"); return 1; } - } - else if (a == "-v") Verbose = true; - else { Usage(); return 1; } - } - if (!mux || *mux < 0 || *mux > 255) { Usage(); return 1; } - if (profile && (*profile < 0 || *profile > 255)) { Err("--profile: 0..255"); return 1; } - - long deadline = NowMs() + static_cast(timeout * 1000); - auto peer = FindWds(node, deadline); - if (!peer) { Err(std::format("no WDS service on qrtr node {}", node)); return 1; } - Wds c(*peer, deadline); - if (!c.Open()) { Err(std::format("socket(AF_QIPCRTR): {}", std::strerror(errno))); return 1; } - - auto step = [&](std::vector req, std::uint16_t msg, std::string_view name) -> std::optional { - auto r = c.Call(req, msg, name); - if (!r) return std::nullopt; - if (!r->Ok()) { - Err(std::format("{}: error {} ({})", name, r->error, imsd::qmi::ErrorName(r->error))); - return std::nullopt; - } - return r; - }; - using namespace imsd::qmi; - if (!step(BindMuxDataPort(c.NextTxn(), epType, epIface, static_cast(*mux)), MsgBindMuxDataPort, "Bind Mux Data Port")) return 1; - if (!step(SetIpFamily(c.NextTxn(), IpFamilyV6), MsgSetIpFamily, "Set IP Family")) return 1; - if (Verbose) { - if (auto r = step(GetPacketServiceStatus(c.NextTxn()), MsgGetPacketServiceStatus, "Get Packet Service Status")) - Err(std::format("packet service status: {}", PacketServiceStatus(*r) ? std::to_string(*PacketServiceStatus(*r)) : "?")); - } - auto settings = c.Call(GetCurrentSettings(c.NextTxn(), RequestedSettingsAll), MsgGetCurrentSettings, "Get Current Settings"); - if (!settings) return 1; - if (!settings->Ok()) { - if (settings->error == ErrOutOfCall) Err(std::format("Get Current Settings: OutOfCall — the client bound to mux {} sees no call there (is that the ims netdev's mux id?)", *mux)); - else Err(std::format("Get Current Settings: error {} ({})", settings->error, ErrorName(settings->error))); - return 1; - } - PcscfLists lists = ParsePcscfLists(*settings); - for (const std::string& a : lists.ipv6) std::println("{}", a); - for (const std::string& a : lists.ipv4) std::println("v4 {}", a); - for (const std::string& n : lists.fqdn) std::println("fqdn {}", n); - std::println("flag pco-requested {}", lists.viaPco ? 1 : 0); - if (profile) { - auto r = c.Call(GetProfileSettings(c.NextTxn(), static_cast(*profile)), MsgGetProfileSettings, "Get Profile Settings"); - if (r && r->Ok()) { - if (auto f = ProfilePcscfViaPco(*r)) std::println("flag profile-pcscf-via-pco {}", *f ? 1 : 0); - else Err(std::format("Get Profile Settings: profile {} carries no PCSCF-via-PCO flag", *profile)); - } else if (r) { - Err(std::format("Get Profile Settings: error {} ({})", r->error, ErrorName(r->error))); - } - } - return lists.ipv6.empty() ? 2 : 0; -} diff --git a/interfaces/Imsd-Engine.cppm b/interfaces/Imsd-Engine.cppm index 7c1d4ce..efb4ada 100644 --- a/interfaces/Imsd-Engine.cppm +++ b/interfaces/Imsd-Engine.cppm @@ -18,10 +18,8 @@ That keeps the whole call FSM — PRACK on reliable 18x, ACK of 2xx vs non-2xx finals, CANCEL/answer races, remote BYE, media-plane far-end-hangup, the emergency attempt chain (urn:service:sos first for a classified number, one fallback to a plain INVITE of the digits; a 380 Alternative Service upgrade -the other way), the terminating (UAS) side: 100/180 on an inbound INVITE, -200-with-answer on Accept, 486 reject, the remote-CANCEL 200+487 pair — and -DTMF, which reaches the media leg only on an active call whose SDP -negotiated telephone-event, as the tones a keypad has — +the other way), and the terminating (UAS) side: 100/180 on an inbound INVITE, +200-with-answer on Accept, 486 reject, the remote-CANCEL 200+487 pair — unit-testable against recorded network dialogs with no I/O (tests/Engine). State/reason strings are the frozen D-Bus vocabulary the dialer maps on @@ -62,9 +60,7 @@ export namespace imsd::engine { // 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 - // with a final other than 200 — a 401, re-authentication, included — or - // a fresh registration's protected REGISTER with a final other than 200 - // (the unprotected one's 401 is its challenge). SILENCE: the + // (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 @@ -78,20 +74,13 @@ export namespace imsd::engine { // fresh registration the network accepted within two seconds (ims // ledger F16). // - // Pacing. Reconnect cycles back off 5 s → 5 min. A refusal without a - // Retry-After of at least a second (none, 0, unparsable) escalates to - // a fresh registration after two (one 503 may be a momentary hiccup a - // retried refresh clears); one with such a Retry-After escalates at - // once — it is the network's considered answer (O2 2026-10-08: the - // refresh after the first wait was refused again, 414 s then 772 s) — - // and so does a 401, the network asking to re-authenticate, which imsd - // answers with a fresh registration (TS 24.229 5.1.1.5.1's temporary - // SAs on the proposed client port are not implemented). Silence - // escalates after two reconnect cycles + // 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, a failed send (the resume - // at start gives up after two unanswered + // 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 @@ -105,10 +94,8 @@ export namespace imsd::engine { // 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 bring-up resume that failed while a fresh - // registration follows (OldGone; with no fresh target a resumable pair - // is kept for the restart, §7.2 step 1) — no reconnect over the old SA - // from then on. Every wait + // 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 @@ -168,18 +155,16 @@ export namespace imsd::engine { double freshHold = 0; int throttleMinutes = ThrottleMinutesMin; bool oldGone = false; // the old registration is gone: the next process has nothing to reconnect over - int refusals = 0; // refused reconnects counted so far (RefusalsBeforeFresh): a restart's refusal is the next one, not the first - // a schedule that has advanced is kept too, until a success resets it, and so are the gone verdict and the count - bool Any() const { return hold > 0 || freshHold > 0 || throttleMinutes > ThrottleMinutesMin || oldGone || refusals > 0; } + // 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, refusals}; } + Held Holds(double now) const { return {std::max(0.0, holdUntil - now), std::max(0.0, freshHoldUntil - now), throttleMinutes, oldGone}; } // A restarted process takes them over; nothing here shortens a // hold it already has, or lengthens one past RetryAfterMax. void Restore(double now, const Held& h) { if (h.hold > 0) holdUntil = std::max(holdUntil, now + std::min(h.hold, static_cast(RetryAfterMax))); if (h.freshHold > 0) freshHoldUntil = std::max(freshHoldUntil, now + std::min(h.freshHold, static_cast(RetryAfterMax))); throttleMinutes = std::clamp(std::max(throttleMinutes, h.throttleMinutes), ThrottleMinutesMin, ThrottleMinutesMax); - refusals = std::max(refusals, h.refusals); if (h.oldGone) OldGone(); // no reconnect over SAs the network has written off } @@ -200,9 +185,8 @@ export namespace imsd::engine { // A refused keepalive refresh, or a failed reconnect + refresh // cycle; `status` is the refusal's. The backoff paces the next step; - // a Retry-After of at least a second holds every attempt for its own - // length (up to RetryAfterMax), whichever is longer, and makes the - // next step the fresh registration, as does a 401. A 403 once a fresh + // 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) { @@ -215,7 +199,6 @@ export namespace imsd::engine { if (f == Failure::Refused) refusals++; else silentCycles++; if (refusals >= RefusalsBeforeFresh || silentCycles >= SilentCyclesBeforeFresh) fresh = true; - if (f == Failure::Refused && (retryAfter > 0 || status == 401)) fresh = true; if (status == 403 && fresh) oldGone = true; backoffUntil = now + reconnectDelay; reconnectDelay = std::min(reconnectDelay * 2, ReconnectDelayMax); @@ -282,24 +265,12 @@ export namespace imsd::engine { // 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). - // `dtmfPt` is the telephone-event payload type at the codec's clock - // (RFC 4733) the leg sends with — the peer's SDP's; nullopt = none - // negotiated, no DTMF on this call. `dtmfRxPt` is a second number the - // leg takes events at: our own SDP's at that clock — our offer's - // number on a call we placed (an answerer sends with the offer's - // numbers, RFC 3264 §6.1, and may renumber ours in the answer), the - // offer's we echoed on a call we answered; the answer's telephone-event - // number instead when the answer gave the audio our offer's number, - // which is then the leg's audio number. The leg takes events at - // either number, never at the audio's. struct MediaLeg { std::string remoteIp; int remotePort = 0; int payloadType = -1; bool octetAlign = false; std::string codec = "AMR-WB"; - std::optional dtmfPt; - std::optional dtmfRxPt; bool Valid() const { return !remoteIp.empty() && remotePort > 0 && payloadType >= 0; } }; @@ -309,7 +280,6 @@ export namespace imsd::engine { SendResponse, // answer the just-handled inbound request on its origin channel StartMedia, // bring up the media leg described by `media` StopMedia, // tear the media leg down - SendDtmf, // hand `text` (the tones) to the running media leg State, // emit CallStateChanged(uni, `state`, `reason`) Deleted, // emit CallDeleted(uni); drop this machine SetDeadline, // arm a setup timer for `seconds`; fire calls OnDeadline() @@ -323,11 +293,6 @@ export namespace imsd::engine { double seconds = 0; }; - // The most tones one SendDtmf may carry (a keypad sends one; a dialer's - // string is a short sequence): the leg's `dtmf \n` line stays - // within one atomic pipe write, POSIX's 512-byte PIPE_BUF floor. - inline constexpr std::size_t DtmfMaxTones = 128; - // ring/answer budget, and how long to wait for a 487 after CANCEL — the // imsd.py INVITE_TIMEOUT / CANCEL_487_TIMEOUT constants. inline constexpr double InviteTimeout = 90.0; @@ -455,7 +420,7 @@ export namespace imsd::engine { spec.payloadType = leg.payloadType; spec.octetAlign = leg.octetAlign; spec.codec = leg.codec; - spec.dtmfPt = leg.dtmfPt; + spec.dtmfPt = imsd::sdp::TelephoneEventPt(Body(invite_), imsd::sdp::CodecRate(leg.codec)); localSdp_ = imsd::sdp::BuildAnswer(spec); // session timers: echo the caller's Session-Expires; with no // refresher stated, make the caller (uac) the refresher — its @@ -628,45 +593,6 @@ export namespace imsd::engine { return a; } - // The user pressed keys: the tones go to the media leg as RFC 4733 - // events on an active call whose SDP negotiated telephone-event at - // the codec's clock; otherwise they are dropped with a line that - // says why — there is no second method (no SIP INFO). - // Upper and lower case letters are one event; anything but 0-9, *, - // # and A-D drops the whole string rather than sending part of - // what was pressed, and so does a string past DtmfMaxTones — a - // keypad sends one key, a dialer's string a short sequence; the - // bound keeps the leg's control line within one atomic pipe write - // (PIPE_BUF), so a line is sent whole or not at all. - std::vector OnDtmf(std::string_view tones) { - std::vector a; - if (tones.size() > DtmfMaxTones) { - a.push_back(LogAct(std::format("call {}: DTMF of {} tones dropped: more than {}", uni_, tones.size(), DtmfMaxTones))); - return a; - } - std::string sent; - for (char c : tones) { - const char u = static_cast(std::toupper(static_cast(c))); - if (!((u >= '0' && u <= '9') || u == '*' || u == '#' || (u >= 'A' && u <= 'D'))) { - a.push_back(LogAct(std::format("call {}: DTMF '{}' dropped: '{}' is not a tone", uni_, tones, c))); - return a; - } - sent += u; - } - if (sent.empty()) return a; - if (state_ != CallState::Active) { - a.push_back(LogAct(std::format("call {}: DTMF '{}' dropped: the call is {}", uni_, sent, engine::StateStr(state_)))); - return a; - } - if (!dtmfPt_) { - a.push_back(LogAct(std::format("call {}: DTMF '{}' dropped: no telephone-event negotiated for this call", uni_, sent))); - return a; - } - a.push_back(LogAct(std::format("call {}: DTMF '{}' -> media leg (telephone-event pt {})", uni_, sent, *dtmfPt_))); - a.push_back(Action{Action::Type::SendDtmf, sent}); - return a; - } - // The shell is abandoning this machine (an emergency dial preempts // it while it waits on a CANCEL's 487): terminate bookkeeping only — // the CANCEL already sent stands, and the late final for this @@ -746,7 +672,6 @@ export namespace imsd::engine { bool userCancelled_ = false; // CANCEL came from OnHangup, not a deadline std::set pracked_; std::string answerSdp_; - std::optional dtmfPt_; // the running leg's telephone-event pt, set at media start // Fresh dialog identity for the current attempt. A fallback INVITE // after a final response is a new transaction AND a clean new dialog @@ -839,13 +764,6 @@ export namespace imsd::engine { leg.payloadType = ans.payloadType; leg.octetAlign = ans.octetAlign; leg.codec = ans.codec; - leg.dtmfPt = ans.dtmfPt; - // the second number the leg takes events at: our offer's on a call - // we placed — the answer's instead when the answer gave the audio - // that very number, which is then the leg's audio number - const int offered = imsd::sdp::OfferedTelephoneEventPt(imsd::sdp::CodecRate(leg.codec)); - leg.dtmfRxPt = !leg.dtmfPt ? std::nullopt - : std::optional(direction_ == Direction::Outgoing && offered != leg.payloadType ? offered : *leg.dtmfPt); return leg; } @@ -887,7 +805,6 @@ export namespace imsd::engine { return a; } Action StartMediaAct(const MediaLeg& leg) { - dtmfPt_ = leg.dtmfPt; Action a{Action::Type::StartMedia}; a.media = leg; return a; diff --git a/interfaces/Imsd-Ipsec.cppm b/interfaces/Imsd-Ipsec.cppm index 7cde032..611bb82 100644 --- a/interfaces/Imsd-Ipsec.cppm +++ b/interfaces/Imsd-Ipsec.cppm @@ -13,20 +13,9 @@ alg (HMAC-SHA-1-96 or HMAC-MD5-96) keyed with IK; confidentiality is the negotiated ealg keyed with CK. Both come from the Security-Server mechanism the P-CSCF selected in its 401 (parsed here), and this module turns the negotiated SPIs/ports/keys into the exact argv lists the daemon shell hands to -`ip`; for a warm resume it reconstructs the SPIs and ports back out of `ip -xfrm state`/`policy` text — the P-CSCF ports for the resume's flow, and the -SPIs in use, which a reregistration's new SPIs must differ from (and the -state file falls back to) — and a one-mechanism Security-Server value that -stands in for the Security-Verify when the state file holds no list for the -SA pair in the kernel (`ChooseResumeSecurityVerify` decides; since 0.3.12 -the 401's own list is stored there). Since 1.0.0 also `SaPeer`: the P-CSCF -the kernel's pair is to, read from the `dir out` policies that carry imsd's -fingerprint; `OwnXfrmDeleteCommands`, the deletes by exact key of what two -rules select — the policies carrying imsd's fingerprint and the reqid-1/2 -transport-mode ESP states in their direction or on the UE's ims-PDN address, -whoever installed them (§12); and `FileNamesAnotherPair`, whether a state -file's spi_uc/spi_us name the pair the kernel holds (unknown on either side -decides nothing). +`ip`; for a warm resume it reconstructs the SPIs, ports, and Security-Server +value back out of `ip xfrm state`/`policy` text so a refresh re-REGISTER can +advertise the SPIs actually in use. Pure std C++ (the shell runs the commands and captures the text). Command shapes and the parser are pinned by tests/Ipsec. @@ -121,11 +110,9 @@ export namespace imsd::ipsec { } } - // The full `ip xfrm` argv sequence to install a fresh SA pair: the four - // ESP states, then the four transport policies — no flush: the caller - // takes imsd's previous entries out first (OwnXfrmDeleteCommands), so - // another IPsec user's stay. Order otherwise matches imscall.setup_sa. - // Callers validate p.alg with + // The full `ip xfrm` argv sequence to install a fresh SA pair: flush + // state+policy, add the four ESP states, add the four transport policies. + // Order matches imscall.setup_sa exactly. Callers validate p.alg with // KernelAuth() first; an unknown alg goes to the kernel by its own name // and is rejected there, never mapped to a default. std::vector> BuildSetupCommands(const SaParams& p) { @@ -136,6 +123,8 @@ export namespace imsd::ipsec { std::vector enc = detail::EncArgs(p); int plen = imsd::util::Is6(local) ? 128 : 32; std::vector> cmds; + cmds.push_back({"ip", "xfrm", "state", "flush"}); + cmds.push_back({"ip", "xfrm", "policy", "flush"}); cmds.push_back(detail::StateAdd(local, pcscf, p.spiPs, 1, auth, ikx, enc)); cmds.push_back(detail::StateAdd(pcscf, local, p.spiUc, 1, auth, ikx, enc)); cmds.push_back(detail::StateAdd(local, pcscf, p.spiPc, 2, auth, ikx, enc)); @@ -147,6 +136,10 @@ export namespace imsd::ipsec { return cmds; } + std::vector> BuildTeardownCommands() { + return {{"ip", "xfrm", "state", "flush"}, {"ip", "xfrm", "policy", "flush"}}; + } + // ---- Security-Server (RFC 3329 §2.2) ----------------------------------- // // The P-CSCF answers the challenge with the mechanisms it supports, each @@ -313,10 +306,10 @@ export namespace imsd::ipsec { // What a warm SA in the kernel tells us — enough for a refresh re-REGISTER // to re-bind without re-authenticating. struct ExistingSa { - std::string securityServer; // one-mechanism Security-Server value: the resume's Security-Verify when the state file holds no list for this pair + std::string securityServer; // reconstructed Security-Server header value int portPs = 0; int portPc = 0; - std::uint32_t spiUc = 0; // our inbound client SPI (a reregistration offers a different one) + std::uint32_t spiUc = 0; // our inbound client SPI (advertise on refresh) std::uint32_t spiUs = 0; // our inbound server SPI std::uint32_t spiPs = 0; std::uint32_t spiPc = 0; @@ -370,21 +363,6 @@ export namespace imsd::ipsec { if (ec != std::errc{} || p != tok.data() + tok.size()) return std::nullopt; return v; } - - // A policy block of imsd's own: transport mode, reqid 1 or 2, and the - // fixed protected port on the UE side — sport PortUc/PortUs on `dir - // out`, dport on `dir in` (BuildSetupCommands' PolicyAdd shape). Reqid - // and mode alone are anyone's (L2TP/IPsec, a host-to-host SA); the - // ports are the fingerprint. The one predicate every reader of the - // kernel's policy text uses. - inline bool OwnPolicy(std::string_view b) { - auto reqid = ParseDec(TokenAfter(b, "reqid ")); - if (!reqid || (*reqid != 1 && *reqid != 2) || b.find("mode transport") == std::string_view::npos) return false; - const int port = *reqid == 1 ? imsd::util::PortUc : imsd::util::PortUs; - if (b.find("dir out") != std::string_view::npos) return ParseDec(TokenAfter(b, "sport ")) == port; - if (b.find("dir in") != std::string_view::npos) return ParseDec(TokenAfter(b, "dport ")) == port; - return false; - } } // Reconstruct SA facts from `ip xfrm state` + `ip xfrm policy` text. @@ -392,38 +370,26 @@ export namespace imsd::ipsec { // state: (src==LOCAL,dst==P): reqid1->spiPs reqid2->spiPc // (src==P,dst==LOCAL): reqid1->spiUc reqid2->spiUs // policy: dir out, reqid1->portPs=dport, reqid2->portPc=dport - // Addresses are compared in RFC 5952 form on both sides (the kernel prints - // that form; PCSCF=/LOCAL= arrive in any spelling), so the spelling in - // /etc/imsd.env can no longer hide a registration's SAs. // Returns nullopt unless spiPs, spiPc, portPs, portPc are all found. std::optional ParseExistingSa(std::string_view stateText, std::string_view policyText, std::string_view pcscf, std::string_view local) { - const std::string p = imsd::util::CanonicalAddr(pcscf); - const std::string l = imsd::util::CanonicalAddr(local); ExistingSa sa; bool havePs = false; bool havePc = false; bool havePortPs = false; bool havePortPc = false; for (std::string_view b : detail::SrcBlocks(stateText)) { - const std::string src = imsd::util::CanonicalAddr(detail::TokenAfter(b, "src ")); - const std::string dst = imsd::util::CanonicalAddr(detail::TokenAfter(b, "dst ")); + std::string_view src = detail::TokenAfter(b, "src "); + std::string_view dst = detail::TokenAfter(b, "dst "); auto spi = detail::ParseHex32(detail::TokenAfter(b, "spi ")); auto reqid = detail::ParseDec(detail::TokenAfter(b, "reqid ")); if (!spi || !reqid) continue; - if (src == l && dst == p && *reqid == 1) { sa.spiPs = *spi; havePs = true; } - if (src == l && dst == p && *reqid == 2) { sa.spiPc = *spi; havePc = true; } - if (src == p && dst == l && *reqid == 1) sa.spiUc = *spi; - if (src == p && dst == l && *reqid == 2) sa.spiUs = *spi; + if (src == local && dst == pcscf && *reqid == 1) { sa.spiPs = *spi; havePs = true; } + if (src == local && dst == pcscf && *reqid == 2) { sa.spiPc = *spi; havePc = true; } + if (src == pcscf && dst == local && *reqid == 1) sa.spiUc = *spi; + if (src == pcscf && dst == local && *reqid == 2) sa.spiUs = *spi; } for (std::string_view b : detail::SrcBlocks(policyText)) { - if (b.find("dir out") == std::string_view::npos || !detail::OwnPolicy(b)) continue; - // the ports come from our own pair — a policy of imsd's own - // (detail::OwnPolicy) between our two addresses — never from a - // foreign policy that happens to carry ports - std::string_view srcTok = detail::TokenAfter(b, "src "); - std::string_view dstTok = detail::TokenAfter(b, "dst "); - if (imsd::util::CanonicalAddr(srcTok.substr(0, srcTok.find('/'))) != l) continue; - if (imsd::util::CanonicalAddr(dstTok.substr(0, dstTok.find('/'))) != p) continue; + if (b.find("dir out") == std::string_view::npos) continue; auto dport = detail::ParseDec(detail::TokenAfter(b, "dport ")); auto reqid = detail::ParseDec(detail::TokenAfter(b, "reqid ")); if (!dport || !reqid) continue; @@ -435,144 +401,16 @@ export namespace imsd::ipsec { return sa; } - // The Security-Verify a resume sends, and where it came from: the state - // file's list when the entry carrying its SPIs names the SA pair the - // kernel holds (a file an attempt the process did not live to record - // left behind names another pair), else the one-mechanism entry rebuilt - // from the SA — for an empty or unparseable value too. - struct ResumeSecurityVerify { - enum class Source { Stored, NoneStored, OtherSa }; - Source source = Source::NoneStored; - std::string value; - std::size_t mechanisms = 0; // of the stored list, when it is used - std::optional storedSpiPs; // the stored list's spi-s (OtherSa); nullopt = none parsed - }; - ResumeSecurityVerify ChooseResumeSecurityVerify(std::string_view stored, const ExistingSa& sa, std::string_view offeredEalg) { - ResumeSecurityVerify r; - r.value = sa.securityServer; - if (stored.empty()) return r; - r.source = ResumeSecurityVerify::Source::OtherSa; - if (auto parsed = ParseSecurityServer(stored, "hmac-sha-1-96", offeredEalg)) { - const SecurityMechanism& c = parsed->Carrier(); - r.storedSpiPs = c.spiPs; - if (c.spiPc == sa.spiPc && c.spiPs == sa.spiPs && c.portPc == sa.portPc && c.portPs == sa.portPs) { - r.source = ResumeSecurityVerify::Source::Stored; - r.value = std::string(stored); - r.mechanisms = parsed->mechanisms.size(); - } - } - return r; - } - - // Whether a state file was written for the SA pair the kernel holds: the - // file's spi_uc/spi_us (the UE's inbound SPIs — drawn per fresh attempt, - // kept by a re-REGISTER, written back by a resume) against the pair's. A - // file another attempt left behind — one that got through the SA setup - // and did not live to record, to this or another P-CSCF — names another - // pair, and its dialog is not this pair's. Unknown on either side (a - // file from before the keys, a 0 from a missing inbound state) decides - // nothing. - bool FileNamesAnotherPair(std::optional fileUc, std::optional fileUs, std::uint32_t saUc, std::uint32_t saUs) { - if (!fileUc || !fileUs || *fileUc <= 0 || *fileUs <= 0 || saUc == 0 || saUs == 0) return false; - return static_cast(*fileUc) != saUc || static_cast(*fileUs) != saUs; - } - // Every local address holding an SA to the P-CSCF, once each, in kernel // order: where a registration made on another address of the link (the // kernel's temporary one, before 0.3.10 — ims ledger F18) still lives. std::vector SaLocals(std::string_view stateText, std::string_view pcscf) { - const std::string p = imsd::util::CanonicalAddr(pcscf); std::vector locals; for (std::string_view b : detail::SrcBlocks(stateText)) { - const std::string src = imsd::util::CanonicalAddr(detail::TokenAfter(b, "src ")); - if (src.empty() || imsd::util::CanonicalAddr(detail::TokenAfter(b, "dst ")) != p) continue; - if (std::ranges::find(locals, src) == locals.end()) locals.push_back(src); + 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; } - - // The P-CSCF the kernel's SAs are to: the one address that a `dir out` - // policy of imsd's own with reqid 1 and one with reqid 2 both name - // (detail::OwnPolicy: transport mode, the fixed protected port as the - // selector's sport — reqid and mode alone are anyone's) — exactly the pair - // BuildSetupCommands installs. The state text cannot - // tell the two ends apart (the UE is a `dst` there too). A foreign policy - // beside the pair (a VPN's: tunnel mode, another destination, whatever - // its reqid) changes nothing; a lone policy is not a registration, and - // two addresses each carrying both reqids are not one pair to tell - // apart: nullopt then, and when there are none. - std::optional SaPeer(std::string_view policyText) { - std::vector r1; - std::vector r2; - for (std::string_view b : detail::SrcBlocks(policyText)) { - if (b.find("dir out") == std::string_view::npos || !detail::OwnPolicy(b)) continue; - auto reqid = detail::ParseDec(detail::TokenAfter(b, "reqid ")); - std::string_view dstTok = detail::TokenAfter(b, "dst "); - const std::string dst = imsd::util::CanonicalAddr(dstTok.substr(0, dstTok.find('/'))); - if (dst.empty()) continue; - std::vector& set = *reqid == 1 ? r1 : r2; - if (std::ranges::find(set, dst) == set.end()) set.push_back(dst); - } - std::optional peer; - for (const std::string& d : r1) { - if (std::ranges::find(r2, d) == r2.end()) continue; - if (peer) return std::nullopt; - peer = d; - } - return peer; - } - - // The `ip xfrm … delete` argv lists that take the entries two rules select - // out of the kernel as the two texts show it: every policy - // of imsd's own (detail::OwnPolicy — transport mode, reqid 1 or 2, the - // fixed protected port on the UE side: the ports BuildSetupCommands - // installs), by the selector `ip xfrm policy` prints (src, dst, proto, - // sport, dport, dev) and its direction; and every transport-mode ESP state - // with reqid 1 or 2 running from such a policy's source to its destination, - // or anchored on `local`, the UE's ims-PDN address, by src, dst and SPI — - // policy or not: a setup whose policy add failed leaves such states, and - // an ESP one another transport-mode user put on that address with reqid 1 - // or 2 goes with them (§12; an AH or IPComp state is not selected). - // Everything else — a policy-based - // VPN's tunnel entries, another transport-mode user's on other ports and - // addresses — stays: the fresh registration's SA setup runs these before - // BuildSetupCommands' adds, the dead pair's teardown (RemoveDeadPair, - // main.cpp) runs them alone, and neither flushes (§12). Residual: a - // transport-mode policy with reqid 1 and the UE-side port 45061, or reqid - // 2 and 45062, alone or paired, carries the fingerprint and goes with the - // reqid-1/2 transport-mode ESP states in its direction, and a reqid-1/2 - // transport-mode ESP state on `local` goes whoever made it (tests/Ipsec). - std::vector> OwnXfrmDeleteCommands(std::string_view stateText, std::string_view policyText, std::string_view local) { - auto addr = [](std::string_view tok) { return imsd::util::CanonicalAddr(tok.substr(0, tok.find('/'))); }; - const std::string l = imsd::util::CanonicalAddr(local); - std::vector> pairs; // (src, dst) of imsd's policies - std::vector> policyDels; - for (std::string_view b : detail::SrcBlocks(policyText)) { - if (!detail::OwnPolicy(b)) continue; - const std::string_view src = detail::TokenAfter(b, "src "); - const std::string_view dst = detail::TokenAfter(b, "dst "); - pairs.emplace_back(addr(src), addr(dst)); - // the selector line as `ip xfrm policy` prints it: src dst [proto] [sport] [dport] [dev] — every token - // present goes into the delete (the tmpl line's `proto esp` is not the selector's), none is emitted empty - const std::string_view sel = b.substr(0, b.find('\n')); - std::vector del{"ip", "xfrm", "policy", "delete", "src", std::string(src), "dst", std::string(dst)}; - for (const char* key : {"proto ", "sport ", "dport ", "dev "}) - if (std::string_view v = detail::TokenAfter(sel, key); !v.empty()) { del.emplace_back(key, std::strlen(key) - 1); del.emplace_back(v); } - del.emplace_back("dir"); del.emplace_back(detail::TokenAfter(b, "dir ")); - policyDels.push_back(std::move(del)); - } - std::vector> cmds; - for (std::string_view b : detail::SrcBlocks(stateText)) { - if (b.find("proto esp ") == std::string_view::npos) continue; // the delete names proto esp: an AH or IPComp state is not this rule's - if (b.find("reqid 1 mode transport") == std::string_view::npos && b.find("reqid 2 mode transport") == std::string_view::npos) continue; - const std::string_view src = detail::TokenAfter(b, "src "); - const std::string_view dst = detail::TokenAfter(b, "dst "); - bool mine = !l.empty() && (addr(src) == l || addr(dst) == l); - for (const auto& p : pairs) if (p.first == addr(src) && p.second == addr(dst)) { mine = true; break; } - if (!mine) continue; - cmds.push_back({"ip", "xfrm", "state", "delete", "src", std::string(src), "dst", std::string(dst), "proto", "esp", "spi", std::string(detail::TokenAfter(b, "spi "))}); - } - cmds.insert(cmds.end(), policyDels.begin(), policyDels.end()); - return cmds; - } } diff --git a/interfaces/Imsd-Messages.cppm b/interfaces/Imsd-Messages.cppm index dcb28de..3ef75e2 100644 --- a/interfaces/Imsd-Messages.cppm +++ b/interfaces/Imsd-Messages.cppm @@ -29,9 +29,8 @@ import :Sip; export namespace imsd::msg { // Everything the builders need that is stable for the life of a - // registration. Ports default to the fixed protected values; route and - // ppi are filled in from the REGISTER 200 OK, securityServer from the - // 401 (at a resume, from the state file). + // registration. Ports default to the fixed protected values; route/ppi/ + // securityServer are filled in from the REGISTER 200 OK. struct Context { imsd::aka::Identity id; std::string local; @@ -64,7 +63,7 @@ export namespace imsd::msg { // self-targeted requests like reg-event // SUBSCRIBE; the temporary (IMSI-derived) // IMPU is barred for anything but REGISTER - std::string securityServer; // Security-Verify value: the 401's Security-Server list (from the state file on a resume) + std::string securityServer; // Security-Verify value (the network's ss) // P-Access-Network-Info value (imsd::aka::ParsePani from live mmcli // location: real MCC/MNC/TAC/ECI, FDD). Empty omits the header — // never fabricate one: the network reads it to learn which access @@ -89,15 +88,9 @@ export namespace imsd::msg { return std::format("Via: SIP/2.0/{} {};branch=z9hG4bK{};rport", transport, imsd::util::HostPort(c.local, port), branch); } - // Security-Client value: the SPIs and protected ports offered for the SA - // pairs this REGISTER may set up — the in-use ones on an initial - // registration, new SPIs and a new client port on a reregistration - // (TS 24.229 5.1.1.4.2 d; TS 33.203 7.4.0: the server port stays). - inline std::string SecurityClient(const Context& c, std::uint32_t spiUc, std::uint32_t spiUs, int portUc, int portUs) { - return std::format("Security-Client: ipsec-3gpp; alg=hmac-sha-1-96; ealg={}; " "spi-c={}; spi-s={}; port-c={}; port-s={}", c.ealgOffer, spiUc, spiUs, portUc, portUs); - } + // Security-Client value carrying our in-use client SPIs + protected ports. inline std::string SecurityClient(const Context& c, std::uint32_t spiUc, std::uint32_t spiUs) { - return SecurityClient(c, spiUc, spiUs, c.portUc, c.portUs); + return std::format("Security-Client: ipsec-3gpp; alg=hmac-sha-1-96; ealg={}; " "spi-c={}; spi-s={}; port-c={}; port-s={}", c.ealgOffer, spiUc, spiUs, c.portUc, c.portUs); } // Authorization line with an empty (unchallenged) digest — the first @@ -224,17 +217,9 @@ export namespace imsd::msg { // The stock oracle sends it in BOTH REGISTERs (2026-07-21 capture, // reg2 build at 19:30:44); an earlier reading that reg2 omits it was // wrong and cost us all terminating delivery. `authLine` is a full - // Authorization line: AuthAka with the challenge's answer for the - // initial registration's second REGISTER and, verbatim, for every - // reregistration after it (TS 24.229 5.1.1.4.2 a: the last received - // nonce, the last calculated response); AuthEmpty for a registration - // resumed from a pre-0.3.11 state file, on every reregistration until - // its next fresh registration. A - // reregistration also passes NEW SPIs and the client port it proposes - // for a re-authentication's SA pairs (`portUc`, TS 33.203 7.4.0: the - // server port stays); 0 offers the in-use client port, the initial - // registration's case. - std::string BuildRegisterProtected(const Context& c, std::string_view callid, std::string_view ftag, std::string_view branch, int cseq, std::uint32_t spiUc, std::uint32_t spiUs, std::string_view authLine, int portUc = 0) { + // Authorization line (AuthEmpty for the refresh's first try, AuthAka + // once challenged). + std::string BuildRegisterProtected(const Context& c, std::string_view callid, std::string_view ftag, std::string_view branch, int cseq, std::uint32_t spiUc, std::uint32_t spiUs, std::string_view authLine) { std::vector l = { std::format("REGISTER {} SIP/2.0", c.id.regUri), ViaLine(c, c.transport, c.portUs, branch), @@ -248,7 +233,7 @@ export namespace imsd::msg { std::string(authLine), "Require: sec-agree", "Proxy-Require: sec-agree", - SecurityClient(c, spiUc, spiUs, portUc ? portUc : c.portUc, c.portUs), + SecurityClient(c, spiUc, spiUs), std::format("Security-Verify: {}", c.securityServer), }; l.push_back("Supported: sec-agree, path"); diff --git a/interfaces/Imsd-Qmi.cppm b/interfaces/Imsd-Qmi.cppm deleted file mode 100644 index f1c2e26..0000000 --- a/interfaces/Imsd-Qmi.cppm +++ /dev/null @@ -1,260 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® - -// lint-disable-file fixed-width-types -/* -Imsd:Qmi — the bytes of the QMI WDS exchanges imsd-pcscf has with the modem -to learn the ims PDN's P-CSCF list, and the readers of the answers. - -Over QRTR a QMI message is {type u8, transaction u16, message u16, length -u16} followed by TLVs {type u8, length u16, value}, integers little endian. -The modem hands the P-CSCF addresses the network put in the PDN's Protocol -Configuration Options to a WDS client bound to the data port that carries the -call (Bind Mux Data Port, Set IP Family, Get Current Settings) — in the TLVs -libqmi models (0x22 the flag, 0x23 the IPv4 list, 0x24 the FQDN list) and in -one it does not: 0x2E, the IPv6 list, Qualcomm's layout as read off the -Fairphone 6 (a count byte, then 16 bytes per address, PCO order). The socket, -the service lookup and the sequence are the helper's -(implementations/pcscf.cpp); this partition only shapes bytes and reads -answers, pinned by tests/Qmi on a response in the exact shape the modem -returned, addresses re-encoded to the documentation prefix. -*/ - -export module Imsd:Qmi; -import std; -import :Util; - -export namespace imsd::qmi { - - constexpr std::uint32_t WdsService = 1; - constexpr std::uint8_t TypeRequest = 0x00; - constexpr std::uint8_t TypeResponse = 0x02; - constexpr std::uint8_t TypeIndication = 0x04; - - constexpr std::uint16_t MsgGetPacketServiceStatus = 0x0022; - constexpr std::uint16_t MsgGetProfileSettings = 0x002B; - constexpr std::uint16_t MsgGetCurrentSettings = 0x002D; - constexpr std::uint16_t MsgSetIpFamily = 0x004D; - constexpr std::uint16_t MsgBindMuxDataPort = 0x00A2; - - // Get Current Settings "Requested Settings": every bit libqmi defines - // (0..18). The P-CSCF ones are 10 (address), 11 (server address list) - // and 12 (domain name list); the whole mask is what the probe used. - constexpr std::uint32_t RequestedSettingsAll = 0x7FFFF; - // The data endpoint of an IPA-attached modem as ModemManager binds it: - // type embedded (4), interface number 1. - constexpr std::uint32_t EndpointEmbedded = 4; - constexpr std::uint32_t EndpointInterfaceIpa = 1; - constexpr std::uint8_t IpFamilyV6 = 6; - constexpr std::uint8_t ProfileType3gpp = 0; - constexpr std::uint8_t PacketServiceConnected = 2; - - constexpr std::uint16_t ErrOutOfCall = 15; - constexpr std::uint16_t ErrNoEffect = 26; - - struct Tlv { - std::uint8_t type = 0; - std::vector value; - }; - - namespace detail { - inline void Put16(std::vector& b, std::uint16_t v) { - b.push_back(static_cast(v & 0xFF)); - b.push_back(static_cast(v >> 8)); - } - inline void Put32(std::vector& b, std::uint32_t v) { - Put16(b, static_cast(v & 0xFFFF)); - Put16(b, static_cast(v >> 16)); - } - inline std::uint16_t Get16(std::span b, std::size_t at) { - return static_cast(b[at] | (b[at + 1] << 8)); - } - inline std::uint32_t Get32(std::span b, std::size_t at) { - return static_cast(Get16(b, at)) | (static_cast(Get16(b, at + 2)) << 16); - } - // RFC 5952 text of 16 address bytes (via the canonicaliser: the - // partition has no inet_ntop) - inline std::string Ip6Text(std::span a) { - std::string s; - for (std::size_t i = 0; i < 16; i += 2) s += std::format("{}{:x}", i ? ":" : "", (a[i] << 8) | a[i + 1]); - return imsd::util::CanonicalIp6(s).value_or(s); - } - } - - inline std::vector TlvBytes(std::uint8_t type, std::span value) { - std::vector b; - b.push_back(type); - detail::Put16(b, static_cast(value.size())); - b.insert(b.end(), value.begin(), value.end()); - return b; - } - inline std::vector Tlv8(std::uint8_t type, std::uint8_t v) { - std::array a{v}; - return TlvBytes(type, a); - } - inline std::vector Tlv32(std::uint8_t type, std::uint32_t v) { - std::vector a; - detail::Put32(a, v); - return TlvBytes(type, a); - } - - // A request: the header, then the TLVs as given. - inline std::vector Request(std::uint16_t txn, std::uint16_t msg, std::span tlvs) { - std::vector b; - b.push_back(TypeRequest); - detail::Put16(b, txn); - detail::Put16(b, msg); - detail::Put16(b, static_cast(tlvs.size())); - b.insert(b.end(), tlvs.begin(), tlvs.end()); - return b; - } - - // Bind Mux Data Port: TLV 0x10 {endpoint type u32, interface number - // u32}, TLV 0x11 mux id u8 — the binding ModemManager gives its own - // bearer clients, which is what lets this client read their call. - inline std::vector BindMuxDataPort(std::uint16_t txn, std::uint32_t epType, std::uint32_t epIface, std::uint8_t mux) { - std::vector ep; - detail::Put32(ep, epType); - detail::Put32(ep, epIface); - std::vector t = TlvBytes(0x10, ep); - std::vector m = Tlv8(0x11, mux); - t.insert(t.end(), m.begin(), m.end()); - return Request(txn, MsgBindMuxDataPort, t); - } - // Set IP Family: TLV 0x01 u8 (4 or 6) — which family's settings the - // client asks about. - inline std::vector SetIpFamily(std::uint16_t txn, std::uint8_t family) { - return Request(txn, MsgSetIpFamily, Tlv8(0x01, family)); - } - inline std::vector GetPacketServiceStatus(std::uint16_t txn) { - return Request(txn, MsgGetPacketServiceStatus, {}); - } - // Get Current Settings: TLV 0x10 the requested-settings mask u32. - inline std::vector GetCurrentSettings(std::uint16_t txn, std::uint32_t mask) { - return Request(txn, MsgGetCurrentSettings, Tlv32(0x10, mask)); - } - // Get Profile Settings: TLV 0x01 {profile type u8 (0 = 3GPP), index u8}. - inline std::vector GetProfileSettings(std::uint16_t txn, std::uint8_t index) { - std::array id{ProfileType3gpp, index}; - return Request(txn, MsgGetProfileSettings, TlvBytes(0x01, id)); - } - - struct Response { - std::uint8_t type = 0; - std::uint16_t txn = 0; - std::uint16_t msg = 0; - std::vector tlvs; - bool hasResult = false; // TLV 0x02 {result u16, error u16} present - std::uint16_t result = 0; - std::uint16_t error = 0; - bool Ok() const { return hasResult && result == 0; } - const Tlv* Find(std::uint8_t t) const { - for (const Tlv& x : tlvs) if (x.type == t) return &x; - return nullptr; - } - }; - - // A received message: the header and every TLV; nullopt when the buffer - // is shorter than its header says or a TLV overruns it. - inline std::optional ParseMessage(std::span b) { - if (b.size() < 7) return std::nullopt; - Response r; - r.type = b[0]; - r.txn = detail::Get16(b, 1); - r.msg = detail::Get16(b, 3); - std::size_t len = detail::Get16(b, 5); - if (7 + len > b.size()) return std::nullopt; - std::size_t off = 7; - std::size_t end = 7 + len; - while (off + 3 <= end) { - Tlv t; - t.type = b[off]; - std::size_t n = detail::Get16(b, off + 1); - if (off + 3 + n > end) return std::nullopt; - t.value.assign(b.begin() + static_cast(off + 3), b.begin() + static_cast(off + 3 + n)); - if (t.type == 0x02 && n == 4) { - r.hasResult = true; - r.result = detail::Get16(t.value, 0); - r.error = detail::Get16(t.value, 2); - } - r.tlvs.push_back(std::move(t)); - off += 3 + n; - } - if (off != end) return std::nullopt; - return r; - } - - // The QMI protocol error names imsd-pcscf can meet (libqmi's nicks). - inline std::string_view ErrorName(std::uint16_t e) { - switch (e) { - case 0: return "None"; - case 1: return "MalformedMessage"; - case 3: return "Internal"; - case 9: return "InvalidHandle"; - case 10: return "InvalidProfile"; - case 14: return "CallFailed"; - case 15: return "OutOfCall"; - case 17: return "MissingArgument"; - case 23: return "DeviceInUse"; - case 26: return "NoEffect"; - case 46: return "GeneralError"; - case 48: return "InvalidArgument"; - case 71: return "InvalidQmiCommand"; - case 82: return "AccessDenied"; - default: return "?"; - } - } - - // What a Get Current Settings answer says about the P-CSCF. - struct PcscfLists { - bool viaPco = false; // TLV 0x22: the modem asked for P-CSCF addresses in the PCO - std::vector ipv6; // TLV 0x2E, PCO order, canonical - std::vector ipv4; // TLV 0x23, dotted quads - std::vector fqdn; // TLV 0x24 - }; - - // Tolerant: a list whose count does not match its length is read as - // empty, never as garbage. - inline PcscfLists ParsePcscfLists(const Response& r) { - PcscfLists out; - if (const Tlv* f = r.Find(0x22); f && f->value.size() == 1) out.viaPco = f->value[0] != 0; - if (const Tlv* t = r.Find(0x2E); t && !t->value.empty() && t->value.size() == 1 + 16 * static_cast(t->value[0])) - for (std::size_t i = 0; i < t->value[0]; i++) - out.ipv6.push_back(detail::Ip6Text(std::span(t->value).subspan(1 + 16 * i, 16))); - if (const Tlv* t = r.Find(0x23); t && !t->value.empty() && t->value.size() == 1 + 4 * static_cast(t->value[0])) - for (std::size_t i = 0; i < t->value[0]; i++) { - std::uint32_t v = detail::Get32(t->value, 1 + 4 * i); // little-endian u32, a.b.c.d = the high byte first - out.ipv4.push_back(std::format("{}.{}.{}.{}", v >> 24, (v >> 16) & 0xFF, (v >> 8) & 0xFF, v & 0xFF)); - } - if (const Tlv* t = r.Find(0x24); t && !t->value.empty()) { - std::vector names; - std::size_t off = 1; - bool good = true; - for (std::size_t i = 0; i < t->value[0] && good; i++) { - if (off + 2 > t->value.size()) { good = false; break; } - std::size_t n = detail::Get16(t->value, off); - if (off + 2 + n > t->value.size()) { good = false; break; } - names.emplace_back(reinterpret_cast(t->value.data() + off + 2), n); - off += 2 + n; - } - if (good && off == t->value.size()) out.fqdn = std::move(names); - } - return out; - } - - // Get Packet Service Status: TLV 0x01 u8 (2 = connected) — whether the - // bound client sees the port's call. - inline std::optional PacketServiceStatus(const Response& r) { - const Tlv* t = r.Find(0x01); - if (!t || t->value.size() != 1) return std::nullopt; - return t->value[0]; - } - - // Get Profile Settings: TLV 0x1F "PCSCF Address Using PCO" u8 — whether - // the profile has the modem ask the network at all. - inline std::optional ProfilePcscfViaPco(const Response& r) { - const Tlv* t = r.Find(0x1F); - if (!t || t->value.size() != 1) return std::nullopt; - return t->value[0] != 0; - } -} diff --git a/interfaces/Imsd-Sdp.cppm b/interfaces/Imsd-Sdp.cppm index 1c77dc1..723720d 100644 --- a/interfaces/Imsd-Sdp.cppm +++ b/interfaces/Imsd-Sdp.cppm @@ -9,9 +9,7 @@ The offer is by default an AMR-WB (octet-aligned) + AMR + telephone-event shape with an optional GSMA IR.92 QoS-precondition block; it is what commercial IMS cores expect from a VoLTE UE, and its exact bytes are pinned by tests/Sdp. The parser extracts only what the media plane needs: remote -address/port, the negotiated payload type, the codec and octet-align mode, and -the telephone-event payload type among the m-line's formats at that codec's -clock (DTMF, RFC 4733) — it +address/port, the negotiated payload type, the codec and octet-align mode — it reads answers to our offers and inbound offers alike (same fields either way). The codecs it knows are the ones imsd-media can play: AMR-WB, AMR (narrowband) and G.711 (PCMA/PCMU) — the last two are what a PSTN gateway offers when a @@ -84,11 +82,6 @@ export namespace imsd::sdp { std::vector codecs; // offered codecs in order; empty = AMR-WB, AMR }; - // The payload type our offer gives telephone-event at a clock rate: the - // number an answerer sends with (RFC 3264 §6.1), whatever the answer - // renumbered ours to. - int OfferedTelephoneEventPt(int clockRate) { return clockRate == 16000 ? 98 : 100; } - // Our offer: AMR-WB (octet-aligned) first, AMR narrowband second — the // 3GPP-mandatory codec every IMS core and PSTN gateway can answer, so a // call we place TO a landline no longer depends on the far side @@ -114,8 +107,8 @@ export namespace imsd::sdp { s += "t=0 0\r\n"; std::string pts; for (const auto& c : codecs) pts += std::format(" {}", pt(c)); - if (wb) pts += std::format(" {}", OfferedTelephoneEventPt(16000)); - if (nb) pts += std::format(" {}", OfferedTelephoneEventPt(8000)); + if (wb) pts += " 98"; + if (nb) pts += " 100"; s += std::format("m=audio {} RTP/AVP{}\r\n", o.rtpPort, pts); s += std::format("b=AS:{}\r\n", wb ? 41 : amr ? 30 : 80); s += "b=RS:512\r\n"; @@ -132,12 +125,12 @@ export namespace imsd::sdp { } } if (wb) { - s += std::format("a=rtpmap:{} telephone-event/16000\r\n", OfferedTelephoneEventPt(16000)); - s += std::format("a=fmtp:{} 0-15\r\n", OfferedTelephoneEventPt(16000)); + s += "a=rtpmap:98 telephone-event/16000\r\n"; + s += "a=fmtp:98 0-15\r\n"; } if (nb) { - s += std::format("a=rtpmap:{} telephone-event/8000\r\n", OfferedTelephoneEventPt(8000)); - s += std::format("a=fmtp:{} 0-15\r\n", OfferedTelephoneEventPt(8000)); + s += "a=rtpmap:100 telephone-event/8000\r\n"; + s += "a=fmtp:100 0-15\r\n"; } s += "a=ptime:20\r\n"; s += "a=maxptime:240\r\n"; @@ -157,68 +150,22 @@ export namespace imsd::sdp { int payloadType = -1; bool octetAlign = false; std::string codec = "AMR-WB"; // "AMR-WB" | "AMR" | "PCMA" | "PCMU" | "" (none we play) - std::optional dtmfPt; // telephone-event at the codec's clock rate; nullopt = no DTMF }; - // Sample rate of a codec we play: AMR-WB is 16 kHz, everything else - // (AMR, G.711) is narrowband 8 kHz. Also the RTP clock rate. - int CodecRate(std::string_view codec) { return codec == "AMR-WB" ? 16000 : 8000; } - constexpr std::string_view Avp = " RTP/AVP "; // after the m=audio port: the one profile we speak - - // The m=audio line's payload types in its order; empty without the line, - // its port or the " RTP/AVP " literal. - std::vector AudioFormats(std::string_view body) { - std::vector pts; - auto m = Find(body, "m=audio "); - if (!m) return pts; - std::string_view portTok = TokenAt(body, *m + 8); - std::size_t afterPort = *m + 8 + portTok.size(); - if (!ParseInt(portTok) || !body.substr(afterPort).starts_with(Avp)) return pts; - // digits + spaces up to CR/LF - std::size_t ptsBegin = afterPort + Avp.size(); - std::size_t ptsEnd = body.find_first_not_of("0123456789 ", ptsBegin); - std::string_view ptsRun = body.substr(ptsBegin, ptsEnd == std::string_view::npos ? std::string_view::npos : ptsEnd - ptsBegin); - for (auto part : std::views::split(ptsRun, ' ')) - if (auto v = ParseInt(std::string_view(part))) pts.push_back(*v); - return pts; - } - - // A payload type's rtpmap encoding, `NAME/CLOCK[/CHANNELS]` upper-cased - // (RFC 4566 §6: encoding names are case-insensitive); empty without one. - std::string Rtpmap(std::string_view body, int pt) { - const std::string key = std::format("a=rtpmap:{} ", pt); - auto at = Find(body, key); - if (!at) return ""; - std::size_t begin = *at + key.size(); - std::size_t end = body.find_first_of("\r\n", begin); - std::string enc(body.substr(begin, end == std::string_view::npos ? std::string_view::npos : end - begin)); - for (char& c : enc) - c = static_cast(std::toupper(static_cast(c))); - return enc; - } - - // Payload type of `telephone-event/` among the m=audio line's - // formats (DTMF, RFC 4733); nullopt when the line lists none. An rtpmap - // the line does not list is not negotiated: nothing is sent with a format - // the answer does not carry (RFC 3264 §8.3.2). The clock is matched - // whole: `/8000` is not `/80000`. - std::optional TelephoneEventPt(std::string_view body, int clockRate) { - const std::string needle = std::format("TELEPHONE-EVENT/{}", clockRate); - for (int pt : AudioFormats(body)) { - const std::string enc = Rtpmap(body, pt); - const bool whole = enc.size() == needle.size() || (enc.size() > needle.size() && (enc[needle.size()] == '/' || enc[needle.size()] == ' ')); - if (enc.starts_with(needle) && whole) return pt; - } - return std::nullopt; - } - // The encoding name an offer gives a payload type: its a=rtpmap line, // upper-cased (RFC 4566 §6: encoding names are case-insensitive), else // the static assignment for 0/8 (RFC 3551 §6 — a G.711 offer may carry // no rtpmap at all). Empty when neither applies. std::string CodecName(std::string_view body, int pt) { - const std::string enc = Rtpmap(body, pt); - if (!enc.empty()) return enc.substr(0, enc.find('/')); + std::string key = std::format("a=rtpmap:{} ", pt); + if (auto at = Find(body, key)) { + std::size_t nameBegin = *at + key.size(); + std::size_t nameEnd = body.find_first_of("/\r\n", nameBegin); + std::string name(body.substr(nameBegin, nameEnd == std::string_view::npos ? std::string_view::npos : nameEnd - nameBegin)); + for (char& c : name) + c = static_cast(std::toupper(static_cast(c))); + return name; + } if (pt == 0) return "PCMU"; if (pt == 8) return "PCMA"; return ""; @@ -255,10 +202,7 @@ export namespace imsd::sdp { // non-empty `prefs` replaces the codec order (and restricts acceptance // to the codecs named) — the CODECS override. With no acceptable codec // at all, the first pt is used and codec comes back EMPTY — pretending - // otherwise would defeat codec checks upstream. The telephone-event pt - // is the one at the chosen codec's clock (an event's rate is its rtpmap - // clock, RFC 4733 §2.4.1; an offer with two codec clocks lists - // telephone-event at each), so a tone goes out on the stream's own clock. + // otherwise would defeat codec checks upstream. Answer ParseAnswer(std::string_view body, std::span prefs = {}) { Answer a; if (auto at = Find(body, "c=IN IP6 ")) a.ip = TokenAt(body, *at + 9); @@ -270,10 +214,17 @@ export namespace imsd::sdp { auto port = ParseInt(portTok); // both the port and the " RTP/AVP " literal are required std::size_t afterPort = *m + 8 + portTok.size(); + constexpr std::string_view Avp = " RTP/AVP "; if (!port || !body.substr(afterPort).starts_with(Avp)) return a; a.port = *port; - std::vector pts = AudioFormats(body); + // payload-type list: digits + spaces up to CR/LF + std::size_t ptsBegin = afterPort + Avp.size(); + std::size_t ptsEnd = body.find_first_not_of("0123456789 ", ptsBegin); + std::string_view ptsRun = body.substr(ptsBegin, ptsEnd == std::string_view::npos ? std::string_view::npos : ptsEnd - ptsBegin); + std::vector pts; + for (auto part : std::views::split(ptsRun, ' ')) + if (auto v = ParseInt(std::string_view(part))) pts.push_back(*v); if (pts.empty()) return a; auto hasOctetAlign = [&](int p) { @@ -310,10 +261,28 @@ export namespace imsd::sdp { a.codec.clear(); } a.octetAlign = a.codec.starts_with("AMR") && hasOctetAlign(a.payloadType); - if (!a.codec.empty()) a.dtmfPt = TelephoneEventPt(body, CodecRate(a.codec)); return a; } + // Payload type of `telephone-event/` in a body's rtpmap lines + // (DTMF, RFC 4733); nullopt when the offer doesn't carry one. + std::optional TelephoneEventPt(std::string_view body, int clockRate) { + std::string needle = std::format("telephone-event/{}", clockRate); + std::size_t pos = 0; + while ((pos = body.find("a=rtpmap:", pos)) != std::string_view::npos) { + std::size_t ptBegin = pos + 9; + std::size_t lineEnd = body.find_first_of("\r\n", ptBegin); + std::size_t sp = body.find(' ', ptBegin); + if (sp != std::string_view::npos && (lineEnd == std::string_view::npos || sp < lineEnd)) { + auto pt = ParseInt(body.substr(ptBegin, sp - ptBegin)); + std::string_view name = body.substr(sp + 1, (lineEnd == std::string_view::npos ? body.size() : lineEnd) - sp - 1); + if (pt && name.starts_with(needle)) return pt; + } + pos = ptBegin; + } + return std::nullopt; + } + struct AnswerSpec { std::string local; // connection address (v4 or v6) int rtpPort = 0; @@ -324,11 +293,14 @@ export namespace imsd::sdp { std::optional dtmfPt; // the offer's telephone-event pt, if any }; + // Sample rate of a codec we play: AMR-WB is 16 kHz, everything else + // (AMR, G.711) is narrowband 8 kHz. Also the RTP clock rate. + int CodecRate(std::string_view codec) { return codec == "AMR-WB" ? 16000 : 8000; } + // SDP answer to an inbound audio offer: single codec at the offer's own // payload type, octet-align echoed only when the offer used it (absence // = bandwidth-efficient, RFC 4867 §8.1; G.711 has no fmtp at all), - // telephone-event kept when the offer's m-line lists one at the codec's - // clock (ParseAnswer's rule). b=AS is the codec's peak payload + // telephone-event kept when offered. b=AS is the codec's peak payload // rate plus IP/UDP/RTP overhead at 20 ms. Byte layout pinned by tests/Sdp. std::string BuildAnswer(const AnswerSpec& s) { std::string ipver = Is6(s.local) ? "IP6" : "IP4"; diff --git a/interfaces/Imsd-Util.cppm b/interfaces/Imsd-Util.cppm index e68eb49..e6f1e13 100644 --- a/interfaces/Imsd-Util.cppm +++ b/interfaces/Imsd-Util.cppm @@ -4,9 +4,8 @@ // lint-disable-file fixed-width-types /* Imsd:Util — small self-contained primitives the rest of the engine builds on: -random SIP tokens, IPv6 detection + host/port formatting, the RFC 5952 -canonical IPv6 text and the address-list parser, hex/base64 decoding, and MD5 -(RFC 1321). None of the standard library ships these last three, and +random SIP tokens, IPv6 detection + host/port formatting, hex/base64 decoding, +and MD5 (RFC 1321). None of the standard library ships these last three, and the IMS AKAv1-MD5 digest (Imsd:Aka) needs MD5 over raw bytes plus base64 to unwrap the RAND||AUTN nonce, so they live here rather than pulling in a crypto dependency. Pure std C++ (no GLib, no I/O); behavior is pinned by tests/Util. @@ -25,12 +24,6 @@ export namespace imsd::util { // an installed package's nftables accept rules. constexpr int PortUc = 45061; constexpr int PortUs = 45062; - // The client port a reregistration proposes for the SA pairs a - // re-authentication would set up: TS 33.203 7.4.0 keeps the server - // ports (port_us, port_ps) and changes the client ports (port_uc, - // port_pc). imsd answers such a challenge with a fresh registration, so - // nothing is bound here and the firewall rules need not open it. - constexpr int PortUcNext = 45063; // An address is IPv6 iff it contains a colon (matches the Python is6()). inline bool Is6(std::string_view addr) { return addr.contains(':'); } @@ -41,114 +34,6 @@ export namespace imsd::util { : std::format("{}:{}", addr, port); } - // The RFC 5952 §4 text of an IPv6 address: lowercase hex, no leading - // zeros, the longest run of two or more zero fields as "::" (the first - // on a tie), never a single zero field. This is the form iproute2 and - // inet_ntop print, so addresses canonicalised here compare as text - // against `ip xfrm` output whatever spelling they arrived in (an - // upper-case or uncompressed PCSCF= in /etc/imsd.env never matched the - // kernel's text before). Surrounding brackets are accepted. nullopt for - // anything that is not a plain IPv6 literal: a zone ("%dev"), a "]:port", - // a dotted-quad tail ("::ffff:1.2.3.4" — RFC 5952 §5 keeps that dotted and - // an IPv6-only PDN never produces it; the same address spelled in hex, - // "::ffff:a00:1", is accepted and comes back in hex — §4 form, where - // inet_ntop prints it dotted), two "::", more than eight fields, - // a field over four digits, an IPv4 address, a name. - inline std::optional CanonicalIp6(std::string_view s) { - if (s.size() >= 2 && s.front() == '[' && s.back() == ']') s = s.substr(1, s.size() - 2); - if (s.empty()) return std::nullopt; - auto nibble = [](char c) -> int { - if (c >= '0' && c <= '9') return c - '0'; - if (c >= 'a' && c <= 'f') return c - 'a' + 10; - if (c >= 'A' && c <= 'F') return c - 'A' + 10; - return -1; - }; - for (char c : s) if (c != ':' && nibble(c) < 0) return std::nullopt; - // "a:b" fields of one part (no "::" inside); false on an empty or - // over-long field - auto fields = [&](std::string_view part, std::vector& out) -> bool { - if (part.empty()) return true; - std::size_t start = 0; - while (true) { - std::size_t colon = part.find(':', start); - std::string_view f = part.substr(start, colon == std::string_view::npos ? std::string_view::npos : colon - start); - if (f.empty() || f.size() > 4) return false; - unsigned v = 0; - for (char c : f) v = v * 16 + static_cast(nibble(c)); - out.push_back(static_cast(v)); - if (colon == std::string_view::npos) return true; - start = colon + 1; - } - }; - std::vector head; - std::vector tail; - std::size_t dc = s.find("::"); - if (dc == std::string_view::npos) { - if (!fields(s, head) || head.size() != 8) return std::nullopt; - } else { - if (s.find("::", dc + 1) != std::string_view::npos) return std::nullopt; - if (!fields(s.substr(0, dc), head) || !fields(s.substr(dc + 2), tail)) return std::nullopt; - if (head.size() + tail.size() > 7) return std::nullopt; - } - std::array g{}; - std::copy(head.begin(), head.end(), g.begin()); - std::copy(tail.begin(), tail.end(), g.end() - static_cast(tail.size())); - int bestStart = -1; - int bestLen = 1; // a run must beat one field to be compressed - for (int i = 0; i < 8;) { - if (g[static_cast(i)] != 0) { i++; continue; } - int j = i; - while (j < 8 && g[static_cast(j)] == 0) j++; - if (j - i > bestLen) { bestStart = i; bestLen = j - i; } - i = j; - } - std::string out; - for (int i = 0; i < 8;) { - if (i == bestStart) { - out += "::"; - i += bestLen; - continue; - } - if (!out.empty() && out.back() != ':') out += ':'; - out += std::format("{:x}", g[static_cast(i)]); - i++; - } - return out; - } - - // The canonical text of any address imsd compares or logs: an IPv6 - // literal in RFC 5952 form, anything else (an IPv4 address, a malformed - // IPv6 that the caller's own checks will refuse) unchanged. - inline std::string CanonicalAddr(std::string_view s) { - if (!Is6(s)) return std::string(s); - return CanonicalIp6(s).value_or(std::string(s)); - } - - // A comma-separated address list (PCSCF_DISCOVERED=) → the canonical IPv6 - // literals in it, in order, duplicates dropped. Entries that are not an - // IPv6 literal (an IPv4 address, a name, garbage) go to `rejected` when - // the caller asks for them; blanks are skipped. - inline std::vector ParseAddressList(std::string_view list, std::vector* rejected = nullptr) { - std::vector out; - std::size_t start = 0; - while (start <= list.size()) { - std::size_t comma = list.find(',', start); - std::string_view item = list.substr(start, comma == std::string_view::npos ? std::string_view::npos : comma - start); - while (!item.empty() && (item.front() == ' ' || item.front() == '\t')) item.remove_prefix(1); - while (!item.empty() && (item.back() == ' ' || item.back() == '\t')) item.remove_suffix(1); - if (!item.empty()) { - if (auto c = CanonicalIp6(item)) { - if (std::find(out.begin(), out.end(), *c) == out.end()) out.push_back(*c); - } else if (rejected) { - rejected->emplace_back(item); - } - } - if (comma == std::string_view::npos) break; - start = comma + 1; - } - return out; - } - // A random token of `n` chars over [a-z0-9] — SIP branch/tag/Call-ID // material (the Python rand()). The generator is a parameter so tests can // pin output; the default constructor seeds from random_device. diff --git a/interfaces/Imsd.cppm b/interfaces/Imsd.cppm index 588b20b..a21c778 100644 --- a/interfaces/Imsd.cppm +++ b/interfaces/Imsd.cppm @@ -9,7 +9,6 @@ export module Imsd; export import :Util; export import :Aka; export import :Ipsec; -export import :Qmi; export import :Sip; export import :Sdp; export import :Messages; diff --git a/packaging/APKBUILD.binary b/packaging/APKBUILD.binary index 2e8ac2a..4abd25e 100644 --- a/packaging/APKBUILD.binary +++ b/packaging/APKBUILD.binary @@ -45,7 +45,6 @@ package() { cd "$srcdir/imsd-$pkgver" install -Dm755 imsd "$pkgdir"/usr/bin/imsd install -Dm755 imsd-media "$pkgdir"/usr/libexec/imsd-media - install -Dm755 imsd-pcscf "$pkgdir"/usr/libexec/imsd-pcscf install -Dm755 imsd-dialerd "$pkgdir"/usr/bin/imsd-dialerd install -Dm755 ims-pdn-up.sh "$pkgdir"/usr/libexec/ims-pdn-up.sh install -Dm755 imsd-handover.sh "$pkgdir"/usr/libexec/imsd-handover.sh @@ -60,17 +59,18 @@ package() { # account created at install — imsd-dialerd owns those session bus names install -Dm644 "$srcdir"/org.kde.modem.daemon.desktop \ "$pkgdir"/etc/skel/.config/autostart/org.kde.modem.daemon.desktop - # enabled by preset: VoLTE surviving reboots must not depend on a manual - # systemctl enable. Since 1.0.0 the unit needs no configuration file (the - # P-CSCF comes from the PDN's PCO) and starts on every phone with the - # package; without a SIM it exits cleanly and stays inactive (IMSD_IDLE), - # and `systemctl mask --now imsd` opts out (a disable is undone by - # imsd-ofonod's Wants=imsd.service) + # enabled by preset: the unit is a no-op until /etc/imsd.env exists, and + # VoLTE surviving reboots must not depend on a manual systemctl enable install -Dm644 "$srcdir"/80-imsd.preset \ "$pkgdir"/usr/lib/systemd/system-preset/80-imsd.preset mkdir -p "$pkgdir"/etc/systemd/system/multi-user.target.wants ln -s /usr/lib/systemd/system/imsd.service \ "$pkgdir"/etc/systemd/system/multi-user.target.wants/imsd.service + # ...but only actually start once the carrier config exists, so + # unconfigured systems don't boot into a failing unit + mkdir -p "$pkgdir"/usr/lib/systemd/system/imsd.service.d + printf '[Unit]\nConditionPathExists=/etc/imsd.env\n' \ + > "$pkgdir"/usr/lib/systemd/system/imsd.service.d/10-require-config.conf install -Dm755 imsd-ofonod "$pkgdir"/usr/bin/imsd-ofonod install -Dm755 imsd-ofono-restart-calls.sh \ "$pkgdir"/usr/libexec/imsd-ofono-restart-calls.sh diff --git a/packaging/build-package.sh b/packaging/build-package.sh index f412a29..53c6153 100755 --- a/packaging/build-package.sh +++ b/packaging/build-package.sh @@ -91,7 +91,6 @@ crafter-build -- $XTARGET crafter-build -- --product=media $XTARGET crafter-build -- --product=dialerd $XTARGET crafter-build -- --product=ofonod $XTARGET -crafter-build -- --product=pcscf $XTARGET crafter-build test # --- bundle + package diff --git a/packaging/ims-pdn-up.sh b/packaging/ims-pdn-up.sh index dafc4d9..191c2b5 100644 --- a/packaging/ims-pdn-up.sh +++ b/packaging/ims-pdn-up.sh @@ -5,15 +5,10 @@ # # Finds-or-creates the ims bearer via ModemManager, connects it, and # configures the muxed netdev with the MM-assigned address (the s34 recipe, -# automated), reads the P-CSCF list the network put in the PDN's PCO from the -# modem (imsd-pcscf) unless PCSCF= names one, and gives the configured or -# the first discovered address (the daemon's fresh target) a host route — -# never a default route (F15 below). Idempotent — safe to run when the PDN -# is already up. Runs as -# ExecStartPre of imsd.service, so imsd starts once the PDN exists — or once -# this script has written IMSD_IDLE= and exited 0 with nothing to do, when -# imsd logs the idle and exits cleanly (idle_exit below); a nonzero exit -# fails the unit and systemd retries per Restart/RestartSec. +# automated) and a host route to the P-CSCF — never a default route (F15 +# below). Idempotent — safe to run when the PDN is already up. Runs as +# ExecStartPre of imsd.service, so imsd only starts once the PDN exists; a +# nonzero exit fails the unit and systemd retries per Restart/RestartSec. # # Configuration, via the unit's EnvironmentFile /etc/imsd.env (also read # directly so manual runs behave the same): @@ -27,16 +22,8 @@ # (journal/ims 2026-09-16/17 bench: Odido, O2 UK, Swisscom). # IMS_REG_TIMEOUT max seconds to wait for network registration before # connect attempts start counting anyway (default 300) -# PCSCF one P-CSCF address, by hand: it wins over discovery -# (which is then skipped) and gets the host route; unset, -# imsd-pcscf reads the PDN's PCO list from the modem and -# this script hands it to imsd as PCSCF_DISCOVERED= in -# /run/imsd.env, the first of them routed -# IMSD_PCSCF the reader's path (default /usr/libexec/imsd-pcscf) — a -# bench seam, from the environment only (not from the file) -# IMSD_ENV the configuration file's path (default /etc/imsd.env) — -# a bench seam. The file is optional (overrides only); its -# existence changes nothing +# PCSCF the P-CSCF address imsd registers with (its own +# required setting); gets the host route over the ims PDN log() { echo "ims-pdn-up: $*"; } @@ -57,15 +44,9 @@ bearer_paths() { # `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 ]; } -# A locked SIM (a PIN not entered yet) is the same kind of state the user -# controls: the modem registers once the PIN is in, so like a disabled radio -# it exits non-zero and the unit's restart tries again. -sim_locked() { [ "$(modem_state)" = locked ]; } -unlock_required() { kv -m "$MODEM" | sed -n 's/^modem\.generic\.unlock-required *: *//p'; } -IMSD_ENV=${IMSD_ENV:-/etc/imsd.env} envval() { # $1 = key — for manual runs; under systemd the vars are inherited - sed -n "s/^$1=//p" "$IMSD_ENV" 2>/dev/null | tail -n1 | tr -d '"' + sed -n "s/^$1=//p" /etc/imsd.env 2>/dev/null | tail -n1 | tr -d '"' } IP_TYPE=${IMS_IP_TYPE:-$(envval IMS_IP_TYPE)} IP_TYPE=${IP_TYPE:-ipv6} @@ -86,44 +67,6 @@ while :; do done log "modem $MODEM" -# ---- nothing to do: a clean exit, not a retry loop. imsd reads IMSD_IDLE= -# from /run/imsd.env, logs it and exits 0, which Restart=on-failure leaves -# alone — the unit stays inactive until the next boot, a `systemctl start -# imsd` or a start of imsd-ofonod (its Wants=imsd.service, the Phosh image). -# Installing the package and inserting a SIM is the opt-in; no configuration -# file changes what follows. The dead ends are facts: no SIM (the modem's -# `failed` state with a SIM reason; final until ModemManager re-probes; -# checked here and while waiting for registration below), a profile-indexed -# connect series refused ten times in a row — each a persistent 3GPP cause -# in ModemManager's typed error (not subscribed, unknown APN, barred, not -# authorised in this PLMN, authentication failed, the service option not -# supported, the wrong PDN type), with packet service; counted only while the -# modem reads registered, connecting or connected with packet service not -# detached — any other read, a timeout or a transient cause starts the count -# again — and — the daemon's own, with the same clean exit — a -# bring-up that ends with no P-CSCF (none configured, the PCO names none) -# and no registration of imsd's own to resume. One attempt per boot (or per -# such imsd-ofonod start). What is not a fact exits 1 like a disabled radio -# and the restart retries: ten failures without coverage, a SIM still waiting -# for its PIN, a modem without an ims profile (the APN-string connect is -# tried), an APN-string series (the modem's own wall under an IPv4 attach, -# F6), a P-CSCF reader that did not answer (PCSCF_DISCOVERY=failed, below). -idle_exit() { # $1 = key, $2 = log text - log "$2" - printf 'IMSD_IDLE=%s\n' "$1" > /run/imsd.env - log "wrote /run/imsd.env: IMSD_IDLE=$1" - exit 0 -} -sim_check() { - [ "$(modem_state)" = failed ] || return 0 - REASON=$(kv -m "$MODEM" | sed -n 's/^modem\.generic\.state-failed-reason *: *//p') - case "$REASON" in - sim-missing|esim-without-profiles|sim-error) - idle_exit no-sim "no SIM ($REASON): not bringing the ims PDN up — systemctl start imsd once one is in" ;; - esac -} -sim_check - # ---- the ims profile index (WDS profile list), unless configured. The # 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 @@ -224,7 +167,7 @@ if [ -z "$BEARER" ]; then waited=0 last= while :; do STATE=$(modem_state) - case "$STATE" in registered|connecting|connected) break ;; failed) sim_check ;; esac + case "$STATE" in registered|connecting|connected) break ;; esac [ "$STATE" != "$last" ] && log "waiting for registration (state: ${STATE:-unknown})" last=$STATE if [ "$waited" -ge "$REG_TIMEOUT" ]; then @@ -232,10 +175,6 @@ if [ -z "$BEARER" ]; then log "modem still disabled after $REG_TIMEOUT s (radio off): not connecting" exit 1 fi - if [ "$STATE" = locked ]; then - log "SIM locked ($(unlock_required)) after $REG_TIMEOUT s: not connecting until the PIN is entered" - exit 1 - fi log "not registered after $REG_TIMEOUT s — attempting anyway" break fi @@ -261,31 +200,10 @@ if [ -z "$BEARER" ]; then fi n=0 -REFUSED=0 # connects refused in a row, each a persistent 3GPP cause with the modem registered/connecting/connected and packet service not detached (stricter than the daemon's PacketServiceUp) while [ -z "$BEARER" ]; do n=$((n + 1)) - if [ "$n" -gt 10 ]; then - # only a profile-indexed series the network refused says anything - # about it: an APN-string series (a lookup that kept failing, a late - # registration) meets the modem's own wall under an IPv4 attach (F6), - # which the restart's fresh lookup gets past, and a failure without - # packet service is a coverage gap, not a refusal — counted only while - # the modem reads registered, connecting or connected with packet - # service not `detached` (a CS-registered modem still attaching) and - # only for a persistent 3GPP cause in ModemManager's typed error; any - # other read, `disconnecting`, `unknown` or an unanswered mmcli among - # them, and a timeout or a transient cause, reset it — stricter than - # the daemon's PacketServiceUp, which counts an unknown state as - # service — so the - # count is of refusals in a row, each with packet service at the time, - # never one state read after the series: a series that failed without - # coverage and found the network at its end exits 1 like any other, - # and the restart retries on the attached modem - [ -z "$PROFILE_ID" ] || [ "$REFUSED" -lt 10 ] || idle_exit no-ims-pdn "bearer connect refused 10 times in a row by profile index with packet service and a persistent cause (last: $(squash "$OUT")): no ims PDN for this SIM here — systemctl start imsd to try again" - log "bearer connect failed after 10 attempts (modem state: $(modem_state), last: $(squash "$OUT"))"; exit 1 - fi + [ "$n" -gt 10 ] && { log "bearer connect failed after 10 attempts"; exit 1; } radio_off && { log "modem disabled (radio off): not connecting"; exit 1; } - sim_locked && { log "SIM locked ($(unlock_required)): not connecting until the PIN is entered"; exit 1; } B=$(find_ims_bearer no) # reuse a stale disconnected ims bearer of this run's shape if [ -z "$B" ]; then if [ -n "$PROFILE_ID" ]; then @@ -299,7 +217,6 @@ while [ -z "$BEARER" ]; do log "created bearer $B ($SPEC)" else log "create-bearer attempt $n failed: $(squash "$OUT"); retrying in 10 s" - REFUSED=0 # not the network's answer to a connect sleep 10 continue fi @@ -307,24 +224,7 @@ while [ -z "$BEARER" ]; do if OUT=$(mmcli -b "$B" --connect 2>&1); then BEARER=$B else - ST=$(modem_state); PS=$(packet_state) - log "connect attempt $n failed (state: ${ST:-unknown}, packet service: ${PS:-unknown}): $(squash "$OUT"); retrying in 10 s" - # a refusal is the network's answer, as ModemManager types it: a - # persistent 3GPP cause — not subscribed, unknown APN, barred, not - # authorised in this PLMN, authentication failed, the service option - # not supported, the wrong PDN type for this attach — with the modem - # attached. A timeout (D-Bus, esm-proc-timeout), a transient cause - # (insufficient resources, out of order, a network failure) or any - # other error starts the count again, as a read without packet service - # does: an outage must not idle VoLTE until the next boot - PERSISTENT= - case "$OUT" in - *MobileEquipment.ServiceOptionNotSubscribed:*|*MobileEquipment.MissingOrUnknownApn:*|*MobileEquipment.OperatorDeterminedBarring:*|*MobileEquipment.ServiceOptionNotAuthorizedInPlmn:*|*MobileEquipment.UserAuthenticationFailed:*|*MobileEquipment.ServiceOptionNotSupported:*|*MobileEquipment.Ipv4OnlyAllowed:*|*MobileEquipment.Ipv6OnlyAllowed:*) PERSISTENT=1 ;; - esac - case "$ST" in - registered|connecting|connected) if [ "$PS" = detached ] || [ -z "$PERSISTENT" ]; then REFUSED=0; else REFUSED=$((REFUSED + 1)); fi ;; - *) REFUSED=0 ;; - esac + log "connect attempt $n failed (state: $(modem_state)): $(squash "$OUT"); retrying in 10 s" case "$OUT" in *call-already-present*) # the PDN is up on a bearer this run did not recognise (ip-type or # APN spelling); use it rather than ask the modem for a second one @@ -391,108 +291,30 @@ log "$IFACE up, $ADDR/${PREFIX:-64}, ipv6 mtu $(cat "/proc/sys/net/ipv6/conf/$IF if [ -n "$(ip -6 route show default dev "$IFACE" 2>/dev/null)" ]; then ip -6 route flush default dev "$IFACE" && log "dropped the RA default route on $IFACE" fi -# ---- the P-CSCF. PCSCF= when the admin set one (one IPv6 address; it wins -# and discovery is skipped), else the list the network put in the PDN's PCO, -# read from the modem by imsd-pcscf: a QMI WDS client bound to this netdev's -# mux data port — the binding ModemManager gives its own clients, and only -# such a client is shown the call's settings. The list goes to imsd as -# PCSCF_DISCOVERED= (its own name: this script inherits the previous run's -# /run/imsd.env through the unit, so a stale discovered value must never -# pass for the admin's PCSCF=, and it is never read from the environment -# here). The PDN is brought up IPv6-only, so only IPv6 addresses are usable; -# IPv4 ones and names are logged and dropped. Nothing usable is not this -# script's failure, and PCSCF_DISCOVERY= in the hand-off tells the daemon -# which kind it was: `none` — the PCO carried no IPv6 P-CSCF (the reader's -# exit 2), a fact: a registration of imsd's own that can be resumed is -# resumed and stays up, else the daemon idles (its `idle:` exit; after a -# resumable pair's failed attempt the fatal and the restart) — or `failed` — -# the reader did not answer (an error, a timeout, nothing printed), no fact: -# a daemon with nothing to resume exits 1 and the unit's restart runs -# discovery again. `found` and `skipped` (PCSCF= set, an IPv4-only PDN, no -# rmnet mux id) are the other two values. -# The P-CSCF is reachable through the ims PDN only: a host route stands in -# for the default — `dev` without a gateway (a raw-IP netdev without -# header_ops, so the kernel resolves no neighbours), the shape imsd uses for -# media peers; of a discovered list the first address — the daemon's fresh -# target — gets one here; imsd routes the P-CSCF it uses itself as well — the -# fresh target before a fresh registration, the resumed SA peer once its SAs -# are confirmed — with the same idempotent replace as a call's media peer, so -# a target this script did not route (a list set in /etc/imsd.env, a route -# lost with the PDN) is reachable. -# Anything else on the IMS network (its DNS servers, a traceroute -# target) needs its own host route. Without the route imsd cannot reach the -# P-CSCF, so a route failure is the unit's failure. -route_pcscf() { # $1 = address - OUT=$(ip -6 route replace "$1/128" dev "$IFACE" 2>&1) || - { log "host route to P-CSCF $1 failed: $(squash "$OUT")"; exit 1; } - log "host route to P-CSCF $1 dev $IFACE" -} +# the P-CSCF is reachable through the ims PDN only: a host route stands in +# for the default. `dev` without a gateway — a raw-IP netdev without +# header_ops, so the kernel resolves no neighbours — the shape imsd uses +# for media peers. The PDN is brought up IPv6-only (the address above), so +# the P-CSCF must be an IPv6 address; anything else on the IMS network +# (its DNS servers, a traceroute target) needs its own host route. Without +# this route imsd cannot reach the P-CSCF, so a failure is the unit's +# failure. PCSCF=${PCSCF:-$(envval PCSCF)} -DISCOVERED= -DISCOVERY=skipped case "$PCSCF" in - '') - if [ "$IP_TYPE" = ipv4 ]; then - log "P-CSCF discovery: not for IMS_IP_TYPE=$IP_TYPE — the bring-up configures only the PDN's IPv6 address" - else - MUX=$(ip -d link show "$IFACE" 2>/dev/null | sed -n 's/.*rmnet mux_id \([0-9]*\).*/\1/p' | head -n1) - if [ -z "$MUX" ]; then - log "P-CSCF discovery: no rmnet mux id on $IFACE (ip -d link); set PCSCF=" - else - DISCOVERY=failed - OUT=$("${IMSD_PCSCF:-/usr/libexec/imsd-pcscf}" --mux "$MUX" ${PROFILE_ID:+--profile "$PROFILE_ID"} --timeout 10 2>&1); rc=$? - V6=; SHOWN=; OTHER=; FLAGS=; ERR=; n=0 - while IFS= read -r line; do - case "$line" in - '') ;; - v4\ *|fqdn\ *) OTHER="${OTHER:+$OTHER }$line" ;; - flag\ *) FLAGS="${FLAGS:+$FLAGS, }${line#flag }" ;; - *[!0-9a-f:]*) ERR="${ERR:+$ERR }$line" ;; - *:*) [ "$n" -ge 8 ] && continue; n=$((n + 1)) - V6="${V6:+$V6 }$line"; DISCOVERED="${DISCOVERED:+$DISCOVERED,}$line"; SHOWN="${SHOWN:+$SHOWN, }$line" ;; - *) ERR="${ERR:+$ERR }$line" ;; - esac - done <&1) || + { log "host route to P-CSCF $PCSCF failed: $(squash "$OUT")"; exit 1; } + log "host route to P-CSCF $PCSCF dev $IFACE" ;; *) log "PCSCF '$PCSCF' is not an IPv6 address; the ims PDN is brought up IPv6-only"; exit 1 ;; esac -# ---- hand the connected ims netdev, the PDN's own address and the -# discovered P-CSCF list to imsd (imsd.service reads /run/imsd.env): the -# daemon carries no baked-in interface name and registers on the address -# the network assigned — not on the kernel's temporary SLAAC address from -# the RA, which the netdev lists first and which is deprecated after a day -# and removed after two (ims ledger F18; the SAs and the Contact would go -# with it). PCSCF_DISCOVERED= is written on every run, empty when nothing -# was discovered or PCSCF= is set; PCSCF_DISCOVERY= says how it came to be -# (found, none, failed, skipped — above). -# The dead end after the PDN is up — nothing to register to (no PCSCF=, -# nothing discovered, no registration of imsd's own in the kernel to resume) -# — is the daemon's to decide (its `idle:` exit before it takes the bus name, -# §7.3): it knows the fresh target, the kernel's SA pair and the holds, and -# PCSCF_DISCOVERY= tells it whether an empty list is the PCO's answer (none: -# the idle) or the reader's failure (failed: exit 1, the restart runs -# discovery again). The hand-off is written on every run. -printf 'DEV=%s\nLOCAL=%s\nPCSCF_DISCOVERED=%s\nPCSCF_DISCOVERY=%s\n' "$IFACE" "$ADDR" "$DISCOVERED" "$DISCOVERY" > /run/imsd.env -log "wrote /run/imsd.env: DEV=$IFACE LOCAL=$ADDR PCSCF_DISCOVERED=$DISCOVERED PCSCF_DISCOVERY=$DISCOVERY" +# ---- hand the connected ims netdev and the PDN's own address to imsd +# (imsd.service reads /run/imsd.env): the daemon carries no baked-in +# interface name and registers on the address the network assigned — not +# on the kernel's temporary SLAAC address from the RA, which the netdev +# lists first and which is deprecated after a day and removed after two +# (ims ledger F18; the SAs and the Contact would go with it) +printf 'DEV=%s\nLOCAL=%s\n' "$IFACE" "$ADDR" > /run/imsd.env # ---- open the IMS protected ports in the firewall (rung 5b root cause, # journal/ims.md s56): pmOS's default nftables INPUT chain is policy-drop and diff --git a/packaging/imsd-ofonod.service b/packaging/imsd-ofonod.service index 4c79515..5229943 100644 --- a/packaging/imsd-ofonod.service +++ b/packaging/imsd-ofonod.service @@ -11,9 +11,7 @@ Documentation=https://forgejo.catcrafts.net/Catcrafts/imsd # — Plasma Mobile uses imsd-dialerd instead. Both may run at once (different # buses, different consumers), but a real ofonod must not. Conflicts=ofono.service -# Wants, not After: imsd starts on every phone since 1.0.0 and its start runs -# the ims PDN bring-up (the whole ExecStartPre, up to TimeoutStartSec=900); -# this backend starts at once and republishes when imsd's name appears (OnImsdAppeared) +After=imsd.service Wants=imsd.service [Service] diff --git a/packaging/imsd.post-upgrade b/packaging/imsd.post-upgrade index 4fba473..4e295e8 100755 --- a/packaging/imsd.post-upgrade +++ b/packaging/imsd.post-upgrade @@ -6,10 +6,8 @@ # 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, goes on waiting -# when the network had imsd wait, or — an upper-case, zero-padded or uncompressed PCSCF=, -# accepted until 0.3.12 — stops at bring-up with the spelling to use; a -# call in progress ends. +# 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 diff --git a/packaging/imsd.service b/packaging/imsd.service index e02aa7f..13c454c 100644 --- a/packaging/imsd.service +++ b/packaging/imsd.service @@ -20,13 +20,10 @@ ExecStartPre=/usr/libexec/ims-pdn-up.sh ExecStartPre=-/usr/libexec/imsd-handover.sh TimeoutStartSec=900 # /run/imsd.env: written by ims-pdn-up.sh (DEV= the connected ims netdev, -# LOCAL= the PDN address ModemManager assigned to it, PCSCF_DISCOVERED= the -# P-CSCF list the network put in the PDN's PCO, read from the modem, -# PCSCF_DISCOVERY= how that list came to be: found, none, failed, skipped; -# or IMSD_IDLE= alone when the bring-up found nothing to do). -# /etc/imsd.env: admin configuration, none required where discovery finds a -# P-CSCF (AGENTS.md §10, the README); it wins over the runtime-derived values -# — PCSCF= overrides discovery. +# LOCAL= the PDN address ModemManager assigned to it). +# /etc/imsd.env: admin configuration — PCSCF= is REQUIRED (the carrier's +# P-CSCF address; see AGENTS.md §10, or the README) and wins over the +# runtime-derived values. EnvironmentFile=-/run/imsd.env EnvironmentFile=-/etc/imsd.env # registration state (imsreg.state) + media stats live here diff --git a/packaging/make-bin-tarball.sh b/packaging/make-bin-tarball.sh index d42df1d..3e695ec 100755 --- a/packaging/make-bin-tarball.sh +++ b/packaging/make-bin-tarball.sh @@ -12,12 +12,10 @@ BIN=$(ls -t bin/imsd-aarch64-*/imsd 2>/dev/null | head -n1) MEDIA=$(ls -t bin/imsd-media-aarch64-*/imsd-media 2>/dev/null | head -n1) DIALERD=$(ls -t bin/imsd-dialerd-aarch64-*/imsd-dialerd 2>/dev/null | head -n1) OFONOD=$(ls -t bin/imsd-ofonod-aarch64-*/imsd-ofonod 2>/dev/null | head -n1) -PCSCF=$(ls -t bin/imsd-pcscf-aarch64-*/imsd-pcscf 2>/dev/null | head -n1) [ -n "$BIN" ] || { echo "no aarch64 imsd build found — cross-compile first" >&2; exit 1; } [ -n "$MEDIA" ] || { echo "no aarch64 imsd-media build — cross-compile with --product=media" >&2; exit 1; } [ -n "$DIALERD" ] || { echo "no aarch64 imsd-dialerd build — cross-compile with --product=dialerd" >&2; exit 1; } [ -n "$OFONOD" ] || { echo "no aarch64 imsd-ofonod build — cross-compile with --product=ofonod" >&2; exit 1; } -[ -n "$PCSCF" ] || { echo "no aarch64 imsd-pcscf build — cross-compile with --product=pcscf" >&2; exit 1; } stage=$(mktemp -d) trap 'rm -rf "$stage"' EXIT @@ -26,7 +24,6 @@ cp "$BIN" "$stage/imsd-$VER/imsd" cp "$MEDIA" "$stage/imsd-$VER/imsd-media" cp "$DIALERD" "$stage/imsd-$VER/imsd-dialerd" cp "$OFONOD" "$stage/imsd-$VER/imsd-ofonod" -cp "$PCSCF" "$stage/imsd-$VER/imsd-pcscf" cp packaging/ims-pdn-up.sh packaging/imsd-handover.sh packaging/imsd.service \ packaging/imsd-dialerd.desktop packaging/imsd-ofonod.service \ packaging/imsd-ofono-restart-calls.sh \ diff --git a/packaging/make-sysroot.sh b/packaging/make-sysroot.sh index 7cc5e41..112b7e2 100755 --- a/packaging/make-sysroot.sh +++ b/packaging/make-sysroot.sh @@ -4,8 +4,7 @@ # make-sysroot.sh — build an Alpine aarch64 sysroot for cross-compiling imsd. # # Downloads apk-tools-static + the Alpine signing keys from the CDN and -# populates a minimal rootfs (musl, libc++, glib and their headers, the Linux -# UAPI headers) that +# populates a minimal rootfs (musl, libc++, glib and their headers) that # clang can use via --sysroot. No root, no qemu, no device needed: # # packaging/make-sysroot.sh [dir] # default: ~/.cache/imsd/sysroot-aarch64-alpine @@ -23,10 +22,8 @@ ARCH=aarch64 # 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. linux-headers is in it for , which -# imsd-pcscf's QRTR socket needs (the sysroot had it only as another -# package's dependency before). -PKGS="musl-dev libc++ libc++-dev llvm-runtimes glib-dev compiler-rt llvm-libunwind-dev gcc linux-headers" +# with it, it did. +PKGS="musl-dev libc++ libc++-dev llvm-runtimes glib-dev compiler-rt llvm-libunwind-dev gcc" work=$(mktemp -d) trap 'rm -rf "$work"' EXIT diff --git a/project.cpp b/project.cpp index e9e89b9..1bc9398 100644 --- a/project.cpp +++ b/project.cpp @@ -77,7 +77,7 @@ static std::unique_ptr BusLibrary(std::span args) { - // Five 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=`: // daemon (default) — imsd, the control-plane daemon (GDBus + engine), // and the unit tests, which link imsd-core. @@ -91,19 +91,13 @@ extern "C" Configuration CrafterBuildProject(std::span a // (org.ofono.*) bridging to imsd, for Phosh and // GNOME Mobile (GDBus translation plus the imsd-bus // polkit gate on its callers, no core). - // pcscf — imsd-pcscf, the P-CSCF reader ims-pdn-up.sh runs - // once the ims PDN is up: one QMI WDS client over a - // raw QRTR socket (pure std + POSIX, links the core - // for Imsd:Qmi, no GLib). bool wantMedia = false; bool wantDialerd = false; bool wantOfonod = false; - bool wantPcscf = false; for (std::string_view a : args) { if (a == "--product=media") wantMedia = true; if (a == "--product=dialerd") wantDialerd = true; if (a == "--product=ofonod") wantOfonod = true; - if (a == "--product=pcscf") wantPcscf = true; } if (wantOfonod) { Configuration ofonod; @@ -167,12 +161,11 @@ extern "C" Configuration CrafterBuildProject(std::span a ApplyStandardArgs(*Core, args); Core->type = ConfigurationType::LibraryStatic; { - std::array ifaces = { + std::array ifaces = { "interfaces/Imsd", "interfaces/Imsd-Util", "interfaces/Imsd-Aka", "interfaces/Imsd-Ipsec", - "interfaces/Imsd-Qmi", "interfaces/Imsd-Sip", "interfaces/Imsd-Sdp", "interfaces/Imsd-Messages", @@ -182,23 +175,6 @@ extern "C" Configuration CrafterBuildProject(std::span a Core->GetInterfacesAndImplementations(ifaces, impls); } - if (wantPcscf) { - Configuration pcscf; - pcscf.path = "./"; - pcscf.name = "imsd-pcscf"; - pcscf.outputName = "imsd-pcscf"; - ApplyStandardArgs(pcscf, args); - pcscf.type = ConfigurationType::Executable; - pcscf.dependencies = { Core.get() }; - { - std::array ifaces = {}; - std::array impls = { "implementations/pcscf" }; - pcscf.GetInterfacesAndImplementations(ifaces, impls); - } - LintRules::AddRules(pcscf); - return pcscf; - } - // imsd — the daemon executable: GLib main loop + GDBus service // (net.catcrafts.IMS1, same ABI as the Python imsd.py) around the core. Configuration cfg; @@ -221,7 +197,6 @@ extern "C" Configuration CrafterBuildProject(std::span a cfg.AddTest("Util").Dependencies({ Core.get() }); cfg.AddTest("Aka").Dependencies({ Core.get() }); cfg.AddTest("Ipsec").Dependencies({ Core.get() }); - cfg.AddTest("Qmi").Dependencies({ Core.get() }); cfg.AddTest("Messages").Dependencies({ Core.get() }); cfg.AddTest("Engine").Dependencies({ Core.get() }); cfg.AddTest("Sip").Dependencies({ Core.get() }); diff --git a/tests/Engine/main.cpp b/tests/Engine/main.cpp index 75e1555..9ab77dd 100644 --- a/tests/Engine/main.cpp +++ b/tests/Engine/main.cpp @@ -11,10 +11,7 @@ // reject -> 486, remote CANCEL -> 200+487, unusable offer -> 488 (naming // what was offered), a landline gateway's G.711-only and AMR-NB offers // ring and are answered at the offer's own payload types, ring timeout -> -// 480, BYE both ways. DTMF: the tones reach the media leg on an active -// call that negotiated telephone-event, and are dropped — with the reason -// — before the answer, after the call, without the negotiation or with a -// character that is no tone. Then RegRecovery, the registration recovery policy: +// 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 @@ -93,21 +90,6 @@ namespace { "v=0\r\no=- 1 1 IN IP6 2001:db8::9\r\ns=-\r\nc=IN IP6 2001:db8::9\r\n" "t=0 0\r\nm=audio 6004 RTP/AVP 97\r\na=rtpmap:97 AMR-WB/16000/1\r\n", callid); } - // A 200 OK around an arbitrary SDP answer, Content-Length from it — a - // message the shell's framing would deliver whole. - std::string R200Sdp(std::string_view callid, std::string_view sdp) { - return std::format( - "SIP/2.0 200 OK\r\n" - "Via: SIP/2.0/TCP [2001:db8::db43]:45061;branch=z9hG4bKx\r\n" - "From: ;tag=it\r\n" - "To: ;tag=remote7\r\n" - "Call-ID: {}\r\n" - "CSeq: 1 INVITE\r\n" - "Contact: \r\n" - "Content-Type: application/sdp\r\n" - "Content-Length: {}\r\n\r\n{}", callid, sdp.size(), sdp); - } - constexpr std::string_view SdpHead = "v=0\r\no=- 1 1 IN IP6 2001:db8::9\r\ns=-\r\nc=IN IP6 2001:db8::9\r\nt=0 0\r\n"; std::string Final(std::string_view callid, int code, std::string_view text) { return std::format( "SIP/2.0 {} {}\r\n" @@ -227,7 +209,6 @@ int main() { Check(media && media->media.remotePort == 6004, "media remote port"); Check(media && media->media.payloadType == 97, "media pt"); Check(media && media->media.codec == "AMR-WB", "media codec"); - Check(media && !media->media.dtmfPt, "an answer without telephone-event: the leg has no DTMF pt"); const Action* active = Find(a200, Action::Type::State); Check(active && active->state == "active" && active->reason == "accepted", "200 emits active/accepted"); Check(m.State() == CallState::Active, "state active"); @@ -239,12 +220,6 @@ int main() { Check(!Has(a200b, Action::Type::StartMedia) && !Has(a200b, Action::Type::State), "retransmitted 200: no second media start, no state action"); Check(m.State() == CallState::Active, "state still active after the retransmit"); - // DTMF on a call whose answer negotiated no telephone-event: dropped, said why - auto adt = m.OnDtmf("1"); - Check(!Has(adt, Action::Type::SendDtmf), "no telephone-event negotiated: no SendDtmf"); - const Action* dtLog = Find(adt, Action::Type::Log); - Check(dtLog && dtLog->text == "call ims-call-1: DTMF '1' dropped: no telephone-event negotiated for this call", "...and the line says so"); - // media leg exits code 3 (downlink dried up) -> BYE + terminated auto amx = m.OnMediaExit(3); Check(Sent(amx, "BYE ") != nullptr, "media far-end hangup sends BYE"); @@ -255,73 +230,6 @@ int main() { Check(m.Terminated(), "terminated"); } - // ---- DTMF, outgoing: the answer echoes our telephone-event/16000 at pt - // 98; the tones go to the leg once active, in upper case, and are - // dropped before the answer, with a character that is no tone, - // and after the call - { - imsd::util::Rng rng(43); - CallMachine m(c, rng, "ims-call-1d", "1233", 50004, false); - m.Start(); - auto early = m.OnDtmf("1"); - Check(!Has(early, Action::Type::SendDtmf), "DTMF while dialing: not sent"); - const Action* earlyLog = Find(early, Action::Type::Log); - Check(earlyLog && earlyLog->text == "call ims-call-1d: DTMF '1' dropped: the call is dialing", "...the line names the state"); - auto a200 = m.OnInviteResponse(R200Sdp(m.CallId(), std::format("{}m=audio 6004 RTP/AVP 97 98 100\r\na=rtpmap:97 AMR-WB/16000/1\r\na=rtpmap:98 telephone-event/16000\r\na=rtpmap:100 telephone-event/8000\r\n", SdpHead))); - const Action* media = Find(a200, Action::Type::StartMedia); - Check(media && media->media.dtmfPt == 98, "the leg carries the answer's telephone-event pt at the codec's clock (98, not the narrowband 100)"); - Check(media && media->media.dtmfRxPt == 98, "outgoing: the far end sends at our offer's number for that clock"); - Check(m.State() == CallState::Active, "active"); - auto sent = m.OnDtmf("12#*a"); - const Action* dt = Find(sent, Action::Type::SendDtmf); - Check(dt && dt->text == "12#*A", "active + negotiated: SendDtmf with the tones, letters upper-cased"); - const Action* dtLog = Find(sent, Action::Type::Log); - Check(dtLog && dtLog->text == "call ims-call-1d: DTMF '12#*A' -> media leg (telephone-event pt 98)", "...and the line names the pt"); - auto bad = m.OnDtmf("1x"); - Check(!Has(bad, Action::Type::SendDtmf), "a character that is no tone: nothing sent, not even the '1'"); - const Action* badLog = Find(bad, Action::Type::Log); - Check(badLog && badLog->text == "call ims-call-1d: DTMF '1x' dropped: 'x' is not a tone", "...the line names the character"); - Check(m.OnDtmf("").empty(), "an empty string: nothing, silently"); - const std::string atMost(DtmfMaxTones, '1'); - auto most = m.OnDtmf(atMost); - const Action* maxDt = Find(most, Action::Type::SendDtmf); - Check(maxDt && maxDt->text == atMost, "a string of DtmfMaxTones tones goes"); - auto tooMany = m.OnDtmf(std::format("{}1", atMost)); - Check(!Has(tooMany, Action::Type::SendDtmf) && Find(tooMany, Action::Type::Log) && Find(tooMany, Action::Type::Log)->text == std::format("call ims-call-1d: DTMF of {} tones dropped: more than {}", DtmfMaxTones + 1, DtmfMaxTones), "one more: dropped, the line names the bound"); - m.OnHangup(); - auto late = m.OnDtmf("5"); - Check(!Has(late, Action::Type::SendDtmf) && Find(late, Action::Type::Log) && Find(late, Action::Type::Log)->text.ends_with("the call is terminated"), "DTMF after the call: dropped, the line names the state"); - } - - // ---- DTMF, outgoing, the answer renumbered telephone-event (RFC 3264 - // allows it): we send with its number, the far end with our offer's - { - imsd::util::Rng rng(44); - CallMachine m(c, rng, "ims-call-1e", "1233", 50004, false); - m.Start(); - auto a200 = m.OnInviteResponse(R200Sdp(m.CallId(), std::format("{}m=audio 6004 RTP/AVP 97 101\r\na=rtpmap:97 AMR-WB/16000/1\r\na=rtpmap:101 telephone-event/16000\r\n", SdpHead))); - const Action* media = Find(a200, Action::Type::StartMedia); - Check(media && media->media.dtmfPt == 101 && media->media.dtmfRxPt == 98, "renumbered answer: send with 101, listen on our 98"); - // a narrowband answer: AMR at 99 with telephone-event/8000 at 102 - imsd::util::Rng rng2(45); - CallMachine n(c, rng2, "ims-call-1f", "1233", 50004, false); - n.Start(); - auto b200 = n.OnInviteResponse(R200Sdp(n.CallId(), std::format("{}m=audio 6004 RTP/AVP 99 102\r\na=rtpmap:99 AMR/8000/1\r\na=rtpmap:102 telephone-event/8000\r\n", SdpHead))); - const Action* nbMedia = Find(b200, Action::Type::StartMedia); - Check(nbMedia && nbMedia->media.codec == "AMR" && nbMedia->media.dtmfPt == 102 && nbMedia->media.dtmfRxPt == 100, "narrowband answer: send with its 102, listen on our offer's 100"); - // an answer numbering for itself that gives the audio our offer's - // telephone-event number: the receive number is the answer's, never - // the audio's (every downlink frame would be taken for an event) - imsd::util::Rng rng3(46); - CallMachine o(c, rng3, "ims-call-1g", "1233", 50004, false); - o.Start(); - auto c200 = o.OnInviteResponse(R200Sdp(o.CallId(), std::format("{}m=audio 6004 RTP/AVP 100 101\r\na=rtpmap:100 AMR/8000/1\r\na=rtpmap:101 telephone-event/8000\r\n", SdpHead))); - const Action* colMedia = Find(c200, Action::Type::StartMedia); - Check(colMedia && colMedia->media.payloadType == 100 && colMedia->media.dtmfPt == 101 && colMedia->media.dtmfRxPt == 101, "the audio at our telephone-event number: listen on the answer's 101, not the audio's 100"); - auto again = m.OnInviteResponse(R200(m.CallId())); - Check(!Find(again, Action::Type::StartMedia), "a retransmitted 200 starts no second leg"); - } - // ---- remote BYE mid-call { imsd::util::Rng rng(7); @@ -460,12 +368,6 @@ int main() { Check(ok && ok->text.contains("a=rtpmap:104 AMR-WB/16000/1\r\n"), "answer keeps AMR-WB at the offer's pt"); Check(ok && ok->text.contains("a=fmtp:104 octet-align=1\r\n"), "answer mirrors octet-align"); Check(ok && ok->text.contains("a=rtpmap:105 telephone-event/16000\r\n"), "answer keeps telephone-event"); - const Action* mtMedia = Find(aa, Action::Type::StartMedia); - Check(mtMedia && mtMedia->media.dtmfPt == 105, "incoming: the leg carries the offer's telephone-event pt"); - Check(mtMedia && mtMedia->media.dtmfRxPt == 105, "incoming: the far end sends at the number we echoed"); - auto mtDtmf = m.OnDtmf("9"); - const Action* mtDt = Find(mtDtmf, Action::Type::SendDtmf); - Check(mtDt && mtDt->text == "9", "incoming, accepted: the tones go to the leg"); Check(ok && ok->text.contains("Session-Expires: 1800;refresher=uac\r\n"), "200 echoes Session-Expires, caller refreshes"); Check(ok && ok->text.contains("Contact: spiPc == 0x0526f2c1u, "spi_pc (UE->P reqid2)"); Check(sa && sa->securityServer == "ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=aes-cbc; " "spi-c=86438593; spi-s=86438592; port-c=33421; port-s=6000", "reconstructed Security-Server value"); } - // the environment's spelling must not matter: upper case, uncompressed, - // bracketed — the kernel prints RFC 5952 form and both sides are - // canonicalised before the compare - { - auto sa = ParseExistingSa(State, Policy, "2001:DB8::105", "2001:DB8:1111:2222:3333:4444:5555:DB43"); - Check(sa && sa->spiPs == 0x0526f2c0u && sa->portPs == 6000, "ParseExistingSa: upper-case spellings find the SAs"); - auto sa2 = ParseExistingSa(State, Policy, "[2001:0db8:0000:0000:0000:0000:0000:0105]", "2001:db8:1111:2222:3333:4444:5555:db43"); - Check(sa2 && sa2->spiPc == 0x0526f2c1u, "ParseExistingSa: uncompressed, bracketed P-CSCF finds the SAs"); - Check(!ParseExistingSa(State, Policy, "2001:db8::106", "2001:db8:1111:2222:3333:4444:5555:db43"), "ParseExistingSa: another P-CSCF still finds nothing"); - auto locals = SaLocals(State, "2001:DB8:0:0:0:0:0:105"); - Check(locals.size() == 1 && locals[0] == "2001:db8:1111:2222:3333:4444:5555:db43", "SaLocals: any spelling of the P-CSCF, canonical locals out"); - } // incomplete input -> nullopt Check(!ParseExistingSa("src a dst b\n\tspi 0x1 reqid 1 mode transport\n", "", "b", "a").has_value(), "missing pieces -> nullopt"); { @@ -101,92 +89,6 @@ int main() { Check(SaLocals(State, "2001:db8::999").empty(), "SaLocals: no SA to that P-CSCF -> none"); } - // ---- SaPeer: the P-CSCF the kernel's policies point at - { - auto peer = SaPeer(Policy); - Check(peer && *peer == "2001:db8::105", "SaPeer: the dir-out policies' dst with reqid 1 and 2 agreeing"); - Check(!SaPeer("").has_value(), "SaPeer: no policies -> none"); - // a VPN's: tunnel mode, reqid 1, another destination — alone, and beside our pair - constexpr std::string_view Vpn = "src 2001:db8::1/128 dst 2001:db8::2/128 \n\tdir out priority 0 \n\ttmpl src 2001:db8::1 dst 2001:db8::2\n\t\tproto esp reqid 1 mode tunnel\n"; - constexpr std::string_view Vpn4 = "src 192.0.2.10/32 dst 0.0.0.0/0 \n\tdir out priority 383615 ptype main \n\ttmpl src 192.0.2.10 dst 192.0.2.1\n\t\tproto esp reqid 1 mode tunnel\n"; - Check(!SaPeer(Vpn).has_value(), "SaPeer: a lone reqid-1 policy (a VPN's) is not ours"); - auto beside = SaPeer(std::string(Policy) + std::string(Vpn)); - Check(beside && *beside == "2001:db8::105", "SaPeer: our pair beside a VPN's reqid-1 tunnel policy -> ours"); - auto beside4 = SaPeer(std::string(Vpn4) + std::string(Policy)); - Check(beside4 && *beside4 == "2001:db8::105", "SaPeer: our pair beside an IPv4 VPN's policy to 0.0.0.0/0 -> ours"); - // a transport policy to another address beside the pair: one address still carries both reqids - const std::string other2 = "src 2001:db8:1111:2222:3333:4444:5555:db43/128 dst 2001:db8::106/128 sport 1 dport 2 \n\tdir out priority 0 \n\ttmpl src 2001:db8:1111:2222:3333:4444:5555:db43 dst 2001:db8::106\n\t\tproto esp reqid 2 mode transport\n"; - const std::string other1 = "src 2001:db8:1111:2222:3333:4444:5555:db43/128 dst 2001:db8::106/128 sport 3 dport 4 \n\tdir out priority 0 \n\ttmpl src 2001:db8:1111:2222:3333:4444:5555:db43 dst 2001:db8::106\n\t\tproto esp reqid 1 mode transport\n"; - auto one = SaPeer(std::string(Policy) + other2); - Check(one && *one == "2001:db8::105", "SaPeer: a reqid-2 policy to another address beside the pair -> the pair's"); - Check(SaPeer(std::string(Policy) + other2 + other1) == "2001:db8::105", "SaPeer: another transport-mode reqid 1 + 2 pair beside ours, on other ports -> ours (the protected ports are the fingerprint)"); - Check(!SaPeer(other2 + other1).has_value(), "SaPeer: a transport-mode reqid 1 + 2 pair on other ports alone -> none (not imsd's)"); - const std::string ours2 = "src 2001:db8:1111:2222:3333:4444:5555:db43/128 dst 2001:db8::106/128 sport 45062 dport 2 \n\tdir out priority 0 \n\ttmpl src 2001:db8:1111:2222:3333:4444:5555:db43 dst 2001:db8::106\n\t\tproto esp reqid 2 mode transport\n" - "src 2001:db8:1111:2222:3333:4444:5555:db43/128 dst 2001:db8::106/128 sport 45061 dport 4 \n\tdir out priority 0 \n\ttmpl src 2001:db8:1111:2222:3333:4444:5555:db43 dst 2001:db8::106\n\t\tproto esp reqid 1 mode transport\n"; - Check(!SaPeer(std::string(Policy) + ours2).has_value(), "SaPeer: two addresses each carrying imsd's fingerprint -> none (not one pair to tell apart)"); - // the fingerprint alone is not a pair: a reqid-1 policy without its reqid-2 partner, and two fingerprinted policies naming different addresses - const std::string lone1 = "src 2001:db8:1111:2222:3333:4444:5555:db43/128 dst 2001:db8::107/128 sport 45061 dport 4 \n\tdir out priority 0 \n\ttmpl src 2001:db8:1111:2222:3333:4444:5555:db43 dst 2001:db8::107\n\t\tproto esp reqid 1 mode transport\n"; - const std::string apart2 = "src 2001:db8:1111:2222:3333:4444:5555:db43/128 dst 2001:db8::108/128 sport 45062 dport 2 \n\tdir out priority 0 \n\ttmpl src 2001:db8:1111:2222:3333:4444:5555:db43 dst 2001:db8::108\n\t\tproto esp reqid 2 mode transport\n"; - Check(!SaPeer(lone1).has_value(), "SaPeer: a lone fingerprinted reqid-1 policy (no reqid-2 partner) -> none"); - Check(!SaPeer(lone1 + apart2).has_value(), "SaPeer: a fingerprinted reqid-1 policy to one address and a fingerprinted reqid-2 policy to another -> none (not one pair)"); - Check(SaPeer(std::string(Policy) + apart2) == "2001:db8::105", "SaPeer: a fingerprinted reqid-2 policy to another address beside our pair -> the pair's"); - Check(!SaPeer(std::string(Vpn) + "src 2001:db8::1/128 dst 2001:db8::2/128 \n\tdir out priority 0 \n\ttmpl src 2001:db8::1 dst 2001:db8::2\n\t\tproto esp reqid 2 mode tunnel\n").has_value(), "SaPeer: a tunnel-mode reqid 1 + 2 pair (a VPN's two child SAs) -> none"); - // transport mode is part of the fingerprint: a tunnel-mode reqid 1 + 2 pair carrying the protected ports is not ours - const std::string tunnelOut1 = "src 2001:db8:1111:2222:3333:4444:5555:db43/128 dst 2001:db8::109/128 sport 45061 dport 4 \n\tdir out priority 0 \n\ttmpl src 2001:db8:1111:2222:3333:4444:5555:db43 dst 2001:db8::109\n\t\tproto esp reqid 1 mode tunnel\n"; - const std::string tunnelOut2 = "src 2001:db8:1111:2222:3333:4444:5555:db43/128 dst 2001:db8::109/128 sport 45062 dport 2 \n\tdir out priority 0 \n\ttmpl src 2001:db8:1111:2222:3333:4444:5555:db43 dst 2001:db8::109\n\t\tproto esp reqid 2 mode tunnel\n"; - Check(!SaPeer(tunnelOut1 + tunnelOut2).has_value(), "SaPeer: a tunnel-mode reqid 1 + 2 pair carrying the protected ports -> none (transport mode is part of the fingerprint)"); - Check(SaPeer(std::string(Policy) + tunnelOut1 + tunnelOut2) == "2001:db8::105", "SaPeer: the same beside our pair -> ours"); - Check(SaPeer(State).has_value() == false, "SaPeer: state text (no dir out) -> none"); - } - - // ---- OwnXfrmDeleteCommands: imsd's own entries out of the kernel by exact key; everything else stays - { - const std::string ue = "2001:db8:1111:2222:3333:4444:5555:db43"; - // a policy-based VPN's: a tunnel policy (v6, reqid 1), an IPv4 one to 0.0.0.0/0, a tunnel SA - const std::string vpn = "src 2001:db8::1/128 dst 2001:db8::2/128 \n\tdir out priority 0 \n\ttmpl src 2001:db8::1 dst 2001:db8::2\n\t\tproto esp reqid 1 mode tunnel\n"; - const std::string vpn4 = "src 192.0.2.10/32 dst 0.0.0.0/0 \n\tdir out priority 383615 ptype main \n\ttmpl src 192.0.2.10 dst 192.0.2.1\n\t\tproto esp reqid 1 mode tunnel\n"; - const std::string tunState = "src 2001:db8::1 dst 2001:db8::2\n\tproto esp spi 0x00000777 reqid 7 mode tunnel\n\treplay-window 32 \n"; - // another transport-mode user's reqid 1 + 2 pair on other ports, with its SA between other addresses - const std::string otherOut2 = "src " + ue + "/128 dst 2001:db8::106/128 sport 1 dport 2 \n\tdir out priority 0 \n\ttmpl src " + ue + " dst 2001:db8::106\n\t\tproto esp reqid 2 mode transport\n"; - const std::string otherOut1 = "src " + ue + "/128 dst 2001:db8::106/128 sport 3 dport 4 \n\tdir out priority 0 \n\ttmpl src " + ue + " dst 2001:db8::106\n\t\tproto esp reqid 1 mode transport\n"; - const std::string otherState = "src 2001:db8::7 dst 2001:db8::106\n\tproto esp spi 0x00000106 reqid 2 mode transport\n\treplay-window 0 \n"; - auto dels = OwnXfrmDeleteCommands(State, Policy, ue); - Check(dels.size() == 8, "OwnXfrmDeleteCommands: our four states and four policies -> 8 deletes"); - Check(dels.size() == 8 && Join(dels[0]) == "ip xfrm state delete src 2001:db8::105 dst " + ue + " proto esp spi 0x0a84ab2f", "OwnXfrmDeleteCommands: a state by src, dst and SPI, byte-exact"); - Check(dels.size() == 8 && Join(dels[4]) == "ip xfrm policy delete src 2001:db8::105/128 dst " + ue + "/128 sport 33421 dport 45062 dir in", "OwnXfrmDeleteCommands: a policy by its selector and direction, byte-exact"); - Check(dels.size() == 8 && Join(dels[1]) == "ip xfrm state delete src " + ue + " dst 2001:db8::105 proto esp spi 0x0526f2c1" && Join(dels[2]) == "ip xfrm state delete src 2001:db8::105 dst " + ue + " proto esp spi 0x0071acd4" && Join(dels[3]) == "ip xfrm state delete src " + ue + " dst 2001:db8::105 proto esp spi 0x0526f2c0", - "OwnXfrmDeleteCommands: the other three states byte-exact, in the kernel's order"); - Check(dels.size() == 8 && Join(dels[5]) == "ip xfrm policy delete src " + ue + "/128 dst 2001:db8::105/128 sport 45062 dport 33421 dir out" && Join(dels[6]) == "ip xfrm policy delete src 2001:db8::105/128 dst " + ue + "/128 sport 6000 dport 45061 dir in" && Join(dels[7]) == "ip xfrm policy delete src " + ue + "/128 dst 2001:db8::105/128 sport 45061 dport 6000 dir out", - "OwnXfrmDeleteCommands: the other three policies byte-exact, in the kernel's order"); - auto besideVpn = OwnXfrmDeleteCommands(std::string(State) + tunState, std::string(Policy) + vpn + vpn4, ue); - Check(besideVpn.size() == 8 && std::ranges::none_of(besideVpn, [](const auto& c) { auto j = Join(c); return j.contains("2001:db8::2") || j.contains("192.0.2.10") || j.contains("0x00000777"); }), - "OwnXfrmDeleteCommands: a VPN's tunnel SA and two tunnel policies beside ours -> ours only, theirs untouched"); - Check(OwnXfrmDeleteCommands(otherState, otherOut2 + otherOut1, ue).empty(), "OwnXfrmDeleteCommands: another transport-mode reqid 1 + 2 pair on other ports, with its SA between other addresses -> nothing deleted"); - Check(OwnXfrmDeleteCommands(std::string(State) + otherState, std::string(Policy) + otherOut2 + otherOut1, ue).size() == 8, "OwnXfrmDeleteCommands: the same beside our pair -> ours only"); - Check(OwnXfrmDeleteCommands("", "", ue).empty(), "OwnXfrmDeleteCommands: nothing -> nothing"); - Check(OwnXfrmDeleteCommands(State, "", ue).size() == 4, "OwnXfrmDeleteCommands: policy-less states of imsd's on the UE's address are deleted (a setup whose policy add failed leaves them)"); - Check(OwnXfrmDeleteCommands(otherState, "", ue).empty(), "OwnXfrmDeleteCommands: a policy-less state between other addresses is left"); - // the fingerprint selects, not a pair: a lone transport-mode reqid-1 policy carrying the protected port between other addresses goes, with the reqid-1 state between them (the §12 residual) - const std::string loneOther = "src 2001:db8::7/128 dst 2001:db8::106/128 sport 45061 dport 4 \n\tdir out priority 0 \n\ttmpl src 2001:db8::7 dst 2001:db8::106\n\t\tproto esp reqid 1 mode transport\n"; - const std::string loneOtherState = "src 2001:db8::7 dst 2001:db8::106\n\tproto esp spi 0x00000107 reqid 1 mode transport\n\treplay-window 0 \n"; - Check(OwnXfrmDeleteCommands(loneOtherState, loneOther, ue).size() == 2, "OwnXfrmDeleteCommands: a lone policy carrying the fingerprint between other addresses, and the reqid-1 state in its direction, are deleted (the fingerprint selects, not a pair)"); - // a reqid-1/2 transport-mode state on the UE's address goes whoever made it: the other user's SA between the UE address and its peer, its policies on other ports staying (the §12 residual) - const std::string otherOnUe = "src " + ue + " dst 2001:db8::106\n\tproto esp spi 0x00000108 reqid 1 mode transport\n\treplay-window 0 \n"; - auto onUe = OwnXfrmDeleteCommands(otherOnUe, otherOut2 + otherOut1, ue); - Check(onUe.size() == 1 && Join(onUe[0]) == "ip xfrm state delete src " + ue + " dst 2001:db8::106 proto esp spi 0x00000108", "OwnXfrmDeleteCommands: another transport-mode user's reqid-1 state on the UE's address is deleted whoever made it; its policies on other ports stay (the §12 residual)"); - Check(OwnXfrmDeleteCommands("src " + ue + " dst 2001:db8::106\n\tproto ah spi 0x00000109 reqid 1 mode transport\n\treplay-window 0 \n", otherOut2 + otherOut1, ue).empty(), "OwnXfrmDeleteCommands: an AH reqid-1 transport state on the UE's address is not selected (the delete names proto esp)"); - // the selector as `ip xfrm policy` prints it: a fingerprinted policy with a protocol (and a dev) keeps them in its delete, a missing port is no empty token - const std::string protoOther = "src 2001:db8::7/128 dst 2001:db8::106/128 proto udp sport 45061 dport 4 \n\tdir out priority 0 \n\ttmpl src 2001:db8::7 dst 2001:db8::106\n\t\tproto esp reqid 1 mode transport\n"; - auto protoDels = OwnXfrmDeleteCommands("", protoOther, ue); - Check(protoDels.size() == 1 && Join(protoDels[0]) == "ip xfrm policy delete src 2001:db8::7/128 dst 2001:db8::106/128 proto udp sport 45061 dport 4 dir out", "OwnXfrmDeleteCommands: a fingerprinted policy with a protocol in its selector is deleted by that selector (proto kept; the tmpl's 'proto esp' is not the selector's)"); - const std::string portlessOther = "src 2001:db8::7/128 dst 2001:db8::106/128 sport 45061 dev qmapmux0.0 \n\tdir out priority 0 \n\ttmpl src 2001:db8::7 dst 2001:db8::106\n\t\tproto esp reqid 1 mode transport\n"; - auto portlessDels = OwnXfrmDeleteCommands("", portlessOther, ue); - Check(portlessDels.size() == 1 && Join(portlessDels[0]) == "ip xfrm policy delete src 2001:db8::7/128 dst 2001:db8::106/128 sport 45061 dev qmapmux0.0 dir out", "OwnXfrmDeleteCommands: a selector without a dport and with a dev -> no empty token, the dev kept"); - // the SA reader's ports come from our pair, never from a foreign transport policy that happens to carry ports - auto saBeside = ParseExistingSa(State, std::string(Policy) + otherOut2, "2001:db8::105", ue); - Check(saBeside && saBeside->portPc == 33421 && saBeside->portPs == 6000, "ParseExistingSa: a foreign transport policy's ports beside ours are not taken"); - } - // ---- fresh setup command sequence { SaParams p; @@ -197,21 +99,23 @@ int main() { p.spiUc = 100; p.spiUs = 200; p.spiPc = 300; p.spiPs = 400; p.portUc = 45061; p.portUs = 45062; p.portPs = 6000; p.portPc = 33421; auto cmds = BuildSetupCommands(p); - Check(cmds.size() == 8, "4 states + 4 policies, no flush (OwnXfrmDeleteCommands takes imsd's previous entries out)"); + Check(cmds.size() == 10, "flush x2 + 4 states + 4 policies"); + Check(Join(cmds[0]) == "ip xfrm state flush", "state flush first"); + Check(Join(cmds[1]) == "ip xfrm policy flush", "policy flush second"); // reqid1 outbound state: src LOCAL dst P spi=spiPs, IK auth, CK enc - Check(Join(cmds[0]) == + Check(Join(cmds[2]) == "ip xfrm state add src 2001:db8::db43 dst 2001:db8::105 " "proto esp spi 400 mode transport reqid 1 " "auth-trunc hmac(sha1) 0xabababababababababababababababab 96 " "enc cbc(aes) 0xcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", "state add reqid1 outbound byte-exact"); // first out policy: sport PORT_UC dport PORT_PS reqid 1 - Check(Join(cmds[4]) == "ip xfrm policy add src 2001:db8::db43/128 dst 2001:db8::105/128 " "sport 45061 dport 6000 dir out tmpl src 2001:db8::db43 " "dst 2001:db8::105 proto esp reqid 1 mode transport", "policy add out reqid1 byte-exact"); + Check(Join(cmds[6]) == "ip xfrm policy add src 2001:db8::db43/128 dst 2001:db8::105/128 " "sport 45061 dport 6000 dir out tmpl src 2001:db8::db43 " "dst 2001:db8::105 proto esp reqid 1 mode transport", "policy add out reqid1 byte-exact"); // v4 uses /32 SaParams p4 = p; p4.local = "10.0.0.2"; p4.pcscf = "10.0.0.1"; auto c4 = BuildSetupCommands(p4); - Check(c4[4][5] == "10.0.0.2/32", "v4 prefix length /32"); + Check(c4[6][5] == "10.0.0.2/32", "v4 prefix length /32"); } // null cipher -> cipher_null + empty key arg { @@ -221,7 +125,7 @@ int main() { p.ck = {}; p.ealg = "null"; auto cmds = BuildSetupCommands(p); - auto& st = cmds[0]; + auto& st = cmds[2]; Check(st[st.size() - 3] == "enc" && st[st.size() - 2] == "cipher_null" && st[st.size() - 1].empty(), "null cipher: cipher_null + empty key"); } @@ -233,7 +137,7 @@ int main() { p.ck = {}; p.alg = "hmac-md5-96"; p.ealg = "null"; auto cmds = BuildSetupCommands(p); - Check(cmds[0][17] == "hmac(md5)" && cmds[0][18] == "0x01010101010101010101010101010101" && cmds[0][19] == "96", "md5: auth-trunc hmac(md5) IK 96"); + Check(cmds[2][17] == "hmac(md5)" && cmds[2][18] == "0x01010101010101010101010101010101" && cmds[2][19] == "96", "md5: auth-trunc hmac(md5) IK 96"); Check(KernelAuth("hmac-sha-1-96") == "hmac(sha1)" && KernelAuth("hmac-md5-96") == "hmac(md5)" && !KernelAuth("hmac-sha-256-128"), "KernelAuth names"); Check(KnownEalg("null") && KnownEalg("aes-cbc") && KnownEalg("des-ede3-cbc") && !KnownEalg("aes-gcm") && !KnownEalg("") && !KnownEalg("3des"), "KnownEalg set (canonical names only)"); } @@ -292,47 +196,6 @@ int main() { auto bare = ParseSecurityServer("ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=null;", "hmac-sha-1-96", "null"); Check(bare && bare->mechanisms.size() == 1 && bare->offerMatched && !bare->Carrier().spiPs && !bare->Carrier().portPs, "no spi-s: absent, not zero"); } - // ---- the resume's Security-Verify: the stored list only for the SA pair the kernel holds - { - using Source = ResumeSecurityVerify::Source; - ExistingSa sa; - sa.spiPc = 129427852u; sa.spiPs = 133937015u; sa.portPc = 6051; sa.portPs = 6060; - sa.securityServer = "ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=aes-cbc; spi-c=129427852; spi-s=133937015; port-c=6051; port-s=6060"; - auto ok = ChooseResumeSecurityVerify(O2, sa, "null"); - Check(ok.source == Source::Stored && ok.value == O2 && ok.mechanisms == 6, "resume: O2's six-entry list whose carrier is the kernel's pair -> the list verbatim, six mechanisms"); - auto okAes = ChooseResumeSecurityVerify(O2, sa, "aes-cbc"); - Check(okAes.source == Source::Stored && okAes.value == O2, "resume: the same under the aes offer (the carrier does not depend on the offer)"); - constexpr std::pair off[] = { - {"spi-c", "ipsec-3gpp;q=0.94;alg=hmac-sha-1-96;mod=trans;spi-c=129427853;spi-s=133937015;port-c=6051;port-s=6060"}, - {"spi-s", "ipsec-3gpp;q=0.94;alg=hmac-sha-1-96;mod=trans;spi-c=129427852;spi-s=133937016;port-c=6051;port-s=6060"}, - {"port-c", "ipsec-3gpp;q=0.94;alg=hmac-sha-1-96;mod=trans;spi-c=129427852;spi-s=133937015;port-c=6052;port-s=6060"}, - {"port-s", "ipsec-3gpp;q=0.94;alg=hmac-sha-1-96;mod=trans;spi-c=129427852;spi-s=133937015;port-c=6051;port-s=6061"}, - }; - for (const auto& [field, text] : off) { - auto r = ChooseResumeSecurityVerify(text, sa, "null"); - Check(r.source == Source::OtherSa && r.value == sa.securityServer, std::format("resume: {} off by one -> the rebuilt entry", field)); - } - auto offSpiS = ChooseResumeSecurityVerify(off[1].second, sa, "null"); - Check(offSpiS.storedSpiPs && *offSpiS.storedSpiPs == 133937016u, "resume: the stored spi-s is reported for the log line"); - auto none = ChooseResumeSecurityVerify("", sa, "null"); - Check(none.source == Source::NoneStored && none.value == sa.securityServer, "resume: nothing stored -> the rebuilt entry"); - auto junk = ChooseResumeSecurityVerify("not a list", sa, "null"); - Check(junk.source == Source::OtherSa && junk.value == sa.securityServer && !junk.storedSpiPs, "resume: an unparseable value -> the rebuilt entry, no spi-s to report"); - ExistingSa kpn; - kpn.spiPc = 49889723u; kpn.spiPs = 49889722u; kpn.portPc = 33142; kpn.portPs = 6000; - kpn.securityServer = "ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=aes-cbc; spi-c=49889723; spi-s=49889722; port-c=33142; port-s=6000"; - auto k = ChooseResumeSecurityVerify(kpn.securityServer, kpn, "aes-cbc"); - Check(k.source == Source::Stored && k.value == kpn.securityServer && k.mechanisms == 1, "resume: KPN's one-entry list for its pair -> the list, one mechanism"); - } - // ---- a state file another attempt left behind: the file's UE SPIs against the kernel pair's - { - Check(!FileNamesAnotherPair(0x0526f2c0L, 0x0526f2c1L, 0x0526f2c0, 0x0526f2c1), "file pair: the pair's own SPIs -> the file is this pair's"); - Check(FileNamesAnotherPair(0x11111111L, 0x22222222L, 0x0526f2c0, 0x0526f2c1), "file pair: other SPIs -> another attempt's"); - Check(FileNamesAnotherPair(0x0526f2c0L, 0x22222222L, 0x0526f2c0, 0x0526f2c1), "file pair: one SPI differs -> another attempt's"); - Check(!FileNamesAnotherPair(std::nullopt, std::nullopt, 0x0526f2c0, 0x0526f2c1), "file pair: a file without the keys -> no verdict"); - Check(!FileNamesAnotherPair(0x11111111L, 0x22222222L, 0, 0x0526f2c1), "file pair: a missing inbound state (0) -> no verdict"); - Check(!FileNamesAnotherPair(0L, 0L, 0x0526f2c0, 0x0526f2c1), "file pair: zeros in the file -> no verdict"); - } // ---- selection rules and syntax corners { // spec-shaped list: every entry carries the SPI set, md5 preferred by q — we offered sha1, so sha1 wins (audit A1) diff --git a/tests/Messages/main.cpp b/tests/Messages/main.cpp index 041ab0a..da46785 100644 --- a/tests/Messages/main.cpp +++ b/tests/Messages/main.cpp @@ -192,20 +192,6 @@ int main() { Reg2, "protected REGISTER"); } - // ---- a reregistration (the keepalive refresh, a resume): the last - // challenge answer verbatim, new SPIs and a new client port in - // Security-Client, the server port unchanged (TS 24.229 5.1.1.4.2, - // TS 33.203 7.4.0) - { - std::string auth = AuthAka(c, "NONCEXYZ", "CNONCE0123456789", "RESP0000"); - std::string r3 = BuildRegisterProtected(c, "REGCALLID@2001:db8::db43", "FTAG5678", "REGBRANCH123456", 3, 55555u, 66666u, auth, 45063); - Check(r3.contains("\r\nSecurity-Client: ipsec-3gpp; alg=hmac-sha-1-96; ealg=aes-cbc; spi-c=55555; spi-s=66666; port-c=45063; port-s=45062\r\n"), "refresh Security-Client: new SPIs, a new client port, the same server port"); - Check(r3.contains("\r\nCSeq: 3 REGISTER\r\n") && r3.contains("\r\nVia: SIP/2.0/TCP [2001:db8::db43]:45062;") && r3.contains("@[2001:db8::db43]:45062>"), "refresh: CSeq+1, the server port in Via and Contact"); - Check(r3.contains(std::format("\r\n{}\r\n", auth)), "refresh carries the last challenge answer verbatim"); - std::string r3same = BuildRegisterProtected(c, "REGCALLID@2001:db8::db43", "FTAG5678", "REGBRANCH123456", 3, 55555u, 66666u, auth); - Check(r3same.contains("port-c=45061; port-s=45062\r\n"), "without a client port the in-use one is offered (the initial registration's second REGISTER)"); - } - // ---- protected-leg transport follows Context.transport (SIP_TRANSPORT=udp, // or the auto fallback): every protected request's Via says UDP, the // challenge's Via stays UDP either way, and TCP is the untouched default. diff --git a/tests/Qmi/main.cpp b/tests/Qmi/main.cpp deleted file mode 100644 index 19070d6..0000000 --- a/tests/Qmi/main.cpp +++ /dev/null @@ -1,95 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® - -// lint-disable-file fixed-width-types -// Imsd:Qmi unit tests — the request bytes imsd-pcscf sends (pinned to the -// bytes the dev-phone probe sent, which are protocol only), a Get Current -// Settings answer in the exact TLV shape the Fairphone 6's modem returned -// for the ims PDN (163 bytes; the addresses re-encoded to 2001:db8::/32), -// the OutOfCall answer an unbound client gets, and the readers' tolerance. -import std; -import Imsd; - -using namespace imsd::qmi; - -namespace { - int Failures = 0; - void Check(bool cond, std::string_view msg) { - if (!cond) { - std::println(std::cerr, "FAIL: {}", msg); - ++Failures; - } - } - - std::vector Hex(std::string_view s) { return *imsd::util::FromHex(s); } - - // The phone's answer to Get Current Settings (mask 0x7FFFF) on the bound - // client, 2026-10-11: profile2 / ims / IPv6 / MTU 1280, TLV 0x22 = 1, - // 0x24 empty, 0x2E = two P-CSCFs (PCO order), 0x2F empty. UE and - // gateway and the two P-CSCFs re-encoded. - const std::vector Settings = Hex("02 04 00 2d 00 9c 00 02 04 00 00 00 00 00 10 08 00 70 72 6f 66 69 6c 65 32 11 01 00 02 14 03 00 69 6d 73 1d 01 00 00 1f 02 00 00 02 22 01 00 01 24 01 00 00 25 11 00 20 01 0d b8 11 11 22 22 33 33 44 44 55 55 db 43 40 26 11 00 20 01 0d b8 11 11 22 22 00 00 00 00 00 00 00 01 40 29 04 00 00 05 00 00 2a 01 00 00 2b 01 00 06 2c 01 00 00 2d 02 00 80 88 2e 21 00 02 20 01 0d b8 00 00 00 00 00 00 00 00 00 00 00 23 20 01 0d b8 00 00 00 00 00 00 00 00 00 00 01 05 2f 08 00 00 00 00 00 00 00 00 00"); - // An unbound client's answer: result 1, error 15 (OutOfCall). - const std::vector OutOfCall = Hex("02 04 00 2d 00 07 00 02 04 00 01 00 0f 00"); - // Synthetic (no recording carries them): an IPv4 list of one and an FQDN - // list of one beside a one-entry IPv6 list. - const std::vector Synthetic = Hex("02 07 00 2d 00 3a 00 02 04 00 00 00 00 00 22 01 00 01 23 05 00 01 05 02 00 c0 24 10 00 01 0d 00 70 63 73 63 66 2e 65 78 61 6d 70 6c 65 2e 11 00 01 20 01 0d b8 00 00 00 00 00 00 00 00 00 00 01 05"); - // Synthetic: a 0x2E whose count says two but carries one. - const std::vector BadCount = Hex("02 08 00 2d 00 1b 00 02 04 00 00 00 00 00 2e 11 00 02 20 01 0d b8 00 00 00 00 00 00 00 00 00 00 00 23"); -} - -int main() { - // ---- requests, byte for byte what the probe sent - Check(BindMuxDataPort(1, EndpointEmbedded, EndpointInterfaceIpa, 1) == Hex("00 01 00 a2 00 0f 00 10 08 00 04 00 00 00 01 00 00 00 11 01 00 01"), "Bind Mux Data Port bytes"); - Check(SetIpFamily(2, IpFamilyV6) == Hex("00 02 00 4d 00 04 00 01 01 00 06"), "Set IP Family bytes"); - Check(GetPacketServiceStatus(3) == Hex("00 03 00 22 00 00 00"), "Get Packet Service Status bytes"); - Check(GetCurrentSettings(4, RequestedSettingsAll) == Hex("00 04 00 2d 00 07 00 10 04 00 ff ff 07 00"), "Get Current Settings bytes"); - Check(GetProfileSettings(5, 2) == Hex("00 05 00 2b 00 05 00 01 02 00 00 02"), "Get Profile Settings bytes"); - - // ---- the recorded answer - { - auto r = ParseMessage(Settings); - Check(r.has_value(), "settings parse"); - Check(r && r->type == TypeResponse && r->txn == 4 && r->msg == MsgGetCurrentSettings, "settings header"); - Check(r && r->Ok(), "settings result ok"); - Check(r && r->tlvs.size() == 17, "settings: 17 TLVs"); - Check(r && r->Find(0x2E) && r->Find(0x2E)->value.size() == 33, "settings: TLV 0x2E is 33 bytes"); - PcscfLists l = ParsePcscfLists(*r); - Check(l.viaPco, "settings: P-CSCF via PCO requested"); - Check(l.ipv6.size() == 2 && l.ipv6[0] == "2001:db8::23" && l.ipv6[1] == "2001:db8::105", "settings: the IPv6 list in PCO order, canonical"); - Check(l.ipv4.empty() && l.fqdn.empty(), "settings: no IPv4 list, an empty FQDN list"); - } - // ---- the unbound client's answer - { - auto r = ParseMessage(OutOfCall); - Check(r && !r->Ok() && r->error == ErrOutOfCall, "OutOfCall parsed"); - Check(r && ErrorName(r->error) == "OutOfCall", "OutOfCall named"); - Check(ErrorName(ErrNoEffect) == "NoEffect" && ErrorName(999) == "?", "error names"); - Check(r && ParsePcscfLists(*r).ipv6.empty(), "no lists in an error answer"); - } - // ---- the other lists, and tolerance - { - auto r = ParseMessage(Synthetic); - Check(r.has_value(), "synthetic parse"); - PcscfLists l = ParsePcscfLists(*r); - Check(l.ipv4.size() == 1 && l.ipv4[0] == "192.0.2.5", "IPv4 list: little-endian word to dotted quad"); - Check(l.fqdn.size() == 1 && l.fqdn[0] == "pcscf.example", "FQDN list: u16-length strings"); - Check(l.ipv6.size() == 1 && l.ipv6[0] == "2001:db8::105", "IPv6 list of one"); - auto b = ParseMessage(BadCount); - Check(b && ParsePcscfLists(*b).ipv6.empty(), "a 0x2E whose count disagrees with its length reads as empty"); - Check(!ParseMessage(Hex("02 04 00 2d 00")).has_value(), "short buffer -> nullopt"); - Check(!ParseMessage(Hex("02 04 00 2d 00 07 00 02 09 00 01 00 0f 00")).has_value(), "a TLV overrunning the message -> nullopt"); - Check(!ParseMessage(Hex("02 04 00 2d 00 20 00 02 04 00 01 00 0f 00")).has_value(), "a header length past the buffer -> nullopt"); - } - // ---- the two small readers - { - auto st = ParseMessage(Hex("02 03 00 22 00 0b 00 02 04 00 00 00 00 00 01 01 00 02")); - Check(st && PacketServiceStatus(*st) == PacketServiceConnected, "packet service status: connected (the probe's answer)"); - auto pf = ParseMessage(Hex("02 05 00 2b 00 0b 00 02 04 00 00 00 00 00 1f 01 00 01")); - Check(pf && ProfilePcscfViaPco(*pf) == true, "profile flag: PCSCF via PCO set"); - auto none = ParseMessage(Hex("02 05 00 2b 00 07 00 02 04 00 00 00 00 00")); - Check(none && !ProfilePcscfViaPco(*none).has_value() && !PacketServiceStatus(*none).has_value(), "absent TLVs -> nullopt"); - } - - if (Failures == 0) std::println("Qmi: all tests passed"); - return Failures; -} diff --git a/tests/Sdp/main.cpp b/tests/Sdp/main.cpp index 25e1c5d..e7f68ca 100644 --- a/tests/Sdp/main.cpp +++ b/tests/Sdp/main.cpp @@ -206,23 +206,8 @@ int main() { "a=rtpmap:104 AMR-WB/16000/1\r\n" "a=rtpmap:105 telephone-event/16000\r\n" "a=rtpmap:106 telephone-event/8000\r\n"; - Check(OfferedTelephoneEventPt(16000) == 98 && OfferedTelephoneEventPt(8000) == 100, "our offer's telephone-event numbers per clock"); Check(TelephoneEventPt(offer, 16000) == 105, "telephone-event pt by clock rate"); - Check(!TelephoneEventPt(offer, 8000), "an rtpmap the m-line does not list (106 among 104 105) is not negotiated"); - Check(ParseAnswer(offer).dtmfPt == 105, "the answer's dtmfPt is the one at the chosen codec's clock (AMR-WB: 16000)"); - std::string both = std::format("{}a=rtpmap:99 AMR/8000/1\r\n", offer); - both.replace(both.find("RTP/AVP 104 105"), 15, "RTP/AVP 104 99 105 106"); - std::array nb = {"AMR"}; - Check(ParseAnswer(both, nb).dtmfPt == 106, "...and the narrowband one when AMR is chosen"); - Check(TelephoneEventPt(both, 8000) == 106, "listed on the m-line, the narrowband telephone-event pt is found"); - Check(!ParseAnswer(offer, nb).dtmfPt, "no codec we play chosen: no dtmfPt either"); - Check(!ParseAnswer("c=IN IP4 192.0.2.1\r\n" "m=audio 5000 RTP/AVP 8\r\n" "a=rtpmap:8 PCMA/8000\r\n").dtmfPt, "no telephone-event in the answer: dtmfPt empty"); - Check(!ParseAnswer("c=IN IP4 192.0.2.1\r\n" "m=audio 5000 RTP/AVP 8\r\n" "a=rtpmap:8 PCMA/8000\r\n" "a=rtpmap:101 telephone-event/8000\r\n").dtmfPt, "the answer dropped telephone-event from its m-line and kept the rtpmap: not negotiated (RFC 3264 §8.3.2: nothing is sent with a format the answer does not list)"); - Check(ParseAnswer("c=IN IP4 192.0.2.1\r\n" "m=audio 5000 RTP/AVP 8 101\r\n" "a=rtpmap:101 telephone-event/8000\r\n").dtmfPt == 101, "G.711 answer with telephone-event/8000: dtmfPt 101"); - Check(!ParseAnswer("c=IN IP4 192.0.2.1\r\n" "m=audio 5000 RTP/AVP 8 101\r\n" "a=rtpmap:101 telephone-event/16000\r\n").dtmfPt, "telephone-event at the wrong clock for the codec is not the call's"); - Check(ParseAnswer("c=IN IP4 192.0.2.1\r\n" "m=audio 5000 RTP/AVP 8 101\r\n" "a=rtpmap:101 TELEPHONE-EVENT/8000\r\n").dtmfPt == 101, "the name in another case (RFC 4566: case-insensitive)"); - Check(TelephoneEventPt("m=audio 1 RTP/AVP 98\r\na=rtpmap:98 Telephone-Event/16000/1\r\n", 16000) == 98, "mixed case, with a channel count"); - Check(!TelephoneEventPt("m=audio 1 RTP/AVP 98\r\na=rtpmap:98 telephone-event/80000\r\n", 8000), "the clock is matched whole: /80000 is not /8000"); + Check(TelephoneEventPt(offer, 8000) == 106, "narrowband telephone-event pt"); Check(!TelephoneEventPt(offer, 48000).has_value(), "absent clock rate -> nullopt"); Check(!TelephoneEventPt("m=audio 1 RTP/AVP 0\r\n", 16000).has_value(), "no rtpmap -> nullopt"); } diff --git a/tests/Util/main.cpp b/tests/Util/main.cpp index f7c0200..174c691 100644 --- a/tests/Util/main.cpp +++ b/tests/Util/main.cpp @@ -3,8 +3,7 @@ // lint-disable-file fixed-width-types // Imsd:Util unit tests — MD5 against the RFC 1321 test suite, base64/hex -// decoding, host/port formatting, the RFC 5952 canonical IPv6 text and the -// address-list parser, and the deterministic-seed RNG. MD5 and +// decoding, host/port formatting, and the deterministic-seed RNG. MD5 and // base64 are our own (the standard library ships neither) and the AKAv1-MD5 // digest depends on them exactly, so these are pinned hard. import std; @@ -96,47 +95,6 @@ int main() { Check(Uuid4(r2) == u, "same seed -> same uuid"); } - // ---- CanonicalIp6: RFC 5952 §4 — the text iproute2/inet_ntop print. - // Global addresses from the documentation prefix only (2001:db8::/32); the - // link-local one is synthetic. - { - auto c = [](std::string_view s) { return CanonicalIp6(s).value_or(""); }; - Check(c("2001:DB8:E000:100::105") == "2001:db8:e000:100::105", "canon upper case"); - Check(c("2001:0db8:e000:0100:0000:0000:0000:0105") == "2001:db8:e000:100::105", "canon uncompressed, leading zeros"); - Check(c("2001:db8:e000:100:0:0:0:105") == "2001:db8:e000:100::105", "canon zero fields -> ::"); - Check(c("2001:db8:0:0:1:0:0:1") == "2001:db8::1:0:0:1", "canon first of two equal runs (5952 4.2.3)"); - Check(c("2001:0:0:1:0:0:0:1") == "2001:0:0:1::1", "canon the longer run wins (4.2.3)"); - Check(c("2001:db8:0:1:1:1:1:1") == "2001:db8:0:1:1:1:1:1", "canon a single zero field stays (4.2.2)"); - Check(c("2001:0db8::0001") == "2001:db8::1", "canon leading zeros dropped (RFC 5952 4.1)"); - Check(c("2001:db8:0:0:0:0:2:1") == "2001:db8::2:1", "canon the zero run compressed (4.2.1)"); - Check(c("2001:db8::0:1") == "2001:db8::1", "canon :: takes the whole run (4.2.1)"); - Check(c("1:2:3:4:5:6:7::") == "1:2:3:4:5:6:7:0", "canon :: standing for one field is written out"); - Check(c("::1") == "::1", "canon ::1"); - Check(c("::") == "::", "canon ::"); - Check(c("1::") == "1::", "canon 1::"); - Check(c("[2001:db8::1]") == "2001:db8::1", "canon brackets stripped"); - // idempotence on inet_ntop-shaped input: the holds file and the state - // file were written in that shape by 0.3.12 and must still compare - for (std::string_view s : {"2001:db8:1111:2222:3333:4444:5555:db43", "2001:db8::105", "2001:db8::1:0:0:1", "fe80::db8:ff:fe00:1"}) - Check(c(s) == s, std::format("canon idempotent on {}", s)); - for (std::string_view s : {"2001:db8::1::2", "2001:db8:1", "12345::1", "2001:db8::1%qmapmux0.0", "[fe80::1]:5060", "::ffff:10.0.0.1", "10.0.0.1", "pcscf.example.com", "", ":1::2", "1:::2", "::1:", "1:2:3:4:5:6:7:8:9", "2001:db8::g"}) - Check(!CanonicalIp6(s), std::format("canon rejects '{}'", s)); - Check(CanonicalAddr("2001:DB8::105") == "2001:db8::105", "CanonicalAddr v6"); - Check(CanonicalAddr("10.0.0.1") == "10.0.0.1", "CanonicalAddr v4 untouched"); - Check(CanonicalAddr("2001:db8::g") == "2001:db8::g", "CanonicalAddr passes a malformed v6 through"); - } - - // ---- ParseAddressList: the PCSCF_DISCOVERED= hand-off - { - std::vector rejected; - auto l = ParseAddressList("2001:DB8::105, 2001:db8::23,,10.1.1.1,2001:db8:0::105, pcscf.example , ", &rejected); - Check(l.size() == 2 && l[0] == "2001:db8::105" && l[1] == "2001:db8::23", "list: canonical, ordered, deduped"); - Check(rejected.size() == 2 && rejected[0] == "10.1.1.1" && rejected[1] == "pcscf.example", "list: rejects reported in order"); - Check(ParseAddressList("").empty(), "list: empty string"); - Check(ParseAddressList(" , ,").empty(), "list: blanks only"); - Check(ParseAddressList("2001:db8::1").size() == 1, "list: single entry, no rejected sink"); - } - if (Failures == 0) std::println("Util: all tests passed"); return Failures; }