8 Commits
Author SHA1 Message Date
MengMengCode 5eee89a92a feat: Enhance SIM identity handling and carrier profile integration
- Added support for reading SIM metadata (SPN, GID1, GID2) in EC20 and Native QMI adapters.
- Refactored ePDG resolver to utilize carrier profiles for DNS resolution.
- Introduced automatic legacy proposal fallback in IKE provider based on negotiation failures.
- Updated IMS provider to cache transport settings per SIM identity and implement transport fallback logic.
- Enhanced SMS center retrieval to fall back to carrier profiles when no explicit configuration is found.
- Updated state management to include carrier profile information.
- Improved integration tests to cover new transport caching and SMS center logic.
- Added UI components to display carrier profile and match source in the device overview.
- Updated internationalization files to include new labels for carrier profile and match source.
2026-08-16 16:10:53 +08:00
MengMengCode ae3a2a6eea FIX #29 2026-08-16 15:16:45 +08:00
MengMengCode 505ee1eac0 FIX #27 2026-08-16 13:49:42 +08:00
MengMengCode b19ae2240a FIX #25 2026-08-16 13:33:55 +08:00
MengMengCode adf7de6d29 FIX #28 2026-08-16 13:20:50 +08:00
MengMengCode ffa0fd23b8 feat: enhance IMS SMS handling with structured logging and payload extraction 2026-08-16 13:05:20 +08:00
MengMengCode f014628048 feat: add curl options for binary download with progress feedback 2026-08-16 12:02:13 +08:00
MengMengCode 68813198f9 fix: complete native 410 eSIM and VoWiFi flow 2026-08-16 02:54:38 +08:00
58 changed files with 3888 additions and 330 deletions
+23 -14
View File
@@ -192,7 +192,7 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
}
cardReaders := pcsc.New()
deviceManager, err := device.NewManager(device.Options{CardReaders: cardReaders})
deviceManager, err := device.NewManager(device.Options{CardReaders: cardReaders, Logger: logger})
if err != nil {
return fmt.Errorf("create device manager: %w", err)
}
@@ -581,6 +581,13 @@ func configureVoWiFiRuntime(
if err != nil {
return nil, err
}
nativeQMIAdapter, err := vowifi.NewNativeQMIAdapter(nativeQMIControllerMapper{Mapper: mapper, Devices: deviceManager}, func(deviceID string) bool {
deviceConfig, configErr := database.Device(context.Background(), deviceID)
return configErr == nil && deviceConfig.VoWiFiEnabled
})
if err != nil {
return nil, err
}
pcscAdapter, err := vowifi.NewPCSCAdapter(cardReaders, func(ctx context.Context, deviceID string) (pcsc.Selector, string, error) {
config, resolveErr := database.Device(ctx, strings.TrimSpace(deviceID))
if resolveErr != nil {
@@ -606,8 +613,10 @@ func configureVoWiFiRuntime(
adapter := vowifiDeviceAdapter(ec20Adapter)
if deviceConfig.DeviceType == store.DeviceTypeUSBSIMReader {
adapter = pcscAdapter
} else if deviceConfig.DeviceType == store.DeviceTypeWiFi410 {
adapter = nativeQMIAdapter
}
return newVoWiFiOrchestrator(deviceConfig, database, adapter)
return newVoWiFiOrchestrator(deviceConfig, database, adapter, logger)
},
})
@@ -706,26 +715,25 @@ func newVoWiFiOrchestrator(
deviceConfig store.Device,
database *store.Store,
adapter vowifiDeviceAdapter,
logger *slog.Logger,
) (*vowifi.Orchestrator, error) {
apn := deviceConfig.APN
if apn == "" {
apn = "ims"
}
tunnelProvider, err := ike.NewProvider(ike.Config{APN: apn})
tunnelProvider, err := ike.NewProvider(ike.Config{
APN: apn, Logger: logger, AutoProposalFallback: true,
})
if err != nil {
return nil, fmt.Errorf("device %q IKE provider: %w", deviceConfig.ID, err)
}
imsProvider, err := ims.NewProvider(adapter, ims.Config{
// The userspace SWu data plane carries protected P-CSCF signalling over
// TCP by default. UK PLMN 234-10 exposes its P-CSCF over UDP/5060 on SWu.
Transport: "tcp",
TransportByPLMN: map[string]string{
"23410": "udp",
"234010": "udp",
},
// Some Vodafone UK SIM profiles leave AT+CSCA empty; Vodafone publishes
// this service-centre number for manual SMS setup.
SMSCenter: "+447785016005",
Logger: logger,
// Carrier-specific transport and SMSC defaults live in the shared data
// profile. Prefer network-provided P-CSCF hints, then safely try the
// alternate transport only if no SIP response was observed.
Transport: "tcp",
AutoTransportFallback: true,
OnSMS: func(ctx context.Context, message ims.ReceivedSMS) error {
extra, _ := json.Marshal(map[string]any{
"transport": "ims",
@@ -856,6 +864,7 @@ func provisionDiscoveredDevices(
if name == "" || strings.EqualFold(name, "Android") {
name = "Quectel EC20 / EC25"
}
supportsSMS := deviceType != store.DeviceTypeWiFi410
if err := database.UpsertDevice(ctx, store.Device{
ID: discovered.ID,
Name: name,
@@ -872,7 +881,7 @@ func provisionDiscoveredDevices(
DeviceBackend: backend,
ESIMTransport: esimTransport,
NetworkEnabled: false,
SMSEnabled: true,
SMSEnabled: supportsSMS,
VoWiFiEnabled: true,
}); err != nil {
return err
+76
View File
@@ -0,0 +1,76 @@
package main
import (
"context"
"vocat/internal/device"
"vocat/internal/vowifi"
"vocat/internal/vowifi/integration"
)
// nativeQMIControllerMapper keeps the configured Web/API device ID stable
// while Linux exposes the physical MHI modem under its discovery ID.
type nativeQMIControllerMapper struct {
Mapper integration.ATMapper
Devices *device.Manager
}
func (mapper nativeQMIControllerMapper) physical(configuredID string) (string, error) {
entry, err := mapper.Mapper.Get(configuredID)
if err != nil {
return "", err
}
return entry.ID, nil
}
func (mapper nativeQMIControllerMapper) ReadNativeQMIIdentity(ctx context.Context, id string) (string, string, string, string, string, error) {
physical, err := mapper.physical(id)
if err != nil {
return "", "", "", "", "", err
}
return mapper.Devices.ReadNativeQMIIdentity(ctx, physical)
}
func (mapper nativeQMIControllerMapper) ReadSIMMetadata(ctx context.Context, id string) (vowifi.SIMMetadata, error) {
return mapper.Mapper.ReadSIMMetadata(ctx, id)
}
func (mapper nativeQMIControllerMapper) ProbeNativeQMIApplication(ctx context.Context, id, preference string) ([]byte, string, error) {
physical, err := mapper.physical(id)
if err != nil {
return nil, "", err
}
return mapper.Devices.ProbeNativeQMIApplication(ctx, physical, preference)
}
func (mapper nativeQMIControllerMapper) AuthenticateNativeQMI(ctx context.Context, id string, aid, apdu []byte) ([]byte, error) {
physical, err := mapper.physical(id)
if err != nil {
return nil, err
}
return mapper.Devices.AuthenticateNativeQMI(ctx, physical, aid, apdu)
}
func (mapper nativeQMIControllerMapper) NativeQMIRadioSnapshot(ctx context.Context, id string) (int, bool, error) {
physical, err := mapper.physical(id)
if err != nil {
return 0, false, err
}
return mapper.Devices.NativeQMIRadioSnapshot(ctx, physical)
}
func (mapper nativeQMIControllerMapper) StopNativeQMICellularData(ctx context.Context, id string) error {
physical, err := mapper.physical(id)
if err != nil {
return err
}
return mapper.Devices.StopNativeQMICellularData(ctx, physical)
}
func (mapper nativeQMIControllerMapper) SetNativeQMIRadioOff(ctx context.Context, id string, off bool) error {
physical, err := mapper.physical(id)
if err != nil {
return err
}
return mapper.Devices.SetNativeQMIRadioOff(ctx, physical, off)
}
+21 -1
View File
@@ -3,6 +3,7 @@ package device
import (
_ "embed"
"encoding/json"
"sort"
"strings"
)
@@ -51,6 +52,25 @@ func CountryForMCC(mcc string) (string, bool) {
return country, len(country) == 2
}
// MCCsByCountry returns the complete MCC grouping from the embedded carrier
// database, keyed by ISO alpha-2 country/territory code. The returned map and
// slices are new values and may be safely modified by callers.
func MCCsByCountry() map[string][]string {
result := make(map[string][]string)
for mcc, rawCountry := range globalCarrierDatabase.Countries {
country := strings.ToUpper(strings.TrimSpace(rawCountry))
mcc = strings.TrimSpace(mcc)
if len(country) != 2 || len(mcc) != 3 {
continue
}
result[country] = append(result[country], mcc)
}
for country := range result {
sort.Strings(result[country])
}
return result
}
var globalCarrierDatabase = func() carrierDatabase {
var database carrierDatabase
if err := json.Unmarshal(carrierDatabaseJSON, &database); err != nil {
@@ -104,7 +124,7 @@ func CarrierForIMSI(imsi string) (plmn, name, countryCode string, ok bool) {
// several customer-facing carriers authenticate through the same home PLMN.
func CarrierForSIM(identity CarrierIdentity) (plmn, name, countryCode string, ok bool) {
imsi := strings.TrimSpace(identity.IMSI)
if !decimalDigits(imsi, 5, 20) {
if !decimalDigits(imsi, 5, 20) || IsPlaceholderIMSI(imsi) {
return "", "", "", false
}
plmns := carrierPLMNCandidates(imsi, identity.MNCLength)
+42 -8
View File
@@ -11,14 +11,23 @@ import (
)
type fakeQMIRadioSession struct {
mode qmi.OperatingMode
getModes []qmi.OperatingMode
setModes []qmi.OperatingMode
getErr error
setErr error
closeCount int
iccid string
iccidErr error
mode qmi.OperatingMode
getModes []qmi.OperatingMode
setModes []qmi.OperatingMode
getErr error
setErr error
closeCount int
iccid string
iccidErr error
imei string
imeiErr error
openedAIDs [][]byte
openChannel byte
openErr error
closedChannels []byte
apdus [][]byte
apduResponse []byte
apduErr error
}
func (session *fakeQMIRadioSession) GetOperatingMode(context.Context) (qmi.OperatingMode, error) {
@@ -48,6 +57,31 @@ func (session *fakeQMIRadioSession) GetICCID(context.Context) (string, error) {
return session.iccid, session.iccidErr
}
func (session *fakeQMIRadioSession) GetIMEI(context.Context) (string, error) {
return session.imei, session.imeiErr
}
func (session *fakeQMIRadioSession) OpenLogicalChannel(_ context.Context, _ uint8, aid []byte) (byte, error) {
session.openedAIDs = append(session.openedAIDs, append([]byte(nil), aid...))
if session.openErr != nil {
return 0, session.openErr
}
if session.openChannel == 0 {
return 1, nil
}
return session.openChannel, nil
}
func (session *fakeQMIRadioSession) CloseLogicalChannel(_ context.Context, _ uint8, channel uint8) error {
session.closedChannels = append(session.closedChannels, channel)
return nil
}
func (session *fakeQMIRadioSession) SendAPDU(_ context.Context, _ uint8, _ uint8, command []byte) ([]byte, error) {
session.apdus = append(session.apdus, append([]byte(nil), command...))
return append([]byte(nil), session.apduResponse...), session.apduErr
}
func newStartedNativeQMITestManager(t *testing.T) (*Manager, *staticOpener, string) {
t.Helper()
const id = "wwan0"
+294 -8
View File
@@ -8,6 +8,8 @@ import (
"strings"
"time"
"github.com/iniwex5/quectel-qmi-go/pkg/qmi"
"vocat/internal/i18n"
"vocat/internal/modem"
"vocat/internal/pcsc"
@@ -159,12 +161,20 @@ func encodeICCID(digits string) ([]byte, error) {
}
func buildEnableProfileRequest(iccid string) ([]byte, error) {
return buildEnableProfileRequestWithRefresh(iccid, true)
}
func buildEnableProfileRequestWithRefresh(iccid string, refresh bool) ([]byte, error) {
bcd, err := encodeICCID(iccid)
if err != nil {
return nil, err
}
profileID := derConstruct(0xA0, derEncode(0x5A, bcd))
return derConstruct(0xBF31, profileID, derEncode(0x81, []byte{0xFF})), nil
refreshFlag := byte(0x00)
if refresh {
refreshFlag = 0xFF
}
return derConstruct(0xBF31, profileID, derEncode(0x81, []byte{refreshFlag})), nil
}
// parseCSIM extracts the payload and status word from an AT+CSIM response.
@@ -195,9 +205,74 @@ type euiccChannel struct {
id string
channel int
pcscSession *pcsc.Session
qmiSession nativeQMIEuiccSession
qmiSlot uint8
resetOnClose bool
}
func (channel *euiccChannel) registerProfileRefresh(ctx context.Context) (bool, error) {
refreshSession, ok := channel.qmiSession.(nativeQMIRefreshSession)
if !ok {
return false, nil
}
if err := refreshSession.RegisterUIMRefresh(ctx); err != nil {
var unsupported *qmi.NotSupportedError
if errors.As(err, &unsupported) {
return false, nil
}
return false, err
}
return true, nil
}
func (channel *euiccChannel) completeProfileRefresh(ctx context.Context) error {
refreshSession, ok := channel.qmiSession.(nativeQMIRefreshSession)
if !ok {
return nil
}
return refreshSession.CompleteUIMRefresh(ctx)
}
func (channel *euiccChannel) acknowledgeProfileRefresh(ctx context.Context) error {
refreshSession, ok := channel.qmiSession.(nativeQMIRefreshSession)
if !ok {
return nil
}
return refreshSession.AcknowledgeUIMRefresh(ctx)
}
func (channel *euiccChannel) recoverCATBusy(ctx context.Context) error {
if channel.qmiSession == nil {
return nil
}
// A power cycle must happen while the CAT2 client remains registered, or
// the card can issue its first proactive command before VoCat is listening
// and immediately become busy again.
if channel.channel > 0 {
_ = channel.qmiSession.CloseLogicalChannel(ctx, channel.qmiSlot, byte(channel.channel))
channel.channel = 0
}
power, ok := channel.qmiSession.(interface {
PowerOffSIM(context.Context, uint8) error
PowerOnSIM(context.Context, uint8) error
})
if !ok {
return nil
}
if err := power.PowerOffSIM(ctx, channel.qmiSlot); err != nil {
return err
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(time.Second):
}
if err := power.PowerOnSIM(ctx, channel.qmiSlot); err != nil {
return err
}
return channel.completeProfileRefresh(ctx)
}
// csimAPDUTimeout bounds a single AT+CSIM exchange. Loading a BoundProfilePackage
// makes the eUICC decrypt/write sizeable SCP03t segments on-card, which can exceed
// the modem's default 3s command timeout, so eSIM APDUs get a longer budget.
@@ -289,6 +364,9 @@ func (manager *Manager) openEuiccOnceAID(ctx context.Context, id, aidHex string)
if candidate.HardwareKind == pcsc.HardwareKind {
return manager.openPCSCEuiccOnceAID(ctx, id, candidate, aidHex)
}
if strings.EqualFold(manager.backendFor(state), "qmi") && isNativeQMICandidate(candidate) {
return manager.openQMIEuiccOnceAID(ctx, id, candidate, aidHex)
}
// MANAGE CHANNEL (open): 00 70 00 00 01 -> "<channel> 90 00". This EC20
// firmware requires the explicit one-byte expected length: Le=00 opens a
// channel but then rejects SELECT ISD-R at the AT+CSIM layer.
@@ -327,6 +405,38 @@ func (manager *Manager) openEuiccOnceAID(ctx context.Context, id, aidHex string)
return channel, nil
}
func (manager *Manager) openQMIEuiccOnceAID(ctx context.Context, id string, candidate modem.Candidate, aidHex string) (*euiccChannel, error) {
aidHex = strings.ToUpper(strings.TrimSpace(aidHex))
aid, err := hex.DecodeString(aidHex)
if err != nil || len(aid) == 0 || len(aid) > 255 {
return nil, fmt.Errorf("esim: invalid ISD-R AID %q", aidHex)
}
if manager.qmiRadioOpener == nil {
return nil, errors.New("esim: QMI UIM transport is unavailable")
}
openContext, cancel := context.WithTimeout(ctx, csimAPDUTimeout)
defer cancel()
radioSession, err := manager.qmiRadioOpener(openContext, candidate.QMIControl)
if err != nil {
return nil, fmt.Errorf("esim: open QMI UIM transport: %w", err)
}
session, ok := radioSession.(nativeQMIEuiccSession)
if !ok {
_ = radioSession.Close()
return nil, errors.New("esim: QMI UIM transport does not support logical channels")
}
const slot uint8 = 1
logicalChannel, err := session.OpenLogicalChannel(openContext, slot, aid)
if err != nil {
_ = session.Close()
return nil, fmt.Errorf("%w: %v", errNoEUICC, err)
}
return &euiccChannel{
manager: manager, id: id, channel: int(logicalChannel),
qmiSession: session, qmiSlot: slot,
}, nil
}
func (manager *Manager) openPCSCEuiccOnceAID(ctx context.Context, id string, candidate modem.Candidate, aidHex string) (*euiccChannel, error) {
session, err := manager.cardReaders.OpenSession(ctx, pcsc.Selector{
USBPath: candidate.USBPath, ReaderName: candidate.ReaderName,
@@ -407,6 +517,14 @@ func isTransientEuiccCME(err error) bool {
// close releases the logical channel (MANAGE CHANNEL close).
func (channel *euiccChannel) close(ctx context.Context) {
if channel.qmiSession != nil {
if channel.channel > 0 {
_ = channel.qmiSession.CloseLogicalChannel(ctx, channel.qmiSlot, byte(channel.channel))
}
_ = channel.qmiSession.Close()
channel.qmiSession = nil
return
}
closeAPDU := []byte{0x00, 0x70, 0x80, byte(channel.channel), 0x00}
_, _, _ = channel.exchange(ctx, closeAPDU)
if channel.pcscSession != nil {
@@ -420,6 +538,17 @@ func (channel *euiccChannel) close(ctx context.Context) {
}
func (channel *euiccChannel) exchange(ctx context.Context, apdu []byte) ([]byte, int, error) {
if channel.qmiSession != nil {
raw, err := channel.qmiSession.SendAPDU(ctx, channel.qmiSlot, byte(channel.channel), apdu)
if err != nil {
return nil, 0, err
}
if len(raw) < 2 {
return nil, 0, fmt.Errorf("esim: short QMI UIM APDU response")
}
sw := int(raw[len(raw)-2])<<8 | int(raw[len(raw)-1])
return raw[:len(raw)-2], sw, nil
}
if channel.pcscSession != nil {
payload, sw, err := channel.pcscSession.Transmit(ctx, apdu)
return payload, int(sw), err
@@ -652,9 +781,9 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
if iccid == "" {
return errors.New("esim: an ICCID is required")
}
der, err := buildEnableProfileRequest(iccid)
if err != nil {
return err
_, nativeQMI, nativeErr := manager.nativeQMIControl(id)
if nativeErr != nil {
return nativeErr
}
manager.lockESIM()
if err := manager.waitForESIMRecovery(ctx, id); err != nil {
@@ -666,6 +795,31 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
manager.unlockESIM()
return err
}
refreshRequested := !nativeQMI
if nativeQMI {
refreshContext, cancelRefresh := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
refreshRequested, err = channel.registerProfileRefresh(refreshContext)
cancelRefresh()
if err != nil {
channel.close(context.Background())
manager.unlockESIM()
return fmt.Errorf("esim: register QMI UIM refresh: %w", err)
}
// After a refresh=true attempt reports catBusy, retry without asking the
// eUICC to start another REFRESH proactive command. SGP.22 permits the
// card to terminate the pre-existing proactive session in this mode; the
// native-QMI recovery below performs the required SIM reset and cache
// reload on behalf of the device.
if attempt, _ := ctx.Value(esimCATBusyRetryKey{}).(int); attempt > 0 {
refreshRequested = false
}
}
der, err := buildEnableProfileRequestWithRefresh(iccid, refreshRequested)
if err != nil {
channel.close(context.Background())
manager.unlockESIM()
return err
}
// EnableProfile request (SGP.22 ES10c, per lpac):
// BF31 { A0 { 5A <iccid bcd> } 81 01 FF } (refresh = yes)
@@ -675,10 +829,38 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
// stays a sibling of A0, directly under BF31.
// EnableProfile is a non-idempotent commit. Once its APDU starts, a browser
// disconnect or reverse-proxy timeout must not cancel it halfway through and
// skip the modem reset, otherwise EC20 remains in SIM failure (+CME 13).
// skip post-commit recovery; EC20 may otherwise remain in SIM failure
// (+CME 13).
commitContext, cancelCommit := context.WithTimeout(context.WithoutCancel(ctx), csimAPDUTimeout)
payload, err := channel.es10(commitContext, der)
cancelCommit()
// A rejected EnableProfile (for example CAT busy) does not emit REFRESH.
// Parse the card-level result before waiting for an indication, otherwise
// every retry needlessly waits for the refresh timeout.
resultBeforeClose, resultPresentBeforeClose := enableProfileResult(payload)
if err == nil && resultPresentBeforeClose && byte(resultBeforeClose) == 5 && nativeQMI {
// Registering CAT2 may immediately deliver a proactive command that was
// already pending before EnableProfile. Drain it on catBusy so the raw
// REFRESH command receives its terminal response before the retry.
catContext, cancelCAT := context.WithTimeout(context.Background(), 3*time.Second)
_ = channel.completeProfileRefresh(catContext)
cancelCAT()
if attempt, _ := ctx.Value(esimCATBusyRetryKey{}).(int); attempt == 0 {
recoveryContext, cancelRecovery := context.WithTimeout(context.Background(), 12*time.Second)
_ = channel.recoverCATBusy(recoveryContext)
cancelRecovery()
}
ackContext, cancelAck := context.WithTimeout(context.Background(), 5*time.Second)
_ = channel.acknowledgeProfileRefresh(ackContext)
cancelAck()
}
if err == nil && resultPresentBeforeClose &&
enableProfileResponseError(byte(resultBeforeClose), payload) == nil &&
refreshRequested && nativeQMI {
refreshContext, cancelRefresh := context.WithTimeout(context.Background(), 20*time.Second)
_ = channel.completeProfileRefresh(refreshContext)
cancelRefresh()
}
// Release the logical channel before any reset: openEuicc's csim holds
// opMu only for the duration of each APDU, so by here the lock is free.
closeContext, cancelClose := context.WithTimeout(context.Background(), csimAPDUTimeout)
@@ -703,10 +885,48 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
return fmt.Errorf("esim: unexpected EnableProfile response %s", strings.ToUpper(hex.EncodeToString(payload)))
}
if err := enableProfileResponseError(byte(result), payload); err != nil {
if errors.Is(err, ErrESIMEnableCATBusy) {
attempt, _ := ctx.Value(esimCATBusyRetryKey{}).(int)
if attempt < 11 {
manager.unlockESIM()
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Second):
}
return manager.ESIMSwitchProfile(context.WithValue(ctx, esimCATBusyRetryKey{}, attempt+1), id, iccid, aidHex)
}
}
manager.unlockESIM()
return err
}
manager.markCachedProfileEnabled(id, iccid)
// EnableProfile already requested an eUICC REFRESH. Some AT modems consume
// that proactive command and expose the new subscription immediately, so a
// full CFUN=1,1 reset would only add downtime. Give those devices a short
// chance to prove that their SIM cache is current; modems that keep reporting
// the old ICCID continue through the established reboot/recovery path below.
if manager.canVerifyProfileSwitchWithoutRestart(id) {
probeContext, cancelProbe := context.WithTimeout(
context.WithoutCancel(ctx),
profileSwitchRefreshProbeTimeout(manager),
)
probeErr := manager.verifySwitchedICCIDAttempts(probeContext, id, iccid, 3, time.Second)
cancelProbe()
if probeErr == nil {
// Repopulate the cached snapshot while the AT transport is still live.
// Verification above is authoritative, so snapshot refresh remains
// best-effort just as it is after the legacy reboot path.
refreshContext, cancelRefresh := context.WithTimeout(
context.WithoutCancel(ctx),
manager.longTimeout,
)
_, _ = manager.Refresh(refreshContext, id)
cancelRefresh()
manager.unlockESIM()
return nil
}
}
// The eUICC accepted the target profile. Reset and repopulate the modem in
// a detached recovery so it survives an HTTP disconnect, but keep this API
// call pending until the live modem ICCID proves that the switch took effect.
@@ -721,6 +941,8 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
return manager.verifySwitchedICCID(verifyContext, id, iccid)
}
type esimCATBusyRetryKey struct{}
func (manager *Manager) startProfileSwitchRecovery(id string) {
done := make(chan struct{})
manager.esimRecoveryMu.Lock()
@@ -853,6 +1075,18 @@ func (manager *Manager) renameCachedProfile(id, iccid, nickname string) {
// initiating HTTP request. EC20 commonly drops the AT port while processing
// CFUN=1,1, so the reset error is intentionally followed by discovery retries.
func (manager *Manager) recoverAfterProfileSwitch(id string) {
resetContext, cancelReset := context.WithTimeout(context.Background(), manager.longTimeout)
if native, err := manager.powerCycleNativeQMISIM(resetContext, id); native {
cancelReset()
if err == nil {
time.Sleep(1500 * time.Millisecond)
}
// Native WWAN identity and profile verification are both QMI-backed.
// Do not enter the AT refresh path: OpenStick firmware can accept the
// switch while timing out every EC20-specific AT identity command.
return
}
cancelReset()
if !manager.isPCSCDevice(id) {
resetContext, cancelReset := context.WithTimeout(context.Background(), manager.longTimeout)
_ = manager.rebootForProfileSwitch(resetContext, id)
@@ -975,17 +1209,69 @@ func profileSwitchVerificationTimeout(manager *Manager) time.Duration {
return timeout
}
func profileSwitchRefreshProbeTimeout(manager *Manager) time.Duration {
// Allow both standard ICCID commands to consume one ordinary command
// timeout, plus a small window for the eUICC REFRESH to settle. Keep the
// optimisation bounded so an older modem reaches its required reboot soon.
timeout := manager.commandTimeout*2 + time.Second
if timeout < 3*time.Second {
return 3 * time.Second
}
if timeout > 10*time.Second {
return 10 * time.Second
}
return timeout
}
func (manager *Manager) canVerifyProfileSwitchWithoutRestart(id string) bool {
_, native, err := manager.nativeQMIControl(id)
return err == nil && !native && !manager.isPCSCDevice(id)
}
// verifySwitchedICCID performs a fresh baseband read after recovery. An ES10c
// result of zero only means the eUICC accepted the operation; the state change
// is finalized by REFRESH/reset. The UI must not report success until the modem
// is actually exposing the requested ICCID.
func (manager *Manager) verifySwitchedICCID(ctx context.Context, id, expected string) error {
return manager.verifySwitchedICCIDAttempts(ctx, id, expected, 6, 2*time.Second)
}
func (manager *Manager) verifySwitchedICCIDAttempts(
ctx context.Context,
id string,
expected string,
attempts int,
interval time.Duration,
) error {
expected = strings.TrimSpace(expected)
const attempts = 6
var lastICCID string
var lastErr error
for attempt := 0; attempt < attempts; attempt++ {
if manager.isPCSCDevice(id) {
if control, native, nativeErr := manager.nativeQMIControl(id); native {
if nativeErr != nil {
lastErr = nativeErr
} else {
state, lookupErr := manager.lookup(id)
if lookupErr != nil {
lastErr = lookupErr
} else {
candidate := manager.candidateFor(state)
candidate.QMIControl = control
live, readErr := manager.readNativeQMIICCID(ctx, candidate)
if readErr == nil {
lastICCID = strings.TrimSpace(live)
if lastICCID == expected {
return nil
}
lastErr = fmt.Errorf("native QMI still reports ICCID %s", lastICCID)
} else {
lastErr = readErr
}
}
}
} else if nativeErr != nil {
lastErr = nativeErr
} else if manager.isPCSCDevice(id) {
snapshot, err := manager.Refresh(ctx, id)
if err == nil {
lastICCID = strings.TrimSpace(snapshot.ICCID)
@@ -1019,7 +1305,7 @@ func (manager *Manager) verifySwitchedICCID(ctx context.Context, id, expected st
}
if attempt+1 < attempts {
select {
case <-time.After(2 * time.Second):
case <-time.After(interval):
case <-ctx.Done():
return fmt.Errorf("esim: verify enabled profile %s: %w", expected, ctx.Err())
}
+68
View File
@@ -1,6 +1,7 @@
package device
import (
"bytes"
"context"
"encoding/hex"
"errors"
@@ -206,6 +207,38 @@ func TestVerifySwitchedICCIDReadsLiveModem(t *testing.T) {
client.assertDone(t)
}
func TestVerifySwitchedICCIDAttemptsAllowsProactiveRefreshToSettle(t *testing.T) {
const target = "89492026266006792824"
client := &transcriptClient{steps: []clientStep{
{command: "AT+CCID", response: okResponse("+CCID: 89441000400128014257F")},
{command: "AT+CCID", response: okResponse("+CCID: " + target + "F")},
}}
manager, id := newStartedTestManager(t, client)
if !manager.canVerifyProfileSwitchWithoutRestart(id) {
t.Fatal("AT modem should be eligible for refresh verification before restart")
}
if err := manager.verifySwitchedICCIDAttempts(context.Background(), id, target, 2, 0); err != nil {
t.Fatalf("verifySwitchedICCIDAttempts: %v", err)
}
client.assertDone(t)
}
func TestProfileSwitchRefreshProbeTimeoutIsBounded(t *testing.T) {
for _, test := range []struct {
command time.Duration
want time.Duration
}{
{command: 100 * time.Millisecond, want: 3 * time.Second},
{command: 3 * time.Second, want: 7 * time.Second},
{command: 30 * time.Second, want: 10 * time.Second},
} {
manager := &Manager{commandTimeout: test.command}
if got := profileSwitchRefreshProbeTimeout(manager); got != test.want {
t.Fatalf("command timeout %s: probe timeout = %s, want %s", test.command, got, test.want)
}
}
}
func TestEUMManufacturerForWatchData(t *testing.T) {
if got := eumManufacturerForEID("35840574202500000125000001855764"); got != "WatchData Technologies Ltd." {
t.Fatalf("manufacturer = %q", got)
@@ -283,6 +316,41 @@ func TestDiscoverEuiccAIDsFindsXeSIMAlternateISDR(t *testing.T) {
client.assertDone(t)
}
func TestNativeQMIUsesUIMLogicalChannelForEUICC(t *testing.T) {
manager, _, id := newStartedNativeQMITestManager(t)
if err := manager.SetBackend(id, "qmi"); err != nil {
t.Fatal(err)
}
session := &fakeQMIRadioSession{
openChannel: 3,
apduResponse: []byte{0xDE, 0xAD, 0x90, 0x00},
}
manager.qmiRadioOpener = func(context.Context, string) (qmiRadioSession, error) {
return session, nil
}
channel, err := manager.openEuiccAID(context.Background(), id, isdRAID)
if err != nil {
t.Fatalf("open QMI eUICC: %v", err)
}
payload, sw, err := channel.transmit(context.Background(), []byte{0x80, 0xCA, 0x00, 0x00, 0x00}, 0x80)
if err != nil {
t.Fatalf("transmit QMI APDU: %v", err)
}
if !bytes.Equal(payload, []byte{0xDE, 0xAD}) || sw != 0x9000 {
t.Fatalf("QMI APDU response = %X/%04X", payload, sw)
}
channel.close(context.Background())
if len(session.openedAIDs) != 1 || strings.ToUpper(hex.EncodeToString(session.openedAIDs[0])) != isdRAID {
t.Fatalf("opened AIDs = %X", session.openedAIDs)
}
if len(session.apdus) != 1 || session.apdus[0][0] != 0x83 {
t.Fatalf("QMI APDUs = %X", session.apdus)
}
if len(session.closedChannels) != 1 || session.closedChannels[0] != 3 || session.closeCount != 1 {
t.Fatalf("closed channels/session = %v/%d", session.closedChannels, session.closeCount)
}
}
func TestEUICCChannelStuckWrapsTransientCME(t *testing.T) {
cause := &modem.CommandError{
Command: `AT+CSIM=10,"0070000001"`,
+441 -3
View File
@@ -2,8 +2,10 @@ package device
import (
"context"
"encoding/binary"
"errors"
"fmt"
"log/slog"
"strings"
"sync"
"time"
@@ -25,6 +27,48 @@ type nativeQMIICCIDSession interface {
GetICCID(context.Context) (string, error)
}
type nativeQMIIMEISession interface {
GetIMEI(context.Context) (string, error)
}
type nativeQMIEuiccSession interface {
qmiRadioSession
OpenLogicalChannel(context.Context, uint8, []byte) (byte, error)
CloseLogicalChannel(context.Context, uint8, uint8) error
SendAPDU(context.Context, uint8, uint8, []byte) ([]byte, error)
}
// nativeQMIRefreshSession is implemented by production QMI sessions that can
// participate in the modem's UIM REFRESH state machine. Keep it separate from
// nativeQMIEuiccSession so transcript fakes and older QMI implementations can
// continue to use the APDU transport without pretending to handle indications.
type nativeQMIRefreshSession interface {
RegisterUIMRefresh(context.Context) error
CompleteUIMRefresh(context.Context) error
AcknowledgeUIMRefresh(context.Context) error
}
type nativeQMIUIMResetSession interface {
ResetUIM(context.Context) error
}
type nativeQMIVoWiFiSession interface {
qmiRadioSession
GetICCID(context.Context) (string, error)
GetIMEI(context.Context) (string, error)
GetIMSI(context.Context) (string, error)
GetNativeMCCMNC(context.Context) (string, string, error)
GetUSIMAID(context.Context) ([]byte, error)
GetISIMAID(context.Context) ([]byte, error)
GetServingSystem(context.Context) (*qmi.ServingSystem, error)
AttachDetach(context.Context, bool) error
OpenLogicalChannel(context.Context, uint8, []byte) (byte, error)
CloseLogicalChannel(context.Context, uint8, uint8) error
SendAPDU(context.Context, uint8, uint8, []byte) ([]byte, error)
PowerOffSIM(context.Context, uint8) error
PowerOnSIM(context.Context, uint8) error
}
// nativeQMIControl identifies the QMI control node exposed by native WWAN
// devices. USB serial modems may also advertise a control path, but only the
// wwanN/qmiN pairing is safe to operate through the native QMI path.
@@ -47,6 +91,7 @@ type productionQMIRadioSession struct {
dms *qmi.DMSService
nas *qmi.NASService
nasErr error
catID uint8
uimMu sync.Mutex
uim *qmi.UIMService
lease *qmiport.Lease
@@ -158,19 +203,408 @@ func openQMIRadioSession(ctx context.Context, controlDevice string) (qmiRadioSes
}
func (session *productionQMIRadioSession) GetICCID(ctx context.Context) (string, error) {
uim, err := session.uimService(ctx)
if err != nil {
return "", err
}
return uim.GetICCID(ctx)
}
func (session *productionQMIRadioSession) GetIMSI(ctx context.Context) (string, error) {
uim, err := session.uimService(ctx)
if err != nil {
return "", err
}
return uim.GetIMSI(ctx)
}
func (session *productionQMIRadioSession) GetNativeMCCMNC(ctx context.Context) (string, string, error) {
uim, err := session.uimService(ctx)
if err != nil {
return "", "", err
}
return uim.GetNativeMCCMNC(ctx)
}
func (session *productionQMIRadioSession) GetUSIMAID(ctx context.Context) ([]byte, error) {
uim, err := session.uimService(ctx)
if err != nil {
return nil, err
}
return uim.GetUSIMAID(ctx)
}
func (session *productionQMIRadioSession) GetISIMAID(ctx context.Context) ([]byte, error) {
uim, err := session.uimService(ctx)
if err != nil {
return nil, err
}
return uim.GetISIMAID(ctx)
}
func (session *productionQMIRadioSession) PowerOffSIM(ctx context.Context, slot uint8) error {
uim, err := session.uimService(ctx)
if err != nil {
return err
}
return uim.PowerOffSIM(ctx, slot)
}
func (session *productionQMIRadioSession) PowerOnSIM(ctx context.Context, slot uint8) error {
uim, err := session.uimService(ctx)
if err != nil {
return err
}
return uim.PowerOnSIM(ctx, slot)
}
func (session *productionQMIRadioSession) ResetUIM(ctx context.Context) error {
uim, err := session.uimService(ctx)
if err != nil {
return err
}
return uim.Reset(ctx)
}
func (session *productionQMIRadioSession) uimService(ctx context.Context) (*qmi.UIMService, error) {
if session == nil || session.client == nil {
return "", errors.New("QMI UIM session is unavailable")
return nil, errors.New("QMI UIM session is unavailable")
}
session.uimMu.Lock()
defer session.uimMu.Unlock()
if session.uim == nil {
uim, err := qmi.NewUIMServiceWithContext(ctx, session.client)
if err != nil {
return "", err
return nil, err
}
session.uim = uim
}
return session.uim.GetICCID(ctx)
return session.uim, nil
}
func (session *productionQMIRadioSession) OpenLogicalChannel(ctx context.Context, slot uint8, aid []byte) (byte, error) {
uim, err := session.uimService(ctx)
if err != nil {
return 0, err
}
return uim.OpenLogicalChannel(ctx, slot, aid)
}
func (session *productionQMIRadioSession) CloseLogicalChannel(ctx context.Context, slot, channel uint8) error {
uim, err := session.uimService(ctx)
if err != nil {
return err
}
return uim.CloseLogicalChannel(ctx, slot, channel)
}
func (session *productionQMIRadioSession) SendAPDU(ctx context.Context, slot, channel uint8, command []byte) ([]byte, error) {
uim, err := session.uimService(ctx)
if err != nil {
return nil, err
}
return uim.SendAPDU(ctx, slot, channel, command)
}
// RegisterUIMRefresh mirrors the terminal registration used by libqmi for a
// physical card slot. EnableProfile(refresh=true) may cause the eUICC to issue
// a proactive REFRESH; without a registered terminal the card remains CAT busy
// after the profile has changed and rejects the next profile operation.
func (session *productionQMIRadioSession) RegisterUIMRefresh(ctx context.Context) error {
uim, err := session.uimService(ctx)
if err != nil {
return err
}
if err := uim.RefreshRegisterAll(ctx, qmi.UIMRefreshRegisterAllRequest{
SessionType: qmi.UIMSessionTypeCardSlot1,
RegisterFlag: true,
}); err != nil {
return err
}
if session.catID == 0 {
clientID, err := session.client.AllocateClientIDWithContext(ctx, qmi.ServiceCAT2)
if err != nil {
return fmt.Errorf("allocate QMI CAT2 client: %w", err)
}
session.catID = clientID
}
configuration, configErr := session.client.SendRequest(ctx, qmi.ServiceCAT2, session.catID, 0x002E, nil)
if configErr == nil && configuration.CheckResult() == nil {
if modeTLV := qmi.FindTLV(configuration.TLVs, 0x10); modeTLV != nil && len(modeTLV.Value) > 0 {
slog.Info("QMI CAT2 configuration", "mode", modeTLV.Value[0])
}
}
response, err := session.client.SendRequest(ctx, qmi.ServiceCAT2, session.catID, 0x0001, []qmi.TLV{
// Claim the raw proactive-command events implemented by this CAT2
// generation (bits 0..22 and 24..25). A profile can leave any STK
// command pending, not only REFRESH, and SGP.22 forbids profile changes
// while that proactive session is unanswered.
{Type: 0x10, Value: []byte{0xFF, 0xFF, 0x7F, 0x03}},
// Slot mask bit 0 selects slot 1.
{Type: 0x12, Value: []byte{0x01}},
})
if err != nil {
return fmt.Errorf("register QMI CAT2 refresh: %w", err)
}
if err := response.CheckResult(); err != nil {
return fmt.Errorf("register QMI CAT2 refresh: %w", err)
}
for _, tlv := range response.TLVs {
if tlv.Type >= 0x10 && tlv.Type <= 0x12 {
slog.Info("QMI CAT2 registration response", "tlv", fmt.Sprintf("0x%02X", tlv.Type), "value", fmt.Sprintf("%X", tlv.Value))
}
}
return nil
}
// CompleteUIMRefresh consumes refresh indications on the same QMI client that
// registered for them. Qualcomm requires RefreshComplete only for START
// indications whose mode is not RESET; RESET is completed by the modem itself.
func (session *productionQMIRadioSession) CompleteUIMRefresh(ctx context.Context) error {
if session == nil || session.client == nil {
return errors.New("QMI UIM refresh session is unavailable")
}
uim, err := session.uimService(ctx)
if err != nil {
return err
}
refreshCompleted := false
uimEnded := false
catEnded := false
for {
select {
case <-ctx.Done():
// Some firmware handles a RESET internally and never forwards an
// indication to this client. A missing indication is therefore not
// a failed profile commit.
return nil
case event, ok := <-session.client.Events():
if !ok {
return nil
}
if event.ServiceID == qmi.ServiceCAT2 && event.MessageID == 0x0001 {
for _, eventTLV := range event.Packet.TLVs {
slog.Info("QMI CAT2 event", "tlv", fmt.Sprintf("0x%02X", eventTLV.Type), "length", len(eventTLV.Value))
}
if tlv := qmi.FindTLV(event.Packet.TLVs, 0x19); tlv != nil && len(tlv.Value) >= 4 {
mode := uint16(tlv.Value[0]) | uint16(tlv.Value[1])<<8
stage := uint16(tlv.Value[2]) | uint16(tlv.Value[3])<<8
slog.Info("QMI CAT2 profile refresh", "stage", stage, "mode", mode)
if stage == 3 {
return errors.New("QMI CAT2 refresh ended with failure")
}
}
// UIM refresh completion is not a CAT terminal response. Qualcomm
// delivers the raw proactive command in a command-specific TLV; send
// a response carrying that command's reference ID. Unsupported UI STK
// commands receive the standards-defined "beyond terminal
// capabilities" result, which still closes the proactive session.
for _, commandTLV := range event.Packet.TLVs {
if !isRawCATCommandTLV(commandTLV.Type) {
continue
}
ref, terminalResponse, commandType, responseOK := catProactiveTerminalResponse(commandTLV.Value)
if !responseOK {
continue
}
if err := session.sendCATTerminalResponse(ctx, ref, terminalResponse); err != nil {
return err
}
slog.Info("QMI CAT2 terminal response sent", "reference", ref, "command", fmt.Sprintf("0x%02X", commandType))
break
}
if tlv := qmi.FindTLV(event.Packet.TLVs, 0x1A); tlv != nil && len(tlv.Value) > 0 {
// Older MDM8916 CAT2 firmware encodes this enum in one byte;
// newer interface descriptions model it as a 32-bit value.
reason := uint32(tlv.Value[0])
if len(tlv.Value) >= 4 {
reason |= uint32(tlv.Value[1])<<8 | uint32(tlv.Value[2])<<16 | uint32(tlv.Value[3])<<24
}
slog.Info("QMI CAT2 proactive session ended", "reason", reason)
catEnded = true
if uimEnded {
return nil
}
}
continue
}
if event.Type != qmi.EventUIMRefresh {
continue
}
info, parseErr := qmi.ParseUIMRefreshIndication(event.Packet)
if parseErr != nil {
return parseErr
}
const (
refreshStageWaitForOK = uint8(0)
refreshStageStart = uint8(1)
refreshStageSuccess = uint8(2)
refreshStageFailure = uint8(3)
refreshModeReset = uint8(0)
)
slog.Info("QMI UIM profile refresh", "stage", info.Stage, "mode", info.Mode)
switch info.Stage {
case refreshStageWaitForOK:
// Registration without a vote advances on its own. Keep the UIM
// client alive for the subsequent START and END indications.
continue
case refreshStageStart:
if info.Mode == refreshModeReset || refreshCompleted {
continue
}
// libqmi intentionally uses CARD_SLOT_1 here rather than echoing
// the provisioning session from the indication.
_ = uim.RefreshComplete(ctx, qmi.UIMRefreshCompleteRequest{
SessionType: qmi.UIMSessionTypeCardSlot1,
RefreshSuccess: true,
})
refreshCompleted = true
continue
case refreshStageSuccess:
uimEnded = true
if catEnded {
return nil
}
continue
case refreshStageFailure:
return errors.New("QMI UIM refresh ended with failure")
default:
continue
}
}
}
}
func (session *productionQMIRadioSession) sendCATTerminalResponse(ctx context.Context, reference uint32, terminalResponse []byte) error {
value := make([]byte, 0, 6+len(terminalResponse))
value = binary.LittleEndian.AppendUint32(value, reference)
value = binary.LittleEndian.AppendUint16(value, uint16(len(terminalResponse)))
value = append(value, terminalResponse...)
response, err := session.client.SendRequest(ctx, qmi.ServiceCAT2, session.catID, 0x0021, []qmi.TLV{
{Type: 0x01, Value: value},
{Type: 0x10, Value: []byte{0x01}}, // CAT slot 1 (not a slot mask)
})
if err != nil {
return fmt.Errorf("send QMI CAT2 refresh terminal response: %w", err)
}
if err := response.CheckResult(); err != nil {
return fmt.Errorf("send QMI CAT2 refresh terminal response: %w", err)
}
return nil
}
// catProactiveTerminalResponse extracts a raw CAT command carried as
// {reference:uint32LE, length:uint16LE, BER-TLV command} and creates the
// standards-shaped terminal response. VoCat has no interactive STK UI, so
// commands other than REFRESH/MORE TIME are explicitly reported unsupported.
func catProactiveTerminalResponse(raw []byte) (uint32, []byte, byte, bool) {
if len(raw) < 8 {
return 0, nil, 0, false
}
reference := binary.LittleEndian.Uint32(raw[:4])
commandLength := int(binary.LittleEndian.Uint16(raw[4:6]))
if commandLength <= 0 || commandLength > len(raw)-6 {
return 0, nil, 0, false
}
command := raw[6 : 6+commandLength]
if len(command) < 2 || command[0] != 0xD0 {
return 0, nil, 0, false
}
bodyLength, lengthBytes, ok := catBERLength(command[1:])
if !ok || 1+lengthBytes+bodyLength > len(command) {
return 0, nil, 0, false
}
body := command[1+lengthBytes : 1+lengthBytes+bodyLength]
for offset := 0; offset < len(body); {
tag := body[offset]
offset++
length, consumed, ok := catBERLength(body[offset:])
if !ok || offset+consumed+length > len(body) {
return 0, nil, 0, false
}
offset += consumed
value := body[offset : offset+length]
offset += length
if tag&0x7F != 0x01 || len(value) < 3 {
continue
}
result := byte(0x30) // command beyond terminal capabilities
if value[1] == 0x01 || value[1] == 0x02 { // REFRESH or MORE TIME
result = 0x00 // command performed successfully
}
terminalResponse := []byte{
0x81, 0x03, value[0], value[1], value[2], // command details
0x82, 0x02, 0x82, 0x81, // terminal -> UICC
0x83, 0x01, result,
}
return reference, terminalResponse, value[1], true
}
return 0, nil, 0, false
}
func catRefreshTerminalResponse(raw []byte) (uint32, []byte, bool) {
reference, response, commandType, ok := catProactiveTerminalResponse(raw)
return reference, response, ok && commandType == 0x01
}
func isRawCATCommandTLV(tag byte) bool {
switch tag {
case 0x10, 0x11, 0x12, 0x13, 0x14, 0x17, 0x18,
0x47, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F,
0x51, 0x52, 0x53, 0x54, 0x66, 0x6A:
return true
default:
return false
}
}
func catBERLength(raw []byte) (length int, consumed int, ok bool) {
if len(raw) == 0 {
return 0, 0, false
}
switch raw[0] {
case 0x81:
if len(raw) < 2 {
return 0, 0, false
}
return int(raw[1]), 2, true
case 0x82:
if len(raw) < 3 {
return 0, 0, false
}
return int(raw[1])<<8 | int(raw[2]), 3, true
default:
if raw[0]&0x80 != 0 {
return 0, 0, false
}
return int(raw[0]), 1, true
}
}
// AcknowledgeUIMRefresh is a recovery vote for a refresh that predates this
// QMI client. Qualcomm documents RefreshComplete as harmless when no vote is
// pending; it lets a newly started service release a stale CAT-busy condition
// left by an interrupted LPA/terminal transaction.
func (session *productionQMIRadioSession) AcknowledgeUIMRefresh(ctx context.Context) error {
uim, err := session.uimService(ctx)
if err != nil {
return err
}
return uim.RefreshComplete(ctx, qmi.UIMRefreshCompleteRequest{
SessionType: qmi.UIMSessionTypeCardSlot1,
RefreshSuccess: true,
})
}
func (session *productionQMIRadioSession) GetIMEI(ctx context.Context) (string, error) {
if session == nil || session.dms == nil {
return "", errors.New("QMI DMS identity session is unavailable")
}
info, err := session.dms.GetDeviceSerialNumbers(ctx)
if err != nil {
return "", err
}
return info.IMEI, nil
}
func (session *productionQMIRadioSession) GetOperatingMode(ctx context.Context) (qmi.OperatingMode, error) {
@@ -200,6 +634,10 @@ func (session *productionQMIRadioSession) Close() error {
closeErrors = append(closeErrors, session.nas.Close())
session.nas = nil
}
if session.client != nil && session.catID != 0 {
closeErrors = append(closeErrors, session.client.ReleaseClientID(qmi.ServiceCAT2, session.catID))
session.catID = 0
}
if session.client != nil {
closeErrors = append(closeErrors, session.client.Close())
session.client = nil
+52
View File
@@ -0,0 +1,52 @@
package device
import (
"bytes"
"testing"
)
func TestCATRefreshTerminalResponse(t *testing.T) {
raw := []byte{
0x44, 0x33, 0x22, 0x11, // reference
0x0B, 0x00, // command length
0xD0, 0x09,
0x81, 0x03, 0x07, 0x01, 0x00,
0x82, 0x02, 0x81, 0x82,
}
reference, response, ok := catRefreshTerminalResponse(raw)
if !ok {
t.Fatal("catRefreshTerminalResponse() did not recognize REFRESH")
}
if reference != 0x11223344 {
t.Fatalf("reference = 0x%08X", reference)
}
want := []byte{
0x81, 0x03, 0x07, 0x01, 0x00,
0x82, 0x02, 0x82, 0x81,
0x83, 0x01, 0x00,
}
if !bytes.Equal(response, want) {
t.Fatalf("response = % X, want % X", response, want)
}
}
func TestCATRefreshTerminalResponseRejectsOtherCommands(t *testing.T) {
raw := []byte{
0x01, 0x00, 0x00, 0x00,
0x0B, 0x00,
0xD0, 0x09,
0x81, 0x03, 0x01, 0x21, 0x00, // DISPLAY TEXT
0x82, 0x02, 0x81, 0x02,
}
if _, _, ok := catRefreshTerminalResponse(raw); ok {
t.Fatal("catRefreshTerminalResponse() accepted a non-REFRESH command")
}
}
func TestCATRefreshTerminalResponseSupportsLongBERLength(t *testing.T) {
command := []byte{0xD0, 0x81, 0x09, 0x81, 0x03, 0x02, 0x01, 0x01, 0x82, 0x02, 0x81, 0x82}
raw := append([]byte{0x02, 0x00, 0x00, 0x00, byte(len(command)), 0x00}, command...)
if _, _, ok := catRefreshTerminalResponse(raw); !ok {
t.Fatal("catRefreshTerminalResponse() rejected 0x81 BER length")
}
}
+24
View File
@@ -37,3 +37,27 @@ func (manager *Manager) readNativeQMIICCID(ctx context.Context, candidate modem.
}
return iccid, nil
}
func (manager *Manager) readNativeQMIIMEI(ctx context.Context, candidate modem.Candidate) (string, error) {
if manager.qmiRadioOpener == nil {
return "", errors.New("QMI DMS IMEI reader is unavailable")
}
session, err := manager.qmiRadioOpener(ctx, candidate.QMIControl)
if err != nil {
return "", err
}
defer session.Close()
reader, ok := session.(nativeQMIIMEISession)
if !ok {
return "", errors.New("QMI session does not expose DMS IMEI reading")
}
value, err := reader.GetIMEI(ctx)
if err != nil {
return "", fmt.Errorf("read device serial numbers: %w", err)
}
imei := parseIdentifier(modem.Response{Lines: []string{value}}, nil, 14, 17)
if imei == "" {
return "", errors.New("QMI DMS returned an invalid IMEI")
}
return imei, nil
}
+84
View File
@@ -0,0 +1,84 @@
package device
import (
"regexp"
"strings"
"unicode"
"vocat/internal/modem"
)
const maxHardwareErrorDetail = 1024
var longHexPayload = regexp.MustCompile(`(?i)\b[0-9a-f]{48,}\b`)
// HardwareErrorDetail returns a diagnostic error suitable for persistent and
// browser-visible logs. AT payloads can contain APDU authentication material,
// SMS data, or APN credentials, so CommandError values retain only the command
// name and modem final result. Very long hexadecimal payloads from wrapped
// protocol errors are removed as a second line of defence.
func HardwareErrorDetail(err error) string {
if err == nil {
return ""
}
detail := redactCommandErrors(err.Error(), err)
detail = longHexPayload.ReplaceAllString(detail, "[redacted hex payload]")
detail = strings.Map(func(character rune) rune {
if unicode.IsControl(character) && character != '\t' && character != '\n' {
return ' '
}
return character
}, strings.TrimSpace(detail))
runes := []rune(detail)
if len(runes) > maxHardwareErrorDetail {
detail = string(runes[:maxHardwareErrorDetail]) + "..."
}
return detail
}
func redactCommandErrors(detail string, err error) string {
if commandErr, ok := err.(*modem.CommandError); ok {
detail = strings.ReplaceAll(detail, commandErr.Error(), safeCommandError(commandErr))
}
switch wrapped := err.(type) {
case interface{ Unwrap() []error }:
for _, child := range wrapped.Unwrap() {
detail = redactCommandErrors(detail, child)
}
case interface{ Unwrap() error }:
if child := wrapped.Unwrap(); child != nil {
detail = redactCommandErrors(detail, child)
}
}
return detail
}
func safeCommandError(err *modem.CommandError) string {
command := safeATCommandName(err.Command)
final := strings.TrimSpace(err.Final)
if final == "" {
final = "unknown modem error"
}
return command + " failed: " + final
}
func safeATCommandName(command string) string {
command = strings.ToUpper(strings.TrimSpace(command))
if command == "" {
return "AT command"
}
if strings.HasPrefix(command, "ATD") {
return "ATD"
}
for index, character := range command {
if character == '=' || character == '?' || character == ',' ||
character == '"' || unicode.IsSpace(character) {
command = command[:index]
break
}
}
if !strings.HasPrefix(command, "AT") || len(command) > 32 {
return "AT command"
}
return command
}
+68
View File
@@ -0,0 +1,68 @@
package device
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"testing"
"vocat/internal/loghub"
"vocat/internal/modem"
)
func TestHardwareErrorDetailRedactsATPayload(t *testing.T) {
const payload = "00880081221000112233445566778899AABBCCDDEEFF1000112233445566778899AABBCCDDEEFF00"
commandErr := &modem.CommandError{
Command: `AT+CSIM=78,"` + payload + `"`,
Final: "+CME ERROR: 13",
Lines: []string{payload},
}
err := fmt.Errorf("select ISIM: %w", errors.Join(errors.New("reader reset failed"), commandErr))
detail := HardwareErrorDetail(err)
if strings.Contains(detail, payload) || strings.Contains(detail, "AT+CSIM=") {
t.Fatalf("hardware error exposed AT payload: %q", detail)
}
if !strings.Contains(detail, "select ISIM") || !strings.Contains(detail, "AT+CSIM failed: +CME ERROR: 13") {
t.Fatalf("hardware error lost useful diagnostics: %q", detail)
}
}
func TestManagerLogsNewHardwareFailuresWithoutPollingSpam(t *testing.T) {
commandError := func() error {
return &modem.CommandError{Command: "AT+CSQ", Final: "+CME ERROR: 13"}
}
client := &transcriptClient{steps: []clientStep{
{command: "AT+CSQ", err: commandError()},
{command: "AT+CSQ", err: commandError()},
{command: "AT+CSQ", response: okResponse("+CSQ: 20,99")},
{command: "AT+CSQ", err: commandError()},
}}
manager, id := newStartedTestManager(t, client)
hub := loghub.New(nil, 100)
manager.logger = slog.New(hub)
for attempt := 0; attempt < 2; attempt++ {
_, _ = manager.ExecuteAT(context.Background(), id, "AT+CSQ")
}
if entries := hub.History(10, slog.LevelDebug, ""); len(entries) != 1 {
t.Fatalf("continuous failure produced %d log entries, want 1", len(entries))
}
_, _ = manager.ExecuteAT(context.Background(), id, "AT+CSQ")
_, _ = manager.ExecuteAT(context.Background(), id, "AT+CSQ")
entries := hub.History(10, slog.LevelDebug, "")
if len(entries) != 2 {
t.Fatalf("failure after recovery produced %d total log entries, want 2", len(entries))
}
for _, entry := range entries {
if entry.Message != "hardware operation failed" || entry.Fields["device_id"] != id {
t.Fatalf("hardware log entry = %#v", entry)
}
if entry.Fields["error"] != "AT+CSQ failed: +CME ERROR: 13" {
t.Fatalf("hardware log detail = %#v", entry.Fields["error"])
}
}
client.assertDone(t)
}
+19 -1
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"sort"
"strings"
"sync"
@@ -21,6 +22,7 @@ type Options struct {
SMSTimeout time.Duration
ScanTimeout time.Duration
CardReaders *pcsc.Service
Logger *slog.Logger
}
type Manager struct {
@@ -38,6 +40,7 @@ type Manager struct {
smsTimeout time.Duration
scanTimeout time.Duration
cardReaders *pcsc.Service
logger *slog.Logger
qmiRadioOpener qmiRadioSessionOpener
nativeQMIRegistrationMu sync.Mutex
@@ -120,6 +123,7 @@ func NewManager(options Options) (*Manager, error) {
smsTimeout: options.SMSTimeout,
scanTimeout: options.ScanTimeout,
cardReaders: options.CardReaders,
logger: options.Logger,
qmiRadioOpener: openQMIRadioSession,
nativeQMIRegistrationInFlight: make(map[string]struct{}),
@@ -373,10 +377,11 @@ func (manager *Manager) setResult(
err error,
) {
manager.mu.Lock()
defer manager.mu.Unlock()
if manager.devices[id] != state {
manager.mu.Unlock()
return
}
previousError := state.lastError
if snapshot != nil {
value := *snapshot
value.Warnings = append([]string(nil), snapshot.Warnings...)
@@ -388,6 +393,19 @@ func (manager *Manager) setResult(
} else {
state.lastError = ""
}
shouldLog := err != nil && manager.logger != nil && previousError != err.Error()
backend := state.backend
hardwareKind := state.candidate.HardwareKind
manager.mu.Unlock()
if shouldLog {
manager.logger.Warn(
"hardware operation failed",
"device_id", id,
"backend", backend,
"hardware_kind", hardwareKind,
"error", HardwareErrorDetail(err),
)
}
}
func (manager *Manager) candidateFor(state *managedDevice) modem.Candidate {
+2 -3
View File
@@ -179,7 +179,6 @@ func TestManagerRefreshReadsNativeWWANICCIDThroughQMIUIM(t *testing.T) {
{command: `AT+QENG="servingcell"`, response: okResponse(`+QENG: "servingcell","SEARCH"`)},
{command: "AT+COPS?", response: okResponse("+COPS: 0")},
{command: "AT+CEREG?", response: okResponse("+CEREG: 0,2")},
{command: "AT+CGSN", response: okResponse("867123456789012")},
{command: "AT+CFUN?", response: okResponse("+CFUN: 1")},
{command: "AT+CNUM", response: okResponse(`+CNUM: "","+8613800138000",145`)},
}}
@@ -201,7 +200,7 @@ func TestManagerRefreshReadsNativeWWANICCIDThroughQMIUIM(t *testing.T) {
}
t.Cleanup(func() { _ = manager.Stop(context.Background()) })
manager.qmiRadioOpener = func(context.Context, string) (qmiRadioSession, error) {
return &fakeQMIRadioSession{iccid: "89441000400316034372"}, nil
return &fakeQMIRadioSession{iccid: "89441000400316034372", imei: "861716070416510"}, nil
}
if err := manager.SetBackend("mhi-wwan0", "qmi"); err != nil {
t.Fatal(err)
@@ -211,7 +210,7 @@ func TestManagerRefreshReadsNativeWWANICCIDThroughQMIUIM(t *testing.T) {
if err != nil {
t.Fatalf("Refresh: %v", err)
}
if snapshot.ICCID != "89441000400316034372" || !snapshot.SIMReady {
if snapshot.ICCID != "89441000400316034372" || snapshot.IMEI != "861716070416510" || !snapshot.SIMReady {
t.Fatalf("native QMI identity = %#v", snapshot)
}
client.assertDone(t)
+15
View File
@@ -34,6 +34,9 @@ func CardMCCMNCWithLength(imsi string, mncLength int) (mcc string, mnc string) {
strings.IndexFunc(digits, func(r rune) bool { return !unicode.IsDigit(r) }) >= 0 {
return "", ""
}
if IsPlaceholderIMSI(digits) {
return "", ""
}
mcc = digits[:3]
mnc = digits[3:]
if mncLength != 2 && mncLength != 3 {
@@ -45,6 +48,18 @@ func CardMCCMNCWithLength(imsi string, mncLength int) (mcc string, mnc string) {
return mcc, mnc
}
// IsPlaceholderIMSI recognizes an unprovisioned/test identity structurally,
// without tying the decision to a vendor-specific hard-coded ICCID. A valid
// subscriber identity cannot consist of an MCC followed only by zeroes; white
// cards commonly ship in exactly that state before a real profile is enabled.
func IsPlaceholderIMSI(imsi string) bool {
digits := strings.TrimSpace(imsi)
if len(digits) < 10 || strings.IndexFunc(digits, func(r rune) bool { return !unicode.IsDigit(r) }) >= 0 {
return false
}
return strings.Trim(digits[3:], "0") == ""
}
// RegionBlockReason returns a human-readable reason when the SIM identified by
// the IMSI belongs to a blocked region. It returns an empty string when the
// card is allowed or when the IMSI is unavailable: only a confirmed blocked
+16
View File
@@ -33,6 +33,22 @@ func TestCardMCCMNC(t *testing.T) {
}
}
func TestPlaceholderIMSIIsNotTreatedAsARealCarrier(t *testing.T) {
t.Parallel()
if !IsPlaceholderIMSI("460000000000000") {
t.Fatal("all-zero subscriber identity should be treated as an unprovisioned placeholder")
}
if IsPlaceholderIMSI("460001234567890") {
t.Fatal("real subscriber identity was classified as a placeholder")
}
if mcc, mnc := CardMCCMNC("460000000000000"); mcc != "" || mnc != "" {
t.Fatalf("placeholder MCC/MNC = %q/%q, want empty", mcc, mnc)
}
if reason := RegionBlockReason("460000000000000"); reason != "" {
t.Fatalf("placeholder identity was region-blocked: %s", reason)
}
}
func TestRegionBlockReason(t *testing.T) {
t.Parallel()
for _, imsi := range []string{"460001234567890", "461001234567890"} {
+11
View File
@@ -74,6 +74,17 @@ func TestCountryForMCCUsesEmbeddedCountryIndex(t *testing.T) {
}
}
func TestMCCsByCountryReturnsCompleteIndependentGrouping(t *testing.T) {
grouped := MCCsByCountry()
if got := grouped["GB"]; len(got) != 2 || got[0] != "234" || got[1] != "235" {
t.Fatalf("GB MCCs = %#v", got)
}
grouped["GB"][0] = "999"
if country, ok := CountryForMCC("234"); !ok || country != "GB" {
t.Fatalf("mutating returned grouping changed embedded index: (%q, %v)", country, ok)
}
}
func TestCarrierForIMSIHandlesTwoAndThreeDigitMNCs(t *testing.T) {
tests := []struct {
imsi string
+38 -7
View File
@@ -39,6 +39,19 @@ func (manager *Manager) readSnapshot(
if snapshot.Model == "" && !strings.EqualFold(candidate.Product, "Android") {
snapshot.Model = candidate.Product
}
// Native MHI/QMI devices expose their immutable modem identity through DMS.
// Read it before any SIM-dependent AT probes: a missing/bad card can make
// those commands slow or fail, but must never prevent IMEI from appearing.
if strings.EqualFold(strings.TrimSpace(backend), "qmi") && isNativeQMICandidate(candidate) {
qmiContext, cancelQMI := manager.withTimeout(ctx, manager.commandTimeout*5)
qmiIMEI, qmiErr := manager.readNativeQMIIMEI(qmiContext, candidate)
cancelQMI()
if qmiErr == nil {
snapshot.IMEI = qmiIMEI
} else {
snapshot.Warnings = append(snapshot.Warnings, "read IMEI via QMI DMS: "+qmiErr.Error())
}
}
optional := func(command string) (modem.Response, bool) {
response, commandErr := manager.command(ctx, client, command)
@@ -172,13 +185,31 @@ func (manager *Manager) readSnapshot(
snapshot.RegistrationStatus = 1
snapshot.RegistrationSource = "COPS"
}
if response, ok := optional("AT+CGSN"); ok {
snapshot.IMEI = parseIdentifier(
response,
[]string{"+CGSN:", "+GSN:"},
14,
17,
)
if snapshot.IMEI == "" {
response, ok := optional("AT+CGSN")
if ok {
snapshot.IMEI = parseIdentifier(
response,
[]string{"+CGSN:", "+GSN:"},
14,
17,
)
}
}
if snapshot.IMEI == "" && strings.EqualFold(strings.TrimSpace(backend), "qmi") && isNativeQMICandidate(candidate) {
qmiContext, cancelQMI := manager.withTimeout(ctx, manager.commandTimeout*5)
qmiIMEI, qmiErr := manager.readNativeQMIIMEI(qmiContext, candidate)
cancelQMI()
if qmiErr == nil {
snapshot.IMEI = qmiIMEI
} else {
snapshot.Warnings = append(snapshot.Warnings, "read IMEI via QMI DMS: "+qmiErr.Error())
}
}
if snapshot.IMEI == "" && previousSnapshot != nil {
// IMEI is hardware identity and does not change with the inserted card.
// Preserve a prior successful read across a transient QMI/AT failure.
snapshot.IMEI = previousSnapshot.IMEI
}
if response, ok := optional("AT+CFUN?"); ok {
+194
View File
@@ -0,0 +1,194 @@
package device
import (
"context"
"errors"
"fmt"
"strings"
"time"
)
func (manager *Manager) withNativeQMIVoWiFiSession(ctx context.Context, id string, fn func(nativeQMIVoWiFiSession) error) error {
control, native, err := manager.nativeQMIControl(id)
if err != nil {
return err
}
if !native {
return errors.New("native QMI control is unavailable")
}
session, err := manager.qmiRadioOpener(ctx, control)
if err != nil {
return fmt.Errorf("open native QMI control: %w", err)
}
defer session.Close()
qmiSession, ok := session.(nativeQMIVoWiFiSession)
if !ok {
return errors.New("native QMI session lacks UIM/NAS support")
}
return fn(qmiSession)
}
// ReadNativeQMIIdentity supplies the live subscription identity without using
// an AT port. The primitive return values intentionally keep device independent
// from the VoWiFi package while satisfying its narrow controller interface.
func (manager *Manager) ReadNativeQMIIdentity(ctx context.Context, id string) (iccid, imsi, imei, mcc, mnc string, err error) {
err = manager.withNativeQMIVoWiFiSession(ctx, id, func(session nativeQMIVoWiFiSession) error {
if iccid, err = session.GetICCID(ctx); err != nil {
return fmt.Errorf("read QMI ICCID: %w", err)
}
if imsi, err = session.GetIMSI(ctx); err != nil {
return fmt.Errorf("read QMI IMSI: %w", err)
}
if imei, err = session.GetIMEI(ctx); err != nil {
return fmt.Errorf("read QMI IMEI: %w", err)
}
if mcc, mnc, err = session.GetNativeMCCMNC(ctx); err != nil {
return fmt.Errorf("read QMI home PLMN: %w", err)
}
return nil
})
return
}
func (manager *Manager) ProbeNativeQMIApplication(ctx context.Context, id, preference string) (aid []byte, application string, err error) {
err = manager.withNativeQMIVoWiFiSession(ctx, id, func(session nativeQMIVoWiFiSession) error {
if strings.EqualFold(strings.TrimSpace(preference), "isim_strict") {
aid, err = session.GetISIMAID(ctx)
application = "ISIM"
return err
}
if aid, err = session.GetUSIMAID(ctx); err == nil {
application = "USIM"
return nil
}
aid, err = session.GetISIMAID(ctx)
application = "ISIM"
return err
})
return
}
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)
if openErr != nil {
return fmt.Errorf("open QMI UIM logical channel: %w", openErr)
}
command := append([]byte(nil), apdu...)
response, err = session.SendAPDU(ctx, 1, channel, command)
// ISO/IEC 7816-4 procedure bytes are transport-level continuation,
// not an AKA rejection. QMI exposes the raw status words, so follow
// 61xx/9Fxx with GET RESPONSE and retry 6Cxx with the advised Le while
// the same logical channel is still open.
for step := 0; err == nil && step < 4 && len(response) >= 2; step++ {
sw1, sw2 := response[len(response)-2], response[len(response)-1]
switch sw1 {
case 0x61, 0x9f:
response, err = session.SendAPDU(ctx, 1, channel, []byte{0x00, 0xc0, 0x00, 0x00, sw2})
case 0x6c:
if len(command) < 5 {
step = 4
continue
}
command[len(command)-1] = sw2
response, err = session.SendAPDU(ctx, 1, channel, command)
default:
step = 4
}
}
closeErr := session.CloseLogicalChannel(ctx, 1, channel)
return errors.Join(err, closeErr)
})
return
}
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)
if modeErr != nil {
return modeErr
}
mode = qmiModeAsCFUN(qmiMode)
serving, servingErr := session.GetServingSystem(ctx)
if servingErr == nil && serving != nil {
psAttached = serving.PSAttached
}
// An RF-off modem commonly rejects NAS serving-system queries; DMS mode
// remains sufficient evidence and data cannot be attached while RF is off.
if servingErr != nil && !isQMIRadioOffMode(qmiMode) {
return servingErr
}
return nil
})
return
}
func (manager *Manager) StopNativeQMICellularData(ctx context.Context, id string) error {
return manager.withNativeQMIVoWiFiSession(ctx, id, func(session nativeQMIVoWiFiSession) error {
serving, err := session.GetServingSystem(ctx)
if err != nil {
return nil
}
if serving == nil || !serving.PSAttached {
return nil
}
if err := session.AttachDetach(ctx, false); err != nil {
return err
}
deadline := time.NewTicker(250 * time.Millisecond)
defer deadline.Stop()
for attempt := 0; attempt < 12; attempt++ {
current, readErr := session.GetServingSystem(ctx)
if readErr == nil && (current == nil || !current.PSAttached) {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-deadline.C:
}
}
return errors.New("native QMI packet service remained attached")
})
}
func (manager *Manager) SetNativeQMIRadioOff(ctx context.Context, id string, off bool) error {
_, err := manager.SetFlight(ctx, id, off)
return err
}
func (manager *Manager) powerCycleNativeQMISIM(ctx context.Context, id string) (bool, error) {
control, native, err := manager.nativeQMIControl(id)
if err != nil || !native {
return native, err
}
session, err := manager.qmiRadioOpener(ctx, control)
if err != nil {
return true, err
}
defer session.Close()
uim, ok := session.(nativeQMIVoWiFiSession)
if !ok {
return true, errors.New("native QMI session lacks SIM power control")
}
if resetter, ok := session.(nativeQMIUIMResetSession); ok {
_ = resetter.ResetUIM(ctx)
}
if err := uim.PowerOffSIM(ctx, 1); err != nil {
return true, err
}
select {
case <-ctx.Done():
return true, ctx.Err()
case <-time.After(3 * time.Second):
}
if err := uim.PowerOnSIM(ctx, 1); err != nil {
return true, err
}
select {
case <-ctx.Done():
return true, ctx.Err()
case <-time.After(time.Second):
}
return true, nil
}
+71 -51
View File
@@ -239,10 +239,14 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) bool {
return true
}
config := payload.toStoreDevice()
isNative410 := config.DeviceType == store.DeviceTypeWiFi410
// Newly added hardware starts fail-closed: RF is disabled immediately and
// VoWiFi becomes the desired service. Cellular registration is only
// restored by the user's later airplane-mode-off action.
// VoWiFi becomes the desired service on supported devices. Native 410
// uses its QMI UIM/DMS/NAS adapter; only cellular SMS remains unavailable.
config.VoWiFiEnabled = true
if isNative410 {
config.SMSEnabled = false
}
config.NetworkEnabled = false
if !s.developerActive(r.Context()) {
config.NetworkEnabled = false
@@ -284,7 +288,7 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) bool {
}
}
}
if s.vowifi != nil {
if s.vowifi != nil && config.VoWiFiEnabled {
if _, err := s.vowifi.RequestEnabled(config.ID, true); err != nil {
s.logger.Warn("new device saved in safe airplane mode but VoWiFi start was not queued", "device_id", config.ID, "error", err)
}
@@ -512,6 +516,10 @@ func (s *Server) handleDevicePath(
}
entry, physicalID, physicalPresent := s.physicalForConfig(config)
if config.DeviceType == store.DeviceTypeWiFi410 && native410UnsupportedOperation(tail) {
writeError(w, http.StatusNotImplemented, "device_feature_unsupported", "this feature is not supported by the native OpenStick 410 backend")
return true
}
if config.DeviceType == store.DeviceTypeUSBSIMReader && len(tail) > 0 {
operation := strings.Join(tail, "/")
unsupported := tail[0] == "network" || tail[0] == "operator_selection" ||
@@ -663,6 +671,14 @@ func (s *Server) handleDevicePath(
return true
}
func native410UnsupportedOperation(tail []string) bool {
if len(tail) == 0 {
return false
}
operation := strings.Join(tail, "/")
return tail[0] == "calls" || operation == "actions/reboot"
}
func (s *Server) handleUSBNetMode(w http.ResponseWriter, r *http.Request, physicalID string) bool {
switch r.Method {
case http.MethodGet:
@@ -1405,9 +1421,9 @@ func (s *Server) writeDeviceError(w http.ResponseWriter, err error) {
case errors.Is(err, context.Canceled):
writeError(w, http.StatusRequestTimeout, "request_canceled", "the modem request was canceled")
default:
// Device errors may echo an AT command. Authentication commands can
// contain APN credentials, so keep raw errors out of logs and responses.
s.logger.Warn("device operation failed")
// Preserve the hardware failure reason in the operator-visible log while
// keeping AT payloads and long APDU material out of it.
s.logger.Warn("device operation failed", "error", device.HardwareErrorDetail(err))
writeError(w, http.StatusBadGateway, "modem_error", "the device operation failed")
}
}
@@ -1670,56 +1686,60 @@ func storedVoWiFiRuntime(runtime store.VoWiFiRuntime) map[string]any {
enabled, _ := extra["enabled"].(bool)
active, _ := extra["active"].(bool)
return map[string]any{
"device_id": runtime.DeviceID,
"phase": runtime.Phase,
"enabled": enabled,
"active": active,
"dataplane_mode": runtime.DataplaneMode,
"iccid": runtime.ICCID,
"imsi": runtime.IMSI,
"sim_ready": runtime.SIMReady,
"access_ready": runtime.AccessReady,
"tunnel_ready": runtime.TunnelReady,
"ims_ready": runtime.IMSReady,
"sms_ready": runtime.SMSReady,
"reg_status": runtime.RegStatus,
"reg_status_text": runtime.RegStatusText,
"network_mode": runtime.NetworkMode,
"local_phone": runtime.LocalPhone,
"phone_number_source": runtime.PhoneNumberSource,
"last_error_class": runtime.LastErrorClass,
"last_error": runtime.LastError,
"last_reason": runtime.LastReason,
"updated_at": runtime.UpdatedAt,
"tunnel": rawJSONObject(runtime.Tunnel),
"imscore": rawJSONObject(runtime.IMSCore),
"smsip": rawJSONObject(runtime.SMSIP),
"device_id": runtime.DeviceID,
"phase": runtime.Phase,
"enabled": enabled,
"active": active,
"carrier_profile": extra["carrier_profile"],
"carrier_profile_from": extra["carrier_profile_from"],
"dataplane_mode": runtime.DataplaneMode,
"iccid": runtime.ICCID,
"imsi": runtime.IMSI,
"sim_ready": runtime.SIMReady,
"access_ready": runtime.AccessReady,
"tunnel_ready": runtime.TunnelReady,
"ims_ready": runtime.IMSReady,
"sms_ready": runtime.SMSReady,
"reg_status": runtime.RegStatus,
"reg_status_text": runtime.RegStatusText,
"network_mode": runtime.NetworkMode,
"local_phone": runtime.LocalPhone,
"phone_number_source": runtime.PhoneNumberSource,
"last_error_class": runtime.LastErrorClass,
"last_error": runtime.LastError,
"last_reason": runtime.LastReason,
"updated_at": runtime.UpdatedAt,
"tunnel": rawJSONObject(runtime.Tunnel),
"imscore": rawJSONObject(runtime.IMSCore),
"smsip": rawJSONObject(runtime.SMSIP),
}
}
func liveVoWiFiRuntime(runtime vowifi.State) map[string]any {
return map[string]any{
"device_id": runtime.DeviceID,
"phase": string(runtime.Phase),
"enabled": runtime.Enabled,
"active": runtime.Active,
"dataplane_mode": runtime.DataplaneMode,
"iccid": runtime.ICCID,
"imsi": runtime.IMSI,
"sim_ready": runtime.SIMReady,
"access_ready": runtime.AccessReady,
"tunnel_ready": runtime.TunnelReady,
"ims_ready": runtime.IMSReady,
"sms_ready": runtime.SMSReady,
"reg_status": map[bool]int{true: 1, false: 0}[runtime.IMSReady],
"reg_status_text": map[bool]string{true: "registered", false: "not registered"}[runtime.IMSReady],
"network_mode": "Wi-Fi",
"local_phone": runtime.PhoneNumber,
"phone_number_source": runtime.PhoneNumberSource,
"last_error_class": runtime.LastErrorClass,
"last_error": runtime.LastError,
"last_reason": runtime.LastReason,
"updated_at": runtime.UpdatedAt,
"device_id": runtime.DeviceID,
"phase": string(runtime.Phase),
"enabled": runtime.Enabled,
"active": runtime.Active,
"carrier_profile": runtime.CarrierProfile,
"carrier_profile_from": runtime.CarrierProfileFrom,
"dataplane_mode": runtime.DataplaneMode,
"iccid": runtime.ICCID,
"imsi": runtime.IMSI,
"sim_ready": runtime.SIMReady,
"access_ready": runtime.AccessReady,
"tunnel_ready": runtime.TunnelReady,
"ims_ready": runtime.IMSReady,
"sms_ready": runtime.SMSReady,
"reg_status": map[bool]int{true: 1, false: 0}[runtime.IMSReady],
"reg_status_text": map[bool]string{true: "registered", false: "not registered"}[runtime.IMSReady],
"network_mode": "Wi-Fi",
"local_phone": runtime.PhoneNumber,
"phone_number_source": runtime.PhoneNumberSource,
"last_error_class": runtime.LastErrorClass,
"last_error": runtime.LastError,
"last_reason": runtime.LastReason,
"updated_at": runtime.UpdatedAt,
"tunnel": map[string]any{
"established": runtime.TunnelReady,
"name": runtime.TunnelName,
@@ -31,6 +31,29 @@ func decodeData(t *testing.T, recorder *httptest.ResponseRecorder) map[string]an
return envelope.Data
}
func TestNative410UnsupportedOperations(t *testing.T) {
tests := []struct {
path []string
unsupported bool
}{
{path: []string{"esim"}},
{path: []string{"esim", "profiles"}},
{path: []string{"vowifi"}},
{path: []string{"vowifi", "actions", "reconnect"}},
{path: []string{"calls"}, unsupported: true},
{path: []string{"actions", "reboot"}, unsupported: true},
{path: []string{"actions", "refresh"}},
{path: []string{"actions", "at"}},
{path: []string{"flight-mode"}},
{path: []string{"operator_selection"}},
}
for _, test := range tests {
if got := native410UnsupportedOperation(test.path); got != test.unsupported {
t.Errorf("native410UnsupportedOperation(%v) = %v, want %v", test.path, got, test.unsupported)
}
}
}
func TestParseModemAPNProfiles(t *testing.T) {
profiles := parseModemAPNProfiles([]string{
`+CGDCONT: 1,"IPV4V6","internet","0.0.0.0",0,0`,
+69 -4
View File
@@ -11,6 +11,7 @@ import (
"vocat/internal/device"
"vocat/internal/store"
"vocat/internal/vowifi"
)
func esimUnavailable(w http.ResponseWriter) {
@@ -400,15 +401,41 @@ func (s *Server) handleEsimSwitch(w http.ResponseWriter, r *http.Request, config
writeError(w, http.StatusBadRequest, "invalid_request", "iccid is required")
return
}
endMaintenance := func() {}
if maintenance, ok := s.vowifi.(VoWiFiMaintenanceController); ok {
if err := maintenance.BeginMaintenance(configuredID); err != nil {
s.writeDeviceError(w, fmt.Errorf("prepare VoWiFi for profile switch: %w", err))
return
}
released := false
endMaintenance = func() {
if !released {
released = true
maintenance.EndMaintenance(configuredID)
}
}
defer endMaintenance()
}
// A live VoWiFi runtime owns the SIM/QMI session while AKA, IMS and SMS are
// active. Tear it down before touching flight mode or the ISD-R logical
// channel; otherwise native-WWAN devices wait on the QMI lease until the HTTP
// request times out. This only changes the runtime desired state. The saved
// per-ICCID policy is left intact and the target profile's policy is restored
// after the verified switch below.
if err := s.quiesceVoWiFiForProfileSwitch(r.Context(), configuredID); err != nil {
s.writeDeviceError(w, err)
return
}
// Profile operations run with RF disabled. The eUICC remains accessible in
// CFUN=4, and the recovery path reapplies CFUN=4 as soon as the AT port comes
// back after the mandatory modem reset.
// CFUN=4. Devices that consume the requested eUICC REFRESH stay online;
// older AT modems enter the reset recovery path and reapply CFUN=4 when the
// port returns.
if _, err := s.devices.SetFlight(r.Context(), physicalID, true); err != nil {
s.writeDeviceError(w, err)
return
}
// A confirmed profile switch includes the EC20 reset and a live ICCID read,
// which normally takes longer than the server's ordinary response deadline.
// A confirmed profile switch always includes a live ICCID read and may also
// include the EC20 reset fallback, so it can exceed the ordinary deadline.
controller := http.NewResponseController(w)
_ = controller.SetWriteDeadline(time.Time{})
aidHex := firstNonEmpty(request.AIDHex, request.AIDHexCamel)
@@ -454,6 +481,10 @@ func (s *Server) handleEsimSwitch(w http.ResponseWriter, r *http.Request, config
s.writeStoreError(w, err)
return
}
// The target profile is now active and its persisted policy has replaced the
// old runtime configuration. Allow reconciliation again before requesting
// the target profile's desired VoWiFi state.
endMaintenance()
canRestoreFlightImmediately := s.vowifi == nil
if s.vowifi != nil {
state, stateErr := s.vowifi.State(configuredID)
@@ -484,6 +515,40 @@ func (s *Server) handleEsimSwitch(w http.ResponseWriter, r *http.Request, config
}})
}
func (s *Server) quiesceVoWiFiForProfileSwitch(ctx context.Context, configuredID string) error {
if s.vowifi == nil {
return nil
}
state, err := s.vowifi.State(configuredID)
if err != nil {
return fmt.Errorf("stop VoWiFi before switching profile: %w", err)
}
if !state.Enabled && !state.Active && state.Phase == vowifi.PhaseIdle {
return nil
}
if _, err := s.vowifi.RequestEnabled(configuredID, false); err != nil {
return fmt.Errorf("stop VoWiFi before switching profile: %w", err)
}
waitContext, cancel := context.WithTimeout(ctx, 45*time.Second)
defer cancel()
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
state, err = s.vowifi.State(configuredID)
if err != nil {
return fmt.Errorf("wait for VoWiFi to stop before switching profile: %w", err)
}
if !state.Enabled && !state.Active && state.Phase == vowifi.PhaseIdle {
return nil
}
select {
case <-waitContext.Done():
return fmt.Errorf("wait for VoWiFi to stop before switching profile: %w", waitContext.Err())
case <-ticker.C:
}
}
}
func (s *Server) handleEsimDisable(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool) {
if s.devices == nil {
writeError(w, http.StatusServiceUnavailable, "device_manager_unavailable", "device manager is unavailable")
+69 -2
View File
@@ -5,9 +5,11 @@ import (
"encoding/json"
"errors"
"net/http"
"sort"
"strings"
"time"
"vocat/internal/device"
"vocat/internal/i18n"
localproxy "vocat/internal/proxy"
"vocat/internal/store"
@@ -100,6 +102,48 @@ func (s *Server) handleUpstreamProxy(w http.ResponseWriter, r *http.Request, id
}
payload.ID = id
s.saveAndProbeUpstream(w, r, payload)
case http.MethodPatch:
var request struct {
Enabled bool `json:"enabled"`
}
if err := s.decodeJSON(w, r, &request); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
value, err := s.store.UpstreamProxy(r.Context(), id)
if err != nil {
s.writeStoreError(w, err)
return
}
value.Enabled = request.Enabled
value.UpdatedAt = time.Now().UTC()
if err := s.store.UpsertUpstreamProxy(r.Context(), value); err != nil {
s.writeStoreError(w, err)
return
}
bindings, err := s.store.ListDeviceProxyBindings(r.Context())
if err != nil {
s.writeStoreError(w, err)
return
}
reconnectRequested := false
var reconnectErrors []string
for _, binding := range bindings {
if binding.UpstreamProxyID != id {
continue
}
requested, reconnectErr := s.requestProfileProxyRouteReconnect(binding.DeviceID, binding.ICCID)
reconnectRequested = reconnectRequested || requested
if reconnectErr != nil {
reconnectErrors = append(reconnectErrors, reconnectErr.Error())
}
}
response := upstreamProxyResponse(value.Redacted())
response["reconnect_requested"] = reconnectRequested
if len(reconnectErrors) > 0 {
response["reconnect_error"] = strings.Join(reconnectErrors, "; ")
}
writeJSON(w, http.StatusOK, map[string]any{"data": response})
case http.MethodDelete:
bindings, listErr := s.store.ListDeviceProxyBindings(r.Context())
if listErr != nil {
@@ -117,7 +161,7 @@ func (s *Server) handleUpstreamProxy(w http.ResponseWriter, r *http.Request, id
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"deleted": true}})
default:
w.Header().Set("Allow", "PUT, DELETE")
w.Header().Set("Allow", "PUT, PATCH, DELETE")
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
}
}
@@ -589,7 +633,7 @@ func countryNameForMCC(mcc string) string {
return ""
}
var proxyCountries = []proxyCountry{
var namedProxyCountries = []proxyCountry{
{Code: "CN", Name: "中国", MCCs: []string{"460", "461"}},
{Code: "HK", Name: "中国香港", MCCs: []string{"454"}},
{Code: "MO", Name: "中国澳门", MCCs: []string{"455"}},
@@ -644,3 +688,26 @@ var proxyCountries = []proxyCountry{
{Code: "NG", Name: "尼日利亚", MCCs: []string{"621"}},
{Code: "KE", Name: "肯尼亚", MCCs: []string{"639"}},
}
var proxyCountries = buildProxyCountries()
func buildProxyCountries() []proxyCountry {
byCode := make(map[string]proxyCountry)
for _, country := range namedProxyCountries {
byCode[country.Code] = country
}
for code, mccs := range device.MCCsByCountry() {
country, found := byCode[code]
if !found {
country = proxyCountry{Code: code, Name: code}
}
country.MCCs = append([]string(nil), mccs...)
byCode[code] = country
}
result := make([]proxyCountry, 0, len(byCode))
for _, country := range byCode {
result = append(result, country)
}
sort.Slice(result, func(i, j int) bool { return result[i].Code < result[j].Code })
return result
}
+5
View File
@@ -167,6 +167,11 @@ type VoWiFiController interface {
RequestReconnect(string) (vowifi.State, error)
}
type VoWiFiMaintenanceController interface {
BeginMaintenance(string) error
EndMaintenance(string)
}
type VoWiFiCallController interface {
Calls(string) ([]vowifi.Call, error)
DialCall(context.Context, string, string) (vowifi.Call, error)
+6 -1
View File
@@ -236,6 +236,10 @@ func (s *Server) handleSMSSend(w http.ResponseWriter, r *http.Request) {
s.writeStoreError(w, err)
return
}
if store.NormalizeDeviceType(config.DeviceType) == store.DeviceTypeWiFi410 {
writeError(w, http.StatusNotImplemented, "device_feature_unsupported", "SMS is not supported by the native OpenStick 410 backend")
return
}
entry, physicalID, present := s.physicalForConfig(config)
if !s.requirePhysicalDevice(w, present) {
return
@@ -667,7 +671,8 @@ func (s *Server) syncModemSMS(ctx context.Context, onlyDevice string) {
}
func supportsModemSMSStorage(config store.Device) bool {
return store.NormalizeDeviceType(config.DeviceType) != store.DeviceTypeUSBSIMReader
deviceType := store.NormalizeDeviceType(config.DeviceType)
return deviceType != store.DeviceTypeUSBSIMReader && deviceType != store.DeviceTypeWiFi410
}
func shouldDeferModemSMSSync(state vowifi.State, stateErr error) bool {
+9
View File
@@ -57,6 +57,15 @@ func TestSMSThreadAllDevicesUsesIMSIFilter(t *testing.T) {
}
}
func TestNative410DoesNotUseModemSMSStorage(t *testing.T) {
if supportsModemSMSStorage(store.Device{DeviceType: store.DeviceTypeWiFi410}) {
t.Fatal("native OpenStick 410 unexpectedly enabled modem SMS storage polling")
}
if !supportsModemSMSStorage(store.Device{DeviceType: store.DeviceTypePCIeEC20EC25}) {
t.Fatal("EC20 modem SMS storage polling was disabled")
}
}
func TestSMSThreadConfiguredDeviceUsesStableIMEI(t *testing.T) {
ctx := context.Background()
database, err := store.Open(ctx, ":memory:")
+46 -17
View File
@@ -357,23 +357,11 @@ func upstreamProxy(row rowScanner) (UpstreamProxy, error) {
}
func (s *Store) UpsertDeviceProxyBinding(ctx context.Context, value DeviceProxyBinding) error {
value.DeviceID = strings.TrimSpace(value.DeviceID)
value.ICCID = strings.TrimSpace(value.ICCID)
value.ProfileName = strings.TrimSpace(value.ProfileName)
value.UpstreamProxyID = strings.TrimSpace(value.UpstreamProxyID)
if value.DeviceID == "" || value.ICCID == "" || value.UpstreamProxyID == "" {
return errors.New("profile proxy binding requires device ID, ICCID, and upstream proxy ID")
value, err := normalizeDeviceProxyBinding(value)
if err != nil {
return err
}
now := time.Now().UTC()
createdAt := value.CreatedAt
if createdAt.IsZero() {
createdAt = now
}
updatedAt := value.UpdatedAt
if updatedAt.IsZero() {
updatedAt = now
}
_, err := s.db.ExecContext(ctx, `
_, err = s.db.ExecContext(ctx, `
INSERT INTO device_proxy_bindings (
iccid, device_id, profile_name, upstream_proxy_id, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?)
@@ -382,13 +370,54 @@ func (s *Store) UpsertDeviceProxyBinding(ctx context.Context, value DeviceProxyB
profile_name = excluded.profile_name,
upstream_proxy_id = excluded.upstream_proxy_id,
updated_at = excluded.updated_at
`, value.ICCID, value.DeviceID, value.ProfileName, value.UpstreamProxyID, createdAt.Unix(), updatedAt.Unix())
`, value.ICCID, value.DeviceID, value.ProfileName, value.UpstreamProxyID, value.CreatedAt.Unix(), value.UpdatedAt.Unix())
if err != nil {
return fmt.Errorf("upsert proxy binding for ICCID %q: %w", value.ICCID, err)
}
return nil
}
// InsertDeviceProxyBindingIfAbsent materializes a default route without ever
// replacing an explicit (or concurrently-created) ICCID binding.
func (s *Store) InsertDeviceProxyBindingIfAbsent(ctx context.Context, value DeviceProxyBinding) (bool, error) {
value, err := normalizeDeviceProxyBinding(value)
if err != nil {
return false, err
}
result, err := s.db.ExecContext(ctx, `
INSERT INTO device_proxy_bindings (
iccid, device_id, profile_name, upstream_proxy_id, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(iccid) DO NOTHING
`, value.ICCID, value.DeviceID, value.ProfileName, value.UpstreamProxyID, value.CreatedAt.Unix(), value.UpdatedAt.Unix())
if err != nil {
return false, fmt.Errorf("insert proxy binding for ICCID %q if absent: %w", value.ICCID, err)
}
affected, err := result.RowsAffected()
if err != nil {
return false, fmt.Errorf("read inserted proxy binding result for ICCID %q: %w", value.ICCID, err)
}
return affected > 0, nil
}
func normalizeDeviceProxyBinding(value DeviceProxyBinding) (DeviceProxyBinding, error) {
value.DeviceID = strings.TrimSpace(value.DeviceID)
value.ICCID = strings.TrimSpace(value.ICCID)
value.ProfileName = strings.TrimSpace(value.ProfileName)
value.UpstreamProxyID = strings.TrimSpace(value.UpstreamProxyID)
if value.DeviceID == "" || value.ICCID == "" || value.UpstreamProxyID == "" {
return DeviceProxyBinding{}, errors.New("profile proxy binding requires device ID, ICCID, and upstream proxy ID")
}
now := time.Now().UTC()
if value.CreatedAt.IsZero() {
value.CreatedAt = now
}
if value.UpdatedAt.IsZero() {
value.UpdatedAt = now
}
return value, nil
}
func (s *Store) DeviceProxyBinding(ctx context.Context, iccid string) (DeviceProxyBinding, error) {
return deviceProxyBinding(s.db.QueryRowContext(
ctx,
+312 -26
View File
@@ -1,52 +1,338 @@
package vowifi
import (
_ "embed"
"encoding/json"
"fmt"
"strings"
)
const att310280EPDG = "epdg.epc.att.net"
const (
CarrierProfileStandard = "standard-3gpp"
IKEProposalModern = "modern"
IKEProposalLegacy = "legacy-sha1-modp1024"
IMSProfileStandard = "standard"
IMSProfileO2Germany = "o2-germany"
IMSProfileATT = "att"
)
// AssignedRoutePLMN returns a narrowly matched ePDG route PLMN without
// changing the subscription PLMN used for AKA identities. Some multi-profile
// and MVNO SIMs authenticate against their own HPLMN but use a host network's
// VoWiFi access gateway.
// CarrierProfile contains only interoperability choices that cannot be
// reliably discovered from the SIM or negotiated with the network. All
// protocol layers consume this common result so their carrier handling cannot
// drift into separate MCC/MNC switch statements.
type CarrierProfile struct {
ID string
MatchSource string
RouteMCC string
RouteMNC string
EPDG string
IKEProposal string
AdvertiseEAPOnly bool
IMSTransport string
IMSIdentityProfile string
IMSRegisterProfile string
IMSIPSecEncryption string
SMSCenter string
}
type carrierProfileDocument struct {
Version int `json:"version"`
Profiles []carrierProfileRule `json:"profiles"`
}
type carrierProfileRule struct {
ID string `json:"id"`
Match carrierProfileMatch `json:"match"`
Route carrierProfileRoute `json:"route"`
EPDG carrierProfileEPDG `json:"epdg"`
IKE carrierProfileIKE `json:"ike"`
IMS carrierProfileIMS `json:"ims"`
}
type carrierProfileMatch struct {
HomePLMNs []string `json:"home_plmns"`
IMSIPrefixes []string `json:"imsi_prefixes"`
ICCIDPrefixes []string `json:"iccid_prefixes"`
SPNs []string `json:"spns"`
GID1Prefixes []string `json:"gid1_prefixes"`
GID2Prefixes []string `json:"gid2_prefixes"`
}
type carrierProfileRoute struct {
MCC string `json:"mcc"`
MNC string `json:"mnc"`
}
type carrierProfileEPDG struct {
Hostname string `json:"hostname"`
DNSHosts []string `json:"dns_hosts"`
DNSClientSubnet string `json:"dns_client_subnet"`
}
type carrierProfileIKE struct {
Proposal string `json:"proposal"`
AdvertiseEAPOnly *bool `json:"advertise_eap_only"`
}
type carrierProfileIMS struct {
Transport string `json:"transport"`
IdentityProfile string `json:"identity_profile"`
RegisterProfile string `json:"register_profile"`
IPSecEncryption string `json:"ipsec_encryption"`
SMSCenter string `json:"sms_center"`
}
//go:embed carrier_profiles.json
var carrierProfilesJSON []byte
var builtinCarrierProfiles = mustLoadCarrierProfiles(carrierProfilesJSON)
func mustLoadCarrierProfiles(encoded []byte) []carrierProfileRule {
var document carrierProfileDocument
if err := json.Unmarshal(encoded, &document); err != nil {
panic("vowifi: invalid embedded carrier profiles: " + err.Error())
}
if document.Version != 1 {
panic(fmt.Sprintf("vowifi: unsupported carrier profile version %d", document.Version))
}
seen := make(map[string]struct{}, len(document.Profiles))
for index := range document.Profiles {
rule := &document.Profiles[index]
rule.ID = strings.TrimSpace(rule.ID)
if rule.ID == "" {
panic("vowifi: carrier profile ID is empty")
}
if _, duplicate := seen[rule.ID]; duplicate {
panic("vowifi: duplicate carrier profile " + rule.ID)
}
seen[rule.ID] = struct{}{}
if !validCarrierProfileRule(*rule) {
panic("vowifi: invalid carrier profile " + rule.ID)
}
}
return document.Profiles
}
func validCarrierProfileRule(rule carrierProfileRule) bool {
match := rule.Match
if len(match.HomePLMNs)+len(match.IMSIPrefixes)+len(match.ICCIDPrefixes)+
len(match.SPNs)+len(match.GID1Prefixes)+len(match.GID2Prefixes) == 0 {
return false
}
for _, plmn := range match.HomePLMNs {
if canonicalPLMNValue(plmn) == "" {
return false
}
}
if (rule.Route.MCC == "") != (rule.Route.MNC == "") ||
(rule.Route.MCC != "" && canonicalPLMN(rule.Route.MCC, rule.Route.MNC) == "") {
return false
}
if proposal := strings.TrimSpace(rule.IKE.Proposal); proposal != "" &&
proposal != IKEProposalModern && proposal != IKEProposalLegacy {
return false
}
if transport := strings.ToLower(strings.TrimSpace(rule.IMS.Transport)); transport != "" &&
transport != "tcp" && transport != "udp" {
return false
}
if encryption := strings.ToLower(strings.TrimSpace(rule.IMS.IPSecEncryption)); encryption != "" &&
encryption != "aes-cbc" && encryption != "null" {
return false
}
return true
}
// ResolveCarrierProfile returns the most specific built-in match. Exact SIM
// attributes add specificity, so a constrained MVNO rule wins over its host
// PLMN without weakening the default match for unrelated subscriptions.
func ResolveCarrierProfile(identity SIMIdentity) CarrierProfile {
resolved := CarrierProfile{
ID: CarrierProfileStandard,
MatchSource: "standard",
IKEProposal: IKEProposalModern,
AdvertiseEAPOnly: true,
IMSIdentityProfile: IMSProfileStandard,
IMSRegisterProfile: IMSProfileStandard,
IMSIPSecEncryption: "aes-cbc",
}
bestScore := -1
for _, rule := range builtinCarrierProfiles {
score, source, matched := matchCarrierProfile(rule.Match, identity)
if !matched || score <= bestScore {
continue
}
bestScore = score
resolved = applyCarrierProfileRule(resolved, rule, source)
}
return resolved
}
func matchCarrierProfile(match carrierProfileMatch, identity SIMIdentity) (int, string, bool) {
score := 0
sources := make([]string, 0, 6)
if len(match.HomePLMNs) > 0 {
wanted := canonicalPLMN(identity.HomeMCC, identity.HomeMNC)
if wanted == "" || !matchesAny(match.HomePLMNs, func(value string) bool {
return canonicalPLMNValue(value) == wanted
}) {
return 0, "", false
}
score += 100
sources = append(sources, "hplmn")
}
for _, selector := range []struct {
name string
weight int
values []string
actual string
foldCase bool
}{
{name: "imsi", weight: 80, values: match.IMSIPrefixes, actual: identity.IMSI},
{name: "iccid", weight: 70, values: match.ICCIDPrefixes, actual: identity.ICCID},
{name: "gid1", weight: 50, values: match.GID1Prefixes, actual: identity.GID1, foldCase: true},
{name: "gid2", weight: 40, values: match.GID2Prefixes, actual: identity.GID2, foldCase: true},
} {
if len(selector.values) == 0 {
continue
}
actual := strings.TrimSpace(selector.actual)
if actual == "" || !matchesAny(selector.values, func(prefix string) bool {
prefix = strings.TrimSpace(prefix)
if selector.foldCase {
return strings.HasPrefix(strings.ToLower(actual), strings.ToLower(prefix))
}
return strings.HasPrefix(actual, prefix)
}) {
return 0, "", false
}
score += selector.weight
sources = append(sources, selector.name)
}
if len(match.SPNs) > 0 {
spn := strings.TrimSpace(identity.SPN)
if spn == "" || !matchesAny(match.SPNs, func(value string) bool {
return strings.EqualFold(strings.TrimSpace(value), spn)
}) {
return 0, "", false
}
score += 20
sources = append(sources, "spn")
}
return score, strings.Join(sources, "+"), score > 0
}
func matchesAny(values []string, match func(string) bool) bool {
for _, value := range values {
if match(value) {
return true
}
}
return false
}
func applyCarrierProfileRule(base CarrierProfile, rule carrierProfileRule, source string) CarrierProfile {
base.ID = rule.ID
base.MatchSource = source
base.RouteMCC = strings.TrimSpace(rule.Route.MCC)
base.RouteMNC = strings.TrimSpace(rule.Route.MNC)
base.EPDG = strings.ToLower(strings.TrimSpace(rule.EPDG.Hostname))
if value := strings.TrimSpace(rule.IKE.Proposal); value != "" {
base.IKEProposal = value
}
if rule.IKE.AdvertiseEAPOnly != nil {
base.AdvertiseEAPOnly = *rule.IKE.AdvertiseEAPOnly
}
if value := strings.ToLower(strings.TrimSpace(rule.IMS.Transport)); value != "" {
base.IMSTransport = value
}
if value := strings.TrimSpace(rule.IMS.IdentityProfile); value != "" {
base.IMSIdentityProfile = value
}
if value := strings.TrimSpace(rule.IMS.RegisterProfile); value != "" {
base.IMSRegisterProfile = value
}
if value := strings.ToLower(strings.TrimSpace(rule.IMS.IPSecEncryption)); value != "" {
base.IMSIPSecEncryption = value
}
base.SMSCenter = strings.TrimSpace(rule.IMS.SMSCenter)
return base
}
func canonicalPLMN(mcc, mnc string) string {
mcc = strings.TrimSpace(mcc)
mnc = strings.TrimSpace(mnc)
if !isNDigits(mcc, 3, 3) || !isNDigits(mnc, 2, 3) {
return ""
}
for len(mnc) < 3 {
mnc = "0" + mnc
}
return mcc + mnc
}
func canonicalPLMNValue(value string) string {
value = strings.TrimSpace(strings.ReplaceAll(value, "/", ""))
if len(value) != 5 && len(value) != 6 {
return ""
}
return canonicalPLMN(value[:3], value[3:])
}
// AssignedRoutePLMN remains available to callers that only have the legacy
// identifier pair. New code resolves the complete SIMIdentity so SPN/GID
// selectors can participate.
func AssignedRoutePLMN(iccid, imsi string) (string, string, bool) {
iccid = strings.TrimSpace(iccid)
imsi = strings.TrimSpace(imsi)
switch {
case strings.HasPrefix(iccid, "894416") && strings.HasPrefix(imsi, "204047"):
// XeSIM/Lebara: keep 204/04 for AKA and use Vodafone UK's ePDG.
return "234", "15", true
case strings.HasPrefix(iccid, "894430") && strings.HasPrefix(imsi, "23433"):
// CTExcel UK: keep 234/33 for AKA and use the EE UK ePDG used by
// the initial VoWiFi provisioning path.
return "234", "30", true
default:
return "", "", false
identity := SIMIdentity{ICCID: strings.TrimSpace(iccid), IMSI: strings.TrimSpace(imsi)}
if len(identity.IMSI) >= 5 {
identity.HomeMCC = identity.IMSI[:3]
for _, length := range []int{3, 2} {
if len(identity.IMSI) < 3+length {
continue
}
identity.HomeMNC = identity.IMSI[3 : 3+length]
profile := ResolveCarrierProfile(identity)
if profile.RouteMCC != "" {
return profile.RouteMCC, profile.RouteMNC, true
}
}
}
return "", "", false
}
// IsATT310280 reports whether the live subscription is on AT&T's three-digit
// 310/280 PLMN. It is shared by SWu and IMS so the carrier exception cannot
// drift between protocol layers.
func IsATT310280(identity SIMIdentity) bool {
mcc := strings.TrimSpace(identity.HomeMCC)
mnc := strings.TrimLeft(strings.TrimSpace(identity.HomeMNC), "0")
imsi := strings.TrimSpace(identity.IMSI)
return mcc == "310" && mnc == "280" && strings.HasPrefix(imsi, "310280")
return ResolveCarrierProfile(identity).IMSRegisterProfile == IMSProfileATT
}
func applyAssignedCarrierRoute(identity SIMIdentity) SIMIdentity {
if strings.TrimSpace(identity.EPDG) != "" {
return identity
}
if routeMCC, routeMNC, ok := AssignedRoutePLMN(identity.ICCID, identity.IMSI); ok {
identity.EPDG = standardEPDGHostname(routeMCC, routeMNC)
profile := ResolveCarrierProfile(identity)
switch {
case profile.EPDG != "":
identity.EPDG = profile.EPDG
case profile.RouteMCC != "":
identity.EPDG = standardEPDGHostname(profile.RouteMCC, profile.RouteMNC)
}
return identity
}
// EPDGDNSClientSubnet returns a deliberately scoped EDNS client subnet for an
// ePDG whose authoritative DNS only exposes addresses to home-country
// resolvers. An empty result means ordinary system DNS remains authoritative.
func EPDGDNSClientSubnet(host string) string {
host = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
for _, rule := range builtinCarrierProfiles {
for _, candidate := range rule.EPDG.DNSHosts {
if host == strings.ToLower(strings.TrimSuffix(strings.TrimSpace(candidate), ".")) {
return strings.TrimSpace(rule.EPDG.DNSClientSubnet)
}
}
}
return ""
}
func standardEPDGHostname(mcc, mnc string) string {
mnc = strings.TrimSpace(mnc)
for len(mnc) < 3 {
+44
View File
@@ -54,3 +54,47 @@ func TestIsATT310280RequiresMatchingPLMNAndIMSI(t *testing.T) {
}
}
}
func TestResolveCarrierProfileUsesStandardDefault(t *testing.T) {
profile := ResolveCarrierProfile(SIMIdentity{
IMSI: "999010000000001", HomeMCC: "999", HomeMNC: "01",
})
if profile.ID != CarrierProfileStandard || profile.MatchSource != "standard" {
t.Fatalf("default profile = %#v", profile)
}
if profile.IKEProposal != IKEProposalModern || !profile.AdvertiseEAPOnly ||
profile.IMSIdentityProfile != IMSProfileStandard || profile.IMSRegisterProfile != IMSProfileStandard {
t.Fatalf("default profile lost standard capabilities: %#v", profile)
}
}
func TestResolveCarrierProfilePrefersConstrainedMVNO(t *testing.T) {
profile := ResolveCarrierProfile(SIMIdentity{
ICCID: "8944160000000000001", IMSI: "204047000000001",
HomeMCC: "204", HomeMNC: "04", SPN: "Lebara",
})
if profile.ID != "xesim-lebara-vodafone-uk" || profile.RouteMCC != "234" || profile.RouteMNC != "15" {
t.Fatalf("MVNO profile = %#v", profile)
}
if profile.MatchSource != "hplmn+imsi+iccid" {
t.Fatalf("MVNO match source = %q", profile.MatchSource)
}
}
func TestResolveCarrierProfileNormalizesMNCWidth(t *testing.T) {
for _, mnc := range []string{"03", "003"} {
profile := ResolveCarrierProfile(SIMIdentity{HomeMCC: "262", HomeMNC: mnc})
if profile.ID != "o2-germany" || profile.AdvertiseEAPOnly || profile.IMSIPSecEncryption != "null" {
t.Errorf("O2 Germany MNC %q profile = %#v", mnc, profile)
}
}
}
func TestEPDGDNSClientSubnetComesFromCarrierProfileData(t *testing.T) {
if got := EPDGDNSClientSubnet("EPDG.EPC.MNC002.MCC262.PUB.3GPPNETWORK.ORG."); got != "109.192.0.0/24" {
t.Fatalf("Vodafone Germany DNS client subnet = %q", got)
}
if got := EPDGDNSClientSubnet("epdg.epc.mnc015.mcc234.pub.3gppnetwork.org"); got != "" {
t.Fatalf("ordinary ePDG received geographic DNS fallback %q", got)
}
}
+73
View File
@@ -0,0 +1,73 @@
{
"version": 1,
"profiles": [
{
"id": "xesim-lebara-vodafone-uk",
"match": {
"home_plmns": ["20404"],
"imsi_prefixes": ["204047"],
"iccid_prefixes": ["894416"]
},
"route": { "mcc": "234", "mnc": "15" },
"ike": { "proposal": "legacy-sha1-modp1024" }
},
{
"id": "ctexcel-ee-uk",
"match": {
"home_plmns": ["23433"],
"imsi_prefixes": ["23433"],
"iccid_prefixes": ["894430"]
},
"route": { "mcc": "234", "mnc": "30" }
},
{
"id": "att-us",
"match": {
"home_plmns": ["310280"],
"imsi_prefixes": ["310280"]
},
"epdg": { "hostname": "epdg.epc.att.net" },
"ims": {
"identity_profile": "att",
"register_profile": "att",
"ipsec_encryption": "aes-cbc"
}
},
{
"id": "o2-germany",
"match": { "home_plmns": ["26203"] },
"ike": { "advertise_eap_only": false },
"ims": {
"register_profile": "o2-germany",
"ipsec_encryption": "null"
}
},
{
"id": "vodafone-uk",
"match": { "home_plmns": ["23415"] },
"ike": { "proposal": "legacy-sha1-modp1024" },
"ims": { "sms_center": "+447785016005" }
},
{
"id": "vodafone-netherlands",
"match": { "home_plmns": ["20404"] },
"ike": { "proposal": "legacy-sha1-modp1024" }
},
{
"id": "o2-uk",
"match": { "home_plmns": ["23410"] },
"ims": {
"transport": "udp",
"sms_center": "+447802000332"
}
},
{
"id": "vodafone-germany",
"match": { "home_plmns": ["26202"] },
"epdg": {
"dns_hosts": ["epdg.epc.mnc002.mcc262.pub.3gppnetwork.org"],
"dns_client_subnet": "109.192.0.0/24"
}
}
]
}
+7
View File
@@ -173,6 +173,13 @@ func (adapter *EC20Adapter) ReadIdentity(
HomeMCC: homeMCC,
HomeMNC: homeMNC,
}
if reader, ok := adapter.executor.(SIMMetadataReader); ok {
if metadata, metadataErr := reader.ReadSIMMetadata(ctx, deviceID); metadataErr == nil {
identity.SPN = strings.TrimSpace(metadata.SPN)
identity.GID1 = strings.TrimSpace(metadata.GID1)
identity.GID2 = strings.TrimSpace(metadata.GID2)
}
}
identity = applyAssignedCarrierRoute(identity)
adapter.mu.Lock()
adapter.bindings[iccid] = ec20SIMBinding{
+3 -10
View File
@@ -10,19 +10,12 @@ import (
"net/url"
"strings"
"time"
"vocat/internal/vowifi"
)
const googleDNSOverHTTPS = "https://dns.google/resolve"
// A small number of operators publish the standard ePDG CNAME globally but
// return its A records only when the recursive DNS query appears to originate
// in the home country. Keep this list deliberately narrow: ordinary ePDGs must
// continue to use the host resolver, and a fallback is attempted only after
// that resolver has failed.
var geoRestrictedEPDGSubnets = map[string]string{
"epdg.epc.mnc002.mcc262.pub.3gppnetwork.org": "109.192.0.0/24", // Vodafone Germany
}
type dnsOverHTTPSResponse struct {
Status int `json:"Status"`
Answer []struct {
@@ -41,7 +34,7 @@ func resolveEPDG(ctx context.Context, resolver *net.Resolver, host string) ([]ne
}
normalized := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
subnet := geoRestrictedEPDGSubnets[normalized]
subnet := vowifi.EPDGDNSClientSubnet(normalized)
if subnet == "" {
if systemErr != nil {
return nil, systemErr
+71 -40
View File
@@ -11,6 +11,7 @@ import (
"errors"
"fmt"
"io"
"log/slog"
"net"
"strings"
"sync"
@@ -20,17 +21,19 @@ import (
)
type Config struct {
Random io.Reader
Resolver *net.Resolver
Dialer *net.Dialer
RootCAs *x509.CertPool
ResponderPublicKey crypto.PublicKey
ServerName string
Timeout time.Duration
KeepaliveInterval time.Duration
Installer ChildSAInstaller
IdentityType uint8
APN string
Random io.Reader
Resolver *net.Resolver
Dialer *net.Dialer
RootCAs *x509.CertPool
ResponderPublicKey crypto.PublicKey
ServerName string
Timeout time.Duration
KeepaliveInterval time.Duration
Installer ChildSAInstaller
IdentityType uint8
APN string
AutoProposalFallback bool
Logger *slog.Logger
}
type Provider struct {
@@ -42,6 +45,9 @@ func NewProvider(config Config) (*Provider, error) {
if config.Random == nil {
config.Random = rand.Reader
}
if config.Logger == nil {
config.Logger = slog.Default()
}
if config.Resolver == nil {
config.Resolver = net.DefaultResolver
}
@@ -77,6 +83,30 @@ func NewProvider(config Config) (*Provider, error) {
}
func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelRequest) (vowifi.TunnelSession, error) {
if provider == nil {
return nil, errors.New("ike: nil provider")
}
session, err := provider.start(ctx, request, false)
if err == nil || !provider.config.AutoProposalFallback {
return session, err
}
profile := vowifi.ResolveCarrierProfile(request.Identity)
if profile.ID != vowifi.CarrierProfileStandard || !retryableLegacyProposal(err) {
return nil, err
}
provider.config.Logger.Warn("IKE ePDG rejected modern proposal; trying bounded legacy fallback",
"carrier_profile", profile.ID, "from_proposal", vowifi.IKEProposalModern,
"to_proposal", vowifi.IKEProposalLegacy, "error", err)
session, fallbackErr := provider.start(ctx, request, true)
if fallbackErr != nil {
return nil, errors.Join(err, fmt.Errorf("ike: legacy proposal fallback failed: %w", fallbackErr))
}
provider.config.Logger.Info("IKE automatic legacy proposal fallback succeeded",
"carrier_profile", profile.ID, "proposal", vowifi.IKEProposalLegacy)
return session, nil
}
func (provider *Provider) start(ctx context.Context, request vowifi.TunnelRequest, forceLegacy bool) (vowifi.TunnelSession, error) {
if provider == nil {
return nil, errors.New("ike: nil provider")
}
@@ -110,8 +140,12 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
}()
group := uint16(dhMODP2048)
legacyFirst := legacyIKEProfile(request.Identity.HomeMCC, request.Identity.HomeMNC)
advertiseEAPOnly := advertiseEAPOnlyAuthentication(request.Identity.HomeMCC, request.Identity.HomeMNC)
carrierProfile := vowifi.ResolveCarrierProfile(request.Identity)
legacyFirst := carrierProfile.IKEProposal == vowifi.IKEProposalLegacy
if forceLegacy {
legacyFirst = true
}
advertiseEAPOnly := carrierProfile.AdvertiseEAPOnly
if legacyFirst {
group = dhMODP1024
}
@@ -582,30 +616,6 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
return session, nil
}
func legacyIKEProfile(mcc, mnc string) bool {
// Vodafone's UK and Netherlands ePDGs use the legacy group-2/SHA-1-first
// proposal ordering. Some Lebara UK subscriptions carry a 204-04 IMSI from
// that Vodafone NL core; treating them as a generic modern network causes
// IKE_SA_INIT to fail before EAP-AKA even begins.
plmn := strings.TrimSpace(mcc) + strings.TrimLeft(strings.TrimSpace(mnc), "0")
return plmn == "23415" || plmn == "2044"
}
func advertiseEAPOnlyAuthentication(mcc, mnc string) bool {
// Android exposes the ePDG authentication method as carrier policy rather
// than unconditionally requesting RFC 5998 EAP-only authentication. O2
// Germany's 262-03 ePDG rejects an initial IKE_AUTH that explicitly carries
// EAP_ONLY_AUTHENTICATION, but then implicitly defers responder AUTH when the
// notify is omitted. Do not advertise RFC 5998 for that PLMN; the final
// responder AUTH derived from the EAP-AKA MSK remains mandatory.
return !o2GermanyIKECompatibility(mcc, mnc)
}
func o2GermanyIKECompatibility(mcc, mnc string) bool {
plmn := strings.TrimSpace(mcc) + strings.TrimLeft(strings.TrimSpace(mnc), "0")
return plmn == "2623"
}
func buildInitialEAPAuth(
idi payload,
requestedIDr payload,
@@ -719,6 +729,27 @@ func decryptAndValidate(
return header, payloads, nil
}
var errNoProposalChosen = errors.New("ike: responder reported NO_PROPOSAL_CHOSEN")
type invalidKEPayloadError struct {
group uint16
}
func (err *invalidKEPayloadError) Error() string {
if err.group != 0 {
return fmt.Sprintf("ike: responder requires DH group %d", err.group)
}
return "ike: responder reported INVALID_KE_PAYLOAD"
}
func retryableLegacyProposal(err error) bool {
if errors.Is(err, errNoProposalChosen) {
return true
}
var invalidKE *invalidKEPayloadError
return errors.As(err, &invalidKE) && (invalidKE.group == 0 || invalidKE.group == dhMODP1024)
}
func rejectFatalNotifications(payloads []payload) error {
for _, item := range payloadsOfType(payloads, payloadNotify) {
kind, data, err := parseNotify(item)
@@ -727,12 +758,12 @@ func rejectFatalNotifications(payloads []payload) error {
}
switch kind {
case notifyNoProposal:
return errors.New("ike: responder reported NO_PROPOSAL_CHOSEN")
return errNoProposalChosen
case notifyInvalidKE:
if len(data) == 2 {
return fmt.Errorf("ike: responder requires DH group %d", binary.BigEndian.Uint16(data))
return &invalidKEPayloadError{group: binary.BigEndian.Uint16(data)}
}
return errors.New("ike: responder reported INVALID_KE_PAYLOAD")
return &invalidKEPayloadError{}
}
if kind < 16384 {
return fmt.Errorf("ike: responder reported fatal notification %d", kind)
+25 -3
View File
@@ -25,15 +25,37 @@ func TestLegacyIKEProfileIncludesVodafoneHostedLebaraCore(t *testing.T) {
{mcc: "204", mnc: "04"},
{mcc: "204", mnc: "004"},
} {
if !legacyIKEProfile(item.mcc, item.mnc) {
t.Errorf("legacyIKEProfile(%q, %q) = false", item.mcc, item.mnc)
profile := vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: item.mcc, HomeMNC: item.mnc})
if profile.IKEProposal != vowifi.IKEProposalLegacy {
t.Errorf("carrier profile IKE proposal for %q/%q = %q", item.mcc, item.mnc, profile.IKEProposal)
}
}
if legacyIKEProfile("234", "87") {
if profile := vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "87"}); profile.IKEProposal == vowifi.IKEProposalLegacy {
t.Fatal("Lebara's 234-87 core must use the modern IKE profile")
}
}
func TestLegacyProposalFallbackIsLimitedToNegotiationFailures(t *testing.T) {
for _, err := range []error{
errNoProposalChosen,
&invalidKEPayloadError{},
&invalidKEPayloadError{group: dhMODP1024},
} {
if !retryableLegacyProposal(err) {
t.Errorf("negotiation failure %v was not retryable", err)
}
}
for _, err := range []error{
&invalidKEPayloadError{group: dhMODP2048},
errors.New("ike: authentication failed"),
vowifi.ErrEAPAuthenticationRejected,
} {
if retryableLegacyProposal(err) {
t.Errorf("unsafe failure %v enabled legacy retry", err)
}
}
}
func (reader constantReader) Read(destination []byte) (int, error) {
for index := range destination {
destination[index] = reader.value
+3 -2
View File
@@ -171,11 +171,12 @@ func TestConfigurationRequestMatchesAndroidAttributes(t *testing.T) {
func TestO2GermanyUsesStandardEAPAuthentication(t *testing.T) {
for _, mnc := range []string{"03", "003"} {
if advertiseEAPOnlyAuthentication("262", mnc) {
if vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: "262", HomeMNC: mnc}).AdvertiseEAPOnly {
t.Fatalf("O2 Germany 262-%s unexpectedly uses EAP-only", mnc)
}
}
if !advertiseEAPOnlyAuthentication("262", "02") || !advertiseEAPOnlyAuthentication("234", "15") {
if !vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: "262", HomeMNC: "02"}).AdvertiseEAPOnly ||
!vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "15"}).AdvertiseEAPOnly {
t.Fatal("non-O2 PLMN lost the existing EAP-only policy")
}
}
+201 -58
View File
@@ -8,6 +8,7 @@ import (
"encoding/hex"
"errors"
"fmt"
"log/slog"
"net"
"strconv"
"strings"
@@ -35,38 +36,50 @@ var (
// LocalAddress is empty, Provider uses the corresponding value proven by the
// TunnelSession. The default transport is TCP and the default port is 5060.
type Config struct {
PCSCF string
LocalAddress string
Transport string
TransportByPLMN map[string]string
Port int
RegistrationExpiry time.Duration
TransactionTimeout time.Duration
PrivateIdentity string
PublicIdentity string
UserAgent string
SecurityMode SecurityMode
IPSecInstaller IPSecSAInstaller
ProtectedClientPort int
ProtectedServerPort int
PCSCF string
LocalAddress string
Transport string
TransportByPLMN map[string]string
// AutoTransportFallback tries the alternate TCP/UDP transport only when
// the initial P-CSCF attempt produced no SIP response at all. A challenge
// or rejection is authoritative and is never retried as another transport.
AutoTransportFallback bool
Port int
RegistrationExpiry time.Duration
TransactionTimeout time.Duration
PrivateIdentity string
PublicIdentity string
UserAgent string
SecurityMode SecurityMode
IPSecInstaller IPSecSAInstaller
ProtectedClientPort int
ProtectedServerPort int
// SMSCenter is an operator-provided fallback when the SIM leaves EF_SMSP
// and AT+CSCA empty. It must be an international or national digit string.
SMSCenter string
// SMSCenterByPLMN provides narrow carrier fallbacks without applying one
// operator's service-centre address to every SIM.
SMSCenterByPLMN map[string]string
// OnSMS is invoked after a valid inbound RP-DATA/SMS-DELIVER has been
// decoded. Returning an error causes an RP-ERROR delivery report.
OnSMS func(context.Context, ReceivedSMS) error
// OnSMSStatus is invoked for an SMS-STATUS-REPORT received after a
// submission that requested a delivery report.
OnSMSStatus func(context.Context, ReceivedSMSStatus) error
// Logger receives structured IMS runtime diagnostics. Inbound SMS logs do
// not include message text or raw protocol payloads.
Logger *slog.Logger
}
// Provider implements vowifi.IMSProvider using a small RFC 3261 REGISTER
// transaction and 3GPP AKAv1-MD5 authentication. It has no SIP stack or
// runtime dependency outside the Go standard library.
type Provider struct {
aka vowifi.AKAProvider
config Config
installer IPSecSAInstaller
aka vowifi.AKAProvider
config Config
installer IPSecSAInstaller
transportMu sync.RWMutex
transportCache map[string]string
}
func NewProvider(aka vowifi.AKAProvider, config Config) (*Provider, error) {
@@ -81,10 +94,16 @@ func NewProvider(aka vowifi.AKAProvider, config Config) (*Provider, error) {
if installer == nil {
installer = defaultIPSecInstaller()
}
return &Provider{aka: aka, config: normalized, installer: installer}, nil
return &Provider{
aka: aka, config: normalized, installer: installer,
transportCache: make(map[string]string),
}, nil
}
func normalizeConfig(config Config) (Config, error) {
if config.Logger == nil {
config.Logger = slog.Default()
}
if config.Port == 0 {
config.Port = defaultSIPPort
}
@@ -120,6 +139,19 @@ func normalizeConfig(config Config) (Config, error) {
transportByPLMN[plmn] = transport
}
config.TransportByPLMN = transportByPLMN
smsCenterByPLMN := make(map[string]string, len(config.SMSCenterByPLMN))
for plmn, smsCenter := range config.SMSCenterByPLMN {
plmn = strings.TrimSpace(plmn)
smsCenter = strings.TrimSpace(smsCenter)
if !digitsBetween(plmn, 5, 6) {
return Config{}, fmt.Errorf("ims: invalid SMS service-centre PLMN %q", plmn)
}
if !validSMSCenter(smsCenter) {
return Config{}, fmt.Errorf("ims: invalid SMS service-centre address for PLMN %s", plmn)
}
smsCenterByPLMN[plmn] = smsCenter
}
config.SMSCenterByPLMN = smsCenterByPLMN
if strings.TrimSpace(config.UserAgent) == "" {
config.UserAgent = "vocat/1"
}
@@ -156,15 +188,17 @@ func normalizeConfig(config Config) (Config, error) {
config.PublicIdentity = strings.TrimSpace(config.PublicIdentity)
config.UserAgent = strings.TrimSpace(config.UserAgent)
config.SMSCenter = strings.TrimSpace(config.SMSCenter)
if config.SMSCenter != "" {
digits := strings.TrimPrefix(config.SMSCenter, "+")
if !digitsBetween(digits, 3, 20) {
return Config{}, errors.New("ims: configured SMS service-centre address is invalid")
}
if config.SMSCenter != "" && !validSMSCenter(config.SMSCenter) {
return Config{}, errors.New("ims: configured SMS service-centre address is invalid")
}
return config, nil
}
func validSMSCenter(value string) bool {
digits := strings.TrimPrefix(strings.TrimSpace(value), "+")
return digitsBetween(digits, 3, 20)
}
func (provider *Provider) Start(ctx context.Context, request vowifi.IMSRequest) (vowifi.IMSSession, error) {
if ctx == nil {
ctx = context.Background()
@@ -199,10 +233,17 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.IMSRequest)
if provider.config.PCSCF != "" && !pcscfProvenByTunnel(endpoint, tunnel.PCSCF, provider.config.Port) {
return nil, errors.New("ims: configured P-CSCF is not proven by the SWu tunnel")
}
transport := transportForIdentity(provider.config, request.Identity)
if transport == "" {
transport, carrierSelected := carrierTransportForIdentity(provider.config, request.Identity)
if cached := provider.cachedTransport(request.Identity); cached != "" {
transport = cached
carrierSelected = true
}
if transport == "" && !carrierSelected {
transport = transportHint
}
if transport == "" {
transport = provider.config.Transport
}
if transport == "" {
transport = "tcp"
}
@@ -225,31 +266,96 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.IMSRequest)
return nil, errors.New("ims: configured local address is not assigned by the SWu tunnel")
}
connection, err := dialSIP(ctx, transport, localAddress, 0, endpoint.address())
if err != nil {
return nil, fmt.Errorf("ims: connect to P-CSCF: %w", err)
transports := []string{transport}
if provider.config.AutoTransportFallback {
alternate := "udp"
if transport == "udp" {
alternate = "tcp"
}
transports = append(transports, alternate)
}
session, err := newSession(provider, request, identities, endpoint, transport, connection)
if err != nil {
_ = connection.Close()
return nil, err
}
if err := session.establish(ctx); err != nil {
var lastErr error
for attempt, candidate := range transports {
connection, dialErr := dialSIP(ctx, candidate, localAddress, 0, endpoint.address())
if dialErr != nil {
lastErr = fmt.Errorf("ims: connect to P-CSCF over %s: %w", candidate, dialErr)
if attempt+1 < len(transports) && ctx.Err() == nil {
provider.logTransportFallback(request.Identity, candidate, transports[attempt+1], lastErr)
continue
}
return nil, lastErr
}
session, sessionErr := newSession(provider, request, identities, endpoint, candidate, connection)
if sessionErr != nil {
_ = connection.Close()
return nil, sessionErr
}
establishErr := session.establish(ctx)
if establishErr == nil {
provider.rememberTransport(request.Identity, candidate)
if attempt > 0 {
provider.config.Logger.Info("IMS automatic transport fallback succeeded",
"carrier_profile", vowifi.ResolveCarrierProfile(request.Identity).ID,
"transport", candidate)
}
return session, nil
}
sipResponseObserved := session.evidence.LastSIPCode != 0
session.abort()
return nil, err
lastErr = establishErr
if sipResponseObserved || attempt+1 >= len(transports) || ctx.Err() != nil {
return nil, lastErr
}
provider.logTransportFallback(request.Identity, candidate, transports[attempt+1], establishErr)
}
return session, nil
return nil, lastErr
}
func transportForIdentity(config Config, identity vowifi.SIMIdentity) string {
mcc := strings.TrimSpace(identity.HomeMCC)
mnc := strings.TrimSpace(identity.HomeMNC)
if transport := config.TransportByPLMN[mcc+mnc]; transport != "" {
if transport, selected := carrierTransportForIdentity(config, identity); selected {
return transport
}
return config.Transport
}
func carrierTransportForIdentity(config Config, identity vowifi.SIMIdentity) (string, bool) {
mcc := strings.TrimSpace(identity.HomeMCC)
mnc := strings.TrimSpace(identity.HomeMNC)
if transport := config.TransportByPLMN[mcc+mnc]; transport != "" {
return transport, true
}
if transport := vowifi.ResolveCarrierProfile(identity).IMSTransport; transport != "" {
return transport, true
}
return "", false
}
func transportCacheKey(identity vowifi.SIMIdentity) string {
if iccid := strings.TrimSpace(identity.ICCID); iccid != "" {
return "iccid:" + iccid
}
return "plmn:" + strings.TrimSpace(identity.HomeMCC) + "/" + strings.TrimSpace(identity.HomeMNC)
}
func (provider *Provider) cachedTransport(identity vowifi.SIMIdentity) string {
provider.transportMu.RLock()
transport := provider.transportCache[transportCacheKey(identity)]
provider.transportMu.RUnlock()
return transport
}
func (provider *Provider) rememberTransport(identity vowifi.SIMIdentity, transport string) {
provider.transportMu.Lock()
provider.transportCache[transportCacheKey(identity)] = transport
provider.transportMu.Unlock()
}
func (provider *Provider) logTransportFallback(identity vowifi.SIMIdentity, from, to string, err error) {
provider.config.Logger.Warn("IMS P-CSCF did not respond; trying alternate SIP transport",
"carrier_profile", vowifi.ResolveCarrierProfile(identity).ID,
"from_transport", from, "to_transport", to, "error", err)
}
type identitySet struct {
domain string
private string
@@ -273,7 +379,7 @@ func deriveIdentities(identity vowifi.SIMIdentity, config Config) (identitySet,
domain := fmt.Sprintf("ims.mnc%s.mcc%s.3gppnetwork.org", mnc, mcc)
privateDomain := domain
publicDomain := domain
if vowifi.IsATT310280(identity) {
if vowifi.ResolveCarrierProfile(identity).IMSIdentityProfile == vowifi.IMSProfileATT {
// AT&T provisions the IMPI and IMPU in its ISIM domains rather than
// the generic 3GPP PLMN IMS domain.
domain = "one.att.net"
@@ -595,18 +701,11 @@ func newSession(
}
func securityEncryptionForIdentity(identity vowifi.SIMIdentity) string {
if usesO2GermanyIMSProfile(identity) {
// O2 Germany's P-CSCF advertises the 3GPP integrity-only ESP profile.
// Proposing aes-cbc is rejected before the AKA challenge is issued.
return "null"
}
return "aes-cbc"
return vowifi.ResolveCarrierProfile(identity).IMSIPSecEncryption
}
func usesO2GermanyIMSProfile(identity vowifi.SIMIdentity) bool {
mcc := strings.TrimSpace(identity.HomeMCC)
mnc := strings.TrimLeft(strings.TrimSpace(identity.HomeMNC), "0")
return mcc+mnc == "2623"
return vowifi.ResolveCarrierProfile(identity).IMSRegisterProfile == vowifi.IMSProfileO2Germany
}
func (session *Session) abort() {
@@ -926,19 +1025,33 @@ func (session *Session) exchange(ctx context.Context, request []byte, cseq uint3
method: "REGISTER",
})
}
deadline := time.Now().Add(session.provider.config.TransactionTimeout)
if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(deadline) {
deadline = contextDeadline
transactionDeadline := time.Now().Add(session.provider.config.TransactionTimeout)
if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(transactionDeadline) {
transactionDeadline = contextDeadline
}
readUDP := session.protectedUDP
protectedUDP := session.securityActive && session.transport == "udp" && readUDP != nil
if err := session.conn.SetDeadline(deadline); err != nil {
return nil, fmt.Errorf("ims: set SIP transaction deadline: %w", err)
}
if protectedUDP {
if err := readUDP.SetReadDeadline(deadline); err != nil {
return nil, fmt.Errorf("ims: set protected SIP receive deadline: %w", err)
setReadDeadline := func(deadline time.Time) error {
if err := session.conn.SetDeadline(deadline); err != nil {
return fmt.Errorf("ims: set SIP transaction deadline: %w", err)
}
if protectedUDP {
if err := readUDP.SetReadDeadline(deadline); err != nil {
return fmt.Errorf("ims: set protected SIP receive deadline: %w", err)
}
}
return nil
}
retransmitInterval := time.Duration(0)
readDeadline := transactionDeadline
if session.transport == "udp" {
retransmitInterval = sipMessageRetransmitT1
if candidate := time.Now().Add(retransmitInterval); candidate.Before(readDeadline) {
readDeadline = candidate
}
}
if err := setReadDeadline(readDeadline); err != nil {
return nil, err
}
stopCancellation := context.AfterFunc(ctx, func() {
_ = session.conn.SetDeadline(time.Now())
@@ -951,6 +1064,7 @@ func (session *Session) exchange(ctx context.Context, request []byte, cseq uint3
return nil, fmt.Errorf("ims: send SIP REGISTER: %w", err)
}
retransmissions := 0
for {
var response *sipResponse
var err error
@@ -979,6 +1093,31 @@ func (session *Session) exchange(ctx context.Context, request []byte, cseq uint3
if contextErr := ctx.Err(); contextErr != nil {
return nil, contextErr
}
var networkErr net.Error
if retransmitInterval > 0 && errors.As(err, &networkErr) && networkErr.Timeout() &&
time.Now().Before(transactionDeadline) {
retransmitInterval *= 2
if retransmitInterval > sipMessageRetransmitMax {
retransmitInterval = sipMessageRetransmitMax
}
nextDeadline := time.Now().Add(retransmitInterval)
if nextDeadline.After(transactionDeadline) {
nextDeadline = transactionDeadline
}
// The previous read deadline has already expired and net.Conn applies
// it to writes too. Extend it before retransmitting.
if deadlineErr := setReadDeadline(nextDeadline); deadlineErr != nil {
return nil, deadlineErr
}
if _, writeErr := session.conn.Write(request); writeErr != nil {
return nil, fmt.Errorf("ims: retransmit SIP REGISTER: %w", writeErr)
}
retransmissions++
session.provider.config.Logger.Debug("IMS SIP REGISTER retransmitted",
"carrier_profile", vowifi.ResolveCarrierProfile(session.request.Identity).ID,
"transport", session.transport, "attempt", retransmissions)
continue
}
return nil, fmt.Errorf("ims: receive SIP REGISTER response: %w", err)
}
if !strings.EqualFold(strings.TrimSpace(response.value("Call-ID")), session.callID) {
@@ -989,6 +1128,10 @@ func (session *Session) exchange(ctx context.Context, request []byte, cseq uint3
continue
}
if response.StatusCode >= 100 && response.StatusCode < 200 {
retransmitInterval = 0
if err := setReadDeadline(transactionDeadline); err != nil {
return nil, err
}
continue
}
return response, nil
+112
View File
@@ -5,6 +5,8 @@ import (
"encoding/base64"
"errors"
"fmt"
"io"
"log/slog"
"net"
"strconv"
"strings"
@@ -18,6 +20,40 @@ type evidenceTunnel struct {
evidence vowifi.TunnelEvidence
}
type immediateTimeoutError struct{}
func (immediateTimeoutError) Error() string { return "test timeout" }
func (immediateTimeoutError) Timeout() bool { return true }
func (immediateTimeoutError) Temporary() bool { return true }
type registerRetransmitConn struct {
writes int
response []byte
}
func (connection *registerRetransmitConn) Read(destination []byte) (int, error) {
if connection.writes < 2 {
return 0, immediateTimeoutError{}
}
return copy(destination, connection.response), nil
}
func (connection *registerRetransmitConn) Write(source []byte) (int, error) {
connection.writes++
return len(source), nil
}
func (*registerRetransmitConn) Close() error { return nil }
func (*registerRetransmitConn) LocalAddr() net.Addr {
return &net.UDPAddr{IP: net.IPv4(192, 0, 2, 10), Port: 5060}
}
func (*registerRetransmitConn) RemoteAddr() net.Addr {
return &net.UDPAddr{IP: net.IPv4(192, 0, 2, 20), Port: 5060}
}
func (*registerRetransmitConn) SetDeadline(time.Time) error { return nil }
func (*registerRetransmitConn) SetReadDeadline(time.Time) error { return nil }
func (*registerRetransmitConn) SetWriteDeadline(time.Time) error { return nil }
func (tunnel evidenceTunnel) Evidence() vowifi.TunnelEvidence {
return tunnel.evidence
}
@@ -73,6 +109,82 @@ func TestTransportForIdentityPreservesLeadingZeroMNCs(t *testing.T) {
}
}
func TestCarrierProfileSuppliesTransportWithoutCodeMap(t *testing.T) {
t.Parallel()
identity := vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "10"}
if got := transportForIdentity(Config{Transport: "tcp"}, identity); got != "udp" {
t.Fatalf("O2 UK profile transport = %q, want udp", got)
}
if got := transportForIdentity(Config{
Transport: "udp", TransportByPLMN: map[string]string{"23410": "tcp"},
}, identity); got != "tcp" {
t.Fatalf("explicit configuration did not override profile: %q", got)
}
}
func TestProviderCachesSuccessfulTransportPerSIM(t *testing.T) {
t.Parallel()
provider := &Provider{transportCache: make(map[string]string)}
first := vowifi.SIMIdentity{ICCID: "8901000000000000001", HomeMCC: "001", HomeMNC: "01"}
second := vowifi.SIMIdentity{ICCID: "8901000000000000002", HomeMCC: "001", HomeMNC: "01"}
provider.rememberTransport(first, "udp")
if got := provider.cachedTransport(first); got != "udp" {
t.Fatalf("cached first transport = %q", got)
}
if got := provider.cachedTransport(second); got != "" {
t.Fatalf("second SIM inherited cached transport %q", got)
}
}
func TestUDPRegisterRetransmitsBeforeTransactionTimeout(t *testing.T) {
t.Parallel()
connection := &registerRetransmitConn{response: []byte(strings.Join([]string{
"SIP/2.0 200 OK",
"Call-ID: register-retransmit-test",
"CSeq: 7 REGISTER",
"Content-Length: 0",
"",
"",
}, "\r\n"))}
session := &Session{
provider: &Provider{config: Config{
TransactionTimeout: 3 * time.Second,
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}},
request: vowifi.IMSRequest{Identity: vowifi.SIMIdentity{HomeMCC: "001", HomeMNC: "01"}},
transport: "udp",
conn: connection,
callID: "register-retransmit-test",
}
response, err := session.exchange(context.Background(), []byte("REGISTER test"), 7)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != 200 || connection.writes != 2 {
t.Fatalf("response=%#v writes=%d, want SIP 200 after one retransmission", response, connection.writes)
}
}
func TestNormalizeConfigValidatesSMSCentersByPLMN(t *testing.T) {
config, err := normalizeConfig(Config{SMSCenterByPLMN: map[string]string{
" 23410 ": " +447802000332 ",
}})
if err != nil {
t.Fatalf("normalizeConfig() error = %v", err)
}
if got := config.SMSCenterByPLMN["23410"]; got != "+447802000332" {
t.Fatalf("normalized O2 SMSC = %q", got)
}
for _, invalid := range []Config{
{SMSCenterByPLMN: map[string]string{"234": "+447802000332"}},
{SMSCenterByPLMN: map[string]string{"23410": "not-a-number"}},
} {
if _, err := normalizeConfig(invalid); err == nil {
t.Fatalf("normalizeConfig(%#v) succeeded", invalid.SMSCenterByPLMN)
}
}
}
func TestProviderRegisterAKAParseEvidenceAndClose(t *testing.T) {
for _, test := range []struct {
name string
+317 -24
View File
@@ -2,10 +2,17 @@ package ims
import (
"bufio"
"bytes"
"context"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"io"
"log/slog"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net"
"strconv"
"strings"
@@ -15,7 +22,11 @@ import (
"vocat/internal/vowifi"
)
const smsContentType = "application/vnd.3gpp.sms"
const (
smsContentType = "application/vnd.3gpp.sms"
sipMessageRetransmitT1 = 500 * time.Millisecond
sipMessageRetransmitMax = 4 * time.Second
)
var (
ErrSMSCUnavailable = errors.New("ims: SMS service-centre address is unavailable")
@@ -152,6 +163,11 @@ func (session *Session) readInboundTCP(connection net.Conn) {
for {
packet, err := readSIPPacket(reader)
if err != nil {
if !session.isClosed() && !errors.Is(err, io.EOF) && !errors.Is(err, net.ErrClosed) {
session.logInboundSMS(slog.LevelWarn, "IMS protected SIP packet read failed", nil,
"stage", "sip_parse", "transport", "tcp",
"remote", connection.RemoteAddr().String(), "error", err)
}
return
}
session.dispatchPacket(packet, func(response []byte) error {
@@ -174,6 +190,9 @@ func (session *Session) readProtectedUDP() {
}
packet, err := parseSIPPacket(buffer[:count])
if err != nil {
session.logInboundSMS(slog.LevelWarn, "IMS protected SIP packet parse failed", nil,
"stage", "sip_parse", "transport", "udp", "remote", remote.String(),
"packet_bytes", count, "error", err)
continue
}
session.dispatchPacket(packet, func(response []byte) error {
@@ -198,6 +217,8 @@ func (session *Session) dispatchPacket(packet sipPacket, respond func([]byte) er
response := packet.Response
cseq, method, err := cseqNumber(response.value("CSeq"))
if err != nil {
session.logOutboundSMS(slog.LevelWarn, "IMS SIP response could not be matched",
"stage", "sip_response", "sip_status", response.StatusCode, "error", err)
return
}
key := sipTransactionKey{
@@ -213,6 +234,10 @@ func (session *Session) dispatchPacket(packet sipPacket, respond func([]byte) er
case channel <- response:
default:
}
} else if method == "MESSAGE" {
session.logOutboundSMS(slog.LevelWarn, "IMS SIP MESSAGE response was unmatched",
"stage", "sip_response", "call_id", key.callID,
"cseq", key.cseq, "sip_status", response.StatusCode)
}
return
}
@@ -240,22 +265,64 @@ func (session *Session) exchangeRuntime(
session.transactionsMu.Unlock()
}()
session.writeMu.Lock()
_, err := session.conn.Write(request)
session.writeMu.Unlock()
if err != nil {
writeRequest := func() error {
session.writeMu.Lock()
defer session.writeMu.Unlock()
_, err := session.conn.Write(request)
return err
}
if err := writeRequest(); err != nil {
return nil, fmt.Errorf("ims: send SIP %s: %w", key.method, err)
}
timer := time.NewTimer(session.provider.config.TransactionTimeout)
defer timer.Stop()
var retransmitTimer *time.Timer
var retransmit <-chan time.Time
retransmitInterval := sipMessageRetransmitT1
retransmitCount := 0
if session.transport == "udp" && key.method == "MESSAGE" {
retransmitTimer = time.NewTimer(retransmitInterval)
retransmit = retransmitTimer.C
defer retransmitTimer.Stop()
}
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-timer.C:
if retransmitTimer != nil {
return nil, fmt.Errorf(
"ims: SIP %s transaction timed out after %d retransmissions",
key.method,
retransmitCount,
)
}
return nil, fmt.Errorf("ims: SIP %s transaction timed out", key.method)
case <-retransmit:
if err := writeRequest(); err != nil {
return nil, fmt.Errorf("ims: retransmit SIP %s: %w", key.method, err)
}
retransmitCount++
session.logOutboundSMS(slog.LevelDebug, "IMS SIP MESSAGE retransmitted",
"stage", "sip_retransmit", "call_id", key.callID,
"cseq", key.cseq, "attempt", retransmitCount)
retransmitInterval *= 2
if retransmitInterval > sipMessageRetransmitMax {
retransmitInterval = sipMessageRetransmitMax
}
retransmitTimer.Reset(retransmitInterval)
case response := <-responses:
if response.StatusCode >= 100 && response.StatusCode < 200 {
if retransmitTimer != nil {
if !retransmitTimer.Stop() {
select {
case <-retransmitTimer.C:
default:
}
}
retransmitInterval = sipMessageRetransmitMax
retransmitTimer.Reset(retransmitInterval)
}
continue
}
return response, nil
@@ -271,23 +338,44 @@ func (session *Session) handleSIPRequest(request *sipRequest, respond func([]byt
switch request.Method {
case "OPTIONS":
case "MESSAGE":
contentType := strings.ToLower(strings.TrimSpace(strings.SplitN(request.value("Content-Type"), ";", 2)[0]))
if contentType != smsContentType {
if !supportsSMSContentType(request.value("Content-Type")) {
status = 415
}
default:
status = 405
}
response, err := buildSIPResponse(request, status, session.fromTag)
if err == nil {
_ = respond(response)
if err != nil {
session.logInboundSMS(slog.LevelWarn, "IMS inbound SIP request response failed", request,
"stage", "sip_response_build", "error", err)
} else if err = respond(response); err != nil {
session.logInboundSMS(slog.LevelWarn, "IMS inbound SIP request response failed", request,
"stage", "sip_response_send", "sip_status", status, "error", err)
}
if status != 200 || request.Method != "MESSAGE" {
if request.Method == "MESSAGE" {
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS MESSAGE rejected", request,
"stage", "content_type", "sip_status", status)
}
return
}
session.logInboundSMS(slog.LevelInfo, "IMS inbound SMS MESSAGE received", request,
"stage", "sip_accepted")
go session.processSMSMessage(request)
}
func supportsSMSContentType(value string) bool {
mediaType, parameters, err := mime.ParseMediaType(strings.TrimSpace(value))
if err != nil {
return false
}
if strings.EqualFold(mediaType, smsContentType) {
return true
}
return strings.EqualFold(mediaType, "multipart/mixed") &&
strings.TrimSpace(parameters["boundary"]) != ""
}
func buildSIPResponse(request *sipRequest, status int, tag string) ([]byte, error) {
reason := map[int]string{200: "OK", 405: "Method Not Allowed", 415: "Unsupported Media Type", 488: "Not Acceptable Here"}[status]
if reason == "" {
@@ -325,24 +413,46 @@ func buildSIPResponse(request *sipRequest, status int, tag string) ([]byte, erro
}
func (session *Session) processSMSMessage(request *sipRequest) {
rpdu, err := parseRPDU(request.Body)
payload, payloadSource, err := extractSMSPayload(request)
if err != nil {
session.sendDeliveryReport(request, buildRPError(0, 95))
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS decode failed", request,
"stage", "mime", "error", err)
session.sendLoggedDeliveryReport(request, buildRPError(0, 95), "rp_error")
return
}
rpdu, err := parseRPDU(payload)
if err != nil {
reference := byte(0)
if len(payload) > 1 {
reference = payload[1]
}
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS decode failed", request,
"stage", "rpdu", "payload_source", payloadSource,
"rp_reference", int(reference), "payload_bytes", len(payload), "error", err)
session.sendLoggedDeliveryReport(request, buildRPError(reference, 95), "rp_error")
return
}
if rpdu.messageType != 1 { // RP-DATA, network to MS.
session.logInboundSMS(slog.LevelInfo, "IMS inbound SMS control message received", request,
"stage", "rpdu", "payload_source", payloadSource,
"rp_message_type", int(rpdu.messageType), "rp_reference", int(rpdu.reference))
return
}
message, err := device.DecodeSMSDeliverTPDU(rpdu.tpdu)
if err != nil {
session.sendDeliveryReport(request, buildRPError(rpdu.reference, 95))
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS decode failed", request,
"stage", "tpdu", "payload_source", payloadSource,
"rp_reference", int(rpdu.reference), "tpdu_bytes", len(rpdu.tpdu), "error", err)
session.sendLoggedDeliveryReport(request, buildRPError(rpdu.reference, 95), "rp_error")
return
}
receivedAt := time.Now().UTC()
callID := strings.TrimSpace(request.value("Call-ID"))
if message.Direction == device.SMSDirectionStatusReport {
if message.MessageReference == nil || message.StatusCode == nil {
session.sendDeliveryReport(request, buildRPError(rpdu.reference, 95))
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS status report is incomplete", request,
"stage", "tpdu", "rp_reference", int(rpdu.reference))
session.sendLoggedDeliveryReport(request, buildRPError(rpdu.reference, 95), "rp_error")
return
}
status := ReceivedSMSStatus{
@@ -357,7 +467,7 @@ func (session *Session) processSMSMessage(request *sipRequest) {
Timestamp: receivedAt,
RPReference: int(rpdu.reference),
CallID: callID,
RawRPDU: strings.ToUpper(hex.EncodeToString(request.Body)),
RawRPDU: strings.ToUpper(hex.EncodeToString(payload)),
RawTPDU: strings.ToUpper(hex.EncodeToString(rpdu.tpdu)),
}
if session.provider.config.OnSMSStatus != nil {
@@ -366,14 +476,21 @@ func (session *Session) processSMSMessage(request *sipRequest) {
cancel()
}
if err != nil {
session.sendDeliveryReport(request, buildRPError(rpdu.reference, 22))
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS status persistence failed", request,
"stage", "status_callback", "rp_reference", int(rpdu.reference), "error", err)
session.sendLoggedDeliveryReport(request, buildRPError(rpdu.reference, 22), "rp_error")
return
}
session.sendDeliveryReport(request, []byte{0x02, rpdu.reference})
session.logInboundSMS(slog.LevelInfo, "IMS inbound SMS status report processed", request,
"stage", "status_callback", "rp_reference", int(rpdu.reference),
"status_code", *message.StatusCode)
session.sendLoggedDeliveryReport(request, []byte{0x02, rpdu.reference}, "rp_ack")
return
}
if message.Direction != device.SMSDirectionReceived {
session.sendDeliveryReport(request, buildRPError(rpdu.reference, 95))
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS has unexpected TPDU direction", request,
"stage", "tpdu", "rp_reference", int(rpdu.reference), "direction", message.Direction)
session.sendLoggedDeliveryReport(request, buildRPError(rpdu.reference, 95), "rp_error")
return
}
var serviceCenterTimestamp *time.Time
@@ -396,7 +513,7 @@ func (session *Session) processSMSMessage(request *sipRequest) {
Concat: message.Concat,
RPReference: int(rpdu.reference),
CallID: callID,
RawRPDU: strings.ToUpper(hex.EncodeToString(request.Body)),
RawRPDU: strings.ToUpper(hex.EncodeToString(payload)),
RawTPDU: strings.ToUpper(hex.EncodeToString(rpdu.tpdu)),
}
if session.provider.config.OnSMS != nil {
@@ -405,26 +522,130 @@ func (session *Session) processSMSMessage(request *sipRequest) {
cancel()
}
if err != nil {
session.sendDeliveryReport(request, buildRPError(rpdu.reference, 22))
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS persistence failed", request,
"stage", "sms_callback", "rp_reference", int(rpdu.reference), "error", err)
session.sendLoggedDeliveryReport(request, buildRPError(rpdu.reference, 22), "rp_error")
return
}
session.sendDeliveryReport(request, []byte{0x02, rpdu.reference})
session.logInboundSMS(slog.LevelInfo, "IMS inbound SMS processed", request,
"stage", "sms_callback", "payload_source", payloadSource,
"rp_reference", int(rpdu.reference), "encoding", message.Encoding,
"concatenated", message.Concat != nil)
session.sendLoggedDeliveryReport(request, []byte{0x02, rpdu.reference}, "rp_ack")
}
func (session *Session) sendDeliveryReport(request *sipRequest, report []byte) {
func extractSMSPayload(request *sipRequest) ([]byte, string, error) {
if request == nil {
return nil, "", errors.New("ims: SMS MESSAGE is nil")
}
mediaType, parameters, err := mime.ParseMediaType(strings.TrimSpace(request.value("Content-Type")))
if err != nil {
return nil, "", fmt.Errorf("ims: parse SMS Content-Type: %w", err)
}
if strings.EqualFold(mediaType, smsContentType) {
payload, decodeErr := decodeSMSTransfer(request.Body, request.value("Content-Transfer-Encoding"))
return payload, smsContentType, decodeErr
}
if !strings.EqualFold(mediaType, "multipart/mixed") {
return nil, "", fmt.Errorf("ims: unsupported SMS Content-Type %q", mediaType)
}
boundary := strings.TrimSpace(parameters["boundary"])
if boundary == "" {
return nil, "", errors.New("ims: multipart SMS has no boundary")
}
reader := multipart.NewReader(bytes.NewReader(request.Body), boundary)
for {
part, nextErr := reader.NextRawPart()
if errors.Is(nextErr, io.EOF) {
break
}
if nextErr != nil {
return nil, "", fmt.Errorf("ims: read multipart SMS: %w", nextErr)
}
partType, _, parseErr := mime.ParseMediaType(strings.TrimSpace(part.Header.Get("Content-Type")))
if parseErr != nil || !strings.EqualFold(partType, smsContentType) {
_ = part.Close()
continue
}
body, readErr := io.ReadAll(part)
_ = part.Close()
if readErr != nil {
return nil, "", fmt.Errorf("ims: read multipart SMS payload: %w", readErr)
}
payload, decodeErr := decodeSMSTransfer(body, part.Header.Get("Content-Transfer-Encoding"))
return payload, "multipart/mixed", decodeErr
}
return nil, "", errors.New("ims: multipart MESSAGE omitted application/vnd.3gpp.sms payload")
}
func decodeSMSTransfer(body []byte, encoding string) ([]byte, error) {
switch strings.ToLower(strings.TrimSpace(encoding)) {
case "", "binary", "8bit":
return append([]byte(nil), body...), nil
case "base64":
decoded, err := io.ReadAll(base64.NewDecoder(base64.StdEncoding, bytes.NewReader(body)))
if err != nil {
return nil, fmt.Errorf("ims: decode base64 SMS payload: %w", err)
}
return decoded, nil
case "quoted-printable":
decoded, err := io.ReadAll(quotedprintable.NewReader(bytes.NewReader(body)))
if err != nil {
return nil, fmt.Errorf("ims: decode quoted-printable SMS payload: %w", err)
}
return decoded, nil
default:
return nil, fmt.Errorf("ims: unsupported SMS Content-Transfer-Encoding %q", encoding)
}
}
func (session *Session) logInboundSMS(level slog.Level, message string, request *sipRequest, attributes ...any) {
logger := slog.Default()
if session != nil && session.provider != nil && session.provider.config.Logger != nil {
logger = session.provider.config.Logger
}
base := []any{"device_id", session.request.DeviceID}
if request != nil {
base = append(base,
"call_id", strings.TrimSpace(request.value("Call-ID")),
"content_type", strings.TrimSpace(request.value("Content-Type")),
"body_bytes", len(request.Body),
)
}
logger.Log(context.Background(), level, message, append(base, attributes...)...)
}
func (session *Session) sendLoggedDeliveryReport(request *sipRequest, report []byte, reportType string) {
if err := session.sendDeliveryReport(request, report); err != nil {
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS delivery report failed", request,
"stage", "delivery_report", "report_type", reportType, "error", err)
return
}
session.logInboundSMS(slog.LevelDebug, "IMS inbound SMS delivery report sent", request,
"stage", "delivery_report", "report_type", reportType)
}
func (session *Session) sendDeliveryReport(request *sipRequest, report []byte) error {
target := firstURI(request.value("P-Asserted-Identity"))
if target == "" {
target = firstURI(request.value("From"))
}
if target == "" {
return
return errors.New("ims: SMS MESSAGE omitted a delivery-report target")
}
_, _ = session.sendSIPMessage(
response, err := session.sendSIPMessage(
context.Background(),
target,
report,
strings.TrimSpace(request.value("Call-ID")),
)
if err != nil {
return err
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return fmt.Errorf("ims: SMS delivery report returned SIP %d", response.StatusCode)
}
return nil
}
func (session *Session) SendSMS(ctx context.Context, request vowifi.SMSSubmitRequest) (vowifi.SMSSubmitResult, error) {
@@ -441,14 +662,21 @@ func (session *Session) SendSMS(ctx context.Context, request vowifi.SMSSubmitReq
}
smsc := strings.TrimSpace(session.request.Identity.SMSC)
session.mu.Unlock()
smscSource := "sim"
if smsc == "" {
smscSource = "sim_reader"
reader, ok := session.provider.aka.(smsCenterReader)
var readErr error
if ok {
smsc, readErr = reader.ReadSMSCenter(ctx, session.request.DeviceID)
}
if strings.TrimSpace(smsc) == "" {
smsc = smsCenterForIdentity(session.provider.config, session.request.Identity)
smscSource = "plmn_fallback"
}
if strings.TrimSpace(smsc) == "" {
smsc = session.provider.config.SMSCenter
smscSource = "configured_fallback"
}
if strings.TrimSpace(smsc) == "" {
return vowifi.SMSSubmitResult{}, errors.Join(ErrSMSCUnavailable, readErr)
@@ -471,6 +699,9 @@ func (session *Session) SendSMS(ctx context.Context, request vowifi.SMSSubmitReq
SubmissionStatus: "pending",
PartResults: make([]vowifi.SMSSubmitPart, 0, len(parts)),
}
session.logOutboundSMS(slog.LevelInfo, "IMS outbound SMS submission started",
"stage", "prepare", "parts", len(parts), "smsc_source", smscSource,
"recipient_type", smsRecipientType(parts[0].To))
psi := "tel:" + normalizeE164(smsc)
for _, part := range parts {
reference := session.allocateRPReference()
@@ -501,19 +732,63 @@ func (session *Session) SendSMS(ctx context.Context, request vowifi.SMSSubmitReq
}
result.PartResults = append(result.PartResults, partResult)
if sendErr != nil {
session.logOutboundSMS(slog.LevelWarn, "IMS outbound SMS submission failed",
"stage", "sip_transaction", "part", part.Part,
"rp_reference", int(reference), "error", sendErr)
result.SubmissionStatus = "failed"
return result, sendErr
}
if !partResult.Accepted {
session.logOutboundSMS(slog.LevelWarn, "IMS outbound SMS was rejected",
"stage", "sip_response", "part", part.Part,
"rp_reference", int(reference), "sip_status", response.StatusCode)
result.SubmissionStatus = "rejected"
return result, fmt.Errorf("%w: SIP %d", ErrSMSRejected, response.StatusCode)
}
}
result.AllPartsAccepted = true
result.SubmissionStatus = "accepted_by_ims"
session.logOutboundSMS(slog.LevelInfo, "IMS outbound SMS submission accepted",
"stage", "sip_response", "parts", result.PartsAccepted)
return result, nil
}
func smsCenterForIdentity(config Config, identity vowifi.SIMIdentity) string {
plmn := strings.TrimSpace(identity.HomeMCC) + strings.TrimSpace(identity.HomeMNC)
if configured := strings.TrimSpace(config.SMSCenterByPLMN[plmn]); configured != "" {
return configured
}
return strings.TrimSpace(vowifi.ResolveCarrierProfile(identity).SMSCenter)
}
func smsRecipientType(recipient string) string {
recipient = strings.TrimSpace(recipient)
digits := strings.TrimPrefix(recipient, "+")
switch {
case strings.HasPrefix(recipient, "+"):
return "international"
case len(digits) <= 6:
return "short_code"
default:
return "national"
}
}
func (session *Session) logOutboundSMS(level slog.Level, message string, attributes ...any) {
logger := slog.Default()
if session != nil && session.provider != nil && session.provider.config.Logger != nil {
logger = session.provider.config.Logger
}
plmn := strings.TrimSpace(session.request.Identity.HomeMCC) + strings.TrimSpace(session.request.Identity.HomeMNC)
base := []any{
"device_id", session.request.DeviceID,
"home_plmn", plmn,
"transport", session.transport,
"security", session.effectiveSecurityMode(),
}
logger.Log(context.Background(), level, message, append(base, attributes...)...)
}
func (session *Session) allocateRPReference() byte {
session.mu.Lock()
defer session.mu.Unlock()
@@ -567,6 +842,8 @@ func (session *Session) sendSIPMessage(
fmt.Sprintf("CSeq: %d MESSAGE", cseq),
"P-Preferred-Identity: <"+session.identity.public+">",
"Accept-Contact: *;+g.3gpp.smsip",
"Request-Disposition: no-fork",
"Allow: MESSAGE",
)
if inReplyTo != "" {
lines = append(lines, "In-Reply-To: "+inReplyTo)
@@ -578,7 +855,23 @@ func (session *Session) sendSIPMessage(
"", "",
)
request := append([]byte(strings.Join(lines, "\r\n")), body...)
return session.exchangeRuntime(ctx, request, sipTransactionKey{callID: callID, cseq: cseq, method: "MESSAGE"})
session.logOutboundSMS(slog.LevelDebug, "IMS SIP MESSAGE transaction started",
"stage", "sip_send", "call_id", callID, "cseq", cseq,
"body_bytes", len(body), "service_routes", len(serviceRoutes))
response, exchangeErr := session.exchangeRuntime(
ctx,
request,
sipTransactionKey{callID: callID, cseq: cseq, method: "MESSAGE"},
)
if exchangeErr != nil {
session.logOutboundSMS(slog.LevelWarn, "IMS SIP MESSAGE transaction failed",
"stage", "sip_transaction", "call_id", callID, "cseq", cseq, "error", exchangeErr)
return response, exchangeErr
}
session.logOutboundSMS(slog.LevelDebug, "IMS SIP MESSAGE response received",
"stage", "sip_response", "call_id", callID, "cseq", cseq,
"sip_status", response.StatusCode)
return response, nil
}
func runtimeSecurityHeaders(active bool, verifyValue string) []string {
+159 -4
View File
@@ -1,11 +1,14 @@
package ims
import (
"bytes"
"context"
"encoding/base64"
"errors"
"fmt"
"mime/multipart"
"net"
"net/textproto"
"strings"
"testing"
"time"
@@ -100,6 +103,127 @@ func TestRuntimeSecurityHeaders(t *testing.T) {
}
}
func TestExtractSMSPayload(t *testing.T) {
rpdu := []byte{0x01, 0x2a, 0x00, 0x00, 0x03, 0x04, 0x00, 0x00}
tests := []struct {
name string
request *sipRequest
wantSource string
wantPayload []byte
}{
{
name: "direct binary",
request: &sipRequest{Headers: map[string][]string{
"content-type": {smsContentType + "; charset=binary"},
"content-transfer-encoding": {"binary"},
}, Body: rpdu},
wantSource: smsContentType,
wantPayload: rpdu,
},
{
name: "multipart base64",
request: multipartSMSRequest(t, rpdu),
wantSource: "multipart/mixed",
wantPayload: rpdu,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
payload, source, err := extractSMSPayload(test.request)
if err != nil {
t.Fatalf("extractSMSPayload() error = %v", err)
}
if source != test.wantSource || !bytes.Equal(payload, test.wantPayload) {
t.Fatalf("extractSMSPayload() = (%x, %q), want (%x, %q)",
payload, source, test.wantPayload, test.wantSource)
}
})
}
}
func TestSupportsSMSContentType(t *testing.T) {
for _, test := range []struct {
value string
want bool
}{
{smsContentType, true},
{"Application/Vnd.3gpp.Sms; charset=binary", true},
{`multipart/mixed; boundary="vodafone-boundary"`, true},
{"multipart/mixed", false},
{"text/plain", false},
} {
if got := supportsSMSContentType(test.value); got != test.want {
t.Errorf("supportsSMSContentType(%q) = %v, want %v", test.value, got, test.want)
}
}
}
func TestSMSCenterForIdentityUsesExactPLMN(t *testing.T) {
config := Config{SMSCenterByPLMN: map[string]string{
"23410": "+447802000332",
"234010": "+447802000332",
"23415": "+447785016005",
}}
for _, test := range []struct {
mnc string
want string
}{
{mnc: "10", want: "+447802000332"},
{mnc: "010", want: "+447802000332"},
{mnc: "15", want: "+447785016005"},
{mnc: "30", want: ""},
} {
identity := vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: test.mnc}
if got := smsCenterForIdentity(config, identity); got != test.want {
t.Errorf("smsCenterForIdentity(234/%s) = %q, want %q", test.mnc, got, test.want)
}
}
}
func TestSMSCenterForIdentityFallsBackToCarrierProfile(t *testing.T) {
for _, test := range []struct {
mnc string
want string
}{
{mnc: "10", want: "+447802000332"},
{mnc: "15", want: "+447785016005"},
{mnc: "30", want: ""},
} {
identity := vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: test.mnc}
if got := smsCenterForIdentity(Config{}, identity); got != test.want {
t.Errorf("profile SMSC for 234/%s = %q, want %q", test.mnc, got, test.want)
}
}
}
func multipartSMSRequest(t *testing.T, payload []byte) *sipRequest {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
if err := writer.SetBoundary("vodafone-boundary"); err != nil {
t.Fatal(err)
}
header := make(textproto.MIMEHeader)
header.Set("Content-Type", smsContentType)
header.Set("Content-Transfer-Encoding", "base64")
part, err := writer.CreatePart(header)
if err != nil {
t.Fatal(err)
}
if _, err = part.Write([]byte(base64.StdEncoding.EncodeToString(payload))); err != nil {
t.Fatal(err)
}
if err = writer.Close(); err != nil {
t.Fatal(err)
}
return &sipRequest{
Headers: map[string][]string{
"content-type": {`multipart/mixed; boundary="vodafone-boundary"`},
},
Body: body.Bytes(),
}
}
func TestSessionSendsSMSOverIMS(t *testing.T) {
listener, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")})
if err != nil {
@@ -202,6 +326,24 @@ func serveInboundSMS(listener *net.UDPConn, nonce string, readyForClose chan<- s
}
rpdu := []byte{0x01, 0x2a, 0x00, 0x00, byte(len(tpdu))}
rpdu = append(rpdu, tpdu...)
var messageBody bytes.Buffer
mimeWriter := multipart.NewWriter(&messageBody)
if err = mimeWriter.SetBoundary("vodafone-delivery"); err != nil {
return err
}
mimeHeader := make(textproto.MIMEHeader)
mimeHeader.Set("Content-Type", smsContentType)
mimeHeader.Set("Content-Transfer-Encoding", "binary")
mimePart, createErr := mimeWriter.CreatePart(mimeHeader)
if createErr != nil {
return createErr
}
if _, err = mimePart.Write(rpdu); err != nil {
return err
}
if err = mimeWriter.Close(); err != nil {
return err
}
request := []byte(strings.Join([]string{
"MESSAGE sip:[email protected] SIP/2.0",
"Via: SIP/2.0/UDP " + listener.LocalAddr().String() + ";branch=z9hG4bKdeliver",
@@ -210,10 +352,10 @@ func serveInboundSMS(listener *net.UDPConn, nonce string, readyForClose chan<- s
"P-Asserted-Identity: <sip:[email protected]>",
"Call-ID: network-deliver-1",
"CSeq: 1 MESSAGE",
"Content-Type: application/vnd.3gpp.sms",
fmt.Sprintf("Content-Length: %d", len(rpdu)), "", "",
`Content-Type: multipart/mixed; boundary="vodafone-delivery"`,
fmt.Sprintf("Content-Length: %d", messageBody.Len()), "", "",
}, "\r\n"))
request = append(request, rpdu...)
request = append(request, messageBody.Bytes()...)
if _, err = listener.WriteToUDP(request, remote); err != nil {
return err
}
@@ -292,12 +434,25 @@ func serveOutboundSMS(listener *net.UDPConn, nonce string, readyForClose chan<-
if err != nil {
return err
}
firstMessage := append([]byte(nil), packet[:count]...)
firstRemote := remote.String()
// Exercise the RFC SIP/UDP non-INVITE transaction retransmission path by
// deliberately dropping the first MESSAGE request.
count, remote, err = listener.ReadFromUDP(packet)
if err != nil {
return err
}
if remote.String() != firstRemote || !bytes.Equal(packet[:count], firstMessage) {
return errors.New("outbound MESSAGE retransmission changed transaction bytes or source")
}
message, err := parseSIPPacket(packet[:count])
if err != nil || message.Request == nil {
return fmt.Errorf("outbound MESSAGE parse: %v", err)
}
if message.Request.Method != "MESSAGE" || message.Request.URI != "tel:+447785016005" ||
strings.ToLower(message.Request.value("Content-Type")) != smsContentType {
strings.ToLower(message.Request.value("Content-Type")) != smsContentType ||
message.Request.value("Request-Disposition") != "no-fork" ||
message.Request.value("Allow") != "MESSAGE" {
return fmt.Errorf("unexpected outbound MESSAGE %#v", message.Request)
}
rpdu, err := parseRPDU(message.Request.Body)
+23
View File
@@ -8,6 +8,7 @@ import (
"vocat/internal/device"
"vocat/internal/modem"
"vocat/internal/store"
"vocat/internal/vowifi"
)
type ATDeviceController interface {
@@ -73,6 +74,28 @@ func (mapper ATMapper) ExecuteSensitiveAT(
return mapper.Devices.ExecuteSensitiveAT(ctx, physicalID, command)
}
// ReadSIMMetadata reuses the device manager's per-ICCID EF cache. VoWiFi
// identity discovery therefore gains Android-style SPN/GID MVNO selectors
// without issuing duplicate APDUs on every reconnect.
func (mapper ATMapper) ReadSIMMetadata(ctx context.Context, configuredID string) (vowifi.SIMMetadata, error) {
physicalID, err := mapper.resolve(ctx, configuredID)
if err != nil {
return vowifi.SIMMetadata{}, err
}
entry, err := mapper.Devices.Get(physicalID)
if err != nil {
return vowifi.SIMMetadata{}, err
}
if entry.Snapshot == nil {
return vowifi.SIMMetadata{}, nil
}
return vowifi.SIMMetadata{
SPN: strings.TrimSpace(entry.Snapshot.SPN),
GID1: strings.TrimSpace(entry.Snapshot.GID1),
GID2: strings.TrimSpace(entry.Snapshot.GID2),
}, nil
}
func (mapper ATMapper) resolve(
ctx context.Context,
configuredID string,
+39 -1
View File
@@ -32,6 +32,7 @@ func (resolver ProxyResolver) Resolve(
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
}
var upstreamID string
matchedCountryRule := false
if iccid != "" {
binding, err := resolver.Store.DeviceProxyBinding(ctx, iccid)
if err == nil {
@@ -43,7 +44,10 @@ func (resolver ProxyResolver) Resolve(
if upstreamID == "" {
country, found := device.CountryForMCC(strings.TrimSpace(request.HomeMCC))
if !found {
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
country = strings.ToUpper(strings.TrimSpace(request.CountryCode))
if len(country) != 2 {
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
}
}
rule, ruleErr := resolver.Store.CountryRule(ctx, country)
if errors.Is(ruleErr, store.ErrNotFound) || (ruleErr == nil && !rule.Enabled) {
@@ -53,6 +57,7 @@ func (resolver ProxyResolver) Resolve(
return vowifi.ProxyRoute{}, fmt.Errorf("resolve proxy country rule for MCC %s: %w", request.HomeMCC, ruleErr)
}
upstreamID = rule.UpstreamProxyID
matchedCountryRule = true
}
upstream, err := resolver.Store.UpstreamProxy(ctx, upstreamID)
if err != nil {
@@ -64,12 +69,43 @@ func (resolver ProxyResolver) Resolve(
)
}
if !upstream.Enabled {
if matchedCountryRule {
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
}
return vowifi.ProxyRoute{}, fmt.Errorf(
"upstream proxy %q for device %s is disabled",
upstream.ID,
deviceID,
)
}
if matchedCountryRule && iccid != "" {
created, bindErr := resolver.Store.InsertDeviceProxyBindingIfAbsent(ctx, store.DeviceProxyBinding{
DeviceID: deviceID,
ICCID: iccid,
ProfileName: iccid,
UpstreamProxyID: upstream.ID,
})
if bindErr != nil {
return vowifi.ProxyRoute{}, fmt.Errorf("materialize MCC proxy route for ICCID %s: %w", iccid, bindErr)
}
if !created {
// Another request or an administrator may have created an explicit
// binding after our first lookup. The persisted ICCID route wins.
binding, bindingErr := resolver.Store.DeviceProxyBinding(ctx, iccid)
if bindingErr != nil {
return vowifi.ProxyRoute{}, fmt.Errorf("reload proxy binding for ICCID %s: %w", iccid, bindingErr)
}
if binding.UpstreamProxyID != upstream.ID {
upstream, err = resolver.Store.UpstreamProxy(ctx, binding.UpstreamProxyID)
if err != nil {
return vowifi.ProxyRoute{}, fmt.Errorf("load materialized upstream proxy %q for device %s: %w", binding.UpstreamProxyID, deviceID, err)
}
if !upstream.Enabled {
return vowifi.ProxyRoute{}, fmt.Errorf("upstream proxy %q for device %s is disabled", upstream.ID, deviceID)
}
}
}
}
return vowifi.ProxyRoute{
Mode: vowifi.ProxyModeSOCKS5,
ID: upstream.ID,
@@ -198,6 +234,8 @@ func (projector StateProjector) Save(
"pure_airplane_policy": state.PureAirplanePolicy,
"home_mcc": state.HomeMCC,
"home_mnc": state.HomeMNC,
"carrier_profile": state.CarrierProfile,
"carrier_profile_from": state.CarrierProfileFrom,
"warnings": state.Warnings,
"cleanup_errors": state.CleanupErrors,
"attempt": state.Attempt,
+151 -7
View File
@@ -103,6 +103,141 @@ func TestProxyResolverUsesCountryRuleWithoutICCIDBinding(t *testing.T) {
}
}
func TestProxyResolverCountryRuleWithDisabledProxyFallsBackDirect(t *testing.T) {
database := testStore(t)
ctx := context.Background()
if err := database.UpsertUpstreamProxy(ctx, store.UpstreamProxy{
ID: "disabled", Name: "Disabled", Addr: "127.0.0.1:1080", Enabled: false,
}); err != nil {
t.Fatal(err)
}
if err := database.UpsertCountryRule(ctx, store.CountryRule{
CountryCode: "GB", CountryName: "United Kingdom", UpstreamProxyID: "disabled", Enabled: true,
}); err != nil {
t.Fatal(err)
}
route, err := (ProxyResolver{Store: database}).Resolve(ctx, vowifi.ProxyRequest{DeviceID: "ec20", HomeMCC: "234"})
if err != nil {
t.Fatal(err)
}
if route.Mode != vowifi.ProxyModeDirect {
t.Fatalf("route = %#v, want direct for a disabled country default", route)
}
}
func TestProxyResolverICCIDBindingWithDisabledProxyFailsClosed(t *testing.T) {
database := testStore(t)
ctx := context.Background()
if err := database.UpsertDevice(ctx, store.Device{ID: "ec20", Name: "EC20"}); err != nil {
t.Fatal(err)
}
if err := database.UpsertUpstreamProxy(ctx, store.UpstreamProxy{
ID: "disabled", Name: "Disabled", Addr: "127.0.0.1:1080", Enabled: false,
}); err != nil {
t.Fatal(err)
}
if err := database.UpsertDeviceProxyBinding(ctx, store.DeviceProxyBinding{
DeviceID: "ec20", ICCID: "89441000400128014257", ProfileName: "Manual", UpstreamProxyID: "disabled",
}); err != nil {
t.Fatal(err)
}
_, err := (ProxyResolver{Store: database}).Resolve(ctx, vowifi.ProxyRequest{
DeviceID: "ec20", ICCID: "89441000400128014257", HomeMCC: "234",
})
if err == nil {
t.Fatal("disabled explicit ICCID binding unexpectedly fell back to another route")
}
}
func TestProxyResolverMaterializesCountryRuleAsICCIDBinding(t *testing.T) {
database := testStore(t)
ctx := context.Background()
if err := database.UpsertDevice(ctx, store.Device{ID: "ec20", Name: "EC20"}); err != nil {
t.Fatal(err)
}
for _, proxy := range []store.UpstreamProxy{
{ID: "first", Name: "First", Addr: "127.0.0.1:1080", Enabled: true},
{ID: "later", Name: "Later", Addr: "127.0.0.1:1081", Enabled: true},
} {
if err := database.UpsertUpstreamProxy(ctx, proxy); err != nil {
t.Fatal(err)
}
}
if err := database.UpsertCountryRule(ctx, store.CountryRule{
CountryCode: "GB", CountryName: "United Kingdom", UpstreamProxyID: "first", Enabled: true,
}); err != nil {
t.Fatal(err)
}
request := vowifi.ProxyRequest{
DeviceID: "ec20", ICCID: "89441000400128014257", HomeMCC: "234",
}
resolver := ProxyResolver{Store: database}
route, err := resolver.Resolve(ctx, request)
if err != nil {
t.Fatal(err)
}
if route.ID != "first" {
t.Fatalf("first route = %#v, want MCC default", route)
}
binding, err := database.DeviceProxyBinding(ctx, request.ICCID)
if err != nil {
t.Fatal(err)
}
if binding.DeviceID != request.DeviceID || binding.UpstreamProxyID != "first" {
t.Fatalf("materialized binding = %#v", binding)
}
if err := database.UpsertCountryRule(ctx, store.CountryRule{
CountryCode: "GB", CountryName: "United Kingdom", UpstreamProxyID: "later", Enabled: true,
}); err != nil {
t.Fatal(err)
}
route, err = resolver.Resolve(ctx, request)
if err != nil {
t.Fatal(err)
}
if route.ID != "first" {
t.Fatalf("route after country rule edit = %#v, want durable ICCID binding", route)
}
}
func TestInsertDeviceProxyBindingIfAbsentDoesNotReplaceExplicitBinding(t *testing.T) {
database := testStore(t)
ctx := context.Background()
if err := database.UpsertDevice(ctx, store.Device{ID: "ec20", Name: "EC20"}); err != nil {
t.Fatal(err)
}
for _, proxyID := range []string{"explicit", "default"} {
if err := database.UpsertUpstreamProxy(ctx, store.UpstreamProxy{
ID: proxyID, Name: proxyID, Addr: "127.0.0.1:1080", Enabled: true,
}); err != nil {
t.Fatal(err)
}
}
iccid := "89441000400128014257"
if err := database.UpsertDeviceProxyBinding(ctx, store.DeviceProxyBinding{
DeviceID: "ec20", ICCID: iccid, ProfileName: "Manual", UpstreamProxyID: "explicit",
}); err != nil {
t.Fatal(err)
}
created, err := database.InsertDeviceProxyBindingIfAbsent(ctx, store.DeviceProxyBinding{
DeviceID: "ec20", ICCID: iccid, ProfileName: "Automatic", UpstreamProxyID: "default",
})
if err != nil {
t.Fatal(err)
}
if created {
t.Fatal("default binding unexpectedly replaced an explicit binding")
}
binding, err := database.DeviceProxyBinding(ctx, iccid)
if err != nil {
t.Fatal(err)
}
if binding.UpstreamProxyID != "explicit" || binding.ProfileName != "Manual" {
t.Fatalf("binding = %#v, want explicit binding unchanged", binding)
}
}
func TestProxyResolverPrefersICCIDBindingOverCountryRule(t *testing.T) {
database := testStore(t)
for _, proxy := range []store.UpstreamProxy{
@@ -216,13 +351,15 @@ func TestStateProjectorPreservesConcreteDataplaneMode(t *testing.T) {
}
projector := StateProjector{Store: database}
if err := projector.Save(context.Background(), vowifi.State{
DeviceID: "ec25",
Phase: vowifi.PhaseIMSReady,
TunnelReady: true,
IMSReady: true,
TunnelName: "vocat-swu-ec25",
DataplaneMode: "userspace",
UpdatedAt: time.Now().UTC(),
DeviceID: "ec25",
Phase: vowifi.PhaseIMSReady,
TunnelReady: true,
IMSReady: true,
TunnelName: "vocat-swu-ec25",
DataplaneMode: "userspace",
CarrierProfile: "vodafone-uk",
CarrierProfileFrom: "hplmn",
UpdatedAt: time.Now().UTC(),
}); err != nil {
t.Fatal(err)
}
@@ -240,6 +377,13 @@ func TestStateProjectorPreservesConcreteDataplaneMode(t *testing.T) {
if tunnel["dataplane_mode"] != "userspace" {
t.Fatalf("tunnel dataplane mode = %#v", tunnel["dataplane_mode"])
}
var extra map[string]any
if err := json.Unmarshal(runtime.Extra, &extra); err != nil {
t.Fatal(err)
}
if extra["carrier_profile"] != "vodafone-uk" || extra["carrier_profile_from"] != "hplmn" {
t.Fatalf("carrier profile projection = %#v", extra)
}
}
func TestStateProjectorDoesNotAttachOldSessionNumberToNewLiveSIM(t *testing.T) {
+177
View File
@@ -0,0 +1,177 @@
package vowifi
import (
"context"
"errors"
"fmt"
"strings"
"sync"
)
// NativeQMIController is implemented by device.Manager. It exposes only the
// QMI UIM/DMS/NAS primitives needed by VoWiFi and keeps transport ownership in
// the device layer.
type NativeQMIController interface {
ReadNativeQMIIdentity(context.Context, string) (iccid, imsi, imei, mcc, mnc string, err error)
ProbeNativeQMIApplication(context.Context, string, string) ([]byte, string, error)
AuthenticateNativeQMI(context.Context, string, []byte, []byte) ([]byte, error)
NativeQMIRadioSnapshot(context.Context, string) (mode int, psAttached bool, err error)
StopNativeQMICellularData(context.Context, string) error
SetNativeQMIRadioOff(context.Context, string, bool) error
}
type NativeQMIAdapter struct {
controller NativeQMIController
pureAirplanePolicy func(string) bool
mu sync.Mutex
bindings map[string]nativeQMIBinding
}
type nativeQMIBinding struct {
deviceID string
iccid string
imsi string
aid []byte
application string
}
var _ SIMIdentityReader = (*NativeQMIAdapter)(nil)
var _ PreferredAKAProvider = (*NativeQMIAdapter)(nil)
var _ RadioController = (*NativeQMIAdapter)(nil)
func NewNativeQMIAdapter(controller NativeQMIController, purePolicy func(string) bool) (*NativeQMIAdapter, error) {
if controller == nil {
return nil, errors.New("vocat: native QMI controller is required")
}
return &NativeQMIAdapter{controller: controller, pureAirplanePolicy: purePolicy, bindings: make(map[string]nativeQMIBinding)}, nil
}
func (adapter *NativeQMIAdapter) ReadIdentity(ctx context.Context, deviceID string) (SIMIdentity, error) {
deviceID = strings.TrimSpace(deviceID)
if deviceID == "" {
return SIMIdentity{}, errors.New("vocat: native QMI device ID is required")
}
iccid, imsi, imei, mcc, mnc, err := adapter.controller.ReadNativeQMIIdentity(ctx, deviceID)
if err != nil {
return SIMIdentity{}, err
}
identity := applyAssignedCarrierRoute(SIMIdentity{ICCID: strings.TrimSpace(iccid), IMSI: strings.TrimSpace(imsi), IMEI: strings.TrimSpace(imei), HomeMCC: strings.TrimSpace(mcc), HomeMNC: strings.TrimSpace(mnc)})
if reader, ok := adapter.controller.(SIMMetadataReader); ok {
if metadata, metadataErr := reader.ReadSIMMetadata(ctx, deviceID); metadataErr == nil {
identity.SPN = strings.TrimSpace(metadata.SPN)
identity.GID1 = strings.TrimSpace(metadata.GID1)
identity.GID2 = strings.TrimSpace(metadata.GID2)
identity = applyAssignedCarrierRoute(identity)
}
}
if err := identity.validate(); err != nil {
return SIMIdentity{}, err
}
adapter.mu.Lock()
adapter.bindings[identity.ICCID] = nativeQMIBinding{deviceID: deviceID, iccid: identity.ICCID, imsi: identity.IMSI}
adapter.mu.Unlock()
return identity, nil
}
func (adapter *NativeQMIAdapter) binding(identity SIMIdentity) (nativeQMIBinding, error) {
adapter.mu.Lock()
binding, ok := adapter.bindings[strings.TrimSpace(identity.ICCID)]
adapter.mu.Unlock()
if !ok {
return nativeQMIBinding{}, errors.New("vocat: native QMI SIM identity is not bound to a device")
}
if binding.imsi != strings.TrimSpace(identity.IMSI) {
return nativeQMIBinding{}, ErrEC20IdentityChanged
}
return binding, nil
}
func (adapter *NativeQMIAdapter) verify(ctx context.Context, binding nativeQMIBinding) error {
iccid, imsi, _, _, _, err := adapter.controller.ReadNativeQMIIdentity(ctx, binding.deviceID)
if err != nil {
return err
}
if strings.TrimSpace(iccid) != binding.iccid || strings.TrimSpace(imsi) != binding.imsi {
return ErrEC20IdentityChanged
}
return nil
}
func (adapter *NativeQMIAdapter) CheckReady(ctx context.Context, identity SIMIdentity) (AKAEvidence, error) {
binding, err := adapter.binding(identity)
if err != nil {
return AKAEvidence{}, err
}
if err := adapter.verify(ctx, binding); err != nil {
return AKAEvidence{}, err
}
aid, application, err := adapter.controller.ProbeNativeQMIApplication(ctx, binding.deviceID, "")
if err != nil {
return AKAEvidence{}, fmt.Errorf("%w: %v", ErrEC20ApplicationAbsent, err)
}
binding.aid, binding.application = append([]byte(nil), aid...), application
adapter.mu.Lock()
adapter.bindings[binding.iccid] = binding
adapter.mu.Unlock()
return AKAEvidence{Ready: true, Application: application}, nil
}
func (adapter *NativeQMIAdapter) Authenticate(ctx context.Context, identity SIMIdentity, challenge AKAChallenge) (AKAResult, error) {
return adapter.AuthenticateWithPreference(ctx, identity, challenge, "")
}
func (adapter *NativeQMIAdapter) AuthenticateWithPreference(ctx context.Context, identity SIMIdentity, challenge AKAChallenge, preference string) (AKAResult, error) {
binding, err := adapter.binding(identity)
if err != nil {
return AKAResult{}, err
}
strictISIM := strings.EqualFold(strings.TrimSpace(preference), "isim_strict")
if len(binding.aid) == 0 || (strictISIM && binding.application != "ISIM") {
aid, application, probeErr := adapter.controller.ProbeNativeQMIApplication(ctx, binding.deviceID, preference)
if probeErr != nil {
return AKAResult{}, fmt.Errorf("%w: %v", ErrEC20ApplicationAbsent, probeErr)
}
binding.aid, binding.application = append([]byte(nil), aid...), application
adapter.mu.Lock()
adapter.bindings[binding.iccid] = binding
adapter.mu.Unlock()
}
if strictISIM && binding.application != "ISIM" {
return AKAResult{}, ErrEC20ApplicationAbsent
}
if err := adapter.verify(ctx, binding); err != nil {
return AKAResult{}, err
}
raw, err := adapter.controller.AuthenticateNativeQMI(ctx, binding.deviceID, binding.aid, buildUSIMAuthenticateAPDU(challenge))
if err != nil {
return AKAResult{}, ErrEC20AKACommand
}
return parseUSIMAuthenticateResponse(raw)
}
func (adapter *NativeQMIAdapter) Snapshot(ctx context.Context, deviceID string) (RadioSnapshot, error) {
mode, attached, err := adapter.controller.NativeQMIRadioSnapshot(ctx, deviceID)
if err != nil {
return RadioSnapshot{}, err
}
pure := false
if adapter.pureAirplanePolicy != nil {
pure = adapter.pureAirplanePolicy(deviceID)
}
return RadioSnapshot{CellularDataEnabled: attached, OperatingMode: mode, PureAirplanePolicy: pure}, nil
}
func (adapter *NativeQMIAdapter) StopCellularData(ctx context.Context, deviceID string) error {
return adapter.controller.StopNativeQMICellularData(ctx, deviceID)
}
func (adapter *NativeQMIAdapter) EnterVoWiFiRFOff(ctx context.Context, deviceID string) error {
return adapter.controller.SetNativeQMIRadioOff(ctx, deviceID, true)
}
func (adapter *NativeQMIAdapter) Restore(ctx context.Context, deviceID string, snapshot RadioSnapshot) error {
// A user VoWiFi policy is fail-closed. Otherwise restore whether the modem
// was online, never a packet context that was detached for VoWiFi.
off := snapshot.PureAirplanePolicy || snapshot.OperatingMode != 1
return adapter.controller.SetNativeQMIRadioOff(ctx, deviceID, off)
}
+9 -2
View File
@@ -241,6 +241,7 @@ func (orchestrator *Orchestrator) Enable(ctx context.Context) (State, error) {
orchestrator.addWarning("SIM SMS service-centre address is unavailable; IMS receive remains available: " + smscErr.Error())
}
}
carrierProfile := ResolveCarrierProfile(identity)
orchestrator.mutate(func(state *State) {
state.Phase = PhaseSIMReady
state.ICCID = strings.TrimSpace(identity.ICCID)
@@ -248,6 +249,8 @@ func (orchestrator *Orchestrator) Enable(ctx context.Context) (State, error) {
state.SIMReady = true
state.HomeMCC = strings.TrimSpace(identity.HomeMCC)
state.HomeMNC = strings.TrimSpace(identity.HomeMNC)
state.CarrierProfile = carrierProfile.ID
state.CarrierProfileFrom = carrierProfile.MatchSource
state.LastReason = "sim_and_aka_ready"
})
@@ -645,8 +648,12 @@ func DeriveEPDG(identity SIMIdentity) (string, error) {
}
return strings.ToLower(configured), nil
}
if IsATT310280(identity) {
return att310280EPDG, nil
profile := ResolveCarrierProfile(identity)
if profile.EPDG != "" {
return profile.EPDG, nil
}
if profile.RouteMCC != "" {
return standardEPDGHostname(profile.RouteMCC, profile.RouteMNC), nil
}
if err := identity.validate(); err != nil {
return "", err
+2 -2
View File
@@ -53,7 +53,7 @@ func (adapter *PCSCAdapter) ReadIdentity(ctx context.Context, deviceID string) (
mncLength := identity.MNCLength
if mncLength != 2 && mncLength != 3 {
if mcc, mnc, ok := assignedHomePLMN(identity.IMSI); ok {
return applyAssignedCarrierRoute(SIMIdentity{ICCID: identity.ICCID, IMSI: identity.IMSI, HomeMCC: mcc, HomeMNC: mnc, SMSC: identity.SMSC}), nil
return applyAssignedCarrierRoute(SIMIdentity{ICCID: identity.ICCID, IMSI: identity.IMSI, HomeMCC: mcc, HomeMNC: mnc, SMSC: identity.SMSC, SPN: identity.SPN}), nil
}
return SIMIdentity{}, ErrEC20MNCUnavailable
}
@@ -63,7 +63,7 @@ func (adapter *PCSCAdapter) ReadIdentity(ctx context.Context, deviceID string) (
return applyAssignedCarrierRoute(SIMIdentity{
ICCID: identity.ICCID, IMSI: identity.IMSI,
HomeMCC: identity.IMSI[:3], HomeMNC: identity.IMSI[3 : 3+mncLength],
SMSC: identity.SMSC,
SMSC: identity.SMSC, SPN: identity.SPN,
}), nil
}
+36
View File
@@ -56,6 +56,7 @@ type Manager struct {
type entry struct {
orchestrator *vowifi.Orchestrator
maintenance bool
busy bool
reconnectPending bool
disablePending bool
@@ -66,6 +67,36 @@ type entry struct {
stopWatch func()
}
// BeginMaintenance temporarily suppresses background enable requests while a
// caller performs an exclusive SIM operation such as switching eSIM profiles.
// Disable requests remain allowed so the current runtime can release QMI/UIM.
func (manager *Manager) BeginMaintenance(deviceID string) error {
if err := manager.Ensure(manager.ctx, deviceID); err != nil {
return err
}
manager.mu.Lock()
defer manager.mu.Unlock()
if manager.closed {
return ErrClosed
}
item := manager.entries[deviceID]
if item == nil {
return ErrNotRegistered
}
item.maintenance = true
return nil
}
// EndMaintenance re-enables ordinary desired-state reconciliation. The caller
// then applies the newly active profile's persisted policy.
func (manager *Manager) EndMaintenance(deviceID string) {
manager.mu.Lock()
if item := manager.entries[deviceID]; item != nil {
item.maintenance = false
}
manager.mu.Unlock()
}
func New(options Options) *Manager {
if options.Logger == nil {
options.Logger = slog.Default()
@@ -210,6 +241,11 @@ func (manager *Manager) RequestEnabled(deviceID string, enabled bool) (vowifi.St
}
manager.mu.Lock()
item := manager.entries[deviceID]
if item.maintenance && enabled {
state := item.orchestrator.State()
manager.mu.Unlock()
return state, nil
}
item.desiredEnabled = enabled
if item.busy {
manager.logger.Info(
+21
View File
@@ -96,6 +96,8 @@ type State struct {
PureAirplanePolicy bool `json:"pure_airplane_policy"`
HomeMCC string `json:"home_mcc,omitempty"`
HomeMNC string `json:"home_mnc,omitempty"`
CarrierProfile string `json:"carrier_profile,omitempty"`
CarrierProfileFrom string `json:"carrier_profile_from,omitempty"`
EPDG string `json:"epdg,omitempty"`
ProxyMode ProxyMode `json:"proxy_mode,omitempty"`
ProxyID string `json:"proxy_id,omitempty"`
@@ -137,6 +139,9 @@ type SIMIdentity struct {
HomeMCC string
HomeMNC string
HomeCountryCode string
SPN string
GID1 string
GID2 string
EPDG string
// SMSC is the TS-Service-Centre address used to build SMS-over-IMS
// RP-DATA. It is optional during identity discovery, but IMS submission
@@ -304,6 +309,22 @@ type SIMIdentityReader interface {
ReadIdentity(context.Context, string) (SIMIdentity, error)
}
// SIMMetadata contains optional, non-secret carrier selectors stored by the
// UICC. They improve MVNO matching but are never required for AKA or exposed in
// the public runtime state.
type SIMMetadata struct {
SPN string
GID1 string
GID2 string
}
// SIMMetadataReader is an optional companion implemented by device mappers
// that already cache EF_SPN and EF_GID1/2. Identity readers degrade to PLMN,
// IMSI and ICCID matching when it is unavailable.
type SIMMetadataReader interface {
ReadSIMMetadata(context.Context, string) (SIMMetadata, error)
}
// SMSCenterReader optionally supplies the SIM-configured service-centre
// address needed for mobile-originated SMS over IMS.
type SMSCenterReader interface {
+13 -1
View File
@@ -298,6 +298,18 @@ detect_arch() {
# --- Download + verify -------------------------------------------------------
VOCAT_TMP=""
# curl transfer options for the binary download. -f makes curl fail on HTTP
# errors and -L follows the release-asset redirect. On an interactive terminal
# we show a single-line progress bar so a multi-megabyte download gives visible
# feedback; otherwise (piped, cron, systemd) we stay quiet but still surface
# errors via -S.
if [ -t 2 ]; then
CURL_DL_OPTS=(-fSL --progress-bar)
else
CURL_DL_OPTS=(-fsSL)
fi
download_and_verify() {
VOCAT_TMP=$(mktemp -d)
trap 'rm -rf "$VOCAT_TMP"' EXIT
@@ -307,7 +319,7 @@ download_and_verify() {
asset="vocat-linux-${ARCH_FALLBACK}"
fi
msg "下载 $asset ..." "Downloading $asset ..."
curl -fsSL -o "${VOCAT_TMP}/vocat" "${base}/${asset}" || die "下载二进制失败。" "Failed to download the binary."
curl "${CURL_DL_OPTS[@]}" -o "${VOCAT_TMP}/vocat" "${base}/${asset}" || die "下载二进制失败。" "Failed to download the binary."
curl -fsSL -o "${VOCAT_TMP}/SHA256SUMS" "${base}/SHA256SUMS" || die "下载 SHA256SUMS 失败。" "Failed to download SHA256SUMS."
local expected actual
+16
View File
@@ -0,0 +1,16 @@
package web
import (
"io/fs"
"testing"
)
func TestEmbeddedDistributionContainsIndex(t *testing.T) {
index, err := fs.ReadFile(Dist, "index.html")
if err != nil {
t.Fatalf("read embedded index.html: %v", err)
}
if len(index) == 0 {
t.Fatal("embedded index.html is empty")
}
}
@@ -14,11 +14,12 @@ export interface CardPolicyPanelProps {
iccid?: string;
policy: CardPolicy | null;
deviceOnline: boolean;
onPolicyChanged: () => void | Promise<void>;
onPolicyChanged: () => void | Promise<void>;
wifiCallingOnly?: boolean;
vowifiUnsupported?: boolean;
}
export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolicyChanged, wifiCallingOnly = false }: CardPolicyPanelProps) {
export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolicyChanged, wifiCallingOnly = false, vowifiUnsupported = false }: CardPolicyPanelProps) {
const { t } = useI18n();
const operable = deviceOnline && !!iccid;
const currentPolicy = policy?.iccid === iccid ? policy : null;
@@ -120,7 +121,7 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
</div>
</div>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<PolicySwitchCard
{!vowifiUnsupported ? <PolicySwitchCard
title="VoWiFi"
subtitle={t("启用时强制关闭蜂窝射频;关闭 VoWiFi 后仍保持飞行模式")}
tone="orange"
@@ -129,7 +130,7 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
pending={toggles.vowifiPending}
failed={toggles.vowifiFailed}
onToggle={toggles.onVoWiFiToggle}
/>
/> : null}
{!wifiCallingOnly ? <PolicySwitchCard
title={t("飞行模式")}
subtitle={t("只有手动关闭此开关才允许设备连接基站")}
@@ -16,6 +16,7 @@ export interface DeviceDetailHeaderProps {
onRebootModem: () => void;
onOpenSms: () => void;
wifiCallingOnly?: boolean;
modemControlOnly?: boolean;
}
export function DeviceDetailHeader(props: DeviceDetailHeaderProps) {
@@ -59,12 +60,12 @@ export function DeviceDetailHeader(props: DeviceDetailHeaderProps) {
/>
</div>
) : null}
{!props.wifiCallingOnly ? <Button loading={props.rebooting} onClick={props.onRebootModem} className="ui-glass-border !border-0 hover:!text-red-600" icon={<PowerRegular />}>
{!props.wifiCallingOnly && !props.modemControlOnly ? <Button loading={props.rebooting} onClick={props.onRebootModem} className="ui-glass-border !border-0 hover:!text-red-600" icon={<PowerRegular />}>
{t("重启模组")}
</Button> : null}
<Button onClick={props.onOpenSms} className="ui-glass-border !border-0" icon={<ChatRegular />}>
{!props.modemControlOnly ? <Button onClick={props.onOpenSms} className="ui-glass-border !border-0" icon={<ChatRegular />}>
{t("短信")}
</Button>
</Button> : null}
</div>
</div>
</div>
@@ -98,6 +98,8 @@ export function OverviewVowifiCard({ device }: { device: DeviceDetail }) {
</div>
) : null}
<FieldRow label={t("数据平面")} value={rt?.dataplaneMode || "--"} monospace />
<FieldRow label={t("运营商配置")} value={rt?.carrierProfile || "standard-3gpp"} monospace copyable />
<FieldRow label={t("匹配依据")} value={rt?.carrierProfileFrom || "standard"} monospace />
<FieldRow label={t("最后原因")} value={rt?.lastReason || "--"} />
<FieldRow label={t("错误分类")} value={rt?.lastErrorClass || "--"} monospace copyable />
{rt?.lastError ? <FieldRow label={t("错误详情")} value={rt.lastError} monospace copyable /> : null}
@@ -0,0 +1,126 @@
import { SearchRegular } from "@fluentui/react-icons";
import { useEffect, useMemo, useState } from "react";
import type { Country, CountryRule, UpstreamProxy } from "../../types";
import { Button, EmptyState, Input, Modal, Select } from "../ui";
import { useI18n } from "../../lib/i18n";
export interface CountryRulesDialogProps {
open: boolean;
proxies: UpstreamProxy[];
countries: Country[];
rules: CountryRule[];
busy: boolean;
onSave: (assignments: Record<string, string>) => void;
onClose: () => void;
}
export function CountryRulesDialog(props: CountryRulesDialogProps) {
const { t, lang } = useI18n();
const { open, proxies, countries, rules, busy, onSave, onClose } = props;
const [query, setQuery] = useState("");
const [assignments, setAssignments] = useState<Record<string, string>>({});
const regionNames = useMemo(() => {
try {
return new Intl.DisplayNames([lang === "zh" ? "zh-CN" : "en"], { type: "region" });
} catch {
return null;
}
}, [lang]);
const countryLabel = (country: Country) => regionNames?.of(country.countryCode) || country.countryName || country.countryCode;
const proxyOptions = useMemo(() => [
{ value: "", label: t("直连") },
...proxies.map((proxy) => ({
value: proxy.id,
label: proxy.enabled ? (proxy.name || proxy.id) : `${proxy.name || proxy.id}${t("已禁用")}`,
disabled: !proxy.enabled,
})),
], [proxies, t, lang]);
useEffect(() => {
if (!open) {
setQuery("");
setAssignments({});
return;
}
setAssignments(Object.fromEntries(rules.filter((rule) => rule.enabled).map((rule) => [rule.countryCode, rule.upstreamProxyId])));
// Sample rules only when opening. Polling must not discard in-progress edits.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const filtered = useMemo(() => {
const needle = query.trim().toLocaleLowerCase();
return [...countries]
.sort((a, b) => countryLabel(a).localeCompare(countryLabel(b), lang === "zh" ? "zh-CN" : "en"))
.filter((country) => {
if (!needle) return true;
return [country.countryCode, country.countryName, countryLabel(country), ...country.mccs]
.some((value) => String(value || "").toLocaleLowerCase().includes(needle));
});
}, [countries, query, lang, regionNames]);
const configuredCount = Object.values(assignments).filter(Boolean).length;
return (
<Modal
open={open}
onClose={onClose}
title={t("MCC 国家规则")}
width="max-w-5xl"
footer={(
<>
<Button onClick={onClose} disabled={busy}>{t("取消")}</Button>
<Button variant="primary" loading={busy} onClick={() => onSave(assignments)}>{t("保存规则")}</Button>
</>
)}
>
<div className="space-y-4 pb-1">
<div className="rounded-lg border border-sky-200/70 bg-sky-50 px-3 py-2 text-xs leading-5 text-sky-800 dark:border-sky-800/50 dark:bg-sky-900/20 dark:text-sky-200">
{t("为每个国家的 MCC 选择代理。未配置时直连;已有 ICCID 绑定始终优先,首次命中国家规则后会生成独立的 ICCID 绑定。")}
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="text-xs text-gray-500">{configuredCount} {t("个国家规则")}</div>
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t("搜索国家、地区代码或 MCC")}
prefix={<SearchRegular />}
className="w-full sm:w-72"
/>
</div>
<div className="overflow-hidden rounded-xl border border-gray-100 dark:border-white/10">
<div className="max-h-[55vh] overflow-auto">
<table className="w-full min-w-[680px] text-left text-sm">
<thead className="sticky top-0 z-10 bg-gray-50 text-xs uppercase tracking-wide text-gray-500 dark:bg-[#202027]">
<tr>
<th className="px-4 py-3">{t("国家 / 地区")}</th>
<th className="px-4 py-3">MCC</th>
<th className="w-72 px-4 py-3">{t("规则")}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-white/10">
{filtered.map((country) => (
<tr key={country.countryCode} className="hover:bg-sky-50/40 dark:hover:bg-sky-500/[0.04]">
<td className="px-4 py-3">
<span className="font-medium">{countryLabel(country)}</span>
<span className="ml-2 font-mono text-xs text-gray-400">{country.countryCode}</span>
</td>
<td className="px-4 py-3 font-mono text-xs text-gray-600 dark:text-gray-300">{country.mccs.join(", ")}</td>
<td className="px-4 py-2">
<Select
value={assignments[country.countryCode] || ""}
options={proxyOptions}
disabled={busy}
onChange={(value) => setAssignments((current) => ({ ...current, [country.countryCode]: value }))}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
{filtered.length === 0 ? <EmptyState title={t("没有匹配的国家或 MCC")} /> : null}
</div>
</div>
</Modal>
);
}
+23 -12
View File
@@ -1,4 +1,4 @@
import { DeleteRegular, DesktopRegular, EditRegular, GlobeRegular } from "@fluentui/react-icons";
import { DeleteRegular, DesktopRegular, EditRegular, GlobeRegular, PauseRegular, PlayRegular } from "@fluentui/react-icons";
import type { UpstreamProxy } from "../../types";
import { Button, Tag } from "../ui";
import type { LoadError, UpstreamRow } from "./shared";
@@ -12,9 +12,11 @@ export interface UpstreamSectionProps {
onEdit: (proxy: UpstreamProxy) => void;
onDelete: (proxy: UpstreamProxy) => void;
onOpenBindings: (proxy: UpstreamProxy) => void;
onToggle: (proxy: UpstreamProxy) => void;
toggleBusyId?: string;
}
export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelete, onOpenBindings }: UpstreamSectionProps) {
export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelete, onOpenBindings, onToggle, toggleBusyId }: UpstreamSectionProps) {
const { t } = useI18n();
return (
<div className="ui-card overflow-hidden">
@@ -30,15 +32,14 @@ export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelet
</div>
) : null}
<div className="overflow-x-auto">
<table className="w-full min-w-[900px] text-left text-sm">
<table className="w-full min-w-[760px] text-left text-sm">
<thead className="border-b border-gray-100 bg-gray-50/70 text-xs uppercase tracking-wide text-gray-500 dark:border-white/10 dark:bg-white/[0.025]">
<tr>
<th className="px-4 py-3">{t("名称")}</th>
<th className="px-4 py-3">{t("协议")}</th>
<th className="px-4 py-3">{t("地址")}</th>
<th className="px-4 py-3">{t("鉴权")}</th>
<th className="px-4 py-3">{t("状态")}</th>
<th className="px-4 py-3">{t("SIM / Profile 绑定")}</th>
<th className="px-4 py-3">{t("国家规则")}</th>
<th className="px-4 py-3 text-right">{t("操作")}</th>
</tr>
</thead>
@@ -46,18 +47,28 @@ export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelet
{rows.map((row) => (
<tr key={row.id} className="hover:bg-sky-50/40 dark:hover:bg-sky-500/[0.04]">
<td className="px-4 py-3 font-semibold">{row.name || row.id}</td>
<td className="px-4 py-3"><Tag type="primary">SOCKS5</Tag></td>
<td className="px-4 py-3 font-mono text-xs">{row.addr}</td>
<td className="px-4 py-3">{row.username || t("无")}</td>
<td className="px-4 py-3"><Tag type={row.enabled ? "success" : "info"}>{row.enabled ? t("已启用") : t("已禁用")}</Tag></td>
<td className="px-4 py-3">
<div className="inline-flex items-center gap-1 rounded border border-indigo-200/60 bg-indigo-50 px-2 py-0.5 text-[11px] font-medium text-indigo-600 dark:border-indigo-800/40 dark:bg-indigo-900/20 dark:text-indigo-400">
<DesktopRegular className="text-[14px]" />
<span>{row.bindingCount} {t("个 SIM / Profile")}</span>
</div>
{row.bindingCount}
</td>
<td className="px-4 py-3">
{row.countryNames.length ? (
<div className="flex max-w-sm flex-wrap gap-1">
{row.countryNames.map((countryName) => <Tag key={countryName} type="primary">{countryName}</Tag>)}
</div>
) : <span className="text-gray-400"></span>}
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-2">
<Button
size="small"
variant={row.enabled ? "warning" : "success"}
plain
icon={row.enabled ? <PauseRegular /> : <PlayRegular />}
loading={toggleBusyId === row.id}
onClick={() => onToggle(row)}
>{row.enabled ? t("禁用") : t("启用")}</Button>
<Button size="small" icon={<DesktopRegular />} onClick={() => onOpenBindings(row)}>{t("SIM / Profile 绑定")}</Button>
<Button size="small" icon={<EditRegular />} onClick={() => onEdit(row)}>{t("编辑")}</Button>
<Button size="small" variant="danger" plain icon={<DeleteRegular />} onClick={() => onDelete(row)}>{t("删除")}</Button>
@@ -72,7 +83,7 @@ export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelet
<div className="flex flex-col items-center justify-center px-6 py-16 text-center text-gray-400">
<GlobeRegular className="mb-3 text-4xl" />
<div className="text-sm">{t("暂无上游代理")}</div>
<div className="mt-1 text-xs">{t("点击“新增代理”创建 SOCKS5 上游代理,再 ICCID 绑定实体 SIM 或 eSIM Profile;未绑定的卡默认直连。")}</div>
<div className="mt-1 text-xs">{t("点击“新增代理”创建 SOCKS5 上游代理,再配置国家规则或 ICCID 绑定;未匹配的卡默认直连。")}</div>
</div>
) : null}
{loading ? <div className="px-6 py-16 text-center text-sm text-gray-400">{t("加载中...")}</div> : null}
+1
View File
@@ -29,6 +29,7 @@ export interface UpstreamProbeResult {
export interface UpstreamRow extends UpstreamProxy {
bindingCount: number;
countryNames: string[];
}
export function ipv6Hint(): string {
+25
View File
@@ -780,6 +780,8 @@ export const EN_DICT: Record<string, string> = {
"改动将在此卡激活后生效": "Changes take effect once this card is activated",
"数据未开启": "Data is off",
"数据平面": "Data Plane",
"运营商配置": "Carrier Profile",
"匹配依据": "Profile Match",
"方向": "Direction",
"无法读取 IMEI(控制口可能挂死),暂不可添加。": "Cannot read the IMEI (the control port may be stuck); cannot add for now.",
"未找到可用的 AT 端口(串口可能仍在枚举),系统会自动重试;也可点击重新扫描。":
@@ -1005,6 +1007,13 @@ export const EN_DICT: Record<string, string> = {
"绑定:": "Bound:",
"鉴权:": "Auth:",
: "Country Rules",
"MCC 国家规则": "MCC Country Rules",
: "Rule",
: "Proxy enabled",
: "Proxy disabled",
: "Failed to change proxy status",
"代理已禁用;显式 ICCID 绑定将停止使用该线路且不会转为直连,尚未固化的 MCC 默认规则会回退直连":
"Proxy disabled. Explicit ICCID bindings stop using this route without falling back to direct; MCC defaults not yet materialized fall back to direct.",
: "Add Proxy",
: "Add Instance",
: "Delete Rule",
@@ -1013,6 +1022,22 @@ export const EN_DICT: Record<string, string> = {
"UDP 中继地址:": "UDP Relay Address: ",
"规则按 SIM 归属 MCC 解析国家。例如 US 会覆盖 MCC 310/311/312/313/314/315/316 等表内分组;没有配置规则的国家默认直连。需要重启 VoWiFi 生效。":
"Country is resolved from the SIM home MCC. For example, US covers the listed MCC 310/311/312/313/314/315/316 groups; countries without a rule use direct connection. Restart VoWiFi to take effect.",
"未绑定 ICCID 的卡会按 SIM 归属 MCC 匹配国家规则;首次命中后会生成独立的 ICCID 绑定。ICCID 绑定优先,未命中任何规则时直连。":
"A SIM without an ICCID binding uses the country rule matching its home MCC. The first match creates an independent ICCID binding. ICCID bindings take priority; otherwise unmatched SIMs connect directly.",
"为每个国家的 MCC 选择代理。未配置时直连;已有 ICCID 绑定始终优先,首次命中国家规则后会生成独立的 ICCID 绑定。":
"Choose a proxy for each country's MCC. Unconfigured MCCs connect directly. Existing ICCID bindings always take priority, and the first country-rule match creates an independent ICCID binding.",
"同一国家只能属于一个代理;选择已分配的国家会将它迁移到当前代理。":
"Each country can belong to only one proxy. Selecting a country assigned elsewhere moves it to this proxy.",
"搜索国家、地区代码或 MCC": "Search country, region code, or MCC",
"国家 / 地区": "Country / Region",
: "Current Rule",
: "This Proxy",
: "Direct",
"没有匹配的国家或 MCC": "No matching country or MCC",
"管理 VoWiFi 上游代理、MCC 国家规则以及实体 SIM / eSIM Profile 绑定":
"Manage VoWiFi upstream proxies, MCC country rules, and physical SIM / eSIM profile bindings",
"点击“新增代理”创建 SOCKS5 上游代理,再配置国家规则或 ICCID 绑定;未匹配的卡默认直连。":
"Create a SOCKS5 upstream proxy, then configure country rules or ICCID bindings. Unmatched SIMs connect directly by default.",
"VoWiFi 通过此 Socks5 代理连接运营商,实现跨区域本地 VoWiFi。":
"VoWiFi connects to the carrier through this Socks5 proxy, enabling cross-region local VoWiFi. ",
+2
View File
@@ -593,6 +593,7 @@ export default function DevicesPage() {
const detailOnline = isDeviceOnline(detail);
const isReader = detail?.deviceType === "usb_sim_reader";
const isNative410 = detail?.deviceType === "wifi_410";
useEffect(() => {
if (isReader && ["at", "ussd"].includes(activeTab)) setActiveTab("overview");
}, [isReader, activeTab]);
@@ -696,6 +697,7 @@ export default function DevicesPage() {
onRebootModem={handleRebootModem}
onOpenSms={handleOpenSms}
wifiCallingOnly={isReader}
modemControlOnly={isNative410}
/>
<div className="device-detail-tabs ui-card p-6">
<Tabs tabs={tabItems} value={activeTab} onChange={handleTabChange} />
+98 -11
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { AddRegular } from "@fluentui/react-icons";
import { AddRegular, GlobeRegular } from "@fluentui/react-icons";
import { api, ApiError, apiMessage } from "../api";
import type { DeviceListItem, DeviceProxyBinding, DevicesResponse, ProfileProxyCandidate, UpstreamProxy } from "../types";
import type { Country, CountryRule, DeviceListItem, DeviceProxyBinding, DevicesResponse, ProfileProxyCandidate, UpstreamProxy } from "../types";
import { usePolling } from "../lib/usePolling";
import { Button, PageHeader, confirmDialog, message } from "../components/ui";
import {
@@ -14,6 +14,7 @@ import {
} from "../components/proxy/shared";
import { UpstreamDialog } from "../components/proxy/UpstreamDialog";
import { DeviceBindingsDialog } from "../components/proxy/DeviceBindingsDialog";
import { CountryRulesDialog } from "../components/proxy/CountryRulesDialog";
import { UpstreamSection } from "../components/proxy/UpstreamSection";
import { tf, useI18n } from "../lib/i18n";
import { listPlugins, pluginAssetURL, type InstalledPlugin } from "../extensions";
@@ -24,11 +25,13 @@ interface BindingMutationResult {
}
export default function ProxyPage() {
const { t } = useI18n();
const { t, lang } = useI18n();
const [proxies, setProxies] = useState<UpstreamProxy[]>([]);
const [devices, setDevices] = useState<DeviceListItem[]>([]);
const [bindings, setBindings] = useState<DeviceProxyBinding[]>([]);
const [countries, setCountries] = useState<Country[]>([]);
const [countryRules, setCountryRules] = useState<CountryRule[]>([]);
const [upstreamLoading, setUpstreamLoading] = useState(true);
const [upstreamError, setUpstreamError] = useState<LoadError | null>(null);
const [upstreamDialogOpen, setUpstreamDialogOpen] = useState(false);
@@ -39,28 +42,46 @@ export default function ProxyPage() {
const [bindingsDialogOpen, setBindingsDialogOpen] = useState(false);
const [bindingsProxy, setBindingsProxy] = useState<UpstreamProxy | null>(null);
const [bindingBusy, setBindingBusy] = useState(false);
const [countryDialogOpen, setCountryDialogOpen] = useState(false);
const [countryBusy, setCountryBusy] = useState(false);
const [toggleBusyId, setToggleBusyId] = useState("");
const [plugins, setPlugins] = useState<InstalledPlugin[]>([]);
const proxyRows = useMemo<UpstreamRow[]>(
() => proxies.map((proxy) => ({
const regionNames = useMemo(() => {
try {
return new Intl.DisplayNames([lang === "zh" ? "zh-CN" : "en"], { type: "region" });
} catch {
return null;
}
}, [lang]);
const proxyRows = useMemo<UpstreamRow[]>(() => proxies.map((proxy) => {
const countryNames = countryRules
.filter((rule) => rule.enabled && rule.upstreamProxyId === proxy.id)
.map((rule) => regionNames?.of(rule.countryCode) || rule.countryName || rule.countryCode);
return {
...proxy,
bindingCount: bindings.filter((binding) => binding.upstreamProxyId === proxy.id).length,
})),
[proxies, bindings],
);
countryNames,
};
}), [proxies, bindings, countryRules, regionNames]);
const loadUpstream = useCallback(async (initial = false) => {
if (initial) setUpstreamLoading(true);
setUpstreamError(null);
try {
const [proxyList, bindingList, deviceList] = await Promise.all([
const [proxyList, bindingList, deviceList, countryList, ruleList] = await Promise.all([
api<UpstreamProxy[]>("/upstream-proxies"),
api<DeviceProxyBinding[]>("/upstream-proxy-profile-bindings"),
api<DevicesResponse>("/devices"),
api<Country[]>("/upstream-proxy-countries"),
api<CountryRule[]>("/upstream-proxy-country-rules"),
]);
setProxies(proxyList || []);
setBindings(bindingList || []);
setDevices(deviceList?.devices || []);
setCountries(countryList || []);
setCountryRules(ruleList || []);
} catch (error) {
setUpstreamError({ message: apiMessage(error), status: error instanceof ApiError ? error.status : undefined });
} finally {
@@ -165,6 +186,8 @@ export default function ProxyPage() {
{tf("确定删除上游代理“{name}”?", { name: proxy.name || proxy.id })}
<br />
{t("绑定到该代理的 Profile 将自动解绑并恢复直连。")}
<br />
{t("绑定到该代理的国家规则将自动删除,相关国家会恢复直连。")}
</>,
t("确认删除"),
{ confirmText: t("删除"), cancelText: t("取消"), type: "warning" },
@@ -174,6 +197,7 @@ export default function ProxyPage() {
await api(`/upstream-proxies/${proxy.id}`, { method: "DELETE" });
message.success(t("上游代理已删除"));
if (bindingsProxy?.id === proxy.id) setBindingsDialogOpen(false);
setCountryDialogOpen(false);
await loadUpstream(false);
} catch (error) {
message.error(apiMessage(error) || t("删除失败"));
@@ -185,6 +209,53 @@ export default function ProxyPage() {
setBindingsDialogOpen(true);
}, []);
const toggleUpstream = useCallback(async (proxy: UpstreamProxy) => {
const enabled = !proxy.enabled;
setToggleBusyId(proxy.id);
try {
const result = await api<BindingMutationResult>(`/upstream-proxies/${encodeURIComponent(proxy.id)}`, {
method: "PATCH",
body: { enabled },
});
if (result.reconnectError) {
message.warning(`${enabled ? t("代理已启用") : t("代理已禁用")}${t("线路已保存,将在下次启动 VoWiFi 时应用")}`);
} else if (enabled) {
message.success(t("代理已启用"));
} else {
message.success(t("代理已禁用;显式 ICCID 绑定将停止使用该线路且不会转为直连,尚未固化的 MCC 默认规则会回退直连"));
}
await loadUpstream(false);
} catch (error) {
message.error(apiMessage(error) || t("切换代理状态失败"));
} finally {
setToggleBusyId("");
}
}, [loadUpstream, t]);
const saveCountryRules = useCallback(async (assignments: Record<string, string>) => {
setCountryBusy(true);
const current = new Map(countryRules.map((rule) => [rule.countryCode, rule.upstreamProxyId]));
const changed = Object.entries(assignments).filter(([code, proxyID]) => proxyID && current.get(code) !== proxyID);
const removed = countryRules.filter((rule) => !assignments[rule.countryCode]);
try {
await Promise.all(changed.map(([code, proxyID]) => api(`/upstream-proxy-country-rules/${encodeURIComponent(code)}`, {
method: "PUT",
body: { upstreamProxyId: proxyID, enabled: true },
})));
await Promise.all(removed.map((rule) => api(`/upstream-proxy-country-rules/${encodeURIComponent(rule.countryCode)}`, {
method: "DELETE",
})));
message.success(t("国家规则已保存"));
await loadUpstream(false);
setCountryDialogOpen(false);
} catch (error) {
await loadUpstream(false);
message.error(apiMessage(error) || t("保存规则失败"));
} finally {
setCountryBusy(false);
}
}, [countryRules, loadUpstream, t]);
const showRouteChangeResult = useCallback((result: BindingMutationResult, successText: string) => {
if (result.reconnectError) {
message.warning(`${successText}${t("线路已保存,将在下次启动 VoWiFi 时应用")}`);
@@ -241,8 +312,13 @@ export default function ProxyPage() {
<div className="mx-auto max-w-7xl">
<PageHeader
title={t("代理管理")}
subtitle={t("管理 VoWiFi 上游代理以及实体 SIM / eSIM Profile 绑定")}
actions={<Button variant="primary" icon={<AddRegular />} onClick={() => openUpstreamDialog()}>{t("新增代理")}</Button>}
subtitle={t("管理 VoWiFi 上游代理、MCC 国家规则以及实体 SIM / eSIM Profile 绑定")}
actions={(
<div className="flex gap-2">
<Button icon={<GlobeRegular />} onClick={() => setCountryDialogOpen(true)}>{t("MCC 国家规则")}</Button>
<Button variant="primary" icon={<AddRegular />} onClick={() => openUpstreamDialog()}>{t("新增代理")}</Button>
</div>
)}
/>
<UpstreamSection
rows={proxyRows}
@@ -252,6 +328,8 @@ export default function ProxyPage() {
onEdit={openUpstreamDialog}
onDelete={removeUpstream}
onOpenBindings={openBindingsDialog}
onToggle={(proxy) => void toggleUpstream(proxy)}
toggleBusyId={toggleBusyId}
/>
{plugins.filter((plugin) => plugin.enabled).flatMap((plugin) =>
plugin.contributions.filter((contribution) => contribution.location === "proxy").map((contribution) => (
@@ -293,6 +371,15 @@ export default function ProxyPage() {
onDelete={(iccids) => void deleteProfileBindings(iccids)}
onClose={() => setBindingsDialogOpen(false)}
/>
<CountryRulesDialog
open={countryDialogOpen}
proxies={proxies}
countries={countries}
rules={countryRules}
busy={countryBusy}
onSave={(assignments) => void saveCountryRules(assignments)}
onClose={() => setCountryDialogOpen(false)}
/>
</div>
);
}
+2
View File
@@ -33,6 +33,8 @@ export interface VoWiFiRuntime {
phase: string;
enabled?: boolean;
active?: boolean;
carrierProfile?: string;
carrierProfileFrom?: string;
dataplaneMode: string;
iccid: string;
imsi: string;