diff --git a/cmd/vocat/main.go b/cmd/vocat/main.go index acfe769..caebec8 100644 --- a/cmd/vocat/main.go +++ b/cmd/vocat/main.go @@ -721,27 +721,19 @@ func newVoWiFiOrchestrator( if apn == "" { apn = "ims" } - tunnelProvider, err := ike.NewProvider(ike.Config{APN: apn}) + tunnelProvider, err := ike.NewProvider(ike.Config{ + APN: apn, Logger: logger, AutoProposalFallback: true, + }) if err != nil { return nil, fmt.Errorf("device %q IKE provider: %w", deviceConfig.ID, err) } imsProvider, err := ims.NewProvider(adapter, ims.Config{ Logger: logger, - // The userspace SWu data plane carries protected P-CSCF signalling over - // TCP by default. UK PLMN 234-10 exposes its P-CSCF over UDP/5060 on SWu. - Transport: "tcp", - TransportByPLMN: map[string]string{ - "23410": "udp", - "234010": "udp", - }, - // Some UK SIM profiles leave EF_SMSP/AT+CSCA empty. Keep fallbacks scoped - // to their HPLMN so an O2/giffgaff SIM can never inherit Vodafone's SMSC. - SMSCenterByPLMN: map[string]string{ - "23410": "+447802000332", - "234010": "+447802000332", - "23415": "+447785016005", - "234015": "+447785016005", - }, + // Carrier-specific transport and SMSC defaults live in the shared data + // profile. Prefer network-provided P-CSCF hints, then safely try the + // alternate transport only if no SIP response was observed. + Transport: "tcp", + AutoTransportFallback: true, OnSMS: func(ctx context.Context, message ims.ReceivedSMS) error { extra, _ := json.Marshal(map[string]any{ "transport": "ims", diff --git a/cmd/vocat/native_qmi_vowifi.go b/cmd/vocat/native_qmi_vowifi.go index e4ce41b..0a02b45 100644 --- a/cmd/vocat/native_qmi_vowifi.go +++ b/cmd/vocat/native_qmi_vowifi.go @@ -4,6 +4,7 @@ import ( "context" "vocat/internal/device" + "vocat/internal/vowifi" "vocat/internal/vowifi/integration" ) @@ -30,6 +31,10 @@ func (mapper nativeQMIControllerMapper) ReadNativeQMIIdentity(ctx context.Contex return mapper.Devices.ReadNativeQMIIdentity(ctx, physical) } +func (mapper nativeQMIControllerMapper) ReadSIMMetadata(ctx context.Context, id string) (vowifi.SIMMetadata, error) { + return mapper.Mapper.ReadSIMMetadata(ctx, id) +} + func (mapper nativeQMIControllerMapper) ProbeNativeQMIApplication(ctx context.Context, id, preference string) ([]byte, string, error) { physical, err := mapper.physical(id) if err != nil { diff --git a/internal/server/device_api.go b/internal/server/device_api.go index ca07ff9..601f612 100644 --- a/internal/server/device_api.go +++ b/internal/server/device_api.go @@ -1686,56 +1686,60 @@ func storedVoWiFiRuntime(runtime store.VoWiFiRuntime) map[string]any { enabled, _ := extra["enabled"].(bool) active, _ := extra["active"].(bool) return map[string]any{ - "device_id": runtime.DeviceID, - "phase": runtime.Phase, - "enabled": enabled, - "active": active, - "dataplane_mode": runtime.DataplaneMode, - "iccid": runtime.ICCID, - "imsi": runtime.IMSI, - "sim_ready": runtime.SIMReady, - "access_ready": runtime.AccessReady, - "tunnel_ready": runtime.TunnelReady, - "ims_ready": runtime.IMSReady, - "sms_ready": runtime.SMSReady, - "reg_status": runtime.RegStatus, - "reg_status_text": runtime.RegStatusText, - "network_mode": runtime.NetworkMode, - "local_phone": runtime.LocalPhone, - "phone_number_source": runtime.PhoneNumberSource, - "last_error_class": runtime.LastErrorClass, - "last_error": runtime.LastError, - "last_reason": runtime.LastReason, - "updated_at": runtime.UpdatedAt, - "tunnel": rawJSONObject(runtime.Tunnel), - "imscore": rawJSONObject(runtime.IMSCore), - "smsip": rawJSONObject(runtime.SMSIP), + "device_id": runtime.DeviceID, + "phase": runtime.Phase, + "enabled": enabled, + "active": active, + "carrier_profile": extra["carrier_profile"], + "carrier_profile_from": extra["carrier_profile_from"], + "dataplane_mode": runtime.DataplaneMode, + "iccid": runtime.ICCID, + "imsi": runtime.IMSI, + "sim_ready": runtime.SIMReady, + "access_ready": runtime.AccessReady, + "tunnel_ready": runtime.TunnelReady, + "ims_ready": runtime.IMSReady, + "sms_ready": runtime.SMSReady, + "reg_status": runtime.RegStatus, + "reg_status_text": runtime.RegStatusText, + "network_mode": runtime.NetworkMode, + "local_phone": runtime.LocalPhone, + "phone_number_source": runtime.PhoneNumberSource, + "last_error_class": runtime.LastErrorClass, + "last_error": runtime.LastError, + "last_reason": runtime.LastReason, + "updated_at": runtime.UpdatedAt, + "tunnel": rawJSONObject(runtime.Tunnel), + "imscore": rawJSONObject(runtime.IMSCore), + "smsip": rawJSONObject(runtime.SMSIP), } } func liveVoWiFiRuntime(runtime vowifi.State) map[string]any { return map[string]any{ - "device_id": runtime.DeviceID, - "phase": string(runtime.Phase), - "enabled": runtime.Enabled, - "active": runtime.Active, - "dataplane_mode": runtime.DataplaneMode, - "iccid": runtime.ICCID, - "imsi": runtime.IMSI, - "sim_ready": runtime.SIMReady, - "access_ready": runtime.AccessReady, - "tunnel_ready": runtime.TunnelReady, - "ims_ready": runtime.IMSReady, - "sms_ready": runtime.SMSReady, - "reg_status": map[bool]int{true: 1, false: 0}[runtime.IMSReady], - "reg_status_text": map[bool]string{true: "registered", false: "not registered"}[runtime.IMSReady], - "network_mode": "Wi-Fi", - "local_phone": runtime.PhoneNumber, - "phone_number_source": runtime.PhoneNumberSource, - "last_error_class": runtime.LastErrorClass, - "last_error": runtime.LastError, - "last_reason": runtime.LastReason, - "updated_at": runtime.UpdatedAt, + "device_id": runtime.DeviceID, + "phase": string(runtime.Phase), + "enabled": runtime.Enabled, + "active": runtime.Active, + "carrier_profile": runtime.CarrierProfile, + "carrier_profile_from": runtime.CarrierProfileFrom, + "dataplane_mode": runtime.DataplaneMode, + "iccid": runtime.ICCID, + "imsi": runtime.IMSI, + "sim_ready": runtime.SIMReady, + "access_ready": runtime.AccessReady, + "tunnel_ready": runtime.TunnelReady, + "ims_ready": runtime.IMSReady, + "sms_ready": runtime.SMSReady, + "reg_status": map[bool]int{true: 1, false: 0}[runtime.IMSReady], + "reg_status_text": map[bool]string{true: "registered", false: "not registered"}[runtime.IMSReady], + "network_mode": "Wi-Fi", + "local_phone": runtime.PhoneNumber, + "phone_number_source": runtime.PhoneNumberSource, + "last_error_class": runtime.LastErrorClass, + "last_error": runtime.LastError, + "last_reason": runtime.LastReason, + "updated_at": runtime.UpdatedAt, "tunnel": map[string]any{ "established": runtime.TunnelReady, "name": runtime.TunnelName, diff --git a/internal/vowifi/carrier_compat.go b/internal/vowifi/carrier_compat.go index 8283389..4a13953 100644 --- a/internal/vowifi/carrier_compat.go +++ b/internal/vowifi/carrier_compat.go @@ -1,52 +1,338 @@ package vowifi import ( + _ "embed" + "encoding/json" "fmt" "strings" ) -const att310280EPDG = "epdg.epc.att.net" +const ( + CarrierProfileStandard = "standard-3gpp" + IKEProposalModern = "modern" + IKEProposalLegacy = "legacy-sha1-modp1024" + IMSProfileStandard = "standard" + IMSProfileO2Germany = "o2-germany" + IMSProfileATT = "att" +) -// AssignedRoutePLMN returns a narrowly matched ePDG route PLMN without -// changing the subscription PLMN used for AKA identities. Some multi-profile -// and MVNO SIMs authenticate against their own HPLMN but use a host network's -// VoWiFi access gateway. +// CarrierProfile contains only interoperability choices that cannot be +// reliably discovered from the SIM or negotiated with the network. All +// protocol layers consume this common result so their carrier handling cannot +// drift into separate MCC/MNC switch statements. +type CarrierProfile struct { + ID string + MatchSource string + RouteMCC string + RouteMNC string + EPDG string + IKEProposal string + AdvertiseEAPOnly bool + IMSTransport string + IMSIdentityProfile string + IMSRegisterProfile string + IMSIPSecEncryption string + SMSCenter string +} + +type carrierProfileDocument struct { + Version int `json:"version"` + Profiles []carrierProfileRule `json:"profiles"` +} + +type carrierProfileRule struct { + ID string `json:"id"` + Match carrierProfileMatch `json:"match"` + Route carrierProfileRoute `json:"route"` + EPDG carrierProfileEPDG `json:"epdg"` + IKE carrierProfileIKE `json:"ike"` + IMS carrierProfileIMS `json:"ims"` +} + +type carrierProfileMatch struct { + HomePLMNs []string `json:"home_plmns"` + IMSIPrefixes []string `json:"imsi_prefixes"` + ICCIDPrefixes []string `json:"iccid_prefixes"` + SPNs []string `json:"spns"` + GID1Prefixes []string `json:"gid1_prefixes"` + GID2Prefixes []string `json:"gid2_prefixes"` +} + +type carrierProfileRoute struct { + MCC string `json:"mcc"` + MNC string `json:"mnc"` +} + +type carrierProfileEPDG struct { + Hostname string `json:"hostname"` + DNSHosts []string `json:"dns_hosts"` + DNSClientSubnet string `json:"dns_client_subnet"` +} + +type carrierProfileIKE struct { + Proposal string `json:"proposal"` + AdvertiseEAPOnly *bool `json:"advertise_eap_only"` +} + +type carrierProfileIMS struct { + Transport string `json:"transport"` + IdentityProfile string `json:"identity_profile"` + RegisterProfile string `json:"register_profile"` + IPSecEncryption string `json:"ipsec_encryption"` + SMSCenter string `json:"sms_center"` +} + +//go:embed carrier_profiles.json +var carrierProfilesJSON []byte + +var builtinCarrierProfiles = mustLoadCarrierProfiles(carrierProfilesJSON) + +func mustLoadCarrierProfiles(encoded []byte) []carrierProfileRule { + var document carrierProfileDocument + if err := json.Unmarshal(encoded, &document); err != nil { + panic("vowifi: invalid embedded carrier profiles: " + err.Error()) + } + if document.Version != 1 { + panic(fmt.Sprintf("vowifi: unsupported carrier profile version %d", document.Version)) + } + seen := make(map[string]struct{}, len(document.Profiles)) + for index := range document.Profiles { + rule := &document.Profiles[index] + rule.ID = strings.TrimSpace(rule.ID) + if rule.ID == "" { + panic("vowifi: carrier profile ID is empty") + } + if _, duplicate := seen[rule.ID]; duplicate { + panic("vowifi: duplicate carrier profile " + rule.ID) + } + seen[rule.ID] = struct{}{} + if !validCarrierProfileRule(*rule) { + panic("vowifi: invalid carrier profile " + rule.ID) + } + } + return document.Profiles +} + +func validCarrierProfileRule(rule carrierProfileRule) bool { + match := rule.Match + if len(match.HomePLMNs)+len(match.IMSIPrefixes)+len(match.ICCIDPrefixes)+ + len(match.SPNs)+len(match.GID1Prefixes)+len(match.GID2Prefixes) == 0 { + return false + } + for _, plmn := range match.HomePLMNs { + if canonicalPLMNValue(plmn) == "" { + return false + } + } + if (rule.Route.MCC == "") != (rule.Route.MNC == "") || + (rule.Route.MCC != "" && canonicalPLMN(rule.Route.MCC, rule.Route.MNC) == "") { + return false + } + if proposal := strings.TrimSpace(rule.IKE.Proposal); proposal != "" && + proposal != IKEProposalModern && proposal != IKEProposalLegacy { + return false + } + if transport := strings.ToLower(strings.TrimSpace(rule.IMS.Transport)); transport != "" && + transport != "tcp" && transport != "udp" { + return false + } + if encryption := strings.ToLower(strings.TrimSpace(rule.IMS.IPSecEncryption)); encryption != "" && + encryption != "aes-cbc" && encryption != "null" { + return false + } + return true +} + +// ResolveCarrierProfile returns the most specific built-in match. Exact SIM +// attributes add specificity, so a constrained MVNO rule wins over its host +// PLMN without weakening the default match for unrelated subscriptions. +func ResolveCarrierProfile(identity SIMIdentity) CarrierProfile { + resolved := CarrierProfile{ + ID: CarrierProfileStandard, + MatchSource: "standard", + IKEProposal: IKEProposalModern, + AdvertiseEAPOnly: true, + IMSIdentityProfile: IMSProfileStandard, + IMSRegisterProfile: IMSProfileStandard, + IMSIPSecEncryption: "aes-cbc", + } + bestScore := -1 + for _, rule := range builtinCarrierProfiles { + score, source, matched := matchCarrierProfile(rule.Match, identity) + if !matched || score <= bestScore { + continue + } + bestScore = score + resolved = applyCarrierProfileRule(resolved, rule, source) + } + return resolved +} + +func matchCarrierProfile(match carrierProfileMatch, identity SIMIdentity) (int, string, bool) { + score := 0 + sources := make([]string, 0, 6) + if len(match.HomePLMNs) > 0 { + wanted := canonicalPLMN(identity.HomeMCC, identity.HomeMNC) + if wanted == "" || !matchesAny(match.HomePLMNs, func(value string) bool { + return canonicalPLMNValue(value) == wanted + }) { + return 0, "", false + } + score += 100 + sources = append(sources, "hplmn") + } + for _, selector := range []struct { + name string + weight int + values []string + actual string + foldCase bool + }{ + {name: "imsi", weight: 80, values: match.IMSIPrefixes, actual: identity.IMSI}, + {name: "iccid", weight: 70, values: match.ICCIDPrefixes, actual: identity.ICCID}, + {name: "gid1", weight: 50, values: match.GID1Prefixes, actual: identity.GID1, foldCase: true}, + {name: "gid2", weight: 40, values: match.GID2Prefixes, actual: identity.GID2, foldCase: true}, + } { + if len(selector.values) == 0 { + continue + } + actual := strings.TrimSpace(selector.actual) + if actual == "" || !matchesAny(selector.values, func(prefix string) bool { + prefix = strings.TrimSpace(prefix) + if selector.foldCase { + return strings.HasPrefix(strings.ToLower(actual), strings.ToLower(prefix)) + } + return strings.HasPrefix(actual, prefix) + }) { + return 0, "", false + } + score += selector.weight + sources = append(sources, selector.name) + } + if len(match.SPNs) > 0 { + spn := strings.TrimSpace(identity.SPN) + if spn == "" || !matchesAny(match.SPNs, func(value string) bool { + return strings.EqualFold(strings.TrimSpace(value), spn) + }) { + return 0, "", false + } + score += 20 + sources = append(sources, "spn") + } + return score, strings.Join(sources, "+"), score > 0 +} + +func matchesAny(values []string, match func(string) bool) bool { + for _, value := range values { + if match(value) { + return true + } + } + return false +} + +func applyCarrierProfileRule(base CarrierProfile, rule carrierProfileRule, source string) CarrierProfile { + base.ID = rule.ID + base.MatchSource = source + base.RouteMCC = strings.TrimSpace(rule.Route.MCC) + base.RouteMNC = strings.TrimSpace(rule.Route.MNC) + base.EPDG = strings.ToLower(strings.TrimSpace(rule.EPDG.Hostname)) + if value := strings.TrimSpace(rule.IKE.Proposal); value != "" { + base.IKEProposal = value + } + if rule.IKE.AdvertiseEAPOnly != nil { + base.AdvertiseEAPOnly = *rule.IKE.AdvertiseEAPOnly + } + if value := strings.ToLower(strings.TrimSpace(rule.IMS.Transport)); value != "" { + base.IMSTransport = value + } + if value := strings.TrimSpace(rule.IMS.IdentityProfile); value != "" { + base.IMSIdentityProfile = value + } + if value := strings.TrimSpace(rule.IMS.RegisterProfile); value != "" { + base.IMSRegisterProfile = value + } + if value := strings.ToLower(strings.TrimSpace(rule.IMS.IPSecEncryption)); value != "" { + base.IMSIPSecEncryption = value + } + base.SMSCenter = strings.TrimSpace(rule.IMS.SMSCenter) + return base +} + +func canonicalPLMN(mcc, mnc string) string { + mcc = strings.TrimSpace(mcc) + mnc = strings.TrimSpace(mnc) + if !isNDigits(mcc, 3, 3) || !isNDigits(mnc, 2, 3) { + return "" + } + for len(mnc) < 3 { + mnc = "0" + mnc + } + return mcc + mnc +} + +func canonicalPLMNValue(value string) string { + value = strings.TrimSpace(strings.ReplaceAll(value, "/", "")) + if len(value) != 5 && len(value) != 6 { + return "" + } + return canonicalPLMN(value[:3], value[3:]) +} + +// AssignedRoutePLMN remains available to callers that only have the legacy +// identifier pair. New code resolves the complete SIMIdentity so SPN/GID +// selectors can participate. func AssignedRoutePLMN(iccid, imsi string) (string, string, bool) { - iccid = strings.TrimSpace(iccid) - imsi = strings.TrimSpace(imsi) - switch { - case strings.HasPrefix(iccid, "894416") && strings.HasPrefix(imsi, "204047"): - // XeSIM/Lebara: keep 204/04 for AKA and use Vodafone UK's ePDG. - return "234", "15", true - case strings.HasPrefix(iccid, "894430") && strings.HasPrefix(imsi, "23433"): - // CTExcel UK: keep 234/33 for AKA and use the EE UK ePDG used by - // the initial VoWiFi provisioning path. - return "234", "30", true - default: - return "", "", false + identity := SIMIdentity{ICCID: strings.TrimSpace(iccid), IMSI: strings.TrimSpace(imsi)} + if len(identity.IMSI) >= 5 { + identity.HomeMCC = identity.IMSI[:3] + for _, length := range []int{3, 2} { + if len(identity.IMSI) < 3+length { + continue + } + identity.HomeMNC = identity.IMSI[3 : 3+length] + profile := ResolveCarrierProfile(identity) + if profile.RouteMCC != "" { + return profile.RouteMCC, profile.RouteMNC, true + } + } } + return "", "", false } -// IsATT310280 reports whether the live subscription is on AT&T's three-digit -// 310/280 PLMN. It is shared by SWu and IMS so the carrier exception cannot -// drift between protocol layers. func IsATT310280(identity SIMIdentity) bool { - mcc := strings.TrimSpace(identity.HomeMCC) - mnc := strings.TrimLeft(strings.TrimSpace(identity.HomeMNC), "0") - imsi := strings.TrimSpace(identity.IMSI) - return mcc == "310" && mnc == "280" && strings.HasPrefix(imsi, "310280") + return ResolveCarrierProfile(identity).IMSRegisterProfile == IMSProfileATT } func applyAssignedCarrierRoute(identity SIMIdentity) SIMIdentity { if strings.TrimSpace(identity.EPDG) != "" { return identity } - if routeMCC, routeMNC, ok := AssignedRoutePLMN(identity.ICCID, identity.IMSI); ok { - identity.EPDG = standardEPDGHostname(routeMCC, routeMNC) + profile := ResolveCarrierProfile(identity) + switch { + case profile.EPDG != "": + identity.EPDG = profile.EPDG + case profile.RouteMCC != "": + identity.EPDG = standardEPDGHostname(profile.RouteMCC, profile.RouteMNC) } return identity } +// EPDGDNSClientSubnet returns a deliberately scoped EDNS client subnet for an +// ePDG whose authoritative DNS only exposes addresses to home-country +// resolvers. An empty result means ordinary system DNS remains authoritative. +func EPDGDNSClientSubnet(host string) string { + host = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), ".")) + for _, rule := range builtinCarrierProfiles { + for _, candidate := range rule.EPDG.DNSHosts { + if host == strings.ToLower(strings.TrimSuffix(strings.TrimSpace(candidate), ".")) { + return strings.TrimSpace(rule.EPDG.DNSClientSubnet) + } + } + } + return "" +} + func standardEPDGHostname(mcc, mnc string) string { mnc = strings.TrimSpace(mnc) for len(mnc) < 3 { diff --git a/internal/vowifi/carrier_compat_test.go b/internal/vowifi/carrier_compat_test.go index 3777470..5866f5e 100644 --- a/internal/vowifi/carrier_compat_test.go +++ b/internal/vowifi/carrier_compat_test.go @@ -54,3 +54,47 @@ func TestIsATT310280RequiresMatchingPLMNAndIMSI(t *testing.T) { } } } + +func TestResolveCarrierProfileUsesStandardDefault(t *testing.T) { + profile := ResolveCarrierProfile(SIMIdentity{ + IMSI: "999010000000001", HomeMCC: "999", HomeMNC: "01", + }) + if profile.ID != CarrierProfileStandard || profile.MatchSource != "standard" { + t.Fatalf("default profile = %#v", profile) + } + if profile.IKEProposal != IKEProposalModern || !profile.AdvertiseEAPOnly || + profile.IMSIdentityProfile != IMSProfileStandard || profile.IMSRegisterProfile != IMSProfileStandard { + t.Fatalf("default profile lost standard capabilities: %#v", profile) + } +} + +func TestResolveCarrierProfilePrefersConstrainedMVNO(t *testing.T) { + profile := ResolveCarrierProfile(SIMIdentity{ + ICCID: "8944160000000000001", IMSI: "204047000000001", + HomeMCC: "204", HomeMNC: "04", SPN: "Lebara", + }) + if profile.ID != "xesim-lebara-vodafone-uk" || profile.RouteMCC != "234" || profile.RouteMNC != "15" { + t.Fatalf("MVNO profile = %#v", profile) + } + if profile.MatchSource != "hplmn+imsi+iccid" { + t.Fatalf("MVNO match source = %q", profile.MatchSource) + } +} + +func TestResolveCarrierProfileNormalizesMNCWidth(t *testing.T) { + for _, mnc := range []string{"03", "003"} { + profile := ResolveCarrierProfile(SIMIdentity{HomeMCC: "262", HomeMNC: mnc}) + if profile.ID != "o2-germany" || profile.AdvertiseEAPOnly || profile.IMSIPSecEncryption != "null" { + t.Errorf("O2 Germany MNC %q profile = %#v", mnc, profile) + } + } +} + +func TestEPDGDNSClientSubnetComesFromCarrierProfileData(t *testing.T) { + if got := EPDGDNSClientSubnet("EPDG.EPC.MNC002.MCC262.PUB.3GPPNETWORK.ORG."); got != "109.192.0.0/24" { + t.Fatalf("Vodafone Germany DNS client subnet = %q", got) + } + if got := EPDGDNSClientSubnet("epdg.epc.mnc015.mcc234.pub.3gppnetwork.org"); got != "" { + t.Fatalf("ordinary ePDG received geographic DNS fallback %q", got) + } +} diff --git a/internal/vowifi/carrier_profiles.json b/internal/vowifi/carrier_profiles.json new file mode 100644 index 0000000..b41556b --- /dev/null +++ b/internal/vowifi/carrier_profiles.json @@ -0,0 +1,73 @@ +{ + "version": 1, + "profiles": [ + { + "id": "xesim-lebara-vodafone-uk", + "match": { + "home_plmns": ["20404"], + "imsi_prefixes": ["204047"], + "iccid_prefixes": ["894416"] + }, + "route": { "mcc": "234", "mnc": "15" }, + "ike": { "proposal": "legacy-sha1-modp1024" } + }, + { + "id": "ctexcel-ee-uk", + "match": { + "home_plmns": ["23433"], + "imsi_prefixes": ["23433"], + "iccid_prefixes": ["894430"] + }, + "route": { "mcc": "234", "mnc": "30" } + }, + { + "id": "att-us", + "match": { + "home_plmns": ["310280"], + "imsi_prefixes": ["310280"] + }, + "epdg": { "hostname": "epdg.epc.att.net" }, + "ims": { + "identity_profile": "att", + "register_profile": "att", + "ipsec_encryption": "aes-cbc" + } + }, + { + "id": "o2-germany", + "match": { "home_plmns": ["26203"] }, + "ike": { "advertise_eap_only": false }, + "ims": { + "register_profile": "o2-germany", + "ipsec_encryption": "null" + } + }, + { + "id": "vodafone-uk", + "match": { "home_plmns": ["23415"] }, + "ike": { "proposal": "legacy-sha1-modp1024" }, + "ims": { "sms_center": "+447785016005" } + }, + { + "id": "vodafone-netherlands", + "match": { "home_plmns": ["20404"] }, + "ike": { "proposal": "legacy-sha1-modp1024" } + }, + { + "id": "o2-uk", + "match": { "home_plmns": ["23410"] }, + "ims": { + "transport": "udp", + "sms_center": "+447802000332" + } + }, + { + "id": "vodafone-germany", + "match": { "home_plmns": ["26202"] }, + "epdg": { + "dns_hosts": ["epdg.epc.mnc002.mcc262.pub.3gppnetwork.org"], + "dns_client_subnet": "109.192.0.0/24" + } + } + ] +} diff --git a/internal/vowifi/ec20_adapter.go b/internal/vowifi/ec20_adapter.go index 8614dee..0fe0235 100644 --- a/internal/vowifi/ec20_adapter.go +++ b/internal/vowifi/ec20_adapter.go @@ -173,6 +173,13 @@ func (adapter *EC20Adapter) ReadIdentity( HomeMCC: homeMCC, HomeMNC: homeMNC, } + if reader, ok := adapter.executor.(SIMMetadataReader); ok { + if metadata, metadataErr := reader.ReadSIMMetadata(ctx, deviceID); metadataErr == nil { + identity.SPN = strings.TrimSpace(metadata.SPN) + identity.GID1 = strings.TrimSpace(metadata.GID1) + identity.GID2 = strings.TrimSpace(metadata.GID2) + } + } identity = applyAssignedCarrierRoute(identity) adapter.mu.Lock() adapter.bindings[iccid] = ec20SIMBinding{ diff --git a/internal/vowifi/ike/epdg_resolver.go b/internal/vowifi/ike/epdg_resolver.go index e95fb77..b07bc5b 100644 --- a/internal/vowifi/ike/epdg_resolver.go +++ b/internal/vowifi/ike/epdg_resolver.go @@ -10,19 +10,12 @@ import ( "net/url" "strings" "time" + + "vocat/internal/vowifi" ) const googleDNSOverHTTPS = "https://dns.google/resolve" -// A small number of operators publish the standard ePDG CNAME globally but -// return its A records only when the recursive DNS query appears to originate -// in the home country. Keep this list deliberately narrow: ordinary ePDGs must -// continue to use the host resolver, and a fallback is attempted only after -// that resolver has failed. -var geoRestrictedEPDGSubnets = map[string]string{ - "epdg.epc.mnc002.mcc262.pub.3gppnetwork.org": "109.192.0.0/24", // Vodafone Germany -} - type dnsOverHTTPSResponse struct { Status int `json:"Status"` Answer []struct { @@ -41,7 +34,7 @@ func resolveEPDG(ctx context.Context, resolver *net.Resolver, host string) ([]ne } normalized := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), ".")) - subnet := geoRestrictedEPDGSubnets[normalized] + subnet := vowifi.EPDGDNSClientSubnet(normalized) if subnet == "" { if systemErr != nil { return nil, systemErr diff --git a/internal/vowifi/ike/provider.go b/internal/vowifi/ike/provider.go index 1f24454..16ed560 100644 --- a/internal/vowifi/ike/provider.go +++ b/internal/vowifi/ike/provider.go @@ -11,6 +11,7 @@ import ( "errors" "fmt" "io" + "log/slog" "net" "strings" "sync" @@ -20,17 +21,19 @@ import ( ) type Config struct { - Random io.Reader - Resolver *net.Resolver - Dialer *net.Dialer - RootCAs *x509.CertPool - ResponderPublicKey crypto.PublicKey - ServerName string - Timeout time.Duration - KeepaliveInterval time.Duration - Installer ChildSAInstaller - IdentityType uint8 - APN string + Random io.Reader + Resolver *net.Resolver + Dialer *net.Dialer + RootCAs *x509.CertPool + ResponderPublicKey crypto.PublicKey + ServerName string + Timeout time.Duration + KeepaliveInterval time.Duration + Installer ChildSAInstaller + IdentityType uint8 + APN string + AutoProposalFallback bool + Logger *slog.Logger } type Provider struct { @@ -42,6 +45,9 @@ func NewProvider(config Config) (*Provider, error) { if config.Random == nil { config.Random = rand.Reader } + if config.Logger == nil { + config.Logger = slog.Default() + } if config.Resolver == nil { config.Resolver = net.DefaultResolver } @@ -77,6 +83,30 @@ func NewProvider(config Config) (*Provider, error) { } func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelRequest) (vowifi.TunnelSession, error) { + if provider == nil { + return nil, errors.New("ike: nil provider") + } + session, err := provider.start(ctx, request, false) + if err == nil || !provider.config.AutoProposalFallback { + return session, err + } + profile := vowifi.ResolveCarrierProfile(request.Identity) + if profile.ID != vowifi.CarrierProfileStandard || !retryableLegacyProposal(err) { + return nil, err + } + provider.config.Logger.Warn("IKE ePDG rejected modern proposal; trying bounded legacy fallback", + "carrier_profile", profile.ID, "from_proposal", vowifi.IKEProposalModern, + "to_proposal", vowifi.IKEProposalLegacy, "error", err) + session, fallbackErr := provider.start(ctx, request, true) + if fallbackErr != nil { + return nil, errors.Join(err, fmt.Errorf("ike: legacy proposal fallback failed: %w", fallbackErr)) + } + provider.config.Logger.Info("IKE automatic legacy proposal fallback succeeded", + "carrier_profile", profile.ID, "proposal", vowifi.IKEProposalLegacy) + return session, nil +} + +func (provider *Provider) start(ctx context.Context, request vowifi.TunnelRequest, forceLegacy bool) (vowifi.TunnelSession, error) { if provider == nil { return nil, errors.New("ike: nil provider") } @@ -110,8 +140,12 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques }() group := uint16(dhMODP2048) - legacyFirst := legacyIKEProfile(request.Identity.HomeMCC, request.Identity.HomeMNC) - advertiseEAPOnly := advertiseEAPOnlyAuthentication(request.Identity.HomeMCC, request.Identity.HomeMNC) + carrierProfile := vowifi.ResolveCarrierProfile(request.Identity) + legacyFirst := carrierProfile.IKEProposal == vowifi.IKEProposalLegacy + if forceLegacy { + legacyFirst = true + } + advertiseEAPOnly := carrierProfile.AdvertiseEAPOnly if legacyFirst { group = dhMODP1024 } @@ -582,30 +616,6 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques return session, nil } -func legacyIKEProfile(mcc, mnc string) bool { - // Vodafone's UK and Netherlands ePDGs use the legacy group-2/SHA-1-first - // proposal ordering. Some Lebara UK subscriptions carry a 204-04 IMSI from - // that Vodafone NL core; treating them as a generic modern network causes - // IKE_SA_INIT to fail before EAP-AKA even begins. - plmn := strings.TrimSpace(mcc) + strings.TrimLeft(strings.TrimSpace(mnc), "0") - return plmn == "23415" || plmn == "2044" -} - -func advertiseEAPOnlyAuthentication(mcc, mnc string) bool { - // Android exposes the ePDG authentication method as carrier policy rather - // than unconditionally requesting RFC 5998 EAP-only authentication. O2 - // Germany's 262-03 ePDG rejects an initial IKE_AUTH that explicitly carries - // EAP_ONLY_AUTHENTICATION, but then implicitly defers responder AUTH when the - // notify is omitted. Do not advertise RFC 5998 for that PLMN; the final - // responder AUTH derived from the EAP-AKA MSK remains mandatory. - return !o2GermanyIKECompatibility(mcc, mnc) -} - -func o2GermanyIKECompatibility(mcc, mnc string) bool { - plmn := strings.TrimSpace(mcc) + strings.TrimLeft(strings.TrimSpace(mnc), "0") - return plmn == "2623" -} - func buildInitialEAPAuth( idi payload, requestedIDr payload, @@ -719,6 +729,27 @@ func decryptAndValidate( return header, payloads, nil } +var errNoProposalChosen = errors.New("ike: responder reported NO_PROPOSAL_CHOSEN") + +type invalidKEPayloadError struct { + group uint16 +} + +func (err *invalidKEPayloadError) Error() string { + if err.group != 0 { + return fmt.Sprintf("ike: responder requires DH group %d", err.group) + } + return "ike: responder reported INVALID_KE_PAYLOAD" +} + +func retryableLegacyProposal(err error) bool { + if errors.Is(err, errNoProposalChosen) { + return true + } + var invalidKE *invalidKEPayloadError + return errors.As(err, &invalidKE) && (invalidKE.group == 0 || invalidKE.group == dhMODP1024) +} + func rejectFatalNotifications(payloads []payload) error { for _, item := range payloadsOfType(payloads, payloadNotify) { kind, data, err := parseNotify(item) @@ -727,12 +758,12 @@ func rejectFatalNotifications(payloads []payload) error { } switch kind { case notifyNoProposal: - return errors.New("ike: responder reported NO_PROPOSAL_CHOSEN") + return errNoProposalChosen case notifyInvalidKE: if len(data) == 2 { - return fmt.Errorf("ike: responder requires DH group %d", binary.BigEndian.Uint16(data)) + return &invalidKEPayloadError{group: binary.BigEndian.Uint16(data)} } - return errors.New("ike: responder reported INVALID_KE_PAYLOAD") + return &invalidKEPayloadError{} } if kind < 16384 { return fmt.Errorf("ike: responder reported fatal notification %d", kind) diff --git a/internal/vowifi/ike/provider_test.go b/internal/vowifi/ike/provider_test.go index f04b8c9..dc88151 100644 --- a/internal/vowifi/ike/provider_test.go +++ b/internal/vowifi/ike/provider_test.go @@ -25,15 +25,37 @@ func TestLegacyIKEProfileIncludesVodafoneHostedLebaraCore(t *testing.T) { {mcc: "204", mnc: "04"}, {mcc: "204", mnc: "004"}, } { - if !legacyIKEProfile(item.mcc, item.mnc) { - t.Errorf("legacyIKEProfile(%q, %q) = false", item.mcc, item.mnc) + profile := vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: item.mcc, HomeMNC: item.mnc}) + if profile.IKEProposal != vowifi.IKEProposalLegacy { + t.Errorf("carrier profile IKE proposal for %q/%q = %q", item.mcc, item.mnc, profile.IKEProposal) } } - if legacyIKEProfile("234", "87") { + if profile := vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "87"}); profile.IKEProposal == vowifi.IKEProposalLegacy { t.Fatal("Lebara's 234-87 core must use the modern IKE profile") } } +func TestLegacyProposalFallbackIsLimitedToNegotiationFailures(t *testing.T) { + for _, err := range []error{ + errNoProposalChosen, + &invalidKEPayloadError{}, + &invalidKEPayloadError{group: dhMODP1024}, + } { + if !retryableLegacyProposal(err) { + t.Errorf("negotiation failure %v was not retryable", err) + } + } + for _, err := range []error{ + &invalidKEPayloadError{group: dhMODP2048}, + errors.New("ike: authentication failed"), + vowifi.ErrEAPAuthenticationRejected, + } { + if retryableLegacyProposal(err) { + t.Errorf("unsafe failure %v enabled legacy retry", err) + } + } +} + func (reader constantReader) Read(destination []byte) (int, error) { for index := range destination { destination[index] = reader.value diff --git a/internal/vowifi/ike/wire_auth_test.go b/internal/vowifi/ike/wire_auth_test.go index fd56892..71ef03f 100644 --- a/internal/vowifi/ike/wire_auth_test.go +++ b/internal/vowifi/ike/wire_auth_test.go @@ -171,11 +171,12 @@ func TestConfigurationRequestMatchesAndroidAttributes(t *testing.T) { func TestO2GermanyUsesStandardEAPAuthentication(t *testing.T) { for _, mnc := range []string{"03", "003"} { - if advertiseEAPOnlyAuthentication("262", mnc) { + if vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: "262", HomeMNC: mnc}).AdvertiseEAPOnly { t.Fatalf("O2 Germany 262-%s unexpectedly uses EAP-only", mnc) } } - if !advertiseEAPOnlyAuthentication("262", "02") || !advertiseEAPOnlyAuthentication("234", "15") { + if !vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: "262", HomeMNC: "02"}).AdvertiseEAPOnly || + !vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "15"}).AdvertiseEAPOnly { t.Fatal("non-O2 PLMN lost the existing EAP-only policy") } } diff --git a/internal/vowifi/ims/provider.go b/internal/vowifi/ims/provider.go index 67b061f..bf4f243 100644 --- a/internal/vowifi/ims/provider.go +++ b/internal/vowifi/ims/provider.go @@ -36,20 +36,24 @@ var ( // LocalAddress is empty, Provider uses the corresponding value proven by the // TunnelSession. The default transport is TCP and the default port is 5060. type Config struct { - PCSCF string - LocalAddress string - Transport string - TransportByPLMN map[string]string - Port int - RegistrationExpiry time.Duration - TransactionTimeout time.Duration - PrivateIdentity string - PublicIdentity string - UserAgent string - SecurityMode SecurityMode - IPSecInstaller IPSecSAInstaller - ProtectedClientPort int - ProtectedServerPort int + PCSCF string + LocalAddress string + Transport string + TransportByPLMN map[string]string + // AutoTransportFallback tries the alternate TCP/UDP transport only when + // the initial P-CSCF attempt produced no SIP response at all. A challenge + // or rejection is authoritative and is never retried as another transport. + AutoTransportFallback bool + Port int + RegistrationExpiry time.Duration + TransactionTimeout time.Duration + PrivateIdentity string + PublicIdentity string + UserAgent string + SecurityMode SecurityMode + IPSecInstaller IPSecSAInstaller + ProtectedClientPort int + ProtectedServerPort int // SMSCenter is an operator-provided fallback when the SIM leaves EF_SMSP // and AT+CSCA empty. It must be an international or national digit string. SMSCenter string @@ -71,9 +75,11 @@ type Config struct { // transaction and 3GPP AKAv1-MD5 authentication. It has no SIP stack or // runtime dependency outside the Go standard library. type Provider struct { - aka vowifi.AKAProvider - config Config - installer IPSecSAInstaller + aka vowifi.AKAProvider + config Config + installer IPSecSAInstaller + transportMu sync.RWMutex + transportCache map[string]string } func NewProvider(aka vowifi.AKAProvider, config Config) (*Provider, error) { @@ -88,7 +94,10 @@ func NewProvider(aka vowifi.AKAProvider, config Config) (*Provider, error) { if installer == nil { installer = defaultIPSecInstaller() } - return &Provider{aka: aka, config: normalized, installer: installer}, nil + return &Provider{ + aka: aka, config: normalized, installer: installer, + transportCache: make(map[string]string), + }, nil } func normalizeConfig(config Config) (Config, error) { @@ -224,10 +233,17 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.IMSRequest) if provider.config.PCSCF != "" && !pcscfProvenByTunnel(endpoint, tunnel.PCSCF, provider.config.Port) { return nil, errors.New("ims: configured P-CSCF is not proven by the SWu tunnel") } - transport := transportForIdentity(provider.config, request.Identity) - if transport == "" { + transport, carrierSelected := carrierTransportForIdentity(provider.config, request.Identity) + if cached := provider.cachedTransport(request.Identity); cached != "" { + transport = cached + carrierSelected = true + } + if transport == "" && !carrierSelected { transport = transportHint } + if transport == "" { + transport = provider.config.Transport + } if transport == "" { transport = "tcp" } @@ -250,31 +266,96 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.IMSRequest) return nil, errors.New("ims: configured local address is not assigned by the SWu tunnel") } - connection, err := dialSIP(ctx, transport, localAddress, 0, endpoint.address()) - if err != nil { - return nil, fmt.Errorf("ims: connect to P-CSCF: %w", err) + transports := []string{transport} + if provider.config.AutoTransportFallback { + alternate := "udp" + if transport == "udp" { + alternate = "tcp" + } + transports = append(transports, alternate) } - session, err := newSession(provider, request, identities, endpoint, transport, connection) - if err != nil { - _ = connection.Close() - return nil, err - } - if err := session.establish(ctx); err != nil { + var lastErr error + for attempt, candidate := range transports { + connection, dialErr := dialSIP(ctx, candidate, localAddress, 0, endpoint.address()) + if dialErr != nil { + lastErr = fmt.Errorf("ims: connect to P-CSCF over %s: %w", candidate, dialErr) + if attempt+1 < len(transports) && ctx.Err() == nil { + provider.logTransportFallback(request.Identity, candidate, transports[attempt+1], lastErr) + continue + } + return nil, lastErr + } + session, sessionErr := newSession(provider, request, identities, endpoint, candidate, connection) + if sessionErr != nil { + _ = connection.Close() + return nil, sessionErr + } + establishErr := session.establish(ctx) + if establishErr == nil { + provider.rememberTransport(request.Identity, candidate) + if attempt > 0 { + provider.config.Logger.Info("IMS automatic transport fallback succeeded", + "carrier_profile", vowifi.ResolveCarrierProfile(request.Identity).ID, + "transport", candidate) + } + return session, nil + } + sipResponseObserved := session.evidence.LastSIPCode != 0 session.abort() - return nil, err + lastErr = establishErr + if sipResponseObserved || attempt+1 >= len(transports) || ctx.Err() != nil { + return nil, lastErr + } + provider.logTransportFallback(request.Identity, candidate, transports[attempt+1], establishErr) } - return session, nil + return nil, lastErr } func transportForIdentity(config Config, identity vowifi.SIMIdentity) string { - mcc := strings.TrimSpace(identity.HomeMCC) - mnc := strings.TrimSpace(identity.HomeMNC) - if transport := config.TransportByPLMN[mcc+mnc]; transport != "" { + if transport, selected := carrierTransportForIdentity(config, identity); selected { return transport } return config.Transport } +func carrierTransportForIdentity(config Config, identity vowifi.SIMIdentity) (string, bool) { + mcc := strings.TrimSpace(identity.HomeMCC) + mnc := strings.TrimSpace(identity.HomeMNC) + if transport := config.TransportByPLMN[mcc+mnc]; transport != "" { + return transport, true + } + if transport := vowifi.ResolveCarrierProfile(identity).IMSTransport; transport != "" { + return transport, true + } + return "", false +} + +func transportCacheKey(identity vowifi.SIMIdentity) string { + if iccid := strings.TrimSpace(identity.ICCID); iccid != "" { + return "iccid:" + iccid + } + return "plmn:" + strings.TrimSpace(identity.HomeMCC) + "/" + strings.TrimSpace(identity.HomeMNC) +} + +func (provider *Provider) cachedTransport(identity vowifi.SIMIdentity) string { + provider.transportMu.RLock() + transport := provider.transportCache[transportCacheKey(identity)] + provider.transportMu.RUnlock() + return transport +} + +func (provider *Provider) rememberTransport(identity vowifi.SIMIdentity, transport string) { + provider.transportMu.Lock() + provider.transportCache[transportCacheKey(identity)] = transport + provider.transportMu.Unlock() +} + +func (provider *Provider) logTransportFallback(identity vowifi.SIMIdentity, from, to string, err error) { + provider.config.Logger.Warn("IMS P-CSCF did not respond; trying alternate SIP transport", + "carrier_profile", vowifi.ResolveCarrierProfile(identity).ID, + "from_transport", from, "to_transport", to, "error", err) +} + type identitySet struct { domain string private string @@ -298,7 +379,7 @@ func deriveIdentities(identity vowifi.SIMIdentity, config Config) (identitySet, domain := fmt.Sprintf("ims.mnc%s.mcc%s.3gppnetwork.org", mnc, mcc) privateDomain := domain publicDomain := domain - if vowifi.IsATT310280(identity) { + if vowifi.ResolveCarrierProfile(identity).IMSIdentityProfile == vowifi.IMSProfileATT { // AT&T provisions the IMPI and IMPU in its ISIM domains rather than // the generic 3GPP PLMN IMS domain. domain = "one.att.net" @@ -620,18 +701,11 @@ func newSession( } func securityEncryptionForIdentity(identity vowifi.SIMIdentity) string { - if usesO2GermanyIMSProfile(identity) { - // O2 Germany's P-CSCF advertises the 3GPP integrity-only ESP profile. - // Proposing aes-cbc is rejected before the AKA challenge is issued. - return "null" - } - return "aes-cbc" + return vowifi.ResolveCarrierProfile(identity).IMSIPSecEncryption } func usesO2GermanyIMSProfile(identity vowifi.SIMIdentity) bool { - mcc := strings.TrimSpace(identity.HomeMCC) - mnc := strings.TrimLeft(strings.TrimSpace(identity.HomeMNC), "0") - return mcc+mnc == "2623" + return vowifi.ResolveCarrierProfile(identity).IMSRegisterProfile == vowifi.IMSProfileO2Germany } func (session *Session) abort() { @@ -951,19 +1025,33 @@ func (session *Session) exchange(ctx context.Context, request []byte, cseq uint3 method: "REGISTER", }) } - deadline := time.Now().Add(session.provider.config.TransactionTimeout) - if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(deadline) { - deadline = contextDeadline + transactionDeadline := time.Now().Add(session.provider.config.TransactionTimeout) + if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(transactionDeadline) { + transactionDeadline = contextDeadline } readUDP := session.protectedUDP protectedUDP := session.securityActive && session.transport == "udp" && readUDP != nil - if err := session.conn.SetDeadline(deadline); err != nil { - return nil, fmt.Errorf("ims: set SIP transaction deadline: %w", err) - } - if protectedUDP { - if err := readUDP.SetReadDeadline(deadline); err != nil { - return nil, fmt.Errorf("ims: set protected SIP receive deadline: %w", err) + setReadDeadline := func(deadline time.Time) error { + if err := session.conn.SetDeadline(deadline); err != nil { + return fmt.Errorf("ims: set SIP transaction deadline: %w", err) } + if protectedUDP { + if err := readUDP.SetReadDeadline(deadline); err != nil { + return fmt.Errorf("ims: set protected SIP receive deadline: %w", err) + } + } + return nil + } + retransmitInterval := time.Duration(0) + readDeadline := transactionDeadline + if session.transport == "udp" { + retransmitInterval = sipMessageRetransmitT1 + if candidate := time.Now().Add(retransmitInterval); candidate.Before(readDeadline) { + readDeadline = candidate + } + } + if err := setReadDeadline(readDeadline); err != nil { + return nil, err } stopCancellation := context.AfterFunc(ctx, func() { _ = session.conn.SetDeadline(time.Now()) @@ -976,6 +1064,7 @@ func (session *Session) exchange(ctx context.Context, request []byte, cseq uint3 return nil, fmt.Errorf("ims: send SIP REGISTER: %w", err) } + retransmissions := 0 for { var response *sipResponse var err error @@ -1004,6 +1093,31 @@ func (session *Session) exchange(ctx context.Context, request []byte, cseq uint3 if contextErr := ctx.Err(); contextErr != nil { return nil, contextErr } + var networkErr net.Error + if retransmitInterval > 0 && errors.As(err, &networkErr) && networkErr.Timeout() && + time.Now().Before(transactionDeadline) { + retransmitInterval *= 2 + if retransmitInterval > sipMessageRetransmitMax { + retransmitInterval = sipMessageRetransmitMax + } + nextDeadline := time.Now().Add(retransmitInterval) + if nextDeadline.After(transactionDeadline) { + nextDeadline = transactionDeadline + } + // The previous read deadline has already expired and net.Conn applies + // it to writes too. Extend it before retransmitting. + if deadlineErr := setReadDeadline(nextDeadline); deadlineErr != nil { + return nil, deadlineErr + } + if _, writeErr := session.conn.Write(request); writeErr != nil { + return nil, fmt.Errorf("ims: retransmit SIP REGISTER: %w", writeErr) + } + retransmissions++ + session.provider.config.Logger.Debug("IMS SIP REGISTER retransmitted", + "carrier_profile", vowifi.ResolveCarrierProfile(session.request.Identity).ID, + "transport", session.transport, "attempt", retransmissions) + continue + } return nil, fmt.Errorf("ims: receive SIP REGISTER response: %w", err) } if !strings.EqualFold(strings.TrimSpace(response.value("Call-ID")), session.callID) { @@ -1014,6 +1128,10 @@ func (session *Session) exchange(ctx context.Context, request []byte, cseq uint3 continue } if response.StatusCode >= 100 && response.StatusCode < 200 { + retransmitInterval = 0 + if err := setReadDeadline(transactionDeadline); err != nil { + return nil, err + } continue } return response, nil diff --git a/internal/vowifi/ims/provider_test.go b/internal/vowifi/ims/provider_test.go index b3b20ee..eedd3ed 100644 --- a/internal/vowifi/ims/provider_test.go +++ b/internal/vowifi/ims/provider_test.go @@ -5,6 +5,8 @@ import ( "encoding/base64" "errors" "fmt" + "io" + "log/slog" "net" "strconv" "strings" @@ -18,6 +20,40 @@ type evidenceTunnel struct { evidence vowifi.TunnelEvidence } +type immediateTimeoutError struct{} + +func (immediateTimeoutError) Error() string { return "test timeout" } +func (immediateTimeoutError) Timeout() bool { return true } +func (immediateTimeoutError) Temporary() bool { return true } + +type registerRetransmitConn struct { + writes int + response []byte +} + +func (connection *registerRetransmitConn) Read(destination []byte) (int, error) { + if connection.writes < 2 { + return 0, immediateTimeoutError{} + } + return copy(destination, connection.response), nil +} + +func (connection *registerRetransmitConn) Write(source []byte) (int, error) { + connection.writes++ + return len(source), nil +} + +func (*registerRetransmitConn) Close() error { return nil } +func (*registerRetransmitConn) LocalAddr() net.Addr { + return &net.UDPAddr{IP: net.IPv4(192, 0, 2, 10), Port: 5060} +} +func (*registerRetransmitConn) RemoteAddr() net.Addr { + return &net.UDPAddr{IP: net.IPv4(192, 0, 2, 20), Port: 5060} +} +func (*registerRetransmitConn) SetDeadline(time.Time) error { return nil } +func (*registerRetransmitConn) SetReadDeadline(time.Time) error { return nil } +func (*registerRetransmitConn) SetWriteDeadline(time.Time) error { return nil } + func (tunnel evidenceTunnel) Evidence() vowifi.TunnelEvidence { return tunnel.evidence } @@ -73,6 +109,62 @@ func TestTransportForIdentityPreservesLeadingZeroMNCs(t *testing.T) { } } +func TestCarrierProfileSuppliesTransportWithoutCodeMap(t *testing.T) { + t.Parallel() + identity := vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "10"} + if got := transportForIdentity(Config{Transport: "tcp"}, identity); got != "udp" { + t.Fatalf("O2 UK profile transport = %q, want udp", got) + } + if got := transportForIdentity(Config{ + Transport: "udp", TransportByPLMN: map[string]string{"23410": "tcp"}, + }, identity); got != "tcp" { + t.Fatalf("explicit configuration did not override profile: %q", got) + } +} + +func TestProviderCachesSuccessfulTransportPerSIM(t *testing.T) { + t.Parallel() + provider := &Provider{transportCache: make(map[string]string)} + first := vowifi.SIMIdentity{ICCID: "8901000000000000001", HomeMCC: "001", HomeMNC: "01"} + second := vowifi.SIMIdentity{ICCID: "8901000000000000002", HomeMCC: "001", HomeMNC: "01"} + provider.rememberTransport(first, "udp") + if got := provider.cachedTransport(first); got != "udp" { + t.Fatalf("cached first transport = %q", got) + } + if got := provider.cachedTransport(second); got != "" { + t.Fatalf("second SIM inherited cached transport %q", got) + } +} + +func TestUDPRegisterRetransmitsBeforeTransactionTimeout(t *testing.T) { + t.Parallel() + connection := ®isterRetransmitConn{response: []byte(strings.Join([]string{ + "SIP/2.0 200 OK", + "Call-ID: register-retransmit-test", + "CSeq: 7 REGISTER", + "Content-Length: 0", + "", + "", + }, "\r\n"))} + session := &Session{ + provider: &Provider{config: Config{ + TransactionTimeout: 3 * time.Second, + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + }}, + request: vowifi.IMSRequest{Identity: vowifi.SIMIdentity{HomeMCC: "001", HomeMNC: "01"}}, + transport: "udp", + conn: connection, + callID: "register-retransmit-test", + } + response, err := session.exchange(context.Background(), []byte("REGISTER test"), 7) + if err != nil { + t.Fatal(err) + } + if response.StatusCode != 200 || connection.writes != 2 { + t.Fatalf("response=%#v writes=%d, want SIP 200 after one retransmission", response, connection.writes) + } +} + func TestNormalizeConfigValidatesSMSCentersByPLMN(t *testing.T) { config, err := normalizeConfig(Config{SMSCenterByPLMN: map[string]string{ " 23410 ": " +447802000332 ", diff --git a/internal/vowifi/ims/sms_runtime.go b/internal/vowifi/ims/sms_runtime.go index b140d47..9320fed 100644 --- a/internal/vowifi/ims/sms_runtime.go +++ b/internal/vowifi/ims/sms_runtime.go @@ -755,7 +755,10 @@ func (session *Session) SendSMS(ctx context.Context, request vowifi.SMSSubmitReq func smsCenterForIdentity(config Config, identity vowifi.SIMIdentity) string { plmn := strings.TrimSpace(identity.HomeMCC) + strings.TrimSpace(identity.HomeMNC) - return strings.TrimSpace(config.SMSCenterByPLMN[plmn]) + if configured := strings.TrimSpace(config.SMSCenterByPLMN[plmn]); configured != "" { + return configured + } + return strings.TrimSpace(vowifi.ResolveCarrierProfile(identity).SMSCenter) } func smsRecipientType(recipient string) string { diff --git a/internal/vowifi/ims/sms_runtime_test.go b/internal/vowifi/ims/sms_runtime_test.go index 2114fea..d10f1d6 100644 --- a/internal/vowifi/ims/sms_runtime_test.go +++ b/internal/vowifi/ims/sms_runtime_test.go @@ -180,6 +180,22 @@ func TestSMSCenterForIdentityUsesExactPLMN(t *testing.T) { } } +func TestSMSCenterForIdentityFallsBackToCarrierProfile(t *testing.T) { + for _, test := range []struct { + mnc string + want string + }{ + {mnc: "10", want: "+447802000332"}, + {mnc: "15", want: "+447785016005"}, + {mnc: "30", want: ""}, + } { + identity := vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: test.mnc} + if got := smsCenterForIdentity(Config{}, identity); got != test.want { + t.Errorf("profile SMSC for 234/%s = %q, want %q", test.mnc, got, test.want) + } + } +} + func multipartSMSRequest(t *testing.T, payload []byte) *sipRequest { t.Helper() var body bytes.Buffer diff --git a/internal/vowifi/integration/at.go b/internal/vowifi/integration/at.go index 35f8c5d..80f7ffc 100644 --- a/internal/vowifi/integration/at.go +++ b/internal/vowifi/integration/at.go @@ -8,6 +8,7 @@ import ( "vocat/internal/device" "vocat/internal/modem" "vocat/internal/store" + "vocat/internal/vowifi" ) type ATDeviceController interface { @@ -73,6 +74,28 @@ func (mapper ATMapper) ExecuteSensitiveAT( return mapper.Devices.ExecuteSensitiveAT(ctx, physicalID, command) } +// ReadSIMMetadata reuses the device manager's per-ICCID EF cache. VoWiFi +// identity discovery therefore gains Android-style SPN/GID MVNO selectors +// without issuing duplicate APDUs on every reconnect. +func (mapper ATMapper) ReadSIMMetadata(ctx context.Context, configuredID string) (vowifi.SIMMetadata, error) { + physicalID, err := mapper.resolve(ctx, configuredID) + if err != nil { + return vowifi.SIMMetadata{}, err + } + entry, err := mapper.Devices.Get(physicalID) + if err != nil { + return vowifi.SIMMetadata{}, err + } + if entry.Snapshot == nil { + return vowifi.SIMMetadata{}, nil + } + return vowifi.SIMMetadata{ + SPN: strings.TrimSpace(entry.Snapshot.SPN), + GID1: strings.TrimSpace(entry.Snapshot.GID1), + GID2: strings.TrimSpace(entry.Snapshot.GID2), + }, nil +} + func (mapper ATMapper) resolve( ctx context.Context, configuredID string, diff --git a/internal/vowifi/integration/store.go b/internal/vowifi/integration/store.go index b540be4..7dd9ede 100644 --- a/internal/vowifi/integration/store.go +++ b/internal/vowifi/integration/store.go @@ -234,6 +234,8 @@ func (projector StateProjector) Save( "pure_airplane_policy": state.PureAirplanePolicy, "home_mcc": state.HomeMCC, "home_mnc": state.HomeMNC, + "carrier_profile": state.CarrierProfile, + "carrier_profile_from": state.CarrierProfileFrom, "warnings": state.Warnings, "cleanup_errors": state.CleanupErrors, "attempt": state.Attempt, diff --git a/internal/vowifi/integration/store_test.go b/internal/vowifi/integration/store_test.go index a4bd8d5..1142678 100644 --- a/internal/vowifi/integration/store_test.go +++ b/internal/vowifi/integration/store_test.go @@ -351,13 +351,15 @@ func TestStateProjectorPreservesConcreteDataplaneMode(t *testing.T) { } projector := StateProjector{Store: database} if err := projector.Save(context.Background(), vowifi.State{ - DeviceID: "ec25", - Phase: vowifi.PhaseIMSReady, - TunnelReady: true, - IMSReady: true, - TunnelName: "vocat-swu-ec25", - DataplaneMode: "userspace", - UpdatedAt: time.Now().UTC(), + DeviceID: "ec25", + Phase: vowifi.PhaseIMSReady, + TunnelReady: true, + IMSReady: true, + TunnelName: "vocat-swu-ec25", + DataplaneMode: "userspace", + CarrierProfile: "vodafone-uk", + CarrierProfileFrom: "hplmn", + UpdatedAt: time.Now().UTC(), }); err != nil { t.Fatal(err) } @@ -375,6 +377,13 @@ func TestStateProjectorPreservesConcreteDataplaneMode(t *testing.T) { if tunnel["dataplane_mode"] != "userspace" { t.Fatalf("tunnel dataplane mode = %#v", tunnel["dataplane_mode"]) } + var extra map[string]any + if err := json.Unmarshal(runtime.Extra, &extra); err != nil { + t.Fatal(err) + } + if extra["carrier_profile"] != "vodafone-uk" || extra["carrier_profile_from"] != "hplmn" { + t.Fatalf("carrier profile projection = %#v", extra) + } } func TestStateProjectorDoesNotAttachOldSessionNumberToNewLiveSIM(t *testing.T) { diff --git a/internal/vowifi/native_qmi_adapter.go b/internal/vowifi/native_qmi_adapter.go index 2186dfb..a661e0f 100644 --- a/internal/vowifi/native_qmi_adapter.go +++ b/internal/vowifi/native_qmi_adapter.go @@ -56,6 +56,14 @@ func (adapter *NativeQMIAdapter) ReadIdentity(ctx context.Context, deviceID stri return SIMIdentity{}, err } identity := applyAssignedCarrierRoute(SIMIdentity{ICCID: strings.TrimSpace(iccid), IMSI: strings.TrimSpace(imsi), IMEI: strings.TrimSpace(imei), HomeMCC: strings.TrimSpace(mcc), HomeMNC: strings.TrimSpace(mnc)}) + if reader, ok := adapter.controller.(SIMMetadataReader); ok { + if metadata, metadataErr := reader.ReadSIMMetadata(ctx, deviceID); metadataErr == nil { + identity.SPN = strings.TrimSpace(metadata.SPN) + identity.GID1 = strings.TrimSpace(metadata.GID1) + identity.GID2 = strings.TrimSpace(metadata.GID2) + identity = applyAssignedCarrierRoute(identity) + } + } if err := identity.validate(); err != nil { return SIMIdentity{}, err } diff --git a/internal/vowifi/orchestrator.go b/internal/vowifi/orchestrator.go index ed5f665..6b43ea0 100644 --- a/internal/vowifi/orchestrator.go +++ b/internal/vowifi/orchestrator.go @@ -241,6 +241,7 @@ func (orchestrator *Orchestrator) Enable(ctx context.Context) (State, error) { orchestrator.addWarning("SIM SMS service-centre address is unavailable; IMS receive remains available: " + smscErr.Error()) } } + carrierProfile := ResolveCarrierProfile(identity) orchestrator.mutate(func(state *State) { state.Phase = PhaseSIMReady state.ICCID = strings.TrimSpace(identity.ICCID) @@ -248,6 +249,8 @@ func (orchestrator *Orchestrator) Enable(ctx context.Context) (State, error) { state.SIMReady = true state.HomeMCC = strings.TrimSpace(identity.HomeMCC) state.HomeMNC = strings.TrimSpace(identity.HomeMNC) + state.CarrierProfile = carrierProfile.ID + state.CarrierProfileFrom = carrierProfile.MatchSource state.LastReason = "sim_and_aka_ready" }) @@ -645,8 +648,12 @@ func DeriveEPDG(identity SIMIdentity) (string, error) { } return strings.ToLower(configured), nil } - if IsATT310280(identity) { - return att310280EPDG, nil + profile := ResolveCarrierProfile(identity) + if profile.EPDG != "" { + return profile.EPDG, nil + } + if profile.RouteMCC != "" { + return standardEPDGHostname(profile.RouteMCC, profile.RouteMNC), nil } if err := identity.validate(); err != nil { return "", err diff --git a/internal/vowifi/pcsc_adapter.go b/internal/vowifi/pcsc_adapter.go index 0b3551e..e1bc0fa 100644 --- a/internal/vowifi/pcsc_adapter.go +++ b/internal/vowifi/pcsc_adapter.go @@ -53,7 +53,7 @@ func (adapter *PCSCAdapter) ReadIdentity(ctx context.Context, deviceID string) ( mncLength := identity.MNCLength if mncLength != 2 && mncLength != 3 { if mcc, mnc, ok := assignedHomePLMN(identity.IMSI); ok { - return applyAssignedCarrierRoute(SIMIdentity{ICCID: identity.ICCID, IMSI: identity.IMSI, HomeMCC: mcc, HomeMNC: mnc, SMSC: identity.SMSC}), nil + return applyAssignedCarrierRoute(SIMIdentity{ICCID: identity.ICCID, IMSI: identity.IMSI, HomeMCC: mcc, HomeMNC: mnc, SMSC: identity.SMSC, SPN: identity.SPN}), nil } return SIMIdentity{}, ErrEC20MNCUnavailable } @@ -63,7 +63,7 @@ func (adapter *PCSCAdapter) ReadIdentity(ctx context.Context, deviceID string) ( return applyAssignedCarrierRoute(SIMIdentity{ ICCID: identity.ICCID, IMSI: identity.IMSI, HomeMCC: identity.IMSI[:3], HomeMNC: identity.IMSI[3 : 3+mncLength], - SMSC: identity.SMSC, + SMSC: identity.SMSC, SPN: identity.SPN, }), nil } diff --git a/internal/vowifi/types.go b/internal/vowifi/types.go index c5a8d11..3643ddd 100644 --- a/internal/vowifi/types.go +++ b/internal/vowifi/types.go @@ -96,6 +96,8 @@ type State struct { PureAirplanePolicy bool `json:"pure_airplane_policy"` HomeMCC string `json:"home_mcc,omitempty"` HomeMNC string `json:"home_mnc,omitempty"` + CarrierProfile string `json:"carrier_profile,omitempty"` + CarrierProfileFrom string `json:"carrier_profile_from,omitempty"` EPDG string `json:"epdg,omitempty"` ProxyMode ProxyMode `json:"proxy_mode,omitempty"` ProxyID string `json:"proxy_id,omitempty"` @@ -137,6 +139,9 @@ type SIMIdentity struct { HomeMCC string HomeMNC string HomeCountryCode string + SPN string + GID1 string + GID2 string EPDG string // SMSC is the TS-Service-Centre address used to build SMS-over-IMS // RP-DATA. It is optional during identity discovery, but IMS submission @@ -304,6 +309,22 @@ type SIMIdentityReader interface { ReadIdentity(context.Context, string) (SIMIdentity, error) } +// SIMMetadata contains optional, non-secret carrier selectors stored by the +// UICC. They improve MVNO matching but are never required for AKA or exposed in +// the public runtime state. +type SIMMetadata struct { + SPN string + GID1 string + GID2 string +} + +// SIMMetadataReader is an optional companion implemented by device mappers +// that already cache EF_SPN and EF_GID1/2. Identity readers degrade to PLMN, +// IMSI and ICCID matching when it is unavailable. +type SIMMetadataReader interface { + ReadSIMMetadata(context.Context, string) (SIMMetadata, error) +} + // SMSCenterReader optionally supplies the SIM-configured service-centre // address needed for mobile-originated SMS over IMS. type SMSCenterReader interface { diff --git a/web/src/components/devices/OverviewVowifiCard.tsx b/web/src/components/devices/OverviewVowifiCard.tsx index 2c2659e..c441b99 100644 --- a/web/src/components/devices/OverviewVowifiCard.tsx +++ b/web/src/components/devices/OverviewVowifiCard.tsx @@ -98,6 +98,8 @@ export function OverviewVowifiCard({ device }: { device: DeviceDetail }) { ) : null} + + {rt?.lastError ? : null} diff --git a/web/src/lib/i18n-en.ts b/web/src/lib/i18n-en.ts index 896e95f..125e90c 100644 --- a/web/src/lib/i18n-en.ts +++ b/web/src/lib/i18n-en.ts @@ -780,6 +780,8 @@ export const EN_DICT: Record = { "改动将在此卡激活后生效": "Changes take effect once this card is activated", "数据未开启": "Data is off", "数据平面": "Data Plane", + "运营商配置": "Carrier Profile", + "匹配依据": "Profile Match", "方向": "Direction", "无法读取 IMEI(控制口可能挂死),暂不可添加。": "Cannot read the IMEI (the control port may be stuck); cannot add for now.", "未找到可用的 AT 端口(串口可能仍在枚举),系统会自动重试;也可点击重新扫描。": diff --git a/web/src/types.ts b/web/src/types.ts index 6057e04..e920eef 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -33,6 +33,8 @@ export interface VoWiFiRuntime { phase: string; enabled?: boolean; active?: boolean; + carrierProfile?: string; + carrierProfileFrom?: string; dataplaneMode: string; iccid: string; imsi: string;