feat: Enhance SIM identity handling and carrier profile integration

- Added support for reading SIM metadata (SPN, GID1, GID2) in EC20 and Native QMI adapters.
- Refactored ePDG resolver to utilize carrier profiles for DNS resolution.
- Introduced automatic legacy proposal fallback in IKE provider based on negotiation failures.
- Updated IMS provider to cache transport settings per SIM identity and implement transport fallback logic.
- Enhanced SMS center retrieval to fall back to carrier profiles when no explicit configuration is found.
- Updated state management to include carrier profile information.
- Improved integration tests to cover new transport caching and SMS center logic.
- Added UI components to display carrier profile and match source in the device overview.
- Updated internationalization files to include new labels for carrier profile and match source.
This commit is contained in:
MengMengCode
2026-08-16 16:10:53 +08:00
parent ae3a2a6eea
commit d896790cd5
25 changed files with 970 additions and 207 deletions
+8 -16
View File
@@ -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",
+5
View File
@@ -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 {
+49 -45
View File
@@ -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,
+312 -26
View File
@@ -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 {
+44
View File
@@ -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)
}
}
+73
View File
@@ -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"
}
}
]
}
+7
View File
@@ -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{
+3 -10
View File
@@ -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
+71 -40
View File
@@ -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)
+25 -3
View File
@@ -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
+3 -2
View File
@@ -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")
}
}
+171 -53
View File
@@ -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
+92
View File
@@ -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 := &registerRetransmitConn{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 ",
+4 -1
View File
@@ -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 {
+16
View File
@@ -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
+23
View File
@@ -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,
+2
View File
@@ -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,
+16 -7
View File
@@ -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) {
+8
View File
@@ -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
}
+9 -2
View File
@@ -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
+2 -2
View File
@@ -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
}
+21
View File
@@ -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 {
@@ -98,6 +98,8 @@ export function OverviewVowifiCard({ device }: { device: DeviceDetail }) {
</div>
) : null}
<FieldRow label={t("数据平面")} value={rt?.dataplaneMode || "--"} monospace />
<FieldRow label={t("运营商配置")} value={rt?.carrierProfile || "standard-3gpp"} monospace copyable />
<FieldRow label={t("匹配依据")} value={rt?.carrierProfileFrom || "standard"} monospace />
<FieldRow label={t("最后原因")} value={rt?.lastReason || "--"} />
<FieldRow label={t("错误分类")} value={rt?.lastErrorClass || "--"} monospace copyable />
{rt?.lastError ? <FieldRow label={t("错误详情")} value={rt.lastError} monospace copyable /> : null}
+2
View File
@@ -780,6 +780,8 @@ export const EN_DICT: Record<string, string> = {
"改动将在此卡激活后生效": "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 端口(串口可能仍在枚举),系统会自动重试;也可点击重新扫描。":
+2
View File
@@ -33,6 +33,8 @@ export interface VoWiFiRuntime {
phase: string;
enabled?: boolean;
active?: boolean;
carrierProfile?: string;
carrierProfileFrom?: string;
dataplaneMode: string;
iccid: string;
imsi: string;