5 Commits
30 changed files with 2147 additions and 84 deletions
+22 -5
View File
@@ -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,6 +715,7 @@ func newVoWiFiOrchestrator(
deviceConfig store.Device,
database *store.Store,
adapter vowifiDeviceAdapter,
logger *slog.Logger,
) (*vowifi.Orchestrator, error) {
apn := deviceConfig.APN
if apn == "" {
@@ -716,6 +726,7 @@ func newVoWiFiOrchestrator(
return nil, fmt.Errorf("device %q IKE provider: %w", deviceConfig.ID, err)
}
imsProvider, err := ims.NewProvider(adapter, ims.Config{
Logger: logger,
// The userspace SWu data plane carries protected P-CSCF signalling over
// TCP by default. UK PLMN 234-10 exposes its P-CSCF over UDP/5060 on SWu.
Transport: "tcp",
@@ -723,9 +734,14 @@ func newVoWiFiOrchestrator(
"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",
// Some UK SIM profiles leave EF_SMSP/AT+CSCA empty. Keep fallbacks scoped
// to their HPLMN so an O2/giffgaff SIM can never inherit Vodafone's SMSC.
SMSCenterByPLMN: map[string]string{
"23410": "+447802000332",
"234010": "+447802000332",
"23415": "+447785016005",
"234015": "+447785016005",
},
OnSMS: func(ctx context.Context, message ims.ReceivedSMS) error {
extra, _ := json.Marshal(map[string]any{
"transport": "ims",
@@ -856,6 +872,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 +889,7 @@ func provisionDiscoveredDevices(
DeviceBackend: backend,
ESIMTransport: esimTransport,
NetworkEnabled: false,
SMSEnabled: true,
SMSEnabled: supportsSMS,
VoWiFiEnabled: true,
}); err != nil {
return err
+71
View File
@@ -0,0 +1,71 @@
package main
import (
"context"
"vocat/internal/device"
"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) 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)
}
+1 -1
View File
@@ -104,7 +104,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
}
+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"} {
+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
}
+19 -3
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:
@@ -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")
+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:")
+30 -5
View File
@@ -8,6 +8,7 @@ import (
"encoding/hex"
"errors"
"fmt"
"log/slog"
"net"
"strconv"
"strings"
@@ -52,12 +53,18 @@ type Config struct {
// 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
@@ -85,6 +92,9 @@ func NewProvider(aka vowifi.AKAProvider, config Config) (*Provider, error) {
}
func normalizeConfig(config Config) (Config, error) {
if config.Logger == nil {
config.Logger = slog.Default()
}
if config.Port == 0 {
config.Port = defaultSIPPort
}
@@ -120,6 +130,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 +179,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()
+20
View File
@@ -73,6 +73,26 @@ func TestTransportForIdentityPreservesLeadingZeroMNCs(t *testing.T) {
}
}
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
+314 -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,60 @@ 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)
return strings.TrimSpace(config.SMSCenterByPLMN[plmn])
}
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 +839,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 +852,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 {
+143 -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,111 @@ 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 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 +310,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 +336,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 +418,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)
+169
View File
@@ -0,0 +1,169 @@
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 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)
}
+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(
+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
@@ -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>
+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} />