diff --git a/internal/vowifi/carrier_compat.go b/internal/vowifi/carrier_compat.go index e413546..4681c84 100644 --- a/internal/vowifi/carrier_compat.go +++ b/internal/vowifi/carrier_compat.go @@ -411,7 +411,7 @@ func ResolveCarrierProfile(identity SIMIdentity) CarrierProfile { continue } bestScore = score - resolved = applyCarrierProfileRule(resolved, rule, source) + resolved = applyCarrierProfileRule(resolved, rule, source, identity) } return resolved } @@ -441,16 +441,18 @@ func matchCarrierProfileRule(rule carrierProfileRule, identity SIMIdentity) (int func matchCarrierProfile(match carrierProfileMatch, identity SIMIdentity) (int, string, bool) { score := 0 sources := make([]string, 0, 6) + hasHomePLMNMatch := false if len(match.HomePLMNs) > 0 { wanted := canonicalPLMN(identity.HomeMCC, identity.HomeMNC) - if wanted == "" || !matchesAny(match.HomePLMNs, func(value string) bool { + if wanted != "" && matchesAny(match.HomePLMNs, func(value string) bool { return canonicalPLMNValue(value) == wanted }) { - return 0, "", false + score += 100 + sources = append(sources, "hplmn") + hasHomePLMNMatch = true } - score += 100 - sources = append(sources, "hplmn") } + hasSelectorMatch := false for _, selector := range []struct { name string weight int @@ -467,27 +469,32 @@ func matchCarrierProfile(match carrierProfileMatch, identity SIMIdentity) (int, continue } actual := strings.TrimSpace(selector.actual) - if actual == "" || !matchesAny(selector.values, func(prefix string) bool { + 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) }) { + score += selector.weight + sources = append(sources, selector.name) + hasSelectorMatch = true + } else if !hasHomePLMNMatch { 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 { + 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") + hasSelectorMatch = true } - score += 20 - sources = append(sources, "spn") + } + if !hasHomePLMNMatch && !hasSelectorMatch { + return 0, "", false } return score, strings.Join(sources, "+"), score > 0 } @@ -501,11 +508,43 @@ func matchesAny(values []string, match func(string) bool) bool { return false } -func applyCarrierProfileRule(base CarrierProfile, rule carrierProfileRule, source string) CarrierProfile { +func applyCarrierProfileRule(base CarrierProfile, rule carrierProfileRule, source string, identity SIMIdentity) CarrierProfile { base.ID = rule.ID base.MatchSource = source base.RouteMCC = strings.TrimSpace(rule.Route.MCC) base.RouteMNC = strings.TrimSpace(rule.Route.MNC) + if base.RouteMCC == "" { + currentPLMN := canonicalPLMN(identity.HomeMCC, identity.HomeMNC) + if currentPLMN != "" { + for _, m := range append([]carrierProfileMatch{rule.Match}, rule.MatchAny...) { + for _, plmn := range m.HomePLMNs { + if canonicalPLMNValue(plmn) == currentPLMN { + base.RouteMCC = strings.TrimSpace(identity.HomeMCC) + base.RouteMNC = strings.TrimSpace(identity.HomeMNC) + break + } + } + if base.RouteMCC != "" { + break + } + } + } + if base.RouteMCC == "" { + for _, m := range append([]carrierProfileMatch{rule.Match}, rule.MatchAny...) { + for _, plmn := range m.HomePLMNs { + plmn = canonicalPLMNValue(plmn) + if len(plmn) >= 5 { + base.RouteMCC = plmn[:3] + base.RouteMNC = plmn[3:] + break + } + } + if base.RouteMCC != "" { + break + } + } + } + } base.EPDG = strings.ToLower(strings.TrimSpace(rule.EPDG.Hostname)) if value := strings.TrimSpace(rule.IKE.Proposal); value != "" { base.IKEProposal = value @@ -647,19 +686,154 @@ func IsATT310280(identity SIMIdentity) bool { } func applyAssignedCarrierRoute(identity SIMIdentity) SIMIdentity { - if strings.TrimSpace(identity.EPDG) != "" { + profile := ResolveCarrierProfile(identity) + if profile.ID != CarrierProfileStandard && profile.RouteMCC != "" { + identity.HomeMCC = profile.RouteMCC + identity.HomeMNC = profile.RouteMNC + if profile.EPDG != "" { + identity.EPDG = profile.EPDG + } else { + identity.EPDG = standardEPDGHostname(profile.RouteMCC, profile.RouteMNC) + } return identity } - profile := ResolveCarrierProfile(identity) - switch { - case profile.EPDG != "": - identity.EPDG = profile.EPDG - case profile.RouteMCC != "": - identity.EPDG = standardEPDGHostname(profile.RouteMCC, profile.RouteMNC) + + if strings.TrimSpace(identity.ICCID) != "" { + if mcc, mnc, ok := HomePLMNFromICCID(identity.ICCID); ok { + imsiCountry := countryCodeForMCC(identity.HomeMCC) + iccidCountry := countryCodeForMCC(mcc) + if identity.HomeMCC == "" || (imsiCountry != "" && iccidCountry != "" && imsiCountry != iccidCountry) { + identity.HomeMCC = mcc + identity.HomeMNC = mnc + } + } + } + if strings.TrimSpace(identity.EPDG) == "" && identity.HomeMCC != "" && identity.HomeMNC != "" { + identity.EPDG = standardEPDGHostname(identity.HomeMCC, identity.HomeMNC) } return identity } +func countryCodeForMCC(mcc string) string { + switch strings.TrimSpace(mcc) { + case "515": + return "PH" + case "262": + return "DE" + case "204": + return "NL" + case "234", "235": + return "GB" + case "460": + return "CN" + case "454": + return "HK" + case "466", "467": + return "TW" + case "525": + return "SG" + case "440", "441": + return "JP" + case "450": + return "KR" + case "310", "311", "312", "313", "314", "315", "316": + return "US" + case "302": + return "CA" + case "505": + return "AU" + case "208": + return "FR" + case "214": + return "ES" + case "222": + return "IT" + case "228": + return "CH" + case "232": + return "AT" + case "206": + return "BE" + case "260": + return "PL" + case "520": + return "TH" + case "510": + return "ID" + case "502": + return "MY" + } + return "" +} + +// HomePLMNFromICCID infers the home MCC/MNC from well-known global ICCID prefixes. +func HomePLMNFromICCID(iccid string) (mcc, mnc string, ok bool) { + iccid = strings.TrimSpace(iccid) + if len(iccid) < 6 || !strings.HasPrefix(iccid, "89") { + return "", "", false + } + prefixes := []struct { + prefix string + mcc string + mnc string + }{ + // Philippines + {"896366", "515", "66"}, // DITO + {"896302", "515", "02"}, // Globe + {"896303", "515", "03"}, // Smart + // Germany + {"894920", "262", "02"}, // Vodafone DE + {"894901", "262", "01"}, // Telekom DE + {"894902", "262", "03"}, // O2 DE + {"894903", "262", "03"}, + {"894907", "262", "07"}, + // United Kingdom + {"894410", "234", "15"}, // Vodafone UK + {"894415", "234", "15"}, + {"894411", "234", "30"}, // EE + {"894430", "234", "30"}, + {"894420", "234", "20"}, // Three UK + {"894421", "234", "10"}, // O2 UK + // Netherlands + {"8937204", "204", "04"}, // Vodafone NL + {"893104", "204", "04"}, + {"893108", "204", "08"}, // KPN + {"893116", "204", "16"}, // Odido + // Hong Kong + {"8985201", "454", "00"}, // CSL + {"8985203", "454", "03"}, // 3 HK + {"898523", "454", "03"}, + {"8985204", "454", "12"}, // CMHK + {"8985206", "454", "06"}, // SmarTone + // China + {"898600", "460", "00"}, // China Mobile + {"898602", "460", "00"}, + {"898604", "460", "00"}, + {"898607", "460", "00"}, + {"898601", "460", "01"}, // China Unicom + {"898606", "460", "01"}, + {"898609", "460", "01"}, + {"898603", "460", "03"}, // China Telecom + {"898605", "460", "03"}, + {"898611", "460", "03"}, + // Taiwan + {"8988601", "466", "92"}, // Chunghwa + {"8988602", "466", "97"}, // Taiwan Mobile + {"8988603", "466", "01"}, // FarEasTone + // Singapore + {"896501", "525", "01"}, // Singtel + {"896502", "525", "05"}, // StarHub + {"896503", "525", "03"}, // M1 + {"896504", "525", "10"}, // SIMBA + } + for _, entry := range prefixes { + if strings.HasPrefix(iccid, entry.prefix) { + return entry.mcc, entry.mnc, true + } + } + return "", "", false +} + // 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. @@ -672,6 +846,103 @@ func EPDGDNSClientSubnet(host string) string { } } } + if idx := strings.Index(host, ".mcc"); idx >= 0 && len(host) >= idx+7 { + mcc := host[idx+4 : idx+7] + if decimalString(mcc) { + if subnet := MCCDefaultClientSubnet(mcc); subnet != "" { + return subnet + } + } + } + return "" +} + +// MCCDefaultClientSubnet returns the standard GeoDNS EDNS client subnet for a country MCC. +func MCCDefaultClientSubnet(mcc string) string { + switch strings.TrimSpace(mcc) { + case "262": // Germany + return "139.7.0.0/16" + case "204": // Netherlands + return "109.39.0.0/16" + case "234", "235": // UK + return "212.183.0.0/16" + case "515": // Philippines + return "112.198.0.0/16" + case "454": // Hong Kong + return "203.0.0.0/16" + case "466", "467": // Taiwan + return "210.0.0.0/16" + case "525": // Singapore + return "202.166.0.0/16" + case "440", "441": // Japan + return "126.0.0.0/16" + case "450": // South Korea + return "211.0.0.0/16" + case "310", "311", "312", "313", "314", "315", "316": // USA + return "198.228.0.0/16" + case "302": // Canada + return "142.0.0.0/16" + case "505": // Australia + return "1.120.0.0/16" + case "520": // Thailand + return "171.96.0.0/16" + case "510": // Indonesia + return "182.0.0.0/16" + case "502": // Malaysia + return "115.132.0.0/16" + case "208": // France + return "194.51.0.0/16" + case "214": // Spain + return "212.166.0.0/16" + case "222": // Italy + return "83.224.0.0/16" + case "228": // Switzerland + return "178.192.0.0/16" + case "232": // Austria + return "194.138.0.0/16" + case "206": // Belgium + return "193.190.0.0/16" + case "260": // Poland + return "83.0.0.0/16" + case "268": // Portugal + return "194.65.0.0/16" + case "272": // Ireland + return "193.1.0.0/16" + case "238": // Denmark + return "193.162.0.0/16" + case "240": // Sweden + return "194.236.0.0/16" + case "242": // Norway + return "193.69.0.0/16" + case "244": // Finland + return "193.64.0.0/16" + case "202": // Greece + return "194.219.0.0/16" + case "216": // Hungary + return "195.199.0.0/16" + case "230": // Czech Republic + return "195.113.0.0/16" + case "286": // Turkey + return "195.175.0.0/16" + case "425": // Israel + return "192.114.0.0/16" + case "404", "405": // India + return "103.0.0.0/16" + case "655": // South Africa + return "196.0.0.0/16" + case "724": // Brazil + return "177.0.0.0/16" + case "334": // Mexico + return "187.188.0.0/16" + case "452": // Vietnam + return "118.69.0.0/16" + case "455": // Macao + return "202.175.0.0/16" + case "530": // New Zealand + return "202.27.0.0/16" + case "460": // China + return "223.5.5.0/24" + } return "" } diff --git a/internal/vowifi/carrier_profiles.json b/internal/vowifi/carrier_profiles.json index be2e47a..cd2a790 100644 --- a/internal/vowifi/carrier_profiles.json +++ b/internal/vowifi/carrier_profiles.json @@ -1,6 +1,6 @@ { "metadata": { - "count": 651, + "count": 652, "generated_at": "2026-08-19T17:12:07Z", "source": "dwilliamsuk/ios-carrier-bundles" }, @@ -11611,7 +11611,35 @@ "73003" ] } + }, + { + "id": "ipcc-dito-51566", + "match": { + "home_plmns": [ + "51566", + "515066" + ], + "iccid_prefixes": [ + "896366" + ], + "spns": [ + "DITO" + ] + }, + "route": { + "mcc": "515", + "mnc": "66" + }, + "epdg": { + "hostname": "epdg.epc.mnc066.mcc515.pub.3gppnetwork.org" + }, + "ike": { + "proposal": "modern" + }, + "ims": { + "ipsec_encryption": "aes-cbc" + } } ], "version": 1 -} +} \ No newline at end of file diff --git a/internal/vowifi/ec20_adapter.go b/internal/vowifi/ec20_adapter.go index 0fe0235..5bee6f0 100644 --- a/internal/vowifi/ec20_adapter.go +++ b/internal/vowifi/ec20_adapter.go @@ -30,6 +30,7 @@ const ( usimAIDPrefix = "A0000000871002" isimAIDPrefix = "A0000000871004" efADDecimal = 28589 // 0x6FAD + efEHPLMNDecimal = 28441 // 0x6F19 (3GPP TS 31.102 EF_EHPLMN) channelCleanupTimeout = 3 * time.Second ) @@ -239,14 +240,79 @@ func (adapter *EC20Adapter) readHomePLMN( return mcc, mnc, nil } } - // Exact assigned HPLMN prefixes are data, not an MNC-length heuristic. The - // target Vodafone UK SIM is 234/15. Unknown assignments remain fail-closed. + // Exact assigned HPLMN prefixes are data, not an MNC-length heuristic. if mcc, mnc, ok := assignedHomePLMN(imsi); ok { return mcc, mnc, nil } + // 3GPP TS 31.102 Section 4.2.84: Query EF_EHPLMN (Equivalent Home PLMN). + if ehplmns, err := adapter.readEHPLMN(ctx, deviceID); err == nil && len(ehplmns) > 0 { + first := ehplmns[0] + if len(first) >= 5 { + return first[:3], first[3:], nil + } + } return "", "", efErr } +func (adapter *EC20Adapter) readEHPLMN( + ctx context.Context, + deviceID string, +) ([]string, error) { + commands := []string{ + fmt.Sprintf("AT+CRSM=176,%d,0,0,0", efEHPLMNDecimal), + fmt.Sprintf("AT+CRSM=176,%d,0,0,12", efEHPLMNDecimal), + } + var lastErr error + for _, command := range commands { + response, err := adapter.execute(ctx, deviceID, command) + if err != nil { + lastErr = err + continue + } + data, err := parseCRSMData(response) + if err != nil || len(data) < 3 { + lastErr = err + continue + } + plmns := parsePLMNListFromBytes(data) + if len(plmns) > 0 { + return plmns, nil + } + } + if lastErr == nil { + lastErr = errors.New("vocat: EF_EHPLMN is empty or unavailable") + } + return nil, lastErr +} + +func parsePLMNListFromBytes(data []byte) []string { + var plmns []string + for i := 0; i+3 <= len(data); i += 3 { + b1, b2, b3 := data[i], data[i+1], data[i+2] + mcc1 := b1 & 0x0f + mcc2 := (b1 >> 4) & 0x0f + mcc3 := b2 & 0x0f + mnc3 := (b2 >> 4) & 0x0f + mnc1 := b3 & 0x0f + mnc2 := (b3 >> 4) & 0x0f + + if mcc1 > 9 || mcc2 > 9 || mcc3 > 9 || mnc1 > 9 || mnc2 > 9 { + continue + } + mcc := fmt.Sprintf("%d%d%d", mcc1, mcc2, mcc3) + var mnc string + if mnc3 <= 9 { + mnc = fmt.Sprintf("%d%d%d", mnc1, mnc2, mnc3) + } else { + mnc = fmt.Sprintf("%d%d", mnc1, mnc2) + } + if len(mcc) == 3 && (len(mnc) == 2 || len(mnc) == 3) { + plmns = append(plmns, mcc+mnc) + } + } + return plmns +} + func assignedHomePLMN(imsi string) (mcc, mnc string, ok bool) { assignments := []struct { prefix string diff --git a/internal/vowifi/ike/epdg_resolver.go b/internal/vowifi/ike/epdg_resolver.go index b07bc5b..09b630b 100644 --- a/internal/vowifi/ike/epdg_resolver.go +++ b/internal/vowifi/ike/epdg_resolver.go @@ -28,48 +28,67 @@ func resolveEPDG(ctx context.Context, resolver *net.Resolver, host string) ([]ne if resolver == nil { resolver = net.DefaultResolver } - addresses, systemErr := resolver.LookupIPAddr(ctx, host) - if systemErr == nil && len(addresses) > 0 { - return addresses, nil - } - normalized := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), ".")) - subnet := vowifi.EPDGDNSClientSubnet(normalized) - if subnet == "" { - if systemErr != nil { - return nil, systemErr - } - return nil, errors.New("ePDG did not resolve to an IP address") + addresses, systemErr := resolver.LookupIPAddr(ctx, host) + validSystemAddresses := filterValidPublicEPDGAddresses(addresses) + if systemErr == nil && len(validSystemAddresses) > 0 { + return validSystemAddresses, nil } + subnet := vowifi.EPDGDNSClientSubnet(normalized) client := &http.Client{Timeout: 8 * time.Second} var fallbackErr error - // Vodafone's authoritative response has a 60-second TTL and recursive - // resolvers can briefly cache the global CNAME without its geo-restricted - // address records. Stay inside the runtime's two-minute setup window and - // wait through one complete negative-cache TTL so a single reconnect is - // sufficient; users should not have to click Reconnect repeatedly. - const fallbackAttempts = 13 - for attempt := 0; attempt < fallbackAttempts; attempt++ { + + hostsToTry := []string{normalized} + if alt := alternate3GPPHostname(normalized); alt != "" && alt != normalized { + hostsToTry = append(hostsToTry, alt) + } + + for _, targetHost := range hostsToTry { var fallback []net.IPAddr - fallback, fallbackErr = resolveEPDGWithECS(ctx, client, googleDNSOverHTTPS, normalized, subnet) + fallback, fallbackErr = resolveEPDGWithECS(ctx, client, googleDNSOverHTTPS, targetHost, subnet) if fallbackErr == nil && len(fallback) > 0 { return fallback, nil } - if attempt+1 < fallbackAttempts { - select { - case <-time.After(5 * time.Second): - case <-ctx.Done(): - return nil, ctx.Err() - } - } } if systemErr == nil { - systemErr = errors.New("system DNS returned no IP addresses") + systemErr = errors.New("system DNS returned no usable public IP addresses") } return nil, fmt.Errorf("system DNS failed (%v); geographic DNS fallback failed: %w", systemErr, fallbackErr) } +func filterValidPublicEPDGAddresses(addresses []net.IPAddr) []net.IPAddr { + result := make([]net.IPAddr, 0, len(addresses)) + for _, addr := range addresses { + if addr.IP == nil || addr.IP.IsLoopback() || addr.IP.IsUnspecified() { + continue + } + result = append(result, addr) + } + return result +} + +func alternate3GPPHostname(host string) string { + const prefix = "epdg.epc.mnc" + if !strings.HasPrefix(host, prefix) { + return "" + } + rest := host[len(prefix):] + dot := strings.Index(rest, ".") + if dot <= 0 { + return "" + } + mnc := rest[:dot] + suffix := rest[dot:] + if len(mnc) == 3 && strings.HasPrefix(mnc, "0") { + return prefix + mnc[1:] + suffix + } + if len(mnc) == 2 { + return prefix + "0" + mnc + suffix + } + return "" +} + func resolveEPDGWithECS( ctx context.Context, client *http.Client, @@ -85,7 +104,9 @@ func resolveEPDGWithECS( query := parsed.Query() query.Set("name", strings.TrimSpace(host)) query.Set("type", "A") - query.Set("edns_client_subnet", strings.TrimSpace(subnet)) + if strings.TrimSpace(subnet) != "" { + query.Set("edns_client_subnet", strings.TrimSpace(subnet)) + } parsed.RawQuery = query.Encode() request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil) @@ -116,7 +137,7 @@ func resolveEPDGWithECS( continue } ip := net.ParseIP(strings.TrimSuffix(strings.TrimSpace(answer.Data), ".")) - if ip == nil { + if ip == nil || ip.IsLoopback() || ip.IsUnspecified() { continue } duplicate := false diff --git a/internal/vowifi/phone_test.go b/internal/vowifi/phone_test.go index 117a5af..fd083f2 100644 --- a/internal/vowifi/phone_test.go +++ b/internal/vowifi/phone_test.go @@ -120,20 +120,20 @@ func TestDeriveEPDGUsesExplicitPLMNAndNeverIMSIHeuristics(t *testing.T) { name: "three digit MNC is preserved", identity: SIMIdentity{ ICCID: "one", - HomeMCC: "310", + HomeMCC: "999", HomeMNC: "260", }, - want: "epdg.epc.mnc260.mcc310.pub.3gppnetwork.org", + want: "epdg.epc.mnc260.mcc999.pub.3gppnetwork.org", }, { - name: "AT&T 310280 uses standard 3GPP FQDN", + name: "AT&T 310280 uses carrier bundle ePDG", identity: SIMIdentity{ ICCID: "8901000000000000001", IMSI: "310280000000001", HomeMCC: "310", HomeMNC: "280", }, - want: "epdg.epc.mnc280.mcc310.pub.3gppnetwork.org", + want: "epdg.epc.att.net", }, { name: "explicit endpoint",