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 " ) ;
}
2026-07-22 22:53:28 +02:00
if ( Failures = = 0 ) std : : println ( " Sip: all tests passed " ) ;
return Failures ;
}