2026-07-22 22:53:28 +02:00
// SPDX-License-Identifier: GPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// lint-disable-file fixed-width-types
// Imsd:Sip unit tests — header access, status-line parsing, granted-expires
// extraction, caller-identity extraction, and the TCP frame buffer (split
// delivery, keepalive pongs, pipelined messages). These pin the module's
// edge-case behavior; see the module header before "fixing" one.
import std ;
import Imsd ;
using namespace imsd : : sip ;
namespace {
int Failures = 0 ;
void Check ( bool cond , std : : string_view msg ) {
if ( ! cond ) {
std : : println ( std : : cerr , " FAIL: {} " , msg ) ;
+ + Failures ;
}
}
const std : : string Resp200 =
" SIP/2.0 200 OK \r \n "
" Via: SIP/2.0/TCP [2001:db8::1]:45061;branch=z9hG4bK1 \r \n "
" Via: SIP/2.0/TCP [2001:db8::2]:5060;branch=z9hG4bK2 \r \n "
" From: <sip:user@ims.mnc001.mcc001.3gppnetwork.org>;tag=abc \r \n "
" To: <sip:user@ims.mnc001.mcc001.3gppnetwork.org>;tag=def \r \n "
" Call-ID: 12345@host \r \n "
" CSeq: 2 REGISTER \r \n "
" contact: <sip:user@[2001:db8::1]:45061>;expires=600000 \r \n "
" P-Associated-URI: <sip:+31612345678@ims.mnc001.mcc001.3gppnetwork.org> \r \n "
" Content-Length: 0 \r \n "
" \r \n " ;
}
int main ( ) {
// ---- Status
Check ( Status ( Resp200 ) = = 200 , " Status parses 200 " ) ;
Check ( Status ( " SIP/2.0 183 Session Progress \r \n \r \n " ) = = 183 , " Status parses 183 " ) ;
Check ( ! Status ( " INVITE sip:+31@x SIP/2.0 \r \n \r \n " ) . has_value ( ) , " Status rejects a request start-line " ) ;
Check ( ! Status ( " SIP/2.0 xx3 \r \n " ) . has_value ( ) , " Status rejects non-digits " ) ;
// ---- Header / Headers
Check ( Header ( Resp200 , " Call-ID " ) = = " 12345@host " , " Header finds Call-ID " ) ;
Check ( Header ( Resp200 , " call-id " ) = = " 12345@host " , " Header is case-insensitive (query) " ) ;
Check ( Header ( Resp200 , " Contact " ) = = " <sip:user@[2001:db8::1]:45061>;expires=600000 " , " Header is case-insensitive (message) " ) ;
Check ( ! Header ( Resp200 , " Route " ) . has_value ( ) , " Header absent -> nullopt " ) ;
Check ( Header ( Resp200 , " CSeq " ) = = " 2 REGISTER " , " Header trims the value " ) ;
Check ( Headers ( Resp200 , " Via " ) . size ( ) = = 2 , " Headers returns every Via " ) ;
Check ( Headers ( Resp200 , " Via " ) [ 1 ] = = " SIP/2.0/TCP [2001:db8::2]:5060;branch=z9hG4bK2 " , " Headers keeps order " ) ;
// name must sit at line start: "P-Associated-URI" must not match "URI"
Check ( ! Header ( Resp200 , " URI " ) . has_value ( ) , " Header anchors at line start " ) ;
// bare "Name:" (only CR after the colon) matches with empty value
Check ( Header ( " X-Empty: \r \n X-Real: v \r \n \r \n " , " X-Empty " ) = = " " , " bare header matches with empty value " ) ;
// ---- GrantedExpires
Check ( GrantedExpires ( Resp200 ) = = 600000 , " GrantedExpires from Contact expires= " ) ;
Check ( GrantedExpires ( " SIP/2.0 200 OK \r \n Expires: 3600 \r \n \r \n " ) = = 3600 , " GrantedExpires falls back to Expires " ) ;
Check ( ! GrantedExpires ( " SIP/2.0 200 OK \r \n Expires: abc \r \n \r \n " ) . has_value ( ) , " non-numeric Expires -> nullopt " ) ;
Check ( ! GrantedExpires ( " SIP/2.0 200 OK \r \n \r \n " ) . has_value ( ) , " no lifetime -> nullopt " ) ;
Check ( GrantedExpires ( " SIP/2.0 200 OK \r \n " " Contact: <sip:a@b> \r \n " " Contact: <sip:c@d>;expires=7200 \r \n \r \n " ) = = 7200 , " GrantedExpires scans all Contacts " ) ;
// ---- FrameBuffer
{
FrameBuffer fb ;
fb . Append ( Resp200 ) ;
auto m = fb . TryExtract ( ) ;
Check ( m = = Resp200 , " whole message extracts intact " ) ;
Check ( ! fb . TryExtract ( ) . has_value ( ) , " buffer empty after extract " ) ;
}
{
// split mid-header and mid-body
std : : string body = " v=0 \r \n o=- 1 1 IN IP6 ::1 \r \n " ;
std : : string msg = std : : format ( " SIP/2.0 200 OK \r \n Content-Length: {} \r \n \r \n {} " , body . size ( ) , body ) ;
FrameBuffer fb ;
fb . Append ( msg . substr ( 0 , 20 ) ) ;
Check ( ! fb . TryExtract ( ) . has_value ( ) , " incomplete head -> nullopt " ) ;
fb . Append ( msg . substr ( 20 , msg . size ( ) - 20 - 5 ) ) ;
Check ( ! fb . TryExtract ( ) . has_value ( ) , " incomplete body -> nullopt " ) ;
fb . Append ( msg . substr ( msg . size ( ) - 5 ) ) ;
Check ( fb . TryExtract ( ) = = msg , " split message reassembles " ) ;
}
{
// leading CRLF keepalive pongs are discarded (RFC 5626)
FrameBuffer fb ;
fb . Append ( " \r \n \r \n " ) ;
Check ( ! fb . TryExtract ( ) . has_value ( ) , " pure keepalive -> nullopt " ) ;
Check ( fb . Size ( ) = = 0 , " keepalive bytes discarded " ) ;
fb . Append ( " \r \n " ) ;
fb . Append ( Resp200 ) ;
Check ( fb . TryExtract ( ) = = Resp200 , " message after keepalive extracts " ) ;
}
{
// two pipelined messages come out one at a time, in order
std : : string first = " SIP/2.0 100 Trying \r \n Content-Length: 0 \r \n \r \n " ;
FrameBuffer fb ;
fb . Append ( first + Resp200 ) ;
Check ( fb . TryExtract ( ) = = first , " pipelined: first message " ) ;
Check ( fb . TryExtract ( ) = = Resp200 , " pipelined: second message " ) ;
Check ( ! fb . TryExtract ( ) . has_value ( ) , " pipelined: then empty " ) ;
}
{
// missing Content-Length counts as 0
std : : string msg = " SIP/2.0 200 OK \r \n Call-ID: x \r \n \r \n " ;
FrameBuffer fb ;
fb . Append ( std : : format ( " {}LEFTOVER " , msg ) ) ;
Check ( fb . TryExtract ( ) = = msg , " no Content-Length -> body length 0 " ) ;
Check ( fb . Size ( ) = = 8 , " trailing bytes stay buffered " ) ;
}
// ---- CallerId: P-Asserted-Identity preferred, From fallback
{
std : : string inv =
" INVITE sip:me SIP/2.0 \r \n "
" From: \" Someone \" <sip:+31687654321@ims.example;user=phone>;tag=f \r \n "
" P-Asserted-Identity: <tel:+31612345678> \r \n "
" Content-Length: 0 \r \n \r \n " ;
Check ( CallerId ( inv ) = = " +31612345678 " , " tel: P-Asserted-Identity wins " ) ;
}
{
std : : string inv =
" INVITE sip:me SIP/2.0 \r \n "
" P-Asserted-Identity: <sip:+31612345678@ims.example;user=phone> \r \n "
" Content-Length: 0 \r \n \r \n " ;
Check ( CallerId ( inv ) = = " +31612345678 " , " sip: identity yields the user part " ) ;
}
{
std : : string inv =
" INVITE sip:me SIP/2.0 \r \n "
" From: <sip:+31687654321@ims.example;user=phone>;tag=f \r \n "
" Content-Length: 0 \r \n \r \n " ;
Check ( CallerId ( inv ) = = " +31687654321 " , " From fallback without P-Asserted-Identity " ) ;
}
{
std : : string inv =
" INVITE sip:me SIP/2.0 \r \n "
" From: <sip:anonymous@anonymous.invalid>;tag=f \r \n "
" Content-Length: 0 \r \n \r \n " ;
Check ( CallerId ( inv ) = = " anonymous " , " anonymous caller passes through " ) ;
}
{
std : : string inv =
" INVITE sip:me SIP/2.0 \r \n "
" From: tel:+31600000001;tag=f \r \n "
" Content-Length: 0 \r \n \r \n " ;
Check ( CallerId ( inv ) = = " +31600000001 " , " bare (bracketless) tel URI " ) ;
Check ( CallerId ( " INVITE sip:me SIP/2.0 \r \n Content-Length: 0 \r \n \r \n " ) . empty ( ) , " no identity headers -> empty " ) ;
}
// ---- ViaResponsePort: RFC 3261 18.2.2 UDP response routing
{
std : : string upd =
" UPDATE sip:me SIP/2.0 \r \n "
" Via: SIP/2.0/UDP [2001:db8::105]:6000;branch=z9hG4bKx;_aluscr_ \r \n "
" Content-Length: 0 \r \n \r \n " ;
Check ( ViaResponsePort ( upd ) = = 6000 , " v6 sent-by port extracted " ) ;
std : : string rp =
" UPDATE sip:me SIP/2.0 \r \n "
" Via: SIP/2.0/UDP [2001:db8::105]:6000;rport;branch=z9hG4bKx \r \n "
" Content-Length: 0 \r \n \r \n " ;
Check ( ! ViaResponsePort ( rp ) . has_value ( ) , " rport -> symmetric (nullopt) " ) ;
std : : string v4 =
" UPDATE sip:me SIP/2.0 \r \n "
" Via: SIP/2.0/UDP 192.0.2.1:5062;branch=z9hG4bKx \r \n "
" Content-Length: 0 \r \n \r \n " ;
Check ( ViaResponsePort ( v4 ) = = 5062 , " v4 sent-by port extracted " ) ;
std : : string np =
" UPDATE sip:me SIP/2.0 \r \n "
" Via: SIP/2.0/UDP [2001:db8::105];branch=z9hG4bKx \r \n "
" Content-Length: 0 \r \n \r \n " ;
Check ( ! ViaResponsePort ( np ) . has_value ( ) , " portless v6 sent-by -> nullopt " ) ;
Check ( ! ViaResponsePort ( " UPDATE sip:me SIP/2.0 \r \n \r \n " ) . has_value ( ) , " no Via -> nullopt " ) ;
}
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
// ---- DatagramMessage (one UDP datagram = one message, RFC 3261 18.3)
{
std : : string why ;
Check ( ! DatagramMessage ( " \r \n " , & why ) . has_value ( ) & & why . empty ( ) , " CRLF-only datagram is a pong, not a message, no problem " ) ;
Check ( ! DatagramMessage ( " \r \n \r \n \r \n " ) . has_value ( ) , " CRLFCRLF-only datagram is a pong " ) ;
std : : string hdr = " SIP/2.0 200 OK \r \n Call-ID: a \r \n " ;
auto m0 = DatagramMessage ( hdr + " Content-Length: 0 \r \n \r \n " ) ;
Check ( m0 & & * m0 = = hdr + " Content-Length: 0 \r \n \r \n " , " Content-Length 0, no body: whole datagram " ) ;
auto m1 = DatagramMessage ( hdr + " Content-Length: 4 \r \n \r \n v=0 \n JUNK " ) ;
Check ( m1 & & * m1 = = hdr + " Content-Length: 4 \r \n \r \n v=0 \n " , " Content-Length that fits truncates the body " ) ;
why . clear ( ) ;
Check ( ! DatagramMessage ( hdr + " Content-Length: 40 \r \n \r \n v=0 \n " , & why ) . has_value ( ) & & why . contains ( " truncated " ) , " Content-Length larger than the datagram: discarded, problem set " ) ;
auto m2 = DatagramMessage ( " \r \n " + hdr + " \r \n v=0 \n " ) ;
Check ( m2 & & * m2 = = hdr + " \r \n v=0 \n " , " no Content-Length: body is the rest of the datagram, leading CRLF stripped " ) ;
why . clear ( ) ;
Check ( ! DatagramMessage ( " SIP/2.0 200 OK \r \n Call-ID: a \r \n " , & why ) . has_value ( ) & & why . contains ( " terminator " ) , " no header terminator: discarded, problem set " ) ;
auto m3 = DatagramMessage ( hdr + " X-Note: Content-Length: 99 \r \n \r \n v=0 \n " ) ;
Check ( m3 & & * m3 = = hdr + " X-Note: Content-Length: 99 \r \n \r \n v=0 \n " , " a header name inside a value does not match " ) ;
auto m4 = DatagramMessage ( hdr + " Content-Length: 99999999999999999999 \r \n \r \n v=0 \n " , & why ) ;
Check ( ! m4 . has_value ( ) , " absurd Content-Length: discarded, no overflow " ) ;
}
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
// ---- 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 \n Event: 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 \n Call-ID: 12345@host \r \n CSeq: 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 " ) ;
}
2026-07-22 22:53:28 +02:00
if ( Failures = = 0 ) std : : println ( " Sip: all tests passed " ) ;
return Failures ;
}