imsd/implementations/main.cpp

2765 lines
142 KiB
C++
Raw Normal View History

// SPDX-License-Identifier: GPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// lint-disable-file fixed-width-types no-char-pointer
/*
imsd — the userspace IMS/VoLTE daemon (control plane).
Owns net.catcrafts.IMS1 on the system bus with the project's frozen D-Bus ABI
(AGENTS.md §8.1) and drives it with the engine core: register on the IMS PDN (USIM AKA
+ IPsec sec-agree, fresh or warm-resume, with a keepalive re-REGISTER refresh),
run outgoing and incoming calls through the Imsd:Engine call machine (inbound
INVITEs on the protected-port listeners become ringing calls a dialer answers
via Accept), and spawn one imsd-media data-plane process per answered call.
GLib/GDBus lives only here and in ImsdBus (the polkit gate in front of call
control, shared with imsd-ofonod) — the integration daemons imsd-dialerd and
imsd-ofonod carry their own; every message, digest, SA command, and
call-state decision comes from the GLib-free core.
Threads: a dedicated engine thread runs the SIP loop (blocking framed recv,
protected-port listeners, media reaping, keepalive); the GLib main loop owns
the bus. D-Bus method calls push commands to the engine queue; engine events
reach the bus via g_idle_add, where the calls table + status snapshot live
(touched only on the main thread).
--session own the name on the session bus (development; no policy, no
polkit)
*/
#include <arpa/inet.h>
#include <fcntl.h>
#include <gio/gio.h>
#include <glib-unix.h>
#include <ifaddrs.h>
#include <netinet/in.h>
#include <poll.h>
#include <pthread.h>
#include <pwd.h>
#include <signal.h>
#include <sys/socket.h>
#include <sys/wait.h>
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
#include <time.h>
#include <unistd.h>
import std;
import Imsd;
import ImsdBus;
namespace {
// ---- static config (env-overridable, same knobs as imsd.py) ---------------
2026-10-11 00:05:59 +02:00
constexpr const char* Version = "0.3.12";
constexpr const char* BusName = "net.catcrafts.IMS1";
constexpr const char* ObjPath = "/net/catcrafts/IMS1";
constexpr const char* Iface = "net.catcrafts.IMS1";
constexpr int PortUc = imsd::util::PortUc;
constexpr int PortUs = imsd::util::PortUs;
engine, messages, util, imsd: the reregistration carries the last challenge answer and new SA parameters The keepalive refresh and the resume's re-REGISTER carried an initial registration's first-REGISTER Authorization — an empty nonce and response — in the second REGISTER's frame, with a Security-Client naming the SPIs and ports in use. TS 24.229 5.1.1.4.2 has a reregistration carry the Authorization of the last successful authentication — the last received nonce and the last calculated response — and a Security-Client with the new parameter values for the two SA pairs a re-authentication would set up; TS 33.203 7.4.0 keeps the server ports and changes the client ports. O2 UK refused the refresh of the old shape with 503 on every occasion on record (alyx, 2026-09-24 to 10-08, a stationary phone included) and accepted the fresh registrations that followed; KPN accepted the old shape without a challenge. The fresh registration now records the 401's nonce, our cnonce and the response in the registration state (auth_nonce, auth_cnonce, auth_response in the state file); a reregistration sends them verbatim, mints new SPIs and proposes PortUcNext (45063) as the client port with the server port unchanged. A registration resumed from a state file written before this change has no answer: its reregistrations carry the empty digest, as before, until its next fresh registration, and the resume says so. A 401 to a reregistration asks for re-authentication over temporary SAs on the proposed ports (TS 24.229 5.1.1.5.1), which imsd does not implement: the refresh throws it as a refusal and RegRecovery makes the next step the fresh registration, replacing the in-place answer over the old SAs. Tested with the Messages and Engine suites and on the dev phone (KPN) with a build of this commit's tree: a resume from the previous state file, a fresh registration, a resume that sent the stored answer, three refreshes at a 120-s interval, an outgoing call to a bench line and an incoming call's ring.
2026-10-10 21:00:28 +02:00
constexpr int PortUcNext = imsd::util::PortUcNext;
constexpr int InitPort = 5060;
std::string EnvOr(const char* k, std::string d) {
const char* v = std::getenv(k);
return v ? std::string(v) : std::move(d);
}
std::string SelfDir; // dir of argv[0], for locating imsd-media
void Log(std::string_view m) { std::println("imsd: {}", m); std::fflush(stdout); }
bool Is6(std::string_view a) { return a.contains(':'); }
// ---- subprocess exec ------------------------------------------------------
struct ProcResult { int rc = -1; std::string out; };
// Run argv, capture stdout (stderr discarded), wait. No shell.
ProcResult RunCapture(const std::vector<std::string>& argv) {
int pipefd[2];
if (pipe(pipefd) != 0) return {};
pid_t pid = fork();
if (pid == 0) {
dup2(pipefd[1], STDOUT_FILENO);
close(pipefd[0]); close(pipefd[1]);
int dn = open("/dev/null", O_WRONLY);
if (dn >= 0) { dup2(dn, STDERR_FILENO); close(dn); }
std::vector<char*> c;
for (auto& s : argv) c.push_back(const_cast<char*>(s.c_str()));
c.push_back(nullptr);
execvp(c[0], c.data());
_exit(127);
}
if (pid < 0) { close(pipefd[0]); close(pipefd[1]); return {}; }
close(pipefd[1]);
std::string out;
char buf[4096];
ssize_t n;
while ((n = read(pipefd[0], buf, sizeof buf)) > 0)
out.append(buf, static_cast<std::size_t>(n));
close(pipefd[0]);
int status = 0;
waitpid(pid, &status, 0);
return {WIFEXITED(status) ? WEXITSTATUS(status) : -1, std::move(out)};
}
// Run argv, discard output, return exit code.
int RunCmd(const std::vector<std::string>& argv) { return RunCapture(argv).rc; }
ipsec: build the SAs from the Security-Server mechanism the UE selects A P-CSCF may answer the challenge with every mechanism it supports and attach the SPIs and ports to the one it applied. O2 UK's core lists six ipsec-3gpp entries with q-values, md5 first, and marks the fourth (sha1, no ealg). imsd took the first ealg= anywhere in the header - the 3DES of an entry the P-CSCF had not selected - and installed SAs the far end could not decrypt: every protected packet after the 401 was dropped at its IPsec layer, over TCP and UDP alike, with nothing to answer. The 401 handling now parses the Security-Server as a mechanism list (commas outside quotes, parameters on semicolons, names and values case-insensitive) and selects the highest-q ipsec-3gpp mechanism that matches what Security-Client offered (RFC 3329 2.3.1, TS 24.229 5.1.1.5.1): alg equal to ours, ealg (absent = null) equal to the offer. The SPIs and ports are per registration and come from the entry carrying them. When no entry matches the offer, the entry carrying the SPIs is installed as listed and the journal says so; hmac-md5-96 becomes hmac(md5) with IK as the key. A single-mechanism header selects itself: on KPN the SAs are the same as before (unit-pinned argv, verified on the phone). An answer that cannot be used - no ipsec-3gpp entry, a missing spi/port, an alg, ealg, prot or mod the kernel cannot be handed - defers the bring-up on the throttle schedule with the reason, before the AKA is spent, instead of a fatal that would restart imsd every RestartSec with an initial REGISTER each time; spi-s=0 stays the throttle deferral. Repeated Security-Server header lines are joined into the one list Security-Verify echoes. The journal shows the Security-Server line, the selected mechanism, the SA parameters, and any ip xfrm command that fails (keys masked) at every fresh registration - the silence this bug produced had no line to read.
2026-09-19 21:36:57 +02:00
// argv as one journal line with key material (the "0x…" tokens) masked.
std::string RedactKeys(const std::vector<std::string>& argv) {
std::string s;
for (const auto& a : argv) {
if (!s.empty()) s += ' ';
s += a.starts_with("0x") ? "<key>" : a;
}
return s;
}
ipsec, imsd: the SA setup deletes by exact key instead of flushing the host's xfrm A fresh registration's SA setup began with `ip xfrm state flush` and `ip xfrm policy flush`: every SA userspace had installed and every SPD policy went, a policy-based VPN's (strongSwan's, libreswan's) SAs and policies included. The setup now takes out what two rules select and leaves the rest; and the SA reader, which took the P-CSCF ports for a resume from any reqid-1/2 `dir out` policy — correct only while the flush left imsd's pair alone in the kernel — takes them from the policies between our two addresses only. Whether imsd runs beside a live strongSwan or libreswan connection is unmeasured: the dev phone shows a tunnel policy and a tunnel state left in place (Tested below), no more. OwnXfrmDeleteCommands (Imsd:Ipsec) builds the deletes from the kernel text the shell reads: every transport-mode policy with reqid 1 or 2 whose selector carries imsd's protected port on the UE side, by the selector ip xfrm policy prints (src, dst, proto, sport, dport, dev) and its direction, and every 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, by src, dst, proto esp and SPI — policy or not: a setup whose policy add failed leaves such states (the setup logs a failed add and goes on), and an ESP state another transport-mode user put on that address with reqid 1 or 2 goes with them (an AH or IPComp state is not selected: the rule asks for ESP). Every reader of the policy text — the deletes and the SA reader's port pass here — goes through one predicate (detail::OwnPolicy), written for whatever reads that text later. BuildSetupCommands loses its two flush lines and the shell runs the deletes right before it (RunOwnXfrmDeletes); BuildTeardownCommands, the same two flushes with no caller, goes. A policy-based VPN's tunnel entries and another transport-mode user's pair on other ports and addresses stay. Residual: a transport-mode policy with reqid 1 and the UE-side port 45061, or reqid 2 and 45062 — alone or paired, between any addresses — carries imsd's fingerprint and goes, with the reqid-1/2 transport-mode ESP states in its direction, and a reqid-1/2 transport-mode ESP state on the UE's ims-PDN address goes whoever made it (tests/Ipsec pins both, and that an AH state stays). Tested: crafter-build test, 7 passed (the 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 the pair untouched; another transport-mode user's pair on other ports and addresses untouched, its reqid-1 ESP state on the UE's address deleted whoever made it, its AH state left; a policy-less state on the UE's address deleted, one between other addresses left; the reader's ports from our pair beside a foreign transport policy with ports); on the dev phone (KPN), in builds of this series, a fresh registration with a VPN's tunnel policy and state in the kernel: both still there afterwards, imsd's previous pair gone.
2026-10-13 22:28:02 +02:00
// imsd's own xfrm entries out of the kernel, each by its key (the
// OwnXfrmDeleteCommands list — never `ip xfrm … flush`: another IPsec user's
// entries, a policy-based VPN's, stay, §12); a delete that fails (an entry
// gone between the read and the delete) is logged and the rest go on. At
// file scope, as LoadHolds and PersistHolds are.
void RunOwnXfrmDeletes(const std::vector<std::vector<std::string>>& cmds) {
for (auto& cmd : cmds) {
int rc = RunCmd(cmd);
if (rc != 0) Log(std::format("{} -> rc {}", RedactKeys(cmd), rc));
}
}
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.
2026-10-11 15:58:49 +02:00
// 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) {
std::size_t at = text.find(key);
if (at == std::string_view::npos) return std::nullopt;
std::size_t start = at + key.size();
std::size_t end = text.find('"', start);
if (end == std::string_view::npos) return std::nullopt;
return std::string(text.substr(start, end - start));
}
std::optional<long> IntAfter(std::string_view text, std::string_view key) {
std::size_t at = text.find(key);
if (at == std::string_view::npos) return std::nullopt;
std::size_t i = at + key.size();
std::size_t start = i;
while (i < text.size() && text[i] >= '0' && text[i] <= '9') i++;
if (i == start) return std::nullopt;
long v = 0;
std::from_chars(text.data() + start, text.data() + i, v);
return v;
}
// value after "completed:" — integer or the hex ("AB:CD:..") token
std::optional<long> CompletedInt(std::string_view out) {
return IntAfter(out, "completed: ");
}
std::optional<std::string> CompletedHex(std::string_view out) {
std::size_t at = out.find("completed:");
if (at == std::string_view::npos) return std::nullopt;
std::size_t i = at + 10;
while (i < out.size() && (out[i] == ' ' || out[i] == '\t')) i++;
std::size_t start = i;
auto ok = [](char c) {
return (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') ||
(c >= 'a' && c <= 'f') || c == ':';
};
while (i < out.size() && ok(out[i])) i++;
if (i == start) return std::nullopt;
std::string s(out.substr(start, i - start));
std::string h;
for (char c : s) if (c != ':') h.push_back(static_cast<char>(std::tolower(c)));
return h;
}
// The UE address on the ims PDN when LOCAL is not given: a global IPv6 on
// <dev> 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<std::string> DetectLocal(const std::string& dev) {
auto r = RunCapture({"ip", "-6", "addr", "show", "dev", dev, "scope", "global"});
std::optional<std::string> 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<const void*>(&reinterpret_cast<sockaddr_in6*>(i->ifa_addr)->sin6_addr)
: static_cast<const void*>(&reinterpret_cast<sockaddr_in*>(i->ifa_addr)->sin_addr);
found = std::memcmp(have, want, fam == AF_INET6 ? 16 : 4) == 0;
}
freeifaddrs(ifs);
return found;
}
// The account whose PipeWire session carries call audio: AUDIO_USER when
// it names an account, else logind's seat0 session (`loginctl show-seat
// seat0` -> `show-session`) when that is a user session — not phrog's
// greeter — with a PipeWire socket. imsd-media gets the answer as
// AUDIO_USER. As packaged (root, on the system bus) imsd resolves it at
// start, so a wrong name or a missing session is in the journal before the
// first call rather than after two silent rounds of trial (alyx report 14,
// 2026-09-20), and again when a call's media starts, because the answer
// changes when someone logs in.
struct AudioSession { std::string name; unsigned uid = 0; std::string how; };
std::string LoginctlProp(std::string_view text, std::string_view key) {
for (auto line : std::views::split(text, '\n')) {
std::string_view l(line);
if (l.size() > key.size() && l.starts_with(key) && l[key.size()] == '=') return std::string(l.substr(key.size() + 1));
}
return {};
}
AudioSession ResolveAudioSession() {
AudioSession s;
passwd pw{};
passwd* res = nullptr;
char buf[4096];
std::string want = EnvOr("AUDIO_USER", "");
if (!want.empty()) {
if (getpwnam_r(want.c_str(), &pw, buf, sizeof buf, &res) == 0 && res) {
s.name = want; s.uid = res->pw_uid; s.how = "AUDIO_USER";
return s;
}
Log(std::format("AUDIO_USER '{}' is not an account; using logind's seat0 session instead", want));
}
auto seat = RunCapture({"loginctl", "show-seat", "seat0", "-p", "ActiveSession", "--value"});
std::string id = seat.out;
while (!id.empty() && (id.back() == '\n' || id.back() == ' ')) id.pop_back();
if (seat.rc != 0 || id.empty()) { s.how = "no session on seat0"; return s; }
auto sess = RunCapture({"loginctl", "show-session", id, "-p", "Name", "-p", "Class", "-p", "User"});
std::string name = LoginctlProp(sess.out, "Name"), cls = LoginctlProp(sess.out, "Class"), uid = LoginctlProp(sess.out, "User");
unsigned u = 0;
auto [end, err] = std::from_chars(uid.data(), uid.data() + uid.size(), u);
if (sess.rc != 0 || name.empty() || err != std::errc{} || end != uid.data() + uid.size()) { s.how = std::format("seat0 session {} not readable", id); return s; }
if (!cls.starts_with("user")) { s.how = std::format("seat0 session {} is {}'s {} session, nobody logged in", id, name, cls); return s; }
std::error_code ec;
if (!std::filesystem::exists(std::format("/run/user/{}/pipewire-0", u), ec)) { s.how = std::format("seat0 session {} ({}) has no PipeWire socket in /run/user/{}", id, name, u); return s; }
s.name = name; s.uid = u; s.how = std::format("logind seat0 session {}", id);
return s;
}
std::string AudioSessionLine(const AudioSession& s) {
if (!s.name.empty()) return std::format("audio session: {} (uid {}, {})", s.name, s.uid, s.how);
return std::format("audio session: none — {}", s.how);
}
void LogAudioSession() {
AudioSession s = ResolveAudioSession();
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<std::string>* discovered = nullptr, std::vector<std::string>* rejected = nullptr) {
std::string configured = imsd::util::CanonicalAddr(EnvOr("PCSCF", ""));
std::vector<std::string> 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);
std::string s(ip);
if (Is6(ip)) {
auto* a = reinterpret_cast<sockaddr_in6*>(&ss);
a->sin6_family = AF_INET6; a->sin6_port = htons(static_cast<uint16_t>(port));
inet_pton(AF_INET6, s.c_str(), &a->sin6_addr);
return sizeof(sockaddr_in6);
}
auto* a = reinterpret_cast<sockaddr_in*>(&ss);
a->sin_family = AF_INET; a->sin_port = htons(static_cast<uint16_t>(port));
inet_pton(AF_INET, s.c_str(), &a->sin_addr);
return sizeof(sockaddr_in);
}
// ---- SIP over the protected TCP flow --------------------------------------
sip: UDP for the protected leg, TCP first with a fallback imsd's protected leg (the second REGISTER and everything after it) was TCP only. A P-CSCF that never answers the TCP connect on its protected server port (O2 UK: the SYNs leave ESP-protected, nothing comes back) left the unit looping with no way forward, although the SAs, the listener sockets and the firewall rule already covered UDP. The client flow now carries a transport. UDP binds the same protected client port and connect()s the datagram socket to the P-CSCF's protected server port, so the kernel delivers that peer's datagrams to it ahead of the unconnected listener on the same port. One datagram is one message (RFC 3261 18.3: a Content-Length that fits truncates, one that does not fit discards the datagram, none means the rest of the datagram); a receive error is logged and marks the flow dead; a message over the single-packet ESP budget at the ims PDN's MTU is logged once per flow, since the kernel fragments it and a P-CSCF may drop the fragments. Every protected request's Via follows the transport; the challenge stays UDP. SIP_TRANSPORT selects the policy: auto (default) registers over TCP and, after two unanswered connects, registers again over UDP (new challenge, new SA pair) - but only on a phone whose state file does not record a successful TCP registration, so an outage on a TCP carrier never becomes a second initial REGISTER; a phone whose last registration was UDP goes straight to UDP. tcp and udp force one. A connect refused locally (EADDRNOTAVAIL: the 4-tuple still in TIME_WAIT from the previous flow) keeps retrying and is not counted as silence; SO_ERROR results are logged by name. The registration's transport is persisted so a warm resume reconnects the same way, and the resume gives up after two silences. Two things the change exposed and fixes: an engine-fatal event exited 0 ("exiting for systemd restart" with Restart=on-failure never firing), and a fresh registration refused with the P-CSCF's fresh-SA throttle (Security-Server spi-s=0) must not be retried by a 120-s restart loop, since every attempt re-arms the ~20-min window - it is now waited out in-process, growing on repeats, with D-Bus commands still served. A resume the network refuses falls through to a fresh registration. A retransmitted 200 OK to our INVITE is ACKed again and no longer starts a second media leg. Verified on KPN: resume over TCP after a binary swap, MT and MO calls with media both ways, the throttle deferral and its self-recovery, the UDP client path up to KPN dropping the datagram. The UDP success path is a field test; SIP retransmission timers over UDP are not in this change.
2026-09-17 14:58:46 +02:00
enum class Transport { Tcp, Udp };
constexpr std::string_view TransportName(Transport t) { return t == Transport::Udp ? "UDP" : "TCP"; }
// The protected client flow: our requests to the P-CSCF's protected server
// port and their responses. TCP is the shape every carrier that works so far
// serves; UDP is the alternative for a P-CSCF that never answers the TCP
// connect (O2 UK, 2026-09-17). Both run through the same SA pair — the xfrm
// policies select on ports only — and the same Via port.
// A P-CSCF refusing a fresh security association (KPN: Security-Server
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
// spi-s=0 to a fresh REGISTER on an address whose registration is still
// live). Handled in-process: a
sip: UDP for the protected leg, TCP first with a fallback imsd's protected leg (the second REGISTER and everything after it) was TCP only. A P-CSCF that never answers the TCP connect on its protected server port (O2 UK: the SYNs leave ESP-protected, nothing comes back) left the unit looping with no way forward, although the SAs, the listener sockets and the firewall rule already covered UDP. The client flow now carries a transport. UDP binds the same protected client port and connect()s the datagram socket to the P-CSCF's protected server port, so the kernel delivers that peer's datagrams to it ahead of the unconnected listener on the same port. One datagram is one message (RFC 3261 18.3: a Content-Length that fits truncates, one that does not fit discards the datagram, none means the rest of the datagram); a receive error is logged and marks the flow dead; a message over the single-packet ESP budget at the ims PDN's MTU is logged once per flow, since the kernel fragments it and a P-CSCF may drop the fragments. Every protected request's Via follows the transport; the challenge stays UDP. SIP_TRANSPORT selects the policy: auto (default) registers over TCP and, after two unanswered connects, registers again over UDP (new challenge, new SA pair) - but only on a phone whose state file does not record a successful TCP registration, so an outage on a TCP carrier never becomes a second initial REGISTER; a phone whose last registration was UDP goes straight to UDP. tcp and udp force one. A connect refused locally (EADDRNOTAVAIL: the 4-tuple still in TIME_WAIT from the previous flow) keeps retrying and is not counted as silence; SO_ERROR results are logged by name. The registration's transport is persisted so a warm resume reconnects the same way, and the resume gives up after two silences. Two things the change exposed and fixes: an engine-fatal event exited 0 ("exiting for systemd restart" with Restart=on-failure never firing), and a fresh registration refused with the P-CSCF's fresh-SA throttle (Security-Server spi-s=0) must not be retried by a 120-s restart loop, since every attempt re-arms the ~20-min window - it is now waited out in-process, growing on repeats, with D-Bus commands still served. A resume the network refuses falls through to a fresh registration. A retransmitted 200 OK to our INVITE is ACKed again and no longer starts a second media leg. Verified on KPN: resume over TCP after a binary swap, MT and MO calls with media both ways, the throttle deferral and its self-recovery, the UDP client path up to KPN dropping the datagram. The UDP success path is a field test; SIP retransmission timers over UDP are not in this change.
2026-09-17 14:58:46 +02:00
// systemd restart every RestartSec would never clear it.
struct ThrottledError : std::runtime_error { using std::runtime_error::runtime_error; };
engine, messages, util, imsd: the reregistration carries the last challenge answer and new SA parameters The keepalive refresh and the resume's re-REGISTER carried an initial registration's first-REGISTER Authorization — an empty nonce and response — in the second REGISTER's frame, with a Security-Client naming the SPIs and ports in use. TS 24.229 5.1.1.4.2 has a reregistration carry the Authorization of the last successful authentication — the last received nonce and the last calculated response — and a Security-Client with the new parameter values for the two SA pairs a re-authentication would set up; TS 33.203 7.4.0 keeps the server ports and changes the client ports. O2 UK refused the refresh of the old shape with 503 on every occasion on record (alyx, 2026-09-24 to 10-08, a stationary phone included) and accepted the fresh registrations that followed; KPN accepted the old shape without a challenge. The fresh registration now records the 401's nonce, our cnonce and the response in the registration state (auth_nonce, auth_cnonce, auth_response in the state file); a reregistration sends them verbatim, mints new SPIs and proposes PortUcNext (45063) as the client port with the server port unchanged. A registration resumed from a state file written before this change has no answer: its reregistrations carry the empty digest, as before, until its next fresh registration, and the resume says so. A 401 to a reregistration asks for re-authentication over temporary SAs on the proposed ports (TS 24.229 5.1.1.5.1), which imsd does not implement: the refresh throws it as a refusal and RegRecovery makes the next step the fresh registration, replacing the in-place answer over the old SAs. Tested with the Messages and Engine suites and on the dev phone (KPN) with a build of this commit's tree: a resume from the previous state file, a fresh registration, a resume that sent the stored answer, three refreshes at a 120-s interval, an outgoing call to a bench line and an incoming call's ring.
2026-10-10 21:00:28 +02:00
// A REGISTER the network refused: the protected one, or a reregistration,
// answered with a final other than 200 (a reregistration's 401 asks for a
// re-authentication imsd does not do over the old SAs: a refusal carrying
// 401); the unprotected initial one answered with a final from 300 up (its
// 401 is the challenge). The status and its Retry-After feed
// imsd::engine::RegRecovery.
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
struct RefusedError : std::runtime_error {
int status;
long retryAfter;
RefusedError(const std::string& what, int st, long ra) : std::runtime_error(what), status(st), retryAfter(ra) {}
};
sip: UDP for the protected leg, TCP first with a fallback imsd's protected leg (the second REGISTER and everything after it) was TCP only. A P-CSCF that never answers the TCP connect on its protected server port (O2 UK: the SYNs leave ESP-protected, nothing comes back) left the unit looping with no way forward, although the SAs, the listener sockets and the firewall rule already covered UDP. The client flow now carries a transport. UDP binds the same protected client port and connect()s the datagram socket to the P-CSCF's protected server port, so the kernel delivers that peer's datagrams to it ahead of the unconnected listener on the same port. One datagram is one message (RFC 3261 18.3: a Content-Length that fits truncates, one that does not fit discards the datagram, none means the rest of the datagram); a receive error is logged and marks the flow dead; a message over the single-packet ESP budget at the ims PDN's MTU is logged once per flow, since the kernel fragments it and a P-CSCF may drop the fragments. Every protected request's Via follows the transport; the challenge stays UDP. SIP_TRANSPORT selects the policy: auto (default) registers over TCP and, after two unanswered connects, registers again over UDP (new challenge, new SA pair) - but only on a phone whose state file does not record a successful TCP registration, so an outage on a TCP carrier never becomes a second initial REGISTER; a phone whose last registration was UDP goes straight to UDP. tcp and udp force one. A connect refused locally (EADDRNOTAVAIL: the 4-tuple still in TIME_WAIT from the previous flow) keeps retrying and is not counted as silence; SO_ERROR results are logged by name. The registration's transport is persisted so a warm resume reconnects the same way, and the resume gives up after two silences. Two things the change exposed and fixes: an engine-fatal event exited 0 ("exiting for systemd restart" with Restart=on-failure never firing), and a fresh registration refused with the P-CSCF's fresh-SA throttle (Security-Server spi-s=0) must not be retried by a 120-s restart loop, since every attempt re-arms the ~20-min window - it is now waited out in-process, growing on repeats, with D-Bus commands still served. A resume the network refuses falls through to a fresh registration. A retransmitted 200 OK to our INVITE is ACKed again and no longer starts a second media leg. Verified on KPN: resume over TCP after a binary swap, MT and MO calls with media both ways, the throttle deferral and its self-recovery, the UDP client path up to KPN dropping the datagram. The UDP success path is a field test; SIP retransmission timers over UDP are not in this change.
2026-09-17 14:58:46 +02:00
// Bytes of SIP that fit one ESP-protected IPv6 packet at the ims PDN's
// 1280-byte MTU (40 IPv6 + 8 ESP + 16 IV + 8 UDP + padding + 12 ICV).
constexpr std::size_t UdpSinglePacketBudget = 1190;
class SipFlow {
public:
sip: UDP for the protected leg, TCP first with a fallback imsd's protected leg (the second REGISTER and everything after it) was TCP only. A P-CSCF that never answers the TCP connect on its protected server port (O2 UK: the SYNs leave ESP-protected, nothing comes back) left the unit looping with no way forward, although the SAs, the listener sockets and the firewall rule already covered UDP. The client flow now carries a transport. UDP binds the same protected client port and connect()s the datagram socket to the P-CSCF's protected server port, so the kernel delivers that peer's datagrams to it ahead of the unconnected listener on the same port. One datagram is one message (RFC 3261 18.3: a Content-Length that fits truncates, one that does not fit discards the datagram, none means the rest of the datagram); a receive error is logged and marks the flow dead; a message over the single-packet ESP budget at the ims PDN's MTU is logged once per flow, since the kernel fragments it and a P-CSCF may drop the fragments. Every protected request's Via follows the transport; the challenge stays UDP. SIP_TRANSPORT selects the policy: auto (default) registers over TCP and, after two unanswered connects, registers again over UDP (new challenge, new SA pair) - but only on a phone whose state file does not record a successful TCP registration, so an outage on a TCP carrier never becomes a second initial REGISTER; a phone whose last registration was UDP goes straight to UDP. tcp and udp force one. A connect refused locally (EADDRNOTAVAIL: the 4-tuple still in TIME_WAIT from the previous flow) keeps retrying and is not counted as silence; SO_ERROR results are logged by name. The registration's transport is persisted so a warm resume reconnects the same way, and the resume gives up after two silences. Two things the change exposed and fixes: an engine-fatal event exited 0 ("exiting for systemd restart" with Restart=on-failure never firing), and a fresh registration refused with the P-CSCF's fresh-SA throttle (Security-Server spi-s=0) must not be retried by a 120-s restart loop, since every attempt re-arms the ~20-min window - it is now waited out in-process, growing on repeats, with D-Bus commands still served. A resume the network refuses falls through to a fresh registration. A retransmitted 200 OK to our INVITE is ACKed again and no longer starts a second media leg. Verified on KPN: resume over TCP after a binary swap, MT and MO calls with media both ways, the throttle deferral and its self-recovery, the UDP client path up to KPN dropping the datagram. The UDP success path is a field test; SIP retransmission timers over UDP are not in this change.
2026-09-17 14:58:46 +02:00
// TCP: connect up to `tries` times (12-s timeout each, 10 s apart). An
// attempt the network never answers counts towards `silentLimit` (0 =
// unlimited); an attempt refused locally — connect() itself failing:
// EADDRNOTAVAIL (the 4-tuple still in TIME_WAIT from the previous flow),
// ENETUNREACH (no route yet) — does not, so a restart within 60 s of the
// last one keeps retrying instead of concluding the P-CSCF is silent. UDP: bind the same client port and connect() the
// datagram socket to the same peer — the kernel then delivers that peer's
// datagrams here in preference to the unconnected ServerPorts socket on
// the same port. A UDP connect() cannot be refused by the peer, so it is
// one attempt.
bool Connect(const std::string& local, int lport, const std::string& pcscf, int pport, Transport t, int tries = 10, int silentLimit = 0) {
transport_ = t;
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
lastConnectLocal_ = false;
int fam = Is6(local) ? AF_INET6 : AF_INET;
sip: UDP for the protected leg, TCP first with a fallback imsd's protected leg (the second REGISTER and everything after it) was TCP only. A P-CSCF that never answers the TCP connect on its protected server port (O2 UK: the SYNs leave ESP-protected, nothing comes back) left the unit looping with no way forward, although the SAs, the listener sockets and the firewall rule already covered UDP. The client flow now carries a transport. UDP binds the same protected client port and connect()s the datagram socket to the P-CSCF's protected server port, so the kernel delivers that peer's datagrams to it ahead of the unconnected listener on the same port. One datagram is one message (RFC 3261 18.3: a Content-Length that fits truncates, one that does not fit discards the datagram, none means the rest of the datagram); a receive error is logged and marks the flow dead; a message over the single-packet ESP budget at the ims PDN's MTU is logged once per flow, since the kernel fragments it and a P-CSCF may drop the fragments. Every protected request's Via follows the transport; the challenge stays UDP. SIP_TRANSPORT selects the policy: auto (default) registers over TCP and, after two unanswered connects, registers again over UDP (new challenge, new SA pair) - but only on a phone whose state file does not record a successful TCP registration, so an outage on a TCP carrier never becomes a second initial REGISTER; a phone whose last registration was UDP goes straight to UDP. tcp and udp force one. A connect refused locally (EADDRNOTAVAIL: the 4-tuple still in TIME_WAIT from the previous flow) keeps retrying and is not counted as silence; SO_ERROR results are logged by name. The registration's transport is persisted so a warm resume reconnects the same way, and the resume gives up after two silences. Two things the change exposed and fixes: an engine-fatal event exited 0 ("exiting for systemd restart" with Restart=on-failure never firing), and a fresh registration refused with the P-CSCF's fresh-SA throttle (Security-Server spi-s=0) must not be retried by a 120-s restart loop, since every attempt re-arms the ~20-min window - it is now waited out in-process, growing on repeats, with D-Bus commands still served. A resume the network refuses falls through to a fresh registration. A retransmitted 200 OK to our INVITE is ACKed again and no longer starts a second media leg. Verified on KPN: resume over TCP after a binary swap, MT and MO calls with media both ways, the throttle deferral and its self-recovery, the UDP client path up to KPN dropping the datagram. The UDP success path is a field test; SIP retransmission timers over UDP are not in this change.
2026-09-17 14:58:46 +02:00
if (t == Transport::Udp) {
int fd = socket(fam, SOCK_DGRAM, 0);
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
if (fd < 0) return SocketFailed();
sip: UDP for the protected leg, TCP first with a fallback imsd's protected leg (the second REGISTER and everything after it) was TCP only. A P-CSCF that never answers the TCP connect on its protected server port (O2 UK: the SYNs leave ESP-protected, nothing comes back) left the unit looping with no way forward, although the SAs, the listener sockets and the firewall rule already covered UDP. The client flow now carries a transport. UDP binds the same protected client port and connect()s the datagram socket to the P-CSCF's protected server port, so the kernel delivers that peer's datagrams to it ahead of the unconnected listener on the same port. One datagram is one message (RFC 3261 18.3: a Content-Length that fits truncates, one that does not fit discards the datagram, none means the rest of the datagram); a receive error is logged and marks the flow dead; a message over the single-packet ESP budget at the ims PDN's MTU is logged once per flow, since the kernel fragments it and a P-CSCF may drop the fragments. Every protected request's Via follows the transport; the challenge stays UDP. SIP_TRANSPORT selects the policy: auto (default) registers over TCP and, after two unanswered connects, registers again over UDP (new challenge, new SA pair) - but only on a phone whose state file does not record a successful TCP registration, so an outage on a TCP carrier never becomes a second initial REGISTER; a phone whose last registration was UDP goes straight to UDP. tcp and udp force one. A connect refused locally (EADDRNOTAVAIL: the 4-tuple still in TIME_WAIT from the previous flow) keeps retrying and is not counted as silence; SO_ERROR results are logged by name. The registration's transport is persisted so a warm resume reconnects the same way, and the resume gives up after two silences. Two things the change exposed and fixes: an engine-fatal event exited 0 ("exiting for systemd restart" with Restart=on-failure never firing), and a fresh registration refused with the P-CSCF's fresh-SA throttle (Security-Server spi-s=0) must not be retried by a 120-s restart loop, since every attempt re-arms the ~20-min window - it is now waited out in-process, growing on repeats, with D-Bus commands still served. A resume the network refuses falls through to a fresh registration. A retransmitted 200 OK to our INVITE is ACKed again and no longer starts a second media leg. Verified on KPN: resume over TCP after a binary swap, MT and MO calls with media both ways, the throttle deferral and its self-recovery, the UDP client path up to KPN dropping the datagram. The UDP success path is a field test; SIP retransmission timers over UDP are not in this change.
2026-09-17 14:58:46 +02:00
int one = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &one, sizeof one);
sockaddr_storage la; socklen_t ll = MakeAddr(local, lport, la);
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
if (!Bind(fd, la, ll, local, lport)) return false;
sip: UDP for the protected leg, TCP first with a fallback imsd's protected leg (the second REGISTER and everything after it) was TCP only. A P-CSCF that never answers the TCP connect on its protected server port (O2 UK: the SYNs leave ESP-protected, nothing comes back) left the unit looping with no way forward, although the SAs, the listener sockets and the firewall rule already covered UDP. The client flow now carries a transport. UDP binds the same protected client port and connect()s the datagram socket to the P-CSCF's protected server port, so the kernel delivers that peer's datagrams to it ahead of the unconnected listener on the same port. One datagram is one message (RFC 3261 18.3: a Content-Length that fits truncates, one that does not fit discards the datagram, none means the rest of the datagram); a receive error is logged and marks the flow dead; a message over the single-packet ESP budget at the ims PDN's MTU is logged once per flow, since the kernel fragments it and a P-CSCF may drop the fragments. Every protected request's Via follows the transport; the challenge stays UDP. SIP_TRANSPORT selects the policy: auto (default) registers over TCP and, after two unanswered connects, registers again over UDP (new challenge, new SA pair) - but only on a phone whose state file does not record a successful TCP registration, so an outage on a TCP carrier never becomes a second initial REGISTER; a phone whose last registration was UDP goes straight to UDP. tcp and udp force one. A connect refused locally (EADDRNOTAVAIL: the 4-tuple still in TIME_WAIT from the previous flow) keeps retrying and is not counted as silence; SO_ERROR results are logged by name. The registration's transport is persisted so a warm resume reconnects the same way, and the resume gives up after two silences. Two things the change exposed and fixes: an engine-fatal event exited 0 ("exiting for systemd restart" with Restart=on-failure never firing), and a fresh registration refused with the P-CSCF's fresh-SA throttle (Security-Server spi-s=0) must not be retried by a 120-s restart loop, since every attempt re-arms the ~20-min window - it is now waited out in-process, growing on repeats, with D-Bus commands still served. A resume the network refuses falls through to a fresh registration. A retransmitted 200 OK to our INVITE is ACKed again and no longer starts a second media leg. Verified on KPN: resume over TCP after a binary swap, MT and MO calls with media both ways, the throttle deferral and its self-recovery, the UDP client path up to KPN dropping the datagram. The UDP success path is a field test; SIP retransmission timers over UDP are not in this change.
2026-09-17 14:58:46 +02:00
sockaddr_storage pa; socklen_t pl = MakeAddr(pcscf, pport, pa);
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
if (connect(fd, reinterpret_cast<sockaddr*>(&pa), pl) != 0) {
// a UDP connect() cannot be refused by the peer: no route
// yet, and the like — local, like the TCP path's ENETUNREACH
Log(std::format("UDP connect: {}", std::strerror(errno)));
close(fd); lastConnectLocal_ = true; return false;
}
sip: UDP for the protected leg, TCP first with a fallback imsd's protected leg (the second REGISTER and everything after it) was TCP only. A P-CSCF that never answers the TCP connect on its protected server port (O2 UK: the SYNs leave ESP-protected, nothing comes back) left the unit looping with no way forward, although the SAs, the listener sockets and the firewall rule already covered UDP. The client flow now carries a transport. UDP binds the same protected client port and connect()s the datagram socket to the P-CSCF's protected server port, so the kernel delivers that peer's datagrams to it ahead of the unconnected listener on the same port. One datagram is one message (RFC 3261 18.3: a Content-Length that fits truncates, one that does not fit discards the datagram, none means the rest of the datagram); a receive error is logged and marks the flow dead; a message over the single-packet ESP budget at the ims PDN's MTU is logged once per flow, since the kernel fragments it and a P-CSCF may drop the fragments. Every protected request's Via follows the transport; the challenge stays UDP. SIP_TRANSPORT selects the policy: auto (default) registers over TCP and, after two unanswered connects, registers again over UDP (new challenge, new SA pair) - but only on a phone whose state file does not record a successful TCP registration, so an outage on a TCP carrier never becomes a second initial REGISTER; a phone whose last registration was UDP goes straight to UDP. tcp and udp force one. A connect refused locally (EADDRNOTAVAIL: the 4-tuple still in TIME_WAIT from the previous flow) keeps retrying and is not counted as silence; SO_ERROR results are logged by name. The registration's transport is persisted so a warm resume reconnects the same way, and the resume gives up after two silences. Two things the change exposed and fixes: an engine-fatal event exited 0 ("exiting for systemd restart" with Restart=on-failure never firing), and a fresh registration refused with the P-CSCF's fresh-SA throttle (Security-Server spi-s=0) must not be retried by a 120-s restart loop, since every attempt re-arms the ~20-min window - it is now waited out in-process, growing on repeats, with D-Bus commands still served. A resume the network refuses falls through to a fresh registration. A retransmitted 200 OK to our INVITE is ACKed again and no longer starts a second media leg. Verified on KPN: resume over TCP after a binary swap, MT and MO calls with media both ways, the throttle deferral and its self-recovery, the UDP client path up to KPN dropping the datagram. The UDP success path is a field test; SIP retransmission timers over UDP are not in this change.
2026-09-17 14:58:46 +02:00
fd_ = fd; alive_ = true; fb_ = {}; sizeWarned_ = false;
return true;
}
int silent = 0;
for (int attempt = 0; attempt < tries; attempt++) {
int fd = socket(fam, SOCK_STREAM, 0);
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
if (fd < 0) return SocketFailed();
int one = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
// REUSEPORT (paired with the listener's): a reconnect must bind
// the protected client port while the ServerPorts listener holds
// it in LISTEN state — REUSEADDR alone only covers TIME_WAIT.
setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &one, sizeof one);
sockaddr_storage la; socklen_t ll = MakeAddr(local, lport, la);
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
if (!Bind(fd, la, ll, local, lport)) return false;
sockaddr_storage pa; socklen_t pl = MakeAddr(pcscf, pport, pa);
// non-blocking connect + poll: Linux connect() ignores SO_SNDTIMEO,
// so bound it ourselves (a silent P-CSCF must not hang the engine).
fcntl(fd, F_SETFL, O_NONBLOCK);
int rc = connect(fd, reinterpret_cast<sockaddr*>(&pa), pl);
bool connected = (rc == 0);
sip: UDP for the protected leg, TCP first with a fallback imsd's protected leg (the second REGISTER and everything after it) was TCP only. A P-CSCF that never answers the TCP connect on its protected server port (O2 UK: the SYNs leave ESP-protected, nothing comes back) left the unit looping with no way forward, although the SAs, the listener sockets and the firewall rule already covered UDP. The client flow now carries a transport. UDP binds the same protected client port and connect()s the datagram socket to the P-CSCF's protected server port, so the kernel delivers that peer's datagrams to it ahead of the unconnected listener on the same port. One datagram is one message (RFC 3261 18.3: a Content-Length that fits truncates, one that does not fit discards the datagram, none means the rest of the datagram); a receive error is logged and marks the flow dead; a message over the single-packet ESP budget at the ims PDN's MTU is logged once per flow, since the kernel fragments it and a P-CSCF may drop the fragments. Every protected request's Via follows the transport; the challenge stays UDP. SIP_TRANSPORT selects the policy: auto (default) registers over TCP and, after two unanswered connects, registers again over UDP (new challenge, new SA pair) - but only on a phone whose state file does not record a successful TCP registration, so an outage on a TCP carrier never becomes a second initial REGISTER; a phone whose last registration was UDP goes straight to UDP. tcp and udp force one. A connect refused locally (EADDRNOTAVAIL: the 4-tuple still in TIME_WAIT from the previous flow) keeps retrying and is not counted as silence; SO_ERROR results are logged by name. The registration's transport is persisted so a warm resume reconnects the same way, and the resume gives up after two silences. Two things the change exposed and fixes: an engine-fatal event exited 0 ("exiting for systemd restart" with Restart=on-failure never firing), and a fresh registration refused with the P-CSCF's fresh-SA throttle (Security-Server spi-s=0) must not be retried by a 120-s restart loop, since every attempt re-arms the ~20-min window - it is now waited out in-process, growing on repeats, with D-Bus commands still served. A resume the network refuses falls through to a fresh registration. A retransmitted 200 OK to our INVITE is ACKed again and no longer starts a second media leg. Verified on KPN: resume over TCP after a binary swap, MT and MO calls with media both ways, the throttle deferral and its self-recovery, the UDP client path up to KPN dropping the datagram. The UDP success path is a field test; SIP retransmission timers over UDP are not in this change.
2026-09-17 14:58:46 +02:00
bool localRefusal = (rc < 0 && errno != EINPROGRESS);
int err = localRefusal ? errno : 0;
if (rc < 0 && errno == EINPROGRESS) {
pollfd p{fd, POLLOUT, 0};
if (poll(&p, 1, 12000) > 0) {
sip: UDP for the protected leg, TCP first with a fallback imsd's protected leg (the second REGISTER and everything after it) was TCP only. A P-CSCF that never answers the TCP connect on its protected server port (O2 UK: the SYNs leave ESP-protected, nothing comes back) left the unit looping with no way forward, although the SAs, the listener sockets and the firewall rule already covered UDP. The client flow now carries a transport. UDP binds the same protected client port and connect()s the datagram socket to the P-CSCF's protected server port, so the kernel delivers that peer's datagrams to it ahead of the unconnected listener on the same port. One datagram is one message (RFC 3261 18.3: a Content-Length that fits truncates, one that does not fit discards the datagram, none means the rest of the datagram); a receive error is logged and marks the flow dead; a message over the single-packet ESP budget at the ims PDN's MTU is logged once per flow, since the kernel fragments it and a P-CSCF may drop the fragments. Every protected request's Via follows the transport; the challenge stays UDP. SIP_TRANSPORT selects the policy: auto (default) registers over TCP and, after two unanswered connects, registers again over UDP (new challenge, new SA pair) - but only on a phone whose state file does not record a successful TCP registration, so an outage on a TCP carrier never becomes a second initial REGISTER; a phone whose last registration was UDP goes straight to UDP. tcp and udp force one. A connect refused locally (EADDRNOTAVAIL: the 4-tuple still in TIME_WAIT from the previous flow) keeps retrying and is not counted as silence; SO_ERROR results are logged by name. The registration's transport is persisted so a warm resume reconnects the same way, and the resume gives up after two silences. Two things the change exposed and fixes: an engine-fatal event exited 0 ("exiting for systemd restart" with Restart=on-failure never firing), and a fresh registration refused with the P-CSCF's fresh-SA throttle (Security-Server spi-s=0) must not be retried by a 120-s restart loop, since every attempt re-arms the ~20-min window - it is now waited out in-process, growing on repeats, with D-Bus commands still served. A resume the network refuses falls through to a fresh registration. A retransmitted 200 OK to our INVITE is ACKed again and no longer starts a second media leg. Verified on KPN: resume over TCP after a binary swap, MT and MO calls with media both ways, the throttle deferral and its self-recovery, the UDP client path up to KPN dropping the datagram. The UDP success path is a field test; SIP retransmission timers over UDP are not in this change.
2026-09-17 14:58:46 +02:00
socklen_t el = sizeof err;
getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &el);
connected = (err == 0);
sip: UDP for the protected leg, TCP first with a fallback imsd's protected leg (the second REGISTER and everything after it) was TCP only. A P-CSCF that never answers the TCP connect on its protected server port (O2 UK: the SYNs leave ESP-protected, nothing comes back) left the unit looping with no way forward, although the SAs, the listener sockets and the firewall rule already covered UDP. The client flow now carries a transport. UDP binds the same protected client port and connect()s the datagram socket to the P-CSCF's protected server port, so the kernel delivers that peer's datagrams to it ahead of the unconnected listener on the same port. One datagram is one message (RFC 3261 18.3: a Content-Length that fits truncates, one that does not fit discards the datagram, none means the rest of the datagram); a receive error is logged and marks the flow dead; a message over the single-packet ESP budget at the ims PDN's MTU is logged once per flow, since the kernel fragments it and a P-CSCF may drop the fragments. Every protected request's Via follows the transport; the challenge stays UDP. SIP_TRANSPORT selects the policy: auto (default) registers over TCP and, after two unanswered connects, registers again over UDP (new challenge, new SA pair) - but only on a phone whose state file does not record a successful TCP registration, so an outage on a TCP carrier never becomes a second initial REGISTER; a phone whose last registration was UDP goes straight to UDP. tcp and udp force one. A connect refused locally (EADDRNOTAVAIL: the 4-tuple still in TIME_WAIT from the previous flow) keeps retrying and is not counted as silence; SO_ERROR results are logged by name. The registration's transport is persisted so a warm resume reconnects the same way, and the resume gives up after two silences. Two things the change exposed and fixes: an engine-fatal event exited 0 ("exiting for systemd restart" with Restart=on-failure never firing), and a fresh registration refused with the P-CSCF's fresh-SA throttle (Security-Server spi-s=0) must not be retried by a 120-s restart loop, since every attempt re-arms the ~20-min window - it is now waited out in-process, growing on repeats, with D-Bus commands still served. A resume the network refuses falls through to a fresh registration. A retransmitted 200 OK to our INVITE is ACKed again and no longer starts a second media leg. Verified on KPN: resume over TCP after a binary swap, MT and MO calls with media both ways, the throttle deferral and its self-recovery, the UDP client path up to KPN dropping the datagram. The UDP success path is a field test; SIP retransmission timers over UDP are not in this change.
2026-09-17 14:58:46 +02:00
} else err = ETIMEDOUT;
}
if (connected) {
fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) & ~O_NONBLOCK);
sip: UDP for the protected leg, TCP first with a fallback imsd's protected leg (the second REGISTER and everything after it) was TCP only. A P-CSCF that never answers the TCP connect on its protected server port (O2 UK: the SYNs leave ESP-protected, nothing comes back) left the unit looping with no way forward, although the SAs, the listener sockets and the firewall rule already covered UDP. The client flow now carries a transport. UDP binds the same protected client port and connect()s the datagram socket to the P-CSCF's protected server port, so the kernel delivers that peer's datagrams to it ahead of the unconnected listener on the same port. One datagram is one message (RFC 3261 18.3: a Content-Length that fits truncates, one that does not fit discards the datagram, none means the rest of the datagram); a receive error is logged and marks the flow dead; a message over the single-packet ESP budget at the ims PDN's MTU is logged once per flow, since the kernel fragments it and a P-CSCF may drop the fragments. Every protected request's Via follows the transport; the challenge stays UDP. SIP_TRANSPORT selects the policy: auto (default) registers over TCP and, after two unanswered connects, registers again over UDP (new challenge, new SA pair) - but only on a phone whose state file does not record a successful TCP registration, so an outage on a TCP carrier never becomes a second initial REGISTER; a phone whose last registration was UDP goes straight to UDP. tcp and udp force one. A connect refused locally (EADDRNOTAVAIL: the 4-tuple still in TIME_WAIT from the previous flow) keeps retrying and is not counted as silence; SO_ERROR results are logged by name. The registration's transport is persisted so a warm resume reconnects the same way, and the resume gives up after two silences. Two things the change exposed and fixes: an engine-fatal event exited 0 ("exiting for systemd restart" with Restart=on-failure never firing), and a fresh registration refused with the P-CSCF's fresh-SA throttle (Security-Server spi-s=0) must not be retried by a 120-s restart loop, since every attempt re-arms the ~20-min window - it is now waited out in-process, growing on repeats, with D-Bus commands still served. A resume the network refuses falls through to a fresh registration. A retransmitted 200 OK to our INVITE is ACKed again and no longer starts a second media leg. Verified on KPN: resume over TCP after a binary swap, MT and MO calls with media both ways, the throttle deferral and its self-recovery, the UDP client path up to KPN dropping the datagram. The UDP success path is a field test; SIP retransmission timers over UDP are not in this change.
2026-09-17 14:58:46 +02:00
fd_ = fd; alive_ = true; fb_ = {}; sizeWarned_ = false;
return true;
}
close(fd);
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
lastConnectLocal_ = localRefusal;
sip: UDP for the protected leg, TCP first with a fallback imsd's protected leg (the second REGISTER and everything after it) was TCP only. A P-CSCF that never answers the TCP connect on its protected server port (O2 UK: the SYNs leave ESP-protected, nothing comes back) left the unit looping with no way forward, although the SAs, the listener sockets and the firewall rule already covered UDP. The client flow now carries a transport. UDP binds the same protected client port and connect()s the datagram socket to the P-CSCF's protected server port, so the kernel delivers that peer's datagrams to it ahead of the unconnected listener on the same port. One datagram is one message (RFC 3261 18.3: a Content-Length that fits truncates, one that does not fit discards the datagram, none means the rest of the datagram); a receive error is logged and marks the flow dead; a message over the single-packet ESP budget at the ims PDN's MTU is logged once per flow, since the kernel fragments it and a P-CSCF may drop the fragments. Every protected request's Via follows the transport; the challenge stays UDP. SIP_TRANSPORT selects the policy: auto (default) registers over TCP and, after two unanswered connects, registers again over UDP (new challenge, new SA pair) - but only on a phone whose state file does not record a successful TCP registration, so an outage on a TCP carrier never becomes a second initial REGISTER; a phone whose last registration was UDP goes straight to UDP. tcp and udp force one. A connect refused locally (EADDRNOTAVAIL: the 4-tuple still in TIME_WAIT from the previous flow) keeps retrying and is not counted as silence; SO_ERROR results are logged by name. The registration's transport is persisted so a warm resume reconnects the same way, and the resume gives up after two silences. Two things the change exposed and fixes: an engine-fatal event exited 0 ("exiting for systemd restart" with Restart=on-failure never firing), and a fresh registration refused with the P-CSCF's fresh-SA throttle (Security-Server spi-s=0) must not be retried by a 120-s restart loop, since every attempt re-arms the ~20-min window - it is now waited out in-process, growing on repeats, with D-Bus commands still served. A resume the network refuses falls through to a fresh registration. A retransmitted 200 OK to our INVITE is ACKed again and no longer starts a second media leg. Verified on KPN: resume over TCP after a binary swap, MT and MO calls with media both ways, the throttle deferral and its self-recovery, the UDP client path up to KPN dropping the datagram. The UDP success path is a field test; SIP retransmission timers over UDP are not in this change.
2026-09-17 14:58:46 +02:00
if (!localRefusal) silent++;
// An answer (RST → ECONNREFUSED, ICMP → EHOSTUNREACH/ENETUNREACH)
// counts towards the silent limit like a timeout: either way the
// P-CSCF does not serve TCP on that port. The log says which.
const char* why = err == ETIMEDOUT ? "no answer" : (err == EADDRNOTAVAIL ? "TIME_WAIT?" : std::strerror(err));
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
if (attempt == tries - 1) {
Log(std::format("connect failed ({}) [{}/{}]", why, attempt + 1, tries));
return false;
}
if (silentLimit > 0 && silent >= silentLimit) {
Log(std::format("connect: no answer from the P-CSCF {} times ({})", silent, why));
return false;
}
sip: UDP for the protected leg, TCP first with a fallback imsd's protected leg (the second REGISTER and everything after it) was TCP only. A P-CSCF that never answers the TCP connect on its protected server port (O2 UK: the SYNs leave ESP-protected, nothing comes back) left the unit looping with no way forward, although the SAs, the listener sockets and the firewall rule already covered UDP. The client flow now carries a transport. UDP binds the same protected client port and connect()s the datagram socket to the P-CSCF's protected server port, so the kernel delivers that peer's datagrams to it ahead of the unconnected listener on the same port. One datagram is one message (RFC 3261 18.3: a Content-Length that fits truncates, one that does not fit discards the datagram, none means the rest of the datagram); a receive error is logged and marks the flow dead; a message over the single-packet ESP budget at the ims PDN's MTU is logged once per flow, since the kernel fragments it and a P-CSCF may drop the fragments. Every protected request's Via follows the transport; the challenge stays UDP. SIP_TRANSPORT selects the policy: auto (default) registers over TCP and, after two unanswered connects, registers again over UDP (new challenge, new SA pair) - but only on a phone whose state file does not record a successful TCP registration, so an outage on a TCP carrier never becomes a second initial REGISTER; a phone whose last registration was UDP goes straight to UDP. tcp and udp force one. A connect refused locally (EADDRNOTAVAIL: the 4-tuple still in TIME_WAIT from the previous flow) keeps retrying and is not counted as silence; SO_ERROR results are logged by name. The registration's transport is persisted so a warm resume reconnects the same way, and the resume gives up after two silences. Two things the change exposed and fixes: an engine-fatal event exited 0 ("exiting for systemd restart" with Restart=on-failure never firing), and a fresh registration refused with the P-CSCF's fresh-SA throttle (Security-Server spi-s=0) must not be retried by a 120-s restart loop, since every attempt re-arms the ~20-min window - it is now waited out in-process, growing on repeats, with D-Bus commands still served. A resume the network refuses falls through to a fresh registration. A retransmitted 200 OK to our INVITE is ACKed again and no longer starts a second media leg. Verified on KPN: resume over TCP after a binary swap, MT and MO calls with media both ways, the throttle deferral and its self-recovery, the UDP client path up to KPN dropping the datagram. The UDP success path is a field test; SIP retransmission timers over UDP are not in this change.
2026-09-17 14:58:46 +02:00
Log(std::format("connect failed; retry in 10s ({}) [{}/{}]", why, attempt + 1, tries));
sleep(10);
}
return false;
}
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
// socket() failing is ours, not the network's: logged, and a local
// refusal like a connect() the kernel turns down.
bool SocketFailed() {
Log(std::format("connect failed: socket: {}", std::strerror(errno)));
lastConnectLocal_ = true;
return false;
}
// A bind the kernel refuses (the address is not on the link:
// EADDRNOTAVAIL) is logged and counts like the network's silence — it is
// not a transient refusal to retry every 5 s. Closes fd on failure.
bool Bind(int fd, const sockaddr_storage& la, socklen_t ll, const std::string& local, int lport) {
if (bind(fd, reinterpret_cast<const sockaddr*>(&la), ll) == 0) return true;
int err = errno;
close(fd);
lastConnectLocal_ = false;
Log(std::format("connect failed: cannot bind {} ({})", imsd::util::HostPort(local, lport), std::strerror(err)));
return false;
}
// Whether the last failed Connect() ended on a local refusal (our own
// 4-tuple still in TIME_WAIT, no route yet) rather than on the network's
// silence or answer — the recovery policy counts only the latter.
bool LastConnectLocal() const { return lastConnectLocal_; }
// MSG_NOSIGNAL: a flow the network killed (CSFB excursion, P-CSCF idle
// drop) must surface as a send error, not a SIGPIPE.
bool Send(std::string_view msg) {
sip: UDP for the protected leg, TCP first with a fallback imsd's protected leg (the second REGISTER and everything after it) was TCP only. A P-CSCF that never answers the TCP connect on its protected server port (O2 UK: the SYNs leave ESP-protected, nothing comes back) left the unit looping with no way forward, although the SAs, the listener sockets and the firewall rule already covered UDP. The client flow now carries a transport. UDP binds the same protected client port and connect()s the datagram socket to the P-CSCF's protected server port, so the kernel delivers that peer's datagrams to it ahead of the unconnected listener on the same port. One datagram is one message (RFC 3261 18.3: a Content-Length that fits truncates, one that does not fit discards the datagram, none means the rest of the datagram); a receive error is logged and marks the flow dead; a message over the single-packet ESP budget at the ims PDN's MTU is logged once per flow, since the kernel fragments it and a P-CSCF may drop the fragments. Every protected request's Via follows the transport; the challenge stays UDP. SIP_TRANSPORT selects the policy: auto (default) registers over TCP and, after two unanswered connects, registers again over UDP (new challenge, new SA pair) - but only on a phone whose state file does not record a successful TCP registration, so an outage on a TCP carrier never becomes a second initial REGISTER; a phone whose last registration was UDP goes straight to UDP. tcp and udp force one. A connect refused locally (EADDRNOTAVAIL: the 4-tuple still in TIME_WAIT from the previous flow) keeps retrying and is not counted as silence; SO_ERROR results are logged by name. The registration's transport is persisted so a warm resume reconnects the same way, and the resume gives up after two silences. Two things the change exposed and fixes: an engine-fatal event exited 0 ("exiting for systemd restart" with Restart=on-failure never firing), and a fresh registration refused with the P-CSCF's fresh-SA throttle (Security-Server spi-s=0) must not be retried by a 120-s restart loop, since every attempt re-arms the ~20-min window - it is now waited out in-process, growing on repeats, with D-Bus commands still served. A resume the network refuses falls through to a fresh registration. A retransmitted 200 OK to our INVITE is ACKed again and no longer starts a second media leg. Verified on KPN: resume over TCP after a binary swap, MT and MO calls with media both ways, the throttle deferral and its self-recovery, the UDP client path up to KPN dropping the datagram. The UDP success path is a field test; SIP retransmission timers over UDP are not in this change.
2026-09-17 14:58:46 +02:00
if (transport_ == Transport::Udp && msg.size() > UdpSinglePacketBudget && !sizeWarned_) {
sizeWarned_ = true;
Log(std::format("UDP: {} bytes of SIP exceed the single-packet ESP budget (~{}) at MTU 1280; the kernel sends it as IPv6 fragments — a P-CSCF that cannot reassemble ESP fragments drops it silently", msg.size(), UdpSinglePacketBudget));
}
std::size_t off = 0;
while (off < msg.size()) {
ssize_t w = send(fd_, msg.data() + off, msg.size() - off, MSG_NOSIGNAL);
if (w <= 0) { alive_ = false; return false; }
off += static_cast<std::size_t>(w);
}
return true;
}
void SendKeepalive() {
sip: UDP for the protected leg, TCP first with a fallback imsd's protected leg (the second REGISTER and everything after it) was TCP only. A P-CSCF that never answers the TCP connect on its protected server port (O2 UK: the SYNs leave ESP-protected, nothing comes back) left the unit looping with no way forward, although the SAs, the listener sockets and the firewall rule already covered UDP. The client flow now carries a transport. UDP binds the same protected client port and connect()s the datagram socket to the P-CSCF's protected server port, so the kernel delivers that peer's datagrams to it ahead of the unconnected listener on the same port. One datagram is one message (RFC 3261 18.3: a Content-Length that fits truncates, one that does not fit discards the datagram, none means the rest of the datagram); a receive error is logged and marks the flow dead; a message over the single-packet ESP budget at the ims PDN's MTU is logged once per flow, since the kernel fragments it and a P-CSCF may drop the fragments. Every protected request's Via follows the transport; the challenge stays UDP. SIP_TRANSPORT selects the policy: auto (default) registers over TCP and, after two unanswered connects, registers again over UDP (new challenge, new SA pair) - but only on a phone whose state file does not record a successful TCP registration, so an outage on a TCP carrier never becomes a second initial REGISTER; a phone whose last registration was UDP goes straight to UDP. tcp and udp force one. A connect refused locally (EADDRNOTAVAIL: the 4-tuple still in TIME_WAIT from the previous flow) keeps retrying and is not counted as silence; SO_ERROR results are logged by name. The registration's transport is persisted so a warm resume reconnects the same way, and the resume gives up after two silences. Two things the change exposed and fixes: an engine-fatal event exited 0 ("exiting for systemd restart" with Restart=on-failure never firing), and a fresh registration refused with the P-CSCF's fresh-SA throttle (Security-Server spi-s=0) must not be retried by a 120-s restart loop, since every attempt re-arms the ~20-min window - it is now waited out in-process, growing on repeats, with D-Bus commands still served. A resume the network refuses falls through to a fresh registration. A retransmitted 200 OK to our INVITE is ACKed again and no longer starts a second media leg. Verified on KPN: resume over TCP after a binary swap, MT and MO calls with media both ways, the throttle deferral and its self-recovery, the UDP client path up to KPN dropping the datagram. The UDP success path is a field test; SIP retransmission timers over UDP are not in this change.
2026-09-17 14:58:46 +02:00
if (transport_ == Transport::Udp) return; // RFC 5626 4.4.1: CRLF keepalives are for connection-oriented transports
if (send(fd_, "\r\n\r\n", 4, MSG_NOSIGNAL) <= 0) alive_ = false;
}
std::optional<std::string> RecvMsg(double timeoutSec) {
auto deadline = std::chrono::steady_clock::now() +
std::chrono::duration_cast<std::chrono::steady_clock::duration>(std::chrono::duration<double>(timeoutSec));
for (;;) {
if (auto m = fb_.TryExtract()) return m;
auto now = std::chrono::steady_clock::now();
if (now >= deadline) return std::nullopt;
int ms = static_cast<int>(std::chrono::duration_cast<std::chrono::milliseconds>(deadline - now).count());
pollfd p{fd_, POLLIN, 0};
int r = poll(&p, 1, ms);
if (r <= 0) { if (r == 0) return std::nullopt; if (errno == EINTR) continue; return std::nullopt; }
char buf[65535];
ssize_t n = recv(fd_, buf, sizeof buf, 0);
sip: UDP for the protected leg, TCP first with a fallback imsd's protected leg (the second REGISTER and everything after it) was TCP only. A P-CSCF that never answers the TCP connect on its protected server port (O2 UK: the SYNs leave ESP-protected, nothing comes back) left the unit looping with no way forward, although the SAs, the listener sockets and the firewall rule already covered UDP. The client flow now carries a transport. UDP binds the same protected client port and connect()s the datagram socket to the P-CSCF's protected server port, so the kernel delivers that peer's datagrams to it ahead of the unconnected listener on the same port. One datagram is one message (RFC 3261 18.3: a Content-Length that fits truncates, one that does not fit discards the datagram, none means the rest of the datagram); a receive error is logged and marks the flow dead; a message over the single-packet ESP budget at the ims PDN's MTU is logged once per flow, since the kernel fragments it and a P-CSCF may drop the fragments. Every protected request's Via follows the transport; the challenge stays UDP. SIP_TRANSPORT selects the policy: auto (default) registers over TCP and, after two unanswered connects, registers again over UDP (new challenge, new SA pair) - but only on a phone whose state file does not record a successful TCP registration, so an outage on a TCP carrier never becomes a second initial REGISTER; a phone whose last registration was UDP goes straight to UDP. tcp and udp force one. A connect refused locally (EADDRNOTAVAIL: the 4-tuple still in TIME_WAIT from the previous flow) keeps retrying and is not counted as silence; SO_ERROR results are logged by name. The registration's transport is persisted so a warm resume reconnects the same way, and the resume gives up after two silences. Two things the change exposed and fixes: an engine-fatal event exited 0 ("exiting for systemd restart" with Restart=on-failure never firing), and a fresh registration refused with the P-CSCF's fresh-SA throttle (Security-Server spi-s=0) must not be retried by a 120-s restart loop, since every attempt re-arms the ~20-min window - it is now waited out in-process, growing on repeats, with D-Bus commands still served. A resume the network refuses falls through to a fresh registration. A retransmitted 200 OK to our INVITE is ACKed again and no longer starts a second media leg. Verified on KPN: resume over TCP after a binary swap, MT and MO calls with media both ways, the throttle deferral and its self-recovery, the UDP client path up to KPN dropping the datagram. The UDP success path is a field test; SIP retransmission timers over UDP are not in this change.
2026-09-17 14:58:46 +02:00
if (transport_ == Transport::Udp) {
// One datagram = one message (RFC 3261 18.3): never accumulated
// across datagrams. An empty datagram is not EOF. A recv error
// is the kernel relaying an ICMP for the inner (decrypted) UDP
// — port unreachable → ECONNREFUSED, prohibited/policy → EACCES
// — i.e. the peer answering "no"; an ICMP quoting the ESP
// packet itself never reaches us (esp6_err ignores all but
// PKT_TOOBIG), so UDP has no dead-flow signal beyond these and
// the refresh timeouts.
if (n < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) continue;
Log(std::format("UDP flow: recv error: {}", std::strerror(errno)));
alive_ = false; return std::nullopt;
}
if (n == 0) continue;
std::string why;
if (auto m = imsd::sip::DatagramMessage(std::string_view(buf, static_cast<std::size_t>(n)), &why)) return m;
if (!why.empty()) Log(std::format("UDP: {} — ignored", why));
continue;
}
// EOF/RST is not a timeout: mark the flow dead so the engine's
// reconnect path notices within one loop turn instead of at the
// next (possibly 30-min-away) keepalive refresh.
if (n <= 0) { alive_ = false; return std::nullopt; }
fb_.Append(std::string_view(buf, static_cast<std::size_t>(n)));
}
}
int Fd() const { return fd_; }
bool Alive() const { return fd_ >= 0 && alive_; }
sip: UDP for the protected leg, TCP first with a fallback imsd's protected leg (the second REGISTER and everything after it) was TCP only. A P-CSCF that never answers the TCP connect on its protected server port (O2 UK: the SYNs leave ESP-protected, nothing comes back) left the unit looping with no way forward, although the SAs, the listener sockets and the firewall rule already covered UDP. The client flow now carries a transport. UDP binds the same protected client port and connect()s the datagram socket to the P-CSCF's protected server port, so the kernel delivers that peer's datagrams to it ahead of the unconnected listener on the same port. One datagram is one message (RFC 3261 18.3: a Content-Length that fits truncates, one that does not fit discards the datagram, none means the rest of the datagram); a receive error is logged and marks the flow dead; a message over the single-packet ESP budget at the ims PDN's MTU is logged once per flow, since the kernel fragments it and a P-CSCF may drop the fragments. Every protected request's Via follows the transport; the challenge stays UDP. SIP_TRANSPORT selects the policy: auto (default) registers over TCP and, after two unanswered connects, registers again over UDP (new challenge, new SA pair) - but only on a phone whose state file does not record a successful TCP registration, so an outage on a TCP carrier never becomes a second initial REGISTER; a phone whose last registration was UDP goes straight to UDP. tcp and udp force one. A connect refused locally (EADDRNOTAVAIL: the 4-tuple still in TIME_WAIT from the previous flow) keeps retrying and is not counted as silence; SO_ERROR results are logged by name. The registration's transport is persisted so a warm resume reconnects the same way, and the resume gives up after two silences. Two things the change exposed and fixes: an engine-fatal event exited 0 ("exiting for systemd restart" with Restart=on-failure never firing), and a fresh registration refused with the P-CSCF's fresh-SA throttle (Security-Server spi-s=0) must not be retried by a 120-s restart loop, since every attempt re-arms the ~20-min window - it is now waited out in-process, growing on repeats, with D-Bus commands still served. A resume the network refuses falls through to a fresh registration. A retransmitted 200 OK to our INVITE is ACKed again and no longer starts a second media leg. Verified on KPN: resume over TCP after a binary swap, MT and MO calls with media both ways, the throttle deferral and its self-recovery, the UDP client path up to KPN dropping the datagram. The UDP success path is a field test; SIP retransmission timers over UDP are not in this change.
2026-09-17 14:58:46 +02:00
Transport CurrentTransport() const { return transport_; }
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
// What was read and not yet dispatched goes with the flow: nothing
// read from it can be answered once it is closed.
void Close() { if (fd_ >= 0) { close(fd_); fd_ = -1; } alive_ = false; fb_ = {}; }
private:
int fd_ = -1;
bool alive_ = false;
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
bool lastConnectLocal_ = false;
sip: UDP for the protected leg, TCP first with a fallback imsd's protected leg (the second REGISTER and everything after it) was TCP only. A P-CSCF that never answers the TCP connect on its protected server port (O2 UK: the SYNs leave ESP-protected, nothing comes back) left the unit looping with no way forward, although the SAs, the listener sockets and the firewall rule already covered UDP. The client flow now carries a transport. UDP binds the same protected client port and connect()s the datagram socket to the P-CSCF's protected server port, so the kernel delivers that peer's datagrams to it ahead of the unconnected listener on the same port. One datagram is one message (RFC 3261 18.3: a Content-Length that fits truncates, one that does not fit discards the datagram, none means the rest of the datagram); a receive error is logged and marks the flow dead; a message over the single-packet ESP budget at the ims PDN's MTU is logged once per flow, since the kernel fragments it and a P-CSCF may drop the fragments. Every protected request's Via follows the transport; the challenge stays UDP. SIP_TRANSPORT selects the policy: auto (default) registers over TCP and, after two unanswered connects, registers again over UDP (new challenge, new SA pair) - but only on a phone whose state file does not record a successful TCP registration, so an outage on a TCP carrier never becomes a second initial REGISTER; a phone whose last registration was UDP goes straight to UDP. tcp and udp force one. A connect refused locally (EADDRNOTAVAIL: the 4-tuple still in TIME_WAIT from the previous flow) keeps retrying and is not counted as silence; SO_ERROR results are logged by name. The registration's transport is persisted so a warm resume reconnects the same way, and the resume gives up after two silences. Two things the change exposed and fixes: an engine-fatal event exited 0 ("exiting for systemd restart" with Restart=on-failure never firing), and a fresh registration refused with the P-CSCF's fresh-SA throttle (Security-Server spi-s=0) must not be retried by a 120-s restart loop, since every attempt re-arms the ~20-min window - it is now waited out in-process, growing on repeats, with D-Bus commands still served. A resume the network refuses falls through to a fresh registration. A retransmitted 200 OK to our INVITE is ACKed again and no longer starts a second media leg. Verified on KPN: resume over TCP after a binary swap, MT and MO calls with media both ways, the throttle deferral and its self-recovery, the UDP client path up to KPN dropping the datagram. The UDP success path is a field test; SIP retransmission timers over UDP are not in this change.
2026-09-17 14:58:46 +02:00
Transport transport_ = Transport::Tcp;
bool sizeWarned_ = false;
imsd::sip::FrameBuffer fb_;
};
// ---- protected-port listeners (terminating requests: remote BYE/UPDATE) ---
struct Inbound {
std::string msg;
std::function<void(const std::string&)> reply;
};
class ServerPorts {
public:
void Open(const std::string& local) {
int fam = Is6(local) ? AF_INET6 : AF_INET;
for (int port : {PortUc, PortUs}) {
int ls = socket(fam, SOCK_STREAM, 0);
if (ls >= 0) {
int one = 1;
setsockopt(ls, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
sip: UDP for the protected leg, TCP first with a fallback imsd's protected leg (the second REGISTER and everything after it) was TCP only. A P-CSCF that never answers the TCP connect on its protected server port (O2 UK: the SYNs leave ESP-protected, nothing comes back) left the unit looping with no way forward, although the SAs, the listener sockets and the firewall rule already covered UDP. The client flow now carries a transport. UDP binds the same protected client port and connect()s the datagram socket to the P-CSCF's protected server port, so the kernel delivers that peer's datagrams to it ahead of the unconnected listener on the same port. One datagram is one message (RFC 3261 18.3: a Content-Length that fits truncates, one that does not fit discards the datagram, none means the rest of the datagram); a receive error is logged and marks the flow dead; a message over the single-packet ESP budget at the ims PDN's MTU is logged once per flow, since the kernel fragments it and a P-CSCF may drop the fragments. Every protected request's Via follows the transport; the challenge stays UDP. SIP_TRANSPORT selects the policy: auto (default) registers over TCP and, after two unanswered connects, registers again over UDP (new challenge, new SA pair) - but only on a phone whose state file does not record a successful TCP registration, so an outage on a TCP carrier never becomes a second initial REGISTER; a phone whose last registration was UDP goes straight to UDP. tcp and udp force one. A connect refused locally (EADDRNOTAVAIL: the 4-tuple still in TIME_WAIT from the previous flow) keeps retrying and is not counted as silence; SO_ERROR results are logged by name. The registration's transport is persisted so a warm resume reconnects the same way, and the resume gives up after two silences. Two things the change exposed and fixes: an engine-fatal event exited 0 ("exiting for systemd restart" with Restart=on-failure never firing), and a fresh registration refused with the P-CSCF's fresh-SA throttle (Security-Server spi-s=0) must not be retried by a 120-s restart loop, since every attempt re-arms the ~20-min window - it is now waited out in-process, growing on repeats, with D-Bus commands still served. A resume the network refuses falls through to a fresh registration. A retransmitted 200 OK to our INVITE is ACKed again and no longer starts a second media leg. Verified on KPN: resume over TCP after a binary swap, MT and MO calls with media both ways, the throttle deferral and its self-recovery, the UDP client path up to KPN dropping the datagram. The UDP success path is a field test; SIP retransmission timers over UDP are not in this change.
2026-09-17 14:58:46 +02:00
// paired with SipFlow::Connect's REUSEPORT (reconnect binds
// the client port while this listener holds it)
setsockopt(ls, SOL_SOCKET, SO_REUSEPORT, &one, sizeof one);
sockaddr_storage a; socklen_t l = MakeAddr(local, port, a);
if (bind(ls, reinterpret_cast<sockaddr*>(&a), l) == 0 && listen(ls, 4) == 0) {
fcntl(ls, F_SETFL, O_NONBLOCK);
listeners_.push_back(ls);
} else { close(ls); }
}
int us = socket(fam, SOCK_DGRAM, 0);
if (us >= 0) {
int one = 1;
setsockopt(us, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
sockaddr_storage a; socklen_t l = MakeAddr(local, port, a);
if (bind(us, reinterpret_cast<sockaddr*>(&a), l) == 0) {
fcntl(us, F_SETFL, O_NONBLOCK);
udp_.push_back(us);
if (port == PortUc) udpUc_ = us;
} else { close(us); }
}
Log(std::format("listening on protected port {} (TCP+UDP)", port));
}
}
std::vector<Inbound> Poll() {
std::vector<Inbound> out;
for (int ls : listeners_) {
for (;;) {
int c = accept(ls, nullptr, nullptr);
if (c < 0) break;
fcntl(c, F_SETFL, O_NONBLOCK);
conns_.push_back({c, {}});
}
}
for (int us : udp_) {
for (;;) {
char buf[65535];
sockaddr_storage src; socklen_t sl = sizeof src;
ssize_t n = recvfrom(us, buf, sizeof buf, 0, reinterpret_cast<sockaddr*>(&src), &sl);
if (n <= 0) break;
std::string_view sv(buf, static_cast<std::size_t>(n));
bool blank = sv.find_first_not_of("\r\n \t") == std::string_view::npos;
if (blank) continue;
std::string msg(sv);
sip: UDP for the protected leg, TCP first with a fallback imsd's protected leg (the second REGISTER and everything after it) was TCP only. A P-CSCF that never answers the TCP connect on its protected server port (O2 UK: the SYNs leave ESP-protected, nothing comes back) left the unit looping with no way forward, although the SAs, the listener sockets and the firewall rule already covered UDP. The client flow now carries a transport. UDP binds the same protected client port and connect()s the datagram socket to the P-CSCF's protected server port, so the kernel delivers that peer's datagrams to it ahead of the unconnected listener on the same port. One datagram is one message (RFC 3261 18.3: a Content-Length that fits truncates, one that does not fit discards the datagram, none means the rest of the datagram); a receive error is logged and marks the flow dead; a message over the single-packet ESP budget at the ims PDN's MTU is logged once per flow, since the kernel fragments it and a P-CSCF may drop the fragments. Every protected request's Via follows the transport; the challenge stays UDP. SIP_TRANSPORT selects the policy: auto (default) registers over TCP and, after two unanswered connects, registers again over UDP (new challenge, new SA pair) - but only on a phone whose state file does not record a successful TCP registration, so an outage on a TCP carrier never becomes a second initial REGISTER; a phone whose last registration was UDP goes straight to UDP. tcp and udp force one. A connect refused locally (EADDRNOTAVAIL: the 4-tuple still in TIME_WAIT from the previous flow) keeps retrying and is not counted as silence; SO_ERROR results are logged by name. The registration's transport is persisted so a warm resume reconnects the same way, and the resume gives up after two silences. Two things the change exposed and fixes: an engine-fatal event exited 0 ("exiting for systemd restart" with Restart=on-failure never firing), and a fresh registration refused with the P-CSCF's fresh-SA throttle (Security-Server spi-s=0) must not be retried by a 120-s restart loop, since every attempt re-arms the ~20-min window - it is now waited out in-process, growing on repeats, with D-Bus commands still served. A resume the network refuses falls through to a fresh registration. A retransmitted 200 OK to our INVITE is ACKed again and no longer starts a second media leg. Verified on KPN: resume over TCP after a binary swap, MT and MO calls with media both ways, the throttle deferral and its self-recovery, the UDP client path up to KPN dropping the datagram. The UDP success path is a field test; SIP retransmission timers over UDP are not in this change.
2026-09-17 14:58:46 +02:00
if (imsd::sip::Status(msg).has_value()) {
// A response here means the P-CSCF answered to the Via port
// instead of the source port of our request (rport) — it
// never reaches the client flow. Say so.
int sp = src.ss_family == AF_INET6 ? ntohs(reinterpret_cast<sockaddr_in6*>(&src)->sin6_port) : ntohs(reinterpret_cast<sockaddr_in*>(&src)->sin_port);
Log(std::format("UDP response on server socket {} from port {} ignored: {}", us == udpUc_ ? PortUc : PortUs, sp, sv.substr(0, sv.find("\r\n"))));
continue;
}
if (!imsd::sip::Status(msg).has_value()) { // request only
sockaddr_storage s = src; socklen_t sll = sl;
int rs = us;
// RFC 3261 18.2.2: rport-less UDP requests get their
// response at the Via sent-by port, not the source
// port. KPN's P-CSCF sends in-dialog requests from its
// protected client port but silently drops responses
// sent back there (s57: every UPDATE/BYE 200 ignored,
// audit kills the call); the Via port is its protected
// server port, whose SA pairs with OUR client-port
// socket — respond from that one so the uc->ps SA
// carries it.
if (auto vp = imsd::sip::ViaResponsePort(msg)) {
if (s.ss_family == AF_INET6)
reinterpret_cast<sockaddr_in6*>(&s)->sin6_port =
htons(static_cast<uint16_t>(*vp));
else
reinterpret_cast<sockaddr_in*>(&s)->sin_port =
htons(static_cast<uint16_t>(*vp));
if (udpUc_ >= 0) rs = udpUc_;
}
out.push_back({std::move(msg), [rs, s, sll](const std::string& r) mutable {
sendto(rs, r.data(), r.size(), 0, reinterpret_cast<sockaddr*>(&s), sll);
}});
}
}
}
for (auto it = conns_.begin(); it != conns_.end();) {
bool eof = false;
for (;;) {
char buf[65535];
ssize_t n = recv(it->fd, buf, sizeof buf, 0);
if (n == 0) { eof = true; break; }
if (n < 0) break;
it->fb.Append(std::string_view(buf, static_cast<std::size_t>(n)));
}
while (auto m = it->fb.TryExtract()) {
if (!imsd::sip::Status(*m).has_value()) {
int fd = it->fd;
out.push_back({*m, [fd](const std::string& r) {
(void)!write(fd, r.data(), r.size());
}});
}
}
if (eof) { close(it->fd); it = conns_.erase(it); } else { ++it; }
}
return out;
}
void CloseAll() {
for (int f : listeners_) close(f);
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; };
int udpUc_ = -1;
std::vector<int> listeners_, udp_;
std::vector<Conn> conns_;
};
// ---- registration state ---------------------------------------------------
struct RegState {
std::string callid, ftag;
long cseq = 2;
std::uint32_t spiUc = 0;
std::uint32_t spiUs = 0;
long expiry = 0;
int slot = 0;
std::string aid;
// Contact user-part of the stored binding (UUID since the s53 oracle
// diff; empty = legacy IMSI binding from an older state file).
std::string contactUser;
engine, messages, util, imsd: the reregistration carries the last challenge answer and new SA parameters The keepalive refresh and the resume's re-REGISTER carried an initial registration's first-REGISTER Authorization — an empty nonce and response — in the second REGISTER's frame, with a Security-Client naming the SPIs and ports in use. TS 24.229 5.1.1.4.2 has a reregistration carry the Authorization of the last successful authentication — the last received nonce and the last calculated response — and a Security-Client with the new parameter values for the two SA pairs a re-authentication would set up; TS 33.203 7.4.0 keeps the server ports and changes the client ports. O2 UK refused the refresh of the old shape with 503 on every occasion on record (alyx, 2026-09-24 to 10-08, a stationary phone included) and accepted the fresh registrations that followed; KPN accepted the old shape without a challenge. The fresh registration now records the 401's nonce, our cnonce and the response in the registration state (auth_nonce, auth_cnonce, auth_response in the state file); a reregistration sends them verbatim, mints new SPIs and proposes PortUcNext (45063) as the client port with the server port unchanged. A registration resumed from a state file written before this change has no answer: its reregistrations carry the empty digest, as before, until its next fresh registration, and the resume says so. A 401 to a reregistration asks for re-authentication over temporary SAs on the proposed ports (TS 24.229 5.1.1.5.1), which imsd does not implement: the refresh throws it as a refusal and RegRecovery makes the next step the fresh registration, replacing the in-place answer over the old SAs. Tested with the Messages and Engine suites and on the dev phone (KPN) with a build of this commit's tree: a resume from the previous state file, a fresh registration, a resume that sent the stored answer, three refreshes at a 120-s interval, an outgoing call to a bench line and an incoming call's ring.
2026-10-10 21:00:28 +02:00
// The answer to the last challenge — the 401's nonce, our cnonce and
// the response — which every reregistration carries verbatim (TS
// 24.229 5.1.1.4.2 a). Empty = a registration resumed from a pre-0.3.11
// state file; its reregistrations carry an empty digest, the pre-0.3.11
// shape, until the next fresh registration.
std::string authNonce, authCnonce, authResponse;
imsd: a resumed registration echoes the 401's Security-Server list from the state file A warm resume rebuilt its Security-Verify from the kernel's SA: one mechanism with hmac-sha-1-96 and aes-cbc written in, where RFC 3329 §2.3.1 has the client mirror the server's whole list. The fresh registration kept the 401's list in memory, so a registration this process made fresh echoed it; after a restart, the resumed registration's re-REGISTER, its keepalives, its reg-event SUBSCRIBE and its INVITEs carried the rebuilt entry until the next fresh registration. KPN's one-entry answer to the default offer is the rebuilt entry byte for byte; against a six-entry list (O2's) the mirror is broken, which the RFC has the server answer with a 494, and a refused resume registers fresh. The fresh registration now stores the joined Security-Server value in the state file (security_server, next to the challenge answer) and the resume takes it from there, logging the mechanism count, when the entry carrying its SPIs names the SA pair in the kernel: a file an attempt the process did not live to record left behind (its SAs installed, its 200 not written) names another. A file from before this change holds none. In both cases the resume falls back to the rebuilt entry, says which, and the registration's next state write stores that entry, which is what it echoes; the choice is Imsd:Ipsec's, pinned in tests/Ipsec with O2's recorded list and KPN's. A value with a quote, a backslash or a newline in it is written empty, which the resume treats as absent; RFC 3329's token syntax produces none of these, a quoted-string extension parameter could carry the first two. Tested on the dev phone (KPN) with the build of this tree: a resume from a state file the 0.3.11 daemon wrote logs the fallback and succeeds, so do one from a file whose list names another SA pair and one whose list is empty; two keepalives at a 120-s interval; a fresh registration stores KPN's list byte-for-byte as the journal shows it received; a restart resumes with the list from the file; an outgoing call.
2026-10-10 23:42:01 +02:00
// The Security-Verify this registration's protected REGISTERs, its
// reg-event SUBSCRIBE and its INVITEs echo: the 401's whole
// Security-Server list (RFC 3329 §2.3.1). Set on the fresh 200 and at
// the resume, never from an attempt in flight:
// the member ss_ holds a fresh attempt's 401 before that attempt can
// still abort (the throttle, an unsupported mechanism), and the
// standing registration's state writes must not store it.
std::string securityServer;
};
// Writable directory for the daemon's own files: the persisted registration
// context, media stats, and optional debug dumps. Root daemon (the packaged
// service): /var/lib/imsd; --session/dev runs: the XDG state dir.
std::string StateDir() {
if (geteuid() == 0) return "/var/lib/imsd";
if (const char* x = std::getenv("XDG_STATE_HOME")) return std::format("{}/imsd", std::string(x));
return std::format("{}/.local/state/imsd", EnvOr("HOME", "/tmp"));
}
// tiny JSON reader/writer for the persisted registration context.
std::string StatePath() { return EnvOr("STATE_FILE", std::format("{}/imsreg.state", StateDir())); }
imsd: a resumed registration echoes the 401's Security-Server list from the state file A warm resume rebuilt its Security-Verify from the kernel's SA: one mechanism with hmac-sha-1-96 and aes-cbc written in, where RFC 3329 §2.3.1 has the client mirror the server's whole list. The fresh registration kept the 401's list in memory, so a registration this process made fresh echoed it; after a restart, the resumed registration's re-REGISTER, its keepalives, its reg-event SUBSCRIBE and its INVITEs carried the rebuilt entry until the next fresh registration. KPN's one-entry answer to the default offer is the rebuilt entry byte for byte; against a six-entry list (O2's) the mirror is broken, which the RFC has the server answer with a 494, and a refused resume registers fresh. The fresh registration now stores the joined Security-Server value in the state file (security_server, next to the challenge answer) and the resume takes it from there, logging the mechanism count, when the entry carrying its SPIs names the SA pair in the kernel: a file an attempt the process did not live to record left behind (its SAs installed, its 200 not written) names another. A file from before this change holds none. In both cases the resume falls back to the rebuilt entry, says which, and the registration's next state write stores that entry, which is what it echoes; the choice is Imsd:Ipsec's, pinned in tests/Ipsec with O2's recorded list and KPN's. A value with a quote, a backslash or a newline in it is written empty, which the resume treats as absent; RFC 3329's token syntax produces none of these, a quoted-string extension parameter could carry the first two. Tested on the dev phone (KPN) with the build of this tree: a resume from a state file the 0.3.11 daemon wrote logs the fallback and succeeds, so do one from a file whose list names another SA pair and one whose list is empty; two keepalives at a 120-s interval; a fresh registration stores KPN's list byte-for-byte as the journal shows it received; a restart resumes with the list from the file; an outgoing call.
2026-10-10 23:42:01 +02:00
// `security_server` is the registration's Security-Verify (RegState's
// securityServer: the 401's joined list); a resume takes it from here
// 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.
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.
2026-10-11 15:58:49 +02:00
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) {
imsd: a resumed registration echoes the 401's Security-Server list from the state file A warm resume rebuilt its Security-Verify from the kernel's SA: one mechanism with hmac-sha-1-96 and aes-cbc written in, where RFC 3329 §2.3.1 has the client mirror the server's whole list. The fresh registration kept the 401's list in memory, so a registration this process made fresh echoed it; after a restart, the resumed registration's re-REGISTER, its keepalives, its reg-event SUBSCRIBE and its INVITEs carried the rebuilt entry until the next fresh registration. KPN's one-entry answer to the default offer is the rebuilt entry byte for byte; against a six-entry list (O2's) the mirror is broken, which the RFC has the server answer with a 494, and a refused resume registers fresh. The fresh registration now stores the joined Security-Server value in the state file (security_server, next to the challenge answer) and the resume takes it from there, logging the mechanism count, when the entry carrying its SPIs names the SA pair in the kernel: a file an attempt the process did not live to record left behind (its SAs installed, its 200 not written) names another. A file from before this change holds none. In both cases the resume falls back to the rebuilt entry, says which, and the registration's next state write stores that entry, which is what it echoes; the choice is Imsd:Ipsec's, pinned in tests/Ipsec with O2's recorded list and KPN's. A value with a quote, a backslash or a newline in it is written empty, which the resume treats as absent; RFC 3329's token syntax produces none of these, a quoted-string extension parameter could carry the first two. Tested on the dev phone (KPN) with the build of this tree: a resume from a state file the 0.3.11 daemon wrote logs the fallback and succeeds, so do one from a file whose list names another SA pair and one whose list is empty; two keepalives at a 120-s interval; a fresh registration stores KPN's list byte-for-byte as the journal shows it received; a restart resumes with the list from the file; an outgoing call.
2026-10-10 23:42:01 +02:00
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\": \"{}\", "
engine, messages, util, imsd: the reregistration carries the last challenge answer and new SA parameters The keepalive refresh and the resume's re-REGISTER carried an initial registration's first-REGISTER Authorization — an empty nonce and response — in the second REGISTER's frame, with a Security-Client naming the SPIs and ports in use. TS 24.229 5.1.1.4.2 has a reregistration carry the Authorization of the last successful authentication — the last received nonce and the last calculated response — and a Security-Client with the new parameter values for the two SA pairs a re-authentication would set up; TS 33.203 7.4.0 keeps the server ports and changes the client ports. O2 UK refused the refresh of the old shape with 503 on every occasion on record (alyx, 2026-09-24 to 10-08, a stationary phone included) and accepted the fresh registrations that followed; KPN accepted the old shape without a challenge. The fresh registration now records the 401's nonce, our cnonce and the response in the registration state (auth_nonce, auth_cnonce, auth_response in the state file); a reregistration sends them verbatim, mints new SPIs and proposes PortUcNext (45063) as the client port with the server port unchanged. A registration resumed from a state file written before this change has no answer: its reregistrations carry the empty digest, as before, until its next fresh registration, and the resume says so. A 401 to a reregistration asks for re-authentication over temporary SAs on the proposed ports (TS 24.229 5.1.1.5.1), which imsd does not implement: the refresh throws it as a refusal and RegRecovery makes the next step the fresh registration, replacing the in-place answer over the old SAs. Tested with the Messages and Engine suites and on the dev phone (KPN) with a build of this commit's tree: a resume from the previous state file, a fresh registration, a resume that sent the stored answer, three refreshes at a 120-s interval, an outgoing call to a bench line and an incoming call's ring.
2026-10-10 21:00:28 +02:00
"\"aor\": \"{}\", \"contact_user\": \"{}\", \"transport\": \"{}\", "
imsd: a resumed registration echoes the 401's Security-Server list from the state file A warm resume rebuilt its Security-Verify from the kernel's SA: one mechanism with hmac-sha-1-96 and aes-cbc written in, where RFC 3329 §2.3.1 has the client mirror the server's whole list. The fresh registration kept the 401's list in memory, so a registration this process made fresh echoed it; after a restart, the resumed registration's re-REGISTER, its keepalives, its reg-event SUBSCRIBE and its INVITEs carried the rebuilt entry until the next fresh registration. KPN's one-entry answer to the default offer is the rebuilt entry byte for byte; against a six-entry list (O2's) the mirror is broken, which the RFC has the server answer with a 494, and a refused resume registers fresh. The fresh registration now stores the joined Security-Server value in the state file (security_server, next to the challenge answer) and the resume takes it from there, logging the mechanism count, when the entry carrying its SPIs names the SA pair in the kernel: a file an attempt the process did not live to record left behind (its SAs installed, its 200 not written) names another. A file from before this change holds none. In both cases the resume falls back to the rebuilt entry, says which, and the registration's next state write stores that entry, which is what it echoes; the choice is Imsd:Ipsec's, pinned in tests/Ipsec with O2's recorded list and KPN's. A value with a quote, a backslash or a newline in it is written empty, which the resume treats as absent; RFC 3329's token syntax produces none of these, a quoted-string extension parameter could carry the first two. Tested on the dev phone (KPN) with the build of this tree: a resume from a state file the 0.3.11 daemon wrote logs the fallback and succeeds, so do one from a file whose list names another SA pair and one whose list is empty; two keepalives at a 120-s interval; a fresh registration stores KPN's list byte-for-byte as the journal shows it received; a restart resumes with the list from the file; an outgoing call.
2026-10-10 23:42:01 +02:00
"\"auth_nonce\": \"{}\", \"auth_cnonce\": \"{}\", \"auth_response\": \"{}\", "
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.
2026-10-11 15:58:49 +02:00
"\"security_server\": \"{}\", \"pcscf\": \"{}\"}}",
2026-09-08 20:37:47 +02:00
r.callid, r.ftag, r.cseq, r.spiUc, r.spiUs, r.expiry, route, ppi, aor,
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.
2026-10-11 15:58:49 +02:00
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);
if (std::FILE* f = std::fopen(path.c_str(), "w")) {
std::fwrite(j.data(), 1, j.size(), f);
std::fclose(f);
}
}
// Raw dump of a received message for offline diffing against the stock-modem
// oracle (rung-5b registration-parity work). One file per message kind,
// overwritten on every registration cycle. The dumps carry the subscriber's
// IMSI/MSISDN and addresses, so they are opt-in (DUMP_SIP=1) and mode 0600.
void DumpRaw(std::string_view name, std::string_view msg) {
if (EnvOr("DUMP_SIP", "0") != "1") return;
std::string path = std::format("{}/{}", EnvOr("DUMP_DIR", StateDir()), name);
int fd = open(path.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0600);
if (fd < 0) return;
(void)!write(fd, msg.data(), msg.size());
close(fd);
}
struct PersistedState {
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.
2026-10-11 15:58:49 +02:00
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() {
std::FILE* f = std::fopen(StatePath().c_str(), "r");
if (!f) return std::nullopt;
std::string s;
char buf[1024]; std::size_t n;
while ((n = std::fread(buf, 1, sizeof buf, f)) > 0) s.append(buf, n);
std::fclose(f);
PersistedState ps;
ps.callid = QuotedAfter(s, "\"callid\": \"");
ps.ftag = QuotedAfter(s, "\"ftag\": \"");
ps.route = QuotedAfter(s, "\"route\": \"");
ps.ppi = QuotedAfter(s, "\"ppi\": \"");
2026-09-08 20:37:47 +02:00
ps.aor = QuotedAfter(s, "\"aor\": \"");
ps.contactUser = QuotedAfter(s, "\"contact_user\": \"");
sip: UDP for the protected leg, TCP first with a fallback imsd's protected leg (the second REGISTER and everything after it) was TCP only. A P-CSCF that never answers the TCP connect on its protected server port (O2 UK: the SYNs leave ESP-protected, nothing comes back) left the unit looping with no way forward, although the SAs, the listener sockets and the firewall rule already covered UDP. The client flow now carries a transport. UDP binds the same protected client port and connect()s the datagram socket to the P-CSCF's protected server port, so the kernel delivers that peer's datagrams to it ahead of the unconnected listener on the same port. One datagram is one message (RFC 3261 18.3: a Content-Length that fits truncates, one that does not fit discards the datagram, none means the rest of the datagram); a receive error is logged and marks the flow dead; a message over the single-packet ESP budget at the ims PDN's MTU is logged once per flow, since the kernel fragments it and a P-CSCF may drop the fragments. Every protected request's Via follows the transport; the challenge stays UDP. SIP_TRANSPORT selects the policy: auto (default) registers over TCP and, after two unanswered connects, registers again over UDP (new challenge, new SA pair) - but only on a phone whose state file does not record a successful TCP registration, so an outage on a TCP carrier never becomes a second initial REGISTER; a phone whose last registration was UDP goes straight to UDP. tcp and udp force one. A connect refused locally (EADDRNOTAVAIL: the 4-tuple still in TIME_WAIT from the previous flow) keeps retrying and is not counted as silence; SO_ERROR results are logged by name. The registration's transport is persisted so a warm resume reconnects the same way, and the resume gives up after two silences. Two things the change exposed and fixes: an engine-fatal event exited 0 ("exiting for systemd restart" with Restart=on-failure never firing), and a fresh registration refused with the P-CSCF's fresh-SA throttle (Security-Server spi-s=0) must not be retried by a 120-s restart loop, since every attempt re-arms the ~20-min window - it is now waited out in-process, growing on repeats, with D-Bus commands still served. A resume the network refuses falls through to a fresh registration. A retransmitted 200 OK to our INVITE is ACKed again and no longer starts a second media leg. Verified on KPN: resume over TCP after a binary swap, MT and MO calls with media both ways, the throttle deferral and its self-recovery, the UDP client path up to KPN dropping the datagram. The UDP success path is a field test; SIP retransmission timers over UDP are not in this change.
2026-09-17 14:58:46 +02:00
ps.transport = QuotedAfter(s, "\"transport\": \""); // absent in pre-0.3.5 files: TCP
engine, messages, util, imsd: the reregistration carries the last challenge answer and new SA parameters The keepalive refresh and the resume's re-REGISTER carried an initial registration's first-REGISTER Authorization — an empty nonce and response — in the second REGISTER's frame, with a Security-Client naming the SPIs and ports in use. TS 24.229 5.1.1.4.2 has a reregistration carry the Authorization of the last successful authentication — the last received nonce and the last calculated response — and a Security-Client with the new parameter values for the two SA pairs a re-authentication would set up; TS 33.203 7.4.0 keeps the server ports and changes the client ports. O2 UK refused the refresh of the old shape with 503 on every occasion on record (alyx, 2026-09-24 to 10-08, a stationary phone included) and accepted the fresh registrations that followed; KPN accepted the old shape without a challenge. The fresh registration now records the 401's nonce, our cnonce and the response in the registration state (auth_nonce, auth_cnonce, auth_response in the state file); a reregistration sends them verbatim, mints new SPIs and proposes PortUcNext (45063) as the client port with the server port unchanged. A registration resumed from a state file written before this change has no answer: its reregistrations carry the empty digest, as before, until its next fresh registration, and the resume says so. A 401 to a reregistration asks for re-authentication over temporary SAs on the proposed ports (TS 24.229 5.1.1.5.1), which imsd does not implement: the refresh throws it as a refusal and RegRecovery makes the next step the fresh registration, replacing the in-place answer over the old SAs. Tested with the Messages and Engine suites and on the dev phone (KPN) with a build of this commit's tree: a resume from the previous state file, a fresh registration, a resume that sent the stored answer, three refreshes at a 120-s interval, an outgoing call to a bench line and an incoming call's ring.
2026-10-10 21:00:28 +02:00
ps.authNonce = QuotedAfter(s, "\"auth_nonce\": \""); // the three absent in pre-0.3.11 files
ps.authCnonce = QuotedAfter(s, "\"auth_cnonce\": \"");
ps.authResponse = QuotedAfter(s, "\"auth_response\": \"");
imsd: a resumed registration echoes the 401's Security-Server list from the state file A warm resume rebuilt its Security-Verify from the kernel's SA: one mechanism with hmac-sha-1-96 and aes-cbc written in, where RFC 3329 §2.3.1 has the client mirror the server's whole list. The fresh registration kept the 401's list in memory, so a registration this process made fresh echoed it; after a restart, the resumed registration's re-REGISTER, its keepalives, its reg-event SUBSCRIBE and its INVITEs carried the rebuilt entry until the next fresh registration. KPN's one-entry answer to the default offer is the rebuilt entry byte for byte; against a six-entry list (O2's) the mirror is broken, which the RFC has the server answer with a 494, and a refused resume registers fresh. The fresh registration now stores the joined Security-Server value in the state file (security_server, next to the challenge answer) and the resume takes it from there, logging the mechanism count, when the entry carrying its SPIs names the SA pair in the kernel: a file an attempt the process did not live to record left behind (its SAs installed, its 200 not written) names another. A file from before this change holds none. In both cases the resume falls back to the rebuilt entry, says which, and the registration's next state write stores that entry, which is what it echoes; the choice is Imsd:Ipsec's, pinned in tests/Ipsec with O2's recorded list and KPN's. A value with a quote, a backslash or a newline in it is written empty, which the resume treats as absent; RFC 3329's token syntax produces none of these, a quoted-string extension parameter could carry the first two. Tested on the dev phone (KPN) with the build of this tree: a resume from a state file the 0.3.11 daemon wrote logs the fallback and succeeds, so do one from a file whose list names another SA pair and one whose list is empty; two keepalives at a 120-s interval; a fresh registration stores KPN's list byte-for-byte as the journal shows it received; a restart resumes with the list from the file; an outgoing call.
2026-10-10 23:42:01 +02:00
ps.securityServer = QuotedAfter(s, "\"security_server\": \""); // absent in pre-0.3.12 files
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.
2026-10-11 15:58:49 +02:00
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\": ");
ps.expiry = IntAfter(s, "\"expiry\": ");
return ps;
}
// ---- daemon-level shared state (main thread only) -------------------------
struct CallInfo {
std::string uni, number, state, reason;
std::int64_t startedAt = 0;
std::int64_t answeredAt = 0;
std::string direction = "outgoing";
};
struct StatusSnapshot {
bool registered = false;
std::string local, pcscf, ppi;
std::int64_t expiry = 0;
std::int64_t cseq = 0;
};
// engine -> main-loop event
struct Event {
enum class Type { Added, State, Deleted, Registration, Status, Fatal } type;
CallInfo info; // Added
std::string uni, state, reason; // State/Deleted
StatusSnapshot status; // Registration/Status
std::string text; // Fatal
};
GDBusConnection* DbusConn = nullptr;
std::map<std::string, CallInfo> Calls; // main thread only
StatusSnapshot StatusSnap; // main thread only
GMainLoop* MainLoop = nullptr;
sip: UDP for the protected leg, TCP first with a fallback imsd's protected leg (the second REGISTER and everything after it) was TCP only. A P-CSCF that never answers the TCP connect on its protected server port (O2 UK: the SYNs leave ESP-protected, nothing comes back) left the unit looping with no way forward, although the SAs, the listener sockets and the firewall rule already covered UDP. The client flow now carries a transport. UDP binds the same protected client port and connect()s the datagram socket to the P-CSCF's protected server port, so the kernel delivers that peer's datagrams to it ahead of the unconnected listener on the same port. One datagram is one message (RFC 3261 18.3: a Content-Length that fits truncates, one that does not fit discards the datagram, none means the rest of the datagram); a receive error is logged and marks the flow dead; a message over the single-packet ESP budget at the ims PDN's MTU is logged once per flow, since the kernel fragments it and a P-CSCF may drop the fragments. Every protected request's Via follows the transport; the challenge stays UDP. SIP_TRANSPORT selects the policy: auto (default) registers over TCP and, after two unanswered connects, registers again over UDP (new challenge, new SA pair) - but only on a phone whose state file does not record a successful TCP registration, so an outage on a TCP carrier never becomes a second initial REGISTER; a phone whose last registration was UDP goes straight to UDP. tcp and udp force one. A connect refused locally (EADDRNOTAVAIL: the 4-tuple still in TIME_WAIT from the previous flow) keeps retrying and is not counted as silence; SO_ERROR results are logged by name. The registration's transport is persisted so a warm resume reconnects the same way, and the resume gives up after two silences. Two things the change exposed and fixes: an engine-fatal event exited 0 ("exiting for systemd restart" with Restart=on-failure never firing), and a fresh registration refused with the P-CSCF's fresh-SA throttle (Security-Server spi-s=0) must not be retried by a 120-s restart loop, since every attempt re-arms the ~20-min window - it is now waited out in-process, growing on repeats, with D-Bus commands still served. A resume the network refuses falls through to a fresh registration. A retransmitted 200 OK to our INVITE is ACKed again and no longer starts a second media leg. Verified on KPN: resume over TCP after a binary swap, MT and MO calls with media both ways, the throttle deferral and its self-recovery, the UDP client path up to KPN dropping the datagram. The UDP success path is a field test; SIP retransmission timers over UDP are not in this change.
2026-09-17 14:58:46 +02:00
bool FatalExit = false; // set by an engine-fatal event: main() exits non-zero so systemd restarts us
std::int64_t NowEpoch() {
return std::chrono::duration_cast<std::chrono::seconds>(std::chrono::system_clock::now().time_since_epoch()).count();
}
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
// 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
packaging: restart imsd and imsd-ofonod on upgrade An upgrade left the old imsd and imsd-ofonod running until they were next restarted, while a changed bus policy is live the moment apk writes it: dbus-broker-launch reloads on its own. A policy that relies on a check inside the daemons would face the old daemons in between. Mark imsd.service and imsd-ofonod.service needs-restart in post-upgrade scripts; the systemd trigger restarts them once the transaction is done (enqueue-marked). A call in progress ends. The same scripts run as post-install: `apk del` removes a package's files and leaves its daemon running, so a later `apk add` of the package is this upgrade for that daemon — and apk runs the post-install script, not post-upgrade, for a package it adds. GNOME Calls 50.0 dials nothing after imsd-ofonod restarts under it until it restarts too, so imsd-ofonod's ExecStartPost restarts the Calls daemon of each user running one — not at the unit's first start since boot, when a Calls that runs already is left alone. A daemon older than 0.3.10 kept its bring-up throttle wait in memory only, so a restart lost it. imsd's post-upgrade records that daemon's invocation, and imsd-handover.sh, an ExecStartPre after ims-pdn-up.sh, reads the invocation's journal and, while the fresh-SA throttle's wait it finds there still runs (`spi-s=0`; the old daemon's other deferrals are not handed over), writes what is left of it to the holds file when this start's LOCAL is the address the wait was earned on, or when that address cannot be read; when LOCAL is another address, only the verdict that the old registration is gone. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-10-04 00:09:37 +02:00
// attempt, a terminated binding's wait) outlives a restart of imsd, and
// an upgrade restarts it. They 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 network's timers do. Whether the old
// registration is gone travels with them: a restart inside a hold must
// not resume over SAs the network has written off.
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
std::int64_t BootSeconds() {
timespec ts{};
clock_gettime(CLOCK_BOOTTIME, &ts);
return ts.tv_sec;
}
std::string BootId() {
std::string id;
if (std::FILE* f = std::fopen("/proc/sys/kernel/random/boot_id", "r")) {
char buf[64];
id.assign(buf, std::fread(buf, 1, sizeof buf, f));
std::fclose(f);
}
while (!id.empty() && std::isspace(static_cast<unsigned char>(id.back()))) id.pop_back();
return id;
}
// The recovery policy runs on the same clock: a hold in the process and
// its copy in the file age alike, through a suspend as the network's
// timers do.
double Boot() {
timespec ts{};
clock_gettime(CLOCK_BOOTTIME, &ts);
return static_cast<double>(ts.tv_sec) + static_cast<double>(ts.tv_nsec) / 1e9;
}
std::string HoldsPath() {
if (const char* o = std::getenv("HOLDS_FILE")) return o;
if (geteuid() == 0) return "/run/imsd.holds";
return std::format("{}/imsd.holds", EnvOr("XDG_RUNTIME_DIR", "/tmp"));
}
void PersistHolds(const imsd::engine::RegRecovery::Held& h, const std::string& pdn) {
std::string path = HoldsPath();
if (!h.Any()) {
// nothing to keep: the file goes — when it is ours. A device node or a
// link the HOLDS_FILE knob points at (/dev/null switches the file off) is
// emptied instead, never unlinked.
std::error_code ec;
auto st = std::filesystem::symlink_status(path, ec);
if (std::filesystem::is_regular_file(st)) std::remove(path.c_str());
else if (std::filesystem::exists(st)) { if (std::FILE* f = std::fopen(path.c_str(), "w")) std::fclose(f); }
return;
}
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.
2026-10-11 15:58:49 +02:00
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);
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
if (std::FILE* f = std::fopen(path.c_str(), "w")) {
std::fwrite(j.data(), 1, j.size(), f);
std::fclose(f);
}
}
std::optional<imsd::engine::RegRecovery::Held> LoadHolds(const std::string& pdn) {
std::FILE* f = std::fopen(HoldsPath().c_str(), "r");
if (!f) return std::nullopt;
std::string s;
char buf[512]; std::size_t n;
while ((n = std::fread(buf, 1, sizeof buf, f)) > 0) s.append(buf, n);
std::fclose(f);
if (imsd::util::CanonicalAddr(QuotedAfter(s, "\"pdn\": \"").value_or("")) != imsd::util::CanonicalAddr(pdn)) return std::nullopt;
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
if (QuotedAfter(s, "\"boot\": \"").value_or("") != BootId()) return std::nullopt;
long elapsed = BootSeconds() - IntAfter(s, "\"written_at\": ").value_or(0);
imsd::engine::RegRecovery::Held h;
h.hold = std::max<long>(0, IntAfter(s, "\"hold\": ").value_or(0) - elapsed);
h.freshHold = std::max<long>(0, IntAfter(s, "\"fresh_hold\": ").value_or(0) - elapsed);
h.throttleMinutes = static_cast<int>(IntAfter(s, "\"throttle_minutes\": ").value_or(h.throttleMinutes));
h.oldGone = s.find("\"old_gone\": true") != std::string::npos;
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.
2026-10-11 15:58:49 +02:00
h.refusals = static_cast<int>(IntAfter(s, "\"refusals\": ").value_or(0));
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
if (!h.Any()) return std::nullopt;
return h;
}
// build an a{sv} from a CallInfo (matches imsd.py _d typing)
GVariant* CallVariant(const CallInfo& c) {
GVariantBuilder b;
g_variant_builder_init(&b, G_VARIANT_TYPE("a{sv}"));
g_variant_builder_add(&b, "{sv}", "uni", g_variant_new_string(c.uni.c_str()));
g_variant_builder_add(&b, "{sv}", "number", g_variant_new_string(c.number.c_str()));
g_variant_builder_add(&b, "{sv}", "state", g_variant_new_string(c.state.c_str()));
g_variant_builder_add(&b, "{sv}", "reason", g_variant_new_string(c.reason.c_str()));
g_variant_builder_add(&b, "{sv}", "startedAt", g_variant_new_int64(c.startedAt));
g_variant_builder_add(&b, "{sv}", "answeredAt", g_variant_new_int64(c.answeredAt));
g_variant_builder_add(&b, "{sv}", "direction", g_variant_new_string(c.direction.c_str()));
return g_variant_builder_end(&b);
}
void EmitSignal(const char* name, GVariant* params) {
if (!DbusConn) return;
g_dbus_connection_emit_signal(DbusConn, nullptr, ObjPath, Iface, name, params, nullptr);
}
gboolean OnIdleEvent(gpointer data) {
std::unique_ptr<Event> ev(static_cast<Event*>(data));
switch (ev->type) {
case Event::Type::Added: {
Calls[ev->info.uni] = ev->info;
EmitSignal("CallAdded", g_variant_new("(s@a{sv})", ev->info.uni.c_str(), CallVariant(ev->info)));
break;
}
case Event::Type::State: {
auto it = Calls.find(ev->uni);
if (it != Calls.end()) {
it->second.state = ev->state;
it->second.reason = ev->reason;
if (ev->state == "active" && it->second.answeredAt == 0) it->second.answeredAt = NowEpoch();
}
EmitSignal("CallStateChanged", g_variant_new("(sss)", ev->uni.c_str(), ev->state.c_str(), ev->reason.c_str()));
break;
}
case Event::Type::Deleted:
Calls.erase(ev->uni);
EmitSignal("CallDeleted", g_variant_new("(s)", ev->uni.c_str()));
break;
case Event::Type::Registration:
StatusSnap = ev->status;
EmitSignal("RegistrationChanged", g_variant_new("(b)", ev->status.registered ? TRUE : FALSE));
break;
case Event::Type::Status:
StatusSnap = ev->status;
break;
case Event::Type::Fatal:
Log(std::format("engine fatal: {} — exiting for systemd restart", ev->text));
sip: UDP for the protected leg, TCP first with a fallback imsd's protected leg (the second REGISTER and everything after it) was TCP only. A P-CSCF that never answers the TCP connect on its protected server port (O2 UK: the SYNs leave ESP-protected, nothing comes back) left the unit looping with no way forward, although the SAs, the listener sockets and the firewall rule already covered UDP. The client flow now carries a transport. UDP binds the same protected client port and connect()s the datagram socket to the P-CSCF's protected server port, so the kernel delivers that peer's datagrams to it ahead of the unconnected listener on the same port. One datagram is one message (RFC 3261 18.3: a Content-Length that fits truncates, one that does not fit discards the datagram, none means the rest of the datagram); a receive error is logged and marks the flow dead; a message over the single-packet ESP budget at the ims PDN's MTU is logged once per flow, since the kernel fragments it and a P-CSCF may drop the fragments. Every protected request's Via follows the transport; the challenge stays UDP. SIP_TRANSPORT selects the policy: auto (default) registers over TCP and, after two unanswered connects, registers again over UDP (new challenge, new SA pair) - but only on a phone whose state file does not record a successful TCP registration, so an outage on a TCP carrier never becomes a second initial REGISTER; a phone whose last registration was UDP goes straight to UDP. tcp and udp force one. A connect refused locally (EADDRNOTAVAIL: the 4-tuple still in TIME_WAIT from the previous flow) keeps retrying and is not counted as silence; SO_ERROR results are logged by name. The registration's transport is persisted so a warm resume reconnects the same way, and the resume gives up after two silences. Two things the change exposed and fixes: an engine-fatal event exited 0 ("exiting for systemd restart" with Restart=on-failure never firing), and a fresh registration refused with the P-CSCF's fresh-SA throttle (Security-Server spi-s=0) must not be retried by a 120-s restart loop, since every attempt re-arms the ~20-min window - it is now waited out in-process, growing on repeats, with D-Bus commands still served. A resume the network refuses falls through to a fresh registration. A retransmitted 200 OK to our INVITE is ACKed again and no longer starts a second media leg. Verified on KPN: resume over TCP after a binary swap, MT and MO calls with media both ways, the throttle deferral and its self-recovery, the UDP client path up to KPN dropping the datagram. The UDP success path is a field test; SIP retransmission timers over UDP are not in this change.
2026-09-17 14:58:46 +02:00
FatalExit = true; // non-zero exit, or Restart=on-failure never fires
g_main_loop_quit(MainLoop);
break;
}
return G_SOURCE_REMOVE;
}
void PostEvent(std::unique_ptr<Event> ev) {
g_idle_add(OnIdleEvent, ev.release());
}
// ================= engine ==================================================
class Engine {
public:
Engine() {
// 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<std::string> 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());
rtpPort_ = std::atoi(EnvOr("RTP_PORT", "50004").c_str());
precond_ = EnvOr("PRECOND", "0") == "1";
ealgOffer_ = EnvOr("EALG", "aes-cbc");
sip: UDP for the protected leg, TCP first with a fallback imsd's protected leg (the second REGISTER and everything after it) was TCP only. A P-CSCF that never answers the TCP connect on its protected server port (O2 UK: the SYNs leave ESP-protected, nothing comes back) left the unit looping with no way forward, although the SAs, the listener sockets and the firewall rule already covered UDP. The client flow now carries a transport. UDP binds the same protected client port and connect()s the datagram socket to the P-CSCF's protected server port, so the kernel delivers that peer's datagrams to it ahead of the unconnected listener on the same port. One datagram is one message (RFC 3261 18.3: a Content-Length that fits truncates, one that does not fit discards the datagram, none means the rest of the datagram); a receive error is logged and marks the flow dead; a message over the single-packet ESP budget at the ims PDN's MTU is logged once per flow, since the kernel fragments it and a P-CSCF may drop the fragments. Every protected request's Via follows the transport; the challenge stays UDP. SIP_TRANSPORT selects the policy: auto (default) registers over TCP and, after two unanswered connects, registers again over UDP (new challenge, new SA pair) - but only on a phone whose state file does not record a successful TCP registration, so an outage on a TCP carrier never becomes a second initial REGISTER; a phone whose last registration was UDP goes straight to UDP. tcp and udp force one. A connect refused locally (EADDRNOTAVAIL: the 4-tuple still in TIME_WAIT from the previous flow) keeps retrying and is not counted as silence; SO_ERROR results are logged by name. The registration's transport is persisted so a warm resume reconnects the same way, and the resume gives up after two silences. Two things the change exposed and fixes: an engine-fatal event exited 0 ("exiting for systemd restart" with Restart=on-failure never firing), and a fresh registration refused with the P-CSCF's fresh-SA throttle (Security-Server spi-s=0) must not be retried by a 120-s restart loop, since every attempt re-arms the ~20-min window - it is now waited out in-process, growing on repeats, with D-Bus commands still served. A resume the network refuses falls through to a fresh registration. A retransmitted 200 OK to our INVITE is ACKed again and no longer starts a second media leg. Verified on KPN: resume over TCP after a binary swap, MT and MO calls with media both ways, the throttle deferral and its self-recovery, the UDP client path up to KPN dropping the datagram. The UDP success path is a field test; SIP retransmission timers over UDP are not in this change.
2026-09-17 14:58:46 +02:00
// SIP_TRANSPORT: the protected leg's transport. auto (default) = TCP,
// and when the P-CSCF never answers the TCP connect, a fresh
// registration over UDP; tcp / udp force one. The challenge (first
// REGISTER) is UDP regardless.
sipPolicy_ = EnvOr("SIP_TRANSPORT", "auto");
if (sipPolicy_ != "auto" && sipPolicy_ != "tcp" && sipPolicy_ != "udp") {
Log(std::format("SIP_TRANSPORT={} unknown; using auto", sipPolicy_));
sipPolicy_ = "auto";
}
transport_ = sipPolicy_ == "udp" ? Transport::Udp : Transport::Tcp;
mediaBin_ = ResolveMediaBin();
// CODECS: restrict + reorder the codecs we offer and accept (bench
// knob — a gateway that transcodes every caller up to AMR-WB never
// lets the narrowband path run otherwise). Empty = defaults.
codecs_ = imsd::sdp::ParseCodecList(EnvOr("CODECS", ""));
if (!codecs_.empty()) {
std::string list;
for (const auto& c : codecs_) list += (list.empty() ? "" : ",") + c;
Log(std::format("CODECS override active: {}", list));
}
// EMERGENCY_NUMBERS: comma-separated additions to the builtin
// 112/911 (a lab core's advertised short code, carrier extras).
std::string extra = EnvOr("EMERGENCY_NUMBERS", "");
for (std::size_t pos = 0; pos <= extra.size();) {
std::size_t comma = extra.find(',', pos);
std::size_t end = comma == std::string::npos ? extra.size() : comma;
std::string n = extra.substr(pos, end - pos);
std::erase(n, ' ');
if (!n.empty()) emergencyExtra_.push_back(std::move(n));
if (comma == std::string::npos) break;
pos = comma + 1;
}
}
void SetLocal(std::string l) { local_ = imsd::util::CanonicalAddr(l); pdnLocal_ = local_; }
void SetDevMode(bool d) { devMode_ = d; }
const std::string& Local() const { return local_; }
// Dial() runs on the D-Bus thread: allocate the uni, queue the work.
std::string Dial(const std::string& number) {
std::scoped_lock g(cmdLock_);
std::string uni = std::format("ims-call-{}", ++callSeq_);
cmds_.push_back({Cmd::Kind::Dial, uni, number});
return uni;
}
void HangUp(const std::string& uni) {
std::scoped_lock g(cmdLock_);
cmds_.push_back({Cmd::Kind::HangUp, uni, {}});
}
void Accept(const std::string& uni) {
std::scoped_lock g(cmdLock_);
cmds_.push_back({Cmd::Kind::Accept, uni, {}});
}
void Stop() {
{ std::scoped_lock g(cmdLock_); cmds_.push_back({Cmd::Kind::Stop, {}, {}}); }
quit_.store(true);
}
void Run() {
if (auto h = LoadHolds(pdnLocal_)) {
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
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)
Log(std::format("a hold the network set before this start stands ({:.0f} s for any attempt, {:.0f} s for a fresh registration; the refusal schedule at {} min)", kept.hold, kept.freshHold, kept.throttleMinutes));
else if (kept.throttleMinutes > imsd::engine::RegRecovery::ThrottleMinutesMin)
Log(std::format("the refusal schedule stands at {} min from before this start: a refused or throttled fresh registration waits that long", kept.throttleMinutes));
if (kept.oldGone) Log("the old registration was gone before this start: no resume over its SAs, the fresh registration once its wait has run");
}
sip: UDP for the protected leg, TCP first with a fallback imsd's protected leg (the second REGISTER and everything after it) was TCP only. A P-CSCF that never answers the TCP connect on its protected server port (O2 UK: the SYNs leave ESP-protected, nothing comes back) left the unit looping with no way forward, although the SAs, the listener sockets and the firewall rule already covered UDP. The client flow now carries a transport. UDP binds the same protected client port and connect()s the datagram socket to the P-CSCF's protected server port, so the kernel delivers that peer's datagrams to it ahead of the unconnected listener on the same port. One datagram is one message (RFC 3261 18.3: a Content-Length that fits truncates, one that does not fit discards the datagram, none means the rest of the datagram); a receive error is logged and marks the flow dead; a message over the single-packet ESP budget at the ims PDN's MTU is logged once per flow, since the kernel fragments it and a P-CSCF may drop the fragments. Every protected request's Via follows the transport; the challenge stays UDP. SIP_TRANSPORT selects the policy: auto (default) registers over TCP and, after two unanswered connects, registers again over UDP (new challenge, new SA pair) - but only on a phone whose state file does not record a successful TCP registration, so an outage on a TCP carrier never becomes a second initial REGISTER; a phone whose last registration was UDP goes straight to UDP. tcp and udp force one. A connect refused locally (EADDRNOTAVAIL: the 4-tuple still in TIME_WAIT from the previous flow) keeps retrying and is not counted as silence; SO_ERROR results are logged by name. The registration's transport is persisted so a warm resume reconnects the same way, and the resume gives up after two silences. Two things the change exposed and fixes: an engine-fatal event exited 0 ("exiting for systemd restart" with Restart=on-failure never firing), and a fresh registration refused with the P-CSCF's fresh-SA throttle (Security-Server spi-s=0) must not be retried by a 120-s restart loop, since every attempt re-arms the ~20-min window - it is now waited out in-process, growing on repeats, with D-Bus commands still served. A resume the network refuses falls through to a fresh registration. A retransmitted 200 OK to our INVITE is ACKed again and no longer starts a second media leg. Verified on KPN: resume over TCP after a binary swap, MT and MO calls with media both ways, the throttle deferral and its self-recovery, the UDP client path up to KPN dropping the datagram. The UDP success path is a field test; SIP retransmission timers over UDP are not in this change.
2026-09-17 14:58:46 +02:00
for (;;) {
try {
BringUp();
sip: UDP for the protected leg, TCP first with a fallback imsd's protected leg (the second REGISTER and everything after it) was TCP only. A P-CSCF that never answers the TCP connect on its protected server port (O2 UK: the SYNs leave ESP-protected, nothing comes back) left the unit looping with no way forward, although the SAs, the listener sockets and the firewall rule already covered UDP. The client flow now carries a transport. UDP binds the same protected client port and connect()s the datagram socket to the P-CSCF's protected server port, so the kernel delivers that peer's datagrams to it ahead of the unconnected listener on the same port. One datagram is one message (RFC 3261 18.3: a Content-Length that fits truncates, one that does not fit discards the datagram, none means the rest of the datagram); a receive error is logged and marks the flow dead; a message over the single-packet ESP budget at the ims PDN's MTU is logged once per flow, since the kernel fragments it and a P-CSCF may drop the fragments. Every protected request's Via follows the transport; the challenge stays UDP. SIP_TRANSPORT selects the policy: auto (default) registers over TCP and, after two unanswered connects, registers again over UDP (new challenge, new SA pair) - but only on a phone whose state file does not record a successful TCP registration, so an outage on a TCP carrier never becomes a second initial REGISTER; a phone whose last registration was UDP goes straight to UDP. tcp and udp force one. A connect refused locally (EADDRNOTAVAIL: the 4-tuple still in TIME_WAIT from the previous flow) keeps retrying and is not counted as silence; SO_ERROR results are logged by name. The registration's transport is persisted so a warm resume reconnects the same way, and the resume gives up after two silences. Two things the change exposed and fixes: an engine-fatal event exited 0 ("exiting for systemd restart" with Restart=on-failure never firing), and a fresh registration refused with the P-CSCF's fresh-SA throttle (Security-Server spi-s=0) must not be retried by a 120-s restart loop, since every attempt re-arms the ~20-min window - it is now waited out in-process, growing on repeats, with D-Bus commands still served. A resume the network refuses falls through to a fresh registration. A retransmitted 200 OK to our INVITE is ACKed again and no longer starts a second media leg. Verified on KPN: resume over TCP after a binary swap, MT and MO calls with media both ways, the throttle deferral and its self-recovery, the UDP client path up to KPN dropping the datagram. The UDP success path is a field test; SIP retransmission timers over UDP are not in this change.
2026-09-17 14:58:46 +02:00
break;
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
} catch (const Stopping&) {
return;
sip: UDP for the protected leg, TCP first with a fallback imsd's protected leg (the second REGISTER and everything after it) was TCP only. A P-CSCF that never answers the TCP connect on its protected server port (O2 UK: the SYNs leave ESP-protected, nothing comes back) left the unit looping with no way forward, although the SAs, the listener sockets and the firewall rule already covered UDP. The client flow now carries a transport. UDP binds the same protected client port and connect()s the datagram socket to the P-CSCF's protected server port, so the kernel delivers that peer's datagrams to it ahead of the unconnected listener on the same port. One datagram is one message (RFC 3261 18.3: a Content-Length that fits truncates, one that does not fit discards the datagram, none means the rest of the datagram); a receive error is logged and marks the flow dead; a message over the single-packet ESP budget at the ims PDN's MTU is logged once per flow, since the kernel fragments it and a P-CSCF may drop the fragments. Every protected request's Via follows the transport; the challenge stays UDP. SIP_TRANSPORT selects the policy: auto (default) registers over TCP and, after two unanswered connects, registers again over UDP (new challenge, new SA pair) - but only on a phone whose state file does not record a successful TCP registration, so an outage on a TCP carrier never becomes a second initial REGISTER; a phone whose last registration was UDP goes straight to UDP. tcp and udp force one. A connect refused locally (EADDRNOTAVAIL: the 4-tuple still in TIME_WAIT from the previous flow) keeps retrying and is not counted as silence; SO_ERROR results are logged by name. The registration's transport is persisted so a warm resume reconnects the same way, and the resume gives up after two silences. Two things the change exposed and fixes: an engine-fatal event exited 0 ("exiting for systemd restart" with Restart=on-failure never firing), and a fresh registration refused with the P-CSCF's fresh-SA throttle (Security-Server spi-s=0) must not be retried by a 120-s restart loop, since every attempt re-arms the ~20-min window - it is now waited out in-process, growing on repeats, with D-Bus commands still served. A resume the network refuses falls through to a fresh registration. A retransmitted 200 OK to our INVITE is ACKed again and no longer starts a second media leg. Verified on KPN: resume over TCP after a binary swap, MT and MO calls with media both ways, the throttle deferral and its self-recovery, the UDP client path up to KPN dropping the datagram. The UDP success path is a field test; SIP retransmission timers over UDP are not in this change.
2026-09-17 14:58:46 +02:00
} catch (const ThrottledError& e) {
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
DeferBringUp(e.what(), imsd::engine::RegRecovery::Failure::Throttled, 0);
if (quit_.load()) return;
continue;
} catch (const RefusedError& e) {
// A refused initial registration (403, 5xx) waits like a
// throttle: a fatal would restart imsd every RestartSec with an
// initial REGISTER each time.
DeferBringUp(e.what(), imsd::engine::RegRecovery::Failure::Refused, e.retryAfter);
sip: UDP for the protected leg, TCP first with a fallback imsd's protected leg (the second REGISTER and everything after it) was TCP only. A P-CSCF that never answers the TCP connect on its protected server port (O2 UK: the SYNs leave ESP-protected, nothing comes back) left the unit looping with no way forward, although the SAs, the listener sockets and the firewall rule already covered UDP. The client flow now carries a transport. UDP binds the same protected client port and connect()s the datagram socket to the P-CSCF's protected server port, so the kernel delivers that peer's datagrams to it ahead of the unconnected listener on the same port. One datagram is one message (RFC 3261 18.3: a Content-Length that fits truncates, one that does not fit discards the datagram, none means the rest of the datagram); a receive error is logged and marks the flow dead; a message over the single-packet ESP budget at the ims PDN's MTU is logged once per flow, since the kernel fragments it and a P-CSCF may drop the fragments. Every protected request's Via follows the transport; the challenge stays UDP. SIP_TRANSPORT selects the policy: auto (default) registers over TCP and, after two unanswered connects, registers again over UDP (new challenge, new SA pair) - but only on a phone whose state file does not record a successful TCP registration, so an outage on a TCP carrier never becomes a second initial REGISTER; a phone whose last registration was UDP goes straight to UDP. tcp and udp force one. A connect refused locally (EADDRNOTAVAIL: the 4-tuple still in TIME_WAIT from the previous flow) keeps retrying and is not counted as silence; SO_ERROR results are logged by name. The registration's transport is persisted so a warm resume reconnects the same way, and the resume gives up after two silences. Two things the change exposed and fixes: an engine-fatal event exited 0 ("exiting for systemd restart" with Restart=on-failure never firing), and a fresh registration refused with the P-CSCF's fresh-SA throttle (Security-Server spi-s=0) must not be retried by a 120-s restart loop, since every attempt re-arms the ~20-min window - it is now waited out in-process, growing on repeats, with D-Bus commands still served. A resume the network refuses falls through to a fresh registration. A retransmitted 200 OK to our INVITE is ACKed again and no longer starts a second media leg. Verified on KPN: resume over TCP after a binary swap, MT and MO calls with media both ways, the throttle deferral and its self-recovery, the UDP client path up to KPN dropping the datagram. The UDP success path is a field test; SIP retransmission timers over UDP are not in this change.
2026-09-17 14:58:46 +02:00
if (quit_.load()) return;
continue;
} catch (const std::exception& e) {
if (devMode_) {
// dev/session mode (no modem): keep the ABI up, unregistered,
// so the D-Bus surface can be exercised without a phone.
Log(std::format("bring-up skipped (dev mode): {}", e.what()));
registered_ = false;
EmitStatus(true);
DevLoop();
return;
}
Log(std::format("bring-up FAILED: {}", e.what()));
auto ev = std::make_unique<Event>();
ev->type = Event::Type::Fatal; ev->text = e.what();
PostEvent(std::move(ev));
return;
}
sip: UDP for the protected leg, TCP first with a fallback imsd's protected leg (the second REGISTER and everything after it) was TCP only. A P-CSCF that never answers the TCP connect on its protected server port (O2 UK: the SYNs leave ESP-protected, nothing comes back) left the unit looping with no way forward, although the SAs, the listener sockets and the firewall rule already covered UDP. The client flow now carries a transport. UDP binds the same protected client port and connect()s the datagram socket to the P-CSCF's protected server port, so the kernel delivers that peer's datagrams to it ahead of the unconnected listener on the same port. One datagram is one message (RFC 3261 18.3: a Content-Length that fits truncates, one that does not fit discards the datagram, none means the rest of the datagram); a receive error is logged and marks the flow dead; a message over the single-packet ESP budget at the ims PDN's MTU is logged once per flow, since the kernel fragments it and a P-CSCF may drop the fragments. Every protected request's Via follows the transport; the challenge stays UDP. SIP_TRANSPORT selects the policy: auto (default) registers over TCP and, after two unanswered connects, registers again over UDP (new challenge, new SA pair) - but only on a phone whose state file does not record a successful TCP registration, so an outage on a TCP carrier never becomes a second initial REGISTER; a phone whose last registration was UDP goes straight to UDP. tcp and udp force one. A connect refused locally (EADDRNOTAVAIL: the 4-tuple still in TIME_WAIT from the previous flow) keeps retrying and is not counted as silence; SO_ERROR results are logged by name. The registration's transport is persisted so a warm resume reconnects the same way, and the resume gives up after two silences. Two things the change exposed and fixes: an engine-fatal event exited 0 ("exiting for systemd restart" with Restart=on-failure never firing), and a fresh registration refused with the P-CSCF's fresh-SA throttle (Security-Server spi-s=0) must not be retried by a 120-s restart loop, since every attempt re-arms the ~20-min window - it is now waited out in-process, growing on repeats, with D-Bus commands still served. A resume the network refuses falls through to a fresh registration. A retransmitted 200 OK to our INVITE is ACKed again and no longer starts a second media leg. Verified on KPN: resume over TCP after a binary swap, MT and MO calls with media both ways, the throttle deferral and its self-recovery, the UDP client path up to KPN dropping the datagram. The UDP success path is a field test; SIP retransmission timers over UDP are not in this change.
2026-09-17 14:58:46 +02:00
}
try { server_.Open(local_); }
catch (...) { Log("server ports unavailable; remote hangup/incoming not seen"); }
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
OnRegistered();
while (!quit_.load()) {
if (auto cmd = PopCmd()) { HandleCmd(*cmd); continue; }
for (Inbound& in : server_.Poll()) {
reply_ = in.reply;
DispatchRequest(in.msg);
}
MaybeRefresh();
MaybeReconnect();
engine: keep the reg-event subscription alive and act on a terminated binding The reg-event subscription is the network's own account of our binding (TS 24.229 5.1.1.3), and the daemon opened a new dialog for it at every refresh and reconnect and otherwise never looked back: a refused SUBSCRIBE was forgotten, an unanswered one had no timeout, an ended subscription got nothing until the next refresh's new dialog, and a NOTIFY was logged whatever it said. O2 UK, 2026-09-24: a fresh registration's SUBSCRIBE got 500, a NOTIFY arrived all the same and showed our contact active, the flow died eight seconds after the 200 and no connect over the SA succeeded after that; the 500 was logged and nothing was done about it. Keep the subscription alive: retry a refused or unanswered SUBSCRIBE on a backoff, renew one the S-CSCF ends with deactivated, timeout, giveup or probation (RFC 6665 4.1.3; after any other reason, or none, the next refresh subscribes anew), and subscribe anew ahead of the lifetime the S-CSCF granted. A refresh or a reconnect opens no dialog while imsd holds one for the registration or has a retry scheduled. Read the reginfo for our own contact and act on its state: a NOTIFY that terminates our binding schedules a fresh registration — at once for deactivated or expired, after the network's retry-after (up to an hour) for probation, on imsd's own refusal schedule (21 min, growing to 60) for rejected or unregistered (RFC 3680). A hold already running stands, and a REGISTER wait a call kept open that still gets its 200 after such a NOTIFY leaves that registration standing. The parser is in Imsd:Sip and pinned by tests/Sip with the O2 document shapes; the policy is RegRecovery's (OnBindingTerminated, added here with its tests in tests/Engine). Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-10-03 19:36:23 +02:00
MaybeResubscribe();
if (!call_ && sip_.Alive() && Mono() - lastKa_ > 30) { sip_.SendKeepalive(); lastKa_ = Mono(); }
ReapMedia();
if (call_ && call_->State() != imsd::engine::CallState::Active && Mono() > deadline_) {
Execute(call_->OnDeadline());
MaybeDropCall();
}
auto msg = sip_.RecvMsg(0.3);
if (!msg) continue;
auto st = imsd::sip::Status(*msg);
if (!st) { reply_ = [this](const std::string& r) { sip_.Send(r); }; DispatchRequest(*msg); continue; }
std::string cseq(imsd::sip::Header(*msg, "CSeq").value_or(""));
std::string callid(imsd::sip::Header(*msg, "Call-ID").value_or(""));
if (call_ && callid == call_->CallId() && cseq.ends_with("INVITE")) {
Execute(call_->OnInviteResponse(*msg));
MaybeDropCall();
engine: keep the reg-event subscription alive and act on a terminated binding The reg-event subscription is the network's own account of our binding (TS 24.229 5.1.1.3), and the daemon opened a new dialog for it at every refresh and reconnect and otherwise never looked back: a refused SUBSCRIBE was forgotten, an unanswered one had no timeout, an ended subscription got nothing until the next refresh's new dialog, and a NOTIFY was logged whatever it said. O2 UK, 2026-09-24: a fresh registration's SUBSCRIBE got 500, a NOTIFY arrived all the same and showed our contact active, the flow died eight seconds after the 200 and no connect over the SA succeeded after that; the 500 was logged and nothing was done about it. Keep the subscription alive: retry a refused or unanswered SUBSCRIBE on a backoff, renew one the S-CSCF ends with deactivated, timeout, giveup or probation (RFC 6665 4.1.3; after any other reason, or none, the next refresh subscribes anew), and subscribe anew ahead of the lifetime the S-CSCF granted. A refresh or a reconnect opens no dialog while imsd holds one for the registration or has a retry scheduled. Read the reginfo for our own contact and act on its state: a NOTIFY that terminates our binding schedules a fresh registration — at once for deactivated or expired, after the network's retry-after (up to an hour) for probation, on imsd's own refusal schedule (21 min, growing to 60) for rejected or unregistered (RFC 3680). A hold already running stands, and a REGISTER wait a call kept open that still gets its 200 after such a NOTIFY leaves that registration standing. The parser is in Imsd:Sip and pinned by tests/Sip with the O2 document shapes; the policy is RegRecovery's (OnBindingTerminated, added here with its tests in tests/Engine). Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-10-03 19:36:23 +02:00
} else {
OnSubscribeResponse(*msg, *st);
}
}
// shutdown: release an active call, keep the SA/registration for resume
if (call_) { Execute(call_->OnHangup()); MaybeDropCall(); }
sip_.Close();
Log("engine stopped (SA left up for resume)");
}
private:
struct Cmd { enum class Kind { Dial, HangUp, Accept, Stop } kind; std::string uni, number; };
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
struct Stopping {}; // the engine was told to stop while a bring-up waited
// A bring-up the network deferred (a fresh-SA throttle) or refused: wait
// it out here, in process, on the recovery policy's schedule, then try
// the bring-up again. There is nothing to reconnect over meanwhile (the
// resume just found the stored SAs unusable, or there are none) and no
// recovery step runs from inside the wait — portPs_ says so to
// MaybeReconnect, whatever the attempt left in it — so a Dial
// meanwhile collapses at once, as in dev mode.
void DeferBringUp(std::string_view why, imsd::engine::RegRecovery::Failure f, long retryAfter) {
FreshAttemptFailed(f, retryAfter, std::format("bring-up deferred: {}", why));
portPs_ = 0;
registered_ = false;
EmitStatus(true);
WaitForPolicy();
}
// Wait until the policy's next step is due. A Dial/Accept/HangUp
// arriving meanwhile must not wait in the queue and run stale once
// registration succeeds; with no flow the call collapses to terminated
// at once (DevLoop's rule). A wait can be long (an hour on the
// schedule), so the REGISTER after one names the access network read
// after it, not before. False when the engine is stopping.
bool WaitForPolicy() {
bool waited = false;
while (!quit_.load() && recovery_.Next(Boot()).at > Boot()) {
waited = true;
if (auto cmd = PopCmd()) { HandleCmd(*cmd); MaybeDropCall(); continue; }
std::this_thread::sleep_for(std::chrono::seconds(1));
}
if (waited && !quit_.load()) RefreshPani();
return !quit_.load();
}
void KeepHolds() { PersistHolds(recovery_.Holds(Boot()), pdnLocal_); }
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.
2026-10-11 15:58:49 +02:00
// 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.
void DevLoop() {
while (!quit_.load()) {
if (auto cmd = PopCmd()) { HandleCmd(*cmd); MaybeDropCall(); continue; }
std::this_thread::sleep_for(std::chrono::milliseconds(200));
}
}
// config
std::string pcscf_, dev_, outDir_, local_, ealgOffer_, mediaBin_;
// 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
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.
2026-10-11 15:58:49 +02:00
// 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_;
std::vector<std::string> 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).
std::string pdnLocal_;
sip: UDP for the protected leg, TCP first with a fallback imsd's protected leg (the second REGISTER and everything after it) was TCP only. A P-CSCF that never answers the TCP connect on its protected server port (O2 UK: the SYNs leave ESP-protected, nothing comes back) left the unit looping with no way forward, although the SAs, the listener sockets and the firewall rule already covered UDP. The client flow now carries a transport. UDP binds the same protected client port and connect()s the datagram socket to the P-CSCF's protected server port, so the kernel delivers that peer's datagrams to it ahead of the unconnected listener on the same port. One datagram is one message (RFC 3261 18.3: a Content-Length that fits truncates, one that does not fit discards the datagram, none means the rest of the datagram); a receive error is logged and marks the flow dead; a message over the single-packet ESP budget at the ims PDN's MTU is logged once per flow, since the kernel fragments it and a P-CSCF may drop the fragments. Every protected request's Via follows the transport; the challenge stays UDP. SIP_TRANSPORT selects the policy: auto (default) registers over TCP and, after two unanswered connects, registers again over UDP (new challenge, new SA pair) - but only on a phone whose state file does not record a successful TCP registration, so an outage on a TCP carrier never becomes a second initial REGISTER; a phone whose last registration was UDP goes straight to UDP. tcp and udp force one. A connect refused locally (EADDRNOTAVAIL: the 4-tuple still in TIME_WAIT from the previous flow) keeps retrying and is not counted as silence; SO_ERROR results are logged by name. The registration's transport is persisted so a warm resume reconnects the same way, and the resume gives up after two silences. Two things the change exposed and fixes: an engine-fatal event exited 0 ("exiting for systemd restart" with Restart=on-failure never firing), and a fresh registration refused with the P-CSCF's fresh-SA throttle (Security-Server spi-s=0) must not be retried by a 120-s restart loop, since every attempt re-arms the ~20-min window - it is now waited out in-process, growing on repeats, with D-Bus commands still served. A resume the network refuses falls through to a fresh registration. A retransmitted 200 OK to our INVITE is ACKed again and no longer starts a second media leg. Verified on KPN: resume over TCP after a binary swap, MT and MO calls with media both ways, the throttle deferral and its self-recovery, the UDP client path up to KPN dropping the datagram. The UDP success path is a field test; SIP retransmission timers over UDP are not in this change.
2026-09-17 14:58:46 +02:00
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)); }
std::vector<std::string> emergencyExtra_;
int pcscfPort_ = 5060;
int rtpPort_ = 50004;
bool precond_ = false;
std::vector<std::string> codecs_; // CODECS override; empty = defaults
// registration
imsd::msg::Context ctx_;
RegState reg_;
std::string route_, ppi_, ss_;
bool registered_ = false;
sip: UDP for the protected leg, TCP first with a fallback imsd's protected leg (the second REGISTER and everything after it) was TCP only. A P-CSCF that never answers the TCP connect on its protected server port (O2 UK: the SYNs leave ESP-protected, nothing comes back) left the unit looping with no way forward, although the SAs, the listener sockets and the firewall rule already covered UDP. The client flow now carries a transport. UDP binds the same protected client port and connect()s the datagram socket to the P-CSCF's protected server port, so the kernel delivers that peer's datagrams to it ahead of the unconnected listener on the same port. One datagram is one message (RFC 3261 18.3: a Content-Length that fits truncates, one that does not fit discards the datagram, none means the rest of the datagram); a receive error is logged and marks the flow dead; a message over the single-packet ESP budget at the ims PDN's MTU is logged once per flow, since the kernel fragments it and a P-CSCF may drop the fragments. Every protected request's Via follows the transport; the challenge stays UDP. SIP_TRANSPORT selects the policy: auto (default) registers over TCP and, after two unanswered connects, registers again over UDP (new challenge, new SA pair) - but only on a phone whose state file does not record a successful TCP registration, so an outage on a TCP carrier never becomes a second initial REGISTER; a phone whose last registration was UDP goes straight to UDP. tcp and udp force one. A connect refused locally (EADDRNOTAVAIL: the 4-tuple still in TIME_WAIT from the previous flow) keeps retrying and is not counted as silence; SO_ERROR results are logged by name. The registration's transport is persisted so a warm resume reconnects the same way, and the resume gives up after two silences. Two things the change exposed and fixes: an engine-fatal event exited 0 ("exiting for systemd restart" with Restart=on-failure never firing), and a fresh registration refused with the P-CSCF's fresh-SA throttle (Security-Server spi-s=0) must not be retried by a 120-s restart loop, since every attempt re-arms the ~20-min window - it is now waited out in-process, growing on repeats, with D-Bus commands still served. A resume the network refuses falls through to a fresh registration. A retransmitted 200 OK to our INVITE is ACKed again and no longer starts a second media leg. Verified on KPN: resume over TCP after a binary swap, MT and MO calls with media both ways, the throttle deferral and its self-recovery, the UDP client path up to KPN dropping the datagram. The UDP success path is a field test; SIP retransmission timers over UDP are not in this change.
2026-09-17 14:58:46 +02:00
SipFlow sip_;
ServerPorts server_;
std::string subCallid_; // live reg-event subscription dialog (empty: none)
engine: keep the reg-event subscription alive and act on a terminated binding The reg-event subscription is the network's own account of our binding (TS 24.229 5.1.1.3), and the daemon opened a new dialog for it at every refresh and reconnect and otherwise never looked back: a refused SUBSCRIBE was forgotten, an unanswered one had no timeout, an ended subscription got nothing until the next refresh's new dialog, and a NOTIFY was logged whatever it said. O2 UK, 2026-09-24: a fresh registration's SUBSCRIBE got 500, a NOTIFY arrived all the same and showed our contact active, the flow died eight seconds after the 200 and no connect over the SA succeeded after that; the 500 was logged and nothing was done about it. Keep the subscription alive: retry a refused or unanswered SUBSCRIBE on a backoff, renew one the S-CSCF ends with deactivated, timeout, giveup or probation (RFC 6665 4.1.3; after any other reason, or none, the next refresh subscribes anew), and subscribe anew ahead of the lifetime the S-CSCF granted. A refresh or a reconnect opens no dialog while imsd holds one for the registration or has a retry scheduled. Read the reginfo for our own contact and act on its state: a NOTIFY that terminates our binding schedules a fresh registration — at once for deactivated or expired, after the network's retry-after (up to an hour) for probation, on imsd's own refusal schedule (21 min, growing to 60) for rejected or unregistered (RFC 3680). A hold already running stands, and a REGISTER wait a call kept open that still gets its 200 after such a NOTIFY leaves that registration standing. The parser is in Imsd:Sip and pinned by tests/Sip with the O2 document shapes; the policy is RegRecovery's (OnBindingTerminated, added here with its tests in tests/Engine). Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-10-03 19:36:23 +02:00
bool subPending_ = false; // SUBSCRIBE sent, no final yet
double subSentAt_ = 0;
double subRetryAt_ = 0; // a failed/ended subscription is renewed at this time (0: none due)
double subRenewAt_ = 0; // the live subscription is subscribed anew at this time, ahead of its granted expiry (0: none)
double subRetryDelay_ = 60; // 60 s doubling to 10 min between failed SUBSCRIBEs
int portPs_ = 0; // P-CSCF protected server port (reconnect target)
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
imsd::engine::RegRecovery recovery_; // what to do when the registration stops working
bool saReplaced_ = false; // the running fresh attempt got past the SA setup (the old registration's SAs are gone)
// call
imsd::util::Rng rng_;
std::optional<imsd::engine::CallMachine> call_;
bool dropCall_ = false;
engine: keep the reg-event subscription alive and act on a terminated binding The reg-event subscription is the network's own account of our binding (TS 24.229 5.1.1.3), and the daemon opened a new dialog for it at every refresh and reconnect and otherwise never looked back: a refused SUBSCRIBE was forgotten, an unanswered one had no timeout, an ended subscription got nothing until the next refresh's new dialog, and a NOTIFY was logged whatever it said. O2 UK, 2026-09-24: a fresh registration's SUBSCRIBE got 500, a NOTIFY arrived all the same and showed our contact active, the flow died eight seconds after the 200 and no connect over the SA succeeded after that; the 500 was logged and nothing was done about it. Keep the subscription alive: retry a refused or unanswered SUBSCRIBE on a backoff, renew one the S-CSCF ends with deactivated, timeout, giveup or probation (RFC 6665 4.1.3; after any other reason, or none, the next refresh subscribes anew), and subscribe anew ahead of the lifetime the S-CSCF granted. A refresh or a reconnect opens no dialog while imsd holds one for the registration or has a retry scheduled. Read the reginfo for our own contact and act on its state: a NOTIFY that terminates our binding schedules a fresh registration — at once for deactivated or expired, after the network's retry-after (up to an hour) for probation, on imsd's own refusal schedule (21 min, growing to 60) for rejected or unregistered (RFC 3680). A hold already running stands, and a REGISTER wait a call kept open that still gets its 200 after such a NOTIFY leaves that registration standing. The parser is in Imsd:Sip and pinned by tests/Sip with the O2 document shapes; the policy is RegRecovery's (OnBindingTerminated, added here with its tests in tests/Engine). Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-10-03 19:36:23 +02:00
bool closeFlowWhenIdle_ = false; // a binding was terminated under a call: its flow closes with the call
pid_t mediaPid_ = -1;
double deadline_ = 0;
// reply_ answers the inbound request being dispatched right now;
// callReply_ is the call's durable response channel — refreshed from
// reply_ on every in-dialog request, so an Accept()'s 200 OK (which
// happens outside any inbound dispatch) still reaches the caller.
std::function<void(const std::string&)> reply_;
std::function<void(const std::string&)> callReply_;
// timers
double lastRefresh_ = 0;
double lastKa_ = 0;
std::atomic<bool> quit_{false};
bool devMode_ = false;
// command queue
std::mutex cmdLock_;
std::deque<Cmd> cmds_;
// atomic: Dial (D-Bus thread) and an inbound INVITE (engine thread) both mint unis
std::atomic<std::uint64_t> callSeq_{0};
static double Mono() {
return std::chrono::duration<double>(std::chrono::steady_clock::now().time_since_epoch()).count();
}
std::optional<Cmd> PopCmd() {
std::scoped_lock g(cmdLock_);
if (cmds_.empty()) return std::nullopt;
Cmd c = cmds_.front(); cmds_.pop_front();
return c;
}
static std::string ResolveMediaBin() {
if (const char* e = std::getenv("IMSD_MEDIA")) return e;
if (!SelfDir.empty()) {
std::string p = std::format("{}/imsd-media", SelfDir);
if (access(p.c_str(), X_OK) == 0) return p;
}
return "/usr/libexec/imsd-media";
}
// Refresh P-Access-Network-Info from the live serving cell (mmcli 3GPP
// location). Serving cell changes as the phone moves, so this runs
// before every REGISTER/INVITE-carrying step; on failure the previous
// value (possibly empty = header omitted) stays.
void RefreshPani() {
auto loc = RunCapture({"mmcli", "-m", "any", "--location-get"});
if (auto p = imsd::aka::ParsePani(loc.out)) ctx_.panInfo = *p;
}
// ---- bring-up: warm resume if an SA + state file exist, else fresh ----
void BringUp() {
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.
2026-10-11 15:58:49 +02:00
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
// script, which routes the value as typed, and the daemon agree on it.
if (std::string raw = EnvOr("PCSCF", ""); !raw.empty()) {
auto canon = imsd::util::CanonicalIp6(raw);
if (!canon || *canon != raw) throw std::runtime_error(std::format("PCSCF='{}' is not written as ip prints it; use PCSCF={}", raw, canon.value_or("<one bare IPv6 address, RFC 5952 form>")));
}
auto card = RunCapture({"qmicli", "-d", "qrtr://0", "--uim-get-card-status"});
auto sel = imsd::aka::ParseCardStatus(card.out);
if (!sel) throw std::runtime_error("no ready USIM found");
auto modem = RunCapture({"mmcli", "-m", "any"});
auto simPath = imsd::aka::ParsePrimarySimPath(modem.out);
if (!simPath) throw std::runtime_error("no primary SIM path");
auto siminfo = RunCapture({"mmcli", "-i", *simPath});
auto info = imsd::aka::ParseSimInfo(siminfo.out);
if (!info) throw std::runtime_error("SIM IMSI/operator not found");
ctx_.id = imsd::aka::MakeIdentity(info->imsi, info->mcc, info->mnc);
ctx_.local = local_;
ctx_.ealgOffer = ealgOffer_;
sip: UDP for the protected leg, TCP first with a fallback imsd's protected leg (the second REGISTER and everything after it) was TCP only. A P-CSCF that never answers the TCP connect on its protected server port (O2 UK: the SYNs leave ESP-protected, nothing comes back) left the unit looping with no way forward, although the SAs, the listener sockets and the firewall rule already covered UDP. The client flow now carries a transport. UDP binds the same protected client port and connect()s the datagram socket to the P-CSCF's protected server port, so the kernel delivers that peer's datagrams to it ahead of the unconnected listener on the same port. One datagram is one message (RFC 3261 18.3: a Content-Length that fits truncates, one that does not fit discards the datagram, none means the rest of the datagram); a receive error is logged and marks the flow dead; a message over the single-packet ESP budget at the ims PDN's MTU is logged once per flow, since the kernel fragments it and a P-CSCF may drop the fragments. Every protected request's Via follows the transport; the challenge stays UDP. SIP_TRANSPORT selects the policy: auto (default) registers over TCP and, after two unanswered connects, registers again over UDP (new challenge, new SA pair) - but only on a phone whose state file does not record a successful TCP registration, so an outage on a TCP carrier never becomes a second initial REGISTER; a phone whose last registration was UDP goes straight to UDP. tcp and udp force one. A connect refused locally (EADDRNOTAVAIL: the 4-tuple still in TIME_WAIT from the previous flow) keeps retrying and is not counted as silence; SO_ERROR results are logged by name. The registration's transport is persisted so a warm resume reconnects the same way, and the resume gives up after two silences. Two things the change exposed and fixes: an engine-fatal event exited 0 ("exiting for systemd restart" with Restart=on-failure never firing), and a fresh registration refused with the P-CSCF's fresh-SA throttle (Security-Server spi-s=0) must not be retried by a 120-s restart loop, since every attempt re-arms the ~20-min window - it is now waited out in-process, growing on repeats, with D-Bus commands still served. A resume the network refuses falls through to a fresh registration. A retransmitted 200 OK to our INVITE is ACKed again and no longer starts a second media leg. Verified on KPN: resume over TCP after a binary swap, MT and MO calls with media both ways, the throttle deferral and its self-recovery, the UDP client path up to KPN dropping the datagram. The UDP success path is a field test; SIP retransmission timers over UDP are not in this change.
2026-09-17 14:58:46 +02:00
SetTransport(transport_);
Log(std::format("SIP transport: {} ({})", TransportName(transport_), sipPolicy_));
// USER_AGENT overrides (some networks fingerprint UAs — setting the
// stock firmware's build string gives oracle parity, journal/ims.md
// s53); USER_AGENT= (empty) omits the header.
ctx_.userAgent = EnvOr("USER_AGENT", std::format("imsd/{}", Version));
if (auto imei = imsd::aka::ParseEquipmentId(modem.out))
if (auto urn = imsd::aka::ImeiUrn(*imei)) ctx_.instanceId = *urn;
reg_.slot = sel->slot;
reg_.aid = sel->aid;
Log(std::format("SIM slot={} IMSI={} domain={}", sel->slot, info->imsi, ctx_.id.domain));
RefreshPani();
std::string resume = EnvOr("RESUME", "auto");
sip: UDP for the protected leg, TCP first with a fallback imsd's protected leg (the second REGISTER and everything after it) was TCP only. A P-CSCF that never answers the TCP connect on its protected server port (O2 UK: the SYNs leave ESP-protected, nothing comes back) left the unit looping with no way forward, although the SAs, the listener sockets and the firewall rule already covered UDP. The client flow now carries a transport. UDP binds the same protected client port and connect()s the datagram socket to the P-CSCF's protected server port, so the kernel delivers that peer's datagrams to it ahead of the unconnected listener on the same port. One datagram is one message (RFC 3261 18.3: a Content-Length that fits truncates, one that does not fit discards the datagram, none means the rest of the datagram); a receive error is logged and marks the flow dead; a message over the single-packet ESP budget at the ims PDN's MTU is logged once per flow, since the kernel fragments it and a P-CSCF may drop the fragments. Every protected request's Via follows the transport; the challenge stays UDP. SIP_TRANSPORT selects the policy: auto (default) registers over TCP and, after two unanswered connects, registers again over UDP (new challenge, new SA pair) - but only on a phone whose state file does not record a successful TCP registration, so an outage on a TCP carrier never becomes a second initial REGISTER; a phone whose last registration was UDP goes straight to UDP. tcp and udp force one. A connect refused locally (EADDRNOTAVAIL: the 4-tuple still in TIME_WAIT from the previous flow) keeps retrying and is not counted as silence; SO_ERROR results are logged by name. The registration's transport is persisted so a warm resume reconnects the same way, and the resume gives up after two silences. Two things the change exposed and fixes: an engine-fatal event exited 0 ("exiting for systemd restart" with Restart=on-failure never firing), and a fresh registration refused with the P-CSCF's fresh-SA throttle (Security-Server spi-s=0) must not be retried by a 120-s restart loop, since every attempt re-arms the ~20-min window - it is now waited out in-process, growing on repeats, with D-Bus commands still served. A resume the network refuses falls through to a fresh registration. A retransmitted 200 OK to our INVITE is ACKed again and no longer starts a second media leg. Verified on KPN: resume over TCP after a binary swap, MT and MO calls with media both ways, the throttle deferral and its self-recovery, the UDP client path up to KPN dropping the datagram. The UDP success path is a field test; SIP retransmission timers over UDP are not in this change.
2026-09-17 14:58:46 +02:00
// A resume that the network refuses (403 after another process
// registered fresh for the same IMPI, a stale binding, …) is not
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.
2026-10-11 15:58:49 +02:00
// 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.
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
// 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
engine: keep the reg-event subscription alive and act on a terminated binding The reg-event subscription is the network's own account of our binding (TS 24.229 5.1.1.3), and the daemon opened a new dialog for it at every refresh and reconnect and otherwise never looked back: a refused SUBSCRIBE was forgotten, an unanswered one had no timeout, an ended subscription got nothing until the next refresh's new dialog, and a NOTIFY was logged whatever it said. O2 UK, 2026-09-24: a fresh registration's SUBSCRIBE got 500, a NOTIFY arrived all the same and showed our contact active, the flow died eight seconds after the 200 and no connect over the SA succeeded after that; the 500 was logged and nothing was done about it. Keep the subscription alive: retry a refused or unanswered SUBSCRIBE on a backoff, renew one the S-CSCF ends with deactivated, timeout, giveup or probation (RFC 6665 4.1.3; after any other reason, or none, the next refresh subscribes anew), and subscribe anew ahead of the lifetime the S-CSCF granted. A refresh or a reconnect opens no dialog while imsd holds one for the registration or has a retry scheduled. Read the reginfo for our own contact and act on its state: a NOTIFY that terminates our binding schedules a fresh registration — at once for deactivated or expired, after the network's retry-after (up to an hour) for probation, on imsd's own refusal schedule (21 min, growing to 60) for rejected or unregistered (RFC 3680). A hold already running stands, and a REGISTER wait a call kept open that still gets its 200 after such a NOTIFY leaves that registration standing. The parser is in Imsd:Sip and pinned by tests/Sip with the O2 document shapes; the policy is RegRecovery's (OnBindingTerminated, added here with its tests in tests/Engine). Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-10-03 19:36:23 +02:00
// 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.
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.
2026-10-11 15:58:49 +02:00
ResumeOutcome outcome = ResumeOutcome::Dead; // not tried (RESUME=0, or written off before this start): a pair nothing resumes
sip: UDP for the protected leg, TCP first with a fallback imsd's protected leg (the second REGISTER and everything after it) was TCP only. A P-CSCF that never answers the TCP connect on its protected server port (O2 UK: the SYNs leave ESP-protected, nothing comes back) left the unit looping with no way forward, although the SAs, the listener sockets and the firewall rule already covered UDP. The client flow now carries a transport. UDP binds the same protected client port and connect()s the datagram socket to the P-CSCF's protected server port, so the kernel delivers that peer's datagrams to it ahead of the unconnected listener on the same port. One datagram is one message (RFC 3261 18.3: a Content-Length that fits truncates, one that does not fit discards the datagram, none means the rest of the datagram); a receive error is logged and marks the flow dead; a message over the single-packet ESP budget at the ims PDN's MTU is logged once per flow, since the kernel fragments it and a P-CSCF may drop the fragments. Every protected request's Via follows the transport; the challenge stays UDP. SIP_TRANSPORT selects the policy: auto (default) registers over TCP and, after two unanswered connects, registers again over UDP (new challenge, new SA pair) - but only on a phone whose state file does not record a successful TCP registration, so an outage on a TCP carrier never becomes a second initial REGISTER; a phone whose last registration was UDP goes straight to UDP. tcp and udp force one. A connect refused locally (EADDRNOTAVAIL: the 4-tuple still in TIME_WAIT from the previous flow) keeps retrying and is not counted as silence; SO_ERROR results are logged by name. The registration's transport is persisted so a warm resume reconnects the same way, and the resume gives up after two silences. Two things the change exposed and fixes: an engine-fatal event exited 0 ("exiting for systemd restart" with Restart=on-failure never firing), and a fresh registration refused with the P-CSCF's fresh-SA throttle (Security-Server spi-s=0) must not be retried by a 120-s restart loop, since every attempt re-arms the ~20-min window - it is now waited out in-process, growing on repeats, with D-Bus commands still served. A resume the network refuses falls through to a fresh registration. A retransmitted 200 OK to our INVITE is ACKed again and no longer starts a second media leg. Verified on KPN: resume over TCP after a binary swap, MT and MO calls with media both ways, the throttle deferral and its self-recovery, the UDP client path up to KPN dropping the datagram. The UDP success path is a field test; SIP retransmission timers over UDP are not in this change.
2026-09-17 14:58:46 +02:00
if (resume != "0") {
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
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
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.
2026-10-11 15:58:49 +02:00
const std::string next = ResumeFailedNext();
try { if (recovery_.Reconnectable()) { outcome = TryResume(); if (outcome == ResumeOutcome::Done) return; } }
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
catch (const RefusedError& e) {
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.
2026-10-11 15:58:49 +02:00
outcome = ResumeOutcome::Failed;
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
CloseFlowAfterWait();
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.
2026-10-11 15:58:49 +02:00
// 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)));
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
}
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.
2026-10-11 15:58:49 +02:00
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));
sip: UDP for the protected leg, TCP first with a fallback imsd's protected leg (the second REGISTER and everything after it) was TCP only. A P-CSCF that never answers the TCP connect on its protected server port (O2 UK: the SYNs leave ESP-protected, nothing comes back) left the unit looping with no way forward, although the SAs, the listener sockets and the firewall rule already covered UDP. The client flow now carries a transport. UDP binds the same protected client port and connect()s the datagram socket to the P-CSCF's protected server port, so the kernel delivers that peer's datagrams to it ahead of the unconnected listener on the same port. One datagram is one message (RFC 3261 18.3: a Content-Length that fits truncates, one that does not fit discards the datagram, none means the rest of the datagram); a receive error is logged and marks the flow dead; a message over the single-packet ESP budget at the ims PDN's MTU is logged once per flow, since the kernel fragments it and a P-CSCF may drop the fragments. Every protected request's Via follows the transport; the challenge stays UDP. SIP_TRANSPORT selects the policy: auto (default) registers over TCP and, after two unanswered connects, registers again over UDP (new challenge, new SA pair) - but only on a phone whose state file does not record a successful TCP registration, so an outage on a TCP carrier never becomes a second initial REGISTER; a phone whose last registration was UDP goes straight to UDP. tcp and udp force one. A connect refused locally (EADDRNOTAVAIL: the 4-tuple still in TIME_WAIT from the previous flow) keeps retrying and is not counted as silence; SO_ERROR results are logged by name. The registration's transport is persisted so a warm resume reconnects the same way, and the resume gives up after two silences. Two things the change exposed and fixes: an engine-fatal event exited 0 ("exiting for systemd restart" with Restart=on-failure never firing), and a fresh registration refused with the P-CSCF's fresh-SA throttle (Security-Server spi-s=0) must not be retried by a 120-s restart loop, since every attempt re-arms the ~20-min window - it is now waited out in-process, growing on repeats, with D-Bus commands still served. A resume the network refuses falls through to a fresh registration. A retransmitted 200 OK to our INVITE is ACKed again and no longer starts a second media leg. Verified on KPN: resume over TCP after a binary swap, MT and MO calls with media both ways, the throttle deferral and its self-recovery, the UDP client path up to KPN dropping the datagram. The UDP success path is a field test; SIP retransmission timers over UDP are not in this change.
2026-09-17 14:58:46 +02:00
}
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
// a resume that failed found the stored SAs unusable (and after a
// reboot there are none): nothing to reconnect over while a deferred
// fresh registration waits
recovery_.OldGone();
portPs_ = 0;
local_ = pdnLocal_; ctx_.local = local_;
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
KeepHolds();
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.
2026-10-11 15:58:49 +02:00
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_});
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
if (recovery_.Next(Boot()).at > Boot()) Log(std::format("the network's hold stands; {}", NextStep()));
if (!WaitForPolicy()) throw Stopping{};
FreshRegister();
}
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.
2026-10-11 15:58:49 +02:00
// 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"});
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.
2026-10-11 15:58:49 +02:00
// 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;
if (*peer != pcscf_) {
bool listed = *peer == pcscfTarget_ || std::ranges::find(pcscfCandidates_, *peer) != pcscfCandidates_.end();
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.
2026-10-11 15:58:49 +02:00
Log(pcscfTarget_.empty()
? 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)"));
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.
2026-10-11 15:58:49 +02:00
pcscf_ = *peer;
}
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
packaging: restart imsd and imsd-ofonod on upgrade An upgrade left the old imsd and imsd-ofonod running until they were next restarted, while a changed bus policy is live the moment apk writes it: dbus-broker-launch reloads on its own. A policy that relies on a check inside the daemons would face the old daemons in between. Mark imsd.service and imsd-ofonod.service needs-restart in post-upgrade scripts; the systemd trigger restarts them once the transaction is done (enqueue-marked). A call in progress ends. The same scripts run as post-install: `apk del` removes a package's files and leaves its daemon running, so a later `apk add` of the package is this upgrade for that daemon — and apk runs the post-install script, not post-upgrade, for a package it adds. GNOME Calls 50.0 dials nothing after imsd-ofonod restarts under it until it restarts too, so imsd-ofonod's ExecStartPost restarts the Calls daemon of each user running one — not at the unit's first start since boot, when a Calls that runs already is left alone. A daemon older than 0.3.10 kept its bring-up throttle wait in memory only, so a restart lost it. imsd's post-upgrade records that daemon's invocation, and imsd-handover.sh, an ExecStartPre after ims-pdn-up.sh, reads the invocation's journal and, while the fresh-SA throttle's wait it finds there still runs (`spi-s=0`; the old daemon's other deferrals are not handed over), writes what is left of it to the holds file when this start's LOCAL is the address the wait was earned on, or when that address cannot be read; when LOCAL is another address, only the verdict that the old registration is gone. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-10-04 00:09:37 +02:00
// F18). Resume there while it is still up — a restart, an upgrade
// among them, 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;
}
}
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.
2026-10-11 15:58:49 +02:00
if (!sa) { Log(std::format("the SA pair to {} has no SA on an address of {}: nothing to resume", pcscf_, dev_)); return ResumeOutcome::Dead; }
// 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();
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.
2026-10-11 15:58:49 +02:00
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;
}
sip: UDP for the protected leg, TCP first with a fallback imsd's protected leg (the second REGISTER and everything after it) was TCP only. A P-CSCF that never answers the TCP connect on its protected server port (O2 UK: the SYNs leave ESP-protected, nothing comes back) left the unit looping with no way forward, although the SAs, the listener sockets and the firewall rule already covered UDP. The client flow now carries a transport. UDP binds the same protected client port and connect()s the datagram socket to the P-CSCF's protected server port, so the kernel delivers that peer's datagrams to it ahead of the unconnected listener on the same port. One datagram is one message (RFC 3261 18.3: a Content-Length that fits truncates, one that does not fit discards the datagram, none means the rest of the datagram); a receive error is logged and marks the flow dead; a message over the single-packet ESP budget at the ims PDN's MTU is logged once per flow, since the kernel fragments it and a P-CSCF may drop the fragments. Every protected request's Via follows the transport; the challenge stays UDP. SIP_TRANSPORT selects the policy: auto (default) registers over TCP and, after two unanswered connects, registers again over UDP (new challenge, new SA pair) - but only on a phone whose state file does not record a successful TCP registration, so an outage on a TCP carrier never becomes a second initial REGISTER; a phone whose last registration was UDP goes straight to UDP. tcp and udp force one. A connect refused locally (EADDRNOTAVAIL: the 4-tuple still in TIME_WAIT from the previous flow) keeps retrying and is not counted as silence; SO_ERROR results are logged by name. The registration's transport is persisted so a warm resume reconnects the same way, and the resume gives up after two silences. Two things the change exposed and fixes: an engine-fatal event exited 0 ("exiting for systemd restart" with Restart=on-failure never firing), and a fresh registration refused with the P-CSCF's fresh-SA throttle (Security-Server spi-s=0) must not be retried by a 120-s restart loop, since every attempt re-arms the ~20-min window - it is now waited out in-process, growing on repeats, with D-Bus commands still served. A resume the network refuses falls through to a fresh registration. A retransmitted 200 OK to our INVITE is ACKed again and no longer starts a second media leg. Verified on KPN: resume over TCP after a binary swap, MT and MO calls with media both ways, the throttle deferral and its self-recovery, the UDP client path up to KPN dropping the datagram. The UDP success path is a field test; SIP retransmission timers over UDP are not in this change.
2026-09-17 14:58:46 +02:00
// 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;
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.
2026-10-11 15:58:49 +02:00
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
// is re-learned from the next 200's P-Associated-URI.
ppi_ = ps->ppi.value_or("");
imsd: a resumed registration echoes the 401's Security-Server list from the state file A warm resume rebuilt its Security-Verify from the kernel's SA: one mechanism with hmac-sha-1-96 and aes-cbc written in, where RFC 3329 §2.3.1 has the client mirror the server's whole list. The fresh registration kept the 401's list in memory, so a registration this process made fresh echoed it; after a restart, the resumed registration's re-REGISTER, its keepalives, its reg-event SUBSCRIBE and its INVITEs carried the rebuilt entry until the next fresh registration. KPN's one-entry answer to the default offer is the rebuilt entry byte for byte; against a six-entry list (O2's) the mirror is broken, which the RFC has the server answer with a 494, and a refused resume registers fresh. The fresh registration now stores the joined Security-Server value in the state file (security_server, next to the challenge answer) and the resume takes it from there, logging the mechanism count, when the entry carrying its SPIs names the SA pair in the kernel: a file an attempt the process did not live to record left behind (its SAs installed, its 200 not written) names another. A file from before this change holds none. In both cases the resume falls back to the rebuilt entry, says which, and the registration's next state write stores that entry, which is what it echoes; the choice is Imsd:Ipsec's, pinned in tests/Ipsec with O2's recorded list and KPN's. A value with a quote, a backslash or a newline in it is written empty, which the resume treats as absent; RFC 3329's token syntax produces none of these, a quoted-string extension parameter could carry the first two. Tested on the dev phone (KPN) with the build of this tree: a resume from a state file the 0.3.11 daemon wrote logs the fallback and succeeds, so do one from a file whose list names another SA pair and one whose list is empty; two keepalives at a 120-s interval; a fresh registration stores KPN's list byte-for-byte as the journal shows it received; a restart resumes with the list from the file; an outgoing call.
2026-10-10 23:42:01 +02:00
// The Security-Verify the protected REGISTERs, the reg-event
// SUBSCRIBE and the INVITEs echo: the 401's whole Security-Server
// list (RFC 3329 §2.3.1), from the state file since 0.3.12, or the
// one-mechanism entry rebuilt from the SA — Imsd:Ipsec chooses
// (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
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.
2026-10-11 15:58:49 +02:00
// registers fresh where there is a fresh target (§7.2 step 1 otherwise).
imsd: a resumed registration echoes the 401's Security-Server list from the state file A warm resume rebuilt its Security-Verify from the kernel's SA: one mechanism with hmac-sha-1-96 and aes-cbc written in, where RFC 3329 §2.3.1 has the client mirror the server's whole list. The fresh registration kept the 401's list in memory, so a registration this process made fresh echoed it; after a restart, the resumed registration's re-REGISTER, its keepalives, its reg-event SUBSCRIBE and its INVITEs carried the rebuilt entry until the next fresh registration. KPN's one-entry answer to the default offer is the rebuilt entry byte for byte; against a six-entry list (O2's) the mirror is broken, which the RFC has the server answer with a 494, and a refused resume registers fresh. The fresh registration now stores the joined Security-Server value in the state file (security_server, next to the challenge answer) and the resume takes it from there, logging the mechanism count, when the entry carrying its SPIs names the SA pair in the kernel: a file an attempt the process did not live to record left behind (its SAs installed, its 200 not written) names another. A file from before this change holds none. In both cases the resume falls back to the rebuilt entry, says which, and the registration's next state write stores that entry, which is what it echoes; the choice is Imsd:Ipsec's, pinned in tests/Ipsec with O2's recorded list and KPN's. A value with a quote, a backslash or a newline in it is written empty, which the resume treats as absent; RFC 3329's token syntax produces none of these, a quoted-string extension parameter could carry the first two. Tested on the dev phone (KPN) with the build of this tree: a resume from a state file the 0.3.11 daemon wrote logs the fallback and succeeds, so do one from a file whose list names another SA pair and one whose list is empty; two keepalives at a 120-s interval; a fresh registration stores KPN's list byte-for-byte as the journal shows it received; a restart resumes with the list from the file; an outgoing call.
2026-10-10 23:42:01 +02:00
auto rsv = imsd::ipsec::ChooseResumeSecurityVerify(ps->securityServer.value_or(""), *sa, ealgOffer_);
ss_ = rsv.value;
using Source = imsd::ipsec::ResumeSecurityVerify::Source;
switch (rsv.source) {
case Source::Stored:
Log(std::format("Security-Verify from the state file ({} mechanism{})", rsv.mechanisms, rsv.mechanisms == 1 ? "" : "s"));
break;
case Source::NoneStored:
Log("state file holds no Security-Server (a pre-0.3.12 file, or a list the file cannot hold): Security-Verify rebuilt from the SA as one mechanism");
break;
case Source::OtherSa:
Log(std::format("state file's Security-Server names another SA pair than the one in the kernel (spi-s {} vs {}): Security-Verify rebuilt from the SA as one mechanism",
rsv.storedSpiPs ? std::to_string(*rsv.storedSpiPs) : "none", sa->spiPs));
break;
}
2026-09-08 20:37:47 +02:00
// The public identity our requests are signed with (From) — restored
// here so a call placed before the refresh 200 re-learns it does not
// fall back to the barred temporary IMPU.
ctx_.aor = ps->aor.value_or("");
ctx_.route = route_; ctx_.ppi = ppi_; ctx_.securityServer = ss_;
ctx_.portUc = PortUc; ctx_.portUs = PortUs;
Log(std::format("RESUME via existing SA (cseq {})", ps->cseq.value_or(1)));
portPs_ = sa->portPs;
sip: UDP for the protected leg, TCP first with a fallback imsd's protected leg (the second REGISTER and everything after it) was TCP only. A P-CSCF that never answers the TCP connect on its protected server port (O2 UK: the SYNs leave ESP-protected, nothing comes back) left the unit looping with no way forward, although the SAs, the listener sockets and the firewall rule already covered UDP. The client flow now carries a transport. UDP binds the same protected client port and connect()s the datagram socket to the P-CSCF's protected server port, so the kernel delivers that peer's datagrams to it ahead of the unconnected listener on the same port. One datagram is one message (RFC 3261 18.3: a Content-Length that fits truncates, one that does not fit discards the datagram, none means the rest of the datagram); a receive error is logged and marks the flow dead; a message over the single-packet ESP budget at the ims PDN's MTU is logged once per flow, since the kernel fragments it and a P-CSCF may drop the fragments. Every protected request's Via follows the transport; the challenge stays UDP. SIP_TRANSPORT selects the policy: auto (default) registers over TCP and, after two unanswered connects, registers again over UDP (new challenge, new SA pair) - but only on a phone whose state file does not record a successful TCP registration, so an outage on a TCP carrier never becomes a second initial REGISTER; a phone whose last registration was UDP goes straight to UDP. tcp and udp force one. A connect refused locally (EADDRNOTAVAIL: the 4-tuple still in TIME_WAIT from the previous flow) keeps retrying and is not counted as silence; SO_ERROR results are logged by name. The registration's transport is persisted so a warm resume reconnects the same way, and the resume gives up after two silences. Two things the change exposed and fixes: an engine-fatal event exited 0 ("exiting for systemd restart" with Restart=on-failure never firing), and a fresh registration refused with the P-CSCF's fresh-SA throttle (Security-Server spi-s=0) must not be retried by a 120-s restart loop, since every attempt re-arms the ~20-min window - it is now waited out in-process, growing on repeats, with D-Bus commands still served. A resume the network refuses falls through to a fresh registration. A retransmitted 200 OK to our INVITE is ACKed again and no longer starts a second media leg. Verified on KPN: resume over TCP after a binary swap, MT and MO calls with media both ways, the throttle deferral and its self-recovery, the UDP client path up to KPN dropping the datagram. The UDP success path is a field test; SIP retransmission timers over UDP are not in this change.
2026-09-17 14:58:46 +02:00
SetTransport(t);
// 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)) {
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.
2026-10-11 15:58:49 +02:00
Log(std::format("resume connect failed; {}", ResumeFailedNext()));
return ResumeOutcome::Failed;
}
reg_.callid = *ps->callid;
reg_.ftag = ps->ftag.value_or("");
// The refresh must carry the SAME Contact URI as the stored binding;
// an absent key means the binding predates the oracle diff and used
// the IMSI (the empty-contactUser fallback).
reg_.contactUser = ps->contactUser.value_or("");
ctx_.contactUser = reg_.contactUser;
reg_.cseq = ps->cseq.value_or(1);
reg_.spiUc = static_cast<std::uint32_t>(ps->spiUc.value_or(sa->spiUc));
reg_.spiUs = static_cast<std::uint32_t>(ps->spiUs.value_or(sa->spiUs));
reg_.expiry = ps->expiry.value_or(0);
imsd: a resumed registration echoes the 401's Security-Server list from the state file A warm resume rebuilt its Security-Verify from the kernel's SA: one mechanism with hmac-sha-1-96 and aes-cbc written in, where RFC 3329 §2.3.1 has the client mirror the server's whole list. The fresh registration kept the 401's list in memory, so a registration this process made fresh echoed it; after a restart, the resumed registration's re-REGISTER, its keepalives, its reg-event SUBSCRIBE and its INVITEs carried the rebuilt entry until the next fresh registration. KPN's one-entry answer to the default offer is the rebuilt entry byte for byte; against a six-entry list (O2's) the mirror is broken, which the RFC has the server answer with a 494, and a refused resume registers fresh. The fresh registration now stores the joined Security-Server value in the state file (security_server, next to the challenge answer) and the resume takes it from there, logging the mechanism count, when the entry carrying its SPIs names the SA pair in the kernel: a file an attempt the process did not live to record left behind (its SAs installed, its 200 not written) names another. A file from before this change holds none. In both cases the resume falls back to the rebuilt entry, says which, and the registration's next state write stores that entry, which is what it echoes; the choice is Imsd:Ipsec's, pinned in tests/Ipsec with O2's recorded list and KPN's. A value with a quote, a backslash or a newline in it is written empty, which the resume treats as absent; RFC 3329's token syntax produces none of these, a quoted-string extension parameter could carry the first two. Tested on the dev phone (KPN) with the build of this tree: a resume from a state file the 0.3.11 daemon wrote logs the fallback and succeeds, so do one from a file whose list names another SA pair and one whose list is empty; two keepalives at a 120-s interval; a fresh registration stores KPN's list byte-for-byte as the journal shows it received; a restart resumes with the list from the file; an outgoing call.
2026-10-10 23:42:01 +02:00
reg_.securityServer = ss_;
engine, messages, util, imsd: the reregistration carries the last challenge answer and new SA parameters The keepalive refresh and the resume's re-REGISTER carried an initial registration's first-REGISTER Authorization — an empty nonce and response — in the second REGISTER's frame, with a Security-Client naming the SPIs and ports in use. TS 24.229 5.1.1.4.2 has a reregistration carry the Authorization of the last successful authentication — the last received nonce and the last calculated response — and a Security-Client with the new parameter values for the two SA pairs a re-authentication would set up; TS 33.203 7.4.0 keeps the server ports and changes the client ports. O2 UK refused the refresh of the old shape with 503 on every occasion on record (alyx, 2026-09-24 to 10-08, a stationary phone included) and accepted the fresh registrations that followed; KPN accepted the old shape without a challenge. The fresh registration now records the 401's nonce, our cnonce and the response in the registration state (auth_nonce, auth_cnonce, auth_response in the state file); a reregistration sends them verbatim, mints new SPIs and proposes PortUcNext (45063) as the client port with the server port unchanged. A registration resumed from a state file written before this change has no answer: its reregistrations carry the empty digest, as before, until its next fresh registration, and the resume says so. A 401 to a reregistration asks for re-authentication over temporary SAs on the proposed ports (TS 24.229 5.1.1.5.1), which imsd does not implement: the refresh throws it as a refusal and RegRecovery makes the next step the fresh registration, replacing the in-place answer over the old SAs. Tested with the Messages and Engine suites and on the dev phone (KPN) with a build of this commit's tree: a resume from the previous state file, a fresh registration, a resume that sent the stored answer, three refreshes at a 120-s interval, an outgoing call to a bench line and an incoming call's ring.
2026-10-10 21:00:28 +02:00
reg_.authNonce = ps->authNonce.value_or(""); reg_.authCnonce = ps->authCnonce.value_or(""); reg_.authResponse = ps->authResponse.value_or("");
if (reg_.authNonce.empty()) Log("state file holds no challenge answer (pre-0.3.11): the refresh carries an empty digest");
auto [okMsg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1);
reg_.cseq = used;
if (auto e = imsd::sip::GrantedExpires(okMsg)) reg_.expiry = *e;
UpdateRouteAndPpi(okMsg);
LogBindings(okMsg);
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.
2026-10-11 15:58:49 +02:00
PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport, pcscf_);
Log("REGISTERED (resumed + true-refreshed)");
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.
2026-10-11 15:58:49 +02:00
return ResumeOutcome::Done;
}
// The registrar's 200 OK echoes EVERY current binding for the implicit
// set — the network's own view. Log them: a stale binding at a dead
// SLAAC address is still tried by terminating routing until it expires.
void LogBindings(const std::string& ok) {
for (auto ct : imsd::sip::Headers(ok, "Contact"))
Log(std::format("binding: {}", ct));
}
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
// A fresh registration. The attempt mints its own Contact user-part into
// ctx_ for its REGISTERs; on any failure the stored binding's comes back,
// so requests over the existing SA meanwhile (a reconnect, a refresh)
// carry the binding the network knows. reg_ takes the new one on the 200.
void FreshRegister() {
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
saReplaced_ = false;
try { FreshRegisterAttempt(); }
catch (...) { ctx_.contactUser = reg_.contactUser; throw; }
}
void FreshRegisterAttempt() {
Log("FRESH registration");
// Oracle diff (journal/ims.md s53): stock's Contact user-part is a
// fresh UUID per registration, not the IMSI. Minted before reg1 so
// both REGISTERs and the stored binding carry the same URI.
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
std::string contactUser = imsd::util::Uuid4(rng_);
ctx_.contactUser = contactUser;
Log(std::format("contact user-part: {}", contactUser));
std::string callidReg = std::format("{}@{}", rng_.Token(16), local_);
std::string ftag = rng_.Token(8);
std::uint32_t spiUc = rng_.UInt(0x10000, 0xFFFFFFF);
std::uint32_t spiUs = rng_.UInt(0x10000, 0xFFFFFFF);
ctx_.portUc = PortUc; ctx_.portUs = PortUs; ctx_.initPort = InitPort;
// reg1 over UDP from the init port -> 401
int fam = Is6(local_) ? AF_INET6 : AF_INET;
int us = socket(fam, SOCK_DGRAM, 0);
int one = 1; setsockopt(us, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
sockaddr_storage la; socklen_t ll = MakeAddr(local_, InitPort, la);
if (bind(us, reinterpret_cast<sockaddr*>(&la), ll) != 0) {
close(us); throw std::runtime_error("bind init port failed");
}
std::string reg1 = imsd::msg::BuildRegisterInitial(ctx_, callidReg, ftag, rng_.Token(16), spiUc, spiUs);
sockaddr_storage pa; socklen_t pl = MakeAddr(pcscf_, pcscfPort_, pa);
sendto(us, reg1.data(), reg1.size(), 0, reinterpret_cast<sockaddr*>(&pa), pl);
sip: log a refused REGISTER or SUBSCRIBE with its status line and reason headers A refused final was logged as its bare status code ("re-REGISTER failed 503", "reg-event SUBSCRIBE -> 500") — the unprotected REGISTER's as "expected 401", without even the code — so a field journal could not say which node refused or what it said. O2 UK answered a phone's 30-minute refresh with 503 twenty-one times over seventy minutes and then refused a fresh registration's reg-event SUBSCRIBE with 500 (alyx, 2026-09-24); the journal holds nothing but the numbers. Log the status line with the headers that carry the reason — Retry-After, Warning, Reason, Server — for a REGISTER's refused final (initial, protected, refresh) and for a refused SUBSCRIBE the run loop reads, and dump the refused message under DUMP_SIP as its own fail dump next to the 200s. To tell a REGISTER's final from what else arrives, the final is now matched by Call-ID and CSeq, and a request that arrives on the client flow while one is awaited is dispatched: a refresh no longer reads such a request as a failed REGISTER. An INVITE among those requests is a call like any other, announced on the bus: a Dial whose reconnect it arrived under meets the call-in-progress rule again before it places its own, and when a resume, a refresh or a reconnect fails the call that arrived under it ends with the flow it came in on (486 while the flow still carries one, and the bus told). The unprotected REGISTER's reply is matched the same way on the init port. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:51:03 +02:00
std::string resp = AwaitInitialFinal(us, callidReg);
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
if (auto st1 = imsd::sip::Status(resp); st1 != 401) {
sip: log a refused REGISTER or SUBSCRIBE with its status line and reason headers A refused final was logged as its bare status code ("re-REGISTER failed 503", "reg-event SUBSCRIBE -> 500") — the unprotected REGISTER's as "expected 401", without even the code — so a field journal could not say which node refused or what it said. O2 UK answered a phone's 30-minute refresh with 503 twenty-one times over seventy minutes and then refused a fresh registration's reg-event SUBSCRIBE with 500 (alyx, 2026-09-24); the journal holds nothing but the numbers. Log the status line with the headers that carry the reason — Retry-After, Warning, Reason, Server — for a REGISTER's refused final (initial, protected, refresh) and for a refused SUBSCRIBE the run loop reads, and dump the refused message under DUMP_SIP as its own fail dump next to the 200s. To tell a REGISTER's final from what else arrives, the final is now matched by Call-ID and CSeq, and a request that arrives on the client flow while one is awaited is dispatched: a refresh no longer reads such a request as a failed REGISTER. An INVITE among those requests is a call like any other, announced on the bus: a Dial whose reconnect it arrived under meets the call-in-progress rule again before it places its own, and when a resume, a refresh or a reconnect fails the call that arrived under it ends with the flow it came in on (486 while the flow still carries one, and the bus told). The unprotected REGISTER's reply is matched the same way on the init port. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:51:03 +02:00
DumpRaw("imsd-register-fail.raw", resp);
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
std::string what = std::format("expected 401, got {}", imsd::sip::RefusalSummary(resp));
if (st1 && *st1 >= 300) throw RefusedError(what, *st1, imsd::sip::RetryAfter(resp).value_or(0));
throw std::runtime_error(what);
sip: log a refused REGISTER or SUBSCRIBE with its status line and reason headers A refused final was logged as its bare status code ("re-REGISTER failed 503", "reg-event SUBSCRIBE -> 500") — the unprotected REGISTER's as "expected 401", without even the code — so a field journal could not say which node refused or what it said. O2 UK answered a phone's 30-minute refresh with 503 twenty-one times over seventy minutes and then refused a fresh registration's reg-event SUBSCRIBE with 500 (alyx, 2026-09-24); the journal holds nothing but the numbers. Log the status line with the headers that carry the reason — Retry-After, Warning, Reason, Server — for a REGISTER's refused final (initial, protected, refresh) and for a refused SUBSCRIBE the run loop reads, and dump the refused message under DUMP_SIP as its own fail dump next to the 200s. To tell a REGISTER's final from what else arrives, the final is now matched by Call-ID and CSeq, and a request that arrives on the client flow while one is awaited is dispatched: a refresh no longer reads such a request as a failed REGISTER. An INVITE among those requests is a call like any other, announced on the bus: a Dial whose reconnect it arrived under meets the call-in-progress rule again before it places its own, and when a resume, a refresh or a reconnect fails the call that arrived under it ends with the flow it came in on (486 while the flow still carries one, and the bus told). The unprotected REGISTER's reply is matched the same way on the init port. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:51:03 +02:00
}
auto nonceB64 = QuotedAfter(resp, "nonce=\"");
if (!nonceB64) throw std::runtime_error("no nonce in 401");
auto raw = imsd::util::FromBase64(*nonceB64);
if (!raw || raw->size() < 32) throw std::runtime_error("bad nonce");
std::vector<std::uint8_t> rand16(raw->begin(), raw->begin() + 16);
std::vector<std::uint8_t> autn16(raw->begin() + 16, raw->begin() + 32);
ipsec: build the SAs from the Security-Server mechanism the UE selects A P-CSCF may answer the challenge with every mechanism it supports and attach the SPIs and ports to the one it applied. O2 UK's core lists six ipsec-3gpp entries with q-values, md5 first, and marks the fourth (sha1, no ealg). imsd took the first ealg= anywhere in the header - the 3DES of an entry the P-CSCF had not selected - and installed SAs the far end could not decrypt: every protected packet after the 401 was dropped at its IPsec layer, over TCP and UDP alike, with nothing to answer. The 401 handling now parses the Security-Server as a mechanism list (commas outside quotes, parameters on semicolons, names and values case-insensitive) and selects the highest-q ipsec-3gpp mechanism that matches what Security-Client offered (RFC 3329 2.3.1, TS 24.229 5.1.1.5.1): alg equal to ours, ealg (absent = null) equal to the offer. The SPIs and ports are per registration and come from the entry carrying them. When no entry matches the offer, the entry carrying the SPIs is installed as listed and the journal says so; hmac-md5-96 becomes hmac(md5) with IK as the key. A single-mechanism header selects itself: on KPN the SAs are the same as before (unit-pinned argv, verified on the phone). An answer that cannot be used - no ipsec-3gpp entry, a missing spi/port, an alg, ealg, prot or mod the kernel cannot be handed - defers the bring-up on the throttle schedule with the reason, before the AKA is spent, instead of a fatal that would restart imsd every RestartSec with an initial REGISTER each time; spi-s=0 stays the throttle deferral. Repeated Security-Server header lines are joined into the one list Security-Verify echoes. The journal shows the Security-Server line, the selected mechanism, the SA parameters, and any ip xfrm command that fails (keys masked) at every fresh registration - the silence this bug produced had no line to read.
2026-09-19 21:36:57 +02:00
// Every Security-Server instance is one comma-list (RFC 3261
// §7.3.1); the joined value is what Security-Verify echoes — RFC
// 3329 §2.2 wants the whole list back, not the chosen entry.
auto ssLines = imsd::sip::Headers(resp, "Security-Server");
if (ssLines.empty()) throw std::runtime_error("no Security-Server");
ss_.clear();
for (std::string_view line : ssLines) {
if (!ss_.empty()) ss_ += ',';
ss_ += line;
}
Log(std::format("Security-Server: {}", ss_));
// Algorithms from the mechanism we select against our offer (RFC
// 3329 §2.3.1), SPIs/ports from the entry carrying them (O2 lists
// six and marks one; ims ledger F11). An answer we cannot use is a
// deferral on the throttle schedule, not a fatal: a fatal restarts
// imsd every RestartSec, an initial REGISTER each time.
auto ss = imsd::ipsec::ParseSecurityServer(ss_, "hmac-sha-1-96", ealgOffer_);
if (!ss) throw ThrottledError("Security-Server has no ipsec-3gpp mechanism");
const imsd::ipsec::SecurityMechanism& mech = ss->Selected();
const imsd::ipsec::SecurityMechanism& carrier = ss->Carrier();
Log(std::format("selected mechanism {} of {}{}: {}", ss->selected + 1, ss->mechanisms.size(), ss->offerMatched ? "" : " (none matches our offer; taking the P-CSCF's)", mech.text));
if (ss->carrier != ss->selected) Log(std::format("SPIs/ports from mechanism {}: {}", ss->carrier + 1, carrier.text));
if (carrier.spiPs == 0u) throw ThrottledError("fresh-SA throttle active (spi-s=0)");
if (!carrier.spiPs || !carrier.spiPc || !carrier.portPs || !carrier.portPc) throw ThrottledError("Security-Server carries no spi-c/spi-s/port-c/port-s");
std::string alg = mech.alg.empty() ? "hmac-sha-1-96" : mech.alg; // absent: what we offered
std::string ealg = imsd::ipsec::NormalizeEalg(mech.ealg); // absent: the null cipher (RFC 3329)
if (!imsd::ipsec::KernelAuth(alg)) throw ThrottledError(std::format("Security-Server alg={} unsupported", alg));
if (!imsd::ipsec::KnownEalg(ealg)) throw ThrottledError(std::format("Security-Server ealg={} unsupported", ealg));
if (!mech.prot.empty() && mech.prot != "esp") throw ThrottledError(std::format("Security-Server prot={} unsupported", mech.prot));
if (!mech.mod.empty() && mech.mod != "trans") throw ThrottledError(std::format("Security-Server mod={} unsupported", mech.mod));
std::uint32_t spiPs = *carrier.spiPs;
std::uint32_t spiPc = *carrier.spiPc;
int portPs = *carrier.portPs;
int portPc = *carrier.portPc;
Log(std::format("SA: alg={} ealg={}{} spi-c={} spi-s={} port-c={} port-s={}", alg, ealg, mech.ealg.empty() ? " (absent)" : "", spiPc, spiPs, portPc, portPs));
auto aka = Authenticate(rand16, autn16);
if (!aka) throw std::runtime_error("USIM AKA failed");
// install the IPsec SA pair
imsd::ipsec::SaParams sp;
sp.local = local_; sp.pcscf = pcscf_;
sp.ik = aka->ik; sp.ck = aka->ck;
sp.spiUc = spiUc; sp.spiUs = spiUs; sp.spiPc = spiPc; sp.spiPs = spiPs;
sp.portUc = PortUc; sp.portUs = PortUs; sp.portPs = portPs; sp.portPc = portPc;
ipsec: build the SAs from the Security-Server mechanism the UE selects A P-CSCF may answer the challenge with every mechanism it supports and attach the SPIs and ports to the one it applied. O2 UK's core lists six ipsec-3gpp entries with q-values, md5 first, and marks the fourth (sha1, no ealg). imsd took the first ealg= anywhere in the header - the 3DES of an entry the P-CSCF had not selected - and installed SAs the far end could not decrypt: every protected packet after the 401 was dropped at its IPsec layer, over TCP and UDP alike, with nothing to answer. The 401 handling now parses the Security-Server as a mechanism list (commas outside quotes, parameters on semicolons, names and values case-insensitive) and selects the highest-q ipsec-3gpp mechanism that matches what Security-Client offered (RFC 3329 2.3.1, TS 24.229 5.1.1.5.1): alg equal to ours, ealg (absent = null) equal to the offer. The SPIs and ports are per registration and come from the entry carrying them. When no entry matches the offer, the entry carrying the SPIs is installed as listed and the journal says so; hmac-md5-96 becomes hmac(md5) with IK as the key. A single-mechanism header selects itself: on KPN the SAs are the same as before (unit-pinned argv, verified on the phone). An answer that cannot be used - no ipsec-3gpp entry, a missing spi/port, an alg, ealg, prot or mod the kernel cannot be handed - defers the bring-up on the throttle schedule with the reason, before the AKA is spent, instead of a fatal that would restart imsd every RestartSec with an initial REGISTER each time; spi-s=0 stays the throttle deferral. Repeated Security-Server header lines are joined into the one list Security-Verify echoes. The journal shows the Security-Server line, the selected mechanism, the SA parameters, and any ip xfrm command that fails (keys masked) at every fresh registration - the silence this bug produced had no line to read.
2026-09-19 21:36:57 +02:00
sp.alg = alg;
sp.ealg = ealg;
ipsec, imsd: the SA setup deletes by exact key instead of flushing the host's xfrm A fresh registration's SA setup began with `ip xfrm state flush` and `ip xfrm policy flush`: every SA userspace had installed and every SPD policy went, a policy-based VPN's (strongSwan's, libreswan's) SAs and policies included. The setup now takes out what two rules select and leaves the rest; and the SA reader, which took the P-CSCF ports for a resume from any reqid-1/2 `dir out` policy — correct only while the flush left imsd's pair alone in the kernel — takes them from the policies between our two addresses only. Whether imsd runs beside a live strongSwan or libreswan connection is unmeasured: the dev phone shows a tunnel policy and a tunnel state left in place (Tested below), no more. OwnXfrmDeleteCommands (Imsd:Ipsec) builds the deletes from the kernel text the shell reads: every transport-mode policy with reqid 1 or 2 whose selector carries imsd's protected port on the UE side, by the selector ip xfrm policy prints (src, dst, proto, sport, dport, dev) and its direction, and every 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, by src, dst, proto esp and SPI — policy or not: a setup whose policy add failed leaves such states (the setup logs a failed add and goes on), and an ESP state another transport-mode user put on that address with reqid 1 or 2 goes with them (an AH or IPComp state is not selected: the rule asks for ESP). Every reader of the policy text — the deletes and the SA reader's port pass here — goes through one predicate (detail::OwnPolicy), written for whatever reads that text later. BuildSetupCommands loses its two flush lines and the shell runs the deletes right before it (RunOwnXfrmDeletes); BuildTeardownCommands, the same two flushes with no caller, goes. A policy-based VPN's tunnel entries and another transport-mode user's pair on other ports and addresses stay. Residual: a transport-mode policy with reqid 1 and the UE-side port 45061, or reqid 2 and 45062 — alone or paired, between any addresses — carries imsd's fingerprint and goes, with the reqid-1/2 transport-mode ESP states in its direction, and a reqid-1/2 transport-mode ESP state on the UE's ims-PDN address goes whoever made it (tests/Ipsec pins both, and that an AH state stays). Tested: crafter-build test, 7 passed (the 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 the pair untouched; another transport-mode user's pair on other ports and addresses untouched, its reqid-1 ESP state on the UE's address deleted whoever made it, its AH state left; a policy-less state on the UE's address deleted, one between other addresses left; the reader's ports from our pair beside a foreign transport policy with ports); on the dev phone (KPN), in builds of this series, a fresh registration with a VPN's tunnel policy and state in the kernel: both still there afterwards, imsd's previous pair gone.
2026-10-13 22:28:02 +02:00
// imsd's previous pair out first, each entry by its key — not `ip xfrm …
// flush`: another IPsec user's entries (a policy-based VPN's) stay (§12)
{
auto state = RunCapture({"ip", "xfrm", "state"});
auto policy = RunCapture({"ip", "xfrm", "policy"});
RunOwnXfrmDeletes(imsd::ipsec::OwnXfrmDeleteCommands(state.out, policy.out, local_));
}
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
saReplaced_ = true; // from here on the old registration's SAs are gone
ipsec: build the SAs from the Security-Server mechanism the UE selects A P-CSCF may answer the challenge with every mechanism it supports and attach the SPIs and ports to the one it applied. O2 UK's core lists six ipsec-3gpp entries with q-values, md5 first, and marks the fourth (sha1, no ealg). imsd took the first ealg= anywhere in the header - the 3DES of an entry the P-CSCF had not selected - and installed SAs the far end could not decrypt: every protected packet after the 401 was dropped at its IPsec layer, over TCP and UDP alike, with nothing to answer. The 401 handling now parses the Security-Server as a mechanism list (commas outside quotes, parameters on semicolons, names and values case-insensitive) and selects the highest-q ipsec-3gpp mechanism that matches what Security-Client offered (RFC 3329 2.3.1, TS 24.229 5.1.1.5.1): alg equal to ours, ealg (absent = null) equal to the offer. The SPIs and ports are per registration and come from the entry carrying them. When no entry matches the offer, the entry carrying the SPIs is installed as listed and the journal says so; hmac-md5-96 becomes hmac(md5) with IK as the key. A single-mechanism header selects itself: on KPN the SAs are the same as before (unit-pinned argv, verified on the phone). An answer that cannot be used - no ipsec-3gpp entry, a missing spi/port, an alg, ealg, prot or mod the kernel cannot be handed - defers the bring-up on the throttle schedule with the reason, before the AKA is spent, instead of a fatal that would restart imsd every RestartSec with an initial REGISTER each time; spi-s=0 stays the throttle deferral. Repeated Security-Server header lines are joined into the one list Security-Verify echoes. The journal shows the Security-Server line, the selected mechanism, the SA parameters, and any ip xfrm command that fails (keys masked) at every fresh registration - the silence this bug produced had no line to read.
2026-09-19 21:36:57 +02:00
for (auto& cmd : imsd::ipsec::BuildSetupCommands(sp)) {
int rc = RunCmd(cmd);
if (rc != 0) Log(std::format("{} -> rc {}", RedactKeys(cmd), rc));
}
ctx_.securityServer = ss_;
portPs_ = portPs;
sip: UDP for the protected leg, TCP first with a fallback imsd's protected leg (the second REGISTER and everything after it) was TCP only. A P-CSCF that never answers the TCP connect on its protected server port (O2 UK: the SYNs leave ESP-protected, nothing comes back) left the unit looping with no way forward, although the SAs, the listener sockets and the firewall rule already covered UDP. The client flow now carries a transport. UDP binds the same protected client port and connect()s the datagram socket to the P-CSCF's protected server port, so the kernel delivers that peer's datagrams to it ahead of the unconnected listener on the same port. One datagram is one message (RFC 3261 18.3: a Content-Length that fits truncates, one that does not fit discards the datagram, none means the rest of the datagram); a receive error is logged and marks the flow dead; a message over the single-packet ESP budget at the ims PDN's MTU is logged once per flow, since the kernel fragments it and a P-CSCF may drop the fragments. Every protected request's Via follows the transport; the challenge stays UDP. SIP_TRANSPORT selects the policy: auto (default) registers over TCP and, after two unanswered connects, registers again over UDP (new challenge, new SA pair) - but only on a phone whose state file does not record a successful TCP registration, so an outage on a TCP carrier never becomes a second initial REGISTER; a phone whose last registration was UDP goes straight to UDP. tcp and udp force one. A connect refused locally (EADDRNOTAVAIL: the 4-tuple still in TIME_WAIT from the previous flow) keeps retrying and is not counted as silence; SO_ERROR results are logged by name. The registration's transport is persisted so a warm resume reconnects the same way, and the resume gives up after two silences. Two things the change exposed and fixes: an engine-fatal event exited 0 ("exiting for systemd restart" with Restart=on-failure never firing), and a fresh registration refused with the P-CSCF's fresh-SA throttle (Security-Server spi-s=0) must not be retried by a 120-s restart loop, since every attempt re-arms the ~20-min window - it is now waited out in-process, growing on repeats, with D-Bus commands still served. A resume the network refuses falls through to a fresh registration. A retransmitted 200 OK to our INVITE is ACKed again and no longer starts a second media leg. Verified on KPN: resume over TCP after a binary swap, MT and MO calls with media both ways, the throttle deferral and its self-recovery, the UDP client path up to KPN dropping the datagram. The UDP success path is a field test; SIP retransmission timers over UDP are not in this change.
2026-09-17 14:58:46 +02:00
// The fallback is for a P-CSCF that never served TCP. Once a TCP
// registration has succeeded on this phone (the state file records
// it), a silent connect is an outage, not a policy — two unanswered
// SYNs during a radio gap must not become a second initial REGISTER
// (the throttle trigger on KPN).
bool tcpKnownGood = false;
if (auto ps = LoadState(); ps && ps->callid.has_value()) {
std::string t = ps->transport.value_or("TCP");
tcpKnownGood = (t == "TCP");
// A phone whose last registration was UDP goes straight to UDP:
// a TCP probe on every boot would be a second initial REGISTER
// per boot on a carrier that never answers it.
if (sipPolicy_ == "auto" && t == "UDP" && transport_ == Transport::Tcp) {
Log("state file records a UDP registration; skipping the TCP probe");
SetTransport(Transport::Udp);
}
}
bool autoTcp = (sipPolicy_ == "auto" && transport_ == Transport::Tcp && !tcpKnownGood);
if (!sip_.Connect(local_, PortUc, pcscf_, portPs, transport_, 10, autoTcp ? 2 : 0)) {
if (autoTcp) {
// Two unanswered TCP connects: treat the P-CSCF as UDP-only
// and register again over UDP — a new challenge and a new SA
ipsec, imsd: the SA setup deletes by exact key instead of flushing the host's xfrm A fresh registration's SA setup began with `ip xfrm state flush` and `ip xfrm policy flush`: every SA userspace had installed and every SPD policy went, a policy-based VPN's (strongSwan's, libreswan's) SAs and policies included. The setup now takes out what two rules select and leaves the rest; and the SA reader, which took the P-CSCF ports for a resume from any reqid-1/2 `dir out` policy — correct only while the flush left imsd's pair alone in the kernel — takes them from the policies between our two addresses only. Whether imsd runs beside a live strongSwan or libreswan connection is unmeasured: the dev phone shows a tunnel policy and a tunnel state left in place (Tested below), no more. OwnXfrmDeleteCommands (Imsd:Ipsec) builds the deletes from the kernel text the shell reads: every transport-mode policy with reqid 1 or 2 whose selector carries imsd's protected port on the UE side, by the selector ip xfrm policy prints (src, dst, proto, sport, dport, dev) and its direction, and every 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, by src, dst, proto esp and SPI — policy or not: a setup whose policy add failed leaves such states (the setup logs a failed add and goes on), and an ESP state another transport-mode user put on that address with reqid 1 or 2 goes with them (an AH or IPComp state is not selected: the rule asks for ESP). Every reader of the policy text — the deletes and the SA reader's port pass here — goes through one predicate (detail::OwnPolicy), written for whatever reads that text later. BuildSetupCommands loses its two flush lines and the shell runs the deletes right before it (RunOwnXfrmDeletes); BuildTeardownCommands, the same two flushes with no caller, goes. A policy-based VPN's tunnel entries and another transport-mode user's pair on other ports and addresses stay. Residual: a transport-mode policy with reqid 1 and the UE-side port 45061, or reqid 2 and 45062 — alone or paired, between any addresses — carries imsd's fingerprint and goes, with the reqid-1/2 transport-mode ESP states in its direction, and a reqid-1/2 transport-mode ESP state on the UE's ims-PDN address goes whoever made it (tests/Ipsec pins both, and that an AH state stays). Tested: crafter-build test, 7 passed (the 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 the pair untouched; another transport-mode user's pair on other ports and addresses untouched, its reqid-1 ESP state on the UE's address deleted whoever made it, its AH state left; a policy-less state on the UE's address deleted, one between other addresses left; the reader's ports from our pair beside a foreign transport policy with ports); on the dev phone (KPN), in builds of this series, a fresh registration with a VPN's tunnel policy and state in the kernel: both still there afterwards, imsd's previous pair gone.
2026-10-13 22:28:02 +02:00
// pair (the SA setup takes this one out), same ports.
sip: UDP for the protected leg, TCP first with a fallback imsd's protected leg (the second REGISTER and everything after it) was TCP only. A P-CSCF that never answers the TCP connect on its protected server port (O2 UK: the SYNs leave ESP-protected, nothing comes back) left the unit looping with no way forward, although the SAs, the listener sockets and the firewall rule already covered UDP. The client flow now carries a transport. UDP binds the same protected client port and connect()s the datagram socket to the P-CSCF's protected server port, so the kernel delivers that peer's datagrams to it ahead of the unconnected listener on the same port. One datagram is one message (RFC 3261 18.3: a Content-Length that fits truncates, one that does not fit discards the datagram, none means the rest of the datagram); a receive error is logged and marks the flow dead; a message over the single-packet ESP budget at the ims PDN's MTU is logged once per flow, since the kernel fragments it and a P-CSCF may drop the fragments. Every protected request's Via follows the transport; the challenge stays UDP. SIP_TRANSPORT selects the policy: auto (default) registers over TCP and, after two unanswered connects, registers again over UDP (new challenge, new SA pair) - but only on a phone whose state file does not record a successful TCP registration, so an outage on a TCP carrier never becomes a second initial REGISTER; a phone whose last registration was UDP goes straight to UDP. tcp and udp force one. A connect refused locally (EADDRNOTAVAIL: the 4-tuple still in TIME_WAIT from the previous flow) keeps retrying and is not counted as silence; SO_ERROR results are logged by name. The registration's transport is persisted so a warm resume reconnects the same way, and the resume gives up after two silences. Two things the change exposed and fixes: an engine-fatal event exited 0 ("exiting for systemd restart" with Restart=on-failure never firing), and a fresh registration refused with the P-CSCF's fresh-SA throttle (Security-Server spi-s=0) must not be retried by a 120-s restart loop, since every attempt re-arms the ~20-min window - it is now waited out in-process, growing on repeats, with D-Bus commands still served. A resume the network refuses falls through to a fresh registration. A retransmitted 200 OK to our INVITE is ACKed again and no longer starts a second media leg. Verified on KPN: resume over TCP after a binary swap, MT and MO calls with media both ways, the throttle deferral and its self-recovery, the UDP client path up to KPN dropping the datagram. The UDP success path is a field test; SIP retransmission timers over UDP are not in this change.
2026-09-17 14:58:46 +02:00
Log("protected TCP connect unanswered; registering fresh over UDP");
SetTransport(Transport::Udp);
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
FreshRegisterAttempt();
sip: UDP for the protected leg, TCP first with a fallback imsd's protected leg (the second REGISTER and everything after it) was TCP only. A P-CSCF that never answers the TCP connect on its protected server port (O2 UK: the SYNs leave ESP-protected, nothing comes back) left the unit looping with no way forward, although the SAs, the listener sockets and the firewall rule already covered UDP. The client flow now carries a transport. UDP binds the same protected client port and connect()s the datagram socket to the P-CSCF's protected server port, so the kernel delivers that peer's datagrams to it ahead of the unconnected listener on the same port. One datagram is one message (RFC 3261 18.3: a Content-Length that fits truncates, one that does not fit discards the datagram, none means the rest of the datagram); a receive error is logged and marks the flow dead; a message over the single-packet ESP budget at the ims PDN's MTU is logged once per flow, since the kernel fragments it and a P-CSCF may drop the fragments. Every protected request's Via follows the transport; the challenge stays UDP. SIP_TRANSPORT selects the policy: auto (default) registers over TCP and, after two unanswered connects, registers again over UDP (new challenge, new SA pair) - but only on a phone whose state file does not record a successful TCP registration, so an outage on a TCP carrier never becomes a second initial REGISTER; a phone whose last registration was UDP goes straight to UDP. tcp and udp force one. A connect refused locally (EADDRNOTAVAIL: the 4-tuple still in TIME_WAIT from the previous flow) keeps retrying and is not counted as silence; SO_ERROR results are logged by name. The registration's transport is persisted so a warm resume reconnects the same way, and the resume gives up after two silences. Two things the change exposed and fixes: an engine-fatal event exited 0 ("exiting for systemd restart" with Restart=on-failure never firing), and a fresh registration refused with the P-CSCF's fresh-SA throttle (Security-Server spi-s=0) must not be retried by a 120-s restart loop, since every attempt re-arms the ~20-min window - it is now waited out in-process, growing on repeats, with D-Bus commands still served. A resume the network refuses falls through to a fresh registration. A retransmitted 200 OK to our INVITE is ACKed again and no longer starts a second media leg. Verified on KPN: resume over TCP after a binary swap, MT and MO calls with media both ways, the throttle deferral and its self-recovery, the UDP client path up to KPN dropping the datagram. The UDP success path is a field test; SIP retransmission timers over UDP are not in this change.
2026-09-17 14:58:46 +02:00
return;
}
throw std::runtime_error(std::format("protected {} connect failed", TransportName(transport_)));
}
Log(std::format("protected leg over {}", TransportName(transport_)));
std::string cnonce = rng_.Token(16);
std::string response = imsd::aka::DigestAkav1(*nonceB64, aka->res, cnonce, ctx_.id.regUri, ctx_.id.impi, ctx_.id.domain);
std::string auth = imsd::msg::AuthAka(ctx_, *nonceB64, cnonce, response);
std::string reg2 = imsd::msg::BuildRegisterProtected(ctx_, callidReg, ftag, rng_.Token(16), 2, spiUc, spiUs, auth);
if (!sip_.Send(reg2)) throw std::runtime_error("send protected REGISTER failed");
sip: log a refused REGISTER or SUBSCRIBE with its status line and reason headers A refused final was logged as its bare status code ("re-REGISTER failed 503", "reg-event SUBSCRIBE -> 500") — the unprotected REGISTER's as "expected 401", without even the code — so a field journal could not say which node refused or what it said. O2 UK answered a phone's 30-minute refresh with 503 twenty-one times over seventy minutes and then refused a fresh registration's reg-event SUBSCRIBE with 500 (alyx, 2026-09-24); the journal holds nothing but the numbers. Log the status line with the headers that carry the reason — Retry-After, Warning, Reason, Server — for a REGISTER's refused final (initial, protected, refresh) and for a refused SUBSCRIBE the run loop reads, and dump the refused message under DUMP_SIP as its own fail dump next to the 200s. To tell a REGISTER's final from what else arrives, the final is now matched by Call-ID and CSeq, and a request that arrives on the client flow while one is awaited is dispatched: a refresh no longer reads such a request as a failed REGISTER. An INVITE among those requests is a call like any other, announced on the bus: a Dial whose reconnect it arrived under meets the call-in-progress rule again before it places its own, and when a resume, a refresh or a reconnect fails the call that arrived under it ends with the flow it came in on (486 while the flow still carries one, and the bus told). The unprotected REGISTER's reply is matched the same way on the init port. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:51:03 +02:00
std::string ok = AwaitFinal(callidReg, 2, "protected REGISTER");
if (auto st = imsd::sip::Status(ok); st != 200) {
DumpRaw("imsd-register-fail.raw", ok);
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
throw RefusedError(std::format("REGISTER refused: {}", imsd::sip::RefusalSummary(ok)), *st, imsd::sip::RetryAfter(ok).value_or(0));
}
reg_.callid = callidReg; reg_.ftag = ftag; reg_.cseq = 2;
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
reg_.contactUser = contactUser;
reg_.spiUc = spiUc; reg_.spiUs = spiUs;
engine, messages, util, imsd: the reregistration carries the last challenge answer and new SA parameters The keepalive refresh and the resume's re-REGISTER carried an initial registration's first-REGISTER Authorization — an empty nonce and response — in the second REGISTER's frame, with a Security-Client naming the SPIs and ports in use. TS 24.229 5.1.1.4.2 has a reregistration carry the Authorization of the last successful authentication — the last received nonce and the last calculated response — and a Security-Client with the new parameter values for the two SA pairs a re-authentication would set up; TS 33.203 7.4.0 keeps the server ports and changes the client ports. O2 UK refused the refresh of the old shape with 503 on every occasion on record (alyx, 2026-09-24 to 10-08, a stationary phone included) and accepted the fresh registrations that followed; KPN accepted the old shape without a challenge. The fresh registration now records the 401's nonce, our cnonce and the response in the registration state (auth_nonce, auth_cnonce, auth_response in the state file); a reregistration sends them verbatim, mints new SPIs and proposes PortUcNext (45063) as the client port with the server port unchanged. A registration resumed from a state file written before this change has no answer: its reregistrations carry the empty digest, as before, until its next fresh registration, and the resume says so. A 401 to a reregistration asks for re-authentication over temporary SAs on the proposed ports (TS 24.229 5.1.1.5.1), which imsd does not implement: the refresh throws it as a refusal and RegRecovery makes the next step the fresh registration, replacing the in-place answer over the old SAs. Tested with the Messages and Engine suites and on the dev phone (KPN) with a build of this commit's tree: a resume from the previous state file, a fresh registration, a resume that sent the stored answer, three refreshes at a 120-s interval, an outgoing call to a bench line and an incoming call's ring.
2026-10-10 21:00:28 +02:00
reg_.authNonce = *nonceB64; reg_.authCnonce = cnonce; reg_.authResponse = response;
imsd: a resumed registration echoes the 401's Security-Server list from the state file A warm resume rebuilt its Security-Verify from the kernel's SA: one mechanism with hmac-sha-1-96 and aes-cbc written in, where RFC 3329 §2.3.1 has the client mirror the server's whole list. The fresh registration kept the 401's list in memory, so a registration this process made fresh echoed it; after a restart, the resumed registration's re-REGISTER, its keepalives, its reg-event SUBSCRIBE and its INVITEs carried the rebuilt entry until the next fresh registration. KPN's one-entry answer to the default offer is the rebuilt entry byte for byte; against a six-entry list (O2's) the mirror is broken, which the RFC has the server answer with a 494, and a refused resume registers fresh. The fresh registration now stores the joined Security-Server value in the state file (security_server, next to the challenge answer) and the resume takes it from there, logging the mechanism count, when the entry carrying its SPIs names the SA pair in the kernel: a file an attempt the process did not live to record left behind (its SAs installed, its 200 not written) names another. A file from before this change holds none. In both cases the resume falls back to the rebuilt entry, says which, and the registration's next state write stores that entry, which is what it echoes; the choice is Imsd:Ipsec's, pinned in tests/Ipsec with O2's recorded list and KPN's. A value with a quote, a backslash or a newline in it is written empty, which the resume treats as absent; RFC 3329's token syntax produces none of these, a quoted-string extension parameter could carry the first two. Tested on the dev phone (KPN) with the build of this tree: a resume from a state file the 0.3.11 daemon wrote logs the fallback and succeeds, so do one from a file whose list names another SA pair and one whose list is empty; two keepalives at a 120-s interval; a fresh registration stores KPN's list byte-for-byte as the journal shows it received; a restart resumes with the list from the file; an outgoing call.
2026-10-10 23:42:01 +02:00
reg_.securityServer = ss_;
reg_.expiry = imsd::sip::GrantedExpires(ok).value_or(0);
DumpRaw("imsd-register-200.raw", ok);
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.
2026-10-11 15:58:49 +02:00
// 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);
ctx_.route = route_; ctx_.ppi = ppi_;
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.
2026-10-11 15:58:49 +02:00
PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport, pcscf_);
Log("REGISTERED (fresh)");
}
sip: log a refused REGISTER or SUBSCRIBE with its status line and reason headers A refused final was logged as its bare status code ("re-REGISTER failed 503", "reg-event SUBSCRIBE -> 500") — the unprotected REGISTER's as "expected 401", without even the code — so a field journal could not say which node refused or what it said. O2 UK answered a phone's 30-minute refresh with 503 twenty-one times over seventy minutes and then refused a fresh registration's reg-event SUBSCRIBE with 500 (alyx, 2026-09-24); the journal holds nothing but the numbers. Log the status line with the headers that carry the reason — Retry-After, Warning, Reason, Server — for a REGISTER's refused final (initial, protected, refresh) and for a refused SUBSCRIBE the run loop reads, and dump the refused message under DUMP_SIP as its own fail dump next to the 200s. To tell a REGISTER's final from what else arrives, the final is now matched by Call-ID and CSeq, and a request that arrives on the client flow while one is awaited is dispatched: a refresh no longer reads such a request as a failed REGISTER. An INVITE among those requests is a call like any other, announced on the bus: a Dial whose reconnect it arrived under meets the call-in-progress rule again before it places its own, and when a resume, a refresh or a reconnect fails the call that arrived under it ends with the flow it came in on (486 while the flow still carries one, and the bus told). The unprotected REGISTER's reply is matched the same way on the init port. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:51:03 +02:00
// Closes the flow of a REGISTER wait that failed. A call that arrived
// under the wait (AwaitFinal dispatches requests) ends with it: a 486
// while the flow still carries one, and the bus told.
void CloseFlowAfterWait() {
if (call_) {
Log(std::format("call {} arrived under a registration that failed; ending it", call_->Uni()));
Execute(call_->OnHangup());
MaybeDropCall();
if (call_) { Execute(call_->Abandon("registration failed")); MaybeDropCall(); }
}
sip_.Close();
}
// The final of the REGISTER (callid, cseq) just sent, within 12 s of
// the send or of its last provisional. A request arriving on the client
// flow meanwhile (a NOTIFY, an OPTIONS) is dispatched and answered, not
// read as the reply; a provisional of our own transaction keeps the
engine: keep the reg-event subscription alive and act on a terminated binding The reg-event subscription is the network's own account of our binding (TS 24.229 5.1.1.3), and the daemon opened a new dialog for it at every refresh and reconnect and otherwise never looked back: a refused SUBSCRIBE was forgotten, an unanswered one had no timeout, an ended subscription got nothing until the next refresh's new dialog, and a NOTIFY was logged whatever it said. O2 UK, 2026-09-24: a fresh registration's SUBSCRIBE got 500, a NOTIFY arrived all the same and showed our contact active, the flow died eight seconds after the 200 and no connect over the SA succeeded after that; the 500 was logged and nothing was done about it. Keep the subscription alive: retry a refused or unanswered SUBSCRIBE on a backoff, renew one the S-CSCF ends with deactivated, timeout, giveup or probation (RFC 6665 4.1.3; after any other reason, or none, the next refresh subscribes anew), and subscribe anew ahead of the lifetime the S-CSCF granted. A refresh or a reconnect opens no dialog while imsd holds one for the registration or has a retry scheduled. Read the reginfo for our own contact and act on its state: a NOTIFY that terminates our binding schedules a fresh registration — at once for deactivated or expired, after the network's retry-after (up to an hour) for probation, on imsd's own refusal schedule (21 min, growing to 60) for rejected or unregistered (RFC 3680). A hold already running stands, and a REGISTER wait a call kept open that still gets its 200 after such a NOTIFY leaves that registration standing. The parser is in Imsd:Sip and pinned by tests/Sip with the O2 document shapes; the policy is RegRecovery's (OnBindingTerminated, added here with its tests in tests/Engine). Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-10-03 19:36:23 +02:00
// wait going; a response to our reg-event SUBSCRIBE is handled as the
// run loop handles it, any other transaction's is ignored. Throws
sip: log a refused REGISTER or SUBSCRIBE with its status line and reason headers A refused final was logged as its bare status code ("re-REGISTER failed 503", "reg-event SUBSCRIBE -> 500") — the unprotected REGISTER's as "expected 401", without even the code — so a field journal could not say which node refused or what it said. O2 UK answered a phone's 30-minute refresh with 503 twenty-one times over seventy minutes and then refused a fresh registration's reg-event SUBSCRIBE with 500 (alyx, 2026-09-24); the journal holds nothing but the numbers. Log the status line with the headers that carry the reason — Retry-After, Warning, Reason, Server — for a REGISTER's refused final (initial, protected, refresh) and for a refused SUBSCRIBE the run loop reads, and dump the refused message under DUMP_SIP as its own fail dump next to the 200s. To tell a REGISTER's final from what else arrives, the final is now matched by Call-ID and CSeq, and a request that arrives on the client flow while one is awaited is dispatched: a refresh no longer reads such a request as a failed REGISTER. An INVITE among those requests is a call like any other, announced on the bus: a Dial whose reconnect it arrived under meets the call-in-progress rule again before it places its own, and when a resume, a refresh or a reconnect fails the call that arrived under it ends with the flow it came in on (486 while the flow still carries one, and the bus told). The unprotected REGISTER's reply is matched the same way on the init port. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:51:03 +02:00
// when nothing answers or the flow dies under a dispatched request.
std::string AwaitFinal(const std::string& callid, long cseq, std::string_view what) {
std::string want = std::format("{} REGISTER", cseq);
double deadline = Mono() + 12.0;
for (;;) {
double left = deadline - Mono();
auto m = left > 0 ? sip_.RecvMsg(left) : std::nullopt;
if (!m) throw std::runtime_error(std::format("no reply to {}", what));
auto st = imsd::sip::Status(*m);
if (!st) {
reply_ = [this](const std::string& r) { sip_.Send(r); };
DispatchRequest(*m);
if (!sip_.Alive()) throw std::runtime_error(std::format("flow closed while awaiting the {} reply", what));
continue;
}
if (!imsd::sip::ResponseOf(*m, callid, want)) {
engine: keep the reg-event subscription alive and act on a terminated binding The reg-event subscription is the network's own account of our binding (TS 24.229 5.1.1.3), and the daemon opened a new dialog for it at every refresh and reconnect and otherwise never looked back: a refused SUBSCRIBE was forgotten, an unanswered one had no timeout, an ended subscription got nothing until the next refresh's new dialog, and a NOTIFY was logged whatever it said. O2 UK, 2026-09-24: a fresh registration's SUBSCRIBE got 500, a NOTIFY arrived all the same and showed our contact active, the flow died eight seconds after the 200 and no connect over the SA succeeded after that; the 500 was logged and nothing was done about it. Keep the subscription alive: retry a refused or unanswered SUBSCRIBE on a backoff, renew one the S-CSCF ends with deactivated, timeout, giveup or probation (RFC 6665 4.1.3; after any other reason, or none, the next refresh subscribes anew), and subscribe anew ahead of the lifetime the S-CSCF granted. A refresh or a reconnect opens no dialog while imsd holds one for the registration or has a retry scheduled. Read the reginfo for our own contact and act on its state: a NOTIFY that terminates our binding schedules a fresh registration — at once for deactivated or expired, after the network's retry-after (up to an hour) for probation, on imsd's own refusal schedule (21 min, growing to 60) for rejected or unregistered (RFC 3680). A hold already running stands, and a REGISTER wait a call kept open that still gets its 200 after such a NOTIFY leaves that registration standing. The parser is in Imsd:Sip and pinned by tests/Sip with the O2 document shapes; the policy is RegRecovery's (OnBindingTerminated, added here with its tests in tests/Engine). Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-10-03 19:36:23 +02:00
if (!OnSubscribeResponse(*m, *st)) Log(std::format("ignoring a response to another transaction while awaiting the {} reply", what));
sip: log a refused REGISTER or SUBSCRIBE with its status line and reason headers A refused final was logged as its bare status code ("re-REGISTER failed 503", "reg-event SUBSCRIBE -> 500") — the unprotected REGISTER's as "expected 401", without even the code — so a field journal could not say which node refused or what it said. O2 UK answered a phone's 30-minute refresh with 503 twenty-one times over seventy minutes and then refused a fresh registration's reg-event SUBSCRIBE with 500 (alyx, 2026-09-24); the journal holds nothing but the numbers. Log the status line with the headers that carry the reason — Retry-After, Warning, Reason, Server — for a REGISTER's refused final (initial, protected, refresh) and for a refused SUBSCRIBE the run loop reads, and dump the refused message under DUMP_SIP as its own fail dump next to the 200s. To tell a REGISTER's final from what else arrives, the final is now matched by Call-ID and CSeq, and a request that arrives on the client flow while one is awaited is dispatched: a refresh no longer reads such a request as a failed REGISTER. An INVITE among those requests is a call like any other, announced on the bus: a Dial whose reconnect it arrived under meets the call-in-progress rule again before it places its own, and when a resume, a refresh or a reconnect fails the call that arrived under it ends with the flow it came in on (486 while the flow still carries one, and the bus told). The unprotected REGISTER's reply is matched the same way on the init port. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:51:03 +02:00
continue;
}
if (*st >= 200) return *m;
Log(std::format("{} -> {} (provisional); waiting for the final", what, *st));
deadline = Mono() + 12.0;
}
}
// The final of the unprotected REGISTER (callid, CSeq 1) on the init
// socket, within 8 s of the send or of its last provisional. The socket
// is bound to the init port, not connected: what else it delivers — a
// late final of an earlier attempt, a stray datagram — is logged with
// its source port and skipped, never judged as the reply. Closes the
// socket; throws when nothing answers.
std::string AwaitInitialFinal(int us, const std::string& callid) {
double deadline = Mono() + 8.0;
char buf[65535];
for (;;) {
double left = deadline - Mono();
if (left <= 0) break;
long sec = static_cast<long>(left);
long usec = static_cast<long>((left - static_cast<double>(sec)) * 1e6);
if (sec == 0 && usec == 0) usec = 1000;
timeval tv{sec, usec};
setsockopt(us, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv);
sockaddr_storage src; socklen_t sl = sizeof src;
ssize_t n = recvfrom(us, buf, sizeof buf, 0, reinterpret_cast<sockaddr*>(&src), &sl);
if (n <= 0) break;
std::string m(buf, static_cast<std::size_t>(n));
if (!imsd::sip::ResponseOf(m, callid, "1 REGISTER")) {
int sp = src.ss_family == AF_INET6 ? ntohs(reinterpret_cast<sockaddr_in6*>(&src)->sin6_port) : ntohs(reinterpret_cast<sockaddr_in*>(&src)->sin_port);
std::string_view sv = m;
Log(std::format("ignoring a datagram on the init port from port {} that is not the unprotected REGISTER's reply: {}", sp, sv.substr(0, sv.find("\r\n"))));
continue;
}
int st = *imsd::sip::Status(m);
if (st >= 200) { close(us); return m; }
Log(std::format("unprotected REGISTER -> {} (provisional); waiting for the final", st));
deadline = Mono() + 8.0;
}
close(us);
throw std::runtime_error("no 401 to unprotected REGISTER");
}
engine, messages, util, imsd: the reregistration carries the last challenge answer and new SA parameters The keepalive refresh and the resume's re-REGISTER carried an initial registration's first-REGISTER Authorization — an empty nonce and response — in the second REGISTER's frame, with a Security-Client naming the SPIs and ports in use. TS 24.229 5.1.1.4.2 has a reregistration carry the Authorization of the last successful authentication — the last received nonce and the last calculated response — and a Security-Client with the new parameter values for the two SA pairs a re-authentication would set up; TS 33.203 7.4.0 keeps the server ports and changes the client ports. O2 UK refused the refresh of the old shape with 503 on every occasion on record (alyx, 2026-09-24 to 10-08, a stationary phone included) and accepted the fresh registrations that followed; KPN accepted the old shape without a challenge. The fresh registration now records the 401's nonce, our cnonce and the response in the registration state (auth_nonce, auth_cnonce, auth_response in the state file); a reregistration sends them verbatim, mints new SPIs and proposes PortUcNext (45063) as the client port with the server port unchanged. A registration resumed from a state file written before this change has no answer: its reregistrations carry the empty digest, as before, until its next fresh registration, and the resume says so. A 401 to a reregistration asks for re-authentication over temporary SAs on the proposed ports (TS 24.229 5.1.1.5.1), which imsd does not implement: the refresh throws it as a refusal and RegRecovery makes the next step the fresh registration, replacing the in-place answer over the old SAs. Tested with the Messages and Engine suites and on the dev phone (KPN) with a build of this commit's tree: a resume from the previous state file, a fresh registration, a resume that sent the stored answer, three refreshes at a 120-s interval, an outgoing call to a bench line and an incoming call's ring.
2026-10-10 21:00:28 +02:00
// A reregistration over the (resumed or live) SA, TS 24.229 5.1.1.4.2:
// the same Call-ID and Contact, CSeq+1, the Authorization of the last
// successful authentication, a Security-Client with new SPIs and a new
// client port for the SA pairs a re-authentication would set up (the
// server port stays, TS 33.203 7.4.0), the Security-Verify (the 401's
imsd: a resumed registration echoes the 401's Security-Server list from the state file A warm resume rebuilt its Security-Verify from the kernel's SA: one mechanism with hmac-sha-1-96 and aes-cbc written in, where RFC 3329 §2.3.1 has the client mirror the server's whole list. The fresh registration kept the 401's list in memory, so a registration this process made fresh echoed it; after a restart, the resumed registration's re-REGISTER, its keepalives, its reg-event SUBSCRIBE and its INVITEs carried the rebuilt entry until the next fresh registration. KPN's one-entry answer to the default offer is the rebuilt entry byte for byte; against a six-entry list (O2's) the mirror is broken, which the RFC has the server answer with a 494, and a refused resume registers fresh. The fresh registration now stores the joined Security-Server value in the state file (security_server, next to the challenge answer) and the resume takes it from there, logging the mechanism count, when the entry carrying its SPIs names the SA pair in the kernel: a file an attempt the process did not live to record left behind (its SAs installed, its 200 not written) names another. A file from before this change holds none. In both cases the resume falls back to the rebuilt entry, says which, and the registration's next state write stores that entry, which is what it echoes; the choice is Imsd:Ipsec's, pinned in tests/Ipsec with O2's recorded list and KPN's. A value with a quote, a backslash or a newline in it is written empty, which the resume treats as absent; RFC 3329's token syntax produces none of these, a quoted-string extension parameter could carry the first two. Tested on the dev phone (KPN) with the build of this tree: a resume from a state file the 0.3.11 daemon wrote logs the fallback and succeeds, so do one from a file whose list names another SA pair and one whose list is empty; two keepalives at a 120-s interval; a fresh registration stores KPN's list byte-for-byte as the journal shows it received; a restart resumes with the list from the file; an outgoing call.
2026-10-10 23:42:01 +02:00
// list, kept in the state file since 0.3.12 so a resumed registration
// echoes it too; a resume from an older file sends the one entry
// rebuilt from the kernel's SA). Returns (200 msg, used cseq). A 401
engine, messages, util, imsd: the reregistration carries the last challenge answer and new SA parameters The keepalive refresh and the resume's re-REGISTER carried an initial registration's first-REGISTER Authorization — an empty nonce and response — in the second REGISTER's frame, with a Security-Client naming the SPIs and ports in use. TS 24.229 5.1.1.4.2 has a reregistration carry the Authorization of the last successful authentication — the last received nonce and the last calculated response — and a Security-Client with the new parameter values for the two SA pairs a re-authentication would set up; TS 33.203 7.4.0 keeps the server ports and changes the client ports. O2 UK refused the refresh of the old shape with 503 on every occasion on record (alyx, 2026-09-24 to 10-08, a stationary phone included) and accepted the fresh registrations that followed; KPN accepted the old shape without a challenge. The fresh registration now records the 401's nonce, our cnonce and the response in the registration state (auth_nonce, auth_cnonce, auth_response in the state file); a reregistration sends them verbatim, mints new SPIs and proposes PortUcNext (45063) as the client port with the server port unchanged. A registration resumed from a state file written before this change has no answer: its reregistrations carry the empty digest, as before, until its next fresh registration, and the resume says so. A 401 to a reregistration asks for re-authentication over temporary SAs on the proposed ports (TS 24.229 5.1.1.5.1), which imsd does not implement: the refresh throws it as a refusal and RegRecovery makes the next step the fresh registration, replacing the in-place answer over the old SAs. Tested with the Messages and Engine suites and on the dev phone (KPN) with a build of this commit's tree: a resume from the previous state file, a fresh registration, a resume that sent the stored answer, three refreshes at a 120-s interval, an outgoing call to a bench line and an incoming call's ring.
2026-10-10 21:00:28 +02:00
// asks for re-authentication over temporary SAs on the proposed ports,
// which imsd does not do: it is thrown as a refusal whose recovery is a
// fresh registration (RegRecovery's 401 rule). Every refresh of the
// pre-0.3.11 shape on record from O2 UK — an initial registration's
// empty digest, the in-use SPIs — was refused 503 (2026-09-24 under
// 0.3.9, 2026-10-08 under 0.3.10) while the fresh registrations that
// followed were accepted; KPN accepted both shapes without a challenge.
std::pair<std::string, long> Reregister(const std::string& callid, const std::string& ftag, long cseq) {
std::string auth = reg_.authNonce.empty() ? imsd::msg::AuthEmpty(ctx_)
: imsd::msg::AuthAka(ctx_, reg_.authNonce, reg_.authCnonce, reg_.authResponse);
std::uint32_t spiUc = 0, spiUs = 0;
do spiUc = rng_.UInt(0x10000, 0xFFFFFFF); while (spiUc == reg_.spiUc || spiUc == reg_.spiUs);
do spiUs = rng_.UInt(0x10000, 0xFFFFFFF); while (spiUs == reg_.spiUc || spiUs == reg_.spiUs || spiUs == spiUc);
std::string reg = imsd::msg::BuildRegisterProtected(ctx_, callid, ftag, rng_.Token(16), cseq, spiUc, spiUs, auth, PortUcNext);
if (!sip_.Send(reg)) throw std::runtime_error("send re-REGISTER failed");
std::string m = AwaitFinal(callid, cseq, "re-REGISTER");
auto st = imsd::sip::Status(m);
if (st == 200) return {m, cseq};
DumpRaw("imsd-register-fail.raw", m);
if (st == 401) throw RefusedError(std::format("re-REGISTER challenged: {}; re-authentication asked, the next registration is a fresh one", imsd::sip::RefusalSummary(m)), 401, imsd::sip::RetryAfter(m).value_or(0));
throw RefusedError(std::format("re-REGISTER refused: {}", imsd::sip::RefusalSummary(m)), *st, imsd::sip::RetryAfter(m).value_or(0));
}
void UpdateRouteAndPpi(const std::string& okMsg) {
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.
2026-10-11 15:58:49 +02:00
// 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;
for (std::size_t i = 0; i < svc.size(); i++) { r += svc[i]; if (i + 1 < svc.size()) r += ", "; }
route_ = r; ctx_.route = r;
Log(std::format("200 Service-Route: {}", r));
}
for (auto p : imsd::sip::Headers(okMsg, "Path"))
Log(std::format("200 Path: {}", p));
auto pau = imsd::sip::Headers(okMsg, "P-Associated-URI");
if (pau.empty()) Log("200: NO P-Associated-URI");
for (auto a : pau)
Log(std::format("200 P-Associated-URI: {}", a));
for (auto a : imsd::sip::Headers(okMsg, "P-Associated-URI")) {
std::size_t lt = a.find("<tel:");
if (lt != std::string_view::npos) {
std::size_t gt = a.find('>', lt);
ppi_ = std::string(a.substr(lt + 1, gt - lt - 1));
ctx_.ppi = ppi_;
break;
}
}
// default public identity for self-targeted requests (reg-event
// SUBSCRIBE): first sip: P-Associated-URI entry, else the tel: one
// (the temporary IMPU is barred for anything but REGISTER)
for (auto a : imsd::sip::Headers(okMsg, "P-Associated-URI")) {
std::size_t lt = a.find("<sip:");
if (lt != std::string_view::npos) {
std::size_t gt = a.find('>', lt);
ctx_.aor = std::string(a.substr(lt + 1, gt - lt - 1));
break;
}
}
if (ctx_.aor.empty() && !ppi_.empty()) ctx_.aor = ppi_;
if (ppi_.empty()) Log("200: no tel: P-Associated-URI — P-Preferred-Identity omitted");
Log(std::format("self AOR: {}", ctx_.aor.empty() ? ctx_.id.impu : ctx_.aor));
}
// USIM AKA via qmicli UIM logical channel.
std::optional<imsd::aka::AkaResult> Authenticate(std::span<const std::uint8_t> rand16, std::span<const std::uint8_t> autn16) {
auto open = RunCapture({"qmicli", "-d", "qrtr://0",
std::format("--uim-open-logical-channel={},{}", reg_.slot, reg_.aid)});
auto ch = CompletedInt(open.out);
if (!ch) return std::nullopt;
int channel = static_cast<int>(*ch);
auto closeCh = [&] {
RunCapture({"qmicli", "-d", "qrtr://0",
std::format("--uim-close-logical-channel={},{}", reg_.slot, channel)});
};
std::string apdu = imsd::aka::BuildAkaApdu(channel, rand16, autn16);
auto r1 = RunCapture({"qmicli", "-d", "qrtr://0",
std::format("--uim-send-apdu={},{},{}", reg_.slot, channel, apdu)});
auto b1 = CompletedHex(r1.out);
if (!b1) { closeCh(); return std::nullopt; }
if (b1->size() >= 2 && b1->substr(0, 2) == "61") {
std::string gr = imsd::aka::BuildGetResponseApdu(channel, std::stoi(b1->substr(2, 2), nullptr, 16));
auto r2 = RunCapture({"qmicli", "-d", "qrtr://0",
std::format("--uim-send-apdu={},{},{}", reg_.slot, channel, gr)});
b1 = CompletedHex(r2.out);
if (!b1) { closeCh(); return std::nullopt; }
}
closeCh();
auto bytes = imsd::util::FromHex(*b1);
if (!bytes) return std::nullopt;
return imsd::aka::ParseAkaResponse(*bytes);
}
// ---- keepalive refresh (only while idle) ------------------------------
void MaybeRefresh() {
if (call_ || reg_.callid.empty()) return;
if (!sip_.Alive()) return; // MaybeReconnect owns dead-flow recovery
long exp = reg_.expiry ? reg_.expiry : 3600;
double interval;
if (const char* o = std::getenv("REFRESH_INTERVAL")) interval = std::atof(o);
else interval = std::max(120.0, std::min(exp * 0.5, 1800.0));
if (Mono() - lastRefresh_ < interval) return;
try {
RefreshPani();
engine, messages, util, imsd: the reregistration carries the last challenge answer and new SA parameters The keepalive refresh and the resume's re-REGISTER carried an initial registration's first-REGISTER Authorization — an empty nonce and response — in the second REGISTER's frame, with a Security-Client naming the SPIs and ports in use. TS 24.229 5.1.1.4.2 has a reregistration carry the Authorization of the last successful authentication — the last received nonce and the last calculated response — and a Security-Client with the new parameter values for the two SA pairs a re-authentication would set up; TS 33.203 7.4.0 keeps the server ports and changes the client ports. O2 UK refused the refresh of the old shape with 503 on every occasion on record (alyx, 2026-09-24 to 10-08, a stationary phone included) and accepted the fresh registrations that followed; KPN accepted the old shape without a challenge. The fresh registration now records the 401's nonce, our cnonce and the response in the registration state (auth_nonce, auth_cnonce, auth_response in the state file); a reregistration sends them verbatim, mints new SPIs and proposes PortUcNext (45063) as the client port with the server port unchanged. A registration resumed from a state file written before this change has no answer: its reregistrations carry the empty digest, as before, until its next fresh registration, and the resume says so. A 401 to a reregistration asks for re-authentication over temporary SAs on the proposed ports (TS 24.229 5.1.1.5.1), which imsd does not implement: the refresh throws it as a refusal and RegRecovery makes the next step the fresh registration, replacing the in-place answer over the old SAs. Tested with the Messages and Engine suites and on the dev phone (KPN) with a build of this commit's tree: a resume from the previous state file, a fresh registration, a resume that sent the stored answer, three refreshes at a 120-s interval, an outgoing call to a bench line and an incoming call's ring.
2026-10-10 21:00:28 +02:00
auto [msg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1);
reg_.cseq = used;
if (auto e = imsd::sip::GrantedExpires(msg)) reg_.expiry = *e;
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.
2026-10-11 15:58:49 +02:00
PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport, pcscf_);
Log(std::format("keepalive re-REGISTER ok (cseq {})", used));
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
OnRegistered();
} catch (const RefusedError& e) {
// The network refused the refresh: the recovery policy counts it
engine, messages, util, imsd: the reregistration carries the last challenge answer and new SA parameters The keepalive refresh and the resume's re-REGISTER carried an initial registration's first-REGISTER Authorization — an empty nonce and response — in the second REGISTER's frame, with a Security-Client naming the SPIs and ports in use. TS 24.229 5.1.1.4.2 has a reregistration carry the Authorization of the last successful authentication — the last received nonce and the last calculated response — and a Security-Client with the new parameter values for the two SA pairs a re-authentication would set up; TS 33.203 7.4.0 keeps the server ports and changes the client ports. O2 UK refused the refresh of the old shape with 503 on every occasion on record (alyx, 2026-09-24 to 10-08, a stationary phone included) and accepted the fresh registrations that followed; KPN accepted the old shape without a challenge. The fresh registration now records the 401's nonce, our cnonce and the response in the registration state (auth_nonce, auth_cnonce, auth_response in the state file); a reregistration sends them verbatim, mints new SPIs and proposes PortUcNext (45063) as the client port with the server port unchanged. A registration resumed from a state file written before this change has no answer: its reregistrations carry the empty digest, as before, until its next fresh registration, and the resume says so. A 401 to a reregistration asks for re-authentication over temporary SAs on the proposed ports (TS 24.229 5.1.1.5.1), which imsd does not implement: the refresh throws it as a refusal and RegRecovery makes the next step the fresh registration, replacing the in-place answer over the old SAs. Tested with the Messages and Engine suites and on the dev phone (KPN) with a build of this commit's tree: a resume from the previous state file, a fresh registration, a resume that sent the stored answer, three refreshes at a 120-s interval, an outgoing call to a bench line and an incoming call's ring.
2026-10-10 21:00:28 +02:00
// — a refusal with a Retry-After of at least a second, or a 401
// (the refresh throws a challenge as a refusal), makes the fresh
// registration the next step, behind any wait the network set; a
// refusal with neither gets one more refresh first, a second
// refusal then escalates — and the flow is dropped so the next
// step runs after the wait.
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
Log(std::format("keepalive re-REGISTER failed: {}", e.what()));
CloseFlowAfterWait();
RecoveryFailed(imsd::engine::RegRecovery::Failure::Refused, e.retryAfter, "keepalive refresh refused", e.status);
} catch (const std::exception& e) {
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
// A dead flow or a silent P-CSCF: the recovery is the same as a
// daemon restart's — drop the flow and let MaybeReconnect re-run
// the connect + true-refresh sequence at once.
Log(std::format("keepalive re-REGISTER failed: {}", e.what()));
sip: log a refused REGISTER or SUBSCRIBE with its status line and reason headers A refused final was logged as its bare status code ("re-REGISTER failed 503", "reg-event SUBSCRIBE -> 500") — the unprotected REGISTER's as "expected 401", without even the code — so a field journal could not say which node refused or what it said. O2 UK answered a phone's 30-minute refresh with 503 twenty-one times over seventy minutes and then refused a fresh registration's reg-event SUBSCRIBE with 500 (alyx, 2026-09-24); the journal holds nothing but the numbers. Log the status line with the headers that carry the reason — Retry-After, Warning, Reason, Server — for a REGISTER's refused final (initial, protected, refresh) and for a refused SUBSCRIBE the run loop reads, and dump the refused message under DUMP_SIP as its own fail dump next to the 200s. To tell a REGISTER's final from what else arrives, the final is now matched by Call-ID and CSeq, and a request that arrives on the client flow while one is awaited is dispatched: a refresh no longer reads such a request as a failed REGISTER. An INVITE among those requests is a call like any other, announced on the bus: a Dial whose reconnect it arrived under meets the call-in-progress rule again before it places its own, and when a resume, a refresh or a reconnect fails the call that arrived under it ends with the flow it came in on (486 while the flow still carries one, and the bus told). The unprotected REGISTER's reply is matched the same way on the init port. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:51:03 +02:00
CloseFlowAfterWait();
}
lastRefresh_ = Mono();
lastKa_ = Mono();
}
// The protected client flow can die under us while the SA and the
// registration stay valid — a CSFB excursion resets it, the P-CSCF drops
// idle flows — which is exactly the state a daemon restart resumes from
// (s51: "restart-to-recover"). Do what the restart does, in place:
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
// reconnect the TCP leg over the existing SA and true-refresh. When that
// keeps failing — the network refuses the refresh, or nothing answers —
// do the rest of what a restart does: register fresh. RegRecovery holds
// the thresholds and the pacing (a dead PDN must not turn this into a
// hot loop; a carrier that refuses fresh SAs must not be hammered).
void MaybeReconnect() {
if (call_ || sip_.Alive() || portPs_ == 0 || reg_.callid.empty()) return;
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
auto plan = recovery_.Next(Boot());
if (plan.at > Boot()) return;
if (plan.step == imsd::engine::RegRecovery::Step::Fresh) FreshRecover();
else ReconnectCycle();
}
// The policy's next step, as the journal line: what, and when.
std::string NextStep() {
auto plan = recovery_.Next(Boot());
double in = plan.at - Boot();
std::string what = plan.step == imsd::engine::RegRecovery::Step::Fresh ? "fresh registration" : "reconnect over the existing SA";
if (in <= 0) return std::format("next: {} now", what);
if (in >= 60) return std::format("next: {} in {:.0f} min", what, in / 60);
return std::format("next: {} in {:.0f} s", what, in);
}
// ModemManager's word on the phone's own service, as ims-pdn-up.sh reads
// it (`mmcli -K`). False only when the modem clearly has none: a state
// short of registered, or packet service detached. Anything else —
// ModemManager not answering, a state or packet-service value this
// code does not know — counts as service: the network's silence, not
// the phone's, is the default.
bool PacketServiceUp() {
auto r = RunCapture({"mmcli", "-m", "any", "-K"});
auto field = [&](std::string_view key) -> std::string {
std::size_t at = r.out.find(key);
if (at == std::string::npos) return {};
std::size_t end = r.out.find('\n', at);
if (end == std::string::npos) end = r.out.size();
std::size_t colon = r.out.find(':', at);
if (colon == std::string::npos || colon > end) return {};
std::string v = r.out.substr(colon + 1, end - colon - 1);
while (!v.empty() && v.front() == ' ') v.erase(v.begin());
while (!v.empty() && (v.back() == ' ' || v.back() == '\r')) v.pop_back();
return v;
};
std::string state = field("modem.generic.state "), ps = field("modem.3gpp.packet-service-state ");
for (const char* down : {"failed", "initializing", "locked", "disabled", "disabling", "enabling", "enabled", "searching"})
if (state == down) return false;
return ps != "detached";
}
// A cycle that failed while the modem reports no packet service is the
// phone's silence, not the network's: not counted, retried on the
// local-refusal pace like a connect() the kernel refused — a radio gap
// across a refresh must not escalate to a fresh registration, which KPN
// refuses while the old one is live, and drops it for.
void CoverageGap(std::string_view what) {
if (registered_) { registered_ = false; EmitStatus(true); }
recovery_.OnLocalRefusal(Boot());
Log(std::format("{} while the modem reports no packet service: not counted; {}", what, NextStep()));
}
void ReconnectCycle() {
using F = imsd::engine::RegRecovery::Failure;
Log("client flow dead; reconnecting");
sip_.Close();
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
if (!sip_.Connect(local_, PortUc, pcscf_, portPs_, transport_, /*tries=*/2)) {
if (AddressGone()) return;
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
if (sip_.LastConnectLocal()) {
if (registered_) { registered_ = false; EmitStatus(true); }
recovery_.OnLocalRefusal(Boot());
return;
}
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
if (!PacketServiceUp()) { CoverageGap("P-CSCF silent"); return; }
RecoveryFailed(F::Silent, 0, "P-CSCF silent");
return;
}
try {
RefreshPani();
engine, messages, util, imsd: the reregistration carries the last challenge answer and new SA parameters The keepalive refresh and the resume's re-REGISTER carried an initial registration's first-REGISTER Authorization — an empty nonce and response — in the second REGISTER's frame, with a Security-Client naming the SPIs and ports in use. TS 24.229 5.1.1.4.2 has a reregistration carry the Authorization of the last successful authentication — the last received nonce and the last calculated response — and a Security-Client with the new parameter values for the two SA pairs a re-authentication would set up; TS 33.203 7.4.0 keeps the server ports and changes the client ports. O2 UK refused the refresh of the old shape with 503 on every occasion on record (alyx, 2026-09-24 to 10-08, a stationary phone included) and accepted the fresh registrations that followed; KPN accepted the old shape without a challenge. The fresh registration now records the 401's nonce, our cnonce and the response in the registration state (auth_nonce, auth_cnonce, auth_response in the state file); a reregistration sends them verbatim, mints new SPIs and proposes PortUcNext (45063) as the client port with the server port unchanged. A registration resumed from a state file written before this change has no answer: its reregistrations carry the empty digest, as before, until its next fresh registration, and the resume says so. A 401 to a reregistration asks for re-authentication over temporary SAs on the proposed ports (TS 24.229 5.1.1.5.1), which imsd does not implement: the refresh throws it as a refusal and RegRecovery makes the next step the fresh registration, replacing the in-place answer over the old SAs. Tested with the Messages and Engine suites and on the dev phone (KPN) with a build of this commit's tree: a resume from the previous state file, a fresh registration, a resume that sent the stored answer, three refreshes at a 120-s interval, an outgoing call to a bench line and an incoming call's ring.
2026-10-10 21:00:28 +02:00
auto [msg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1);
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
reg_.cseq = used;
if (auto e = imsd::sip::GrantedExpires(msg)) reg_.expiry = *e;
UpdateRouteAndPpi(msg);
LogBindings(msg);
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.
2026-10-11 15:58:49 +02:00
PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport, pcscf_);
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
Log("reconnected + re-registered");
OnRegistered();
} catch (const RefusedError& e) {
Log(std::format("reconnect re-REGISTER failed: {}", e.what()));
CloseFlowAfterWait();
RecoveryFailed(F::Refused, e.retryAfter, "refresh refused", e.status);
} catch (const std::exception& e) {
Log(std::format("reconnect re-REGISTER failed: {}", e.what()));
CloseFlowAfterWait();
engine: keep the reg-event subscription alive and act on a terminated binding The reg-event subscription is the network's own account of our binding (TS 24.229 5.1.1.3), and the daemon opened a new dialog for it at every refresh and reconnect and otherwise never looked back: a refused SUBSCRIBE was forgotten, an unanswered one had no timeout, an ended subscription got nothing until the next refresh's new dialog, and a NOTIFY was logged whatever it said. O2 UK, 2026-09-24: a fresh registration's SUBSCRIBE got 500, a NOTIFY arrived all the same and showed our contact active, the flow died eight seconds after the 200 and no connect over the SA succeeded after that; the 500 was logged and nothing was done about it. Keep the subscription alive: retry a refused or unanswered SUBSCRIBE on a backoff, renew one the S-CSCF ends with deactivated, timeout, giveup or probation (RFC 6665 4.1.3; after any other reason, or none, the next refresh subscribes anew), and subscribe anew ahead of the lifetime the S-CSCF granted. A refresh or a reconnect opens no dialog while imsd holds one for the registration or has a retry scheduled. Read the reginfo for our own contact and act on its state: a NOTIFY that terminates our binding schedules a fresh registration — at once for deactivated or expired, after the network's retry-after (up to an hour) for probation, on imsd's own refusal schedule (21 min, growing to 60) for rejected or unregistered (RFC 3680). A hold already running stands, and a REGISTER wait a call kept open that still gets its 200 after such a NOTIFY leaves that registration standing. The parser is in Imsd:Sip and pinned by tests/Sip with the O2 document shapes; the policy is RegRecovery's (OnBindingTerminated, added here with its tests in tests/Engine). Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-10-03 19:36:23 +02:00
// the binding terminated under the wait (the flow closed, or
// under a call the wait timed out): that verdict named the next
// step, this is no silence
if (!recovery_.Reconnectable()) return;
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
if (!PacketServiceUp()) { CoverageGap("refresh unanswered"); return; }
RecoveryFailed(F::Silent, 0, "refresh unanswered");
}
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
}
// A failed reconnect + refresh cycle: the status goes unregistered, the
// policy decides whether the next step is another cycle or a fresh
// registration, and when.
void RecoveryFailed(imsd::engine::RegRecovery::Failure f, long retryAfter, std::string_view what, int status = 0) {
if (registered_) { registered_ = false; EmitStatus(true); }
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
bool wasReconnectable = recovery_.Reconnectable();
recovery_.OnReconnectFailure(Boot(), f, retryAfter, status);
KeepHolds();
Log(std::format("{} ({} refused, {} silent cycles){}; {}", what, recovery_.refusals, recovery_.silentCycles,
wasReconnectable && !recovery_.Reconnectable() ? std::format(" — the old registration is gone ({})", status) : "", NextStep()));
}
// The rest of what a restart does, in place: a new Call-ID, a new
ipsec, imsd: the SA setup deletes by exact key instead of flushing the host's xfrm A fresh registration's SA setup began with `ip xfrm state flush` and `ip xfrm policy flush`: every SA userspace had installed and every SPD policy went, a policy-based VPN's (strongSwan's, libreswan's) SAs and policies included. The setup now takes out what two rules select and leaves the rest; and the SA reader, which took the P-CSCF ports for a resume from any reqid-1/2 `dir out` policy — correct only while the flush left imsd's pair alone in the kernel — takes them from the policies between our two addresses only. Whether imsd runs beside a live strongSwan or libreswan connection is unmeasured: the dev phone shows a tunnel policy and a tunnel state left in place (Tested below), no more. OwnXfrmDeleteCommands (Imsd:Ipsec) builds the deletes from the kernel text the shell reads: every transport-mode policy with reqid 1 or 2 whose selector carries imsd's protected port on the UE side, by the selector ip xfrm policy prints (src, dst, proto, sport, dport, dev) and its direction, and every 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, by src, dst, proto esp and SPI — policy or not: a setup whose policy add failed leaves such states (the setup logs a failed add and goes on), and an ESP state another transport-mode user put on that address with reqid 1 or 2 goes with them (an AH or IPComp state is not selected: the rule asks for ESP). Every reader of the policy text — the deletes and the SA reader's port pass here — goes through one predicate (detail::OwnPolicy), written for whatever reads that text later. BuildSetupCommands loses its two flush lines and the shell runs the deletes right before it (RunOwnXfrmDeletes); BuildTeardownCommands, the same two flushes with no caller, goes. A policy-based VPN's tunnel entries and another transport-mode user's pair on other ports and addresses stay. Residual: a transport-mode policy with reqid 1 and the UE-side port 45061, or reqid 2 and 45062 — alone or paired, between any addresses — carries imsd's fingerprint and goes, with the reqid-1/2 transport-mode ESP states in its direction, and a reqid-1/2 transport-mode ESP state on the UE's ims-PDN address goes whoever made it (tests/Ipsec pins both, and that an AH state stays). Tested: crafter-build test, 7 passed (the 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 the pair untouched; another transport-mode user's pair on other ports and addresses untouched, its reqid-1 ESP state on the UE's address deleted whoever made it, its AH state left; a policy-less state on the UE's address deleted, one between other addresses left; the reader's ports from our pair beside a foreign transport policy with ports); on the dev phone (KPN), in builds of this series, a fresh registration with a VPN's tunnel policy and state in the kernel: both still there afterwards, imsd's previous pair gone.
2026-10-13 22:28:02 +02:00
// challenge, a new SA pair (FreshRegister takes the old ones out), then
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
// the post-registration steps. A refused or throttled attempt waits on
// the throttle schedule (a wait no Dial cuts short — on KPN a refused
// attempt costs the old registration) and a silent one on the
// short backoff. The attempt's flow is closed whichever way it failed;
// past the SA setup the old registration's SAs are gone with it, so
// nothing is retried until the next fresh attempt.
void FreshRecover() {
using F = imsd::engine::RegRecovery::Failure;
Log(std::format("registering fresh (recovery attempt {})", recovery_.freshAttempts + 1));
sip_.Close();
saReplaced_ = false; // this attempt's, whatever fails first
engine: keep the reg-event subscription alive and act on a terminated binding The reg-event subscription is the network's own account of our binding (TS 24.229 5.1.1.3), and the daemon opened a new dialog for it at every refresh and reconnect and otherwise never looked back: a refused SUBSCRIBE was forgotten, an unanswered one had no timeout, an ended subscription got nothing until the next refresh's new dialog, and a NOTIFY was logged whatever it said. O2 UK, 2026-09-24: a fresh registration's SUBSCRIBE got 500, a NOTIFY arrived all the same and showed our contact active, the flow died eight seconds after the 200 and no connect over the SA succeeded after that; the 500 was logged and nothing was done about it. Keep the subscription alive: retry a refused or unanswered SUBSCRIBE on a backoff, renew one the S-CSCF ends with deactivated, timeout, giveup or probation (RFC 6665 4.1.3; after any other reason, or none, the next refresh subscribes anew), and subscribe anew ahead of the lifetime the S-CSCF granted. A refresh or a reconnect opens no dialog while imsd holds one for the registration or has a retry scheduled. Read the reginfo for our own contact and act on its state: a NOTIFY that terminates our binding schedules a fresh registration — at once for deactivated or expired, after the network's retry-after (up to an hour) for probation, on imsd's own refusal schedule (21 min, growing to 60) for rejected or unregistered (RFC 3680). A hold already running stands, and a REGISTER wait a call kept open that still gets its 200 after such a NOTIFY leaves that registration standing. The parser is in Imsd:Sip and pinned by tests/Sip with the O2 document shapes; the policy is RegRecovery's (OnBindingTerminated, added here with its tests in tests/Engine). Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-10-03 19:36:23 +02:00
// the subscription dialog is the registration's and goes with the
// attempt; a retry the network scheduled (its Retry-After) stands
// until a fresh registration replaces the old one; an attempt that
// 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
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.
2026-10-11 15:58:49 +02:00
// 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_));
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;
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
try {
RefreshPani();
FreshRegister();
engine: keep the reg-event subscription alive and act on a terminated binding The reg-event subscription is the network's own account of our binding (TS 24.229 5.1.1.3), and the daemon opened a new dialog for it at every refresh and reconnect and otherwise never looked back: a refused SUBSCRIBE was forgotten, an unanswered one had no timeout, an ended subscription got nothing until the next refresh's new dialog, and a NOTIFY was logged whatever it said. O2 UK, 2026-09-24: a fresh registration's SUBSCRIBE got 500, a NOTIFY arrived all the same and showed our contact active, the flow died eight seconds after the 200 and no connect over the SA succeeded after that; the 500 was logged and nothing was done about it. Keep the subscription alive: retry a refused or unanswered SUBSCRIBE on a backoff, renew one the S-CSCF ends with deactivated, timeout, giveup or probation (RFC 6665 4.1.3; after any other reason, or none, the next refresh subscribes anew), and subscribe anew ahead of the lifetime the S-CSCF granted. A refresh or a reconnect opens no dialog while imsd holds one for the registration or has a retry scheduled. Read the reginfo for our own contact and act on its state: a NOTIFY that terminates our binding schedules a fresh registration — at once for deactivated or expired, after the network's retry-after (up to an hour) for probation, on imsd's own refusal schedule (21 min, growing to 60) for rejected or unregistered (RFC 3680). A hold already running stands, and a REGISTER wait a call kept open that still gets its 200 after such a NOTIFY leaves that registration standing. The parser is in Imsd:Sip and pinned by tests/Sip with the O2 document shapes; the policy is RegRecovery's (OnBindingTerminated, added here with its tests in tests/Engine). Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-10-03 19:36:23 +02:00
subRetryAt_ = 0; // a new registration: its subscription starts afresh
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
OnRegistered();
ok = true;
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
Log("recovered by a fresh registration");
} catch (const ThrottledError& e) {
FreshAttemptFailed(F::Throttled, 0, std::format("fresh registration deferred: {}", e.what()));
} catch (const RefusedError& e) {
FreshAttemptFailed(F::Refused, e.retryAfter, std::format("fresh registration refused: {}", e.what()));
} 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()));
}
}
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.
2026-10-11 15:58:49 +02:00
// 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,
// the requests' Via and Contact, the media leg.
void MoveTo(const std::string& addr) {
server_.CloseAll();
local_ = addr; ctx_.local = addr;
server_.Open(local_);
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
}
// After a failed fresh attempt, in recovery or at bring-up: the
// attempt's flow closed (a refused protected REGISTER leaves it open),
// 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;
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
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<Event>();
ev->type = Event::Type::Fatal; ev->text = why;
PostEvent(std::move(ev));
}
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
// 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
// subscription.
void OnRegistered() {
recovery_.OnSuccess();
engine: keep the reg-event subscription alive and act on a terminated binding The reg-event subscription is the network's own account of our binding (TS 24.229 5.1.1.3), and the daemon opened a new dialog for it at every refresh and reconnect and otherwise never looked back: a refused SUBSCRIBE was forgotten, an unanswered one had no timeout, an ended subscription got nothing until the next refresh's new dialog, and a NOTIFY was logged whatever it said. O2 UK, 2026-09-24: a fresh registration's SUBSCRIBE got 500, a NOTIFY arrived all the same and showed our contact active, the flow died eight seconds after the 200 and no connect over the SA succeeded after that; the 500 was logged and nothing was done about it. Keep the subscription alive: retry a refused or unanswered SUBSCRIBE on a backoff, renew one the S-CSCF ends with deactivated, timeout, giveup or probation (RFC 6665 4.1.3; after any other reason, or none, the next refresh subscribes anew), and subscribe anew ahead of the lifetime the S-CSCF granted. A refresh or a reconnect opens no dialog while imsd holds one for the registration or has a retry scheduled. Read the reginfo for our own contact and act on its state: a NOTIFY that terminates our binding schedules a fresh registration — at once for deactivated or expired, after the network's retry-after (up to an hour) for probation, on imsd's own refusal schedule (21 min, growing to 60) for rejected or unregistered (RFC 3680). A hold already running stands, and a REGISTER wait a call kept open that still gets its 200 after such a NOTIFY leaves that registration standing. The parser is in Imsd:Sip and pinned by tests/Sip with the O2 document shapes; the policy is RegRecovery's (OnBindingTerminated, added here with its tests in tests/Engine). Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-10-03 19:36:23 +02:00
closeFlowWhenIdle_ = false; // this registration's flow stands, whatever a verdict said of the one before
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
KeepHolds();
bool was = registered_;
registered_ = true;
EmitStatus(!was);
lastRefresh_ = Mono(); lastKa_ = Mono();
engine: keep the reg-event subscription alive and act on a terminated binding The reg-event subscription is the network's own account of our binding (TS 24.229 5.1.1.3), and the daemon opened a new dialog for it at every refresh and reconnect and otherwise never looked back: a refused SUBSCRIBE was forgotten, an unanswered one had no timeout, an ended subscription got nothing until the next refresh's new dialog, and a NOTIFY was logged whatever it said. O2 UK, 2026-09-24: a fresh registration's SUBSCRIBE got 500, a NOTIFY arrived all the same and showed our contact active, the flow died eight seconds after the 200 and no connect over the SA succeeded after that; the 500 was logged and nothing was done about it. Keep the subscription alive: retry a refused or unanswered SUBSCRIBE on a backoff, renew one the S-CSCF ends with deactivated, timeout, giveup or probation (RFC 6665 4.1.3; after any other reason, or none, the next refresh subscribes anew), and subscribe anew ahead of the lifetime the S-CSCF granted. A refresh or a reconnect opens no dialog while imsd holds one for the registration or has a retry scheduled. Read the reginfo for our own contact and act on its state: a NOTIFY that terminates our binding schedules a fresh registration — at once for deactivated or expired, after the network's retry-after (up to an hour) for probation, on imsd's own refusal schedule (21 min, growing to 60) for rejected or unregistered (RFC 3680). A hold already running stands, and a REGISTER wait a call kept open that still gets its 200 after such a NOTIFY leaves that registration standing. The parser is in Imsd:Sip and pinned by tests/Sip with the O2 document shapes; the policy is RegRecovery's (OnBindingTerminated, added here with its tests in tests/Engine). Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-10-03 19:36:23 +02:00
// a refresh or a reconnect opens no subscription dialog while imsd
// holds one for the registration or has a retry scheduled; a fresh
// registration (or a start) has none yet, and a fresh attempt drops
// the one it holds before it sends. A retry already scheduled (the
// network's Retry-After, the backoff) is MaybeResubscribe's to send,
// not a registration's to bring forward — nor to start its backoff
// over: that resets with a subscription that starts afresh here, or
// is accepted
if (subCallid_.empty() && !subPending_ && subRetryAt_ == 0) { subRetryDelay_ = 60; SubscribeRegEvent(); }
}
engine: keep the reg-event subscription alive and act on a terminated binding The reg-event subscription is the network's own account of our binding (TS 24.229 5.1.1.3), and the daemon opened a new dialog for it at every refresh and reconnect and otherwise never looked back: a refused SUBSCRIBE was forgotten, an unanswered one had no timeout, an ended subscription got nothing until the next refresh's new dialog, and a NOTIFY was logged whatever it said. O2 UK, 2026-09-24: a fresh registration's SUBSCRIBE got 500, a NOTIFY arrived all the same and showed our contact active, the flow died eight seconds after the 200 and no connect over the SA succeeded after that; the 500 was logged and nothing was done about it. Keep the subscription alive: retry a refused or unanswered SUBSCRIBE on a backoff, renew one the S-CSCF ends with deactivated, timeout, giveup or probation (RFC 6665 4.1.3; after any other reason, or none, the next refresh subscribes anew), and subscribe anew ahead of the lifetime the S-CSCF granted. A refresh or a reconnect opens no dialog while imsd holds one for the registration or has a retry scheduled. Read the reginfo for our own contact and act on its state: a NOTIFY that terminates our binding schedules a fresh registration — at once for deactivated or expired, after the network's retry-after (up to an hour) for probation, on imsd's own refusal schedule (21 min, growing to 60) for rejected or unregistered (RFC 3680). A hold already running stands, and a REGISTER wait a call kept open that still gets its 200 after such a NOTIFY leaves that registration standing. The parser is in Imsd:Sip and pinned by tests/Sip with the O2 document shapes; the policy is RegRecovery's (OnBindingTerminated, added here with its tests in tests/Engine). Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-10-03 19:36:23 +02:00
// Subscribe to the reg event package (TS 24.229 5.1.1.3): one dialog per
// registration, sent over the registration flow; the S-CSCF's immediate
// NOTIFY (logged in DispatchRequest) is the network's own view of our
// bindings. Failure is non-fatal — registration is unaffected.
void SubscribeRegEvent() {
engine: keep the reg-event subscription alive and act on a terminated binding The reg-event subscription is the network's own account of our binding (TS 24.229 5.1.1.3), and the daemon opened a new dialog for it at every refresh and reconnect and otherwise never looked back: a refused SUBSCRIBE was forgotten, an unanswered one had no timeout, an ended subscription got nothing until the next refresh's new dialog, and a NOTIFY was logged whatever it said. O2 UK, 2026-09-24: a fresh registration's SUBSCRIBE got 500, a NOTIFY arrived all the same and showed our contact active, the flow died eight seconds after the 200 and no connect over the SA succeeded after that; the 500 was logged and nothing was done about it. Keep the subscription alive: retry a refused or unanswered SUBSCRIBE on a backoff, renew one the S-CSCF ends with deactivated, timeout, giveup or probation (RFC 6665 4.1.3; after any other reason, or none, the next refresh subscribes anew), and subscribe anew ahead of the lifetime the S-CSCF granted. A refresh or a reconnect opens no dialog while imsd holds one for the registration or has a retry scheduled. Read the reginfo for our own contact and act on its state: a NOTIFY that terminates our binding schedules a fresh registration — at once for deactivated or expired, after the network's retry-after (up to an hour) for probation, on imsd's own refusal schedule (21 min, growing to 60) for rejected or unregistered (RFC 3680). A hold already running stands, and a REGISTER wait a call kept open that still gets its 200 after such a NOTIFY leaves that registration standing. The parser is in Imsd:Sip and pinned by tests/Sip with the O2 document shapes; the policy is RegRecovery's (OnBindingTerminated, added here with its tests in tests/Engine). Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-10-03 19:36:23 +02:00
subRetryAt_ = 0; subRenewAt_ = 0;
subCallid_ = std::format("{}@{}", rng_.Token(16), local_);
std::string sub = imsd::msg::BuildSubscribeReg(ctx_, subCallid_, rng_.Token(8), rng_.Token(16));
engine: keep the reg-event subscription alive and act on a terminated binding The reg-event subscription is the network's own account of our binding (TS 24.229 5.1.1.3), and the daemon opened a new dialog for it at every refresh and reconnect and otherwise never looked back: a refused SUBSCRIBE was forgotten, an unanswered one had no timeout, an ended subscription got nothing until the next refresh's new dialog, and a NOTIFY was logged whatever it said. O2 UK, 2026-09-24: a fresh registration's SUBSCRIBE got 500, a NOTIFY arrived all the same and showed our contact active, the flow died eight seconds after the 200 and no connect over the SA succeeded after that; the 500 was logged and nothing was done about it. Keep the subscription alive: retry a refused or unanswered SUBSCRIBE on a backoff, renew one the S-CSCF ends with deactivated, timeout, giveup or probation (RFC 6665 4.1.3; after any other reason, or none, the next refresh subscribes anew), and subscribe anew ahead of the lifetime the S-CSCF granted. A refresh or a reconnect opens no dialog while imsd holds one for the registration or has a retry scheduled. Read the reginfo for our own contact and act on its state: a NOTIFY that terminates our binding schedules a fresh registration — at once for deactivated or expired, after the network's retry-after (up to an hour) for probation, on imsd's own refusal schedule (21 min, growing to 60) for rejected or unregistered (RFC 3680). A hold already running stands, and a REGISTER wait a call kept open that still gets its 200 after such a NOTIFY leaves that registration standing. The parser is in Imsd:Sip and pinned by tests/Sip with the O2 document shapes; the policy is RegRecovery's (OnBindingTerminated, added here with its tests in tests/Engine). Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-10-03 19:36:23 +02:00
if (sip_.Send(sub)) { Log("reg-event SUBSCRIBE sent"); subPending_ = true; subSentAt_ = Mono(); }
else { Log("reg-event SUBSCRIBE send failed"); subCallid_.clear(); ScheduleResubscribe(0); }
}
// A response on the client flow that belongs to our reg-event
// SUBSCRIBE, wherever the flow is read (the run loop, a REGISTER
// wait). False when it is not ours.
bool OnSubscribeResponse(const std::string& msg, int st) {
if (subCallid_.empty() || imsd::sip::Header(msg, "Call-ID") != subCallid_) return false;
if (!imsd::sip::Header(msg, "CSeq").value_or("").ends_with("SUBSCRIBE")) return false;
if (st >= 300) {
Log(std::format("reg-event SUBSCRIBE refused: {}", imsd::sip::RefusalSummary(msg)));
DumpRaw("imsd-subscribe-fail.raw", msg);
subPending_ = false; subCallid_.clear();
ScheduleResubscribe(imsd::sip::RetryAfter(msg).value_or(0));
} else {
Log(std::format("reg-event SUBSCRIBE -> {}", st));
if (st >= 200) {
DumpRaw("imsd-subscribe-200.raw", msg); subPending_ = false; subRetryDelay_ = 60;
if (auto e = imsd::sip::GrantedExpires(msg)) ArmSubscriptionRenewal(*e);
}
}
return true;
}
// The subscription is part of being registered (TS 24.229 5.1.1.3): a
// refused or unanswered SUBSCRIBE is retried, 60 s doubling to 10 min
// (a Retry-After wins), and a subscription the S-CSCF ends is renewed.
// Until now one failure left the daemon deaf to the network's view of
// its binding — O2 UK 2026-09-24: SUBSCRIBE 500, the registration
// killed 8 s later, nothing heard.
void ScheduleResubscribe(long retryAfter) {
double wait = std::max(subRetryDelay_, static_cast<double>(std::min(retryAfter, imsd::engine::RegRecovery::RetryAfterMax)));
subRetryDelay_ = std::min(subRetryDelay_ * 2, 600.0);
subRetryAt_ = Mono() + wait;
Log(std::format("reg-event: next SUBSCRIBE in {:.0f} s", wait));
}
void MaybeResubscribe() {
if (subPending_ && Mono() - subSentAt_ > 32) { // Timer F: no final at all
Log("reg-event SUBSCRIBE unanswered");
subPending_ = false; subCallid_.clear();
ScheduleResubscribe(0);
}
if (call_ || !registered_ || !sip_.Alive()) return;
if (!subCallid_.empty() && !subPending_ && subRenewAt_ > 0 && Mono() >= subRenewAt_) {
// the granted lifetime is running out: a new dialog (the S-CSCF
// closes the previous one — KPN with noresource), on the same
// registration
Log("reg-event: the subscription nears its expiry; subscribing anew");
subCallid_.clear(); subRenewAt_ = 0;
SubscribeRegEvent();
return;
}
if (subRetryAt_ == 0 || Mono() < subRetryAt_) return;
SubscribeRegEvent();
}
// The network granted the subscription `expires` seconds (a SUBSCRIBE
// 2xx, a NOTIFY's Subscription-State): subscribe anew 10 min before the
// end, or halfway through a shorter grant.
void ArmSubscriptionRenewal(long expires) {
double ahead = expires > 1200 ? expires - 600.0 : expires / 2.0;
subRenewAt_ = Mono() + std::max(ahead, 30.0);
Log(std::format("reg-event: subscription granted {} s; renewed in {:.0f} s", expires, subRenewAt_ - Mono()));
}
// The network's view of our binding (RFC 3680 reginfo), read from any
// reg-event NOTIFY — a subscription an earlier process left behind
// carries the same account (O2, 2026-09-24: the previous dialog's NOTIFY
// reached a restarted daemon whose own SUBSCRIBE had just got 500). A
// binding the registrar terminated needs a new registration: the
// policy decides when (TS 24.229 5.4.1.5, 5.1.1.7 — now for deactivated
// and expired, after retry-after for probation, on the refusal schedule
// for rejected and unregistered). Active or not listed: nothing to do.
// Subscription state is per dialog: only our live dialog's end is ours
// to renew, unless the binding went with it.
void OnRegEventNotify(const std::string& msg, std::string_view body) {
using S = imsd::sip::RegInfoContact::State;
auto c = imsd::sip::RegInfoContactState(body, imsd::msg::ContactUser(ctx_));
bool bindingGone = false;
if (c.state == S::Terminated) {
bindingGone = true;
if (registered_) { registered_ = false; EmitStatus(true); }
// a call lives on the client flow: the flow closes when the
// call is dropped, not under it — the call goes on, or the
// network releases it, and the fresh step follows the call as
// every other recovery step does
if (call_) closeFlowWhenIdle_ = true;
else
sip_.Close();
subCallid_.clear(); subPending_ = false; subRetryAt_ = 0; subRenewAt_ = 0;
recovery_.OnBindingTerminated(Boot(), c.event, c.retryAfter);
KeepHolds();
Log(std::format("reg-event: our binding is terminated ({}); {}", c.event, NextStep()));
} else if (c.state == S::Active) {
Log("reg-event: our binding is active");
}
auto ss = imsd::sip::Header(msg, "Subscription-State");
std::string_view callid = imsd::sip::Header(msg, "Call-ID").value_or("");
bool ours = !subCallid_.empty() && callid == subCallid_;
if (ss && ss->starts_with("active") && ours)
if (auto e = IntAfter(*ss, "expires=")) ArmSubscriptionRenewal(*e);
if (ss && ss->starts_with("terminated") && !ours && !bindingGone) {
Log(std::format("reg-event: an earlier subscription ({}) ended ({}); ours ({}) stands", callid, *ss, subCallid_.empty() ? "none" : subCallid_));
} else if (ss && ss->starts_with("terminated") && !bindingGone) {
// RFC 6665 §4.1.3: renew after deactivated, timeout, giveup and
// probation (after its retry-after, on the 60 s retry floor); not after rejected
// or noresource. KPN's S-CSCF ends a registration's previous
// subscription with noresource when a new one replaces it (dev
// phone 2026-09-25, Call-IDs compared); the 2026-09-24 reading
// that it ends every subscription came from opening a new one
// at every refresh and taking that closure for our own
std::string reason;
if (auto r = ss->find("reason="); r != std::string_view::npos) {
std::string_view v = ss->substr(r + 7);
reason = std::string(v.substr(0, v.find_first_of(";, \t\r")));
}
subCallid_.clear(); subPending_ = false; subRenewAt_ = 0;
if (reason == "deactivated" || reason == "timeout" || reason == "giveup" || reason == "probation") {
Log(std::format("reg-event: subscription ended ({}); renewing", *ss));
ScheduleResubscribe(IntAfter(*ss, "retry-after=").value_or(0));
} else {
Log(std::format("reg-event: subscription ended ({}); the next refresh subscribes anew", *ss));
}
}
}
// ---- command handling -------------------------------------------------
sip: log a refused REGISTER or SUBSCRIBE with its status line and reason headers A refused final was logged as its bare status code ("re-REGISTER failed 503", "reg-event SUBSCRIBE -> 500") — the unprotected REGISTER's as "expected 401", without even the code — so a field journal could not say which node refused or what it said. O2 UK answered a phone's 30-minute refresh with 503 twenty-one times over seventy minutes and then refused a fresh registration's reg-event SUBSCRIBE with 500 (alyx, 2026-09-24); the journal holds nothing but the numbers. Log the status line with the headers that carry the reason — Retry-After, Warning, Reason, Server — for a REGISTER's refused final (initial, protected, refresh) and for a refused SUBSCRIBE the run loop reads, and dump the refused message under DUMP_SIP as its own fail dump next to the 200s. To tell a REGISTER's final from what else arrives, the final is now matched by Call-ID and CSeq, and a request that arrives on the client flow while one is awaited is dispatched: a refresh no longer reads such a request as a failed REGISTER. An INVITE among those requests is a call like any other, announced on the bus: a Dial whose reconnect it arrived under meets the call-in-progress rule again before it places its own, and when a resume, a refresh or a reconnect fails the call that arrived under it ends with the flow it came in on (486 while the flow still carries one, and the bus told). The unprotected REGISTER's reply is matched the same way on the init port. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:51:03 +02:00
// A Dial while a call exists: refused, unless it is an emergency dial,
// which preempts whatever exists (TS 22.101) — released, and abandoned
// if it lingers awaiting a CANCEL's 487 (the late final is ignored by
// Call-ID mismatch). False when the dial was refused.
bool ClearForDial(const Cmd& c, bool emergency) {
if (!call_) return true;
if (!emergency) {
Log(std::format("dial({}) refused: call in progress", c.number));
CallInfo bad{c.uni, c.number, "terminated", "error", NowEpoch(), 0};
PostAdded(bad);
PostState(c.uni, "terminated", "error");
PostDeleted(c.uni);
return false;
}
Log(std::format("emergency dial: preempting call {}", call_->Uni()));
Execute(call_->OnHangup());
MaybeDropCall();
if (call_) { Execute(call_->Abandon("preempted by emergency dial")); MaybeDropCall(); }
return true;
}
void HandleCmd(const Cmd& c) {
if (c.kind == Cmd::Kind::Stop) { quit_.store(true); return; }
if (c.kind == Cmd::Kind::Dial) {
bool emergency = imsd::msg::IsEmergency(c.number, emergencyExtra_);
sip: log a refused REGISTER or SUBSCRIBE with its status line and reason headers A refused final was logged as its bare status code ("re-REGISTER failed 503", "reg-event SUBSCRIBE -> 500") — the unprotected REGISTER's as "expected 401", without even the code — so a field journal could not say which node refused or what it said. O2 UK answered a phone's 30-minute refresh with 503 twenty-one times over seventy minutes and then refused a fresh registration's reg-event SUBSCRIBE with 500 (alyx, 2026-09-24); the journal holds nothing but the numbers. Log the status line with the headers that carry the reason — Retry-After, Warning, Reason, Server — for a REGISTER's refused final (initial, protected, refresh) and for a refused SUBSCRIBE the run loop reads, and dump the refused message under DUMP_SIP as its own fail dump next to the 200s. To tell a REGISTER's final from what else arrives, the final is now matched by Call-ID and CSeq, and a request that arrives on the client flow while one is awaited is dispatched: a refresh no longer reads such a request as a failed REGISTER. An INVITE among those requests is a call like any other, announced on the bus: a Dial whose reconnect it arrived under meets the call-in-progress rule again before it places its own, and when a resume, a refresh or a reconnect fails the call that arrived under it ends with the flow it came in on (486 while the flow still carries one, and the bus told). The unprotected REGISTER's reply is matched the same way on the init port. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:51:03 +02:00
if (!ClearForDial(c, emergency)) return;
// a dead client flow fails the INVITE instantly — give the
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
// recovery path one immediate shot first: user action cuts the
// backoff short, never a wait the network imposed (a
// Retry-After, the schedule after a refused or throttled fresh
engine: keep the reg-event subscription alive and act on a terminated binding The reg-event subscription is the network's own account of our binding (TS 24.229 5.1.1.3), and the daemon opened a new dialog for it at every refresh and reconnect and otherwise never looked back: a refused SUBSCRIBE was forgotten, an unanswered one had no timeout, an ended subscription got nothing until the next refresh's new dialog, and a NOTIFY was logged whatever it said. O2 UK, 2026-09-24: a fresh registration's SUBSCRIBE got 500, a NOTIFY arrived all the same and showed our contact active, the flow died eight seconds after the 200 and no connect over the SA succeeded after that; the 500 was logged and nothing was done about it. Keep the subscription alive: retry a refused or unanswered SUBSCRIBE on a backoff, renew one the S-CSCF ends with deactivated, timeout, giveup or probation (RFC 6665 4.1.3; after any other reason, or none, the next refresh subscribes anew), and subscribe anew ahead of the lifetime the S-CSCF granted. A refresh or a reconnect opens no dialog while imsd holds one for the registration or has a retry scheduled. Read the reginfo for our own contact and act on its state: a NOTIFY that terminates our binding schedules a fresh registration — at once for deactivated or expired, after the network's retry-after (up to an hour) for probation, on imsd's own refusal schedule (21 min, growing to 60) for rejected or unregistered (RFC 3680). A hold already running stands, and a REGISTER wait a call kept open that still gets its 200 after such a NOTIFY leaves that registration standing. The parser is in Imsd:Sip and pinned by tests/Sip with the O2 document shapes; the policy is RegRecovery's (OnBindingTerminated, added here with its tests in tests/Engine). Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-10-03 19:36:23 +02:00
// attempt, the one after a binding the network rejected or
// unregistered, probation's retry-after — the policy keeps those).
// The shot's REGISTER wait dispatches
sip: log a refused REGISTER or SUBSCRIBE with its status line and reason headers A refused final was logged as its bare status code ("re-REGISTER failed 503", "reg-event SUBSCRIBE -> 500") — the unprotected REGISTER's as "expected 401", without even the code — so a field journal could not say which node refused or what it said. O2 UK answered a phone's 30-minute refresh with 503 twenty-one times over seventy minutes and then refused a fresh registration's reg-event SUBSCRIBE with 500 (alyx, 2026-09-24); the journal holds nothing but the numbers. Log the status line with the headers that carry the reason — Retry-After, Warning, Reason, Server — for a REGISTER's refused final (initial, protected, refresh) and for a refused SUBSCRIBE the run loop reads, and dump the refused message under DUMP_SIP as its own fail dump next to the 200s. To tell a REGISTER's final from what else arrives, the final is now matched by Call-ID and CSeq, and a request that arrives on the client flow while one is awaited is dispatched: a refresh no longer reads such a request as a failed REGISTER. An INVITE among those requests is a call like any other, announced on the bus: a Dial whose reconnect it arrived under meets the call-in-progress rule again before it places its own, and when a resume, a refresh or a reconnect fails the call that arrived under it ends with the flow it came in on (486 while the flow still carries one, and the bus told). The unprotected REGISTER's reply is matched the same way on the init port. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:51:03 +02:00
// requests, so an incoming call may have arrived under it: the
// same rule again.
if (!sip_.Alive()) {
engine: register fresh when the refresh is refused or nothing answers After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:56:34 +02:00
recovery_.OnDial(); MaybeReconnect();
sip: log a refused REGISTER or SUBSCRIBE with its status line and reason headers A refused final was logged as its bare status code ("re-REGISTER failed 503", "reg-event SUBSCRIBE -> 500") — the unprotected REGISTER's as "expected 401", without even the code — so a field journal could not say which node refused or what it said. O2 UK answered a phone's 30-minute refresh with 503 twenty-one times over seventy minutes and then refused a fresh registration's reg-event SUBSCRIBE with 500 (alyx, 2026-09-24); the journal holds nothing but the numbers. Log the status line with the headers that carry the reason — Retry-After, Warning, Reason, Server — for a REGISTER's refused final (initial, protected, refresh) and for a refused SUBSCRIBE the run loop reads, and dump the refused message under DUMP_SIP as its own fail dump next to the 200s. To tell a REGISTER's final from what else arrives, the final is now matched by Call-ID and CSeq, and a request that arrives on the client flow while one is awaited is dispatched: a refresh no longer reads such a request as a failed REGISTER. An INVITE among those requests is a call like any other, announced on the bus: a Dial whose reconnect it arrived under meets the call-in-progress rule again before it places its own, and when a resume, a refresh or a reconnect fails the call that arrived under it ends with the flow it came in on (486 while the flow still carries one, and the bus told). The unprotected REGISTER's reply is matched the same way on the init port. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:51:03 +02:00
if (!ClearForDial(c, emergency)) return;
}
RefreshPani();
if (emergency)
Log(std::format("EMERGENCY dial ({}): urn:service:sos over the live registration, "
"fallback plain INVITE — stage 1, no emergency registration/PDN; "
"carrier-side behavior UNVERIFIED", c.number));
call_.emplace(ctx_, rng_, c.uni, c.number, rtpPort_, precond_, emergency, codecs_);
CallInfo info{c.uni, c.number, "dialing", "outgoing", NowEpoch(), 0};
PostAdded(info);
bool ok = Execute(call_->Start());
if (!ok && call_ && !call_->Terminated()) { Execute(call_->Fail("error")); }
MaybeDropCall();
} else if (c.kind == Cmd::Kind::HangUp) {
if (call_ && call_->Uni() == c.uni) { Execute(call_->OnHangup()); MaybeDropCall(); }
} else if (c.kind == Cmd::Kind::Accept) {
if (call_ && call_->Uni() == c.uni) { Execute(call_->OnAccept()); MaybeDropCall(); }
else Log(std::format("Accept({}) ignored: no such ringing call", c.uni));
}
}
void DispatchRequest(const std::string& msg) {
std::string callid(imsd::sip::Header(msg, "Call-ID").value_or(""));
std::size_t sp = msg.find(' ');
std::string method = msg.substr(0, sp == std::string::npos ? 0 : sp);
if (call_ && callid == call_->CallId()) {
callReply_ = reply_; // freshest channel for this dialog
Execute(call_->OnRequest(msg));
MaybeDropCall();
return;
}
// ACK is never answered — e.g. the caller ACKing the 486/487 of a
// call machine we already dropped
if (method == "ACK") return;
// reg-event NOTIFY: log the S-CSCF's reginfo (every binding it holds
// for the implicit set, feature tags as stored), then 200 it below.
// MESSAGE (terminating SMS-over-IP while +g.3gpp.smsip is
// advertised): log the whole body — the 200 acks delivery, so the
// journal is the only place the payload survives until real SMSoIP
// handling exists.
if (method == "NOTIFY" || method == "MESSAGE") {
std::size_t at = msg.find("\r\n\r\n");
std::string_view body = at == std::string::npos
? std::string_view{}
: std::string_view(msg).substr(at + 4);
if (method == "MESSAGE") Log(std::format("MESSAGE from {} ({} bytes):\n{}", imsd::sip::CallerId(msg), body.size(), body));
engine: keep the reg-event subscription alive and act on a terminated binding The reg-event subscription is the network's own account of our binding (TS 24.229 5.1.1.3), and the daemon opened a new dialog for it at every refresh and reconnect and otherwise never looked back: a refused SUBSCRIBE was forgotten, an unanswered one had no timeout, an ended subscription got nothing until the next refresh's new dialog, and a NOTIFY was logged whatever it said. O2 UK, 2026-09-24: a fresh registration's SUBSCRIBE got 500, a NOTIFY arrived all the same and showed our contact active, the flow died eight seconds after the 200 and no connect over the SA succeeded after that; the 500 was logged and nothing was done about it. Keep the subscription alive: retry a refused or unanswered SUBSCRIBE on a backoff, renew one the S-CSCF ends with deactivated, timeout, giveup or probation (RFC 6665 4.1.3; after any other reason, or none, the next refresh subscribes anew), and subscribe anew ahead of the lifetime the S-CSCF granted. A refresh or a reconnect opens no dialog while imsd holds one for the registration or has a retry scheduled. Read the reginfo for our own contact and act on its state: a NOTIFY that terminates our binding schedules a fresh registration — at once for deactivated or expired, after the network's retry-after (up to an hour) for probation, on imsd's own refusal schedule (21 min, growing to 60) for rejected or unregistered (RFC 3680). A hold already running stands, and a REGISTER wait a call kept open that still gets its 200 after such a NOTIFY leaves that registration standing. The parser is in Imsd:Sip and pinned by tests/Sip with the O2 document shapes; the policy is RegRecovery's (OnBindingTerminated, added here with its tests in tests/Engine). Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-10-03 19:36:23 +02:00
else if (auto ev = imsd::sip::Header(msg, "Event"); ev && ev->starts_with("reg")) {
Log(std::format("reg-event NOTIFY ({} bytes):\n{}", body.size(), body));
if (reply_) reply_(imsd::msg::BuildResponse200(ctx_, msg));
OnRegEventNotify(msg, body);
return;
}
if (reply_) reply_(imsd::msg::BuildResponse200(ctx_, msg));
return;
}
if (method == "INVITE") {
OnIncomingInvite(msg);
return;
}
if (reply_)
reply_(imsd::msg::BuildResponse200(ctx_, msg)); // unsolicited (OPTIONS etc.)
}
// An out-of-dialog INVITE: a terminating (incoming) call.
void OnIncomingInvite(const std::string& msg) {
if (call_) {
Log("incoming INVITE while a call exists: 486");
if (reply_) reply_(imsd::msg::BuildInviteResponse(ctx_, msg, 486, "Busy Here", rng_.Token(10)));
return;
}
std::string uni = std::format("ims-call-{}", ++callSeq_);
DumpRaw("imsd-invite-in.raw", msg);
call_.emplace(ctx_, rng_, uni, imsd::engine::IncomingInvite{msg}, rtpPort_, codecs_);
callReply_ = reply_;
Log(std::format("incoming call {} from {}", uni, call_->Number()));
// Validate the offer BEFORE the UI hears of the call. A refused
// INVITE (488: nothing we can play) is answered and dropped without
// ever announcing it — announcing first produced a 24 ms
// added/terminated/deleted burst that left Plasma Dialer stuck as a
// lock-screen overlay with no call to show (field report 2026-09-01).
// The journal keeps the record: caller and reason are logged above
// and by the engine's teardown line.
auto actions = call_->OnInvite();
if (call_->Terminated()) {
Execute(actions, /*announce=*/false);
MaybeDropCall();
return;
}
CallInfo info{uni, call_->Number(), "incoming", "incoming", NowEpoch(), 0,
"incoming"};
PostAdded(info);
Execute(actions);
MaybeDropCall();
}
// ---- action execution -------------------------------------------------
// Returns false if a client-flow send failed (used to fail a dial's INVITE).
// `announce` false suppresses the D-Bus state/deleted events — for a call
// that was never announced (refused before ringing), so the bus never
// sees a call it cannot show.
bool Execute(const std::vector<imsd::engine::Action>& actions, bool announce = true) {
using T = imsd::engine::Action::Type;
bool sendOk = true;
for (const auto& a : actions) {
switch (a.type) {
case T::SendClient:
2026-09-08 20:37:47 +02:00
// the outgoing INVITE is the one request a field log could
// never show (DUMP_SIP dumped inbound messages only)
if (a.text.starts_with("INVITE ")) DumpRaw("imsd-invite-out.raw", a.text);
if (!sip_.Send(a.text)) { sendOk = false; Log("client send failed"); }
break;
case T::SendResponse:
if (callReply_) callReply_(a.text);
else if (reply_) reply_(a.text);
break;
case T::StartMedia: StartMedia(a.media); break;
case T::StopMedia: StopMedia(); break;
case T::State:
if (announce) PostState(call_->Uni(), a.state, a.reason);
break;
case T::Deleted:
if (announce) PostDeleted(call_->Uni());
dropCall_ = true;
break;
case T::SetDeadline: deadline_ = Mono() + a.seconds; break;
case T::Log: Log(a.text); break;
}
}
return sendOk;
}
void MaybeDropCall() {
engine: keep the reg-event subscription alive and act on a terminated binding The reg-event subscription is the network's own account of our binding (TS 24.229 5.1.1.3), and the daemon opened a new dialog for it at every refresh and reconnect and otherwise never looked back: a refused SUBSCRIBE was forgotten, an unanswered one had no timeout, an ended subscription got nothing until the next refresh's new dialog, and a NOTIFY was logged whatever it said. O2 UK, 2026-09-24: a fresh registration's SUBSCRIBE got 500, a NOTIFY arrived all the same and showed our contact active, the flow died eight seconds after the 200 and no connect over the SA succeeded after that; the 500 was logged and nothing was done about it. Keep the subscription alive: retry a refused or unanswered SUBSCRIBE on a backoff, renew one the S-CSCF ends with deactivated, timeout, giveup or probation (RFC 6665 4.1.3; after any other reason, or none, the next refresh subscribes anew), and subscribe anew ahead of the lifetime the S-CSCF granted. A refresh or a reconnect opens no dialog while imsd holds one for the registration or has a retry scheduled. Read the reginfo for our own contact and act on its state: a NOTIFY that terminates our binding schedules a fresh registration — at once for deactivated or expired, after the network's retry-after (up to an hour) for probation, on imsd's own refusal schedule (21 min, growing to 60) for rejected or unregistered (RFC 3680). A hold already running stands, and a REGISTER wait a call kept open that still gets its 200 after such a NOTIFY leaves that registration standing. The parser is in Imsd:Sip and pinned by tests/Sip with the O2 document shapes; the policy is RegRecovery's (OnBindingTerminated, added here with its tests in tests/Engine). Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-10-03 19:36:23 +02:00
if (!dropCall_) return;
call_.reset(); callReply_ = nullptr; dropCall_ = false;
if (closeFlowWhenIdle_) {
closeFlowWhenIdle_ = false;
Log("the call is over: closing the flow of the binding the network terminated under it");
sip_.Close();
}
}
void StartMedia(const imsd::engine::MediaLeg& leg) {
if (!leg.Valid()) { Log("SDP answer missing media endpoint; no media leg"); return; }
Log(std::format("media {} pt={} octet={} -> [{}]:{}", leg.codec, leg.payloadType, leg.octetAlign, leg.remoteIp, leg.remotePort));
if (Is6(leg.remoteIp)) RunCmd({"ip", "-6", "route", "replace", leg.remoteIp, "dev", dev_});
else
RunCmd({"ip", "route", "replace", leg.remoteIp, "dev", dev_});
std::string outBase = std::format("{}/dialer_{}", outDir_, call_ ? call_->Uni() : std::string("x"));
std::vector<std::string> argv = {
mediaBin_, local_, std::to_string(rtpPort_), leg.remoteIp,
std::to_string(leg.remotePort), std::to_string(leg.payloadType),
"86400", outBase};
AudioSession session;
if (geteuid() == 0) {
session = ResolveAudioSession();
Log(AudioSessionLine(session) + (session.name.empty() ? "; this call has no audio" : ""));
}
pid_t pid = fork();
if (pid == 0) {
if (geteuid() == 0) {
// no session is said with an empty value: imsd-media reads a
// missing variable as a caller older than 0.3.10
setenv("AUDIO_USER", session.name.c_str(), 1);
}
setenv("MIC", EnvOr("MIC", "1").c_str(), 1);
setenv("PLAY", EnvOr("PLAY", "1").c_str(), 1);
setenv("GAIN", EnvOr("GAIN", "10").c_str(), 1);
setenv("PLAY_GAIN", EnvOr("PLAY_GAIN", "1.0").c_str(), 1);
setenv("AMR_MODE", EnvOr("AMR_MODE", "2").c_str(), 1);
setenv("DTX", EnvOr("DTX", "0").c_str(), 1);
setenv("OCTET_ALIGN", leg.octetAlign ? "1" : "0", 1);
media: play AMR narrowband and G.711 as well as AMR-WB imsd-media spoke exactly one codec, AMR-WB. A landline caller reaches the IMS core through the PSTN gateway, which offers narrowband — AMR (NB) and/or G.711 — so with the engine now accepting those offers the media leg has to play them. CODEC (set by the daemon from the negotiated SDP) selects AMR-WB (the default, unchanged), AMR, PCMA or PCMU. AMR narrowband rides the same RFC 4867 payload code as AMR-WB with its own frame-size table (RFC 4867 table 1) and libopencore-amrnb dlopen'd like the wideband pair — same package as the AMR-WB decoder, no new dependency; AMR_MODE defaults to 7 (12.2 kbit/s) for it. G.711 is the ITU-T table codec, raw samples in the payload, digital zero as keepalive. The narrowband path runs pw-record/pw-play at 8 kHz and steps the RTP clock by 160 per frame. Two test seams so the leg can be driven against a synthetic RTP peer with no PipeWire and no network: MIC_SRC=<file> feeds raw PCM through the encoder in real time instead of pw-record, PCM_DUMP=1 writes the decoded downlink to <out>.pcm. --selftest now covers both AMR tables (both payload formats) and G.711 (digital zero, idempotence over the full 16-bit range, 1 kHz sine SNR >= 30 dB for both laws). Verified on the workstation with a Python gateway stand-in for PCMA, PCMU and AMR (octet-aligned and bandwidth-efficient): uplink RTP shape (pt, seq, ts step 160, payload sizes 160 / 33 / 32) and a 440 Hz mic tone recovered from our packets by an independent decoder; a 1 kHz gateway tone recovered from our decoded downlink. The AMR-WB default path keeps its legacy keepalive shape (ts step 320, FT0 payloads 19/18 bytes).
2026-09-02 03:18:58 +02:00
setenv("CODEC", leg.codec.c_str(), 1);
std::vector<char*> c;
for (auto& s : argv) c.push_back(const_cast<char*>(s.c_str()));
c.push_back(nullptr);
execvp(c[0], c.data());
_exit(127);
}
mediaPid_ = pid;
}
void StopMedia() {
if (mediaPid_ <= 0) return;
kill(mediaPid_, SIGTERM);
for (int i = 0; i < 100; i++) {
if (waitpid(mediaPid_, nullptr, WNOHANG) != 0) { mediaPid_ = -1; return; }
usleep(100000);
}
kill(mediaPid_, SIGKILL);
waitpid(mediaPid_, nullptr, 0);
mediaPid_ = -1;
}
void ReapMedia() {
if (!call_ || mediaPid_ <= 0) return;
if (call_->State() != imsd::engine::CallState::Active) return;
int status = 0;
pid_t r = waitpid(mediaPid_, &status, WNOHANG);
if (r == 0) return; // still running
int code = WIFEXITED(status) ? WEXITSTATUS(status) : -1;
mediaPid_ = -1; // reaped; don't re-terminate
Execute(call_->OnMediaExit(code));
MaybeDropCall();
}
// ---- events -----------------------------------------------------------
void EmitStatus(bool registrationSignal) {
auto ev = std::make_unique<Event>();
ev->type = registrationSignal ? Event::Type::Registration : Event::Type::Status;
ev->status = StatusSnapshot{registered_, local_, pcscf_, ppi_, reg_.expiry, reg_.cseq};
PostEvent(std::move(ev));
}
void PostAdded(const CallInfo& info) {
auto ev = std::make_unique<Event>(); ev->type = Event::Type::Added; ev->info = info;
PostEvent(std::move(ev));
}
void PostState(const std::string& uni, const std::string& state, const std::string& reason) {
auto ev = std::make_unique<Event>(); ev->type = Event::Type::State;
ev->uni = uni; ev->state = state; ev->reason = reason;
PostEvent(std::move(ev));
}
void PostDeleted(const std::string& uni) {
auto ev = std::make_unique<Event>(); ev->type = Event::Type::Deleted; ev->uni = uni;
PostEvent(std::move(ev));
}
};
Engine* TheEngine = nullptr;
// ================= GDBus ===================================================
constexpr const char* IntrospectionXml = R"xml(
<node>
<interface name="net.catcrafts.IMS1">
<method name="Dial">
<arg type="s" name="number" direction="in"/>
<arg type="s" name="callUni" direction="out"/>
</method>
<method name="HangUp"><arg type="s" name="callUni" direction="in"/></method>
<method name="Accept"><arg type="s" name="callUni" direction="in"/></method>
<method name="SendDtmf">
<arg type="s" name="callUni" direction="in"/>
<arg type="s" name="tones" direction="in"/>
</method>
<method name="GetCalls"><arg type="aa{sv}" name="calls" direction="out"/></method>
<method name="GetStatus"><arg type="a{sv}" name="status" direction="out"/></method>
<signal name="CallAdded"><arg type="s" name="callUni"/><arg type="a{sv}" name="info"/></signal>
<signal name="CallStateChanged">
<arg type="s" name="callUni"/><arg type="s" name="state"/><arg type="s" name="reason"/>
</signal>
<signal name="CallDeleted"><arg type="s" name="callUni"/></signal>
<signal name="RegistrationChanged"><arg type="b" name="registered"/></signal>
</interface>
</node>)xml";
void HandleMethodCall(GDBusConnection*, const gchar*, const gchar*, const gchar*, const gchar* method, GVariant* params, GDBusMethodInvocation* inv, gpointer) {
std::string_view m = method;
if (m == "Dial") {
const gchar* number = nullptr;
g_variant_get(params, "(&s)", &number);
std::string uni = TheEngine->Dial(number ? number : "");
g_dbus_method_invocation_return_value(inv, g_variant_new("(s)", uni.c_str()));
return;
}
if (m == "HangUp") {
const gchar* uni = nullptr;
g_variant_get(params, "(&s)", &uni);
TheEngine->HangUp(uni ? uni : "");
g_dbus_method_invocation_return_value(inv, nullptr);
return;
}
if (m == "Accept") {
const gchar* uni = nullptr;
g_variant_get(params, "(&s)", &uni);
TheEngine->Accept(uni ? uni : "");
g_dbus_method_invocation_return_value(inv, nullptr);
return;
}
if (m == "SendDtmf") {
Log("SendDtmf accepted but not yet sent (TODO RFC4733)");
g_dbus_method_invocation_return_value(inv, nullptr);
return;
}
if (m == "GetCalls") {
GVariantBuilder b;
g_variant_builder_init(&b, G_VARIANT_TYPE("aa{sv}"));
for (auto& [uni, info] : Calls)
g_variant_builder_add_value(&b, CallVariant(info));
g_dbus_method_invocation_return_value(inv, g_variant_new("(aa{sv})", &b));
return;
}
if (m == "GetStatus") {
GVariantBuilder b;
g_variant_builder_init(&b, G_VARIANT_TYPE("a{sv}"));
g_variant_builder_add(&b, "{sv}", "registered", g_variant_new_boolean(StatusSnap.registered));
g_variant_builder_add(&b, "{sv}", "local", g_variant_new_string(StatusSnap.local.c_str()));
g_variant_builder_add(&b, "{sv}", "pcscf", g_variant_new_string(StatusSnap.pcscf.c_str()));
g_variant_builder_add(&b, "{sv}", "expiry", g_variant_new_int64(StatusSnap.expiry));
g_variant_builder_add(&b, "{sv}", "cseq", g_variant_new_int64(StatusSnap.cseq));
g_variant_builder_add(&b, "{sv}", "ppi", g_variant_new_string(StatusSnap.ppi.c_str()));
g_dbus_method_invocation_return_value(inv, g_variant_new("(a{sv})", &b));
return;
}
g_dbus_method_invocation_return_dbus_error(inv, "org.freedesktop.DBus.Error.UnknownMethod", "no such method");
}
bool IsRead(std::string_view method) { return method == "GetStatus" || method == "GetCalls"; }
constexpr char ErrAccessDenied[] = "org.freedesktop.DBus.Error.AccessDenied";
const GDBusInterfaceVTable Vtable = { imsd::bus::Gated<HandleMethodCall, IsRead, ErrAccessDenied, Log>, nullptr, nullptr, {} };
void OnBusAcquired(GDBusConnection* conn, const gchar*, gpointer) {
DbusConn = conn;
GDBusNodeInfo* node = g_dbus_node_info_new_for_xml(IntrospectionXml, nullptr);
g_dbus_connection_register_object(conn, ObjPath, node->interfaces[0], &Vtable, nullptr, nullptr, nullptr);
g_dbus_node_info_unref(node);
Log(std::format("object registered at {}", ObjPath));
}
gboolean OnTerm(gpointer loop) {
Log("SIGTERM — stopping engine (SA kept)");
if (TheEngine) TheEngine->Stop();
g_timeout_add_seconds(2, [](gpointer l) -> gboolean {
g_main_loop_quit(static_cast<GMainLoop*>(l)); return G_SOURCE_REMOVE; }, loop);
return G_SOURCE_REMOVE;
}
} // namespace
int main(int argc, char** argv) {
bool session = false;
for (int i = 1; i < argc; i++)
if (std::string_view(argv[i]) == "--session") session = true;
if (argv[0]) {
std::string p = argv[0];
auto slash = p.find_last_of('/');
if (slash != std::string::npos) SelfDir = p.substr(0, slash);
}
if (!session && geteuid() != 0) {
std::println(std::cerr, "imsd: must run as root (xfrm / qmicli)");
return 1;
}
std::string dev = EnvOr("DEV", "qmapmux0.0");
std::string local = EnvOr("LOCAL", "");
if (local.empty()) {
if (auto l = DetectLocal(dev)) local = *l;
}
if (local.empty() && !session) {
std::println(std::cerr, "imsd: no global IPv6 on {} — is the ims PDN up?", dev);
return 1;
}
local = imsd::util::CanonicalAddr(local);
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;
Engine engine;
engine.SetLocal(local);
engine.SetDevMode(session);
TheEngine = &engine;
MainLoop = g_main_loop_new(nullptr, FALSE);
guint owner = g_bus_own_name(
session ? G_BUS_TYPE_SESSION : G_BUS_TYPE_SYSTEM, BusName,
G_BUS_NAME_OWNER_FLAGS_NONE, OnBusAcquired,
[](GDBusConnection*, const gchar* name, gpointer) { Log(std::format("owning {}", name)); },
[](GDBusConnection*, const gchar* name, gpointer lp) {
Log(std::format("lost {} — exiting", name));
g_main_loop_quit(static_cast<GMainLoop*>(lp));
},
MainLoop, nullptr);
g_unix_signal_add(SIGTERM, OnTerm, MainLoop);
g_unix_signal_add(SIGINT, OnTerm, MainLoop);
// Run the engine on a pthread with an explicit 8 MiB stack: musl's default
// thread stack is only 128 KiB, and the registration paths hold 64 KiB SIP
// recv buffers on the stack (a std::thread here segfaults on entry). The
// registration is bus-independent, so start it immediately.
pthread_t engineTid{};
pthread_attr_t attr;
pthread_attr_init(&attr);
pthread_attr_setstacksize(&attr, 8 * 1024 * 1024);
pthread_create(&engineTid, &attr, [](void*) -> void* { TheEngine->Run(); return nullptr; }, nullptr);
pthread_attr_destroy(&attr);
Log(std::format("starting on the {} bus", session ? "session" : "system"));
g_main_loop_run(MainLoop);
engine.Stop();
pthread_join(engineTid, nullptr);
g_bus_unown_name(owner);
g_main_loop_unref(MainLoop);
sip: UDP for the protected leg, TCP first with a fallback imsd's protected leg (the second REGISTER and everything after it) was TCP only. A P-CSCF that never answers the TCP connect on its protected server port (O2 UK: the SYNs leave ESP-protected, nothing comes back) left the unit looping with no way forward, although the SAs, the listener sockets and the firewall rule already covered UDP. The client flow now carries a transport. UDP binds the same protected client port and connect()s the datagram socket to the P-CSCF's protected server port, so the kernel delivers that peer's datagrams to it ahead of the unconnected listener on the same port. One datagram is one message (RFC 3261 18.3: a Content-Length that fits truncates, one that does not fit discards the datagram, none means the rest of the datagram); a receive error is logged and marks the flow dead; a message over the single-packet ESP budget at the ims PDN's MTU is logged once per flow, since the kernel fragments it and a P-CSCF may drop the fragments. Every protected request's Via follows the transport; the challenge stays UDP. SIP_TRANSPORT selects the policy: auto (default) registers over TCP and, after two unanswered connects, registers again over UDP (new challenge, new SA pair) - but only on a phone whose state file does not record a successful TCP registration, so an outage on a TCP carrier never becomes a second initial REGISTER; a phone whose last registration was UDP goes straight to UDP. tcp and udp force one. A connect refused locally (EADDRNOTAVAIL: the 4-tuple still in TIME_WAIT from the previous flow) keeps retrying and is not counted as silence; SO_ERROR results are logged by name. The registration's transport is persisted so a warm resume reconnects the same way, and the resume gives up after two silences. Two things the change exposed and fixes: an engine-fatal event exited 0 ("exiting for systemd restart" with Restart=on-failure never firing), and a fresh registration refused with the P-CSCF's fresh-SA throttle (Security-Server spi-s=0) must not be retried by a 120-s restart loop, since every attempt re-arms the ~20-min window - it is now waited out in-process, growing on repeats, with D-Bus commands still served. A resume the network refuses falls through to a fresh registration. A retransmitted 200 OK to our INVITE is ACKed again and no longer starts a second media leg. Verified on KPN: resume over TCP after a binary swap, MT and MO calls with media both ways, the throttle deferral and its self-recovery, the UDP client path up to KPN dropping the datagram. The UDP success path is a field test; SIP retransmission timers over UDP are not in this change.
2026-09-17 14:58:46 +02:00
return FatalExit ? 1 : 0;
}