imsd/interfaces/Imsd-Sip.cppm

355 lines
16 KiB
Text
Raw Permalink Normal View History

// 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;
}
sip: log a refused REGISTER or SUBSCRIBE with its status line and reason headers A refused final was logged as its bare status code ("re-REGISTER failed 503", "reg-event SUBSCRIBE -> 500") — the unprotected REGISTER's as "expected 401", without even the code — so a field journal could not say which node refused or what it said. O2 UK answered a phone's 30-minute refresh with 503 twenty-one times over seventy minutes and then refused a fresh registration's reg-event SUBSCRIBE with 500 (alyx, 2026-09-24); the journal holds nothing but the numbers. Log the status line with the headers that carry the reason — Retry-After, Warning, Reason, Server — for a REGISTER's refused final (initial, protected, refresh) and for a refused SUBSCRIBE the run loop reads, and dump the refused message under DUMP_SIP as its own fail dump next to the 200s. To tell a REGISTER's final from what else arrives, the final is now matched by Call-ID and CSeq, and a request that arrives on the client flow while one is awaited is dispatched: a refresh no longer reads such a request as a failed REGISTER. An INVITE among those requests is a call like any other, announced on the bus: a Dial whose reconnect it arrived under meets the call-in-progress rule again before it places its own, and when a resume, a refresh or a reconnect fails the call that arrived under it ends with the flow it came in on (486 while the flow still carries one, and the bus told). The unprotected REGISTER's reply is matched the same way on the init port. Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-09-24 18:51:03 +02:00
// 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;
}
engine: keep the reg-event subscription alive and act on a terminated binding The reg-event subscription is the network's own account of our binding (TS 24.229 5.1.1.3), and the daemon opened a new dialog for it at every refresh and reconnect and otherwise never looked back: a refused SUBSCRIBE was forgotten, an unanswered one had no timeout, an ended subscription got nothing until the next refresh's new dialog, and a NOTIFY was logged whatever it said. O2 UK, 2026-09-24: a fresh registration's SUBSCRIBE got 500, a NOTIFY arrived all the same and showed our contact active, the flow died eight seconds after the 200 and no connect over the SA succeeded after that; the 500 was logged and nothing was done about it. Keep the subscription alive: retry a refused or unanswered SUBSCRIBE on a backoff, renew one the S-CSCF ends with deactivated, timeout, giveup or probation (RFC 6665 4.1.3; after any other reason, or none, the next refresh subscribes anew), and subscribe anew ahead of the lifetime the S-CSCF granted. A refresh or a reconnect opens no dialog while imsd holds one for the registration or has a retry scheduled. Read the reginfo for our own contact and act on its state: a NOTIFY that terminates our binding schedules a fresh registration — at once for deactivated or expired, after the network's retry-after (up to an hour) for probation, on imsd's own refusal schedule (21 min, growing to 60) for rejected or unregistered (RFC 3680). A hold already running stands, and a REGISTER wait a call kept open that still gets its 200 after such a NOTIFY leaves that registration standing. The parser is in Imsd:Sip and pinned by tests/Sip with the O2 document shapes; the policy is RegRecovery's (OnBindingTerminated, added here with its tests in tests/Engine). Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-10-03 19:36:23 +02:00
// 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_;
};
sip: UDP for the protected leg, TCP first with a fallback imsd's protected leg (the second REGISTER and everything after it) was TCP only. A P-CSCF that never answers the TCP connect on its protected server port (O2 UK: the SYNs leave ESP-protected, nothing comes back) left the unit looping with no way forward, although the SAs, the listener sockets and the firewall rule already covered UDP. The client flow now carries a transport. UDP binds the same protected client port and connect()s the datagram socket to the P-CSCF's protected server port, so the kernel delivers that peer's datagrams to it ahead of the unconnected listener on the same port. One datagram is one message (RFC 3261 18.3: a Content-Length that fits truncates, one that does not fit discards the datagram, none means the rest of the datagram); a receive error is logged and marks the flow dead; a message over the single-packet ESP budget at the ims PDN's MTU is logged once per flow, since the kernel fragments it and a P-CSCF may drop the fragments. Every protected request's Via follows the transport; the challenge stays UDP. SIP_TRANSPORT selects the policy: auto (default) registers over TCP and, after two unanswered connects, registers again over UDP (new challenge, new SA pair) - but only on a phone whose state file does not record a successful TCP registration, so an outage on a TCP carrier never becomes a second initial REGISTER; a phone whose last registration was UDP goes straight to UDP. tcp and udp force one. A connect refused locally (EADDRNOTAVAIL: the 4-tuple still in TIME_WAIT from the previous flow) keeps retrying and is not counted as silence; SO_ERROR results are logged by name. The registration's transport is persisted so a warm resume reconnects the same way, and the resume gives up after two silences. Two things the change exposed and fixes: an engine-fatal event exited 0 ("exiting for systemd restart" with Restart=on-failure never firing), and a fresh registration refused with the P-CSCF's fresh-SA throttle (Security-Server spi-s=0) must not be retried by a 120-s restart loop, since every attempt re-arms the ~20-min window - it is now waited out in-process, growing on repeats, with D-Bus commands still served. A resume the network refuses falls through to a fresh registration. A retransmitted 200 OK to our INVITE is ACKed again and no longer starts a second media leg. Verified on KPN: resume over TCP after a binary swap, MT and MO calls with media both ways, the throttle deferral and its self-recovery, the UDP client path up to KPN dropping the datagram. The UDP success path is a field test; SIP retransmission timers over UDP are not in this change.
2026-09-17 14:58:46 +02:00
// The 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);
}
}