8 Commits
Author SHA1 Message Date
MengMengCode 2780dd96de Make EC20 eSIM channels coexist with VoWiFi 2026-08-12 16:56:30 +08:00
MengMengCode 2922d6a275 Wait for OpenWrt service shutdown during upgrades 2026-08-12 16:36:04 +08:00
MengMengCode 288e856fdb Stabilize OpenWrt modem startup and upgrades 2026-08-12 16:30:22 +08:00
MengMengCode f17d925c4c Fix static ARM builds and OpenWrt VoWiFi setup 2026-08-12 15:58:02 +08:00
Meng MengandGitHub 296f963885 Merge pull request #3 from MengMengCode/dependabot/go_modules/go_modules-a3c8a40308
build(deps): bump golang.org/x/crypto from 0.41.0 to 0.52.0 in the go_modules group across 1 directory
2026-08-12 02:38:34 +08:00
dependabot[bot]andGitHub 1b9546a73d build(deps): bump golang.org/x/crypto
Bumps the go_modules group with 1 update in the / directory: [golang.org/x/crypto](https://github.com/golang/crypto).


Updates `golang.org/x/crypto` from 0.41.0 to 0.52.0
- [Commits](https://github.com/golang/crypto/compare/v0.41.0...v0.52.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.52.0
  dependency-type: direct:production
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-08-11 18:35:30 +00:00
MengMengCode 22487dbb1f Add PC/SC backend support for USB SIM readers
- Implemented a new `unsupportedBackend` in `backend_stub.go` to handle unsupported platforms.
- Created a `Service` struct in `service.go` to manage interactions with smart cards, including session management and identity reading.
- Added methods for reading identity, checking readiness, and authenticating with USIM applications.
- Introduced a `scriptedCard` for testing purposes in `service_test.go` to simulate card responses.
- Defined necessary types and error handling in `types.go` for better clarity and usability.
- Developed a `PCSCAdapter` in `vowifi/pcsc_adapter.go` to integrate PC/SC service with Wi-Fi calling functionalities.
- Added a new SVG icon for USB SIM readers in `public/sim-reader.svg`.
- Implemented tests to ensure correct functionality and error handling in various scenarios.
2026-08-12 02:33:32 +08:00
Meng MengandGitHub f9bb38aabe Add star history badge to README 2026-08-11 23:52:46 +08:00
67 changed files with 3124 additions and 139 deletions
+8
View File
@@ -110,6 +110,14 @@ jobs:
-o "$OUTPUT" \
./cmd/vocat
chmod 0755 "$OUTPUT"
if readelf -l "$OUTPUT" | grep -q 'Requesting program interpreter'; then
echo "ERROR: $OUTPUT unexpectedly requires a dynamic loader" >&2
readelf -l "$OUTPUT" >&2
exit 1
fi
if [ "${{ matrix.goarch }}" = "amd64" ]; then
"$OUTPUT" version
fi
- name: Upload ${{ matrix.target }}
uses: actions/upload-artifact@v4
with:
+22
View File
@@ -65,10 +65,24 @@ Available features depend on the module firmware, USB composition, SIM/eSIM capa
### One-click Linux installation
As root (including OpenWrt/Kwrt, where `sudo` is normally absent):
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash
```
From a normal user on a distribution with sudo:
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | sudo bash
```
Check the host's VoWiFi/XFRM prerequisites without installing VoCat:
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash -s -- --check-env
```
Install a specific version:
```bash
@@ -76,6 +90,12 @@ curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/i
sudo bash install.sh 0.0.2
```
VoWiFi IMS requires Linux XFRM/IPsec. On OpenWrt/Kwrt the installer attempts
to install matching `ip-full`, `kmod-ipsec`, `kmod-ipsec4/6`,
`kmod-crypto-authenc`, AES-CBC and SHA1 packages from the firmware's own feed.
If matching kernel modules are unavailable, use a firmware that includes them;
never force-install kmods built for a different kernel.
The installer:
- detects `amd64`, `386`, `arm64`, `aarch64`, or `armv7`;
@@ -316,3 +336,5 @@ cd web && npm run build
## License
See [LICENSE](LICENSE).
[![MengMengCode/VoCat Star History](https://mengmeng.meteor-history.com/api/embed/MengMengCode/VoCat.svg?sig=sdeXRVxAoY3yLWgXL7JViY2USYIN3t9neJ6ScPvgUAo&theme=light&style=xkcd&color=dd4528&background=ffffff&textColor=000000&width=900&height=600&lineWidth=3&showTitle=true&showLegend=true&showDots=false&v=0.0.14)](https://meteor-history.com)
+45
View File
@@ -0,0 +1,45 @@
//go:build linux
package main
import (
"errors"
"fmt"
"os"
"path/filepath"
"golang.org/x/sys/unix"
)
func lockServerInstance(databasePath string) (*os.File, error) {
// The modem, PC/SC reader, XFRM policies and listener are host resources,
// not database resources. Lock per OS user so a diagnostic instance using a
// different VOCAT_DATABASE_PATH cannot silently steal the same AT port from
// the managed service. Prefer /run because systemd's PrivateTmp would
// otherwise hide the managed service's lock from a manually started process.
// The UID-specific directory still permits intentionally isolated users to
// operate independently; development hosts without writable /run fall back
// to TempDir.
uid := os.Geteuid()
directory := filepath.Join("/run", fmt.Sprintf("vocat-%d", uid))
if uid == 0 {
directory = "/run/vocat"
}
if err := os.MkdirAll(directory, 0o700); err != nil {
directory = os.TempDir()
}
path := filepath.Join(directory, "vocat-server.lock")
fd, err := unix.Open(path, unix.O_CREAT|unix.O_RDWR|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0o600)
if err != nil {
return nil, fmt.Errorf("open server instance lock: %w", err)
}
file := os.NewFile(uintptr(fd), path)
if err := unix.Flock(fd, unix.LOCK_EX|unix.LOCK_NB); err != nil {
_ = file.Close()
if errors.Is(err, unix.EWOULDBLOCK) || errors.Is(err, unix.EAGAIN) {
return nil, errors.New("another vocat server already controls this host's modem resources")
}
return nil, fmt.Errorf("lock server instance: %w", err)
}
return file, nil
}
+26
View File
@@ -0,0 +1,26 @@
//go:build linux
package main
import (
"path/filepath"
"strings"
"testing"
)
func TestServerInstanceLockRejectsSecondProcess(t *testing.T) {
firstDatabase := filepath.Join(t.TempDir(), "vocat.db")
first, err := lockServerInstance(firstDatabase)
if err != nil {
t.Fatal(err)
}
defer first.Close()
secondDatabase := filepath.Join(t.TempDir(), "other.db")
second, err := lockServerInstance(secondDatabase)
if second != nil {
second.Close()
}
if err == nil || !strings.Contains(err.Error(), "already controls this host") {
t.Fatalf("second lock error = %v", err)
}
}
+12
View File
@@ -0,0 +1,12 @@
//go:build !linux
package main
import (
"os"
"path/filepath"
)
func lockServerInstance(databasePath string) (*os.File, error) {
return os.OpenFile(filepath.Join(filepath.Dir(databasePath), ".vocat.lock"), os.O_CREATE|os.O_RDWR, 0o600)
}
+136 -9
View File
@@ -27,6 +27,7 @@ import (
"vocat/internal/extensions"
"vocat/internal/httpsmode"
"vocat/internal/loghub"
"vocat/internal/pcsc"
"vocat/internal/server"
"vocat/internal/store"
"vocat/internal/update"
@@ -111,6 +112,11 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
if err != nil {
return fmt.Errorf("load configuration: %w", err)
}
instanceLock, err := lockServerInstance(cfg.DatabasePath)
if err != nil {
return err
}
defer instanceLock.Close()
if cfg.UsesDefaultCredentials() {
logger.Warn(
"default admin credentials are active; set VOCAT_ADMIN_PASSWORD before exposing the service",
@@ -184,7 +190,8 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
return err
}
deviceManager, err := device.NewManager(device.Options{})
cardReaders := pcsc.New()
deviceManager, err := device.NewManager(device.Options{CardReaders: cardReaders})
if err != nil {
return fmt.Errorf("create device manager: %w", err)
}
@@ -220,6 +227,7 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
logger,
database,
deviceManager,
cardReaders,
)
if err != nil {
return fmt.Errorf("configure VoWiFi runtime: %w", err)
@@ -362,6 +370,12 @@ func configureDeviceBackends(
if mapErr != nil {
continue
}
if config.DeviceType == store.DeviceTypeUSBSIMReader {
if err := manager.SetSIMPin(entry.ID, config.SIMPIN); err != nil {
logger.Warn("configure USB SIM reader", "device_id", config.ID, "error", err)
}
continue
}
if err := manager.SetBackend(entry.ID, config.DeviceBackend); err != nil {
logger.Warn("configure device backend", "device_id", config.ID, "backend", config.DeviceBackend, "error", err)
}
@@ -384,6 +398,9 @@ func restoreDefaultCellularRadios(
}
mapper := integration.ATMapper{Store: database, Devices: manager}
for _, config := range configs {
if config.DeviceType == store.DeviceTypeUSBSIMReader {
continue
}
if config.VoWiFiEnabled {
continue
}
@@ -431,6 +448,9 @@ func restoreConfiguredCellularData(
}
mapper := integration.ATMapper{Store: database, Devices: manager}
for _, config := range configs {
if config.DeviceType == store.DeviceTypeUSBSIMReader {
continue
}
if !config.NetworkEnabled || config.VoWiFiEnabled {
continue
}
@@ -482,6 +502,9 @@ func disableAllDeveloperCellularData(
}
mapper := integration.ATMapper{Store: database, Devices: manager}
for _, config := range configs {
if config.DeviceType == store.DeviceTypeUSBSIMReader {
continue
}
entry, err := mapper.Get(config.ID)
if err != nil {
continue
@@ -536,12 +559,13 @@ func configureVoWiFiRuntime(
logger *slog.Logger,
database *store.Store,
deviceManager *device.Manager,
cardReaders *pcsc.Service,
) (*vowifiruntime.Manager, error) {
mapper := integration.ATMapper{
Store: database,
Devices: deviceManager,
}
adapter, err := vowifi.NewEC20Adapter(mapper, vowifi.EC20AdapterOptions{
ec20Adapter, err := vowifi.NewEC20Adapter(mapper, vowifi.EC20AdapterOptions{
// The test deployment is deliberately non-cellular. VoWiFi teardown
// may restore CFUN, but it must never reactivate a PDP context.
RestoreCellularData: false,
@@ -556,6 +580,16 @@ func configureVoWiFiRuntime(
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 {
return pcsc.Selector{}, "", resolveErr
}
return pcsc.Selector{USBPath: config.USBPath, ReaderName: config.ControlDevice}, config.SIMPIN, nil
})
if err != nil {
return nil, err
}
projector := integration.StateProjector{
Store: database,
Devices: mapper,
@@ -568,6 +602,10 @@ func configureVoWiFiRuntime(
if err != nil {
return nil, fmt.Errorf("load device %q VoWiFi config: %w", deviceID, err)
}
adapter := vowifiDeviceAdapter(ec20Adapter)
if deviceConfig.DeviceType == store.DeviceTypeUSBSIMReader {
adapter = pcscAdapter
}
return newVoWiFiOrchestrator(deviceConfig, database, adapter)
},
})
@@ -584,12 +622,18 @@ func configureVoWiFiRuntime(
}
if deviceConfig.VoWiFiEnabled {
if entry, mapErr := mapper.Get(deviceConfig.ID); mapErr == nil {
flightContext, cancelFlight := context.WithTimeout(ctx, 10*time.Second)
_, flightErr := deviceManager.SetFlight(flightContext, entry.ID, true)
cancelFlight()
flightErr := protectVoWiFiStartupRadio(ctx, deviceManager, entry.ID)
if flightErr != nil {
_ = manager.Close(context.Background())
return nil, fmt.Errorf("protect device %q before VoWiFi startup: %w", deviceConfig.ID, flightErr)
// A modem can be temporarily unavailable while OpenWrt/procd is
// restarting the service (notably after loading XFRM modules). Do
// not take the Web/API service down with it: the orchestrator below
// remains fail-closed and its runtime manager retries until CFUN=4
// can be established.
logger.Warn(
"VoWiFi startup radio protection deferred to automatic retry",
"device_id", deviceConfig.ID,
"error", flightErr,
)
}
}
if _, err := manager.RequestEnabled(deviceConfig.ID, true); err != nil {
@@ -601,10 +645,66 @@ func configureVoWiFiRuntime(
return manager, nil
}
const (
vowifiStartupRadioAttempts = 3
vowifiStartupRadioDelay = time.Second
)
type flightModeSetter interface {
SetFlight(context.Context, string, bool) (device.FlightResult, error)
}
func protectVoWiFiStartupRadio(ctx context.Context, manager flightModeSetter, physicalID string) error {
return protectVoWiFiStartupRadioWithRetry(
ctx,
manager,
physicalID,
vowifiStartupRadioAttempts,
vowifiStartupRadioDelay,
)
}
func protectVoWiFiStartupRadioWithRetry(
ctx context.Context,
manager flightModeSetter,
physicalID string,
attempts int,
delay time.Duration,
) error {
var lastErr error
for attempt := 0; attempt < attempts; attempt++ {
flightContext, cancel := context.WithTimeout(ctx, 10*time.Second)
_, lastErr = manager.SetFlight(flightContext, physicalID, true)
cancel()
if lastErr == nil {
return nil
}
if attempt+1 == attempts {
break
}
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
if !timer.Stop() {
<-timer.C
}
return errors.Join(lastErr, ctx.Err())
case <-timer.C:
}
}
return lastErr
}
type vowifiDeviceAdapter interface {
vowifi.SIMIdentityReader
vowifi.AKAProvider
vowifi.RadioController
}
func newVoWiFiOrchestrator(
deviceConfig store.Device,
database *store.Store,
adapter *vowifi.EC20Adapter,
adapter vowifiDeviceAdapter,
) (*vowifi.Orchestrator, error) {
apn := deviceConfig.APN
if apn == "" {
@@ -734,9 +834,18 @@ func provisionDiscoveredDevices(
candidate := discovered.Candidate
backend := "at"
control := candidate.ATPort.OpenPath()
deviceType := store.DeviceTypePCIeEC20EC25
esimTransport := backend
if candidate.QMIControl != "" {
backend = "qmi"
control = candidate.QMIControl
esimTransport = backend
}
if candidate.HardwareKind == pcsc.HardwareKind {
backend = "pcsc"
control = candidate.ReaderName
deviceType = store.DeviceTypeUSBSIMReader
esimTransport = "pcsc"
}
name := candidate.Product
if name == "" || strings.EqualFold(name, "Android") {
@@ -745,6 +854,7 @@ func provisionDiscoveredDevices(
if err := database.UpsertDevice(ctx, store.Device{
ID: discovered.ID,
Name: name,
DeviceType: deviceType,
Interface: candidate.NetworkInterface,
ControlDevice: control,
ATPort: candidate.ATPort.OpenPath(),
@@ -755,7 +865,7 @@ func provisionDiscoveredDevices(
StopBits: 1,
Parity: "none",
DeviceBackend: backend,
ESIMTransport: backend,
ESIMTransport: esimTransport,
NetworkEnabled: false,
SMSEnabled: true,
VoWiFiEnabled: true,
@@ -931,6 +1041,7 @@ func reconcileCardPolicies(
manager *device.Manager,
vowifiManager *vowifiruntime.Manager,
) {
observedCards := make(map[string]string)
reconcile := func() {
policies, policyListErr := database.ListCardPolicies(ctx)
if policyListErr == nil {
@@ -953,12 +1064,26 @@ func reconcileCardPolicies(
for _, config := range configs {
entry, mapErr := mapper.Get(config.ID)
if mapErr != nil || entry.Snapshot == nil {
if config.DeviceType == store.DeviceTypeUSBSIMReader && observedCards[config.ID] != "missing" {
if state, stateErr := vowifiManager.State(config.ID); stateErr == nil && state.ICCID != "" {
_, _ = vowifiManager.RequestReconnect(config.ID)
}
observedCards[config.ID] = "missing"
}
continue
}
iccid := strings.TrimSpace(entry.Snapshot.ICCID)
if iccid == "" {
if config.DeviceType == store.DeviceTypeUSBSIMReader && observedCards[config.ID] != "missing" {
if state, stateErr := vowifiManager.State(config.ID); stateErr == nil && state.ICCID != "" {
_, _ = vowifiManager.RequestReconnect(config.ID)
}
observedCards[config.ID] = "missing"
}
continue
}
previousObserved := observedCards[config.ID]
observedCards[config.ID] = iccid
policy, policyErr := database.CardPolicy(ctx, iccid)
if policyErr != nil {
continue
@@ -1001,6 +1126,8 @@ func reconcileCardPolicies(
_, _ = vowifiManager.RequestEnabled(config.ID, true)
case state.ICCID != "" && !strings.EqualFold(strings.TrimSpace(state.ICCID), iccid):
_, _ = vowifiManager.RequestReconnect(config.ID)
case config.DeviceType == store.DeviceTypeUSBSIMReader && previousObserved == "missing":
_, _ = vowifiManager.RequestReconnect(config.ID)
}
continue
}
+56
View File
@@ -0,0 +1,56 @@
package main
import (
"context"
"errors"
"testing"
"vocat/internal/device"
)
type startupFlightSetter struct {
errors []error
calls int
id string
}
func (setter *startupFlightSetter) SetFlight(
_ context.Context,
id string,
enabled bool,
) (device.FlightResult, error) {
setter.calls++
setter.id = id
if !enabled {
return device.FlightResult{}, errors.New("expected flight mode to be enabled")
}
if setter.calls <= len(setter.errors) {
return device.FlightResult{}, setter.errors[setter.calls-1]
}
return device.FlightResult{CurrentMode: 4, FlightMode: true, RadioOff: true}, nil
}
func TestProtectVoWiFiStartupRadioRetriesTransientFailure(t *testing.T) {
transient := errors.New("modem is reopening")
setter := &startupFlightSetter{errors: []error{transient, transient}}
if err := protectVoWiFiStartupRadioWithRetry(
context.Background(), setter, "quectel-1", 3, 0,
); err != nil {
t.Fatalf("protect startup radio: %v", err)
}
if setter.calls != 3 || setter.id != "quectel-1" {
t.Fatalf("SetFlight calls = %d, id = %q", setter.calls, setter.id)
}
}
func TestProtectVoWiFiStartupRadioReturnsLastFailure(t *testing.T) {
first := errors.New("first")
last := errors.New("last")
setter := &startupFlightSetter{errors: []error{first, last}}
err := protectVoWiFiStartupRadioWithRetry(
context.Background(), setter, "quectel-1", 2, 0,
)
if !errors.Is(err, last) || setter.calls != 2 {
t.Fatalf("protect startup radio = %v after %d calls", err, setter.calls)
}
}
+16
View File
@@ -65,10 +65,24 @@ Vocat 面向基于高通芯片、并暴露兼容 AT、QMI、串口与 USB 网络
### Linux 一键安装
已是 root(包括默认没有 `sudo` 的 OpenWrt/Kwrt):
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash
```
普通 Linux 用户且系统装有 sudo
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | sudo bash
```
只检查 VoWiFi/XFRM 环境,不安装 VoCat
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash -s -- --check-env
```
安装指定版本:
```bash
@@ -76,6 +90,8 @@ curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/i
sudo bash install.sh 0.0.2
```
VoWiFi IMS 必须使用 Linux XFRM/IPsec。OpenWrt/Kwrt 上安装脚本会从当前固件自己的软件源尝试安装严格匹配的 `ip-full``kmod-ipsec``kmod-ipsec4/6``kmod-crypto-authenc`、AES-CBC 和 SHA1 组件。若软件源没有与当前内核匹配的模块,必须更换包含这些组件的固件,禁止强装其他内核版本的 kmod。
安装程序会:
- 检测 `amd64``386``arm64``armv7` 架构;
+2 -2
View File
@@ -5,9 +5,9 @@ go 1.25.0
require (
github.com/coder/websocket v1.8.15
go.bug.st/serial v1.6.4
golang.org/x/crypto v0.41.0
golang.org/x/crypto v0.52.0
golang.org/x/sys v0.47.0
golang.org/x/term v0.34.0
golang.org/x/term v0.43.0
modernc.org/sqlite v1.38.2
)
+6 -6
View File
@@ -18,12 +18,12 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A=
go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
@@ -33,8 +33,8 @@ golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+3
View File
@@ -274,6 +274,9 @@ func (manager *Manager) SetFlight(
if err := manager.validateActive(id, state); err != nil {
return FlightResult{}, err
}
if manager.candidateFor(state).HardwareKind == "pcsc" {
return FlightResult{PreviousMode: 4, CurrentMode: 4, FlightMode: true, RadioOff: true}, nil
}
client, err := manager.clientLocked(ctx, state, manager.candidateFor(state))
if err != nil {
manager.setResult(id, state, nil, err)
+144 -35
View File
@@ -10,6 +10,7 @@ import (
"vocat/internal/i18n"
"vocat/internal/modem"
"vocat/internal/pcsc"
)
// eUICC / eSIM (LPA, SGP.22) access over the modem's AT+CSIM APDU passthrough.
@@ -190,9 +191,11 @@ func parseCSIM(response modem.Response) ([]byte, int, error) {
// euiccChannel is an open logical channel to the eUICC's ISD-R.
type euiccChannel struct {
manager *Manager
id string
channel int
manager *Manager
id string
channel int
pcscSession *pcsc.Session
resetOnClose bool
}
// csimAPDUTimeout bounds a single AT+CSIM exchange. Loading a BoundProfilePackage
@@ -243,6 +246,15 @@ func (manager *Manager) openEuiccAID(ctx context.Context, id, aidHex string) (*e
return channel, nil
}
lastErr = err
if attempt == 0 && errors.Is(err, errNoLogicalChannel) &&
manager.releaseStaleEuiccChannel(ctx, id) {
// EC20 firmware exposes only one MANAGE CHANNEL slot. A canceled or
// interrupted APDU transaction can leave channel 1 allocated, after
// which every eSIM page load returns 6A81 until reboot. Closing the
// orphan while holding the shared UICC transaction lock makes the
// operation self-healing without disturbing an active AKA exchange.
continue
}
if !isTransientEuiccCME(err) {
return nil, err
}
@@ -259,11 +271,24 @@ func (manager *Manager) openEuiccAID(ctx context.Context, id, aidHex string) (*e
return nil, lastErr
}
func (manager *Manager) releaseStaleEuiccChannel(ctx context.Context, id string) bool {
_, sw, err := manager.csim(ctx, id, []byte{0x00, 0x70, 0x80, 0x01, 0x00})
return err == nil && sw == 0x9000
}
func (manager *Manager) openEuiccOnce(ctx context.Context, id string) (*euiccChannel, error) {
return manager.openEuiccOnceAID(ctx, id, isdRAID)
}
func (manager *Manager) openEuiccOnceAID(ctx context.Context, id, aidHex string) (*euiccChannel, error) {
state, lookupErr := manager.lookup(id)
if lookupErr != nil {
return nil, lookupErr
}
candidate := manager.candidateFor(state)
if candidate.HardwareKind == pcsc.HardwareKind {
return manager.openPCSCEuiccOnceAID(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.
@@ -302,6 +327,37 @@ func (manager *Manager) openEuiccOnceAID(ctx context.Context, id, aidHex string)
return channel, 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,
})
if err != nil {
return nil, err
}
payload, sw, err := session.Transmit(ctx, []byte{0x00, 0x70, 0x00, 0x00, 0x01})
if err != nil || sw != 0x9000 || len(payload) != 1 {
session.Close()
if err != nil {
return nil, fmt.Errorf("esim: PC/SC MANAGE CHANNEL: %w", err)
}
return nil, errNoLogicalChannel
}
channel := &euiccChannel{manager: manager, id: id, channel: int(payload[0]), pcscSession: session}
aidHex = strings.ToUpper(strings.TrimSpace(aidHex))
aid, err := hex.DecodeString(aidHex)
if err != nil || len(aid) == 0 || len(aid) > 255 {
channel.close(context.Background())
return nil, fmt.Errorf("esim: invalid ISD-R AID %q", aidHex)
}
selectAID := append([]byte{byte(channel.channel), 0xA4, 0x04, 0x00, byte(len(aid))}, aid...)
_, selectSW, err := channel.transmit(ctx, selectAID, 0x00)
if err != nil || selectSW != 0x9000 {
channel.close(context.Background())
return nil, errNoEUICC
}
return channel, nil
}
// discoverEuiccAIDs detects eSTK multi-SE and alternate-ISD-R cards without
// changing any profile state. The vendor product applet and candidate ISD-R
// applications are selected only as read-only capability probes. Per
@@ -352,7 +408,23 @@ func isTransientEuiccCME(err error) bool {
// close releases the logical channel (MANAGE CHANNEL close).
func (channel *euiccChannel) close(ctx context.Context) {
closeAPDU := []byte{0x00, 0x70, 0x80, byte(channel.channel), 0x00}
_, _, _ = channel.manager.csim(ctx, channel.id, closeAPDU)
_, _, _ = channel.exchange(ctx, closeAPDU)
if channel.pcscSession != nil {
if channel.resetOnClose {
_ = channel.pcscSession.CloseWithReset()
} else {
_ = channel.pcscSession.Close()
}
channel.pcscSession = nil
}
}
func (channel *euiccChannel) exchange(ctx context.Context, apdu []byte) ([]byte, int, error) {
if channel.pcscSession != nil {
payload, sw, err := channel.pcscSession.Transmit(ctx, apdu)
return payload, int(sw), err
}
return channel.manager.csim(ctx, channel.id, apdu)
}
// transmit sends one APDU on the logical channel (CLA high nibble from insClass,
@@ -360,7 +432,7 @@ func (channel *euiccChannel) close(ctx context.Context) {
// and returns the assembled payload.
func (channel *euiccChannel) transmit(ctx context.Context, apdu []byte, insClass byte) ([]byte, int, error) {
apdu[0] = (apdu[0] & 0xF0) | byte(channel.channel)
payload, sw, err := channel.manager.csim(ctx, channel.id, apdu)
payload, sw, err := channel.exchange(ctx, apdu)
if err != nil {
return nil, 0, err
}
@@ -369,7 +441,7 @@ func (channel *euiccChannel) transmit(ctx context.Context, apdu []byte, insClass
for sw>>8 == 0x61 && guard < 24 {
guard++
getResponse := []byte{0x80 | byte(channel.channel), 0xC0, 0x00, 0x00, byte(sw & 0xFF)}
frag, nextSW, err := channel.manager.csim(ctx, channel.id, getResponse)
frag, nextSW, err := channel.exchange(ctx, getResponse)
if err != nil {
return nil, 0, err
}
@@ -543,8 +615,8 @@ func validProfileICCID(iccid string) bool {
// ESIMListProfiles reads the eUICC profile list via ES10c GetProfilesInfo.
func (manager *Manager) ESIMListProfiles(ctx context.Context, id string) (EsimInfo, error) {
manager.esimMu.Lock()
defer manager.esimMu.Unlock()
manager.lockESIM()
defer manager.unlockESIM()
if manager.esimRecoveryActive(id) {
if cached, ok := manager.cachedESIMInfo(id); ok {
return cached, nil
@@ -584,14 +656,14 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
if err != nil {
return err
}
manager.esimMu.Lock()
manager.lockESIM()
if err := manager.waitForESIMRecovery(ctx, id); err != nil {
manager.esimMu.Unlock()
manager.unlockESIM()
return err
}
channel, err := manager.openEuiccAID(ctx, id, targetEuiccAID(aidHex))
if err != nil {
manager.esimMu.Unlock()
manager.unlockESIM()
return err
}
@@ -610,6 +682,7 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
// 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)
channel.resetOnClose = channel.pcscSession != nil
channel.close(closeContext)
cancelClose()
if err != nil {
@@ -617,7 +690,7 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
// detached reset is safe in either case and prevents an uncertain switch
// from leaving the modem's SIM cache unusable.
manager.startProfileSwitchRecovery(id)
manager.esimMu.Unlock()
manager.unlockESIM()
return err
}
// A transport SW 9000 only means the APDU reached the eUICC. The real outcome
@@ -626,11 +699,11 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
result, ok := enableProfileResult(payload)
if !ok {
manager.startProfileSwitchRecovery(id)
manager.esimMu.Unlock()
manager.unlockESIM()
return fmt.Errorf("esim: unexpected EnableProfile response %s", strings.ToUpper(hex.EncodeToString(payload)))
}
if err := enableProfileResponseError(byte(result), payload); err != nil {
manager.esimMu.Unlock()
manager.unlockESIM()
return err
}
manager.markCachedProfileEnabled(id, iccid)
@@ -638,7 +711,7 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
// 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.
manager.startProfileSwitchRecovery(id)
manager.esimMu.Unlock()
manager.unlockESIM()
verifyContext, cancelVerify := context.WithTimeout(context.WithoutCancel(ctx), profileSwitchVerificationTimeout(manager))
defer cancelVerify()
@@ -780,9 +853,11 @@ 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)
_ = manager.rebootForProfileSwitch(resetContext, id)
cancelReset()
if !manager.isPCSCDevice(id) {
resetContext, cancelReset := context.WithTimeout(context.Background(), manager.longTimeout)
_ = manager.rebootForProfileSwitch(resetContext, id)
cancelReset()
}
manager.refreshAfterProfileSwitch(id)
}
@@ -795,6 +870,20 @@ func (manager *Manager) recoverAfterProfileSwitch(id string) {
// the next attempt. All errors are swallowed: this is best-effort self-healing
// and setResult already records the last failure for the UI.
func (manager *Manager) refreshAfterProfileSwitch(id string) {
if manager.isPCSCDevice(id) {
time.Sleep(750 * time.Millisecond)
for attempt := 0; attempt < 10; attempt++ {
ctx, cancel := context.WithTimeout(context.Background(), manager.commandTimeout*4)
_, _ = manager.Discover(ctx)
_, err := manager.Refresh(ctx, id)
cancel()
if err == nil {
return
}
time.Sleep(time.Second)
}
return
}
const (
settle = 8 * time.Second
interval = 4 * time.Second
@@ -819,6 +908,14 @@ func (manager *Manager) refreshAfterProfileSwitch(id string) {
}
}
func (manager *Manager) isPCSCDevice(id string) bool {
state, err := manager.lookup(id)
if err != nil {
return false
}
return manager.candidateFor(state).HardwareKind == pcsc.HardwareKind
}
// enableProfileResult extracts the EnableProfile result code (tag 80) from the
// ES10c response body. ok is false when no result code is present.
func enableProfileResult(payload []byte) (int, bool) {
@@ -888,25 +985,37 @@ func (manager *Manager) verifySwitchedICCID(ctx context.Context, id, expected st
var lastICCID string
var lastErr error
for attempt := 0; attempt < attempts; attempt++ {
for _, command := range []string{"AT+CCID", "AT+QCCID"} {
commandContext, cancel := context.WithTimeout(ctx, manager.commandTimeout)
response, err := manager.ExecuteAT(commandContext, id, command)
cancel()
if err != nil {
lastErr = err
continue
if manager.isPCSCDevice(id) {
snapshot, err := manager.Refresh(ctx, id)
if err == nil {
lastICCID = strings.TrimSpace(snapshot.ICCID)
if lastICCID == expected {
return nil
}
err = fmt.Errorf("reader still reports ICCID %s", lastICCID)
}
live := parseICCIDIdentifier(response, []string{"+CCID:", "+QCCID:"}, 18, 22)
if live == "" {
lastErr = errors.New("modem response contained no valid ICCID")
continue
lastErr = err
} else {
for _, command := range []string{"AT+CCID", "AT+QCCID"} {
commandContext, cancel := context.WithTimeout(ctx, manager.commandTimeout)
response, err := manager.ExecuteAT(commandContext, id, command)
cancel()
if err != nil {
lastErr = err
continue
}
live := parseICCIDIdentifier(response, []string{"+CCID:", "+QCCID:"}, 18, 22)
if live == "" {
lastErr = errors.New("modem response contained no valid ICCID")
continue
}
lastICCID = live
if live == expected {
return nil
}
lastErr = fmt.Errorf("modem still reports ICCID %s", live)
break
}
lastICCID = live
if live == expected {
return nil
}
lastErr = fmt.Errorf("modem still reports ICCID %s", live)
break
}
if attempt+1 < attempts {
select {
+2 -2
View File
@@ -69,8 +69,8 @@ func (manager *Manager) ESIMDeleteProfile(ctx context.Context, id, iccid, aidHex
if err != nil {
return nil, err
}
manager.esimMu.Lock()
defer manager.esimMu.Unlock()
manager.lockESIM()
defer manager.unlockESIM()
if err := manager.waitForESIMRecovery(ctx, id); err != nil {
return nil, err
}
+2 -2
View File
@@ -63,8 +63,8 @@ func (manager *Manager) ESIMDisableProfile(ctx context.Context, id, iccid, aidHe
if err != nil {
return err
}
manager.esimMu.Lock()
defer manager.esimMu.Unlock()
manager.lockESIM()
defer manager.unlockESIM()
if err := manager.waitForESIMRecovery(ctx, id); err != nil {
return err
}
+6 -6
View File
@@ -48,8 +48,8 @@ func (manager *Manager) ESIMDownloadProfile(ctx context.Context, id string, para
}
}
manager.esimMu.Lock()
defer manager.esimMu.Unlock()
manager.lockESIM()
defer manager.unlockESIM()
report("preflight", "正在检查 eUICC 剩余空间...", 10)
channel, err := manager.openEuiccAID(ctx, id, targetEuiccAID(params.AIDHex))
@@ -228,8 +228,8 @@ type EsimChipInfo struct {
// ESIMChipInfo reads the eUICC's EID, EUICCInfo2, and configured addresses for
// the chip header. It takes the eSIM lock like the other card ops.
func (manager *Manager) ESIMChipInfo(ctx context.Context, id string) (*EsimChipInfo, error) {
manager.esimMu.Lock()
defer manager.esimMu.Unlock()
manager.lockESIM()
defer manager.unlockESIM()
var lastErr error
for _, aid := range manager.discoverEuiccAIDs(ctx, id) {
@@ -286,8 +286,8 @@ func readEsimChipInfo(ctx context.Context, channel *euiccChannel, aidHex string)
// the inserted card. It is entirely read-only: only SELECT, GetProfilesInfo,
// GetEuiccData, GetEuiccInfo2 and GetEuiccConfiguredAddresses are issued.
func (manager *Manager) ESIMInventory(ctx context.Context, id string) ([]EsimInventoryEntry, error) {
manager.esimMu.Lock()
defer manager.esimMu.Unlock()
manager.lockESIM()
defer manager.unlockESIM()
if manager.esimRecoveryActive(id) {
return nil, errESIMRecovering
}
+9 -1
View File
@@ -52,7 +52,7 @@ func (channel *euiccChannel) storeDataChained(ctx context.Context, derRequest []
if err != nil {
return nil, err
}
if sw != 0x9000 {
if !es10StatusOK(sw) {
return nil, fmt.Errorf("%w: SW=%04X", errESIMSW, sw)
}
assembled = append(assembled, payload...)
@@ -62,6 +62,14 @@ func (channel *euiccChannel) storeDataChained(ctx context.Context, derRequest []
return assembled, nil
}
// 91xx is a successful UICC result with a proactive SIM Toolkit command
// pending. EnableProfile commonly returns it on direct PC/SC transports because
// the requested refresh is delivered to the terminal rather than consumed by
// modem firmware. Resetting the card after the operation applies that refresh.
func es10StatusOK(sw int) bool {
return sw == 0x9000 || sw>>8 == 0x91
}
// getEUICCChallenge (ES10c, BF2E) returns the eUICC challenge bytes.
func (channel *euiccChannel) getEUICCChallenge(ctx context.Context) ([]byte, error) {
payload, err := channel.es10(ctx, []byte{0xBF, 0x2E, 0x00})
+13
View File
@@ -155,3 +155,16 @@ func TestEuiccFreeNVRAM(t *testing.T) {
t.Fatalf("expected ok=false when extCardResource absent")
}
}
func TestES10StatusAcceptsProactiveRefresh(t *testing.T) {
for _, status := range []int{0x9000, 0x9100, 0x910B, 0x91FF} {
if !es10StatusOK(status) {
t.Fatalf("status %04X should be successful", status)
}
}
for _, status := range []int{0x6A82, 0x6985, 0x9200} {
if es10StatusOK(status) {
t.Fatalf("status %04X should fail", status)
}
}
}
+4 -4
View File
@@ -295,8 +295,8 @@ func (channel *euiccChannel) deliverPendingNotifications(ctx context.Context) er
// ESIMNotifications returns the notifications retained across every eUICC
// storage exposed by the physical card.
func (manager *Manager) ESIMNotifications(ctx context.Context, id string) ([]EsimNotification, error) {
manager.esimMu.Lock()
defer manager.esimMu.Unlock()
manager.lockESIM()
defer manager.unlockESIM()
if err := manager.waitForESIMRecovery(ctx, id); err != nil {
return nil, err
}
@@ -331,8 +331,8 @@ func (manager *Manager) ESIMNotifications(ctx context.Context, id string) ([]Esi
// ESIMRetryNotification sends one retained notification and removes it from the
// eUICC only after the receiver returns the SGP.22 success acknowledgement.
func (manager *Manager) ESIMRetryNotification(ctx context.Context, id, aidHex string, sequenceNumber uint64) error {
manager.esimMu.Lock()
defer manager.esimMu.Unlock()
manager.lockESIM()
defer manager.unlockESIM()
if err := manager.waitForESIMRecovery(ctx, id); err != nil {
return err
}
+2 -2
View File
@@ -49,8 +49,8 @@ func (manager *Manager) ESIMRenameProfile(ctx context.Context, id, iccid, nickna
if err != nil {
return err
}
manager.esimMu.Lock()
defer manager.esimMu.Unlock()
manager.lockESIM()
defer manager.unlockESIM()
if err := manager.waitForESIMRecovery(ctx, id); err != nil {
return err
}
+40
View File
@@ -294,6 +294,46 @@ func TestEUICCChannelStuckWrapsTransientCME(t *testing.T) {
}
}
func TestOpenEuiccRecoversOrphanedSingleLogicalChannel(t *testing.T) {
client := &transcriptClient{steps: []clientStep{
{
command: `AT+CSIM=10,"0070000001"`,
response: okResponse(`+CSIM: 6,"006A81"`),
},
{
command: `AT+CSIM=10,"0070800100"`,
response: okResponse(`+CSIM: 4,"9000"`),
},
{
command: `AT+CSIM=10,"0070000001"`,
response: okResponse(`+CSIM: 6,"019000"`),
},
{
command: fmt.Sprintf(
`AT+CSIM=42,"01A4040010%s"`,
isdRAID,
),
response: okResponse(`+CSIM: 4,"9000"`),
},
{
command: `AT+CSIM=10,"0070800100"`,
response: okResponse(`+CSIM: 4,"9000"`),
},
}}
manager, id := newStartedTestManager(t, client)
manager.lockESIM()
channel, err := manager.openEuiccAID(context.Background(), id, isdRAID)
if err == nil {
channel.close(context.Background())
}
manager.unlockESIM()
if err != nil {
t.Fatalf("open eUICC after orphaned channel: %v", err)
}
client.assertDone(t)
}
func TestWaitForESIMRecovery(t *testing.T) {
done := make(chan struct{})
manager := &Manager{esimRecoveries: map[string]chan struct{}{"dev": done}}
+95 -4
View File
@@ -10,6 +10,7 @@ import (
"time"
"vocat/internal/modem"
"vocat/internal/pcsc"
)
type Options struct {
@@ -19,10 +20,12 @@ type Options struct {
LongTimeout time.Duration
SMSTimeout time.Duration
ScanTimeout time.Duration
CardReaders *pcsc.Service
}
type Manager struct {
mu sync.RWMutex
uiccMu sync.Mutex // serializes all multi-command UICC/APDU transactions
esimMu sync.Mutex // serializes eSIM card access (list/switch/download)
esimRecoveryMu sync.Mutex
esimRecoveries map[string]chan struct{}
@@ -34,11 +37,29 @@ type Manager struct {
longTimeout time.Duration
smsTimeout time.Duration
scanTimeout time.Duration
cardReaders *pcsc.Service
started bool
devices map[string]*managedDevice
ussdSessions map[string]ussdSession
}
// LockUICC and UnlockUICC allow another in-process UICC client (currently the
// VoWiFi AKA adapter) to share the same transaction boundary as eSIM ES10.
// Individual AT commands are already serialized per modem, but a logical-
// channel transaction spans several commands and must not be interleaved.
func (manager *Manager) LockUICC() { manager.uiccMu.Lock() }
func (manager *Manager) UnlockUICC() { manager.uiccMu.Unlock() }
func (manager *Manager) lockESIM() {
manager.esimMu.Lock()
manager.uiccMu.Lock()
}
func (manager *Manager) unlockESIM() {
manager.uiccMu.Unlock()
manager.esimMu.Unlock()
}
// ussdSession tracks an open USSD dialog on a device so a follow-up Continue or
// Cancel can be routed back to the right modem. The modem owns the actual
// network session; this map only records which device a session id belongs to.
@@ -59,6 +80,7 @@ type managedDevice struct {
discovered bool
preFlightMode *int
resetClientOnLock bool
simPIN string
}
func NewManager(options Options) (*Manager, error) {
@@ -82,6 +104,9 @@ func NewManager(options Options) (*Manager, error) {
// AT+COPS=? can take well over a minute while the modem sweeps every band.
options.ScanTimeout = 150 * time.Second
}
if options.CardReaders == nil {
options.CardReaders = pcsc.New()
}
return &Manager{
discoverer: options.Discoverer,
opener: options.Opener,
@@ -89,6 +114,7 @@ func NewManager(options Options) (*Manager, error) {
longTimeout: options.LongTimeout,
smsTimeout: options.SMSTimeout,
scanTimeout: options.ScanTimeout,
cardReaders: options.CardReaders,
devices: make(map[string]*managedDevice),
ussdSessions: make(map[string]ussdSession),
esimRecoveries: make(map[string]chan struct{}),
@@ -146,9 +172,23 @@ func (manager *Manager) Discover(ctx context.Context) ([]Device, error) {
if ctx == nil {
ctx = context.Background()
}
candidates, err := manager.discoverer.Discover(ctx)
if err != nil {
return nil, err
candidates, modemErr := manager.discoverer.Discover(ctx)
readers, readerErr := manager.cardReaders.Readers(ctx)
if readerErr == nil {
for _, reader := range readers {
candidates = append(candidates, modem.Candidate{
ID: pcsc.DeviceID(reader), HardwareKind: pcsc.HardwareKind,
ReaderName: reader.Name, USBPath: reader.USBPath,
VendorID: reader.VendorID, ProductID: reader.ProductID,
Manufacturer: reader.Manufacturer, Product: reader.Product,
})
}
}
if modemErr != nil && readerErr != nil && !errors.Is(readerErr, pcsc.ErrUnsupported) && !errors.Is(readerErr, pcsc.ErrUnavailable) {
return nil, errors.Join(modemErr, readerErr)
}
if modemErr != nil && len(candidates) == 0 {
return nil, modemErr
}
seen := make(map[string]struct{}, len(candidates))
@@ -360,6 +400,9 @@ func (manager *Manager) Refresh(ctx context.Context, id string) (Snapshot, error
return Snapshot{}, err
}
candidate := manager.candidateFor(state)
if candidate.HardwareKind == pcsc.HardwareKind {
return manager.refreshCardReader(ctx, id, state, candidate)
}
backend := manager.backendFor(state)
client, err := manager.clientLocked(ctx, state, candidate)
if err != nil {
@@ -375,11 +418,59 @@ func (manager *Manager) Refresh(ctx context.Context, id string) (Snapshot, error
return snapshot, err
}
func (manager *Manager) refreshCardReader(ctx context.Context, id string, state *managedDevice, candidate modem.Candidate) (Snapshot, error) {
result := Snapshot{
DeviceID: id, Port: candidate.ReaderName, Responsive: true,
Manufacturer: candidate.Manufacturer, Model: candidate.Product,
AccessTech: "Wi-Fi", RegistrationSource: "pcsc", OperatingMode: 4,
ModeKnown: true, FlightMode: true, RadioOff: true, UpdatedAt: time.Now().UTC(),
}
previousICCID := state.lastICCID
card, err := manager.cardReaders.Snapshot(ctx, pcsc.Selector{USBPath: candidate.USBPath, ReaderName: candidate.ReaderName}, state.simPIN)
if err != nil {
switch {
case errors.Is(err, pcsc.ErrNoCard):
result.SIMStatus = ""
err = nil
case errors.Is(err, pcsc.ErrPINRequired), errors.Is(err, pcsc.ErrPINTriesLow), errors.Is(err, pcsc.ErrPINRejected):
result.SIMStatus = "SIM PIN"
result.Warnings = []string{err.Error()}
err = nil
default:
manager.setResult(id, state, &result, err)
return result, err
}
} else {
result.SIMStatus = "READY"
result.SIMReady = true
result.ICCID = card.Identity.ICCID
result.IMSI = card.Identity.IMSI
result.SPN = card.Identity.SPN
result.SIMChanged = previousICCID != "" && !strings.EqualFold(previousICCID, result.ICCID)
state.lastICCID = result.ICCID
}
manager.setResult(id, state, &result, err)
return result, err
}
// SetSIMPin updates the in-memory PIN used for protected USIM files and AKA.
// It is deliberately never retained in runtime snapshots or logs.
func (manager *Manager) SetSIMPin(id, pin string) error {
manager.mu.Lock()
defer manager.mu.Unlock()
state := manager.devices[id]
if state == nil || !state.discovered {
return ErrNotFound
}
state.simPIN = strings.TrimSpace(pin)
return nil
}
// SetBackend selects which control plane supplies registration and data state.
// AT remains available in either mode for UICC, RF, SMS, voice and diagnostics.
func (manager *Manager) SetBackend(id, backend string) error {
backend = strings.ToLower(strings.TrimSpace(backend))
if backend != "at" && backend != "qmi" {
if backend != "at" && backend != "qmi" && backend != "pcsc" {
return fmt.Errorf("unsupported device backend %q", backend)
}
manager.mu.Lock()
+37
View File
@@ -6,8 +6,45 @@ import (
"testing"
"vocat/internal/modem"
"vocat/internal/pcsc"
)
type testPCSCBackend struct{ readers []pcsc.Reader }
func (backend testPCSCBackend) Readers(context.Context) ([]pcsc.Reader, error) {
return append([]pcsc.Reader(nil), backend.readers...), nil
}
func (testPCSCBackend) Open(context.Context, pcsc.Selector) (pcsc.Card, error) {
return nil, pcsc.ErrNoCard
}
func TestManagerDiscoversWiFiCallingOnlyReaderWithoutATPort(t *testing.T) {
manager, err := NewManager(Options{
Discoverer: staticDiscoverer{}, Opener: &staticOpener{},
CardReaders: pcsc.NewWithBackend(testPCSCBackend{readers: []pcsc.Reader{{
Name: "Alcor Link AK9563 00 00", USBPath: "1-3", Product: "AK9563",
}}}),
})
if err != nil {
t.Fatal(err)
}
if err := manager.Start(context.Background()); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = manager.Stop(context.Background()) })
items := manager.List()
if len(items) != 1 || items[0].Candidate.HardwareKind != pcsc.HardwareKind || items[0].Candidate.HasATPort() {
t.Fatalf("discovered readers = %#v", items)
}
snapshot, err := manager.Refresh(context.Background(), items[0].ID)
if err != nil {
t.Fatal(err)
}
if !snapshot.Responsive || snapshot.SIMReady || snapshot.SIMStatus != "" || !snapshot.FlightMode {
t.Fatalf("reader snapshot = %#v", snapshot)
}
}
func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
client := &transcriptClient{steps: []clientStep{
{
+1
View File
@@ -11,6 +11,7 @@ var (
ErrNotStarted = errors.New("device manager is not started")
ErrNotFound = errors.New("device not found")
ErrNoATPort = errors.New("device has no usable AT port")
ErrUnsupportedCapability = errors.New("device does not support this capability")
ErrSMSPromptUnsupported = errors.New("device AT client does not support SMS prompt mode")
ErrSMSInvalidRecipient = errors.New("invalid SMS recipient")
ErrSMSEmpty = errors.New("SMS text is empty")
+2
View File
@@ -44,6 +44,8 @@ func (p Port) OpenPath() string {
}
type Candidate struct {
HardwareKind string `json:"hardwareKind,omitempty"`
ReaderName string `json:"readerName,omitempty"`
ID string `json:"id"`
VendorID string `json:"vendorId"`
ProductID string `json:"productId"`
+238
View File
@@ -0,0 +1,238 @@
//go:build linux && (amd64 || arm64)
package pcsc
import (
"context"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
type nativeBackend struct{}
func newNativeBackend() Backend { return &nativeBackend{} }
func (backend *nativeBackend) dial(ctx context.Context) (*pcscdClient, error) {
paths := []string{strings.TrimSpace(os.Getenv("PCSCLITE_CSOCK_NAME")), "/run/pcscd/pcscd.comm", "/var/run/pcscd/pcscd.comm"}
var failures []error
seen := make(map[string]bool)
for _, path := range paths {
if path == "" || seen[path] {
continue
}
seen[path] = true
conn, err := (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, "unix", path)
if err != nil {
failures = append(failures, err)
continue
}
client, err := establishPCSCD(ctx, conn)
if err == nil {
return client, nil
}
_ = conn.Close()
failures = append(failures, err)
}
return nil, fmt.Errorf("%w: pcscd socket is not reachable: %w", ErrUnavailable, errors.Join(failures...))
}
func (backend *nativeBackend) Readers(ctx context.Context) ([]Reader, error) {
client, err := backend.dial(ctx)
if err != nil {
return nil, err
}
defer client.closeContext(context.Background())
states, err := client.readers(ctx)
if err != nil {
return nil, err
}
readers := make([]Reader, 0, len(states))
for _, state := range states {
reader := Reader{
Name: state.name,
CardPresent: state.state&pcscCardPresent != 0,
ATR: strings.ToUpper(hex.EncodeToString(state.atr)),
}
if path, ok := backend.readerUSBPath(ctx, client, state.name); ok {
reader.USBPath = path
reader.VendorID = readSysfsText(path, "idVendor")
reader.ProductID = readSysfsText(path, "idProduct")
reader.Manufacturer = readSysfsText(path, "manufacturer")
reader.Product = readSysfsText(path, "product")
} else {
reader.USBPath = "pcsc:" + state.name
}
if reader.Product == "" {
reader.Product = strings.TrimSpace(strings.TrimSuffix(state.name, " 00 00"))
}
readers = append(readers, reader)
}
return readers, nil
}
func (backend *nativeBackend) readerUSBPath(ctx context.Context, client *pcscdClient, name string) (string, bool) {
card, _, err := client.connect(ctx, name, pcscShareDirect, 0)
if err != nil {
return "", false
}
disposition := uint32(pcscLeaveCard)
defer client.simpleCardCommand(context.Background(), pcscCmdDisconnect, card, &disposition)
attribute, err := client.getAttrib(ctx, card, pcscAttrChannelID)
if err != nil || len(attribute) < 4 {
return "", false
}
channel := binary.LittleEndian.Uint32(attribute[:4])
if channel>>16 != 0x0020 {
return "", false
}
bus, device := int((channel>>8)&0xff), int(channel&0xff)
entries, err := os.ReadDir("/sys/bus/usb/devices")
if err != nil {
return "", false
}
for _, entry := range entries {
if !entry.IsDir() && entry.Type()&os.ModeSymlink == 0 {
continue
}
path := filepath.Join("/sys/bus/usb/devices", entry.Name())
entryBus, busErr := readSysfsInt(path, "busnum")
entryDevice, deviceErr := readSysfsInt(path, "devnum")
if busErr == nil && deviceErr == nil && entryBus == bus && entryDevice == device {
return entry.Name(), true
}
}
return "", false
}
func (backend *nativeBackend) Open(ctx context.Context, selector Selector) (Card, error) {
readers, err := backend.Readers(ctx)
if err != nil {
return nil, err
}
reader, ok := matchReader(readers, selector)
if !ok {
return nil, ErrReaderNotFound
}
if !reader.CardPresent {
return nil, ErrNoCard
}
client, err := backend.dial(ctx)
if err != nil {
return nil, err
}
handle, protocol, err := client.connect(ctx, reader.Name, pcscShareShared, pcscProtocolAny)
if err != nil {
_ = client.closeContext(context.Background())
return nil, err
}
if err := client.simpleCardCommand(ctx, pcscCmdBeginTransaction, handle, nil); err != nil {
disposition := uint32(pcscLeaveCard)
_ = client.simpleCardCommand(context.Background(), pcscCmdDisconnect, handle, &disposition)
_ = client.closeContext(context.Background())
return nil, fmt.Errorf("pcsc: begin card transaction: %w", err)
}
return &nativeCard{client: client, handle: handle, protocol: protocol}, nil
}
type nativeCard struct {
client *pcscdClient
handle int32
protocol uint32
closed bool
}
func (card *nativeCard) Transmit(ctx context.Context, command []byte) ([]byte, uint16, error) {
if card == nil || card.client == nil || card.closed {
return nil, 0, errors.New("pcsc: card session is closed")
}
return card.transmit(ctx, append([]byte(nil), command...), 0)
}
func (card *nativeCard) TransmitRaw(ctx context.Context, command []byte) ([]byte, uint16, error) {
if card == nil || card.client == nil || card.closed {
return nil, 0, errors.New("pcsc: card session is closed")
}
response, err := card.client.transmit(ctx, card.handle, card.protocol, append([]byte(nil), command...))
if err != nil {
return nil, 0, err
}
if len(response) < 2 {
return nil, 0, errors.New("pcsc: APDU response omitted its status word")
}
last := len(response) - 2
return append([]byte(nil), response[:last]...), uint16(response[last])<<8 | uint16(response[last+1]), nil
}
func (card *nativeCard) transmit(ctx context.Context, command []byte, depth int) ([]byte, uint16, error) {
if depth > 8 {
return nil, 0, errors.New("pcsc: too many APDU continuations")
}
data, status, err := card.TransmitRaw(ctx, command)
if err != nil {
return nil, 0, err
}
sw1, sw2 := byte(status>>8), byte(status)
if sw1 == 0x6c && len(command) >= 5 {
retry := append([]byte(nil), command...)
retry[len(retry)-1] = sw2
return card.transmit(ctx, retry, depth+1)
}
if sw1 == 0x61 || sw1 == 0x9f {
more, sw, err := card.transmit(ctx, []byte{0x00, 0xc0, 0x00, 0x00, sw2}, depth+1)
if err != nil {
return nil, 0, err
}
return append(data, more...), sw, nil
}
return data, status, ctx.Err()
}
func (card *nativeCard) Close() error { return card.close(pcscLeaveCard) }
func (card *nativeCard) CloseWithReset() error { return card.close(pcscResetCard) }
func (card *nativeCard) close(disposition uint32) error {
if card == nil || card.closed {
return nil
}
card.closed = true
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var result []error
if card.client != nil {
if err := card.client.simpleCardCommand(ctx, pcscCmdEndTransaction, card.handle, &disposition); err != nil {
result = append(result, err)
}
if err := card.client.simpleCardCommand(ctx, pcscCmdDisconnect, card.handle, &disposition); err != nil {
result = append(result, err)
}
if err := card.client.closeContext(ctx); err != nil {
result = append(result, err)
}
}
return errors.Join(result...)
}
func readSysfsText(usbPath, name string) string {
value, err := os.ReadFile(filepath.Join("/sys/bus/usb/devices", usbPath, name))
if err != nil {
return ""
}
return strings.TrimSpace(string(value))
}
func readSysfsInt(path, name string) (int, error) {
value, err := os.ReadFile(filepath.Join(path, name))
if err != nil {
return 0, err
}
return strconv.Atoi(strings.TrimSpace(string(value)))
}
+17
View File
@@ -0,0 +1,17 @@
//go:build !linux || (!amd64 && !arm64)
package pcsc
import "context"
type unsupportedBackend struct{}
func newNativeBackend() Backend { return unsupportedBackend{} }
func (unsupportedBackend) Readers(context.Context) ([]Reader, error) {
return nil, ErrUnsupported
}
func (unsupportedBackend) Open(context.Context, Selector) (Card, error) {
return nil, ErrUnsupported
}
+334
View File
@@ -0,0 +1,334 @@
package pcsc
import (
"context"
"encoding/binary"
"errors"
"fmt"
"io"
"net"
"time"
)
// pcsc-lite exposes a small, versioned protocol over its local Unix socket.
// Speaking that protocol directly keeps VoCat's Linux binaries fully static;
// loading libpcsclite through dlopen would pull a glibc interpreter into an
// otherwise CGO-free build and make it unusable on musl-based routers.
const (
pcscProtocolMajor = 4
pcscProtocolCurrentMinor = 6
pcscProtocolOldestMinor = 4
pcscCmdEstablishContext = 0x01
pcscCmdReleaseContext = 0x02
pcscCmdConnect = 0x04
pcscCmdDisconnect = 0x06
pcscCmdBeginTransaction = 0x07
pcscCmdEndTransaction = 0x08
pcscCmdTransmit = 0x09
pcscCmdGetAttrib = 0x0f
pcscCmdVersion = 0x11
pcscCmdGetReadersState = 0x12
pcscScopeSystem = 0x0002
pcscProtocolT0 = 0x0001
pcscProtocolT1 = 0x0002
pcscProtocolAny = pcscProtocolT0 | pcscProtocolT1
pcscShareShared = 0x0002
pcscShareDirect = 0x0003
pcscLeaveCard = 0x0000
pcscResetCard = 0x0001
pcscCardPresent = 0x0004
pcscAttrChannelID = 0x00020110
pcscMaxReaderName = 128
pcscMaxATR = 33
pcscMaxReaders = 16
pcscReaderStateSize = 184
pcscGetSetBodySize = 280
pcscMaxAttribute = 264
pcscMaxAPDUResponse = 65548
pcscDefaultIOTimeout = 30 * time.Second
pcscSuccess = uint32(0)
pcscNoSmartcard = uint32(0x8010000c)
pcscNoService = uint32(0x8010001d)
pcscServiceStopped = uint32(0x8010001e)
pcscNoReaders = uint32(0x8010002e)
)
type pcscdClient struct {
conn net.Conn
contextID uint32
serverMinor int32
}
type pcscdReaderState struct {
name string
state uint32
atr []byte
protocol uint32
}
func establishPCSCD(ctx context.Context, conn net.Conn) (*pcscdClient, error) {
client := &pcscdClient{conn: conn}
version := make([]byte, 12)
binary.LittleEndian.PutUint32(version[0:4], pcscProtocolMajor)
binary.LittleEndian.PutUint32(version[4:8], pcscProtocolCurrentMinor)
for {
if err := client.exchange(ctx, pcscCmdVersion, version); err != nil {
return nil, err
}
major := int32(binary.LittleEndian.Uint32(version[0:4]))
client.serverMinor = int32(binary.LittleEndian.Uint32(version[4:8]))
rv := binary.LittleEndian.Uint32(version[8:12])
if rv == pcscSuccess {
break
}
if rv != pcscServiceStopped || major != pcscProtocolMajor || client.serverMinor < pcscProtocolOldestMinor || client.serverMinor >= pcscProtocolCurrentMinor {
return nil, pcscError("negotiate protocol", rv)
}
// pcsc-lite answers a newer client's first probe with its own
// compatible minor version. Retry on the same connection with that
// value, matching libpcsclite's official fallback behavior.
binary.LittleEndian.PutUint32(version[0:4], pcscProtocolMajor)
binary.LittleEndian.PutUint32(version[4:8], uint32(client.serverMinor))
binary.LittleEndian.PutUint32(version[8:12], pcscSuccess)
}
if client.serverMinor < pcscProtocolOldestMinor {
return nil, fmt.Errorf("pcsc: unsupported pcscd protocol %d.%d", pcscProtocolMajor, client.serverMinor)
}
body := make([]byte, 12)
binary.LittleEndian.PutUint32(body[0:4], pcscScopeSystem)
if err := client.exchange(ctx, pcscCmdEstablishContext, body); err != nil {
return nil, err
}
if rv := binary.LittleEndian.Uint32(body[8:12]); rv != pcscSuccess {
return nil, pcscError("establish context", rv)
}
client.contextID = binary.LittleEndian.Uint32(body[4:8])
return client, nil
}
func (client *pcscdClient) exchange(ctx context.Context, command uint32, body []byte) error {
if client == nil || client.conn == nil {
return errors.New("pcsc: pcscd connection is closed")
}
if err := client.setDeadline(ctx); err != nil {
return err
}
header := make([]byte, 8)
binary.LittleEndian.PutUint32(header[0:4], uint32(len(body)))
binary.LittleEndian.PutUint32(header[4:8], command)
if err := writeAll(client.conn, header); err != nil {
return fmt.Errorf("pcsc: send command %02x: %w", command, err)
}
if len(body) > 0 {
if err := writeAll(client.conn, body); err != nil {
return fmt.Errorf("pcsc: send command body %02x: %w", command, err)
}
if _, err := io.ReadFull(client.conn, body); err != nil {
return fmt.Errorf("pcsc: receive command %02x: %w", command, err)
}
}
return nil
}
func (client *pcscdClient) send(ctx context.Context, command uint32, body, extra []byte) error {
if client == nil || client.conn == nil {
return errors.New("pcsc: pcscd connection is closed")
}
if err := client.setDeadline(ctx); err != nil {
return err
}
header := make([]byte, 8)
binary.LittleEndian.PutUint32(header[0:4], uint32(len(body)))
binary.LittleEndian.PutUint32(header[4:8], command)
if err := writeAll(client.conn, header); err != nil {
return err
}
if err := writeAll(client.conn, body); err != nil {
return err
}
return writeAll(client.conn, extra)
}
func (client *pcscdClient) setDeadline(ctx context.Context) error {
if err := ctx.Err(); err != nil {
return err
}
deadline := time.Now().Add(pcscDefaultIOTimeout)
if value, ok := ctx.Deadline(); ok && value.Before(deadline) {
deadline = value
}
return client.conn.SetDeadline(deadline)
}
func (client *pcscdClient) readers(ctx context.Context) ([]pcscdReaderState, error) {
if err := client.setDeadline(ctx); err != nil {
return nil, err
}
header := make([]byte, 8)
binary.LittleEndian.PutUint32(header[4:8], pcscCmdGetReadersState)
if err := writeAll(client.conn, header); err != nil {
return nil, fmt.Errorf("pcsc: request reader states: %w", err)
}
raw := make([]byte, pcscMaxReaders*pcscReaderStateSize)
if _, err := io.ReadFull(client.conn, raw); err != nil {
return nil, fmt.Errorf("pcsc: read reader states: %w", err)
}
result := make([]pcscdReaderState, 0, pcscMaxReaders)
for offset := 0; offset < len(raw); offset += pcscReaderStateSize {
state := raw[offset : offset+pcscReaderStateSize]
name := cString(state[:pcscMaxReaderName])
if name == "" {
continue
}
atrLen := int(binary.LittleEndian.Uint32(state[176:180]))
if atrLen < 0 || atrLen > pcscMaxATR {
atrLen = 0
}
result = append(result, pcscdReaderState{
name: name,
state: binary.LittleEndian.Uint32(state[132:136]),
atr: append([]byte(nil), state[140:140+atrLen]...),
protocol: binary.LittleEndian.Uint32(state[180:184]),
})
}
return result, nil
}
func (client *pcscdClient) connect(ctx context.Context, reader string, share, protocols uint32) (int32, uint32, error) {
if len(reader) >= pcscMaxReaderName {
return 0, 0, errors.New("pcsc: reader name is too long")
}
body := make([]byte, 152)
binary.LittleEndian.PutUint32(body[0:4], client.contextID)
copy(body[4:132], reader)
binary.LittleEndian.PutUint32(body[132:136], share)
binary.LittleEndian.PutUint32(body[136:140], protocols)
if err := client.exchange(ctx, pcscCmdConnect, body); err != nil {
return 0, 0, err
}
if rv := binary.LittleEndian.Uint32(body[148:152]); rv != pcscSuccess {
return 0, 0, pcscError("connect reader", rv)
}
return int32(binary.LittleEndian.Uint32(body[140:144])), binary.LittleEndian.Uint32(body[144:148]), nil
}
func (client *pcscdClient) simpleCardCommand(ctx context.Context, command uint32, card int32, disposition *uint32) error {
size := 8
if disposition != nil {
size = 12
}
body := make([]byte, size)
binary.LittleEndian.PutUint32(body[0:4], uint32(card))
if disposition != nil {
binary.LittleEndian.PutUint32(body[4:8], *disposition)
}
if err := client.exchange(ctx, command, body); err != nil {
return err
}
if rv := binary.LittleEndian.Uint32(body[size-4:]); rv != pcscSuccess {
return pcscError("card command", rv)
}
return nil
}
func (client *pcscdClient) transmit(ctx context.Context, card int32, protocol uint32, command []byte) ([]byte, error) {
body := make([]byte, 32)
binary.LittleEndian.PutUint32(body[0:4], uint32(card))
binary.LittleEndian.PutUint32(body[4:8], protocol)
binary.LittleEndian.PutUint32(body[8:12], 8)
binary.LittleEndian.PutUint32(body[12:16], uint32(len(command)))
binary.LittleEndian.PutUint32(body[16:20], pcscProtocolAny)
binary.LittleEndian.PutUint32(body[20:24], 8)
binary.LittleEndian.PutUint32(body[24:28], pcscMaxAPDUResponse)
if err := client.send(ctx, pcscCmdTransmit, body, command); err != nil {
return nil, fmt.Errorf("pcsc: transmit APDU: %w", err)
}
if _, err := io.ReadFull(client.conn, body); err != nil {
return nil, fmt.Errorf("pcsc: receive APDU result: %w", err)
}
if rv := binary.LittleEndian.Uint32(body[28:32]); rv != pcscSuccess {
return nil, pcscError("transmit APDU", rv)
}
length := binary.LittleEndian.Uint32(body[24:28])
if length > pcscMaxAPDUResponse {
return nil, errors.New("pcsc: pcscd returned an oversized APDU")
}
response := make([]byte, length)
if _, err := io.ReadFull(client.conn, response); err != nil {
return nil, fmt.Errorf("pcsc: receive APDU: %w", err)
}
return response, nil
}
func (client *pcscdClient) getAttrib(ctx context.Context, card int32, attribute uint32) ([]byte, error) {
body := make([]byte, pcscGetSetBodySize)
binary.LittleEndian.PutUint32(body[0:4], uint32(card))
binary.LittleEndian.PutUint32(body[4:8], attribute)
binary.LittleEndian.PutUint32(body[272:276], pcscMaxAttribute)
if err := client.exchange(ctx, pcscCmdGetAttrib, body); err != nil {
return nil, err
}
if rv := binary.LittleEndian.Uint32(body[276:280]); rv != pcscSuccess {
return nil, pcscError("get reader attribute", rv)
}
length := binary.LittleEndian.Uint32(body[272:276])
if length > pcscMaxAttribute {
return nil, errors.New("pcsc: pcscd returned an oversized attribute")
}
return append([]byte(nil), body[8:8+length]...), nil
}
func (client *pcscdClient) closeContext(ctx context.Context) error {
if client == nil || client.conn == nil {
return nil
}
body := make([]byte, 8)
binary.LittleEndian.PutUint32(body[0:4], client.contextID)
err := client.exchange(ctx, pcscCmdReleaseContext, body)
if err == nil {
if rv := binary.LittleEndian.Uint32(body[4:8]); rv != pcscSuccess {
err = pcscError("release context", rv)
}
}
closeErr := client.conn.Close()
client.conn = nil
return errors.Join(err, closeErr)
}
func pcscError(operation string, code uint32) error {
switch code {
case pcscNoSmartcard:
return ErrNoCard
case pcscNoService, pcscServiceStopped:
return fmt.Errorf("%w: %s failed with PC/SC status %08X", ErrUnavailable, operation, code)
case pcscNoReaders:
return ErrReaderNotFound
default:
return fmt.Errorf("pcsc: %s failed with status %08X", operation, code)
}
}
func cString(value []byte) string {
for index, current := range value {
if current == 0 {
return string(value[:index])
}
}
return string(value)
}
func writeAll(writer io.Writer, value []byte) error {
for len(value) > 0 {
written, err := writer.Write(value)
if err != nil {
return err
}
if written == 0 {
return io.ErrUnexpectedEOF
}
value = value[written:]
}
return nil
}
+127
View File
@@ -0,0 +1,127 @@
package pcsc
import (
"bytes"
"context"
"encoding/binary"
"errors"
"io"
"net"
"testing"
)
func TestPCSCDClientLifecycleAndTransmit(t *testing.T) {
clientConn, serverConn := net.Pipe()
serverDone := make(chan error, 1)
go func() {
defer serverConn.Close()
serverDone <- servePCSCDTestSession(serverConn)
}()
client, err := establishPCSCD(context.Background(), clientConn)
if err != nil {
t.Fatalf("establishPCSCD: %v", err)
}
states, err := client.readers(context.Background())
if err != nil {
t.Fatalf("readers: %v", err)
}
if len(states) != 1 || states[0].name != "VoCat Test Reader 00 00" || states[0].state&pcscCardPresent == 0 {
t.Fatalf("states = %#v", states)
}
handle, protocol, err := client.connect(context.Background(), states[0].name, pcscShareShared, pcscProtocolAny)
if err != nil {
t.Fatalf("connect: %v", err)
}
if handle != 42 || protocol != pcscProtocolT1 {
t.Fatalf("handle/protocol = %d/%d", handle, protocol)
}
if err := client.simpleCardCommand(context.Background(), pcscCmdBeginTransaction, handle, nil); err != nil {
t.Fatalf("begin: %v", err)
}
response, err := client.transmit(context.Background(), handle, protocol, []byte{0x00, 0xa4, 0x00, 0x00})
if err != nil {
t.Fatalf("transmit: %v", err)
}
if !bytes.Equal(response, []byte{0x62, 0x02, 0x90, 0x00}) {
t.Fatalf("response = %x", response)
}
disposition := uint32(pcscLeaveCard)
if err := client.simpleCardCommand(context.Background(), pcscCmdEndTransaction, handle, &disposition); err != nil {
t.Fatalf("end: %v", err)
}
if err := client.simpleCardCommand(context.Background(), pcscCmdDisconnect, handle, &disposition); err != nil {
t.Fatalf("disconnect: %v", err)
}
if err := client.closeContext(context.Background()); err != nil {
t.Fatalf("close context: %v", err)
}
if err := <-serverDone; err != nil {
t.Fatalf("fake pcscd: %v", err)
}
}
func servePCSCDTestSession(conn net.Conn) error {
for {
header := make([]byte, 8)
if _, err := io.ReadFull(conn, header); err != nil {
return err
}
size := binary.LittleEndian.Uint32(header[0:4])
command := binary.LittleEndian.Uint32(header[4:8])
body := make([]byte, size)
if _, err := io.ReadFull(conn, body); err != nil {
return err
}
switch command {
case pcscCmdVersion:
binary.LittleEndian.PutUint32(body[0:4], pcscProtocolMajor)
binary.LittleEndian.PutUint32(body[4:8], pcscProtocolCurrentMinor)
if err := writeAll(conn, body); err != nil {
return err
}
case pcscCmdEstablishContext:
binary.LittleEndian.PutUint32(body[4:8], 7)
if err := writeAll(conn, body); err != nil {
return err
}
case pcscCmdGetReadersState:
states := make([]byte, pcscMaxReaders*pcscReaderStateSize)
copy(states, "VoCat Test Reader 00 00")
binary.LittleEndian.PutUint32(states[132:136], pcscCardPresent)
copy(states[140:143], []byte{0x3b, 0x00, 0x00})
binary.LittleEndian.PutUint32(states[176:180], 3)
binary.LittleEndian.PutUint32(states[180:184], pcscProtocolT1)
if err := writeAll(conn, states); err != nil {
return err
}
case pcscCmdConnect:
binary.LittleEndian.PutUint32(body[140:144], 42)
binary.LittleEndian.PutUint32(body[144:148], pcscProtocolT1)
if err := writeAll(conn, body); err != nil {
return err
}
case pcscCmdBeginTransaction, pcscCmdEndTransaction, pcscCmdDisconnect:
if err := writeAll(conn, body); err != nil {
return err
}
case pcscCmdTransmit:
commandBody := make([]byte, binary.LittleEndian.Uint32(body[12:16]))
if _, err := io.ReadFull(conn, commandBody); err != nil {
return err
}
response := []byte{0x62, 0x02, 0x90, 0x00}
binary.LittleEndian.PutUint32(body[24:28], uint32(len(response)))
if err := writeAll(conn, body); err != nil {
return err
}
if err := writeAll(conn, response); err != nil {
return err
}
case pcscCmdReleaseContext:
return writeAll(conn, body)
default:
return errors.New("unexpected fake pcscd command")
}
}
}
+610
View File
@@ -0,0 +1,610 @@
package pcsc
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strings"
"sync"
)
const usimAIDPrefix = "A0000000871002"
type Service struct {
mu sync.Mutex
backend Backend
}
// Session is an exclusive connection to one smart card. It is used by eUICC
// operations which must keep the same PC/SC transaction and logical channel
// alive across a sequence of APDUs.
type Session struct {
service *Service
card Card
closed bool
}
func New() *Service {
return &Service{backend: newNativeBackend()}
}
func NewWithBackend(backend Backend) *Service {
return &Service{backend: backend}
}
func DeviceID(reader Reader) string {
identity := strings.TrimSpace(reader.USBPath)
if identity == "" {
identity = strings.TrimSpace(reader.Name)
}
sum := sha256.Sum256([]byte(identity))
return "reader-" + hex.EncodeToString(sum[:8])
}
func (service *Service) Readers(ctx context.Context) ([]Reader, error) {
if service == nil || service.backend == nil {
return nil, ErrUnavailable
}
service.mu.Lock()
defer service.mu.Unlock()
return service.backend.Readers(ctx)
}
// OpenSession opens one card and holds the service lock until Close. Callers
// must close the returned session; this prevents AKA/identity reads from
// interleaving with a stateful ES10 transaction.
func (service *Service) OpenSession(ctx context.Context, selector Selector) (*Session, error) {
if service == nil || service.backend == nil {
return nil, ErrUnavailable
}
if err := selector.validate(); err != nil {
return nil, err
}
service.mu.Lock()
card, err := service.backend.Open(ctx, selector)
if err != nil {
service.mu.Unlock()
return nil, err
}
return &Session{service: service, card: card}, nil
}
// Transmit sends one raw APDU. Unlike the ordinary SIM helpers, it leaves
// 61xx continuation handling to the eUICC logical-channel implementation so
// GET RESPONSE uses the correct channel CLA.
func (session *Session) Transmit(ctx context.Context, command []byte) ([]byte, uint16, error) {
if session == nil || session.card == nil || session.closed {
return nil, 0, errors.New("pcsc: card session is closed")
}
if raw, ok := session.card.(interface {
TransmitRaw(context.Context, []byte) ([]byte, uint16, error)
}); ok {
return raw.TransmitRaw(ctx, command)
}
return session.card.Transmit(ctx, command)
}
func (session *Session) Close() error {
return session.close(false)
}
// CloseWithReset resets the card while releasing the PC/SC connection. eUICC
// EnableProfile requires this refresh boundary before the newly enabled USIM
// application and ICCID become visible to subsequent callers.
func (session *Session) CloseWithReset() error {
return session.close(true)
}
func (session *Session) close(reset bool) error {
if session == nil || session.closed {
return nil
}
session.closed = true
var err error
if resetter, ok := session.card.(interface{ CloseWithReset() error }); reset && ok {
err = resetter.CloseWithReset()
} else {
err = session.card.Close()
}
session.service.mu.Unlock()
return err
}
func (service *Service) Snapshot(ctx context.Context, selector Selector, pin string) (Snapshot, error) {
readers, err := service.Readers(ctx)
if err != nil {
return Snapshot{}, err
}
reader, ok := matchReader(readers, selector)
if !ok {
return Snapshot{}, ErrReaderNotFound
}
result := Snapshot{Reader: reader}
if !reader.CardPresent {
return result, ErrNoCard
}
identity, err := service.ReadIdentity(ctx, selector, pin)
result.Identity = identity
return result, err
}
func (service *Service) ReadIdentity(ctx context.Context, selector Selector, pin string) (Identity, error) {
if service == nil || service.backend == nil {
return Identity{}, ErrUnavailable
}
if err := selector.validate(); err != nil {
return Identity{}, err
}
service.mu.Lock()
defer service.mu.Unlock()
card, err := service.backend.Open(ctx, selector)
if err != nil {
return Identity{}, err
}
defer card.Close()
return readIdentity(ctx, card, pin)
}
func (service *Service) CheckReady(
ctx context.Context,
selector Selector,
expectedICCID string,
pin string,
) (string, error) {
if service == nil || service.backend == nil {
return "", ErrUnavailable
}
service.mu.Lock()
defer service.mu.Unlock()
card, err := service.backend.Open(ctx, selector)
if err != nil {
return "", err
}
defer card.Close()
iccid, err := readICCID(ctx, card)
if err != nil {
return "", err
}
if expected := strings.TrimSpace(expectedICCID); expected != "" && !strings.EqualFold(expected, iccid) {
return "", ErrCardChanged
}
aid, err := selectUSIM(ctx, card)
if err != nil {
return "", err
}
if err := verifyPIN(ctx, card, pin); err != nil {
return "", err
}
return strings.ToUpper(hex.EncodeToString(aid)), nil
}
func (service *Service) Authenticate(
ctx context.Context,
selector Selector,
expectedICCID string,
pin string,
challenge AKAChallenge,
) (AKAResult, error) {
if service == nil || service.backend == nil {
return AKAResult{}, ErrUnavailable
}
service.mu.Lock()
defer service.mu.Unlock()
card, err := service.backend.Open(ctx, selector)
if err != nil {
return AKAResult{}, err
}
defer card.Close()
iccid, err := readICCID(ctx, card)
if err != nil {
return AKAResult{}, err
}
if expected := strings.TrimSpace(expectedICCID); expected != "" && !strings.EqualFold(expected, iccid) {
return AKAResult{}, ErrCardChanged
}
if _, err := selectUSIM(ctx, card); err != nil {
return AKAResult{}, err
}
if err := verifyPIN(ctx, card, pin); err != nil {
return AKAResult{}, err
}
apdu := make([]byte, 0, 40)
apdu = append(apdu, 0x00, 0x88, 0x00, 0x81, 0x22, 0x10)
apdu = append(apdu, challenge.RAND[:]...)
apdu = append(apdu, 0x10)
apdu = append(apdu, challenge.AUTN[:]...)
apdu = append(apdu, 0x00)
data, sw, err := card.Transmit(ctx, apdu)
if err != nil {
return AKAResult{}, errors.New("pcsc: USIM authentication transport failed")
}
if sw == 0x9862 {
return AKAResult{}, ErrAKARejected
}
if sw != 0x9000 {
return AKAResult{}, fmt.Errorf("pcsc: USIM authentication failed with status %04X", sw)
}
return parseAKAResponse(data)
}
func matchReader(readers []Reader, selector Selector) (Reader, bool) {
path := strings.TrimSpace(selector.USBPath)
name := strings.TrimSpace(selector.ReaderName)
for _, reader := range readers {
if path != "" && reader.USBPath == path {
return reader, true
}
}
for _, reader := range readers {
if name != "" && reader.Name == name {
return reader, true
}
}
return Reader{}, false
}
func readIdentity(ctx context.Context, card Card, pin string) (Identity, error) {
identity := Identity{PINTries: -1}
iccid, err := readICCID(ctx, card)
if err != nil {
return identity, err
}
identity.ICCID = iccid
aid, err := selectUSIM(ctx, card)
if err != nil {
return identity, err
}
identity.USIMAID = append([]byte(nil), aid...)
if err := verifyPIN(ctx, card, pin); err != nil {
identity.PINRequired = errors.Is(err, ErrPINRequired) || errors.Is(err, ErrPINTriesLow)
var pinErr *PINError
if errors.As(err, &pinErr) {
identity.PINTries = pinErr.Tries
}
return identity, err
}
if err := selectFile(ctx, card, []byte{0x6F, 0x07}); err != nil {
return identity, fmt.Errorf("pcsc: select EF_IMSI: %w", err)
}
imsiData, err := readBinary(ctx, card, 9)
if err != nil {
return identity, fmt.Errorf("pcsc: read EF_IMSI: %w", err)
}
identity.IMSI, err = decodeIMSI(imsiData)
if err != nil {
return identity, err
}
if _, selectErr := selectApplication(ctx, card, aid); selectErr == nil {
if selectErr = selectFile(ctx, card, []byte{0x6F, 0xAD}); selectErr == nil {
if data, readErr := readBinary(ctx, card, 4); readErr == nil && len(data) >= 4 {
length := int(data[3] & 0x0f)
if length == 2 || length == 3 {
identity.MNCLength = length
}
}
}
}
if _, selectErr := selectApplication(ctx, card, aid); selectErr == nil {
identity.SPN = readSPN(ctx, card)
}
if _, selectErr := selectApplication(ctx, card, aid); selectErr == nil {
identity.SMSC = readSMSC(ctx, card)
}
return identity, nil
}
func readICCID(ctx context.Context, card Card) (string, error) {
if err := selectMF(ctx, card); err != nil {
return "", err
}
if err := selectFile(ctx, card, []byte{0x2F, 0xE2}); err != nil {
return "", fmt.Errorf("pcsc: select EF_ICCID: %w", err)
}
data, err := readBinary(ctx, card, 10)
if err != nil {
return "", fmt.Errorf("pcsc: read EF_ICCID: %w", err)
}
value := decodeSwappedBCD(data, false)
if len(value) < 18 || len(value) > 22 {
return "", errors.New("pcsc: card returned an invalid ICCID")
}
return value, nil
}
func selectUSIM(ctx context.Context, card Card) ([]byte, error) {
if err := selectMF(ctx, card); err != nil {
return nil, err
}
if err := selectFile(ctx, card, []byte{0x2F, 0x00}); err != nil {
return nil, fmt.Errorf("pcsc: select EF_DIR: %w", err)
}
var usimAID []byte
for record := 1; record <= 32; record++ {
data, sw, err := card.Transmit(ctx, []byte{0x00, 0xB2, byte(record), 0x04, 0x00})
if err != nil {
return nil, err
}
if sw == 0x6A83 || sw == 0x9402 {
break
}
if sw != 0x9000 {
continue
}
aid := findTLV(data, 0x4F)
if len(aid) == 0 {
continue
}
if strings.HasPrefix(strings.ToUpper(hex.EncodeToString(aid)), usimAIDPrefix) {
usimAID = append([]byte(nil), aid...)
break
}
}
if len(usimAID) == 0 {
return nil, ErrUSIMUnavailable
}
if _, err := selectApplication(ctx, card, usimAID); err != nil {
return nil, err
}
return usimAID, nil
}
func selectMF(ctx context.Context, card Card) error {
_, sw, err := card.Transmit(ctx, []byte{0x00, 0xA4, 0x00, 0x04, 0x02, 0x3F, 0x00, 0x00})
return requireStatus("select MF", sw, err)
}
func selectFile(ctx context.Context, card Card, fileID []byte) error {
if len(fileID) != 2 {
return errors.New("pcsc: invalid file identifier")
}
apdu := []byte{0x00, 0xA4, 0x00, 0x04, 0x02, fileID[0], fileID[1], 0x00}
_, sw, err := card.Transmit(ctx, apdu)
return requireStatus("select file", sw, err)
}
func selectApplication(ctx context.Context, card Card, aid []byte) ([]byte, error) {
if len(aid) == 0 || len(aid) > 32 {
return nil, errors.New("pcsc: invalid USIM AID")
}
apdu := []byte{0x00, 0xA4, 0x04, 0x04, byte(len(aid))}
apdu = append(apdu, aid...)
apdu = append(apdu, 0x00)
data, sw, err := card.Transmit(ctx, apdu)
if err := requireStatus("select USIM application", sw, err); err != nil {
return nil, err
}
return data, nil
}
func readBinary(ctx context.Context, card Card, length int) ([]byte, error) {
if length <= 0 || length > 256 {
return nil, errors.New("pcsc: invalid binary read length")
}
le := byte(length)
if length == 256 {
le = 0
}
data, sw, err := card.Transmit(ctx, []byte{0x00, 0xB0, 0x00, 0x00, le})
if err := requireStatus("read binary", sw, err); err != nil {
return nil, err
}
return data, nil
}
func verifyPIN(ctx context.Context, card Card, pin string) error {
pin = strings.TrimSpace(pin)
if pin == "" {
return nil
}
if len(pin) < 4 || len(pin) > 8 || !decimalDigits(pin) {
return errors.New("pcsc: SIM PIN must contain 4 to 8 digits")
}
_, sw, err := card.Transmit(ctx, []byte{0x00, 0x20, 0x00, 0x01, 0x00})
if err != nil {
return errors.New("pcsc: SIM PIN status check failed")
}
if sw == 0x9000 {
return nil
}
tries := -1
if sw&0xFFF0 == 0x63C0 {
tries = int(sw & 0x000F)
if tries <= 2 {
return &PINError{Kind: ErrPINTriesLow, Tries: tries}
}
}
body := bytes.Repeat([]byte{0xFF}, 8)
copy(body, []byte(pin))
apdu := append([]byte{0x00, 0x20, 0x00, 0x01, 0x08}, body...)
_, sw, err = card.Transmit(ctx, apdu)
if err != nil {
return errors.New("pcsc: SIM PIN verification transport failed")
}
if sw == 0x9000 {
return nil
}
if sw&0xFFF0 == 0x63C0 {
return &PINError{Kind: ErrPINRejected, Tries: int(sw & 0x000F)}
}
return ErrPINRejected
}
func requireStatus(operation string, sw uint16, err error) error {
if err != nil {
return fmt.Errorf("pcsc: %s transport failed", operation)
}
if sw == 0x9000 {
return nil
}
if sw == 0x6982 || sw == 0x9804 {
return &PINError{Kind: ErrPINRequired, Tries: -1}
}
return fmt.Errorf("pcsc: %s failed with status %04X", operation, sw)
}
func decodeSwappedBCD(value []byte, dropFirstNibble bool) string {
var result strings.Builder
for _, octet := range value {
for _, nibble := range []byte{octet & 0x0F, octet >> 4} {
if dropFirstNibble {
dropFirstNibble = false
continue
}
if nibble == 0x0F {
return result.String()
}
if nibble > 9 {
return ""
}
result.WriteByte('0' + nibble)
}
}
return result.String()
}
func decodeIMSI(data []byte) (string, error) {
if len(data) < 2 {
return "", errors.New("pcsc: EF_IMSI is too short")
}
length := int(data[0])
if length <= 0 || length > len(data)-1 {
return "", errors.New("pcsc: EF_IMSI has an invalid length")
}
value := decodeSwappedBCD(data[1:1+length], true)
if len(value) < 10 || len(value) > 18 || !decimalDigits(value) {
return "", errors.New("pcsc: card returned an invalid IMSI")
}
return value, nil
}
func decimalDigits(value string) bool {
if value == "" {
return false
}
for _, character := range value {
if character < '0' || character > '9' {
return false
}
}
return true
}
func findTLV(data []byte, wanted byte) []byte {
for len(data) >= 2 {
tag := data[0]
data = data[1:]
length, consumed, ok := decodeTLVLength(data)
if !ok || consumed+length > len(data) {
return nil
}
value := data[consumed : consumed+length]
if tag == wanted {
return append([]byte(nil), value...)
}
if tag&0x20 != 0 {
if nested := findTLV(value, wanted); len(nested) > 0 {
return nested
}
}
data = data[consumed+length:]
}
return nil
}
func decodeTLVLength(data []byte) (length, consumed int, ok bool) {
if len(data) == 0 {
return 0, 0, false
}
if data[0]&0x80 == 0 {
return int(data[0]), 1, true
}
count := int(data[0] & 0x7F)
if count < 1 || count > 2 || len(data) < 1+count {
return 0, 0, false
}
length = 0
for _, octet := range data[1 : 1+count] {
length = length<<8 | int(octet)
}
return length, 1 + count, true
}
func parseAKAResponse(data []byte) (AKAResult, error) {
if len(data) < 2 {
return AKAResult{}, errors.New("pcsc: USIM returned a short AKA response")
}
switch data[0] {
case 0xDB:
res, rest, ok := takeLV(data[1:])
if !ok || len(res) < 4 || len(res) > 16 {
return AKAResult{}, errors.New("pcsc: USIM returned an invalid AKA RES")
}
ck, rest, ok := takeLV(rest)
if !ok || len(ck) != 16 {
return AKAResult{}, errors.New("pcsc: USIM returned an invalid AKA CK")
}
ik, rest, ok := takeLV(rest)
if !ok || len(ik) != 16 {
return AKAResult{}, errors.New("pcsc: USIM returned an invalid AKA IK")
}
if len(rest) > 0 {
kc, tail, valid := takeLV(rest)
if !valid || len(kc) != 8 || len(tail) != 0 {
return AKAResult{}, errors.New("pcsc: USIM returned invalid trailing AKA material")
}
}
return AKAResult{RES: append([]byte(nil), res...), CK: append([]byte(nil), ck...), IK: append([]byte(nil), ik...)}, nil
case 0xDC:
auts, tail, ok := takeLV(data[1:])
if !ok || len(auts) != 14 || len(tail) != 0 {
return AKAResult{}, errors.New("pcsc: USIM returned invalid AKA synchronization evidence")
}
return AKAResult{AUTS: append([]byte(nil), auts...), SynchronizationFailure: true}, nil
default:
return AKAResult{}, errors.New("pcsc: USIM returned an unsupported AKA response")
}
}
func takeLV(data []byte) (value, rest []byte, ok bool) {
if len(data) == 0 || int(data[0]) > len(data)-1 {
return nil, data, false
}
length := int(data[0])
return data[1 : 1+length], data[1+length:], true
}
func readSPN(ctx context.Context, card Card) string {
if err := selectFile(ctx, card, []byte{0x6F, 0x46}); err != nil {
return ""
}
data, err := readBinary(ctx, card, 17)
if err != nil || len(data) < 2 {
return ""
}
return strings.TrimSpace(strings.TrimRight(string(data[1:]), "\x00\xFF"))
}
func readSMSC(ctx context.Context, card Card) string {
if err := selectFile(ctx, card, []byte{0x6F, 0x42}); err != nil {
return ""
}
data, sw, err := card.Transmit(ctx, []byte{0x00, 0xB2, 0x01, 0x04, 0x00})
if err != nil || sw != 0x9000 || len(data) < 15 {
return ""
}
sca := data[len(data)-15 : len(data)-3]
if len(sca) < 2 || sca[0] < 2 || int(sca[0]) > len(sca)-1 {
return ""
}
digits := decodeSwappedBCD(sca[2:1+int(sca[0])], false)
if !decimalDigits(digits) {
return ""
}
if sca[1]&0x70 == 0x10 {
return "+" + digits
}
return digits
}
+84
View File
@@ -0,0 +1,84 @@
package pcsc
import (
"bytes"
"context"
"errors"
"testing"
)
type scriptedReply struct {
data []byte
sw uint16
}
type scriptedCard struct {
replies []scriptedReply
calls [][]byte
}
func (card *scriptedCard) Transmit(_ context.Context, command []byte) ([]byte, uint16, error) {
card.calls = append(card.calls, append([]byte(nil), command...))
if len(card.replies) == 0 {
return nil, 0, errors.New("unexpected APDU")
}
reply := card.replies[0]
card.replies = card.replies[1:]
return append([]byte(nil), reply.data...), reply.sw, nil
}
func (*scriptedCard) Close() error { return nil }
func TestDecodeIdentifiers(t *testing.T) {
if got := decodeSwappedBCD([]byte{0x98, 0x10, 0x32, 0x54, 0xF6}, false); got != "890123456" {
t.Fatalf("ICCID BCD = %q", got)
}
imsi, err := decodeIMSI([]byte{0x08, 0x19, 0x32, 0x54, 0x76, 0x98, 0x10, 0x32, 0x54})
if err != nil {
t.Fatal(err)
}
if imsi != "123456789012345" {
t.Fatalf("IMSI = %q", imsi)
}
}
func TestVerifyPINRefusesLowAttemptCount(t *testing.T) {
card := &scriptedCard{replies: []scriptedReply{{sw: 0x63C2}}}
err := verifyPIN(context.Background(), card, "1234")
if !errors.Is(err, ErrPINTriesLow) {
t.Fatalf("error = %v", err)
}
if len(card.calls) != 1 {
t.Fatalf("APDU calls = %d, PIN must not be submitted", len(card.calls))
}
}
func TestParseAKAResponse(t *testing.T) {
data := []byte{0xDB, 0x08, 1, 2, 3, 4, 5, 6, 7, 8, 0x10}
data = append(data, bytes.Repeat([]byte{0xAA}, 16)...)
data = append(data, 0x10)
data = append(data, bytes.Repeat([]byte{0xBB}, 16)...)
result, err := parseAKAResponse(data)
if err != nil {
t.Fatal(err)
}
if len(result.RES) != 8 || len(result.CK) != 16 || len(result.IK) != 16 || result.SynchronizationFailure {
t.Fatalf("unexpected AKA result: %#v", result)
}
syncResult, err := parseAKAResponse(append([]byte{0xDC, 0x0E}, bytes.Repeat([]byte{0xCC}, 14)...))
if err != nil {
t.Fatal(err)
}
if !syncResult.SynchronizationFailure || len(syncResult.AUTS) != 14 {
t.Fatalf("unexpected sync result: %#v", syncResult)
}
}
func TestDeviceIDUsesStableUSBPath(t *testing.T) {
a := DeviceID(Reader{Name: "reader 00 00", USBPath: "1-3"})
b := DeviceID(Reader{Name: "renamed reader", USBPath: "1-3"})
if a != b || a == "" {
t.Fatalf("device IDs = %q, %q", a, b)
}
}
+107
View File
@@ -0,0 +1,107 @@
package pcsc
import (
"context"
"errors"
"fmt"
"strings"
)
const HardwareKind = "pcsc"
var (
ErrUnsupported = errors.New("pcsc: platform is not supported")
ErrUnavailable = errors.New("pcsc: service is unavailable")
ErrReaderNotFound = errors.New("pcsc: reader not found")
ErrNoCard = errors.New("pcsc: no card is inserted")
ErrPINRequired = errors.New("pcsc: SIM PIN is required")
ErrPINTriesLow = errors.New("pcsc: refusing PIN verification because too few attempts remain")
ErrPINRejected = errors.New("pcsc: SIM PIN was rejected")
ErrUSIMUnavailable = errors.New("pcsc: no usable USIM application was found")
ErrCardChanged = errors.New("pcsc: card identity changed during authentication")
ErrAKARejected = errors.New("pcsc: USIM rejected the network authentication token")
)
type Reader struct {
Name string
USBPath string
VendorID string
ProductID string
Manufacturer string
Product string
CardPresent bool
ATR string
}
type Selector struct {
USBPath string
ReaderName string
}
func (selector Selector) validate() error {
if strings.TrimSpace(selector.USBPath) == "" && strings.TrimSpace(selector.ReaderName) == "" {
return errors.New("pcsc: reader selector is empty")
}
return nil
}
type Identity struct {
ICCID string
IMSI string
MNCLength int
USIMAID []byte
SMSC string
SPN string
PINRequired bool
PINTries int
}
type Snapshot struct {
Reader Reader
Identity Identity
}
type AKAChallenge struct {
RAND [16]byte
AUTN [16]byte
}
type AKAResult struct {
RES []byte
CK []byte
IK []byte
AUTS []byte
SynchronizationFailure bool
}
type PINError struct {
Kind error
Tries int
}
func (err *PINError) Error() string {
if err == nil {
return "pcsc: SIM PIN error"
}
if err.Tries >= 0 {
return fmt.Sprintf("%v (%d attempts remain)", err.Kind, err.Tries)
}
return err.Kind.Error()
}
func (err *PINError) Unwrap() error {
if err == nil {
return nil
}
return err.Kind
}
type Card interface {
Transmit(context.Context, []byte) ([]byte, uint16, error)
Close() error
}
type Backend interface {
Readers(context.Context) ([]Reader, error)
Open(context.Context, Selector) (Card, error)
}
+49 -1
View File
@@ -231,6 +231,9 @@ func (s *Server) ensureAutomaticTaskProfile(ctx context.Context, task store.Auto
if err != nil {
return store.Device{}, device.Device{}, "", fmt.Errorf("read device: %w", err)
}
if err := validateAutomaticTaskDeviceCapabilities(config, task.TaskType, task.Environment); err != nil {
return store.Device{}, device.Device{}, "", err
}
entry, physicalID, present := s.physicalForConfig(config)
if !present || entry.Snapshot == nil {
return store.Device{}, device.Device{}, "", errors.New("configured device is offline")
@@ -511,6 +514,25 @@ func (s *Server) restoreAutomaticTaskEnvironment(physicalID string, snapshot aut
if err := s.store.UpsertDevice(cleanupContext, config); err != nil {
restoreErrors = append(restoreErrors, fmt.Errorf("persist device policy: %w", err))
}
if config.DeviceType == store.DeviceTypeUSBSIMReader {
if s.vowifi == nil {
return errors.Join(append(restoreErrors, errors.New("VoWiFi runtime is unavailable"))...)
}
state, stateErr := s.vowifi.State(config.ID)
if policy.VoWiFiEnabled {
if stateErr == nil && state.Enabled {
_, stateErr = s.vowifi.RequestReconnect(config.ID)
} else {
_, stateErr = s.vowifi.RequestEnabled(config.ID, true)
}
} else if stateErr == nil && (state.Enabled || state.Active) {
_, stateErr = s.vowifi.RequestEnabled(config.ID, false)
}
if stateErr != nil {
restoreErrors = append(restoreErrors, fmt.Errorf("restore reader VoWiFi: %w", stateErr))
}
return errors.Join(restoreErrors...)
}
if policy.VoWiFiEnabled {
if _, err := s.devices.SetNetwork(cleanupContext, physicalID, s.cardNetworkRequest(cleanupContext, physicalID, config, policy, false)); err != nil {
@@ -710,6 +732,15 @@ func (s *Server) handleAutomaticTaskRunNow(w http.ResponseWriter, r *http.Reques
s.writeStoreError(w, err)
return
}
config, err := s.store.Device(r.Context(), task.DeviceID)
if err != nil {
s.writeStoreError(w, err)
return
}
if err := validateAutomaticTaskDeviceCapabilities(config, task.TaskType, task.Environment); err != nil {
writeError(w, http.StatusConflict, "wifi_calling_only_device", err.Error())
return
}
run, err := s.store.QueueAutomaticTaskNow(r.Context(), task)
if err != nil {
s.writeStoreError(w, err)
@@ -745,7 +776,8 @@ func (s *Server) decodeAutomaticTask(r *http.Request, id int64) (store.Automatic
if request.Name == "" || request.DeviceID == "" || request.ProfileICCID == "" {
return store.AutomaticTask{}, errors.New("name, device, and eSIM profile are required")
}
if _, err := s.store.Device(r.Context(), request.DeviceID); err != nil {
selectedDevice, err := s.store.Device(r.Context(), request.DeviceID)
if err != nil {
return store.AutomaticTask{}, errors.New("selected device does not exist")
}
if request.Environment != "vowifi" && request.Environment != "cellular" {
@@ -757,6 +789,9 @@ func (s *Server) decodeAutomaticTask(r *http.Request, id int64) (store.Automatic
if request.TaskType == "public_ip" && request.Environment != "cellular" {
return store.AutomaticTask{}, errors.New("public IP tasks must use cellular direct mode")
}
if err := validateAutomaticTaskDeviceCapabilities(selectedDevice, request.TaskType, request.Environment); err != nil {
return store.AutomaticTask{}, err
}
if request.IntervalDays < 1 || request.IntervalDays > 365 || request.RetryCount < 0 || request.RetryCount > 10 {
return store.AutomaticTask{}, errors.New("interval_days must be 1-365 and retry_count must be 0-10")
}
@@ -796,6 +831,19 @@ func (s *Server) decodeAutomaticTask(r *http.Request, id int64) (store.Automatic
return task, nil
}
func validateAutomaticTaskDeviceCapabilities(config store.Device, taskType, environment string) error {
if config.DeviceType != store.DeviceTypeUSBSIMReader {
return nil
}
if environment != "vowifi" {
return errors.New("USB SIM reader tasks must use the VoWiFi environment")
}
if taskType != "sms" && taskType != "call" {
return errors.New("USB SIM readers support only VoWiFi SMS and call tasks")
}
return nil
}
func nextAutomaticRun(date, clock string, intervalDays int, now time.Time) (time.Time, error) {
location := now.Location()
start, err := time.ParseInLocation("2006-01-02 15:04", strings.TrimSpace(date)+" "+strings.TrimSpace(clock), location)
+21
View File
@@ -3,6 +3,8 @@ package server
import (
"testing"
"time"
"vocat/internal/store"
)
func TestNextAutomaticRunUsesIntervalAndLocalClock(t *testing.T) {
@@ -18,6 +20,25 @@ func TestNextAutomaticRunUsesIntervalAndLocalClock(t *testing.T) {
}
}
func TestUSBSIMReaderAutomaticTasksRequireVoWiFi(t *testing.T) {
reader := store.Device{DeviceType: store.DeviceTypeUSBSIMReader}
for _, test := range []struct {
taskType string
environment string
wantError bool
}{
{taskType: "sms", environment: "vowifi"},
{taskType: "call", environment: "vowifi"},
{taskType: "sms", environment: "cellular", wantError: true},
{taskType: "public_ip", environment: "cellular", wantError: true},
} {
err := validateAutomaticTaskDeviceCapabilities(reader, test.taskType, test.environment)
if (err != nil) != test.wantError {
t.Errorf("type=%s environment=%s error=%v", test.taskType, test.environment, err)
}
}
}
func TestAutomaticSMSRetrySafetyPreventsDuplicateSubmission(t *testing.T) {
unsafe := []byte(`{"data":{"parts_attempted":1,"parts_accepted":1,"retry_safe":false}}`)
if automaticSMSRetrySafe(unsafe) {
+67 -3
View File
@@ -66,6 +66,7 @@ type deviceConfigPayload struct {
USBPath string `json:"usb_path"`
AudioDevice string `json:"audio_device"`
ModemIMEI string `json:"modem_imei"`
SIMPIN string `json:"sim_pin"`
APN string `json:"apn"`
ProxyPort int `json:"proxy_port"`
BaudRate int `json:"baud_rate"`
@@ -97,6 +98,7 @@ func (payload deviceConfigPayload) toStoreDevice() store.Device {
USBPath: strings.TrimSpace(payload.USBPath),
AudioDevice: strings.TrimSpace(payload.AudioDevice),
ModemIMEI: strings.TrimSpace(payload.ModemIMEI),
SIMPIN: strings.TrimSpace(payload.SIMPIN),
APN: strings.TrimSpace(payload.APN),
ProxyPort: payload.ProxyPort,
BaudRate: payload.BaudRate,
@@ -246,6 +248,12 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) bool {
config.NetworkEnabled = false
}
fillConfigFromPhysical(&config, *selected)
if pinSetter, ok := s.devices.(interface{ SetSIMPin(string, string) error }); ok {
if err := pinSetter.SetSIMPin(selected.ID, config.SIMPIN); err != nil {
s.writeDeviceError(w, err)
return true
}
}
if selector, ok := s.devices.(interface{ SetBackend(string, string) error }); ok {
if err := selector.SetBackend(selected.ID, config.DeviceBackend); err != nil {
s.writeDeviceError(w, err)
@@ -363,6 +371,8 @@ func (s *Server) handleDiscoveredDevices(w http.ResponseWriter, r *http.Request)
}
}
result = append(result, map[string]any{
"hardware_kind": candidate.HardwareKind,
"reader_name": candidate.ReaderName,
"discovery_key": entry.ID,
"control_path": controlPath,
"net_interface": candidate.NetworkInterface,
@@ -374,10 +384,10 @@ func (s *Server) handleDiscoveredDevices(w http.ResponseWriter, r *http.Request)
"at_port": candidate.ATPort.OpenPath(),
"imei": snapshotString(entry.Snapshot, func(snapshot *device.Snapshot) string { return snapshot.IMEI }),
"mode": backendMode(candidate),
"network_capable": candidate.NetworkInterface != "" || candidate.QMIControl != "",
"network_capable": candidate.HardwareKind != "pcsc" && (candidate.NetworkInterface != "" || candidate.QMIControl != ""),
"configured": configuredID != "",
"configured_id": configuredID,
"degraded": !candidate.HasATPort(),
"degraded": candidate.HardwareKind != "pcsc" && !candidate.HasATPort(),
})
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"devices": result}})
@@ -459,7 +469,16 @@ func (s *Server) handleDevicePath(
if next.Name == id && strings.TrimSpace(payload.Name) == "" {
next.Name = config.Name
}
if next.SIMPIN == "" || next.SIMPIN == store.SecretMask {
next.SIMPIN = config.SIMPIN
}
if _, physicalID, present := s.physicalForConfig(next); present {
if pinSetter, ok := s.devices.(interface{ SetSIMPin(string, string) error }); ok {
if err := pinSetter.SetSIMPin(physicalID, next.SIMPIN); err != nil {
s.writeDeviceError(w, err)
return true
}
}
if selector, ok := s.devices.(interface{ SetBackend(string, string) error }); ok {
if err := selector.SetBackend(physicalID, next.DeviceBackend); err != nil {
s.writeDeviceError(w, err)
@@ -482,6 +501,16 @@ func (s *Server) handleDevicePath(
}
entry, physicalID, physicalPresent := s.physicalForConfig(config)
if config.DeviceType == store.DeviceTypeUSBSIMReader && len(tail) > 0 {
operation := strings.Join(tail, "/")
unsupported := tail[0] == "network" || tail[0] == "operator_selection" ||
operation == "actions/at" || operation == "actions/ussd" || operation == "actions/ussd/continue" ||
operation == "actions/ussd/cancel" || operation == "actions/reboot" || operation == "usbnet-mode"
if unsupported {
writeError(w, http.StatusConflict, "wifi_calling_only_device", "USB SIM readers support WiFi Calling, IMS SMS and calls only")
return true
}
}
if len(tail) > 0 && tail[0] == "esim" {
return s.handleESIM(w, r, tail[1:], physicalID, physicalPresent, config.ID)
}
@@ -1803,6 +1832,10 @@ func deviceStatus(entry device.Device) map[string]any {
}
func storedDeviceConfig(config store.Device) map[string]any {
simPIN := ""
if strings.TrimSpace(config.SIMPIN) != "" {
simPIN = store.SecretMask
}
return map[string]any{
"id": config.ID,
"name": config.Name,
@@ -1813,6 +1846,7 @@ func storedDeviceConfig(config store.Device) map[string]any {
"usb_path": config.USBPath,
"audio_device": config.AudioDevice,
"modem_imei": config.ModemIMEI,
"sim_pin": simPIN,
"apn": config.APN,
"proxy_port": config.ProxyPort,
"baud_rate": config.BaudRate,
@@ -1832,6 +1866,17 @@ func storedDeviceConfig(config store.Device) map[string]any {
func fillConfigFromPhysical(config *store.Device, entry device.Device) {
candidate := entry.Candidate
if candidate.HardwareKind == "pcsc" {
config.DeviceType = store.DeviceTypeUSBSIMReader
config.ControlDevice = candidate.ReaderName
config.ATPort = ""
config.Interface = ""
config.DeviceBackend = "pcsc"
config.ESIMTransport = "pcsc"
config.NetworkEnabled = false
config.SMSEnabled = true
config.VoWiFiEnabled = true
}
if config.Interface == "" {
config.Interface = candidate.NetworkInterface
}
@@ -1914,13 +1959,29 @@ func modemSummary(snapshot *device.Snapshot, phone string, phoneSource string) m
"reg_status": snapshot.RegistrationStatus,
"reg_status_text": registrationText(snapshot),
"ps_attached": snapshot.PSAttached,
"sim_inserted": snapshot.SIMStatus != "",
"sim_inserted": snapshotHasSIM(snapshot),
"operating_mode": snapshot.OperatingMode,
"phone_number": phone,
"phone_number_source": phoneSource,
}
}
func snapshotHasSIM(snapshot *device.Snapshot) bool {
if snapshot == nil {
return false
}
if snapshot.SIMReady || strings.TrimSpace(snapshot.ICCID) != "" || strings.TrimSpace(snapshot.IMSI) != "" {
return true
}
switch strings.ToLower(strings.TrimSpace(snapshot.SIMStatus)) {
case "", "unknown", "not_inserted", "not inserted", "absent":
return false
default:
// PIN/PUK and other explicit UICC states prove that a card is present.
return true
}
}
func idleVoWiFiRuntime(id string, snapshot *device.Snapshot) map[string]any {
iccid := ""
imsi := ""
@@ -1970,6 +2031,9 @@ func deviceName(entry device.Device) string {
}
func backendMode(candidate modem.Candidate) string {
if candidate.HardwareKind == "pcsc" {
return "pcsc"
}
if candidate.QMIControl != "" {
return "qmi"
}
+21
View File
@@ -115,3 +115,24 @@ func TestConfiguredDeviceSummaryMarksIdleRuntimeAsNotInUse(t *testing.T) {
t.Fatalf("summary = %#v", got)
}
}
func TestSnapshotHasSIMDoesNotTreatUnknownStatusAsInserted(t *testing.T) {
for _, snapshot := range []*device.Snapshot{
{IMEI: "867123456789012"},
{IMEI: "867123456789012", SIMStatus: "unknown"},
{IMEI: "867123456789012", SIMStatus: "not_inserted"},
} {
if snapshotHasSIM(snapshot) {
t.Fatalf("snapshot was reported with a SIM: %#v", snapshot)
}
}
for _, snapshot := range []*device.Snapshot{
{SIMStatus: "pin_required"},
{ICCID: "89441000400128014257"},
{SIMReady: true},
} {
if !snapshotHasSIM(snapshot) {
t.Fatalf("snapshot was reported without a SIM: %#v", snapshot)
}
}
}
+21
View File
@@ -1,11 +1,13 @@
package server
import (
"context"
"errors"
"net/http"
"strings"
"vocat/internal/exportproxy"
"vocat/internal/store"
)
func (s *Server) routeExportProxyAPI(w http.ResponseWriter, r *http.Request, cleanPath string) bool {
@@ -33,6 +35,9 @@ func (s *Server) routeExportProxyAPI(w http.ResponseWriter, r *http.Request, cle
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return true
}
if s.rejectUnsupportedExportProxyDevice(w, r.Context(), config.DeviceID) {
return true
}
created, err := s.exportProxy.Create(r.Context(), config)
if err != nil {
s.writeExportProxyError(w, err)
@@ -71,6 +76,9 @@ func (s *Server) routeExportProxyAPI(w http.ResponseWriter, r *http.Request, cle
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return true
}
if s.rejectUnsupportedExportProxyDevice(w, r.Context(), config.DeviceID) {
return true
}
updated, err := s.exportProxy.Update(r.Context(), id, config)
if err != nil {
s.writeExportProxyError(w, err)
@@ -90,6 +98,19 @@ func (s *Server) routeExportProxyAPI(w http.ResponseWriter, r *http.Request, cle
return true
}
func (s *Server) rejectUnsupportedExportProxyDevice(w http.ResponseWriter, ctx context.Context, deviceID string) bool {
config, err := s.store.Device(ctx, strings.TrimSpace(deviceID))
if err != nil {
s.writeStoreError(w, err)
return true
}
if config.DeviceType == store.DeviceTypeUSBSIMReader {
writeError(w, http.StatusConflict, "wifi_calling_only_device", "USB SIM readers cannot export cellular data as a proxy")
return true
}
return false
}
func (s *Server) writeExportProxyError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, exportproxy.ErrDisabled):
@@ -0,0 +1,31 @@
package server
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"vocat/internal/store"
)
func TestExportProxyRejectsUSBSIMReader(t *testing.T) {
database, err := store.Open(context.Background(), ":memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
if err := database.UpsertDevice(context.Background(), store.Device{
ID: "reader-1", Name: "USB SIM Reader", DeviceType: store.DeviceTypeUSBSIMReader,
}); err != nil {
t.Fatal(err)
}
server := &Server{store: database}
response := httptest.NewRecorder()
if !server.rejectUnsupportedExportProxyDevice(response, context.Background(), "reader-1") {
t.Fatal("reader was accepted as an export-proxy device")
}
if response.Code != http.StatusConflict {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
}
+25
View File
@@ -93,3 +93,28 @@ func TestProfileProxyBindingRejectsSameICCIDOnDifferentProxy(t *testing.T) {
t.Fatalf("binding after rejected rebind = %+v, %v", binding, err)
}
}
func TestProfileProxyBindingSupportsPCSCReader(t *testing.T) {
server, database, controller := newProfileBindingTestServer(t)
readerICCID := "89104100000028106378"
if err := database.UpsertDevice(context.Background(), store.Device{
ID: "reader-1", Name: "USB SIM Reader", DeviceType: store.DeviceTypeUSBSIMReader,
}); err != nil {
t.Fatal(err)
}
controller.state = vowifi.State{DeviceID: "reader-1", ICCID: readerICCID, Enabled: true}
response := profileBindingRequest(t, server, http.MethodPost, `{
"upstream_proxy_id":"route-1",
"bindings":[{"device_id":"reader-1","iccid":"89104100000028106378","profile_name":"Reader Profile"}]
}`)
if response.Code != http.StatusOK {
t.Fatalf("POST status = %d, body = %s", response.Code, response.Body.String())
}
binding, err := database.DeviceProxyBinding(context.Background(), readerICCID)
if err != nil || binding.DeviceID != "reader-1" || binding.UpstreamProxyID != "route-1" {
t.Fatalf("reader binding = %+v, %v", binding, err)
}
if controller.reconnects != 1 {
t.Fatalf("reader reconnects = %d, want 1", controller.reconnects)
}
}
+11
View File
@@ -546,6 +546,13 @@ func (s *Server) syncModemSMS(ctx context.Context, onlyDevice string) {
if onlyDevice != "" && config.ID != onlyDevice {
continue
}
// A PC/SC USB reader has no modem storage or AT command channel. Its
// messages are delivered by the active VoWiFi IMS session, so attempting
// an AT+CMGL catch-up scan would only poison the reader's health state
// with ErrNoATPort.
if !supportsModemSMSStorage(config) {
continue
}
// Do not queue CMGL traffic on the same serial actor while VoWiFi is
// reading the SIM or running AKA. Once the session is stable, resume the
// SM/ME scan as a catch-up path: an SMS submitted while the card was
@@ -659,6 +666,10 @@ func (s *Server) syncModemSMS(ctx context.Context, onlyDevice string) {
}
}
func supportsModemSMSStorage(config store.Device) bool {
return store.NormalizeDeviceType(config.DeviceType) != store.DeviceTypeUSBSIMReader
}
func shouldDeferModemSMSSync(state vowifi.State, stateErr error) bool {
if stateErr != nil || !state.Enabled {
return false
+9
View File
@@ -108,6 +108,15 @@ func TestNormalizeSMSDeviceFilter(t *testing.T) {
}
}
func TestSupportsModemSMSStorageRejectsUSBReader(t *testing.T) {
if supportsModemSMSStorage(store.Device{DeviceType: store.DeviceTypeUSBSIMReader}) {
t.Fatal("USB SIM reader must not be polled with modem SMS AT commands")
}
if !supportsModemSMSStorage(store.Device{DeviceType: store.DeviceTypePCIeEC20EC25}) {
t.Fatal("cellular modem should retain modem SMS storage synchronization")
}
}
func TestSMSSendOutcome(t *testing.T) {
tests := []struct {
name string
+24 -7
View File
@@ -17,6 +17,7 @@ const (
DeviceTypeWiFi410 = "wifi_410"
DeviceTypeDJI4G = "dji_4g"
DeviceTypePCIeEC20EC25 = "pcie_ec20_ec25"
DeviceTypeUSBSIMReader = "usb_sim_reader"
)
// NormalizeDeviceType returns a stable persisted device type identifier.
@@ -27,6 +28,8 @@ func NormalizeDeviceType(value string) string {
return DeviceTypeWiFi410
case DeviceTypeDJI4G:
return DeviceTypeDJI4G
case DeviceTypeUSBSIMReader:
return DeviceTypeUSBSIMReader
case "", DeviceTypePCIeEC20EC25:
return DeviceTypePCIeEC20EC25
default:
@@ -132,16 +135,29 @@ func upsertDevice(ctx context.Context, executor contextExecer, value Device) err
value.DeviceBackend = "at"
}
value.DeviceBackend = strings.ToLower(strings.TrimSpace(value.DeviceBackend))
if value.DeviceBackend != "at" && value.DeviceBackend != "qmi" {
if value.DeviceBackend != "at" && value.DeviceBackend != "qmi" && value.DeviceBackend != "pcsc" {
return fmt.Errorf("unsupported device backend %q", value.DeviceBackend)
}
if value.ESIMTransport == "" {
value.ESIMTransport = "at"
}
value.ESIMTransport = strings.ToLower(strings.TrimSpace(value.ESIMTransport))
if value.ESIMTransport != "at" && value.ESIMTransport != "qmi" {
if value.ESIMTransport != "at" && value.ESIMTransport != "qmi" && value.ESIMTransport != "pcsc" && value.ESIMTransport != "none" {
return fmt.Errorf("unsupported eSIM transport %q", value.ESIMTransport)
}
value.SIMPIN = strings.TrimSpace(value.SIMPIN)
if value.SIMPIN != "" {
if len(value.SIMPIN) < 4 || len(value.SIMPIN) > 8 || strings.Trim(value.SIMPIN, "0123456789") != "" {
return errors.New("SIM PIN must contain 4 to 8 digits")
}
}
if value.DeviceType == DeviceTypeUSBSIMReader {
value.DeviceBackend = "pcsc"
value.ESIMTransport = "pcsc"
value.NetworkEnabled = false
value.SMSEnabled = true
value.VoWiFiEnabled = true
}
extra, err := normalizeJSONObject(value.Extra)
if err != nil {
return fmt.Errorf("normalize device extra data: %w", err)
@@ -159,12 +175,12 @@ func upsertDevice(ctx context.Context, executor contextExecer, value Device) err
_, err = executor.ExecContext(ctx, `
INSERT INTO devices (
id, name, device_type, interface, control_device, at_port, usb_path,
audio_device, modem_imei, apn, proxy_port, baud_rate,
audio_device, modem_imei, sim_pin, apn, proxy_port, baud_rate,
data_bits, stop_bits, parity, device_backend, esim_transport,
qmi_use_proxy, qmi_proxy_path, qmi_proxy_executable,
network_enabled, sms_enabled, vowifi_enabled, extra_json,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
device_type = excluded.device_type,
@@ -174,6 +190,7 @@ func upsertDevice(ctx context.Context, executor contextExecer, value Device) err
usb_path = excluded.usb_path,
audio_device = excluded.audio_device,
modem_imei = excluded.modem_imei,
sim_pin = excluded.sim_pin,
apn = excluded.apn,
proxy_port = excluded.proxy_port,
baud_rate = excluded.baud_rate,
@@ -192,7 +209,7 @@ func upsertDevice(ctx context.Context, executor contextExecer, value Device) err
updated_at = excluded.updated_at
`,
value.ID, value.Name, value.DeviceType, value.Interface, value.ControlDevice, value.ATPort,
value.USBPath, value.AudioDevice, value.ModemIMEI, value.APN,
value.USBPath, value.AudioDevice, value.ModemIMEI, value.SIMPIN, value.APN,
value.ProxyPort, value.BaudRate, value.DataBits, value.StopBits,
value.Parity, value.DeviceBackend, value.ESIMTransport,
boolInt(value.QMIUseProxy), value.QMIProxyPath, value.QMIProxyExecutable,
@@ -282,7 +299,7 @@ func (s *Store) DeleteDevice(ctx context.Context, id string) error {
const deviceSelect = `
SELECT id, name, device_type, interface, control_device, at_port, usb_path,
audio_device, modem_imei, apn, proxy_port, baud_rate, data_bits,
audio_device, modem_imei, sim_pin, apn, proxy_port, baud_rate, data_bits,
stop_bits, parity, device_backend, esim_transport, qmi_use_proxy,
qmi_proxy_path, qmi_proxy_executable, network_enabled, sms_enabled,
vowifi_enabled, extra_json, created_at, updated_at
@@ -295,7 +312,7 @@ func scanDevice(row rowScanner) (Device, error) {
var createdAt, updatedAt int64
err := row.Scan(
&value.ID, &value.Name, &value.DeviceType, &value.Interface, &value.ControlDevice,
&value.ATPort, &value.USBPath, &value.AudioDevice, &value.ModemIMEI,
&value.ATPort, &value.USBPath, &value.AudioDevice, &value.ModemIMEI, &value.SIMPIN,
&value.APN, &value.ProxyPort, &value.BaudRate, &value.DataBits,
&value.StopBits, &value.Parity, &value.DeviceBackend,
&value.ESIMTransport, &qmiUseProxy, &value.QMIProxyPath,
+25
View File
@@ -366,6 +366,31 @@ func TestDeviceStateRoundTripAndCascade(t *testing.T) {
}
}
func TestUSBSIMReaderConfigurationIsWiFiCallingOnly(t *testing.T) {
ctx := context.Background()
database := openTestStore(t, ":memory:")
err := database.UpsertDevice(ctx, Device{
ID: "reader-1", Name: "USB SIM", DeviceType: DeviceTypeUSBSIMReader,
USBPath: "1-3", ControlDevice: "Reader 00 00", SIMPIN: "1234",
DeviceBackend: "at", ESIMTransport: "at", NetworkEnabled: true,
})
if err != nil {
t.Fatal(err)
}
got, err := database.Device(ctx, "reader-1")
if err != nil {
t.Fatal(err)
}
if got.DeviceBackend != "pcsc" || got.ESIMTransport != "pcsc" || got.NetworkEnabled || !got.SMSEnabled || !got.VoWiFiEnabled || got.SIMPIN != "1234" {
t.Fatalf("reader config = %+v", got)
}
bad := got
bad.SIMPIN = "12x4"
if err := database.UpsertDevice(ctx, bad); err == nil {
t.Fatal("non-numeric SIM PIN was accepted")
}
}
func TestSMSPersistenceAndDerivedThreads(t *testing.T) {
ctx := context.Background()
database := openTestStore(t, ":memory:")
+4
View File
@@ -260,6 +260,10 @@ func migrationStatements(version int) []string {
`ALTER TABLE card_policies
ADD COLUMN custom_phone_number TEXT NOT NULL DEFAULT ''`,
}
case 16:
return []string{
`ALTER TABLE devices ADD COLUMN sim_pin TEXT NOT NULL DEFAULT ''`,
}
default:
return nil
}
+1
View File
@@ -24,6 +24,7 @@ type Device struct {
USBPath string
AudioDevice string
ModemIMEI string
SIMPIN string
APN string
ProxyPort int
BaudRate int
+3 -2
View File
@@ -13,7 +13,7 @@ import (
_ "modernc.org/sqlite"
)
const schemaVersion = 15
const schemaVersion = 16
var ErrNotFound = errors.New("store: not found")
@@ -122,7 +122,8 @@ func migrate(ctx context.Context, db *sql.DB) error {
// migration are still safe and must be applied.
duplicateAdditiveColumn := (nextVersion == 7 && strings.Contains(statement, "ADD COLUMN modem_imei")) ||
(nextVersion == 8 && strings.Contains(statement, "ADD COLUMN device_type")) ||
(nextVersion == 14 && strings.Contains(statement, "ADD COLUMN"))
(nextVersion == 14 && strings.Contains(statement, "ADD COLUMN")) ||
(nextVersion == 16 && strings.Contains(statement, "ADD COLUMN sim_pin"))
if duplicateAdditiveColumn && strings.Contains(strings.ToLower(err.Error()), "duplicate column name") {
continue
}
+32 -8
View File
@@ -184,6 +184,10 @@ func applyUpdate(ctx context.Context, logger *slog.Logger, opts Options, release
cleanup()
return fmt.Errorf("update: chmod temp binary: %w", err)
}
if err := validateExecutable(ctx, tmpPath); err != nil {
cleanup()
return err
}
if err := backupAndReplace(opts.Target, tmpPath); err != nil {
cleanup()
return err
@@ -202,10 +206,25 @@ func applyUpdate(ctx context.Context, logger *slog.Logger, opts Options, release
return nil
}
// validateExecutable catches incompatible architectures and missing dynamic
// loaders before the working installation is touched. A valid checksum alone
// cannot detect those packaging errors.
func validateExecutable(ctx context.Context, path string) error {
checkCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
output, err := exec.CommandContext(checkCtx, path, "version").CombinedOutput()
if err != nil {
return fmt.Errorf("update: downloaded binary cannot run on this host: %w (%s)", err, strings.TrimSpace(string(output)))
}
if !strings.Contains(strings.ToLower(string(output)), "vocat") {
return fmt.Errorf("update: downloaded binary returned an unexpected version response: %q", strings.TrimSpace(string(output)))
}
return nil
}
// backupAndReplace renames the current binary aside, then moves the verified
// temp file into place. Both renames are atomic on the same filesystem. On
// Linux the kernel holds the running binary's inode, so replacing it mid-flight
// is safe.
// temp file into place. Both renames are atomic on the same filesystem. The
// previous working binary is retained for service-level or manual rollback.
func backupAndReplace(target, tmp string) error {
backup := target + ".previous"
if _, err := os.Stat(target); err == nil {
@@ -221,16 +240,21 @@ func backupAndReplace(target, tmp string) error {
}
return fmt.Errorf("update: move new binary into place: %w", err)
}
_ = os.Remove(backup)
return nil
}
// RestartService restarts the vocat systemd unit. If systemctl is unavailable
// (non-systemd hosts, containers), it returns an error the caller surfaces as
// a non-fatal warning.
// RestartService supports both systemd hosts and OpenWrt/procd routers.
func RestartService(logger *slog.Logger) error {
if _, err := os.Stat("/etc/init.d/vocat"); err == nil {
cmd := exec.Command("/etc/init.d/vocat", "restart")
if out, err := cmd.CombinedOutput(); err != nil {
logger.Warn("OpenWrt service restart failed", "error", err, "output", string(out))
return fmt.Errorf("restart OpenWrt vocat service: %w", err)
}
return nil
}
if _, err := exec.LookPath("systemctl"); err != nil {
return fmt.Errorf("systemctl not found in PATH")
return fmt.Errorf("neither /etc/init.d/vocat nor systemctl is available")
}
// Queue the restart and let systemctl exit before systemd stops this unit.
// A blocking restart command becomes part of vocat.service's own cgroup and
+44
View File
@@ -0,0 +1,44 @@
package update
import (
"context"
"os"
"path/filepath"
"runtime"
"testing"
)
func TestValidateExecutableRejectsNonExecutableFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "not-vocat")
if err := os.WriteFile(path, []byte("not an executable"), 0o755); err != nil {
t.Fatal(err)
}
if err := validateExecutable(context.Background(), path); err == nil {
t.Fatal("validateExecutable accepted invalid file")
}
}
func TestBackupAndReplaceRetainsPreviousBinary(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Linux replacement behavior")
}
directory := t.TempDir()
target := filepath.Join(directory, "vocat")
replacement := filepath.Join(directory, "replacement")
if err := os.WriteFile(target, []byte("old"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(replacement, []byte("new"), 0o755); err != nil {
t.Fatal(err)
}
if err := backupAndReplace(target, replacement); err != nil {
t.Fatal(err)
}
old, err := os.ReadFile(target + ".previous")
if err != nil {
t.Fatalf("read retained backup: %v", err)
}
if string(old) != "old" {
t.Fatalf("backup = %q", old)
}
}
+13
View File
@@ -47,6 +47,11 @@ type EC20SensitiveATExecutor interface {
ExecuteSensitiveAT(context.Context, string, string) (modem.Response, error)
}
type EC20UICCLocker interface {
LockUICC()
UnlockUICC()
}
type EC20AdapterOptions struct {
// PureAirplanePolicy reports the independent user policy. The adapter only
// changes the transactional CFUN projection used by VoWiFi and never
@@ -339,6 +344,10 @@ func (adapter *EC20Adapter) CheckReady(
// cannot insert an APDU between a 61xx response and GET RESPONSE.
adapter.apduMu.Lock()
defer adapter.apduMu.Unlock()
if locker, ok := adapter.executor.(EC20UICCLocker); ok {
locker.LockUICC()
defer locker.UnlockUICC()
}
aid, application, err := adapter.discoverAKAApplication(ctx, binding.deviceID)
if err != nil {
@@ -402,6 +411,10 @@ func (adapter *EC20Adapter) Authenticate(
adapter.apduMu.Lock()
defer adapter.apduMu.Unlock()
if locker, ok := adapter.executor.(EC20UICCLocker); ok {
locker.LockUICC()
defer locker.UnlockUICC()
}
apdu := buildUSIMAuthenticateAPDU(challenge)
var raw []byte
+8
View File
@@ -84,6 +84,14 @@ func runIPCommand(ctx context.Context, command string, operation xfrmOperation)
if message == "" {
message = err.Error()
}
if strings.Contains(strings.ToLower(message), "protocol not supported") ||
strings.Contains(strings.ToLower(message), "operation not supported") {
return fmt.Errorf(
"%s: host kernel lacks XFRM/IPsec support; install matching kmod-ipsec and kmod-ipsec4/6 (OpenWrt), or enable CONFIG_XFRM_USER and ESP in the kernel: %s",
operation.description,
message,
)
}
// Operation descriptions contain no SPI keys or subscriber identity.
return fmt.Errorf("%s: %s", operation.description, message)
}
@@ -11,6 +11,19 @@ import (
"time"
)
func TestRunIPCommandExplainsMissingKernelXFRM(t *testing.T) {
directory := t.TempDir()
command := directory + "/ip"
script := "#!/bin/sh\necho 'Cannot open netlink socket: Protocol not supported' >&2\nexit 1\n"
if err := os.WriteFile(command, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
err := runIPCommand(context.Background(), command, xfrmOperation{description: "test state"})
if err == nil || !strings.Contains(err.Error(), "kmod-ipsec") || !strings.Contains(err.Error(), "CONFIG_XFRM_USER") {
t.Fatalf("error = %v", err)
}
}
func TestLinuxIPSecInstallerLifecycle(t *testing.T) {
if os.Getenv("VOCAT_NETNS_TEST") != "1" {
t.Skip("set VOCAT_NETNS_TEST=1 inside an isolated Linux network namespace")
+17
View File
@@ -24,6 +24,23 @@ type ATMapper struct {
Devices ATDeviceController
}
type uiccLocker interface {
LockUICC()
UnlockUICC()
}
func (mapper ATMapper) LockUICC() {
if locker, ok := mapper.Devices.(uiccLocker); ok {
locker.LockUICC()
}
}
func (mapper ATMapper) UnlockUICC() {
if locker, ok := mapper.Devices.(uiccLocker); ok {
locker.UnlockUICC()
}
}
func (mapper ATMapper) Get(configuredID string) (device.Device, error) {
physicalID, err := mapper.resolve(context.Background(), configuredID)
if err != nil {
+124
View File
@@ -0,0 +1,124 @@
package vowifi
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"vocat/internal/pcsc"
)
type PCSCBindingResolver func(context.Context, string) (pcsc.Selector, string, error)
// PCSCAdapter uses a directly attached USB smart-card reader as the UICC for
// Wi-Fi Calling. It deliberately exposes no cellular-radio behaviour.
type PCSCAdapter struct {
service *pcsc.Service
resolve PCSCBindingResolver
mu sync.RWMutex
bindings map[string]string
}
var (
_ SIMIdentityReader = (*PCSCAdapter)(nil)
_ SMSCenterReader = (*PCSCAdapter)(nil)
_ AKAProvider = (*PCSCAdapter)(nil)
_ RadioController = (*PCSCAdapter)(nil)
)
func NewPCSCAdapter(service *pcsc.Service, resolver PCSCBindingResolver) (*PCSCAdapter, error) {
if service == nil || resolver == nil {
return nil, errors.New("vocat: PC/SC service and reader resolver are required")
}
return &PCSCAdapter{service: service, resolve: resolver, bindings: make(map[string]string)}, nil
}
func (adapter *PCSCAdapter) ReadIdentity(ctx context.Context, deviceID string) (SIMIdentity, error) {
selector, pin, err := adapter.resolve(ctx, strings.TrimSpace(deviceID))
if err != nil {
return SIMIdentity{}, err
}
identity, err := adapter.service.ReadIdentity(ctx, selector, pin)
if err != nil {
return SIMIdentity{}, fmt.Errorf("read USB SIM identity: %w", err)
}
if len(identity.IMSI) < 5 {
return SIMIdentity{}, errors.New("vocat: USB SIM reader returned an invalid IMSI")
}
adapter.mu.Lock()
adapter.bindings[identity.ICCID] = strings.TrimSpace(deviceID)
adapter.mu.Unlock()
mncLength := identity.MNCLength
if mncLength != 2 && mncLength != 3 {
if mcc, mnc, ok := assignedHomePLMN(identity.IMSI); ok {
return SIMIdentity{ICCID: identity.ICCID, IMSI: identity.IMSI, HomeMCC: mcc, HomeMNC: mnc, SMSC: identity.SMSC}, nil
}
return SIMIdentity{}, ErrEC20MNCUnavailable
}
if len(identity.IMSI) < 3+mncLength {
return SIMIdentity{}, errors.New("vocat: USB SIM IMSI is shorter than its EF_AD home PLMN")
}
return SIMIdentity{
ICCID: identity.ICCID, IMSI: identity.IMSI,
HomeMCC: identity.IMSI[:3], HomeMNC: identity.IMSI[3 : 3+mncLength],
SMSC: identity.SMSC,
}, nil
}
func (adapter *PCSCAdapter) ReadSMSCenter(ctx context.Context, deviceID string) (string, error) {
selector, pin, err := adapter.resolve(ctx, strings.TrimSpace(deviceID))
if err != nil {
return "", err
}
identity, err := adapter.service.ReadIdentity(ctx, selector, pin)
if err != nil {
return "", err
}
if strings.TrimSpace(identity.SMSC) == "" {
return "", errors.New("vocat: USB SIM does not expose a service-centre address")
}
return identity.SMSC, nil
}
func (adapter *PCSCAdapter) CheckReady(ctx context.Context, identity SIMIdentity) (AKAEvidence, error) {
selector, pin, err := adapter.resolve(ctx, adapter.deviceID(identity))
if err != nil {
return AKAEvidence{}, err
}
aid, err := adapter.service.CheckReady(ctx, selector, identity.ICCID, pin)
if err != nil {
return AKAEvidence{}, fmt.Errorf("check USB SIM AKA application: %w", err)
}
return AKAEvidence{Ready: true, Application: aid}, nil
}
func (adapter *PCSCAdapter) deviceID(identity SIMIdentity) string {
adapter.mu.RLock()
deviceID := adapter.bindings[identity.ICCID]
adapter.mu.RUnlock()
return deviceID
}
func (adapter *PCSCAdapter) Authenticate(ctx context.Context, identity SIMIdentity, challenge AKAChallenge) (AKAResult, error) {
selector, pin, err := adapter.resolve(ctx, adapter.deviceID(identity))
if err != nil {
return AKAResult{}, err
}
result, err := adapter.service.Authenticate(ctx, selector, identity.ICCID, pin, pcsc.AKAChallenge(challenge))
if err != nil {
if errors.Is(err, pcsc.ErrAKARejected) {
return AKAResult{}, errors.Join(ErrEC20AKAMACFailure, err)
}
return AKAResult{}, fmt.Errorf("authenticate with USB SIM: %w", err)
}
return AKAResult(result), nil
}
func (*PCSCAdapter) Snapshot(context.Context, string) (RadioSnapshot, error) {
return RadioSnapshot{OperatingMode: 4, PureAirplanePolicy: true}, nil
}
func (*PCSCAdapter) StopCellularData(context.Context, string) error { return nil }
func (*PCSCAdapter) EnterVoWiFiRFOff(context.Context, string) error { return nil }
func (*PCSCAdapter) Restore(context.Context, string, RadioSnapshot) error { return nil }
+224 -11
View File
@@ -1,12 +1,11 @@
#!/usr/bin/env bash
#
# vocat install / update script for binary + systemd deployments.
# vocat install / update script for systemd and OpenWrt/procd deployments.
#
# Usage:
# sudo bash install.sh [version] # install a specific version
# sudo bash install.sh # install latest release
# sudo bash install.sh --force # reinstall even at the same version
# curl -fsSL <raw url> | sudo bash # one-liner (latest)
# bash install.sh [version] # run directly when already root
# sudo bash install.sh [version] # run through sudo as a normal user
# bash install.sh --check-env # check VoWiFi host prerequisites
#
# Behavior:
# - Prompts for script language (中文 / English) as soon as it runs.
@@ -14,7 +13,9 @@
# - On first install, generates a random 32-char admin password, writes it to
# /etc/vocat/env (0600, loaded by the systemd unit), and prints it ONCE.
# - On update, preserves the existing env file and credentials.
# - (Re)writes the systemd unit and restarts the service.
# - Verifies Linux XFRM/IPsec support required by IMS; on OpenWrt it tries
# the matching opkg packages first.
# - (Re)writes a systemd or OpenWrt/procd service and restarts it.
#
# Published script: must contain no secrets, IPs, or passwords.
@@ -31,6 +32,7 @@ LINK_PATH="/usr/local/bin/vocat"
ENV_DIR="/etc/vocat"
ENV_FILE="${ENV_DIR}/env"
UNIT_PATH="/etc/systemd/system/vocat.service"
OPENWRT_INIT_PATH="/etc/init.d/vocat"
# --- Language ----------------------------------------------------------------
LANG_CHOICE=""
@@ -76,14 +78,44 @@ die() {
prompt_language
# BusyBox/OpenWrt images often omit coreutils' install(1). Provide the small
# subset used by this script so the same installer works on router firmware.
if ! command -v install >/dev/null 2>&1; then
install() {
if [ "${1:-}" = "-d" ]; then
shift
local mode="0755"
if [ "${1:-}" = "-m" ]; then
mode="$2"
shift 2
fi
mkdir -p "$@"
chmod "$mode" "$@"
return
fi
local mode="0755"
if [ "${1:-}" = "-m" ]; then
mode="$2"
shift 2
fi
[ "$#" -eq 2 ] || return 2
cp "$1" "$2"
chmod "$mode" "$2"
}
fi
# --- Parse args --------------------------------------------------------------
FORCE=0
CHECK_ENV=0
SKIP_VOWIFI_CHECK="${VOCAT_SKIP_VOWIFI_CHECK:-0}"
TARGET_VERSION=""
for arg in "$@"; do
case "$arg" in
--force) FORCE=1 ;;
--check-env) CHECK_ENV=1 ;;
--skip-vowifi-check) SKIP_VOWIFI_CHECK=1 ;;
-h|--help)
msg "用法: sudo bash install.sh [--force] [版本]" "Usage: sudo bash install.sh [--force] [version]"
msg "用法: bash install.sh [--force] [--check-env] [--skip-vowifi-check] [版本]" "Usage: bash install.sh [--force] [--check-env] [--skip-vowifi-check] [version]"
exit 0
;;
*) TARGET_VERSION="${arg#v}" ;;
@@ -110,6 +142,89 @@ resolve_target_version() {
TARGET_VERSION="${tag#v}"
}
# --- Host prerequisites ------------------------------------------------------
is_openwrt() {
[ -f /etc/openwrt_release ] || [ -x /sbin/procd ]
}
xfrm_works() {
command -v ip >/dev/null 2>&1 && ip xfrm state list >/dev/null 2>&1
}
opkg_has_package() {
opkg list "$1" 2>/dev/null | grep -q "^$1 -"
}
install_openwrt_vowifi_packages() {
msg "正在检查 OpenWrt/Kwrt 的 VoWiFi 内核组件..." "Checking OpenWrt/Kwrt VoWiFi kernel components..."
opkg update >/dev/null 2>&1 || msg \
"警告:opkg 软件源更新失败,将使用现有索引继续检查。" \
"Warning: opkg feed update failed; checking the existing index."
local packages=""
local package
for package in \
ip-full \
kmod-ipsec kmod-ipsec4 kmod-ipsec6 \
kmod-crypto-authenc kmod-crypto-cbc kmod-crypto-aes \
kmod-crypto-hmac kmod-crypto-sha1; do
if opkg_has_package "$package"; then
packages="$packages $package"
fi
done
if [ -n "$packages" ]; then
# Kernel packages must come from this firmware's own feed. opkg checks
# the kernel ABI and refuses mismatched modules; never bypass that check.
# shellcheck disable=SC2086
opkg install $packages >/dev/null 2>&1 || true
fi
}
install_linux_ip_tool() {
command -v ip >/dev/null 2>&1 && return 0
if command -v apt-get >/dev/null 2>&1; then
apt-get update -qq && apt-get install -y iproute2
elif command -v dnf >/dev/null 2>&1; then
dnf install -y iproute
elif command -v yum >/dev/null 2>&1; then
yum install -y iproute
elif command -v pacman >/dev/null 2>&1; then
pacman -Sy --noconfirm iproute2
elif command -v apk >/dev/null 2>&1; then
apk add --no-cache iproute2
fi
}
check_vowifi_environment() {
if [ "$SKIP_VOWIFI_CHECK" = "1" ]; then
msg \
"已跳过 VoWiFi 内核环境检查;IMS 通话和短信可能不可用。" \
"Skipped the VoWiFi kernel check; IMS calls and SMS may not work."
return
fi
if is_openwrt && command -v opkg >/dev/null 2>&1; then
# Install the crypto algorithms even when NETLINK_XFRM already works;
# some minimal images provide xfrm_user but omit AES-CBC/authenc.
install_openwrt_vowifi_packages
elif ! xfrm_works; then
install_linux_ip_tool
fi
if xfrm_works; then
msg "VoWiFi XFRM/IPsec 环境安装并验证成功。" "VoWiFi XFRM/IPsec environment installed and verified."
return
fi
if is_openwrt; then
die \
"当前 OpenWrt/Kwrt 内核 $(uname -r) 不支持 NETLINK_XFRM,且软件源没有匹配的 kmod-ipsec。请使用包含 kmod-ipsec、kmod-ipsec4、kmod-ipsec6、kmod-crypto-authenc、kmod-crypto-cbc、kmod-crypto-aes 和 kmod-crypto-sha1 的同版本固件;严禁安装其他内核版本的 kmod。仅使用非 VoWiFi 功能时可加 --skip-vowifi-check。" \
"The OpenWrt/Kwrt kernel $(uname -r) lacks NETLINK_XFRM and its feed has no matching kmod-ipsec. Use a firmware built with matching kmod-ipsec, kmod-ipsec4/6, crypto-authenc, CBC, AES and SHA1 modules. Never force kmods from another kernel. Use --skip-vowifi-check only for non-VoWiFi operation."
fi
die \
"当前 Linux 内核不支持 XFRM/IPsecVoWiFi IMS 无法工作。请启用 CONFIG_XFRM、CONFIG_XFRM_USER、CONFIG_INET_ESP、CONFIG_INET6_ESP、AES-CBC 和 HMAC-SHA1。" \
"This Linux kernel lacks XFRM/IPsec required by VoWiFi IMS. Enable CONFIG_XFRM, CONFIG_XFRM_USER, CONFIG_INET_ESP, CONFIG_INET6_ESP, AES-CBC and HMAC-SHA1."
}
# --- Skip if already installed at the same version ---------------------------
skip_if_equal() {
[ -x "$BINARY_PATH" ] || return 0
@@ -159,6 +274,10 @@ download_and_verify() {
[ -n "$expected" ] || die "SHA256SUMS 中找不到 $asset 的校验行。" "$asset not found in SHA256SUMS."
actual=$(sha256sum "${VOCAT_TMP}/vocat" | awk '{print $1}')
[ "$actual" = "$expected" ] || die "SHA-256 校验失败。" "SHA-256 verification failed."
chmod 0755 "${VOCAT_TMP}/vocat"
"${VOCAT_TMP}/vocat" version >/dev/null 2>&1 || die \
"Downloaded binary cannot run on this system; keeping the installed version." \
"The downloaded binary cannot run on this host; the installed version was not changed."
}
# --- Install binary ----------------------------------------------------------
@@ -218,6 +337,8 @@ TimeoutStartSec=30s
# HTTP, VoWiFi, and modem cleanup have bounded shutdown contexts totalling up
# to 30 seconds. Leave a small margin before systemd resorts to SIGKILL.
TimeoutStopSec=40s
RuntimeDirectory=vocat
RuntimeDirectoryMode=0755
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW
@@ -246,11 +367,98 @@ EOF
chmod 0644 "$UNIT_PATH"
}
write_openwrt_init() {
cat > "$OPENWRT_INIT_PATH" <<'EOF'
#!/bin/sh /etc/rc.common
START=95
STOP=10
USE_PROCD=1
PROCD_TERM_TIMEOUT=40
PROGRAM=/opt/vocat/bin/vocat
ENV_FILE=/etc/vocat/env
start_service() {
procd_open_instance
procd_set_param command "$PROGRAM" serve
procd_set_param env VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db
if [ -r "$ENV_FILE" ]; then
while IFS='=' read -r name value; do
case "$name" in VOCAT_*) procd_append_param env "$name=$value" ;; esac
done < "$ENV_FILE"
fi
procd_set_param respawn 3600 5 5
procd_set_param stdout 1
procd_set_param stderr 1
procd_close_instance
}
service_triggers() { procd_add_reload_trigger vocat; }
EOF
chmod 0755 "$OPENWRT_INIT_PATH"
}
write_service() {
if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then
write_unit
return
fi
if [ -x /sbin/procd ] || [ -x /sbin/ubusd ]; then
write_openwrt_init
return
fi
die "Unsupported service manager." "Neither systemd nor OpenWrt procd was detected."
}
enable_and_start() {
if [ -x "$OPENWRT_INIT_PATH" ] && { [ -x /sbin/procd ] || [ -x /sbin/ubusd ]; }; then
"$OPENWRT_INIT_PATH" enable
# Stop explicitly before restart. Some procd/rc.common variants return
# from restart while the previous process is still inside its bounded
# VoWiFi cleanup, so the replacement can race the host-wide instance
# lock and enter a respawn cycle.
"$OPENWRT_INIT_PATH" stop || true
local stop_attempt
for stop_attempt in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40; do
if ! "$OPENWRT_INIT_PATH" running; then
break
fi
sleep 1
done
if "$OPENWRT_INIT_PATH" restart; then
# Modems may need several seconds to release and reopen their AT
# port after procd stops the previous process. Require consecutive
# healthy observations so a short-lived respawn is not mistaken for
# a successful upgrade.
local attempt stable
stable=0
for attempt in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30; do
sleep 1
if "$OPENWRT_INIT_PATH" running; then
stable=$((stable + 1))
if [ "$stable" -ge 3 ]; then
rm -f "${BINARY_PATH}.bak"
return
fi
else
stable=0
fi
done
fi
if [ -e "${BINARY_PATH}.bak" ]; then
cp -a "${BINARY_PATH}.bak" "$BINARY_PATH"
"$OPENWRT_INIT_PATH" restart || true
fi
die "OpenWrt vocat service failed to start." "The OpenWrt vocat service failed to start."
fi
systemctl daemon-reload
systemctl enable vocat
if systemctl restart vocat; then
return
local attempt
for attempt in 1 2 3 4 5; do
if systemctl is-active --quiet vocat; then
rm -f "${BINARY_PATH}.bak"
return
fi
sleep 1
done
fi
if [ -e "${BINARY_PATH}.bak" ]; then
msg "新版本启动失败,正在恢复旧二进制。" "The new version failed to start; restoring the previous binary."
@@ -261,14 +469,19 @@ enable_and_start() {
}
# --- Main --------------------------------------------------------------------
resolve_target_version
detect_arch
check_vowifi_environment
if [ "$CHECK_ENV" -eq 1 ]; then
msg "VoCat 运行环境检查完成。" "VoCat host environment check completed."
exit 0
fi
resolve_target_version
skip_if_equal
download_and_verify
install_binary
ensure_data_dir
setup_env
write_unit
write_service
enable_and_start
if [ "$FIRST_INSTALL" -eq 1 ]; then
@@ -281,7 +494,7 @@ if [ "$FIRST_INSTALL" -eq 1 ]; then
echo
msg "用户名为 admin。请立即记录此密码。" "Username is admin. Record this password now."
msg "登录后或运行以下命令修改密码:" "Change it via the web UI or run:"
echo " sudo vocat menu"
echo " vocat menu"
msg "==========================================" "=============================================="
else
echo
+30
View File
@@ -0,0 +1,30 @@
#!/bin/sh /etc/rc.common
START=95
STOP=10
USE_PROCD=1
PROCD_TERM_TIMEOUT=40
PROGRAM=/opt/vocat/bin/vocat
ENV_FILE=/etc/vocat/env
start_service() {
procd_open_instance
procd_set_param command "$PROGRAM" serve
procd_set_param env VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db
if [ -r "$ENV_FILE" ]; then
while IFS='=' read -r name value; do
case "$name" in
VOCAT_*) procd_append_param env "$name=$value" ;;
esac
done < "$ENV_FILE"
fi
procd_set_param respawn 3600 5 5
procd_set_param stdout 1
procd_set_param stderr 1
procd_close_instance
}
service_triggers() {
procd_add_reload_trigger vocat
}
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="USB SIM reader">
<defs><linearGradient id="a" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#64748b"/><stop offset="1" stop-color="#1e293b"/></linearGradient></defs>
<rect x="12" y="31" width="104" height="68" rx="14" fill="url(#a)"/>
<rect x="25" y="43" width="55" height="42" rx="7" fill="#0f172a" stroke="#94a3b8" stroke-width="2"/>
<path d="M40 51h24l8 8v18H40z" fill="#facc15"/><path d="M44 60h24M50 54v23M60 54v23" stroke="#a16207" stroke-width="2"/>
<rect x="91" y="51" width="25" height="29" rx="4" fill="#cbd5e1"/>
<path d="M98 58h11M98 65h11M98 72h11" stroke="#64748b" stroke-width="3"/>
<circle cx="101" cy="89" r="3" fill="#22c55e"/>
</svg>

After

Width:  |  Height:  |  Size: 752 B

@@ -15,9 +15,10 @@ export interface CardPolicyPanelProps {
policy: CardPolicy | null;
deviceOnline: boolean;
onPolicyChanged: () => void | Promise<void>;
wifiCallingOnly?: boolean;
}
export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolicyChanged }: CardPolicyPanelProps) {
export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolicyChanged, wifiCallingOnly = false }: CardPolicyPanelProps) {
const { t } = useI18n();
const operable = deviceOnline && !!iccid;
const currentPolicy = policy?.iccid === iccid ? policy : null;
@@ -66,7 +67,7 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
</div>
<div>
<div className="text-lg font-bold text-gray-900 dark:text-white">{t("卡策略")}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{t("VoWiFi / 飞行模式 开关跟着 SIM 卡走,切换即时生效")}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{wifiCallingOnly ? t("USB SIM 读卡器仅用于 WiFi Calling,策略跟随 ICCID 保存") : t("VoWiFi / 飞行模式 开关跟着 SIM 卡走,切换即时生效")}</div>
</div>
</div>
{!iccid ? (
@@ -119,7 +120,7 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
</div>
</div>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<PolicySwitchCard
<PolicySwitchCard
title="VoWiFi"
subtitle={t("启用时强制关闭蜂窝射频;关闭 VoWiFi 后仍保持飞行模式")}
tone="orange"
@@ -129,7 +130,7 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
failed={toggles.vowifiFailed}
onToggle={toggles.onVoWiFiToggle}
/>
<PolicySwitchCard
{!wifiCallingOnly ? <PolicySwitchCard
title={t("飞行模式")}
subtitle={t("只有手动关闭此开关才允许设备连接基站")}
tone="indigo"
@@ -138,15 +139,15 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
pending={toggles.airplanePending}
failed={toggles.airplaneFailed}
onToggle={toggles.onAirplaneToggle}
/>
/> : null}
</div>
<CardPolicyAPN
{!wifiCallingOnly ? <CardPolicyAPN
deviceId={deviceId}
iccid={iccid}
policy={currentPolicy}
deviceOnline={deviceOnline}
onSaved={onPolicyChanged}
/>
/> : null}
</div>
) : null}
</div>
@@ -30,7 +30,7 @@ function isQmiMode(d?: DiscoveredDevice | null): boolean {
}
function modeLabel(d?: DiscoveredDevice | null): string {
const m = String(d?.mode || "unknown").toLowerCase();
return m === "qmi" ? "QMI" : m === "mbim" ? "MBIM" : m === "ecm" ? "ECM" : m === "rndis" ? "RNDIS" : m === "ncm" ? "NCM" : "UNKNOWN";
return m === "pcsc" ? "PC/SC" : m === "qmi" ? "QMI" : m === "mbim" ? "MBIM" : m === "ecm" ? "ECM" : m === "rndis" ? "RNDIS" : m === "ncm" ? "NCM" : "UNKNOWN";
}
function Field({ label, children }: { label: ReactNode; children: ReactNode }) {
@@ -47,6 +47,7 @@ export function DeviceAddDialog(props: DeviceAddDialogProps) {
const { addSelected, addConfig } = props;
const fixedQmi = isQmiControl(addSelected?.controlPath || addConfig?.controlDevice);
const isMbim = String(addSelected?.mode || "").toLowerCase() === "mbim";
const isReader = addSelected?.hardwareKind === "pcsc" || String(addSelected?.mode || "").toLowerCase() === "pcsc";
useEffect(() => {
if (fixedQmi && addConfig.deviceBackend !== "qmi") props.onConfigChange({ ...addConfig, deviceBackend: "qmi" });
@@ -57,7 +58,7 @@ export function DeviceAddDialog(props: DeviceAddDialogProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMbim]);
const backendOptions = [
const backendOptions = isReader ? [{ value: "pcsc", label: "PC/SC" }] : [
...(isMbim
? []
: [
@@ -161,6 +162,9 @@ export function DeviceAddDialog(props: DeviceAddDialogProps) {
<Field label={t("控制设备")}>
<Input value={addConfig.controlDevice} disabled />
</Field>
{isReader ? <Field label="SIM PIN">
<Input type="password" value={addConfig.simPin} onChange={(e) => set({ simPin: e.target.value })} maxLength={8} inputMode="numeric" placeholder={t("仅在 SIM 启用 PIN 时填写")} />
</Field> : null}
<div className="flex items-center justify-between rounded-xl border border-gray-200 bg-gray-50 p-3">
<div>
<div className="text-sm font-bold text-gray-800">{t("设备后端模式")}</div>
@@ -173,7 +177,7 @@ export function DeviceAddDialog(props: DeviceAddDialogProps) {
onChange={(v) => set({ deviceBackend: v })}
className="w-[110px]"
placeholder="AT"
disabled={fixedQmi || isMbim}
disabled={fixedQmi || isMbim || isReader}
options={backendOptions}
/>
</div>
@@ -34,13 +34,14 @@ export function DeviceConfigTab({ editConfig, deviceStatus, saving, deleting, on
const usbPath = deviceStatus?.usbPath || editConfig?.usbPath;
const isQmi = isQmiControl(controlDevice);
const isMbim = String(editConfig?.deviceBackend || "").toLowerCase() === "mbim";
const isReader = editConfig?.deviceType === "usb_sim_reader";
useEffect(() => {
if (isQmi && editConfig && editConfig.deviceBackend !== "qmi") onEditConfig({ ...editConfig, deviceBackend: "qmi" });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isQmi]);
const backendOptions = [
const backendOptions = isReader ? [{ value: "pcsc", label: "PC/SC" }] : [
...(isMbim
? []
: [
@@ -106,6 +107,9 @@ export function DeviceConfigTab({ editConfig, deviceStatus, saving, deleting, on
<Field label={t("控制设备")}>
<Input value={controlDevice || ""} disabled placeholder={t("由系统自动探测")} />
</Field>
{isReader ? <Field label="SIM PIN">
<Input type="password" value={editConfig.simPin || ""} onChange={(e) => onEditConfig({ ...editConfig, simPin: e.target.value })} maxLength={8} inputMode="numeric" placeholder={t("留空表示不修改;仅在 SIM 启用 PIN 时填写")} />
</Field> : null}
<div className="ui-panel-muted space-y-2 p-3">
<div className="flex items-center justify-between">
<div>
@@ -123,7 +127,7 @@ export function DeviceConfigTab({ editConfig, deviceStatus, saving, deleting, on
onChange={(v) => onEditConfig({ ...editConfig, deviceBackend: v as DeviceConfig["deviceBackend"] })}
className="w-[120px]"
placeholder="AT"
disabled={isQmi || isMbim}
disabled={isQmi || isMbim || isReader}
options={backendOptions}
/>
</div>
@@ -15,6 +15,7 @@ export interface DeviceDetailHeaderProps {
onReconnectVowifi: () => void;
onRebootModem: () => void;
onOpenSms: () => void;
wifiCallingOnly?: boolean;
}
export function DeviceDetailHeader(props: DeviceDetailHeaderProps) {
@@ -42,7 +43,7 @@ export function DeviceDetailHeader(props: DeviceDetailHeaderProps) {
<Button loading={props.reconnectingVoWiFi} onClick={props.onReconnectVowifi} className="ui-glass-border !border-0" icon={<ArrowSyncRegular />}>
{t("重连 VoWiFi")}
</Button>
) : device.developerEnabled ? (
) : device.developerEnabled && !props.wifiCallingOnly ? (
<div
className="ui-glass-border flex h-8 items-center gap-2 rounded-lg px-3 text-sm text-gray-700 dark:text-gray-200"
title={t("蜂窝数据仅进入 Export Proxy 的受保护路由,不会成为主机默认出口")}
@@ -58,9 +59,9 @@ export function DeviceDetailHeader(props: DeviceDetailHeaderProps) {
/>
</div>
) : null}
<Button loading={props.rebooting} onClick={props.onRebootModem} className="ui-glass-border !border-0 hover:!text-red-600" icon={<PowerRegular />}>
{!props.wifiCallingOnly ? <Button loading={props.rebooting} onClick={props.onRebootModem} className="ui-glass-border !border-0 hover:!text-red-600" icon={<PowerRegular />}>
{t("重启模组")}
</Button>
</Button> : null}
<Button onClick={props.onOpenSms} className="ui-glass-border !border-0" icon={<ChatRegular />}>
{t("短信")}
</Button>
@@ -26,12 +26,13 @@ export function DeviceOverviewTab(props: DeviceOverviewTabProps) {
const { t } = useI18n();
const [operatorOpen, setOperatorOpen] = useState(false);
const { device } = props;
const wifiCallingOnly = device.deviceType === "usb_sim_reader";
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<div className={`grid grid-cols-1 gap-4 ${wifiCallingOnly ? "lg:grid-cols-2" : "lg:grid-cols-3"}`}>
<div className="ui-panel-muted p-4">
<div className="mb-3 text-xs font-bold uppercase tracking-wider text-gray-500">{t("运行状态")}</div>
{isVoWiFiInUse(device) ? (
{isVoWiFiInUse(device) && !(device.modem?.imei && device.modem?.simInserted === false) ? (
<OverviewVowifiCard device={device} />
) : (
<OverviewNetworkCard device={device} onOpenOperatorSelection={() => setOperatorOpen(true)} />
@@ -44,16 +45,16 @@ export function DeviceOverviewTab(props: DeviceOverviewTabProps) {
e911Starting={props.e911Starting}
onSetupE911={props.onSetupE911}
/>
<OverviewNetworkPanel
{!wifiCallingOnly ? <OverviewNetworkPanel
device={device}
trafficMinuteRx={props.trafficMinuteRx}
trafficMinuteTx={props.trafficMinuteTx}
trafficSpeedRx={props.trafficSpeedRx}
trafficSpeedTx={props.trafficSpeedTx}
/>
/> : null}
</div>
{device.developerEnabled && device.networkEnabled && device.id ? <OverviewTrafficChart deviceId={device.id} /> : null}
{device?.id ? (
{device?.id && !wifiCallingOnly ? (
<OperatorSelectionDialog
open={operatorOpen}
deviceId={device.id}
@@ -26,17 +26,20 @@ export function OverviewNetworkCard({ device, onOpenOperatorSelection }: { devic
const modem = device.modem;
const online = isDeviceOnline(device);
const cellularRegistered = isRegistered(device);
const simMissing = !!modem?.imei && modem?.simInserted === false;
// A persisted runtime may briefly describe the old session while disable is
// being cleaned up. Desired policy is authoritative for the overview badge.
const vowifiRegistered = !!device.vowifiEnabled && !!(device.vowifiActive || device.vowifiRuntime?.smsReady);
const registered = cellularRegistered || vowifiRegistered;
const registered = !simMissing && (cellularRegistered || vowifiRegistered);
const radioOffForVowifi = vowifiRegistered && (modem?.operatingMode === 0 || modem?.operatingMode === 4 || device.flightMode);
const tone = isRecoveringPhase(device.lifecyclePhase) ? "warning" : online ? (registered ? "success" : "warning") : "danger";
let statusText: string;
const phaseLabel = lifecycleLabel(device.lifecyclePhase);
if (phaseLabel && device.lifecyclePhase !== "online" && device.lifecyclePhase !== "offline") statusText = phaseLabel;
else if (online) {
else if (online) {
if (simMissing) statusText = t("SIM卡未插入");
else
statusText = registered
? ""
: device.registrationStateLabel === "searching"
@@ -49,7 +52,9 @@ export function OverviewNetworkCard({ device, onOpenOperatorSelection }: { devic
const level = signalLevel(modem?.signalDbm);
const sigTone = signalTone(modem?.signalDbm);
const netMode = [modem?.networkDuplex, modem?.networkMode].filter(Boolean).join(" ");
const cellularRegistrationText = modem?.regStatus === 5
const cellularRegistrationText = simMissing
? t("SIM卡未插入")
: modem?.regStatus === 5
? t("已驻网(漫游)")
: modem?.regStatus === 1
? t("已驻网")
+1
View File
@@ -44,6 +44,7 @@ export interface AddDeviceForm {
atPort: string;
controlDevice: string;
deviceBackend: string;
simPin: string;
}
export interface LoadError {
+1
View File
@@ -6,6 +6,7 @@ export const DEVICE_TYPES: ReadonlyArray<{ value: DeviceType; label: string; ima
{ value: "wifi_410", label: "410 WiFi 棒(高通芯片)", image: "/410.png" },
{ value: "dji_4g", label: "大疆 4G 模块(移远芯片)", image: "/dj.png" },
{ value: "pcie_ec20_ec25", label: "PCIe EC20/EC25(移远芯片)", image: "/ec20.png" },
{ value: "usb_sim_reader", label: "USB SIM 读卡器(仅 WiFi Calling", image: "/sim-reader.svg" },
];
export function normalizeDeviceType(value?: string | null): DeviceType {
+7 -1
View File
@@ -5,6 +5,10 @@
* /
*/
export const EN_DICT: Record<string, string> = {
"USB SIM 读卡器(仅 WiFi Calling": "USB SIM Reader (WiFi Calling only)",
"仅在 SIM 启用 PIN 时填写": "Only enter this when SIM PIN is enabled",
"留空表示不修改;仅在 SIM 启用 PIN 时填写": "Leave blank to keep unchanged; only enter this when SIM PIN is enabled",
"USB SIM 读卡器仅用于 WiFi Calling,策略跟随 ICCID 保存": "The USB SIM reader is for WiFi Calling only; policy is saved per ICCID",
// Cellular APN profiles.
"蜂窝 APN": "Cellular APN",
"APN 列表和启用状态跟随当前 ICCID/Profile 保存": "APN profiles and the active selection are saved per ICCID/Profile",
@@ -765,7 +769,9 @@ export const EN_DICT: Record<string, string> = {
"暂无设备": "No devices",
"最后原因": "Last Reason",
"最后成功:": "Last success: ",
"未检测到 eUICC": "No eUICC detected",
"未检测到 eUICC": "No eUICC detected",
"SIM卡未插入": "No SIM card inserted",
"USB SIM读卡器仅支持VoWiFi短信和通话任务": "USB SIM readers support VoWiFi SMS and call tasks only",
"未生效": "Not in effect",
"未知": "Unknown",
"未知日期": "Unknown date",
+26 -4
View File
@@ -251,7 +251,7 @@ export default function AutomaticTasksPage() {
function edit(task?: AutomaticTask) {
const deviceId = task?.deviceId || devices[0]?.id || "";
const next = task ? {
let next = task ? {
id: task.id,
name: task.name,
enabled: task.enabled,
@@ -269,13 +269,21 @@ export default function AutomaticTasksPage() {
message: task.payload?.message || "",
durationSeconds: task.payload?.durationSeconds || 30,
} : emptyForm(deviceId);
if (devices.find((device) => device.id === deviceId)?.deviceType === "usb_sim_reader") {
next = { ...next, taskType: next.taskType === "public_ip" ? "sms" : next.taskType, environment: "vowifi" };
}
setForm(next);
setOpen(true);
void loadProfiles(deviceId, next.profileIccid);
}
function chooseDevice(deviceId: string) {
setForm((current) => ({ ...current, deviceId, profileIccid: "", profileAid: "" }));
const reader = devices.find((device) => device.id === deviceId)?.deviceType === "usb_sim_reader";
setForm((current) => ({
...current, deviceId, profileIccid: "", profileAid: "",
taskType: reader && current.taskType === "public_ip" ? "sms" : current.taskType,
environment: reader ? "vowifi" : current.environment,
}));
void loadProfiles(deviceId);
}
@@ -285,6 +293,7 @@ export default function AutomaticTasksPage() {
}
function chooseTaskType(taskType: TaskType) {
if (deviceByID.get(form.deviceId)?.deviceType === "usb_sim_reader" && taskType === "public_ip") return;
setForm((current) => ({
...current,
taskType,
@@ -296,6 +305,9 @@ export default function AutomaticTasksPage() {
if (!form.name.trim()) return message.warning(t("请输入任务名称"));
if (!form.deviceId) return message.warning(t("请选择设备"));
if (!form.profileIccid) return message.warning(t("请选择 eSIM Profile"));
if (deviceByID.get(form.deviceId)?.deviceType === "usb_sim_reader" && (form.environment !== "vowifi" || form.taskType === "public_ip")) {
return message.warning(t("USB SIM读卡器仅支持VoWiFi短信和通话任务"));
}
if (form.taskType !== "public_ip" && !form.phone.trim()) return message.warning(t("请输入号码"));
if (form.taskType === "sms" && !form.message.trim()) return message.warning(t("请输入短信内容"));
setSaving(true);
@@ -377,6 +389,15 @@ export default function AutomaticTasksPage() {
const taskTypeLabel = (value: TaskType) => ({ sms: t("发送短信"), call: t("拨打电话并自动挂断"), public_ip: t("获取漫游公网 IP") })[value];
const environmentLabel = (value: TaskEnvironment) => value === "vowifi" ? "VoWiFi" : t("基站直连");
const selectedTaskDeviceIsReader = deviceByID.get(form.deviceId)?.deviceType === "usb_sim_reader";
const taskTypeOptions = [
{ value: "sms", label: t("发送短信") },
{ value: "call", label: t("拨打电话并自动挂断") },
...(!selectedTaskDeviceIsReader ? [{ value: "public_ip", label: t("开启漫游流量并获取一次公网 IP") }] : []),
];
const environmentOptions = selectedTaskDeviceIsReader
? [{ value: "vowifi", label: "VoWiFi" }]
: [{ value: "vowifi", label: "VoWiFi" }, { value: "cellular", label: t("基站直连(自动选网)") }];
return (
<div className="mx-auto max-w-7xl">
@@ -473,8 +494,9 @@ export default function AutomaticTasksPage() {
<div className="md:col-span-2"><label className={fieldLabel}>{t("任务名称")}</label><Input value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} placeholder={t("例如:每日短信保活")} /></div>
<div><label className={fieldLabel}>{t("设备")}</label><Select value={form.deviceId} onChange={chooseDevice} options={devices.map((device) => ({ value: device.id, label: `${device.name || device.id} (${device.id})` }))} /></div>
<div><label className={fieldLabel}>{t("eSIM Profile")}</label><Select value={form.profileIccid} onChange={chooseProfile} disabled={profileLoading || !form.deviceId} placeholder={profileLoading ? t("读取 Profile 中...") : t("请选择 Profile")} options={profiles.map((profile) => ({ value: profile.iccid, label: profile.label }))} /></div>
<div><label className={fieldLabel}>{t("任务类型")}</label><Select value={form.taskType} onChange={(value) => chooseTaskType(value as TaskType)} options={[{ value: "sms", label: t("发送短信") }, { value: "call", label: t("拨打电话并自动挂断") }, { value: "public_ip", label: t("开启漫游流量并获取一次公网 IP") }]} /></div>
<div><label className={fieldLabel}>{t("执行环境")}</label><Select value={form.environment} onChange={(value) => setForm({ ...form, environment: value as TaskEnvironment })} disabled={form.taskType === "public_ip"} options={[{ value: "vowifi", label: "VoWiFi" }, { value: "cellular", label: t("基站直连(自动选网)") }]} /></div>
<div><label className={fieldLabel}>{t("任务类型")}</label><Select value={form.taskType} onChange={(value) => chooseTaskType(value as TaskType)} options={taskTypeOptions} /></div>
<div><label className={fieldLabel}>{t("执行环境")}</label><Select value={form.environment} onChange={(value) => setForm({ ...form, environment: value as TaskEnvironment })} disabled={form.taskType === "public_ip" || selectedTaskDeviceIsReader} options={environmentOptions} /></div>
{selectedTaskDeviceIsReader ? <div className="md:col-span-2 rounded-lg border border-sky-200 bg-sky-50 p-3 text-sm text-sky-700 dark:border-sky-500/20 dark:bg-sky-500/10 dark:text-sky-300">{t("USB SIM读卡器仅支持VoWiFi短信和通话任务")}</div> : null}
{form.taskType !== "public_ip" ? <div><label className={fieldLabel}>{t("号码")}</label><Input value={form.phone} onChange={(event) => setForm({ ...form, phone: event.target.value })} placeholder="+447700900123" /></div> : null}
{form.taskType === "call" ? <div><label className={fieldLabel}>{t("自动挂断")}</label><Input type="number" min={1} max={600} value={form.durationSeconds} suffix="s" onChange={(event) => setForm({ ...form, durationSeconds: Number(event.target.value) })} /></div> : null}
+12 -3
View File
@@ -32,6 +32,7 @@ const EMPTY_ADD: AddDeviceForm = {
atPort: "",
controlDevice: "",
deviceBackend: "at",
simPin: "",
};
export default function DevicesPage() {
@@ -373,7 +374,8 @@ export default function DevicesPage() {
setAddSelected(d);
setAddConfig((prev) => {
const mode = String(d.mode || "").toLowerCase();
const backend = mode === "mbim" ? "mbim" : isQmiControl(d.controlPath) || (mode === "qmi" && d.controlPath) ? "qmi" : "at";
const isReader = d.hardwareKind === "pcsc" || mode === "pcsc";
const backend = isReader ? "pcsc" : mode === "mbim" ? "mbim" : isQmiControl(d.controlPath) || (mode === "qmi" && d.controlPath) ? "qmi" : "at";
return {
...prev,
interface: d.netInterface || "",
@@ -382,6 +384,8 @@ export default function DevicesPage() {
modemImei: d.imei || "",
usbPath: d.usbPath || "",
deviceBackend: backend,
deviceType: isReader ? "usb_sim_reader" : prev.deviceType,
esimTransport: isReader ? "pcsc" : backend,
};
});
}, []);
@@ -574,6 +578,10 @@ export default function DevicesPage() {
const unconfiguredDiscovered = useMemo(() => discovered.filter((d) => !d.configured), [discovered]);
const detailOnline = isDeviceOnline(detail);
const isReader = detail?.deviceType === "usb_sim_reader";
useEffect(() => {
if (isReader && ["at", "ussd"].includes(activeTab)) setActiveTab("overview");
}, [isReader, activeTab]);
const addAtLimit = deviceLimit > 0 && list.length >= deviceLimit;
const tabItems = [
{ key: "overview", label: t("概览") },
@@ -582,7 +590,7 @@ export default function DevicesPage() {
{ key: "ussd", label: t("USSD") },
{ key: "config", label: t("配置") },
{ key: "card", label: t("卡策略") },
];
].filter((tab) => !isReader || !["at", "ussd"].includes(tab.key));
const overviewNode = detail ? (
<div className="space-y-4">
@@ -673,6 +681,7 @@ export default function DevicesPage() {
onReconnectVowifi={handleReconnectVoWiFi}
onRebootModem={handleRebootModem}
onOpenSms={handleOpenSms}
wifiCallingOnly={isReader}
/>
<div className="device-detail-tabs ui-card p-6">
<Tabs tabs={tabItems} value={activeTab} onChange={handleTabChange} />
@@ -697,7 +706,7 @@ export default function DevicesPage() {
<DeviceConfigTab editConfig={editConfig} deviceStatus={detail} saving={saving} deleting={deleting} onSave={handleSaveConfig} onDelete={handleDeleteDevice} onEditConfig={setEditConfig} />
) : null}
{activeTab === "card" ? (
<CardPolicyPanel deviceId={detail.id} iccid={detail.modem?.iccid} policy={cardPolicy} deviceOnline={detailOnline} onPolicyChanged={handlePolicyChanged} />
<CardPolicyPanel deviceId={detail.id} iccid={detail.modem?.iccid} policy={cardPolicy} deviceOnline={detailOnline} onPolicyChanged={handlePolicyChanged} wifiCallingOnly={isReader} />
) : null}
</div>
</div>
+6 -3
View File
@@ -1,6 +1,6 @@
export type ApiStatus = "ok" | "error";
export type DeviceType = "wifi_410" | "dji_4g" | "pcie_ec20_ec25";
export type DeviceType = "wifi_410" | "dji_4g" | "pcie_ec20_ec25" | "usb_sim_reader";
export interface Session {
authenticated: boolean;
@@ -167,6 +167,8 @@ export interface DeviceStatus {
}
export interface DiscoveredDevice {
hardwareKind?: string;
readerName?: string;
discoveryKey: string;
controlPath: string;
netInterface: string;
@@ -196,14 +198,15 @@ export interface DeviceConfig {
usbPath: string;
audioDevice?: string;
modemImei?: string;
simPin?: string;
apn: string;
proxyPort: number;
baudRate: number;
dataBits: number;
stopBits: number;
parity: string;
deviceBackend: "at" | "qmi";
esimTransport: "at" | "qmi";
deviceBackend: "at" | "qmi" | "pcsc";
esimTransport: "at" | "qmi" | "pcsc" | "none";
qmiUseProxy: boolean;
qmiProxyPath?: string;
qmiProxyExecutable?: string;