Compare commits

..
4 Commits
Author SHA1 Message Date
ihipopandGitHub 54288e5657 fix(ims): align VoWiFi SIP profile behavior (#75) 2026-08-21 22:48:55 +08:00
76af0784e1 fix: stabilize OpenStick 410 VoWiFi startup (#74)
* fix: stabilize OpenStick 410 VoWiFi startup

* fix: recover OpenStick 410 eUICC channel allocation

---------

Co-authored-by: MengMengCode <[email protected]>
2026-08-21 19:26:37 +08:00
06ea65558c fix: ignore non-voice CLCC records in call monitor (#71)
Co-authored-by: geekouc <[email protected]>
2026-08-21 15:44:00 +08:00
MengMengCode d26937f9eb Fix something 2026-08-21 12:25:16 +08:00
21 changed files with 742 additions and 91 deletions
+63 -1
View File
@@ -684,7 +684,18 @@ func configureVoWiFiRuntime(
)
}
}
if _, err := manager.RequestEnabled(deviceConfig.ID, true); err != nil {
requestEnable := func() error {
_, requestErr := manager.RequestEnabled(deviceConfig.ID, true)
return requestErr
}
if err := requestVoWiFiStartup(
ctx,
logger,
deviceConfig.DeviceType,
deviceConfig.ID,
wifi410VoWiFiStartupDelay,
requestEnable,
); err != nil {
_ = manager.Close(context.Background())
return nil, fmt.Errorf("start device %q VoWiFi policy: %w", deviceConfig.ID, err)
}
@@ -696,8 +707,55 @@ func configureVoWiFiRuntime(
const (
vowifiStartupRadioAttempts = 3
vowifiStartupRadioDelay = time.Second
wifi410VoWiFiStartupDelay = 80 * time.Second
)
// requestVoWiFiStartup delays only the persisted startup policy for OpenStick
// 410 devices. Their Qualcomm UIM and Vodafone ePDG path need a short quiet
// period after a cold boot; user-triggered reconnects and every other device
// type continue to execute immediately.
func requestVoWiFiStartup(
ctx context.Context,
logger *slog.Logger,
deviceType string,
deviceID string,
delay time.Duration,
request func() error,
) error {
if deviceType != store.DeviceTypeWiFi410 || delay <= 0 {
return request()
}
if logger == nil {
logger = slog.Default()
}
logger.Info(
"OpenStick 410 VoWiFi startup delayed",
"device_id", deviceID,
"delay", delay,
)
go func() {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return
case <-timer.C:
}
if err := request(); err != nil {
logger.Warn(
"OpenStick 410 delayed VoWiFi startup failed",
"device_id", deviceID,
"error", err,
)
}
}()
return nil
}
func shouldDelayWiFi410VoWiFi(deviceType string, now, notBefore time.Time) bool {
return deviceType == store.DeviceTypeWiFi410 && now.Before(notBefore)
}
type flightModeSetter interface {
SetFlight(context.Context, string, bool) (device.FlightResult, error)
}
@@ -1136,6 +1194,7 @@ func reconcileCardPolicies(
vowifiManager *vowifiruntime.Manager,
) {
observedCards := make(map[string]string)
wifi410StartupNotBefore := time.Now().Add(wifi410VoWiFiStartupDelay)
reconcile := func() {
policies, policyListErr := database.ListCardPolicies(ctx)
if policyListErr == nil {
@@ -1217,6 +1276,9 @@ func reconcileCardPolicies(
}
switch {
case stateErr != nil || !state.Enabled:
if shouldDelayWiFi410VoWiFi(config.DeviceType, time.Now(), wifi410StartupNotBefore) {
continue
}
_, _ = vowifiManager.RequestEnabled(config.ID, true)
case state.ICCID != "" && !strings.EqualFold(strings.TrimSpace(state.ICCID), iccid):
_, _ = vowifiManager.RequestReconnect(config.ID)
+3
View File
@@ -439,6 +439,9 @@ func (manager *Manager) openQMIEuiccOnceAID(ctx context.Context, id string, cand
}
const slot uint8 = 1
logicalChannel, err := session.OpenLogicalChannel(openContext, slot, aid)
if recoverySession, recoveryOK := session.(nativeQMIChannelRecoverySession); recoveryOK && isQMIInsufficientResources(err) {
logicalChannel, err = openNativeQMIChannelWithRecovery(openContext, recoverySession, slot, aid)
}
if err != nil {
_ = session.Close()
return nil, fmt.Errorf("%w: %v", errNoEUICC, err)
+57 -1
View File
@@ -6,6 +6,8 @@ import (
"fmt"
"strings"
"time"
"github.com/iniwex5/quectel-qmi-go/pkg/qmi"
)
func (manager *Manager) withNativeQMIVoWiFiSession(ctx context.Context, id string, fn func(nativeQMIVoWiFiSession) error) error {
@@ -70,7 +72,7 @@ func (manager *Manager) ProbeNativeQMIApplication(ctx context.Context, id, prefe
func (manager *Manager) AuthenticateNativeQMI(ctx context.Context, id string, aid, apdu []byte) (response []byte, err error) {
err = manager.withNativeQMIVoWiFiSession(ctx, id, func(session nativeQMIVoWiFiSession) error {
channel, openErr := session.OpenLogicalChannel(ctx, 1, aid)
channel, openErr := openNativeQMIChannelWithRecovery(ctx, session, 1, aid)
if openErr != nil {
return fmt.Errorf("open QMI UIM logical channel: %w", openErr)
}
@@ -102,6 +104,60 @@ func (manager *Manager) AuthenticateNativeQMI(ctx context.Context, id string, ai
return
}
type nativeQMIChannelRecoverySession interface {
OpenLogicalChannel(context.Context, uint8, []byte) (byte, error)
PowerOffSIM(context.Context, uint8) error
PowerOnSIM(context.Context, uint8) error
}
// OpenStick 410 can leave the physical UICC powered but unable to allocate a
// logical channel after a SIM hot-swap. A UIM service reset alone does not
// clear that state; cycling the affected physical slot does. Recover only the
// precise QMI InsufficientResources response, then retry the original AID once.
func openNativeQMIChannelWithRecovery(
ctx context.Context,
session nativeQMIChannelRecoverySession,
slot uint8,
aid []byte,
) (byte, error) {
channel, err := session.OpenLogicalChannel(ctx, slot, aid)
if err == nil || !isQMIInsufficientResources(err) {
return channel, err
}
if resetter, ok := session.(nativeQMIUIMResetSession); ok {
_ = resetter.ResetUIM(ctx)
}
if powerErr := session.PowerOffSIM(ctx, slot); powerErr != nil {
return 0, errors.Join(err, fmt.Errorf("power off QMI UIM slot %d: %w", slot, powerErr))
}
if waitErr := waitNativeQMIRecovery(ctx, 3*time.Second); waitErr != nil {
return 0, errors.Join(err, waitErr)
}
if powerErr := session.PowerOnSIM(ctx, slot); powerErr != nil {
return 0, errors.Join(err, fmt.Errorf("power on QMI UIM slot %d: %w", slot, powerErr))
}
if waitErr := waitNativeQMIRecovery(ctx, 5*time.Second); waitErr != nil {
return 0, errors.Join(err, waitErr)
}
return session.OpenLogicalChannel(ctx, slot, aid)
}
func isQMIInsufficientResources(err error) bool {
qmiErr := qmi.GetQMIError(err)
return qmiErr != nil && qmiErr.Service == qmi.ServiceUIM && qmiErr.ErrorCode == 0x0044
}
var waitNativeQMIRecovery = func(ctx context.Context, delay time.Duration) error {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func (manager *Manager) NativeQMIRadioSnapshot(ctx context.Context, id string) (mode int, psAttached bool, err error) {
err = manager.withNativeQMIVoWiFiSession(ctx, id, func(session nativeQMIVoWiFiSession) error {
qmiMode, modeErr := session.GetOperatingMode(ctx)
+11 -5
View File
@@ -325,11 +325,7 @@ func (s *Server) pollCellularCalls(ctx context.Context) {
}
calls := parseCLCC(response)
for _, call := range calls {
direction, _ := call["direction"].(int)
state, _ := call["state"].(int)
// direction 1 = incoming (Mobile Terminated)
// state 4 = incoming/ringing, 5 = waiting, 0 = active, 3 = alerting
if direction == 1 && (state == 4 || state == 5 || state == 0 || state == 3) {
if isIncomingVoiceCLCC(call) {
caller, _ := call["number"].(string)
if caller == "" {
caller = "未知号码"
@@ -351,3 +347,13 @@ func (s *Server) pollCellularCalls(ctx context.Context) {
}
}
}
func isIncomingVoiceCLCC(call map[string]any) bool {
direction, _ := call["direction"].(int)
state, _ := call["state"].(int)
mode, _ := call["mode"].(int)
// direction 1 = incoming (Mobile Terminated)
// mode 0 = voice; some modems also expose packet-data sessions as CLCC mode 1
// state 4 = incoming/ringing, 5 = waiting, 0 = active, 3 = alerting
return direction == 1 && mode == 0 && (state == 4 || state == 5 || state == 0 || state == 3)
}
@@ -4,6 +4,8 @@ import (
"strings"
"testing"
"time"
"vocat/internal/modem"
)
func TestIncomingCallNotificationTextFormatting(t *testing.T) {
@@ -61,6 +63,56 @@ func TestIncomingCallDeduplication(t *testing.T) {
}
}
func TestIncomingVoiceCLCCIgnoresDataSessions(t *testing.T) {
tests := []struct {
name string
call map[string]any
want bool
}{
{
name: "incoming voice ringing",
call: map[string]any{"direction": 1, "state": 4, "mode": 0},
want: true,
},
{
name: "incoming voice active",
call: map[string]any{"direction": 1, "state": 0, "mode": 0},
want: true,
},
{
name: "incoming packet data active",
call: map[string]any{"direction": 1, "state": 0, "mode": 1},
want: false,
},
{
name: "outgoing voice alerting",
call: map[string]any{"direction": 0, "state": 3, "mode": 0},
want: false,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := isIncomingVoiceCLCC(test.call); got != test.want {
t.Fatalf("isIncomingVoiceCLCC() = %v, want %v", got, test.want)
}
})
}
// EC20/EC25 firmware may expose an active packet-data session in CLCC.
// It must not be treated as an incoming voice call.
dataCalls := parseCLCC(modem.Response{
Lines: []string{`+CLCC: 1,1,0,1,0,"",128`},
Final: "OK",
})
if len(dataCalls) != 1 {
t.Fatalf("parseCLCC() returned %d data calls, want 1", len(dataCalls))
}
if isIncomingVoiceCLCC(dataCalls[0]) {
t.Fatal("active packet-data CLCC record was treated as an incoming voice call")
}
}
func TestRenderCallWebhookTemplate(t *testing.T) {
now := time.Date(2026, 8, 20, 10, 30, 0, 0, time.UTC)
message := IncomingCallNotification{
+33 -19
View File
@@ -42,8 +42,10 @@ type CarrierProfile struct {
IMSRegisterProfile string
IMSIPSecEncryption string
SMSCenter string
PANIEnabled *bool
PANICountry string
PANINode string
IMSUserAgent string
IMSDialURIScheme string
IMSUserEqPhone bool
IMSVoiceCodecs []string
@@ -57,7 +59,6 @@ type IMSRegisterOptions struct {
ContactExtraTags []string
SupportedHeader *string
AllowHeader *string
UserAgent string
PPreferredIdentity bool
PVisitedNetworkID string
PAccessNetworkInfo *string
@@ -68,6 +69,7 @@ type IMSRegisterOptions struct {
const (
IMSContactFormatStandard = "standard"
IMSContactFormatATT = "att"
IMSContactFormatGSMA = "gsma"
)
type carrierProfileDocument struct {
@@ -76,13 +78,13 @@ type carrierProfileDocument struct {
}
type carrierProfileRule struct {
ID string `json:"id"`
Match carrierProfileMatch `json:"match,omitzero"`
MatchAny []carrierProfileMatch `json:"match_any,omitempty"`
Route carrierProfileRoute `json:"route,omitzero"`
EPDG carrierProfileEPDG `json:"epdg,omitzero"`
IKE carrierProfileIKE `json:"ike,omitzero"`
IMS carrierProfileIMS `json:"ims,omitzero"`
ID string `json:"id"`
Match carrierProfileMatch `json:"match,omitzero"`
MatchAny []carrierProfileMatch `json:"match_any,omitempty"`
Route carrierProfileRoute `json:"route,omitzero"`
EPDG carrierProfileEPDG `json:"epdg,omitzero"`
IKE carrierProfileIKE `json:"ike,omitzero"`
IMS carrierProfileIMS `json:"ims,omitzero"`
}
type carrierProfileMatch struct {
@@ -116,8 +118,10 @@ type carrierProfileIMS struct {
RegisterProfile string `json:"register_profile,omitempty"`
IPSecEncryption string `json:"ipsec_encryption,omitempty"`
SMSCenter string `json:"sms_center,omitempty"`
PANIEnabled *bool `json:"pani_enabled,omitempty"`
PANICountry string `json:"pani_country,omitempty"`
PANINode string `json:"pani_node,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
DialURIScheme string `json:"dial_uri_scheme,omitempty"`
UserEqPhone *bool `json:"user_eq_phone,omitempty"`
VoiceCodecs []string `json:"voice_codecs,omitempty"`
@@ -131,7 +135,6 @@ type carrierProfileRegisterOptions struct {
ContactExtraTags []string `json:"contact_extra_tags,omitempty"`
SupportedHeader *string `json:"supported_header,omitempty"`
AllowHeader *string `json:"allow_header,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
PPreferredIdentity bool `json:"p_preferred_identity,omitempty"`
PVisitedNetworkID string `json:"p_visited_network_id,omitempty"`
PAccessNetworkInfo *string `json:"p_access_network_info,omitempty"`
@@ -322,6 +325,7 @@ func validCarrierProfileRule(rule carrierProfileRule) bool {
return false
}
if country := strings.ToUpper(strings.TrimSpace(rule.IMS.PANICountry)); country != "" &&
country != "AUTO" &&
(len(country) != 2 || country[0] < 'A' || country[0] > 'Z' || country[1] < 'A' || country[1] > 'Z') {
return false
}
@@ -340,7 +344,7 @@ func validCarrierProfileRule(rule carrierProfileRule) bool {
return false
}
if format := strings.ToLower(strings.TrimSpace(rule.IMS.RegisterOptions.ContactFormat)); format != "" &&
format != IMSContactFormatStandard && format != IMSContactFormatATT {
format != IMSContactFormatStandard && format != IMSContactFormatATT && format != IMSContactFormatGSMA {
return false
}
for _, value := range rule.IMS.RegisterOptions.ContactExtraTags {
@@ -353,7 +357,7 @@ func validCarrierProfileRule(rule carrierProfileRule) bool {
return false
}
}
for _, value := range []string{rule.IMS.RegisterOptions.UserAgent, rule.IMS.RegisterOptions.PVisitedNetworkID, rule.IMS.RegisterOptions.CellularNetworkInfo} {
for _, value := range []string{rule.IMS.UserAgent, rule.IMS.RegisterOptions.PVisitedNetworkID, rule.IMS.RegisterOptions.CellularNetworkInfo} {
if strings.ContainsAny(value, "\r\n") {
return false
}
@@ -479,8 +483,12 @@ func matchCarrierProfile(match carrierProfileMatch, identity SIMIdentity) (int,
}{
{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},
// GID values identify an MVNO/service profile within a host network and
// therefore outrank the host issuer's broad ICCID prefix. Otherwise a
// home-PLMN+ICCID AT&T rule hides RedPocket/Cricket/etc. even when the SIM
// exposes the carrier bundle's exact GID selector.
{name: "gid1", weight: 90, values: match.GID1Prefixes, actual: identity.GID1, foldCase: true},
{name: "gid2", weight: 85, values: match.GID2Prefixes, actual: identity.GID2, foldCase: true},
} {
if len(selector.values) == 0 {
continue
@@ -584,8 +592,15 @@ func applyCarrierProfileRule(base CarrierProfile, rule carrierProfileRule, sourc
base.IMSIPSecEncryption = value
}
base.SMSCenter = strings.TrimSpace(rule.IMS.SMSCenter)
if rule.IMS.PANIEnabled != nil {
enabled := *rule.IMS.PANIEnabled
base.PANIEnabled = &enabled
}
base.PANICountry = strings.ToUpper(strings.TrimSpace(rule.IMS.PANICountry))
base.PANINode = strings.TrimSpace(rule.IMS.PANINode)
if value := strings.TrimSpace(rule.IMS.UserAgent); value != "" {
base.IMSUserAgent = value
}
if value := strings.ToLower(strings.TrimSpace(rule.IMS.DialURIScheme)); value != "" {
base.IMSDialURIScheme = value
}
@@ -620,9 +635,6 @@ func applyRegisterOptions(base IMSRegisterOptions, rule carrierProfileRegisterOp
value := strings.TrimSpace(*rule.AllowHeader)
base.AllowHeader = &value
}
if value := strings.TrimSpace(rule.UserAgent); value != "" {
base.UserAgent = value
}
if rule.PPreferredIdentity {
base.PPreferredIdentity = true
}
@@ -719,8 +731,8 @@ func applyAssignedCarrierRoute(identity SIMIdentity) SIMIdentity {
if strings.TrimSpace(identity.ICCID) != "" {
if mcc, mnc, ok := HomePLMNFromICCID(identity.ICCID); ok {
imsiCountry := countryCodeForMCC(identity.HomeMCC)
iccidCountry := countryCodeForMCC(mcc)
imsiCountry := CountryCodeForMCC(identity.HomeMCC)
iccidCountry := CountryCodeForMCC(mcc)
if identity.HomeMCC == "" || (imsiCountry != "" && iccidCountry != "" && imsiCountry != iccidCountry) {
identity.HomeMCC = mcc
identity.HomeMNC = mnc
@@ -733,7 +745,9 @@ func applyAssignedCarrierRoute(identity SIMIdentity) SIMIdentity {
return identity
}
func countryCodeForMCC(mcc string) string {
// CountryCodeForMCC returns the ISO 3166-1 alpha-2 country code associated
// with an MCC known to the carrier compatibility database.
func CountryCodeForMCC(mcc string) string {
switch strings.TrimSpace(mcc) {
case "515":
return "PH"
+38
View File
@@ -46,6 +46,31 @@ func TestResolveCarrierProfileUsesAppleGID1Selector(t *testing.T) {
}
}
func TestResolveCarrierProfileGiffgaffIMSHeaders(t *testing.T) {
profile := ResolveCarrierProfile(SIMIdentity{
IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10", GID1: "508FFFFF",
})
options := profile.IMSRegisterOptions
if profile.IMSTransport != "tcp" || options.ContactFormat != IMSContactFormatGSMA {
t.Fatalf("giffgaff IMS transport/contact profile = %#v", profile)
}
if profile.IMSUserAgent != "iOS/18.6.2 iPhone" {
t.Fatalf("giffgaff User-Agent = %q", profile.IMSUserAgent)
}
if options.SupportedHeader != nil || options.AllowHeader != nil {
t.Fatalf("giffgaff REGISTER header overrides = supported=%v allow=%v", options.SupportedHeader, options.AllowHeader)
}
if options.PAccessNetworkInfo != nil {
t.Fatalf("giffgaff unexpectedly defines a carrier PANI override = %v", *options.PAccessNetworkInfo)
}
if profile.PANIEnabled == nil || !*profile.PANIEnabled || profile.PANICountry != "AUTO" {
t.Fatalf("giffgaff PANI behavior = enabled=%v country=%q", profile.PANIEnabled, profile.PANICountry)
}
if len(options.ContactExtraTags) != 2 || options.ContactExtraTags[0] != "+g.3gpp.mid-call" || options.ContactExtraTags[1] != "+g.3gpp.smsip" {
t.Fatalf("giffgaff Contact tags = %#v", options.ContactExtraTags)
}
}
func TestResolveCarrierProfileATT(t *testing.T) {
profile := ResolveCarrierProfile(SIMIdentity{
ICCID: "8901410000000000001", IMSI: "310410000000001", HomeMCC: "310", HomeMNC: "410",
@@ -55,6 +80,16 @@ func TestResolveCarrierProfileATT(t *testing.T) {
}
}
func TestResolveCarrierProfileRedPocketOutranksBroadATTICCID(t *testing.T) {
profile := ResolveCarrierProfile(SIMIdentity{
ICCID: "8901410000000000001", IMSI: "310170000000001",
HomeMCC: "310", HomeMNC: "170", SPN: "Red Pocket", GID1: "42FFFF",
})
if profile.ID != "ipcc-redpocket-310170" || profile.MatchSource != "hplmn+gid1" {
t.Fatalf("RedPocket profile = %#v", profile)
}
}
func TestResolveCarrierProfileStandardHasNoRegisterOverrides(t *testing.T) {
profile := ResolveCarrierProfile(SIMIdentity{HomeMCC: "999", HomeMNC: "99"})
if profile.ID != CarrierProfileStandard {
@@ -69,6 +104,9 @@ func TestResolveCarrierProfileStandardHasNoRegisterOverrides(t *testing.T) {
if profile.IMSRegisterOptions.SupportedHeader != nil {
t.Fatalf("standard supported header = %v", *profile.IMSRegisterOptions.SupportedHeader)
}
if profile.PANIEnabled != nil || profile.PANICountry != "" {
t.Fatalf("standard PANI behavior = enabled=%v country=%q", profile.PANIEnabled, profile.PANICountry)
}
if profile.AllowSMSWithoutContactConfirmation {
t.Fatal("standard profile should require SMS contact confirmation")
}
+5 -4
View File
@@ -635,6 +635,11 @@ func importCarrierIMS(rule *carrierProfileRule, plists []ipccPlist, warnings *ip
warnings.add("disabled_ims_ipsec_ignored", "UseIPSec=false was not imported because VoWiFi IMS security cannot be weakened automatically", document.name+":"+strings.Join(signaling.path, ".")+".UseIPSec")
}
}
if strings.EqualFold(plistString(signaling.value["CountryOfOriginationFormat"]), "PANI") {
enabled := true
rule.IMS.PANIEnabled = &enabled
rule.IMS.PANICountry = "AUTO"
}
}
}
if useIPSec {
@@ -661,10 +666,6 @@ func inspectIgnoredCarrierFields(plists []ipccPlist, warnings *ipccWarningSet) {
warnings.add("apn_settings_ignored", "APN settings and credentials are outside the VoCat carrier-profile importer", fullPath)
case key == "media" && strings.Contains(strings.ToLower(strings.Join(keyPath, ".")), "imsconfig"):
warnings.add("device_media_overrides_ignored", "device-family media and codec overrides require hardware validation and were not imported", fullPath)
case key == "countryoforiginationformat":
// PANI is access/session metadata, not a carrier location constant.
// The IMS runtime provides one globally and consistently across
// REGISTER, MESSAGE, RP-ACK and dialogs, so no profile field is needed.
case strings.Contains(key, "emergency") || strings.Contains(key, "e911"):
warnings.add("emergency_settings_ignored", "emergency-service settings are never imported", fullPath)
}
+3
View File
@@ -76,6 +76,9 @@ func TestImportCarrierIPCCConvertsBinaryAndXMLPlistsSafely(t *testing.T) {
if rule.IMS.IPSecEncryption != "aes-cbc" {
t.Fatalf("converted IMS profile = %#v", rule.IMS)
}
if rule.IMS.PANIEnabled == nil || !*rule.IMS.PANIEnabled || rule.IMS.PANICountry != "AUTO" {
t.Fatalf("converted PANI behavior = enabled=%v country=%q", rule.IMS.PANIEnabled, rule.IMS.PANICountry)
}
for _, code := range []string{
"remote_certificate_bypass_ignored",
"disabled_dpd_ignored",
+33 -2
View File
@@ -570,6 +570,14 @@
{
"id": "ipcc-redpocket-310170",
"match_any": [
{
"home_plmns": [
"310170"
],
"gid1_prefixes": [
"42"
]
},
{
"home_plmns": [
"310410"
@@ -585,6 +593,18 @@
"gid1_prefixes": [
"42"
]
},
{
"home_plmns": [
"310170",
"310410",
"310280"
],
"spns": [
"Red Pocket",
"RedPocket",
"Red Pocket Mobile"
]
}
],
"epdg": {
@@ -5766,7 +5786,18 @@
"proposal": "modern"
},
"ims": {
"ipsec_encryption": "aes-cbc"
"transport": "tcp",
"ipsec_encryption": "aes-cbc",
"pani_enabled": true,
"pani_country": "AUTO",
"user_agent": "iOS/18.6.2 iPhone",
"register_options": {
"contact_format": "gsma",
"contact_extra_tags": [
"+g.3gpp.mid-call",
"+g.3gpp.smsip"
]
}
}
},
{
@@ -11783,4 +11814,4 @@
}
],
"version": 1
}
}
+82 -1
View File
@@ -29,6 +29,7 @@ var (
const (
usimAIDPrefix = "A0000000871002"
isimAIDPrefix = "A0000000871004"
efDIRFileID = 0x2f00
efADDecimal = 28589 // 0x6FAD
efEHPLMNDecimal = 28441 // 0x6F19 (3GPP TS 31.102 EF_EHPLMN)
channelCleanupTimeout = 3 * time.Second
@@ -1025,6 +1026,19 @@ func (adapter *EC20Adapter) discoverAKAApplication(
}
}
}
// CUAD is optional and is rejected by a number of EC20 firmware branches.
// In that case do not immediately fall back to the seven-byte registered
// application-provider prefix: cards may expose multiple USIM instances and
// require the complete PIX from EF_DIR to select the provisioned one. Read
// EF_DIR over the standards-based basic channel, which remains available on
// the same firmware that rejects CCHO/CGLA.
if discovered, discoverErr := adapter.discoverBasicApplicationAID(
ctx,
deviceID,
usimAIDPrefix,
); discoverErr == nil {
return discovered, "USIM", nil
}
// AT+CUAD is optional on older EC20 firmware. CCHO still provides a
// standards-based, evidence-bearing probe of the assigned USIM AID.
@@ -1053,6 +1067,64 @@ func (adapter *EC20Adapter) discoverPreferredAKAApplication(
return aidPrefix, application, nil
}
func (adapter *EC20Adapter) discoverBasicApplicationAID(
ctx context.Context,
deviceID string,
aidPrefix string,
) (string, error) {
selectFile := func(fileID uint16) error {
apdu := []byte{
0x00, 0xa4, 0x00, 0x04, 0x02,
byte(fileID >> 8), byte(fileID), 0x00,
}
raw, err := adapter.transmitBasicAPDU(ctx, deviceID, apdu, false)
if err != nil {
return err
}
_, status, err := splitAPDUStatus(raw)
if err != nil {
return err
}
if status != 0x9000 {
return fmt.Errorf("vocat: EC20 basic-channel SELECT returned %04X", status)
}
return nil
}
if err := selectFile(0x3f00); err != nil {
return "", fmt.Errorf("select EC20 MF for application discovery: %w", err)
}
if err := selectFile(efDIRFileID); err != nil {
return "", fmt.Errorf("select EC20 EF_DIR for application discovery: %w", err)
}
for record := 1; record <= 32; record++ {
raw, err := adapter.transmitBasicAPDU(
ctx,
deviceID,
[]byte{0x00, 0xb2, byte(record), 0x04, 0x00},
false,
)
if err != nil {
return "", fmt.Errorf("read EC20 EF_DIR record %d: %w", record, err)
}
body, status, err := splitAPDUStatus(raw)
if err != nil {
return "", err
}
if status == 0x6a83 || status == 0x9402 {
break
}
if status != 0x9000 {
continue
}
for _, candidate := range collectApplicationAIDs(body) {
if strings.HasPrefix(candidate, aidPrefix) {
return candidate, nil
}
}
}
return "", ErrEC20ApplicationAbsent
}
func (adapter *EC20Adapter) openLogicalChannel(
ctx context.Context,
deviceID string,
@@ -1174,8 +1246,17 @@ func (adapter *EC20Adapter) transmitBasicAPDU(
if err != nil {
return nil, err
}
collected = append(collected, body...)
sw1 := byte(status >> 8)
if sw1 == 0x6c {
// The UICC knows the exact response length. Retry the original APDU
// with the advised Le without retaining the procedure response.
if len(current) < 5 {
return nil, errors.New("vocat: EC20 APDU cannot apply corrected response length")
}
current[len(current)-1] = byte(status)
continue
}
collected = append(collected, body...)
if sw1 != 0x61 && sw1 != 0x9f {
collected = append(collected, byte(status>>8), byte(status))
return collected, nil
+53
View File
@@ -263,6 +263,59 @@ func TestEC20AdapterCSIMFallbackSupportsSuccessAndSynchronizationFailure(
}
}
func TestEC20AdapterDiscoversFullUSIMAIDFromEFDIRWhenCUADFails(t *testing.T) {
t.Parallel()
const fullAID = "A0000000871002FFFFFFFF8903020000"
record := "61184F10" + fullAID + "50045553494D"
encodedResponse := strings.ToUpper(hex.EncodeToString(successfulUSIMResponse()))
var challenge AKAChallenge
for index := range challenge.RAND {
challenge.RAND[index] = byte(index)
challenge.AUTN[index] = byte(0xf0 + index)
}
authAPDU := buildUSIMAuthenticateAPDU(challenge)
authCommand := fmt.Sprintf(
`AT+CSIM=%d,"%s"`,
len(authAPDU)*2,
strings.ToUpper(hex.EncodeToString(authAPDU)),
)
selectApplication := `AT+CSIM=42,"00A4040410` + fullAID + `"`
transcript := &ec20Transcript{
t: t,
steps: append(
identityTranscriptStepsWithoutEFAD("310280000000001"),
[]ec20TranscriptStep{
{command: "AT+CCID", lines: []string{"+CCID: 8944101234567890123"}},
{command: "AT+CUAD", err: errors.New("+CME ERROR: 13"), final: "+CME ERROR: 13"},
{command: `AT+CSIM=16,"00A40004023F0000"`, lines: []string{`+CSIM: 4,"9000"`}},
{command: `AT+CSIM=16,"00A40004022F0000"`, lines: []string{`+CSIM: 4,"9000"`}},
{command: `AT+CSIM=10,"00B2010400"`, lines: []string{`+CSIM: 4,"6C1A"`}},
{command: `AT+CSIM=10,"00B201041A"`, lines: []string{fmt.Sprintf(`+CSIM: %d,"%s9000"`, len(record)+4, record)}},
{command: `AT+CCHO="` + fullAID + `"`, err: errors.New("unsupported"), final: "ERROR"},
{command: selectApplication, lines: []string{`+CSIM: 4,"9000"`}},
{command: "AT+CCID", lines: []string{"+CCID: 8944101234567890123"}},
{command: selectApplication, lines: []string{`+CSIM: 4,"9000"`}},
{command: authCommand, sensitive: true, lines: []string{fmt.Sprintf(`+CSIM: %d,"%s"`, len(encodedResponse), encodedResponse)}},
}...,
),
}
adapter, err := NewEC20Adapter(transcript, EC20AdapterOptions{})
if err != nil {
t.Fatal(err)
}
identity, err := adapter.ReadIdentity(context.Background(), "ec20-1")
if err != nil {
t.Fatalf("ReadIdentity: %v", err)
}
if _, err := adapter.CheckReady(context.Background(), identity); err != nil {
t.Fatalf("CheckReady: %v", err)
}
if _, err := adapter.Authenticate(context.Background(), identity, challenge); err != nil {
t.Fatalf("Authenticate: %v", err)
}
transcript.assertDone()
}
func TestEC20AdapterLogicalChannelAuthenticateFollowsGetResponse(
t *testing.T,
) {
+5 -1
View File
@@ -36,8 +36,12 @@ func resolveEPDG(ctx context.Context, resolver *net.Resolver, host string) ([]ne
var systemErr error
for _, targetHost := range hostsToTry {
addresses, err := resolver.LookupIPAddr(ctx, targetHost)
ips, err := resolver.LookupIP(ctx, "ip4", targetHost)
if err == nil {
addresses := make([]net.IPAddr, 0, len(ips))
for _, ip := range ips {
addresses = append(addresses, net.IPAddr{IP: ip})
}
valid := filterValidPublicEPDGAddresses(addresses)
if len(valid) > 0 {
return valid, nil
+3 -1
View File
@@ -393,9 +393,11 @@ func parseInnerIPv6(packet []byte) (innerPacketMetadata, error) {
return innerPacketMetadata{}, errors.New("ike: inner IPv6 packet is truncated")
}
payloadLength := int(binary.BigEndian.Uint16(packet[4:6]))
if payloadLength+40 != len(packet) {
declaredLength := payloadLength + 40
if declaredLength > len(packet) {
return innerPacketMetadata{}, errors.New("ike: inner IPv6 payload length is invalid")
}
packet = packet[:declaredLength]
metadata := innerPacketMetadata{
source: append(net.IP(nil), packet[8:24]...),
destination: append(net.IP(nil), packet[24:40]...),
+20
View File
@@ -318,6 +318,26 @@ func TestParseInnerIPv6ESP(t *testing.T) {
}
}
func TestParseInnerIPv6ESPTrimsTrailingAlignmentBytes(t *testing.T) {
t.Parallel()
packet := make([]byte, 40+20+4)
packet[0] = 0x60
binary.BigEndian.PutUint16(packet[4:6], 20)
packet[6] = 6
packet[7] = 64
copy(packet[8:24], net.ParseIP("2001:db8::1").To16())
copy(packet[24:40], net.ParseIP("2001:db8::2").To16())
binary.BigEndian.PutUint16(packet[40:42], 49686)
binary.BigEndian.PutUint16(packet[42:44], 5060)
metadata, err := parseInnerPacket(packet)
if err != nil {
t.Fatal(err)
}
if metadata.protocol != 6 || metadata.sourcePort != 49686 || metadata.destinationPort != 5060 {
t.Fatalf("metadata = %+v", metadata)
}
}
func mustDefaultESPTunnel(t *testing.T) *espTunnel {
t.Helper()
return mustTestESPTunnel(
+16 -15
View File
@@ -116,7 +116,7 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal
"P-Preferred-Service: "+mmtelServiceURN,
`Accept-Contact: *;+g.3gpp.icsi-ref="`+mmtelFeatureTag+`"`,
"P-Access-Network-Info: "+session.pAccessNetworkInfo(),
"User-Agent: "+session.callUserAgent(),
"User-Agent: "+session.imsUserAgent(),
"Allow: INVITE, ACK, CANCEL, BYE, OPTIONS, MESSAGE, PRACK, UPDATE, INFO",
"Supported: 100rel, timer, replaces",
"Session-Expires: 1800;refresher=uac",
@@ -500,7 +500,7 @@ func (session *Session) sendRejectedInviteACK(call *imsCall, response *sipRespon
"Call-ID: "+call.callID,
fmt.Sprintf("CSeq: %d ACK", call.cseq),
"P-Access-Network-Info: "+session.pAccessNetworkInfo(),
"User-Agent: "+session.callUserAgent(),
"User-Agent: "+session.imsUserAgent(),
"Content-Length: 0", "", "",
)
session.writeMu.Lock()
@@ -578,7 +578,7 @@ func (session *Session) sendPRACK(call *imsCall, response *sipResponse) {
"From: "+from, "To: "+to, "Call-ID: "+call.callID,
fmt.Sprintf("CSeq: %d PRACK", cseq), "RAck: "+rseq+" "+inviteCSeq,
"P-Access-Network-Info: "+session.pAccessNetworkInfo(),
"User-Agent: "+session.callUserAgent(),
"User-Agent: "+session.imsUserAgent(),
"Content-Length: 0", "", "",
)
ctx, cancel := context.WithTimeout(session.refreshContext, 10*time.Second)
@@ -709,7 +709,7 @@ func (session *Session) buildDialogRequest(call *imsCall, method string, cseq ui
"Call-ID: "+call.callID,
fmt.Sprintf("CSeq: %d %s", cseq, method),
"Supported: 100rel, timer",
"User-Agent: "+session.callUserAgent(),
"User-Agent: "+session.imsUserAgent(),
)
if method != "CANCEL" {
lines = append(lines, "P-Access-Network-Info: "+session.pAccessNetworkInfo())
@@ -771,6 +771,13 @@ func (session *Session) dialogContactHeader() string {
if session == nil || session.conn == nil || strings.TrimSpace(session.identity.user) == "" {
return ""
}
if session.imsRegisterOptions().ContactFormat == vowifi.IMSContactFormatGSMA {
contact := "Contact: <sip:" + session.contactAddress() + `>;+g.3gpp.icsi-ref="` + mmtelFeatureTag + `"`
if strings.TrimSpace(session.instanceID) != "" {
contact += `;+sip.instance="<` + session.instanceID + `>"`
}
return contact
}
contact := "Contact: <sip:" + session.identity.user + "@" + session.contactAddress() + ";transport=" + session.transport + ">"
if strings.TrimSpace(session.instanceID) != "" {
contact += `;+sip.instance="<` + session.instanceID + `>"`
@@ -811,19 +818,13 @@ func (session *Session) callOriginatingIdentitiesLocked(profile vowifi.CarrierPr
}
func (session *Session) pAccessNetworkInfo() string {
if session == nil {
return ""
}
if session.paniResolved {
return ueProvidedPANI(session.pani)
return session.pani
}
return sessionPAccessNetworkInfo(session.instanceID)
}
func (session *Session) callUserAgent() string {
if session != nil && session.provider != nil {
if value := strings.TrimSpace(session.provider.config.UserAgent); value != "" {
return value
}
}
return "vocat/1"
return resolveSessionPAccessNetworkInfo(session.request.Identity, session.imsLogger())
}
func callResponseDiagnostic(response *sipResponse) string {
+1 -1
View File
@@ -229,7 +229,7 @@ func TestOutgoingLocalNumberUsesIMSPhoneContextAndMMTelHeaders(t *testing.T) {
"P-Preferred-Identity: <tel:+447700900123>\r\n",
"P-Preferred-Service: " + mmtelServiceURN + "\r\n",
`Accept-Contact: *;+g.3gpp.icsi-ref="` + mmtelFeatureTag + `"` + "\r\n",
"P-Access-Network-Info: " + sessionPAccessNetworkInfo(session.instanceID) + "\r\n",
"P-Access-Network-Info: IEEE-802.11;i-wlan-node-id=" + defaultPANIWLANNode + "\r\n",
"User-Agent: VoCat Test\r\n",
"Accept: application/sdp\r\n",
} {
+128 -26
View File
@@ -4,7 +4,6 @@ import (
"bufio"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
@@ -24,6 +23,7 @@ const (
defaultRegistrationExpiry = 3600 * time.Second
defaultTransactionTimeout = 12 * time.Second
maxAuthenticationChallenges = 3
defaultPANIWLANNode = "ffffffffffff"
)
var (
@@ -649,6 +649,11 @@ func newSession(
if err != nil {
return nil, err
}
instanceURI := "urn:uuid:" + instanceID
profile := vowifi.ResolveCarrierProfile(request.Identity)
if profile.IMSRegisterOptions.ContactFormat == vowifi.IMSContactFormatGSMA {
instanceURI = sipInstanceID(request.Identity, instanceID)
}
refreshContext, refreshCancel := context.WithCancel(context.Background())
session := &Session{
provider: provider,
@@ -660,8 +665,8 @@ func newSession(
conn: connection,
callID: callToken + "@" + addressHost(connection.LocalAddr()),
fromTag: fromTag,
instanceID: "urn:uuid:" + instanceID,
pani: resolveSessionPAccessNetworkInfo(request.Identity, "urn:uuid:"+instanceID),
instanceID: instanceURI,
pani: resolveSessionPAccessNetworkInfo(request.Identity, provider.config.Logger),
paniResolved: true,
cseq: 1,
refreshContext: refreshContext,
@@ -970,11 +975,7 @@ func (session *Session) buildRegister(
allow = *registerOptions.AllowHeader
}
userAgent := strings.TrimSpace(session.provider.config.UserAgent)
if override := strings.TrimSpace(registerOptions.UserAgent); override != "" &&
(userAgent == "" || userAgent == "vocat/1") {
userAgent = override
}
userAgent := session.imsUserAgent()
lines := []string{
"REGISTER " + requestURI + " SIP/2.0",
@@ -1064,6 +1065,15 @@ func (session *Session) buildContact(contactAddress string, registerOptions vowi
`%s%s;audio;+g.3gpp.smsip;+g.3gpp.icsi-ref="%s";+sip.instance="<%s>"`,
base, extra, icsiRef, instanceID,
)
case vowifi.IMSContactFormatGSMA:
extra := ""
for _, tag := range registerOptions.ContactExtraTags {
extra += ";" + tag
}
return fmt.Sprintf(
`<sip:%s>;+g.3gpp.icsi-ref="%s"%s;+sip.instance="<%s>"`,
contactAddress, icsiRef, extra, instanceID,
)
default:
extra := ""
for _, tag := range registerOptions.ContactExtraTags {
@@ -1076,41 +1086,127 @@ func (session *Session) buildContact(contactAddress string, registerOptions vowi
}
}
// sessionPAccessNetworkInfo creates a syntactically valid, locally
// administered unicast WLAN node identifier from the already-random SIP
// instance ID. It discloses neither a real BSSID nor subscriber identity, but
// remains stable for every transaction belonging to this IMS registration.
func sessionPAccessNetworkInfo(instanceID string) string {
instanceID = strings.TrimSpace(instanceID)
if instanceID == "" {
return ""
// sipInstanceID uses the standardized GSMA device-instance URI when a valid
// modem identity is available and keeps the generated UUID as the fallback.
func sipInstanceID(identity vowifi.SIMIdentity, fallback string) string {
imei := strings.TrimSpace(identity.IMEI)
if len(imei) == 15 {
valid := true
for _, digit := range imei {
if digit < '0' || digit > '9' {
valid = false
break
}
}
if valid {
return "urn:gsma:imei:" + imei + "-0"
}
}
digest := sha256.Sum256([]byte(instanceID))
digest[0] = (digest[0] | 0x02) & 0xfe // locally administered, unicast
return "IEEE-802.11;i-wlan-node-id=" + hex.EncodeToString(digest[:6])
return "urn:uuid:" + strings.TrimSpace(fallback)
}
func (session *Session) imsRegisterOptions() vowifi.IMSRegisterOptions {
if session == nil {
return vowifi.IMSRegisterOptions{}
}
return vowifi.ResolveCarrierProfile(session.request.Identity).IMSRegisterOptions
}
func (session *Session) imsUserAgent() string {
if session != nil && session.provider != nil {
if value := strings.TrimSpace(session.provider.config.UserAgent); value != "" {
if value != "vocat/1" {
return value
}
}
}
if session != nil {
profile := vowifi.ResolveCarrierProfile(session.request.Identity)
if value := strings.TrimSpace(profile.IMSUserAgent); value != "" {
return value
}
}
if session != nil && session.provider != nil {
if value := strings.TrimSpace(session.provider.config.UserAgent); value != "" {
return value
}
}
return "vocat/1"
}
func (session *Session) imsLogger() *slog.Logger {
if session != nil && session.provider != nil && session.provider.config.Logger != nil {
return session.provider.config.Logger
}
return slog.Default()
}
// resolveSessionPAccessNetworkInfo freezes the selected value when the IMS
// session is created. This prevents a carrier-profile reload from changing
// access identity between REGISTER, SMS MESSAGE and its RP-ACK.
func resolveSessionPAccessNetworkInfo(identity vowifi.SIMIdentity, instanceID string) string {
func resolveSessionPAccessNetworkInfo(identity vowifi.SIMIdentity, logger *slog.Logger) string {
if logger == nil {
logger = slog.Default()
}
profile := vowifi.ResolveCarrierProfile(identity)
if profile.PANIEnabled != nil && !*profile.PANIEnabled {
return ""
}
if configured := profile.IMSRegisterOptions.PAccessNetworkInfo; configured != nil {
return ueProvidedPANI(*configured)
return appendPaniCountry(ueProvidedPANI(*configured), identity, profile, logger)
}
node := strings.ToLower(strings.TrimSpace(profile.PANINode))
if decoded, err := hex.DecodeString(node); err != nil || len(decoded) != 6 {
node = strings.TrimPrefix(sessionPAccessNetworkInfo(instanceID), "IEEE-802.11;i-wlan-node-id=")
node = defaultPANIWLANNode
}
if node == "" {
return ""
}
value := "IEEE-802.11;i-wlan-node-id=" + node
if country := strings.ToUpper(strings.TrimSpace(profile.PANICountry)); country != "" {
value += ";country=" + country
return appendPaniCountry(value, identity, profile, logger)
}
func appendPaniCountry(value string, identity vowifi.SIMIdentity, profile vowifi.CarrierProfile, logger *slog.Logger) string {
value = strings.TrimSpace(value)
if value == "" {
return value
}
return value
parts := strings.Split(value, ";")
for _, parameter := range parts {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(parameter)), "country=") {
return value
}
}
countryMode := strings.ToUpper(strings.TrimSpace(profile.PANICountry))
country := countryMode
if countryMode == "AUTO" {
mcc := strings.TrimSpace(identity.HomeMCC)
if mcc == "" {
mcc = strings.TrimSpace(profile.RouteMCC)
}
country = vowifi.CountryCodeForMCC(mcc)
if country == "" {
if logger == nil {
logger = slog.Default()
}
logger.Error("IMS PANI country code could not be derived",
"category", "ims",
"stage", "pani_country",
"carrier_profile", profile.ID,
"mcc", mcc,
)
return value
}
}
if country == "" {
return value
}
parts = append(parts, "")
copy(parts[2:], parts[1:])
parts[1] = "country=" + country
return strings.Join(parts, ";")
}
// ueProvidedPANI removes the network-provided marker from a profile override.
@@ -1263,6 +1359,7 @@ func (session *Session) applyRegistrationEvidence(response *sipResponse) error {
serviceRoutes := splitHeaderValues(response.values("Service-Route"))
registeredContact := ""
smsConfirmed := false
profile := vowifi.ResolveCarrierProfile(session.request.Identity)
instanceLower := strings.ToLower(session.instanceID)
contactURILower := strings.ToLower(fmt.Sprintf(
"sip:%s@%s;transport=%s",
@@ -1270,10 +1367,15 @@ func (session *Session) applyRegistrationEvidence(response *sipResponse) error {
session.contactAddress(),
session.transport,
))
contactAddressLower := ""
if profile.IMSRegisterOptions.ContactFormat == vowifi.IMSContactFormatGSMA {
contactAddressLower = strings.ToLower("sip:" + session.contactAddress())
}
for _, contact := range contacts {
lower := strings.ToLower(contact)
matchesThisSession := strings.Contains(lower, instanceLower) ||
strings.Contains(lower, contactURILower)
strings.Contains(lower, contactURILower) ||
(contactAddressLower != "" && strings.Contains(lower, contactAddressLower))
if matchesThisSession {
registeredContact = contact
smsConfirmed = strings.Contains(lower, "+g.3gpp.smsip")
+134 -13
View File
@@ -506,13 +506,8 @@ func serveRegistration(listener *net.UDPConn, nonce string, confirmSMS bool) err
}
func TestSessionPAccessNetworkInfoIsStableAndUEProvided(t *testing.T) {
instanceID := "urn:uuid:00000000-0000-4000-8000-000000000001"
first := sessionPAccessNetworkInfo(instanceID)
second := sessionPAccessNetworkInfo(instanceID)
if first != second {
t.Fatalf("PANI changed for one SIP instance: %q != %q", first, second)
}
if err := validateTestPANI(first); err != nil {
defaultPANI := "IEEE-802.11;i-wlan-node-id=" + defaultPANIWLANNode
if err := validateTestPANI(defaultPANI); err != nil {
t.Fatal(err)
}
if got := ueProvidedPANI(" IEEE-802.11;i-wlan-node-id=aabbccddeeff;network-provided "); got != "IEEE-802.11;i-wlan-node-id=aabbccddeeff" {
@@ -521,23 +516,149 @@ func TestSessionPAccessNetworkInfoIsStableAndUEProvided(t *testing.T) {
if got := ueProvidedPANI("network-provided"); got != "" {
t.Fatalf("marker-only PANI = %q, want empty", got)
}
if got := (&Session{paniResolved: true}).pAccessNetworkInfo(); got != "" {
t.Fatalf("explicitly omitted session PANI = %q, want empty", got)
if got := (&Session{pani: "IEEE-802.11;i-wlan-node-id=" + defaultPANIWLANNode, paniResolved: true}).pAccessNetworkInfo(); got != "IEEE-802.11;i-wlan-node-id="+defaultPANIWLANNode {
t.Fatalf("session PANI = %q, want default WLAN node", got)
}
}
func TestPAccessNetworkInfoUsesDefaultNodeAndConditionalCountry(t *testing.T) {
cases := []struct {
name string
identity vowifi.SIMIdentity
want string
}{
{
name: "standard without PANI country format",
identity: vowifi.SIMIdentity{IMSI: "001010123456789", HomeMCC: "001", HomeMNC: "01"},
want: "IEEE-802.11;i-wlan-node-id=" + defaultPANIWLANNode,
},
{
name: "giffgaff with IPCC PANI country format",
identity: vowifi.SIMIdentity{IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10", GID1: "508FFFFF"},
want: "IEEE-802.11;country=GB;i-wlan-node-id=" + defaultPANIWLANNode,
},
{
name: "AT&T without PANI country format",
identity: vowifi.SIMIdentity{IMSI: "310410000000001", HomeMCC: "310", HomeMNC: "410"},
want: "IEEE-802.11;i-wlan-node-id=" + defaultPANIWLANNode,
},
{
name: "VOXI without PANI country format",
identity: vowifi.SIMIdentity{IMSI: "234150000000001", HomeMCC: "234", HomeMNC: "15", SPN: "VOXI"},
want: "IEEE-802.11;i-wlan-node-id=" + defaultPANIWLANNode,
},
}
for _, test := range cases {
t.Run(test.name, func(t *testing.T) {
got := resolveSessionPAccessNetworkInfo(test.identity, slog.New(slog.NewTextHandler(io.Discard, nil)))
if got != test.want {
t.Fatalf("PANI = %q, want %q", got, test.want)
}
})
}
}
func TestAppendPaniCountryModes(t *testing.T) {
base := "IEEE-802.11;i-wlan-node-id=" + defaultPANIWLANNode
identity := vowifi.SIMIdentity{HomeMCC: "234"}
if got := appendPaniCountry(base, identity, vowifi.CarrierProfile{}, slog.Default()); got != base {
t.Fatalf("empty PANI country = %q, want %q", got, base)
}
if got := appendPaniCountry(base, identity, vowifi.CarrierProfile{PANICountry: "GB"}, slog.Default()); got != "IEEE-802.11;country=GB;i-wlan-node-id="+defaultPANIWLANNode {
t.Fatalf("fixed PANI country = %q", got)
}
if got := appendPaniCountry(base, identity, vowifi.CarrierProfile{PANICountry: "AUTO"}, slog.Default()); got != "IEEE-802.11;country=GB;i-wlan-node-id="+defaultPANIWLANNode {
t.Fatalf("automatic PANI country = %q", got)
}
var logs strings.Builder
logger := slog.New(slog.NewTextHandler(&logs, nil))
got := appendPaniCountry(base, vowifi.SIMIdentity{}, vowifi.CarrierProfile{ID: "test-auto", PANICountry: "AUTO"}, logger)
if got != base || !strings.Contains(logs.String(), "IMS PANI country code could not be derived") {
t.Fatalf("failed automatic PANI country = %q, logs = %q", got, logs.String())
}
}
func TestIMSProfileUserAgentUsesUnifiedHeaderValue(t *testing.T) {
giffgaff := &Session{request: vowifi.IMSRequest{Identity: vowifi.SIMIdentity{
IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10", GID1: "508FFFFF",
}}}
if got := giffgaff.imsUserAgent(); got != "iOS/18.6.2 iPhone" {
t.Fatalf("giffgaff IMS User-Agent = %q", got)
}
if options := giffgaff.imsRegisterOptions(); options.AllowHeader != nil || options.SupportedHeader != nil {
t.Fatalf("giffgaff REGISTER capability overrides leaked from business headers: %#v", options)
}
standard := &Session{request: vowifi.IMSRequest{Identity: vowifi.SIMIdentity{
IMSI: "999010000000001", HomeMCC: "999", HomeMNC: "01",
}}}
if got := standard.imsUserAgent(); got != "vocat/1" {
t.Fatalf("standard IMS User-Agent fallback = %q", got)
}
}
func TestSipInstanceIDUsesGSMAFormWhenIMEIIsAvailable(t *testing.T) {
identity := vowifi.SIMIdentity{IMEI: "353024112557010"}
if got := sipInstanceID(identity, "00000000-0000-4000-8000-000000000001"); got != "urn:gsma:imei:353024112557010-0" {
t.Fatalf("sipInstanceID() = %q", got)
}
if got := sipInstanceID(vowifi.SIMIdentity{IMEI: "not-an-imei"}, "00000000-0000-4000-8000-000000000001"); got != "urn:uuid:00000000-0000-4000-8000-000000000001" {
t.Fatalf("sipInstanceID() fallback = %q", got)
}
}
func TestGSMAContactFormatUsesAddressAndDeviceInstance(t *testing.T) {
session := &Session{
identity: identitySet{user: "234105776448519"},
transport: "tcp",
instanceID: "urn:gsma:imei:353024112557010-0",
}
got := session.buildContact("[2001:db8::1]:49686", vowifi.IMSRegisterOptions{
ContactFormat: vowifi.IMSContactFormatGSMA,
ContactExtraTags: []string{"+g.3gpp.mid-call", "+g.3gpp.smsip"},
})
want := `<sip:[2001:db8::1]:49686>;+g.3gpp.icsi-ref="urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel";+g.3gpp.mid-call;+g.3gpp.smsip;+sip.instance="<urn:gsma:imei:353024112557010-0>"`
if got != want {
t.Fatalf("GSMA Contact = %q, want %q", got, want)
}
}
func validateTestPANI(value string) error {
const prefix = "IEEE-802.11;i-wlan-node-id="
if !strings.HasPrefix(value, prefix) {
return fmt.Errorf("value %q does not start with %q", value, prefix)
const accessType = "IEEE-802.11"
if !strings.HasPrefix(value, accessType+";") {
return fmt.Errorf("value %q does not start with %q", value, accessType+";")
}
if strings.Contains(strings.ToLower(value), "network-provided") {
return fmt.Errorf("UE PANI incorrectly claims network-provided provenance: %q", value)
}
node, err := hex.DecodeString(strings.TrimPrefix(value, prefix))
var nodeValue, country string
for _, parameter := range strings.Split(strings.TrimPrefix(value, accessType+";"), ";") {
key, parameterValue, ok := strings.Cut(parameter, "=")
if !ok {
continue
}
switch strings.ToLower(strings.TrimSpace(key)) {
case "i-wlan-node-id":
nodeValue = strings.TrimSpace(parameterValue)
case "country":
country = strings.TrimSpace(parameterValue)
}
}
if nodeValue == "" {
return fmt.Errorf("i-wlan-node-id is missing: %q", value)
}
node, err := hex.DecodeString(nodeValue)
if err != nil || len(node) != 6 {
return fmt.Errorf("i-wlan-node-id must be 12 hexadecimal digits: %q", value)
}
if strings.EqualFold(nodeValue, defaultPANIWLANNode) {
if country != "" && len(country) != 2 {
return fmt.Errorf("country must be an ISO alpha-2 code: %q", value)
}
return nil
}
if node[0]&0x03 != 0x02 {
return fmt.Errorf("i-wlan-node-id must be a locally administered unicast identifier: %q", value)
}
+1
View File
@@ -1177,6 +1177,7 @@ func (session *Session) sendSIPMessageWith(
lines = append(lines,
"Request-Disposition: no-fork",
"Allow: MESSAGE",
"User-Agent: "+session.imsUserAgent(),
)
if inReplyTo != "" {
lines = append(lines, "In-Reply-To: "+inReplyTo)
+1 -1
View File
@@ -144,7 +144,7 @@ func (adapter *NativeQMIAdapter) AuthenticateWithPreference(ctx context.Context,
}
raw, err := adapter.controller.AuthenticateNativeQMI(ctx, binding.deviceID, binding.aid, buildUSIMAuthenticateAPDU(challenge))
if err != nil {
return AKAResult{}, ErrEC20AKACommand
return AKAResult{}, fmt.Errorf("%w: %v", ErrEC20AKACommand, err)
}
return parseUSIMAuthenticateResponse(raw)
}