mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-13 03:13:43 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f17d925c4c | ||
|
|
296f963885 | ||
|
|
1b9546a73d | ||
|
|
22487dbb1f | ||
|
|
f9bb38aabe |
@@ -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:
|
||||
|
||||
@@ -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).
|
||||
|
||||
[](https://meteor-history.com)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func lockServerInstance(databasePath string) (*os.File, error) {
|
||||
directory := filepath.Dir(databasePath)
|
||||
if err := os.MkdirAll(directory, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create data directory for instance lock: %w", err)
|
||||
}
|
||||
path := filepath.Join(directory, ".vocat.lock")
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open server instance lock: %w", err)
|
||||
}
|
||||
if err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil {
|
||||
_ = file.Close()
|
||||
if errors.Is(err, unix.EWOULDBLOCK) || errors.Is(err, unix.EAGAIN) {
|
||||
return nil, fmt.Errorf("another vocat server is already using database %s", databasePath)
|
||||
}
|
||||
return nil, fmt.Errorf("lock server instance: %w", err)
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestServerInstanceLockRejectsSecondProcess(t *testing.T) {
|
||||
database := filepath.Join(t.TempDir(), "vocat.db")
|
||||
first, err := lockServerInstance(database)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer first.Close()
|
||||
second, err := lockServerInstance(database)
|
||||
if second != nil {
|
||||
second.Close()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "already using database") {
|
||||
t.Fatalf("second lock error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
+75
-4
@@ -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)
|
||||
},
|
||||
})
|
||||
@@ -601,10 +639,16 @@ func configureVoWiFiRuntime(
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
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 +778,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 +798,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 +809,7 @@ func provisionDiscoveredDevices(
|
||||
StopBits: 1,
|
||||
Parity: "none",
|
||||
DeviceBackend: backend,
|
||||
ESIMTransport: backend,
|
||||
ESIMTransport: esimTransport,
|
||||
NetworkEnabled: false,
|
||||
SMSEnabled: true,
|
||||
VoWiFiEnabled: true,
|
||||
@@ -931,6 +985,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 +1008,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 +1070,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
|
||||
}
|
||||
|
||||
@@ -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` 架构;
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
|
||||
+121
-26
@@ -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
|
||||
@@ -264,6 +267,14 @@ func (manager *Manager) openEuiccOnce(ctx context.Context, id string) (*euiccCha
|
||||
}
|
||||
|
||||
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 +313,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 +394,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 +418,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 +427,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
|
||||
}
|
||||
@@ -610,6 +668,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 {
|
||||
@@ -780,9 +839,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 +856,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 +894,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 +971,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 {
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"vocat/internal/modem"
|
||||
"vocat/internal/pcsc"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
@@ -19,6 +20,7 @@ type Options struct {
|
||||
LongTimeout time.Duration
|
||||
SMSTimeout time.Duration
|
||||
ScanTimeout time.Duration
|
||||
CardReaders *pcsc.Service
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
@@ -34,6 +36,7 @@ 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
|
||||
@@ -59,6 +62,7 @@ type managedDevice struct {
|
||||
discovered bool
|
||||
preFlightMode *int
|
||||
resetClientOnLock bool
|
||||
simPIN string
|
||||
}
|
||||
|
||||
func NewManager(options Options) (*Manager, error) {
|
||||
@@ -82,6 +86,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 +96,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 +154,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 +382,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 +400,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()
|
||||
|
||||
@@ -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{
|
||||
{
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ type Device struct {
|
||||
USBPath string
|
||||
AudioDevice string
|
||||
ModemIMEI string
|
||||
SIMPIN string
|
||||
APN string
|
||||
ProxyPort int
|
||||
BaudRate int
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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 }
|
||||
+197
-11
@@ -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/IPsec,VoWiFi 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 ----------------------------------------------------------
|
||||
@@ -246,11 +365,73 @@ 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
|
||||
if "$OPENWRT_INIT_PATH" restart; then
|
||||
sleep 2
|
||||
if "$OPENWRT_INIT_PATH" running; then
|
||||
rm -f "${BINARY_PATH}.bak"
|
||||
return
|
||||
fi
|
||||
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 +442,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 +467,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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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("已驻网")
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface AddDeviceForm {
|
||||
atPort: string;
|
||||
controlDevice: string;
|
||||
deviceBackend: string;
|
||||
simPin: string;
|
||||
}
|
||||
|
||||
export interface LoadError {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user