ipsec, engine, imsd: the state file records the P-CSCF and a resume follows the kernel's SA peer

A resume compared PCSCF= against the kernel's SAs and, when they did not
match, registered fresh without a word — a second initial REGISTER from an
address whose registration is live; on KPN a fresh pair to the same P-CSCF
meets the 21-minute refusal that costs the live registration (§13), what
another P-CSCF does with it is unmeasured. The registration was made to
whatever P-CSCF the SAs name (the SA setup takes imsd's previous pair
out first), so that is where a resume belongs: SaPeer reads
it from the dir-out transport policies — the one address a reqid-1 and a
reqid-2 policy both name, each with imsd's fixed protected port as its
selector's sport (reqid and mode alone are anyone's): the state text cannot tell the UE from the
P-CSCF, a lone policy is not ours and a VPN's beside the pair changes
nothing — TryResume resumes there, routes it, and says so when PCSCF=
differs; the next fresh registration moves to PCSCF= and moves back when
it fails before the SA setup and the old registration still stands (its
address still on the link), as it already did between addresses, and
its fallback Route is the P-CSCF it registers at, set before the 200's
Service-Route is looked for — an empty Service-Route value counts as
none — so a move never keeps the previous node's route. A file left by an attempt that got through the SA setup and did not live
to record — to this or another P-CSCF — is recognised by its SA pair: the
file's spi_uc/spi_us (a 0.3.x file carries them too) against the kernel's
(FileNamesAnotherPair, Imsd:Ipsec, pinned in tests/Ipsec), instead of
resumed with the wrong dialog; the state file gains pcscf, named in that
line. Both fresh paths — the bring-up's and recovery's — now set
their target themselves: with no PCSCF=, a bring-up that did not resume
fails with its own message, and since no fresh one follows: a resumable
pair whose attempt failed stays for the restart to resume, a pair nothing
can resume — one the engine's rule wrote off during the attempt among them,
a refresh refused with a 403 that carries a Retry-After or a binding a NOTIFY
terminated under the re-REGISTER wait, decided once after both catch arms by
its verdict (Reconnectable(), and with no fresh target Fresh(): a 401, a
refusal with a Retry-After, the second refusal — with no fresh target
every refusal reaches the reducer and its count travels in the holds, so
the restart's refusal is the second, while with a target only a
Retry-After does, as before — a refusal without one is not counted and the
fresh registration follows at once; without the count a refused no-target resume would re-REGISTER on the
same Call-ID at every RestartSec, the F16 loop) — is removed so the next
bring-up finds nothing — and a recovery step that needs one restarts the daemon; the "PCSCF not set" check at the top of BringUp is gone, so a
registration can be resumed with no setting at all. Three journal lines of
the fresh-target path change with it: `resume connect failed; falling back
to fresh` and `state file transport differs from SIP_TRANSPORT; fresh` end
in `the next registration is a fresh one`, and a resume the engine writes
off (a 403 with a Retry-After, a binding a NOTIFY terminated under the wait)
logs `resume failed: … — written off by the network: the next registration
is a fresh one` (§7.8). `no reg state file; resume falls back to fresh`
keeps its text with a fresh target and ends in `nothing can resume it: the
pair is removed` without one; the new `the state file names another SA pair
(0x…/0x…[ to X]) than the kernel's (0x…/0x… to Y): another attempt's; …`
line takes the same two tails.

Tested: crafter-build test, 7 passed (tests/Engine: the refusal count's travel
through the holds); the resume on the SA peer with
the fresh target absent in builds of this series on the dev
phone (KPN), and the no-target bring-up there with a resume that failed on
a transport mismatch (the pair kept, the restart resumed) and with the
state file removed (the pair removed, the next bring-up found nothing); the
two dead-resume lines' no-target tail — a missing file, a file with another
pair's SPIs — and the pair's removal on the tip's host build (bench 23f: a
fake modem on PATH, a real xfrm in a namespace; a file with the pair's SPIs
but another P-CSCF's address passes the check and the resume runs — `RESUME
via existing SA`, the connect then failing in the namespace); on the dev
phone (bench 41, the tip's build) a resume on a file without its pcscf key,
the live pair's SPIs.
This commit is contained in:
Jorijn van der Graaf 2026-10-11 15:58:49 +02:00
commit 40a960f749
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
7 changed files with 448 additions and 84 deletions

188
AGENTS.md
View file

@ -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-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-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-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-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-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`. | | `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 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 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 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 subscription schedule (§7.4: its retry
ladder, Retry-After clamp, Timer F, renewal point and RFC 6665 reasons); the ladder, Retry-After clamp, Timer F, renewal point and RFC 6665 reasons); the
registration sequence and its transactions (`AwaitFinal`/`AwaitInitialFinal`: 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 (`FreshRecover`, `DeferBringUp`) into Refused, Silent or Throttled with the
status, the Retry-After and `saReplaced_`, the bring-up resume's catch (a 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 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, when its address has left the netdev (`FreshRecover` after a failed move,
`AddressGone`), and `PacketServiceUp`'s read of `AddressGone`), and `PacketServiceUp`'s read of
ModemManager before a silent cycle is counted; ModemManager before a silent cycle is counted;
and the xfrm deletes the SA setup runs — `OwnXfrmDeleteCommands` builds them and the xfrm deletes the SA setup and the dead pair's teardown run —
from the kernel text, with a test; the shell reads that text and runs them `OwnXfrmDeleteCommands` builds them from the kernel text, with a test; the
(§12); shell reads that text and runs them (§12);
the shell's reaction to a NOTIFY the shell's reaction to a NOTIFY
terminating our binding (the flow closed at once, or kept under a call and 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 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` - `Aka` — the AKAv1-MD5 digest, identity derivation, the `qmicli`/`mmcli`
text parsers, the AUTHENTICATE APDU and its response parser. text parsers, the AUTHENTICATE APDU and its response parser.
- `Ipsec` — the exact `ip xfrm` argv lists for the SA pairs, the - `Ipsec` — the exact `ip xfrm` argv lists for the SA pairs, the
Security-Server mechanism selection, the SA reader, and Security-Server mechanism selection, the SA reader, the SA peer
`OwnXfrmDeleteCommands` (our pair's eight deletes byte-exact on (`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 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, 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 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. 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` `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 (§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 netdev and two cut-down ones). The 1.0.0 arms no bench has reached: a
setup's deletes with another transport-mode user's pair in the kernel fresh registration moving from the resumed SA peer to `PCSCF=` and back
(`tests/Ipsec` only — with a VPN's tunnel policy and tunnel state there, 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
left in place through a fresh registration, they ran on the dev phone, addresses each carrying imsd's fingerprint (`tests/Ipsec` only — a VPN's
2026-10-12); `RunOwnXfrmDeletes`' line for a delete that failed (`… -> rc 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` 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 compare in canonical form (no capture
spelled unlike `LOCAL`). stages a holds file whose `pdn` is spelled unlike `LOCAL`).
- `imsd-media --selftest` round-trips the RFC 4867 payload packing (both - `imsd-media --selftest` round-trips the RFC 4867 payload packing (both
formats, both AMR codecs) and the G.711 tables. `MIC_SRC=<file>` (raw s16 formats, both AMR codecs) and the G.711 tables. `MIC_SRC=<file>` (raw s16
PCM at the codec rate, paced in real time) and `PCM_DUMP=1` are test seams 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`): 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); 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 `mmcli -m any` gives the primary SIM path and the IMEI (for the
`+sip.instance` URN); `mmcli -i <sim>` gives IMSI and MCC/MNC, from which `+sip.instance` URN); `mmcli -i <sim>` 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 registration: reconnect through the existing SA and send a refresh
re-REGISTER (`RESUME via existing SA`, then `REGISTERED (resumed + re-REGISTER (`RESUME via existing SA`, then `REGISTERED (resumed +
true-refreshed)`). This avoids the network's fresh-SA throttle. `RESUME=0` 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 forces a fresh registration where there is a fresh target; with none the
fresh registration replaces its SAs. The SA reader, the holds file's `pdn` 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 key, `LOCAL` and `PCSCF` compare addresses in RFC 5952 §4 form
(`imsd::util::CanonicalAddr`: lower-case hex, leading zeros dropped, the (`imsd::util::CanonicalAddr`: lower-case hex, leading zeros dropped, the
longest zero run compressed — what `ip xfrm` prints for a plain IPv6 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 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 accepted and, compared as text against `ip xfrm`, matched no SA and
registered fresh at every restart; an upgraded phone with such a setting 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 4. **Fresh registration** otherwise (`FRESH registration`): an unprotected
REGISTER on `PCSCF_PORT` (UDP, always), the 401 with the AKA challenge and 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 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 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 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 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 on the resume's re-REGISTER, the keepalive re-REGISTERs, the reg-event
SUBSCRIBE and INVITEs, until its next fresh registration — the one SUBSCRIBE and INVITEs, until its next fresh registration — the one
entry rebuilt from the SA entry rebuilt from the SA
(`Imsd:Ipsec`, which makes the choice): KPN's one-entry answer to the (`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 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 A 401 to a
reregistration asks for re-authentication over temporary SAs on the reregistration asks for re-authentication over temporary SAs on the
proposed ports, which imsd does not implement: it is a refusal and 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). in-use SPIs) was refused with 503 (§13).
The state file is JSON: `callid`, `ftag`, `cseq`, `spi_uc`, `spi_us`, 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, `security_server` (the registration's Security-Verify: the 401's list,
written empty when it holds a quote, a backslash or a newline; absent, 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 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 step took it); anything else on the IMS
network (its DNS servers, a traceroute target) needs its own host route network (its DNS servers, a traceroute target) needs its own host route
(`ip -6 route add <addr> dev qmapmux0.0`). imsd installs a host route per (`ip -6 route add <addr> 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 - 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 nftables when a `inet filter` table exists (pmOS's default INPUT chain drops
inbound on `qmapmux*`, which silently killed every network-initiated inbound on `qmapmux*`, which silently killed every network-initiated
@ -611,12 +684,12 @@ The policy, with its constants:
| Step | Rule | | Step | Rule |
|---|---| |---|---|
| Reconnect | Through the existing SA, then refresh. Back-off 5 s → 5 min (`ReconnectDelayMin`/`Max`). | | 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 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`). | | 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`). | | `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). | | 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. | | 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. | | 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 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 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 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 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 before the resume (a refresh's `Retry-After`) or before the fresh
registration (a fresh attempt's schedule or `Retry-After`, a terminated 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 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: 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 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 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 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 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 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 binding** triggers a fresh registration where there is a fresh target (with
`retry-after` (up to an hour); after the call, when one is up (one exception: 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 a REGISTER wait that still gets its 200 after such a NOTIFY, a call having
kept the flow, leaves that registration standing). After kept the flow, leaves that registration standing). After
`rejected` and `unregistered` it waits on the refusal schedule, a wait no `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 | | 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.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.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 | | `/run/imsd-ofonod.started` | `imsd-ofono-restart-calls.sh` | marker: imsd-ofonod has started once since boot |
| `/var/lib/imsd/imsreg.state` (`STATE_FILE`) | imsd | the registration context for a warm resume | | `/var/lib/imsd/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 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 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: …`. 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 - `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 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 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. for a bracketed value.
- `engine fatal: … — exiting for systemd restart`. - `engine fatal: … — exiting for systemd restart`.
- `reg-event SUBSCRIBE sent`, and the binding's state from NOTIFY. - `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: …`. - `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 - A refused REGISTER or SUBSCRIBE is logged with its status line and
any `Retry-After` / `Warning` / `Reason` / `Server` headers; 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 ungated write member, and do not add a root translation daemon that
forwards calls without gating its own callers through `imsd-bus`. forwards calls without gating its own callers through `imsd-bus`.
- imsd deletes by exact key what two rules select, whoever installed it. A - 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 fresh registration's SA setup and the dead pair's teardown (`RemoveDeadPair`)
esp spi`, `ip xfrm policy delete <selector> dir` — what two rules select delete — `ip xfrm state delete src dst proto esp spi`, `ip xfrm policy
(`OwnXfrmDeleteCommands`): a transport-mode policy with reqid 1 or 2 whose delete <selector> 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 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 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 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. (`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 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 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. 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 - The fixed protected ports (45061/45062) are opened in nftables for
`qmapmux*` only, by the bring-up script. `qmapmux*` only, by the bring-up script.

View file

@ -49,8 +49,8 @@ Both can be found in the packages tab:
https://forgejo.catcrafts.net/Catcrafts/-/packages/alpine/imsd https://forgejo.catcrafts.net/Catcrafts/-/packages/alpine/imsd
``` ```
Configuration is environment variables in `/etc/imsd.env`; the one required Configuration is environment variables in `/etc/imsd.env`; the one setting a fresh registration
setting is the P-CSCF: needs is the P-CSCF:
```sh ```sh
# your carrier's P-CSCF address, capture via: `adb shell dumpsys telephony.registry` on Android # 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 | | 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 | | `PCSCF_PORT` | `5060` | unprotected SIP port for the initial REGISTER |
| `DEV` | `qmapmux0.0` | ims-PDN netdev; the packaged service passes the real one via `/run/imsd.env` | | `DEV` | `qmapmux0.0` | ims-PDN netdev; the packaged service passes the real one via `/run/imsd.env` |
| `LOCAL` | *(auto)* | UE address (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 (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/<version>` | REGISTER User-Agent. Some networks fingerprint UAs; setting your device's stock build string reproduces the stock modem's registration exactly. Empty omits the header | | `USER_AGENT` | `imsd/<version>` | REGISTER User-Agent. Some networks fingerprint UAs; setting your device's stock build string reproduces the stock modem's registration exactly. Empty omits the header |
| `STATE_FILE` | `/var/lib/imsd/imsreg.state` (root) / `$XDG_STATE_HOME/imsd/imsreg.state` | persisted registration context for warm resume | | `STATE_FILE` | `/var/lib/imsd/imsreg.state` (root) / `$XDG_STATE_HOME/imsd/imsreg.state` | persisted registration context for warm resume |
| `HOLDS_FILE` | `/run/imsd.holds` (root) / `$XDG_RUNTIME_DIR/imsd.holds` (`/tmp/imsd.holds` without a runtime dir) | the waits the network imposed (a `Retry-After`, the refusal schedule), where that schedule stands and whether the old registration is gone (`old_gone`), kept across a restart of imsd for the PDN they were set on — not across a reboot; `/dev/null` switches the file off (only a regular file is ever removed; anything else is emptied). `imsd-handover.sh` honours it too | | `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) | | `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] | | `REFRESH_INTERVAL` | *(auto)* | keepalive re-REGISTER period in s; default = half the granted expiry, clamped to [120, 1800] |
| `EALG` | `aes-cbc` | offered ESP cipher (`aes-cbc`, `des-ede3-cbc`, `null`). From the 401's `Security-Server` list imsd selects the highest-preference `ipsec-3gpp` mechanism that matches this offer (RFC 3329 §2.3.1) and takes the SPIs/ports from the entry carrying them (some cores list every mechanism they support and put the SPIs on the one they applied); when no entry matches, the P-CSCF's SPI-carrying entry is installed as listed (`hmac-md5-96`, an absent `ealg` = null) and the journal says so. The line, the selection and the SA parameters are logged at every fresh registration | | `EALG` | `aes-cbc` | offered ESP cipher (`aes-cbc`, `des-ede3-cbc`, `null`). From the 401's `Security-Server` list imsd selects the highest-preference `ipsec-3gpp` mechanism that matches this offer (RFC 3329 §2.3.1) and takes the SPIs/ports from the entry carrying them (some cores list every mechanism they support and put the SPIs on the one they applied); when no entry matches, the P-CSCF's SPI-carrying entry is installed as listed (`hmac-md5-96`, an absent `ealg` = null) and the journal says so. The line, the selection and the SA parameters are logged at every fresh registration |
| `SIP_TRANSPORT` | `auto` | transport of the protected leg (the second REGISTER and everything after it): `tcp`, `udp`, or `auto` = TCP, falling back to a fresh registration over UDP when the P-CSCF leaves two TCP connects unanswered — but only on a phone that has never registered over TCP: once the state file records a TCP registration, a silent connect is treated as an outage, not a policy (delete the file or set `udp` to override, e.g. after a SIM change). The challenge (first REGISTER) is always UDP. Over UDP a REGISTER or INVITE larger than ~1.2 KB leaves as IPv6 fragments at the ims PDN's 1280-byte MTU (the journal says so once per flow); there are no SIP retransmission timers over UDP yet, so a lost datagram costs a timeout. The SAs, the listener and the firewall rule cover both transports | | `SIP_TRANSPORT` | `auto` | transport of the protected leg (the second REGISTER and everything after it): `tcp`, `udp`, or `auto` = TCP, falling back to a fresh registration over UDP when the P-CSCF leaves two TCP connects unanswered — but only on a phone that has never registered over TCP: once the state file records a TCP registration, a silent connect is treated as an outage, not a policy (delete the file or set `udp` to override, e.g. after a SIM change). The challenge (first REGISTER) is always UDP. Over UDP a REGISTER or INVITE larger than ~1.2 KB leaves as IPv6 fragments at the ims PDN's 1280-byte MTU (the journal says so once per flow); there are no SIP retransmission timers over UDP yet, so a lost datagram costs a timeout. The SAs, the listener and the firewall rule cover both transports |

View file

@ -126,6 +126,22 @@ void RunOwnXfrmDeletes(const std::vector<std::vector<std::string>>& 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:") ------- // ---- small text extractors (401 header params, qmicli "completed:") -------
std::optional<std::string> QuotedAfter(std::string_view text, std::string_view key) { std::optional<std::string> 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 // 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 // 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. // 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 ss = r.securityServer.find_first_of("\"\\\n") == std::string::npos ? r.securityServer : std::string();
std::string j = std::format( std::string j = std::format(
"{{\"callid\": \"{}\", \"ftag\": \"{}\", \"cseq\": {}, \"spi_uc\": {}, " "{{\"callid\": \"{}\", \"ftag\": \"{}\", \"cseq\": {}, \"spi_uc\": {}, "
"\"spi_us\": {}, \"expiry\": {}, \"route\": \"{}\", \"ppi\": \"{}\", " "\"spi_us\": {}, \"expiry\": {}, \"route\": \"{}\", \"ppi\": \"{}\", "
"\"aor\": \"{}\", \"contact_user\": \"{}\", \"transport\": \"{}\", " "\"aor\": \"{}\", \"contact_user\": \"{}\", \"transport\": \"{}\", "
"\"auth_nonce\": \"{}\", \"auth_cnonce\": \"{}\", \"auth_response\": \"{}\", " "\"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.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::string path = StatePath();
std::error_code ec; std::error_code ec;
std::filesystem::create_directories(std::filesystem::path(path).parent_path(), 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 { struct PersistedState {
std::optional<std::string> callid, ftag, route, ppi, aor, contactUser, transport, authNonce, authCnonce, authResponse, securityServer; std::optional<std::string> callid, ftag, route, ppi, aor, contactUser, transport, authNonce, authCnonce, authResponse, securityServer, pcscf;
std::optional<long> cseq, spiUc, spiUs, expiry; std::optional<long> cseq, spiUc, spiUs, expiry;
}; };
std::optional<PersistedState> LoadState() { std::optional<PersistedState> LoadState() {
@ -738,6 +754,7 @@ std::optional<PersistedState> LoadState() {
ps.authCnonce = QuotedAfter(s, "\"auth_cnonce\": \""); ps.authCnonce = QuotedAfter(s, "\"auth_cnonce\": \"");
ps.authResponse = QuotedAfter(s, "\"auth_response\": \""); ps.authResponse = QuotedAfter(s, "\"auth_response\": \"");
ps.securityServer = QuotedAfter(s, "\"security_server\": \""); // absent in pre-0.3.12 files 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.cseq = IntAfter(s, "\"cseq\": ");
ps.spiUc = IntAfter(s, "\"spi_uc\": "); ps.spiUc = IntAfter(s, "\"spi_uc\": ");
ps.spiUs = IntAfter(s, "\"spi_us\": "); 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); } else if (std::filesystem::exists(st)) { if (std::FILE* f = std::fopen(path.c_str(), "w")) std::fclose(f); }
return; return;
} }
std::string j = std::format("{{\"pdn\": \"{}\", \"boot\": \"{}\", \"written_at\": {}, \"hold\": {}, \"fresh_hold\": {}, \"throttle_minutes\": {}, \"old_gone\": {}}}\n", std::string j = std::format("{{\"pdn\": \"{}\", \"boot\": \"{}\", \"written_at\": {}, \"hold\": {}, \"fresh_hold\": {}, \"throttle_minutes\": {}, \"old_gone\": {}, \"refusals\": {}}}\n",
pdn, BootId(), BootSeconds(), static_cast<long>(std::ceil(h.hold)), static_cast<long>(std::ceil(h.freshHold)), h.throttleMinutes, h.oldGone ? "true" : "false"); pdn, BootId(), BootSeconds(), static_cast<long>(std::ceil(h.hold)), static_cast<long>(std::ceil(h.freshHold)), h.throttleMinutes, h.oldGone ? "true" : "false", h.refusals);
if (std::FILE* f = std::fopen(path.c_str(), "w")) { if (std::FILE* f = std::fopen(path.c_str(), "w")) {
std::fwrite(j.data(), 1, j.size(), f); std::fwrite(j.data(), 1, j.size(), f);
std::fclose(f); std::fclose(f);
@ -855,6 +872,7 @@ std::optional<imsd::engine::RegRecovery::Held> LoadHolds(const std::string& pdn)
h.freshHold = std::max<long>(0, IntAfter(s, "\"fresh_hold\": ").value_or(0) - elapsed); h.freshHold = std::max<long>(0, IntAfter(s, "\"fresh_hold\": ").value_or(0) - elapsed);
h.throttleMinutes = static_cast<int>(IntAfter(s, "\"throttle_minutes\": ").value_or(h.throttleMinutes)); h.throttleMinutes = static_cast<int>(IntAfter(s, "\"throttle_minutes\": ").value_or(h.throttleMinutes));
h.oldGone = s.find("\"old_gone\": true") != std::string::npos; h.oldGone = s.find("\"old_gone\": true") != std::string::npos;
h.refusals = static_cast<int>(IntAfter(s, "\"refusals\": ").value_or(0));
if (!h.Any()) return std::nullopt; if (!h.Any()) return std::nullopt;
return h; return h;
} }
@ -924,10 +942,11 @@ void PostEvent(std::unique_ptr<Event> ev) {
class Engine { class Engine {
public: public:
Engine() { Engine() {
// No baked-in default: the P-CSCF is carrier infrastructure, learned // The P-CSCF a fresh registration goes to: PCSCF= (no baked-in
// from the IMS PDN's PCO on a stock stack. Until PCO discovery is // default — carrier infrastructure). A resume follows the kernel's
// implemented, PCSCF= must be configured (see README). // SAs instead (TryResume), whatever this says today.
pcscf_ = imsd::util::CanonicalAddr(EnvOr("PCSCF", "")); pcscfTarget_ = imsd::util::CanonicalAddr(EnvOr("PCSCF", ""));
pcscf_ = pcscfTarget_;
pcscfPort_ = std::atoi(EnvOr("PCSCF_PORT", "5060").c_str()); pcscfPort_ = std::atoi(EnvOr("PCSCF_PORT", "5060").c_str());
dev_ = EnvOr("DEV", "qmapmux0.0"); dev_ = EnvOr("DEV", "qmapmux0.0");
outDir_ = EnvOr("OUT_DIR", StateDir()); outDir_ = EnvOr("OUT_DIR", StateDir());
@ -1111,6 +1130,20 @@ private:
} }
void KeepHolds() { PersistHolds(recovery_.Holds(Boot()), pdnLocal_); } 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, // dev/session mode without a modem: process commands so the ABI answers,
// but there is no SIP socket — a Dial collapses to terminated/error. // but there is no SIP socket — a Dial collapses to terminated/error.
@ -1123,6 +1156,13 @@ private:
// config // config
std::string pcscf_, dev_, outDir_, local_, ealgOffer_, mediaBin_; 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 // The PDN's own address (LOCAL, else DetectLocal) — every fresh
// registration's. local_ differs from it only while a resume runs on the // registration's. local_ differs from it only while a resume runs on the
// address an older registration was made on (TryResume). // 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 ---- // ---- bring-up: warm resume if an SA + state file exist, else fresh ----
void BringUp() { 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 (pcscfTarget_.contains(',')) throw std::runtime_error(std::format("PCSCF='{}' must be one address", pcscfTarget_));
if (pcscf_.contains(',')) throw std::runtime_error(std::format("PCSCF='{}' must be one address", pcscf_));
// PCSCF= is accepted in one spelling only: the one `ip` prints (RFC // PCSCF= is accepted in one spelling only: the one `ip` prints (RFC
// 5952 — what the journal and `ip xfrm` show). Another spelling is // 5952 — what the journal and `ip xfrm` show). Another spelling is
// refused with the one to use rather than normalised, so the bring-up // refused with the one to use rather than normalised, so the bring-up
@ -1248,25 +1287,62 @@ private:
std::string resume = EnvOr("RESUME", "auto"); std::string resume = EnvOr("RESUME", "auto");
// A resume that the network refuses (403 after another process // A resume that the network refuses (403 after another process
// registered fresh for the same IMPI, a stale binding, …) is not // registered fresh for the same IMPI, a stale binding, …) is not
// fatal: the fresh registration below replaces its SAs. Only a // fatal while there is a fresh target: the fresh registration below
// failed FRESH registration is. // 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 // 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 // hold from before this start is waited out before the resume (a
// refresh's Retry-After) or the fresh registration (a fresh attempt's // refresh's Retry-After) or the fresh registration (a fresh attempt's
// schedule or Retry-After, a terminated binding's wait), and so is // schedule or Retry-After, a terminated binding's wait), and so is
// one the resume itself earns — a refusal's Retry-After, a binding // one the resume itself earns — a refusal's Retry-After, a binding
// a NOTIFY terminated under it. // 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 (resume != "0") {
if (!WaitForPolicy()) throw Stopping{}; if (!WaitForPolicy()) throw Stopping{};
// a registration the network wrote off before this start (the // a registration the network wrote off before this start (the
// holds carry it) is not resumed: the wait above was the fresh one // 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) { catch (const RefusedError& e) {
Log(std::format("resume failed: {} — the next registration is a fresh one", e.what())); outcome = ResumeOutcome::Failed;
CloseFlowAfterWait(); 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 // a resume that failed found the stored SAs unusable (and after a
// reboot there are none): nothing to reconnect over while a deferred // reboot there are none): nothing to reconnect over while a deferred
@ -1275,14 +1351,36 @@ private:
portPs_ = 0; portPs_ = 0;
local_ = pdnLocal_; ctx_.local = local_; local_ = pdnLocal_; ctx_.local = local_;
KeepHolds(); KeepHolds();
pcscf_ = pcscfTarget_;
if (recovery_.Next(Boot()).at > Boot()) Log(std::format("the network's hold stands; {}", NextStep())); if (recovery_.Next(Boot()).at > Boot()) Log(std::format("the network's hold stands; {}", NextStep()));
if (!WaitForPolicy()) throw Stopping{}; if (!WaitForPolicy()) throw Stopping{};
FreshRegister(); 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 state = RunCapture({"ip", "xfrm", "state"});
auto policy = RunCapture({"ip", "xfrm", "policy"}); 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_); auto sa = imsd::ipsec::ParseExistingSa(state.out, policy.out, pcscf_, local_);
// The registration may sit on another address of the link: before // The registration may sit on another address of the link: before
// 0.3.10 imsd registered on the kernel's temporary one (ims ledger // 0.3.10 imsd registered on the kernel's temporary one (ims ledger
@ -1304,14 +1402,28 @@ private:
break; 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(); 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<std::uint32_t>(*ps->spiUc), static_cast<std::uint32_t>(*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 // The registration's transport is what the P-CSCF knows; a resume
// must reuse it. A forced policy that disagrees means a fresh start // must reuse it. A forced policy that disagrees means a fresh start
// — decided before anything from the file is adopted. // — decided before anything from the file is adopted.
Transport t = ps->transport.value_or("TCP") == "UDP" ? Transport::Udp : Transport::Tcp; 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("<sip:{};lr>", imsd::util::HostPort(pcscf_, sa->portPs))); route_ = ps->route.value_or(std::format("<sip:{};lr>", imsd::util::HostPort(pcscf_, sa->portPs)));
// No fallback identity: an empty ppi omits P-Preferred-Identity and // 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 // (ChooseResumeSecurityVerify). The rebuilt entry is KPN's answer to
// the default offer in the same bytes, not a list such as O2's six, // 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 // 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_); auto rsv = imsd::ipsec::ChooseResumeSecurityVerify(ps->securityServer.value_or(""), *sa, ealgOffer_);
ss_ = rsv.value; ss_ = rsv.value;
using Source = imsd::ipsec::ResumeSecurityVerify::Source; using Source = imsd::ipsec::ResumeSecurityVerify::Source;
@ -1353,8 +1465,8 @@ private:
// Local refusals (TIME_WAIT after a restart) keep retrying; two // Local refusals (TIME_WAIT after a restart) keep retrying; two
// silences mean the flow is not coming back — fresh instead. // silences mean the flow is not coming back — fresh instead.
if (!sip_.Connect(local_, PortUc, pcscf_, sa->portPs, transport_, 10, 2)) { if (!sip_.Connect(local_, PortUc, pcscf_, sa->portPs, transport_, 10, 2)) {
Log("resume connect failed; falling back to fresh"); Log(std::format("resume connect failed; {}", ResumeFailedNext()));
return false; return ResumeOutcome::Failed;
} }
reg_.callid = *ps->callid; reg_.callid = *ps->callid;
reg_.ftag = ps->ftag.value_or(""); reg_.ftag = ps->ftag.value_or("");
@ -1375,9 +1487,9 @@ private:
if (auto e = imsd::sip::GrantedExpires(okMsg)) reg_.expiry = *e; if (auto e = imsd::sip::GrantedExpires(okMsg)) reg_.expiry = *e;
UpdateRouteAndPpi(okMsg); UpdateRouteAndPpi(okMsg);
LogBindings(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)"); Log("REGISTERED (resumed + true-refreshed)");
return true; return ResumeOutcome::Done;
} }
// The registrar's 200 OK echoes EVERY current binding for the implicit // The registrar's 200 OK echoes EVERY current binding for the implicit
@ -1547,11 +1659,15 @@ private:
reg_.securityServer = ss_; reg_.securityServer = ss_;
reg_.expiry = imsd::sip::GrantedExpires(ok).value_or(0); reg_.expiry = imsd::sip::GrantedExpires(ok).value_or(0);
DumpRaw("imsd-register-200.raw", ok); 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("<sip:{};lr>", imsd::util::HostPort(pcscf_, portPs));
UpdateRouteAndPpi(ok); UpdateRouteAndPpi(ok);
LogBindings(ok); LogBindings(ok);
if (route_.empty()) route_ = std::format("<sip:{};lr>", imsd::util::HostPort(pcscf_, portPs));
ctx_.route = route_; ctx_.ppi = ppi_; 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)"); Log("REGISTERED (fresh)");
} }
@ -1667,7 +1783,11 @@ private:
} }
void UpdateRouteAndPpi(const std::string& okMsg) { 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()) Log("200: no Service-Route (route stays P-CSCF)");
if (!svc.empty()) { if (!svc.empty()) {
std::string r; std::string r;
@ -1749,7 +1869,7 @@ private:
auto [msg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1); auto [msg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1);
reg_.cseq = used; reg_.cseq = used;
if (auto e = imsd::sip::GrantedExpires(msg)) reg_.expiry = *e; 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)); Log(std::format("keepalive re-REGISTER ok (cseq {})", used));
OnRegistered(); OnRegistered();
} catch (const RefusedError& e) { } catch (const RefusedError& e) {
@ -1860,7 +1980,7 @@ private:
if (auto e = imsd::sip::GrantedExpires(msg)) reg_.expiry = *e; if (auto e = imsd::sip::GrantedExpires(msg)) reg_.expiry = *e;
UpdateRouteAndPpi(msg); UpdateRouteAndPpi(msg);
LogBindings(msg); LogBindings(msg);
PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport); PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport, pcscf_);
Log("reconnected + re-registered"); Log("reconnected + re-registered");
OnRegistered(); OnRegistered();
} catch (const RefusedError& e) { } catch (const RefusedError& e) {
@ -1913,7 +2033,14 @@ private:
// a registration resumed on an older address (a pre-0.3.10 temporary // 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; // one) moves to the PDN's own address here, server ports included;
// an attempt that fails before the SA setup leaves that registration // 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_; std::string from = local_;
if (local_ != pdnLocal_) { if (local_ != pdnLocal_) {
Log(std::format("the fresh registration moves from {} to the PDN's own address {}", 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())); 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, // Our address for everything from here on: the flow, the server ports,

View file

@ -103,8 +103,10 @@ export namespace imsd::engine {
// due it also says the old registration is gone (KPN drops it the // due it also says the old registration is gone (KPN drops it the
// moment an initial REGISTER arrives, throttled or not), as does a // moment an initial REGISTER arrives, throttled or not), as does a
// fresh attempt that got past the SA setup, a binding the network // fresh attempt that got past the SA setup, a binding the network
// terminated, and a resume that failed at bring-up (OldGone) — no // terminated, and a bring-up resume that failed while a fresh
// reconnect over the old SA from then on. Every wait // 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, // 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 // or the network's own word (a Retry-After, the schedule after a
// refused or throttled fresh attempt, a terminated binding's probation // refused or throttled fresh attempt, a terminated binding's probation
@ -164,16 +166,18 @@ export namespace imsd::engine {
double freshHold = 0; double freshHold = 0;
int throttleMinutes = ThrottleMinutesMin; int throttleMinutes = ThrottleMinutesMin;
bool oldGone = false; // the old registration is gone: the next process has nothing to reconnect over 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 int refusals = 0; // refused reconnects counted so far (RefusalsBeforeFresh): a restart's refusal is the next one, not the first
bool Any() const { return hold > 0 || freshHold > 0 || throttleMinutes > ThrottleMinutesMin || oldGone; } // 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 // A restarted process takes them over; nothing here shortens a
// hold it already has, or lengthens one past RetryAfterMax. // hold it already has, or lengthens one past RetryAfterMax.
void Restore(double now, const Held& h) { void Restore(double now, const Held& h) {
if (h.hold > 0) holdUntil = std::max(holdUntil, now + std::min(h.hold, static_cast<double>(RetryAfterMax))); if (h.hold > 0) holdUntil = std::max(holdUntil, now + std::min(h.hold, static_cast<double>(RetryAfterMax)));
if (h.freshHold > 0) freshHoldUntil = std::max(freshHoldUntil, now + std::min(h.freshHold, static_cast<double>(RetryAfterMax))); if (h.freshHold > 0) freshHoldUntil = std::max(freshHoldUntil, now + std::min(h.freshHold, static_cast<double>(RetryAfterMax)));
throttleMinutes = std::clamp(std::max(throttleMinutes, h.throttleMinutes), ThrottleMinutesMin, ThrottleMinutesMax); 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 if (h.oldGone) OldGone(); // no reconnect over SAs the network has written off
} }

View file

@ -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 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 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 SA pair in the kernel (`ChooseResumeSecurityVerify` decides; since 0.3.12
the 401's own list is stored there). Since 1.0.0 also the 401's own list is stored there). Since 1.0.0 also `SaPeer`: the P-CSCF
`OwnXfrmDeleteCommands`: the deletes by exact key of what two rules select — the kernel's pair is to, read from the `dir out` policies that carry imsd's
the policies carrying imsd's fingerprint and the reqid-1/2 transport-mode ESP fingerprint; `OwnXfrmDeleteCommands`, the deletes by exact key of what two
states in their direction or on the UE's ims-PDN address, whoever installed rules select — the policies carrying imsd's fingerprint and the reqid-1/2
them (§12). 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 Pure std C++ (the shell runs the commands and captures the text). Command
shapes and the parser are pinned by tests/Ipsec. shapes and the parser are pinned by tests/Ipsec.
@ -461,6 +464,19 @@ export namespace imsd::ipsec {
return r; 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<long> fileUc, std::optional<long> 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<std::uint32_t>(*fileUc) != saUc || static_cast<std::uint32_t>(*fileUs) != saUs;
}
// Every local address holding an SA to the P-CSCF, once each, in kernel // 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 // 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. // kernel's temporary one, before 0.3.10 — ims ledger F18) still lives.
@ -475,6 +491,37 @@ export namespace imsd::ipsec {
return locals; 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<std::string> SaPeer(std::string_view policyText) {
std::vector<std::string> r1;
std::vector<std::string> 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<std::string>& set = *reqid == 1 ? r1 : r2;
if (std::ranges::find(set, dst) == set.end()) set.push_back(dst);
}
std::optional<std::string> 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 // 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 // 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 // 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 // Everything else — a policy-based
// VPN's tunnel entries, another transport-mode user's on other ports and // VPN's tunnel entries, another transport-mode user's on other ports and
// addresses — stays: the fresh registration's SA setup runs these before // 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 // 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 // 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 // reqid-1/2 transport-mode ESP states in its direction, and a reqid-1/2

View file

@ -1097,6 +1097,20 @@ int main() {
n.Restore(5, m.Holds(5)); 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(!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"); 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; RegRecovery x;
x.Restore(2000, RegRecovery::Held{86400, 86400, 21}); 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"); Check(x.holdUntil == 2000 + 3600 && x.freshHoldUntil == 2000 + 3600, "a restored hold is cut to the hour like the Retry-After it came from");

View file

@ -101,6 +101,44 @@ int main() {
Check(SaLocals(State, "2001:db8::999").empty(), "SaLocals: no SA to that P-CSCF -> none"); 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 // ---- 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"; 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"); 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"); 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 // ---- 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) // spec-shaped list: every entry carries the SPI set, md5 preferred by q — we offered sha1, so sha1 wins (audit A1)