imsd/interfaces/Imsd-Sip.cppm
Jorijn van der Graaf db848c6490
engine: keep the reg-event subscription alive and act on a terminated binding
The reg-event subscription is the network's own account of our binding
(TS 24.229 5.1.1.3), and the daemon opened a new dialog for it at every
refresh and reconnect and otherwise never looked back: a refused
SUBSCRIBE was forgotten, an unanswered one had no timeout, an ended
subscription got nothing until the next refresh's new dialog, and a
NOTIFY was logged whatever it said. O2 UK, 2026-09-24: a fresh
registration's SUBSCRIBE got 500, a NOTIFY arrived all the same and
showed our contact active, the flow died eight seconds after the 200 and
no connect over the SA succeeded after that; the 500 was logged and
nothing was done about it.

Keep the subscription alive: retry a refused or unanswered SUBSCRIBE on
a backoff, renew one the S-CSCF ends with deactivated, timeout, giveup
or probation (RFC 6665 4.1.3; after any other reason, or none, the next
refresh subscribes anew), and subscribe anew ahead of the lifetime the
S-CSCF granted. A refresh or a reconnect opens no dialog while imsd
holds one for the registration or has a retry scheduled.

Read the reginfo for our own contact and act on its state: a NOTIFY that
terminates our binding schedules a fresh registration — at once for
deactivated or expired, after the network's retry-after (up to an hour)
for probation, on imsd's own refusal schedule (21 min, growing to 60)
for rejected or unregistered (RFC 3680). A hold already running stands,
and a REGISTER wait a call kept open that still gets its 200 after such
a NOTIFY leaves that registration standing. The parser is in Imsd:Sip
and pinned by tests/Sip with the O2 document shapes; the policy is
RegRecovery's (OnBindingTerminated, added here with its tests in
tests/Engine).

Tested on the dev phone (KPN) in builds of this series; not every path
has run there.
2026-10-06 14:01:33 +02:00

355 lines
16 KiB
C++

// SPDX-License-Identifier: GPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// lint-disable-file fixed-width-types
/*
Imsd:Sip — SIP message text layer + TCP framing.
Deliberately a text layer, not a full RFC 3261 parser: the engine works on
whole messages as strings and pulls out exactly what it needs (headers,
status codes, the granted registration lifetime), which keeps every message
loggable and testable verbatim. Edge-case behavior (empty header values,
line anchoring, keepalive discards) is pinned by tests/Sip — a change that
flips one of those tests is a behavior change, not a cleanup.
Pure std C++ (no GLib).
*/
export module Imsd:Sip;
import std;
namespace imsd::sip {
constexpr char AsciiLower(char c) {
return (c >= 'A' && c <= 'Z') ? static_cast<char>(c - 'A' + 'a') : c;
}
constexpr bool IEqualsPrefix(std::string_view text, std::string_view prefix) {
if (text.size() < prefix.size()) return false;
for (std::size_t i = 0; i < prefix.size(); i++)
if (AsciiLower(text[i]) != AsciiLower(prefix[i])) return false;
return true;
}
// spaces, tabs, CR, LF off both ends
constexpr std::string_view Trim(std::string_view s) {
while (!s.empty() && (s.front() == ' ' || s.front() == '\t' || s.front() == '\r' || s.front() == '\n'))
s.remove_prefix(1);
while (!s.empty() && (s.back() == ' ' || s.back() == '\t' || s.back() == '\r' || s.back() == '\n'))
s.remove_suffix(1);
return s;
}
// Iterate \n-separated lines (any trailing CR stays on the line and is
// stripped by Trim when a value is extracted).
template <typename F>
void ForEachLine(std::string_view msg, F&& f) {
std::size_t pos = 0;
while (pos <= msg.size()) {
std::size_t nl = msg.find('\n', pos);
std::string_view line = msg.substr(pos, nl == std::string_view::npos ? std::string_view::npos : nl - pos);
if (!f(line)) return;
if (nl == std::string_view::npos) return;
pos = nl + 1;
}
}
// One "Name: value" match: name anchored at line start (case-insensitive,
// per RFC 3261 header names), immediately followed by the colon — no
// whitespace before it — and at least one character (even just the CR)
// after it. A bare "Name:\r" line matches with value ""; a "Name:" that
// ends the buffer does not match. Matching never crosses the line
// boundary: an empty-valued header yields "", NOT the next line's
// content (a regex-based implementation whose whitespace class eats the
// CRLF gets that wrong — pinned by the "bare header" test).
std::optional<std::string_view> MatchHeaderLine(std::string_view line, std::string_view name) {
if (!IEqualsPrefix(line, name)) return std::nullopt;
if (line.size() <= name.size() || line[name.size()] != ':') return std::nullopt;
std::string_view rest = line.substr(name.size() + 1);
if (rest.empty()) return std::nullopt;
return Trim(rest);
}
// First run of ASCII digits starting at `s`; nullopt if none.
std::optional<long> LeadingDigits(std::string_view s) {
std::size_t n = 0;
while (n < s.size() && s[n] >= '0' && s[n] <= '9')
n++;
if (n == 0) return std::nullopt;
long v = 0;
auto [_, ec] = std::from_chars(s.data(), s.data() + n, v);
if (ec != std::errc{}) return std::nullopt;
return v;
}
}
export namespace imsd::sip {
// Value of the first `name:` header, trimmed; nullopt if absent.
std::optional<std::string_view> Header(std::string_view msg, std::string_view name) {
std::optional<std::string_view> out;
ForEachLine(msg, [&](std::string_view line) {
if (auto v = MatchHeaderLine(line, name)) {
out = *v;
return false;
}
return true;
});
return out;
}
// Every `name:` header value, in order.
std::vector<std::string_view> Headers(std::string_view msg, std::string_view name) {
std::vector<std::string_view> out;
ForEachLine(msg, [&](std::string_view line) {
if (auto v = MatchHeaderLine(line, name)) out.push_back(*v);
return true;
});
return out;
}
// The 3-digit code of a "SIP/2.0 NNN ..." response start-line; nullopt
// for requests / anything else. Only matches at the start of the message.
std::optional<int> Status(std::string_view msg) {
constexpr std::string_view Prefix = "SIP/2.0 ";
if (!msg.starts_with(Prefix) || msg.size() < Prefix.size() + 3) return std::nullopt;
std::string_view d = msg.substr(Prefix.size(), 3);
for (char c : d)
if (c < '0' || c > '9') return std::nullopt;
int v = 0;
std::from_chars(d.data(), d.data() + 3, v);
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<long> 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;
}
// Our binding in a reg-event NOTIFY body (RFC 3680 reginfo): the
// <contact> whose <uri> carries `user` as its SIP user part, across every
// <registration> 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:<user>@… / sips:<user>@…
std::size_t pos = 0;
while ((pos = reginfo.find("<contact", pos)) != npos) {
std::size_t tagEnd = reginfo.find('>', pos);
if (tagEnd == npos) break;
std::string_view tag = reginfo.substr(pos, tagEnd - pos);
std::size_t end = reginfo.find("</contact>", 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("<uri>");
if (u == npos) continue;
std::size_t ue = body.find("</uri>", 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
// (nullopt). Also nullopt when the Via has no explicit port (5060 is
// the default, but our P-CSCF flows always carry one). Handles
// "[v6]:port" and "host:port" sent-by forms.
std::optional<int> ViaResponsePort(std::string_view msg) {
auto via = Header(msg, "Via");
if (!via) return std::nullopt;
std::string_view v = *via;
if (std::size_t sc = v.find(';'); sc != std::string_view::npos) {
if (v.substr(sc).find("rport") != std::string_view::npos) return std::nullopt;
v = v.substr(0, sc);
}
std::size_t colon;
if (std::size_t br = v.rfind(']'); br != std::string_view::npos)
colon = v.find(':', br); // v6: port only after the ]
else
colon = v.rfind(':'); // v4/hostname (transport token
// has no colon of its own)
if (colon == std::string_view::npos) return std::nullopt;
if (auto p = LeadingDigits(Trim(v.substr(colon + 1))))
if (*p > 0 && *p < 65536) return static_cast<int>(*p);
return std::nullopt;
}
// Registration lifetime granted by a REGISTER 200 OK: the Contact
// header's expires= parameter (the registrar's answer for our binding),
// else the Expires header; nullopt if absent. "expires=" is searched
// anywhere in each Contact value, not parameter-parsed.
std::optional<long> GrantedExpires(std::string_view msg) {
for (std::string_view contact : Headers(msg, "Contact")) {
std::size_t at = contact.find("expires=");
if (at != std::string_view::npos)
if (auto v = LeadingDigits(contact.substr(at + 8))) return v;
}
if (auto e = Header(msg, "Expires")) {
std::string_view v = *e;
if (!v.empty() && v.find_first_not_of("0123456789") == std::string_view::npos) return LeadingDigits(v);
}
return std::nullopt;
}
// Caller identity of an inbound request: the number/user of the first
// P-Asserted-Identity (the network-asserted identity, RFC 3325), falling
// back to From. Understands "<tel:+31612345678>", "<sip:+31612345678@
// host;user=phone>" (user part) and bare values; returns "" if neither
// header yields one. Anonymous callers come out as "anonymous".
std::string CallerId(std::string_view msg) {
auto extract = [](std::string_view v) -> std::optional<std::string> {
std::string_view uri = v;
std::size_t lt = v.find('<');
if (lt != std::string_view::npos) {
std::size_t gt = v.find('>', lt);
if (gt != std::string_view::npos) uri = v.substr(lt + 1, gt - lt - 1);
} else {
std::size_t semi = v.find(';');
if (semi != std::string_view::npos) uri = v.substr(0, semi);
uri = Trim(uri);
}
if (uri.starts_with("tel:")) {
std::string_view n = uri.substr(4);
std::size_t semi = n.find(';');
if (semi != std::string_view::npos) n = n.substr(0, semi);
return std::string(n);
}
if (uri.starts_with("sip:") || uri.starts_with("sips:")) {
std::string_view n = uri.substr(uri.find(':') + 1);
std::size_t end = n.find_first_of("@;");
if (end == std::string_view::npos) return std::nullopt;
return std::string(n.substr(0, end));
}
return std::nullopt;
};
for (std::string_view h : Headers(msg, "P-Asserted-Identity"))
if (auto n = extract(h)) return *n;
if (auto f = Header(msg, "From"))
if (auto n = extract(*f)) return *n;
return "";
}
// SIP-over-TCP stream framing, transport-free: feed received bytes into
// Append(), pull complete messages (headers + Content-Length body) out
// of TryExtract(). Leading CRLFs (RFC 5626 keepalive pongs) are
// discarded.
class FrameBuffer {
public:
void Append(std::string_view data) { buf_.append(data); }
// One complete SIP message off the front of the buffer, or nullopt
// if more bytes are needed. Content-Length is located with the same
// line-anchored matching rules as Header().
std::optional<std::string> TryExtract() {
std::size_t keep = buf_.find_first_not_of("\r\n");
buf_.erase(0, keep == std::string::npos ? buf_.size() : keep);
std::size_t idx = buf_.find("\r\n\r\n");
if (idx == std::string::npos) return std::nullopt;
std::string_view head = std::string_view(buf_).substr(0, idx);
long clen = 0;
if (auto v = Header(head, "Content-Length")) clen = LeadingDigits(*v).value_or(0);
std::size_t total = idx + 4 + static_cast<std::size_t>(clen);
if (buf_.size() < total) return std::nullopt;
std::string msg = buf_.substr(0, total);
buf_.erase(0, total);
return msg;
}
std::size_t Size() const { return buf_.size(); }
private:
std::string buf_;
};
// The SIP message carried by one UDP datagram (RFC 3261 18.3): a
// datagram is never accumulated with the next. Leading CRLFs (an RFC
// 5626 keepalive pong) are not a message → nullopt, no problem. A
// Content-Length that fits truncates the body to it; one that does not
// fit is a truncated datagram → nullopt with `problem` set; no
// Content-Length means the body is the rest of the datagram. A datagram
// without a header terminator is malformed → nullopt with `problem`.
std::optional<std::string> DatagramMessage(std::string_view d, std::string* problem = nullptr) {
std::size_t start = d.find_first_not_of("\r\n");
if (start == std::string_view::npos) return std::nullopt;
d.remove_prefix(start);
std::size_t idx = d.find("\r\n\r\n");
if (idx == std::string_view::npos) {
if (problem) *problem = std::format("datagram without a header terminator ({} bytes)", d.size());
return std::nullopt;
}
std::size_t bodyStart = idx + 4;
std::size_t body = d.size() - bodyStart;
if (auto v = Header(d.substr(0, idx), "Content-Length")) {
std::size_t clen = 0; int digits = 0;
for (char c : *v) { if (c < '0' || c > '9' || digits == 9) break; clen = clen * 10 + static_cast<std::size_t>(c - '0'); digits++; }
if (digits) {
if (clen > body) {
if (problem) *problem = std::format("datagram truncated (Content-Length {} > {} body bytes)", clen, body);
return std::nullopt;
}
return std::string(d.substr(0, bodyStart + clen));
}
}
return std::string(d);
}
}