From 7eb2deae0c0c1aa63f23cdaf2878e14cdeaccc52 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Mon, 28 Sep 2026 18:28:58 +0200 Subject: [PATCH] ims-pdn-up, imsd: register on the PDN's own address, not the kernel's temporary one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit imsd took the first global IPv6 on the ims netdev as its address, and the kernel lists the temporary (privacy) SLAAC address first: `temporary dynamic`, preferred for a day and valid for two by its lifetimes (read 2026-09-24). Contact, Via and the four SAs were bound to it, so once the kernel removes it the registration has lost its address. The address ModemManager assigned — the one the script installs — sat third on the list. Seen on the dev phone; a field unit's journal shows the split too — imsd registered on an address other than the one the script installed (ims ledger F18). Hand the assigned address to the daemon as LOCAL through /run/imsd.env, next to DEV. For a manual run without it, prefer a global address that is neither `temporary` nor `dynamic`, then any non-temporary one. A restart after the upgrade finds the registration's SAs on the old address. It resumes there while that address is still on the link, and moves to the PDN's address in place: with its next fresh registration, or when the old address leaves the link first. When the address it is registered on leaves the netdev and the PDN's own address is not there to move to, a registered imsd exits at its next recovery step, non-zero, for a systemd restart, which runs the PDN bring-up again. Tested on the dev phone (KPN) in builds of this series; not every path has run there. --- README.md | 22 ++++-- implementations/main.cpp | 150 ++++++++++++++++++++++++++++++++++--- interfaces/Imsd-Ipsec.cppm | 13 ++++ packaging/ims-pdn-up.sh | 10 ++- packaging/imsd.service | 6 +- tests/Ipsec/main.cpp | 8 ++ 6 files changed, 187 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 1ba3617..86220c8 100644 --- a/README.md +++ b/README.md @@ -252,7 +252,8 @@ IMSD_BUS=session imsd-ofonod ## Configuration Everything is environment variables. The packaged unit reads -`/run/imsd.env` (written by `ims-pdn-up.sh`: the connected ims netdev) and +`/run/imsd.env` (written by `ims-pdn-up.sh`: the connected ims netdev and +the PDN address ModemManager assigned to it) and then `/etc/imsd.env` (your configuration, wins on conflict). Minimum viable `/etc/imsd.env`: @@ -270,10 +271,10 @@ PCSCF=2001:db8::105 | `PCSCF` | *(none — required)* | P-CSCF address; without it registration fails with a clear error | | `PCSCF_PORT` | `5060` | unprotected SIP port for the initial REGISTER | | `DEV` | `qmapmux0.0` | ims-PDN netdev; the packaged service passes the real one via `/run/imsd.env` | -| `LOCAL` | *(auto)* | UE address; default = the global IPv6 on `DEV` | +| `LOCAL` | *(auto)* | UE address. The packaged service gets the PDN address ModemManager assigned, via `/run/imsd.env` from `ims-pdn-up.sh`; a manual run prefers a global IPv6 on `DEV` that is neither temporary nor `dynamic` (RA-derived), then any non-temporary one, then the first (the kernel's temporary SLAAC address is listed first, and a registration bound to it is lost when the kernel retires it) | | `USER_AGENT` | `imsd/` | REGISTER User-Agent. Some networks fingerprint UAs; setting your device's stock build string reproduces the stock modem's registration exactly. Empty omits the header | | `STATE_FILE` | `/var/lib/imsd/imsreg.state` (root) / `$XDG_STATE_HOME/imsd/imsreg.state` | persisted registration context for warm resume | -| `HOLDS_FILE` | `/run/imsd.holds` (root) / `$XDG_RUNTIME_DIR/imsd.holds` (`/tmp/imsd.holds` without a runtime dir) | the waits the network imposed (a `Retry-After`, the refusal schedule) and where that schedule stands, kept across a restart of imsd for the address 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) | +| `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) and where that schedule stands, 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) | | `RESUME` | `auto` | `0` forces a fresh registration (ignores a warm SA) | | `REFRESH_INTERVAL` | *(auto)* | keepalive re-REGISTER period in s; default = half the granted expiry, clamped to [120, 1800] | | `EALG` | `aes-cbc` | offered ESP cipher (`aes-cbc`, `des-ede3-cbc`, `null`). From the 401's `Security-Server` list imsd selects the highest-preference `ipsec-3gpp` mechanism that matches this offer (RFC 3329 §2.3.1) and takes the SPIs/ports from the entry carrying them (some cores list every mechanism they support and put the SPIs on the one they applied); when no entry matches, the P-CSCF's SPI-carrying entry is installed as listed (`hmac-md5-96`, an absent `ealg` = null) and the journal says so. The line, the selection and the SA parameters are logged at every fresh registration | @@ -311,14 +312,23 @@ imsd-dialerd lists no modem. A bring-up the network deferred waits the same schedule out with nothing to reconnect over (the resume just found the stored SAs unusable), then tries again. The network's holds outlive a restart of imsd: they are kept in -`/run/imsd.holds` for the address they were set on, with the schedule's place +`/run/imsd.holds` for the PDN they were set on, with the schedule's place (a restart does not start it over at 21 min), and waited out at the next start, 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); a reboot or another address starts clean. +binding's wait); a reboot or another PDN address starts clean. The journal says which (`…; next: fresh registration in N s`, `registering fresh (recovery attempt N)`, `fresh registration deferred/refused/failed: -…`, `bring-up deferred: …`). The reg-event subscription — the network's own +…`, `bring-up deferred: …`). One case leaves through a real restart +instead: when the address imsd is registered on has left the ims netdev +and the PDN's own address is not there to move to (the PDN went away) — +the journal shows `engine fatal: … — exiting for systemd restart`, +and after `RestartSec` (120 s) `ims-pdn-up.sh` runs again and imsd +registers on the PDN's address. A registration resumed on an older +address (a pre-0.3.10 temporary one) moves to the PDN's own address in +place: with its next fresh registration, or when the older address leaves +the netdev first, which takes that registration with it. The reg-event +subscription — the network's own view of the binding, one live dialog per registration, subscribed anew ahead of the lifetime the S-CSCF granted — is retried when refused or unanswered (60 s doubling to 10 min, a `Retry-After` honoured up to an hour: a registration refresh diff --git a/implementations/main.cpp b/implementations/main.cpp index d20e74c..155ab50 100644 --- a/implementations/main.cpp +++ b/implementations/main.cpp @@ -27,6 +27,7 @@ reach the bus via g_idle_add, where the calls table + status snapshot live #include #include #include +#include #include #include #include @@ -152,15 +153,54 @@ std::optional CompletedHex(std::string_view out) { return h; } -// global IPv6 on the ims PDN, from `ip -6 addr show dev scope global`. +// The UE address on the ims PDN when LOCAL is not given: a global IPv6 on +// that is neither temporary nor RA-derived (`dynamic`) — the one +// ims-pdn-up.sh assigned from ModemManager — else the first non-temporary +// one, else the first. The kernel lists the temporary (privacy) address +// first, and registering on it binds Contact, Via and the SAs to an address +// that is deprecated after a day and removed after two (ims ledger F18). std::optional DetectLocal(const std::string& dev) { auto r = RunCapture({"ip", "-6", "addr", "show", "dev", dev, "scope", "global"}); - std::size_t at = r.out.find("inet6 "); - if (at == std::string_view::npos) return std::nullopt; - std::size_t start = at + 6; - std::size_t end = r.out.find('/', start); - if (end == std::string_view::npos) return std::nullopt; - return r.out.substr(start, end - start); + std::optional first, nonTemporary, assigned; + std::size_t pos = 0; + while ((pos = r.out.find("inet6 ", pos)) != std::string::npos) { + std::size_t start = pos + 6; + pos = start; + std::size_t eol = r.out.find('\n', start); + if (eol == std::string::npos) eol = r.out.size(); + std::size_t slash = r.out.find('/', start); + if (slash == std::string::npos || slash > eol) continue; + std::string addr = r.out.substr(start, slash - start); + std::string_view flags = std::string_view(r.out).substr(slash, eol - slash); + bool temporary = flags.contains(" temporary"); + bool dynamic = flags.contains(" dynamic"); + if (!first) first = addr; + if (!temporary && !nonTemporary) nonTemporary = addr; + if (!temporary && !dynamic && !assigned) assigned = addr; + } + if (assigned) return assigned; + if (nonTemporary) return nonTemporary; + return first; +} + +// Whether `addr` is configured on `dev` now. Compared as bytes, so any +// spelling of the address matches; when the interface list cannot be read +// the answer is yes — nothing here acts on a guess that it is gone. +bool OnLink(const std::string& dev, const std::string& addr) { + int fam = Is6(addr) ? AF_INET6 : AF_INET; + unsigned char want[16] = {}; + if (inet_pton(fam, addr.c_str(), want) != 1) return false; + ifaddrs* ifs = nullptr; + if (getifaddrs(&ifs) != 0) return true; + bool found = false; + for (ifaddrs* i = ifs; i && !found; i = i->ifa_next) { + if (!i->ifa_addr || i->ifa_addr->sa_family != fam || dev != i->ifa_name) continue; + const void* have = fam == AF_INET6 ? static_cast(&reinterpret_cast(i->ifa_addr)->sin6_addr) + : static_cast(&reinterpret_cast(i->ifa_addr)->sin_addr); + found = std::memcmp(have, want, fam == AF_INET6 ? 16 : 4) == 0; + } + freeifaddrs(ifs); + return found; } // ---- sockaddr helper ------------------------------------------------------ @@ -511,6 +551,7 @@ public: for (int f : udp_) close(f); for (auto& c : conns_) close(c.fd); listeners_.clear(); udp_.clear(); conns_.clear(); + udpUc_ = -1; } private: struct Conn { int fd; imsd::sip::FrameBuffer fb; }; @@ -636,7 +677,7 @@ std::int64_t NowEpoch() { // The recovery policy's holds, kept for the next process: the network's // word (a Retry-After, the schedule after a refused or throttled fresh // attempt, a terminated binding's wait) outlives a restart of imsd. They -// are filed under the daemon's address — another one, or a reboot, starts +// are filed under the PDN's address — another one, or a reboot, starts // clean — so the file names that address and the boot, and // lives in /run. Seconds left at `written_at`, which is on the boot // clock: no clock step moves it, and it runs through a suspend as the @@ -823,7 +864,7 @@ public: } } - void SetLocal(std::string l) { local_ = std::move(l); } + void SetLocal(std::string l) { local_ = l; pdnLocal_ = std::move(l); } void SetDevMode(bool d) { devMode_ = d; } const std::string& Local() const { return local_; } @@ -848,7 +889,7 @@ public: } void Run() { - if (auto h = LoadHolds(local_)) { + if (auto h = LoadHolds(pdnLocal_)) { recovery_.Restore(Boot(), *h); auto kept = recovery_.Holds(Boot()); // what the policy took over: the file's values after the clamp if (kept.hold > 0 || kept.freshHold > 0) @@ -965,7 +1006,7 @@ private: return !quit_.load(); } - void KeepHolds() { PersistHolds(recovery_.Holds(Boot()), local_); } + void KeepHolds() { PersistHolds(recovery_.Holds(Boot()), pdnLocal_); } // dev/session mode without a modem: process commands so the ABI answers, // but there is no SIP socket — a Dial collapses to terminated/error. @@ -978,6 +1019,10 @@ private: // config std::string pcscf_, dev_, outDir_, local_, ealgOffer_, mediaBin_; + // 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). + std::string pdnLocal_; std::string sipPolicy_; // SIP_TRANSPORT: auto | tcp | udp Transport transport_ = Transport::Tcp; // the protected leg's transport in effect void SetTransport(Transport t) { transport_ = t; ctx_.transport = std::string(TransportName(t)); } @@ -1115,6 +1160,7 @@ private: // fresh registration waits recovery_.OldGone(); portPs_ = 0; + local_ = pdnLocal_; ctx_.local = local_; KeepHolds(); if (recovery_.Next(Boot()).at > Boot()) Log(std::format("the network's hold stands; {}", NextStep())); if (!WaitForPolicy()) throw Stopping{}; @@ -1125,6 +1171,25 @@ private: auto state = RunCapture({"ip", "xfrm", "state"}); auto policy = RunCapture({"ip", "xfrm", "policy"}); 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 + // F18). Resume there while it is still up — a restart keeps the + // registration for the price of a re-REGISTER, where a fresh one is + // an AKA run, a new SA pair and, when KPN refuses it, the old + // registration and a 21-minute wait (KPN refuses a fresh pair on an + // address whose registration is live and has accepted one on another + // address of the link meanwhile; other carriers' rules are unknown) — + // and move at the next fresh registration. + if (!sa) { + for (const std::string& other : imsd::ipsec::SaLocals(state.out, pcscf_)) { + if (other == local_ || !OnLink(dev_, other)) continue; + sa = imsd::ipsec::ParseExistingSa(state.out, policy.out, pcscf_, other); + if (!sa) continue; + Log(std::format("the registration's SAs are on {}, not the PDN's own address {}: resuming there until the next fresh registration", other, local_)); + local_ = other; ctx_.local = other; + break; + } + } if (!sa) return false; auto ps = LoadState(); if (!ps || !ps->callid) { Log("no reg state file; resume falls back to fresh"); return false; } @@ -1627,6 +1692,7 @@ private: Log("client flow dead; reconnecting"); sip_.Close(); if (!sip_.Connect(local_, PortUc, pcscf_, portPs_, transport_, /*tries=*/2)) { + if (AddressGone()) return; if (sip_.LastConnectLocal()) { if (registered_) { registered_ = false; EmitStatus(true); } recovery_.OnLocalRefusal(Boot()); @@ -1693,11 +1759,22 @@ private: // fails before the SA setup leaves the old registration and that // retry standing subCallid_.clear(); subPending_ = false; subRenewAt_ = 0; + // 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 + std::string from = local_; + if (local_ != pdnLocal_) { + Log(std::format("the fresh registration moves from {} to the PDN's own address {}", local_, pdnLocal_)); + MoveTo(pdnLocal_); + } + bool ok = false; try { RefreshPani(); FreshRegister(); subRetryAt_ = 0; // a new registration: its subscription starts afresh OnRegistered(); + ok = true; Log("recovered by a fresh registration"); } catch (const ThrottledError& e) { FreshAttemptFailed(F::Throttled, 0, std::format("fresh registration deferred: {}", e.what())); @@ -1706,6 +1783,24 @@ private: } catch (const std::exception& e) { FreshAttemptFailed(F::Silent, 0, std::format("fresh registration failed: {}", e.what())); } + if (!ok && !quit_.load() && recovery_.Reconnectable() && local_ != from) { + if (OnLink(dev_, from)) { + Log(std::format("back on {}: the old registration stands until the next fresh attempt", from)); + MoveTo(from); + } else { + recovery_.OldGone(); + KeepHolds(); // the verdict outlives a restart like the hold it goes with + Log(std::format("{} has left {}: the registration on it is gone; staying on {}; {}", from, dev_, local_, NextStep())); + } + } + } + + // Our address for everything from here on: the flow, the server ports, + // the requests' Via and Contact, the media leg. + void MoveTo(const std::string& addr) { + server_.CloseAll(); + local_ = addr; ctx_.local = addr; + server_.Open(local_); } // After a failed fresh attempt, in recovery or at bring-up: the @@ -1713,12 +1808,45 @@ private: // the policy told, and its next step logged. void FreshAttemptFailed(imsd::engine::RegRecovery::Failure f, long retryAfter, const std::string& what) { CloseFlowAfterWait(); + if (!OnLink(dev_, local_)) Log(what); // said before the address's own line: a refusal is always in the journal + if (AddressGone()) return; bool wasReconnectable = recovery_.Reconnectable(); recovery_.OnFreshFailure(Boot(), f, retryAfter, saReplaced_); KeepHolds(); Log(std::format("{}{}; {}", what, wasReconnectable && !recovery_.Reconnectable() ? " — the old registration is gone" : "", NextStep())); } + // After a failed recovery step: has our address left the link? An + // older registration's address (a pre-0.3.10 temporary one that + // expired) takes that registration with it: imsd moves to the PDN's + // own address and the policy's fresh step runs there, every wait it + // holds included. The PDN's own address leaving means the PDN went + // away — no retry brings it back; a restart runs the PDN bring-up + // again. True when the step that failed has nothing left to do. + bool AddressGone() { + if (OnLink(dev_, local_)) return false; + if (local_ != pdnLocal_ && OnLink(dev_, pdnLocal_)) { + if (registered_) { registered_ = false; EmitStatus(true); } + std::string from = local_; + recovery_.OldGone(); + MoveTo(pdnLocal_); + KeepHolds(); // the caller returns before its own write + Log(std::format("{} is no longer on {}: the registration on it is gone; on the PDN's own address {} now; {}", from, dev_, local_, NextStep())); + return true; + } + RequestRestart(std::format("{} is no longer on {}", local_, dev_)); + return true; + } + + // Leave through systemd (Restart=on-failure), which runs ims-pdn-up.sh + // again and registers on the PDN's own address. + void RequestRestart(const std::string& why) { + quit_.store(true); + auto ev = std::make_unique(); + ev->type = Event::Type::Fatal; ev->text = why; + PostEvent(std::move(ev)); + } + // After a registration succeeds — bring-up, a keepalive refresh, a // reconnect + refresh, or a fresh recovery: the status (a signal only // when it changes), the timers, the recovery policy, the reg-event diff --git a/interfaces/Imsd-Ipsec.cppm b/interfaces/Imsd-Ipsec.cppm index fbbd514..611bb82 100644 --- a/interfaces/Imsd-Ipsec.cppm +++ b/interfaces/Imsd-Ipsec.cppm @@ -400,4 +400,17 @@ export namespace imsd::ipsec { sa.securityServer = std::format("ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=aes-cbc; " "spi-c={}; spi-s={}; port-c={}; port-s={}", sa.spiPc, sa.spiPs, sa.portPc, sa.portPs); return sa; } + + // Every local address holding an SA to the P-CSCF, once each, in kernel + // order: where a registration made on another address of the link (the + // kernel's temporary one, before 0.3.10 — ims ledger F18) still lives. + std::vector SaLocals(std::string_view stateText, std::string_view pcscf) { + std::vector locals; + for (std::string_view b : detail::SrcBlocks(stateText)) { + std::string_view src = detail::TokenAfter(b, "src "); + if (src.empty() || detail::TokenAfter(b, "dst ") != pcscf) continue; + if (std::ranges::find(locals, src) == locals.end()) locals.emplace_back(src); + } + return locals; + } } diff --git a/packaging/ims-pdn-up.sh b/packaging/ims-pdn-up.sh index fa6292c..a20068e 100644 --- a/packaging/ims-pdn-up.sh +++ b/packaging/ims-pdn-up.sh @@ -297,9 +297,13 @@ case "$PCSCF" in *) log "PCSCF '$PCSCF' is not an IPv6 address; the ims PDN is brought up IPv6-only"; exit 1 ;; esac -# ---- hand the connected ims netdev to imsd (imsd.service reads -# /run/imsd.env) so the daemon carries no baked-in interface name -echo "DEV=$IFACE" > /run/imsd.env +# ---- hand the connected ims netdev and the PDN's own address to imsd +# (imsd.service reads /run/imsd.env): the daemon carries no baked-in +# interface name and registers on the address the network assigned — not +# on the kernel's temporary SLAAC address from the RA, which the netdev +# lists first and which is deprecated after a day and removed after two +# (ims ledger F18; the SAs and the Contact would go with it) +printf 'DEV=%s\nLOCAL=%s\n' "$IFACE" "$ADDR" > /run/imsd.env # ---- open the IMS protected ports in the firewall (rung 5b root cause, # journal/ims.md s56): pmOS's default nftables INPUT chain is policy-drop and diff --git a/packaging/imsd.service b/packaging/imsd.service index add1594..17b88a0 100644 --- a/packaging/imsd.service +++ b/packaging/imsd.service @@ -14,7 +14,8 @@ Type=simple # gate + connect retries), hence TimeoutStartSec ExecStartPre=/usr/libexec/ims-pdn-up.sh TimeoutStartSec=900 -# /run/imsd.env: written by ims-pdn-up.sh (DEV= the connected ims netdev). +# /run/imsd.env: written by ims-pdn-up.sh (DEV= the connected ims netdev, +# LOCAL= the PDN address ModemManager assigned to it). # /etc/imsd.env: admin configuration — PCSCF= is REQUIRED (the carrier's # P-CSCF address; see the README's Configuration section) and wins over the # runtime-derived values. @@ -24,7 +25,8 @@ EnvironmentFile=-/etc/imsd.env StateDirectory=imsd ExecStart=/usr/bin/imsd # registration: resume an existing SA when possible (avoids fresh-SA -# throttles); a failed bring-up exits nonzero and systemd retries +# throttles); a failed bring-up exits nonzero and systemd retries, as does +# a registered imsd whose PDN address left the netdev Restart=on-failure RestartSec=120 # SIGTERM: engine keeps the SA + registration state for the next resume diff --git a/tests/Ipsec/main.cpp b/tests/Ipsec/main.cpp index 4132a3d..fcb1d89 100644 --- a/tests/Ipsec/main.cpp +++ b/tests/Ipsec/main.cpp @@ -80,6 +80,14 @@ int main() { } // incomplete input -> nullopt Check(!ParseExistingSa("src a dst b\n\tspi 0x1 reqid 1 mode transport\n", "", "b", "a").has_value(), "missing pieces -> nullopt"); + { + // a registration still on another address of the link, plus an SA to + // another P-CSCF that is none of ours + std::string two = std::format("{}src 2001:db8:1111:2222:aaaa:bbbb:cccc:dddd dst 2001:db8::105\n\tproto esp spi 0x00000001 reqid 1 mode transport\nsrc 2001:db8:1111:2222:eeee:ffff:0:1 dst 2001:db8::999\n\tproto esp spi 0x00000002 reqid 1 mode transport\n", State); + auto locals = SaLocals(two, "2001:db8::105"); + Check(locals.size() == 2 && locals[0] == "2001:db8:1111:2222:3333:4444:5555:db43" && locals[1] == "2001:db8:1111:2222:aaaa:bbbb:cccc:dddd", "SaLocals: every local address with an SA to the P-CSCF, once each, in kernel order"); + Check(SaLocals(State, "2001:db8::999").empty(), "SaLocals: no SA to that P-CSCF -> none"); + } // ---- fresh setup command sequence {