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.
407 lines
23 KiB
C++
407 lines
23 KiB
C++
// SPDX-License-Identifier: GPL-3.0-only
|
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
|
|
|
// lint-disable-file fixed-width-types
|
|
// Imsd:Messages unit tests — every builder compared byte-for-byte against a
|
|
// reference message cross-generated from the Python VoLTE stack with pinned
|
|
// tags/branches/SPIs. A divergence here is a wire-format change, i.e. a
|
|
// registration or call that the commercial IMS core may reject; these are the
|
|
// hardest pins in the project. Two deliberate post-parity divergences:
|
|
// the REGISTER Contact carries +sip.instance and the MMTEL ICSI feature tag
|
|
// (the Python stack registered without them, which made the network divert
|
|
// incoming calls to voicemail — see ContactFeatures in Imsd:Messages), and
|
|
// every ICSI feature-tag value is percent-encoded (urn%3Aurn-7%3A...) — the
|
|
// TS 24.229 coding and what the stock modem sends on the wire; the Python
|
|
// stack's plain-colon form is a different string under RFC 3841 matching
|
|
// and left terminating access-domain selection routing incoming calls to
|
|
// CS fallback (observed live 2026-07-20).
|
|
import std;
|
|
import Imsd;
|
|
|
|
using namespace imsd::msg;
|
|
|
|
namespace {
|
|
int Failures = 0;
|
|
void Check(bool cond, std::string_view msg) {
|
|
if (!cond) {
|
|
std::println(std::cerr, "FAIL: {}", msg);
|
|
++Failures;
|
|
}
|
|
}
|
|
void Eq(std::string_view got, std::string_view want, std::string_view msg) {
|
|
if (got != want) {
|
|
std::println(std::cerr, "FAIL: {}\n--- got ---\n{}\n--- want ---\n{}", msg, got, want);
|
|
++Failures;
|
|
}
|
|
}
|
|
|
|
Context MakeCtx() {
|
|
Context c;
|
|
c.id = imsd::aka::MakeIdentity("001010123456789", "001", "01");
|
|
c.local = "2001:db8::db43";
|
|
c.route = "<sip:[2001:db8::105]:6000;lr>";
|
|
c.ppi = "tel:+31611111111";
|
|
c.securityServer =
|
|
"ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=aes-cbc; "
|
|
"spi-c=86438593; spi-s=86438592; port-c=33421; port-s=6000";
|
|
c.instanceId = "urn:gsma:imei:35999999-000000-1";
|
|
c.panInfo = "3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=0010112340ABCDE0";
|
|
return c;
|
|
}
|
|
|
|
constexpr std::string_view Reg1 =
|
|
"REGISTER sip:ims.mnc001.mcc001.3gppnetwork.org SIP/2.0\r\n"
|
|
"Via: SIP/2.0/UDP [2001:db8::db43]:5060;branch=z9hG4bKREGBRANCH123456;rport\r\n"
|
|
"Max-Forwards: 70\r\n"
|
|
"From: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>;tag=FTAG5678\r\n"
|
|
"To: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>\r\n"
|
|
"Call-ID: REGCALLID@2001:db8::db43\r\n"
|
|
"CSeq: 1 REGISTER\r\n"
|
|
"Contact: <sip:001010123456789@[2001:db8::db43]:5060>;+sip.instance=\"<urn:gsma:imei:35999999-000000-1>\";+g.3gpp.icsi-ref=\"urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel\";expires=600000\r\n"
|
|
"Allow: INVITE, ACK, BYE, CANCEL, OPTIONS, NOTIFY, PRACK, UPDATE, MESSAGE, REFER\r\n"
|
|
"Authorization: Digest username=\"001010123456789@ims.mnc001.mcc001.3gppnetwork.org\", realm=\"ims.mnc001.mcc001.3gppnetwork.org\", uri=\"sip:ims.mnc001.mcc001.3gppnetwork.org\", nonce=\"\", response=\"\"\r\n"
|
|
"Require: sec-agree\r\n"
|
|
"Proxy-Require: sec-agree\r\n"
|
|
"Security-Client: ipsec-3gpp; alg=hmac-sha-1-96; ealg=aes-cbc; spi-c=7449812; spi-s=176466735; port-c=45061; port-s=45062\r\n"
|
|
"Supported: sec-agree, path\r\n"
|
|
"Expires: 600000\r\n"
|
|
"P-Access-Network-Info: 3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=0010112340ABCDE0\r\n"
|
|
"Content-Length: 0\r\n"
|
|
"\r\n";
|
|
|
|
constexpr std::string_view Reg2 =
|
|
"REGISTER sip:ims.mnc001.mcc001.3gppnetwork.org SIP/2.0\r\n"
|
|
"Via: SIP/2.0/TCP [2001:db8::db43]:45062;branch=z9hG4bKREGBRANCH123456;rport\r\n"
|
|
"Max-Forwards: 70\r\n"
|
|
"From: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>;tag=FTAG5678\r\n"
|
|
"To: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>\r\n"
|
|
"Call-ID: REGCALLID@2001:db8::db43\r\n"
|
|
"CSeq: 2 REGISTER\r\n"
|
|
"Contact: <sip:001010123456789@[2001:db8::db43]:45062>;+sip.instance=\"<urn:gsma:imei:35999999-000000-1>\";+g.3gpp.icsi-ref=\"urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel\";expires=600000\r\n"
|
|
"Allow: INVITE, ACK, BYE, CANCEL, OPTIONS, NOTIFY, PRACK, UPDATE, MESSAGE, REFER\r\n"
|
|
"Authorization: Digest username=\"001010123456789@ims.mnc001.mcc001.3gppnetwork.org\", realm=\"ims.mnc001.mcc001.3gppnetwork.org\", uri=\"sip:ims.mnc001.mcc001.3gppnetwork.org\", nonce=\"NONCEXYZ\", qop=auth, nc=00000001, cnonce=\"CNONCE0123456789\", response=\"RESP0000\", algorithm=AKAv1-MD5\r\n"
|
|
"Require: sec-agree\r\n"
|
|
"Proxy-Require: sec-agree\r\n"
|
|
"Security-Client: ipsec-3gpp; alg=hmac-sha-1-96; ealg=aes-cbc; spi-c=7449812; spi-s=176466735; port-c=45061; port-s=45062\r\n"
|
|
"Security-Verify: ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=aes-cbc; spi-c=86438593; spi-s=86438592; port-c=33421; port-s=6000\r\n"
|
|
"Supported: sec-agree, path\r\n"
|
|
"Expires: 600000\r\n"
|
|
"P-Access-Network-Info: 3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=0010112340ABCDE0\r\n"
|
|
"Content-Length: 0\r\n"
|
|
"\r\n";
|
|
|
|
constexpr std::string_view Sdp =
|
|
"v=0\r\n"
|
|
"o=- 7777777 7777777 IN IP6 2001:db8::db43\r\n"
|
|
"s=-\r\n"
|
|
"c=IN IP6 2001:db8::db43\r\n"
|
|
"t=0 0\r\n"
|
|
"m=audio 50004 RTP/AVP 97 99 98 100\r\n"
|
|
"b=AS:41\r\n"
|
|
"b=RS:512\r\n"
|
|
"b=RR:1536\r\n"
|
|
"a=rtpmap:97 AMR-WB/16000/1\r\n"
|
|
"a=fmtp:97 octet-align=1;mode-change-capability=2;max-red=0\r\n"
|
|
"a=rtpmap:99 AMR/8000/1\r\n"
|
|
"a=fmtp:99 octet-align=1;mode-change-capability=2;max-red=0\r\n"
|
|
"a=rtpmap:98 telephone-event/16000\r\n"
|
|
"a=fmtp:98 0-15\r\n"
|
|
"a=rtpmap:100 telephone-event/8000\r\n"
|
|
"a=fmtp:100 0-15\r\n"
|
|
"a=ptime:20\r\n"
|
|
"a=maxptime:240\r\n"
|
|
"a=sendrecv\r\n";
|
|
|
|
constexpr std::string_view Invite =
|
|
"INVITE sip:1233;phone-context=ims.mnc001.mcc001.3gppnetwork.org@ims.mnc001.mcc001.3gppnetwork.org;user=phone SIP/2.0\r\n"
|
|
"Via: SIP/2.0/TCP [2001:db8::db43]:45062;branch=z9hG4bKINVBRANCH0000000000;rport\r\n"
|
|
"Max-Forwards: 70\r\n"
|
|
"Route: <sip:[2001:db8::105]:6000;lr>\r\n"
|
|
"From: <tel:+31611111111>;tag=ITAG123456\r\n"
|
|
"To: <sip:1233;phone-context=ims.mnc001.mcc001.3gppnetwork.org@ims.mnc001.mcc001.3gppnetwork.org;user=phone>\r\n"
|
|
"Call-ID: CALLID789@2001:db8::db43\r\n"
|
|
"CSeq: 1 INVITE\r\n"
|
|
"Contact: <sip:001010123456789@[2001:db8::db43]:45062>;+g.3gpp.icsi-ref=\"urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel\"\r\n"
|
|
"Allow: INVITE, ACK, BYE, CANCEL, OPTIONS, PRACK, UPDATE, MESSAGE, REFER\r\n"
|
|
"Supported: 100rel, timer\r\n"
|
|
"Accept-Contact: *;+g.3gpp.icsi-ref=\"urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel\"\r\n"
|
|
"P-Preferred-Identity: <tel:+31611111111>\r\n"
|
|
"P-Access-Network-Info: 3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=0010112340ABCDE0\r\n"
|
|
"Security-Verify: ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=aes-cbc; spi-c=86438593; spi-s=86438592; port-c=33421; port-s=6000\r\n"
|
|
"Proxy-Require: sec-agree\r\n"
|
|
"Require: sec-agree\r\n"
|
|
"Session-Expires: 1800;refresher=uac\r\n"
|
|
"Min-SE: 90\r\n"
|
|
"Content-Type: application/sdp\r\n"
|
|
"Content-Length: 466\r\n"
|
|
"\r\n"
|
|
"v=0\r\n"
|
|
"o=- 7777777 7777777 IN IP6 2001:db8::db43\r\n"
|
|
"s=-\r\n"
|
|
"c=IN IP6 2001:db8::db43\r\n"
|
|
"t=0 0\r\n"
|
|
"m=audio 50004 RTP/AVP 97 99 98 100\r\n"
|
|
"b=AS:41\r\n"
|
|
"b=RS:512\r\n"
|
|
"b=RR:1536\r\n"
|
|
"a=rtpmap:97 AMR-WB/16000/1\r\n"
|
|
"a=fmtp:97 octet-align=1;mode-change-capability=2;max-red=0\r\n"
|
|
"a=rtpmap:99 AMR/8000/1\r\n"
|
|
"a=fmtp:99 octet-align=1;mode-change-capability=2;max-red=0\r\n"
|
|
"a=rtpmap:98 telephone-event/16000\r\n"
|
|
"a=fmtp:98 0-15\r\n"
|
|
"a=rtpmap:100 telephone-event/8000\r\n"
|
|
"a=fmtp:100 0-15\r\n"
|
|
"a=ptime:20\r\n"
|
|
"a=maxptime:240\r\n"
|
|
"a=sendrecv\r\n";
|
|
|
|
constexpr std::string_view Subscribe =
|
|
"SUBSCRIBE sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org SIP/2.0\r\n"
|
|
"Via: SIP/2.0/TCP [2001:db8::db43]:45062;branch=z9hG4bKSUBBRANCH0000000000;rport\r\n"
|
|
"Max-Forwards: 70\r\n"
|
|
"Route: <sip:[2001:db8::105]:6000;lr>\r\n"
|
|
"From: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>;tag=STAG1234\r\n"
|
|
"To: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>\r\n"
|
|
"Call-ID: SUBCALLID@2001:db8::db43\r\n"
|
|
"CSeq: 1 SUBSCRIBE\r\n"
|
|
"Contact: <sip:001010123456789@[2001:db8::db43]:45062>\r\n"
|
|
"Event: reg\r\n"
|
|
"Accept: application/reginfo+xml\r\n"
|
|
"Expires: 600000\r\n"
|
|
"P-Preferred-Identity: <tel:+31611111111>\r\n"
|
|
"P-Access-Network-Info: 3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=0010112340ABCDE0\r\n"
|
|
"Security-Verify: ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=aes-cbc; spi-c=86438593; spi-s=86438592; port-c=33421; port-s=6000\r\n"
|
|
"Proxy-Require: sec-agree\r\n"
|
|
"Require: sec-agree\r\n"
|
|
"Content-Length: 0\r\n"
|
|
"\r\n";
|
|
}
|
|
|
|
int main() {
|
|
Context c = MakeCtx();
|
|
|
|
// ---- REGISTER (initial + protected), byte-exact vs Python
|
|
Eq(BuildRegisterInitial(c, "REGCALLID@2001:db8::db43", "FTAG5678",
|
|
"REGBRANCH123456", 7449812u, 176466735u),
|
|
Reg1, "unprotected REGISTER");
|
|
{
|
|
std::string auth = AuthAka(c, "NONCEXYZ", "CNONCE0123456789", "RESP0000");
|
|
Eq(BuildRegisterProtected(c, "REGCALLID@2001:db8::db43", "FTAG5678",
|
|
"REGBRANCH123456", 2, 7449812u, 176466735u, auth),
|
|
Reg2, "protected REGISTER");
|
|
}
|
|
|
|
// ---- protected-leg transport follows Context.transport (SIP_TRANSPORT=udp,
|
|
// or the auto fallback): every protected request's Via says UDP, the
|
|
// challenge's Via stays UDP either way, and TCP is the untouched default.
|
|
{
|
|
Context cu = c;
|
|
cu.transport = "UDP";
|
|
std::string auth = AuthAka(cu, "NONCEXYZ", "CNONCE0123456789", "RESP0000");
|
|
std::string r2u = BuildRegisterProtected(cu, "REGCALLID@2001:db8::db43", "FTAG5678", "REGBRANCH123456", 2, 7449812u, 176466735u, auth);
|
|
Check(r2u.contains("\r\nVia: SIP/2.0/UDP [2001:db8::db43]:45062;branch=z9hG4bKREGBRANCH123456;rport\r\n"), "protected REGISTER Via follows transport=UDP");
|
|
Check(!r2u.contains("SIP/2.0/TCP"), "no TCP token left in a UDP protected REGISTER");
|
|
Check(BuildSubscribeReg(cu, "SUBCALLID@2001:db8::db43", "STAG1234", "SUBBRANCH0000000000").contains("\r\nVia: SIP/2.0/UDP [2001:db8::db43]:45062;"), "SUBSCRIBE Via follows transport=UDP");
|
|
Check(BuildRegisterInitial(cu, "REGCALLID@2001:db8::db43", "FTAG5678", "REGBRANCH123456", 7449812u, 176466735u).contains("\r\nVia: SIP/2.0/UDP [2001:db8::db43]:5060;"), "initial REGISTER Via is UDP regardless");
|
|
Check(BuildRegisterProtected(c, "REGCALLID@2001:db8::db43", "FTAG5678", "REGBRANCH123456", 2, 7449812u, 176466735u, auth).contains("\r\nVia: SIP/2.0/TCP [2001:db8::db43]:45062;"), "default transport stays TCP");
|
|
}
|
|
|
|
// ---- RuriFor variants
|
|
Eq(RuriFor("1233", c.id.domain), "sip:1233;phone-context=ims.mnc001.mcc001.3gppnetwork.org@ims.mnc001.mcc001.3gppnetwork.org;user=phone", "ruri short code");
|
|
Eq(RuriFor("+31612345678", c.id.domain), "sip:+31612345678@ims.mnc001.mcc001.3gppnetwork.org;user=phone", "ruri E.164");
|
|
Eq(RuriFor("0612345678", c.id.domain), "sip:+31612345678@ims.mnc001.mcc001.3gppnetwork.org;user=phone", "ruri Dutch national");
|
|
Eq(RuriFor("0031612345678", c.id.domain), "sip:+31612345678@ims.mnc001.mcc001.3gppnetwork.org;user=phone", "ruri 00 intl");
|
|
|
|
// ---- SUBSCRIBE (reg event, RFC 3680) — no Python counterpart; pinned
|
|
// against the shape of the other protected in-flow requests
|
|
Eq(BuildSubscribeReg(c, "SUBCALLID@2001:db8::db43", "STAG1234", "SUBBRANCH0000000000"), Subscribe, "reg-event SUBSCRIBE (no aor: temporary IMPU fallback)");
|
|
{
|
|
Context c2 = MakeCtx();
|
|
c2.aor = "sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org";
|
|
std::string sub = BuildSubscribeReg(c2, "SUBCALLID@2001:db8::db43", "STAG1234", "SUBBRANCH0000000000");
|
|
Check(sub.starts_with("SUBSCRIBE sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org SIP/2.0\r\n"), "reg-event SUBSCRIBE aor R-URI");
|
|
Check(sub.contains("\r\nFrom: <sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org>;tag=STAG1234\r\n"), "reg-event SUBSCRIBE aor From");
|
|
Check(sub.contains("\r\nTo: <sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org>\r\n"), "reg-event SUBSCRIBE aor To");
|
|
}
|
|
{
|
|
// no identity learned yet: P-Preferred-Identity is omitted, not "<>"
|
|
Context c3 = MakeCtx();
|
|
c3.ppi.clear();
|
|
Check(!BuildSubscribeReg(c3, "SUBCALLID@2001:db8::db43", "STAG1234", "SUBBRANCH0000000000") .contains("P-Preferred-Identity"), "empty ppi omits P-Preferred-Identity in SUBSCRIBE");
|
|
Dialog d;
|
|
d.ruri = RuriFor("1233", c3.id.domain);
|
|
d.callid = "CID@x"; d.itag = "it"; d.invBranch = "ib";
|
|
Check(!BuildInvite(c3, d, "v=0\r\n", false).contains("P-Preferred-Identity"), "empty ppi omits P-Preferred-Identity in INVITE");
|
|
}
|
|
|
|
// ---- INVITE, byte-exact vs Python
|
|
{
|
|
Dialog d;
|
|
d.ruri = RuriFor("1233", c.id.domain);
|
|
d.callid = "CALLID789@2001:db8::db43";
|
|
d.itag = "ITAG123456";
|
|
d.invBranch = "INVBRANCH0000000000";
|
|
Eq(BuildInvite(c, d, Sdp, /*precond=*/false), Invite, "INVITE + AMR-WB SDP offer");
|
|
}
|
|
|
|
// ---- From carries our PUBLIC identity, never the IMSI-derived temporary
|
|
// IMPU once anything better is known (ledger F1: a Telia P-CSCF showed
|
|
// the reporter's IMSI to the callee). aor > tel: ppi > temp IMPU; the
|
|
// same URI on every request of the dialog; a UAS signs with the
|
|
// INVITE's To.
|
|
{
|
|
Dialog d;
|
|
d.ruri = RuriFor("1233", c.id.domain);
|
|
d.callid = "CID@x"; d.itag = "it"; d.invBranch = "ib";
|
|
Context c2 = MakeCtx();
|
|
c2.aor = "sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org";
|
|
std::string inv = BuildInvite(c2, d, "v=0\r\n", false);
|
|
Check(inv.contains("\r\nFrom: <sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org>;tag=it\r\n"), "INVITE From is the registered sip: identity when known");
|
|
Check(!inv.contains("001010123456789@ims"), "INVITE never carries the IMSI IMPU once the aor is known");
|
|
Check(inv.contains("\r\nP-Preferred-Identity: <tel:+31611111111>\r\n"), "P-Preferred-Identity unchanged");
|
|
Check(BuildCancel(c2, d).contains("\r\nFrom: <sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org>;tag=it\r\n"), "CANCEL From follows");
|
|
Check(BuildAckNon2xx(c2, d, "<sip:x@y>;tag=r").contains("\r\nFrom: <sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org>;tag=it\r\n"), "non-2xx ACK From follows");
|
|
d.dialogTo = "<sip:x@y>;tag=r"; d.remoteTarget = "sip:x@[2001:db8::9]:5060";
|
|
Check(BuildAck2xx(c2, d, "ab", "<sip:x@y>;tag=r").contains("\r\nFrom: <sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org>;tag=it\r\n"), "2xx ACK From follows");
|
|
Check(BuildInDialog(c2, d, "BYE", 2, "bb").contains("\r\nFrom: <sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org>;tag=it\r\n"), "BYE From follows");
|
|
// only tel: known (pre-aor): the tel: identity, as the pinned INVITE shows
|
|
Check(BuildInvite(c, d, "v=0\r\n", false).contains("\r\nFrom: <tel:+31611111111>;tag=it\r\n"), "INVITE From falls back to the tel: identity");
|
|
// nothing learned yet: the temporary IMPU is all there is
|
|
Context c4 = MakeCtx();
|
|
c4.ppi.clear();
|
|
Check(BuildInvite(c4, d, "v=0\r\n", false).contains("\r\nFrom: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>;tag=it\r\n"), "INVITE From is the temporary IMPU only before any identity is learned");
|
|
// UAS: the dialog's local URI is the INVITE's To, whatever we registered as
|
|
Dialog du = d;
|
|
du.localUri = "sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org;user=phone";
|
|
Check(BuildInDialog(c4, du, "BYE", 1, "cc").contains("\r\nFrom: <sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org;user=phone>;tag=it\r\n"), "UAS BYE From is the INVITE's To URI");
|
|
}
|
|
|
|
// ---- emergency INVITE: byte-identical to the plain INVITE except the
|
|
// Request-URI and To carry the service URN (RFC 5031 / TS 24.229 5.1.6)
|
|
{
|
|
Dialog d;
|
|
d.ruri = std::string(SosUrn);
|
|
d.callid = "CALLID789@2001:db8::db43";
|
|
d.itag = "ITAG123456";
|
|
d.invBranch = "INVBRANCH0000000000";
|
|
std::string want(Invite);
|
|
auto swap = [&](std::string_view from, std::string_view to) {
|
|
std::size_t at = want.find(from);
|
|
Check(at != std::string::npos, "sos pin template line present");
|
|
if (at != std::string::npos) want.replace(at, from.size(), to);
|
|
};
|
|
swap("INVITE sip:1233;phone-context=ims.mnc001.mcc001.3gppnetwork.org@ims.mnc001.mcc001.3gppnetwork.org;user=phone SIP/2.0\r\n",
|
|
"INVITE urn:service:sos SIP/2.0\r\n");
|
|
swap("To: <sip:1233;phone-context=ims.mnc001.mcc001.3gppnetwork.org@ims.mnc001.mcc001.3gppnetwork.org;user=phone>\r\n",
|
|
"To: <urn:service:sos>\r\n");
|
|
Eq(BuildInvite(c, d, Sdp, /*precond=*/false), want, "emergency INVITE (urn:service:sos)");
|
|
}
|
|
|
|
// ---- IsEmergency classification
|
|
Check(IsEmergency("112"), "112 is emergency");
|
|
Check(IsEmergency("911"), "911 is emergency");
|
|
Check(IsEmergency("1 1 2"), "space separators stripped");
|
|
Check(IsEmergency("1-1-2"), "dash separators stripped");
|
|
Check(!IsEmergency("0112"), "prefixed 112 is not emergency");
|
|
Check(!IsEmergency("1120"), "suffixed 112 is not emergency");
|
|
Check(!IsEmergency("+31612345678"), "E.164 number is not emergency");
|
|
Check(!IsEmergency(""), "empty string is not emergency");
|
|
Check(!IsEmergency("199"), "extra number not classified by default");
|
|
{
|
|
std::vector<std::string> extra = {"199", "088"};
|
|
Check(IsEmergency("199", extra), "EMERGENCY_NUMBERS extra matches");
|
|
Check(IsEmergency("088", extra), "second extra matches");
|
|
Check(!IsEmergency("19", extra), "extra requires exact match");
|
|
}
|
|
|
|
// ---- in-dialog PRACK/BYE + ACK + CANCEL + 200 (structural checks)
|
|
{
|
|
Dialog d;
|
|
d.ruri = RuriFor("1233", c.id.domain);
|
|
d.callid = "CID@x";
|
|
d.itag = "it";
|
|
d.invBranch = "ib";
|
|
d.dialogTo = "<sip:1233@x>;tag=remote99";
|
|
d.remoteTarget = "sip:termgw@2001:db8::9";
|
|
d.recordRoute = {"<sip:rr1;lr>", "<sip:rr2;lr>"};
|
|
|
|
// dialog route = reversed record-route
|
|
Check(DialogRoute(c, d) == "<sip:rr2;lr>, <sip:rr1;lr>", "dialog route reversed");
|
|
|
|
std::array<std::string, 1> rack = {"RAck: 42 1 INVITE"};
|
|
std::string prack = BuildInDialog(c, d, "PRACK", 2, "prbranch", rack);
|
|
Check(prack.starts_with("PRACK sip:termgw@2001:db8::9 SIP/2.0\r\n"), "PRACK request-URI = remote target");
|
|
Check(prack.contains("Route: <sip:rr2;lr>, <sip:rr1;lr>\r\n"), "PRACK dialog route");
|
|
Check(prack.contains("To: <sip:1233@x>;tag=remote99\r\n"), "PRACK To has remote tag");
|
|
Check(prack.contains("CSeq: 2 PRACK\r\n"), "PRACK CSeq");
|
|
Check(prack.contains("RAck: 42 1 INVITE\r\n"), "PRACK RAck");
|
|
Check(prack.ends_with("Content-Length: 0\r\n\r\n"), "PRACK empty body");
|
|
|
|
std::string bye = BuildInDialog(c, d, "BYE", 3, "byebranch");
|
|
Check(bye.starts_with("BYE sip:termgw@2001:db8::9 SIP/2.0\r\n"), "BYE line");
|
|
Check(bye.contains("CSeq: 3 BYE\r\n"), "BYE CSeq");
|
|
|
|
std::string ack = BuildAck2xx(c, d, "ackbranch", "<ignored>");
|
|
Check(ack.starts_with("ACK sip:termgw@2001:db8::9 SIP/2.0\r\n"), "ACK 2xx target");
|
|
Check(ack.contains("To: <sip:1233@x>;tag=remote99\r\n"), "ACK 2xx dialog To");
|
|
Check(ack.contains("CSeq: 1 ACK\r\n"), "ACK CSeq 1");
|
|
|
|
std::string cancel = BuildCancel(c, d);
|
|
Check(cancel.starts_with(std::format("CANCEL {} SIP/2.0\r\n", d.ruri)), "CANCEL request-URI = ruri");
|
|
Check(cancel.contains("branch=z9hG4bKib;rport\r\n"), "CANCEL reuses INVITE branch");
|
|
Check(cancel.contains("CSeq: 1 CANCEL\r\n"), "CANCEL CSeq copies INVITE");
|
|
}
|
|
{
|
|
// non-2xx ACK uses the INVITE branch + registration route + resp To
|
|
Dialog d;
|
|
d.ruri = "sip:x@y"; d.callid = "CID"; d.itag = "it"; d.invBranch = "ib9";
|
|
std::string ack = BuildAckNon2xx(c, d, "<sip:x@y>;tag=z");
|
|
Check(ack.contains("branch=z9hG4bKib9;rport\r\n"), "non-2xx ACK INVITE branch");
|
|
Check(ack.contains(std::format("Route: {}\r\n", c.route)), "non-2xx ACK registration route");
|
|
Check(ack.contains("To: <sip:x@y>;tag=z\r\n"), "non-2xx ACK echoes response To");
|
|
}
|
|
|
|
// ---- 200 OK response echoes the request's dialog headers, in order
|
|
{
|
|
std::string req =
|
|
"BYE sip:me@here SIP/2.0\r\n"
|
|
"Via: SIP/2.0/TCP [a::1]:45061;branch=z9hG4bKother\r\n"
|
|
"From: <sip:them@far>;tag=T\r\n"
|
|
"To: <sip:me@here>;tag=M\r\n"
|
|
"Call-ID: xyz@far\r\n"
|
|
"CSeq: 7 BYE\r\n"
|
|
"Content-Length: 0\r\n\r\n";
|
|
std::string r = BuildResponse200(c, req);
|
|
Check(r.starts_with("SIP/2.0 200 OK\r\n"), "200 status line");
|
|
Check(r.contains("Via: SIP/2.0/TCP [a::1]:45061;branch=z9hG4bKother\r\n"), "200 echoes Via");
|
|
Check(r.contains("Call-ID: xyz@far\r\n"), "200 echoes Call-ID");
|
|
Check(r.contains("CSeq: 7 BYE\r\n"), "200 echoes CSeq");
|
|
Check(r.ends_with("Content-Length: 0\r\n\r\n"), "200 empty body");
|
|
// with an SDP answer: our Contact + application/sdp appear
|
|
std::string r2 = BuildResponse200(c, req, Sdp);
|
|
Check(r2.contains("Content-Type: application/sdp\r\n"), "200 w/ SDP content-type");
|
|
Check(r2.contains("Contact: <sip:001010123456789@[2001:db8::db43]:45062>\r\n"), "200 w/ SDP includes our Contact");
|
|
Check(r2.ends_with(Sdp), "200 w/ SDP body");
|
|
}
|
|
|
|
// ---- s53 oracle diff: UUID Contact user-part + User-Agent ------------
|
|
{
|
|
Context c = MakeCtx();
|
|
c.contactUser = "029dd36c-857c-4270-ad7f-0ae70d31d50f";
|
|
c.userAgent = "Fairphone_Fairphone 6_FP6.QREL.16.82.0";
|
|
std::string r1 = BuildRegisterInitial(c, "cid@h", "ft", "br", 1, 2);
|
|
Check(r1.contains("Contact: <sip:029dd36c-857c-4270-ad7f-0ae70d31d50f@" "[2001:db8::db43]:5060>"), "reg1 Contact carries contactUser, not the IMSI");
|
|
Check(!r1.contains("sip:001010123456789@[2001:db8::db43]"), "reg1 has no IMSI-user Contact when contactUser set");
|
|
Check(r1.contains("From: <sip:001010123456789@ims.mnc001.mcc001"), "reg1 From/AOR stays the IMSI IMPU");
|
|
Check(r1.contains("User-Agent: Fairphone_Fairphone 6_FP6.QREL.16.82.0\r\n"), "reg1 User-Agent emitted");
|
|
std::string r2 = BuildRegisterProtected(c, "cid@h", "ft", "br", 2, 1, 2, "Authorization: x");
|
|
Check(r2.contains("Contact: <sip:029dd36c-857c-4270-ad7f-0ae70d31d50f@" "[2001:db8::db43]:45062>"), "reg2 Contact carries contactUser at the protected port");
|
|
Check(r2.contains("User-Agent: Fairphone_Fairphone 6_FP6.QREL.16.82.0\r\n"), "reg2 User-Agent emitted");
|
|
c.userAgent.clear();
|
|
Check(!BuildRegisterInitial(c, "cid@h", "ft", "br", 1, 2) .contains("User-Agent:"), "empty userAgent omits the header");
|
|
}
|
|
|
|
if (Failures == 0) std::println("Messages: all tests passed");
|
|
return Failures;
|
|
}
|