diff --git a/AGENTS.md b/AGENTS.md index 6c5daf5..e441aa2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,7 @@ PipeWire. The verified device/carrier combinations are in the README. | `interfaces/Imsd-Sip.cppm` | `imsd::sip` — SIP as a text layer plus TCP framing: header extraction, status codes, the granted lifetime, `ResponseOf` transaction matching. Not a full RFC 3261 parser, deliberately. | | `interfaces/Imsd-Sdp.cppm` | `imsd::sdp` — SDP offer/answer: the AMR-WB + AMR + telephone-event offer (optional IR.92 preconditions), the parser the media plane needs, `BuildAnswer` for the terminating side, the `CODECS` override. | | `interfaces/Imsd-Aka.cppm` | `imsd::aka` — USIM AKA glue: the AKAv1-MD5 digest (RFC 3310), IMPI/IMPU/realm from IMSI + MCC/MNC, parsers for `qmicli --uim-get-card-status` and `mmcli` output, the AUTHENTICATE APDU and its response. | -| `interfaces/Imsd-Ipsec.cppm` | `imsd::ipsec` — the two ESP SA pairs (TS 33.203) as `ip xfrm` argv lists, the Security-Server mechanism selection, and the reader that reconstructs SPIs/ports from `ip xfrm state`/`policy` text for a warm resume, and (1.0.0) `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). | +| `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-Messages.cppm` | `imsd::msg` — every SIP request and response imsd sends, built from an explicit `Context` and per-request variables so the builders are pure functions; byte-pinned by `tests/Messages`. Also `IsEmergency`. | | `interfaces/Imsd-Engine.cppm` | `imsd::engine` — the decisions as pure reducers: `CallMachine` (one call's dialog, returning `Action`s for the shell) and `RegRecovery` (the registration recovery policy, §7.4). | | `interfaces/ImsdBus.cppm` | `imsd::bus` — the polkit gate in front of call control, shared by imsd and imsd-ofonod. GLib, so outside `imsd-core`. | @@ -73,7 +73,9 @@ in every source but the re-exporting `Imsd.cppm`, clang + libc++ + lld. The rule behind the split: **`imsd-core` stays GLib-free and free of I/O so that every decision in it has a unit test.** New protocol logic goes into a partition with a test. The shell (`main.cpp`) holds protocol decisions no -test reaches — some older than 0.3.10, some added by 0.3.10, 0.3.11, 0.3.12 or 1.0.0: the reg-event +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=` at a fresh +registration, the move between them and back — §7.2); 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`: @@ -92,13 +94,17 @@ refusals and network silence, and the catch sites that sort a failed refresh (`FreshRecover`, `DeferBringUp`) into Refused, Silent or Throttled with the status, the Retry-After and `saReplaced_`, the bring-up resume's catch (a refusal's Retry-After fed as a hold, the old registration written off after -any failed resume), the `OnLink` read that writes the old registration off +a failed resume that a fresh registration follows — with no fresh target it +is kept for the restart to resume, unless the engine's rule ends it — a 401, a refusal with a Retry-After, the +second refusal across the restart (the count travels in the holds), a 403 +once fresh is due, a NOTIFY-terminated binding: the pair is taken out of the +kernel like one nothing can 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; -and the xfrm deletes the SA setup runs — `OwnXfrmDeleteCommands` builds them -from the kernel text, with a test; the shell reads that text and runs them -(§12); +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 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 @@ -227,8 +233,14 @@ What the suites pin: - `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, and - `OwnXfrmDeleteCommands` (our pair's eight deletes byte-exact on + 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 @@ -338,14 +350,32 @@ 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: the SA - setup'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, - left in place through a fresh registration, they ran on the dev phone, - 2026-10-12); `RunOwnXfrmDeletes`' line for a delete that failed (`… -> rc + 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); `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`). + compare in canonical form (no capture + stages a holds file whose `pdn` is spelled unlike `LOCAL`). - `imsd-media --selftest` round-trips the RFC 4867 payload packing (both formats, both AMR codecs) and the G.711 tables. `MIC_SRC=` (raw s16 PCM at the codec rate, paced in real time) and `PCM_DUMP=1` are test seams @@ -438,7 +468,22 @@ a 5-minute registration gate, connect retries). `Restart=on-failure`, The daemon's own bring-up (`BringUp()` in `main.cpp`): -1. `PCSCF` must be set (no P-CSCF discovery from the PDN's PCO yet). +1. `PCSCF=` is where a fresh registration goes; a resume needs none — the + kernel's SAs name its P-CSCF (step 3). With none set, a bring-up that did + not resume fails with `no P-CSCF for a fresh registration: …`, 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) 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 (`old_gone`) — so 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 by the engine during the attempt, `RESUME=0`) is 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; the holds + keep the Retry-After of a refusal and, for a registration the engine wrote + off, the write-off — a removed pair needs none. 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 @@ -449,8 +494,15 @@ 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. A resume the network refuses is not fatal: the - fresh registration replaces its SAs. The SA reader, the holds file's `pdn` + 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 (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 @@ -468,7 +520,22 @@ The daemon's own bring-up (`BringUp()` in `main.cpp`): 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. + 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 …`) 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, step 1). Every fresh registration goes to `PCSCF=`; with + none set, a bring-up that cannot resume fails with `no P-CSCF for a fresh + registration: …` and a recovery step that needs one restarts the daemon + through systemd. 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 @@ -492,13 +559,15 @@ The daemon's own bring-up (`BringUp()` in `main.cpp`): 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), it sends — + 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. + 494 when it does not mirror its own; a refused resume registers fresh (with + no fresh target: the fatal and the restart, 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 @@ -507,7 +576,10 @@ The daemon's own bring-up (`BringUp()` in `main.cpp`): in-use SPIs) was refused with 503 (§13). The state file is JSON: `callid`, `ftag`, `cseq`, `spi_uc`, `spi_us`, -`expiry`, `route`, `ppi`, `aor`, `contact_user`, `transport`, since 0.3.12 +`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 @@ -575,7 +647,8 @@ configures the muxed netdev. Idempotent; a non-zero exit fails the unit. step took it); 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. + media peer itself, and one to the SA peer of a registration it resumes + when that differs from the fresh target (§7.2). - 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 @@ -611,12 +684,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)`). | +| 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). | | A refused or throttled fresh attempt | Waits on the **throttle schedule**: 21 min, growing ×1.5 to 60 (`ThrottleMinutesMin`/`Max`). KPN refuses a fresh SA pair on an address whose registration is live (§13), and drops that registration the moment an initial REGISTER arrives. | | A silent fresh attempt | Backs off 30 s → 10 min (`FreshDelayMin`/`Max`). | | `Retry-After` | Always wins over a shorter wait, honoured up to an hour (`RetryAfterMax`, the schedule's longest wait); a longer one is cut to an hour and the refusal heard again then. On a refused fresh attempt it replaces the schedule's wait, shorter or longer, never below 30 s (`FreshDelayMin`). | | A 403 | A refusal like the others: never hammered (the schedule), never abandoned (a SIM that is unbarred registers within the hour). | -| The old registration is gone | A 403 once a fresh registration is due, a fresh attempt that got past the SA setup, a binding the network terminated (reg-event), an older address that left the netdev, or a bring-up whose resume failed: no reconnect over the old SA from then on. Otherwise the existing SA is still retried on the reconnect back-off while a fresh registration is deferred. | +| 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 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. | | 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. | @@ -628,12 +701,23 @@ 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) — and `LoadHolds` takes them +failed or there was nothing to resume; a bring-up with no fresh target +writes `old_gone` when the engine's rule writes the registration off — a +403 once a fresh registration is due (one with a Retry-After, or the second +refusal with the count restored from the holds), a binding a NOTIFY +terminated under the attempt — and keeps one the holds already carried, +§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 over at the next start for the same `pdn` and boot id, waiting them out before the resume (a refresh's `Retry-After`) or before the fresh registration (a fresh attempt's schedule or `Retry-After`, a terminated binding's wait); with `old_gone` set the start does not resume over the old SAs at -all, it waits the fresh hold out and registers fresh — also after a start +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 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 @@ -663,8 +747,9 @@ 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 — 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 where there is a fresh target (with +none the daemon restarts instead, §7.2 step 3) — 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 @@ -786,9 +871,9 @@ account `user`, its old default. | Path | Written by | Content | |---|---|---| -| `/etc/imsd.env` | the admin | configuration (§10); `PCSCF=` is required. The apk adds a drop-in with `ConditionPathExists=/etc/imsd.env` (§9.2), so an unconfigured phone does not boot into a failing unit | +| `/etc/imsd.env` | the admin | configuration (§10); `PCSCF=` is where a fresh registration goes — a resume needs none (§7.2 step 1). 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.holds` (`HOLDS_FILE`) | imsd, `imsd-handover.sh` | the network's holds, the schedule's place and the reducer's refusal count (§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 | @@ -817,6 +902,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: resuming on X, the registration's SA peer; the configured P-CSCF + is Y — the next fresh registration moves there` (or `… (no P-CSCF + configured)`), `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=)`. - `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 @@ -824,6 +919,27 @@ Lines the operator (and the scripts) look for, all prefixed `imsd: `: 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: …`. - A refused REGISTER or SUBSCRIBE is logged with its status line and any `Retry-After` / `Warning` / `Reason` / `Server` headers; @@ -1102,9 +1218,9 @@ only the imsd side of its bus choice. 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 deletes — `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 + 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 @@ -1128,7 +1244,9 @@ only the imsd side of its bus choice. (`DUMP_SIP=0`), and never leave the phone in a commit. - The ims PDN is a walled garden: `ims-pdn-up.sh` keeps it from becoming the default route and gives the P-CSCF a host route; imsd adds one per - call for the media peer (§7.3). Nothing else is routed over it. + call for the media peer (§7.3) and, at a resume, one for the registration's + SA peer when that differs from the fresh target (§7.2: the script routed + only the configured address). 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. diff --git a/README.md b/README.md index c72a52d..f4d1b95 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,8 @@ 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: +Configuration is environment variables in `/etc/imsd.env`; the one setting a fresh registration +needs is the P-CSCF: ```sh # your carrier's P-CSCF address, capture via: `adb shell dumpsys telephony.registry` on Android @@ -98,14 +98,14 @@ session and root may place, answer and end calls; an ssh login may not. | Variable | Default | Meaning | |---|---|---| -| `PCSCF` | *(none — required)* | 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; without it registration fails with a clear error | +| `PCSCF` | *(none)* | 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. 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. Without it a fresh 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) | | `USER_AGENT` | `imsd/` | REGISTER User-Agent. Some networks fingerprint UAs; setting your device's stock build string reproduces the stock modem's registration exactly. Empty omits the header | | `STATE_FILE` | `/var/lib/imsd/imsreg.state` (root) / `$XDG_STATE_HOME/imsd/imsreg.state` | persisted registration context for warm resume | -| `HOLDS_FILE` | `/run/imsd.holds` (root) / `$XDG_RUNTIME_DIR/imsd.holds` (`/tmp/imsd.holds` without a runtime dir) | the waits the network imposed (a `Retry-After`, the refusal schedule), where that schedule stands and whether the old registration is gone (`old_gone`), kept across a restart of imsd for the PDN they were set on — not across a reboot; `/dev/null` switches the file off (only a regular file is ever removed; anything else is emptied). `imsd-handover.sh` honours it too | -| `RESUME` | `auto` | `0` forces a fresh registration (ignores a warm SA) | +| `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 | | `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 | diff --git a/implementations/main.cpp b/implementations/main.cpp index 25eadb4..a75e47c 100644 --- a/implementations/main.cpp +++ b/implementations/main.cpp @@ -126,6 +126,22 @@ void RunOwnXfrmDeletes(const std::vector>& cmds) { } } +// 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. +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) { @@ -683,16 +699,16 @@ std::string StatePath() { return EnvOr("STATE_FILE", std::format("{}/imsreg.stat // 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) { +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(); 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\": \"{}\"}}", + "\"security_server\": \"{}\", \"pcscf\": \"{}\"}}", 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); + r.contactUser, transport, r.authNonce, r.authCnonce, r.authResponse, ss, pcscf); std::string path = StatePath(); std::error_code ec; std::filesystem::create_directories(std::filesystem::path(path).parent_path(), ec); @@ -716,7 +732,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; + std::optional callid, ftag, route, ppi, aor, contactUser, transport, authNonce, authCnonce, authResponse, securityServer, pcscf; std::optional cseq, spiUc, spiUs, expiry; }; std::optional LoadState() { @@ -738,6 +754,7 @@ std::optional LoadState() { 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\": "); @@ -832,8 +849,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\": {}}}\n", - pdn, BootId(), BootSeconds(), static_cast(std::ceil(h.hold)), static_cast(std::ceil(h.freshHold)), h.throttleMinutes, h.oldGone ? "true" : "false"); + 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); if (std::FILE* f = std::fopen(path.c_str(), "w")) { std::fwrite(j.data(), 1, j.size(), f); std::fclose(f); @@ -855,6 +872,7 @@ 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; } @@ -924,10 +942,11 @@ void PostEvent(std::unique_ptr ev) { class Engine { public: Engine() { - // 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_ = imsd::util::CanonicalAddr(EnvOr("PCSCF", "")); + // The P-CSCF a fresh registration goes to: PCSCF= (no baked-in + // default — carrier infrastructure). A resume follows the kernel's + // SAs instead (TryResume), whatever this says today. + pcscfTarget_ = imsd::util::CanonicalAddr(EnvOr("PCSCF", "")); + pcscf_ = pcscfTarget_; pcscfPort_ = std::atoi(EnvOr("PCSCF_PORT", "5060").c_str()); dev_ = EnvOr("DEV", "qmapmux0.0"); outDir_ = EnvOr("OUT_DIR", StateDir()); @@ -1111,6 +1130,20 @@ 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. @@ -1123,6 +1156,13 @@ private: // config std::string pcscf_, dev_, outDir_, local_, ealgOffer_, mediaBin_; + // The P-CSCF a fresh registration goes to (PCSCF=). 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_; + static constexpr std::string_view NoFreshPcscf = "no P-CSCF for a fresh registration: none configured (PCSCF=)"; // 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). @@ -1210,8 +1250,7 @@ private: // ---- bring-up: warm resume if an SA + state file exist, else fresh ---- void BringUp() { - 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)"); - if (pcscf_.contains(',')) throw std::runtime_error(std::format("PCSCF='{}' must be one address", pcscf_)); + 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 @@ -1248,25 +1287,62 @@ 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: the fresh registration below replaces its SAs. Only a - // failed FRESH registration is. + // 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. // 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 - try { if (recovery_.Reconnectable() && TryResume()) return; } + const std::string next = ResumeFailedNext(); + try { if (recovery_.Reconnectable()) { outcome = TryResume(); if (outcome == ResumeOutcome::Done) return; } } catch (const RefusedError& e) { - Log(std::format("resume failed: {} — the next registration is a fresh one", e.what())); + outcome = ResumeOutcome::Failed; CloseFlowAfterWait(); - if (e.retryAfter > 0) recovery_.OnReconnectFailure(Boot(), imsd::engine::RegRecovery::Failure::Refused, e.retryAfter, e.status); + // 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))); } - catch (const std::exception& e) { Log(std::format("resume failed: {} — the next registration is a fresh one", e.what())); CloseFlowAfterWait(); } + 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 this is no write-off: a + // resumable pair whose attempt failed (a silent connect, an + // unanswered or refused refresh — its Retry-After is in the + // holds) stays for the restart to resume — 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 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. + if (outcome == ResumeOutcome::Dead) RemoveDeadPair(pdnLocal_); + KeepHolds(); + throw std::runtime_error(std::string(NoFreshPcscf)); } // a resume that failed found the stored SAs unusable (and after a // reboot there are none): nothing to reconnect over while a deferred @@ -1275,14 +1351,36 @@ private: portPs_ = 0; local_ = pdnLocal_; ctx_.local = local_; KeepHolds(); + pcscf_ = pcscfTarget_; if (recovery_.Next(Boot()).at > Boot()) Log(std::format("the network's hold stands; {}", NextStep())); if (!WaitForPolicy()) throw Stopping{}; FreshRegister(); } - bool TryResume() { + // 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() { 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; + bool routePeer = false; + if (*peer != pcscf_) { + Log(pcscfTarget_.empty() + ? std::format("pcscf: resuming on {}, the registration's SA peer (no P-CSCF configured)", *peer) + : std::format("pcscf: resuming on {}, the registration's SA peer; the configured P-CSCF is {} — the next fresh registration moves there", *peer, pcscfTarget_)); + pcscf_ = *peer; + routePeer = true; // routed below, once the SAs are confirmed: a peer named by a pair that is not ours gets no route into the PDN + } 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 @@ -1304,14 +1402,28 @@ private: break; } } - if (!sa) return false; + if (!sa) { Log(std::format("the SA pair to {} has no SA on an address of {}: nothing to resume", pcscf_, dev_)); return ResumeOutcome::Dead; } + if (routePeer) RunCmd({"ip", Is6(pcscf_) ? "-6" : "-4", "route", "replace", pcscf_, "dev", dev_}); auto ps = LoadState(); - if (!ps || !ps->callid) { Log("no reg state file; resume falls back to fresh"); return false; } + 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; + } // 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("state file transport differs from SIP_TRANSPORT; fresh"); return false; } + if (sipPolicy_ != "auto" && t != transport_) { Log(std::format("state file transport differs from SIP_TRANSPORT; {}", ResumeFailedNext())); return ResumeOutcome::Failed; } route_ = ps->route.value_or(std::format("", imsd::util::HostPort(pcscf_, sa->portPs))); // No fallback identity: an empty ppi omits P-Preferred-Identity and @@ -1324,7 +1436,7 @@ private: // (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. + // 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; @@ -1353,8 +1465,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("resume connect failed; falling back to fresh"); - return false; + Log(std::format("resume connect failed; {}", ResumeFailedNext())); + return ResumeOutcome::Failed; } reg_.callid = *ps->callid; reg_.ftag = ps->ftag.value_or(""); @@ -1375,9 +1487,9 @@ private: if (auto e = imsd::sip::GrantedExpires(okMsg)) reg_.expiry = *e; UpdateRouteAndPpi(okMsg); LogBindings(okMsg); - PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport); + PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport, pcscf_); Log("REGISTERED (resumed + true-refreshed)"); - return true; + return ResumeOutcome::Done; } // The registrar's 200 OK echoes EVERY current binding for the implicit @@ -1547,11 +1659,15 @@ private: 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); + PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport, pcscf_); Log("REGISTERED (fresh)"); } @@ -1667,7 +1783,11 @@ private: } void UpdateRouteAndPpi(const std::string& okMsg) { - auto svc = imsd::sip::Headers(okMsg, "Service-Route"); + // 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); if (svc.empty()) Log("200: no Service-Route (route stays P-CSCF)"); if (!svc.empty()) { std::string r; @@ -1749,7 +1869,7 @@ private: auto [msg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1); reg_.cseq = used; if (auto e = imsd::sip::GrantedExpires(msg)) reg_.expiry = *e; - PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport); + PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport, pcscf_); Log(std::format("keepalive re-REGISTER ok (cseq {})", used)); OnRegistered(); } catch (const RefusedError& e) { @@ -1860,7 +1980,7 @@ private: if (auto e = imsd::sip::GrantedExpires(msg)) reg_.expiry = *e; UpdateRouteAndPpi(msg); LogBindings(msg); - PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport); + PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport, pcscf_); Log("reconnected + re-registered"); OnRegistered(); } catch (const RefusedError& e) { @@ -1913,7 +2033,14 @@ 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 + // 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_; + } std::string from = local_; if (local_ != pdnLocal_) { Log(std::format("the fresh registration moves from {} to the PDN's own address {}", local_, pdnLocal_)); @@ -1944,6 +2071,12 @@ 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, diff --git a/interfaces/Imsd-Engine.cppm b/interfaces/Imsd-Engine.cppm index 2515e5c..52a4e1f 100644 --- a/interfaces/Imsd-Engine.cppm +++ b/interfaces/Imsd-Engine.cppm @@ -103,8 +103,10 @@ 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 resume that failed at bring-up (OldGone) — no - // reconnect over the old SA from then on. Every wait + // 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 // 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 @@ -164,16 +166,18 @@ 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 - // 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; } + 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; } }; - Held Holds(double now) const { return {std::max(0.0, holdUntil - now), std::max(0.0, freshHoldUntil - now), throttleMinutes, oldGone}; } + Held Holds(double now) const { return {std::max(0.0, holdUntil - now), std::max(0.0, freshHoldUntil - now), throttleMinutes, oldGone, refusals}; } // 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 } diff --git a/interfaces/Imsd-Ipsec.cppm b/interfaces/Imsd-Ipsec.cppm index 54e52e2..7cde032 100644 --- a/interfaces/Imsd-Ipsec.cppm +++ b/interfaces/Imsd-Ipsec.cppm @@ -19,11 +19,14 @@ 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 -`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). +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). Pure std C++ (the shell runs the commands and captures the text). Command shapes and the parser are pinned by tests/Ipsec. @@ -461,6 +464,19 @@ export namespace imsd::ipsec { 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. @@ -475,6 +491,37 @@ export namespace imsd::ipsec { 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 @@ -489,7 +536,8 @@ export namespace imsd::ipsec { // 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, and flushes nothing (§12). Residual: a + // 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 diff --git a/tests/Engine/main.cpp b/tests/Engine/main.cpp index 6d0f942..610dd79 100644 --- a/tests/Engine/main.cpp +++ b/tests/Engine/main.cpp @@ -1097,6 +1097,20 @@ int main() { n.Restore(5, m.Holds(5)); Check(!n.Reconnectable() && freshAt(n, 5, 0), "…so the next start registers fresh at once instead of resuming"); Check(!RegRecovery::Held{0, 0, 21, false}.Any(), "nothing gone, nothing held: nothing to keep"); + // the refusal count travels: a resume refused without a Retry-After at a start with no fresh target is one + // refusal, the restart's is the second — without the travel every restart would be the first (F16's loop) + RegRecovery rq; + rq.OnReconnectFailure(10, F::Refused, 0, 503); + auto hq = rq.Holds(20); + Check(hq.Any() && hq.refusals == 1 && !hq.oldGone && hq.hold == 0, "a refusal without a Retry-After is held as a count, nothing else"); + RegRecovery sq; + sq.Restore(100, hq); + Check(!sq.Fresh() && sq.Reconnectable(), "the count alone gives nothing up"); + sq.OnReconnectFailure(100, F::Refused, 0, 503); + Check(sq.Fresh() && sq.Reconnectable(), "the second refusal, across the restart, gives the resume up (fresh) without writing the registration off"); + RegRecovery tq; + tq.OnReconnectFailure(10, F::Refused, 0, 401); + Check(tq.Fresh() && tq.Reconnectable() && tq.Holds(20).refusals == 1, "a 401 gives the resume up at once; its count travels too"); RegRecovery x; x.Restore(2000, RegRecovery::Held{86400, 86400, 21}); Check(x.holdUntil == 2000 + 3600 && x.freshHoldUntil == 2000 + 3600, "a restored hold is cut to the hour like the Retry-After it came from"); diff --git a/tests/Ipsec/main.cpp b/tests/Ipsec/main.cpp index 0f9a299..9e75c7a 100644 --- a/tests/Ipsec/main.cpp +++ b/tests/Ipsec/main.cpp @@ -101,6 +101,44 @@ 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"; @@ -286,6 +324,15 @@ int main() { 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)