From 54288e565747b7054b37b012aae227a18c9a6c87 Mon Sep 17 00:00:00 2001 From: ihipop Date: Fri, 21 Aug 2026 22:48:55 +0800 Subject: [PATCH] fix(ims): align VoWiFi SIP profile behavior (#75) --- internal/vowifi/carrier_compat.go | 30 +++-- internal/vowifi/carrier_compat_test.go | 28 +++++ internal/vowifi/carrier_ipcc.go | 9 +- internal/vowifi/carrier_ipcc_test.go | 3 + internal/vowifi/carrier_profiles.json | 13 +- internal/vowifi/ike/esp.go | 4 +- internal/vowifi/ike/esp_test.go | 20 +++ internal/vowifi/ims/call_runtime.go | 31 ++--- internal/vowifi/ims/call_runtime_test.go | 2 +- internal/vowifi/ims/provider.go | 154 +++++++++++++++++++---- internal/vowifi/ims/provider_test.go | 147 ++++++++++++++++++++-- internal/vowifi/ims/sms_runtime.go | 1 + 12 files changed, 371 insertions(+), 71 deletions(-) diff --git a/internal/vowifi/carrier_compat.go b/internal/vowifi/carrier_compat.go index 90b1663..99a765c 100644 --- a/internal/vowifi/carrier_compat.go +++ b/internal/vowifi/carrier_compat.go @@ -42,8 +42,10 @@ type CarrierProfile struct { IMSRegisterProfile string IMSIPSecEncryption string SMSCenter string + PANIEnabled *bool PANICountry string PANINode string + IMSUserAgent string IMSDialURIScheme string IMSUserEqPhone bool IMSVoiceCodecs []string @@ -57,7 +59,6 @@ type IMSRegisterOptions struct { ContactExtraTags []string SupportedHeader *string AllowHeader *string - UserAgent string PPreferredIdentity bool PVisitedNetworkID string PAccessNetworkInfo *string @@ -68,6 +69,7 @@ type IMSRegisterOptions struct { const ( IMSContactFormatStandard = "standard" IMSContactFormatATT = "att" + IMSContactFormatGSMA = "gsma" ) type carrierProfileDocument struct { @@ -116,8 +118,10 @@ type carrierProfileIMS struct { RegisterProfile string `json:"register_profile,omitempty"` IPSecEncryption string `json:"ipsec_encryption,omitempty"` SMSCenter string `json:"sms_center,omitempty"` + PANIEnabled *bool `json:"pani_enabled,omitempty"` PANICountry string `json:"pani_country,omitempty"` PANINode string `json:"pani_node,omitempty"` + UserAgent string `json:"user_agent,omitempty"` DialURIScheme string `json:"dial_uri_scheme,omitempty"` UserEqPhone *bool `json:"user_eq_phone,omitempty"` VoiceCodecs []string `json:"voice_codecs,omitempty"` @@ -131,7 +135,6 @@ type carrierProfileRegisterOptions struct { ContactExtraTags []string `json:"contact_extra_tags,omitempty"` SupportedHeader *string `json:"supported_header,omitempty"` AllowHeader *string `json:"allow_header,omitempty"` - UserAgent string `json:"user_agent,omitempty"` PPreferredIdentity bool `json:"p_preferred_identity,omitempty"` PVisitedNetworkID string `json:"p_visited_network_id,omitempty"` PAccessNetworkInfo *string `json:"p_access_network_info,omitempty"` @@ -322,6 +325,7 @@ func validCarrierProfileRule(rule carrierProfileRule) bool { return false } if country := strings.ToUpper(strings.TrimSpace(rule.IMS.PANICountry)); country != "" && + country != "AUTO" && (len(country) != 2 || country[0] < 'A' || country[0] > 'Z' || country[1] < 'A' || country[1] > 'Z') { return false } @@ -340,7 +344,7 @@ func validCarrierProfileRule(rule carrierProfileRule) bool { return false } if format := strings.ToLower(strings.TrimSpace(rule.IMS.RegisterOptions.ContactFormat)); format != "" && - format != IMSContactFormatStandard && format != IMSContactFormatATT { + format != IMSContactFormatStandard && format != IMSContactFormatATT && format != IMSContactFormatGSMA { return false } for _, value := range rule.IMS.RegisterOptions.ContactExtraTags { @@ -353,7 +357,7 @@ func validCarrierProfileRule(rule carrierProfileRule) bool { return false } } - for _, value := range []string{rule.IMS.RegisterOptions.UserAgent, rule.IMS.RegisterOptions.PVisitedNetworkID, rule.IMS.RegisterOptions.CellularNetworkInfo} { + for _, value := range []string{rule.IMS.UserAgent, rule.IMS.RegisterOptions.PVisitedNetworkID, rule.IMS.RegisterOptions.CellularNetworkInfo} { if strings.ContainsAny(value, "\r\n") { return false } @@ -588,8 +592,15 @@ func applyCarrierProfileRule(base CarrierProfile, rule carrierProfileRule, sourc base.IMSIPSecEncryption = value } base.SMSCenter = strings.TrimSpace(rule.IMS.SMSCenter) + if rule.IMS.PANIEnabled != nil { + enabled := *rule.IMS.PANIEnabled + base.PANIEnabled = &enabled + } base.PANICountry = strings.ToUpper(strings.TrimSpace(rule.IMS.PANICountry)) base.PANINode = strings.TrimSpace(rule.IMS.PANINode) + if value := strings.TrimSpace(rule.IMS.UserAgent); value != "" { + base.IMSUserAgent = value + } if value := strings.ToLower(strings.TrimSpace(rule.IMS.DialURIScheme)); value != "" { base.IMSDialURIScheme = value } @@ -624,9 +635,6 @@ func applyRegisterOptions(base IMSRegisterOptions, rule carrierProfileRegisterOp value := strings.TrimSpace(*rule.AllowHeader) base.AllowHeader = &value } - if value := strings.TrimSpace(rule.UserAgent); value != "" { - base.UserAgent = value - } if rule.PPreferredIdentity { base.PPreferredIdentity = true } @@ -723,8 +731,8 @@ func applyAssignedCarrierRoute(identity SIMIdentity) SIMIdentity { if strings.TrimSpace(identity.ICCID) != "" { if mcc, mnc, ok := HomePLMNFromICCID(identity.ICCID); ok { - imsiCountry := countryCodeForMCC(identity.HomeMCC) - iccidCountry := countryCodeForMCC(mcc) + imsiCountry := CountryCodeForMCC(identity.HomeMCC) + iccidCountry := CountryCodeForMCC(mcc) if identity.HomeMCC == "" || (imsiCountry != "" && iccidCountry != "" && imsiCountry != iccidCountry) { identity.HomeMCC = mcc identity.HomeMNC = mnc @@ -737,7 +745,9 @@ func applyAssignedCarrierRoute(identity SIMIdentity) SIMIdentity { return identity } -func countryCodeForMCC(mcc string) string { +// CountryCodeForMCC returns the ISO 3166-1 alpha-2 country code associated +// with an MCC known to the carrier compatibility database. +func CountryCodeForMCC(mcc string) string { switch strings.TrimSpace(mcc) { case "515": return "PH" diff --git a/internal/vowifi/carrier_compat_test.go b/internal/vowifi/carrier_compat_test.go index 05eaf26..9adafe3 100644 --- a/internal/vowifi/carrier_compat_test.go +++ b/internal/vowifi/carrier_compat_test.go @@ -46,6 +46,31 @@ func TestResolveCarrierProfileUsesAppleGID1Selector(t *testing.T) { } } +func TestResolveCarrierProfileGiffgaffIMSHeaders(t *testing.T) { + profile := ResolveCarrierProfile(SIMIdentity{ + IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10", GID1: "508FFFFF", + }) + options := profile.IMSRegisterOptions + if profile.IMSTransport != "tcp" || options.ContactFormat != IMSContactFormatGSMA { + t.Fatalf("giffgaff IMS transport/contact profile = %#v", profile) + } + if profile.IMSUserAgent != "iOS/18.6.2 iPhone" { + t.Fatalf("giffgaff User-Agent = %q", profile.IMSUserAgent) + } + if options.SupportedHeader != nil || options.AllowHeader != nil { + t.Fatalf("giffgaff REGISTER header overrides = supported=%v allow=%v", options.SupportedHeader, options.AllowHeader) + } + if options.PAccessNetworkInfo != nil { + t.Fatalf("giffgaff unexpectedly defines a carrier PANI override = %v", *options.PAccessNetworkInfo) + } + if profile.PANIEnabled == nil || !*profile.PANIEnabled || profile.PANICountry != "AUTO" { + t.Fatalf("giffgaff PANI behavior = enabled=%v country=%q", profile.PANIEnabled, profile.PANICountry) + } + if len(options.ContactExtraTags) != 2 || options.ContactExtraTags[0] != "+g.3gpp.mid-call" || options.ContactExtraTags[1] != "+g.3gpp.smsip" { + t.Fatalf("giffgaff Contact tags = %#v", options.ContactExtraTags) + } +} + func TestResolveCarrierProfileATT(t *testing.T) { profile := ResolveCarrierProfile(SIMIdentity{ ICCID: "8901410000000000001", IMSI: "310410000000001", HomeMCC: "310", HomeMNC: "410", @@ -79,6 +104,9 @@ func TestResolveCarrierProfileStandardHasNoRegisterOverrides(t *testing.T) { if profile.IMSRegisterOptions.SupportedHeader != nil { t.Fatalf("standard supported header = %v", *profile.IMSRegisterOptions.SupportedHeader) } + if profile.PANIEnabled != nil || profile.PANICountry != "" { + t.Fatalf("standard PANI behavior = enabled=%v country=%q", profile.PANIEnabled, profile.PANICountry) + } if profile.AllowSMSWithoutContactConfirmation { t.Fatal("standard profile should require SMS contact confirmation") } diff --git a/internal/vowifi/carrier_ipcc.go b/internal/vowifi/carrier_ipcc.go index f2b39fd..fa10c82 100644 --- a/internal/vowifi/carrier_ipcc.go +++ b/internal/vowifi/carrier_ipcc.go @@ -635,6 +635,11 @@ func importCarrierIMS(rule *carrierProfileRule, plists []ipccPlist, warnings *ip warnings.add("disabled_ims_ipsec_ignored", "UseIPSec=false was not imported because VoWiFi IMS security cannot be weakened automatically", document.name+":"+strings.Join(signaling.path, ".")+".UseIPSec") } } + if strings.EqualFold(plistString(signaling.value["CountryOfOriginationFormat"]), "PANI") { + enabled := true + rule.IMS.PANIEnabled = &enabled + rule.IMS.PANICountry = "AUTO" + } } } if useIPSec { @@ -661,10 +666,6 @@ func inspectIgnoredCarrierFields(plists []ipccPlist, warnings *ipccWarningSet) { warnings.add("apn_settings_ignored", "APN settings and credentials are outside the VoCat carrier-profile importer", fullPath) case key == "media" && strings.Contains(strings.ToLower(strings.Join(keyPath, ".")), "imsconfig"): warnings.add("device_media_overrides_ignored", "device-family media and codec overrides require hardware validation and were not imported", fullPath) - case key == "countryoforiginationformat": - // PANI is access/session metadata, not a carrier location constant. - // The IMS runtime provides one globally and consistently across - // REGISTER, MESSAGE, RP-ACK and dialogs, so no profile field is needed. case strings.Contains(key, "emergency") || strings.Contains(key, "e911"): warnings.add("emergency_settings_ignored", "emergency-service settings are never imported", fullPath) } diff --git a/internal/vowifi/carrier_ipcc_test.go b/internal/vowifi/carrier_ipcc_test.go index 3634fdc..c5656b1 100644 --- a/internal/vowifi/carrier_ipcc_test.go +++ b/internal/vowifi/carrier_ipcc_test.go @@ -76,6 +76,9 @@ func TestImportCarrierIPCCConvertsBinaryAndXMLPlistsSafely(t *testing.T) { if rule.IMS.IPSecEncryption != "aes-cbc" { t.Fatalf("converted IMS profile = %#v", rule.IMS) } + if rule.IMS.PANIEnabled == nil || !*rule.IMS.PANIEnabled || rule.IMS.PANICountry != "AUTO" { + t.Fatalf("converted PANI behavior = enabled=%v country=%q", rule.IMS.PANIEnabled, rule.IMS.PANICountry) + } for _, code := range []string{ "remote_certificate_bypass_ignored", "disabled_dpd_ignored", diff --git a/internal/vowifi/carrier_profiles.json b/internal/vowifi/carrier_profiles.json index f77b24d..1b1d55c 100644 --- a/internal/vowifi/carrier_profiles.json +++ b/internal/vowifi/carrier_profiles.json @@ -5786,7 +5786,18 @@ "proposal": "modern" }, "ims": { - "ipsec_encryption": "aes-cbc" + "transport": "tcp", + "ipsec_encryption": "aes-cbc", + "pani_enabled": true, + "pani_country": "AUTO", + "user_agent": "iOS/18.6.2 iPhone", + "register_options": { + "contact_format": "gsma", + "contact_extra_tags": [ + "+g.3gpp.mid-call", + "+g.3gpp.smsip" + ] + } } }, { diff --git a/internal/vowifi/ike/esp.go b/internal/vowifi/ike/esp.go index 59b3e97..6681e67 100644 --- a/internal/vowifi/ike/esp.go +++ b/internal/vowifi/ike/esp.go @@ -393,9 +393,11 @@ func parseInnerIPv6(packet []byte) (innerPacketMetadata, error) { return innerPacketMetadata{}, errors.New("ike: inner IPv6 packet is truncated") } payloadLength := int(binary.BigEndian.Uint16(packet[4:6])) - if payloadLength+40 != len(packet) { + declaredLength := payloadLength + 40 + if declaredLength > len(packet) { return innerPacketMetadata{}, errors.New("ike: inner IPv6 payload length is invalid") } + packet = packet[:declaredLength] metadata := innerPacketMetadata{ source: append(net.IP(nil), packet[8:24]...), destination: append(net.IP(nil), packet[24:40]...), diff --git a/internal/vowifi/ike/esp_test.go b/internal/vowifi/ike/esp_test.go index 00f8cc4..708a92e 100644 --- a/internal/vowifi/ike/esp_test.go +++ b/internal/vowifi/ike/esp_test.go @@ -318,6 +318,26 @@ func TestParseInnerIPv6ESP(t *testing.T) { } } +func TestParseInnerIPv6ESPTrimsTrailingAlignmentBytes(t *testing.T) { + t.Parallel() + packet := make([]byte, 40+20+4) + packet[0] = 0x60 + binary.BigEndian.PutUint16(packet[4:6], 20) + packet[6] = 6 + packet[7] = 64 + copy(packet[8:24], net.ParseIP("2001:db8::1").To16()) + copy(packet[24:40], net.ParseIP("2001:db8::2").To16()) + binary.BigEndian.PutUint16(packet[40:42], 49686) + binary.BigEndian.PutUint16(packet[42:44], 5060) + metadata, err := parseInnerPacket(packet) + if err != nil { + t.Fatal(err) + } + if metadata.protocol != 6 || metadata.sourcePort != 49686 || metadata.destinationPort != 5060 { + t.Fatalf("metadata = %+v", metadata) + } +} + func mustDefaultESPTunnel(t *testing.T) *espTunnel { t.Helper() return mustTestESPTunnel( diff --git a/internal/vowifi/ims/call_runtime.go b/internal/vowifi/ims/call_runtime.go index ca00c04..1105122 100644 --- a/internal/vowifi/ims/call_runtime.go +++ b/internal/vowifi/ims/call_runtime.go @@ -116,7 +116,7 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal "P-Preferred-Service: "+mmtelServiceURN, `Accept-Contact: *;+g.3gpp.icsi-ref="`+mmtelFeatureTag+`"`, "P-Access-Network-Info: "+session.pAccessNetworkInfo(), - "User-Agent: "+session.callUserAgent(), + "User-Agent: "+session.imsUserAgent(), "Allow: INVITE, ACK, CANCEL, BYE, OPTIONS, MESSAGE, PRACK, UPDATE, INFO", "Supported: 100rel, timer, replaces", "Session-Expires: 1800;refresher=uac", @@ -500,7 +500,7 @@ func (session *Session) sendRejectedInviteACK(call *imsCall, response *sipRespon "Call-ID: "+call.callID, fmt.Sprintf("CSeq: %d ACK", call.cseq), "P-Access-Network-Info: "+session.pAccessNetworkInfo(), - "User-Agent: "+session.callUserAgent(), + "User-Agent: "+session.imsUserAgent(), "Content-Length: 0", "", "", ) session.writeMu.Lock() @@ -578,7 +578,7 @@ func (session *Session) sendPRACK(call *imsCall, response *sipResponse) { "From: "+from, "To: "+to, "Call-ID: "+call.callID, fmt.Sprintf("CSeq: %d PRACK", cseq), "RAck: "+rseq+" "+inviteCSeq, "P-Access-Network-Info: "+session.pAccessNetworkInfo(), - "User-Agent: "+session.callUserAgent(), + "User-Agent: "+session.imsUserAgent(), "Content-Length: 0", "", "", ) ctx, cancel := context.WithTimeout(session.refreshContext, 10*time.Second) @@ -709,7 +709,7 @@ func (session *Session) buildDialogRequest(call *imsCall, method string, cseq ui "Call-ID: "+call.callID, fmt.Sprintf("CSeq: %d %s", cseq, method), "Supported: 100rel, timer", - "User-Agent: "+session.callUserAgent(), + "User-Agent: "+session.imsUserAgent(), ) if method != "CANCEL" { lines = append(lines, "P-Access-Network-Info: "+session.pAccessNetworkInfo()) @@ -771,6 +771,13 @@ func (session *Session) dialogContactHeader() string { if session == nil || session.conn == nil || strings.TrimSpace(session.identity.user) == "" { return "" } + if session.imsRegisterOptions().ContactFormat == vowifi.IMSContactFormatGSMA { + contact := "Contact: ;+g.3gpp.icsi-ref="` + mmtelFeatureTag + `"` + if strings.TrimSpace(session.instanceID) != "" { + contact += `;+sip.instance="<` + session.instanceID + `>"` + } + return contact + } contact := "Contact: " if strings.TrimSpace(session.instanceID) != "" { contact += `;+sip.instance="<` + session.instanceID + `>"` @@ -811,19 +818,13 @@ func (session *Session) callOriginatingIdentitiesLocked(profile vowifi.CarrierPr } func (session *Session) pAccessNetworkInfo() string { + if session == nil { + return "" + } if session.paniResolved { - return ueProvidedPANI(session.pani) + return session.pani } - return sessionPAccessNetworkInfo(session.instanceID) -} - -func (session *Session) callUserAgent() string { - if session != nil && session.provider != nil { - if value := strings.TrimSpace(session.provider.config.UserAgent); value != "" { - return value - } - } - return "vocat/1" + return resolveSessionPAccessNetworkInfo(session.request.Identity, session.imsLogger()) } func callResponseDiagnostic(response *sipResponse) string { diff --git a/internal/vowifi/ims/call_runtime_test.go b/internal/vowifi/ims/call_runtime_test.go index 18f7c37..fe96d3b 100644 --- a/internal/vowifi/ims/call_runtime_test.go +++ b/internal/vowifi/ims/call_runtime_test.go @@ -229,7 +229,7 @@ func TestOutgoingLocalNumberUsesIMSPhoneContextAndMMTelHeaders(t *testing.T) { "P-Preferred-Identity: \r\n", "P-Preferred-Service: " + mmtelServiceURN + "\r\n", `Accept-Contact: *;+g.3gpp.icsi-ref="` + mmtelFeatureTag + `"` + "\r\n", - "P-Access-Network-Info: " + sessionPAccessNetworkInfo(session.instanceID) + "\r\n", + "P-Access-Network-Info: IEEE-802.11;i-wlan-node-id=" + defaultPANIWLANNode + "\r\n", "User-Agent: VoCat Test\r\n", "Accept: application/sdp\r\n", } { diff --git a/internal/vowifi/ims/provider.go b/internal/vowifi/ims/provider.go index ce72c02..599eb66 100644 --- a/internal/vowifi/ims/provider.go +++ b/internal/vowifi/ims/provider.go @@ -4,7 +4,6 @@ import ( "bufio" "context" "crypto/rand" - "crypto/sha256" "encoding/base64" "encoding/hex" "errors" @@ -24,6 +23,7 @@ const ( defaultRegistrationExpiry = 3600 * time.Second defaultTransactionTimeout = 12 * time.Second maxAuthenticationChallenges = 3 + defaultPANIWLANNode = "ffffffffffff" ) var ( @@ -649,6 +649,11 @@ func newSession( if err != nil { return nil, err } + instanceURI := "urn:uuid:" + instanceID + profile := vowifi.ResolveCarrierProfile(request.Identity) + if profile.IMSRegisterOptions.ContactFormat == vowifi.IMSContactFormatGSMA { + instanceURI = sipInstanceID(request.Identity, instanceID) + } refreshContext, refreshCancel := context.WithCancel(context.Background()) session := &Session{ provider: provider, @@ -660,8 +665,8 @@ func newSession( conn: connection, callID: callToken + "@" + addressHost(connection.LocalAddr()), fromTag: fromTag, - instanceID: "urn:uuid:" + instanceID, - pani: resolveSessionPAccessNetworkInfo(request.Identity, "urn:uuid:"+instanceID), + instanceID: instanceURI, + pani: resolveSessionPAccessNetworkInfo(request.Identity, provider.config.Logger), paniResolved: true, cseq: 1, refreshContext: refreshContext, @@ -970,11 +975,7 @@ func (session *Session) buildRegister( allow = *registerOptions.AllowHeader } - userAgent := strings.TrimSpace(session.provider.config.UserAgent) - if override := strings.TrimSpace(registerOptions.UserAgent); override != "" && - (userAgent == "" || userAgent == "vocat/1") { - userAgent = override - } + userAgent := session.imsUserAgent() lines := []string{ "REGISTER " + requestURI + " SIP/2.0", @@ -1064,6 +1065,15 @@ func (session *Session) buildContact(contactAddress string, registerOptions vowi `%s%s;audio;+g.3gpp.smsip;+g.3gpp.icsi-ref="%s";+sip.instance="<%s>"`, base, extra, icsiRef, instanceID, ) + case vowifi.IMSContactFormatGSMA: + extra := "" + for _, tag := range registerOptions.ContactExtraTags { + extra += ";" + tag + } + return fmt.Sprintf( + `;+g.3gpp.icsi-ref="%s"%s;+sip.instance="<%s>"`, + contactAddress, icsiRef, extra, instanceID, + ) default: extra := "" for _, tag := range registerOptions.ContactExtraTags { @@ -1076,41 +1086,127 @@ func (session *Session) buildContact(contactAddress string, registerOptions vowi } } -// sessionPAccessNetworkInfo creates a syntactically valid, locally -// administered unicast WLAN node identifier from the already-random SIP -// instance ID. It discloses neither a real BSSID nor subscriber identity, but -// remains stable for every transaction belonging to this IMS registration. -func sessionPAccessNetworkInfo(instanceID string) string { - instanceID = strings.TrimSpace(instanceID) - if instanceID == "" { - return "" +// sipInstanceID uses the standardized GSMA device-instance URI when a valid +// modem identity is available and keeps the generated UUID as the fallback. +func sipInstanceID(identity vowifi.SIMIdentity, fallback string) string { + imei := strings.TrimSpace(identity.IMEI) + if len(imei) == 15 { + valid := true + for _, digit := range imei { + if digit < '0' || digit > '9' { + valid = false + break + } + } + if valid { + return "urn:gsma:imei:" + imei + "-0" + } } - digest := sha256.Sum256([]byte(instanceID)) - digest[0] = (digest[0] | 0x02) & 0xfe // locally administered, unicast - return "IEEE-802.11;i-wlan-node-id=" + hex.EncodeToString(digest[:6]) + return "urn:uuid:" + strings.TrimSpace(fallback) +} + +func (session *Session) imsRegisterOptions() vowifi.IMSRegisterOptions { + if session == nil { + return vowifi.IMSRegisterOptions{} + } + return vowifi.ResolveCarrierProfile(session.request.Identity).IMSRegisterOptions +} + +func (session *Session) imsUserAgent() string { + if session != nil && session.provider != nil { + if value := strings.TrimSpace(session.provider.config.UserAgent); value != "" { + if value != "vocat/1" { + return value + } + } + } + if session != nil { + profile := vowifi.ResolveCarrierProfile(session.request.Identity) + if value := strings.TrimSpace(profile.IMSUserAgent); value != "" { + return value + } + } + if session != nil && session.provider != nil { + if value := strings.TrimSpace(session.provider.config.UserAgent); value != "" { + return value + } + } + return "vocat/1" +} + +func (session *Session) imsLogger() *slog.Logger { + if session != nil && session.provider != nil && session.provider.config.Logger != nil { + return session.provider.config.Logger + } + return slog.Default() } // resolveSessionPAccessNetworkInfo freezes the selected value when the IMS // session is created. This prevents a carrier-profile reload from changing // access identity between REGISTER, SMS MESSAGE and its RP-ACK. -func resolveSessionPAccessNetworkInfo(identity vowifi.SIMIdentity, instanceID string) string { +func resolveSessionPAccessNetworkInfo(identity vowifi.SIMIdentity, logger *slog.Logger) string { + if logger == nil { + logger = slog.Default() + } profile := vowifi.ResolveCarrierProfile(identity) + if profile.PANIEnabled != nil && !*profile.PANIEnabled { + return "" + } if configured := profile.IMSRegisterOptions.PAccessNetworkInfo; configured != nil { - return ueProvidedPANI(*configured) + return appendPaniCountry(ueProvidedPANI(*configured), identity, profile, logger) } node := strings.ToLower(strings.TrimSpace(profile.PANINode)) if decoded, err := hex.DecodeString(node); err != nil || len(decoded) != 6 { - node = strings.TrimPrefix(sessionPAccessNetworkInfo(instanceID), "IEEE-802.11;i-wlan-node-id=") + node = defaultPANIWLANNode } if node == "" { return "" } value := "IEEE-802.11;i-wlan-node-id=" + node - if country := strings.ToUpper(strings.TrimSpace(profile.PANICountry)); country != "" { - value += ";country=" + country + return appendPaniCountry(value, identity, profile, logger) +} + +func appendPaniCountry(value string, identity vowifi.SIMIdentity, profile vowifi.CarrierProfile, logger *slog.Logger) string { + value = strings.TrimSpace(value) + if value == "" { + return value } - return value + parts := strings.Split(value, ";") + for _, parameter := range parts { + if strings.HasPrefix(strings.ToLower(strings.TrimSpace(parameter)), "country=") { + return value + } + } + + countryMode := strings.ToUpper(strings.TrimSpace(profile.PANICountry)) + country := countryMode + if countryMode == "AUTO" { + mcc := strings.TrimSpace(identity.HomeMCC) + if mcc == "" { + mcc = strings.TrimSpace(profile.RouteMCC) + } + country = vowifi.CountryCodeForMCC(mcc) + if country == "" { + if logger == nil { + logger = slog.Default() + } + logger.Error("IMS PANI country code could not be derived", + "category", "ims", + "stage", "pani_country", + "carrier_profile", profile.ID, + "mcc", mcc, + ) + return value + } + } + if country == "" { + return value + } + parts = append(parts, "") + copy(parts[2:], parts[1:]) + parts[1] = "country=" + country + return strings.Join(parts, ";") } // ueProvidedPANI removes the network-provided marker from a profile override. @@ -1263,6 +1359,7 @@ func (session *Session) applyRegistrationEvidence(response *sipResponse) error { serviceRoutes := splitHeaderValues(response.values("Service-Route")) registeredContact := "" smsConfirmed := false + profile := vowifi.ResolveCarrierProfile(session.request.Identity) instanceLower := strings.ToLower(session.instanceID) contactURILower := strings.ToLower(fmt.Sprintf( "sip:%s@%s;transport=%s", @@ -1270,10 +1367,15 @@ func (session *Session) applyRegistrationEvidence(response *sipResponse) error { session.contactAddress(), session.transport, )) + contactAddressLower := "" + if profile.IMSRegisterOptions.ContactFormat == vowifi.IMSContactFormatGSMA { + contactAddressLower = strings.ToLower("sip:" + session.contactAddress()) + } for _, contact := range contacts { lower := strings.ToLower(contact) matchesThisSession := strings.Contains(lower, instanceLower) || - strings.Contains(lower, contactURILower) + strings.Contains(lower, contactURILower) || + (contactAddressLower != "" && strings.Contains(lower, contactAddressLower)) if matchesThisSession { registeredContact = contact smsConfirmed = strings.Contains(lower, "+g.3gpp.smsip") diff --git a/internal/vowifi/ims/provider_test.go b/internal/vowifi/ims/provider_test.go index 714c968..6b9cc77 100644 --- a/internal/vowifi/ims/provider_test.go +++ b/internal/vowifi/ims/provider_test.go @@ -506,13 +506,8 @@ func serveRegistration(listener *net.UDPConn, nonce string, confirmSMS bool) err } func TestSessionPAccessNetworkInfoIsStableAndUEProvided(t *testing.T) { - instanceID := "urn:uuid:00000000-0000-4000-8000-000000000001" - first := sessionPAccessNetworkInfo(instanceID) - second := sessionPAccessNetworkInfo(instanceID) - if first != second { - t.Fatalf("PANI changed for one SIP instance: %q != %q", first, second) - } - if err := validateTestPANI(first); err != nil { + defaultPANI := "IEEE-802.11;i-wlan-node-id=" + defaultPANIWLANNode + if err := validateTestPANI(defaultPANI); err != nil { t.Fatal(err) } if got := ueProvidedPANI(" IEEE-802.11;i-wlan-node-id=aabbccddeeff;network-provided "); got != "IEEE-802.11;i-wlan-node-id=aabbccddeeff" { @@ -521,23 +516,149 @@ func TestSessionPAccessNetworkInfoIsStableAndUEProvided(t *testing.T) { if got := ueProvidedPANI("network-provided"); got != "" { t.Fatalf("marker-only PANI = %q, want empty", got) } - if got := (&Session{paniResolved: true}).pAccessNetworkInfo(); got != "" { - t.Fatalf("explicitly omitted session PANI = %q, want empty", got) + if got := (&Session{pani: "IEEE-802.11;i-wlan-node-id=" + defaultPANIWLANNode, paniResolved: true}).pAccessNetworkInfo(); got != "IEEE-802.11;i-wlan-node-id="+defaultPANIWLANNode { + t.Fatalf("session PANI = %q, want default WLAN node", got) + } +} + +func TestPAccessNetworkInfoUsesDefaultNodeAndConditionalCountry(t *testing.T) { + cases := []struct { + name string + identity vowifi.SIMIdentity + want string + }{ + { + name: "standard without PANI country format", + identity: vowifi.SIMIdentity{IMSI: "001010123456789", HomeMCC: "001", HomeMNC: "01"}, + want: "IEEE-802.11;i-wlan-node-id=" + defaultPANIWLANNode, + }, + { + name: "giffgaff with IPCC PANI country format", + identity: vowifi.SIMIdentity{IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10", GID1: "508FFFFF"}, + want: "IEEE-802.11;country=GB;i-wlan-node-id=" + defaultPANIWLANNode, + }, + { + name: "AT&T without PANI country format", + identity: vowifi.SIMIdentity{IMSI: "310410000000001", HomeMCC: "310", HomeMNC: "410"}, + want: "IEEE-802.11;i-wlan-node-id=" + defaultPANIWLANNode, + }, + { + name: "VOXI without PANI country format", + identity: vowifi.SIMIdentity{IMSI: "234150000000001", HomeMCC: "234", HomeMNC: "15", SPN: "VOXI"}, + want: "IEEE-802.11;i-wlan-node-id=" + defaultPANIWLANNode, + }, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + got := resolveSessionPAccessNetworkInfo(test.identity, slog.New(slog.NewTextHandler(io.Discard, nil))) + if got != test.want { + t.Fatalf("PANI = %q, want %q", got, test.want) + } + }) + } +} + +func TestAppendPaniCountryModes(t *testing.T) { + base := "IEEE-802.11;i-wlan-node-id=" + defaultPANIWLANNode + identity := vowifi.SIMIdentity{HomeMCC: "234"} + + if got := appendPaniCountry(base, identity, vowifi.CarrierProfile{}, slog.Default()); got != base { + t.Fatalf("empty PANI country = %q, want %q", got, base) + } + if got := appendPaniCountry(base, identity, vowifi.CarrierProfile{PANICountry: "GB"}, slog.Default()); got != "IEEE-802.11;country=GB;i-wlan-node-id="+defaultPANIWLANNode { + t.Fatalf("fixed PANI country = %q", got) + } + if got := appendPaniCountry(base, identity, vowifi.CarrierProfile{PANICountry: "AUTO"}, slog.Default()); got != "IEEE-802.11;country=GB;i-wlan-node-id="+defaultPANIWLANNode { + t.Fatalf("automatic PANI country = %q", got) + } + + var logs strings.Builder + logger := slog.New(slog.NewTextHandler(&logs, nil)) + got := appendPaniCountry(base, vowifi.SIMIdentity{}, vowifi.CarrierProfile{ID: "test-auto", PANICountry: "AUTO"}, logger) + if got != base || !strings.Contains(logs.String(), "IMS PANI country code could not be derived") { + t.Fatalf("failed automatic PANI country = %q, logs = %q", got, logs.String()) + } +} + +func TestIMSProfileUserAgentUsesUnifiedHeaderValue(t *testing.T) { + giffgaff := &Session{request: vowifi.IMSRequest{Identity: vowifi.SIMIdentity{ + IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10", GID1: "508FFFFF", + }}} + if got := giffgaff.imsUserAgent(); got != "iOS/18.6.2 iPhone" { + t.Fatalf("giffgaff IMS User-Agent = %q", got) + } + if options := giffgaff.imsRegisterOptions(); options.AllowHeader != nil || options.SupportedHeader != nil { + t.Fatalf("giffgaff REGISTER capability overrides leaked from business headers: %#v", options) + } + + standard := &Session{request: vowifi.IMSRequest{Identity: vowifi.SIMIdentity{ + IMSI: "999010000000001", HomeMCC: "999", HomeMNC: "01", + }}} + if got := standard.imsUserAgent(); got != "vocat/1" { + t.Fatalf("standard IMS User-Agent fallback = %q", got) + } +} + +func TestSipInstanceIDUsesGSMAFormWhenIMEIIsAvailable(t *testing.T) { + identity := vowifi.SIMIdentity{IMEI: "353024112557010"} + if got := sipInstanceID(identity, "00000000-0000-4000-8000-000000000001"); got != "urn:gsma:imei:353024112557010-0" { + t.Fatalf("sipInstanceID() = %q", got) + } + if got := sipInstanceID(vowifi.SIMIdentity{IMEI: "not-an-imei"}, "00000000-0000-4000-8000-000000000001"); got != "urn:uuid:00000000-0000-4000-8000-000000000001" { + t.Fatalf("sipInstanceID() fallback = %q", got) + } +} + +func TestGSMAContactFormatUsesAddressAndDeviceInstance(t *testing.T) { + session := &Session{ + identity: identitySet{user: "234105776448519"}, + transport: "tcp", + instanceID: "urn:gsma:imei:353024112557010-0", + } + got := session.buildContact("[2001:db8::1]:49686", vowifi.IMSRegisterOptions{ + ContactFormat: vowifi.IMSContactFormatGSMA, + ContactExtraTags: []string{"+g.3gpp.mid-call", "+g.3gpp.smsip"}, + }) + want := `;+g.3gpp.icsi-ref="urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel";+g.3gpp.mid-call;+g.3gpp.smsip;+sip.instance=""` + if got != want { + t.Fatalf("GSMA Contact = %q, want %q", got, want) } } func validateTestPANI(value string) error { - const prefix = "IEEE-802.11;i-wlan-node-id=" - if !strings.HasPrefix(value, prefix) { - return fmt.Errorf("value %q does not start with %q", value, prefix) + const accessType = "IEEE-802.11" + if !strings.HasPrefix(value, accessType+";") { + return fmt.Errorf("value %q does not start with %q", value, accessType+";") } if strings.Contains(strings.ToLower(value), "network-provided") { return fmt.Errorf("UE PANI incorrectly claims network-provided provenance: %q", value) } - node, err := hex.DecodeString(strings.TrimPrefix(value, prefix)) + var nodeValue, country string + for _, parameter := range strings.Split(strings.TrimPrefix(value, accessType+";"), ";") { + key, parameterValue, ok := strings.Cut(parameter, "=") + if !ok { + continue + } + switch strings.ToLower(strings.TrimSpace(key)) { + case "i-wlan-node-id": + nodeValue = strings.TrimSpace(parameterValue) + case "country": + country = strings.TrimSpace(parameterValue) + } + } + if nodeValue == "" { + return fmt.Errorf("i-wlan-node-id is missing: %q", value) + } + node, err := hex.DecodeString(nodeValue) if err != nil || len(node) != 6 { return fmt.Errorf("i-wlan-node-id must be 12 hexadecimal digits: %q", value) } + if strings.EqualFold(nodeValue, defaultPANIWLANNode) { + if country != "" && len(country) != 2 { + return fmt.Errorf("country must be an ISO alpha-2 code: %q", value) + } + return nil + } if node[0]&0x03 != 0x02 { return fmt.Errorf("i-wlan-node-id must be a locally administered unicast identifier: %q", value) } diff --git a/internal/vowifi/ims/sms_runtime.go b/internal/vowifi/ims/sms_runtime.go index b1382d9..ec55dd9 100644 --- a/internal/vowifi/ims/sms_runtime.go +++ b/internal/vowifi/ims/sms_runtime.go @@ -1177,6 +1177,7 @@ func (session *Session) sendSIPMessageWith( lines = append(lines, "Request-Disposition: no-fork", "Allow: MESSAGE", + "User-Agent: "+session.imsUserAgent(), ) if inReplyTo != "" { lines = append(lines, "In-Reply-To: "+inReplyTo)