diff --git a/README.md b/README.md index 2b631e6..b4451ca 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,8 @@ PCSCF=2001:db8::105 | `EMERGENCY_NUMBERS` | *(empty)* | comma-separated additions to the builtin 112/911 emergency numbers (e.g. a private test core's short code). SIM `EF_ECC` is not read yet | | `RTP_PORT` | `50004` | local RTP port advertised in SDP | | `PRECOND` | `0` | `1` offers SDP QoS preconditions | -| `DUMP_SIP` | `0` | `1` writes raw REGISTER-200/SUBSCRIBE-200/INVITE dumps (mode 0600) for debugging — they contain your IMSI/MSISDN and addresses | +| `CODECS` | *(empty — defaults)* | comma-separated codec preference list over `AMR-WB`, `AMR` (or `AMR-NB`), `PCMA`, `PCMU`: restricts and orders both the codecs offered on an outgoing call and those accepted from an inbound offer (default: offer AMR-WB + AMR, accept all four in that order). A bench knob — a network whose gateway transcodes every caller up to AMR-WB otherwise never lets the narrowband path run | +| `DUMP_SIP` | `0` | `1` writes raw REGISTER-200/SUBSCRIBE-200 dumps, the last inbound INVITE (`imsd-invite-in.raw`) and the last outgoing INVITE (`imsd-invite-out.raw`), mode 0600, for debugging — they contain your IMSI/MSISDN and addresses | | `DUMP_DIR`, `OUT_DIR` | state dir | where dumps / per-call media stats land | | `IMSD_MEDIA` | *(auto)* | path to `imsd-media` (default: next to `imsd`, else `/usr/libexec/imsd-media`) | diff --git a/implementations/main.cpp b/implementations/main.cpp index 0ee566c..be113ef 100644 --- a/implementations/main.cpp +++ b/implementations/main.cpp @@ -41,7 +41,7 @@ import Imsd; namespace { // ---- static config (env-overridable, same knobs as imsd.py) --------------- -constexpr const char* Version = "0.3.3"; +constexpr const char* Version = "0.3.4"; constexpr const char* BusName = "net.catcrafts.IMS1"; constexpr const char* ObjPath = "/net/catcrafts/IMS1"; constexpr const char* Iface = "net.catcrafts.IMS1"; @@ -411,12 +411,12 @@ std::string StateDir() { // tiny JSON reader/writer for the persisted registration context. std::string StatePath() { return EnvOr("STATE_FILE", std::format("{}/imsreg.state", StateDir())); } -void PersistState(const RegState& r, const std::string& route, const std::string& ppi) { +void PersistState(const RegState& r, const std::string& route, const std::string& ppi, const std::string& aor) { std::string j = std::format( "{{\"callid\": \"{}\", \"ftag\": \"{}\", \"cseq\": {}, \"spi_uc\": {}, " "\"spi_us\": {}, \"expiry\": {}, \"route\": \"{}\", \"ppi\": \"{}\", " - "\"contact_user\": \"{}\"}}", - r.callid, r.ftag, r.cseq, r.spiUc, r.spiUs, r.expiry, route, ppi, + "\"aor\": \"{}\", \"contact_user\": \"{}\"}}", + r.callid, r.ftag, r.cseq, r.spiUc, r.spiUs, r.expiry, route, ppi, aor, r.contactUser); std::string path = StatePath(); std::error_code ec; @@ -441,7 +441,7 @@ void DumpRaw(std::string_view name, std::string_view msg) { } struct PersistedState { - std::optional callid, ftag, route, ppi, contactUser; + std::optional callid, ftag, route, ppi, aor, contactUser; std::optional cseq, spiUc, spiUs, expiry; }; std::optional LoadState() { @@ -456,6 +456,7 @@ std::optional LoadState() { ps.ftag = QuotedAfter(s, "\"ftag\": \""); ps.route = QuotedAfter(s, "\"route\": \""); ps.ppi = QuotedAfter(s, "\"ppi\": \""); + ps.aor = QuotedAfter(s, "\"aor\": \""); ps.contactUser = QuotedAfter(s, "\"contact_user\": \""); ps.cseq = IntAfter(s, "\"cseq\": "); ps.spiUc = IntAfter(s, "\"spi_uc\": "); @@ -809,6 +810,10 @@ private: // is re-learned from the next 200's P-Associated-URI. ppi_ = ps->ppi.value_or(""); ss_ = sa->securityServer; + // The public identity our requests are signed with (From) — restored + // here so a call placed before the refresh 200 re-learns it does not + // fall back to the barred temporary IMPU. + ctx_.aor = ps->aor.value_or(""); ctx_.route = route_; ctx_.ppi = ppi_; ctx_.securityServer = ss_; ctx_.portUc = PortUc; ctx_.portUs = PortUs; Log(std::format("RESUME via existing SA (cseq {})", ps->cseq.value_or(1))); @@ -834,7 +839,7 @@ private: if (auto e = imsd::sip::GrantedExpires(okMsg)) reg_.expiry = *e; UpdateRouteAndPpi(okMsg); LogBindings(okMsg); - PersistState(reg_, route_, ppi_); + PersistState(reg_, route_, ppi_, ctx_.aor); Log("REGISTERED (resumed + true-refreshed)"); return true; } @@ -933,7 +938,7 @@ private: LogBindings(ok); if (route_.empty()) route_ = std::format("", imsd::util::HostPort(pcscf_, portPs)); ctx_.route = route_; ctx_.ppi = ppi_; - PersistState(reg_, route_, ppi_); + PersistState(reg_, route_, ppi_, ctx_.aor); Log("REGISTERED (fresh)"); } @@ -1051,7 +1056,7 @@ private: auto [msg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1, reg_.spiUc, reg_.spiUs); reg_.cseq = used; if (auto e = imsd::sip::GrantedExpires(msg)) reg_.expiry = *e; - PersistState(reg_, route_, ppi_); + PersistState(reg_, route_, ppi_, ctx_.aor); Log(std::format("keepalive re-REGISTER ok (cseq {})", used)); SubscribeRegEvent(); if (!registered_) { registered_ = true; EmitStatus(true); } @@ -1087,7 +1092,7 @@ private: if (auto e = imsd::sip::GrantedExpires(msg)) reg_.expiry = *e; UpdateRouteAndPpi(msg); LogBindings(msg); - PersistState(reg_, route_, ppi_); + PersistState(reg_, route_, ppi_, ctx_.aor); Log("reconnected + re-registered"); SubscribeRegEvent(); if (!registered_) { registered_ = true; EmitStatus(true); } @@ -1242,6 +1247,9 @@ private: for (const auto& a : actions) { switch (a.type) { case T::SendClient: + // the outgoing INVITE is the one request a field log could + // never show (DUMP_SIP dumped inbound messages only) + if (a.text.starts_with("INVITE ")) DumpRaw("imsd-invite-out.raw", a.text); if (!sip_.Send(a.text)) { sendOk = false; Log("client send failed"); } break; case T::SendResponse: diff --git a/interfaces/Imsd-Engine.cppm b/interfaces/Imsd-Engine.cppm index b91eef1..2a372d0 100644 --- a/interfaces/Imsd-Engine.cppm +++ b/interfaces/Imsd-Engine.cppm @@ -120,6 +120,7 @@ export namespace imsd::engine { d_.itag = rng_.Token(10); // the To tag we mint = our dialog tag d_.invBranch = rng_.Token(20); // unused as UAS; kept initialized if (auto from = imsd::sip::Header(invite_, "From")) d_.dialogTo = std::string(*from); + if (auto to = imsd::sip::Header(invite_, "To")) d_.localUri = AngleUri(*to).value_or(""); if (auto ct = imsd::sip::Header(invite_, "Contact")) d_.remoteTarget = AngleUri(*ct); d_.ruri = d_.remoteTarget.value_or(""); // The UAS route set is the Record-Route in RECEIVED order diff --git a/interfaces/Imsd-Messages.cppm b/interfaces/Imsd-Messages.cppm index 1d8bce1..d70acdf 100644 --- a/interfaces/Imsd-Messages.cppm +++ b/interfaces/Imsd-Messages.cppm @@ -290,8 +290,28 @@ export namespace imsd::msg { std::optional dialogTo; // remote To/From incl. tag, once known std::vector recordRoute; // route set, UAC order (see DialogRoute) std::optional remoteTarget; // remote Contact URI + std::string localUri; // our URI in this dialog: the UAS takes the + // INVITE's To URI; empty = CallerId(c) (UAC) }; + // The identity we sign our own requests with: the registered public + // identity (P-Associated-URI sip: entry), else the tel: one, and the + // IMSI-derived temporary IMPU only before either is learned. 3GPP allows + // the temporary IMPU in REGISTER alone; a P-CSCF that does not overwrite + // From shows it to the callee (Telia, 2026-09-01: the reporter's IMSI on + // the far phone) and a strict one may reject the request. + inline std::string_view CallerId(const Context& c) { + if (!c.aor.empty()) return c.aor; + if (!c.ppi.empty()) return c.ppi; + return c.id.impu; + } + + // From URI of a request inside a dialog (RFC 3261 12.2.1.1: the dialog's + // local URI — as UAS that is the INVITE's To, as UAC our caller identity). + inline std::string_view FromUri(const Context& c, const Dialog& d) { + return d.localUri.empty() ? CallerId(c) : std::string_view(d.localUri); + } + // Dialog route set: the reversed Record-Route, or the registration Route // when none was recorded (imsd._dialog_route). inline std::string DialogRoute(const Context& c, const Dialog& d) { @@ -314,7 +334,7 @@ export namespace imsd::msg { std::format("Via: SIP/2.0/TCP {};branch=z9hG4bK{};rport", imsd::util::HostPort(c.local, c.portUs), d.invBranch), "Max-Forwards: 70", std::format("Route: {}", c.route), - std::format("From: <{}>;tag={}", c.id.impu, d.itag), + std::format("From: <{}>;tag={}", FromUri(c, d), d.itag), std::format("To: <{}>", d.ruri), std::format("Call-ID: {}", d.callid), "CSeq: 1 INVITE", @@ -347,7 +367,7 @@ export namespace imsd::msg { ViaLine(c, "TCP", c.portUs, viaBranch), "Max-Forwards: 70", std::format("Route: {}", DialogRoute(c, d)), - std::format("From: <{}>;tag={}", c.id.impu, d.itag), + std::format("From: <{}>;tag={}", FromUri(c, d), d.itag), std::format("To: {}", to), std::format("Call-ID: {}", d.callid), std::format("CSeq: {} {}", cseq, method), @@ -374,7 +394,7 @@ export namespace imsd::msg { ViaLine(c, "TCP", c.portUs, viaBranch), "Max-Forwards: 70", std::format("Route: {}", DialogRoute(c, d)), - std::format("From: <{}>;tag={}", c.id.impu, d.itag), + std::format("From: <{}>;tag={}", FromUri(c, d), d.itag), std::format("To: {}", to), std::format("Call-ID: {}", d.callid), "CSeq: 1 ACK", @@ -391,7 +411,7 @@ export namespace imsd::msg { std::format("Via: SIP/2.0/TCP {};branch=z9hG4bK{};rport", imsd::util::HostPort(c.local, c.portUs), d.invBranch), "Max-Forwards: 70", std::format("Route: {}", c.route), - std::format("From: <{}>;tag={}", c.id.impu, d.itag), + std::format("From: <{}>;tag={}", FromUri(c, d), d.itag), std::format("To: {}", toHeader), std::format("Call-ID: {}", d.callid), "CSeq: 1 ACK", @@ -407,7 +427,7 @@ export namespace imsd::msg { std::format("Via: SIP/2.0/TCP {};branch=z9hG4bK{};rport", imsd::util::HostPort(c.local, c.portUs), d.invBranch), "Max-Forwards: 70", std::format("Route: {}", c.route), - std::format("From: <{}>;tag={}", c.id.impu, d.itag), + std::format("From: <{}>;tag={}", FromUri(c, d), d.itag), std::format("To: <{}>", d.ruri), std::format("Call-ID: {}", d.callid), "CSeq: 1 CANCEL", diff --git a/packaging/ims-pdn-up.sh b/packaging/ims-pdn-up.sh index 9632ae2..5c50877 100644 --- a/packaging/ims-pdn-up.sh +++ b/packaging/ims-pdn-up.sh @@ -11,7 +11,14 @@ # # Configuration, via the unit's EnvironmentFile /etc/imsd.env (also read # directly so manual runs behave the same): +# IMS_APN ims APN name (default ims) # IMS_IP_TYPE bearer ip-type (default ipv6) +# IMS_PROFILE_ID 3GPP profile index to connect through (default: looked up +# by APN in the modem's profile list; empty/none = connect by +# APN string). A profile-indexed call is exempt from the +# modem's attach-family forcing that refuses an APN-string +# IPv6 request when the attach PDN was granted IPv4-only +# (journal/ims 2026-09-16/17 bench: Odido, O2 UK, Swisscom). # IMS_REG_TIMEOUT max seconds to wait for network registration before # connect attempts start counting anyway (default 300) @@ -34,6 +41,9 @@ envval() { # $1 = key — for manual runs; under systemd the vars are inherited } IP_TYPE=${IMS_IP_TYPE:-$(envval IMS_IP_TYPE)} IP_TYPE=${IP_TYPE:-ipv6} +IMS_APN=${IMS_APN:-$(envval IMS_APN)} +IMS_APN=${IMS_APN:-ims} +PROFILE_ID=${IMS_PROFILE_ID:-$(envval IMS_PROFILE_ID)} REG_TIMEOUT=${IMS_REG_TIMEOUT:-$(envval IMS_REG_TIMEOUT)} REG_TIMEOUT=${REG_TIMEOUT:-300} @@ -48,11 +58,30 @@ while :; do done log "modem $MODEM" +# ---- the ims profile index (WDS profile list), unless configured +ims_profile_index() { # $1 = apn + qmicli -d qrtr://0 --wds-get-profile-list=3gpp 2>/dev/null | awk -v apn="$1" ' + /^[ \t]*\[[0-9]+\] 3gpp/ { idx = $1; gsub(/[^0-9]/, "", idx) } + /APN:/ { a = $0; sub(/.*APN: '"'"'/, "", a); sub(/'"'"'.*/, "", a); + if (tolower(a) == tolower(apn) && idx != "") { print idx; exit } }' +} +if [ -z "$PROFILE_ID" ]; then + PROFILE_ID=$(ims_profile_index "$IMS_APN") + [ -n "$PROFILE_ID" ] && log "ims profile: index $PROFILE_ID (apn $IMS_APN)" || log "ims profile: none for apn $IMS_APN, connecting by APN string" +elif [ "$PROFILE_ID" = none ]; then + PROFILE_ID= +fi + # ---- find a connected ims bearer; else find-or-create one and connect it find_ims_bearer() { # $1 = required bearer.status.connected value for B in $(bearer_paths); do INFO=$(kv -b "$B") || continue - echo "$INFO" | grep -q '^bearer\.properties\.apn *: *ims$' || continue + if [ -n "$PROFILE_ID" ]; then + echo "$INFO" | grep -q "^bearer\.properties\.profile-id *: *$PROFILE_ID\$" || + echo "$INFO" | grep -q "^bearer\.properties\.apn *: *$IMS_APN\$" || continue + else + echo "$INFO" | grep -q "^bearer\.properties\.apn *: *$IMS_APN\$" || continue + fi echo "$INFO" | grep -q "^bearer\.properties\.ip-type *: *$IP_TYPE\$" || continue echo "$INFO" | grep -q "^bearer\.status\.connected *: *$1\$" || continue echo "$B" @@ -90,10 +119,15 @@ while [ -z "$BEARER" ]; do [ "$n" -gt 10 ] && { log "bearer connect failed after 10 attempts"; exit 1; } B=$(find_ims_bearer no) # reuse a stale disconnected ims bearer if [ -z "$B" ]; then - OUT=$(mmcli -m "$MODEM" --create-bearer="apn=ims,ip-type=$IP_TYPE" 2>&1) + if [ -n "$PROFILE_ID" ]; then + SPEC="profile-id=$PROFILE_ID,ip-type=$IP_TYPE" + else + SPEC="apn=$IMS_APN,ip-type=$IP_TYPE" + fi + OUT=$(mmcli -m "$MODEM" --create-bearer="$SPEC" 2>&1) B=$(printf '%s' "$OUT" | sed -n 's,.*\(/org/freedesktop/ModemManager1/Bearer/[0-9]*\).*,\1,p') if [ -n "$B" ]; then - log "created bearer $B (ip-type=$IP_TYPE)" + log "created bearer $B ($SPEC)" else log "create-bearer attempt $n failed: $(squash "$OUT"); retrying in 10 s" sleep 10 diff --git a/tests/Engine/main.cpp b/tests/Engine/main.cpp index 2ad2f25..504e15f 100644 --- a/tests/Engine/main.cpp +++ b/tests/Engine/main.cpp @@ -164,6 +164,8 @@ int main() { CallMachine m(c, rng, "ims-call-1", "1233", 50004, false); auto start = m.Start(); Check(Sent(start, "INVITE ") != nullptr, "Start sends INVITE"); + Check(Sent(start, "INVITE ")->text.contains("\r\nFrom: ;tag="), "MO INVITE From is our public (tel:) identity, not the IMSI IMPU"); + Check(!Sent(start, "INVITE ")->text.contains("From: text.starts_with("BYE sip:origgw@[2001:db8::9]:5060 SIP/2.0"), "BYE targets the caller's Contact"); Check(bye && bye->text.contains("CSeq: 1 BYE\r\n"), "our UAS CSeq space starts at 1"); Check(bye && bye->text.contains("To: " ";tag=caller1\r\n"), "BYE To is the caller (their tag)"); - Check(bye && bye->text.contains("From: ;tag="), "BYE From is us (our tag)"); + Check(bye && bye->text.contains("From: ;tag="), "UAS BYE From is the INVITE's To URI (our tag), not the IMSI IMPU"); + Check(bye && !bye->text.contains("001010123456789@ims"), "UAS BYE carries no IMSI-derived identity"); // UAS route set: Record-Route in RECEIVED order Check(bye && bye->text.contains("Route: , " "\r\n"), "BYE routes via Record-Route in received order"); const Action* t = Find(ah, Action::Type::State); @@ -828,6 +831,22 @@ int main() { Check(inv && inv->text.contains("a=rtpmap:100 telephone-event/8000\r\n") && !inv->text.contains("telephone-event/16000"), "CODECS=PCMA,PCMU: narrowband DTMF only"); } + // ---- MO with the registered sip: identity learned (the normal case on a + // live network): From, CANCEL and BYE all carry it, never the IMSI + { + Context ca = MakeCtx(); + ca.aor = "sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org"; + imsd::util::Rng rng(61); + CallMachine m(ca, rng, "ims-call-30", "1233", 50004, false); + auto start = m.Start(); + const Action* inv = Sent(start, "INVITE "); + Check(inv && inv->text.contains("\r\nFrom: ;tag="), "MO INVITE From is the registered sip: identity"); + auto cancel = m.OnHangup(); + const Action* can = Sent(cancel, "CANCEL "); + Check(can && can->text.contains("\r\nFrom: ;tag="), "CANCEL From is the same identity"); + Check(can && !can->text.contains("001010123456789@ims"), "CANCEL carries no IMSI"); + } + if (Failures == 0) std::println("Engine: all tests passed"); return Failures; } diff --git a/tests/Messages/main.cpp b/tests/Messages/main.cpp index 3ee9ba2..0c9eea6 100644 --- a/tests/Messages/main.cpp +++ b/tests/Messages/main.cpp @@ -117,7 +117,7 @@ namespace { "Via: SIP/2.0/TCP [2001:db8::db43]:45062;branch=z9hG4bKINVBRANCH0000000000;rport\r\n" "Max-Forwards: 70\r\n" "Route: \r\n" - "From: ;tag=ITAG123456\r\n" + "From: ;tag=ITAG123456\r\n" "To: \r\n" "Call-ID: CALLID789@2001:db8::db43\r\n" "CSeq: 1 INVITE\r\n" @@ -230,6 +230,38 @@ int main() { 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: ;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: \r\n"), "P-Preferred-Identity unchanged"); + Check(BuildCancel(c2, d).contains("\r\nFrom: ;tag=it\r\n"), "CANCEL From follows"); + Check(BuildAckNon2xx(c2, d, ";tag=r").contains("\r\nFrom: ;tag=it\r\n"), "non-2xx ACK From follows"); + d.dialogTo = ";tag=r"; d.remoteTarget = "sip:x@[2001:db8::9]:5060"; + Check(BuildAck2xx(c2, d, "ab", ";tag=r").contains("\r\nFrom: ;tag=it\r\n"), "2xx ACK From follows"); + Check(BuildInDialog(c2, d, "BYE", 2, "bb").contains("\r\nFrom: ;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: ;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: ;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: ;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) {