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.
This commit is contained in:
Jorijn van der Graaf 2026-09-17 14:58:46 +02:00
commit 5c89dcb033
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
8 changed files with 292 additions and 29 deletions

View file

@ -257,6 +257,10 @@ export namespace imsd::engine {
return a; // 1xx other than 100<code<200 (only 100): nothing
if (code == 200) {
a.push_back(Send(imsd::msg::BuildAck2xx(ctx_, d_, rng_.Token(20), std::string(imsd::sip::Header(msg, "To").value_or("")))));
// A 2xx retransmission (RFC 3261 13.3.1.4: the UAS repeats
// it until our ACK arrives) is ACKed again and nothing else —
// media is already running, or the call is already over.
if (state_ != CallState::Ringing && state_ != CallState::Dialing) return a;
if (cancelled_ && userCancelled_) {
// answered in the user's CANCEL race — we no longer want it
nextCseq_++;

View file

@ -37,6 +37,10 @@ export namespace imsd::msg {
int portUc = imsd::util::PortUc;
int portUs = imsd::util::PortUs;
int initPort = 5060;
// Transport of the protected leg (REGISTER 2 onwards): the Via token
// of every request we send over it. TCP unless the engine fell back
// to, or was told to use, UDP.
std::string transport = "TCP";
std::string ealgOffer = "aes-cbc";
// RFC 7254 IMEI URN for +sip.instance (e.g. urn:gsma:imei:35999999-
// 000000-1); empty omits the parameter.
@ -218,7 +222,7 @@ export namespace imsd::msg {
std::string BuildRegisterProtected(const Context& c, std::string_view callid, std::string_view ftag, std::string_view branch, int cseq, std::uint32_t spiUc, std::uint32_t spiUs, std::string_view authLine) {
std::vector<std::string> l = {
std::format("REGISTER {} SIP/2.0", c.id.regUri),
ViaLine(c, "TCP", c.portUs, branch),
ViaLine(c, c.transport, c.portUs, branch),
"Max-Forwards: 70",
std::format("From: <{}>;tag={}", c.id.impu, ftag),
std::format("To: <{}>", c.id.impu),
@ -252,7 +256,7 @@ export namespace imsd::msg {
: std::string_view(c.aor);
std::vector<std::string> l = {
std::format("SUBSCRIBE {} SIP/2.0", aor),
ViaLine(c, "TCP", c.portUs, branch),
ViaLine(c, c.transport, c.portUs, branch),
"Max-Forwards: 70",
std::format("Route: {}", c.route),
std::format("From: <{}>;tag={}", aor, ftag),
@ -331,7 +335,7 @@ export namespace imsd::msg {
: "100rel, timer";
std::vector<std::string> l = {
std::format("INVITE {} SIP/2.0", d.ruri),
std::format("Via: SIP/2.0/TCP {};branch=z9hG4bK{};rport", imsd::util::HostPort(c.local, c.portUs), d.invBranch),
ViaLine(c, c.transport, c.portUs, d.invBranch),
"Max-Forwards: 70",
std::format("Route: {}", c.route),
std::format("From: <{}>;tag={}", FromUri(c, d), d.itag),
@ -364,7 +368,7 @@ export namespace imsd::msg {
std::string to = d.dialogTo.value_or(std::format("<{}>", d.ruri));
std::vector<std::string> l = {
std::format("{} {} SIP/2.0", method, target),
ViaLine(c, "TCP", c.portUs, viaBranch),
ViaLine(c, c.transport, c.portUs, viaBranch),
"Max-Forwards: 70",
std::format("Route: {}", DialogRoute(c, d)),
std::format("From: <{}>;tag={}", FromUri(c, d), d.itag),
@ -391,7 +395,7 @@ export namespace imsd::msg {
std::string to = d.dialogTo.value_or(std::string(toHeader));
std::vector<std::string> l = {
std::format("ACK {} SIP/2.0", target),
ViaLine(c, "TCP", c.portUs, viaBranch),
ViaLine(c, c.transport, c.portUs, viaBranch),
"Max-Forwards: 70",
std::format("Route: {}", DialogRoute(c, d)),
std::format("From: <{}>;tag={}", FromUri(c, d), d.itag),
@ -408,7 +412,7 @@ export namespace imsd::msg {
std::string BuildAckNon2xx(const Context& c, const Dialog& d, std::string_view toHeader) {
std::vector<std::string> l = {
std::format("ACK {} SIP/2.0", d.ruri),
std::format("Via: SIP/2.0/TCP {};branch=z9hG4bK{};rport", imsd::util::HostPort(c.local, c.portUs), d.invBranch),
ViaLine(c, c.transport, c.portUs, d.invBranch),
"Max-Forwards: 70",
std::format("Route: {}", c.route),
std::format("From: <{}>;tag={}", FromUri(c, d), d.itag),
@ -424,7 +428,7 @@ export namespace imsd::msg {
std::string BuildCancel(const Context& c, const Dialog& d) {
std::vector<std::string> l = {
std::format("CANCEL {} SIP/2.0", d.ruri),
std::format("Via: SIP/2.0/TCP {};branch=z9hG4bK{};rport", imsd::util::HostPort(c.local, c.portUs), d.invBranch),
ViaLine(c, c.transport, c.portUs, d.invBranch),
"Max-Forwards: 70",
std::format("Route: {}", c.route),
std::format("From: <{}>;tag={}", FromUri(c, d), d.itag),

View file

@ -233,4 +233,36 @@ export namespace imsd::sip {
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);
}
}