From 46bf363915b41c718d557c71a3647fd03110f363 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Thu, 24 Sep 2026 18:51:03 +0200 Subject: [PATCH 01/15] sip: log a refused REGISTER or SUBSCRIBE with its status line and reason headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 2 +- implementations/main.cpp | 177 +++++++++++++++++++++++++++++---------- interfaces/Imsd-Sip.cppm | 33 ++++++++ tests/Sip/main.cpp | 45 ++++++++++ 4 files changed, 212 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 826eb6a..49a9176 100644 --- a/README.md +++ b/README.md @@ -281,7 +281,7 @@ PCSCF=2001:db8::105 | `RTP_PORT` | `50004` | local RTP port advertised in SDP | | `PRECOND` | `0` | `1` offers SDP QoS preconditions | | `CODECS` | *(empty — defaults)* | comma-separated codec preference list over `AMR-WB`, `AMR` (or `AMR-NB`), `PCMA`, `PCMU`: restricts and orders both the codecs offered on an outgoing call and those accepted from an inbound offer (default: offer AMR-WB + AMR, accept all four in that order). A bench knob — a network whose gateway transcodes every caller up to AMR-WB otherwise never lets the narrowband path run | -| `DUMP_SIP` | `0` | `1` writes raw REGISTER-200/SUBSCRIBE-200 dumps, the last inbound INVITE (`imsd-invite-in.raw`) and the last outgoing INVITE (`imsd-invite-out.raw`), mode 0600, for debugging — they contain your IMSI/MSISDN and addresses | +| `DUMP_SIP` | `0` | `1` writes raw REGISTER-200/SUBSCRIBE-200 dumps, the last refused REGISTER or SUBSCRIBE final (`imsd-register-fail.raw`, `imsd-subscribe-fail.raw`), the last inbound INVITE (`imsd-invite-in.raw`) and the last outgoing INVITE (`imsd-invite-out.raw`), mode 0600, for debugging — they contain your IMSI/MSISDN and addresses. A refused REGISTER, and a refused SUBSCRIBE final the run loop reads, are logged with the status line and any Retry-After / Warning / Reason / Server headers | | `DUMP_DIR`, `OUT_DIR` | state dir | where dumps / per-call media stats land | | `IMSD_MEDIA` | *(auto)* | path to `imsd-media` (default: next to `imsd`, else `/usr/libexec/imsd-media`) | diff --git a/implementations/main.cpp b/implementations/main.cpp index b8145e0..40d3eea 100644 --- a/implementations/main.cpp +++ b/implementations/main.cpp @@ -796,8 +796,13 @@ public: Execute(call_->OnInviteResponse(*msg)); MaybeDropCall(); } else if (!subCallid_.empty() && callid == subCallid_ && cseq.ends_with("SUBSCRIBE")) { - Log(std::format("reg-event SUBSCRIBE -> {}", *st)); - DumpRaw("imsd-subscribe-200.raw", *msg); + if (*st >= 300) { + Log(std::format("reg-event SUBSCRIBE refused: {}", imsd::sip::RefusalSummary(*msg))); + DumpRaw("imsd-subscribe-fail.raw", *msg); + } else { + Log(std::format("reg-event SUBSCRIBE -> {}", *st)); + if (*st >= 200) DumpRaw("imsd-subscribe-200.raw", *msg); + } } } // shutdown: release an active call, keep the SA/registration for resume @@ -932,7 +937,7 @@ private: // failed FRESH registration is. if (resume != "0") { try { if (TryResume()) return; } - catch (const std::exception& e) { Log(std::format("resume failed: {} — registering fresh", e.what())); sip_.Close(); } + catch (const std::exception& e) { Log(std::format("resume failed: {} — registering fresh", e.what())); CloseFlowAfterWait(); } } FreshRegister(); } @@ -1022,16 +1027,14 @@ private: if (bind(us, reinterpret_cast(&la), ll) != 0) { close(us); throw std::runtime_error("bind init port failed"); } - timeval tv{8, 0}; setsockopt(us, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv); 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(&pa), pl); - char buf[65535]; - ssize_t n = recv(us, buf, sizeof buf, 0); - close(us); - if (n <= 0) throw std::runtime_error("no 401 to unprotected REGISTER"); - std::string resp(buf, static_cast(n)); - if (imsd::sip::Status(resp) != 401) throw std::runtime_error("expected 401"); + std::string resp = AwaitInitialFinal(us, callidReg); + if (imsd::sip::Status(resp) != 401) { + DumpRaw("imsd-register-fail.raw", resp); + throw std::runtime_error(std::format("expected 401, got {}", imsd::sip::RefusalSummary(resp))); + } auto nonceB64 = QuotedAfter(resp, "nonce=\""); if (!nonceB64) throw std::runtime_error("no nonce in 401"); auto raw = imsd::util::FromBase64(*nonceB64); @@ -1129,13 +1132,10 @@ private: 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"); - std::string ok; - for (;;) { - auto m = sip_.RecvMsg(12.0); - if (!m) throw std::runtime_error("no reply to protected REGISTER"); - auto st = imsd::sip::Status(*m); - if (st == 200) { ok = *m; break; } - if (st && *st >= 300) throw std::runtime_error(std::format("REGISTER failed {}", *st)); + std::string ok = AwaitFinal(callidReg, 2, "protected REGISTER"); + if (auto st = imsd::sip::Status(ok); st != 200) { + DumpRaw("imsd-register-fail.raw", ok); + throw std::runtime_error(std::format("REGISTER refused: {}", imsd::sip::RefusalSummary(ok))); } reg_.callid = callidReg; reg_.ftag = ftag; reg_.cseq = 2; reg_.spiUc = spiUc; reg_.spiUs = spiUs; @@ -1149,6 +1149,85 @@ private: Log("REGISTERED (fresh)"); } + // 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 + // wait going; a response to another transaction is ignored. Throws + // 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)) { + Log(std::format("ignoring a response to another transaction while awaiting the {} reply", what)); + 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(left); + long usec = static_cast((left - static_cast(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(&src), &sl); + if (n <= 0) break; + std::string m(buf, static_cast(n)); + if (!imsd::sip::ResponseOf(m, callid, "1 REGISTER")) { + int sp = src.ss_family == AF_INET6 ? ntohs(reinterpret_cast(&src)->sin6_port) : ntohs(reinterpret_cast(&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"); + } + // re-REGISTER over the (resumed or live) SA. Returns (200 msg, used cseq). std::pair Reregister(const std::string& callid, const std::string& ftag, long startCseq, std::uint32_t spiUc, std::uint32_t spiUs) { std::string auth = imsd::msg::AuthEmpty(ctx_); @@ -1157,13 +1236,12 @@ private: for (;;) { std::string reg = imsd::msg::BuildRegisterProtected(ctx_, callid, ftag, rng_.Token(16), cseq, spiUc, spiUs, auth); if (!sip_.Send(reg)) throw std::runtime_error("send re-REGISTER failed"); - auto m = sip_.RecvMsg(12.0); - if (!m) throw std::runtime_error("no reply to re-REGISTER"); - auto st = imsd::sip::Status(*m); - if (st == 200) return {*m, cseq}; + std::string m = AwaitFinal(callid, cseq, "re-REGISTER"); + auto st = imsd::sip::Status(m); + if (st == 200) return {m, cseq}; if (st == 401 && !challenged) { challenged = true; - auto nonce = QuotedAfter(*m, "nonce=\""); + auto nonce = QuotedAfter(m, "nonce=\""); auto raw = nonce ? imsd::util::FromBase64(*nonce) : std::nullopt; if (!raw || raw->size() < 32) throw std::runtime_error("bad 401 nonce on refresh"); std::vector rnd(raw->begin(), raw->begin() + 16); @@ -1176,7 +1254,8 @@ private: cseq++; continue; } - throw std::runtime_error(std::format("re-REGISTER failed {}", st.value_or(0))); + DumpRaw("imsd-register-fail.raw", m); + throw std::runtime_error(std::format("re-REGISTER refused: {}", imsd::sip::RefusalSummary(m))); } } @@ -1273,7 +1352,7 @@ private: // recovery is the same as a daemon restart: drop the flow and // let MaybeReconnect re-run the connect + true-refresh sequence. Log(std::format("keepalive re-REGISTER failed: {}", e.what())); - sip_.Close(); + CloseFlowAfterWait(); } lastRefresh_ = Mono(); lastKa_ = Mono(); @@ -1310,7 +1389,7 @@ private: return; } catch (const std::exception& e) { Log(std::format("reconnect re-REGISTER failed: {}", e.what())); - sip_.Close(); + CloseFlowAfterWait(); } } if (registered_) { registered_ = false; EmitStatus(true); } @@ -1330,31 +1409,41 @@ private: } // ---- command handling ------------------------------------------------- + // 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_); - if (call_) { - 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; - } - // an emergency dial preempts whatever exists (TS 22.101): - // release it; if it lingers awaiting a CANCEL's 487, abandon - // it — the late final is ignored by Call-ID mismatch - Log(std::format("emergency dial: preempting call {}", call_->Uni())); - Execute(call_->OnHangup()); - MaybeDropCall(); - if (call_) { Execute(call_->Abandon("preempted by emergency dial")); MaybeDropCall(); } - } + if (!ClearForDial(c, emergency)) return; // a dead client flow fails the INVITE instantly — give the // reconnect path one immediate shot first (user action beats - // the backoff timer) - if (!sip_.Alive()) { nextReconnect_ = 0; MaybeReconnect(); } + // the backoff timer). The shot's REGISTER wait dispatches + // requests, so an incoming call may have arrived under it: the + // same rule again. + if (!sip_.Alive()) { + nextReconnect_ = 0; MaybeReconnect(); + if (!ClearForDial(c, emergency)) return; + } RefreshPani(); if (emergency) Log(std::format("EMERGENCY dial ({}): urn:service:sos over the live registration, " diff --git a/interfaces/Imsd-Sip.cppm b/interfaces/Imsd-Sip.cppm index bc3290c..98b3511 100644 --- a/interfaces/Imsd-Sip.cppm +++ b/interfaces/Imsd-Sip.cppm @@ -121,6 +121,39 @@ export namespace imsd::sip { return v; } + // True for a response of the transaction (callid, cseq) — the reply a + // wait for that request may judge. A request, another transaction's + // response or anything else a socket delivers is not. + bool ResponseOf(std::string_view msg, std::string_view callid, std::string_view cseq) { + return Status(msg).has_value() && Header(msg, "Call-ID") == callid && Header(msg, "CSeq") == cseq; + } + + // Retry-After of a response (RFC 3261 20.33), in seconds; nullopt when + // absent or not a number. + std::optional RetryAfter(std::string_view msg) { + auto v = Header(msg, "Retry-After"); + if (!v) return std::nullopt; + return LeadingDigits(*v); + } + + // A refused final for the journal: its status line, then the headers + // that say who refused and for how long — Retry-After (RFC 3261 20.33), + // Warning (20.43: the node's own words), Reason (RFC 3326), Server + // (20.35). "503 Service Unavailable; Retry-After: 60; Warning: 399 + // pcscf1.example \"...\"". A bare status code says neither which node + // nor why: O2 UK answered a refresh 503 twenty-one times and a + // subscription 500 (alyx, 2026-09-24) and the journal could not tell + // the P-CSCF from the S-CSCF (ims ledger F17). + std::string RefusalSummary(std::string_view msg) { + std::string_view line = msg.substr(0, msg.find_first_of("\r\n")); + if (line.starts_with("SIP/2.0 ")) line.remove_prefix(8); + std::string s(Trim(line)); + for (std::string_view name : {"Retry-After", "Warning", "Reason", "Server"}) + for (std::string_view v : Headers(msg, name)) + s += std::format("; {}: {}", name, v); + return s; + } + // Where a UDP response to this request must go (RFC 3261 18.2.2): the // top Via's sent-by port — unless the Via carries rport, in which case // the caller should reply to the request's source port instead diff --git a/tests/Sip/main.cpp b/tests/Sip/main.cpp index 623669f..b7a4ed2 100644 --- a/tests/Sip/main.cpp +++ b/tests/Sip/main.cpp @@ -196,6 +196,51 @@ int main() { Check(!m4.has_value(), "absurd Content-Length: discarded, no overflow"); } + // ---- RetryAfter / RefusalSummary (what a refused final is logged as) + { + std::string r503 = + "SIP/2.0 503 Service Unavailable\r\n" + "Via: SIP/2.0/TCP [2001:db8::db43]:45062;branch=z9hG4bKx\r\n" + "Retry-After: 120\r\n" + "Warning: 399 pcscf1.example \"S-CSCF unreachable\"\r\n" + "Server: Mavenir-P-CSCF/4.2\r\n" + "Content-Length: 0\r\n\r\n"; + Check(RetryAfter(r503) == 120, "Retry-After parsed"); + Check(RefusalSummary(r503) == "503 Service Unavailable; Retry-After: 120; Warning: 399 pcscf1.example \"S-CSCF unreachable\"; Server: Mavenir-P-CSCF/4.2", "summary: status line + reason headers in order"); + std::string r500 = + "SIP/2.0 500 Server Internal Error\r\n" + "Via: SIP/2.0/TCP [2001:db8::db43]:45062;branch=z9hG4bKx\r\n" + "Content-Length: 0\r\n\r\n"; + Check(!RetryAfter(r500).has_value(), "no Retry-After -> nullopt"); + Check(RefusalSummary(r500) == "500 Server Internal Error", "summary without reason headers is the status line"); + std::string two = + "SIP/2.0 403 Forbidden\r\n" + "Reason: Q.850;cause=21;text=\"Call rejected\"\r\n" + "Reason: SIP;cause=403\r\n" + "Retry-After: soon\r\n" + "Content-Length: 0\r\n\r\n"; + Check(!RetryAfter(two).has_value(), "non-numeric Retry-After -> nullopt"); + Check(RefusalSummary(two) == "403 Forbidden; Retry-After: soon; Reason: Q.850;cause=21;text=\"Call rejected\"; Reason: SIP;cause=403", "every instance of a reason header is kept"); + Check(RefusalSummary("NOTIFY sip:x SIP/2.0\r\nEvent: reg\r\n\r\n") == "NOTIFY sip:x SIP/2.0", "a request summarises to its request line"); + Check(RefusalSummary("") == "", "empty message -> empty summary"); + } + + // ---- ResponseOf (which datagram a REGISTER wait may judge) + { + Check(ResponseOf(Resp200, "12345@host", "2 REGISTER"), "the transaction's own final"); + Check(!ResponseOf(Resp200, "12345@host", "1 REGISTER"), "another CSeq is not it"); + std::string stale = + "SIP/2.0 408 Request Timeout\r\n" + "Via: SIP/2.0/UDP [2001:db8::db43]:5060;branch=z9hG4bKa;rport=5060\r\n" + "Call-ID: earlier@[2001:db8::db43]\r\n" + "CSeq: 1 REGISTER\r\n" + "Content-Length: 0\r\n\r\n"; + Check(!ResponseOf(stale, "12345@host", "1 REGISTER"), "an earlier attempt's late final is not it"); + Check(ResponseOf(stale, "earlier@[2001:db8::db43]", "1 REGISTER"), "matched by Call-ID and CSeq"); + Check(!ResponseOf("NOTIFY sip:x SIP/2.0\r\nCall-ID: 12345@host\r\nCSeq: 2 REGISTER\r\n\r\n", "12345@host", "2 REGISTER"), "a request is never a response"); + Check(!ResponseOf("", "12345@host", "2 REGISTER"), "nothing is not it"); + } + if (Failures == 0) std::println("Sip: all tests passed"); return Failures; } From 142adf057c417f4996428a3820d3a665ceff4057 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Thu, 24 Sep 2026 18:56:34 +0200 Subject: [PATCH 02/15] engine: register fresh when the refresh is refused or nothing answers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: — attempt N, next fresh registration in M min` becomes `bring-up deferred: ; 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. --- README.md | 30 +++ implementations/main.cpp | 493 ++++++++++++++++++++++++++++++------ interfaces/Imsd-Engine.cppm | 179 ++++++++++++- tests/Engine/main.cpp | 191 +++++++++++++- 4 files changed, 806 insertions(+), 87 deletions(-) diff --git a/README.md b/README.md index 49a9176..e05617b 100644 --- a/README.md +++ b/README.md @@ -273,6 +273,7 @@ PCSCF=2001:db8::105 | `LOCAL` | *(auto)* | UE address; default = the global IPv6 on `DEV` | | `USER_AGENT` | `imsd/` | REGISTER User-Agent. Some networks fingerprint UAs; setting your device's stock build string reproduces the stock modem's registration exactly. Empty omits the header | | `STATE_FILE` | `/var/lib/imsd/imsreg.state` (root) / `$XDG_STATE_HOME/imsd/imsreg.state` | persisted registration context for warm resume | +| `HOLDS_FILE` | `/run/imsd.holds` (root) / `$XDG_RUNTIME_DIR/imsd.holds` (`/tmp/imsd.holds` without a runtime dir) | the waits the network imposed (a `Retry-After`, the refusal schedule) and where that schedule stands, kept across a restart of imsd for the address they were set on — not across a reboot; `/dev/null` switches the file off (only a regular file is ever removed; anything else is emptied) | | `RESUME` | `auto` | `0` forces a fresh registration (ignores a warm SA) | | `REFRESH_INTERVAL` | *(auto)* | keepalive re-REGISTER period in s; default = half the granted expiry, clamped to [120, 1800] | | `EALG` | `aes-cbc` | offered ESP cipher (`aes-cbc`, `des-ede3-cbc`, `null`). From the 401's `Security-Server` list imsd selects the highest-preference `ipsec-3gpp` mechanism that matches this offer (RFC 3329 §2.3.1) and takes the SPIs/ports from the entry carrying them (some cores list every mechanism they support and put the SPIs on the one they applied); when no entry matches, the P-CSCF's SPI-carrying entry is installed as listed (`hmac-md5-96`, an absent `ealg` = null) and the journal says so. The line, the selection and the SA parameters are logged at every fresh registration | @@ -289,6 +290,35 @@ The protected client/server ports are fixed at 45061/45062 (`imsd::util::kPortUc`/`kPortUs`) — changing them in source also requires updating the firewall rules `ims-pdn-up.sh` installs. +**When the registration stops working** (the refresh is refused, or the +protected flow dies and nothing answers the reconnect) imsd does in place +what a restart does: it reconnects through the existing SA and refreshes; +after two refused refreshes or two unanswered reconnect cycles (such a +cycle is not counted while the modem reports no packet service) it +registers fresh (new Call-ID, new challenge, new SA pair). Reconnects back +off 5 s → 5 min; a refused or throttled fresh attempt waits on the throttle +schedule (21 min, growing to 60), a silent one 30 s → 10 min, and a +network `Retry-After` is honoured, up to an hour (the schedule's longest wait): a longer +one is cut to it and the refusal heard again then. While a fresh registration is deferred, the existing SA is +still retried on the reconnect backoff, unless the old registration is known gone: a `403` once a +fresh registration is due, or an attempt that got past the SA setup. A `Dial` +that reaches imsd while it is unregistered brings the next recovery step +forward (the reconnect, or the fresh attempt once it is due), never a wait +the network imposed (a `Retry-After`, or the schedule after a +refused or throttled fresh attempt); imsd-ofonod refuses a `Dial` then, and +imsd-dialerd lists no modem. A bring-up the network deferred waits the same +schedule out with nothing to reconnect over (the resume just found the +stored SAs unusable), then tries again. The network's holds outlive a +restart of imsd: they are kept in +`/run/imsd.holds` for the address they were set on, with the schedule's place +(a restart does not start it over at 21 min), and waited out at the next +start, before the resume (a refresh's `Retry-After`) or before the fresh +registration (a fresh attempt's schedule or `Retry-After`); a reboot or +another address starts clean. +The journal says which (`…; next: fresh registration in N s`, `registering +fresh (recovery attempt N)`, `fresh registration deferred/refused/failed: +…`, `bring-up deferred: …`). + ### imsd-media (per-call, set through imsd's environment) | Variable | Default | Meaning | diff --git a/implementations/main.cpp b/implementations/main.cpp index 40d3eea..28819e5 100644 --- a/implementations/main.cpp +++ b/implementations/main.cpp @@ -33,6 +33,7 @@ reach the bus via g_idle_add, where the calls table + status snapshot live #include #include #include +#include #include import std; @@ -188,11 +189,19 @@ constexpr std::string_view TransportName(Transport t) { return t == Transport::U // 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 -// spi-s=0 to a fresh REGISTER made too soon after another; the window is -// ~20 min and every fresh attempt re-arms it). Handled in-process: a +// spi-s=0 to a fresh REGISTER on an address whose registration is still +// live). Handled in-process: a // systemd restart every RestartSec would never clear it. struct ThrottledError : std::runtime_error { using std::runtime_error::runtime_error; }; -constexpr int ThrottleWaitMin = 21; +// A REGISTER the network refused: the protected one, or a refresh, answered +// with a final other than 200 (a refresh's first 401 is its challenge); 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. +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) {} +}; // 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; @@ -211,24 +220,30 @@ public: // 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; + lastConnectLocal_ = false; int fam = Is6(local) ? AF_INET6 : AF_INET; if (t == Transport::Udp) { int fd = socket(fam, SOCK_DGRAM, 0); - if (fd < 0) return false; + if (fd < 0) return SocketFailed(); 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); - if (bind(fd, reinterpret_cast(&la), ll) != 0) { close(fd); return false; } + if (!Bind(fd, la, ll, local, lport)) return false; sockaddr_storage pa; socklen_t pl = MakeAddr(pcscf, pport, pa); - if (connect(fd, reinterpret_cast(&pa), pl) != 0) { Log(std::format("UDP connect: {}", std::strerror(errno))); close(fd); return false; } + if (connect(fd, reinterpret_cast(&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; + } 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); - if (fd < 0) return false; + 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 @@ -236,7 +251,7 @@ public: // 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); - if (bind(fd, reinterpret_cast(&la), ll) != 0) { close(fd); return false; } + 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). @@ -259,21 +274,47 @@ public: return true; } close(fd); + lastConnectLocal_ = localRefusal; if (!localRefusal) silent++; - if (attempt == tries - 1) return false; - if (silentLimit > 0 && silent >= silentLimit) { - Log(std::format("connect: no answer from the P-CSCF {} times", silent)); - return false; - } // 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)); + 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; + } Log(std::format("connect failed; retry in 10s ({}) [{}/{}]", why, attempt + 1, tries)); sleep(10); } return false; } + // 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(&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) { @@ -337,10 +378,13 @@ public: int Fd() const { return fd_; } bool Alive() const { return fd_ >= 0 && alive_; } Transport CurrentTransport() const { return transport_; } - void Close() { if (fd_ >= 0) { close(fd_); fd_ = -1; } alive_ = false; } + // 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; + bool lastConnectLocal_ = false; Transport transport_ = Transport::Tcp; bool sizeWarned_ = false; imsd::sip::FrameBuffer fb_; @@ -589,6 +633,87 @@ std::int64_t NowEpoch() { return std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); } +// The recovery policy's holds, kept for the next process: the network's +// word (a Retry-After, the schedule after a refused or throttled fresh +// attempt) outlives a restart of imsd. They +// are filed under the daemon'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. +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(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(ts.tv_sec) + static_cast(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; + } + std::string j = std::format("{{\"pdn\": \"{}\", \"boot\": \"{}\", \"written_at\": {}, \"hold\": {}, \"fresh_hold\": {}, \"throttle_minutes\": {}, \"old_gone\": {}}}\n", + pdn, BootId(), BootSeconds(), static_cast(std::ceil(h.hold)), static_cast(std::ceil(h.freshHold)), h.throttleMinutes, h.oldGone ? "true" : "false"); + if (std::FILE* f = std::fopen(path.c_str(), "w")) { + std::fwrite(j.data(), 1, j.size(), f); + std::fclose(f); + } +} + +std::optional 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 (QuotedAfter(s, "\"pdn\": \"").value_or("") != pdn) return std::nullopt; + 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(0, IntAfter(s, "\"hold\": ").value_or(0) - elapsed); + h.freshHold = std::max(0, IntAfter(s, "\"fresh_hold\": ").value_or(0) - elapsed); + h.throttleMinutes = static_cast(IntAfter(s, "\"throttle_minutes\": ").value_or(h.throttleMinutes)); + h.oldGone = s.find("\"old_gone\": true") != std::string::npos; + 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; @@ -723,28 +848,31 @@ public: } void Run() { + if (auto h = LoadHolds(local_)) { + 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"); + } for (;;) { try { BringUp(); break; + } catch (const Stopping&) { + return; } catch (const ThrottledError& e) { - // The window grows on repeats (a carrier's window may exceed the - // first guess and every throttled attempt re-arms it); it resets - // after a successful bring-up. - int waitMin = throttleWaitMin_; - throttleCount_++; - Log(std::format("bring-up deferred: {} — attempt {}, next fresh registration in {} min", e.what(), throttleCount_, waitMin)); - registered_ = false; - EmitStatus(true); - for (int i = 0; i < waitMin * 60 && !quit_.load(); i++) { - // A Dial/Accept/HangUp arriving now 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). - if (auto cmd = PopCmd()) { HandleCmd(*cmd); MaybeDropCall(); continue; } - std::this_thread::sleep_for(std::chrono::seconds(1)); - } + 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); if (quit_.load()) return; - throttleWaitMin_ = std::min(throttleWaitMin_ * 3 / 2, 60); continue; } catch (const std::exception& e) { if (devMode_) { @@ -763,14 +891,9 @@ public: return; } } - throttleWaitMin_ = ThrottleWaitMin; throttleCount_ = 0; - registered_ = true; - EmitStatus(true); - lastRefresh_ = Mono(); - lastKa_ = Mono(); try { server_.Open(local_); } catch (...) { Log("server ports unavailable; remote hangup/incoming not seen"); } - SubscribeRegEvent(); + OnRegistered(); while (!quit_.load()) { if (auto cmd = PopCmd()) { HandleCmd(*cmd); continue; } @@ -813,6 +936,41 @@ public: private: struct Cmd { enum class Kind { Dial, HangUp, Accept, Stop } kind; std::string uni, number; }; + 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()), local_); } // dev/session mode without a modem: process commands so the ABI answers, // but there is no SIP socket — a Dial collapses to terminated/error. @@ -826,8 +984,6 @@ private: // config std::string pcscf_, dev_, outDir_, local_, ealgOffer_, mediaBin_; std::string sipPolicy_; // SIP_TRANSPORT: auto | tcp | udp - int throttleWaitMin_ = ThrottleWaitMin; // grows on consecutive throttles, resets on success - int throttleCount_ = 0; 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 emergencyExtra_; @@ -845,8 +1001,8 @@ private: ServerPorts server_; std::string subCallid_; // live reg-event subscription dialog (empty: none) int portPs_ = 0; // P-CSCF protected server port (reconnect target) - double nextReconnect_ = 0; - double reconnectDelay_ = 5; + 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_; @@ -935,10 +1091,31 @@ private: // registered fresh for the same IMPI, a stale binding, …) is not // fatal: the fresh registration below replaces its SAs. Only a // failed FRESH registration is. + // The network's word holds at bring-up as it does in recovery: a + // hold from before this start is waited out before the resume (a + // refresh's Retry-After) or the fresh registration (a fresh attempt's + // schedule or Retry-After), and so is + // one the resume itself earns: a refusal's Retry-After. if (resume != "0") { - try { if (TryResume()) return; } - catch (const std::exception& e) { Log(std::format("resume failed: {} — registering fresh", e.what())); CloseFlowAfterWait(); } + if (!WaitForPolicy()) throw Stopping{}; + // a registration the network wrote off before this start (the + // holds carry it) is not resumed: the wait above was the fresh one + try { if (recovery_.Reconnectable() && TryResume()) return; } + catch (const RefusedError& e) { + Log(std::format("resume failed: {} — the next registration is a fresh one", e.what())); + CloseFlowAfterWait(); + if (e.retryAfter > 0) recovery_.OnReconnectFailure(Boot(), imsd::engine::RegRecovery::Failure::Refused, e.retryAfter, e.status); + } + catch (const std::exception& e) { Log(std::format("resume failed: {} — the next registration is a fresh one", e.what())); CloseFlowAfterWait(); } } + // 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; + KeepHolds(); + if (recovery_.Next(Boot()).at > Boot()) Log(std::format("the network's hold stands; {}", NextStep())); + if (!WaitForPolicy()) throw Stopping{}; FreshRegister(); } @@ -1005,14 +1182,24 @@ private: Log(std::format("binding: {}", ct)); } + // 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() { + 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. - reg_.contactUser = imsd::util::Uuid4(rng_); - ctx_.contactUser = reg_.contactUser; - Log(std::format("contact user-part: {}", reg_.contactUser)); + 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); @@ -1031,9 +1218,11 @@ private: sockaddr_storage pa; socklen_t pl = MakeAddr(pcscf_, pcscfPort_, pa); sendto(us, reg1.data(), reg1.size(), 0, reinterpret_cast(&pa), pl); std::string resp = AwaitInitialFinal(us, callidReg); - if (imsd::sip::Status(resp) != 401) { + if (auto st1 = imsd::sip::Status(resp); st1 != 401) { DumpRaw("imsd-register-fail.raw", resp); - throw std::runtime_error(std::format("expected 401, got {}", imsd::sip::RefusalSummary(resp))); + 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); } auto nonceB64 = QuotedAfter(resp, "nonce=\""); if (!nonceB64) throw std::runtime_error("no nonce in 401"); @@ -1088,6 +1277,7 @@ private: sp.portUc = PortUc; sp.portUs = PortUs; sp.portPs = portPs; sp.portPc = portPc; sp.alg = alg; sp.ealg = ealg; + saReplaced_ = true; // from here on the old registration's SAs are gone for (auto& cmd : imsd::ipsec::BuildSetupCommands(sp)) { int rc = RunCmd(cmd); if (rc != 0) Log(std::format("{} -> rc {}", RedactKeys(cmd), rc)); @@ -1120,7 +1310,7 @@ private: // pair (the SA setup flushes this one), same ports. Log("protected TCP connect unanswered; registering fresh over UDP"); SetTransport(Transport::Udp); - FreshRegister(); + FreshRegisterAttempt(); return; } throw std::runtime_error(std::format("protected {} connect failed", TransportName(transport_))); @@ -1135,9 +1325,10 @@ private: std::string ok = AwaitFinal(callidReg, 2, "protected REGISTER"); if (auto st = imsd::sip::Status(ok); st != 200) { DumpRaw("imsd-register-fail.raw", ok); - throw std::runtime_error(std::format("REGISTER refused: {}", imsd::sip::RefusalSummary(ok))); + 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; + reg_.contactUser = contactUser; reg_.spiUc = spiUc; reg_.spiUs = spiUs; reg_.expiry = imsd::sip::GrantedExpires(ok).value_or(0); DumpRaw("imsd-register-200.raw", ok); @@ -1255,7 +1446,7 @@ private: continue; } DumpRaw("imsd-register-fail.raw", m); - throw std::runtime_error(std::format("re-REGISTER refused: {}", imsd::sip::RefusalSummary(m))); + throw RefusedError(std::format("re-REGISTER refused: {}", imsd::sip::RefusalSummary(m)), *st, imsd::sip::RetryAfter(m).value_or(0)); } } @@ -1344,13 +1535,19 @@ private: if (auto e = imsd::sip::GrantedExpires(msg)) reg_.expiry = *e; PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport); Log(std::format("keepalive re-REGISTER ok (cseq {})", used)); - SubscribeRegEvent(); - if (!registered_) { registered_ = true; EmitStatus(true); } - else EmitStatus(false); + OnRegistered(); + } catch (const RefusedError& e) { + // The network refused the refresh: the recovery policy counts it + // (a second refusal means a fresh registration) and the flow is + // dropped so the reconnect step retries the refresh after the + // wait (Retry-After when given). + 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) { - // Whatever failed (dead flow, silent P-CSCF, error status), the - // recovery is the same as a daemon restart: drop the flow and - // let MaybeReconnect re-run the connect + true-refresh sequence. + // 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())); CloseFlowAfterWait(); } @@ -1362,39 +1559,165 @@ private: // 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: - // reconnect the TCP leg over the existing SA and true-refresh. Backoff - // doubles 5 s → 5 min so a dead PDN doesn't turn this into a hot loop. + // 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; - if (Mono() < nextReconnect_) return; + 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(); - bool ok = sip_.Connect(local_, PortUc, pcscf_, portPs_, transport_, /*tries=*/2); - if (ok) { - try { - RefreshPani(); - auto [msg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1, reg_.spiUc, reg_.spiUs); - reg_.cseq = used; - if (auto e = imsd::sip::GrantedExpires(msg)) reg_.expiry = *e; - UpdateRouteAndPpi(msg); - LogBindings(msg); - PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport); - Log("reconnected + re-registered"); - SubscribeRegEvent(); - if (!registered_) { registered_ = true; EmitStatus(true); } - else EmitStatus(false); - lastRefresh_ = Mono(); lastKa_ = Mono(); - reconnectDelay_ = 5; - nextReconnect_ = 0; + if (!sip_.Connect(local_, PortUc, pcscf_, portPs_, transport_, /*tries=*/2)) { + if (sip_.LastConnectLocal()) { + if (registered_) { registered_ = false; EmitStatus(true); } + recovery_.OnLocalRefusal(Boot()); return; - } catch (const std::exception& e) { - Log(std::format("reconnect re-REGISTER failed: {}", e.what())); - CloseFlowAfterWait(); } + if (!PacketServiceUp()) { CoverageGap("P-CSCF silent"); return; } + RecoveryFailed(F::Silent, 0, "P-CSCF silent"); + return; } + try { + RefreshPani(); + auto [msg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1, reg_.spiUc, reg_.spiUs); + reg_.cseq = used; + if (auto e = imsd::sip::GrantedExpires(msg)) reg_.expiry = *e; + UpdateRouteAndPpi(msg); + LogBindings(msg); + PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport); + 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(); + if (!PacketServiceUp()) { CoverageGap("refresh unanswered"); return; } + RecoveryFailed(F::Silent, 0, "refresh unanswered"); + } + } + + // 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); } - nextReconnect_ = Mono() + reconnectDelay_; - reconnectDelay_ = std::min(reconnectDelay_ * 2, 300.0); + 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 + // challenge, a new SA pair (FreshRegister flushes the old ones), then + // 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 + subCallid_.clear(); + try { + RefreshPani(); + FreshRegister(); + OnRegistered(); + 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())); + } + } + + // 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(); + 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 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(); + KeepHolds(); + bool was = registered_; + registered_ = true; + EmitStatus(!was); + lastRefresh_ = Mono(); lastKa_ = Mono(); + SubscribeRegEvent(); } // (Re-)subscribe to the reg event package (TS 24.229 5.1.1.3): a fresh @@ -1436,12 +1759,14 @@ private: bool emergency = imsd::msg::IsEmergency(c.number, emergencyExtra_); if (!ClearForDial(c, emergency)) return; // a dead client flow fails the INVITE instantly — give the - // reconnect path one immediate shot first (user action beats - // the backoff timer). The shot's REGISTER wait dispatches + // 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 + // attempt — the policy keeps those). The shot's REGISTER wait dispatches // requests, so an incoming call may have arrived under it: the // same rule again. if (!sip_.Alive()) { - nextReconnect_ = 0; MaybeReconnect(); + recovery_.OnDial(); MaybeReconnect(); if (!ClearForDial(c, emergency)) return; } RefreshPani(); diff --git a/interfaces/Imsd-Engine.cppm b/interfaces/Imsd-Engine.cppm index 7b7805e..a76dffe 100644 --- a/interfaces/Imsd-Engine.cppm +++ b/interfaces/Imsd-Engine.cppm @@ -3,7 +3,9 @@ // lint-disable-file fixed-width-types /* -Imsd:Engine — the call state machine as a pure reducer. +Imsd:Engine — the daemon's decisions as pure reducers: the call state +machine (CallMachine) and the registration recovery policy (RegRecovery, +its own section below). A CallMachine owns one call's SIP dialog, outgoing or incoming. It never touches a socket, a clock, or a subprocess: each event (the INVITE's @@ -52,6 +54,181 @@ export namespace imsd::engine { return d == Direction::Incoming ? "incoming" : "outgoing"; } + // ---- registration recovery policy --------------------------------- + // + // What the daemon does when the registration stops working after + // bring-up, as a pure reducer: the shell supplies the clock and the + // network, this decides the next step and how long to wait for it. Two + // kinds of failure reach it. A REFUSAL: the network answered a refresh + // (or a fresh REGISTER) with a final other than 200/401. SILENCE: the + // flow died and reconnecting through the SA, or the refresh over it, + // got no answer. Neither is retried for long on the same registration. + // TS 24.229 5.1.1.4.1 has the UE perform an initial registration after + // a 408, 500, 504 or 403 on a re-REGISTER (5.1.1.2.1: not before a + // Retry-After has passed), and RFC 5626 has it register again when its + // flow cannot be recovered. imsd does so after any refusal: the 503s + // below are not in that list. Before this reducer the daemon looped + // reconnect + re-REGISTER on the same Call-ID and SA forever — O2 UK, + // 2026-09-24: 21 × 503 over 70 min, then 33 unanswered reconnect + // cycles over 2 h 39 min, each ended only by a manual restart whose + // fresh registration the network accepted within two seconds (ims + // ledger F16). + // + // Pacing. Reconnect cycles back off 5 s → 5 min. A refusal escalates + // to a fresh registration after two (one 503 may be a momentary + // hiccup a retried refresh clears), silence after two reconnect cycles + // that end without an answer — a TCP connect series whose last attempt + // was not a local refusal, a bind() the kernel refused, or a refresh + // that fails short of a refusal: unanswered, an unusable challenge, a + // failed send (the resume at start gives up after two unanswered + // connects). + // A fresh registration is the expensive step: an AKA run, a + // new SA pair, and on KPN a refusal (spi-s=0) while the registration on + // the address is live, which costs that registration. So a refused or + // throttled fresh attempt + // waits on the throttle schedule (21 min, growing ×1.5 to 60) and a + // silent one backs off 30 s → 10 min. Retry-After (RFC 3261 20.33) + // always wins over a shorter wait. A 403 is a refusal like the others: + // never hammered (the schedule), never abandoned (a SIM that is + // unbarred registers within the hour); once a fresh registration is + // due it also says the old registration is gone (KPN drops it the + // moment an initial REGISTER arrives, throttled or not), as does a + // fresh attempt that got past the SA setup, and a resume that failed at + // bring-up (OldGone) — no reconnect over the old SA from then on. Every wait + // is either the self-imposed backoff, which a user's Dial cuts short, + // or the network's own word (a Retry-After, the schedule after a + // refused or throttled fresh attempt), which nothing cuts short — a + // restart of the process neither: the shell keeps the holds (Holds) + // and hands them to the next one (Restore). The shell supplies the + // clock and the network; this holds the deadlines and names the next + // step (Next). Success resets everything. + struct RegRecovery { + enum class Failure { Refused, Silent, Throttled }; + enum class Step { Reconnect, Fresh }; + // What comes next: the step, and the earliest time it may run (the + // shell's clock; at <= now means now). + struct Plan { Step step; double at; }; + + static constexpr int RefusalsBeforeFresh = 2; + static constexpr int SilentCyclesBeforeFresh = 2; + static constexpr double ReconnectDelayMin = 5; + static constexpr double ReconnectDelayMax = 300; + static constexpr double FreshDelayMin = 30; + static constexpr double FreshDelayMax = 600; + static constexpr int ThrottleMinutesMin = 21; + static constexpr int ThrottleMinutesMax = 60; + // The network's word is honoured up to the schedule's own longest + // wait: a Retry-After beyond an hour is cut to it, and the refusal + // heard again then — one REGISTER an hour at most, never a day + // unregistered on one answer. + static constexpr long RetryAfterMax = ThrottleMinutesMax * 60; + // A connect refused locally — our own 4-tuple still in TIME_WAIT + // (up to 60 s after we closed it), no route yet — is not the + // network's answer: retried soon, never counted, the backoff + // untouched. Dev phone 2026-09-24: four such refusals in 26 s read + // as two silent cycles cost a fresh registration into the throttle. + static constexpr double LocalRetry = 5; + + int refusals = 0; + int silentCycles = 0; + int freshAttempts = 0; + double reconnectDelay = ReconnectDelayMin; + double freshDelay = FreshDelayMin; + int throttleMinutes = ThrottleMinutesMin; + bool fresh = false; // the next registration attempt is a fresh one + bool oldGone = false; // the old registration is gone: nothing to reconnect over + // the deadlines, on the shell's clock + double backoffUntil = 0; // self-imposed: no step before this; a Dial clears it + double holdUntil = 0; // the network's word for any attempt (a Retry-After on a refresh): nothing clears it + double freshHoldUntil = 0; // no fresh attempt before this (the schedule after a refused or throttled fresh attempt, a fresh Retry-After) + + bool Fresh() const { return fresh; } + bool Reconnectable() const { return !oldGone; } + + // The network's holds as seconds still to run, and where the + // schedule stands: what a restart of the process must not forget. + struct Held { + double hold = 0; + double freshHold = 0; + int throttleMinutes = ThrottleMinutesMin; + bool oldGone = false; // the old registration is gone: the next process has nothing to reconnect over + // a schedule that has advanced is kept too, until a success resets it, and so is the gone verdict + bool Any() const { return hold > 0 || freshHold > 0 || throttleMinutes > ThrottleMinutesMin || oldGone; } + }; + Held Holds(double now) const { return {std::max(0.0, holdUntil - now), std::max(0.0, freshHoldUntil - now), throttleMinutes, oldGone}; } + // A restarted process takes them over; nothing here shortens a + // hold it already has, or lengthens one past RetryAfterMax. + void Restore(double now, const Held& h) { + if (h.hold > 0) holdUntil = std::max(holdUntil, now + std::min(h.hold, static_cast(RetryAfterMax))); + if (h.freshHold > 0) freshHoldUntil = std::max(freshHoldUntil, now + std::min(h.freshHold, static_cast(RetryAfterMax))); + throttleMinutes = std::clamp(std::max(throttleMinutes, h.throttleMinutes), ThrottleMinutesMin, ThrottleMinutesMax); + if (h.oldGone) OldGone(); // no reconnect over SAs the network has written off + } + + // The next step and when. Not escalated: a reconnect after the + // backoff and any hold. Escalated: the fresh attempt once the + // backoff, any hold and the fresh hold have passed — and while a + // fresh hold still runs, a reconnect over the old SA meanwhile, the + // cheap thing, unless that registration is gone. + Plan Next(double now) const { + double any = std::max(backoffUntil, holdUntil); + double freshAt = std::max(any, freshHoldUntil); + if (!fresh) return {Step::Reconnect, any}; + if (!oldGone && freshHoldUntil > any && now < freshHoldUntil) return {Step::Reconnect, any}; + return {Step::Fresh, freshAt}; + } + + void OnLocalRefusal(double now) { backoffUntil = now + LocalRetry; } + + // A refused keepalive refresh, or a failed reconnect + refresh + // cycle; `status` is the refusal's. The backoff paces the next step; + // a Retry-After holds every attempt for its own length (up to + // RetryAfterMax), whichever is longer. A 403 once a fresh + // registration is due says the old registration is gone (KPN drops + // it the moment an initial REGISTER arrives, throttled or not). + void OnReconnectFailure(double now, Failure f, long retryAfter = 0, int status = 0) { + if (f == Failure::Refused) refusals++; + else silentCycles++; + if (refusals >= RefusalsBeforeFresh || silentCycles >= SilentCyclesBeforeFresh) fresh = true; + if (status == 403 && fresh) oldGone = true; + backoffUntil = now + reconnectDelay; + reconnectDelay = std::min(reconnectDelay * 2, ReconnectDelayMax); + if (retryAfter > 0) holdUntil = std::max(holdUntil, now + static_cast(std::min(retryAfter, RetryAfterMax))); + } + + // A failed fresh registration; `saReplaced`: it got past the SA + // setup, so the old registration's SAs went with it. Refusals and + // throttles hold the next fresh attempt on the schedule (a Retry-After + // replaces it, never below the short backoff's floor nor above + // RetryAfterMax); silence backs + // it off 30 s → 10 min. A reconnect over the old SA meanwhile waits + // at least the reconnect backoff. + void OnFreshFailure(double now, Failure f, long retryAfter = 0, bool saReplaced = false) { + freshAttempts++; + fresh = true; + if (saReplaced) oldGone = true; + backoffUntil = std::max(backoffUntil, now + reconnectDelay); + if (f == Failure::Silent) { + backoffUntil = std::max(backoffUntil, now + freshDelay); + freshDelay = std::min(freshDelay * 2, FreshDelayMax); + return; + } + double wait = throttleMinutes * 60.0; + throttleMinutes = std::min(throttleMinutes * 3 / 2, ThrottleMinutesMax); + if (retryAfter > 0) wait = std::max(static_cast(std::min(retryAfter, RetryAfterMax)), FreshDelayMin); + freshHoldUntil = now + wait; + } + + // The stored registration is not there to reconnect over (a resume + // that failed at bring-up found its SAs unusable, or there are none). + void OldGone() { oldGone = true; fresh = true; } + + // A user's Dial cuts the backoff short, never the network's word. + void OnDial() { backoffUntil = 0; } + + void OnSuccess() { *this = RegRecovery{}; } + }; + // Parsed SDP answer needed to bring up the media leg. `codec` is one of // imsd-media's: "AMR-WB", "AMR", "PCMA", "PCMU" (empty = none we play). struct MediaLeg { diff --git a/tests/Engine/main.cpp b/tests/Engine/main.cpp index 284186e..be18212 100644 --- a/tests/Engine/main.cpp +++ b/tests/Engine/main.cpp @@ -11,7 +11,11 @@ // reject -> 486, remote CANCEL -> 200+487, unusable offer -> 488 (naming // what was offered), a landline gateway's G.711-only and AMR-NB offers // ring and are answered at the offer's own payload types, ring timeout -> -// 480, BYE both ways. Pure reducer, no I/O. +// 480, BYE both ways. Then RegRecovery, the registration recovery policy: +// the refusal and silence thresholds, local refusals, the reconnect and +// fresh backoffs, the throttle schedule, Retry-After, which waits a Dial +// may cut short, when the old registration counts as gone, and the holds +// a restart takes over (Holds/Restore). Pure reducers, no I/O. import std; import Imsd; @@ -854,6 +858,189 @@ int main() { Check(can && !can->text.contains("001010123456789@ims"), "CANCEL carries no IMSI"); } + // ---- RegRecovery: when a registration stops working after bring-up. + // The clock is ours: seconds, starting at 1000. + { + using F = RegRecovery::Failure; + using S = RegRecovery::Step; + auto reconnectAt = [](const RegRecovery& r, double now, double at) { auto p = r.Next(now); return p.step == S::Reconnect && p.at == at; }; + auto freshAt = [](const RegRecovery& r, double now, double at) { auto p = r.Next(now); return p.step == S::Fresh && p.at == at; }; + RegRecovery r; + Check(!r.Fresh() && r.Reconnectable() && reconnectAt(r, 1000, 0), "the first step is a reconnect, at once"); + r.OnLocalRefusal(1000); + Check(r.refusals == 0 && r.silentCycles == 0 && r.reconnectDelay == 5 && reconnectAt(r, 1000, 1005), "a local connect refusal: retried in 5 s, not counted, backoff untouched"); + // O2 2026-09-24, 09:29: the keepalive refresh refused (503), then a + // reconnect's refresh refused again -> fresh, not a third refresh + r.OnReconnectFailure(1000, F::Refused); + Check(!r.Fresh() && reconnectAt(r, 1000, 1005), "first refusal: reconnect after 5 s, not yet an escalation"); + r.OnReconnectFailure(1005, F::Refused); + Check(r.Fresh() && r.refusals == 2 && freshAt(r, 1005, 1015), "second refusal: escalated, the fresh attempt after the doubled backoff"); + Check(r.Reconnectable(), "…and the old SA is still there"); + r.OnSuccess(); + Check(!r.Fresh() && r.refusals == 0 && r.reconnectDelay == 5, "success resets the policy"); + // Retry-After is the network's word: it holds every attempt for its + // own length, next to the backoff, and a Dial cuts only the backoff + r.OnReconnectFailure(1000, F::Refused, 90); + Check(reconnectAt(r, 1000, 1090), "Retry-After 90 beats the 5-s backoff"); + r.OnDial(); + Check(reconnectAt(r, 1000, 1090), "…and a Dial does not cut it"); + r.OnReconnectFailure(1090, F::Refused, 3); + Check(r.Fresh() && freshAt(r, 1090, 1100), "a Retry-After shorter than the backoff does not shorten it"); + r.OnDial(); + Check(freshAt(r, 1090, 1093), "…but a Dial cuts the backoff down to it, no further"); + r.OnSuccess(); + // a 403 once a fresh registration is due: the old registration is gone + { + RegRecovery k; + k.OnReconnectFailure(1000, F::Refused, 0, 403); + Check(!k.Fresh() && k.Reconnectable(), "a first 403 is a refusal like the others"); + k.OnReconnectFailure(1005, F::Refused, 0, 403); + Check(k.Fresh() && !k.Reconnectable() && freshAt(k, 1005, 1015), "a 403 once fresh is due: gone, the fresh attempt after the backoff"); + k.OnSuccess(); + Check(k.Reconnectable(), "success forgets it"); + } + // O2 10:43: the flow killed 8 s after the 200, every SYN unanswered + r.OnReconnectFailure(1000, F::Silent); + Check(!r.Fresh() && reconnectAt(r, 1000, 1005), "first silent cycle: retry in 5 s"); + r.OnReconnectFailure(1005, F::Silent); + Check(r.Fresh() && r.silentCycles == 2, "two silent cycles escalate"); + // a mix: one refusal + one silence keeps reconnecting (neither + // threshold reached) + RegRecovery m; + m.OnReconnectFailure(1000, F::Refused); + m.OnReconnectFailure(1000, F::Silent); + Check(!m.Fresh(), "one refusal + one silence: still reconnecting"); + m.OnReconnectFailure(1000, F::Silent); + Check(m.Fresh(), "…until a threshold is reached"); + // the reconnect backoff caps at 5 min + RegRecovery b; + for (int i = 0; i < 10; i++) b.OnReconnectFailure(1000, F::Silent); + Check(b.reconnectDelay == 300 && b.Next(1000).at == 1300, "reconnect backoff caps at 300 s"); + // fresh attempts: silence backs off 30 s -> 10 min (a Dial may cut + // it); a refusal or a throttle holds on the throttle schedule (21 + // min growing to 60), the network's word, and the old SA is + // reconnected meanwhile + RegRecovery f; + f.OldGone(); + Check(f.Fresh() && !f.Reconnectable() && freshAt(f, 1000, 0), "the old registration gone: fresh now, nothing to reconnect to"); + f.OnFreshFailure(1000, F::Silent); + Check(freshAt(f, 1000, 1030), "silent fresh attempt: 30 s"); + f.OnDial(); + Check(freshAt(f, 1000, 0), "…which a Dial cuts"); + f.OnFreshFailure(1030, F::Silent); + Check(freshAt(f, 1030, 1090), "…then 60 s"); + double last = 0; + for (int i = 0; i < 8; i++) { f.OnFreshFailure(2000, F::Silent); last = f.Next(2000).at - 2000; } + Check(last == 600 && f.freshAttempts == 10, "silent fresh backoff caps at 600 s; attempts counted"); + RegRecovery g; + g.OnReconnectFailure(1000, F::Refused); g.OnReconnectFailure(1000, F::Refused); // escalated, backoff 5 then 10 + g.OnFreshFailure(1010, F::Refused); + Check(g.Reconnectable() && reconnectAt(g, 1010, 1030) && g.freshHoldUntil == 1010 + 21 * 60, "refused fresh attempt: the old SA is reconnected after the backoff, the next fresh attempt held 21 min"); + Check(freshAt(g, 1010 + 21 * 60, 1010 + 21 * 60), "…and runs once the hold ends"); + g.OnDial(); + Check(g.Next(1010).step == S::Reconnect && g.Next(1010).at <= 1010, "a Dial meanwhile: the reconnect at once, never the held fresh attempt"); + g.OnFreshFailure(2000, F::Throttled); + Check(g.freshHoldUntil == 2000 + 31 * 60, "throttled next: 31 min (×1.5)"); + g.OnFreshFailure(3000, F::Refused); + Check(g.freshHoldUntil == 3000 + 46 * 60, "then 46 min"); + g.OnFreshFailure(4000, F::Refused); + Check(g.freshHoldUntil == 4000 + 60 * 60, "capped at 60 min"); + g.OnFreshFailure(5000, F::Refused); + Check(g.freshHoldUntil == 5000 + 60 * 60, "stays at 60 min"); + g.OnFreshFailure(6000, F::Refused, 7200); + Check(g.freshHoldUntil == 6000 + 3600, "a Retry-After beyond an hour is cut to the hour, the schedule's own ceiling"); + RegRecovery ra; + ra.OnFreshFailure(1000, F::Refused, 2700); + Check(ra.freshHoldUntil == 3700, "a Retry-After longer than the schedule's wait holds for its own length"); + ra.OnFreshFailure(5000, F::Refused, 86400); + Check(ra.freshHoldUntil == 5000 + 3600, "…a day's for an hour: the refusal is heard again then"); + RegRecovery rb; + rb.OnReconnectFailure(1000, F::Refused, 86400); + Check(reconnectAt(rb, 1000, 1000 + 3600), "a refresh's Retry-After is cut to the hour the same"); + RegRecovery h; + h.OnFreshFailure(1000, F::Refused, 60); + Check(h.freshHoldUntil == 1060, "a refused fresh attempt with Retry-After 60 waits 60 s, not the schedule"); + h.OnFreshFailure(1000, F::Refused, 5); + Check(h.freshHoldUntil == 1030, "…but never under the 30-s floor"); + Check(h.throttleMinutes == 46, "the schedule still advances underneath (21 -> 31 -> 46)"); + h.OnFreshFailure(1000, F::Silent, 600); + Check(h.freshHoldUntil == 1030 && h.Next(1000).at == 1030, "Retry-After is a refusal's word; silence keeps its own backoff"); + Check(g.Fresh(), "a failed fresh attempt stays on the fresh step"); + g.OnSuccess(); + Check(!g.Fresh() && g.throttleMinutes == 21 && g.freshDelay == 30, "success resets the fresh schedule too"); + // an attempt that got past the SA setup took the old SAs with it + { + RegRecovery s; + s.OnFreshFailure(1000, F::Silent, 0, /*saReplaced=*/true); + Check(!s.Reconnectable() && freshAt(s, 1000, 1030), "no reply past the SA setup: the old registration is gone, the next fresh attempt on the short backoff"); + RegRecovery t; + t.OnFreshFailure(1000, F::Refused, 0, /*saReplaced=*/true); + Check(!t.Reconnectable() && freshAt(t, 1000, 1000 + 21 * 60), "refused past the SA setup: gone, and on the schedule"); + } + // the network's holds outlive the process + { + RegRecovery a; + a.OnReconnectFailure(1000, F::Refused); a.OnReconnectFailure(1000, F::Refused); + a.OnFreshFailure(1010, F::Throttled); + auto h = a.Holds(1100); + Check(h.Any() && h.hold == 0 && h.freshHold == 1010 + 21 * 60 - 1100 && h.throttleMinutes == 31, "holds: the seconds still to run and the schedule's place"); + RegRecovery b; + b.Restore(50, h); + Check(reconnectAt(b, 50, 0) && b.throttleMinutes == 31, "restored fresh hold: the resume (a reconnect) is free at once"); + b.OldGone(); + Check(freshAt(b, 50, 50 + h.freshHold), "…and the fresh step waits what was left of it"); + b.OnDial(); + Check(freshAt(b, 50, 50 + h.freshHold), "…which a Dial does not cut"); + RegRecovery c; + c.OnReconnectFailure(1000, F::Refused, 600); + RegRecovery d; + d.Restore(10, c.Holds(1200)); + Check(reconnectAt(d, 10, 10 + 400), "restored Retry-After: no step before it ends"); + d.Restore(20, RegRecovery::Held{30, 0, 21}); + Check(reconnectAt(d, 20, 10 + 400), "a restore never shortens a hold"); + Check(!RegRecovery{}.Holds(5).Any(), "no hold, nothing to keep"); + auto k = a.Holds(1010 + 21 * 60 + 5); + Check(k.Any() && k.hold == 0 && k.freshHold == 0 && k.throttleMinutes == 31, "the hold run out, the schedule's place is still kept"); + RegRecovery e; + e.Restore(10, k); + Check(reconnectAt(e, 10, 0) && e.throttleMinutes == 31, "…and a restart takes it over: no wait, the next refused or throttled fresh attempt on the advanced schedule"); + e.OldGone(); e.OnFreshFailure(20, F::Throttled); + Check(freshAt(e, 20, 20 + 31 * 60), "…31 min, not 21"); + RegRecovery g; + g.OnReconnectFailure(1000, F::Refused); g.OnReconnectFailure(1000, F::Refused, 0, 403); + auto hg = g.Holds(1100); + Check(hg.Any() && hg.oldGone, "a 403 once fresh is due: the holds carry that the old registration is gone"); + RegRecovery h2; + h2.Restore(50, hg); + Check(!h2.Reconnectable() && h2.Fresh() && freshAt(h2, 50, 0), "…and the restart does not reconnect over its SAs: fresh at once"); + RegRecovery i; + i.OnFreshFailure(1000, F::Throttled, 0, true); + auto hi = i.Holds(1100); + RegRecovery j; + j.Restore(50, hi); + Check(hi.oldGone && !j.Reconnectable() && freshAt(j, 50, 50 + hi.freshHold), "an attempt past the SA setup: gone travels with the hold, the restart waits it out without resuming"); + RegRecovery m; + m.OldGone(); + Check(m.Holds(5).Any() && m.Holds(5).oldGone && m.Holds(5).freshHold == 0, "gone with no hold left is still kept"); + RegRecovery n; + n.Restore(5, m.Holds(5)); + Check(!n.Reconnectable() && freshAt(n, 5, 0), "…so the next start registers fresh at once instead of resuming"); + Check(!RegRecovery::Held{0, 0, 21, false}.Any(), "nothing gone, nothing held: nothing to keep"); + RegRecovery x; + x.Restore(2000, RegRecovery::Held{86400, 86400, 21}); + Check(x.holdUntil == 2000 + 3600 && x.freshHoldUntil == 2000 + 3600, "a restored hold is cut to the hour like the Retry-After it came from"); + } + // bring-up: a resume that failed leaves nothing to reconnect over + { + RegRecovery u; + u.OldGone(); + u.OnFreshFailure(1000, F::Throttled); + Check(!u.Reconnectable() && freshAt(u, 1000, 1000 + 21 * 60), "a throttled bring-up: the next fresh attempt in 21 min, nothing meanwhile"); + u.OnDial(); + Check(freshAt(u, 1000, 1000 + 21 * 60), "…a Dial changes nothing"); + } + } + if (Failures == 0) std::println("Engine: all tests passed"); - return Failures; + return Failures != 0; // not the count: an exit status keeps 8 bits, and this suite has more checks than that } From db848c649060b92f4b936d36057054871d79593b Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sat, 3 Oct 2026 19:36:23 +0200 Subject: [PATCH 03/15] engine: keep the reg-event subscription alive and act on a terminated binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 30 ++++- implementations/main.cpp | 211 +++++++++++++++++++++++++++++++----- interfaces/Imsd-Engine.cppm | 44 +++++++- interfaces/Imsd-Sip.cppm | 54 +++++++++ tests/Engine/main.cpp | 64 ++++++++++- tests/Sip/main.cpp | 47 ++++++++ 6 files changed, 412 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index e05617b..1ba3617 100644 --- a/README.md +++ b/README.md @@ -282,7 +282,7 @@ PCSCF=2001:db8::105 | `RTP_PORT` | `50004` | local RTP port advertised in SDP | | `PRECOND` | `0` | `1` offers SDP QoS preconditions | | `CODECS` | *(empty — defaults)* | comma-separated codec preference list over `AMR-WB`, `AMR` (or `AMR-NB`), `PCMA`, `PCMU`: restricts and orders both the codecs offered on an outgoing call and those accepted from an inbound offer (default: offer AMR-WB + AMR, accept all four in that order). A bench knob — a network whose gateway transcodes every caller up to AMR-WB otherwise never lets the narrowband path run | -| `DUMP_SIP` | `0` | `1` writes raw REGISTER-200/SUBSCRIBE-200 dumps, the last refused REGISTER or SUBSCRIBE final (`imsd-register-fail.raw`, `imsd-subscribe-fail.raw`), the last inbound INVITE (`imsd-invite-in.raw`) and the last outgoing INVITE (`imsd-invite-out.raw`), mode 0600, for debugging — they contain your IMSI/MSISDN and addresses. A refused REGISTER, and a refused SUBSCRIBE final the run loop reads, are logged with the status line and any Retry-After / Warning / Reason / Server headers | +| `DUMP_SIP` | `0` | `1` writes raw REGISTER-200/SUBSCRIBE-200 dumps, the last refused REGISTER or SUBSCRIBE final (`imsd-register-fail.raw`, `imsd-subscribe-fail.raw`), the last inbound INVITE (`imsd-invite-in.raw`) and the last outgoing INVITE (`imsd-invite-out.raw`), mode 0600, for debugging — they contain your IMSI/MSISDN and addresses. A refused REGISTER or SUBSCRIBE is logged with its status line and any Retry-After / Warning / Reason / Server headers | | `DUMP_DIR`, `OUT_DIR` | state dir | where dumps / per-call media stats land | | `IMSD_MEDIA` | *(auto)* | path to `imsd-media` (default: next to `imsd`, else `/usr/libexec/imsd-media`) | @@ -301,7 +301,8 @@ schedule (21 min, growing to 60), a silent one 30 s → 10 min, and a network `Retry-After` is honoured, up to an hour (the schedule's longest wait): a longer one is cut to it and the refusal heard again then. While a fresh registration is deferred, the existing SA is still retried on the reconnect backoff, unless the old registration is known gone: a `403` once a -fresh registration is due, or an attempt that got past the SA setup. A `Dial` +fresh registration is due, an attempt that got past the SA setup, or a binding +the network terminated (reg-event). A `Dial` that reaches imsd while it is unregistered brings the next recovery step forward (the reconnect, or the fresh attempt once it is due), never a wait the network imposed (a `Retry-After`, or the schedule after a @@ -313,11 +314,30 @@ restart of imsd: they are kept in `/run/imsd.holds` for the address they were set on, with the schedule's place (a restart does not start it over at 21 min), and waited out at the next start, before the resume (a refresh's `Retry-After`) or before the fresh -registration (a fresh attempt's schedule or `Retry-After`); a reboot or -another address starts clean. +registration (a fresh attempt's schedule or `Retry-After`, a terminated +binding's wait); a reboot or another address starts clean. The journal says which (`…; next: fresh registration in N s`, `registering fresh (recovery attempt N)`, `fresh registration deferred/refused/failed: -…`, `bring-up deferred: …`). +…`, `bring-up deferred: …`). The reg-event subscription — the network's own +view of the binding, one live dialog per registration, subscribed anew ahead of +the lifetime the S-CSCF granted — is retried when refused or unanswered +(60 s doubling to 10 min, a `Retry-After` honoured up to an hour: a registration refresh +neither brings a scheduled retry forward nor starts its back-off over) and renewed when the S-CSCF ends +it for a reason that allows renewal (RFC 6665; after `noresource`, +`rejected` or any other reason the renewal does not cover the next +registration refresh subscribes anew); an earlier +subscription's NOTIFY is still read for the binding's state, but its end is +not ours to renew (KPN closes a registration's previous subscription with +`noresource` when a new one replaces it); a NOTIFY that terminates the binding triggers a fresh +registration (at once, or after the network's `retry-after`, up to an hour; after the +call, when one is up; one exception: a REGISTER wait that still gets its 200 +after such a NOTIFY, a call having kept the flow, leaves that registration +standing); after +`rejected` and `unregistered` it waits on the refusal schedule (21 min, +growing to 60), a wait no `Dial` cuts short — the binding is gone, so +nothing is retried meanwhile. One termination counts once, however many +NOTIFYs report it (a repeated `probation` report may extend its hold from +its own `retry-after`). ### imsd-media (per-call, set through imsd's environment) diff --git a/implementations/main.cpp b/implementations/main.cpp index 28819e5..d20e74c 100644 --- a/implementations/main.cpp +++ b/implementations/main.cpp @@ -635,7 +635,7 @@ std::int64_t NowEpoch() { // The recovery policy's holds, kept for the next process: the network's // word (a Retry-After, the schedule after a refused or throttled fresh -// attempt) outlives a restart of imsd. They +// attempt, a terminated binding's wait) outlives a restart of imsd. They // are filed under the daemon's address — another one, or a reboot, starts // clean — so the file names that address and the boot, and // lives in /run. Seconds left at `written_at`, which is on the boot @@ -903,6 +903,7 @@ public: } MaybeRefresh(); MaybeReconnect(); + MaybeResubscribe(); if (!call_ && sip_.Alive() && Mono() - lastKa_ > 30) { sip_.SendKeepalive(); lastKa_ = Mono(); } ReapMedia(); if (call_ && call_->State() != imsd::engine::CallState::Active && Mono() > deadline_) { @@ -918,14 +919,8 @@ public: if (call_ && callid == call_->CallId() && cseq.ends_with("INVITE")) { Execute(call_->OnInviteResponse(*msg)); MaybeDropCall(); - } else if (!subCallid_.empty() && callid == subCallid_ && cseq.ends_with("SUBSCRIBE")) { - if (*st >= 300) { - Log(std::format("reg-event SUBSCRIBE refused: {}", imsd::sip::RefusalSummary(*msg))); - DumpRaw("imsd-subscribe-fail.raw", *msg); - } else { - Log(std::format("reg-event SUBSCRIBE -> {}", *st)); - if (*st >= 200) DumpRaw("imsd-subscribe-200.raw", *msg); - } + } else { + OnSubscribeResponse(*msg, *st); } } // shutdown: release an active call, keep the SA/registration for resume @@ -1000,6 +995,11 @@ private: SipFlow sip_; ServerPorts server_; std::string subCallid_; // live reg-event subscription dialog (empty: none) + 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) 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) @@ -1008,6 +1008,7 @@ private: imsd::util::Rng rng_; std::optional call_; bool dropCall_ = false; + 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; @@ -1094,8 +1095,9 @@ private: // The network's word holds at bring-up as it does in recovery: a // hold from before this start is waited out before the resume (a // refresh's Retry-After) or the fresh registration (a fresh attempt's - // schedule or Retry-After), and so is - // one the resume itself earns: a refusal's Retry-After. + // 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. if (resume != "0") { if (!WaitForPolicy()) throw Stopping{}; // a registration the network wrote off before this start (the @@ -1357,7 +1359,8 @@ private: // 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 - // wait going; a response to another transaction is ignored. Throws + // wait going; a response to our reg-event SUBSCRIBE is handled as the + // run loop handles it, any other transaction's is ignored. Throws // 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); @@ -1374,7 +1377,7 @@ private: continue; } if (!imsd::sip::ResponseOf(*m, callid, want)) { - Log(std::format("ignoring a response to another transaction while awaiting the {} reply", what)); + if (!OnSubscribeResponse(*m, *st)) Log(std::format("ignoring a response to another transaction while awaiting the {} reply", what)); continue; } if (*st >= 200) return *m; @@ -1650,6 +1653,10 @@ private: } catch (const std::exception& e) { Log(std::format("reconnect re-REGISTER failed: {}", e.what())); CloseFlowAfterWait(); + // 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; if (!PacketServiceUp()) { CoverageGap("refresh unanswered"); return; } RecoveryFailed(F::Silent, 0, "refresh unanswered"); } @@ -1680,10 +1687,16 @@ private: Log(std::format("registering fresh (recovery attempt {})", recovery_.freshAttempts + 1)); sip_.Close(); saReplaced_ = false; // this attempt's, whatever fails first - subCallid_.clear(); + // 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; try { RefreshPani(); FreshRegister(); + subRetryAt_ = 0; // a new registration: its subscription starts afresh OnRegistered(); Log("recovered by a fresh registration"); } catch (const ThrottledError& e) { @@ -1712,23 +1725,156 @@ private: // subscription. void OnRegistered() { recovery_.OnSuccess(); + closeFlowWhenIdle_ = false; // this registration's flow stands, whatever a verdict said of the one before KeepHolds(); bool was = registered_; registered_ = true; EmitStatus(!was); lastRefresh_ = Mono(); lastKa_ = Mono(); + // 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(); } + } + + // 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() { + 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)); + 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(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(); } - // (Re-)subscribe to the reg event package (TS 24.229 5.1.1.3): a fresh - // dialog each time, 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() { - subCallid_ = std::format("{}@{}", rng_.Token(16), local_); - std::string sub = imsd::msg::BuildSubscribeReg(ctx_, subCallid_, rng_.Token(8), rng_.Token(16)); - if (sip_.Send(sub)) Log("reg-event SUBSCRIBE sent"); - else { Log("reg-event SUBSCRIBE send failed"); subCallid_.clear(); } + // 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 ------------------------------------------------- @@ -1762,7 +1908,9 @@ private: // 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 - // attempt — the policy keeps those). The shot's REGISTER wait dispatches + // 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 // requests, so an incoming call may have arrived under it: the // same rule again. if (!sip_.Alive()) { @@ -1813,7 +1961,12 @@ private: ? 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)); - 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)); + 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; } @@ -1894,7 +2047,13 @@ private: } void MaybeDropCall() { - if (dropCall_) { call_.reset(); callReply_ = nullptr; dropCall_ = false; } + 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) { diff --git a/interfaces/Imsd-Engine.cppm b/interfaces/Imsd-Engine.cppm index a76dffe..efb4ada 100644 --- a/interfaces/Imsd-Engine.cppm +++ b/interfaces/Imsd-Engine.cppm @@ -93,11 +93,13 @@ export namespace imsd::engine { // unbarred registers within the hour); once a fresh registration is // due it also says the old registration is gone (KPN drops it the // moment an initial REGISTER arrives, throttled or not), as does a - // fresh attempt that got past the SA setup, and a resume that failed at - // bring-up (OldGone) — no reconnect over the old SA from then on. Every wait + // fresh attempt that got past the SA setup, a binding the network + // terminated, and a resume that failed at bring-up (OldGone) — no + // reconnect over the old SA from then on. Every wait // is either the self-imposed backoff, which a user's Dial cuts short, // or the network's own word (a Retry-After, the schedule after a - // refused or throttled fresh attempt), which nothing cuts short — a + // refused or throttled fresh attempt, a terminated binding's probation + // or its refusal schedule), which nothing cuts short — a // restart of the process neither: the shell keeps the holds (Holds) // and hands them to the next one (Restore). The shell supplies the // clock and the network; this holds the deadlines and names the next @@ -137,10 +139,11 @@ export namespace imsd::engine { int throttleMinutes = ThrottleMinutesMin; bool fresh = false; // the next registration attempt is a fresh one bool oldGone = false; // the old registration is gone: nothing to reconnect over + bool terminated = false; // the network terminated our binding, and the policy has counted it // the deadlines, on the shell's clock double backoffUntil = 0; // self-imposed: no step before this; a Dial clears it double holdUntil = 0; // the network's word for any attempt (a Retry-After on a refresh): nothing clears it - double freshHoldUntil = 0; // no fresh attempt before this (the schedule after a refused or throttled fresh attempt, a fresh Retry-After) + double freshHoldUntil = 0; // no fresh attempt before this (the schedule after a refused or throttled fresh attempt, a fresh Retry-After, probation, rejected/unregistered) bool Fresh() const { return fresh; } bool Reconnectable() const { return !oldGone; } @@ -187,13 +190,18 @@ export namespace imsd::engine { // registration is due says the old registration is gone (KPN drops // it the moment an initial REGISTER arrives, throttled or not). void OnReconnectFailure(double now, Failure f, long retryAfter = 0, int status = 0) { + if (retryAfter > 0) holdUntil = std::max(holdUntil, now + static_cast(std::min(retryAfter, RetryAfterMax))); + // A cycle over a registration already known gone — the network + // terminated the binding while the cycle waited for its final — + // fails by definition: not a silent cycle, not a refusal, and no + // backoff before the fresh registration that verdict scheduled. + if (oldGone) return; if (f == Failure::Refused) refusals++; else silentCycles++; if (refusals >= RefusalsBeforeFresh || silentCycles >= SilentCyclesBeforeFresh) fresh = true; if (status == 403 && fresh) oldGone = true; backoffUntil = now + reconnectDelay; reconnectDelay = std::min(reconnectDelay * 2, ReconnectDelayMax); - if (retryAfter > 0) holdUntil = std::max(holdUntil, now + static_cast(std::min(retryAfter, RetryAfterMax))); } // A failed fresh registration; `saReplaced`: it got past the SA @@ -219,6 +227,32 @@ export namespace imsd::engine { freshHoldUntil = now + wait; } + // The network terminated our binding (a reg-event NOTIFY, RFC + // 3680): the old registration is gone. deactivated and expired + // mean a fresh registration now; probation after the network's + // retry-after (up to RetryAfterMax); rejected and unregistered on the + // refusal schedule — + // the network's word is neither hammered nor taken as final. One + // termination counts once, however often it is reported (every + // reg-event dialog of the account carries it, a NOTIFY may be + // retransmitted), and never shortens a hold already running. + void OnBindingTerminated(double now, std::string_view event, long retryAfter = 0) { + bool reported = terminated; + terminated = true; + oldGone = true; + fresh = true; + if (event == "probation") { + freshHoldUntil = std::max(freshHoldUntil, now + static_cast(std::clamp(retryAfter, 1L, RetryAfterMax))); + } else if (reported) { + return; + } else if (event == "rejected" || event == "unregistered") { + freshHoldUntil = std::max(freshHoldUntil, now + throttleMinutes * 60.0); + throttleMinutes = std::min(throttleMinutes * 3 / 2, ThrottleMinutesMax); + } else { + backoffUntil = 0; + } + } + // The stored registration is not there to reconnect over (a resume // that failed at bring-up found its SAs unusable, or there are none). void OldGone() { oldGone = true; fresh = true; } diff --git a/interfaces/Imsd-Sip.cppm b/interfaces/Imsd-Sip.cppm index 98b3511..306d199 100644 --- a/interfaces/Imsd-Sip.cppm +++ b/interfaces/Imsd-Sip.cppm @@ -154,6 +154,60 @@ export namespace imsd::sip { return s; } + // Our binding in a reg-event NOTIFY body (RFC 3680 reginfo): the + // whose carries `user` as its SIP user part, across every + // of the implicit set (the sip: and the tel: identity + // list the same contact). Active anywhere = Active; otherwise the first + // terminated occurrence's event and retry-after attribute; NotListed + // when no contact of ours is in the document — a partial update about + // other bindings, or a previous registration's binding (the contact our + // own fresh registration deactivated, O2 UK 2026-09-24). + struct RegInfoContact { + enum class State { NotListed, Active, Terminated }; + State state = State::NotListed; + std::string event; // terminated: deactivated | probation | rejected | unregistered | expired | … + long retryAfter = 0; // terminated: the retry-after attribute (probation), seconds + }; + RegInfoContact RegInfoContactState(std::string_view reginfo, std::string_view user) { + constexpr std::size_t npos = std::string_view::npos; + RegInfoContact out; + std::string needle = std::format(":{}@", user); // sip:@… / sips:@… + std::size_t pos = 0; + while ((pos = reginfo.find("', pos); + if (tagEnd == npos) break; + std::string_view tag = reginfo.substr(pos, tagEnd - pos); + std::size_t end = reginfo.find("", tagEnd); + std::string_view body = reginfo.substr(tagEnd + 1, end == npos ? npos : end - tagEnd - 1); + pos = end == npos ? reginfo.size() : end + 10; + std::size_t u = body.find(""); + if (u == npos) continue; + std::size_t ue = body.find("", u); + std::string_view uri = Trim(body.substr(u + 5, ue == npos ? npos : ue - u - 5)); + if (!uri.contains(needle)) continue; + auto attr = [&](std::string_view name) -> std::string_view { + std::string key = std::format(" {}=\"", name); + std::size_t a = tag.find(key); + if (a == npos) return {}; + std::size_t b = tag.find('"', a + key.size()); + return tag.substr(a + key.size(), b == npos ? npos : b - a - key.size()); + }; + std::string_view state = attr("state"); + if (state == "active") { + out.state = RegInfoContact::State::Active; + out.event.clear(); + out.retryAfter = 0; + return out; + } + if (state == "terminated" && out.state == RegInfoContact::State::NotListed) { + out.state = RegInfoContact::State::Terminated; + out.event = std::string(attr("event")); + out.retryAfter = LeadingDigits(attr("retry-after")).value_or(0); + } + } + return out; + } + // Where a UDP response to this request must go (RFC 3261 18.2.2): the // top Via's sent-by port — unless the Via carries rport, in which case // the caller should reply to the request's source port instead diff --git a/tests/Engine/main.cpp b/tests/Engine/main.cpp index be18212..9ab77dd 100644 --- a/tests/Engine/main.cpp +++ b/tests/Engine/main.cpp @@ -14,8 +14,9 @@ // 480, BYE both ways. Then RegRecovery, the registration recovery policy: // the refusal and silence thresholds, local refusals, the reconnect and // fresh backoffs, the throttle schedule, Retry-After, which waits a Dial -// may cut short, when the old registration counts as gone, and the holds -// a restart takes over (Holds/Restore). Pure reducers, no I/O. +// may cut short, when the old registration counts as gone, the holds a +// restart takes over (Holds/Restore), and a binding the network terminated +// (OnBindingTerminated). Pure reducers, no I/O. import std; import Imsd; @@ -977,6 +978,65 @@ int main() { t.OnFreshFailure(1000, F::Refused, 0, /*saReplaced=*/true); Check(!t.Reconnectable() && freshAt(t, 1000, 1000 + 21 * 60), "refused past the SA setup: gone, and on the schedule"); } + // the network terminated our binding + { + RegRecovery n; + n.OnBindingTerminated(1000, "expired"); + Check(n.Fresh() && !n.Reconnectable() && freshAt(n, 1000, 0), "expired: fresh now, nothing to reconnect to"); + RegRecovery o; + o.OnBindingTerminated(1000, "probation", 300); + Check(freshAt(o, 1000, 1300), "probation: after the network's retry-after"); + o.OnDial(); + Check(freshAt(o, 1000, 1300), "…which a Dial does not cut"); + RegRecovery p; + p.OnBindingTerminated(1000, "probation"); + Check(freshAt(p, 1000, 1001), "probation without a retry-after: at least 1 s"); + RegRecovery q; + q.OnBindingTerminated(1000, "rejected"); + Check(freshAt(q, 1000, 1000 + 21 * 60) && !q.Reconnectable(), "rejected: on the refusal schedule, a hold, the old SA not retried"); + q.OnBindingTerminated(1001, "rejected"); + q.OnBindingTerminated(2000, "unregistered"); + Check(q.freshHoldUntil == 1000 + 21 * 60 && q.throttleMinutes == 31, "the same termination reported again (another dialog, a retransmission): counted once"); + RegRecovery u; + u.OnBindingTerminated(1000, "deactivated"); + u.OnFreshFailure(1000, F::Silent); + u.OnBindingTerminated(1005, "deactivated"); + Check(freshAt(u, 1005, 1030), "…nor does a repeat cut the backoff of the attempt made since"); + RegRecovery v; + v.OnFreshFailure(1000, F::Refused, 3000); + v.OnBindingTerminated(1010, "rejected"); + Check(!v.Reconnectable() && freshAt(v, 1010, 4000) && v.throttleMinutes == 46, "a rejected binding never shortens the network's Retry-After"); + RegRecovery w; + w.OnBindingTerminated(1000, "probation", 3000); + w.OnBindingTerminated(1010, "probation", 60); + Check(freshAt(w, 1010, 4000), "…nor a second probation report the first one's retry-after"); + RegRecovery x; + x.OnBindingTerminated(1000, "probation", 86400); + Check(freshAt(x, 1000, 1000 + 3600), "probation's retry-after is cut to the hour like a Retry-After"); + // terminated while a reconnect cycle waited for its re-REGISTER final: the cycle's failure is no news + RegRecovery y; + y.OnReconnectFailure(1000, F::Silent); + y.OnBindingTerminated(1010, "expired"); + y.OnReconnectFailure(1011, F::Silent); + Check(y.silentCycles == 1 && freshAt(y, 1011, 0), "the flow closed under the cycle: not a silent cycle, the fresh registration now"); + y.OnReconnectFailure(1012, F::Refused, 90, 503); + Check(y.refusals == 0 && freshAt(y, 1012, 1102), "…a refusal with a Retry-After on it: not counted, the network's hold kept"); + } + // a terminated binding's verdict outlives the process with its hold + { + RegRecovery tg; + tg.OnBindingTerminated(1000, "rejected"); + auto htg = tg.Holds(1100); + Check(htg.Any() && htg.oldGone && htg.freshHold == 1000 + 21 * 60 - 1100, "a terminated binding's holds carry that the old registration is gone"); + RegRecovery th; + th.Restore(50, htg); + Check(!th.Reconnectable() && freshAt(th, 50, 50 + htg.freshHold), "…and the restart does not reconnect over its SAs: the fresh attempt waits what was left"); + RegRecovery ti; + ti.OnBindingTerminated(1000, "probation", 1800); + RegRecovery tj; + tj.Restore(0, ti.Holds(1300)); + Check(!tj.Reconnectable() && freshAt(tj, 0, 1500), "probation's retry-after survives a restart the same way"); + } // the network's holds outlive the process { RegRecovery a; diff --git a/tests/Sip/main.cpp b/tests/Sip/main.cpp index b7a4ed2..43e3454 100644 --- a/tests/Sip/main.cpp +++ b/tests/Sip/main.cpp @@ -241,6 +241,53 @@ int main() { Check(!ResponseOf("", "12345@host", "2 REGISTER"), "nothing is not it"); } + // ---- RegInfoContactState: our binding in a reg-event NOTIFY (RFC 3680) + { + using S = RegInfoContact::State; + auto contact = [](std::string_view id, std::string_view state, std::string_view extra, std::string_view user) { + return std::format( + "\t\t\n" + "\t\t\tsip:{}@[2a03:db8::1]:45062\n" + "\t\t\t"<urn:gsma:imei:35901961-000000-0>" \n" + "\t\t\n", id, state, extra, user); + }; + auto reginfo = [](std::string_view contacts) { + return std::format( + "\n" + "\n" + "\t\n{0}\t\n" + "\t\n{0}\t\n" + "\n", contacts); + }; + const std::string me = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + const std::string old = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + // the first NOTIFY of a registration: our contact active in both registrations + auto r0 = RegInfoContactState(reginfo(contact("3", "active", " event=\"created\" expires=\"10800\"", me)), me); + Check(r0.state == S::Active, "our contact active -> Active"); + // O2 2026-09-24 10:43: the previous registration's contact deactivated by ours, ours created + std::string both = contact("3", "terminated", " event=\"deactivated\"", old) + contact("4", "active", " event=\"created\" expires=\"10800\"", me); + auto r1 = RegInfoContactState(reginfo(both), me); + Check(r1.state == S::Active, "old contact terminated + ours active -> Active (the old one is not ours)"); + auto r1o = RegInfoContactState(reginfo(both), old); + Check(r1o.state == S::Terminated && r1o.event == "deactivated" && r1o.retryAfter == 0, "seen from the old contact's side: Terminated/deactivated"); + // a network-initiated deregistration of our own binding + auto r2 = RegInfoContactState(reginfo(contact("4", "terminated", " event=\"probation\" retry-after=\"120\"", me)), me); + Check(r2.state == S::Terminated && r2.event == "probation" && r2.retryAfter == 120, "probation with retry-after"); + auto r3 = RegInfoContactState(reginfo(contact("4", "terminated", " event=\"rejected\"", me)), me); + Check(r3.state == S::Terminated && r3.event == "rejected", "rejected"); + // active in one registration of the set beats terminated in another + std::string mixed = + "\n\n" + contact("4", "terminated", " event=\"expired\"", me) + + "\n\n" + contact("4", "active", "", me) + "\n"; + Check(RegInfoContactState(mixed, me).state == S::Active, "active anywhere in the set -> Active"); + // not ours at all + auto r4 = RegInfoContactState(reginfo(contact("9", "terminated", " event=\"unregistered\"", old)), me); + Check(r4.state == S::NotListed && r4.event.empty(), "a document without our contact -> NotListed"); + Check(RegInfoContactState("", me).state == S::NotListed, "empty body -> NotListed"); + Check(RegInfoContactState("sip:aaaaaaaa@x", me).state == S::NotListed, "a user part that is only a prefix of ours does not match"); + Check(RegInfoContactState("sip:" + me + "@h", me).state == S::Terminated, "an unterminated element still parses"); + } + if (Failures == 0) std::println("Sip: all tests passed"); return Failures; } From f8fd5a3aa8b952ba94d4fbebcf486c90e647f3cd Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Thu, 24 Sep 2026 18:58:59 +0200 Subject: [PATCH 04/15] ims-pdn-up: a profile list without the ims APN is not final before the modem is registered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The profile-list query answered while ModemManager is still enabling the modem can be partial: entries, but no ims yet. The lookup took that as "no ims profile", fell back to the APN-string request — the one a walled carrier refuses — and never looked again (dev phone cold boot 2026-09-24 18:18:59: "none for apn ims" at `enabling`, the ims profile listed when read minutes later (18:19–18:25); harmless on KPN, a refused boot on O2 UK). The 0.3.8 fix covered only an empty list. Treat a list without the APN as final only when the modem is registered (or already connecting/connected); before that, retry it after the registration wait like a failed lookup. A retry that still has no final answer logs the modem state. Tested on the dev phone (KPN) in builds of this series; not every path has run there. --- packaging/ims-pdn-up.sh | 37 +++++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/packaging/ims-pdn-up.sh b/packaging/ims-pdn-up.sh index 3ab4725..fa6292c 100644 --- a/packaging/ims-pdn-up.sh +++ b/packaging/ims-pdn-up.sh @@ -65,34 +65,49 @@ log "modem $MODEM" # lookup itself can fail while ModemManager is still enabling the modem; a # failed lookup is not "no profile" (the APN-string request it would fall # back to is the one the modem refuses under an IPv4 attach), so it is -# retried after the registration wait and only a list without the APN -# falls back to the APN string. +# retried after the registration wait; a list without the APN is final — +# the APN string is used — only once the modem is registered. LOOKUP=pending if [ -n "$PROFILE_ID" ]; then [ "$PROFILE_ID" = none ] && PROFILE_ID= LOOKUP=done fi -lookup_profile() { # $1 = log suffix; sets PROFILE_ID, LOOKUP=done on a usable list +lookup_profile() { # $1 = modem state, $2 = log suffix; sets PROFILE_ID, LOOKUP=done on a final answer [ "$LOOKUP" = done ] && return 0 if OUT=$(qmicli -d qrtr://0 --wds-get-profile-list=3gpp 2>&1); then # a list with no profiles at all is the modem still coming up, not # a modem without an ims profile (dev phone cold boot 2026-09-20 # 16:47: exit 0, no entries, index 2 present a minute later) if ! printf '%s\n' "$OUT" | grep -q -E '^[[:space:]]*\[[0-9]+\] 3gpp'; then - log "ims profile lookup returned no profiles$1" + log "ims profile lookup returned no profiles$2" return 0 fi PROFILE_ID=$(printf '%s\n' "$OUT" | awk -v apn="$IMS_APN" ' /^[ \t]*\[[0-9]+\] 3gpp/ { idx = $1; gsub(/[^0-9]/, "", idx) } /APN:/ { a = $0; sub(/.*APN: '"'"'/, "", a); sub(/'"'"'.*/, "", a); if (tolower(a) == tolower(apn) && idx != "") { print idx; exit } }') - LOOKUP=done - [ -n "$PROFILE_ID" ] && log "ims profile: index $PROFILE_ID (apn $IMS_APN)" || log "ims profile: none for apn $IMS_APN, connecting by APN string" + if [ -n "$PROFILE_ID" ]; then + LOOKUP=done + log "ims profile: index $PROFILE_ID (apn $IMS_APN)" + return 0 + fi + # a list without the APN is final only once the modem is registered: + # while it enables, the list can be partial (dev phone cold boot + # 2026-09-24 18:18: entries but no ims at `enabling`, ims listed when + # read minutes later), and on a walled carrier the APN-string fallback + # that would follow is the request the modem refuses + case "$1" in + registered|connecting|connected) + LOOKUP=done + log "ims profile: none for apn $IMS_APN, connecting by APN string" ;; + *) log "ims profile: not in the list yet$2" ;; + esac else - log "ims profile lookup failed$1: $(squash "$OUT")" + log "ims profile lookup failed$2: $(squash "$OUT")" fi } -lookup_profile " (modem state: $(modem_state))" +STATE=$(modem_state) +lookup_profile "$STATE" " (modem state: $STATE)" # ---- find a connected ims bearer; else find-or-create one and connect it. # APN names are case-insensitive. A DISCONNECTED bearer is reused only when @@ -157,12 +172,14 @@ if [ -z "$BEARER" ]; then waited=$((waited + 5)) done log "modem state: $(modem_state), packet service: $(packet_state)" - # a lookup that failed while the modem was still coming up + # a lookup with no final answer yet: it failed, or its list lacked the + # APN while the modem was still coming up n=0 while [ "$LOOKUP" != done ] && [ "$n" -lt 3 ]; do n=$((n + 1)) sleep 5 - lookup_profile " (retry $n)" + STATE=$(modem_state) + lookup_profile "$STATE" " (retry $n, modem state: $STATE)" done if [ "$LOOKUP" != done ]; then PROFILE_ID= From 7eb2deae0c0c1aa63f23cdaf2878e14cdeaccc52 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Mon, 28 Sep 2026 18:28:58 +0200 Subject: [PATCH 05/15] ims-pdn-up, imsd: register on the PDN's own address, not the kernel's temporary one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit imsd took the first global IPv6 on the ims netdev as its address, and the kernel lists the temporary (privacy) SLAAC address first: `temporary dynamic`, preferred for a day and valid for two by its lifetimes (read 2026-09-24). Contact, Via and the four SAs were bound to it, so once the kernel removes it the registration has lost its address. The address ModemManager assigned — the one the script installs — sat third on the list. Seen on the dev phone; a field unit's journal shows the split too — imsd registered on an address other than the one the script installed (ims ledger F18). Hand the assigned address to the daemon as LOCAL through /run/imsd.env, next to DEV. For a manual run without it, prefer a global address that is neither `temporary` nor `dynamic`, then any non-temporary one. A restart after the upgrade finds the registration's SAs on the old address. It resumes there while that address is still on the link, and moves to the PDN's address in place: with its next fresh registration, or when the old address leaves the link first. When the address it is registered on leaves the netdev and the PDN's own address is not there to move to, a registered imsd exits at its next recovery step, non-zero, for a systemd restart, which runs the PDN bring-up again. Tested on the dev phone (KPN) in builds of this series; not every path has run there. --- README.md | 22 ++++-- implementations/main.cpp | 150 ++++++++++++++++++++++++++++++++++--- interfaces/Imsd-Ipsec.cppm | 13 ++++ packaging/ims-pdn-up.sh | 10 ++- packaging/imsd.service | 6 +- tests/Ipsec/main.cpp | 8 ++ 6 files changed, 187 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 1ba3617..86220c8 100644 --- a/README.md +++ b/README.md @@ -252,7 +252,8 @@ IMSD_BUS=session imsd-ofonod ## Configuration Everything is environment variables. The packaged unit reads -`/run/imsd.env` (written by `ims-pdn-up.sh`: the connected ims netdev) and +`/run/imsd.env` (written by `ims-pdn-up.sh`: the connected ims netdev and +the PDN address ModemManager assigned to it) and then `/etc/imsd.env` (your configuration, wins on conflict). Minimum viable `/etc/imsd.env`: @@ -270,10 +271,10 @@ PCSCF=2001:db8::105 | `PCSCF` | *(none — required)* | P-CSCF address; without it registration fails with a clear error | | `PCSCF_PORT` | `5060` | unprotected SIP port for the initial REGISTER | | `DEV` | `qmapmux0.0` | ims-PDN netdev; the packaged service passes the real one via `/run/imsd.env` | -| `LOCAL` | *(auto)* | UE address; default = the global IPv6 on `DEV` | +| `LOCAL` | *(auto)* | UE address. The packaged service gets the PDN address ModemManager assigned, via `/run/imsd.env` from `ims-pdn-up.sh`; a manual run prefers a global IPv6 on `DEV` that is neither temporary nor `dynamic` (RA-derived), then any non-temporary one, then the first (the kernel's temporary SLAAC address is listed first, and a registration bound to it is lost when the kernel retires it) | | `USER_AGENT` | `imsd/` | REGISTER User-Agent. Some networks fingerprint UAs; setting your device's stock build string reproduces the stock modem's registration exactly. Empty omits the header | | `STATE_FILE` | `/var/lib/imsd/imsreg.state` (root) / `$XDG_STATE_HOME/imsd/imsreg.state` | persisted registration context for warm resume | -| `HOLDS_FILE` | `/run/imsd.holds` (root) / `$XDG_RUNTIME_DIR/imsd.holds` (`/tmp/imsd.holds` without a runtime dir) | the waits the network imposed (a `Retry-After`, the refusal schedule) and where that schedule stands, kept across a restart of imsd for the address they were set on — not across a reboot; `/dev/null` switches the file off (only a regular file is ever removed; anything else is emptied) | +| `HOLDS_FILE` | `/run/imsd.holds` (root) / `$XDG_RUNTIME_DIR/imsd.holds` (`/tmp/imsd.holds` without a runtime dir) | the waits the network imposed (a `Retry-After`, the refusal schedule) and where that schedule stands, kept across a restart of imsd for the PDN they were set on — not across a reboot; `/dev/null` switches the file off (only a regular file is ever removed; anything else is emptied) | | `RESUME` | `auto` | `0` forces a fresh registration (ignores a warm SA) | | `REFRESH_INTERVAL` | *(auto)* | keepalive re-REGISTER period in s; default = half the granted expiry, clamped to [120, 1800] | | `EALG` | `aes-cbc` | offered ESP cipher (`aes-cbc`, `des-ede3-cbc`, `null`). From the 401's `Security-Server` list imsd selects the highest-preference `ipsec-3gpp` mechanism that matches this offer (RFC 3329 §2.3.1) and takes the SPIs/ports from the entry carrying them (some cores list every mechanism they support and put the SPIs on the one they applied); when no entry matches, the P-CSCF's SPI-carrying entry is installed as listed (`hmac-md5-96`, an absent `ealg` = null) and the journal says so. The line, the selection and the SA parameters are logged at every fresh registration | @@ -311,14 +312,23 @@ imsd-dialerd lists no modem. A bring-up the network deferred waits the same schedule out with nothing to reconnect over (the resume just found the stored SAs unusable), then tries again. The network's holds outlive a restart of imsd: they are kept in -`/run/imsd.holds` for the address they were set on, with the schedule's place +`/run/imsd.holds` for the PDN they were set on, with the schedule's place (a restart does not start it over at 21 min), and waited out at the next start, before the resume (a refresh's `Retry-After`) or before the fresh registration (a fresh attempt's schedule or `Retry-After`, a terminated -binding's wait); a reboot or another address starts clean. +binding's wait); a reboot or another PDN address starts clean. The journal says which (`…; next: fresh registration in N s`, `registering fresh (recovery attempt N)`, `fresh registration deferred/refused/failed: -…`, `bring-up deferred: …`). The reg-event subscription — the network's own +…`, `bring-up deferred: …`). One case leaves through a real restart +instead: when the address imsd is registered on has left the ims netdev +and the PDN's own address is not there to move to (the PDN went away) — +the journal shows `engine fatal: … — exiting for systemd restart`, +and after `RestartSec` (120 s) `ims-pdn-up.sh` runs again and imsd +registers on the PDN's address. A registration resumed on an older +address (a pre-0.3.10 temporary one) moves to the PDN's own address in +place: with its next fresh registration, or when the older address leaves +the netdev first, which takes that registration with it. The reg-event +subscription — the network's own view of the binding, one live dialog per registration, subscribed anew ahead of the lifetime the S-CSCF granted — is retried when refused or unanswered (60 s doubling to 10 min, a `Retry-After` honoured up to an hour: a registration refresh diff --git a/implementations/main.cpp b/implementations/main.cpp index d20e74c..155ab50 100644 --- a/implementations/main.cpp +++ b/implementations/main.cpp @@ -27,6 +27,7 @@ reach the bus via g_idle_add, where the calls table + status snapshot live #include #include #include +#include #include #include #include @@ -152,15 +153,54 @@ std::optional CompletedHex(std::string_view out) { return h; } -// global IPv6 on the ims PDN, from `ip -6 addr show dev scope global`. +// The UE address on the ims PDN when LOCAL is not given: a global IPv6 on +// that is neither temporary nor RA-derived (`dynamic`) — the one +// ims-pdn-up.sh assigned from ModemManager — else the first non-temporary +// one, else the first. The kernel lists the temporary (privacy) address +// first, and registering on it binds Contact, Via and the SAs to an address +// that is deprecated after a day and removed after two (ims ledger F18). std::optional DetectLocal(const std::string& dev) { auto r = RunCapture({"ip", "-6", "addr", "show", "dev", dev, "scope", "global"}); - std::size_t at = r.out.find("inet6 "); - if (at == std::string_view::npos) return std::nullopt; - std::size_t start = at + 6; - std::size_t end = r.out.find('/', start); - if (end == std::string_view::npos) return std::nullopt; - return r.out.substr(start, end - start); + std::optional first, nonTemporary, assigned; + std::size_t pos = 0; + while ((pos = r.out.find("inet6 ", pos)) != std::string::npos) { + std::size_t start = pos + 6; + pos = start; + std::size_t eol = r.out.find('\n', start); + if (eol == std::string::npos) eol = r.out.size(); + std::size_t slash = r.out.find('/', start); + if (slash == std::string::npos || slash > eol) continue; + std::string addr = r.out.substr(start, slash - start); + std::string_view flags = std::string_view(r.out).substr(slash, eol - slash); + bool temporary = flags.contains(" temporary"); + bool dynamic = flags.contains(" dynamic"); + if (!first) first = addr; + if (!temporary && !nonTemporary) nonTemporary = addr; + if (!temporary && !dynamic && !assigned) assigned = addr; + } + if (assigned) return assigned; + if (nonTemporary) return nonTemporary; + return first; +} + +// Whether `addr` is configured on `dev` now. Compared as bytes, so any +// spelling of the address matches; when the interface list cannot be read +// the answer is yes — nothing here acts on a guess that it is gone. +bool OnLink(const std::string& dev, const std::string& addr) { + int fam = Is6(addr) ? AF_INET6 : AF_INET; + unsigned char want[16] = {}; + if (inet_pton(fam, addr.c_str(), want) != 1) return false; + ifaddrs* ifs = nullptr; + if (getifaddrs(&ifs) != 0) return true; + bool found = false; + for (ifaddrs* i = ifs; i && !found; i = i->ifa_next) { + if (!i->ifa_addr || i->ifa_addr->sa_family != fam || dev != i->ifa_name) continue; + const void* have = fam == AF_INET6 ? static_cast(&reinterpret_cast(i->ifa_addr)->sin6_addr) + : static_cast(&reinterpret_cast(i->ifa_addr)->sin_addr); + found = std::memcmp(have, want, fam == AF_INET6 ? 16 : 4) == 0; + } + freeifaddrs(ifs); + return found; } // ---- sockaddr helper ------------------------------------------------------ @@ -511,6 +551,7 @@ public: for (int f : udp_) close(f); for (auto& c : conns_) close(c.fd); listeners_.clear(); udp_.clear(); conns_.clear(); + udpUc_ = -1; } private: struct Conn { int fd; imsd::sip::FrameBuffer fb; }; @@ -636,7 +677,7 @@ std::int64_t NowEpoch() { // The recovery policy's holds, kept for the next process: the network's // word (a Retry-After, the schedule after a refused or throttled fresh // attempt, a terminated binding's wait) outlives a restart of imsd. They -// are filed under the daemon's address — another one, or a reboot, starts +// are filed under the PDN's address — another one, or a reboot, starts // clean — so the file names that address and the boot, and // lives in /run. Seconds left at `written_at`, which is on the boot // clock: no clock step moves it, and it runs through a suspend as the @@ -823,7 +864,7 @@ public: } } - void SetLocal(std::string l) { local_ = std::move(l); } + void SetLocal(std::string l) { local_ = l; pdnLocal_ = std::move(l); } void SetDevMode(bool d) { devMode_ = d; } const std::string& Local() const { return local_; } @@ -848,7 +889,7 @@ public: } void Run() { - if (auto h = LoadHolds(local_)) { + if (auto h = LoadHolds(pdnLocal_)) { recovery_.Restore(Boot(), *h); auto kept = recovery_.Holds(Boot()); // what the policy took over: the file's values after the clamp if (kept.hold > 0 || kept.freshHold > 0) @@ -965,7 +1006,7 @@ private: return !quit_.load(); } - void KeepHolds() { PersistHolds(recovery_.Holds(Boot()), local_); } + void KeepHolds() { PersistHolds(recovery_.Holds(Boot()), pdnLocal_); } // dev/session mode without a modem: process commands so the ABI answers, // but there is no SIP socket — a Dial collapses to terminated/error. @@ -978,6 +1019,10 @@ private: // config std::string pcscf_, dev_, outDir_, local_, ealgOffer_, mediaBin_; + // The PDN's own address (LOCAL, else DetectLocal) — every fresh + // registration's. local_ differs from it only while a resume runs on the + // address an older registration was made on (TryResume). + std::string pdnLocal_; std::string sipPolicy_; // SIP_TRANSPORT: auto | tcp | udp Transport transport_ = Transport::Tcp; // the protected leg's transport in effect void SetTransport(Transport t) { transport_ = t; ctx_.transport = std::string(TransportName(t)); } @@ -1115,6 +1160,7 @@ private: // fresh registration waits recovery_.OldGone(); portPs_ = 0; + local_ = pdnLocal_; ctx_.local = local_; KeepHolds(); if (recovery_.Next(Boot()).at > Boot()) Log(std::format("the network's hold stands; {}", NextStep())); if (!WaitForPolicy()) throw Stopping{}; @@ -1125,6 +1171,25 @@ private: auto state = RunCapture({"ip", "xfrm", "state"}); auto policy = RunCapture({"ip", "xfrm", "policy"}); auto sa = imsd::ipsec::ParseExistingSa(state.out, policy.out, pcscf_, local_); + // The registration may sit on another address of the link: before + // 0.3.10 imsd registered on the kernel's temporary one (ims ledger + // F18). Resume there while it is still up — a restart keeps the + // registration for the price of a re-REGISTER, where a fresh one is + // an AKA run, a new SA pair and, when KPN refuses it, the old + // registration and a 21-minute wait (KPN refuses a fresh pair on an + // address whose registration is live and has accepted one on another + // address of the link meanwhile; other carriers' rules are unknown) — + // and move at the next fresh registration. + if (!sa) { + for (const std::string& other : imsd::ipsec::SaLocals(state.out, pcscf_)) { + if (other == local_ || !OnLink(dev_, other)) continue; + sa = imsd::ipsec::ParseExistingSa(state.out, policy.out, pcscf_, other); + if (!sa) continue; + Log(std::format("the registration's SAs are on {}, not the PDN's own address {}: resuming there until the next fresh registration", other, local_)); + local_ = other; ctx_.local = other; + break; + } + } if (!sa) return false; auto ps = LoadState(); if (!ps || !ps->callid) { Log("no reg state file; resume falls back to fresh"); return false; } @@ -1627,6 +1692,7 @@ private: Log("client flow dead; reconnecting"); sip_.Close(); if (!sip_.Connect(local_, PortUc, pcscf_, portPs_, transport_, /*tries=*/2)) { + if (AddressGone()) return; if (sip_.LastConnectLocal()) { if (registered_) { registered_ = false; EmitStatus(true); } recovery_.OnLocalRefusal(Boot()); @@ -1693,11 +1759,22 @@ private: // fails before the SA setup leaves the old registration and that // retry standing subCallid_.clear(); subPending_ = false; subRenewAt_ = 0; + // a registration resumed on an older address (a pre-0.3.10 temporary + // one) moves to the PDN's own address here, server ports included; + // an attempt that fails before the SA setup leaves that registration + // standing, so it moves back to it + std::string from = local_; + if (local_ != pdnLocal_) { + Log(std::format("the fresh registration moves from {} to the PDN's own address {}", local_, pdnLocal_)); + MoveTo(pdnLocal_); + } + bool ok = false; try { RefreshPani(); FreshRegister(); subRetryAt_ = 0; // a new registration: its subscription starts afresh OnRegistered(); + ok = true; Log("recovered by a fresh registration"); } catch (const ThrottledError& e) { FreshAttemptFailed(F::Throttled, 0, std::format("fresh registration deferred: {}", e.what())); @@ -1706,6 +1783,24 @@ private: } catch (const std::exception& e) { FreshAttemptFailed(F::Silent, 0, std::format("fresh registration failed: {}", e.what())); } + if (!ok && !quit_.load() && recovery_.Reconnectable() && local_ != from) { + if (OnLink(dev_, from)) { + Log(std::format("back on {}: the old registration stands until the next fresh attempt", from)); + MoveTo(from); + } else { + recovery_.OldGone(); + KeepHolds(); // the verdict outlives a restart like the hold it goes with + Log(std::format("{} has left {}: the registration on it is gone; staying on {}; {}", from, dev_, local_, NextStep())); + } + } + } + + // Our address for everything from here on: the flow, the server ports, + // the requests' Via and Contact, the media leg. + void MoveTo(const std::string& addr) { + server_.CloseAll(); + local_ = addr; ctx_.local = addr; + server_.Open(local_); } // After a failed fresh attempt, in recovery or at bring-up: the @@ -1713,12 +1808,45 @@ private: // the policy told, and its next step logged. void FreshAttemptFailed(imsd::engine::RegRecovery::Failure f, long retryAfter, const std::string& what) { CloseFlowAfterWait(); + if (!OnLink(dev_, local_)) Log(what); // said before the address's own line: a refusal is always in the journal + if (AddressGone()) return; bool wasReconnectable = recovery_.Reconnectable(); recovery_.OnFreshFailure(Boot(), f, retryAfter, saReplaced_); KeepHolds(); Log(std::format("{}{}; {}", what, wasReconnectable && !recovery_.Reconnectable() ? " — the old registration is gone" : "", NextStep())); } + // After a failed recovery step: has our address left the link? An + // older registration's address (a pre-0.3.10 temporary one that + // expired) takes that registration with it: imsd moves to the PDN's + // own address and the policy's fresh step runs there, every wait it + // holds included. The PDN's own address leaving means the PDN went + // away — no retry brings it back; a restart runs the PDN bring-up + // again. True when the step that failed has nothing left to do. + bool AddressGone() { + if (OnLink(dev_, local_)) return false; + if (local_ != pdnLocal_ && OnLink(dev_, pdnLocal_)) { + if (registered_) { registered_ = false; EmitStatus(true); } + std::string from = local_; + recovery_.OldGone(); + MoveTo(pdnLocal_); + KeepHolds(); // the caller returns before its own write + Log(std::format("{} is no longer on {}: the registration on it is gone; on the PDN's own address {} now; {}", from, dev_, local_, NextStep())); + return true; + } + RequestRestart(std::format("{} is no longer on {}", local_, dev_)); + return true; + } + + // Leave through systemd (Restart=on-failure), which runs ims-pdn-up.sh + // again and registers on the PDN's own address. + void RequestRestart(const std::string& why) { + quit_.store(true); + auto ev = std::make_unique(); + ev->type = Event::Type::Fatal; ev->text = why; + PostEvent(std::move(ev)); + } + // After a registration succeeds — bring-up, a keepalive refresh, a // reconnect + refresh, or a fresh recovery: the status (a signal only // when it changes), the timers, the recovery policy, the reg-event diff --git a/interfaces/Imsd-Ipsec.cppm b/interfaces/Imsd-Ipsec.cppm index fbbd514..611bb82 100644 --- a/interfaces/Imsd-Ipsec.cppm +++ b/interfaces/Imsd-Ipsec.cppm @@ -400,4 +400,17 @@ export namespace imsd::ipsec { sa.securityServer = std::format("ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=aes-cbc; " "spi-c={}; spi-s={}; port-c={}; port-s={}", sa.spiPc, sa.spiPs, sa.portPc, sa.portPs); return sa; } + + // Every local address holding an SA to the P-CSCF, once each, in kernel + // order: where a registration made on another address of the link (the + // kernel's temporary one, before 0.3.10 — ims ledger F18) still lives. + std::vector SaLocals(std::string_view stateText, std::string_view pcscf) { + std::vector locals; + for (std::string_view b : detail::SrcBlocks(stateText)) { + std::string_view src = detail::TokenAfter(b, "src "); + if (src.empty() || detail::TokenAfter(b, "dst ") != pcscf) continue; + if (std::ranges::find(locals, src) == locals.end()) locals.emplace_back(src); + } + return locals; + } } diff --git a/packaging/ims-pdn-up.sh b/packaging/ims-pdn-up.sh index fa6292c..a20068e 100644 --- a/packaging/ims-pdn-up.sh +++ b/packaging/ims-pdn-up.sh @@ -297,9 +297,13 @@ case "$PCSCF" in *) log "PCSCF '$PCSCF' is not an IPv6 address; the ims PDN is brought up IPv6-only"; exit 1 ;; esac -# ---- hand the connected ims netdev to imsd (imsd.service reads -# /run/imsd.env) so the daemon carries no baked-in interface name -echo "DEV=$IFACE" > /run/imsd.env +# ---- hand the connected ims netdev and the PDN's own address to imsd +# (imsd.service reads /run/imsd.env): the daemon carries no baked-in +# interface name and registers on the address the network assigned — not +# on the kernel's temporary SLAAC address from the RA, which the netdev +# lists first and which is deprecated after a day and removed after two +# (ims ledger F18; the SAs and the Contact would go with it) +printf 'DEV=%s\nLOCAL=%s\n' "$IFACE" "$ADDR" > /run/imsd.env # ---- open the IMS protected ports in the firewall (rung 5b root cause, # journal/ims.md s56): pmOS's default nftables INPUT chain is policy-drop and diff --git a/packaging/imsd.service b/packaging/imsd.service index add1594..17b88a0 100644 --- a/packaging/imsd.service +++ b/packaging/imsd.service @@ -14,7 +14,8 @@ Type=simple # gate + connect retries), hence TimeoutStartSec ExecStartPre=/usr/libexec/ims-pdn-up.sh TimeoutStartSec=900 -# /run/imsd.env: written by ims-pdn-up.sh (DEV= the connected ims netdev). +# /run/imsd.env: written by ims-pdn-up.sh (DEV= the connected ims netdev, +# LOCAL= the PDN address ModemManager assigned to it). # /etc/imsd.env: admin configuration — PCSCF= is REQUIRED (the carrier's # P-CSCF address; see the README's Configuration section) and wins over the # runtime-derived values. @@ -24,7 +25,8 @@ EnvironmentFile=-/etc/imsd.env StateDirectory=imsd ExecStart=/usr/bin/imsd # registration: resume an existing SA when possible (avoids fresh-SA -# throttles); a failed bring-up exits nonzero and systemd retries +# throttles); a failed bring-up exits nonzero and systemd retries, as does +# a registered imsd whose PDN address left the netdev Restart=on-failure RestartSec=120 # SIGTERM: engine keeps the SA + registration state for the next resume diff --git a/tests/Ipsec/main.cpp b/tests/Ipsec/main.cpp index 4132a3d..fcb1d89 100644 --- a/tests/Ipsec/main.cpp +++ b/tests/Ipsec/main.cpp @@ -80,6 +80,14 @@ int main() { } // incomplete input -> nullopt Check(!ParseExistingSa("src a dst b\n\tspi 0x1 reqid 1 mode transport\n", "", "b", "a").has_value(), "missing pieces -> nullopt"); + { + // a registration still on another address of the link, plus an SA to + // another P-CSCF that is none of ours + std::string two = std::format("{}src 2001:db8:1111:2222:aaaa:bbbb:cccc:dddd dst 2001:db8::105\n\tproto esp spi 0x00000001 reqid 1 mode transport\nsrc 2001:db8:1111:2222:eeee:ffff:0:1 dst 2001:db8::999\n\tproto esp spi 0x00000002 reqid 1 mode transport\n", State); + auto locals = SaLocals(two, "2001:db8::105"); + Check(locals.size() == 2 && locals[0] == "2001:db8:1111:2222:3333:4444:5555:db43" && locals[1] == "2001:db8:1111:2222:aaaa:bbbb:cccc:dddd", "SaLocals: every local address with an SA to the P-CSCF, once each, in kernel order"); + Check(SaLocals(State, "2001:db8::999").empty(), "SaLocals: no SA to that P-CSCF -> none"); + } // ---- fresh setup command sequence { From 0a9105063578c73f668b4a6e397941f1f6ceccc7 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Fri, 25 Sep 2026 17:15:02 +0200 Subject: [PATCH 06/15] imsd, media: aim call audio at logind's seat0 session, not an account named `user` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit imsd-media defaulted AUDIO_USER to `user`, postmarketOS's default account name, and with no such account a call had no audio on the phone and noise at the far end, the message only in imsd-media's stderr, at each call. A field user found it by trial (alyx report 14, 2026-09-20; ims ledger F14). Have imsd, as packaged (root, on the system bus), resolve the session from logind when a call's media starts — the user session active on seat0 (a greeter is not one) when it has a PipeWire socket — and hand it to imsd-media as AUDIO_USER; set in imsd's own environment to an account's name, AUDIO_USER pins that account instead. imsd as packaged (root, on the system bus) logs the resolution at start, before the first call, and when a call's media starts. No session is an empty AUDIO_USER. imsd-media started without the variable at all keeps `user`: that is an imsd older than 0.3.10, still running while an upgrade replaces the helper under it, whose calls would otherwise be silent until the restart, or a hand run. Tested on the dev phone (KPN) in builds of this series; not every path has run there. --- README.md | 6 ++-- implementations/main.cpp | 70 +++++++++++++++++++++++++++++++++++++++ implementations/media.cpp | 55 +++++++++++++++++++----------- 3 files changed, 108 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 86220c8..b32708f 100644 --- a/README.md +++ b/README.md @@ -146,8 +146,8 @@ busctl --user call net.catcrafts.IMS1 /net/catcrafts/IMS1 net.catcrafts.IMS1 Get On a phone the daemon runs as root on the system bus (policy in `packaging/net.catcrafts.IMS1.conf` — it grants the postmarketOS default -account `user` access; adjust it alongside `AUDIO_USER` if your session -account is named differently), started by `packaging/imsd.service` +account `user` access; adjust it if your session account is named +differently), started by `packaging/imsd.service` after `packaging/ims-pdn-up.sh` has brought up the ims PDN through ModemManager. The bring-up never lets the ims PDN become the phone's IPv6 default route: the PDN's router advertisement is not accepted as a default @@ -354,7 +354,7 @@ its own `retry-after`). | Variable | Default | Meaning | |---|---|---| | `MIC`, `PLAY` | `1`, `1` | uplink mic capture / downlink playout via PipeWire | -| `AUDIO_USER` | `user` | desktop user whose PipeWire session carries call audio (postmarketOS default account) | +| `AUDIO_USER` | *(auto)* | account whose PipeWire session carries call audio. imsd sets it for each call from logind: the user session active on seat0 (`loginctl show-seat seat0`, a greeter does not count) when it has a `/run/user//pipewire-0` socket. Set in imsd's own environment it pins an account instead. imsd as packaged (root, on the system bus) logs the resolution at start and when a call's media starts (`audio session: …`), imsd-media once per call. imsd-media started without the variable at all (by an imsd older than 0.3.10 while an upgrade replaces it, or by hand) tries the account `user`, its old default. | | `GAIN`, `PLAY_GAIN` | `10`, `1.0` | uplink / downlink gain | | `AMR_MODE` | `2` | AMR-WB encoder mode (0–8) | | `DTX` | `0` | encoder discontinuous transmission | diff --git a/implementations/main.cpp b/implementations/main.cpp index 155ab50..c644053 100644 --- a/implementations/main.cpp +++ b/implementations/main.cpp @@ -31,6 +31,7 @@ reach the bus via g_idle_add, where the calls table + status snapshot live #include #include #include +#include #include #include #include @@ -203,6 +204,64 @@ bool OnLink(const std::string& dev, const std::string& addr) { 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" : "")); +} + // ---- sockaddr helper ------------------------------------------------------ socklen_t MakeAddr(std::string_view ip, int port, sockaddr_storage& ss) { std::memset(&ss, 0, sizeof ss); @@ -2195,8 +2254,18 @@ private: 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); @@ -2384,6 +2453,7 @@ int main(int argc, char** argv) { return 1; } Log(std::format("LOCAL={} P-CSCF=[{}]:{}", local, EnvOr("PCSCF", "(unset)"), EnvOr("PCSCF_PORT", "5060"))); + if (!session) LogAudioSession(); Engine engine; engine.SetLocal(local); diff --git a/implementations/media.cpp b/implementations/media.cpp index b7d5964..7f37fa7 100644 --- a/implementations/media.cpp +++ b/implementations/media.cpp @@ -410,41 +410,56 @@ std::size_t RtpPayloadOffset(std::span pkt) { return off; } -// The desktop user's uid, resolved ONCE and thread-safely. getpwnam() hands -// out one static buffer, and the mic and playout threads spawn their pw -// helpers at the same instant — the race sent both into /run/user/0, where -// no PipeWire session lives, and a call was answered into static (dev phone, -// 2026-09-08 20:01: tx_mic=0 rx_played=0 qdrop=883). Logged per call so a -// journal shows which session the helpers were aimed at. -uid_t AudioUid() { +// The desktop session whose PipeWire instance carries call audio, resolved +// ONCE and thread-safely: getpwnam() hands out one static buffer, and the +// mic and playout threads spawn their pw helpers at the same instant — the +// race sent both into /run/user/0, where no PipeWire session lives, and a +// call was answered into static (dev phone, 2026-09-08 20:01: tx_mic=0 +// rx_played=0 qdrop=883). AUDIO_USER names the account: imsd sets it per +// call from logind's seat0 session (or its own AUDIO_USER), and sets it +// empty when nobody is logged in. The packaged default used to be +// postmarketOS's account name `user`: a unit whose account was called +// otherwise got no audio and no message (alyx report 14, 2026-09-20). +// That default stays for a caller that does not set the variable at all — +// an imsd older than 0.3.10, still running while an upgrade replaces this +// binary, or a hand run — so its calls keep their audio. +// Logged per call so a journal shows which session the helpers were aimed at. +struct AudioSession { uid_t uid = 0; std::string name; }; + +const AudioSession& Session() { static std::once_flag once; - static uid_t uid = 0; + static AudioSession s; std::call_once(once, [] { - std::string user = EnvOr("AUDIO_USER", "user"); passwd pw{}; passwd* res = nullptr; char buf[4096]; - if (getpwnam_r(user.c_str(), &pw, buf, sizeof buf, &res) == 0 && res) uid = res->pw_uid; + const char* given = std::getenv("AUDIO_USER"); + std::string want = given ? given : "user"; + if (!given) std::println(std::cerr, "imsd-media: no AUDIO_USER in the environment (a caller older than 0.3.10, or a hand run); trying the account `user`"); + if (want.empty()) + std::println(std::cerr, "imsd-media: AUDIO_USER empty (no session to aim at); the helpers run as uid 0 (no audio)"); + else if (getpwnam_r(want.c_str(), &pw, buf, sizeof buf, &res) == 0 && res) { s.uid = res->pw_uid; s.name = want; } else - std::println(std::cerr, "imsd-media: AUDIO_USER '{}' not found; no PipeWire session for uid 0", user); - std::println(std::cerr, "imsd-media: audio session: {} -> /run/user/{}", user, uid); + std::println(std::cerr, "imsd-media: AUDIO_USER '{}' is not an account; the helpers run as uid 0 (no audio)", want); + std::println(std::cerr, "imsd-media: audio session: {} -> /run/user/{}", s.name.empty() ? std::string("(none)") : s.name, s.uid); }); - return uid; + return s; } -// Spawn pw-record/pw-play in the desktop user's PipeWire session (sudo -u -// when run as root). AUDIO_USER names the session owner; the default "user" -// is postmarketOS's standard account. `toChild` true = we write the child's -// stdin (pw-play); false = we read its stdout (pw-record). Returns {pid, fd}. +// Spawn pw-record/pw-play in the desktop session's PipeWire instance (sudo -u +// when run as root; as ourselves when there is no session to aim at). +// `toChild` true = we write the child's stdin (pw-play); false = we read its +// stdout (pw-record). Returns {pid, fd}. struct Child { pid_t pid = -1; int fd = -1; }; Child SpawnPw(bool play, bool toChild, int rate) { int pipefd[2]; if (pipe(pipefd) != 0) return {}; std::vector argv; if (geteuid() == 0) { - std::string user = EnvOr("AUDIO_USER", "user"); - argv = {"sudo", "-u", user, "env", - std::format("XDG_RUNTIME_DIR=/run/user/{}", AudioUid())}; + const AudioSession& session = Session(); + if (!session.name.empty()) + argv = {"sudo", "-u", session.name, "env", + std::format("XDG_RUNTIME_DIR=/run/user/{}", session.uid)}; } const char* tool = play ? "pw-play" : "pw-record"; const char* lat = play ? "40ms" : "20ms"; From 2fe991a14ea57426049c688881372726a2ce1558 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sun, 4 Oct 2026 00:09:37 +0200 Subject: [PATCH 07/15] packaging: restart imsd and imsd-ofonod on upgrade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Makefile | 7 ++- README.md | 29 ++++++++-- implementations/main.cpp | 31 +++++----- packaging/APKBUILD | 3 + packaging/APKBUILD.binary | 12 +++- packaging/build-package.sh | 2 +- packaging/imsd-handover.sh | 83 +++++++++++++++++++++++++++ packaging/imsd-ofono-restart-calls.sh | 24 ++++++++ packaging/imsd-ofono.post-install | 1 + packaging/imsd-ofono.post-upgrade | 21 +++++++ packaging/imsd-ofonod.service | 8 ++- packaging/imsd-systemd.post-install | 1 + packaging/imsd-systemd.post-upgrade | 23 ++++++++ packaging/imsd.post-install | 1 + packaging/imsd.post-upgrade | 48 ++++++++++++++++ packaging/imsd.service | 5 ++ packaging/make-bin-tarball.sh | 3 +- 17 files changed, 275 insertions(+), 27 deletions(-) create mode 100755 packaging/imsd-handover.sh create mode 100755 packaging/imsd-ofono-restart-calls.sh create mode 120000 packaging/imsd-ofono.post-install create mode 100755 packaging/imsd-ofono.post-upgrade create mode 120000 packaging/imsd-systemd.post-install create mode 100755 packaging/imsd-systemd.post-upgrade create mode 120000 packaging/imsd.post-install create mode 100755 packaging/imsd.post-upgrade diff --git a/Makefile b/Makefile index a72e15e..149d266 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,8 @@ # make check -> build + run the 7 unit-test suites # make install -> DESTDIR/PREFIX staged install (binaries + systemd # units, D-Bus policies, dialerd autostart, the GNOME -# Calls plugin override, ims-pdn-up.sh) +# Calls plugin override, the imsd-ofono preset, and the libexec scripts +# ims-pdn-up.sh, imsd-handover.sh, imsd-ofono-restart-calls.sh) # # Requires clang++ with libc++ and the libc++ std module sources # (std.cppm — Alpine: llvm-runtimes, Arch: libc++). Override STD_CPPM if @@ -133,6 +134,8 @@ install: all install -Dm755 $(O)/imsd-media $(DESTDIR)$(PREFIX)/libexec/imsd-media install -Dm755 packaging/ims-pdn-up.sh \ $(DESTDIR)$(PREFIX)/libexec/ims-pdn-up.sh + install -Dm755 packaging/imsd-handover.sh \ + $(DESTDIR)$(PREFIX)/libexec/imsd-handover.sh install -Dm644 packaging/imsd.service \ $(DESTDIR)$(PREFIX)/lib/systemd/system/imsd.service install -Dm644 packaging/imsd-dialerd.desktop \ @@ -141,6 +144,8 @@ install: all $(DESTDIR)$(PREFIX)/share/dbus-1/system.d/net.catcrafts.IMS1.conf install -Dm644 packaging/imsd-ofonod.service \ $(DESTDIR)$(PREFIX)/lib/systemd/system/imsd-ofonod.service + install -Dm755 packaging/imsd-ofono-restart-calls.sh \ + $(DESTDIR)$(PREFIX)/libexec/imsd-ofono-restart-calls.sh install -Dm644 packaging/org.ofono.conf \ $(DESTDIR)$(PREFIX)/share/dbus-1/system.d/imsd-ofono.conf install -Dm644 packaging/90_imsd-ofono.gschema.override \ diff --git a/README.md b/README.md index b32708f..08cfc8a 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,11 @@ On a phone the daemon runs as root on the system bus (policy in account `user` access; adjust it if your session account is named differently), started by `packaging/imsd.service` after `packaging/ims-pdn-up.sh` has brought up the ims PDN through -ModemManager. The bring-up never lets the ims PDN become the phone's IPv6 +ModemManager. An apk upgrade restarts `imsd` and `imsd-ofonod` once the +transaction is done (a call in progress ends), as does adding a package +again over the daemon an `apk del` left running; the restart resumes the +registration over its existing SAs, or goes on waiting out a hold the +network had set. The bring-up never lets the ims PDN become the phone's IPv6 default route: the PDN's router advertisement is not accepted as a default router (`accept_ra_defrtr=0` on the netdev; its prefix and MTU still apply) and the P-CSCF gets a host route instead, so on IPv4-only wifi the phone's @@ -227,7 +231,7 @@ Known gaps, all inherited rather than introduced: normal dialpad is unaffected — that reaches imsd's own classifier. - DTMF is forwarded to imsd, where `SendDtmf` is still a stub (RFC 4733 is not implemented yet) — the same limitation Plasma has. -- **Restart GNOME Calls after restarting `imsd-ofonod` itself.** When the +- **Restarting `imsd-ofonod` restarts GNOME Calls with it.** When the modem's `VoiceCallManager` interface goes away and comes back, Calls 50.0 adds a new origin but leaves the old origin's `CallAdded` handler connected: after one such cycle every call is added twice, after a few the stale handler @@ -237,7 +241,10 @@ Known gaps, all inherited rather than introduced: restarts and registration drops from triggering it, `imsd-ofonod` lists the interface permanently: `Online` tracks registration, and `Dial` fails with "imsd is not registered" / "imsd is not running" instead. Only `org.ofono` - vanishing — this daemon restarting — still cycles the origin. + vanishing — this daemon restarting — still cycles the origin, and after + that Calls dials nothing until it restarts, so the unit's `ExecStartPost` + restarts the Calls daemon of every user running one. Its first start + since boot leaves a running Calls alone. Development, without a phone or root: @@ -311,12 +318,24 @@ refused or throttled fresh attempt); imsd-ofonod refuses a `Dial` then, and imsd-dialerd lists no modem. A bring-up the network deferred waits the same schedule out with nothing to reconnect over (the resume just found the stored SAs unusable), then tries again. The network's holds outlive a -restart of imsd: they are kept in +restart of imsd (an upgrade restarts it): they are kept in `/run/imsd.holds` for the PDN they were set on, with the schedule's place (a restart does not start it over at 21 min), and waited out at the next start, before the resume (a refresh's `Retry-After`) or before the fresh registration (a fresh attempt's schedule or `Retry-After`, a terminated -binding's wait); a reboot or another PDN address starts clean. +binding's wait); a reboot or another PDN address starts clean. An upgrade from a daemon +older than 0.3.10, which kept its bring-up throttle wait in memory only, +hands that wait over the same way when this start registers on the address +it was earned on (the fresh-SA throttle's wait, `spi-s=0`; the old daemon's +other deferrals, after a 401 it could not use, are not handed over): the +upgrade records that daemon's invocation, and the next +start reads its journal lines once the daemon has stopped — a wait that +ended meanwhile is read as it ended; on another address (the normal hop: +0.3.9 sat on the kernel's temporary address) only the verdict that the old +registration is gone is kept and the start registers fresh at once. When +the old address cannot be read the wait is handed over anyway; when the wait +cannot be timed, or this start has no `LOCAL`, the journal says it was not +handed over. The journal says which (`…; next: fresh registration in N s`, `registering fresh (recovery attempt N)`, `fresh registration deferred/refused/failed: …`, `bring-up deferred: …`). One case leaves through a real restart diff --git a/implementations/main.cpp b/implementations/main.cpp index c644053..2d5e318 100644 --- a/implementations/main.cpp +++ b/implementations/main.cpp @@ -735,14 +735,14 @@ std::int64_t NowEpoch() { // The recovery policy's holds, kept for the next process: the network's // word (a Retry-After, the schedule after a refused or throttled fresh -// attempt, a terminated binding's wait) outlives a restart of imsd. They -// are filed under the 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. +// 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. std::int64_t BootSeconds() { timespec ts{}; clock_gettime(CLOCK_BOOTTIME, &ts); @@ -1232,13 +1232,14 @@ private: auto sa = imsd::ipsec::ParseExistingSa(state.out, policy.out, pcscf_, local_); // The registration may sit on another address of the link: before // 0.3.10 imsd registered on the kernel's temporary one (ims ledger - // F18). Resume there while it is still up — a restart keeps the - // registration for the price of a re-REGISTER, where a fresh one is - // an AKA run, a new SA pair and, when KPN refuses it, the old - // registration and a 21-minute wait (KPN refuses a fresh pair on an - // address whose registration is live and has accepted one on another - // address of the link meanwhile; other carriers' rules are unknown) — - // and move at the next fresh registration. + // 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; diff --git a/packaging/APKBUILD b/packaging/APKBUILD index b85b738..0621cb7 100644 --- a/packaging/APKBUILD +++ b/packaging/APKBUILD @@ -24,6 +24,8 @@ provides="81voltd=$pkgver-r$pkgrel" # Calls at the ofono provider. A Plasma Mobile phone wants imsd-dialerd, which # is in the main package, and must NOT get that override. subpackages="$pkgname-ofono:ofono" +# restart the running daemons onto an upgrade (systemd's enqueue-marked) +install="$pkgname.post-upgrade $pkgname.post-install $pkgname-ofono.post-upgrade $pkgname-ofono.post-install" source="$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz" builddir="$srcdir/$pkgname" @@ -43,6 +45,7 @@ ofono() { pkgdesc="GNOME Calls (Phosh, GNOME Mobile) backend for imsd" depends="$pkgname=$pkgver-r$pkgrel calls" amove usr/bin/imsd-ofonod + amove usr/libexec/imsd-ofono-restart-calls.sh amove usr/lib/systemd/system/imsd-ofonod.service amove usr/share/dbus-1/system.d/imsd-ofono.conf amove usr/share/glib-2.0/schemas/90_imsd-ofono.gschema.override diff --git a/packaging/APKBUILD.binary b/packaging/APKBUILD.binary index 486f4bc..8582c39 100644 --- a/packaging/APKBUILD.binary +++ b/packaging/APKBUILD.binary @@ -5,8 +5,9 @@ # README's "Cross-compiling" section into a proper apk — used by this repo's # package CI (packaging/build-package.sh, which also seds pkgver from # implementations/main.cpp) and runnable by hand. The source tarball is -# produced by packaging/make-bin-tarball.sh; the desktop/preset files live -# next to it in packaging/ (copy them beside this file). Since 2026-09-02 +# produced by packaging/make-bin-tarball.sh; the desktop/preset files and +# the install scripts (*.post-upgrade, *.post-install) live next to it in +# packaging/ (copy them beside this file). Since 2026-09-02 # this is the only imsd packaging: fp6-img images install the apk this # builds from the registry rather than building their own. pkgname=imsd @@ -29,6 +30,9 @@ provides="81voltd=$pkgver-r$pkgrel" # (ofono before systemd: systemd() amoves all of usr/lib/systemd/system, # so ofono() must take imsd-ofonod.service first) subpackages="$pkgname-ofono:ofono $pkgname-systemd" +# restart the running daemons onto an upgrade (systemd's enqueue-marked); the +# systemd subpackage marks imsd.service too, since it brings the unit file +install="$pkgname.post-upgrade $pkgname.post-install $pkgname-ofono.post-upgrade $pkgname-ofono.post-install $pkgname-systemd.post-upgrade $pkgname-systemd.post-install" options="!check !tracedeps" source=" imsd-$pkgver.tar.gz @@ -42,6 +46,7 @@ package() { install -Dm755 imsd-media "$pkgdir"/usr/libexec/imsd-media install -Dm755 imsd-dialerd "$pkgdir"/usr/bin/imsd-dialerd install -Dm755 ims-pdn-up.sh "$pkgdir"/usr/libexec/ims-pdn-up.sh + install -Dm755 imsd-handover.sh "$pkgdir"/usr/libexec/imsd-handover.sh install -Dm644 imsd.service "$pkgdir"/usr/lib/systemd/system/imsd.service install -Dm644 imsd-dialerd.desktop \ "$pkgdir"/etc/xdg/autostart/imsd-dialerd.desktop @@ -64,6 +69,8 @@ package() { printf '[Unit]\nConditionPathExists=/etc/imsd.env\n' \ > "$pkgdir"/usr/lib/systemd/system/imsd.service.d/10-require-config.conf install -Dm755 imsd-ofonod "$pkgdir"/usr/bin/imsd-ofonod + install -Dm755 imsd-ofono-restart-calls.sh \ + "$pkgdir"/usr/libexec/imsd-ofono-restart-calls.sh install -Dm644 imsd-ofonod.service \ "$pkgdir"/usr/lib/systemd/system/imsd-ofonod.service install -Dm644 org.ofono.conf \ @@ -78,6 +85,7 @@ ofono() { pkgdesc="GNOME Calls (Phosh, GNOME Mobile) backend for imsd" depends="$pkgname=$pkgver-r$pkgrel calls" amove usr/bin/imsd-ofonod + amove usr/libexec/imsd-ofono-restart-calls.sh amove usr/lib/systemd/system/imsd-ofonod.service amove usr/share/dbus-1/system.d/imsd-ofono.conf amove usr/share/glib-2.0/schemas/90_imsd-ofono.gschema.override diff --git a/packaging/build-package.sh b/packaging/build-package.sh index 75a0aeb..a089cac 100755 --- a/packaging/build-package.sh +++ b/packaging/build-package.sh @@ -94,7 +94,7 @@ rm -rf "$PKG" mkdir -p "$PKG" cp "$SRC/packaging/APKBUILD.binary" "$PKG/APKBUILD" cp "$SRC/packaging/org.kde.modem.daemon.desktop" \ - "$SRC/packaging/80-imsd.preset" "$PKG/" + "$SRC/packaging/80-imsd.preset" "$SRC"/packaging/*.post-upgrade "$SRC"/packaging/*.post-install "$PKG/" mv "imsd-$VER.tar.gz" "$PKG/" sed -i "s/^pkgver=.*/pkgver=$VER/" "$PKG/APKBUILD" # a throwaway signing key: phones trust the registry-signed APKINDEX, not diff --git a/packaging/imsd-handover.sh b/packaging/imsd-handover.sh new file mode 100755 index 0000000..02a907d --- /dev/null +++ b/packaging/imsd-handover.sh @@ -0,0 +1,83 @@ +#!/bin/sh +# SPDX-License-Identifier: GPL-3.0-only +# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +# +# ExecStartPre of imsd.service, after ims-pdn-up.sh: hand a daemon older +# than 0.3.10 its bring-up throttle wait over. Such a daemon kept the wait +# in memory only, so the restart an upgrade makes registered fresh at once +# instead of waiting the schedule out. imsd.post-upgrade +# records the running daemon's invocation in /run/imsd.handover; here, with +# that daemon stopped and its journal final, the invocation's last bring-up +# line is read and, when it is the old throttle's `bring-up deferred: +# fresh-SA throttle active (spi-s=0) — … next fresh registration in +# N min`, what is left of the N min is written to +# the holds file (/run/imsd.holds, or HOLDS_FILE) the way 0.3.10 keeps +# it — with the verdict that the old registration is gone: that line +# follows a throttled initial REGISTER, at which KPN drops the +# registration, so the new start does not resume over the written-off SAs. +# The old daemon deferred the same way after a 401 it could not use (no +# ipsec-3gpp mechanism, an unsupported algorithm); nothing says the +# network dropped the registration then, and that wait is not handed over. +# The wait itself is handed over only for the address it was earned on +# (the old daemon logged its LOCAL once at start): KPN's refusal is the +# address's — a fresh pair is refused where a registration is live or has +# just gone — and on the normal hop the old daemon sat on the kernel's +# temporary address while this start registers on the PDN's own, which +# holds none: then only the verdict is written and the new start registers +# fresh at once (should the network refuse it there, the daemon's own +# schedule takes over from its start: the old daemon's place in it was +# the other address's). +# When the old address cannot be read the wait is handed over anyway. +# Reading at the start, not at the upgrade, means a wait that +# ended between the two — the old daemon making another attempt — is +# read as it ended. +set -u +marker=/run/imsd.handover +[ -r "$marker" ] || exit 0 +inv=$(cat "$marker"); rm -f "$marker" +[ -n "$inv" ] || exit 0 +holds=${HOLDS_FILE:-/run/imsd.holds} # the holds file as imsd reads it (HoldsPath) +[ -e "$holds" ] && exit 0 # a 0.3.10 or later daemon kept its own holds +line=$(journalctl -u imsd.service "_SYSTEMD_INVOCATION_ID=$inv" -o json --no-pager 2>/dev/null \ + | grep -E '"MESSAGE" *: *"imsd: (bring-up deferred: |REGISTERED|FRESH registration|a hold the network)' | tail -n 1) +case "$line" in *'imsd: bring-up deferred: fresh-SA throttle active (spi-s=0)'*) ;; *) exit 0 ;; esac +mins=$(printf '%s\n' "$line" | sed -n 's/.*next fresh registration in \([0-9][0-9]*\) min.*/\1/p') +# the line's age on the monotonic clock (journald stamps every entry with it; +# /proc/timer_list reads it now), which no clock step moves — this phone's +# wall clock reads 1970 until its first NTP sync. It stops in a suspend, so +# the age can only come out short and the wait handed over long, never +# short of the network's window. +at=$(printf '%s\n' "$line" | sed -n 's/.*"__MONOTONIC_TIMESTAMP" *: *"\([0-9][0-9]*\)".*/\1/p') +now=$(sed -n 's/^now at \([0-9][0-9]*\) nsecs$/\1/p' /proc/timer_list 2>/dev/null | head -n 1) +if [ -z "$mins" ] || [ -z "$at" ] || [ -z "$now" ]; then + echo "imsd-handover: the previous daemon waited for a fresh registration, but its wait could not be timed: NOT handed over" + exit 0 +fi +left=$((mins * 60 - (now / 1000000000 - at / 1000000))) +[ "$left" -gt 0 ] || exit 0 +# the address this start registers on, as systemd hands it to imsd: the unit +# reads its EnvironmentFile= lines anew for every command it spawns, so LOCAL +# here is the /etc/imsd.env pin when there is one, else the one ims-pdn-up.sh +# just wrote to /run/imsd.env — unquoted by systemd's parser, the same bytes +# imsd compares the holds file's pdn with. The file read is for a run outside +# the unit. +pdn=${LOCAL:-$(sed -n 's/^LOCAL=//p' /run/imsd.env 2>/dev/null | tail -n 1)} +if [ -z "$pdn" ]; then + echo "imsd-handover: the previous daemon waited $mins min for a fresh registration, but this start has no LOCAL: NOT handed over" + exit 0 +fi +# the address the wait was earned on: the old daemon's own start line +old=$(journalctl -u imsd.service "_SYSTEMD_INVOCATION_ID=$inv" -o cat --no-pager 2>/dev/null \ + | sed -n 's/^imsd: LOCAL=\([^ ]*\) P-CSCF=.*/\1/p' | tail -n 1) +boot=$(cat /proc/sys/kernel/random/boot_id); now_s=$(cut -d. -f1 /proc/uptime) +if [ -n "$old" ] && [ "$old" != "$pdn" ]; then + printf '{"pdn": "%s", "boot": "%s", "written_at": %s, "hold": 0, "fresh_hold": 0, "old_gone": true}\n' \ + "$pdn" "$boot" "$now_s" > "$holds" \ + && echo "imsd-handover: the previous daemon's fresh-registration wait ($left s of $mins min left) was earned on $old, not this start's $pdn: only the verdict that its registration is gone is handed over" + exit 0 +fi +tm=$((mins * 3 / 2)); [ "$tm" -gt 60 ] && tm=60 +printf '{"pdn": "%s", "boot": "%s", "written_at": %s, "hold": 0, "fresh_hold": %s, "throttle_minutes": %s, "old_gone": true}\n' \ + "$pdn" "$boot" "$now_s" "$left" "$tm" > "$holds" \ + && echo "imsd-handover: the previous daemon's fresh-registration wait ($left s of $mins min left) handed over for $pdn$([ -n "$old" ] && echo " (its own address)" || echo "; its address could not be read")" +exit 0 diff --git a/packaging/imsd-ofono-restart-calls.sh b/packaging/imsd-ofono-restart-calls.sh new file mode 100755 index 0000000..44ee402 --- /dev/null +++ b/packaging/imsd-ofono-restart-calls.sh @@ -0,0 +1,24 @@ +#!/bin/sh +# SPDX-License-Identifier: GPL-3.0-only +# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +# +# ExecStartPost of imsd-ofonod.service. GNOME Calls 50.0 keeps a stale modem +# when org.ofono vanishes and dials nothing until it is restarted (README, +# Known gaps), so every start of imsd-ofonod after org.ofono went away +# restarts the Calls daemon of each user running one. Its first start since +# boot is not one: no Calls has seen org.ofono go, and a Calls that runs +# already (a login during the bring-up) is left alone. The unit's own +# timestamp tells the first start from a restart (an upgrade from 0.3.9 is +# a restart); the marker covers a stop and a later start. +marker=/run/imsd-ofonod.started +if [ ! -e "$marker" ] && [ "$(systemctl show -P ActiveExitTimestampMonotonic imsd-ofonod.service)" = 0 ]; then + : > "$marker" + echo "imsd-ofono-restart-calls: first start since boot, Calls left alone" + exit 0 +fi +: > "$marker" +for uid in $(systemctl list-units 'user@*.service' --legend=no --plain | sed -n 's/^user@\([0-9]*\)\.service.*/\1/p'); do + echo "imsd-ofono-restart-calls: restarting the Calls daemon of uid $uid, if it runs" + systemctl --user -M "$uid@" try-restart --no-block calls-daemon.service || : +done +exit 0 diff --git a/packaging/imsd-ofono.post-install b/packaging/imsd-ofono.post-install new file mode 120000 index 0000000..a5acf7b --- /dev/null +++ b/packaging/imsd-ofono.post-install @@ -0,0 +1 @@ +imsd-ofono.post-upgrade \ No newline at end of file diff --git a/packaging/imsd-ofono.post-upgrade b/packaging/imsd-ofono.post-upgrade new file mode 100755 index 0000000..a1edb48 --- /dev/null +++ b/packaging/imsd-ofono.post-upgrade @@ -0,0 +1,21 @@ +#!/bin/sh +# SPDX-License-Identifier: GPL-3.0-only +# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +# +# As imsd.post-upgrade, for imsd-ofonod: restarted once apk is done, and +# its ExecStartPost restarts GNOME Calls with it. Also installed as +# imsd-ofono.post-install, for a package added again over the daemon an +# `apk del` left running. + +# A unit whose file an apk del removed is not-found to systemd until a +# reload and refuses properties ("failed to load properly"); its file is +# back on disk by the time post-install runs, so reload and mark again. +mark() { + systemctl set-property "$1" Markers=+needs-restart 2>/dev/null && return 0 + systemctl daemon-reload && systemctl set-property "$1" Markers=+needs-restart || : +} + +if [ -d /run/systemd/system ] && systemctl is-active -q imsd-ofonod.service; then + mark imsd-ofonod.service +fi +exit 0 diff --git a/packaging/imsd-ofonod.service b/packaging/imsd-ofonod.service index be308ee..c8eb4a3 100644 --- a/packaging/imsd-ofonod.service +++ b/packaging/imsd-ofonod.service @@ -3,8 +3,9 @@ [Unit] Description=oFono-compatible GNOME Calls backend for imsd Documentation=https://forgejo.catcrafts.net/Catcrafts/imsd -# org.ofono is a system-bus name, so this is a system service; it needs no -# privileges beyond owning that name. Only enable it on a GNOME/Phosh session +# org.ofono is a system-bus name, so this is a system service, and it runs +# as root: its start hook restarts GNOME Calls in the users' own managers. +# Only enable it on a GNOME/Phosh session # — Plasma Mobile uses imsd-dialerd instead. Both may run at once (different # buses, different consumers), but a real ofonod must not. Conflicts=ofono.service @@ -17,6 +18,9 @@ Type=simple # matches imsd's configuration; imsd itself owns emergency classification. EnvironmentFile=-/etc/imsd.env ExecStart=/usr/bin/imsd-ofonod +# GNOME Calls keeps a stale modem when org.ofono restarts and dials nothing +# until it restarts too: restart the Calls daemon of each user running one +ExecStartPost=-/usr/libexec/imsd-ofono-restart-calls.sh # it holds no call state of its own — imsd is re-queried on every reconnect Restart=on-failure RestartSec=5 diff --git a/packaging/imsd-systemd.post-install b/packaging/imsd-systemd.post-install new file mode 120000 index 0000000..99e6bcc --- /dev/null +++ b/packaging/imsd-systemd.post-install @@ -0,0 +1 @@ +imsd-systemd.post-upgrade \ No newline at end of file diff --git a/packaging/imsd-systemd.post-upgrade b/packaging/imsd-systemd.post-upgrade new file mode 100755 index 0000000..846fdc4 --- /dev/null +++ b/packaging/imsd-systemd.post-upgrade @@ -0,0 +1,23 @@ +#!/bin/sh +# SPDX-License-Identifier: GPL-3.0-only +# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +# +# imsd.service ships in this subpackage, which apk installs after imsd. +# When imsd is added back over a daemon an `apk del` left running, +# imsd.post-install runs before this unit file is on disk and cannot mark +# the unit (not-found to systemd), so the package that brings the file +# marks it too; on an upgrade both mark it, which is one marker. +# Also installed as imsd-systemd.post-install. + +# A unit whose file an apk del removed is not-found to systemd until a +# reload and refuses properties ("failed to load properly"); the file is +# on disk by now, so reload and mark again. +mark() { + systemctl set-property "$1" Markers=+needs-restart 2>/dev/null && return 0 + systemctl daemon-reload && systemctl set-property "$1" Markers=+needs-restart || : +} + +if [ -d /run/systemd/system ] && systemctl is-active -q imsd.service; then + mark imsd.service +fi +exit 0 diff --git a/packaging/imsd.post-install b/packaging/imsd.post-install new file mode 120000 index 0000000..0a3397d --- /dev/null +++ b/packaging/imsd.post-install @@ -0,0 +1 @@ +imsd.post-upgrade \ No newline at end of file diff --git a/packaging/imsd.post-upgrade b/packaging/imsd.post-upgrade new file mode 100755 index 0000000..4e295e8 --- /dev/null +++ b/packaging/imsd.post-upgrade @@ -0,0 +1,48 @@ +#!/bin/sh +# SPDX-License-Identifier: GPL-3.0-only +# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +# +# Restart imsd onto the upgraded binary once apk is done: the systemd +# trigger restarts every unit marked needs-restart (enqueue-marked). A +# changed bus policy is live the moment apk writes it (dbus-broker-launch +# reloads on its own), so it must not be left facing the old daemon. The +# restart resumes the registration over its existing SAs, or goes on +# waiting when the network had imsd wait; a call in progress ends. +# +# Also installed as imsd.post-install: `apk del imsd` removes the files +# and leaves the daemon running, so a later `apk add` of the package is +# this upgrade for that daemon, and apk runs post-install for it. A first +# install finds no unit active and marks nothing. On the binary route +# (APKBUILD.binary) the unit file ships in imsd-systemd, installed after +# this package; its imsd-systemd.post-upgrade marks the unit then. +# +# A daemon older than 0.3.10 kept its bring-up throttle wait in memory +# only, so a restart inside it registered fresh at once instead of +# waiting the schedule out. Before marking, record the running +# invocation in /run/imsd.handover: the next start (imsd-handover.sh, an +# ExecStartPre after ims-pdn-up.sh) reads that invocation's last bring-up +# line once the daemon has stopped and hands what is left of its wait over +# as imsd's holds file (on another address only the verdict that its +# registration is gone). Read then, not here: a wait that ends between this +# script and the restart at the end of the transaction — the old daemon +# making another attempt — is read as it ended. + +# A unit whose file an apk del removed is not-found to systemd until a +# reload and refuses properties ("failed to load properly"); its file is +# back on disk by the time post-install runs, so reload and mark again. +mark() { + systemctl set-property "$1" Markers=+needs-restart 2>/dev/null && return 0 + systemctl daemon-reload && systemctl set-property "$1" Markers=+needs-restart || : +} + +record_old_invocation() { + [ -e /run/imsd.holds ] && return 0 # a 0.3.10 or later daemon keeps its own holds + inv=$(systemctl show -P InvocationID imsd.service 2>/dev/null) + [ -n "$inv" ] && echo "$inv" > /run/imsd.handover +} + +if [ -d /run/systemd/system ] && systemctl is-active -q imsd.service; then + record_old_invocation + mark imsd.service +fi +exit 0 diff --git a/packaging/imsd.service b/packaging/imsd.service index 17b88a0..b5c6d57 100644 --- a/packaging/imsd.service +++ b/packaging/imsd.service @@ -13,6 +13,11 @@ Type=simple # idempotent. worst case ~14 min (2 min modem wait + 5 min registration # gate + connect retries), hence TimeoutStartSec ExecStartPre=/usr/libexec/ims-pdn-up.sh +# a daemon older than 0.3.10 that an upgrade restarted inside its bring-up +# throttle wait: the wait — on another address only the verdict that its +# registration is gone — is handed over from its journal now that it has +# stopped (imsd.post-upgrade recorded the invocation in /run/imsd.handover) +ExecStartPre=-/usr/libexec/imsd-handover.sh TimeoutStartSec=900 # /run/imsd.env: written by ims-pdn-up.sh (DEV= the connected ims netdev, # LOCAL= the PDN address ModemManager assigned to it). diff --git a/packaging/make-bin-tarball.sh b/packaging/make-bin-tarball.sh index 72934bb..1d44451 100755 --- a/packaging/make-bin-tarball.sh +++ b/packaging/make-bin-tarball.sh @@ -24,8 +24,9 @@ cp "$BIN" "$stage/imsd-$VER/imsd" cp "$MEDIA" "$stage/imsd-$VER/imsd-media" cp "$DIALERD" "$stage/imsd-$VER/imsd-dialerd" cp "$OFONOD" "$stage/imsd-$VER/imsd-ofonod" -cp packaging/ims-pdn-up.sh packaging/imsd.service \ +cp packaging/ims-pdn-up.sh packaging/imsd-handover.sh packaging/imsd.service \ packaging/imsd-dialerd.desktop packaging/imsd-ofonod.service \ + packaging/imsd-ofono-restart-calls.sh \ packaging/org.ofono.conf packaging/90_imsd-ofono.gschema.override \ packaging/80-imsd-ofono.preset \ packaging/net.catcrafts.IMS1.conf "$stage/imsd-$VER/" From f18baf279033331dd10ab4683ff7dd5043e915d9 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sat, 3 Oct 2026 20:44:58 +0200 Subject: [PATCH 08/15] imsd, imsd-ofonod: let polkit decide call control, not the account name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The system-bus policies granted net.catcrafts.IMS1 and org.ofono to root and to the account `user`, postmarketOS's default name, so on a unit whose account is called something else dbus denied every Dial from the dialer — nothing in imsd's journal, nothing on screen. A field user found it by trial (alyx report 14, 2026-09-20; ims ledger F14). Let any local account reach both names and have the daemons decide: placing, answering or ending a call needs polkit's net.catcrafts.IMS1.Voice (the active local session; not an ssh login, an inactive session or a system service account). imsd-ofonod asks about its own callers, since its calls reach imsd as root. Reads such as the status and the call list are not gated, and a refusal is logged with the sender; a `--session` run, the development mode without root or polkit, leaves the gate off. The check is ImsdBus, a small GLib module both daemons link; the package depends on polkit. An upgrade restarts both daemons (the previous commit), so the open policy faces the old, ungated daemons only until the apk transaction ends. Tested on the dev phone (KPN) in builds of this series; not every path has run there. --- Makefile | 18 +++-- README.md | 31 +++++--- implementations/main.cpp | 15 ++-- implementations/ofonod.cpp | 19 +++-- interfaces/ImsdBus.cppm | 106 ++++++++++++++++++++++++++++ packaging/APKBUILD | 5 +- packaging/APKBUILD.binary | 7 +- packaging/imsd-ofonod.service | 8 ++- packaging/make-bin-tarball.sh | 3 +- packaging/net.catcrafts.IMS1.conf | 11 +-- packaging/net.catcrafts.IMS1.policy | 26 +++++++ packaging/org.ofono.conf | 22 +++--- project.cpp | 26 ++++++- 13 files changed, 245 insertions(+), 52 deletions(-) create mode 100644 interfaces/ImsdBus.cppm create mode 100644 packaging/net.catcrafts.IMS1.policy diff --git a/Makefile b/Makefile index 149d266..be81add 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ # make -> build/make/{imsd,imsd-media,imsd-dialerd,imsd-ofonod} # make check -> build + run the 7 unit-test suites # make install -> DESTDIR/PREFIX staged install (binaries + systemd -# units, D-Bus policies, dialerd autostart, the GNOME +# units, D-Bus and polkit policies, dialerd autostart, the GNOME # Calls plugin override, the imsd-ofono preset, and the libexec scripts # ims-pdn-up.sh, imsd-handover.sh, imsd-ofono-restart-calls.sh) # @@ -75,6 +75,12 @@ $(PCM)/Imsd-Engine.pcm: $(PCM)/Imsd-Util.pcm $(PCM)/Imsd-Sip.pcm \ $(PCM)/Imsd-Sdp.pcm $(PCM)/Imsd-Messages.pcm $(PCM)/Imsd.pcm: $(PARTITIONS:%=$(PCM)/%.pcm) +# ImsdBus: the polkit gate imsd and imsd-ofonod share — GLib, so outside +# imsd-core, and its BMI needs the gio include paths. +$(PCM)/ImsdBus.pcm: interfaces/ImsdBus.cppm $(PCM)/std.pcm | $(PCM) + $(CXX) $(ALL_CXXFLAGS) $(GIO_CFLAGS) -fprebuilt-module-path=$(PCM) \ + --precompile $< -o $@ + # ---- imsd-core (static lib of the pure-C++ engine modules) ---------------- $(OBJ)/%.o: $(PCM)/%.pcm | $(OBJ) @@ -85,11 +91,11 @@ $(O)/libimsd-core.a: $(CORE_OBJS) # ---- executables ----------------------------------------------------------- -$(OBJ)/main.o: implementations/main.cpp $(PCM)/Imsd.pcm | $(OBJ) +$(OBJ)/main.o: implementations/main.cpp $(PCM)/Imsd.pcm $(PCM)/ImsdBus.pcm | $(OBJ) $(CXX) $(ALL_CXXFLAGS) $(GIO_CFLAGS) -fprebuilt-module-path=$(PCM) \ -c $< -o $@ -$(O)/imsd: $(OBJ)/main.o $(O)/libimsd-core.a +$(O)/imsd: $(OBJ)/main.o $(OBJ)/ImsdBus.o $(O)/libimsd-core.a $(CXX) $(ALL_CXXFLAGS) $(ALL_LDFLAGS) $^ $(GIO_LIBS) -lpthread -o $@ $(OBJ)/media.o: implementations/media.cpp $(PCM)/std.pcm | $(OBJ) @@ -105,11 +111,11 @@ $(OBJ)/dialerd.o: implementations/dialerd.cpp $(PCM)/std.pcm | $(OBJ) $(O)/imsd-dialerd: $(OBJ)/dialerd.o $(CXX) $(ALL_CXXFLAGS) $(ALL_LDFLAGS) $^ $(GIO_LIBS) -o $@ -$(OBJ)/ofonod.o: implementations/ofonod.cpp $(PCM)/std.pcm | $(OBJ) +$(OBJ)/ofonod.o: implementations/ofonod.cpp $(PCM)/ImsdBus.pcm | $(OBJ) $(CXX) $(ALL_CXXFLAGS) $(GIO_CFLAGS) -fprebuilt-module-path=$(PCM) \ -c $< -o $@ -$(O)/imsd-ofonod: $(OBJ)/ofonod.o +$(O)/imsd-ofonod: $(OBJ)/ofonod.o $(OBJ)/ImsdBus.o $(CXX) $(ALL_CXXFLAGS) $(ALL_LDFLAGS) $^ $(GIO_LIBS) -o $@ # ---- tests ----------------------------------------------------------------- @@ -142,6 +148,8 @@ install: all $(DESTDIR)/etc/xdg/autostart/imsd-dialerd.desktop install -Dm644 packaging/net.catcrafts.IMS1.conf \ $(DESTDIR)$(PREFIX)/share/dbus-1/system.d/net.catcrafts.IMS1.conf + install -Dm644 packaging/net.catcrafts.IMS1.policy \ + $(DESTDIR)$(PREFIX)/share/polkit-1/actions/net.catcrafts.IMS1.policy install -Dm644 packaging/imsd-ofonod.service \ $(DESTDIR)$(PREFIX)/lib/systemd/system/imsd-ofonod.service install -Dm755 packaging/imsd-ofono-restart-calls.sh \ diff --git a/README.md b/README.md index 08cfc8a..67797cb 100644 --- a/README.md +++ b/README.md @@ -25,11 +25,12 @@ every decision — SIP framing, SDP selection, the AKAv1-MD5 digest, the `ip xfrm` command sequence, and the whole call FSM — is unit-tested on a dev box with no bus, modem, or phone; several suites pin output byte-for-byte against recorded network traffic. GLib/GDBus, sockets, and subprocess control -live only in the `imsd` daemon shell. +live only in the `imsd` daemon shell, apart from imsd-bus, the GLib polkit +gate it shares with imsd-ofonod. ## Architecture -C++26 modules, built with Crafter Build. Five build products: +C++26 modules, built with Crafter Build. Six build products: - **imsd-core** (static library) — the engine, pure `import std` C++ with no GLib and no I/O side effects. Everything here is unit-testable on any dev @@ -37,7 +38,12 @@ C++26 modules, built with Crafter Build. Five build products: byte-for-byte against recorded network traffic. - **imsd** (executable) — the daemon shell: GLib main loop, GDBus service, sockets, subprocess control (`qmicli`/`mmcli`/`ip`), and the registration - sequence + run loop that pumps the core. All GLib and I/O live here. + sequence + run loop that pumps the core. +- **imsd-bus** (static library) — the polkit gate in front of call control + (`net.catcrafts.IMS1.Voice`), which imsd and imsd-ofonod both link. GLib, + so kept outside imsd-core. A translation daemon that runs as root on the + system bus reaches imsd as root, which polkit always allows, so it gates + its own callers through this or it is a bypass. - **imsd-media** (executable) — the RTP/AMR-WB data plane, spawned once per call. Pure `import std` + POSIX, no GLib; links `-ldl` and dlopen's the codecs. A separate process for crash isolation and the far-end-hangup @@ -52,7 +58,8 @@ C++26 modules, built with Crafter Build. Five build products: - **imsd-ofonod** (executable) — the GNOME Calls integration, for Phosh and GNOME Mobile: a system daemon owning `org.ofono` and presenting imsd as an oFono modem, which is the seam GNOME Calls' bundled `ofono` provider plugin - drives. GDBus translation only, no core; built with `-- --product=ofonod`. + drives. GDBus translation, no core, plus the imsd-bus gate on its own + callers; built with `-- --product=ofonod`. See [GNOME Calls (Phosh, GNOME Mobile)](#gnome-calls-phosh-gnome-mobile). ### Desktop integration @@ -144,10 +151,12 @@ crafter-build -- --target=aarch64-alpine-linux-musl \ busctl --user call net.catcrafts.IMS1 /net/catcrafts/IMS1 net.catcrafts.IMS1 GetStatus ``` -On a phone the daemon runs as root on the system bus (policy in -`packaging/net.catcrafts.IMS1.conf` — it grants the postmarketOS default -account `user` access; adjust it if your session account is named -differently), started by `packaging/imsd.service` +On a phone the daemon runs as root on the system bus, where any local +account may reach it (`packaging/net.catcrafts.IMS1.conf`); placing, +answering and ending a call is polkit's `net.catcrafts.IMS1.Voice` +(`packaging/net.catcrafts.IMS1.policy`, checked by imsd and imsd-ofonod +alike): the active local session — the dialer, whatever the account is +called — and root, not an ssh login. It is started by `packaging/imsd.service` after `packaging/ims-pdn-up.sh` has brought up the ims PDN through ModemManager. An apk upgrade restarts `imsd` and `imsd-ofonod` once the transaction is done (a call in progress ends), as does adding a package @@ -398,8 +407,10 @@ integrations depend on it: | `CallDeleted` | `s` | | `RegistrationChanged` | `b` | -Both `imsd-dialerd` and `imsd-ofonod` are pure translations of this ABI; a new -desktop needs a new translation daemon, not changes here. +Both `imsd-dialerd` and `imsd-ofonod` translate this ABI and add no members +to it; a new desktop needs a new translation daemon, not changes here — one +that runs as root on the system bus also gates its own callers through +imsd-bus (see Architecture), or it bypasses `net.catcrafts.IMS1.Voice`. ## License diff --git a/implementations/main.cpp b/implementations/main.cpp index 2d5e318..81103b8 100644 --- a/implementations/main.cpp +++ b/implementations/main.cpp @@ -11,8 +11,10 @@ Owns net.catcrafts.IMS1 on the system bus with the project's frozen D-Bus ABI 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; every message, digest, SA command, and call-state -decision comes from the GLib-free core. +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 @@ -20,7 +22,8 @@ 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 needed) + --session own the name on the session bus (development; no policy, no + polkit) */ #include @@ -40,6 +43,7 @@ reach the bus via g_idle_add, where the calls table + status snapshot live import std; import Imsd; +import ImsdBus; namespace { @@ -2408,7 +2412,9 @@ void HandleMethodCall(GDBusConnection*, const gchar*, const gchar*, const gchar* g_dbus_method_invocation_return_dbus_error(inv, "org.freedesktop.DBus.Error.UnknownMethod", "no such method"); } -const GDBusInterfaceVTable Vtable = { HandleMethodCall, nullptr, nullptr, {} }; +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, nullptr, nullptr, {} }; void OnBusAcquired(GDBusConnection* conn, const gchar*, gpointer) { DbusConn = conn; @@ -2455,6 +2461,7 @@ int main(int argc, char** argv) { } Log(std::format("LOCAL={} P-CSCF=[{}]:{}", local, EnvOr("PCSCF", "(unset)"), EnvOr("PCSCF_PORT", "5060"))); if (!session) LogAudioSession(); + imsd::bus::AuthorizeCalls = !session; Engine engine; engine.SetLocal(local); diff --git a/implementations/ofonod.cpp b/implementations/ofonod.cpp index a93bb15..2122b56 100644 --- a/implementations/ofonod.cpp +++ b/implementations/ofonod.cpp @@ -45,11 +45,12 @@ plugins/provider/ofono/ — get these wrong and the plugin silently ignores us: Runs as root because `org.ofono` is a system-bus name (policy in packaging/org.ofono.conf). It does no I/O beyond D-Bus: every call decision -stays in imsd. Everything happens on the GLib main loop, so there is no -locking. +stays in imsd. Its calls reach imsd as root, so it asks polkit about its own +callers (ImsdBus): call control is net.catcrafts.IMS1.Voice, as on imsd. +Everything happens on the GLib main loop, so there is no locking. --session own org.ofono on the session bus and talk to imsd there - (development: no root, no policy file) + (development: no root, no policy file, no polkit) IMSD_BUS=session|system override only the imsd side of that choice */ @@ -57,6 +58,7 @@ locking. #include import std; +import ImsdBus; namespace { @@ -315,7 +317,9 @@ void HandleCallMethod(GDBusConnection*, const gchar*, const gchar* objectPath, c g_dbus_method_invocation_return_dbus_error(inv, "org.freedesktop.DBus.Error.UnknownMethod", "no such method"); } -const GDBusInterfaceVTable CallVtable = { HandleCallMethod, nullptr, nullptr, {} }; +bool IsRead(std::string_view method) { return method == "GetProperties" || method == "GetCalls" || method == "GetModems"; } +constexpr char ErrAccessDenied[] = "org.ofono.Error.AccessDenied"; +const GDBusInterfaceVTable CallVtable = { imsd::bus::Gated, nullptr, nullptr, {} }; guint RegisterObject(const char* xml, const char* path, const GDBusInterfaceVTable* vtable) { GDBusNodeInfo* node = g_dbus_node_info_new_for_xml(xml, nullptr); @@ -596,9 +600,9 @@ void HandleSsMethod(GDBusConnection*, const gchar*, const gchar*, const gchar*, } const GDBusInterfaceVTable ManagerVtable = { HandleManagerMethod, nullptr, nullptr, {} }; -const GDBusInterfaceVTable ModemVtable = { HandleModemMethod, nullptr, nullptr, {} }; -const GDBusInterfaceVTable VcmVtable = { HandleVcmMethod, nullptr, nullptr, {} }; -const GDBusInterfaceVTable SsVtable = { HandleSsMethod, nullptr, nullptr, {} }; +const GDBusInterfaceVTable ModemVtable = { imsd::bus::Gated, nullptr, nullptr, {} }; +const GDBusInterfaceVTable VcmVtable = { imsd::bus::Gated, nullptr, nullptr, {} }; +const GDBusInterfaceVTable SsVtable = { imsd::bus::Gated, nullptr, nullptr, {} }; // ---- imsd events ------------------------------------------------------------ void PublishVoiceState() { @@ -727,6 +731,7 @@ int main(int argc, char** argv) { if (std::string_view(argv[i]) == "--session") session = true; GBusType ofonoBus = session ? G_BUS_TYPE_SESSION : G_BUS_TYPE_SYSTEM; + imsd::bus::AuthorizeCalls = !session; std::string imsdBusName = EnvOr("IMSD_BUS", session ? "session" : "system"); GBusType imsdBus = imsdBusName == "session" ? G_BUS_TYPE_SESSION : G_BUS_TYPE_SYSTEM; diff --git a/interfaces/ImsdBus.cppm b/interfaces/ImsdBus.cppm new file mode 100644 index 0000000..4156d88 --- /dev/null +++ b/interfaces/ImsdBus.cppm @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: GPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// lint-disable-file fixed-width-types no-char-pointer +/* +ImsdBus — what the two system-bus daemons (imsd, imsd-ofonod) share on the +bus side: the polkit gate in front of call control. + +The bus policies let any local account reach both names, so the session +account can dial whatever it is called; whether a caller may place, answer +or end a call is polkit's net.catcrafts.IMS1.Voice +(packaging/net.catcrafts.IMS1.policy), shaped like ModemManager's Voice +action: the active local session and root, not an ssh login, an inactive +session or a system service account. Reads (status, the call list) stay +open, as ModemManager's are to every local account; the call signals reach +them all anyway. + +GLib, so not part of the GLib-free imsd-core. +*/ + +module; +#include +export module ImsdBus; +import std; + +namespace imsd::bus { + +constexpr const char* VoiceAction = "net.catcrafts.IMS1.Voice"; + +// CheckAuthorization's AllowUserInteraction, as ModemManager passes it: a +// rule an administrator sets to auth_self prompts through the session's +// polkit agent. +constexpr guint32 AllowUserInteraction = 1; + +struct Pending { + GDBusMethodInvocation* inv; + GDBusInterfaceMethodCallFunc body; + const char* deniedError; + void (*log)(std::string_view); +}; + +void OnCheck(GObject* src, GAsyncResult* res, gpointer data) { + std::unique_ptr p(static_cast(data)); + GDBusMethodInvocation* inv = p->inv; + GError* err = nullptr; + GVariant* r = g_dbus_connection_call_finish(G_DBUS_CONNECTION(src), res, &err); + gboolean authorized = FALSE; + gboolean challenge = FALSE; + if (r) { + GVariant* details = nullptr; + g_variant_get(r, "((bb@a{ss}))", &authorized, &challenge, &details); + g_variant_unref(details); + g_variant_unref(r); + } + if (authorized) { + p->body(g_dbus_method_invocation_get_connection(inv), g_dbus_method_invocation_get_sender(inv), + g_dbus_method_invocation_get_object_path(inv), g_dbus_method_invocation_get_interface_name(inv), + g_dbus_method_invocation_get_method_name(inv), g_dbus_method_invocation_get_parameters(inv), inv, + g_dbus_method_invocation_get_user_data(inv)); + return; + } + std::string why = err ? std::format("{}: polkit did not answer: {}", g_dbus_method_invocation_get_sender(inv), err->message) + : std::format("{} is not authorized for {}", g_dbus_method_invocation_get_sender(inv), VoiceAction); + p->log(std::format("{} refused: {}", g_dbus_method_invocation_get_method_name(inv), why)); + g_dbus_method_invocation_return_dbus_error(inv, p->deniedError, why.c_str()); + if (err) g_error_free(err); +} + +} // namespace imsd::bus + +export namespace imsd::bus { + +// Off for a --session run: the session bus has no polkit, and its callers +// are the owner's own processes. +inline bool AuthorizeCalls = true; + +// Run `body` for the call `inv` once polkit allows its sender +// net.catcrafts.IMS1.Voice; otherwise answer `deniedError` and log why. The +// check is asynchronous — nothing else on the main loop waits for it. +void Authorize(GDBusMethodInvocation* inv, GDBusInterfaceMethodCallFunc body, const char* deniedError, void (*log)(std::string_view)) { + GVariantBuilder subject; + g_variant_builder_init(&subject, G_VARIANT_TYPE("a{sv}")); + g_variant_builder_add(&subject, "{sv}", "name", g_variant_new_string(g_dbus_method_invocation_get_sender(inv))); + GVariantBuilder details; + g_variant_builder_init(&details, G_VARIANT_TYPE("a{ss}")); + g_dbus_connection_call(g_dbus_method_invocation_get_connection(inv), "org.freedesktop.PolicyKit1", + "/org/freedesktop/PolicyKit1/Authority", "org.freedesktop.PolicyKit1.Authority", + "CheckAuthorization", + g_variant_new("((sa{sv})sa{ss}us)", "system-bus-name", &subject, VoiceAction, &details, AllowUserInteraction, ""), + G_VARIANT_TYPE("((bba{ss}))"), G_DBUS_CALL_FLAGS_NONE, G_MAXINT, nullptr, OnCheck, + new Pending{ inv, body, deniedError, log }); +} + +// A vtable method handler for `Body` that lets `IsRead` methods through and +// puts every other method — call control, and anything added later — behind +// Authorize. +template +void Gated(GDBusConnection* conn, const gchar* sender, const gchar* path, const gchar* iface, const gchar* method, GVariant* params, GDBusMethodInvocation* inv, gpointer user) { + if (!AuthorizeCalls || IsRead(method)) { + Body(conn, sender, path, iface, method, params, inv, user); + return; + } + Authorize(inv, Body, DeniedError, Log); +} + +} // namespace imsd::bus diff --git a/packaging/APKBUILD b/packaging/APKBUILD index 0621cb7..ca32436 100644 --- a/packaging/APKBUILD +++ b/packaging/APKBUILD @@ -9,8 +9,9 @@ url="https://forgejo.catcrafts.net/Catcrafts/imsd" arch="aarch64 x86_64" license="GPL-3.0-only" # the media leg dlopen's the AMR-WB codecs; pw-record/pw-play drive PipeWire — -# none of which abuild's .so auto-scan can see -depends="modemmanager opencore-amr vo-amrwbenc pipewire-tools" +# none of which abuild's .so auto-scan can see; polkit answers the daemons' +# call-control check (ImsdBus) +depends="modemmanager opencore-amr vo-amrwbenc pipewire-tools polkit" # clang/libc++ C++26 modules build (see Makefile); llvm-runtimes ships the # libc++ std module sources (/usr/share/libc++/v1/std.cppm) it precompiles makedepends="clang lld libc++-dev llvm-libunwind-dev llvm-runtimes glib-dev pkgconf" diff --git a/packaging/APKBUILD.binary b/packaging/APKBUILD.binary index 8582c39..333529e 100644 --- a/packaging/APKBUILD.binary +++ b/packaging/APKBUILD.binary @@ -17,8 +17,9 @@ pkgdesc="Userspace IMS/VoLTE daemon for mainline Linux phones" url="https://forgejo.catcrafts.net/Catcrafts/imsd" arch="aarch64" license="GPL-3.0-only" -# the media leg dlopen's the AMR-WB codecs; pw-record/pw-play drive PipeWire -depends="modemmanager libc++ opencore-amr vo-amrwbenc pipewire-tools" +# the media leg dlopen's the AMR-WB codecs; pw-record/pw-play drive PipeWire; +# polkit answers the daemons' call-control check (ImsdBus) +depends="modemmanager libc++ opencore-amr vo-amrwbenc pipewire-tools polkit" # versioned provides = imsd satisfies soc-qcom-modem's 81voltd dependency AND # excludes the real package: 81voltd serves the modem's own ims-PDN requests, # which races imsd for the PDN and flaps it with a new prefix every ~2.5 min @@ -52,6 +53,8 @@ package() { "$pkgdir"/etc/xdg/autostart/imsd-dialerd.desktop install -Dm644 net.catcrafts.IMS1.conf \ "$pkgdir"/usr/share/dbus-1/system.d/net.catcrafts.IMS1.conf + install -Dm644 net.catcrafts.IMS1.policy \ + "$pkgdir"/usr/share/polkit-1/actions/net.catcrafts.IMS1.policy # the skel override hides kde-telephony's modem daemon autostart for the # account created at install — imsd-dialerd owns those session bus names install -Dm644 "$srcdir"/org.kde.modem.daemon.desktop \ diff --git a/packaging/imsd-ofonod.service b/packaging/imsd-ofonod.service index c8eb4a3..5229943 100644 --- a/packaging/imsd-ofonod.service +++ b/packaging/imsd-ofonod.service @@ -3,9 +3,11 @@ [Unit] Description=oFono-compatible GNOME Calls backend for imsd Documentation=https://forgejo.catcrafts.net/Catcrafts/imsd -# org.ofono is a system-bus name, so this is a system service, and it runs -# as root: its start hook restarts GNOME Calls in the users' own managers. -# Only enable it on a GNOME/Phosh session +# org.ofono is a system-bus name only root may own (org.ofono.conf), so this +# is a system service run as root: imsd admits the calls it forwards as +# root's (it has asked polkit about its own caller), and its start hook +# restarts GNOME Calls in the users' own managers. Only enable it on a +# GNOME/Phosh session # — Plasma Mobile uses imsd-dialerd instead. Both may run at once (different # buses, different consumers), but a real ofonod must not. Conflicts=ofono.service diff --git a/packaging/make-bin-tarball.sh b/packaging/make-bin-tarball.sh index 1d44451..3e695ec 100755 --- a/packaging/make-bin-tarball.sh +++ b/packaging/make-bin-tarball.sh @@ -29,6 +29,7 @@ cp packaging/ims-pdn-up.sh packaging/imsd-handover.sh packaging/imsd.service \ packaging/imsd-ofono-restart-calls.sh \ packaging/org.ofono.conf packaging/90_imsd-ofono.gschema.override \ packaging/80-imsd-ofono.preset \ - packaging/net.catcrafts.IMS1.conf "$stage/imsd-$VER/" + packaging/net.catcrafts.IMS1.conf packaging/net.catcrafts.IMS1.policy \ + "$stage/imsd-$VER/" tar -C "$stage" -czf "imsd-$VER.tar.gz" "imsd-$VER" echo "wrote imsd-$VER.tar.gz ($(du -h "imsd-$VER.tar.gz" | cut -f1))" diff --git a/packaging/net.catcrafts.IMS1.conf b/packaging/net.catcrafts.IMS1.conf index c365a8a..cd1c18b 100644 --- a/packaging/net.catcrafts.IMS1.conf +++ b/packaging/net.catcrafts.IMS1.conf @@ -11,12 +11,13 @@ - - + + - - - diff --git a/packaging/net.catcrafts.IMS1.policy b/packaging/net.catcrafts.IMS1.policy new file mode 100644 index 0000000..181972d --- /dev/null +++ b/packaging/net.catcrafts.IMS1.policy @@ -0,0 +1,26 @@ + + + + + + + Catcrafts + https://forgejo.catcrafts.net/Catcrafts/imsd + + Place, answer and end voice calls over IMS + System policy prevents voice calls. + + no + no + yes + + + diff --git a/packaging/org.ofono.conf b/packaging/org.ofono.conf index 0b73669..23a7cf0 100644 --- a/packaging/org.ofono.conf +++ b/packaging/org.ofono.conf @@ -15,20 +15,20 @@ + every name the receiver owns (dbus-daemon does not). The default + below allows sending too since 0.3.10; this line stays so that a + tighter default can never again silently drop every CallAdded / + CallStateChanged from imsd on the way to imsd-ofonod, as the old + default deny did (found on the FP6, 2026-09-20). --> - - + + + - - - - diff --git a/project.cpp b/project.cpp index 3507130..6e53866 100644 --- a/project.cpp +++ b/project.cpp @@ -57,6 +57,24 @@ static void ApplyGioFlags(Configuration& cfg) { } } +// ImsdBus — the polkit gate imsd and imsd-ofonod put in front of call +// control. A static library of its own: GLib, so outside imsd-core. +static std::unique_ptr BusLibrary(std::span args) { + auto bus = std::make_unique(); + bus->path = "./"; + bus->name = "imsd-bus"; + bus->outputName = "imsd-bus"; + ApplyStandardArgs(*bus, args); + bus->type = ConfigurationType::LibraryStatic; + { + std::array ifaces = { "interfaces/ImsdBus" }; + std::array impls = {}; + bus->GetInterfacesAndImplementations(ifaces, impls); + } + ApplyGioFlags(*bus); + return bus; +} + extern "C" Configuration CrafterBuildProject(std::span args) { // Four executables come out of this repo; which one a build produces is // chosen by `--product=`: @@ -70,7 +88,8 @@ extern "C" Configuration CrafterBuildProject(std::span a // (pure GDBus translation, no core). // ofonod — imsd-ofonod, the system-bus GNOME Calls backend // (org.ofono.*) bridging to imsd, for Phosh and - // GNOME Mobile (pure GDBus translation, no core). + // GNOME Mobile (GDBus translation plus the imsd-bus + // polkit gate on its callers, no core). bool wantMedia = false; bool wantDialerd = false; bool wantOfonod = false; @@ -86,6 +105,8 @@ extern "C" Configuration CrafterBuildProject(std::span a ofonod.outputName = "imsd-ofonod"; ApplyStandardArgs(ofonod, args); ofonod.type = ConfigurationType::Executable; + static auto Bus = BusLibrary(args); + ofonod.dependencies = { Bus.get() }; { std::array ifaces = {}; std::array impls = { "implementations/ofonod" }; @@ -161,7 +182,8 @@ extern "C" Configuration CrafterBuildProject(std::span a cfg.outputName = "imsd"; ApplyStandardArgs(cfg, args); cfg.type = ConfigurationType::Executable; - cfg.dependencies = { Core.get() }; + static auto Bus = BusLibrary(args); + cfg.dependencies = { Core.get(), Bus.get() }; { std::array ifaces = {}; std::array impls = { "implementations/main" }; From 828b0af95babd3ca17eaca6ffa2fe8ae4b49efef Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sat, 3 Oct 2026 19:36:52 +0200 Subject: [PATCH 09/15] README, AGENTS.md: a short README for people, the detail in AGENTS.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README had grown to 422 lines: the device and carrier table came after 61 lines of architecture, and the configuration section after 237 lines of architecture, that table, build, run and packaging notes and GNOME Calls detail. AGENTS.md, a file for coding agents, takes that detail and adds to it: a reference to the tree at this commit, written from the code and the bench record. The README is now what a person needs: what imsd is and its status, the verified device/carrier table, what it needs, how to install and enable it, the two dialers, a status check for the installed daemon, a `Not there yet` list, and where the rest is. In-tree comments that sent readers to README sections which moved — the cross-compiling flow (the CI workflow, build-package.sh, APKBUILD.binary), GNOME Calls' known gaps (imsd-ofono-restart-calls.sh), the D-Bus ABI (main.cpp) and the Configuration section (imsd.service) — point at AGENTS.md's sections now. --- .forgejo/workflows/package.yml | 2 +- AGENTS.md | 1030 +++++++++++++++++++++++++ README.md | 458 ++--------- implementations/main.cpp | 2 +- packaging/APKBUILD.binary | 2 +- packaging/build-package.sh | 2 +- packaging/imsd-ofono-restart-calls.sh | 4 +- packaging/imsd.service | 2 +- 8 files changed, 1122 insertions(+), 380 deletions(-) create mode 100644 AGENTS.md diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml index 783fa74..f64b0a5 100644 --- a/.forgejo/workflows/package.yml +++ b/.forgejo/workflows/package.yml @@ -6,7 +6,7 @@ name: package # plain 'apk upgrade' without an fp6-img image run. # # Build: crafter-build cross-compiles against an Alpine aarch64 sysroot -# (packaging/build-package.sh — the README's "Cross-compiling" flow), the +# (packaging/build-package.sh — AGENTS.md §4.3's cross-compiling flow), the # test suite runs natively, and packaging/APKBUILD.binary wraps the result. # This needs no privileged runner: it runs in an alpine:edge container on # the ordinary arch-latest runner. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5c9f446 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,1030 @@ + + + +# AGENTS.md — working on imsd + +This file is for coding agents and for anyone changing imsd: what the +repository contains, how it is built, tested and packaged, how the daemon +behaves at runtime, and the conventions a change has to follow. The README +is the short, human-facing introduction; everything it leaves out is here. +Where this file and the code disagree, the code is right and this file has a +bug — fix it in the same change. + +## 1. What imsd is + +imsd is a userspace IMS/VoLTE client for mainline Linux phones. The modem +firmware on such phones brings up the IMS PDN but cannot carry call audio, so +imsd does the whole IMS client itself: SIP registration with USIM AKA +authentication over kernel IPsec (ESP), call signalling for outgoing and +incoming calls, and the RTP media plane (AMR-WB, AMR, G.711) wired into +PipeWire. A stock mobile dialer — Plasma Dialer, or GNOME Calls under Phosh +and GNOME Mobile — places and receives real VoLTE calls on a commercial +network through it. + +It talks to the modem through ModemManager (`mmcli`) and `qmicli` over QRTR +(the USIM AKA run goes through a UIM logical channel), installs its security +associations with `ip xfrm`, and plays and records audio with the PipeWire +tools (`pw-play`, `pw-record`). In practice that means a Qualcomm modem on a +mainline kernel with working WWAN/ModemManager integration, kernel ESP, and +PipeWire. The verified device/carrier combinations are in the README. + +## 2. Repository layout + +| Path | What it is | +|---|---| +| `project.cpp` | The canonical build description, for [Crafter Build](https://forgejo.catcrafts.net/Catcrafts/Crafter.Build). Chooses the product by `--product=`. | +| `Makefile` | Distro-packaging mirror of `project.cpp`: the same clang module pipeline, the same flags. Keep the two in sync when products, module partitions or link flags change. | +| `lint-rules.h` | The house-style lint rules (§6), registered from `project.cpp` through `AddProjectLintRules`. Project-local on purpose. | +| `interfaces/Imsd.cppm` | The primary module interface of `imsd-core`: `export import`s the seven partitions below. | +| `interfaces/Imsd-Util.cppm` | `imsd::util` — SIP tokens, IPv6 host/port formatting, hex/base64, MD5 (RFC 1321), the fixed protected ports `PortUc`/`PortUs` (45061/45062). | +| `interfaces/Imsd-Sip.cppm` | `imsd::sip` — SIP as a text layer plus TCP framing: header extraction, status codes, the granted lifetime, `ResponseOf` transaction matching. Not a full RFC 3261 parser, deliberately. | +| `interfaces/Imsd-Sdp.cppm` | `imsd::sdp` — SDP offer/answer: the AMR-WB + AMR + telephone-event offer (optional IR.92 preconditions), the parser the media plane needs, `BuildAnswer` for the terminating side, the `CODECS` override. | +| `interfaces/Imsd-Aka.cppm` | `imsd::aka` — USIM AKA glue: the AKAv1-MD5 digest (RFC 3310), IMPI/IMPU/realm from IMSI + MCC/MNC, parsers for `qmicli --uim-get-card-status` and `mmcli` output, the AUTHENTICATE APDU and its response. | +| `interfaces/Imsd-Ipsec.cppm` | `imsd::ipsec` — the two ESP SA pairs (TS 33.203) as `ip xfrm` argv lists, the Security-Server mechanism selection, and the reader that reconstructs SPIs/ports from `ip xfrm state`/`policy` text for a warm resume. | +| `interfaces/Imsd-Messages.cppm` | `imsd::msg` — every SIP request and response imsd sends, built from an explicit `Context` and per-request variables so the builders are pure functions; byte-pinned by `tests/Messages`. Also `IsEmergency`. | +| `interfaces/Imsd-Engine.cppm` | `imsd::engine` — the decisions as pure reducers: `CallMachine` (one call's dialog, returning `Action`s for the shell) and `RegRecovery` (the registration recovery policy, §7.4). | +| `interfaces/ImsdBus.cppm` | `imsd::bus` — the polkit gate in front of call control, shared by imsd and imsd-ofonod. GLib, so outside `imsd-core`. | +| `implementations/main.cpp` | The `imsd` daemon shell: GLib main loop, GDBus service, sockets, subprocesses, the registration sequence and the run loop that pumps the core. | +| `implementations/media.cpp` | `imsd-media`, the per-call RTP leg. Pure std + POSIX, dlopen's the AMR codecs. | +| `implementations/dialerd.cpp` | `imsd-dialerd`, the Plasma Mobile session-bus backend (`org.kde.telephony.*`). | +| `implementations/ofonod.cpp` | `imsd-ofonod`, the GNOME Calls system-bus backend (`org.ofono`). | +| `tests//main.cpp` | The seven unit-test suites: `Util`, `Aka`, `Ipsec`, `Messages`, `Engine`, `Sip`, `Sdp`. | +| `packaging/` | systemd units, the bring-up and upgrade scripts, D-Bus and polkit policies, presets, the gschema override, the two APKBUILDs and the CI helpers (§9). | +| `.forgejo/workflows/package.yml` | CI: builds the aarch64 apks on every push to `main` and publishes them to the Forgejo Alpine registry (§9.4). | +| `bin/`, `build/` | Build outputs (crafter-build and make respectively). Ignored. | + +Also ignored by `.gitignore`, and never to be committed: `*.raw`, `*.rtp`, +`*.stats`, `imsreg.state`, `imsd-*.tar.gz` — captures and runtime state carry +the subscriber's IMSI/MSISDN and network addresses. + +## 3. Build products + +Six products come out of the tree. C++26 modules throughout, `import std;` +in every source but the re-exporting `Imsd.cppm`, clang + libc++ + lld. + +| Product | Kind | Built from | Links | Notes | +|---|---|---|---|---| +| `imsd-core` | static library | the `Imsd` module and its seven partitions | — | Pure `import std` C++, no GLib, no I/O side effects: every message, digest, SA command and call-state decision is here and unit-testable on any dev box without a bus, a modem or a phone. | +| `imsd` | executable | `implementations/main.cpp` | `imsd-core`, `imsd-bus`, gio-2.0, pthread | The daemon. Default product. | +| `imsd-bus` | static library | `interfaces/ImsdBus.cppm` | gio-2.0 | The polkit gate (`net.catcrafts.IMS1.Voice`) imsd and imsd-ofonod both link. A translation daemon that runs as root on the system bus reaches imsd as root, which polkit always allows, so it gates its own callers through this or it is a bypass. | +| `imsd-media` | executable | `implementations/media.cpp` | `-ldl -lpthread` | The RTP/AMR data plane, spawned once per call. No GLib, no core. A separate process for crash isolation and for the far-end-hangup exit-code contract (§7.6). `-- --product=media`. | +| `imsd-dialerd` | executable | `implementations/dialerd.cpp` | gio-2.0 | Plasma Mobile integration: a session daemon owning `org.kde.telephony.{CallUtils,DeviceUtils,UssdUtils}`, bridging to imsd. GDBus translation only. A separate process because those are per-user session-bus names a root system daemon cannot own. `-- --product=dialerd`. | +| `imsd-ofonod` | executable | `implementations/ofonod.cpp` | `imsd-bus`, gio-2.0 | GNOME Calls integration: a system daemon owning `org.ofono`, presenting imsd as an oFono modem. GDBus translation plus the polkit gate on its own callers. `-- --product=ofonod`. | + +The rule behind the split: **`imsd-core` stays GLib-free and free of I/O so +that every decision in it has a unit test.** New protocol logic goes into a +partition with a test. The shell (`main.cpp`) holds protocol decisions no +test reaches — some older than 0.3.10, some added by it: the reg-event +subscription schedule (§7.4: its retry +ladder, Retry-After clamp, Timer F, renewal point and RFC 6665 reasons); the +registration sequence and its transactions (`AwaitFinal`/`AwaitInitialFinal`: +which message is a REGISTER's final, the dispatch of requests arriving +meanwhile and the routing of our reg-event SUBSCRIBE's final read there, the +12 s wait restarted on a provisional; `Reregister`'s one re-challenge); the +inputs to `RegRecovery` — `SipFlow::Connect`'s split of failures into local +refusals and network silence, and the catch sites that sort a failed refresh +(the keepalive's, `ReconnectCycle`'s) and a failed fresh attempt +(`FreshRecover`, `DeferBringUp`) into Refused, Silent or Throttled with the +status, the Retry-After and `saReplaced_`, the bring-up resume's catch (a +refusal's Retry-After fed as a hold, the old registration written off after +any failed resume), the `OnLink` read that writes the old registration off +when its address has left the netdev (`FreshRecover` after a failed move, +`AddressGone`), and `PacketServiceUp`'s read of +ModemManager before a silent cycle is counted; the shell's reaction to a NOTIFY +terminating our binding (the flow closed at once, or kept under a call and +closed when the call ends, a reconnect cycle's or the bring-up resume's wait +ended by it — the reducer's verdict is pinned, the reaction is not); and the +holds file's codec (`PersistHolds`/`LoadHolds`: aging by boot seconds, the +pdn/boot match, defaults for absent keys — proven on the phone only, captures +45- and 70- of 2026-09-25-polkit-voice). For the schedule, a reducer next to +`RegRecovery`, with a Subscription-State parser in `Imsd:Sip` pinned by +`tests/Sip`, is the follow-up that moves it; a pure format/parse pair in +`Imsd:Engine` is the holds file's. + +## 4. Building + +### 4.1 Native, with crafter-build + +Prerequisites: crafter-build, clang with libc++, lld, and the gio-2.0 headers +(`glib2` on Arch, `glib-dev` on Alpine). + +```sh +crafter-build # bin/imsd----/imsd +crafter-build -- --product=media # bin/imsd-media-…/imsd-media +crafter-build -- --product=dialerd # bin/imsd-dialerd-…/imsd-dialerd +crafter-build -- --product=ofonod # bin/imsd-ofonod-…/imsd-ofonod +crafter-build test # the seven unit-test suites +crafter-build lint # the house-style rules of lint-rules.h +``` + +Output directories under `bin/` are named `----`; +the `./bin/imsd-*/imsd` globs in §8.3 rely on that. + +### 4.2 With make (distro packaging) + +GNU make, `clang++` with libc++ and the libc++ std module sources (`std.cppm`: +package `llvm-runtimes` on Alpine, `libc++` on Arch; override `STD_CPPM` if it +lives elsewhere), lld, pkg-config and the gio-2.0 headers. + +```sh +make # build/make/{imsd,imsd-media,imsd-dialerd,imsd-ofonod} +make check # the same seven suites +make install # DESTDIR/PREFIX staged install, including the packaging/ files +``` + +The Makefile forces `CXX = clang++` (abuild exports `CXX=c++`, which is gcc +and has no `--precompile`); an explicit `make CXX=…` still wins. One flag set +is used for every translation unit because BMIs are rejected on +compiler-option mismatch — per-target extras are limited to `-I`/`-D` and +`-l`/`-L`. From `pkg-config gio-2.0` only `-I`/`-D` and `-l`/`-L` pass +through: it also emits `-pthread`, which would config-mismatch the shared +`std` BMI (`project.cpp` filters identically; pthread lives in libc on every +platform targeted). The BMI file names must stay `-.pcm` — +that is how clang resolves `import :Part;` under `-fprebuilt-module-path`. + +`make install` places: the four binaries (`imsd-media` under `libexec`), +`ims-pdn-up.sh` and `imsd-handover.sh` under `libexec`, `imsd.service` and +`imsd-ofonod.service`, `imsd-dialerd.desktop` in `/etc/xdg/autostart`, the +D-Bus policies (`net.catcrafts.IMS1.conf`, `imsd-ofono.conf`), the polkit +policy, `imsd-ofono-restart-calls.sh`, the GNOME Calls gschema override and +the `80-imsd-ofono.preset`. + +### 4.3 Cross-compiling for a phone (aarch64 Alpine / postmarketOS) + +```sh +packaging/make-sysroot.sh # one-time: Alpine aarch64 sysroot from the CDN +crafter-build -- --target=aarch64-alpine-linux-musl \ + --sysroot=$HOME/.cache/imsd/sysroot-aarch64-alpine \ + --march=armv8.6-a+fp16fml+aes+sha3+sm4 --mtune=cortex-a720 +# the same flags after `crafter-build test --target=aarch64-alpine-linux-musl` +# run the suite under qemu-aarch64 against the sysroot +``` + +`make-sysroot.sh` needs no root, qemu or device: it downloads +`apk-tools-static` and the Alpine keys and populates a minimal rootfs (musl, +libc++, glib and their headers, compiler-rt, libunwind, gcc for its +`crtbeginS.o`/libgcc, which clang's `*-alpine-linux-musl` driver links +against). In a cross build `project.cpp` does not ask the host's pkg-config +(it would answer for the wrong architecture); it adds `-I=/usr/include/glib-2.0` +and `-I=/usr/lib/glib-2.0/include`, which clang resolves inside the sysroot. +This is the flow CI runs (§9.4). + +## 5. Tests + +`crafter-build test` and `make check` run the same seven suites. Each suite +is a plain `main()` with a `Check(bool, message)` counter: failures print +`FAIL: …`, success prints `: all tests passed`, and the exit status is +the failure count for six suites — non-zero short of a multiple of 256 — and +1 for `Engine`. Seven `all tests passed` lines from `make check`, on every +commit of a range (§11), is the project's definition of green. + +What the suites pin: + +- `Util` — tokens, IPv6 detection and host/port formatting, hex/base64, MD5. +- `Aka` — the AKAv1-MD5 digest, identity derivation, the `qmicli`/`mmcli` + text parsers, the AUTHENTICATE APDU and its response parser. +- `Ipsec` — the exact `ip xfrm` argv lists for the SA pairs, the + Security-Server mechanism selection, the SA reader. +- `Messages` — every outgoing SIP message byte-for-byte, cross-generated from + the Python reference stack imsd replaced. +- `Sip` — the text layer's edge cases (empty header values, line anchoring, + keepalive discards), `ResponseOf`, refused finals with reason headers, + reginfo documents. A change that flips one of those tests is a behaviour + change, not a cleanup. +- `Sdp` — the offer's exact bytes, the parser, `BuildAnswer` at the offer's + own payload types and octet-align mode, the `CODECS` override. +- `Engine` — the call state machine driven through recorded network dialogs + (reliable 18x/PRACK, 2xx ACK + media start, far-end hangup, CANCEL/answer + races, busy/error finals, the terminating side's 100/180/200/486/487/488, + G.711-only and AMR-NB gateway offers, ring timeout, BYE both ways, the + emergency chain) and `RegRecovery` (thresholds, backoffs, the throttle + schedule, Retry-After, which waits a Dial may cut short, when the old + registration counts as gone, the holds a restart takes over, a binding the + network terminated). + +Rules: + +- A decision in `imsd-core` gets a test in the suite that owns the partition. + Fixtures are recorded network traffic where any exists, inline fixtures + only where no recording exists. +- Behaviour that lives in the shell (`main.cpp`: timers, the reg-event + subscription schedule, the audio-session resolution, the hand-over scripts) + is proven on a phone where a bench can reach it; the commit message says + in one sentence that it was tested, and the arms no bench has reached are + named here. The known gaps: the reg-event subscription schedule (the + arming and KPN's + `noresource` closure have run on hardware, the 60 s retry only on a + pre-fix build that renewed that closure; the retry on this code, the + renewal firing, Timer F, the doubling, a SUBSCRIBE `Retry-After`, the + renew arm and the back-off's survival across a refresh have run nowhere — + §3, §7.4); a `Retry-After` on a REGISTER and a `Dial` bringing a recovery + step forward (KPN's refusals, all 403, carry none; imsd-ofonod refuses the + `Dial` itself and imsd-dialerd forwards it — pinned in `tests/Engine` + only); a refused initial or fresh + REGISTER (403, 5xx) at bring-up or in recovery (KPN has only throttled or + stayed silent — pinned in `tests/Engine` only); the write-off of the old + registration by a recovery's fresh attempt that fails past the SA setup + (such attempts are on record only as a bring-up's fatal exit, which does + not pass that arm — pinned in `tests/Engine` only); a refused + refresh counted + towards the escalation, and the escalation by two of them (KPN refuses a + refresh only with the 403 that says the registration is gone — pinned in + `tests/Engine` only); of the throttled-attempt chain in recovery (a + throttled fresh attempt, the old SA retried once meanwhile with the stored + binding's Contact and refused 403, the deferred attempt accepted at the + hold's end — run 2026-10-03 on the series as it then stood) the deferred + attempt made by the + same process, since a restart inside the hold made it from the holds file + instead (the same process made it on 2026-09-24, on the build before the + policy's rework); the address moves + writing `old_gone` to the holds file (the move benches predate that + field); a SUBSCRIBE final read inside a REGISTER wait (KPN answers within + the second); the shell's handling of a NOTIFY that terminates our own + binding (§3; every captured termination is a previous contact's O2 + `deactivated`, and a deregistration during a call cannot be staged); + `SipFlow::Connect`'s errno arms — socket() failing, a UDP connect() + error, a bind() refused while the address is still on the link — which + no bench reaches without fault injection (§3); a coverage gap on the air + (no bench stages a radio gap without an RF shield) — the packet-service + read's rule has run through a fake `mmcli` only (2026-10-03: `searching` + and `detached` not counted, `connecting` with `unknown` counted), its + other states, ModemManager not answering and its second site (a refresh + unanswered over a reconnected flow) not at all, and `tests/Engine` pin + nothing of it; the REGISTER wait's diverting arms — a datagram skipped on + the init port, a response to another transaction ignored, a provisional + restarting the wait, a request (an INVITE among them) dispatched and + answered under the wait; an incoming call answered from a session's + dialer since the polkit gate — imsd-dialerd's `Accept` by the active + session, or GNOME Calls' `Answer` through imsd-ofonod's gate (imsd's own + `Accept` gate has run: refused for an account without a seat session and + for nobody, accepted for root, 2026-10-04); a refused + final carrying reason headers (KPN's 403s carry none — the rendering is pinned + in `tests/Sip` only); an `AUDIO_USER` pin in imsd's own environment that + names no account (the logind fallback) or an account without a PipeWire + socket (a silent call) — a pin naming an account with one ran, 2026-10-04. + `DetectLocal`'s three-tier address ranking for a run without `LOCAL` + (§10.1) ran through a fake `ip` (2026-10-05: the phone's listing of the + netdev and two cut-down ones). +- `imsd-media --selftest` round-trips the RFC 4867 payload packing (both + formats, both AMR codecs) and the G.711 tables. `MIC_SRC=` (raw s16 + PCM at the codec rate, paced in real time) and `PCM_DUMP=1` are test seams + for driving the leg against a synthetic RTP peer without a phone. +- `imsd --session` and `imsd-ofonod --session` own their names on the session + bus for development without root, polkit or a phone (§8.3). + +## 6. Code style and lint + +`crafter-build lint` reports the rules of `lint-rules.h`; `crafter-build format` +applies the transform rules. The rules, by name: + +| Rule | Meaning | +|---|---| +| `spdx-header` | Line 1 `// SPDX-License-Identifier: …`, line 2 `// SPDX-FileCopyrightText: …`, line 3 blank. Line 4 may carry `// lint-disable-file …` where a file needs one. | +| `no-tabs` | Spaces only. | +| `naming` | Types, aliases, functions, statics, namespace-scope globals and `constexpr` constants are PascalCase; locals and parameters camelCase; one trailing underscore marks a member or shadows a keyword (`label_`, `requires_`). Heuristic, report-only. | +| `enum-class` | `enum class`, never a plain `enum`. | +| `no-iostream-print` | `std::println` (and `std::println(std::cerr, …)`), never `std::cout` / `<<` to `std::cerr`. | +| `no-char-pointer` | `std::string` / `std::string_view` over `char*`. C interop stays: argv, getenv/setenv, dlerror, `c_str()`/`.data()` call sites, `reinterpret_cast`, `extern "C"`. | +| `format-concat` | `std::format` instead of `+` with a string literal; simple single-line chains are rewritten automatically, anything else is reported. | +| `fixed-width-types` | `std::int32_t` and friends instead of `short`/`int`/`long` (transform); lines with `main`/`argc`/`argv` or `extern "C"` keep C ints. Nearly every source file disables this one on line 4. | +| `single-declaration` | One declaration per statement; `Type a = x, b = y;` is auto-split, declarators with pointers, references, parens or templates are reported. | +| `brace-style` | K&R: a `{` alone on a line after a `)`/`else`/`do`/`try` header joins the header line. Standalone scope blocks stay. | +| `if-single-line` | A single-statement `if` body joins the `if` line when the result stays within 250 columns and neither line carries a comment. | +| `wrap-join` | Wrapped call arguments and `&&`/`||`/`|` continuations join back onto one line when the expression fits in 250 columns. | +| `paren-spacing` | No padding inside parentheses. | +| `trim-trailing-ws` | No trailing whitespace. | +| `final-newline` | The file ends with a newline: one is appended when it is missing. | + +Beyond the rules: + +- Comments explain constraints the code cannot show — a network's observed + behaviour, a spec clause, the reason a shape was chosen — and name their + evidence (a dated bench, a capture) when they rest on one. They do not + narrate what the code does. +- Log lines are the operator's interface (§7.8). A new state the daemon can + be in gets a journal line that names it; existing lines are grepped for by + scripts (`imsd-handover.sh` reads a pre-0.3.10 daemon's `bring-up deferred: … + next fresh registration in N min`), so changing one is an interface change. +- GLib only in `main.cpp`, `dialerd.cpp`, `ofonod.cpp` and `ImsdBus.cppm`. + `media.cpp` is std + POSIX. The partitions are std only. + +## 7. How the daemon works + +### 7.1 Processes and buses + +`imsd` runs as root on the system bus as `net.catcrafts.IMS1` (object +`/net/catcrafts/IMS1`, interface `net.catcrafts.IMS1`; the frozen ABI, §8). +The bus policy `packaging/net.catcrafts.IMS1.conf` lets any local account +reach it; placing, answering and ending a call is polkit's +`net.catcrafts.IMS1.Voice` (`packaging/net.catcrafts.IMS1.policy`, shaped like +ModemManager's Voice action: the active local session and root — not an ssh +login, an inactive session or a system service account). Reads (`GetStatus`, +`GetCalls`) stay open, as ModemManager's are to every local account: the call +signals reach them all anyway, the status (the address, the P-CSCF, the +phone number) travels in no signal. + +Two threads: a dedicated engine thread runs the SIP loop (blocking framed +receive on the protected client flow, the protected-port listeners for +terminating requests, media reaping, the keepalive refresh); the GLib main +loop owns the bus. D-Bus method calls push commands to the engine queue; +engine events reach the bus through `g_idle_add`, where the calls table and +the status snapshot live (main thread only). + +Per answered call imsd forks `imsd-media` (`IMSD_MEDIA`, default next to the +`imsd` binary, else `/usr/libexec/imsd-media`) with the addresses, ports and +payload type as arguments and the codec and audio knobs in its environment +(§7.6, §10.2). + +The desktop daemons are translation layers: `imsd-dialerd` runs as the +session user (autostarted from `/etc/xdg/autostart/imsd-dialerd.desktop` in +place of plasma-dialer's `modem-daemon`, whose autostart the package hides +through `/etc/skel/.config/autostart/org.kde.modem.daemon.desktop`); +`imsd-ofonod` runs as root on the system bus (`imsd-ofonod.service`, +`Conflicts=ofono.service`). Both consume the frozen ABI and add nothing to it. + +### 7.2 Bring-up + +`imsd.service` runs two `ExecStartPre=` steps before the daemon: + +1. `/usr/libexec/ims-pdn-up.sh` brings the `ims` PDN up through ModemManager + (§7.3) and writes `/run/imsd.env` with `DEV=` (the connected netdev) and + `LOCAL=` (the PDN address ModemManager assigned). +2. `/usr/libexec/imsd-handover.sh` (`ExecStartPre=-`, failure-tolerant) hands + a pre-0.3.10 daemon's throttle wait over (§7.5). + +The unit then reads `EnvironmentFile=-/run/imsd.env` and +`EnvironmentFile=-/etc/imsd.env` (the admin's file wins) and starts +`/usr/bin/imsd`. systemd reads those files anew for every command it spawns, +so each `ExecStartPre=` already sees the values the previous one wrote. +`TimeoutStartSec=900` covers the bring-up's worst case (a 2-minute modem wait, +a 5-minute registration gate, connect retries). `Restart=on-failure`, +`RestartSec=120`; `KillMode=mixed` with `TimeoutStopSec=10`; `StateDirectory=imsd` +(`/var/lib/imsd`). + +The daemon's own bring-up (`BringUp()` in `main.cpp`): + +1. `PCSCF` must be set (no P-CSCF discovery from the PDN's PCO yet). +2. `qmicli -d qrtr://0 --uim-get-card-status` finds the ready USIM (slot, AID); + `mmcli -m any` gives the primary SIM path and the IMEI (for the + `+sip.instance` URN); `mmcli -i ` gives IMSI and MCC/MNC, from which + the IMPI/IMPU/realm are derived. `mmcli --location-get` fills + `P-Access-Network-Info`. +3. **Warm resume** when an SA pair is already in the kernel and the state + file (`STATE_FILE`, default `/var/lib/imsd/imsreg.state`) describes the + registration: reconnect through the existing SA and send a refresh + re-REGISTER (`RESUME via existing SA`, then `REGISTERED (resumed + + true-refreshed)`). This avoids the network's fresh-SA throttle. `RESUME=0` + forces a fresh registration. A resume the network refuses is not fatal: the + fresh registration replaces its SAs. +4. **Fresh registration** otherwise (`FRESH registration`): an unprotected + REGISTER on `PCSCF_PORT` (UDP, always), the 401 with the AKA challenge and + the `Security-Server` list, the AKA run over a UIM logical channel + (`--uim-open-logical-channel`, `--uim-send-apdu`), the SA pair installed + with `ip xfrm`, the protected REGISTER over the negotiated transport + (§10.1, `SIP_TRANSPORT`), `REGISTERED (fresh)`. Then the reg-event + SUBSCRIBE (§7.4). +5. A keepalive re-REGISTER refresh at half the granted lifetime, within + 120–1800 s (`REFRESH_INTERVAL`), only while idle. + +The state file is JSON: `callid`, `ftag`, `cseq`, `spi_uc`, `spi_us`, +`expiry`, `route`, `ppi`, `aor`, `contact_user`, `transport`. + +A failed fresh bring-up exits non-zero and systemd retries after +`RestartSec` — except a bring-up the network refused or throttled, which +waits in process on the refusal schedule (§7.4) with nothing to reconnect +over, then tries again (`bring-up deferred: …`). A registered daemon whose +address leaves the netdev with no PDN address there to move to also exits +(`engine fatal: … is no longer on qmapmux0.0 — exiting for systemd +restart`) — not at once: the loss is +noticed at the next recovery step, the refresh (every 1800 s on KPN's +7200 s grant), whose +re-REGISTER gets no reply and whose reconnect cannot bind the address (dev +phone 2026-10-02, the ims bearer disconnected under a registered daemon: +14 min from the disconnect to the exit). After `RestartSec` (120 s) +`ims-pdn-up.sh` runs again and imsd registers on the PDN's address (the +same bench: a new prefix, `REGISTERED (fresh)` six seconds into the new +start). + +### 7.3 The ims PDN (`packaging/ims-pdn-up.sh`) + +Finds or creates the `ims` bearer through ModemManager, connects it and +configures the muxed netdev. Idempotent; a non-zero exit fails the unit. + +- Waits up to 120 s for a modem, then gates the connect attempts on network + registration (`IMS_REG_TIMEOUT`, default 300 s): some carriers attach ~2 min + after boot. +- Connects by **3GPP profile index** (`IMS_PROFILE_ID`, default: looked up by + APN in `qmicli --wds-get-profile-list=3gpp`), not by APN string: a + profile-indexed request is exempt from the modem's attach-family forcing + that refuses an APN-string IPv6 request when the attach PDN was granted + IPv4-only (Odido, O2 UK, Swisscom). An empty profile list is the modem still + coming up; a list without the APN is final only once the modem is + registered (the list can be partial while it enables). A leftover + APN-string bearer is never reused and is deleted when connecting by index. +- Sets `accept_ra_defrtr=0` on the netdev before link-up: the PDN's router + advertisement must never make the ims PDN the phone's IPv6 default route (on + IPv4-only wifi every AAAA-first client would send its SYNs into the IMS + walled garden and hang). The RA's prefix and MTU still apply. An RA default + route already present (an imsd restart) is flushed. +- Sets the PDN MTU ModemManager reports on the link (1280 on the FP6), adds + the address, waits out DAD, and installs a host route to `PCSCF` + (`dev `, no gateway — a raw-IP netdev without header_ops). The PDN + is IPv6-only, so `PCSCF` must be an IPv6 address; anything else on the IMS + network (its DNS servers, a traceroute target) needs its own host route + (`ip -6 route add dev qmapmux0.0`). imsd installs a host route per + media peer itself. +- Opens imsd's protected ports 45061–45062 (TCP and UDP) on `qmapmux*` in + nftables when a `inet filter` table exists (pmOS's default INPUT chain drops + inbound on `qmapmux*`, which silently killed every network-initiated + request after ESP decapsulation). Best-effort. +- Writes `/run/imsd.env`: `DEV=`, `LOCAL=`. imsd + registers on the address the network assigned, not on the kernel's + temporary SLAAC address from the RA, which the netdev lists first and + retires after about two days (the SAs and the Contact would go with it). + +Its own settings (read from the environment, else from `/etc/imsd.env` for +manual runs): `IMS_APN` (default `ims`), `IMS_IP_TYPE` (`ipv6`), +`IMS_PROFILE_ID` (empty = look up; `none` = connect by APN string), +`IMS_REG_TIMEOUT` (300), `PCSCF`. + +### 7.4 Registration recovery (`RegRecovery`, `Imsd-Engine.cppm`) + +What the daemon does when the registration stops working after bring-up, as +a pure reducer the shell feeds with the clock and the network's answers. Two +kinds of failure reach it: a **refusal** (the network answered a refresh or a +fresh REGISTER with a final other than 200/401) and **silence** (the flow died +and reconnecting through the SA, or the refresh over it, got no answer). +Neither is retried for long on the same registration (TS 24.229 5.1.1.4 and +RFC 5626 have the UE register anew). Before this reducer existed the daemon +looped reconnect + re-REGISTER on one Call-ID and SA forever (O2 UK, +2026-09-24: 21 × 503 over 70 min, then 33 unanswered reconnect cycles over +2 h 39 min). + +The policy, with its constants: + +| Step | Rule | +|---|---| +| Reconnect | Through the existing SA, then refresh. Back-off 5 s → 5 min (`ReconnectDelayMin`/`Max`). | +| Escalate to a fresh registration | After two refused refreshes (`RefusalsBeforeFresh`) or two reconnect cycles that end without an answer (`SilentCyclesBeforeFresh`) while the modem reports packet service (`PacketServiceUp`: ModemManager's registration and packet-service state, as `ims-pdn-up.sh` reads them). A cycle that fails during a coverage gap is the phone's silence, not the network's: not counted, retried on the local-refusal pace, so a radio gap across a refresh does not escalate into a fresh registration, which KPN refuses while the registration is live and drops it for (§13). New Call-ID, new challenge, new SA pair (`registering fresh (recovery attempt N)`). | +| A refused or throttled fresh attempt | Waits on the **throttle schedule**: 21 min, growing ×1.5 to 60 (`ThrottleMinutesMin`/`Max`). KPN refuses a fresh SA pair on an address whose registration is live (§13), and drops that registration the moment an initial REGISTER arrives. | +| A silent fresh attempt | Backs off 30 s → 10 min (`FreshDelayMin`/`Max`). | +| `Retry-After` | Always wins over a shorter wait, honoured up to an hour (`RetryAfterMax`, the schedule's longest wait); a longer one is cut to an hour and the refusal heard again then. On a refused fresh attempt it replaces the schedule's wait, shorter or longer, never below 30 s (`FreshDelayMin`). | +| A 403 | A refusal like the others: never hammered (the schedule), never abandoned (a SIM that is unbarred registers within the hour). | +| The old registration is gone | A 403 once a fresh registration is due, a fresh attempt that got past the SA setup, a binding the network terminated (reg-event), an older address that left the netdev, or a bring-up whose resume failed: no reconnect over the old SA from then on. Otherwise the existing SA is still retried on the reconnect back-off while a fresh registration is deferred. | +| A `Dial` while unregistered | Brings the next recovery step forward (the reconnect, or the fresh attempt once it is due) — never a wait the network imposed (a `Retry-After`, or the schedule after a refused or throttled fresh attempt). imsd-ofonod refuses a `Dial` then and imsd-dialerd lists no modem. | +| Success | Resets everything. | + +The network's waits are **holds**, and a restart of imsd (an upgrade restarts +it) does not cut them short: `PersistHolds` writes them to the holds file +(`HOLDS_FILE`, default `/run/imsd.holds`) as JSON — `pdn` (the PDN's own +address), `boot` (the boot id), `written_at` (seconds of uptime), `hold`, +`fresh_hold`, `throttle_minutes` (the schedule's place), `old_gone` (the old +registration is gone: a 403 once a fresh registration is due, an attempt past +the SA setup, a binding the network terminated, an older address that left +the netdev, and every bring-up that goes on to register fresh — its resume +failed or there was nothing to resume) — and `LoadHolds` takes them +over at the next start for the same `pdn` and boot id, waiting them out +before the resume (a refresh's `Retry-After`) or before the fresh +registration (a fresh attempt's schedule or `Retry-After`, a terminated +binding's wait); with `old_gone` set the start does not resume over the old SAs at +all, it waits the fresh hold out and registers fresh — also after a start +during an outage, whose resume and fresh attempt both fail without an answer: +the next start then registers fresh while the network still holds the old +registration, which KPN refuses (§13; dev phone 2026-10-04: 21 min where +0.3.9's retried resume would have recovered at once). A file without the key +reads as not gone; the 0.3.9 hand-over (`imsd-handover.sh`) writes the key as +true, since the wait it hands over follows a throttled initial REGISTER, at +which KPN drops the old registration (§13). A reboot or another PDN address +starts clean. The schedule's place is kept +even after the holds run out, so a restart does not start it over at 21 min. +When nothing is held the file is removed — when it is a regular file; +`/dev/null` switches the knob off and anything that is not a regular file is +emptied, never unlinked. The recovery policy's waits and the holds are +measured on the boot clock (`CLOCK_BOOTTIME`), so a suspend counts; the +keepalive refresh gate, the CRLF keepalive and the reg-event retry and +renewal timers run on the steady clock and count awake seconds only. + +The **reg-event subscription** (RFC 3680; the network's own view of the +binding) is one live dialog per registration, subscribed anew ahead of the +lifetime the S-CSCF granted. Refused or unanswered, it is retried after 60 s +doubling to 10 min (a `Retry-After` honoured up to an hour; a registration +refresh neither brings a scheduled retry forward nor starts its back-off +over — the back-off resets when a subscription starts afresh or is +accepted). When the S-CSCF ends it +for a reason that allows renewal (RFC 6665: `deactivated`, `timeout`, +`giveup`, `probation`) it is renewed; after any other reason — `noresource`, +`rejected`, a reason-less end — the next registration refresh subscribes anew. An +earlier subscription's NOTIFY is still read for the binding's state, but its +end is not ours to renew (KPN closes a registration's previous subscription +with `noresource` when a new one replaces it). A NOTIFY that **terminates the +binding** triggers a fresh registration — at once, or after the network's +`retry-after` (up to an hour); after the call, when one is up (one exception: +a REGISTER wait that still gets its 200 after such a NOTIFY, a call having +kept the flow, leaves that registration standing). After +`rejected` and `unregistered` it waits on the refusal schedule, a wait no +`Dial` cuts short: the binding is gone, so nothing is retried meanwhile. One +termination counts once, however many NOTIFYs report it (a repeated +`probation` report may extend its hold from its own `retry-after`). The schedule is +shell code in `main.cpp`, not a reducer (§3); its hardware coverage per arm +is in §5. + +A registration resumed on an older address (a pre-0.3.10 temporary one) +moves to the PDN's own address in place: with its next fresh registration, or +when the older address leaves the netdev first, which takes that registration +with it. + +### 7.5 Upgrades and the 0.3.9 hand-over + +An apk upgrade (or an `apk add` of a package whose daemon an `apk del` left +running) restarts `imsd` and `imsd-ofonod` once the transaction is done: the +packages' `post-upgrade`/`post-install` scripts mark the units +`needs-restart` (`systemctl set-property … Markers=+needs-restart`, after a +`daemon-reload` when the unit is not-found because its file was just +reinstalled) and pmOS's systemd trigger runs `enqueue-marked`. A call in +progress ends. The restart resumes the registration over its existing SAs, +or goes on waiting out a hold the network had set (§7.4). A stopped unit +keeps no marker and `enqueue-marked` leaves it stopped (dev phone, systemd +262~rc3, 2026-10-03). + +A daemon older than 0.3.10 kept its bring-up throttle wait in memory only, so +the restart registered fresh at once instead of waiting the schedule out. +For that one hop: `imsd.post-upgrade` records the running +daemon's `InvocationID` in `/run/imsd.handover` (when no holds file exists); +at the next start `imsd-handover.sh` reads that finished invocation's last +bring-up line from the journal and, when it is the throttle's (`bring-up +deferred: fresh-SA throttle active (spi-s=0) — … next fresh registration in +N min`; the old daemon's other deferrals, after a 401 it could not use, are +not handed over), ages it on the monotonic clock (`__MONOTONIC_TIMESTAMP` against +`/proc/timer_list`; the phone's wall clock can read 1970 until its first NTP +sync, and the monotonic clock stops in a suspend, so the wait can only come +out long, never short of the network's window), and writes what is left to +the holds file as 0.3.10 keeps it — for the address this start registers on +when that is the address the wait was earned on (the old daemon logged its +`LOCAL=` once at start); on another address — the normal hop, 0.3.9 on the +kernel's temporary address and 0.3.10 on the PDN's own, which holds no +registration (§13) — only the verdict that the old registration is gone is +written and the new start registers fresh at once (dev phone 2026-10-03: +accepted, registered 16 s after 0.3.9's refused attempt) — +`LOCAL` as systemd hands it to the unit's commands (the `/etc/imsd.env` pin +if there is one, else what `ims-pdn-up.sh` just wrote), unquoted the way imsd +itself gets it. Reading at the start rather than at the upgrade means a wait +that ended in between — the old daemon making another attempt — is +read as it ended. When the old address cannot be read the wait is handed +over anyway; when the wait cannot be timed, or this start has no `LOCAL`, the +journal says it was not handed over. + +### 7.6 Calls and the media plane + +`CallMachine` owns one call's SIP dialog, outgoing or incoming, and never +touches a socket, a clock or a subprocess: each event returns `Action`s for +the shell (send this on the client flow, answer that request on the channel +it came in on, start/stop the media leg, emit this D-Bus state change, arm +this deadline). States and reasons are the frozen D-Bus vocabulary: +`dialing` / `ringing` / `incoming` / `active` / `terminated`, with reasons +`outgoing` / `incoming` / `accepted` / `local-hangup` / `remote-hangup` / +`refused-or-busy` / `error`. + +- Outgoing: INVITE with the SDP offer (AMR-WB + AMR + telephone-event by + default; `CODECS` restricts and orders; `PRECOND=1` adds IR.92 + preconditions), reliable 18x with PRACK, 2xx ACK and media start, CANCEL + before answer, BYE after. Busy and error finals end the call with + `refused-or-busy` or `error`. +- Incoming: an INVITE on a protected-port listener is validated before it is + announced to the UI; 100 and 180 (reliable when the caller requires + 100rel); `Accept` sends the 200 with the SDP answer at the offer's own + payload types and octet-align mode; a remote CANCEL gets 200 + 487; an + unusable offer 488 naming what was offered; ring timeout 480. A landline + gateway's G.711-only or AMR-NB offer rings and is answered at its own + payload types. +- Emergency: a number `IsEmergency` classifies (112, 911, plus + `EMERGENCY_NUMBERS`; SIM `EF_ECC` is not read yet) is tried as + `urn:service:sos` first, with one fallback to a plain INVITE of the digits; + a plain INVITE answered `380 Alternative Service` with an emergency body is + re-attempted as `urn:service:sos`. +- `SendDtmf` is a stub (RFC 4733 not implemented). +- Incoming calls reach imsd only when the network's T-ADS routes the call to + the IMS domain; the REGISTER Contact advertises the MMTEL ICSI and + `+sip.instance` for that. When T-ADS still picks CS, no INVITE arrives — + a network/modem-integration matter, not a daemon defect. + +`imsd-media` is one process per call: `imsd-media + `. It binds the advertised +RTP port (`RTP_PORT`, default 50004), sends uplink frames from `pw-record` +through the encoder, captures the downlink, and with `PLAY=1` reconstructs +the media clock from RTP timestamps so `pw-play` stays real-time-paced +through far-end DTX silence. Codecs: AMR-WB (16 kHz, the default), AMR +narrowband and G.711 PCMA/PCMU (8 kHz, what a PSTN gateway offers); AMR +frames ride RFC 4867 payloads, octet-aligned or bandwidth-efficient +(`OCTET_ALIGN`, mirrored from the SDP). The AMR codecs are dlopen'd +(`libvo-amrwbenc` + `libopencore-amrwb` for WB, `libopencore-amrnb` for NB); +G.711 is a table. **Exit code 3 means the downlink dried up** for +`MEDIA_TIMEOUT` seconds (default 6.0) — the far-end-hangup signal, which on +carriers whose BYE never reaches our SAs is the reliable teardown trigger. +Per-call stats land in `OUT_DIR` (default the state dir); `RTP_DUMP=1` +captures the raw downlink next to them. + +**Audio session.** Call audio goes through the PipeWire of one account. +imsd resolves it for each call from logind: the user session active on +seat0 (`loginctl show-seat seat0`; a greeter does not count) when it has a +`/run/user//pipewire-0` socket, and passes it to `imsd-media` as +`AUDIO_USER`. `AUDIO_USER` set in imsd's own environment pins an account +instead; one that is not an account falls back to logind. imsd as packaged +(root, on the system bus) logs the resolution at start and when a call's +media starts (`audio session: …`), `imsd-media` once per call. An `imsd-media` started without the variable at all (by hand, or +by an imsd older than 0.3.10 while an upgrade replaces it) tries the +account `user`, its old default. + +### 7.7 Runtime files + +| Path | Written by | Content | +|---|---|---| +| `/etc/imsd.env` | the admin | configuration (§10); `PCSCF=` is required. The apk adds a drop-in with `ConditionPathExists=/etc/imsd.env` (§9.2), so an unconfigured phone does not boot into a failing unit | +| `/run/imsd.env` | `ims-pdn-up.sh` | `DEV=`, `LOCAL=` of the connected PDN | +| `/run/imsd.holds` (`HOLDS_FILE`) | imsd, `imsd-handover.sh` | the network's holds and the schedule's place (§7.4) | +| `/run/imsd.handover` | `imsd.post-upgrade` | the InvocationID of the daemon an upgrade replaces, written when no holds file exists (§7.5); consumed by `imsd-handover.sh` | +| `/run/imsd-ofonod.started` | `imsd-ofono-restart-calls.sh` | marker: imsd-ofonod has started once since boot | +| `/var/lib/imsd/imsreg.state` (`STATE_FILE`) | imsd | the registration context for a warm resume | +| `/var/lib/imsd/*.stats`, `*.rtp`, `*.raw` | imsd, imsd-media | per-call media stats, raw RTP (`RTP_DUMP`), SIP dumps (`DUMP_SIP`; mode 0600 — they carry the IMSI/MSISDN) | +| `$XDG_STATE_HOME/imsd/` (else `~/.local/state/imsd/`), `$XDG_RUNTIME_DIR/imsd.holds` (else `/tmp/imsd.holds`) | imsd | the same files for a non-root (`--session`) run | + +### 7.8 Journal vocabulary + +Lines the operator (and the scripts) look for, all prefixed `imsd: `: + +- `LOCAL=… P-CSCF=…`, `SIP transport: …`, `SIM slot=… IMSI=… domain=…` at start. +- `RESUME via existing SA (cseq N)`, `REGISTERED (resumed + true-refreshed)`, + `FRESH registration`, `Security-Server: …`, `REGISTERED (fresh)`. +- `bring-up deferred: …; next: fresh registration in N s` — one line; `in N + min` from a minute (the pre-0.3.10 form `bring-up deferred: … next fresh + registration in N min` is what `imsd-handover.sh` reads from an older + daemon's journal), `a hold the network set before this start stands (…)`, + `the refusal schedule stands at N min from before this start: …`. +- `…; next: fresh registration in N s` (`in N min` from a minute, `now`), + `registering fresh (recovery attempt N)`, + `fresh registration deferred/refused/failed: …`, `reconnected + re-registered`, + `recovered by a fresh registration`. +- `the registration's SAs are on X, not the PDN's own address Y: resuming + there until the next fresh registration`, `the fresh registration moves + from X to the PDN's own address Y`, `back on X: the old registration stands + until the next fresh attempt`, `X has left DEV: the registration on it is + gone; staying on Y; next: …`, `X is no longer on DEV: the registration on + it is gone; on the PDN's own address Y now; next: …`. +- `engine fatal: … — exiting for systemd restart`. +- `reg-event SUBSCRIBE sent`, and the binding's state from NOTIFY. +- `audio session: …` at start and at each call's media start; `CODECS override active: …`. +- A refused REGISTER or SUBSCRIBE is logged with its status line and + any `Retry-After` / `Warning` / `Reason` / `Server` headers. +- `connect: no answer from the P-CSCF N times (…)` when the protected + connect series ends unanswered. + +From the scripts: `ims-pdn-up: …`, `imsd-handover: …`, +`imsd-ofono-restart-calls: …`. + +## 8. D-Bus ABI and desktop integration + +### 8.1 `net.catcrafts.IMS1` (frozen) + +Object `/net/catcrafts/IMS1`, interface `net.catcrafts.IMS1`. The ABI is +frozen — the two translation daemons depend on it, and a new desktop needs a +new translation daemon, not a change here. + +| Member | Signature | +|---|---| +| `Dial(number)` | `s → s` (callUni) | +| `HangUp(callUni)` | `s` | +| `Accept(callUni)` | `s` | +| `SendDtmf(callUni, tones)` | `ss` | +| `GetCalls()` | `→ aa{sv}` | +| `GetStatus()` | `→ a{sv}`: `registered` (b), `local` (s), `pcscf` (s), `expiry` (x), `cseq` (x), `ppi` (s) | +| `CallAdded` | `s a{sv}` | +| `CallStateChanged` | `s s s` (callUni, state, reason) | +| `CallDeleted` | `s` | +| `RegistrationChanged` | `b` | + +`Dial`, `HangUp`, `Accept` and `SendDtmf` are gated by polkit (§7.1); a +refused caller gets `org.freedesktop.DBus.Error.AccessDenied`, and polkit not +answering is logged with the sender. A translation daemon that runs as root +on the system bus must gate its own callers through `imsd-bus`, or it is a +bypass of `net.catcrafts.IMS1.Voice`. + +### 8.2 Plasma Mobile (`imsd-dialerd`) + +A drop-in for plasma-dialer's `modem-daemon`, owning the same session-bus +names and objects (`org.kde.telephony.CallUtils` at +`/org/kde/telephony/CallUtils/tel/mm`, likewise `DeviceUtils` and +`UssdUtils`; the `/tel/mm` suffix is hard-coded in plasma-dialer's QML plugin +and kde-telephony-daemon — keep it). plasma-dialer, kde-telephony-daemon and +spacebar run unmodified. ABI details verified against plasma-dialer 6.7.3 are +in the header of `implementations/dialerd.cpp` (`CallData` marshalled as a +struct wrapping `a{sv}`, struct-wrapped int32 enums in MMCallState numbering, +an empty callUni meaning the active call, `callsChanged` with the full vector +on every add/delete). It lists no modem while imsd is unregistered. + +### 8.3 GNOME Calls, Phosh, GNOME Mobile (`imsd-ofonod`) + +Presents imsd as an oFono modem: `org.ofono` with a `Manager` at `/`, one +`Modem` + `VoiceCallManager` at `/imsd`, and a `VoiceCall` object per live +call — what GNOME Calls' bundled `ofono` provider plugin drives; Calls, Phosh +and GNOME Shell run unmodified. `org.ofono` is the seam because it is the +only one a third party can implement: `org.gnome.Calls.Call` is what Calls +*exports* so a shell can observe and control a call that already exists +(Accept, Hangup, SendDtmf, Silence — no Dial), and Phosh consumes it from +Calls — its call UI needs nothing extra once Calls has a working origin; +Calls' own backends are libpeas provider plugins (`mm`, `ofono`, `sip`, +`dummy`) against private headers no distro installs. +Consumer-shaped details verified against calls 50.0 are in the header of +`implementations/ofonod.cpp`. Verified on a Fairphone 6 (postmarketOS, +dbus-broker) with GNOME Calls 50.0: outgoing and incoming calls with audio +both ways, answered and hung up through `org.gnome.Calls.Call` — the surface +Phosh drives. The answered calls predate the polkit gate (§5). + +Setup: `apk add imsd-ofono` enables `imsd-ofonod.service` by preset and ships +a gschema override setting `org.gnome.Calls autoload-plugins` to `['ofono']` +(the per-user equivalent is `gsettings set org.gnome.Calls autoload-plugins +"['ofono']"`); a hand install (`make install`) enables the unit itself with +`systemctl enable --now imsd-ofonod` (it is a system service: `org.ofono` is +a system-bus name) and compiles the schema override. Drop `mm` rather than adding `ofono` alongside it: the +ModemManager provider would offer the modem's CS voice path as a second +origin, and on a phone whose voice path is imsd that origin carries no audio. +Never run it beside a real `ofonod` (one name, one owner; +the unit has `Conflicts=ofono.service` and the daemon exits if it loses the +name). Running `imsd-dialerd` at the same time is fine (different bus, +different consumer). One dbus-broker +detail is load-bearing and lives in `packaging/org.ofono.conf`: dbus-broker +checks a broadcast against the sender's send policy for every name the +receiver owns, so root (imsd) must be allowed `send_destination="org.ofono"` +or imsd's call signals never reach the daemon. + +Known gaps, all inherited rather than introduced: + +- USSD is refused with `org.ofono.Error.NotSupported` and not advertised in + the modem's `Interfaces`: the IMS stack has no USSD path. +- No hold, multiparty, transfer or call waiting (`org.ofono.Error.NotImplemented`): + imsd is a single-call stack. +- Per-call caller-ID withholding is refused rather than silently ignored: + imsd cannot honour a CLIR request, and failing is safer than dialling with + the caller ID exposed. +- Phosh's emergency-call screen lists nothing: `imsd-ofonod` publishes + `org.ofono.VoiceCallManager.EmergencyNumbers` (112, 911, plus + `EMERGENCY_NUMBERS`), oFono's standard place for it, but Calls' ofono + provider never reads it: it makes no `GetProperties` call on the VoiceCallManager + and returns NULL for the origin's `emergency-numbers`, which is what + `org.gnome.Calls.EmergencyCalls.GetEmergencyContacts` is built from (the + ModemManager provider fills its equivalent via + `mm_sim_dup_emergency_numbers()`). An upstream fix would close this. + Dialling an emergency number from the dialpad is unaffected — that reaches + imsd's own classifier. +- DTMF is forwarded to imsd, where `SendDtmf` is a stub — the same + limitation Plasma has. +- Restarting `imsd-ofonod` restarts GNOME Calls with it: when the modem's + `VoiceCallManager` interface goes away and comes back, Calls 50.0 adds a + new origin but leaves the old origin's `CallAdded` handler connected, so + after one such cycle every call is added twice, and after a few the stale handler runs on + freed memory and Calls dies with SIGBUS in `g_hash_table_lookup` (measured + on the FP6). Calls-side; an upstream fix belongs in + `plugins/provider/ofono/calls-ofono-provider.c`. To keep imsd's own + restarts and registration drops from triggering it, the + `VoiceCallManager` interface is listed permanently (`Online` + tracks registration; `Dial` fails with "imsd is not registered" / "imsd is + not running"), and only this daemon's own restart cycles the origin — after + which Calls dials nothing until it restarts, so the unit's `ExecStartPost` + (`imsd-ofono-restart-calls.sh`) restarts the Calls daemon of every user + running one. The first start since boot leaves a running Calls alone. + +Development without a phone or root: + +```sh +./bin/imsd-*/imsd --session & # imsd's ABI on the session bus +./bin/imsd-ofonod-*/imsd-ofonod --session +busctl --user call org.ofono / org.ofono.Manager GetModems +``` + +## 9. Packaging, CI and releases + +### 9.1 The two APKBUILDs + +- `packaging/APKBUILD.binary` is **the** packaging since 2026-09-02: it wraps + a crafter-build binary cross-compiled per §4.3 (bundled by + `packaging/make-bin-tarball.sh` together with the units, scripts and + policies) into the apks CI publishes. `pkgver` is taken from the `Version` + constant in `implementations/main.cpp` by `build-package.sh`. + Subpackages: `imsd-ofono` (the GNOME Calls backend, opt-in) and + `imsd-systemd` (the unit files; `install_if` systemd — ordered after + `imsd-ofono` because `systemd()` amoves all of `usr/lib/systemd/system`). +- `packaging/APKBUILD` is the from-source route through the Makefile (`make`, + `make check`, `make install`) for distros that build from source. It is + not the binary route's equal: no `imsd-systemd` subpackage and none of what + §9.2 lists for the apk (imsd's preset, the enable link, the condition + drop-in, the `/etc/skel` override), no `libc++` in `depends`; only the + `imsd-ofono` file list matches. Its `pkgver` is not bumped per release + (0.3.0) and its `source=` expects a `v$pkgver` tag archive, which the + repository has no tags for. + +Both declare `provides="81voltd=$pkgver-r$pkgrel"`: it satisfies +`soc-qcom-modem`'s dependency and excludes the real package, because 81voltd +serves the modem's own ims-PDN requests, races imsd for the PDN and flaps it +with a new prefix every ~2.5 min — two IMS stacks cannot share one PDN. +`depends` lists `modemmanager`, the AMR codec libraries, `pipewire-tools` and +`polkit` (the gate fails closed without it). There is no OpenRC service: the +unit's sequencing is only tested under systemd. + +### 9.2 What the package installs and enables + +`imsd.service` is enabled by preset (`80-imsd.preset`, plus the +`multi-user.target.wants` link) and gated by a drop-in +`ConditionPathExists=/etc/imsd.env`, so VoLTE survives reboots without a +manual `systemctl enable` and an unconfigured phone does not boot into a +failing unit. `imsd-ofonod.service` is enabled the same way by `imsd-ofono`. +All of that is the apk's doing (`APKBUILD.binary`'s `package()`): `make +install` places the unit files and `80-imsd-ofono.preset` only, so a hand +install runs `systemctl enable imsd` (and `imsd-ofonod`) itself and gets no +`/etc/imsd.env` condition. +The main package hides kde-telephony's `modem-daemon` autostart for accounts +created after it is installed (`/etc/skel/.config/autostart/org.kde.modem.daemon.desktop`) +because `imsd-dialerd` owns those names. + +### 9.3 Install scripts + +`imsd.post-upgrade`, `imsd-ofono.post-upgrade` and `imsd-systemd.post-upgrade` +mark their units `needs-restart` (§7.5); `imsd.post-upgrade` also records the +old invocation for the 0.3.9 hand-over. Each `*.post-install` is a symlink to +its `post-upgrade`: `apk del` leaves the daemon running, so a later `apk add` +is an upgrade for that daemon and apk runs post-install for it. A first +install finds no unit active and marks nothing. `imsd-systemd` marks +`imsd.service` too, since it ships the unit file and apk installs it after +`imsd`. + +### 9.4 CI and what a push means + +`.forgejo/workflows/package.yml` runs on every push to `main` (and on +dispatch): in an `alpine:edge` container, `packaging/build-package.sh` +provisions clang/lld/llvm-runtimes/glib-dev/abuild, fetches the musl build of +crafter-build, builds the sysroot, cross-compiles the four products, runs the +suite natively, wraps the result with `APKBUILD.binary`, and the publish step +uploads the apks to the Forgejo Alpine registry +(`…/api/packages/Catcrafts/alpine/edge/fp6`) with `PACKAGE_TOKEN`. The +registry answers 409 for an already-published version and the step treats +that as "nothing to do", so **a push releases exactly when the `Version` +constant changes**. This is the only producer of the imsd apk: fp6-img +images install the published package from the registry, and +installed phones upgrade with plain `apk upgrade`. **A push to `main` is a +release to real users' phones.** A release is a range of commits ending in a +bump commit titled `imsd X.Y.Z` that changes only the `Version` constant. + +## 10. Configuration reference + +Everything is environment variables. The packaged unit reads +`/run/imsd.env` (from `ims-pdn-up.sh`) and then `/etc/imsd.env` (yours, wins +on conflict). Minimum viable `/etc/imsd.env`: + +```sh +# REQUIRED until P-CSCF discovery from the PDN's PCO is implemented: your +# carrier's P-CSCF address. Find it in a stock-firmware capture or your +# carrier's IMS documentation. +PCSCF=2001:db8::105 +``` + +### 10.1 imsd + +| Variable | Default | Meaning | +|---|---|---| +| `PCSCF` | *(none — required)* | P-CSCF address (IPv6); without it registration fails with a clear error | +| `PCSCF_PORT` | `5060` | unprotected SIP port for the initial REGISTER | +| `DEV` | `qmapmux0.0` | ims-PDN netdev; the packaged service passes the real one via `/run/imsd.env` | +| `LOCAL` | *(auto)* | UE address. The packaged service gets the PDN address ModemManager assigned, via `/run/imsd.env` from `ims-pdn-up.sh`; a manual run prefers a global IPv6 on `DEV` that is neither temporary nor `dynamic` (RA-derived), then any non-temporary one, then the first (the kernel's temporary SLAAC address is listed first, and a registration bound to it is lost when the kernel retires it) | +| `USER_AGENT` | `imsd/` | REGISTER User-Agent. Some networks fingerprint UAs; setting your device's stock build string reproduces the stock modem's registration exactly. Empty omits the header | +| `STATE_FILE` | `/var/lib/imsd/imsreg.state` (root) / `$XDG_STATE_HOME/imsd/imsreg.state` | persisted registration context for warm resume | +| `HOLDS_FILE` | `/run/imsd.holds` (root) / `$XDG_RUNTIME_DIR/imsd.holds` (`/tmp/imsd.holds` without a runtime dir) | the waits the network imposed (a `Retry-After`, the refusal schedule), where that schedule stands and whether the old registration is gone (`old_gone`), kept across a restart of imsd for the PDN they were set on — not across a reboot; `/dev/null` switches the file off (only a regular file is ever removed; anything else is emptied). `imsd-handover.sh` honours it too | +| `RESUME` | `auto` | `0` forces a fresh registration (ignores a warm SA) | +| `REFRESH_INTERVAL` | *(auto)* | keepalive re-REGISTER period in s; default = half the granted expiry, clamped to [120, 1800] | +| `EALG` | `aes-cbc` | offered ESP cipher (`aes-cbc`, `des-ede3-cbc`, `null`). From the 401's `Security-Server` list imsd selects the highest-preference `ipsec-3gpp` mechanism that matches this offer (RFC 3329 §2.3.1) and takes the SPIs/ports from the entry carrying them (some cores list every mechanism they support and put the SPIs on the one they applied); when no entry matches, the P-CSCF's SPI-carrying entry is installed as listed (`hmac-md5-96`, an absent `ealg` = null) and the journal says so. The line, the selection and the SA parameters are logged at every fresh registration | +| `SIP_TRANSPORT` | `auto` | transport of the protected leg (the second REGISTER and everything after it): `tcp`, `udp`, or `auto` = TCP, falling back to a fresh registration over UDP when the P-CSCF leaves two TCP connects unanswered — but only on a phone that has never registered over TCP: once the state file records a TCP registration, a silent connect is treated as an outage, not a policy (delete the file or set `udp` to override, e.g. after a SIM change). The challenge (first REGISTER) is always UDP. Over UDP a REGISTER or INVITE larger than ~1.2 KB leaves as IPv6 fragments at the ims PDN's 1280-byte MTU (the journal says so once per flow); there are no SIP retransmission timers over UDP yet, so a lost datagram costs a timeout. The SAs, the listener and the firewall rule cover both transports | +| `EMERGENCY_NUMBERS` | *(empty)* | comma-separated additions to the builtin 112/911 emergency numbers (e.g. a private test core's short code). SIM `EF_ECC` is not read yet. `imsd-ofonod` reads it too, for the oFono `EmergencyNumbers` property | +| `RTP_PORT` | `50004` | local RTP port advertised in SDP | +| `PRECOND` | `0` | `1` offers SDP QoS preconditions | +| `CODECS` | *(empty — defaults)* | comma-separated codec preference list over `AMR-WB`, `AMR` (or `AMR-NB`), `PCMA`, `PCMU`: restricts and orders both the codecs offered on an outgoing call and those accepted from an inbound offer (default: offer AMR-WB + AMR, accept all four in that order). A bench knob — a network whose gateway transcodes every caller up to AMR-WB otherwise never lets the narrowband path run | +| `DUMP_SIP` | `0` | `1` writes raw REGISTER-200/SUBSCRIBE-200 dumps, the last refused REGISTER or SUBSCRIBE final (`imsd-register-fail.raw`, `imsd-subscribe-fail.raw`), the last inbound INVITE (`imsd-invite-in.raw`) and the last outgoing INVITE (`imsd-invite-out.raw`), mode 0600, for debugging — they contain your IMSI/MSISDN and addresses. A refused REGISTER or SUBSCRIBE is logged with its status line and any Retry-After / Warning / Reason / Server headers | +| `DUMP_DIR`, `OUT_DIR` | state dir | where dumps / per-call media stats land | +| `IMSD_MEDIA` | *(auto)* | path to `imsd-media` (default: next to `imsd`, else `/usr/libexec/imsd-media`) | + +The protected client/server ports are fixed at 45061/45062 +(`imsd::util::PortUc`/`PortUs`) — changing them in source also requires +updating the firewall rules `ims-pdn-up.sh` installs. + +### 10.2 imsd-media (per call, set through imsd's environment) + +| Variable | Default | Meaning | +|---|---|---| +| `MIC`, `PLAY` | `1`, `1` | uplink mic capture / downlink playout via PipeWire, as imsd passes them (`imsd-media` started by hand defaults both to off) | +| `AUDIO_USER` | *(auto)* | account whose PipeWire session carries call audio; imsd sets it per call from logind's seat0 session (§7.6). Set in imsd's own environment it pins an account instead | +| `GAIN`, `PLAY_GAIN` | `10`, `1.0` | uplink / downlink gain, as imsd passes them (`imsd-media` started by hand defaults `GAIN` to `1.0`) | +| `AMR_MODE` | `2` | AMR encoder mode of whichever AMR codec is active, as imsd passes it (12.65 kbit/s for AMR-WB; `imsd-media` started by hand defaults to 2 for AMR-WB and 7 for AMR) | +| `DTX` | `0` | encoder discontinuous transmission | +| `MEDIA_TIMEOUT` | `6.0` | seconds of downlink silence before exit 3 (far-end-hangup signal) | +| `RTP_DUMP` | `0` | `1` captures raw downlink RTP next to the stats file | +| `CODEC`, `OCTET_ALIGN` | *(from the SDP)* | set by imsd from the negotiated answer; not configuration | +| `MIC_SRC`, `PCM_DUMP` | *(unset)* | test seams (§5) | + +### 10.3 ims-pdn-up.sh and imsd-ofonod + +`IMS_APN`, `IMS_IP_TYPE`, `IMS_PROFILE_ID`, `IMS_REG_TIMEOUT` and `PCSCF` for +the bring-up script (§7.3). `imsd-ofonod` reads `EMERGENCY_NUMBERS` (from +`/etc/imsd.env` through its unit) and `IMSD_BUS=session|system` to override +only the imsd side of its bus choice. + +## 11. Making changes + +- **Commit messages.** Subject `area: what changes` — the area is the module + partition, program, script or `packaging`/`ci`/`tests`/`README` the change + lives in (several joined by commas), the rest a short lowercase clause + with no trailing period, imperative or declarative. The body says why — + the field incident or the spec clause — and, where the change alters + behaviour, in one sentence how it was tested. A version bump is a commit + titled `imsd X.Y.Z` that changes only the `Version` constant and has no + body. +- **No AI attribution trailers.** Commits carry no `Assisted-by:`, + `Co-Authored-By:` or similar lines, whoever or whatever drafted them. +- **Signed commits.** Every commit is GPG-signed with the repository's + configured identity (`git config user.signingkey`, `commit.gpgsign`). Do + not override the identity from the environment. +- **One change, one commit; a fix goes into the commit that introduced the + problem** while a range is unpublished (rebase), never as a follow-up on + top. Every commit of a range builds and passes the seven suites on its own. +- **Keep `project.cpp` and the Makefile in sync**, and the two APKBUILDs' + file lists with `make install`, `make-bin-tarball.sh` and + `APKBUILD.binary`'s `package()` when a packaged file is added. +- **The README and this file follow the code in the same commit.** A new + knob gets its row in §10; a new state its journal line in §7.8; a new file + under `/run` or `/var/lib` its row in §7.7. +- **Compatibility notes are kept where the mechanism lives**, not in a + changelog the repository does not have: the 0.3.9 hand-over is documented + in §7.5 because `imsd.post-upgrade` ships in every later package and the + path stays live for any direct upgrade from an older daemon. +- **Never commit captures or state** (`*.raw`, `*.rtp`, `*.stats`, + `imsreg.state`): they carry the subscriber's identity. + +## 12. Security considerations + +- imsd runs as root (xfrm, qmicli). Its attack surface on the bus is the + frozen ABI; the writes are polkit-gated (§7.1, §8.1). Do not add an + ungated write member, and do not add a root translation daemon that + forwards calls without gating its own callers through `imsd-bus`. +- The bus policies deliberately let any local account *reach* both names; + authorization is polkit's, not the policy file's. Keep it that way: a + policy that names an account breaks the moment the account is called + something else. +- SIP dumps and the state file carry the IMSI, the MSISDN, SPIs and + addresses. Dumps are mode 0600 under the state dir, off by default + (`DUMP_SIP=0`), and never leave the phone in a commit. +- The ims PDN is a walled garden: `ims-pdn-up.sh` keeps it from becoming + the default route and gives the P-CSCF a host route; imsd adds one per + call for the media peer (§7.3). Nothing else is routed over it. + A change that routes anything else over it needs a reason and a bench. +- The fixed protected ports (45061/45062) are opened in nftables for + `qmapmux*` only, by the bring-up script. + +## 13. Carrier and platform facts the code relies on + +Observed on real networks and encoded in the policy; cite them when touching +the related code. + +- **KPN's fresh-SA throttle** — the `bring-up deferred: fresh-SA throttle + active (spi-s=0)` case — refuses a fresh SA pair on an address while the + registration on that address is live, whatever its age or last refresh: + refused 11 s, 23 min and 2 h 58 min into a registration, 4 and 6.6 min + after its last refresh (2026-09-19, -20, -24, 2026-10-03). KPN drops that + registration the moment the initial REGISTER arrives, throttled or not, + and with it gone accepts a fresh pair there: 5 and 21 min after the + throttled attempt (2026-10-03). An address that holds no registration is + accepted while another address's is live (2026-09-26, 2026-10-03), and a + new prefix's PDN address was accepted 15 s after a throttled attempt on + the temporary one (the 0.3.9 → 0.3.10 hop, 2026-10-03). An address whose + registration another address's had replaced was refused 26 s after, + accepted 141 s and 10 min after (2026-10-03): how soon KPN accepts where a + registration has just gone is not measured more closely. The schedule's + 21 min is the wait that has never been refused, not a measured minimum; + imsd resumes rather than registers fresh wherever it can, since on a live + registration's address the attempt is refused and costs the registration. +- **O2 UK, 2026-09-24:** a refresh refused 21 × 503 over 70 min, then 33 + unanswered reconnect cycles; a fresh registration was accepted in one + second. The origin of `RegRecovery`. +- **KPN closes a registration's previous reg-event subscription with + `noresource`** when a new one replaces it: that end is not renewed. +- **Modem attach-family forcing** (Odido, O2 UK, Swisscom): an APN-string + IPv6 bearer request is refused when the attach PDN was granted IPv4-only; + a profile-indexed request is not. +- **pmOS's default nftables** drops inbound on `qmapmux*`; without the + protected-port rule every network-initiated request (NOTIFY, MT INVITE) + dies after ESP decapsulation and MT calls fall back to CS. +- **The kernel's temporary SLAAC address** on the ims netdev is listed first + and retired after about two days; a registration bound to it dies with it. +- **systemd reads `EnvironmentFile=` per spawned command** (systemd 262~rc3 + on the FP6, 2026-10-03: an `ExecStart` saw what its `ExecStartPre` had + just written), and unquotes a quoted value — which is why the hand-over script + takes `LOCAL` from its environment rather than parsing the files itself. +- **GNOME Calls 50.0** crashes after an oFono origin withdraw/re-add cycle + and dials nothing after `org.ofono` restarts until it restarts itself. +- **dbus-broker** checks broadcasts against the sender's send policy for + every name the receiver owns (the `org.ofono.conf` detail). +- **The phone's wall clock reads 1970 until the first NTP sync**; anything + that ages a journal line uses the monotonic or boot clock. diff --git a/README.md b/README.md index 67797cb..ff99e53 100644 --- a/README.md +++ b/README.md @@ -15,402 +15,114 @@ network. ## Status -Call-capable, both directions. Outgoing VoLTE calls place, answer, carry -full-duplex AMR-WB audio, and tear down; incoming calls ring, answer via -`Accept`, and carry media the same way — driven end-to-end from a stock -dialer over the frozen D-Bus ABI. Current state: +Call-capable, both directions: outgoing calls place, answer, carry full-duplex +AMR-WB audio and tear down; incoming calls ring, answer and carry media the +same way, driven end-to-end from a stock dialer over a frozen D-Bus ABI. +Not there yet: DTMF (`SendDtmf` is a stub), hold/multiparty/call waiting, +USSD, P-CSCF discovery (the address is configured by hand). -The engine core (`imsd-core`) is a GLib-free, pure-`std` static library whose -every decision — SIP framing, SDP selection, the AKAv1-MD5 digest, the -`ip xfrm` command sequence, and the whole call FSM — is unit-tested on a dev -box with no bus, modem, or phone; several suites pin output byte-for-byte -against recorded network traffic. GLib/GDBus, sockets, and subprocess control -live only in the `imsd` daemon shell, apart from imsd-bus, the GLib polkit -gate it shares with imsd-ofonod. +Verified by community members to be fully working: -## Architecture - -C++26 modules, built with Crafter Build. Six build products: - -- **imsd-core** (static library) — the engine, pure `import std` C++ with no - GLib and no I/O side effects. Everything here is unit-testable on any dev - box without a modem, a bus, or a phone; several suites pin output - byte-for-byte against recorded network traffic. -- **imsd** (executable) — the daemon shell: GLib main loop, GDBus service, - sockets, subprocess control (`qmicli`/`mmcli`/`ip`), and the registration - sequence + run loop that pumps the core. -- **imsd-bus** (static library) — the polkit gate in front of call control - (`net.catcrafts.IMS1.Voice`), which imsd and imsd-ofonod both link. GLib, - so kept outside imsd-core. A translation daemon that runs as root on the - system bus reaches imsd as root, which polkit always allows, so it gates - its own callers through this or it is a bypass. -- **imsd-media** (executable) — the RTP/AMR-WB data plane, spawned once per - call. Pure `import std` + POSIX, no GLib; links `-ldl` and dlopen's the - codecs. A separate process for crash isolation and the far-end-hangup - exit-code contract. -- **imsd-dialerd** (executable) — the Plasma Mobile integration: a session - daemon owning `org.kde.telephony.{CallUtils,DeviceUtils,UssdUtils}`, - bridging them to imsd on the system bus. GDBus translation only, no core. - It is a separate process because those are per-user *session*-bus names - that the root system daemon cannot own; built with `-- --product=dialerd`, - autostarted from an `.desktop` file in place of plasma-dialer's - `modem-daemon` (whose autostart must be disabled). -- **imsd-ofonod** (executable) — the GNOME Calls integration, for Phosh and - GNOME Mobile: a system daemon owning `org.ofono` and presenting imsd as an - oFono modem, which is the seam GNOME Calls' bundled `ofono` provider plugin - drives. GDBus translation, no core, plus the imsd-bus gate on its own - callers; built with `-- --product=ofonod`. - See [GNOME Calls (Phosh, GNOME Mobile)](#gnome-calls-phosh-gnome-mobile). - -### Desktop integration - -imsd itself is desktop-agnostic: everything a dialer needs is on the system -bus as `net.catcrafts.IMS1`. Each desktop gets a small translation daemon -speaking whatever backend protocol its dialer already consumes, so the dialer, -the shell and the call-history/contacts stack all stay unmodified. - -| Desktop | Daemon | Owns | Consumed by | -|---|---|---|---| -| Plasma Mobile | `imsd-dialerd` | `org.kde.telephony.*` (session bus) | plasma-dialer + kde-telephony-daemon, replacing `modem-daemon` | -| Phosh / GNOME Mobile | `imsd-ofonod` | `org.ofono` (system bus) | GNOME Calls' `ofono` provider plugin | - -Phosh's own call UI needs nothing extra: it consumes `org.gnome.Calls`, which -GNOME Calls exports once it has a working origin. - -### Terminating-network dependency (incoming) - -A UE only receives a VoLTE call as a SIP INVITE if the network's Terminating -Access Domain Selection (T-ADS) routes it to the PS/IMS domain rather than -paging the modem over CS. That requires the IMS registration to advertise -voice capability — the MMTEL ICSI (`+g.3gpp.icsi-ref=...mmtel`) and -`+sip.instance` media-feature tags in the REGISTER Contact, which -`Imsd:Messages` emits. T-ADS may still select CS for reasons outside the -client's control (operator policy, the modem's own radio-capability -signalling, a competing registration). When it does, no INVITE reaches imsd; -that is a network/modem-integration matter, not a daemon defect. - -## Supported hardware & carriers - -imsd talks to the modem through ModemManager plus `qmicli` over QRTR (USIM -AKA via a UIM logical channel), so in practice it currently needs a Qualcomm -modem on a mainline kernel with working WWAN/MM integration, kernel ESP -(`ip xfrm`), and PipeWire for audio. - -Everything below has been verified by community members to be fully workking: - -| Device | OS | Carrier +| Device | OS | Carrier | |---|---|---| | The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | KPN NL | | The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | Telekom Deutschland GER | | The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | Phonero | | The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | Telia Norge | -## Build +## What it needs -[Crafter Build](https://forgejo.catcrafts.net/Catcrafts/Crafter.Build) + -gio-2.0 headers (`glib2` on Arch, `glib-dev` on Alpine). +imsd talks to the modem through ModemManager plus `qmicli` over QRTR, installs +its security associations with `ip xfrm` and plays audio through PipeWire, so +in practice: a Qualcomm modem on a mainline kernel with working +WWAN/ModemManager integration, kernel ESP, PipeWire, systemd and polkit — and +your carrier's P-CSCF address. + +## Install and configure + +On a Catcrafts Fairphone 6 image imsd is installed from the project's apk +registry and upgrades with `apk upgrade` (an upgrade restarts the daemon; a +call in progress ends). Elsewhere, build it (below), `make install`, and +enable the units yourself — `systemctl enable imsd`, and `systemctl enable +--now imsd-ofonod` for GNOME Calls: imsd's preset, the +`multi-user.target.wants` link and the `/etc/imsd.env` condition below come +with the apk, not with `make install`. + +Configuration is environment variables in `/etc/imsd.env`; the one required +setting is the P-CSCF: ```sh -crafter-build # bin/imsd--/imsd (the daemon) -crafter-build -- --product=media # bin/imsd-media-.../imsd-media (the media leg) -crafter-build -- --product=dialerd # bin/imsd-dialerd-.../imsd-dialerd (Plasma backend) -crafter-build -- --product=ofonod # bin/imsd-ofonod-.../imsd-ofonod (GNOME Calls backend) -crafter-build test # unit tests (Util, Aka, Ipsec, Messages, Engine, Sip, Sdp) -``` - -### Building with make (distro packaging) - -For environments without crafter-build, the Makefile mirrors the same clang -module pipeline: GNU make, clang++ with libc++ and the libc++ std module -sources (`std.cppm` — package `llvm-runtimes` on Alpine, `libc++` on Arch), -lld, and gio-2.0 headers. - -```sh -make # build/make/{imsd,imsd-media,imsd-dialerd,imsd-ofonod} -make check # the same 7 unit-test suites -make install # DESTDIR/PREFIX staged install incl. the packaging/ files -``` - -`project.cpp` stays the canonical build description; the Makefile tracks it. - -### Cross-compiling for a phone (aarch64 Alpine/postmarketOS) - -```sh -packaging/make-sysroot.sh # one-time: Alpine aarch64 sysroot from the CDN -crafter-build -- --target=aarch64-alpine-linux-musl \ - --sysroot=$HOME/.cache/imsd/sysroot-aarch64-alpine \ - --march=armv8.6-a+fp16fml+aes+sha3+sm4 --mtune=cortex-a720 -# same flags after `crafter-build test --target=aarch64-alpine-linux-musl` -# run the suite under qemu-aarch64 against the sysroot -``` - -## Run (development) - -```sh -./bin/imsd-*/imsd --session # own net.catcrafts.IMS1 on the session bus -busctl --user call net.catcrafts.IMS1 /net/catcrafts/IMS1 net.catcrafts.IMS1 GetStatus -``` - -On a phone the daemon runs as root on the system bus, where any local -account may reach it (`packaging/net.catcrafts.IMS1.conf`); placing, -answering and ending a call is polkit's `net.catcrafts.IMS1.Voice` -(`packaging/net.catcrafts.IMS1.policy`, checked by imsd and imsd-ofonod -alike): the active local session — the dialer, whatever the account is -called — and root, not an ssh login. It is started by `packaging/imsd.service` -after `packaging/ims-pdn-up.sh` has brought up the ims PDN through -ModemManager. An apk upgrade restarts `imsd` and `imsd-ofonod` once the -transaction is done (a call in progress ends), as does adding a package -again over the daemon an `apk del` left running; the restart resumes the -registration over its existing SAs, or goes on waiting out a hold the -network had set. The bring-up never lets the ims PDN become the phone's IPv6 -default route: the PDN's router advertisement is not accepted as a default -router (`accept_ra_defrtr=0` on the netdev; its prefix and MTU still apply) -and the P-CSCF gets a host route instead, so on IPv4-only wifi the phone's -other IPv6 traffic (apk, browsers) is not sent into the IMS walled garden, -where it would hang. Media peers get a host route per call; anything else -on the IMS network (its DNS servers, a traceroute target) needs its own -(`ip -6 route add dev qmapmux0.0`). The PDN MTU ModemManager reports -is set on the link. It spawns -`/usr/libexec/imsd-media` per call. Registration -resumes an in-kernel IPsec SA when one is present (avoiding the network's -fresh-SA throttle) and keeps itself alive with a re-REGISTER refresh at half -the granted lifetime. `packaging/APKBUILD` builds the apk from source via -the Makefile; `packaging/APKBUILD.binary` + `make-bin-tarball.sh` wrap a -cross-compiled build into an apk instead. - -## GNOME Calls (Phosh, GNOME Mobile) - -`imsd-ofonod` presents imsd on the system bus as an oFono modem — a Manager at -`/`, one Modem + VoiceCallManager at `/imsd`, and a VoiceCall object per live -call — which is what GNOME Calls' bundled `ofono` provider plugin drives. -Calls, Phosh and GNOME Shell run unmodified. - -`org.ofono` is the seam because it is the only one a third party can -implement. `org.gnome.Calls.Call` is what Calls *exports* so a shell can -observe and control a call that already exists (Accept, Hangup, SendDtmf, -Silence — there is no Dial); Phosh consumes it from Calls. Calls' own backends -are libpeas provider plugins (`mm`, `ofono`, `sip`, `dummy`) linked against -private headers that no distro installs. - -Setup: - -```sh -apk add imsd-ofono # enables imsd-ofonod via a systemd preset -systemctl enable --now imsd-ofonod # hand installs: system service, org.ofono is a system name -# point Calls at the ofono provider (the package ships this as a gschema -# override; this is the per-user equivalent) -gsettings set org.gnome.Calls autoload-plugins "['ofono']" -``` - -Drop `mm` from `autoload-plugins` rather than adding `ofono` alongside it: the -ModemManager provider would offer the modem's own CS voice path as a second -origin, and on a phone whose voice path is imsd that origin carries no audio. - -Do not run this alongside a real `ofonod` — there is one `org.ofono` name and -one owner; the unit declares `Conflicts=ofono.service` and the daemon exits if -it loses the name. Running `imsd-dialerd` at the same time is fine (different -bus, different consumer). - -Verified on a Fairphone 6 (postmarketOS, dbus-broker) with GNOME Calls 50.0: -outgoing and incoming calls with audio both ways, answered and hung up through -`org.gnome.Calls.Call` — the surface Phosh drives. One dbus-broker detail is -load-bearing and lives in the policy file: dbus-broker checks a broadcast -against the sender's send policy for every name the receiver owns, so root -(imsd) must be allowed `send_destination="org.ofono"` or imsd's call signals -never reach the daemon. - -Known gaps, all inherited rather than introduced: - -- **USSD is refused** with `org.ofono.Error.NotSupported`; the IMS stack has no - USSD path. It is deliberately not advertised in the modem's `Interfaces`. -- **No hold, multiparty, transfer or call waiting** — imsd is a single-call - stack, so those methods return `org.ofono.Error.NotImplemented`. -- **Per-call caller-ID withholding is refused** rather than silently ignored: - imsd cannot honour a CLIR request, and failing is safer than dialling with - the caller ID exposed. -- **Phosh's emergency-call screen lists nothing.** Not for want of the data: - `imsd-ofonod` publishes `org.ofono.VoiceCallManager.EmergencyNumbers` (112, - 911, plus `EMERGENCY_NUMBERS`), which is oFono's standard place for it. - Calls' ofono provider simply never reads it — it makes no `GetProperties` - call on the VoiceCallManager, and returns NULL for the origin's - `emergency-numbers`, which is what - `org.gnome.Calls.EmergencyCalls.GetEmergencyContacts` is built from. (The - ModemManager provider does read it, via `mm_sim_dup_emergency_numbers()`.) - An upstream fix would close this. Dialling an emergency number from the - normal dialpad is unaffected — that reaches imsd's own classifier. -- DTMF is forwarded to imsd, where `SendDtmf` is still a stub (RFC 4733 is not - implemented yet) — the same limitation Plasma has. -- **Restarting `imsd-ofonod` restarts GNOME Calls with it.** When the - modem's `VoiceCallManager` interface goes away and comes back, Calls 50.0 - adds a new origin but leaves the old origin's `CallAdded` handler connected: - after one such cycle every call is added twice, after a few the stale handler - runs on freed memory and Calls dies with SIGBUS in `g_hash_table_lookup` - (measured on the FP6). Calls-side; an upstream fix belongs in - `plugins/provider/ofono/calls-ofono-provider.c`. To keep imsd's own - restarts and registration drops from triggering it, `imsd-ofonod` lists the - interface permanently: `Online` tracks registration, and `Dial` fails with - "imsd is not registered" / "imsd is not running" instead. Only `org.ofono` - vanishing — this daemon restarting — still cycles the origin, and after - that Calls dials nothing until it restarts, so the unit's `ExecStartPost` - restarts the Calls daemon of every user running one. Its first start - since boot leaves a running Calls alone. - -Development, without a phone or root: - -```sh -./bin/imsd-*/imsd --session & # imsd's ABI on the session bus -./bin/imsd-ofonod-*/imsd-ofonod --session -busctl --user call org.ofono / org.ofono.Manager GetModems -# or keep org.ofono on the system bus and imsd on the session bus: -IMSD_BUS=session imsd-ofonod -``` - -## Configuration - -Everything is environment variables. The packaged unit reads -`/run/imsd.env` (written by `ims-pdn-up.sh`: the connected ims netdev and -the PDN address ModemManager assigned to it) and -then `/etc/imsd.env` (your configuration, wins on conflict). Minimum viable -`/etc/imsd.env`: - -```sh -# REQUIRED until P-CSCF discovery from the PDN's PCO is implemented: your -# carrier's P-CSCF address. Find it in a stock-firmware capture or your -# carrier's IMS documentation. +# your carrier's P-CSCF address — from a stock-firmware capture or the +# carrier's IMS documentation PCSCF=2001:db8::105 ``` -### imsd +The apk's `imsd.service` is enabled by preset and starts at boot once that +file exists (the condition is checked when the unit starts, so after writing +the file run `systemctl start imsd`). It first brings the `ims` PDN up through ModemManager, then registers: +resuming an existing registration when it can, registering fresh otherwise, +and waiting out whatever the network tells it to wait out. Check on it with: -| Variable | Default | Meaning | -|---|---|---| -| `PCSCF` | *(none — required)* | P-CSCF address; without it registration fails with a clear error | -| `PCSCF_PORT` | `5060` | unprotected SIP port for the initial REGISTER | -| `DEV` | `qmapmux0.0` | ims-PDN netdev; the packaged service passes the real one via `/run/imsd.env` | -| `LOCAL` | *(auto)* | UE address. The packaged service gets the PDN address ModemManager assigned, via `/run/imsd.env` from `ims-pdn-up.sh`; a manual run prefers a global IPv6 on `DEV` that is neither temporary nor `dynamic` (RA-derived), then any non-temporary one, then the first (the kernel's temporary SLAAC address is listed first, and a registration bound to it is lost when the kernel retires it) | -| `USER_AGENT` | `imsd/` | REGISTER User-Agent. Some networks fingerprint UAs; setting your device's stock build string reproduces the stock modem's registration exactly. Empty omits the header | -| `STATE_FILE` | `/var/lib/imsd/imsreg.state` (root) / `$XDG_STATE_HOME/imsd/imsreg.state` | persisted registration context for warm resume | -| `HOLDS_FILE` | `/run/imsd.holds` (root) / `$XDG_RUNTIME_DIR/imsd.holds` (`/tmp/imsd.holds` without a runtime dir) | the waits the network imposed (a `Retry-After`, the refusal schedule) and where that schedule stands, kept across a restart of imsd for the PDN they were set on — not across a reboot; `/dev/null` switches the file off (only a regular file is ever removed; anything else is emptied) | -| `RESUME` | `auto` | `0` forces a fresh registration (ignores a warm SA) | -| `REFRESH_INTERVAL` | *(auto)* | keepalive re-REGISTER period in s; default = half the granted expiry, clamped to [120, 1800] | -| `EALG` | `aes-cbc` | offered ESP cipher (`aes-cbc`, `des-ede3-cbc`, `null`). From the 401's `Security-Server` list imsd selects the highest-preference `ipsec-3gpp` mechanism that matches this offer (RFC 3329 §2.3.1) and takes the SPIs/ports from the entry carrying them (some cores list every mechanism they support and put the SPIs on the one they applied); when no entry matches, the P-CSCF's SPI-carrying entry is installed as listed (`hmac-md5-96`, an absent `ealg` = null) and the journal says so. The line, the selection and the SA parameters are logged at every fresh registration | -| `SIP_TRANSPORT` | `auto` | transport of the protected leg (the second REGISTER and everything after it): `tcp`, `udp`, or `auto` = TCP, falling back to a fresh registration over UDP when the P-CSCF leaves two TCP connects unanswered — but only on a phone that has never registered over TCP: once the state file (`/var/lib/imsd/imsreg.state`) records a TCP registration, a silent connect is treated as an outage, not a policy (delete the file or set `udp` to override, e.g. after a SIM change). The challenge (first REGISTER) is always UDP. Over UDP a REGISTER or INVITE larger than ~1.2 KB leaves as IPv6 fragments at the ims PDN's 1280-byte MTU (the journal says so once per flow); there are no SIP retransmission timers over UDP yet, so a lost datagram costs a timeout. The SAs, the listener and the firewall rule cover both transports | -| `EMERGENCY_NUMBERS` | *(empty)* | comma-separated additions to the builtin 112/911 emergency numbers (e.g. a private test core's short code). SIM `EF_ECC` is not read yet | -| `RTP_PORT` | `50004` | local RTP port advertised in SDP | -| `PRECOND` | `0` | `1` offers SDP QoS preconditions | -| `CODECS` | *(empty — defaults)* | comma-separated codec preference list over `AMR-WB`, `AMR` (or `AMR-NB`), `PCMA`, `PCMU`: restricts and orders both the codecs offered on an outgoing call and those accepted from an inbound offer (default: offer AMR-WB + AMR, accept all four in that order). A bench knob — a network whose gateway transcodes every caller up to AMR-WB otherwise never lets the narrowband path run | -| `DUMP_SIP` | `0` | `1` writes raw REGISTER-200/SUBSCRIBE-200 dumps, the last refused REGISTER or SUBSCRIBE final (`imsd-register-fail.raw`, `imsd-subscribe-fail.raw`), the last inbound INVITE (`imsd-invite-in.raw`) and the last outgoing INVITE (`imsd-invite-out.raw`), mode 0600, for debugging — they contain your IMSI/MSISDN and addresses. A refused REGISTER or SUBSCRIBE is logged with its status line and any Retry-After / Warning / Reason / Server headers | -| `DUMP_DIR`, `OUT_DIR` | state dir | where dumps / per-call media stats land | -| `IMSD_MEDIA` | *(auto)* | path to `imsd-media` (default: next to `imsd`, else `/usr/libexec/imsd-media`) | +```sh +journalctl -u imsd -f +busctl --system call net.catcrafts.IMS1 /net/catcrafts/IMS1 net.catcrafts.IMS1 GetStatus +``` -The protected client/server ports are fixed at 45061/45062 -(`imsd::util::kPortUc`/`kPortUs`) — changing them in source also requires -updating the firewall rules `ims-pdn-up.sh` installs. +Every variable, what the daemon does when the registration breaks, and +what the journal lines mean are in [AGENTS.md](AGENTS.md) (§10, §7.4, §7.8). -**When the registration stops working** (the refresh is refused, or the -protected flow dies and nothing answers the reconnect) imsd does in place -what a restart does: it reconnects through the existing SA and refreshes; -after two refused refreshes or two unanswered reconnect cycles (such a -cycle is not counted while the modem reports no packet service) it -registers fresh (new Call-ID, new challenge, new SA pair). Reconnects back -off 5 s → 5 min; a refused or throttled fresh attempt waits on the throttle -schedule (21 min, growing to 60), a silent one 30 s → 10 min, and a -network `Retry-After` is honoured, up to an hour (the schedule's longest wait): a longer -one is cut to it and the refusal heard again then. While a fresh registration is deferred, the existing SA is -still retried on the reconnect backoff, unless the old registration is known gone: a `403` once a -fresh registration is due, an attempt that got past the SA setup, or a binding -the network terminated (reg-event). A `Dial` -that reaches imsd while it is unregistered brings the next recovery step -forward (the reconnect, or the fresh attempt once it is due), never a wait -the network imposed (a `Retry-After`, or the schedule after a -refused or throttled fresh attempt); imsd-ofonod refuses a `Dial` then, and -imsd-dialerd lists no modem. A bring-up the network deferred waits the same -schedule out with nothing to reconnect over (the resume just found the -stored SAs unusable), then tries again. The network's holds outlive a -restart of imsd (an upgrade restarts it): they are kept in -`/run/imsd.holds` for the PDN they were set on, with the schedule's place -(a restart does not start it over at 21 min), and waited out at the next -start, before the resume (a refresh's `Retry-After`) or before the fresh -registration (a fresh attempt's schedule or `Retry-After`, a terminated -binding's wait); a reboot or another PDN address starts clean. An upgrade from a daemon -older than 0.3.10, which kept its bring-up throttle wait in memory only, -hands that wait over the same way when this start registers on the address -it was earned on (the fresh-SA throttle's wait, `spi-s=0`; the old daemon's -other deferrals, after a 401 it could not use, are not handed over): the -upgrade records that daemon's invocation, and the next -start reads its journal lines once the daemon has stopped — a wait that -ended meanwhile is read as it ended; on another address (the normal hop: -0.3.9 sat on the kernel's temporary address) only the verdict that the old -registration is gone is kept and the start registers fresh at once. When -the old address cannot be read the wait is handed over anyway; when the wait -cannot be timed, or this start has no `LOCAL`, the journal says it was not -handed over. -The journal says which (`…; next: fresh registration in N s`, `registering -fresh (recovery attempt N)`, `fresh registration deferred/refused/failed: -…`, `bring-up deferred: …`). One case leaves through a real restart -instead: when the address imsd is registered on has left the ims netdev -and the PDN's own address is not there to move to (the PDN went away) — -the journal shows `engine fatal: … — exiting for systemd restart`, -and after `RestartSec` (120 s) `ims-pdn-up.sh` runs again and imsd -registers on the PDN's address. A registration resumed on an older -address (a pre-0.3.10 temporary one) moves to the PDN's own address in -place: with its next fresh registration, or when the older address leaves -the netdev first, which takes that registration with it. The reg-event -subscription — the network's own -view of the binding, one live dialog per registration, subscribed anew ahead of -the lifetime the S-CSCF granted — is retried when refused or unanswered -(60 s doubling to 10 min, a `Retry-After` honoured up to an hour: a registration refresh -neither brings a scheduled retry forward nor starts its back-off over) and renewed when the S-CSCF ends -it for a reason that allows renewal (RFC 6665; after `noresource`, -`rejected` or any other reason the renewal does not cover the next -registration refresh subscribes anew); an earlier -subscription's NOTIFY is still read for the binding's state, but its end is -not ours to renew (KPN closes a registration's previous subscription with -`noresource` when a new one replaces it); a NOTIFY that terminates the binding triggers a fresh -registration (at once, or after the network's `retry-after`, up to an hour; after the -call, when one is up; one exception: a REGISTER wait that still gets its 200 -after such a NOTIFY, a call having kept the flow, leaves that registration -standing); after -`rejected` and `unregistered` it waits on the refusal schedule (21 min, -growing to 60), a wait no `Dial` cuts short — the binding is gone, so -nothing is retried meanwhile. One termination counts once, however many -NOTIFYs report it (a repeated `probation` report may extend its hold from -its own `retry-after`). +## Dialers -### imsd-media (per-call, set through imsd's environment) +imsd itself is desktop-agnostic: everything a dialer needs is on the system +bus as `net.catcrafts.IMS1`. A small translation daemon per desktop speaks +the backend protocol its dialer already consumes, so dialer, shell and the +call-history/contacts stack run unmodified. -| Variable | Default | Meaning | -|---|---|---| -| `MIC`, `PLAY` | `1`, `1` | uplink mic capture / downlink playout via PipeWire | -| `AUDIO_USER` | *(auto)* | account whose PipeWire session carries call audio. imsd sets it for each call from logind: the user session active on seat0 (`loginctl show-seat seat0`, a greeter does not count) when it has a `/run/user//pipewire-0` socket. Set in imsd's own environment it pins an account instead. imsd as packaged (root, on the system bus) logs the resolution at start and when a call's media starts (`audio session: …`), imsd-media once per call. imsd-media started without the variable at all (by an imsd older than 0.3.10 while an upgrade replaces it, or by hand) tries the account `user`, its old default. | -| `GAIN`, `PLAY_GAIN` | `10`, `1.0` | uplink / downlink gain | -| `AMR_MODE` | `2` | AMR-WB encoder mode (0–8) | -| `DTX` | `0` | encoder discontinuous transmission | -| `MEDIA_TIMEOUT` | `6.0` | seconds of downlink silence before exit 3 (far-end-hangup signal) | -| `RTP_DUMP` | `0` | `1` captures raw downlink RTP next to the stats file | +| Desktop | Daemon | Package | Owns | Consumed by | +|---|---|---|---|---| +| Plasma Mobile | `imsd-dialerd` | `imsd` | `org.kde.telephony.*` (session bus) | plasma-dialer + kde-telephony-daemon, replacing `modem-daemon` | +| Phosh / GNOME Mobile | `imsd-ofonod` | `imsd-ofono` | `org.ofono` (system bus) | GNOME Calls' `ofono` provider plugin | -## D-Bus ABI +Plasma Mobile needs nothing beyond the `imsd` package: `imsd-dialerd` is +autostarted in the session in place of plasma-dialer's `modem-daemon`, whose +own autostart must be off — the package hides it for accounts created after +the install (`/etc/skel`); an existing account hides it itself with a +`Hidden=true` copy of `org.kde.modem.daemon.desktop` in `~/.config/autostart`. -`net.catcrafts.IMS1` at `/net/catcrafts/IMS1`. The ABI is frozen — dialer -integrations depend on it: +For Phosh and GNOME Mobile, `apk add imsd-ofono` enables `imsd-ofonod` and +points GNOME Calls at its `ofono` provider (a gschema override; the per-user +equivalent is `gsettings set org.gnome.Calls autoload-plugins "['ofono']"`). +Do not run it alongside a real `ofonod`. Known gaps — USSD, hold and +multiparty, caller-ID withholding, the empty emergency-call screen, and why a +restart of `imsd-ofonod` restarts GNOME Calls — are listed in +[AGENTS.md §8.3](AGENTS.md). -| Member | Signature | -|---|---| -| `Dial(number)` | `s → s` (callUni) | -| `HangUp(callUni)` | `s` | -| `Accept(callUni)` | `s` | -| `SendDtmf(callUni, tones)` | `ss` | -| `GetCalls()` | `→ aa{sv}` | -| `GetStatus()` | `→ a{sv}` | -| `CallAdded` | `s a{sv}` | -| `CallStateChanged` | `s s s` (callUni, state, reason) | -| `CallDeleted` | `s` | -| `RegistrationChanged` | `b` | +Call control is polkit's `net.catcrafts.IMS1.Voice`: the active local +session and root may place, answer and end calls; an ssh login may not. -Both `imsd-dialerd` and `imsd-ofonod` translate this ABI and add no members -to it; a new desktop needs a new translation daemon, not changes here — one -that runs as root on the system bus also gates its own callers through -imsd-bus (see Architecture), or it bypasses `net.catcrafts.IMS1.Voice`. +## Building + +[Crafter Build](https://forgejo.catcrafts.net/Catcrafts/Crafter.Build) plus +clang/libc++/lld and the gio-2.0 headers: + +```sh +crafter-build # the daemon; -- --product=media|dialerd|ofonod for the others +crafter-build test # the seven unit-test suites +make && make check # the same, without crafter-build (distro packaging) +``` + +Cross-compiling for a phone, the Makefile's requirements, the package layout +and the CI that publishes the apks are in [AGENTS.md](AGENTS.md) (§4, §9). + +## Documentation + +[AGENTS.md](AGENTS.md) is the complete reference — for coding agents, and +for anyone who wants to know how imsd works or change it: the build products +and the rule that keeps the engine core free of GLib and I/O, the test +suites, the lint rules, the registration and recovery behaviour, the ims PDN +bring-up, upgrades, the frozen D-Bus ABI, packaging and release mechanics, +the full configuration reference, and the carrier behaviours the code was +shaped by. ## License diff --git a/implementations/main.cpp b/implementations/main.cpp index 81103b8..aa82987 100644 --- a/implementations/main.cpp +++ b/implementations/main.cpp @@ -6,7 +6,7 @@ imsd — the userspace IMS/VoLTE daemon (control plane). Owns net.catcrafts.IMS1 on the system bus with the project's frozen D-Bus ABI -(README) and drives it with the engine core: register on the IMS PDN (USIM AKA +(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 diff --git a/packaging/APKBUILD.binary b/packaging/APKBUILD.binary index 333529e..4abd25e 100644 --- a/packaging/APKBUILD.binary +++ b/packaging/APKBUILD.binary @@ -2,7 +2,7 @@ # SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® # Maintainer: Jorijn van der Graaf # Binary packaging: wraps a crafter-build binary cross-compiled per the -# README's "Cross-compiling" section into a proper apk — used by this repo's +# cross-compiling flow of AGENTS.md §4.3 into a proper apk — used by this repo's # package CI (packaging/build-package.sh, which also seds pkgver from # implementations/main.cpp) and runnable by hand. The source tarball is # produced by packaging/make-bin-tarball.sh; the desktop/preset files and diff --git a/packaging/build-package.sh b/packaging/build-package.sh index a089cac..e316a10 100755 --- a/packaging/build-package.sh +++ b/packaging/build-package.sh @@ -2,7 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-only # SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® # CI package build: cross-compile imsd for aarch64 with crafter-build (the -# README's "Cross-compiling" flow), run the test suite natively, and package +# cross-compiling flow of AGENTS.md §4.3), run the test suite natively, and package # the result via packaging/APKBUILD.binary. Expects an x86_64 Alpine # environment with root — the workflow runs it in an alpine:edge job # container on an ordinary runner. Root only installs packages and hands off diff --git a/packaging/imsd-ofono-restart-calls.sh b/packaging/imsd-ofono-restart-calls.sh index 44ee402..a3563ed 100755 --- a/packaging/imsd-ofono-restart-calls.sh +++ b/packaging/imsd-ofono-restart-calls.sh @@ -3,8 +3,8 @@ # SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® # # ExecStartPost of imsd-ofonod.service. GNOME Calls 50.0 keeps a stale modem -# when org.ofono vanishes and dials nothing until it is restarted (README, -# Known gaps), so every start of imsd-ofonod after org.ofono went away +# when org.ofono vanishes and dials nothing until it is restarted (AGENTS.md +# §8.3, Known gaps), so every start of imsd-ofonod after org.ofono went away # restarts the Calls daemon of each user running one. Its first start since # boot is not one: no Calls has seen org.ofono go, and a Calls that runs # already (a login during the bring-up) is left alone. The unit's own diff --git a/packaging/imsd.service b/packaging/imsd.service index b5c6d57..13c454c 100644 --- a/packaging/imsd.service +++ b/packaging/imsd.service @@ -22,7 +22,7 @@ TimeoutStartSec=900 # /run/imsd.env: written by ims-pdn-up.sh (DEV= the connected ims netdev, # LOCAL= the PDN address ModemManager assigned to it). # /etc/imsd.env: admin configuration — PCSCF= is REQUIRED (the carrier's -# P-CSCF address; see the README's Configuration section) and wins over the +# P-CSCF address; see AGENTS.md §10, or the README) and wins over the # runtime-derived values. EnvironmentFile=-/run/imsd.env EnvironmentFile=-/etc/imsd.env From a625248e180ebafebd250e66a071a282096d14c9 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sun, 4 Oct 2026 01:23:23 +0200 Subject: [PATCH 10/15] packaging, ci: build the apks with the Crafter.Build Flatpak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crafter.Build's CI publishes a Flatpak and nothing else since 2026-10-02: the rolling `latest` release's `crafter-build-linux-x86_64-musl-v2.tar.gz` that build-package.sh fetched is gone (404 since 2026-10-03), so a push of a release would fail at `fetch crafter-build` and publish nothing. Install the Flatpak (`net.catcrafts.CrafterBuild//stable` from flatpak.catcrafts.net) in the job container and run it as crafter-build. bubblewrap inside the container needs a privileged runner, so the job runs on the `flatpak` label. The sysroot stays Alpine edge and gains llvm-runtimes, the package with the std module source of its own libc++ (libc++-dev ships none). make-sysroot.sh adds its list with --upgrade: run over a sysroot from before this change (libc++ 22.1.8, no std module source) a plain add installed llvm-runtimes 23.1.2 alone, and its std.cppm did not compile against the 22.1.8 headers (`'text_encoding' file not found`); with --upgrade libc++, libc++-dev and llvm-runtimes came out at 23.1.2 and it compiled. AGENTS.md §4.1, §4.3, §8.3 and §9.4 describe the route as it runs, and the README's Building section carries the install lines. Three comments in the files this touches said what is not so and are corrected: package.yml had fp6-img install the package by a pinned version and sha256 (it takes the newest in the registry), build-package.sh had fp6-img's image build run `make check` on the aarch64 binaries (it builds no imsd: no suite runs on them), and make-sysroot.sh's usage line lacked the --march without which the cross build fails on an x86 host. Tested: build-package.sh run end to end on this commit in a privileged alpine:edge container (podman) on the workstation. --- .forgejo/workflows/package.yml | 13 +++--- AGENTS.md | 63 +++++++++++++++++++++----- README.md | 26 +++++++---- packaging/build-package.sh | 83 ++++++++++++++++++---------------- packaging/make-sysroot.sh | 14 ++++-- 5 files changed, 131 insertions(+), 68 deletions(-) diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml index f64b0a5..30c381d 100644 --- a/.forgejo/workflows/package.yml +++ b/.forgejo/workflows/package.yml @@ -8,16 +8,17 @@ name: package # Build: crafter-build cross-compiles against an Alpine aarch64 sysroot # (packaging/build-package.sh — AGENTS.md §4.3's cross-compiling flow), the # test suite runs natively, and packaging/APKBUILD.binary wraps the result. -# This needs no privileged runner: it runs in an alpine:edge container on -# the ordinary arch-latest runner. +# crafter-build is the Crafter.Build Flatpak, and bubblewrap inside the job +# container needs a privileged runner: the job runs on the `flatpak` label +# (a privileged runner registered to this repository), in an alpine:edge +# container. # # Release gating is the version: pkgver comes from implementations/main.cpp, # the registry answers 409 for an already-published version, and the publish # step treats that as "nothing to do" — so pushes only release when the # Version constant bumps. This is the only producer of the imsd apk: fp6-img -# images install the published package from the registry (pinned version, -# pinned sha256) instead of building their own, so image and 'apk upgrade' -# carry the same binary. +# images install the published package from the registry instead of +# building their own, so image and 'apk upgrade' carry the same binary. # # Publishing needs PACKAGE_TOKEN (catbot account, package:write scope) — an # org-level secret on Catcrafts since 2026-09-02, shared with fp6-img; @@ -30,7 +31,7 @@ on: jobs: package: - runs-on: arch-latest + runs-on: flatpak container: image: alpine:edge timeout-minutes: 90 diff --git a/AGENTS.md b/AGENTS.md index 5c9f446..e48a494 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,8 +105,24 @@ pdn/boot match, defaults for absent keys — proven on the phone only, captures ### 4.1 Native, with crafter-build -Prerequisites: crafter-build, clang with libc++, lld, and the gio-2.0 headers -(`glib2` on Arch, `glib-dev` on Alpine). +crafter-build is the Crafter.Build Flatpak. It carries its own clang, lld and +libc++, and its SDK (org.freedesktop.Sdk, from Flathub) has the gio-2.0 +headers, so the host needs Flatpak and nothing else: + +```sh +flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo +flatpak remote-add --if-not-exists catcrafts https://flatpak.catcrafts.net/catcrafts.flatpakrepo +flatpak install catcrafts net.catcrafts.CrafterBuild//stable +mkdir -p ~/.local/bin +printf '#!/bin/sh\nexec flatpak run net.catcrafts.CrafterBuild//stable "$@"\n' > ~/.local/bin/crafter-build +chmod +x ~/.local/bin/crafter-build +``` + +`crafter-build` is a script and not an alias so that the name also resolves in +a shell that has not defined the alias (a fresh `bash -c`, a CI step); +`~/.local/bin` has to be on `PATH` (any directory on it will do). The +sandbox does not see the host's `/tmp`: keep the checkout under `/home`, or +grant its directory with `flatpak run --filesystem=`. ```sh crafter-build # bin/imsd----/imsd @@ -120,6 +136,14 @@ crafter-build lint # the house-style rules of lint-rules.h Output directories under `bin/` are named `----`; the `./bin/imsd-*/imsd` globs in §8.3 rely on that. +A host-native binary built this way links the Flatpak's libc++ through an +rpath into `/app/lib`: run it inside the sandbox (`flatpak run --command=sh +net.catcrafts.CrafterBuild//stable`, with `--socket=session-bus` for §8.3's +daemons — the sandbox's own session bus is a filter that refuses their names; +`crafter-build test` runs the suites there +anyway), or build with `make` (§4.2) for one that runs on the host. A sysroot +build (§4.3) gets no such rpath — it links the sysroot's libc++. + ### 4.2 With make (distro packaging) GNU make, `clang++` with libc++ and the libc++ std module sources (`std.cppm`: @@ -152,22 +176,30 @@ the `80-imsd-ofono.preset`. ### 4.3 Cross-compiling for a phone (aarch64 Alpine / postmarketOS) ```sh -packaging/make-sysroot.sh # one-time: Alpine aarch64 sysroot from the CDN +packaging/make-sysroot.sh # one-time: Alpine aarch64 sysroot from the CDN (edge) crafter-build -- --target=aarch64-alpine-linux-musl \ --sysroot=$HOME/.cache/imsd/sysroot-aarch64-alpine \ --march=armv8.6-a+fp16fml+aes+sha3+sm4 --mtune=cortex-a720 -# the same flags after `crafter-build test --target=aarch64-alpine-linux-musl` -# run the suite under qemu-aarch64 against the sysroot +# `crafter-build test` with the same flags fails: its runner is qemu-aarch64, +# which the Flatpak's sandbox does not have. Test host-native (§4.1). ``` `make-sysroot.sh` needs no root, qemu or device: it downloads `apk-tools-static` and the Alpine keys and populates a minimal rootfs (musl, -libc++, glib and their headers, compiler-rt, libunwind, gcc for its +libc++ with its std module source, glib and their headers, compiler-rt, +libunwind, gcc for its `crtbeginS.o`/libgcc, which clang's `*-alpine-linux-musl` driver links against). In a cross build `project.cpp` does not ask the host's pkg-config (it would answer for the wrong architecture); it adds `-I=/usr/include/glib-2.0` and `-I=/usr/lib/glib-2.0/include`, which clang resolves inside the sysroot. -This is the flow CI runs (§9.4). +crafter-build compiles the std module against the sysroot's libc++ headers +from the sysroot's own source — Alpine's `llvm-runtimes`, the same release +as its libc++, which `make-sysroot.sh` installs (`libc++-dev` ships none); +run over an existing sysroot the script upgrades its packages (`apk add +--upgrade`). +Without that source it falls back to the toolchain's source for the same libc++ +major release, then to the sources Crafter.Build carries for other +releases. This is the flow CI runs (§9.4). ## 5. Tests @@ -791,7 +823,9 @@ Known gaps, all inherited rather than introduced: (`imsd-ofono-restart-calls.sh`) restarts the Calls daemon of every user running one. The first start since boot leaves a running Calls alone. -Development without a phone or root: +Development without a phone or root (crafter-build's binaries inside the +sandbox, the `make` build's `build/make/imsd` and `build/make/imsd-ofonod` on +the host: §4.1): ```sh ./bin/imsd-*/imsd --session & # imsd's ABI on the session bus @@ -857,10 +891,15 @@ install finds no unit active and marks nothing. `imsd-systemd` marks ### 9.4 CI and what a push means `.forgejo/workflows/package.yml` runs on every push to `main` (and on -dispatch): in an `alpine:edge` container, `packaging/build-package.sh` -provisions clang/lld/llvm-runtimes/glib-dev/abuild, fetches the musl build of -crafter-build, builds the sysroot, cross-compiles the four products, runs the -suite natively, wraps the result with `APKBUILD.binary`, and the publish step +dispatch): on the `flatpak` runner label (a privileged runner registered to +this repository — bubblewrap inside the job container needs the privilege), in an +`alpine:edge` container, `packaging/build-package.sh` installs the +Crafter.Build Flatpak (`net.catcrafts.CrafterBuild//stable` from +flatpak.catcrafts.net — the branch Crafter.Build publishes from its release +tags, where `beta` follows every push to its master; the build log names its +commit) and llvm/abuild, builds +the sysroot, cross-compiles the four products, runs the suite natively inside +the Flatpak, wraps the result with `APKBUILD.binary`, and the publish step uploads the apks to the Forgejo Alpine registry (`…/api/packages/Catcrafts/alpine/edge/fp6`) with `PACKAGE_TOKEN`. The registry answers 409 for an already-published version and the step treats diff --git a/README.md b/README.md index ff99e53..81745cc 100644 --- a/README.md +++ b/README.md @@ -42,11 +42,11 @@ your carrier's P-CSCF address. On a Catcrafts Fairphone 6 image imsd is installed from the project's apk registry and upgrades with `apk upgrade` (an upgrade restarts the daemon; a -call in progress ends). Elsewhere, build it (below), `make install`, and -enable the units yourself — `systemctl enable imsd`, and `systemctl enable ---now imsd-ofonod` for GNOME Calls: imsd's preset, the -`multi-user.target.wants` link and the `/etc/imsd.env` condition below come -with the apk, not with `make install`. +call in progress ends). Elsewhere, build it with `make` (below), run +`make install`, and enable the units yourself — `systemctl enable imsd`, +and `systemctl enable --now imsd-ofonod` for GNOME Calls: imsd's preset, +the `multi-user.target.wants` link and the `/etc/imsd.env` condition +below come with the apk, not with `make install`. Configuration is environment variables in `/etc/imsd.env`; the one required setting is the P-CSCF: @@ -102,13 +102,23 @@ session and root may place, answer and end calls; an ssh login may not. ## Building -[Crafter Build](https://forgejo.catcrafts.net/Catcrafts/Crafter.Build) plus -clang/libc++/lld and the gio-2.0 headers: +With the [Crafter Build](https://forgejo.catcrafts.net/Catcrafts/Crafter.Build) +Flatpak, which carries the compiler: ```sh +flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo +flatpak remote-add --if-not-exists catcrafts https://flatpak.catcrafts.net/catcrafts.flatpakrepo +flatpak install catcrafts net.catcrafts.CrafterBuild//stable +alias crafter-build='flatpak run net.catcrafts.CrafterBuild//stable' crafter-build # the daemon; -- --product=media|dialerd|ofonod for the others crafter-build test # the seven unit-test suites -make && make check # the same, without crafter-build (distro packaging) +``` + +Or without crafter-build (distro packaging), with clang/libc++/lld and the +gio-2.0 headers: + +```sh +make && make check ``` Cross-compiling for a phone, the Makefile's requirements, the package layout diff --git a/packaging/build-package.sh b/packaging/build-package.sh index e316a10..53c6153 100755 --- a/packaging/build-package.sh +++ b/packaging/build-package.sh @@ -5,44 +5,28 @@ # cross-compiling flow of AGENTS.md §4.3), run the test suite natively, and package # the result via packaging/APKBUILD.binary. Expects an x86_64 Alpine # environment with root — the workflow runs it in an alpine:edge job -# container on an ordinary runner. Root only installs packages and hands off -# to a scratch user: the sysroot is built with apk.static --usermode (which -# refuses root) and abuild wants a user too. +# container on the privileged `flatpak` runner: crafter-build is the +# Crafter.Build Flatpak, and bubblewrap inside the container needs the +# privilege. Root only installs packages and hands off to a scratch user: the +# sysroot is built with apk.static --usermode (which refuses root) and abuild +# wants a user too. # # Built packages land in /home/build/.local/share/abuild/*/aarch64/imsd*.apk; # the workflow's publish step uploads them to the Forgejo Alpine registry. set -eu -# The musl build of crafter-build (Crafter.Build CI's release-musl job): -# this container is Alpine, and the glibc launcher cannot run on musl. v2 = -# SSE4.2 baseline: the CI box is an Intel N5105 (no AVX). Overridable for -# local rehearsals (file:// works). -CRAFTER_URL=${CRAFTER_URL:-https://forgejo.catcrafts.net/Catcrafts/Crafter.Build/releases/download/latest/crafter-build-linux-x86_64-musl-v2.tar.gz} +# crafter-build is the Crafter.Build Flatpak: it carries its own clang, lld +# and libc++, and its SDK has glib, so nothing that compiles is installed +# here. `stable` is the branch Crafter.Build publishes from its release tags +# (`beta` follows every push to its master), so the toolchain of a release +# moves only with a release of the toolchain. The branch is overridable +# for a rehearsal against another one. +CRAFTER_REMOTE=${CRAFTER_REMOTE:-https://flatpak.catcrafts.net/catcrafts.flatpakrepo} +CRAFTER_REF=${CRAFTER_REF:-net.catcrafts.CrafterBuild//stable} SRC=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) -# clang cross-targets aarch64 natively and the target's libc++/glib come from -# the sysroot; llvm-runtimes/libc++-dev/glib-dev here serve the NATIVE -# test-suite run. build-base = Alpine's standard build environment (the one -# abuild implies): binutils' ld/ar for clang's default link driver, gcc's -# libgcc_s/crt objects the musl clang driver links against. -if [ "$(id -u)" = 0 ]; then - apk add -q git curl tar clang lld llvm llvm-runtimes libc++-dev llvm-libunwind-dev glib-dev \ - build-base abuild sudo - id build >/dev/null 2>&1 || adduser -D build - addgroup build abuild 2>/dev/null || true - echo 'build ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/build - # abuild in cross mode strips with $CHOST-strip; llvm-strip handles any - # ELF arch, so give it that name - ln -sf "$(command -v llvm-strip)" /usr/local/bin/aarch64-alpine-linux-musl-strip - # the CI checkout arrives root-owned; crafter-build writes bin/ into it - chown -R build "$SRC" - # -l: a login shell, so HOME really is /home/build (abuild keys + output); - # it scrubs the environment, so carry the one knob that matters across - exec su -l build -c "CRAFTER_URL='${CRAFTER_URL:-}' sh -eu '$SRC/packaging/build-package.sh'" -fi - retry() { # retry _desc=$1; shift for _i in 1 2 3; do @@ -54,26 +38,49 @@ retry() { # retry return 1 } +# llvm for llvm-strip (abuild in cross mode strips with $CHOST-strip); the +# compiler is the Flatpak's. +if [ "$(id -u)" = 0 ]; then + apk add -q git curl tar flatpak llvm abuild sudo + # the Flatpak as a system installation the build user runs; its SDK + # (org.freedesktop.Sdk) comes from Flathub + flatpak remote-add --if-not-exists catcrafts "$CRAFTER_REMOTE" + flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo + retry "install crafter-build" flatpak install -y --noninteractive catcrafts "$CRAFTER_REF" + id build >/dev/null 2>&1 || adduser -D build + addgroup build abuild 2>/dev/null || true + echo 'build ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/build + # abuild in cross mode strips with $CHOST-strip; llvm-strip handles any + # ELF arch, so give it that name + ln -sf "$(command -v llvm-strip)" /usr/local/bin/aarch64-alpine-linux-musl-strip + # the CI checkout arrives root-owned; crafter-build writes bin/ into it + chown -R build "$SRC" + # -l: a login shell, so HOME really is /home/build (abuild keys + output); + # it scrubs the environment, so carry the one knob that matters across + exec su -l build -c "CRAFTER_REF='$CRAFTER_REF' sh -eu '$SRC/packaging/build-package.sh'" +fi + # implementations/main.cpp is the version's single source of truth (same # derivation as make-bin-tarball.sh) VER=$(sed -n 's/.*char\* Version = "\(.*\)".*/\1/p' "$SRC/implementations/main.cpp") [ -n "$VER" ] || { echo "cannot read Version from implementations/main.cpp" >&2; exit 1; } echo ">> packaging imsd $VER" -# --- crafter-build: static launcher from the rolling release -mkdir -p "$HOME/crafter-build" -retry "fetch crafter-build" \ - sh -c "curl -fsSL '$CRAFTER_URL' | tar -xz -C '$HOME/crafter-build'" -PATH="$HOME/crafter-build/bin:$PATH" -export CRAFTER_BUILD_HOME="$HOME/crafter-build/share/crafter-build" +# --- crafter-build: the Flatpak, under the name the rest of this file uses. +# The build log names the build it ran with. +mkdir -p "$HOME/bin" +printf '#!/bin/sh\nexec flatpak run %s "$@"\n' "$CRAFTER_REF" > "$HOME/bin/crafter-build" +chmod +x "$HOME/bin/crafter-build" +PATH="$HOME/bin:$PATH" +flatpak info "$CRAFTER_REF" | sed -n 's/^ *\(Version\|Commit\|Date\): */crafter-build \1: /p' +crafter-build --version # --- aarch64 Alpine sysroot (unprivileged: apk.static --usermode) SYSROOT="$HOME/.cache/imsd/sysroot-aarch64-alpine" retry "make sysroot" "$SRC/packaging/make-sysroot.sh" "$SYSROOT" -# --- cross-compile the products; run the suites natively (same sources, -# host arch — the aarch64 binaries are additionally exercised by fp6-img's -# chroot `make check` whenever an image builds) +# --- cross-compile the products; run the suites natively, inside the Flatpak +# (same sources, host arch: no suite runs on the aarch64 binaries) cd "$SRC" # The phone is one SoC, 4x Cortex-A520 + 4x Cortex-A720, so build for it. # armv8.6-a is the highest level whose mandatory set it exposes (8.7 would diff --git a/packaging/make-sysroot.sh b/packaging/make-sysroot.sh index fdca8d8..112b7e2 100755 --- a/packaging/make-sysroot.sh +++ b/packaging/make-sysroot.sh @@ -8,7 +8,8 @@ # clang can use via --sysroot. No root, no qemu, no device needed: # # packaging/make-sysroot.sh [dir] # default: ~/.cache/imsd/sysroot-aarch64-alpine -# crafter-build --target=aarch64-alpine-linux-musl --sysroot= +# crafter-build -- --target=aarch64-alpine-linux-musl --sysroot= \ +# --march=… --mtune=… # the phone's values: AGENTS.md §4.3 set -eu SYSROOT="${1:-$HOME/.cache/imsd/sysroot-aarch64-alpine}" @@ -16,8 +17,13 @@ MIRROR="${MIRROR:-https://dl-cdn.alpinelinux.org/alpine/edge}" ARCH=aarch64 # gcc is in the list for its crtbeginS.o/libgcc: clang's driver for # *-alpine-linux-musl links against the GCC runtime it detects under -# /usr/lib/gcc// -PKGS="musl-dev libc++ libc++-dev glib-dev compiler-rt llvm-libunwind-dev gcc" +# /usr/lib/gcc//. llvm-runtimes is in it for the std module +# source of this very libc++ (usr/share/libc++/v1/std.cppm; libc++-dev ships +# none): crafter-build compiles the sysroot's own copy first. --upgrade below +# is for a run over an existing sysroot: added alone beside libc++ 22, +# llvm-runtimes 23's source did not compile against its headers; upgraded +# with it, it did. +PKGS="musl-dev libc++ libc++-dev llvm-runtimes glib-dev compiler-rt llvm-libunwind-dev gcc" work=$(mktemp -d) trap 'rm -rf "$work"' EXIT @@ -40,6 +46,6 @@ mkdir -p "$SYSROOT" --arch "$ARCH" --root "$SYSROOT" --initdb --no-scripts --no-cache \ --usermode --keys-dir "$work/usr/share/apk/keys/$ARCH" \ -X "$MIRROR/main" -X "$MIRROR/community" \ - add $PKGS + add --upgrade $PKGS echo ">> done: $SYSROOT" From 19346908f91f316e97b78afa5e8837c458d77de9 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Tue, 6 Oct 2026 16:45:44 +0200 Subject: [PATCH 11/15] README, AGENTS.md: the README documents imsd for users, configuration included MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README had been cut down to an introduction and the configuration variables moved to AGENTS.md, a file for coding agents. A user who has to set a variable should not need that file. The README is the user documentation again: installing from the Catcrafts Alpine repository or the apks, the configuration tables for imsd and imsd-media, the dialers and the D-Bus ABI. Its build instructions go: building is in AGENTS.md (§4), with the tests, the packaging and how the daemon works. AGENTS.md §10 points to the README for the tables and keeps the two imsd-media pairs that are not configuration. --- AGENTS.md | 54 ++++++------------------- README.md | 119 +++++++++++++++++++++++++++++++----------------------- 2 files changed, 81 insertions(+), 92 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e48a494..ec0db27 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,8 @@ This file is for coding agents and for anyone changing imsd: what the repository contains, how it is built, tested and packaged, how the daemon behaves at runtime, and the conventions a change has to follow. The README -is the short, human-facing introduction; everything it leaves out is here. +is for users: what imsd is, how to install and configure it. The +configuration variables are documented there, not here (§10). Where this file and the code disagree, the code is right and this file has a bug — fix it in the same change. @@ -913,54 +914,23 @@ bump commit titled `imsd X.Y.Z` that changes only the `Version` constant. ## 10. Configuration reference Everything is environment variables. The packaged unit reads -`/run/imsd.env` (from `ims-pdn-up.sh`) and then `/etc/imsd.env` (yours, wins -on conflict). Minimum viable `/etc/imsd.env`: - -```sh -# REQUIRED until P-CSCF discovery from the PDN's PCO is implemented: your -# carrier's P-CSCF address. Find it in a stock-firmware capture or your -# carrier's IMS documentation. -PCSCF=2001:db8::105 -``` +`/run/imsd.env` (from `ims-pdn-up.sh`) and then `/etc/imsd.env` (the user's, +wins on conflict). The variables are user documentation: the tables and the +minimum `/etc/imsd.env` are in the README. ### 10.1 imsd -| Variable | Default | Meaning | -|---|---|---| -| `PCSCF` | *(none — required)* | P-CSCF address (IPv6); without it registration fails with a clear error | -| `PCSCF_PORT` | `5060` | unprotected SIP port for the initial REGISTER | -| `DEV` | `qmapmux0.0` | ims-PDN netdev; the packaged service passes the real one via `/run/imsd.env` | -| `LOCAL` | *(auto)* | UE address. The packaged service gets the PDN address ModemManager assigned, via `/run/imsd.env` from `ims-pdn-up.sh`; a manual run prefers a global IPv6 on `DEV` that is neither temporary nor `dynamic` (RA-derived), then any non-temporary one, then the first (the kernel's temporary SLAAC address is listed first, and a registration bound to it is lost when the kernel retires it) | -| `USER_AGENT` | `imsd/` | REGISTER User-Agent. Some networks fingerprint UAs; setting your device's stock build string reproduces the stock modem's registration exactly. Empty omits the header | -| `STATE_FILE` | `/var/lib/imsd/imsreg.state` (root) / `$XDG_STATE_HOME/imsd/imsreg.state` | persisted registration context for warm resume | -| `HOLDS_FILE` | `/run/imsd.holds` (root) / `$XDG_RUNTIME_DIR/imsd.holds` (`/tmp/imsd.holds` without a runtime dir) | the waits the network imposed (a `Retry-After`, the refusal schedule), where that schedule stands and whether the old registration is gone (`old_gone`), kept across a restart of imsd for the PDN they were set on — not across a reboot; `/dev/null` switches the file off (only a regular file is ever removed; anything else is emptied). `imsd-handover.sh` honours it too | -| `RESUME` | `auto` | `0` forces a fresh registration (ignores a warm SA) | -| `REFRESH_INTERVAL` | *(auto)* | keepalive re-REGISTER period in s; default = half the granted expiry, clamped to [120, 1800] | -| `EALG` | `aes-cbc` | offered ESP cipher (`aes-cbc`, `des-ede3-cbc`, `null`). From the 401's `Security-Server` list imsd selects the highest-preference `ipsec-3gpp` mechanism that matches this offer (RFC 3329 §2.3.1) and takes the SPIs/ports from the entry carrying them (some cores list every mechanism they support and put the SPIs on the one they applied); when no entry matches, the P-CSCF's SPI-carrying entry is installed as listed (`hmac-md5-96`, an absent `ealg` = null) and the journal says so. The line, the selection and the SA parameters are logged at every fresh registration | -| `SIP_TRANSPORT` | `auto` | transport of the protected leg (the second REGISTER and everything after it): `tcp`, `udp`, or `auto` = TCP, falling back to a fresh registration over UDP when the P-CSCF leaves two TCP connects unanswered — but only on a phone that has never registered over TCP: once the state file records a TCP registration, a silent connect is treated as an outage, not a policy (delete the file or set `udp` to override, e.g. after a SIM change). The challenge (first REGISTER) is always UDP. Over UDP a REGISTER or INVITE larger than ~1.2 KB leaves as IPv6 fragments at the ims PDN's 1280-byte MTU (the journal says so once per flow); there are no SIP retransmission timers over UDP yet, so a lost datagram costs a timeout. The SAs, the listener and the firewall rule cover both transports | -| `EMERGENCY_NUMBERS` | *(empty)* | comma-separated additions to the builtin 112/911 emergency numbers (e.g. a private test core's short code). SIM `EF_ECC` is not read yet. `imsd-ofonod` reads it too, for the oFono `EmergencyNumbers` property | -| `RTP_PORT` | `50004` | local RTP port advertised in SDP | -| `PRECOND` | `0` | `1` offers SDP QoS preconditions | -| `CODECS` | *(empty — defaults)* | comma-separated codec preference list over `AMR-WB`, `AMR` (or `AMR-NB`), `PCMA`, `PCMU`: restricts and orders both the codecs offered on an outgoing call and those accepted from an inbound offer (default: offer AMR-WB + AMR, accept all four in that order). A bench knob — a network whose gateway transcodes every caller up to AMR-WB otherwise never lets the narrowband path run | -| `DUMP_SIP` | `0` | `1` writes raw REGISTER-200/SUBSCRIBE-200 dumps, the last refused REGISTER or SUBSCRIBE final (`imsd-register-fail.raw`, `imsd-subscribe-fail.raw`), the last inbound INVITE (`imsd-invite-in.raw`) and the last outgoing INVITE (`imsd-invite-out.raw`), mode 0600, for debugging — they contain your IMSI/MSISDN and addresses. A refused REGISTER or SUBSCRIBE is logged with its status line and any Retry-After / Warning / Reason / Server headers | -| `DUMP_DIR`, `OUT_DIR` | state dir | where dumps / per-call media stats land | -| `IMSD_MEDIA` | *(auto)* | path to `imsd-media` (default: next to `imsd`, else `/usr/libexec/imsd-media`) | - -The protected client/server ports are fixed at 45061/45062 -(`imsd::util::PortUc`/`PortUs`) — changing them in source also requires -updating the firewall rules `ims-pdn-up.sh` installs. +The README's first table. The protected client/server ports are fixed at +45061/45062 (`imsd::util::PortUc`/`PortUs`) — changing them in source also +requires updating the firewall rules `ims-pdn-up.sh` installs. ### 10.2 imsd-media (per call, set through imsd's environment) +The README's second table. Two pairs are not configuration and are left out +there: + | Variable | Default | Meaning | |---|---|---| -| `MIC`, `PLAY` | `1`, `1` | uplink mic capture / downlink playout via PipeWire, as imsd passes them (`imsd-media` started by hand defaults both to off) | -| `AUDIO_USER` | *(auto)* | account whose PipeWire session carries call audio; imsd sets it per call from logind's seat0 session (§7.6). Set in imsd's own environment it pins an account instead | -| `GAIN`, `PLAY_GAIN` | `10`, `1.0` | uplink / downlink gain, as imsd passes them (`imsd-media` started by hand defaults `GAIN` to `1.0`) | -| `AMR_MODE` | `2` | AMR encoder mode of whichever AMR codec is active, as imsd passes it (12.65 kbit/s for AMR-WB; `imsd-media` started by hand defaults to 2 for AMR-WB and 7 for AMR) | -| `DTX` | `0` | encoder discontinuous transmission | -| `MEDIA_TIMEOUT` | `6.0` | seconds of downlink silence before exit 3 (far-end-hangup signal) | -| `RTP_DUMP` | `0` | `1` captures raw downlink RTP next to the stats file | | `CODEC`, `OCTET_ALIGN` | *(from the SDP)* | set by imsd from the negotiated answer; not configuration | | `MIC_SRC`, `PCM_DUMP` | *(unset)* | test seams (§5) | @@ -993,7 +963,7 @@ only the imsd side of its bus choice. file lists with `make install`, `make-bin-tarball.sh` and `APKBUILD.binary`'s `package()` when a packaged file is added. - **The README and this file follow the code in the same commit.** A new - knob gets its row in §10; a new state its journal line in §7.8; a new file + knob gets its row in the README's configuration tables; a new state its journal line in §7.8; a new file under `/run` or `/var/lib` its row in §7.7. - **Compatibility notes are kept where the mechanism lives**, not in a changelog the repository does not have: the 0.3.9 hand-over is documented diff --git a/README.md b/README.md index 81745cc..3116d23 100644 --- a/README.md +++ b/README.md @@ -5,18 +5,17 @@ Userspace IMS/VoLTE daemon for mainline Linux phones. -Modem firmware on typical mainline-Linux phones will bring up the IMS PDN but -can't be used for actual call audio. imsd implements the whole IMS client in -userspace instead: SIP registration with USIM AKA authentication over kernel +Most Linux phones drive the modem's internal IMS engine. +imsd implements the whole IMS client in userspace instead: +SIP registration with USIM AKA authentication over kernel IPsec (ESP), call signalling, and the AMR-WB RTP media plane wired into -PipeWire so a stock mobile dialer — Plasma Dialer, or GNOME Calls under Phosh -and GNOME Mobile — can place and receive real VoLTE calls on a commercial -network. +PipeWire so a stock mobile dialer like Plasma Dialer or GNOME Calls under Phosh +can place and receive real VoLTE calls on a commercial network. ## Status Call-capable, both directions: outgoing calls place, answer, carry full-duplex -AMR-WB audio and tear down; incoming calls ring, answer and carry media the +AMR-WB audio and tear down. Incoming calls ring, answer and carry media the same way, driven end-to-end from a stock dialer over a frozen D-Bus ABI. Not there yet: DTMF (`SendDtmf` is a stub), hold/multiparty/call waiting, USSD, P-CSCF discovery (the address is configured by hand). @@ -35,25 +34,26 @@ Verified by community members to be fully working: imsd talks to the modem through ModemManager plus `qmicli` over QRTR, installs its security associations with `ip xfrm` and plays audio through PipeWire, so in practice: a Qualcomm modem on a mainline kernel with working -WWAN/ModemManager integration, kernel ESP, PipeWire, systemd and polkit — and +WWAN/ModemManager integration, kernel ESP, PipeWire, systemd, polkit and your carrier's P-CSCF address. ## Install and configure -On a Catcrafts Fairphone 6 image imsd is installed from the project's apk -registry and upgrades with `apk upgrade` (an upgrade restarts the daemon; a -call in progress ends). Elsewhere, build it with `make` (below), run -`make install`, and enable the units yourself — `systemctl enable imsd`, -and `systemctl enable --now imsd-ofonod` for GNOME Calls: imsd's preset, -the `multi-user.target.wants` link and the `/etc/imsd.env` condition -below come with the apk, not with `make install`. +For automatic updates add the Catcrafts Alpine repository. Note this does also pull in other packages. + +Otherwise just download the latest `imsd` and `imsd-systemd` APKs and install them. + +Both can be found in the packages tab: + +``` +https://forgejo.catcrafts.net/Catcrafts/-/packages/alpine/imsd +``` Configuration is environment variables in `/etc/imsd.env`; the one required setting is the P-CSCF: ```sh -# your carrier's P-CSCF address — from a stock-firmware capture or the -# carrier's IMS documentation +# your carrier's P-CSCF address, capture via: `adb shell dumpsys telephony.registry` on Android PCSCF=2001:db8::105 ``` @@ -68,9 +68,6 @@ journalctl -u imsd -f busctl --system call net.catcrafts.IMS1 /net/catcrafts/IMS1 net.catcrafts.IMS1 GetStatus ``` -Every variable, what the daemon does when the registration breaks, and -what the journal lines mean are in [AGENTS.md](AGENTS.md) (§10, §7.4, §7.8). - ## Dialers imsd itself is desktop-agnostic: everything a dialer needs is on the system @@ -92,47 +89,69 @@ the install (`/etc/skel`); an existing account hides it itself with a For Phosh and GNOME Mobile, `apk add imsd-ofono` enables `imsd-ofonod` and points GNOME Calls at its `ofono` provider (a gschema override; the per-user equivalent is `gsettings set org.gnome.Calls autoload-plugins "['ofono']"`). -Do not run it alongside a real `ofonod`. Known gaps — USSD, hold and -multiparty, caller-ID withholding, the empty emergency-call screen, and why a -restart of `imsd-ofonod` restarts GNOME Calls — are listed in -[AGENTS.md §8.3](AGENTS.md). +Do not run it alongside a real `ofonod`. Call control is polkit's `net.catcrafts.IMS1.Voice`: the active local session and root may place, answer and end calls; an ssh login may not. -## Building +## Full config: -With the [Crafter Build](https://forgejo.catcrafts.net/Catcrafts/Crafter.Build) -Flatpak, which carries the compiler: +| Variable | Default | Meaning | +|---|---|---| +| `PCSCF` | *(none — required)* | P-CSCF address (IPv6); without it registration fails with a clear error | +| `PCSCF_PORT` | `5060` | unprotected SIP port for the initial REGISTER | +| `DEV` | `qmapmux0.0` | ims-PDN netdev; the packaged service passes the real one via `/run/imsd.env` | +| `LOCAL` | *(auto)* | UE address. The packaged service gets the PDN address ModemManager assigned, via `/run/imsd.env` from `ims-pdn-up.sh`; a manual run prefers a global IPv6 on `DEV` that is neither temporary nor `dynamic` (RA-derived), then any non-temporary one, then the first (the kernel's temporary SLAAC address is listed first, and a registration bound to it is lost when the kernel retires it) | +| `USER_AGENT` | `imsd/` | REGISTER User-Agent. Some networks fingerprint UAs; setting your device's stock build string reproduces the stock modem's registration exactly. Empty omits the header | +| `STATE_FILE` | `/var/lib/imsd/imsreg.state` (root) / `$XDG_STATE_HOME/imsd/imsreg.state` | persisted registration context for warm resume | +| `HOLDS_FILE` | `/run/imsd.holds` (root) / `$XDG_RUNTIME_DIR/imsd.holds` (`/tmp/imsd.holds` without a runtime dir) | the waits the network imposed (a `Retry-After`, the refusal schedule), where that schedule stands and whether the old registration is gone (`old_gone`), kept across a restart of imsd for the PDN they were set on — not across a reboot; `/dev/null` switches the file off (only a regular file is ever removed; anything else is emptied). `imsd-handover.sh` honours it too | +| `RESUME` | `auto` | `0` forces a fresh registration (ignores a warm SA) | +| `REFRESH_INTERVAL` | *(auto)* | keepalive re-REGISTER period in s; default = half the granted expiry, clamped to [120, 1800] | +| `EALG` | `aes-cbc` | offered ESP cipher (`aes-cbc`, `des-ede3-cbc`, `null`). From the 401's `Security-Server` list imsd selects the highest-preference `ipsec-3gpp` mechanism that matches this offer (RFC 3329 §2.3.1) and takes the SPIs/ports from the entry carrying them (some cores list every mechanism they support and put the SPIs on the one they applied); when no entry matches, the P-CSCF's SPI-carrying entry is installed as listed (`hmac-md5-96`, an absent `ealg` = null) and the journal says so. The line, the selection and the SA parameters are logged at every fresh registration | +| `SIP_TRANSPORT` | `auto` | transport of the protected leg (the second REGISTER and everything after it): `tcp`, `udp`, or `auto` = TCP, falling back to a fresh registration over UDP when the P-CSCF leaves two TCP connects unanswered — but only on a phone that has never registered over TCP: once the state file records a TCP registration, a silent connect is treated as an outage, not a policy (delete the file or set `udp` to override, e.g. after a SIM change). The challenge (first REGISTER) is always UDP. Over UDP a REGISTER or INVITE larger than ~1.2 KB leaves as IPv6 fragments at the ims PDN's 1280-byte MTU (the journal says so once per flow); there are no SIP retransmission timers over UDP yet, so a lost datagram costs a timeout. The SAs, the listener and the firewall rule cover both transports | +| `EMERGENCY_NUMBERS` | *(empty)* | comma-separated additions to the builtin 112/911 emergency numbers (e.g. a private test core's short code). SIM `EF_ECC` is not read yet. `imsd-ofonod` reads it too, for the oFono `EmergencyNumbers` property | +| `RTP_PORT` | `50004` | local RTP port advertised in SDP | +| `PRECOND` | `0` | `1` offers SDP QoS preconditions | +| `CODECS` | *(empty — defaults)* | comma-separated codec preference list over `AMR-WB`, `AMR` (or `AMR-NB`), `PCMA`, `PCMU`: restricts and orders both the codecs offered on an outgoing call and those accepted from an inbound offer (default: offer AMR-WB + AMR, accept all four in that order). A bench knob — a network whose gateway transcodes every caller up to AMR-WB otherwise never lets the narrowband path run | +| `DUMP_SIP` | `0` | `1` writes raw REGISTER-200/SUBSCRIBE-200 dumps, the last refused REGISTER or SUBSCRIBE final (`imsd-register-fail.raw`, `imsd-subscribe-fail.raw`), the last inbound INVITE (`imsd-invite-in.raw`) and the last outgoing INVITE (`imsd-invite-out.raw`), mode 0600, for debugging — they contain your IMSI/MSISDN and addresses. A refused REGISTER or SUBSCRIBE is logged with its status line and any Retry-After / Warning / Reason / Server headers | +| `DUMP_DIR`, `OUT_DIR` | state dir | where dumps / per-call media stats land | +| `IMSD_MEDIA` | *(auto)* | path to `imsd-media` (default: next to `imsd`, else `/usr/libexec/imsd-media`) | -```sh -flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo -flatpak remote-add --if-not-exists catcrafts https://flatpak.catcrafts.net/catcrafts.flatpakrepo -flatpak install catcrafts net.catcrafts.CrafterBuild//stable -alias crafter-build='flatpak run net.catcrafts.CrafterBuild//stable' -crafter-build # the daemon; -- --product=media|dialerd|ofonod for the others -crafter-build test # the seven unit-test suites -``` +The protected client/server ports are fixed at 45061/45062 +(`imsd::util::PortUc`/`PortUs`) — changing them in source also requires +updating the firewall rules `ims-pdn-up.sh` installs. -Or without crafter-build (distro packaging), with clang/libc++/lld and the -gio-2.0 headers: +### imsd-media (per-call, set through imsd's environment) -```sh -make && make check -``` +| Variable | Default | Meaning | +|---|---|---| +| `MIC`, `PLAY` | `1`, `1` | uplink mic capture / downlink playout via PipeWire, as imsd passes them (`imsd-media` started by hand defaults both to off) | +| `AUDIO_USER` | *(auto)* | account whose PipeWire session carries call audio; imsd sets it per call from logind's seat0 session. Set in imsd's own environment it pins an account instead | +| `GAIN`, `PLAY_GAIN` | `10`, `1.0` | uplink / downlink gain, as imsd passes them (`imsd-media` started by hand defaults `GAIN` to `1.0`) | +| `AMR_MODE` | `2` | AMR encoder mode of whichever AMR codec is active, as imsd passes it (12.65 kbit/s for AMR-WB; `imsd-media` started by hand defaults to 2 for AMR-WB and 7 for AMR) | +| `DTX` | `0` | encoder discontinuous transmission | +| `MEDIA_TIMEOUT` | `6.0` | seconds of downlink silence before exit 3 (far-end-hangup signal) | +| `RTP_DUMP` | `0` | `1` captures raw downlink RTP next to the stats file | -Cross-compiling for a phone, the Makefile's requirements, the package layout -and the CI that publishes the apks are in [AGENTS.md](AGENTS.md) (§4, §9). +## D-Bus ABI -## Documentation +`net.catcrafts.IMS1` at `/net/catcrafts/IMS1`. The ABI is frozen. -[AGENTS.md](AGENTS.md) is the complete reference — for coding agents, and -for anyone who wants to know how imsd works or change it: the build products -and the rule that keeps the engine core free of GLib and I/O, the test -suites, the lint rules, the registration and recovery behaviour, the ims PDN -bring-up, upgrades, the frozen D-Bus ABI, packaging and release mechanics, -the full configuration reference, and the carrier behaviours the code was -shaped by. +| Member | Signature | +|---|---| +| `Dial(number)` | `s → s` (callUni) | +| `HangUp(callUni)` | `s` | +| `Accept(callUni)` | `s` | +| `SendDtmf(callUni, tones)` | `ss` | +| `GetCalls()` | `→ aa{sv}` | +| `GetStatus()` | `→ a{sv}` | +| `CallAdded` | `s a{sv}` | +| `CallStateChanged` | `s s s` (callUni, state, reason) | +| `CallDeleted` | `s` | +| `RegistrationChanged` | `b` | + +Both `imsd-dialerd` and `imsd-ofonod` are users of this ABI. +A new desktop needs a new translation daemon, not changes here. ## License From f089c8dd71cc0581d1252527d90cf1efb61807fa Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Tue, 6 Oct 2026 16:45:44 +0200 Subject: [PATCH 12/15] packaging: drop the make route and the from-source APKBUILD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit imsd had two build descriptions to keep in sync: project.cpp for crafter-build, and a Makefile mirroring it for distros that build from source, used by packaging/APKBUILD. CI has not used that route since 2026-09-02 — its apks are a crafter-build binary wrapped by APKBUILD.binary — and the recipe had not followed the releases: its pkgver was still 0.3.0. crafter-build is now the only build. AGENTS.md loses the make route (§4.2) and describes one recipe (§9.1). Tested: build-package.sh run end to end on this commit in a privileged alpine:edge container (podman) on the workstation. --- AGENTS.md | 88 ++++++----------------- Makefile | 170 --------------------------------------------- packaging/APKBUILD | 58 ---------------- 3 files changed, 22 insertions(+), 294 deletions(-) delete mode 100644 Makefile delete mode 100644 packaging/APKBUILD diff --git a/AGENTS.md b/AGENTS.md index ec0db27..28af44c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,6 @@ PipeWire. The verified device/carrier combinations are in the README. | Path | What it is | |---|---| | `project.cpp` | The canonical build description, for [Crafter Build](https://forgejo.catcrafts.net/Catcrafts/Crafter.Build). Chooses the product by `--product=`. | -| `Makefile` | Distro-packaging mirror of `project.cpp`: the same clang module pipeline, the same flags. Keep the two in sync when products, module partitions or link flags change. | | `lint-rules.h` | The house-style lint rules (§6), registered from `project.cpp` through `AddProjectLintRules`. Project-local on purpose. | | `interfaces/Imsd.cppm` | The primary module interface of `imsd-core`: `export import`s the seven partitions below. | | `interfaces/Imsd-Util.cppm` | `imsd::util` — SIP tokens, IPv6 host/port formatting, hex/base64, MD5 (RFC 1321), the fixed protected ports `PortUc`/`PortUs` (45061/45062). | @@ -50,9 +49,9 @@ PipeWire. The verified device/carrier combinations are in the README. | `implementations/dialerd.cpp` | `imsd-dialerd`, the Plasma Mobile session-bus backend (`org.kde.telephony.*`). | | `implementations/ofonod.cpp` | `imsd-ofonod`, the GNOME Calls system-bus backend (`org.ofono`). | | `tests//main.cpp` | The seven unit-test suites: `Util`, `Aka`, `Ipsec`, `Messages`, `Engine`, `Sip`, `Sdp`. | -| `packaging/` | systemd units, the bring-up and upgrade scripts, D-Bus and polkit policies, presets, the gschema override, the two APKBUILDs and the CI helpers (§9). | +| `packaging/` | systemd units, the bring-up and upgrade scripts, D-Bus and polkit policies, presets, the gschema override, `APKBUILD.binary` and the CI helpers (§9). | | `.forgejo/workflows/package.yml` | CI: builds the aarch64 apks on every push to `main` and publishes them to the Forgejo Alpine registry (§9.4). | -| `bin/`, `build/` | Build outputs (crafter-build and make respectively). Ignored. | +| `bin/`, `build/` | crafter-build's outputs and intermediates. Ignored. | Also ignored by `.gitignore`, and never to be committed: `*.raw`, `*.rtp`, `*.stats`, `imsreg.state`, `imsd-*.tar.gz` — captures and runtime state carry @@ -142,38 +141,9 @@ rpath into `/app/lib`: run it inside the sandbox (`flatpak run --command=sh net.catcrafts.CrafterBuild//stable`, with `--socket=session-bus` for §8.3's daemons — the sandbox's own session bus is a filter that refuses their names; `crafter-build test` runs the suites there -anyway), or build with `make` (§4.2) for one that runs on the host. A sysroot +anyway). A sysroot build (§4.3) gets no such rpath — it links the sysroot's libc++. -### 4.2 With make (distro packaging) - -GNU make, `clang++` with libc++ and the libc++ std module sources (`std.cppm`: -package `llvm-runtimes` on Alpine, `libc++` on Arch; override `STD_CPPM` if it -lives elsewhere), lld, pkg-config and the gio-2.0 headers. - -```sh -make # build/make/{imsd,imsd-media,imsd-dialerd,imsd-ofonod} -make check # the same seven suites -make install # DESTDIR/PREFIX staged install, including the packaging/ files -``` - -The Makefile forces `CXX = clang++` (abuild exports `CXX=c++`, which is gcc -and has no `--precompile`); an explicit `make CXX=…` still wins. One flag set -is used for every translation unit because BMIs are rejected on -compiler-option mismatch — per-target extras are limited to `-I`/`-D` and -`-l`/`-L`. From `pkg-config gio-2.0` only `-I`/`-D` and `-l`/`-L` pass -through: it also emits `-pthread`, which would config-mismatch the shared -`std` BMI (`project.cpp` filters identically; pthread lives in libc on every -platform targeted). The BMI file names must stay `-.pcm` — -that is how clang resolves `import :Part;` under `-fprebuilt-module-path`. - -`make install` places: the four binaries (`imsd-media` under `libexec`), -`ims-pdn-up.sh` and `imsd-handover.sh` under `libexec`, `imsd.service` and -`imsd-ofonod.service`, `imsd-dialerd.desktop` in `/etc/xdg/autostart`, the -D-Bus policies (`net.catcrafts.IMS1.conf`, `imsd-ofono.conf`), the polkit -policy, `imsd-ofono-restart-calls.sh`, the GNOME Calls gschema override and -the `80-imsd-ofono.preset`. - ### 4.3 Cross-compiling for a phone (aarch64 Alpine / postmarketOS) ```sh @@ -204,11 +174,11 @@ releases. This is the flow CI runs (§9.4). ## 5. Tests -`crafter-build test` and `make check` run the same seven suites. Each suite +`crafter-build test` runs the seven suites. Each suite is a plain `main()` with a `Check(bool, message)` counter: failures print `FAIL: …`, success prints `: all tests passed`, and the exit status is the failure count for six suites — non-zero short of a multiple of 256 — and -1 for `Engine`. Seven `all tests passed` lines from `make check`, on every +1 for `Engine`. `crafter-build test` ending in `7 passed`, on every commit of a range (§11), is the project's definition of green. What the suites pin: @@ -774,9 +744,7 @@ Phosh drives. The answered calls predate the polkit gate (§5). Setup: `apk add imsd-ofono` enables `imsd-ofonod.service` by preset and ships a gschema override setting `org.gnome.Calls autoload-plugins` to `['ofono']` (the per-user equivalent is `gsettings set org.gnome.Calls autoload-plugins -"['ofono']"`); a hand install (`make install`) enables the unit itself with -`systemctl enable --now imsd-ofonod` (it is a system service: `org.ofono` is -a system-bus name) and compiles the schema override. Drop `mm` rather than adding `ofono` alongside it: the +"['ofono']"`). Drop `mm` rather than adding `ofono` alongside it: the ModemManager provider would offer the modem's CS voice path as a second origin, and on a phone whose voice path is imsd that origin carries no audio. Never run it beside a real `ofonod` (one name, one owner; @@ -824,9 +792,8 @@ Known gaps, all inherited rather than introduced: (`imsd-ofono-restart-calls.sh`) restarts the Calls daemon of every user running one. The first start since boot leaves a running Calls alone. -Development without a phone or root (crafter-build's binaries inside the -sandbox, the `make` build's `build/make/imsd` and `build/make/imsd-ofonod` on -the host: §4.1): +Development without a phone or root (crafter-build's binaries run inside the +sandbox: §4.1): ```sh ./bin/imsd-*/imsd --session & # imsd's ABI on the session bus @@ -836,26 +803,19 @@ busctl --user call org.ofono / org.ofono.Manager GetModems ## 9. Packaging, CI and releases -### 9.1 The two APKBUILDs +### 9.1 The APKBUILD -- `packaging/APKBUILD.binary` is **the** packaging since 2026-09-02: it wraps - a crafter-build binary cross-compiled per §4.3 (bundled by - `packaging/make-bin-tarball.sh` together with the units, scripts and - policies) into the apks CI publishes. `pkgver` is taken from the `Version` - constant in `implementations/main.cpp` by `build-package.sh`. - Subpackages: `imsd-ofono` (the GNOME Calls backend, opt-in) and - `imsd-systemd` (the unit files; `install_if` systemd — ordered after - `imsd-ofono` because `systemd()` amoves all of `usr/lib/systemd/system`). -- `packaging/APKBUILD` is the from-source route through the Makefile (`make`, - `make check`, `make install`) for distros that build from source. It is - not the binary route's equal: no `imsd-systemd` subpackage and none of what - §9.2 lists for the apk (imsd's preset, the enable link, the condition - drop-in, the `/etc/skel` override), no `libc++` in `depends`; only the - `imsd-ofono` file list matches. Its `pkgver` is not bumped per release - (0.3.0) and its `source=` expects a `v$pkgver` tag archive, which the - repository has no tags for. +`packaging/APKBUILD.binary` is **the** packaging since 2026-09-02: it wraps +a crafter-build binary cross-compiled per §4.3 (bundled by +`packaging/make-bin-tarball.sh` together with the units, scripts and +policies) into the apks CI publishes. `pkgver` is taken from the `Version` +constant in `implementations/main.cpp` by `build-package.sh`. +Subpackages: `imsd-ofono` (the GNOME Calls backend, opt-in) and +`imsd-systemd` (the unit files; `install_if` systemd — ordered after +`imsd-ofono` because `systemd()` amoves all of `usr/lib/systemd/system`). +There is no from-source recipe: a build needs crafter-build (§4.1). -Both declare `provides="81voltd=$pkgver-r$pkgrel"`: it satisfies +It declares `provides="81voltd=$pkgver-r$pkgrel"`: this satisfies `soc-qcom-modem`'s dependency and excludes the real package, because 81voltd serves the modem's own ims-PDN requests, races imsd for the PDN and flaps it with a new prefix every ~2.5 min — two IMS stacks cannot share one PDN. @@ -870,10 +830,7 @@ unit's sequencing is only tested under systemd. `ConditionPathExists=/etc/imsd.env`, so VoLTE survives reboots without a manual `systemctl enable` and an unconfigured phone does not boot into a failing unit. `imsd-ofonod.service` is enabled the same way by `imsd-ofono`. -All of that is the apk's doing (`APKBUILD.binary`'s `package()`): `make -install` places the unit files and `80-imsd-ofono.preset` only, so a hand -install runs `systemctl enable imsd` (and `imsd-ofonod`) itself and gets no -`/etc/imsd.env` condition. +All of that is the apk's doing (`APKBUILD.binary`'s `package()`). The main package hides kde-telephony's `modem-daemon` autostart for accounts created after it is installed (`/etc/skel/.config/autostart/org.kde.modem.daemon.desktop`) because `imsd-dialerd` owns those names. @@ -959,9 +916,8 @@ only the imsd side of its bus choice. - **One change, one commit; a fix goes into the commit that introduced the problem** while a range is unpublished (rebase), never as a follow-up on top. Every commit of a range builds and passes the seven suites on its own. -- **Keep `project.cpp` and the Makefile in sync**, and the two APKBUILDs' - file lists with `make install`, `make-bin-tarball.sh` and - `APKBUILD.binary`'s `package()` when a packaged file is added. +- **Keep `make-bin-tarball.sh`'s file list and `APKBUILD.binary`'s + `package()` in sync** when a packaged file is added. - **The README and this file follow the code in the same commit.** A new knob gets its row in the README's configuration tables; a new state its journal line in §7.8; a new file under `/run` or `/var/lib` its row in §7.7. diff --git a/Makefile b/Makefile deleted file mode 100644 index be81add..0000000 --- a/Makefile +++ /dev/null @@ -1,170 +0,0 @@ -# SPDX-License-Identifier: GPL-3.0-only -# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® -# -# Distro-packaging build path. project.cpp (crafter-build) remains the -# canonical build description; this Makefile mirrors it for environments -# where crafter-build isn't packaged — same clang module pipeline, same -# flags. Keep the two in sync when products, module partitions or link -# flags change. -# -# make -> build/make/{imsd,imsd-media,imsd-dialerd,imsd-ofonod} -# make check -> build + run the 7 unit-test suites -# make install -> DESTDIR/PREFIX staged install (binaries + systemd -# units, D-Bus and polkit policies, dialerd autostart, the GNOME -# Calls plugin override, the imsd-ofono preset, and the libexec scripts -# ims-pdn-up.sh, imsd-handover.sh, imsd-ofono-restart-calls.sh) -# -# Requires clang++ with libc++ and the libc++ std module sources -# (std.cppm — Alpine: llvm-runtimes, Arch: libc++). Override STD_CPPM if -# yours lives elsewhere. - -# make's builtin default is CXX=g++, and distro build environments (abuild) -# export CXX=c++ — both gcc, which has no --precompile. This build requires -# clang: the file assignment deliberately ignores the environment while still -# yielding to an explicit `make CXX=...`. -CXX = clang++ -AR ?= ar -PKG_CONFIG ?= pkg-config -STD_CPPM ?= /usr/share/libc++/v1/std.cppm - -O ?= build/make -PCM = $(O)/pcm -OBJ = $(O)/obj - -OPT ?= -O3 -# One flag set for every TU: BMIs are rejected on compiler-option mismatch, -# so per-target extras are limited to -I/-D (compile) and -l/-L (link). -ALL_CXXFLAGS = -std=c++26 -stdlib=libc++ $(OPT) $(CXXFLAGS) -ALL_LDFLAGS = -fuse-ld=lld $(LDFLAGS) - -# pkg-config gio-2.0: only -I/-D pass through — it also emits -pthread, -# which would config-mismatch the shared std BMI (project.cpp filters -# identically). pthread lives in libc on every platform we target. -GIO_CFLAGS := $(filter -I% -D%,$(shell $(PKG_CONFIG) --cflags gio-2.0)) -GIO_LIBS := $(filter -l% -L%,$(shell $(PKG_CONFIG) --libs gio-2.0)) -ifeq ($(strip $(GIO_LIBS)),) -GIO_LIBS := -lgio-2.0 -lgobject-2.0 -lglib-2.0 -endif - -# Imsd module partitions, plus the primary interface last. The BMI file -# names must stay -.pcm — that is how clang resolves -# `import :Part;` under -fprebuilt-module-path. -PARTITIONS = Imsd-Util Imsd-Sip Imsd-Sdp Imsd-Aka Imsd-Ipsec \ - Imsd-Messages Imsd-Engine -CORE_MODULES = $(PARTITIONS) Imsd -CORE_OBJS = $(CORE_MODULES:%=$(OBJ)/%.o) - -TESTS = Util Aka Ipsec Messages Engine Sip Sdp - -all: $(O)/imsd $(O)/imsd-media $(O)/imsd-dialerd $(O)/imsd-ofonod - -# ---- module BMIs ---------------------------------------------------------- - -$(PCM)/std.pcm: $(STD_CPPM) | $(PCM) - $(CXX) $(ALL_CXXFLAGS) -Wno-reserved-identifier \ - -Wno-reserved-module-identifier --precompile $< -o $@ - -$(PCM)/%.pcm: interfaces/%.cppm $(PCM)/std.pcm | $(PCM) - $(CXX) $(ALL_CXXFLAGS) -fprebuilt-module-path=$(PCM) --precompile $< -o $@ - -# partition import graph (see the `import :X;` lines in interfaces/) -$(PCM)/Imsd-Aka.pcm $(PCM)/Imsd-Ipsec.pcm: $(PCM)/Imsd-Util.pcm -$(PCM)/Imsd-Messages.pcm: $(PCM)/Imsd-Util.pcm $(PCM)/Imsd-Aka.pcm \ - $(PCM)/Imsd-Sip.pcm -$(PCM)/Imsd-Engine.pcm: $(PCM)/Imsd-Util.pcm $(PCM)/Imsd-Sip.pcm \ - $(PCM)/Imsd-Sdp.pcm $(PCM)/Imsd-Messages.pcm -$(PCM)/Imsd.pcm: $(PARTITIONS:%=$(PCM)/%.pcm) - -# ImsdBus: the polkit gate imsd and imsd-ofonod share — GLib, so outside -# imsd-core, and its BMI needs the gio include paths. -$(PCM)/ImsdBus.pcm: interfaces/ImsdBus.cppm $(PCM)/std.pcm | $(PCM) - $(CXX) $(ALL_CXXFLAGS) $(GIO_CFLAGS) -fprebuilt-module-path=$(PCM) \ - --precompile $< -o $@ - -# ---- imsd-core (static lib of the pure-C++ engine modules) ---------------- - -$(OBJ)/%.o: $(PCM)/%.pcm | $(OBJ) - $(CXX) $(ALL_CXXFLAGS) -fprebuilt-module-path=$(PCM) -c $< -o $@ - -$(O)/libimsd-core.a: $(CORE_OBJS) - $(AR) rcs $@ $^ - -# ---- executables ----------------------------------------------------------- - -$(OBJ)/main.o: implementations/main.cpp $(PCM)/Imsd.pcm $(PCM)/ImsdBus.pcm | $(OBJ) - $(CXX) $(ALL_CXXFLAGS) $(GIO_CFLAGS) -fprebuilt-module-path=$(PCM) \ - -c $< -o $@ - -$(O)/imsd: $(OBJ)/main.o $(OBJ)/ImsdBus.o $(O)/libimsd-core.a - $(CXX) $(ALL_CXXFLAGS) $(ALL_LDFLAGS) $^ $(GIO_LIBS) -lpthread -o $@ - -$(OBJ)/media.o: implementations/media.cpp $(PCM)/std.pcm | $(OBJ) - $(CXX) $(ALL_CXXFLAGS) -fprebuilt-module-path=$(PCM) -c $< -o $@ - -$(O)/imsd-media: $(OBJ)/media.o - $(CXX) $(ALL_CXXFLAGS) $(ALL_LDFLAGS) $^ -ldl -lpthread -o $@ - -$(OBJ)/dialerd.o: implementations/dialerd.cpp $(PCM)/std.pcm | $(OBJ) - $(CXX) $(ALL_CXXFLAGS) $(GIO_CFLAGS) -fprebuilt-module-path=$(PCM) \ - -c $< -o $@ - -$(O)/imsd-dialerd: $(OBJ)/dialerd.o - $(CXX) $(ALL_CXXFLAGS) $(ALL_LDFLAGS) $^ $(GIO_LIBS) -o $@ - -$(OBJ)/ofonod.o: implementations/ofonod.cpp $(PCM)/ImsdBus.pcm | $(OBJ) - $(CXX) $(ALL_CXXFLAGS) $(GIO_CFLAGS) -fprebuilt-module-path=$(PCM) \ - -c $< -o $@ - -$(O)/imsd-ofonod: $(OBJ)/ofonod.o $(OBJ)/ImsdBus.o - $(CXX) $(ALL_CXXFLAGS) $(ALL_LDFLAGS) $^ $(GIO_LIBS) -o $@ - -# ---- tests ----------------------------------------------------------------- - -$(O)/tests/%: tests/%/main.cpp $(PCM)/Imsd.pcm $(O)/libimsd-core.a | $(O)/tests - $(CXX) $(ALL_CXXFLAGS) $(ALL_LDFLAGS) -fprebuilt-module-path=$(PCM) \ - $< $(O)/libimsd-core.a -lpthread -o $@ - -check: $(TESTS:%=$(O)/tests/%) - @fail=0; for t in $(TESTS); do \ - echo "== $$t"; $(O)/tests/$$t || fail=1; \ - done; exit $$fail - -# ---- install ---------------------------------------------------------------- - -PREFIX ?= /usr - -install: all - install -Dm755 $(O)/imsd $(DESTDIR)$(PREFIX)/bin/imsd - install -Dm755 $(O)/imsd-dialerd $(DESTDIR)$(PREFIX)/bin/imsd-dialerd - install -Dm755 $(O)/imsd-ofonod $(DESTDIR)$(PREFIX)/bin/imsd-ofonod - install -Dm755 $(O)/imsd-media $(DESTDIR)$(PREFIX)/libexec/imsd-media - install -Dm755 packaging/ims-pdn-up.sh \ - $(DESTDIR)$(PREFIX)/libexec/ims-pdn-up.sh - install -Dm755 packaging/imsd-handover.sh \ - $(DESTDIR)$(PREFIX)/libexec/imsd-handover.sh - install -Dm644 packaging/imsd.service \ - $(DESTDIR)$(PREFIX)/lib/systemd/system/imsd.service - install -Dm644 packaging/imsd-dialerd.desktop \ - $(DESTDIR)/etc/xdg/autostart/imsd-dialerd.desktop - install -Dm644 packaging/net.catcrafts.IMS1.conf \ - $(DESTDIR)$(PREFIX)/share/dbus-1/system.d/net.catcrafts.IMS1.conf - install -Dm644 packaging/net.catcrafts.IMS1.policy \ - $(DESTDIR)$(PREFIX)/share/polkit-1/actions/net.catcrafts.IMS1.policy - install -Dm644 packaging/imsd-ofonod.service \ - $(DESTDIR)$(PREFIX)/lib/systemd/system/imsd-ofonod.service - install -Dm755 packaging/imsd-ofono-restart-calls.sh \ - $(DESTDIR)$(PREFIX)/libexec/imsd-ofono-restart-calls.sh - install -Dm644 packaging/org.ofono.conf \ - $(DESTDIR)$(PREFIX)/share/dbus-1/system.d/imsd-ofono.conf - install -Dm644 packaging/90_imsd-ofono.gschema.override \ - $(DESTDIR)$(PREFIX)/share/glib-2.0/schemas/90_imsd-ofono.gschema.override - install -Dm644 packaging/80-imsd-ofono.preset \ - $(DESTDIR)$(PREFIX)/lib/systemd/system-preset/80-imsd-ofono.preset - -$(PCM) $(OBJ) $(O)/tests: - mkdir -p $@ - -clean: - rm -rf $(O) - -.PHONY: all check install clean diff --git a/packaging/APKBUILD b/packaging/APKBUILD deleted file mode 100644 index ca32436..0000000 --- a/packaging/APKBUILD +++ /dev/null @@ -1,58 +0,0 @@ -# SPDX-License-Identifier: GPL-3.0-only -# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® -# Maintainer: Jorijn van der Graaf -pkgname=imsd -pkgver=0.3.0 -pkgrel=0 -pkgdesc="Userspace IMS/VoLTE daemon for mainline Linux phones" -url="https://forgejo.catcrafts.net/Catcrafts/imsd" -arch="aarch64 x86_64" -license="GPL-3.0-only" -# the media leg dlopen's the AMR-WB codecs; pw-record/pw-play drive PipeWire — -# none of which abuild's .so auto-scan can see; polkit answers the daemons' -# call-control check (ImsdBus) -depends="modemmanager opencore-amr vo-amrwbenc pipewire-tools polkit" -# clang/libc++ C++26 modules build (see Makefile); llvm-runtimes ships the -# libc++ std module sources (/usr/share/libc++/v1/std.cppm) it precompiles -makedepends="clang lld libc++-dev llvm-libunwind-dev llvm-runtimes glib-dev pkgconf" -# the versioned provides both satisfies soc-qcom-modem's 81voltd dependency -# and excludes the real package: 81voltd serves the modem's own ims-PDN -# requests, which races imsd for the PDN and flaps it with a new prefix every -# ~2.5 min — the two IMS stacks cannot share one PDN -provides="81voltd=$pkgver-r$pkgrel" -# imsd-ofono is opt-in: it carries the org.ofono shim that lets GNOME Calls -# (Phosh, GNOME Mobile) drive imsd, plus the gschema override that repoints -# Calls at the ofono provider. A Plasma Mobile phone wants imsd-dialerd, which -# is in the main package, and must NOT get that override. -subpackages="$pkgname-ofono:ofono" -# restart the running daemons onto an upgrade (systemd's enqueue-marked) -install="$pkgname.post-upgrade $pkgname.post-install $pkgname-ofono.post-upgrade $pkgname-ofono.post-install" -source="$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz" -builddir="$srcdir/$pkgname" - -build() { - make -} - -check() { - make check -} - -package() { - make install DESTDIR="$pkgdir" -} - -ofono() { - pkgdesc="GNOME Calls (Phosh, GNOME Mobile) backend for imsd" - depends="$pkgname=$pkgver-r$pkgrel calls" - amove usr/bin/imsd-ofonod - amove usr/libexec/imsd-ofono-restart-calls.sh - amove usr/lib/systemd/system/imsd-ofonod.service - amove usr/share/dbus-1/system.d/imsd-ofono.conf - amove usr/share/glib-2.0/schemas/90_imsd-ofono.gschema.override - amove usr/lib/systemd/system-preset/80-imsd-ofono.preset - # enabled on install, like imsd.service in the main package - mkdir -p "$subpkgdir"/etc/systemd/system/multi-user.target.wants - ln -s /usr/lib/systemd/system/imsd-ofonod.service \ - "$subpkgdir"/etc/systemd/system/multi-user.target.wants/imsd-ofonod.service -} From af5b98ec8bdcc00b148815b50bf9c03116cad829 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Tue, 6 Oct 2026 16:45:44 +0200 Subject: [PATCH 13/15] project.cpp: take the lint rules from Crafter.LintRules lint-rules.h was a project-local copy of the Catcrafts house-style rules. The rules have their own repository now, and project.cpp imports them from it (`#pragma crafter dependency`), as Crafter.Build's own project.cpp does. The dependency is pinned to a commit; crafter-build clones it whenever it loads project.cpp, so every build fetches it, CI's included. The tree does not pass the shared rules yet: `crafter-build lint` reports 72 findings where the copy reported 20. The shared `naming` wants camelCase constants and statics (44), `const-local` is new (10), and `single-declaration` reports two files the copy did not. They are left for a later change; CI does not run lint. Tested: build-package.sh run end to end on this commit in a privileged alpine:edge container (podman) on the workstation, and `crafter-build lint` on a built copy of this commit. --- AGENTS.md | 62 ++-- lint-rules.h | 858 --------------------------------------------------- project.cpp | 11 +- 3 files changed, 47 insertions(+), 884 deletions(-) delete mode 100644 lint-rules.h diff --git a/AGENTS.md b/AGENTS.md index 28af44c..216170d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,6 @@ PipeWire. The verified device/carrier combinations are in the README. | Path | What it is | |---|---| | `project.cpp` | The canonical build description, for [Crafter Build](https://forgejo.catcrafts.net/Catcrafts/Crafter.Build). Chooses the product by `--product=`. | -| `lint-rules.h` | The house-style lint rules (§6), registered from `project.cpp` through `AddProjectLintRules`. Project-local on purpose. | | `interfaces/Imsd.cppm` | The primary module interface of `imsd-core`: `export import`s the seven partitions below. | | `interfaces/Imsd-Util.cppm` | `imsd::util` — SIP tokens, IPv6 host/port formatting, hex/base64, MD5 (RFC 1321), the fixed protected ports `PortUc`/`PortUs` (45061/45062). | | `interfaces/Imsd-Sip.cppm` | `imsd::sip` — SIP as a text layer plus TCP framing: header extraction, status codes, the granted lifetime, `ResponseOf` transaction matching. Not a full RFC 3261 parser, deliberately. | @@ -130,7 +129,7 @@ crafter-build -- --product=media # bin/imsd-media-…/imsd-media crafter-build -- --product=dialerd # bin/imsd-dialerd-…/imsd-dialerd crafter-build -- --product=ofonod # bin/imsd-ofonod-…/imsd-ofonod crafter-build test # the seven unit-test suites -crafter-build lint # the house-style rules of lint-rules.h +crafter-build lint # the house-style rules (§4.2, §6) ``` Output directories under `bin/` are named `----`; @@ -144,6 +143,33 @@ daemons — the sandbox's own session bus is a filter that refuses their names; anyway). A sysroot build (§4.3) gets no such rpath — it links the sysroot's libc++. +### 4.2 The lint rules, a dependency of project.cpp + +`project.cpp` imports the house-style rules (§6) from their own repository: + +```cpp +#pragma crafter dependency "https://forgejo.catcrafts.net/Catcrafts/Crafter.LintRules.git" commit "" +import Crafter.LintRules; +``` + +crafter-build clones that repository — and updates the clone on later loads — +and builds it for the host whenever it loads `project.cpp`: every build does +this, CI's included, not only `lint`. The commit is pinned, so a change to +the rules reaches imsd when the pin moves. `LintRules::AddRules` registers +the rules on each product's configuration. + +```sh +crafter-build lint # report every finding; exit 1 on any +crafter-build lint 'naming' # one rule (the argument is a glob) +crafter-build format # apply the transform rules in place +``` + +Six rules read the AST (`naming`, `no-char-pointer`, `const-local`, +`constexpr-constant`, `fixed-width-types`, `single-declaration`) and need the +module PCMs: build the four programs and the tests first. With crafter-build +2.1.0 they still do not run on 2 of the 18 files, `implementations/main.cpp` +among them (`'type_traits' file not found`). + ### 4.3 Cross-compiling for a phone (aarch64 Alpine / postmarketOS) ```sh @@ -277,26 +303,20 @@ Rules: ## 6. Code style and lint -`crafter-build lint` reports the rules of `lint-rules.h`; `crafter-build format` -applies the transform rules. The rules, by name: +The rules are the Catcrafts house style, +[Crafter.LintRules](https://forgejo.catcrafts.net/Catcrafts/Crafter.LintRules), +pulled in by `project.cpp` (§4.2); that repository's README lists the +seventeen of them. `// lint-disable-file ` (by convention on line 4, +after the SPDX header and a blank line) and `// lint-disable-next-line ` +suppress a rule where a file needs it; nearly every source file disables +`fixed-width-types`. -| Rule | Meaning | -|---|---| -| `spdx-header` | Line 1 `// SPDX-License-Identifier: …`, line 2 `// SPDX-FileCopyrightText: …`, line 3 blank. Line 4 may carry `// lint-disable-file …` where a file needs one. | -| `no-tabs` | Spaces only. | -| `naming` | Types, aliases, functions, statics, namespace-scope globals and `constexpr` constants are PascalCase; locals and parameters camelCase; one trailing underscore marks a member or shadows a keyword (`label_`, `requires_`). Heuristic, report-only. | -| `enum-class` | `enum class`, never a plain `enum`. | -| `no-iostream-print` | `std::println` (and `std::println(std::cerr, …)`), never `std::cout` / `<<` to `std::cerr`. | -| `no-char-pointer` | `std::string` / `std::string_view` over `char*`. C interop stays: argv, getenv/setenv, dlerror, `c_str()`/`.data()` call sites, `reinterpret_cast`, `extern "C"`. | -| `format-concat` | `std::format` instead of `+` with a string literal; simple single-line chains are rewritten automatically, anything else is reported. | -| `fixed-width-types` | `std::int32_t` and friends instead of `short`/`int`/`long` (transform); lines with `main`/`argc`/`argv` or `extern "C"` keep C ints. Nearly every source file disables this one on line 4. | -| `single-declaration` | One declaration per statement; `Type a = x, b = y;` is auto-split, declarators with pointers, references, parens or templates are reported. | -| `brace-style` | K&R: a `{` alone on a line after a `)`/`else`/`do`/`try` header joins the header line. Standalone scope blocks stay. | -| `if-single-line` | A single-statement `if` body joins the `if` line when the result stays within 250 columns and neither line carries a comment. | -| `wrap-join` | Wrapped call arguments and `&&`/`||`/`|` continuations join back onto one line when the expression fits in 250 columns. | -| `paren-spacing` | No padding inside parentheses. | -| `trim-trailing-ws` | No trailing whitespace. | -| `final-newline` | The file ends with a newline: one is appended when it is missing. | +The tree was written under an earlier, project-local copy of the rules and +does not pass the shared ones yet: `crafter-build lint` reports 72 findings +(2026-10-06) — `naming` 44 (constants and statics are PascalCase here, the +shared rule wants camelCase), `const-local` 10 (a rule the copy did not +have), `single-declaration` 2 (not reported under the copy), `format-concat` +13 and `wrap-join` 3 (as under the copy). CI does not run lint. Beyond the rules: diff --git a/lint-rules.h b/lint-rules.h deleted file mode 100644 index 12461fd..0000000 --- a/lint-rules.h +++ /dev/null @@ -1,858 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-only -// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® -#pragma once - -// This repo's house-style lint rules. Deliberately project-local — the rule -// set is NOT part of the crafter-build library, so downstream consumers are -// never nudged toward one team's style. Include from project.cpp AFTER -// `import std;` and `import Crafter.Build;` (the header uses both and has no -// includes of its own), then call AddProjectLintRules(cfg). -// -// Report-only rules are registered first so their file:line numbers always -// match the on-disk file; transforms run last because each one sees the -// previous transform's output, which can shift line numbers. -// -// All detection runs on ctx.CommentStripped() — comments and string/char -// literal bodies are blanked to spaces with newlines preserved, so byte -// offsets and line numbers in the stripped text are valid in ctx.content. -// Transforms use that property directly: find in stripped, edit in content. - -namespace ProjectLint { - -inline bool IsCppFile(const Crafter::LintContext& ctx) { - std::string ext = ctx.file.extension().string(); - return ext == ".cpp" || ext == ".cppm" || ext == ".h"; -} - -inline std::vector Lines(std::string_view text) { - std::vector lines; - std::size_t start = 0; - while (start <= text.size()) { - std::size_t end = text.find('\n', start); - if (end == std::string_view::npos) { - if (start < text.size()) lines.push_back(text.substr(start)); - break; - } - lines.push_back(text.substr(start, end - start)); - start = end + 1; - } - return lines; -} - -inline std::string_view Trim(std::string_view s) { - while (!s.empty() && (s.front() == ' ' || s.front() == '\t' || s.front() == '\r')) s.remove_prefix(1); - while (!s.empty() && (s.back() == ' ' || s.back() == '\t' || s.back() == '\r')) s.remove_suffix(1); - return s; -} - -inline bool IsWordChar(char c) { - return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'; -} - -// Net '(' minus ')' on one stripped line. -inline std::int64_t ParenDelta(std::string_view s) { - std::int64_t d = 0; - for (char c : s) { - if (c == '(') ++d; - if (c == ')') --d; - } - return d; -} - -inline bool IsPascalCase(std::string_view name) { - return !name.empty() && name[0] >= 'A' && name[0] <= 'Z' && !name.contains('_'); -} - -inline bool IsCamelCase(std::string_view name) { - // One trailing underscore is the member-shadowing-a-keyword convention - // (requires_, label_) — allowed. - if (name.ends_with('_')) name.remove_suffix(1); - return !name.empty() && ((name[0] >= 'a' && name[0] <= 'z') || name[0] == '_') && name.find('_', 1) == std::string_view::npos; -} - -// The identifier ending right before position `pos` (exclusive), or empty. -inline std::string_view WordBefore(std::string_view s, std::size_t pos) { - std::size_t end = pos; - while (end > 0 && (s[end - 1] == ' ' || s[end - 1] == '\t')) --end; - std::size_t begin = end; - while (begin > 0 && (IsWordChar(s[begin - 1]) || s[begin - 1] == '~')) --begin; - return s.substr(begin, end - begin); -} - -inline void AddProjectLintRules(Crafter::Configuration& cfg) { - using Crafter::LintContext; - - // ---------------- report-only rules ---------------- - - // License header: SPDX identifier on line 1, copyright on line 2, blank - // line 3. - cfg.AddLintRule("spdx-header", [](LintContext& ctx) { - if (!IsCppFile(ctx)) return; - if (!ctx.Line(1).starts_with("// SPDX-License-Identifier:")) { - ctx.Report(1, "first line must be // SPDX-License-Identifier: ..."); - } - if (!ctx.Line(2).starts_with("// SPDX-FileCopyrightText:")) { - ctx.Report(2, "second line must be // SPDX-FileCopyrightText: ..."); - } - if (ctx.lines.size() >= 3 && !Trim(ctx.Line(3)).empty()) { - ctx.Report(3, "third line must be blank (separates the license header)"); - } - }); - - cfg.AddLintRule("no-tabs", [](LintContext& ctx) { - if (!IsCppFile(ctx)) return; - for (std::size_t n = 1; n <= ctx.lines.size(); ++n) { - if (ctx.Line(n).contains('\t')) ctx.Report(n, "tab character (use spaces)"); - } - }); - - // Naming: functions/types PascalCase, variables camelCase, statics and - // namespace-scope globals PascalCase. A line-based scope tracker decides - // whether a declaration sits at namespace scope (global) or inside a - // function/type (local/member). Heuristic by nature — report-only. - cfg.AddLintRule("naming", [](LintContext& ctx) { - if (!IsCppFile(ctx)) return; - std::vector lines = Lines(ctx.CommentStripped()); - - enum class Scope { Namespace, Type, Function, Other }; - std::vector stack; - auto currentScope = [&]() { return stack.empty() ? Scope::Namespace : stack.back(); }; - - static const std::regex typeDecl(R"(\b(?:class|struct|union)\s+(?:CRAFTER_API\s+)?([A-Za-z_]\w*))"); - static const std::regex enumDecl(R"(\benum\s+(?:class\s+|struct\s+)?([A-Za-z_]\w*))"); - static const std::regex usingDecl(R"(^\s*using\s+([A-Za-z_]\w*)\s*=)"); - static const std::regex varDecl( - R"(^\s*((?:static|constexpr|const|inline|mutable|thread_local|export|CRAFTER_API)\s+)*)" - R"((?:std::)?[A-Za-z_][\w:]*(?:<[^;={]*>)?(?:\s*[&*])*\s+([A-Za-z_]\w*)\s*(=|;|\{))"); - static const std::unordered_set keywords = { - "if", "for", "while", "switch", "catch", "return", "else", "do", "case", "goto", - "new", "delete", "throw", "using", "namespace", "template", "typedef", "friend", - "public", "private", "protected", "class", "struct", "enum", "union", "import", - "module", "export", "break", "continue", "co_return", "co_await", "co_yield", - "sizeof", "alignof", "decltype", "static_assert", "operator", "try", "requires", - }; - - // Cumulative paren depth at the start of each line: declaration and - // function checks only run at depth 0, so wrapped parameter lists and - // continuation lines (`) {` closers) never look like declarations. - std::int64_t parenDepth = 0; - for (std::size_t i = 0; i < lines.size(); ++i) { - std::string_view trimmed = Trim(lines[i]); - std::string lineStr(lines[i]); - std::smatch m; - bool atDepth0 = parenDepth == 0; - parenDepth = std::max(0, parenDepth + ParenDelta(lines[i])); - - if (!trimmed.starts_with('#') && atDepth0) { - // Type / alias names must be PascalCase. - if (std::regex_search(lineStr, m, typeDecl) || std::regex_search(lineStr, m, enumDecl)) { - std::string name = m[1].str(); - if (!keywords.contains(name) && !IsPascalCase(name)) { - ctx.Report(i + 1, std::format("type '{}' should be PascalCase", name)); - } - } - if (std::regex_search(lineStr, m, usingDecl) && !IsPascalCase(m[1].str())) { - ctx.Report(i + 1, std::format("type alias '{}' should be PascalCase", m[1].str())); - } - - // Function definitions: identifier before the first '(' on a - // line that ends where a body opens or closes — a multi-line - // def's trailing '{' or a one-liner's trailing '}'. Statement - // calls with inline lambda arguments look similar - // (`bool x = std::any_of(..., [](T v) { ... });`) but end in - // ';' and/or carry '=' before the name — both excluded. - // Lambdas ("](") and control keywords are skipped; ctors/ - // dtors pass the Pascal check by construction; `main` and - // operators exempt. - std::size_t bodyBrace = trimmed.find('{'); - bool functionShaped = trimmed.ends_with('{') || trimmed.ends_with('}'); - if (functionShaped && currentScope() != Scope::Function && !trimmed.starts_with("return")) { - std::size_t paren = trimmed.find('('); - if (paren != std::string_view::npos && paren > 0 && paren < bodyBrace) { - std::string_view name = WordBefore(trimmed, paren); - char before = trimmed[paren - 1]; - bool looksLikeDef = !name.empty() && IsWordChar(before) - && !keywords.contains(name) && name != "main" - && !name.starts_with('~'); - // Require a type token before the name (or a - // qualified Class::Name), with no '=' in front — - // plain calls and initializations don't match. - if (looksLikeDef) { - std::size_t nameStart = trimmed.rfind(name, paren); - std::string_view prefix = Trim(trimmed.substr(0, nameStart)); - looksLikeDef = !prefix.empty() && !prefix.contains('=') - && (IsWordChar(prefix.back()) || prefix.back() == '>' - || prefix.back() == '*' || prefix.back() == '&' - || prefix.ends_with("::")); - } - if (looksLikeDef && !IsPascalCase(name)) { - ctx.Report(i + 1, std::format("function '{}' should be PascalCase", name)); - } - } - } - - // Variable declarations: camelCase locally, PascalCase for - // statics and namespace-scope globals. - if (std::regex_search(lineStr, m, varDecl)) { - std::string name = m[2].str(); - std::string_view typeToken = Trim(std::string_view(lineStr).substr(0, static_cast(m.position(2)))); - std::string_view firstWord = typeToken.substr(0, typeToken.find_first_of(" \t<")); - if (!keywords.contains(firstWord) && !keywords.contains(name)) { - bool isStatic = lineStr.contains("static "); - // constexpr variables are compile-time constants — - // constant naming (PascalCase) like statics/globals. - bool isConstexpr = lineStr.contains("constexpr "); - bool global = currentScope() == Scope::Namespace; - if ((isStatic || global || isConstexpr) && !IsPascalCase(name)) { - ctx.Report(i + 1, std::format("{} '{}' should be PascalCase", - isStatic ? "static variable" - : isConstexpr ? "constexpr constant" - : "global variable", name)); - } else if (!isStatic && !global && !isConstexpr && !IsCamelCase(name)) { - ctx.Report(i + 1, std::format("variable '{}' should be camelCase", name)); - } - } - } - } - - // Scope tracking: classify each '{' opened on this line; pop on '}'. - for (std::size_t c = 0; c < lines[i].size(); ++c) { - if (lines[i][c] == '{') { - Scope kind = Scope::Other; - std::string_view upTo = Trim(lines[i].substr(0, c)); - if (trimmed.starts_with("namespace") || upTo.contains("namespace ")) { - kind = Scope::Namespace; - } else if (std::regex_search(lineStr, typeDecl) || std::regex_search(lineStr, enumDecl)) { - kind = Scope::Type; - } else if (upTo.ends_with(')') || upTo.ends_with("const") || upTo.ends_with("noexcept") - || upTo.ends_with("->") || trimmed.starts_with("extern")) { - kind = Scope::Function; // function/lambda/control body — all non-global - } - stack.push_back(kind); - } else if (lines[i][c] == '}') { - if (!stack.empty()) stack.pop_back(); - } - } - } - }); - - cfg.AddLintRule("enum-class", [](LintContext& ctx) { - if (!IsCppFile(ctx)) return; - std::vector lines = Lines(ctx.CommentStripped()); - static const std::regex plainEnum(R"(\benum\s+(?!class\b|struct\b)[A-Za-z_])"); - for (std::size_t i = 0; i < lines.size(); ++i) { - std::string lineStr(lines[i]); - if (std::regex_search(lineStr, plainEnum)) { - ctx.Report(i + 1, "use enum class instead of plain enum"); - } - } - }); - - cfg.AddLintRule("no-iostream-print", [](LintContext& ctx) { - if (!IsCppFile(ctx)) return; - std::vector lines = Lines(ctx.CommentStripped()); - for (std::size_t i = 0; i < lines.size(); ++i) { - if (lines[i].contains("std::cout")) { - ctx.Report(i + 1, "use std::println instead of std::cout"); - } else if (lines[i].contains("std::cerr") && lines[i].contains("<<")) { - ctx.Report(i + 1, "use std::println(std::cerr, ...) instead of streaming to std::cerr"); - } - } - }); - - // Prefer std::string / std::string_view over char*. OS interop stays: - // getenv returns char*, argv is char**, extern "C" prototypes mirror C. - cfg.AddLintRule("no-char-pointer", [](LintContext& ctx) { - if (!IsCppFile(ctx)) return; - std::vector lines = Lines(ctx.CommentStripped()); - static const std::regex charPtr(R"(\bchar\s*\*)"); - for (std::size_t i = 0; i < lines.size(); ++i) { - std::string lineStr(lines[i]); - // C-interop stays char*: argv, getenv/setenv, dlerror, C APIs fed - // by c_str()/data(), binary IO reinterpret_casts, extern "C" - // prototypes. (extern " matches with the literal body blanked.) - if (lineStr.contains("argv") || lineStr.contains("getenv") || lineStr.contains("setenv") - || lineStr.contains("dlerror") || lineStr.contains("c_str") || lineStr.contains(".data(") - || lineStr.contains("reinterpret_cast") || lineStr.contains("extern \"")) continue; - if (std::regex_search(lineStr, charPtr)) { - ctx.Report(i + 1, "prefer std::string / std::string_view over char*"); - } - } - }); - - // String building via `+` with a literal operand → std::format. Plain - // `a += b` accumulation (builder pattern) stays legal. Single-line chains - // of simple operands (identifier / member / call chains, parenthesized - // groups, literals) are REWRITTEN automatically; anything the operand - // scanner can't prove safe — raw-string lines, ternaries, mixed - // operators, multi-line expressions — is reported for a human instead. - cfg.AddLintRule("format-concat", [](LintContext& ctx) { - if (!IsCppFile(ctx)) return; - const std::string& code = ctx.CommentStripped(); - std::vector stripped = Lines(code); - - // Walk one operand leftwards from `from` (exclusive). Returns the - // operand's begin, or npos when the shape isn't a simple postfix - // chain (then the whole chain bails to a report). - auto operandLeft = [](std::string_view s, std::size_t from) -> std::size_t { - std::size_t i = from; - while (i > 0 && (s[i - 1] == ' ' || s[i - 1] == '\t')) --i; - bool any = false; - for (;;) { - if (i == 0) break; - char c = s[i - 1]; - if (c == ')' || c == ']') { - char open = c == ')' ? '(' : '['; - std::size_t depth = 0; - do { - --i; - if (s[i] == c) ++depth; - if (s[i] == open) --depth; - if (depth == 0) break; - } while (i > 0); - if (depth != 0) return std::string_view::npos; - any = true; - // A group directly preceded by an identifier (or another - // group) is a call/index postfix — keep consuming the - // callee: `path.string()` is ONE operand, not `()`. - if (i > 0 && (IsWordChar(s[i - 1]) || s[i - 1] == ')' || s[i - 1] == ']')) continue; - } else if (c == '"') { - --i; // closing quote; interior is blanked, find the opener - while (i > 0 && s[i - 1] != '"') --i; - if (i == 0) return std::string_view::npos; - --i; - any = true; - } else if (IsWordChar(c)) { - while (i > 0 && IsWordChar(s[i - 1])) --i; - any = true; - } else if (c == '.' && any) { - --i; - continue; - } else if (c == ':' && i > 1 && s[i - 2] == ':' && any) { - i -= 2; - continue; - } else if (c == '>' && i > 1 && s[i - 2] == '-' && any) { - i -= 2; - continue; - } else { - break; - } - // After a primary, only connectors continue the operand. - if (i > 0 && (s[i - 1] == '.' || (s[i - 1] == ':' && i > 1 && s[i - 2] == ':') - || (s[i - 1] == '>' && i > 1 && s[i - 2] == '-'))) continue; - break; - } - if (!any) return std::string_view::npos; - // A unary operator, ternary, or other-precedence operator in - // front means expression structure we don't reason about — bail. - std::size_t b = i; - while (b > 0 && (s[b - 1] == ' ' || s[b - 1] == '\t')) --b; - if (b > 0 && std::string_view("!*&~-?:<>/%^|").contains(s[b - 1])) return std::string_view::npos; - return i; - }; - - // Walk one operand rightwards from `from` (inclusive). Returns one - // past the operand's end, or npos on bail. - auto operandRight = [](std::string_view s, std::size_t from) -> std::size_t { - std::size_t i = from; - while (i < s.size() && (s[i] == ' ' || s[i] == '\t')) ++i; - bool any = false; - for (;;) { - if (i >= s.size()) break; - char c = s[i]; - if (c == '(' || c == '[') { - char close = c == '(' ? ')' : ']'; - std::size_t depth = 0; - while (i < s.size()) { - if (s[i] == c) ++depth; - if (s[i] == close && --depth == 0) { ++i; break; } - ++i; - } - if (depth != 0) return std::string_view::npos; - any = true; - } else if (c == '"') { - ++i; - while (i < s.size() && s[i] != '"') ++i; - if (i >= s.size()) return std::string_view::npos; - ++i; - any = true; - } else if (IsWordChar(c)) { - while (i < s.size() && IsWordChar(s[i])) ++i; - any = true; - } else if (any && c == '.') { - ++i; - continue; - } else if (any && c == ':' && i + 1 < s.size() && s[i + 1] == ':') { - i += 2; - continue; - } else if (any && c == '-' && i + 1 < s.size() && s[i + 1] == '>') { - i += 2; - continue; - } else { - break; - } - if (i < s.size() && (s[i] == '(' || s[i] == '[' || s[i] == '.' - || (s[i] == ':' && i + 1 < s.size() && s[i + 1] == ':') - || (s[i] == '-' && i + 1 < s.size() && s[i + 1] == '>'))) continue; - break; - } - return any ? i : std::string_view::npos; - }; - - struct Edit { std::size_t begin; std::size_t end; std::string replacement; }; - std::vector edits; // offsets into ctx.content - std::size_t lineStart = 0; - for (std::size_t li = 0; li < stripped.size(); ++li) { - std::string_view line = stripped[li]; - std::size_t lineOff = lineStart; - lineStart += line.size() + 1; - std::string_view trimmed = Trim(line); - if (trimmed.starts_with('#')) continue; - // Raw strings defeat the comment stripper's quote tracking; any - // literal-+ pattern on such a line is a manual fix. - bool hasRaw = std::string_view(ctx.content).substr(lineOff, line.size()).contains("R\""); - - std::size_t searchFrom = 0; - while (searchFrom < line.size()) { - // A candidate `+` that isn't ++ / += and touches a literal. - std::size_t plus = line.find('+', searchFrom); - if (plus == std::string_view::npos) break; - searchFrom = plus + 1; - if (plus + 1 < line.size() && (line[plus + 1] == '+' || line[plus + 1] == '=')) { ++searchFrom; continue; } - if (plus > 0 && line[plus - 1] == '+') continue; - std::size_t leftEnd = plus; - while (leftEnd > 0 && (line[leftEnd - 1] == ' ' || line[leftEnd - 1] == '\t')) --leftEnd; - std::size_t rightBegin = plus + 1; - while (rightBegin < line.size() && (line[rightBegin] == ' ' || line[rightBegin] == '\t')) ++rightBegin; - bool literalAdjacent = (leftEnd > 0 && line[leftEnd - 1] == '"') - || (rightBegin < line.size() && line[rightBegin] == '"'); - if (!literalAdjacent) continue; - if (hasRaw) { - ctx.Report(li + 1, "use std::format instead of string concatenation with + (raw-string line, fix manually)"); - break; - } - - // Expand to the full chain: operands joined by `+`. - std::size_t chainBegin = operandLeft(line, plus); - std::size_t chainEnd = operandRight(line, plus + 1); - bool bail = chainBegin == std::string_view::npos || chainEnd == std::string_view::npos; - while (!bail) { - std::size_t b = chainBegin; - while (b > 0 && (line[b - 1] == ' ' || line[b - 1] == '\t')) --b; - if (b > 0 && line[b - 1] == '+' && !(b > 1 && line[b - 2] == '+')) { - std::size_t prev = operandLeft(line, b - 1); - if (prev == std::string_view::npos) { bail = true; break; } - chainBegin = prev; - } else break; - } - while (!bail) { - std::size_t e = chainEnd; - while (e < line.size() && (line[e] == ' ' || line[e] == '\t')) ++e; - if (e < line.size() && line[e] == '+' && !(e + 1 < line.size() && (line[e + 1] == '+' || line[e + 1] == '='))) { - std::size_t next = operandRight(line, e + 1); - if (next == std::string_view::npos) { bail = true; break; } - chainEnd = next; - } else break; - } - if (bail) { - ctx.Report(li + 1, "use std::format instead of string concatenation with + (not auto-fixable)"); - break; - } - - // Split the chain at depth-0 '+' into parts; literals feed the - // format string, everything else becomes an argument. - std::string_view chain = line.substr(chainBegin, chainEnd - chainBegin); - std::string_view rawChain = std::string_view(ctx.content).substr(lineOff + chainBegin, chainEnd - chainBegin); - std::string fmt; - std::vector args; - std::size_t partBegin = 0; - std::int64_t depth = 0; - bool inLit = false; - for (std::size_t p = 0; p <= chain.size(); ++p) { - if (p < chain.size()) { - char c = chain[p]; - if (c == '"') inLit = !inLit; - if (inLit) continue; - if (c == '(' || c == '[') ++depth; - if (c == ')' || c == ']') --depth; - if (!(c == '+' && depth == 0)) continue; - } - std::string_view part = Trim(chain.substr(partBegin, p - partBegin)); - std::string_view rawPart = Trim(rawChain.substr(partBegin, p - partBegin)); - if (part.starts_with('"') && part.ends_with('"') && part.size() >= 2 - && std::count(part.begin(), part.end(), '"') == 2) { - for (char c : rawPart.substr(1, rawPart.size() - 2)) { - fmt += c; - if (c == '{') fmt += '{'; - if (c == '}') fmt += '}'; - } - } else if (part.contains('"') && part.contains('+')) { - bail = true; // nested concat inside an operand — human territory - break; - } else { - fmt += "{}"; - args.push_back(rawPart); - } - partBegin = p + 1; - } - if (bail || args.empty()) { - ctx.Report(li + 1, "use std::format instead of string concatenation with + (not auto-fixable)"); - break; - } - std::string replacement = std::format("std::format(\"{}\"", fmt); - for (std::string_view a : args) replacement += std::format(", {}", a); - replacement += ")"; - edits.push_back({lineOff + chainBegin, lineOff + chainEnd, std::move(replacement)}); - searchFrom = chainEnd; - } - } - if (edits.empty()) return; - std::string out = ctx.content; - std::sort(edits.begin(), edits.end(), [](const Edit& a, const Edit& b) { return a.begin > b.begin; }); - for (const Edit& e : edits) out.replace(e.begin, e.end - e.begin, e.replacement); - ctx.SetContent(std::move(out)); - }); - - // ---------------- transforms (auto-fixed by `crafter-build format`) ---------------- - - // short/int/long → fixed-width types. Lines mentioning main/argc/argv or - // extern "C" keep their C-conventional ints. - cfg.AddLintRule("fixed-width-types", [](LintContext& ctx) { - if (!IsCppFile(ctx)) return; - const std::string& code = ctx.CommentStripped(); - struct Rep { std::size_t pos; std::size_t len; std::string to; }; - std::vector reps; - // Builtin integer type specifiers combine in any order (`unsigned - // long`, `long unsigned int`, ...), so match whole RUNS of these - // keywords and classify the run, rather than the words one by one. - static const std::unordered_set IntWords = { - "unsigned", "signed", "short", "long", "int", "char", - }; - std::size_t lineStart = 0; - while (lineStart <= code.size()) { - std::size_t lineEnd = code.find('\n', lineStart); - if (lineEnd == std::string::npos) lineEnd = code.size(); - std::string_view line(code.data() + lineStart, lineEnd - lineStart); - // `int main` / argc / argv keep their C-conventional type; so do - // extern "C" prototypes (the literal body is blanked in the - // stripped text, so match `extern "`). - bool exempt = line.contains("int main") || line.contains("argc") || line.contains("argv") - || line.contains("extern \""); - std::size_t pos = 0; - while (!exempt && pos < line.size()) { - if (!IsWordChar(line[pos])) { ++pos; continue; } - std::size_t wordEnd = pos; - while (wordEnd < line.size() && IsWordChar(line[wordEnd])) ++wordEnd; - if (!IntWords.contains(line.substr(pos, wordEnd - pos))) { pos = wordEnd; continue; } - - // Extend the run over consecutive specifier keywords. - std::size_t runBegin = pos; - std::size_t runEnd = wordEnd; - bool hasUnsigned = false; - bool hasSigned = false; - bool hasShort = false; - bool hasChar = false; - bool hasLong = false; - std::string_view nextWord; - for (;;) { - std::string_view word = line.substr(pos, wordEnd - pos); - if (word == "unsigned") hasUnsigned = true; - else if (word == "signed") hasSigned = true; - else if (word == "short") hasShort = true; - else if (word == "char") hasChar = true; - else if (word == "long") hasLong = true; - runEnd = wordEnd; - pos = wordEnd; - while (pos < line.size() && (line[pos] == ' ' || line[pos] == '\t')) ++pos; - wordEnd = pos; - while (wordEnd < line.size() && IsWordChar(line[wordEnd])) ++wordEnd; - nextWord = line.substr(pos, wordEnd - pos); - if (!IntWords.contains(nextWord)) break; - } - pos = runEnd; - - // Bare `char` is text, not an integer — only signed/unsigned - // char is byte arithmetic. `long double` is a floating type. - if (hasChar && !hasUnsigned && !hasSigned) continue; - if (nextWord == "double") continue; - - std::string_view width = hasChar ? "8" : hasShort ? "16" : hasLong ? "64" : "32"; - reps.push_back({lineStart + runBegin, runEnd - runBegin, - std::format("std::{}int{}_t", hasUnsigned ? "u" : "", width)}); - } - lineStart = lineEnd + 1; - } - if (reps.empty()) return; - std::string out = ctx.content; - std::sort(reps.begin(), reps.end(), [](const Rep& a, const Rep& b) { return a.pos > b.pos; }); - for (const Rep& r : reps) out.replace(r.pos, r.len, r.to); - ctx.SetContent(std::move(out)); - }); - - // One declaration per statement. Auto-splits the simple initialized form - // `Type a = x, b = y;`; anything with pointers/references, parens, or - // templates in the declarators is only reported (splitting `int* a, b;` - // would change b's type). - cfg.AddLintRule("single-declaration", [](LintContext& ctx) { - if (!IsCppFile(ctx)) return; - std::vector stripped = Lines(ctx.CommentStripped()); - // The declarator char class excludes quotes: string-literal bodies are - // blanked in the stripped text, so reconstructing them would corrupt - // the file — those lines are left alone. - static const std::regex simpleMulti( - R"(^(\s*)((?:std::)?[A-Za-z_][\w:]*)\s+([A-Za-z_]\w*\s*=\s*[^,;()<>*&"]+(?:,\s*[A-Za-z_]\w*\s*=\s*[^,;()<>*&"]+)+);\s*$)"); - std::string out; - bool changed = false; - for (std::size_t i = 0; i < stripped.size(); ++i) { - std::string lineStr(stripped[i]); - std::smatch m; - std::string_view raw = i + 1 <= ctx.lines.size() ? ctx.Line(i + 1) : std::string_view{}; - if (!raw.contains("//") && !ctx.Suppressed("single-declaration", i + 1) - && std::regex_match(lineStr, m, simpleMulti)) { - std::string indent = m[1].str(), type = m[2].str(), decls = m[3].str(); - std::size_t start = 0; - bool first = true; - while (start < decls.size()) { - std::size_t comma = decls.find(',', start); - std::string_view d = Trim(std::string_view(decls).substr(start, comma == std::string::npos ? std::string::npos : comma - start)); - if (!first) out += '\n'; - out += std::format("{}{} {};", indent, type, d); - first = false; - if (comma == std::string::npos) break; - start = comma + 1; - } - changed = true; - } else { - out += raw; - } - if (i + 1 < stripped.size() || ctx.content.ends_with('\n')) out += '\n'; - } - if (changed) ctx.SetContent(std::move(out)); - }); - - // K&R braces: `{` on its own line after a `)`/else/do/try header joins - // onto the header line. Standalone scope blocks (previous line ends with - // `;`, `{`, a comment, …) are intentional and stay. - cfg.AddLintRule("brace-style", [](LintContext& ctx) { - if (!IsCppFile(ctx)) return; - std::vector stripped = Lines(ctx.CommentStripped()); - std::vector outLines; - outLines.reserve(ctx.lines.size()); - bool changed = false; - for (std::size_t i = 0; i < ctx.lines.size(); ++i) { - std::string_view trimmed = Trim(stripped[i]); - if (trimmed == "{" && !outLines.empty()) { - std::string_view prevTrim = i > 0 ? Trim(stripped[i - 1]) : std::string_view{}; - bool headerBefore = prevTrim.ends_with(')') || prevTrim == "else" || prevTrim == "do" || prevTrim == "try"; - bool hasComment = ctx.Line(i + 1).contains("//") || (i > 0 && ctx.Line(i).contains("//")); - bool suppressed = ctx.Suppressed("brace-style", i) || ctx.Suppressed("brace-style", i + 1); - if (headerBefore && !hasComment && !suppressed) { - std::string& prev = outLines.back(); - while (!prev.empty() && (prev.back() == ' ' || prev.back() == '\t')) prev.pop_back(); - prev += " {"; - changed = true; - continue; - } - } - outLines.emplace_back(ctx.Line(i + 1)); - } - if (!changed) return; - std::string out; - for (std::size_t i = 0; i < outLines.size(); ++i) { - out += outLines[i]; - if (i + 1 < outLines.size() || ctx.content.ends_with('\n')) out += '\n'; - } - ctx.SetContent(std::move(out)); - }); - - // Single-statement if bodies join onto the if line: `if (x)\n y;` → - // `if (x) y;` — when the condition's parens balance on one line, the body - // is one `;`-terminated statement, neither line carries a comment, and - // the joined line stays ≤ 250 columns. - cfg.AddLintRule("if-single-line", [](LintContext& ctx) { - if (!IsCppFile(ctx)) return; - std::vector stripped = Lines(ctx.CommentStripped()); - static const std::regex ifHeader(R"(^\s*(?:\}?\s*else\s+)?if\s*\(.*\)\s*$)"); - std::vector outLines; - bool changed = false; - for (std::size_t i = 0; i < ctx.lines.size(); ++i) { - std::string lineStr(stripped[i]); - if (i + 1 < ctx.lines.size() && std::regex_match(lineStr, ifHeader) && ParenDelta(stripped[i]) == 0) { - std::string_view body = Trim(stripped[i + 1]); - bool joinable = !body.empty() && body != "{" && !body.starts_with("if") && body.ends_with(';') - && !ctx.Line(i + 1).contains("//") && !ctx.Line(i + 2).contains("//") - && !ctx.Suppressed("if-single-line", i + 1) && !ctx.Suppressed("if-single-line", i + 2); - std::string joined = std::string(ctx.Line(i + 1)); - while (!joined.empty() && (joined.back() == ' ' || joined.back() == '\t')) joined.pop_back(); - joined += " "; - joined += Trim(ctx.Line(i + 2)); - if (joinable && joined.size() <= 250) { - outLines.push_back(std::move(joined)); - ++i; // consume the body line - changed = true; - continue; - } - } - outLines.emplace_back(ctx.Line(i + 1)); - } - if (!changed) return; - std::string out; - for (std::size_t i = 0; i < outLines.size(); ++i) { - out += outLines[i]; - if (i + 1 < outLines.size() || ctx.content.ends_with('\n')) out += '\n'; - } - ctx.SetContent(std::move(out)); - }); - - // Wrapped call arguments join back onto one line when the whole - // expression fits in 250 columns. Lambda bodies (lines ending `{`), - // comments, raw strings, and preprocessor lines are left alone. - // Operator-style continuations (next line starts with && / || / |) join - // under the same length limit. - cfg.AddLintRule("wrap-join", [](LintContext& ctx) { - if (!IsCppFile(ctx)) return; - // Fixpoint loop: a join can enable another (joining an inner paren - // wrap balances the line an operator continuation hangs off), so one - // pass is not idempotent. Iterate until a pass changes nothing; the - // cap is a safety net — joins strictly reduce the line count, so - // termination is guaranteed anyway. - for (std::int32_t pass = 0; pass < 16; ++pass) { - std::vector stripped = Lines(ctx.CommentStripped()); - std::vector outLines; - bool changed = false; - for (std::size_t i = 0; i < ctx.lines.size(); ++i) { - std::string_view trimmed = Trim(stripped[i]); - std::int64_t delta = ParenDelta(stripped[i]); - bool candidate = delta > 0 && !trimmed.empty() && !trimmed.starts_with('#') - && !trimmed.ends_with('{') && !ctx.Line(i + 1).contains("//") && !ctx.Line(i + 1).contains("R\""); - if (candidate) { - std::string joined(ctx.Line(i + 1)); - std::size_t j = i + 1; - bool ok = true; - while (delta > 0 && j < ctx.lines.size() && j - i <= 4) { - std::string_view next = Trim(stripped[j]); - // A `{`-ending line is fine when it closes the expression - // (a control header's `) {`); mid-expression it means a - // lambda body starts — leave those wrapped. - bool closes = delta + ParenDelta(stripped[j]) <= 0; - if (next.empty() || (next.ends_with('{') && !closes) - || ctx.Line(j + 1).contains("//") || ctx.Line(j + 1).contains("R\"")) { - ok = false; - break; - } - while (!joined.empty() && (joined.back() == ' ' || joined.back() == '\t')) joined.pop_back(); - std::string_view fragment = Trim(ctx.Line(j + 1)); - // No separator right after an opening paren or before a - // closing one — joining must not manufacture `( x` / `x )`. - if (!joined.ends_with('(') && !fragment.starts_with(')') && !fragment.starts_with(',')) joined += " "; - joined += fragment; - delta += ParenDelta(stripped[j]); - ++j; - } - for (std::size_t l = i + 1; ok && l <= j; ++l) ok = !ctx.Suppressed("wrap-join", l); - if (ok && delta <= 0 && joined.size() <= 250) { - outLines.push_back(std::move(joined)); - i = j - 1; // consumed through line j-1 (0-based i) - changed = true; - continue; - } - } else if (delta == 0 && i + 1 < ctx.lines.size() && !trimmed.starts_with('#') - && !ctx.Line(i + 1).contains("//") && !ctx.Line(i + 1).contains("R\"")) { - auto isOpStart = [](std::string_view s) { - return s.starts_with("&&") || s.starts_with("||") || s.starts_with("| "); - }; - // Operator-style continuation chain (`a\n && b\n && c`). - // Joined when the WHOLE chain stays within 250 columns — - // longer conditions legitimately wrap. Same mechanics as the - // paren join: chain lines must be comment-free, raw-string - // free, and paren-balanced. - if (isOpStart(Trim(stripped[i + 1])) && !isOpStart(trimmed)) { - std::string joined(ctx.Line(i + 1)); - std::size_t j = i + 1; - bool ok = true; - while (j < ctx.lines.size() && isOpStart(Trim(stripped[j]))) { - if (ParenDelta(stripped[j]) != 0 || ctx.Line(j + 1).contains("//") || ctx.Line(j + 1).contains("R\"")) { - ok = false; - break; - } - while (!joined.empty() && (joined.back() == ' ' || joined.back() == '\t')) joined.pop_back(); - joined += " "; - joined += Trim(ctx.Line(j + 1)); - ++j; - } - for (std::size_t l = i + 1; ok && l <= j; ++l) ok = !ctx.Suppressed("wrap-join", l); - if (ok && joined.size() <= 250) { - outLines.push_back(std::move(joined)); - i = j - 1; // consumed the chain - changed = true; - continue; - } - } - } - outLines.emplace_back(ctx.Line(i + 1)); - } - if (!changed) return; - std::string out; - for (std::size_t i = 0; i < outLines.size(); ++i) { - out += outLines[i]; - if (i + 1 < outLines.size() || ctx.content.ends_with('\n')) out += '\n'; - } - ctx.SetContent(std::move(out)); - } - }); - - // No space padding inside parens: `( x` / `x )` → `(x` / `x)`. Only - // intra-line (a line legitimately ends with '(' when a call wraps); - // string/comment interiors are excluded via the stripped text. - cfg.AddLintRule("paren-spacing", [](LintContext& ctx) { - if (!IsCppFile(ctx)) return; - const std::string& code = ctx.CommentStripped(); - std::vector> cuts; // [begin, end) spans of spaces to delete - for (std::size_t i = 0; i < code.size(); ++i) { - if (code[i] == '(' ) { - std::size_t j = i + 1; - while (j < code.size() && code[j] == ' ') ++j; - if (j > i + 1 && j < code.size() && code[j] != '\n' && code[j] != '\r') cuts.push_back({i + 1, j}); - } else if (code[i] == ')') { - std::size_t j = i; - while (j > 0 && code[j - 1] == ' ') --j; - // Only single-space padding: multi-space runs are usually - // deliberate alignment columns. - if (j == i - 1 && j > 0 && code[j - 1] != '\n' && code[j - 1] != ',') cuts.push_back({j, i}); - } - } - if (cuts.empty()) return; - std::string out = ctx.content; - for (auto it = cuts.rbegin(); it != cuts.rend(); ++it) out.erase(it->first, it->second - it->first); - ctx.SetContent(std::move(out)); - }); - - cfg.AddLintRule("trim-trailing-ws", [](LintContext& ctx) { - if (!IsCppFile(ctx)) return; - std::string out; - out.reserve(ctx.content.size()); - for (std::size_t n = 1; n <= ctx.lines.size(); ++n) { - std::string_view line = ctx.Line(n); - // Byte fidelity on CRLF files: peel the \r, trim, put it back. - bool crlf = line.ends_with('\r'); - if (crlf) line.remove_suffix(1); - while (line.ends_with(' ') || line.ends_with('\t')) line.remove_suffix(1); - out += line; - if (crlf) out += '\r'; - // The last line only had a newline if the file ended with one — - // preserve that byte exactly so this rule doesn't shadow - // final-newline. - if (n < ctx.lines.size() || ctx.content.ends_with('\n')) out += '\n'; - } - ctx.SetContent(std::move(out)); - }); - - cfg.AddLintRule("final-newline", [](LintContext& ctx) { - if (!IsCppFile(ctx)) return; - if (!ctx.content.empty() && !ctx.content.ends_with('\n')) { - ctx.SetContent(ctx.content + '\n'); - } - }); -} - -} // namespace ProjectLint diff --git a/project.cpp b/project.cpp index 6e53866..1bc9398 100644 --- a/project.cpp +++ b/project.cpp @@ -4,7 +4,8 @@ // lint-disable-file no-char-pointer import std; import Crafter.Build; -#include "lint-rules.h" +#pragma crafter dependency "https://forgejo.catcrafts.net/Catcrafts/Crafter.LintRules.git" commit "e906f797da2cd0b1776dd1b420f9b04d3cd24baf" +import Crafter.LintRules; namespace fs = std::filesystem; using namespace Crafter; @@ -113,7 +114,7 @@ extern "C" Configuration CrafterBuildProject(std::span a ofonod.GetInterfacesAndImplementations(ifaces, impls); } ApplyGioFlags(ofonod); - ProjectLint::AddProjectLintRules(ofonod); + LintRules::AddRules(ofonod); return ofonod; } if (wantDialerd) { @@ -129,7 +130,7 @@ extern "C" Configuration CrafterBuildProject(std::span a dialerd.GetInterfacesAndImplementations(ifaces, impls); } ApplyGioFlags(dialerd); - ProjectLint::AddProjectLintRules(dialerd); + LintRules::AddRules(dialerd); return dialerd; } if (wantMedia) { @@ -146,7 +147,7 @@ extern "C" Configuration CrafterBuildProject(std::span a } media.linkFlags.push_back("-ldl"); // dlopen the AMR-WB codecs media.linkFlags.push_back("-lpthread"); // std::jthread playout/mic - ProjectLint::AddProjectLintRules(media); + LintRules::AddRules(media); return media; } @@ -201,7 +202,7 @@ extern "C" Configuration CrafterBuildProject(std::span a cfg.AddTest("Sip").Dependencies({ Core.get() }); cfg.AddTest("Sdp").Dependencies({ Core.get() }); - ProjectLint::AddProjectLintRules(cfg); + LintRules::AddRules(cfg); return cfg; } From 7d38846bb6c93c78d40483771ca268c39a5d0683 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Tue, 6 Oct 2026 17:54:09 +0200 Subject: [PATCH 14/15] ims-pdn-up: do not connect a bearer while the modem is disabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the modem's radio off (airplane mode) the bring-up waited five minutes for registration and then tried to connect the ims bearer anyway. That attempt fails with no-service and leaves ModemManager's modem state `enabled` while the modem stays in low power; switching the radio back on then no longer brings the modem up, and the phone has no mobile service until the modem is disabled and enabled again (dev phone 2026-10-06: not registered 150 s after the radio came on, against 5 s without the attempt). 0.3.9's bring-up made the same attempts, but it only ran at boot or at a manual restart. Since this series a registered imsd exits when its address leaves the netdev and systemd runs the bring-up again, and an upgrade restarts it: the bring-up now also runs during a radio-off period. A modem still disabled after the registration wait, or disabled when a connect attempt is due, now makes the script exit non-zero without touching the modem; systemd retries after RestartSec. Tested on the dev phone (KPN) with this script: a registered imsd, the radio switched off — imsd exited at its next refresh, the bring-up gave up after the wait with the modem still disabled, and when the radio came back the modem registered in 5 s and imsd registered by itself two minutes later; the same with an upgrade's restart during the radio-off period. The twelve mock scenarios of the bring-up are unchanged. --- AGENTS.md | 9 +++++++++ packaging/ims-pdn-up.sh | 11 +++++++++++ 2 files changed, 20 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 216170d..2a8715b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -436,6 +436,15 @@ configures the muxed netdev. Idempotent; a non-zero exit fails the unit. - Waits up to 120 s for a modem, then gates the connect attempts on network registration (`IMS_REG_TIMEOUT`, default 300 s): some carriers attach ~2 min after boot. +- Never creates or connects a bearer while the modem is `disabled` — its + radio is off (airplane mode): a modem still disabled after the wait, or + disabled when an attempt is due, makes the script exit non-zero (`modem … + disabled … (radio off): not connecting`) and systemd retries. A bearer + connect on a disabled modem fails with `no-service` and leaves + ModemManager's state `enabled` with the modem in low power; switching the + radio back on then no longer brings it up (dev phone 2026-10-06: not + registered after 150 s, against 5 s without the attempt; a ModemManager + disable/enable recovered it). - Connects by **3GPP profile index** (`IMS_PROFILE_ID`, default: looked up by APN in `qmicli --wds-get-profile-list=3gpp`), not by APN string: a profile-indexed request is exempt from the modem's attach-family forcing diff --git a/packaging/ims-pdn-up.sh b/packaging/ims-pdn-up.sh index a20068e..191c2b5 100644 --- a/packaging/ims-pdn-up.sh +++ b/packaging/ims-pdn-up.sh @@ -39,6 +39,12 @@ bearer_paths() { kv -m "$MODEM" | sed -n 's/^modem\.generic\.bearers\.value\[[0-9]*\] *: *//p' } +# A disabled modem has its radio off (airplane mode): leave it alone. A bearer +# connect on it fails with no-service and leaves ModemManager's state +# `enabled` with the modem still in low power; switching the radio back on +# then no longer brings the modem up (dev phone 2026-10-06). +radio_off() { [ "$(modem_state)" = disabled ]; } + envval() { # $1 = key — for manual runs; under systemd the vars are inherited sed -n "s/^$1=//p" /etc/imsd.env 2>/dev/null | tail -n1 | tr -d '"' } @@ -165,6 +171,10 @@ if [ -z "$BEARER" ]; then [ "$STATE" != "$last" ] && log "waiting for registration (state: ${STATE:-unknown})" last=$STATE if [ "$waited" -ge "$REG_TIMEOUT" ]; then + if [ "$STATE" = disabled ]; then + log "modem still disabled after $REG_TIMEOUT s (radio off): not connecting" + exit 1 + fi log "not registered after $REG_TIMEOUT s — attempting anyway" break fi @@ -193,6 +203,7 @@ n=0 while [ -z "$BEARER" ]; do n=$((n + 1)) [ "$n" -gt 10 ] && { log "bearer connect failed after 10 attempts"; exit 1; } + radio_off && { log "modem disabled (radio off): not connecting"; exit 1; } B=$(find_ims_bearer no) # reuse a stale disconnected ims bearer of this run's shape if [ -z "$B" ]; then if [ -n "$PROFILE_ID" ]; then From 7583ee36061fc755f0b8bde1a18b74eea9e29b51 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Thu, 24 Sep 2026 19:57:22 +0200 Subject: [PATCH 15/15] imsd 0.3.10 --- implementations/main.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/implementations/main.cpp b/implementations/main.cpp index aa82987..4ef4ff3 100644 --- a/implementations/main.cpp +++ b/implementations/main.cpp @@ -48,7 +48,7 @@ import ImsdBus; namespace { // ---- static config (env-overridable, same knobs as imsd.py) --------------- -constexpr const char* Version = "0.3.9"; +constexpr const char* Version = "0.3.10"; constexpr const char* BusName = "net.catcrafts.IMS1"; constexpr const char* ObjPath = "/net/catcrafts/IMS1"; constexpr const char* Iface = "net.catcrafts.IMS1";