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;