From bd52afd931df4b1f2b16eb2df3c62c10a6aaa5ec Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sun, 11 Oct 2026 16:09:45 +0200 Subject: [PATCH] imsd: P-CSCF candidates from PCSCF_DISCOVERED when PCSCF= is not set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit imsd reads PCSCF_DISCOVERED= — a comma-separated IPv6 list — from its environment; nothing in this tree writes it yet. FreshPcscfFromEnv picks the fresh target: PCSCF= when set — one address, it wins — else the first entry of the list CanonicalIp6 accepts; the journal says which, and names the entries it refused. imsd routes the P-CSCF it uses itself — the fresh target before a fresh registration (BringUp, FreshRecover), the resumed SA peer once its SAs are confirmed — with the same idempotent `ip -6 route replace` as a call's media peer: the bring-up script routes only the configured PCSCF=, so a target taken from PCSCF_DISCOVERED= (set by hand in this tree), or a route lost with the PDN, had no route. The start line prints that target; a resume still follows the kernel's SA peer and now says whether that peer is in today's list. With neither variable and nothing to resume, the fatal names both. Tested: crafter-build test, 8 passed; the configured, discovered, differing (the discovered list's order flipped: the resume stays on the SA peer, the next fresh registration's target is the other address) and empty arms in builds of this series on the dev phone (KPN). --- AGENTS.md | 79 +++++++++++++++++++++++++--------------- README.md | 3 +- implementations/main.cpp | 66 ++++++++++++++++++++++++++------- 3 files changed, 105 insertions(+), 43 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0515675..f71164e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,8 +79,9 @@ partition with a test. The shell (`main.cpp`; from 1.0.0 also `pcscf.cpp`'s QRTR control lookup and its matching of answers by type, transaction and message id) holds protocol decisions no test reaches — some older than 0.3.10, some added by 0.3.10, 0.3.11, 0.3.12 or 1.0.0: the P-CSCF in -use (`pcscf_`: the kernel's SA peer at a resume, `PCSCF=` at a fresh -registration, the move between them and back — §7.2); the reg-event +use (`pcscf_`: the kernel's SA peer at a resume, `PCSCF=` or the first of +`PCSCF_DISCOVERED=` at a fresh registration, the move between them and back — +§7.2); the reg-event 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`: @@ -496,10 +497,14 @@ a 5-minute registration gate, connect retries). `Restart=on-failure`, The daemon's own bring-up (`BringUp()` in `main.cpp`): -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 +1. The P-CSCF a fresh registration goes to is `PCSCF=` when set, else the + first address of `PCSCF_DISCOVERED=` (a comma-separated IPv6 list read + from the environment; nothing in this tree writes it yet, so a bench sets + it by hand); the journal says which (`pcscf: configured …` / `pcscf: + discovered … (using …)`). A resume needs neither: the kernel's SAs name + its P-CSCF (step 3). With neither, a bring-up that did not resume 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 @@ -507,8 +512,7 @@ The daemon's own bring-up (`BringUp()` in `main.cpp`): 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 … + 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. @@ -550,7 +554,8 @@ The daemon's own bring-up (`BringUp()` in `main.cpp`): registered fresh at every restart; an upgraded phone with such a setting stops at bring-up with the spelling to use. A resume follows the kernel's SA peer (`SaPeer`), not `PCSCF=`: a registration made to another P-CSCF than today's setting is - resumed there (`pcscf: resuming on …`) and moves at the next fresh + resumed there (`pcscf: resuming on …`; imsd routes that peer itself once + `ParseExistingSa` confirms the SAs) and moves at the next fresh registration (`the fresh registration moves from P-CSCF X to Y`; an attempt that fails before the SA setup goes back while the old registration still stands — not written off by its address leaving the link, a NOTIFY @@ -560,12 +565,13 @@ The daemon's own bring-up (`BringUp()` in `main.cpp`): `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 + bring-up registers fresh (with no fresh target: the fatal, step 1). Every fresh registration goes to `PCSCF=`, else the first + entry of `PCSCF_DISCOVERED=` that `CanonicalIp6` accepts; with neither, a bring-up that cannot resume 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`): the target's host + route (imsd's own `ip -6 route replace`; step 3 says which target), an + unprotected REGISTER on `PCSCF_PORT` (UDP, always), the 401 with the AKA challenge and the `Security-Server` list, the AKA run over a UIM logical channel (`--uim-open-logical-channel`, `--uim-send-apdu`), the SA pair installed with `ip xfrm`, the protected REGISTER over the negotiated transport @@ -674,9 +680,9 @@ configures the muxed netdev. Idempotent; a non-zero exit fails the unit. uncompressed one by the daemon with the spelling to use once the route step took it); anything else on the IMS network (its DNS servers, a traceroute target) needs its own host route - (`ip -6 route add dev qmapmux0.0`). imsd installs a host route per - media peer itself, and one to the SA peer of a registration it resumes - when that differs from the fresh target (§7.2). + (`ip -6 route add dev qmapmux0.0`). imsd installs a host route + per media peer itself, and one to the P-CSCF it registers to or resumes on + (§7.2 step 3). - 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 @@ -930,16 +936,20 @@ 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 +- `pcscf: configured X` / `pcscf: configured X; discovery gave Y, Z (ignored: + PCSCF= wins)` / `pcscf: discovered Y, Z (using Y)` at start, `pcscf: + PCSCF_DISCOVERED entry '…' is not an IPv6 address; ignored`; `pcscf: + resuming on X, the registration's SA peer; the next fresh registration + goes to Y` (+ ` (the peer is not in today's list)`, or `… (no P-CSCF + configured or discovered)`), `the fresh registration moves from P-CSCF X + to Y`, `back to P-CSCF X: the old registration stands until the next fresh + attempt`, `no reg state file; resume falls back to fresh` and `the state file names another SA pair (0x…/0x…[ to X]) than the kernel's (0x…/0x… to Y): another attempt's; resume falls back to fresh` (` to X` only when the file records a P-CSCF — a pre-1.0.0 file does not; with no fresh target both end in - `nothing can resume it: the pair is removed` instead), `no P-CSCF for a fresh - registration: none configured (PCSCF=)`. + `nothing can resume it: the pair is removed` instead), `no P-CSCF for a + fresh registration: none configured (PCSCF=) and none discovered + (PCSCF_DISCOVERED empty)`. - `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 @@ -1184,7 +1194,19 @@ minimum `/etc/imsd.env` are in the README. The README's first table. The protected client/server ports are fixed at 45061/45062 (`imsd::util::PortUc`/`PortUs`) — changing them in source also -requires updating the firewall rules `ims-pdn-up.sh` installs. +requires updating the firewall rules `ims-pdn-up.sh` installs. `PCSCF` and +`PCSCF_DISCOVERED` are read at start (`FreshPcscfFromEnv`, for the start line +and again by the engine): the first is one address in RFC 5952 form (§7.2 step 3: another spelling is +refused at bring-up), and wins, the second a comma-separated IPv6 list whose +first entry `CanonicalIp6` accepts is the fresh target otherwise (an IPv4 +address, a name, a literal with a zone or a dotted-quad tail is logged and +skipped). A resume goes to the kernel's SA +peer whatever they say (and routes it itself); beyond the resume's `pcscf:` +line, having a fresh target decides what a refused or failed resume +leads to — the fresh registration (a refusal without a Retry-After is not +counted) or, with none, the pair kept for the restart or removed (§7.2 +step 1) — and a comma-separated `PCSCF=` stops the bring-up before the +resume. ### 10.2 imsd-media (per call, set through imsd's environment) @@ -1272,9 +1294,8 @@ 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) 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. + call for the media peer (§7.3) and one for the P-CSCF it registers to or resumes on (§7.2 step 3; the + script routes only the configured address). Nothing else is routed over it. A change that routes anything else over it needs a reason and a bench. - The fixed protected ports (45061/45062) are opened in nftables for `qmapmux*` only, by the bring-up script. diff --git a/README.md b/README.md index f4d1b95..f67bafe 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,8 @@ session and root may place, answer and end calls; an ssh login may not. | Variable | Default | Meaning | |---|---|---| -| `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` | *(the first of `PCSCF_DISCOVERED`)* | P-CSCF address (IPv6), exactly one — not a comma-separated list — in RFC 5952 §4 form (lowercase, leading zeros dropped, the longest zero run compressed, no brackets — what `ip` prints for a plain IPv6 address; an IPv4-mapped address only as hex, `::ffff:a00:1` — the dotted spelling RFC 5952 §5 and `ip` use for it is refused); upper case, leading zeros, an uncompressed run or brackets are refused at start with the spelling to use (in the packaged unit the bring-up script refuses brackets first, with the rule), a dotted-quad tail with the rule; before 1.0.0 an upper-case, zero-padded or uncompressed `PCSCF=` matched no SA in `ip xfrm`'s text and registered fresh at every restart — an upgraded phone with such a setting stops at start with the spelling to use. Set, it wins over `PCSCF_DISCOVERED`. It is where a fresh registration goes; a registration resumed from the kernel's SAs stays on the P-CSCF it was made to until its next fresh registration. With neither this nor `PCSCF_DISCOVERED`, a fresh registration fails with a clear error | +| `PCSCF_DISCOVERED` | *(empty)* | a comma-separated IPv6 P-CSCF list, the first of which is used when `PCSCF` is unset; entries `CanonicalIp6` does not read — an IPv4 address, a name, an IPv6 literal with a zone or a dotted-quad tail (an IPv4-mapped entry in hex only) — are logged and ignored. imsd reads it from its environment; nothing in this tree writes it yet (a bench sets it in `/etc/imsd.env`) | | `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) | diff --git a/implementations/main.cpp b/implementations/main.cpp index a75e47c..a70c910 100644 --- a/implementations/main.cpp +++ b/implementations/main.cpp @@ -295,6 +295,20 @@ void LogAudioSession() { Log(AudioSessionLine(s) + (s.name.empty() ? "; looked up again at each call, set AUDIO_USER to pin one" : "")); } +// The P-CSCF a fresh registration goes to, from the environment: PCSCF= +// when set (one address, any spelling — a list is left as it came and +// refused at bring-up), else the first address of PCSCF_DISCOVERED=, the +// list the bring-up read from the PDN's PCO (comma-separated IPv6, PCO +// order). `discovered` and `rejected` get the list and what was not an +// IPv6 literal, for the caller's log lines. +std::string FreshPcscfFromEnv(std::vector* discovered = nullptr, std::vector* rejected = nullptr) { + std::string configured = imsd::util::CanonicalAddr(EnvOr("PCSCF", "")); + std::vector list = imsd::util::ParseAddressList(EnvOr("PCSCF_DISCOVERED", ""), rejected); + if (discovered) *discovered = list; + if (!configured.empty()) return configured; + return list.empty() ? std::string() : list.front(); +} + // ---- sockaddr helper ------------------------------------------------------ socklen_t MakeAddr(std::string_view ip, int port, sockaddr_storage& ss) { std::memset(&ss, 0, sizeof ss); @@ -942,11 +956,25 @@ void PostEvent(std::unique_ptr ev) { class Engine { public: Engine() { - // 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_; + // The P-CSCF a fresh registration goes to: PCSCF= when set, else the + // first address the bring-up read from the PDN's PCO + // (PCSCF_DISCOVERED=); no baked-in default — carrier infrastructure. + // A resume follows the kernel's SAs instead (TryResume), whatever + // this says today. + { + std::vector rejected; + pcscfTarget_ = FreshPcscfFromEnv(&pcscfCandidates_, &rejected); + pcscf_ = pcscfTarget_; + std::string configured = imsd::util::CanonicalAddr(EnvOr("PCSCF", "")); + std::string list; + for (const std::string& c : pcscfCandidates_) list += (list.empty() ? "" : ", ") + c; + if (!configured.empty()) + Log(pcscfCandidates_.empty() ? std::format("pcscf: configured {}", configured) + : std::format("pcscf: configured {}; discovery gave {} (ignored: PCSCF= wins)", configured, list)); + else if (!pcscfCandidates_.empty()) + Log(std::format("pcscf: discovered {} (using {})", list, pcscfTarget_)); + for (const std::string& r : rejected) Log(std::format("pcscf: PCSCF_DISCOVERED entry '{}' is not an IPv6 address; ignored", r)); + } pcscfPort_ = std::atoi(EnvOr("PCSCF_PORT", "5060").c_str()); dev_ = EnvOr("DEV", "qmapmux0.0"); outDir_ = EnvOr("OUT_DIR", StateDir()); @@ -1156,13 +1184,15 @@ private: // config std::string pcscf_, dev_, outDir_, local_, ealgOffer_, mediaBin_; - // The P-CSCF a fresh registration goes to (PCSCF=). pcscf_ is the one in + // The P-CSCF a fresh registration goes to (PCSCF=, else the first of + // PCSCF_DISCOVERED=) and the whole discovered list. pcscf_ is the one in // use: the same, or the kernel's SA peer while a resumed registration // made to another one lives (TryResume) — the next fresh registration // moves to the target, and back when it fails before the SA setup // (FreshRecover), as local_ does against pdnLocal_. std::string pcscfTarget_; - static constexpr std::string_view NoFreshPcscf = "no P-CSCF for a fresh registration: none configured (PCSCF=)"; + std::vector pcscfCandidates_; + static constexpr std::string_view NoFreshPcscf = "no P-CSCF for a fresh registration: none configured (PCSCF=) and none discovered (PCSCF_DISCOVERED empty)"; // 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). @@ -1352,6 +1382,11 @@ private: local_ = pdnLocal_; ctx_.local = local_; KeepHolds(); pcscf_ = pcscfTarget_; + // the target's host route is imsd's own: the bring-up script routes only + // the configured address (§7.2 step 3), not a target set in /etc/imsd.env + // as PCSCF_DISCOVERED= nor a route lost with the PDN — the same idempotent + // replace as a media peer's + RunCmd({"ip", Is6(pcscf_) ? "-6" : "-4", "route", "replace", pcscf_, "dev", dev_}); if (recovery_.Next(Boot()).at > Boot()) Log(std::format("the network's hold stands; {}", NextStep())); if (!WaitForPolicy()) throw Stopping{}; FreshRegister(); @@ -1373,13 +1408,12 @@ private: // 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_) { + bool listed = *peer == pcscfTarget_ || std::ranges::find(pcscfCandidates_, *peer) != pcscfCandidates_.end(); Log(pcscfTarget_.empty() - ? std::format("pcscf: resuming on {}, the registration's SA peer (no P-CSCF configured)", *peer) - : std::format("pcscf: resuming on {}, the registration's SA peer; the configured P-CSCF is {} — the next fresh registration moves there", *peer, pcscfTarget_)); + ? std::format("pcscf: resuming on {}, the registration's SA peer (no P-CSCF configured or discovered)", *peer) + : std::format("pcscf: resuming on {}, the registration's SA peer; the next fresh registration goes to {}{}", *peer, pcscfTarget_, listed ? "" : " (the peer is not in today's list)")); pcscf_ = *peer; - 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 @@ -1403,7 +1437,10 @@ private: } } 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_}); + // the peer's host route is imsd's own once the SAs are confirmed (a peer + // named by a pair that is not ours gets no route into the PDN), whether + // or not the bring-up script routed the same address — idempotent + RunCmd({"ip", Is6(pcscf_) ? "-6" : "-4", "route", "replace", pcscf_, "dev", dev_}); auto ps = LoadState(); if (!ps || !ps->callid) { Log(std::format("no reg state file; {}", ResumeDeadNext())); return ResumeOutcome::Dead; } // A file another attempt left behind — one that got through the SA @@ -2046,6 +2083,8 @@ private: Log(std::format("the fresh registration moves from {} to the PDN's own address {}", local_, pdnLocal_)); MoveTo(pdnLocal_); } + // the fresh target's host route, imsd's own (BringUp's fresh path does the same) + RunCmd({"ip", Is6(pcscf_) ? "-6" : "-4", "route", "replace", pcscf_, "dev", dev_}); bool ok = false; try { RefreshPani(); @@ -2680,7 +2719,8 @@ int main(int argc, char** argv) { return 1; } local = imsd::util::CanonicalAddr(local); - Log(std::format("LOCAL={} P-CSCF=[{}]:{}", local, imsd::util::CanonicalAddr(EnvOr("PCSCF", "(unset)")), EnvOr("PCSCF_PORT", "5060"))); + std::string freshPcscf = FreshPcscfFromEnv(); + Log(std::format("LOCAL={} P-CSCF=[{}]:{}", local, freshPcscf.empty() ? "(none)" : freshPcscf, EnvOr("PCSCF_PORT", "5060"))); if (!session) LogAudioSession(); imsd::bus::AuthorizeCalls = !session;