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.
355 lines
16 KiB
C++
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);
|
|
}
|
|
}
|