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:
parent
21cb0f870d
commit
40a960f749
7 changed files with 448 additions and 84 deletions
188
AGENTS.md
188
AGENTS.md
|
|
@ -39,7 +39,7 @@ PipeWire. The verified device/carrier combinations are in the README.
|
|||
| `interfaces/Imsd-Sip.cppm` | `imsd::sip` — SIP as a text layer plus TCP framing: header extraction, status codes, the granted lifetime, `ResponseOf` transaction matching. Not a full RFC 3261 parser, deliberately. |
|
||||
| `interfaces/Imsd-Sdp.cppm` | `imsd::sdp` — SDP offer/answer: the AMR-WB + AMR + telephone-event offer (optional IR.92 preconditions), the parser the media plane needs, `BuildAnswer` for the terminating side, the `CODECS` override. |
|
||||
| `interfaces/Imsd-Aka.cppm` | `imsd::aka` — USIM AKA glue: the AKAv1-MD5 digest (RFC 3310), IMPI/IMPU/realm from IMSI + MCC/MNC, parsers for `qmicli --uim-get-card-status` and `mmcli` output, the AUTHENTICATE APDU and its response. |
|
||||
| `interfaces/Imsd-Ipsec.cppm` | `imsd::ipsec` — the two ESP SA pairs (TS 33.203) as `ip xfrm` argv lists, the Security-Server mechanism selection, and the reader that reconstructs SPIs/ports from `ip xfrm state`/`policy` text for a warm resume, and (1.0.0) `OwnXfrmDeleteCommands`, the deletes by exact key of what two rules select — the policies carrying imsd's fingerprint and the reqid-1/2 transport-mode ESP states in their direction or on the UE's ims-PDN address, whoever installed them (§12). |
|
||||
| `interfaces/Imsd-Ipsec.cppm` | `imsd::ipsec` — the two ESP SA pairs (TS 33.203) as `ip xfrm` argv lists, the Security-Server mechanism selection, and the reader that reconstructs SPIs/ports from `ip xfrm state`/`policy` text for a warm resume, and (1.0.0) `SaPeer`, the P-CSCF the kernel's pair is to, read from the `dir out` policies that carry imsd's fingerprint, `OwnXfrmDeleteCommands`, the deletes by exact key of what two rules select — the policies carrying imsd's fingerprint and the reqid-1/2 transport-mode ESP states in their direction or on the UE's ims-PDN address, whoever installed them (§12), and `FileNamesAnotherPair`, whether a state file's `spi_uc`/`spi_us` name the kernel's pair (unknown on either side decides nothing). |
|
||||
| `interfaces/Imsd-Messages.cppm` | `imsd::msg` — every SIP request and response imsd sends, built from an explicit `Context` and per-request variables so the builders are pure functions; byte-pinned by `tests/Messages`. Also `IsEmergency`. |
|
||||
| `interfaces/Imsd-Engine.cppm` | `imsd::engine` — the decisions as pure reducers: `CallMachine` (one call's dialog, returning `Action`s for the shell) and `RegRecovery` (the registration recovery policy, §7.4). |
|
||||
| `interfaces/ImsdBus.cppm` | `imsd::bus` — the polkit gate in front of call control, shared by imsd and imsd-ofonod. GLib, so outside `imsd-core`. |
|
||||
|
|
@ -73,7 +73,9 @@ in every source but the re-exporting `Imsd.cppm`, clang + libc++ + lld.
|
|||
The rule behind the split: **`imsd-core` stays GLib-free and free of I/O so
|
||||
that every decision in it has a unit test.** New protocol logic goes into a
|
||||
partition with a test. The shell (`main.cpp`) holds protocol decisions no
|
||||
test reaches — some older than 0.3.10, some added by 0.3.10, 0.3.11, 0.3.12 or 1.0.0: the reg-event
|
||||
test reaches — some older than 0.3.10, some added by 0.3.10, 0.3.11, 0.3.12 or 1.0.0: the P-CSCF in
|
||||
use (`pcscf_`: the kernel's SA peer at a resume, `PCSCF=` at a fresh
|
||||
registration, the move between them and back — §7.2); the reg-event
|
||||
subscription schedule (§7.4: its retry
|
||||
ladder, Retry-After clamp, Timer F, renewal point and RFC 6665 reasons); the
|
||||
registration sequence and its transactions (`AwaitFinal`/`AwaitInitialFinal`:
|
||||
|
|
@ -92,13 +94,17 @@ refusals and network silence, and the catch sites that sort a failed refresh
|
|||
(`FreshRecover`, `DeferBringUp`) into Refused, Silent or Throttled with the
|
||||
status, the Retry-After and `saReplaced_`, the bring-up resume's catch (a
|
||||
refusal's Retry-After fed as a hold, the old registration written off after
|
||||
any failed resume), the `OnLink` read that writes the old registration off
|
||||
a failed resume that a fresh registration follows — with no fresh target it
|
||||
is kept for the restart to resume, unless the engine's rule ends it — a 401, a refusal with a Retry-After, the
|
||||
second refusal across the restart (the count travels in the holds), a 403
|
||||
once fresh is due, a NOTIFY-terminated binding: the pair is taken out of the
|
||||
kernel like one nothing can resume), the `OnLink` read that writes the old registration off
|
||||
when its address has left the netdev (`FreshRecover` after a failed move,
|
||||
`AddressGone`), and `PacketServiceUp`'s read of
|
||||
ModemManager before a silent cycle is counted;
|
||||
and the xfrm deletes the SA setup runs — `OwnXfrmDeleteCommands` builds them
|
||||
from the kernel text, with a test; the shell reads that text and runs them
|
||||
(§12);
|
||||
and the xfrm deletes the SA setup and the dead pair's teardown run —
|
||||
`OwnXfrmDeleteCommands` builds them from the kernel text, with a test; the
|
||||
shell reads that text and runs them (§12);
|
||||
the shell's reaction to a NOTIFY
|
||||
terminating our binding (the flow closed at once, or kept under a call and
|
||||
closed when the call ends, a reconnect cycle's or the bring-up resume's wait
|
||||
|
|
@ -227,8 +233,14 @@ What the suites pin:
|
|||
- `Aka` — the AKAv1-MD5 digest, identity derivation, the `qmicli`/`mmcli`
|
||||
text parsers, the AUTHENTICATE APDU and its response parser.
|
||||
- `Ipsec` — the exact `ip xfrm` argv lists for the SA pairs, the
|
||||
Security-Server mechanism selection, the SA reader, and
|
||||
`OwnXfrmDeleteCommands` (our pair's eight deletes byte-exact on
|
||||
Security-Server mechanism selection, the SA reader, the SA peer
|
||||
(`SaPeer`: the one address a reqid-1 and a reqid-2 `dir out` transport
|
||||
policy both name with imsd's protected port as the selector's sport — the
|
||||
fingerprint —, the pair kept beside a VPN's policies;
|
||||
a lone or disagreeing policy names no
|
||||
peer), `FileNamesAnotherPair` (the pair's own SPIs, other SPIs, one
|
||||
differing, no keys, a 0 from a missing inbound state, zeros in the file),
|
||||
and `OwnXfrmDeleteCommands` (our pair's eight deletes byte-exact on
|
||||
the fixture in the registered FP6's shape (synthetic addresses and keys), a VPN's tunnel SA and policies beside it
|
||||
untouched, another transport-mode user's pair on other ports and addresses
|
||||
untouched — its reqid-1 ESP state on the UE's address deleted, its AH state
|
||||
|
|
@ -338,14 +350,32 @@ Rules:
|
|||
socket (a silent call) — a pin naming an account with one ran, 2026-10-04.
|
||||
`DetectLocal`'s three-tier address ranking for a run without `LOCAL`
|
||||
(§10.1) ran through a fake `ip` (2026-10-05: the phone's listing of the
|
||||
netdev and two cut-down ones). The 1.0.0 arms no bench has reached: the SA
|
||||
setup's deletes with another transport-mode user's pair in the kernel
|
||||
(`tests/Ipsec` only — with a VPN's tunnel policy and tunnel state there,
|
||||
left in place through a fresh registration, they ran on the dev phone,
|
||||
2026-10-12); `RunOwnXfrmDeletes`' line for a delete that failed (`… -> rc
|
||||
netdev and two cut-down ones). The 1.0.0 arms no bench has reached: a
|
||||
fresh registration moving from the resumed SA peer to `PCSCF=` and back
|
||||
when the attempt fails before the SA setup (`FreshRecover`); the fresh-target tail of a state file another attempt left behind (its no-target tail ran on the tip's host build, bench 23f, with a file naming another SA pair; the check itself, `FileNamesAnotherPair`, is pinned in `tests/Ipsec`; the window itself is not staged on the phone); another transport-mode reqid 1 + 2 pair beside ours on other ports, and two
|
||||
addresses each carrying imsd's fingerprint (`tests/Ipsec` only — a VPN's
|
||||
tunnel policy and a transport reqid-1 policy beside the live pair, resumed
|
||||
and kept, and a lone one with
|
||||
no pair of ours, none found, ran on the dev phone, 2026-10-11/12); the SA
|
||||
setup's and the dead pair's deletes with another transport-mode user's pair
|
||||
in the kernel (`tests/Ipsec` only — with a VPN's tunnel policy and tunnel
|
||||
state there, both left in place through a fresh registration and a dead
|
||||
pair's removal, they ran on the dev phone, 2026-10-12); a no-target resume
|
||||
the network refuses (the reducer's side of the count's travel, `Held` →
|
||||
`Restore`, is pinned in `tests/Engine`; the holds file's `refusals` key —
|
||||
`PersistHolds`/`LoadHolds`, shell code — and the shell's giving the pair up
|
||||
on the engine's verdict are unbenched); `FreshRecover`'s restart for want of
|
||||
a fresh target (a resumed no-target registration whose recovery needs a
|
||||
fresh one); an empty `Service-Route` value in the 200, ignored (KPN's 200
|
||||
carries none); a no-target resume whose refresh goes
|
||||
unanswered (the connect case ran on the tip's host build, bench 23f: the
|
||||
pair kept for the restart, `the restart tries the resume again`); `RESUME=0` at a no-target start (BringUp's removal of the untried
|
||||
pair); `RemoveDeadPair`'s
|
||||
no-peer line (`N xfrm entries of imsd's own with no pair to resume:
|
||||
removing them`); `RunOwnXfrmDeletes`' line for a delete that failed (`… -> rc
|
||||
N`: an entry gone between the read and the delete); the holds file's `pdn`
|
||||
compare in canonical form (no capture stages a holds file whose `pdn` is
|
||||
spelled unlike `LOCAL`).
|
||||
compare in canonical form (no capture
|
||||
stages a holds file whose `pdn` is spelled unlike `LOCAL`).
|
||||
- `imsd-media --selftest` round-trips the RFC 4867 payload packing (both
|
||||
formats, both AMR codecs) and the G.711 tables. `MIC_SRC=<file>` (raw s16
|
||||
PCM at the codec rate, paced in real time) and `PCM_DUMP=1` are test seams
|
||||
|
|
@ -438,7 +468,22 @@ a 5-minute registration gate, connect retries). `Restart=on-failure`,
|
|||
|
||||
The daemon's own bring-up (`BringUp()` in `main.cpp`):
|
||||
|
||||
1. `PCSCF` must be set (no P-CSCF discovery from the PDN's PCO yet).
|
||||
1. `PCSCF=` is where a fresh registration goes; a resume needs none — the
|
||||
kernel's SAs name its P-CSCF (step 3). With none set, a bring-up that did
|
||||
not resume fails with `no P-CSCF for a fresh registration: …`, and since
|
||||
no fresh one follows: a resumable pair whose attempt failed (a silent
|
||||
connect, an unanswered refresh, a first refusal without a Retry-After —
|
||||
the refusal count goes to the holds) stays for the restart to resume, unless the engine's rule
|
||||
ends it — a 401, a refusal with a Retry-After or the second refusal
|
||||
(`RefusalsBeforeFresh`; the count travels in the holds, so the restart's
|
||||
refusal is the second) give the resume up, a 403 once a fresh registration
|
||||
is due or a binding a NOTIFY terminated under the attempt write the
|
||||
registration off (`old_gone`) — so the pair counts as one nothing can
|
||||
resume; a pair nothing can resume (its UE address left
|
||||
the link, no state file, a file another attempt left behind, written off before this start or by the engine during the attempt, `RESUME=0`) is taken out of the kernel first (`the SA pair to …
|
||||
is nothing to resume: removing it` — by exact key, what two rules select — the fingerprinted policies and the reqid-1/2 transport-mode ESP states in their direction or on the UE's ims-PDN address, §12), so the next bring-up finds nothing to resume; the holds
|
||||
keep the Retry-After of a refusal and, for a registration the engine wrote
|
||||
off, the write-off — a removed pair needs none.
|
||||
2. `qmicli -d qrtr://0 --uim-get-card-status` finds the ready USIM (slot, AID);
|
||||
`mmcli -m any` gives the primary SIM path and the IMEI (for the
|
||||
`+sip.instance` URN); `mmcli -i <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
|
||||
re-REGISTER (`RESUME via existing SA`, then `REGISTERED (resumed +
|
||||
true-refreshed)`). This avoids the network's fresh-SA throttle. `RESUME=0`
|
||||
forces a fresh registration. A resume the network refuses is not fatal: the
|
||||
fresh registration replaces its SAs. The SA reader, the holds file's `pdn`
|
||||
forces a fresh registration where there is a fresh target; with none the
|
||||
untried pair is taken out and the bring-up ends as step 1 says. A resume the network refuses is not fatal while there is a fresh target:
|
||||
the fresh registration follows and replaces its SAs — at once, or after
|
||||
the hold the reducer keeps (a Retry-After the refusal carries, the one
|
||||
refusal that reaches the reducer with a target; a binding a NOTIFY
|
||||
terminated under the resume; a fresh hold kept from before this start);
|
||||
with no target every refusal reaches the reducer,
|
||||
§7.4; with none it is the fatal and the
|
||||
restart (step 1). The SA reader, the holds file's `pdn`
|
||||
key, `LOCAL` and `PCSCF` compare addresses in RFC 5952 §4 form
|
||||
(`imsd::util::CanonicalAddr`: lower-case hex, leading zeros dropped, the
|
||||
longest zero run compressed — what `ip xfrm` prints for a plain IPv6
|
||||
|
|
@ -468,7 +520,22 @@ The daemon's own bring-up (`BringUp()` in `main.cpp`):
|
|||
it. Before 1.0.0 an upper-case, zero-padded or uncompressed `PCSCF=` was
|
||||
accepted and, compared as text against `ip xfrm`, matched no SA and
|
||||
registered fresh at every restart; an upgraded phone with such a setting
|
||||
stops at bring-up with the spelling to use.
|
||||
stops at bring-up with the spelling to use. A resume follows the kernel's SA peer (`SaPeer`), not
|
||||
`PCSCF=`: a registration made to another P-CSCF than today's setting is
|
||||
resumed there (`pcscf: resuming on …`) and moves at the next fresh
|
||||
registration (`the fresh registration moves from P-CSCF X to Y`; an
|
||||
attempt that fails before the SA setup goes back while the old registration
|
||||
still stands — not written off by its address leaving the link, a NOTIFY
|
||||
that terminated the binding or a 403 once a fresh registration was due:
|
||||
`back to P-CSCF X: …`).
|
||||
A state file written for another SA pair than the kernel's — its
|
||||
`spi_uc`/`spi_us` against the pair's (`FileNamesAnotherPair`), a 0.3.x
|
||||
file's too: an attempt that got through the SA setup and did not live to
|
||||
record, to this or another P-CSCF — is not used; the
|
||||
bring-up registers fresh (with no fresh target: the fatal, step 1). Every fresh registration goes to `PCSCF=`; with
|
||||
none set, a bring-up that cannot resume fails with `no P-CSCF for a fresh
|
||||
registration: …` and a recovery step that needs one restarts the daemon
|
||||
through systemd.
|
||||
4. **Fresh registration** otherwise (`FRESH registration`): an unprotected
|
||||
REGISTER on `PCSCF_PORT` (UDP, always), the 401 with the AKA challenge and
|
||||
the `Security-Server` list, the AKA run over a UIM logical channel
|
||||
|
|
@ -492,13 +559,15 @@ The daemon's own bring-up (`BringUp()` in `main.cpp`):
|
|||
0.3.12, from one whose list `PersistState` wrote empty, or from one
|
||||
whose list names another SA pair than the kernel holds (an attempt the
|
||||
process did not live to record: its SAs installed, its 200 not
|
||||
written), it sends —
|
||||
written — since 1.0.0 such a file is not resumed at all when its own
|
||||
`spi_uc`/`spi_us` are not the pair's, step 3), it sends —
|
||||
on the resume's re-REGISTER, the keepalive re-REGISTERs, the reg-event
|
||||
SUBSCRIBE and INVITEs, until its next fresh registration — the one
|
||||
entry rebuilt from the SA
|
||||
(`Imsd:Ipsec`, which makes the choice): KPN's one-entry answer to the
|
||||
default offer in the same bytes, not a list such as O2's six, which RFC 3329 §2.3.1 has the server answer with a
|
||||
494 when it does not mirror its own; a refused resume registers fresh.
|
||||
494 when it does not mirror its own; a refused resume registers fresh (with
|
||||
no fresh target: the fatal and the restart, step 1).
|
||||
A 401 to a
|
||||
reregistration asks for re-authentication over temporary SAs on the
|
||||
proposed ports, which imsd does not implement: it is a refusal and the
|
||||
|
|
@ -507,7 +576,10 @@ The daemon's own bring-up (`BringUp()` in `main.cpp`):
|
|||
in-use SPIs) was refused with 503 (§13).
|
||||
|
||||
The state file is JSON: `callid`, `ftag`, `cseq`, `spi_uc`, `spi_us`,
|
||||
`expiry`, `route`, `ppi`, `aor`, `contact_user`, `transport`, since 0.3.12
|
||||
`expiry`, `route`, `ppi`, `aor`, `contact_user`, `transport`, since 1.0.0
|
||||
`pcscf` (the P-CSCF the registration was made to; named in the line that
|
||||
reports a file another attempt left behind — the check is the SPIs',
|
||||
`FileNamesAnotherPair`), since 0.3.12
|
||||
`security_server` (the registration's Security-Verify: the 401's list,
|
||||
written empty when it holds a quote, a backslash or a newline; absent,
|
||||
empty or naming another SA pair than the kernel's — a file from before
|
||||
|
|
@ -575,7 +647,8 @@ configures the muxed netdev. Idempotent; a non-zero exit fails the unit.
|
|||
step took it); anything else on the IMS
|
||||
network (its DNS servers, a traceroute target) needs its own host route
|
||||
(`ip -6 route add <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
|
||||
nftables when a `inet filter` table exists (pmOS's default INPUT chain drops
|
||||
inbound on `qmapmux*`, which silently killed every network-initiated
|
||||
|
|
@ -611,12 +684,12 @@ The policy, with its constants:
|
|||
| Step | Rule |
|
||||
|---|---|
|
||||
| Reconnect | Through the existing SA, then refresh. Back-off 5 s → 5 min (`ReconnectDelayMin`/`Max`). |
|
||||
| Escalate to a fresh registration | After one refused refresh whose `Retry-After` is at least a second (the network's considered answer; a `Retry-After` of 0 or one that does not parse counts as none; O2 2026-10-08: the refresh refused 503 with Retry-After 414 s, the refresh after that wait 503 with Retry-After 772 s) or a 401 (re-authentication, which imsd answers with a fresh registration rather than over temporary SAs), after two refused refreshes without one (`RefusalsBeforeFresh`), or two reconnect cycles that end without an answer (`SilentCyclesBeforeFresh`) while the modem reports packet service (`PacketServiceUp`: ModemManager's registration and packet-service state, as `ims-pdn-up.sh` reads them). A cycle that fails during a coverage gap is the phone's silence, not the network's: not counted, retried on the local-refusal pace, so a radio gap across a refresh does not escalate into a fresh registration, which KPN refuses while the registration is live and drops it for (§13). New Call-ID, new challenge, new SA pair (`registering fresh (recovery attempt N)`). |
|
||||
| Escalate to a fresh registration | After one refused refresh whose `Retry-After` is at least a second (the network's considered answer; a `Retry-After` of 0 or one that does not parse counts as none; O2 2026-10-08: the refresh refused 503 with Retry-After 414 s, the refresh after that wait 503 with Retry-After 772 s) or a 401 (re-authentication, which imsd answers with a fresh registration rather than over temporary SAs), after two refused refreshes without one (`RefusalsBeforeFresh`), or two reconnect cycles that end without an answer (`SilentCyclesBeforeFresh`) while the modem reports packet service (`PacketServiceUp`: ModemManager's registration and packet-service state, as `ims-pdn-up.sh` reads them). A cycle that fails during a coverage gap is the phone's silence, not the network's: not counted, retried on the local-refusal pace, so a radio gap across a refresh does not escalate into a fresh registration, which KPN refuses while the registration is live and drops it for (§13). New Call-ID, new challenge, new SA pair (`registering fresh (recovery attempt N)`; with no fresh target the daemon restarts instead, §7.2 step 3). |
|
||||
| A refused or throttled fresh attempt | Waits on the **throttle schedule**: 21 min, growing ×1.5 to 60 (`ThrottleMinutesMin`/`Max`). KPN refuses a fresh SA pair on an address whose registration is live (§13), and drops that registration the moment an initial REGISTER arrives. |
|
||||
| A silent fresh attempt | Backs off 30 s → 10 min (`FreshDelayMin`/`Max`). |
|
||||
| `Retry-After` | Always wins over a shorter wait, honoured up to an hour (`RetryAfterMax`, the schedule's longest wait); a longer one is cut to an hour and the refusal heard again then. On a refused fresh attempt it replaces the schedule's wait, shorter or longer, never below 30 s (`FreshDelayMin`). |
|
||||
| A 403 | A refusal like the others: never hammered (the schedule), never abandoned (a SIM that is unbarred registers within the hour). |
|
||||
| The old registration is gone | A 403 once a fresh registration is due, a fresh attempt that got past the SA setup, a binding the network terminated (reg-event), an older address that left the netdev, or a bring-up whose resume failed: no reconnect over the old SA from then on. Otherwise the existing SA is still retried on the reconnect back-off while a fresh registration is deferred. |
|
||||
| The old registration is gone | A 403 once a fresh registration is due, a fresh attempt that got past the SA setup, a binding the network terminated (reg-event), an older address that left the netdev, or a bring-up whose resume failed while a fresh registration follows (with no fresh target a resumable pair whose attempt failed is kept for the restart; a pair nothing can resume — given up or written off by the engine, its address off the link, no state file, a file another attempt left behind, left untried by `RESUME=0` — is taken out of the kernel, §7.2 step 1): no reconnect over the old SA from then on. Otherwise the existing SA is still retried on the reconnect back-off while a fresh registration is deferred. |
|
||||
| A `Dial` while unregistered | Brings the next recovery step forward (the reconnect, or the fresh attempt once it is due) — never a wait the network imposed (a `Retry-After`, or the schedule after a refused or throttled fresh attempt). imsd-ofonod refuses a `Dial` then and imsd-dialerd lists no modem. |
|
||||
| Success | Resets everything. |
|
||||
|
||||
|
|
@ -628,12 +701,23 @@ address), `boot` (the boot id), `written_at` (seconds of uptime), `hold`,
|
|||
registration is gone: a 403 once a fresh registration is due, an attempt past
|
||||
the SA setup, a binding the network terminated, an older address that left
|
||||
the netdev, and every bring-up that goes on to register fresh — its resume
|
||||
failed or there was nothing to resume) — and `LoadHolds` takes them
|
||||
failed or there was nothing to resume; a bring-up with no fresh target
|
||||
writes `old_gone` when the engine's rule writes the registration off — a
|
||||
403 once a fresh registration is due (one with a Retry-After, or the second
|
||||
refusal with the count restored from the holds), a binding a NOTIFY
|
||||
terminated under the attempt — and keeps one the holds already carried,
|
||||
§7.2 step 1); the reducer's refusal count travels too (`refusals`): a resume
|
||||
refused without a Retry-After at a start with no fresh target is one
|
||||
refusal, the restart's is the second and `RefusalsBeforeFresh` gives the
|
||||
resume up — without it the restart loop would re-REGISTER on the same
|
||||
Call-ID every `RestartSec`, the F16 shape — and
|
||||
`LoadHolds` takes them
|
||||
over at the next start for the same `pdn` and boot id, waiting them out
|
||||
before the resume (a refresh's `Retry-After`) or before the fresh
|
||||
registration (a fresh attempt's schedule or `Retry-After`, a terminated
|
||||
binding's wait); with `old_gone` set the start does not resume over the old SAs at
|
||||
all, it waits the fresh hold out and registers fresh — also after a start
|
||||
all: with a fresh target it waits the fresh hold out and registers fresh,
|
||||
with none it removes the pair and ends as §7.2 step 1 says — also after a start
|
||||
during an outage, whose resume and fresh attempt both fail without an answer:
|
||||
the next start then registers fresh while the network still holds the old
|
||||
registration, which KPN refuses (§13; dev phone 2026-10-04: 21 min where
|
||||
|
|
@ -663,8 +747,9 @@ for a reason that allows renewal (RFC 6665: `deactivated`, `timeout`,
|
|||
earlier subscription's NOTIFY is still read for the binding's state, but its
|
||||
end is not ours to renew (KPN closes a registration's previous subscription
|
||||
with `noresource` when a new one replaces it). A NOTIFY that **terminates the
|
||||
binding** triggers a fresh registration — at once, or after the network's
|
||||
`retry-after` (up to an hour); after the call, when one is up (one exception:
|
||||
binding** triggers a fresh registration where there is a fresh target (with
|
||||
none the daemon restarts instead, §7.2 step 3) — at once, or after the
|
||||
network's `retry-after` (up to an hour); after the call, when one is up (one exception:
|
||||
a REGISTER wait that still gets its 200 after such a NOTIFY, a call having
|
||||
kept the flow, leaves that registration standing). After
|
||||
`rejected` and `unregistered` it waits on the refusal schedule, a wait no
|
||||
|
|
@ -786,9 +871,9 @@ account `user`, its old default.
|
|||
|
||||
| Path | Written by | Content |
|
||||
|---|---|---|
|
||||
| `/etc/imsd.env` | the admin | configuration (§10); `PCSCF=` is required. The apk adds a drop-in with `ConditionPathExists=/etc/imsd.env` (§9.2), so an unconfigured phone does not boot into a failing unit |
|
||||
| `/etc/imsd.env` | the admin | configuration (§10); `PCSCF=` is where a fresh registration goes — a resume needs none (§7.2 step 1). The apk adds a drop-in with `ConditionPathExists=/etc/imsd.env` (§9.2), so an unconfigured phone does not boot into a failing unit |
|
||||
| `/run/imsd.env` | `ims-pdn-up.sh` | `DEV=`, `LOCAL=` of the connected PDN |
|
||||
| `/run/imsd.holds` (`HOLDS_FILE`) | imsd, `imsd-handover.sh` | the network's holds and the schedule's place (§7.4) |
|
||||
| `/run/imsd.holds` (`HOLDS_FILE`) | imsd, `imsd-handover.sh` | the network's holds, the schedule's place and the reducer's refusal count (§7.4) |
|
||||
| `/run/imsd.handover` | `imsd.post-upgrade` | the InvocationID of the daemon an upgrade replaces, written when no holds file exists (§7.5); consumed by `imsd-handover.sh` |
|
||||
| `/run/imsd-ofonod.started` | `imsd-ofono-restart-calls.sh` | marker: imsd-ofonod has started once since boot |
|
||||
| `/var/lib/imsd/imsreg.state` (`STATE_FILE`) | imsd | the registration context for a warm resume |
|
||||
|
|
@ -817,6 +902,16 @@ Lines the operator (and the scripts) look for, all prefixed `imsd: `:
|
|||
until the next fresh attempt`, `X has left DEV: the registration on it is
|
||||
gone; staying on Y; next: …`, `X is no longer on DEV: the registration on
|
||||
it is gone; on the PDN's own address Y now; next: …`.
|
||||
- `pcscf: resuming on X, the registration's SA peer; the configured P-CSCF
|
||||
is Y — the next fresh registration moves there` (or `… (no P-CSCF
|
||||
configured)`), `the fresh registration moves from P-CSCF X to Y`, `back to
|
||||
P-CSCF X: the old registration stands until the next fresh attempt`,
|
||||
`no reg state file; resume falls back to fresh` and `the state file names
|
||||
another SA pair (0x…/0x…[ to X]) than the kernel's (0x…/0x… to Y): another
|
||||
attempt's; resume falls back to fresh` (` to X` only when the file records a
|
||||
P-CSCF — a pre-1.0.0 file does not; with no fresh target both end in
|
||||
`nothing can resume it: the pair is removed` instead), `no P-CSCF for a fresh
|
||||
registration: none configured (PCSCF=)`.
|
||||
- `PCSCF='…' is not written as ip prints it; use PCSCF=…` (under `bring-up
|
||||
FAILED:`) — one accepted spelling of the setting (§7.2 step 3); the
|
||||
bring-up script's `ims-pdn-up: PCSCF '…': write the bare address, as ip
|
||||
|
|
@ -824,6 +919,27 @@ Lines the operator (and the scripts) look for, all prefixed `imsd: `:
|
|||
for a bracketed value.
|
||||
- `engine fatal: … — exiting for systemd restart`.
|
||||
- `reg-event SUBSCRIBE sent`, and the binding's state from NOTIFY.
|
||||
- `the SA pair to X is nothing to resume: removing it` — a bring-up with no
|
||||
fresh target that found a pair nothing can resume (§7.2 step 1; `N xfrm
|
||||
entries of imsd's own with no pair to resume: removing them` when the
|
||||
removal finds entries of imsd's own but no pair `SaPeer` can name —
|
||||
policy-less states on the UE's address, a lone or disagreeing fingerprinted
|
||||
policy —, §12); a
|
||||
resumable pair whose attempt failed is kept for the restart (`resume
|
||||
failed: … — the restart tries the resume again`, `resume connect failed;
|
||||
the restart tries the resume again`, `state file transport differs from
|
||||
SIP_TRANSPORT; the restart tries the resume again` — the last at every
|
||||
restart while the forced transport stands; with a fresh target each ends in
|
||||
`the next registration is a fresh one`); a registration the engine's rule
|
||||
wrote off — a 403 once a fresh registration is due, a binding a NOTIFY
|
||||
terminated under the re-REGISTER wait — is removed instead (`resume failed:
|
||||
… — written off by the network: the pair is removed`; with a fresh target
|
||||
`… — written off by the network: the next registration is a fresh one`),
|
||||
and so is a resume
|
||||
the engine gives up with no fresh target — a 401, a refusal with a
|
||||
Retry-After, the second refusal, the count travelling in the holds (`resume
|
||||
failed: … — the resume is given up (the engine's rule): the pair is
|
||||
removed`).
|
||||
- `audio session: …` at start and at each call's media start; `CODECS override active: …`.
|
||||
- A refused REGISTER or SUBSCRIBE is logged with its status line and
|
||||
any `Retry-After` / `Warning` / `Reason` / `Server` headers;
|
||||
|
|
@ -1102,9 +1218,9 @@ only the imsd side of its bus choice.
|
|||
ungated write member, and do not add a root translation daemon that
|
||||
forwards calls without gating its own callers through `imsd-bus`.
|
||||
- imsd deletes by exact key what two rules select, whoever installed it. A
|
||||
fresh registration's SA setup deletes — `ip xfrm state delete src dst proto
|
||||
esp spi`, `ip xfrm policy delete <selector> dir` — what two rules select
|
||||
(`OwnXfrmDeleteCommands`): a transport-mode policy with reqid 1 or 2 whose
|
||||
fresh registration's SA setup and the dead pair's teardown (`RemoveDeadPair`)
|
||||
delete — `ip xfrm state delete src dst proto esp spi`, `ip xfrm policy
|
||||
delete <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
|
||||
fingerprint), and a transport-mode reqid-1/2 ESP state running from such a
|
||||
policy's source to its destination or anchored on the UE's ims-PDN address
|
||||
|
|
@ -1128,7 +1244,9 @@ only the imsd side of its bus choice.
|
|||
(`DUMP_SIP=0`), and never leave the phone in a commit.
|
||||
- The ims PDN is a walled garden: `ims-pdn-up.sh` keeps it from becoming
|
||||
the default route and gives the P-CSCF a host route; imsd adds one per
|
||||
call for the media peer (§7.3). Nothing else is routed over it.
|
||||
call for the media peer (§7.3) and, at a resume, one for the registration's
|
||||
SA peer when that differs from the fresh target (§7.2: the script routed
|
||||
only the configured address). Nothing else is routed over it.
|
||||
A change that routes anything else over it needs a reason and a bench.
|
||||
- The fixed protected ports (45061/45062) are opened in nftables for
|
||||
`qmapmux*` only, by the bring-up script.
|
||||
|
|
|
|||
10
README.md
10
README.md
|
|
@ -49,8 +49,8 @@ Both can be found in the packages tab:
|
|||
https://forgejo.catcrafts.net/Catcrafts/-/packages/alpine/imsd
|
||||
```
|
||||
|
||||
Configuration is environment variables in `/etc/imsd.env`; the one required
|
||||
setting is the P-CSCF:
|
||||
Configuration is environment variables in `/etc/imsd.env`; the one setting a fresh registration
|
||||
needs is the P-CSCF:
|
||||
|
||||
```sh
|
||||
# your carrier's P-CSCF address, capture via: `adb shell dumpsys telephony.registry` on Android
|
||||
|
|
@ -98,14 +98,14 @@ session and root may place, answer and end calls; an ssh login may not.
|
|||
|
||||
| Variable | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `PCSCF` | *(none — required)* | P-CSCF address (IPv6), exactly one — not a comma-separated list — in RFC 5952 §4 form (lowercase, leading zeros dropped, the longest zero run compressed, no brackets — what `ip` prints for a plain IPv6 address; an IPv4-mapped address only as hex, `::ffff:a00:1` — the dotted spelling RFC 5952 §5 and `ip` use for it is refused); upper case, leading zeros, an uncompressed run or brackets are refused at start with the spelling to use (in the packaged unit the bring-up script refuses brackets first, with the rule), a dotted-quad tail with the rule; before 1.0.0 an upper-case, zero-padded or uncompressed `PCSCF=` matched no SA in `ip xfrm`'s text and registered fresh at every restart — an upgraded phone with such a setting stops at start with the spelling to use; without it registration fails with a clear error |
|
||||
| `PCSCF` | *(none)* | P-CSCF address (IPv6), exactly one — not a comma-separated list — in RFC 5952 §4 form (lowercase, leading zeros dropped, the longest zero run compressed, no brackets — what `ip` prints for a plain IPv6 address; an IPv4-mapped address only as hex, `::ffff:a00:1` — the dotted spelling RFC 5952 §5 and `ip` use for it is refused); upper case, leading zeros, an uncompressed run or brackets are refused at start with the spelling to use (in the packaged unit the bring-up script refuses brackets first, with the rule), a dotted-quad tail with the rule; before 1.0.0 an upper-case, zero-padded or uncompressed `PCSCF=` matched no SA in `ip xfrm`'s text and registered fresh at every restart — an upgraded phone with such a setting stops at start with the spelling to use. It is where a fresh registration goes; a registration resumed from the kernel's SAs stays on the P-CSCF it was made to until its next fresh registration. Without it a fresh registration fails with a clear error |
|
||||
| `PCSCF_PORT` | `5060` | unprotected SIP port for the initial REGISTER |
|
||||
| `DEV` | `qmapmux0.0` | ims-PDN netdev; the packaged service passes the real one via `/run/imsd.env` |
|
||||
| `LOCAL` | *(auto)* | UE address (compared in RFC 5952 form). The packaged service gets the PDN address ModemManager assigned, via `/run/imsd.env` from `ims-pdn-up.sh`; a manual run prefers a global IPv6 on `DEV` that is neither temporary nor `dynamic` (RA-derived), then any non-temporary one, then the first (the kernel's temporary SLAAC address is listed first, and a registration bound to it is lost when the kernel retires it) |
|
||||
| `USER_AGENT` | `imsd/<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 |
|
||||
| `HOLDS_FILE` | `/run/imsd.holds` (root) / `$XDG_RUNTIME_DIR/imsd.holds` (`/tmp/imsd.holds` without a runtime dir) | the waits the network imposed (a `Retry-After`, the refusal schedule), where that schedule stands and whether the old registration is gone (`old_gone`), kept across a restart of imsd for the PDN they were set on — not across a reboot; `/dev/null` switches the file off (only a regular file is ever removed; anything else is emptied). `imsd-handover.sh` honours it too |
|
||||
| `RESUME` | `auto` | `0` forces a fresh registration (ignores a warm SA) |
|
||||
| `HOLDS_FILE` | `/run/imsd.holds` (root) / `$XDG_RUNTIME_DIR/imsd.holds` (`/tmp/imsd.holds` without a runtime dir) | the waits the network imposed (a `Retry-After`, the refusal schedule), where that schedule stands whether the old registration is gone (`old_gone`) and the refusal count (`refusals`: a resume refused without a Retry-After at a start with no fresh target is one, the restart's the second), kept across a restart of imsd for the PDN they were set on — not across a reboot; `/dev/null` switches the file off (only a regular file is ever removed; anything else is emptied). `imsd-handover.sh` honours it too |
|
||||
| `RESUME` | `auto` | `0` forces a fresh registration (ignores a warm SA) where there is a fresh target; with none the untried pair is removed and the bring-up ends as AGENTS §7.2 (the daemon's own bring-up) step 1 says |
|
||||
| `REFRESH_INTERVAL` | *(auto)* | keepalive re-REGISTER period in s; default = half the granted expiry, clamped to [120, 1800] |
|
||||
| `EALG` | `aes-cbc` | offered ESP cipher (`aes-cbc`, `des-ede3-cbc`, `null`). From the 401's `Security-Server` list imsd selects the highest-preference `ipsec-3gpp` mechanism that matches this offer (RFC 3329 §2.3.1) and takes the SPIs/ports from the entry carrying them (some cores list every mechanism they support and put the SPIs on the one they applied); when no entry matches, the P-CSCF's SPI-carrying entry is installed as listed (`hmac-md5-96`, an absent `ealg` = null) and the journal says so. The line, the selection and the SA parameters are logged at every fresh registration |
|
||||
| `SIP_TRANSPORT` | `auto` | transport of the protected leg (the second REGISTER and everything after it): `tcp`, `udp`, or `auto` = TCP, falling back to a fresh registration over UDP when the P-CSCF leaves two TCP connects unanswered — but only on a phone that has never registered over TCP: once the state file records a TCP registration, a silent connect is treated as an outage, not a policy (delete the file or set `udp` to override, e.g. after a SIM change). The challenge (first REGISTER) is always UDP. Over UDP a REGISTER or INVITE larger than ~1.2 KB leaves as IPv6 fragments at the ims PDN's 1280-byte MTU (the journal says so once per flow); there are no SIP retransmission timers over UDP yet, so a lost datagram costs a timeout. The SAs, the listener and the firewall rule cover both transports |
|
||||
|
|
|
|||
|
|
@ -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:") -------
|
||||
|
||||
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
|
||||
// with a quote, a backslash or a newline in it (none RFC 3329's token
|
||||
// syntax produces) is written empty, and the resume falls back to the SA.
|
||||
void PersistState(const RegState& r, const std::string& route, const std::string& ppi, const std::string& aor, const std::string& transport) {
|
||||
void PersistState(const RegState& r, const std::string& route, const std::string& ppi, const std::string& aor, const std::string& transport, const std::string& pcscf) {
|
||||
std::string ss = r.securityServer.find_first_of("\"\\\n") == std::string::npos ? r.securityServer : std::string();
|
||||
std::string j = std::format(
|
||||
"{{\"callid\": \"{}\", \"ftag\": \"{}\", \"cseq\": {}, \"spi_uc\": {}, "
|
||||
"\"spi_us\": {}, \"expiry\": {}, \"route\": \"{}\", \"ppi\": \"{}\", "
|
||||
"\"aor\": \"{}\", \"contact_user\": \"{}\", \"transport\": \"{}\", "
|
||||
"\"auth_nonce\": \"{}\", \"auth_cnonce\": \"{}\", \"auth_response\": \"{}\", "
|
||||
"\"security_server\": \"{}\"}}",
|
||||
"\"security_server\": \"{}\", \"pcscf\": \"{}\"}}",
|
||||
r.callid, r.ftag, r.cseq, r.spiUc, r.spiUs, r.expiry, route, ppi, aor,
|
||||
r.contactUser, transport, r.authNonce, r.authCnonce, r.authResponse, ss);
|
||||
r.contactUser, transport, r.authNonce, r.authCnonce, r.authResponse, ss, pcscf);
|
||||
std::string path = StatePath();
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(std::filesystem::path(path).parent_path(), ec);
|
||||
|
|
@ -716,7 +732,7 @@ void DumpRaw(std::string_view name, std::string_view msg) {
|
|||
}
|
||||
|
||||
struct PersistedState {
|
||||
std::optional<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<PersistedState> LoadState() {
|
||||
|
|
@ -738,6 +754,7 @@ std::optional<PersistedState> LoadState() {
|
|||
ps.authCnonce = QuotedAfter(s, "\"auth_cnonce\": \"");
|
||||
ps.authResponse = QuotedAfter(s, "\"auth_response\": \"");
|
||||
ps.securityServer = QuotedAfter(s, "\"security_server\": \""); // absent in pre-0.3.12 files
|
||||
ps.pcscf = QuotedAfter(s, "\"pcscf\": \""); // absent in pre-1.0.0 files
|
||||
ps.cseq = IntAfter(s, "\"cseq\": ");
|
||||
ps.spiUc = IntAfter(s, "\"spi_uc\": ");
|
||||
ps.spiUs = IntAfter(s, "\"spi_us\": ");
|
||||
|
|
@ -832,8 +849,8 @@ void PersistHolds(const imsd::engine::RegRecovery::Held& h, const std::string& p
|
|||
else if (std::filesystem::exists(st)) { if (std::FILE* f = std::fopen(path.c_str(), "w")) std::fclose(f); }
|
||||
return;
|
||||
}
|
||||
std::string j = std::format("{{\"pdn\": \"{}\", \"boot\": \"{}\", \"written_at\": {}, \"hold\": {}, \"fresh_hold\": {}, \"throttle_minutes\": {}, \"old_gone\": {}}}\n",
|
||||
pdn, BootId(), BootSeconds(), static_cast<long>(std::ceil(h.hold)), static_cast<long>(std::ceil(h.freshHold)), h.throttleMinutes, h.oldGone ? "true" : "false");
|
||||
std::string j = std::format("{{\"pdn\": \"{}\", \"boot\": \"{}\", \"written_at\": {}, \"hold\": {}, \"fresh_hold\": {}, \"throttle_minutes\": {}, \"old_gone\": {}, \"refusals\": {}}}\n",
|
||||
pdn, BootId(), BootSeconds(), static_cast<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")) {
|
||||
std::fwrite(j.data(), 1, j.size(), 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.throttleMinutes = static_cast<int>(IntAfter(s, "\"throttle_minutes\": ").value_or(h.throttleMinutes));
|
||||
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;
|
||||
return h;
|
||||
}
|
||||
|
|
@ -924,10 +942,11 @@ void PostEvent(std::unique_ptr<Event> ev) {
|
|||
class Engine {
|
||||
public:
|
||||
Engine() {
|
||||
// No baked-in default: the P-CSCF is carrier infrastructure, learned
|
||||
// from the IMS PDN's PCO on a stock stack. Until PCO discovery is
|
||||
// implemented, PCSCF= must be configured (see README).
|
||||
pcscf_ = imsd::util::CanonicalAddr(EnvOr("PCSCF", ""));
|
||||
// The P-CSCF a fresh registration goes to: PCSCF= (no baked-in
|
||||
// default — carrier infrastructure). A resume follows the kernel's
|
||||
// SAs instead (TryResume), whatever this says today.
|
||||
pcscfTarget_ = imsd::util::CanonicalAddr(EnvOr("PCSCF", ""));
|
||||
pcscf_ = pcscfTarget_;
|
||||
pcscfPort_ = std::atoi(EnvOr("PCSCF_PORT", "5060").c_str());
|
||||
dev_ = EnvOr("DEV", "qmapmux0.0");
|
||||
outDir_ = EnvOr("OUT_DIR", StateDir());
|
||||
|
|
@ -1111,6 +1130,20 @@ private:
|
|||
}
|
||||
|
||||
void KeepHolds() { PersistHolds(recovery_.Holds(Boot()), pdnLocal_); }
|
||||
// what follows a resume that failed: with a fresh target the fresh
|
||||
// registration, without one the restart's new resume attempt
|
||||
std::string ResumeFailedNext() const { return pcscfTarget_.empty() ? "the restart tries the resume again" : "the next registration is a fresh one"; }
|
||||
// a pair nothing can resume (no state file, the file another attempt's): with a fresh target the fresh registration replaces it; with none BringUp writes it off and takes it out of the kernel
|
||||
std::string ResumeDeadNext() const { return pcscfTarget_.empty() ? "nothing can resume it: the pair is removed" : "resume falls back to fresh"; }
|
||||
// the failed resume's line tail by the engine's verdict: a registration it
|
||||
// wrote off is removed (no fresh target) or replaced by the fresh one; with
|
||||
// no fresh target a resume it gives up (a 401, a refusal with a Retry-After,
|
||||
// the second refusal) is removed too; else what ResumeFailedNext says
|
||||
std::string ResumeFailedTail(const std::string& next) {
|
||||
if (!recovery_.Reconnectable()) return pcscfTarget_.empty() ? "written off by the network: the pair is removed" : "written off by the network: the next registration is a fresh one";
|
||||
if (pcscfTarget_.empty() && recovery_.Fresh()) return "the resume is given up (the engine's rule): the pair is removed";
|
||||
return next;
|
||||
}
|
||||
|
||||
// dev/session mode without a modem: process commands so the ABI answers,
|
||||
// but there is no SIP socket — a Dial collapses to terminated/error.
|
||||
|
|
@ -1123,6 +1156,13 @@ private:
|
|||
|
||||
// config
|
||||
std::string pcscf_, dev_, outDir_, local_, ealgOffer_, mediaBin_;
|
||||
// The P-CSCF a fresh registration goes to (PCSCF=). pcscf_ is the one in
|
||||
// use: the same, or the kernel's SA peer while a resumed registration
|
||||
// made to another one lives (TryResume) — the next fresh registration
|
||||
// moves to the target, and back when it fails before the SA setup
|
||||
// (FreshRecover), as local_ does against pdnLocal_.
|
||||
std::string pcscfTarget_;
|
||||
static constexpr std::string_view NoFreshPcscf = "no P-CSCF for a fresh registration: none configured (PCSCF=)";
|
||||
// The PDN's own address (LOCAL, else DetectLocal) — every fresh
|
||||
// registration's. local_ differs from it only while a resume runs on the
|
||||
// address an older registration was made on (TryResume).
|
||||
|
|
@ -1210,8 +1250,7 @@ private:
|
|||
|
||||
// ---- bring-up: warm resume if an SA + state file exist, else fresh ----
|
||||
void BringUp() {
|
||||
if (pcscf_.empty()) throw std::runtime_error("PCSCF not set — configure the carrier's P-CSCF address " "(P-CSCF discovery from the PDN's PCO is not implemented yet)");
|
||||
if (pcscf_.contains(',')) throw std::runtime_error(std::format("PCSCF='{}' must be one address", pcscf_));
|
||||
if (pcscfTarget_.contains(',')) throw std::runtime_error(std::format("PCSCF='{}' must be one address", pcscfTarget_));
|
||||
// PCSCF= is accepted in one spelling only: the one `ip` prints (RFC
|
||||
// 5952 — what the journal and `ip xfrm` show). Another spelling is
|
||||
// refused with the one to use rather than normalised, so the bring-up
|
||||
|
|
@ -1248,25 +1287,62 @@ private:
|
|||
std::string resume = EnvOr("RESUME", "auto");
|
||||
// A resume that the network refuses (403 after another process
|
||||
// registered fresh for the same IMPI, a stale binding, …) is not
|
||||
// fatal: the fresh registration below replaces its SAs. Only a
|
||||
// failed FRESH registration is.
|
||||
// fatal while there is a fresh target: the fresh registration below
|
||||
// replaces its SAs, and only a failed FRESH registration is. With no
|
||||
// fresh target the bring-up ends as §7.2 step 1 says.
|
||||
// The network's word holds at bring-up as it does in recovery: a
|
||||
// hold from before this start is waited out before the resume (a
|
||||
// refresh's Retry-After) or the fresh registration (a fresh attempt's
|
||||
// schedule or Retry-After, a terminated binding's wait), and so is
|
||||
// one the resume itself earns — a refusal's Retry-After, a binding
|
||||
// a NOTIFY terminated under it.
|
||||
ResumeOutcome outcome = ResumeOutcome::Dead; // not tried (RESUME=0, or written off before this start): a pair nothing resumes
|
||||
if (resume != "0") {
|
||||
if (!WaitForPolicy()) throw Stopping{};
|
||||
// a registration the network wrote off before this start (the
|
||||
// holds carry it) is not resumed: the wait above was the fresh one
|
||||
try { if (recovery_.Reconnectable() && TryResume()) return; }
|
||||
const std::string next = ResumeFailedNext();
|
||||
try { if (recovery_.Reconnectable()) { outcome = TryResume(); if (outcome == ResumeOutcome::Done) return; } }
|
||||
catch (const RefusedError& e) {
|
||||
Log(std::format("resume failed: {} — the next registration is a fresh one", e.what()));
|
||||
outcome = ResumeOutcome::Failed;
|
||||
CloseFlowAfterWait();
|
||||
if (e.retryAfter > 0) recovery_.OnReconnectFailure(Boot(), imsd::engine::RegRecovery::Failure::Refused, e.retryAfter, e.status);
|
||||
// the network's word (a Retry-After) reaches the reducer as before;
|
||||
// with no fresh target every refusal does — its count travels in
|
||||
// the holds, so a restart's refusal is the second (§7.4). With a
|
||||
// target a refusal without one feeds nothing: the fresh
|
||||
// registration follows at once, not after a self-imposed backoff
|
||||
// the fresh path would log as the network's hold
|
||||
if (e.retryAfter > 0 || pcscfTarget_.empty()) recovery_.OnReconnectFailure(Boot(), imsd::engine::RegRecovery::Failure::Refused, e.retryAfter, e.status);
|
||||
Log(std::format("resume failed: {} — {}", e.what(), ResumeFailedTail(next)));
|
||||
}
|
||||
catch (const std::exception& e) { Log(std::format("resume failed: {} — the next registration is a fresh one", e.what())); CloseFlowAfterWait(); }
|
||||
catch (const std::exception& e) { outcome = ResumeOutcome::Failed; CloseFlowAfterWait(); Log(std::format("resume failed: {} — {}", e.what(), ResumeFailedTail(next))); }
|
||||
// the engine's verdict decides, in one place for both arms: a
|
||||
// registration it wrote off (a 403 once a fresh registration is
|
||||
// due; a binding a NOTIFY terminated under the re-REGISTER wait,
|
||||
// Imsd-Engine.cppm) is nothing to resume any more, and so, with no
|
||||
// fresh target, is one whose resume it gives up (a 401, a refusal
|
||||
// with a Retry-After, the second refusal — RefusalsBeforeFresh,
|
||||
// the count surviving the restart): Dead, taken out below. With a
|
||||
// target the fresh registration follows either way.
|
||||
if (outcome == ResumeOutcome::Failed && (!recovery_.Reconnectable() || (pcscfTarget_.empty() && recovery_.Fresh()))) outcome = ResumeOutcome::Dead;
|
||||
}
|
||||
if (pcscfTarget_.empty()) {
|
||||
// No fresh registration follows, so this is no write-off: a
|
||||
// resumable pair whose attempt failed (a silent connect, an
|
||||
// unanswered or refused refresh — its Retry-After is in the
|
||||
// holds) stays for the restart to resume — unless the engine's
|
||||
// rule wrote the registration off or gave the resume up (a 403 once
|
||||
// fresh is due, a NOTIFY-terminated binding; a 401, a refusal with a
|
||||
// Retry-After, the second refusal — the count travels in the holds;
|
||||
// Dead above); a pair nothing can
|
||||
// resume (its UE address left the link, no state file, the file
|
||||
// another attempt's, written off before this start) is taken out
|
||||
// of the kernel, by exact key — the fresh registration that would
|
||||
// have replaced it does not come — so the next bring-up finds
|
||||
// nothing to resume.
|
||||
if (outcome == ResumeOutcome::Dead) RemoveDeadPair(pdnLocal_);
|
||||
KeepHolds();
|
||||
throw std::runtime_error(std::string(NoFreshPcscf));
|
||||
}
|
||||
// a resume that failed found the stored SAs unusable (and after a
|
||||
// reboot there are none): nothing to reconnect over while a deferred
|
||||
|
|
@ -1275,14 +1351,36 @@ private:
|
|||
portPs_ = 0;
|
||||
local_ = pdnLocal_; ctx_.local = local_;
|
||||
KeepHolds();
|
||||
pcscf_ = pcscfTarget_;
|
||||
if (recovery_.Next(Boot()).at > Boot()) Log(std::format("the network's hold stands; {}", NextStep()));
|
||||
if (!WaitForPolicy()) throw Stopping{};
|
||||
FreshRegister();
|
||||
}
|
||||
|
||||
bool TryResume() {
|
||||
// What a bring-up's resume attempt found: the registration (resumed),
|
||||
// no pair of ours in the kernel, a pair nothing can resume, or a
|
||||
// resumable pair whose attempt failed — worth a retry.
|
||||
enum class ResumeOutcome { Done, None, Dead, Failed };
|
||||
|
||||
ResumeOutcome TryResume() {
|
||||
auto state = RunCapture({"ip", "xfrm", "state"});
|
||||
auto policy = RunCapture({"ip", "xfrm", "policy"});
|
||||
// The registration was made to whatever P-CSCF the kernel's SAs name
|
||||
// (the SA setup takes imsd's previous pair out), whatever PCSCF= says
|
||||
// today: resume there and move at the next fresh registration — a
|
||||
// fresh one now would cost the registration, on KPN with the
|
||||
// 21-minute refusal. A route to it exists while the netdev lives;
|
||||
// the replace covers a target that changed under a running phone.
|
||||
auto peer = imsd::ipsec::SaPeer(policy.out);
|
||||
if (!peer) return ResumeOutcome::None;
|
||||
bool routePeer = false;
|
||||
if (*peer != pcscf_) {
|
||||
Log(pcscfTarget_.empty()
|
||||
? std::format("pcscf: resuming on {}, the registration's SA peer (no P-CSCF configured)", *peer)
|
||||
: std::format("pcscf: resuming on {}, the registration's SA peer; the configured P-CSCF is {} — the next fresh registration moves there", *peer, pcscfTarget_));
|
||||
pcscf_ = *peer;
|
||||
routePeer = true; // routed below, once the SAs are confirmed: a peer named by a pair that is not ours gets no route into the PDN
|
||||
}
|
||||
auto sa = imsd::ipsec::ParseExistingSa(state.out, policy.out, pcscf_, local_);
|
||||
// The registration may sit on another address of the link: before
|
||||
// 0.3.10 imsd registered on the kernel's temporary one (ims ledger
|
||||
|
|
@ -1304,14 +1402,28 @@ private:
|
|||
break;
|
||||
}
|
||||
}
|
||||
if (!sa) return false;
|
||||
if (!sa) { Log(std::format("the SA pair to {} has no SA on an address of {}: nothing to resume", pcscf_, dev_)); return ResumeOutcome::Dead; }
|
||||
if (routePeer) RunCmd({"ip", Is6(pcscf_) ? "-6" : "-4", "route", "replace", pcscf_, "dev", dev_});
|
||||
auto ps = LoadState();
|
||||
if (!ps || !ps->callid) { Log("no reg state file; resume falls back to fresh"); return false; }
|
||||
if (!ps || !ps->callid) { Log(std::format("no reg state file; {}", ResumeDeadNext())); return ResumeOutcome::Dead; }
|
||||
// A file another attempt left behind — one that got through the SA
|
||||
// setup and did not live to record, to this or another P-CSCF — names
|
||||
// another SA pair than the kernel's (its spi_uc/spi_us; a 0.3.x file
|
||||
// carries them too): its dialog is that attempt's, and a Call-ID
|
||||
// cannot be rebuilt from an SA (the Security-Verify can —
|
||||
// ChooseResumeSecurityVerify).
|
||||
if (imsd::ipsec::FileNamesAnotherPair(ps->spiUc, ps->spiUs, sa->spiUc, sa->spiUs)) {
|
||||
Log(std::format("the state file names another SA pair ({:#x}/{:#x}{}) than the kernel's ({:#x}/{:#x} to {}): another attempt's; {}",
|
||||
static_cast<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
|
||||
// must reuse it. A forced policy that disagrees means a fresh start
|
||||
// — decided before anything from the file is adopted.
|
||||
Transport t = ps->transport.value_or("TCP") == "UDP" ? Transport::Udp : Transport::Tcp;
|
||||
if (sipPolicy_ != "auto" && t != transport_) { Log("state file transport differs from SIP_TRANSPORT; fresh"); return false; }
|
||||
if (sipPolicy_ != "auto" && t != transport_) { Log(std::format("state file transport differs from SIP_TRANSPORT; {}", ResumeFailedNext())); return ResumeOutcome::Failed; }
|
||||
|
||||
route_ = ps->route.value_or(std::format("<sip:{};lr>", imsd::util::HostPort(pcscf_, sa->portPs)));
|
||||
// No fallback identity: an empty ppi omits P-Preferred-Identity and
|
||||
|
|
@ -1324,7 +1436,7 @@ private:
|
|||
// (ChooseResumeSecurityVerify). The rebuilt entry is KPN's answer to
|
||||
// the default offer in the same bytes, not a list such as O2's six,
|
||||
// which the RFC has the server answer with a 494; a refused resume
|
||||
// registers fresh.
|
||||
// registers fresh where there is a fresh target (§7.2 step 1 otherwise).
|
||||
auto rsv = imsd::ipsec::ChooseResumeSecurityVerify(ps->securityServer.value_or(""), *sa, ealgOffer_);
|
||||
ss_ = rsv.value;
|
||||
using Source = imsd::ipsec::ResumeSecurityVerify::Source;
|
||||
|
|
@ -1353,8 +1465,8 @@ private:
|
|||
// Local refusals (TIME_WAIT after a restart) keep retrying; two
|
||||
// silences mean the flow is not coming back — fresh instead.
|
||||
if (!sip_.Connect(local_, PortUc, pcscf_, sa->portPs, transport_, 10, 2)) {
|
||||
Log("resume connect failed; falling back to fresh");
|
||||
return false;
|
||||
Log(std::format("resume connect failed; {}", ResumeFailedNext()));
|
||||
return ResumeOutcome::Failed;
|
||||
}
|
||||
reg_.callid = *ps->callid;
|
||||
reg_.ftag = ps->ftag.value_or("");
|
||||
|
|
@ -1375,9 +1487,9 @@ private:
|
|||
if (auto e = imsd::sip::GrantedExpires(okMsg)) reg_.expiry = *e;
|
||||
UpdateRouteAndPpi(okMsg);
|
||||
LogBindings(okMsg);
|
||||
PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport);
|
||||
PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport, pcscf_);
|
||||
Log("REGISTERED (resumed + true-refreshed)");
|
||||
return true;
|
||||
return ResumeOutcome::Done;
|
||||
}
|
||||
|
||||
// The registrar's 200 OK echoes EVERY current binding for the implicit
|
||||
|
|
@ -1547,11 +1659,15 @@ private:
|
|||
reg_.securityServer = ss_;
|
||||
reg_.expiry = imsd::sip::GrantedExpires(ok).value_or(0);
|
||||
DumpRaw("imsd-register-200.raw", ok);
|
||||
// The Route for everything over this registration is the P-CSCF it
|
||||
// was made at — never a previous one's, after a move from the
|
||||
// resumed peer — unless the 200 carries a Service-Route, which
|
||||
// UpdateRouteAndPpi puts in its place.
|
||||
route_ = std::format("<sip:{};lr>", imsd::util::HostPort(pcscf_, portPs));
|
||||
UpdateRouteAndPpi(ok);
|
||||
LogBindings(ok);
|
||||
if (route_.empty()) route_ = std::format("<sip:{};lr>", imsd::util::HostPort(pcscf_, portPs));
|
||||
ctx_.route = route_; ctx_.ppi = ppi_;
|
||||
PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport);
|
||||
PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport, pcscf_);
|
||||
Log("REGISTERED (fresh)");
|
||||
}
|
||||
|
||||
|
|
@ -1667,7 +1783,11 @@ private:
|
|||
}
|
||||
|
||||
void UpdateRouteAndPpi(const std::string& okMsg) {
|
||||
auto svc = imsd::sip::Headers(okMsg, "Service-Route");
|
||||
// an empty Service-Route value carries no route: ignored like a
|
||||
// missing header, so the fallback Route the caller set stands
|
||||
auto svcAll = imsd::sip::Headers(okMsg, "Service-Route");
|
||||
decltype(svcAll) svc;
|
||||
for (auto& v : svcAll) if (!v.empty()) svc.push_back(v);
|
||||
if (svc.empty()) Log("200: no Service-Route (route stays P-CSCF)");
|
||||
if (!svc.empty()) {
|
||||
std::string r;
|
||||
|
|
@ -1749,7 +1869,7 @@ private:
|
|||
auto [msg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1);
|
||||
reg_.cseq = used;
|
||||
if (auto e = imsd::sip::GrantedExpires(msg)) reg_.expiry = *e;
|
||||
PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport);
|
||||
PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport, pcscf_);
|
||||
Log(std::format("keepalive re-REGISTER ok (cseq {})", used));
|
||||
OnRegistered();
|
||||
} catch (const RefusedError& e) {
|
||||
|
|
@ -1860,7 +1980,7 @@ private:
|
|||
if (auto e = imsd::sip::GrantedExpires(msg)) reg_.expiry = *e;
|
||||
UpdateRouteAndPpi(msg);
|
||||
LogBindings(msg);
|
||||
PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport);
|
||||
PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport, pcscf_);
|
||||
Log("reconnected + re-registered");
|
||||
OnRegistered();
|
||||
} catch (const RefusedError& e) {
|
||||
|
|
@ -1913,7 +2033,14 @@ private:
|
|||
// a registration resumed on an older address (a pre-0.3.10 temporary
|
||||
// one) moves to the PDN's own address here, server ports included;
|
||||
// an attempt that fails before the SA setup leaves that registration
|
||||
// standing, so it moves back to it
|
||||
// standing, so it moves back to it — the P-CSCF too, once the address
|
||||
// verdict below says the registration still stands
|
||||
if (pcscfTarget_.empty()) { RequestRestart(std::string(NoFreshPcscf)); return; }
|
||||
std::string fromPcscf = pcscf_;
|
||||
if (pcscf_ != pcscfTarget_) {
|
||||
Log(std::format("the fresh registration moves from P-CSCF {} to {}", pcscf_, pcscfTarget_));
|
||||
pcscf_ = pcscfTarget_;
|
||||
}
|
||||
std::string from = local_;
|
||||
if (local_ != pdnLocal_) {
|
||||
Log(std::format("the fresh registration moves from {} to the PDN's own address {}", local_, pdnLocal_));
|
||||
|
|
@ -1944,6 +2071,12 @@ private:
|
|||
Log(std::format("{} has left {}: the registration on it is gone; staying on {}; {}", from, dev_, local_, NextStep()));
|
||||
}
|
||||
}
|
||||
// after the address verdict: a registration just declared gone has
|
||||
// no P-CSCF to go back to
|
||||
if (!ok && !quit_.load() && recovery_.Reconnectable() && pcscf_ != fromPcscf) {
|
||||
Log(std::format("back to P-CSCF {}: the old registration stands until the next fresh attempt", fromPcscf));
|
||||
pcscf_ = fromPcscf;
|
||||
}
|
||||
}
|
||||
|
||||
// Our address for everything from here on: the flow, the server ports,
|
||||
|
|
|
|||
|
|
@ -103,8 +103,10 @@ export namespace imsd::engine {
|
|||
// due it also says the old registration is gone (KPN drops it the
|
||||
// moment an initial REGISTER arrives, throttled or not), as does a
|
||||
// fresh attempt that got past the SA setup, a binding the network
|
||||
// terminated, and a resume that failed at bring-up (OldGone) — no
|
||||
// reconnect over the old SA from then on. Every wait
|
||||
// terminated, and a bring-up resume that failed while a fresh
|
||||
// registration follows (OldGone; with no fresh target a resumable pair
|
||||
// is kept for the restart, §7.2 step 1) — no reconnect over the old SA
|
||||
// from then on. Every wait
|
||||
// is either the self-imposed backoff, which a user's Dial cuts short,
|
||||
// or the network's own word (a Retry-After, the schedule after a
|
||||
// refused or throttled fresh attempt, a terminated binding's probation
|
||||
|
|
@ -164,16 +166,18 @@ export namespace imsd::engine {
|
|||
double freshHold = 0;
|
||||
int throttleMinutes = ThrottleMinutesMin;
|
||||
bool oldGone = false; // the old registration is gone: the next process has nothing to reconnect over
|
||||
// a schedule that has advanced is kept too, until a success resets it, and so is the gone verdict
|
||||
bool Any() const { return hold > 0 || freshHold > 0 || throttleMinutes > ThrottleMinutesMin || oldGone; }
|
||||
int refusals = 0; // refused reconnects counted so far (RefusalsBeforeFresh): a restart's refusal is the next one, not the first
|
||||
// a schedule that has advanced is kept too, until a success resets it, and so are the gone verdict and the count
|
||||
bool Any() const { return hold > 0 || freshHold > 0 || throttleMinutes > ThrottleMinutesMin || oldGone || refusals > 0; }
|
||||
};
|
||||
Held Holds(double now) const { return {std::max(0.0, holdUntil - now), std::max(0.0, freshHoldUntil - now), throttleMinutes, oldGone}; }
|
||||
Held Holds(double now) const { return {std::max(0.0, holdUntil - now), std::max(0.0, freshHoldUntil - now), throttleMinutes, oldGone, refusals}; }
|
||||
// A restarted process takes them over; nothing here shortens a
|
||||
// hold it already has, or lengthens one past RetryAfterMax.
|
||||
void Restore(double now, const Held& h) {
|
||||
if (h.hold > 0) holdUntil = std::max(holdUntil, now + std::min(h.hold, static_cast<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);
|
||||
refusals = std::max(refusals, h.refusals);
|
||||
if (h.oldGone) OldGone(); // no reconnect over SAs the network has written off
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,11 +19,14 @@ SPIs in use, which a reregistration's new SPIs must differ from (and the
|
|||
state file falls back to) — and a one-mechanism Security-Server value that
|
||||
stands in for the Security-Verify when the state file holds no list for the
|
||||
SA pair in the kernel (`ChooseResumeSecurityVerify` decides; since 0.3.12
|
||||
the 401's own list is stored there). Since 1.0.0 also
|
||||
`OwnXfrmDeleteCommands`: the deletes by exact key of what two rules select —
|
||||
the policies carrying imsd's fingerprint and the reqid-1/2 transport-mode ESP
|
||||
states in their direction or on the UE's ims-PDN address, whoever installed
|
||||
them (§12).
|
||||
the 401's own list is stored there). Since 1.0.0 also `SaPeer`: the P-CSCF
|
||||
the kernel's pair is to, read from the `dir out` policies that carry imsd's
|
||||
fingerprint; `OwnXfrmDeleteCommands`, the deletes by exact key of what two
|
||||
rules select — the policies carrying imsd's fingerprint and the reqid-1/2
|
||||
transport-mode ESP states in their direction or on the UE's ims-PDN address,
|
||||
whoever installed them (§12); and `FileNamesAnotherPair`, whether a state
|
||||
file's spi_uc/spi_us name the pair the kernel holds (unknown on either side
|
||||
decides nothing).
|
||||
|
||||
Pure std C++ (the shell runs the commands and captures the text). Command
|
||||
shapes and the parser are pinned by tests/Ipsec.
|
||||
|
|
@ -461,6 +464,19 @@ export namespace imsd::ipsec {
|
|||
return r;
|
||||
}
|
||||
|
||||
// Whether a state file was written for the SA pair the kernel holds: the
|
||||
// file's spi_uc/spi_us (the UE's inbound SPIs — drawn per fresh attempt,
|
||||
// kept by a re-REGISTER, written back by a resume) against the pair's. A
|
||||
// file another attempt left behind — one that got through the SA setup
|
||||
// and did not live to record, to this or another P-CSCF — names another
|
||||
// pair, and its dialog is not this pair's. Unknown on either side (a
|
||||
// file from before the keys, a 0 from a missing inbound state) decides
|
||||
// nothing.
|
||||
bool FileNamesAnotherPair(std::optional<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
|
||||
// order: where a registration made on another address of the link (the
|
||||
// kernel's temporary one, before 0.3.10 — ims ledger F18) still lives.
|
||||
|
|
@ -475,6 +491,37 @@ export namespace imsd::ipsec {
|
|||
return locals;
|
||||
}
|
||||
|
||||
// The P-CSCF the kernel's SAs are to: the one address that a `dir out`
|
||||
// policy of imsd's own with reqid 1 and one with reqid 2 both name
|
||||
// (detail::OwnPolicy: transport mode, the fixed protected port as the
|
||||
// selector's sport — reqid and mode alone are anyone's) — exactly the pair
|
||||
// BuildSetupCommands installs. The state text cannot
|
||||
// tell the two ends apart (the UE is a `dst` there too). A foreign policy
|
||||
// beside the pair (a VPN's: tunnel mode, another destination, whatever
|
||||
// its reqid) changes nothing; a lone policy is not a registration, and
|
||||
// two addresses each carrying both reqids are not one pair to tell
|
||||
// apart: nullopt then, and when there are none.
|
||||
std::optional<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
|
||||
// out of the kernel as the two texts show it: every policy
|
||||
// of imsd's own (detail::OwnPolicy — transport mode, reqid 1 or 2, the
|
||||
|
|
@ -489,7 +536,8 @@ export namespace imsd::ipsec {
|
|||
// Everything else — a policy-based
|
||||
// VPN's tunnel entries, another transport-mode user's on other ports and
|
||||
// addresses — stays: the fresh registration's SA setup runs these before
|
||||
// BuildSetupCommands' adds, and flushes nothing (§12). Residual: a
|
||||
// BuildSetupCommands' adds, the dead pair's teardown (RemoveDeadPair,
|
||||
// main.cpp) runs them alone, and neither flushes (§12). Residual: a
|
||||
// transport-mode policy with reqid 1 and the UE-side port 45061, or reqid
|
||||
// 2 and 45062, alone or paired, carries the fingerprint and goes with the
|
||||
// reqid-1/2 transport-mode ESP states in its direction, and a reqid-1/2
|
||||
|
|
|
|||
|
|
@ -1097,6 +1097,20 @@ int main() {
|
|||
n.Restore(5, m.Holds(5));
|
||||
Check(!n.Reconnectable() && freshAt(n, 5, 0), "…so the next start registers fresh at once instead of resuming");
|
||||
Check(!RegRecovery::Held{0, 0, 21, false}.Any(), "nothing gone, nothing held: nothing to keep");
|
||||
// the refusal count travels: a resume refused without a Retry-After at a start with no fresh target is one
|
||||
// refusal, the restart's is the second — without the travel every restart would be the first (F16's loop)
|
||||
RegRecovery rq;
|
||||
rq.OnReconnectFailure(10, F::Refused, 0, 503);
|
||||
auto hq = rq.Holds(20);
|
||||
Check(hq.Any() && hq.refusals == 1 && !hq.oldGone && hq.hold == 0, "a refusal without a Retry-After is held as a count, nothing else");
|
||||
RegRecovery sq;
|
||||
sq.Restore(100, hq);
|
||||
Check(!sq.Fresh() && sq.Reconnectable(), "the count alone gives nothing up");
|
||||
sq.OnReconnectFailure(100, F::Refused, 0, 503);
|
||||
Check(sq.Fresh() && sq.Reconnectable(), "the second refusal, across the restart, gives the resume up (fresh) without writing the registration off");
|
||||
RegRecovery tq;
|
||||
tq.OnReconnectFailure(10, F::Refused, 0, 401);
|
||||
Check(tq.Fresh() && tq.Reconnectable() && tq.Holds(20).refusals == 1, "a 401 gives the resume up at once; its count travels too");
|
||||
RegRecovery x;
|
||||
x.Restore(2000, RegRecovery::Held{86400, 86400, 21});
|
||||
Check(x.holdUntil == 2000 + 3600 && x.freshHoldUntil == 2000 + 3600, "a restored hold is cut to the hour like the Retry-After it came from");
|
||||
|
|
|
|||
|
|
@ -101,6 +101,44 @@ int main() {
|
|||
Check(SaLocals(State, "2001:db8::999").empty(), "SaLocals: no SA to that P-CSCF -> none");
|
||||
}
|
||||
|
||||
// ---- SaPeer: the P-CSCF the kernel's policies point at
|
||||
{
|
||||
auto peer = SaPeer(Policy);
|
||||
Check(peer && *peer == "2001:db8::105", "SaPeer: the dir-out policies' dst with reqid 1 and 2 agreeing");
|
||||
Check(!SaPeer("").has_value(), "SaPeer: no policies -> none");
|
||||
// a VPN's: tunnel mode, reqid 1, another destination — alone, and beside our pair
|
||||
constexpr std::string_view Vpn = "src 2001:db8::1/128 dst 2001:db8::2/128 \n\tdir out priority 0 \n\ttmpl src 2001:db8::1 dst 2001:db8::2\n\t\tproto esp reqid 1 mode tunnel\n";
|
||||
constexpr std::string_view Vpn4 = "src 192.0.2.10/32 dst 0.0.0.0/0 \n\tdir out priority 383615 ptype main \n\ttmpl src 192.0.2.10 dst 192.0.2.1\n\t\tproto esp reqid 1 mode tunnel\n";
|
||||
Check(!SaPeer(Vpn).has_value(), "SaPeer: a lone reqid-1 policy (a VPN's) is not ours");
|
||||
auto beside = SaPeer(std::string(Policy) + std::string(Vpn));
|
||||
Check(beside && *beside == "2001:db8::105", "SaPeer: our pair beside a VPN's reqid-1 tunnel policy -> ours");
|
||||
auto beside4 = SaPeer(std::string(Vpn4) + std::string(Policy));
|
||||
Check(beside4 && *beside4 == "2001:db8::105", "SaPeer: our pair beside an IPv4 VPN's policy to 0.0.0.0/0 -> ours");
|
||||
// a transport policy to another address beside the pair: one address still carries both reqids
|
||||
const std::string other2 = "src 2001:db8:1111:2222:3333:4444:5555:db43/128 dst 2001:db8::106/128 sport 1 dport 2 \n\tdir out priority 0 \n\ttmpl src 2001:db8:1111:2222:3333:4444:5555:db43 dst 2001:db8::106\n\t\tproto esp reqid 2 mode transport\n";
|
||||
const std::string other1 = "src 2001:db8:1111:2222:3333:4444:5555:db43/128 dst 2001:db8::106/128 sport 3 dport 4 \n\tdir out priority 0 \n\ttmpl src 2001:db8:1111:2222:3333:4444:5555:db43 dst 2001:db8::106\n\t\tproto esp reqid 1 mode transport\n";
|
||||
auto one = SaPeer(std::string(Policy) + other2);
|
||||
Check(one && *one == "2001:db8::105", "SaPeer: a reqid-2 policy to another address beside the pair -> the pair's");
|
||||
Check(SaPeer(std::string(Policy) + other2 + other1) == "2001:db8::105", "SaPeer: another transport-mode reqid 1 + 2 pair beside ours, on other ports -> ours (the protected ports are the fingerprint)");
|
||||
Check(!SaPeer(other2 + other1).has_value(), "SaPeer: a transport-mode reqid 1 + 2 pair on other ports alone -> none (not imsd's)");
|
||||
const std::string ours2 = "src 2001:db8:1111:2222:3333:4444:5555:db43/128 dst 2001:db8::106/128 sport 45062 dport 2 \n\tdir out priority 0 \n\ttmpl src 2001:db8:1111:2222:3333:4444:5555:db43 dst 2001:db8::106\n\t\tproto esp reqid 2 mode transport\n"
|
||||
"src 2001:db8:1111:2222:3333:4444:5555:db43/128 dst 2001:db8::106/128 sport 45061 dport 4 \n\tdir out priority 0 \n\ttmpl src 2001:db8:1111:2222:3333:4444:5555:db43 dst 2001:db8::106\n\t\tproto esp reqid 1 mode transport\n";
|
||||
Check(!SaPeer(std::string(Policy) + ours2).has_value(), "SaPeer: two addresses each carrying imsd's fingerprint -> none (not one pair to tell apart)");
|
||||
// the fingerprint alone is not a pair: a reqid-1 policy without its reqid-2 partner, and two fingerprinted policies naming different addresses
|
||||
const std::string lone1 = "src 2001:db8:1111:2222:3333:4444:5555:db43/128 dst 2001:db8::107/128 sport 45061 dport 4 \n\tdir out priority 0 \n\ttmpl src 2001:db8:1111:2222:3333:4444:5555:db43 dst 2001:db8::107\n\t\tproto esp reqid 1 mode transport\n";
|
||||
const std::string apart2 = "src 2001:db8:1111:2222:3333:4444:5555:db43/128 dst 2001:db8::108/128 sport 45062 dport 2 \n\tdir out priority 0 \n\ttmpl src 2001:db8:1111:2222:3333:4444:5555:db43 dst 2001:db8::108\n\t\tproto esp reqid 2 mode transport\n";
|
||||
Check(!SaPeer(lone1).has_value(), "SaPeer: a lone fingerprinted reqid-1 policy (no reqid-2 partner) -> none");
|
||||
Check(!SaPeer(lone1 + apart2).has_value(), "SaPeer: a fingerprinted reqid-1 policy to one address and a fingerprinted reqid-2 policy to another -> none (not one pair)");
|
||||
Check(SaPeer(std::string(Policy) + apart2) == "2001:db8::105", "SaPeer: a fingerprinted reqid-2 policy to another address beside our pair -> the pair's");
|
||||
Check(!SaPeer(std::string(Vpn) + "src 2001:db8::1/128 dst 2001:db8::2/128 \n\tdir out priority 0 \n\ttmpl src 2001:db8::1 dst 2001:db8::2\n\t\tproto esp reqid 2 mode tunnel\n").has_value(), "SaPeer: a tunnel-mode reqid 1 + 2 pair (a VPN's two child SAs) -> none");
|
||||
// transport mode is part of the fingerprint: a tunnel-mode reqid 1 + 2 pair carrying the protected ports is not ours
|
||||
const std::string tunnelOut1 = "src 2001:db8:1111:2222:3333:4444:5555:db43/128 dst 2001:db8::109/128 sport 45061 dport 4 \n\tdir out priority 0 \n\ttmpl src 2001:db8:1111:2222:3333:4444:5555:db43 dst 2001:db8::109\n\t\tproto esp reqid 1 mode tunnel\n";
|
||||
const std::string tunnelOut2 = "src 2001:db8:1111:2222:3333:4444:5555:db43/128 dst 2001:db8::109/128 sport 45062 dport 2 \n\tdir out priority 0 \n\ttmpl src 2001:db8:1111:2222:3333:4444:5555:db43 dst 2001:db8::109\n\t\tproto esp reqid 2 mode tunnel\n";
|
||||
Check(!SaPeer(tunnelOut1 + tunnelOut2).has_value(), "SaPeer: a tunnel-mode reqid 1 + 2 pair carrying the protected ports -> none (transport mode is part of the fingerprint)");
|
||||
Check(SaPeer(std::string(Policy) + tunnelOut1 + tunnelOut2) == "2001:db8::105", "SaPeer: the same beside our pair -> ours");
|
||||
Check(SaPeer(State).has_value() == false, "SaPeer: state text (no dir out) -> none");
|
||||
}
|
||||
|
||||
// ---- OwnXfrmDeleteCommands: imsd's own entries out of the kernel by exact key; everything else stays
|
||||
{
|
||||
const std::string ue = "2001:db8:1111:2222:3333:4444:5555:db43";
|
||||
|
|
@ -286,6 +324,15 @@ int main() {
|
|||
auto k = ChooseResumeSecurityVerify(kpn.securityServer, kpn, "aes-cbc");
|
||||
Check(k.source == Source::Stored && k.value == kpn.securityServer && k.mechanisms == 1, "resume: KPN's one-entry list for its pair -> the list, one mechanism");
|
||||
}
|
||||
// ---- a state file another attempt left behind: the file's UE SPIs against the kernel pair's
|
||||
{
|
||||
Check(!FileNamesAnotherPair(0x0526f2c0L, 0x0526f2c1L, 0x0526f2c0, 0x0526f2c1), "file pair: the pair's own SPIs -> the file is this pair's");
|
||||
Check(FileNamesAnotherPair(0x11111111L, 0x22222222L, 0x0526f2c0, 0x0526f2c1), "file pair: other SPIs -> another attempt's");
|
||||
Check(FileNamesAnotherPair(0x0526f2c0L, 0x22222222L, 0x0526f2c0, 0x0526f2c1), "file pair: one SPI differs -> another attempt's");
|
||||
Check(!FileNamesAnotherPair(std::nullopt, std::nullopt, 0x0526f2c0, 0x0526f2c1), "file pair: a file without the keys -> no verdict");
|
||||
Check(!FileNamesAnotherPair(0x11111111L, 0x22222222L, 0, 0x0526f2c1), "file pair: a missing inbound state (0) -> no verdict");
|
||||
Check(!FileNamesAnotherPair(0L, 0L, 0x0526f2c0, 0x0526f2c1), "file pair: zeros in the file -> no verdict");
|
||||
}
|
||||
// ---- selection rules and syntax corners
|
||||
{
|
||||
// spec-shaped list: every entry carries the SPI set, md5 preferred by q — we offered sha1, so sha1 wins (audit A1)
|
||||
|
|
|
|||
Loading…
Reference in a new issue