mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-21 23:33:42 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54288e5657 | ||
|
|
76af0784e1 | ||
|
|
06ea65558c | ||
|
|
d26937f9eb |
+63
-1
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]...),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user