mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-13 03:13:43 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22487dbb1f | ||
|
|
f9bb38aabe | ||
|
|
5bb5808706 | ||
|
|
d70937cc47 | ||
|
|
eab658dc90 | ||
|
|
0d738d4ce4 | ||
|
|
962c58fdd1 | ||
|
|
7b2e005b37 | ||
|
|
f1e70ecee5 | ||
|
|
f012c556e9 | ||
|
|
ab8bbbc1ed | ||
|
|
609a591045 | ||
|
|
461054615b | ||
|
|
020fb619a9 | ||
|
|
3cc73f1885 | ||
|
|
48fc4c5ab5 | ||
|
|
707ca3c124 | ||
|
|
a09f9af646 | ||
|
|
928ba7746e | ||
|
|
21f210d219 | ||
|
|
8a260e86f1 | ||
|
|
5b8d1a86e8 |
@@ -55,3 +55,4 @@ Thumbs.db
|
||||
|
||||
# ---- Claude Code / agent ----
|
||||
.claude/
|
||||
.worktrees/
|
||||
|
||||
@@ -305,6 +305,16 @@ cd web && npm run build
|
||||
- [Linux.do](https://linux.do) — An inspiring tech community
|
||||
- [iniwex5](https://github.com/iniwex5) - Style and Functionality Guidelines
|
||||
|
||||
## Buy me a coffee
|
||||
|
||||
| Network | Address |
|
||||
| ------- | ------- |
|
||||
| USDT-TRON (TRC20) | `TQQAbboBoU8h5xX4YCA1rqWJU2WjK3seSg` |
|
||||
| USDT-BSC (BEP20) | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
|
||||
| USDT-Polygon | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
|
||||
|
||||
## License
|
||||
|
||||
See [LICENSE](LICENSE).
|
||||
|
||||
[](https://meteor-history.com)
|
||||
|
||||
+2
-2
@@ -32,8 +32,8 @@ Usage:
|
||||
GITHUB_TOKEN Optional bearer token for private repos
|
||||
or higher rate limits.
|
||||
vocat menu Interactive lifecycle menu (root on the host):
|
||||
toggle language, change password, restart, update,
|
||||
uninstall.
|
||||
toggle language, change password, change the Web port,
|
||||
restart, update, uninstall.
|
||||
vocat help Show this help message.
|
||||
|
||||
When run without a subcommand on a non-TTY (e.g. systemd), vocat starts the
|
||||
|
||||
+354
-51
@@ -13,6 +13,7 @@ import (
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -26,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"
|
||||
@@ -183,7 +185,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)
|
||||
}
|
||||
@@ -193,6 +196,7 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
||||
if err := provisionDiscoveredDevices(startupContext, database, deviceManager); err != nil {
|
||||
logger.Warn("automatic first-run device provisioning failed", "error", err)
|
||||
}
|
||||
configureDeviceBackends(startupContext, logger, database, deviceManager)
|
||||
restoreDefaultCellularRadios(startupContext, logger, database, deviceManager)
|
||||
defer func() {
|
||||
stopContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
@@ -218,10 +222,12 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
||||
logger,
|
||||
database,
|
||||
deviceManager,
|
||||
cardReaders,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("configure VoWiFi runtime: %w", err)
|
||||
}
|
||||
go reconcileCardPolicies(pollContext, logger, database, deviceManager, vowifiManager)
|
||||
defer func() {
|
||||
stopContext, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
@@ -254,6 +260,7 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
||||
go handler.StartSMSSyncLoop(pollContext, 15*time.Second)
|
||||
handler.StartTelegramBot(pollContext)
|
||||
handler.StartSMSNotificationDispatchers(pollContext)
|
||||
handler.StartAutomaticTasks(pollContext)
|
||||
|
||||
serverConfig := func(handler http.Handler) *http.Server {
|
||||
return &http.Server{
|
||||
@@ -341,11 +348,38 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// restoreDefaultCellularRadios repairs an interrupted VoWiFi teardown. CFUN=4
|
||||
// survives process restarts, while the in-memory radio checkpoint does not. If
|
||||
// VoWiFi is disabled and the current SIM has no explicit airplane policy, the
|
||||
// automatic/default policy is cellular service and the modem must return to
|
||||
// CFUN=1.
|
||||
func configureDeviceBackends(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
database *store.Store,
|
||||
manager *device.Manager,
|
||||
) {
|
||||
configs, err := database.ListDevices(ctx)
|
||||
if err != nil {
|
||||
logger.Warn("configure device backends: list devices", "error", err)
|
||||
return
|
||||
}
|
||||
mapper := integration.ATMapper{Store: database, Devices: manager}
|
||||
for _, config := range configs {
|
||||
entry, mapErr := mapper.Get(config.ID)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// restoreDefaultCellularRadios applies an explicitly saved cellular policy
|
||||
// after restart. Missing policies remain RF-off and are claimed by the safe
|
||||
// default policy; there is no automatic cellular fallback.
|
||||
func restoreDefaultCellularRadios(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
@@ -359,6 +393,9 @@ func restoreDefaultCellularRadios(
|
||||
}
|
||||
mapper := integration.ATMapper{Store: database, Devices: manager}
|
||||
for _, config := range configs {
|
||||
if config.DeviceType == store.DeviceTypeUSBSIMReader {
|
||||
continue
|
||||
}
|
||||
if config.VoWiFiEnabled {
|
||||
continue
|
||||
}
|
||||
@@ -367,11 +404,16 @@ func restoreDefaultCellularRadios(
|
||||
continue
|
||||
}
|
||||
iccid := strings.TrimSpace(entry.Snapshot.ICCID)
|
||||
if iccid == "" {
|
||||
continue
|
||||
}
|
||||
if iccid != "" {
|
||||
policy, policyErr := database.CardPolicy(ctx, iccid)
|
||||
switch {
|
||||
case policyErr == nil && policy.AirplaneEnabled:
|
||||
continue
|
||||
case errors.Is(policyErr, store.ErrNotFound):
|
||||
continue
|
||||
case policyErr != nil && !errors.Is(policyErr, store.ErrNotFound):
|
||||
logger.Warn("startup cellular recovery: read card policy", "device_id", config.ID, "error", policyErr)
|
||||
continue
|
||||
@@ -401,6 +443,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
|
||||
}
|
||||
@@ -408,13 +453,31 @@ func restoreConfiguredCellularData(
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
networkRequest := device.NetworkRequest{
|
||||
Enabled: true, APN: config.APN, IPVersion: "IPV4V6", Backend: config.DeviceBackend,
|
||||
}
|
||||
if entry.Snapshot != nil {
|
||||
iccid := strings.TrimSpace(entry.Snapshot.ICCID)
|
||||
if policy, policyErr := database.CardPolicy(ctx, iccid); policyErr == nil {
|
||||
networkRequest.APN = policy.APN
|
||||
if policy.IPVersion != "" {
|
||||
networkRequest.IPVersion = policy.IPVersion
|
||||
}
|
||||
if profile, profileErr := database.CardAPNProfileByAPN(ctx, iccid, policy.APN, policy.IPVersion); profileErr == nil {
|
||||
networkRequest.Username = profile.Username
|
||||
networkRequest.Password = profile.Password
|
||||
networkRequest.Authentication = profile.AuthType
|
||||
if entry.Snapshot.RegistrationStatus == 5 && profile.RoamingIPVersion != "" {
|
||||
networkRequest.IPVersion = profile.RoamingIPVersion
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
dataContext, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
_, err = manager.SetNetwork(dataContext, entry.ID, device.NetworkRequest{
|
||||
Enabled: true, APN: config.APN, IPVersion: "IPV4V6",
|
||||
})
|
||||
_, err = manager.SetNetwork(dataContext, entry.ID, networkRequest)
|
||||
cancel()
|
||||
if err != nil {
|
||||
logger.Warn("startup cellular data recovery failed", "device_id", config.ID, "error", err)
|
||||
logger.Warn("startup cellular data recovery failed", "device_id", config.ID)
|
||||
continue
|
||||
}
|
||||
logger.Info("restored protected cellular data route", "device_id", config.ID, "interface", config.Interface)
|
||||
@@ -434,15 +497,18 @@ 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
|
||||
}
|
||||
disableContext, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
_, err = manager.SetNetwork(disableContext, entry.ID, device.NetworkRequest{Enabled: false})
|
||||
_, err = manager.SetNetwork(disableContext, entry.ID, device.NetworkRequest{Enabled: false, Backend: config.DeviceBackend})
|
||||
cancel()
|
||||
if err != nil && ctx.Err() == nil {
|
||||
logger.Warn("developer cleanup: stop cellular data", "device_id", config.ID, "error", err)
|
||||
logger.Warn("developer cleanup: stop cellular data", "device_id", config.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -488,15 +554,33 @@ 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,
|
||||
// VoWiFi is always fail-closed with respect to cellular RF. Its teardown
|
||||
// leaves CFUN=4; only the explicit airplane-mode-off endpoint restores
|
||||
// CFUN=1.
|
||||
PureAirplanePolicy: func(deviceID string) bool {
|
||||
deviceConfig, configErr := database.Device(context.Background(), deviceID)
|
||||
return configErr == nil && deviceConfig.VoWiFiEnabled
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pcscAdapter, err := vowifi.NewPCSCAdapter(cardReaders, func(ctx context.Context, deviceID string) (pcsc.Selector, string, error) {
|
||||
config, resolveErr := database.Device(ctx, strings.TrimSpace(deviceID))
|
||||
if resolveErr != nil {
|
||||
return pcsc.Selector{}, "", resolveErr
|
||||
}
|
||||
return pcsc.Selector{USBPath: config.USBPath, ReaderName: config.ControlDevice}, config.SIMPIN, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -513,6 +597,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)
|
||||
},
|
||||
})
|
||||
@@ -528,6 +616,15 @@ func configureVoWiFiRuntime(
|
||||
return nil, fmt.Errorf("register device %q VoWiFi runtime: %w", deviceConfig.ID, err)
|
||||
}
|
||||
if deviceConfig.VoWiFiEnabled {
|
||||
if entry, mapErr := mapper.Get(deviceConfig.ID); mapErr == nil {
|
||||
flightContext, cancelFlight := context.WithTimeout(ctx, 10*time.Second)
|
||||
_, flightErr := deviceManager.SetFlight(flightContext, entry.ID, true)
|
||||
cancelFlight()
|
||||
if flightErr != nil {
|
||||
_ = manager.Close(context.Background())
|
||||
return nil, fmt.Errorf("protect device %q before VoWiFi startup: %w", deviceConfig.ID, flightErr)
|
||||
}
|
||||
}
|
||||
if _, err := manager.RequestEnabled(deviceConfig.ID, true); err != nil {
|
||||
_ = manager.Close(context.Background())
|
||||
return nil, fmt.Errorf("start device %q VoWiFi policy: %w", deviceConfig.ID, err)
|
||||
@@ -537,10 +634,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 == "" {
|
||||
@@ -670,9 +773,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") {
|
||||
@@ -681,6 +793,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(),
|
||||
@@ -691,10 +804,10 @@ func provisionDiscoveredDevices(
|
||||
StopBits: 1,
|
||||
Parity: "none",
|
||||
DeviceBackend: backend,
|
||||
ESIMTransport: backend,
|
||||
ESIMTransport: esimTransport,
|
||||
NetworkEnabled: false,
|
||||
SMSEnabled: true,
|
||||
VoWiFiEnabled: false,
|
||||
VoWiFiEnabled: true,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -754,20 +867,42 @@ func pollDeviceSnapshots(
|
||||
logger.Debug("periodic modem discovery failed", "error", err)
|
||||
return
|
||||
}
|
||||
for _, entry := range manager.List() {
|
||||
// Hotplug can replace the physical discovery ID. Rebind each configured
|
||||
// device's selected QMI/AT control plane before collecting its snapshot.
|
||||
configureDeviceBackends(ctx, logger, database, manager)
|
||||
entries := manager.List()
|
||||
// Each physical modem owns its own operation lock. Refresh them in
|
||||
// parallel so a slow or wedged EC20 on one hub port cannot delay signal
|
||||
// and identity updates for every other modem by 30 seconds at a time.
|
||||
var refreshGroup sync.WaitGroup
|
||||
refreshSlots := make(chan struct{}, 4)
|
||||
for _, entry := range entries {
|
||||
if !entry.Discovered {
|
||||
continue
|
||||
}
|
||||
refreshContext, cancelRefresh := context.WithTimeout(ctx, 30*time.Second)
|
||||
snapshot, err := manager.Refresh(refreshContext, entry.ID)
|
||||
cancelRefresh()
|
||||
if err != nil && ctx.Err() == nil {
|
||||
logger.Warn("modem snapshot refresh failed", "device_id", entry.ID, "error", err)
|
||||
}
|
||||
if err == nil && ctx.Err() == nil {
|
||||
enforceCardRegion(ctx, logger, database, manager, entry.ID, &snapshot)
|
||||
}
|
||||
entry := entry
|
||||
refreshGroup.Add(1)
|
||||
go func() {
|
||||
defer refreshGroup.Done()
|
||||
select {
|
||||
case refreshSlots <- struct{}{}:
|
||||
defer func() { <-refreshSlots }()
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
refreshContext, cancelRefresh := context.WithTimeout(ctx, 30*time.Second)
|
||||
snapshot, refreshErr := manager.Refresh(refreshContext, entry.ID)
|
||||
cancelRefresh()
|
||||
if refreshErr != nil && ctx.Err() == nil {
|
||||
logger.Warn("modem snapshot refresh failed", "device_id", entry.ID, "error", refreshErr)
|
||||
}
|
||||
if refreshErr == nil && ctx.Err() == nil {
|
||||
enforceCardRegion(ctx, logger, database, manager, entry.ID, &snapshot)
|
||||
enforceDefaultSafeCardPolicy(ctx, logger, database, manager, entry.ID, &snapshot)
|
||||
}
|
||||
}()
|
||||
}
|
||||
refreshGroup.Wait()
|
||||
}
|
||||
refresh()
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
@@ -782,6 +917,183 @@ func pollDeviceSnapshots(
|
||||
}
|
||||
}
|
||||
|
||||
// enforceDefaultSafeCardPolicy handles a newly inserted physical SIM or a
|
||||
// profile that has never had a policy. RF is turned off before the default is
|
||||
// persisted; the VoWiFi runtime reconciler then starts service asynchronously.
|
||||
func enforceDefaultSafeCardPolicy(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
database *store.Store,
|
||||
manager *device.Manager,
|
||||
physicalID string,
|
||||
snapshot *device.Snapshot,
|
||||
) {
|
||||
if snapshot == nil || !snapshot.SIMReady || strings.TrimSpace(snapshot.ICCID) == "" ||
|
||||
device.RegionBlockReason(snapshot.IMSI) != "" {
|
||||
return
|
||||
}
|
||||
iccid := strings.TrimSpace(snapshot.ICCID)
|
||||
if _, err := database.CardPolicy(ctx, iccid); err == nil {
|
||||
return
|
||||
} else if !errors.Is(err, store.ErrNotFound) {
|
||||
logger.Warn("default card policy: read policy", "iccid", iccid, "error", err)
|
||||
return
|
||||
}
|
||||
flightContext, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
_, err := manager.SetFlight(flightContext, physicalID, true)
|
||||
cancel()
|
||||
if err != nil {
|
||||
logger.Warn("default card policy: failed to establish airplane mode", "device_id", physicalID, "iccid", iccid, "error", err)
|
||||
return
|
||||
}
|
||||
if err := database.UpsertCardPolicy(ctx, store.CardPolicy{
|
||||
ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true,
|
||||
IPVersion: "IPV4V6", Source: "default",
|
||||
}); err != nil {
|
||||
logger.Warn("default card policy: persist policy", "iccid", iccid, "error", err)
|
||||
return
|
||||
}
|
||||
mapper := integration.ATMapper{Store: database, Devices: manager}
|
||||
configs, err := database.ListDevices(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, config := range configs {
|
||||
entry, mapErr := mapper.Get(config.ID)
|
||||
if mapErr != nil || entry.ID != physicalID {
|
||||
continue
|
||||
}
|
||||
config.NetworkEnabled = false
|
||||
config.VoWiFiEnabled = true
|
||||
if err := database.UpsertDevice(ctx, config); err != nil {
|
||||
logger.Warn("default card policy: update device policy", "device_id", config.ID, "error", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
logger.Info("new SIM protected by default VoWiFi/airplane policy", "device_id", physicalID, "iccid", iccid)
|
||||
}
|
||||
|
||||
func reconcileCardPolicies(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
database *store.Store,
|
||||
manager *device.Manager,
|
||||
vowifiManager *vowifiruntime.Manager,
|
||||
) {
|
||||
observedCards := make(map[string]string)
|
||||
reconcile := func() {
|
||||
policies, policyListErr := database.ListCardPolicies(ctx)
|
||||
if policyListErr == nil {
|
||||
for _, policy := range policies {
|
||||
if !policy.VoWiFiEnabled || (policy.AirplaneEnabled && !policy.NetworkEnabled) {
|
||||
continue
|
||||
}
|
||||
policy.AirplaneEnabled = true
|
||||
policy.NetworkEnabled = false
|
||||
if err := database.UpsertCardPolicy(ctx, policy); err != nil {
|
||||
logger.Warn("reconcile card policy: normalize stored RF-safe VoWiFi policy", "iccid", policy.ICCID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
configs, err := database.ListDevices(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
mapper := integration.ATMapper{Store: database, Devices: manager}
|
||||
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
|
||||
}
|
||||
if policy.VoWiFiEnabled && (!policy.AirplaneEnabled || policy.NetworkEnabled) {
|
||||
policy.AirplaneEnabled = true
|
||||
policy.NetworkEnabled = false
|
||||
if err := database.UpsertCardPolicy(ctx, policy); err != nil {
|
||||
logger.Warn("reconcile card policy: normalize RF-safe VoWiFi policy", "device_id", config.ID, "iccid", iccid, "error", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
deviceChanged := false
|
||||
if config.VoWiFiEnabled != policy.VoWiFiEnabled || (policy.VoWiFiEnabled && config.NetworkEnabled) {
|
||||
config.VoWiFiEnabled = policy.VoWiFiEnabled
|
||||
if policy.VoWiFiEnabled {
|
||||
config.NetworkEnabled = false
|
||||
}
|
||||
deviceChanged = true
|
||||
}
|
||||
if config.APN != strings.TrimSpace(policy.APN) {
|
||||
config.APN = strings.TrimSpace(policy.APN)
|
||||
deviceChanged = true
|
||||
}
|
||||
if deviceChanged {
|
||||
if err := database.UpsertDevice(ctx, config); err != nil {
|
||||
logger.Warn("reconcile card policy: update device", "device_id", config.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
state, stateErr := vowifiManager.State(config.ID)
|
||||
if policy.VoWiFiEnabled {
|
||||
if !entry.Snapshot.FlightMode {
|
||||
flightContext, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
_, _ = manager.SetFlight(flightContext, entry.ID, true)
|
||||
cancel()
|
||||
}
|
||||
switch {
|
||||
case stateErr != nil || !state.Enabled:
|
||||
_, _ = 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
|
||||
}
|
||||
if stateErr == nil && state.Enabled {
|
||||
_, _ = vowifiManager.RequestEnabled(config.ID, false)
|
||||
continue
|
||||
}
|
||||
if policy.AirplaneEnabled != entry.Snapshot.FlightMode {
|
||||
flightContext, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
_, _ = manager.SetFlight(flightContext, entry.ID, policy.AirplaneEnabled)
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
reconcile()
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
reconcile()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cardPolicySourceRegionBlock marks a card policy that was written automatically
|
||||
// because the inserted SIM belongs to a region the product does not serve. It
|
||||
// doubles as the persistent record that the radio was forced off by us, so the
|
||||
@@ -824,15 +1136,18 @@ func enforceCardRegion(
|
||||
}
|
||||
}
|
||||
if snapshot.ICCID != "" {
|
||||
policy := store.CardPolicy{
|
||||
ICCID: snapshot.ICCID,
|
||||
NetworkEnabled: false,
|
||||
VoWiFiEnabled: false,
|
||||
AirplaneEnabled: true,
|
||||
IPVersion: "IPV4V6",
|
||||
Source: cardPolicySourceRegionBlock,
|
||||
policy, policyErr := database.CardPolicy(ctx, snapshot.ICCID)
|
||||
if errors.Is(policyErr, store.ErrNotFound) {
|
||||
policy = store.CardPolicy{ICCID: snapshot.ICCID, IPVersion: "IPV4V6"}
|
||||
policyErr = nil
|
||||
}
|
||||
if err := database.UpsertCardPolicy(ctx, policy); err != nil && ctx.Err() == nil {
|
||||
policy.NetworkEnabled = false
|
||||
policy.VoWiFiEnabled = false
|
||||
policy.AirplaneEnabled = true
|
||||
policy.Source = cardPolicySourceRegionBlock
|
||||
if policyErr != nil && ctx.Err() == nil {
|
||||
logger.Warn("region block: failed to read card policy", "device_id", id, "iccid", snapshot.ICCID, "error", policyErr)
|
||||
} else if err := database.UpsertCardPolicy(ctx, policy); err != nil && ctx.Err() == nil {
|
||||
logger.Warn(
|
||||
"region block: failed to persist card policy",
|
||||
"device_id", id, "iccid", snapshot.ICCID, "error", err,
|
||||
@@ -848,10 +1163,10 @@ func enforceCardRegion(
|
||||
liftCardRegionBlock(ctx, logger, database, manager, id, snapshot)
|
||||
}
|
||||
|
||||
// liftCardRegionBlock reverses an automatic region block once the current SIM
|
||||
// is positively confirmed to be allowed. It restores the radio only when an
|
||||
// outstanding auto-forced block exists, so it never overrides a flight mode the
|
||||
// user enabled deliberately.
|
||||
// liftCardRegionBlock removes the regional marker once an allowed SIM is
|
||||
// confirmed. It deliberately does not restore RF: the replacement SIM is
|
||||
// picked up by enforceDefaultSafeCardPolicy and remains in airplane/VoWiFi
|
||||
// mode until an explicit user action.
|
||||
func liftCardRegionBlock(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
@@ -876,18 +1191,6 @@ func liftCardRegionBlock(
|
||||
if len(outstanding) == 0 {
|
||||
return
|
||||
}
|
||||
if snapshot.FlightMode {
|
||||
flightContext, cancelFlight := context.WithTimeout(ctx, 30*time.Second)
|
||||
_, err := manager.SetFlight(flightContext, id, false)
|
||||
cancelFlight()
|
||||
if err != nil && ctx.Err() == nil {
|
||||
logger.Warn(
|
||||
"region block: failed to restore radio",
|
||||
"device_id", id, "error", err,
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, policy := range outstanding {
|
||||
if err := database.DeleteCardPolicy(ctx, policy.ICCID); err != nil && ctx.Err() == nil {
|
||||
logger.Warn(
|
||||
@@ -897,7 +1200,7 @@ func liftCardRegionBlock(
|
||||
}
|
||||
}
|
||||
logger.Info(
|
||||
"region block lifted; SIM is allowed",
|
||||
"region marker removed; allowed SIM remains RF protected",
|
||||
"device_id", id, "iccid", snapshot.ICCID, "imsi", snapshot.IMSI,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -152,11 +152,7 @@ func TestEnforceCardRegionSkipsRadioWhenAlreadyOff(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEnforceCardRegionLiftsBlockForAllowedSIM(t *testing.T) {
|
||||
client := &fakeModemClient{steps: []fakeStep{
|
||||
{command: "AT+CFUN?", lines: []string{"+CFUN: 4"}},
|
||||
{command: "AT+CFUN=1"},
|
||||
{command: "AT+CFUN?", lines: []string{"+CFUN: 1"}},
|
||||
}}
|
||||
client := &fakeModemClient{}
|
||||
manager := newRegionTestManager(t, client)
|
||||
database := newRegionTestStore(t)
|
||||
|
||||
|
||||
+256
-51
@@ -7,8 +7,10 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -25,6 +27,11 @@ import (
|
||||
// rewrite it or the next restart reverts the password.
|
||||
const envFilePath = "/etc/vocat/env"
|
||||
|
||||
// legacyEnvFilePath was used by the standalone deploy/vocat.service. Keep it
|
||||
// discoverable so the menu works on installations made before the installer
|
||||
// and service template converged on /etc/vocat/env.
|
||||
const legacyEnvFilePath = "/etc/vocat/vocat.env"
|
||||
|
||||
const systemdUnitPath = "/etc/systemd/system/vocat.service"
|
||||
|
||||
// defaultDatabasePath is the install-default SQLite location written into the
|
||||
@@ -50,7 +57,7 @@ func loadMenuEnv() {
|
||||
if _, ok := os.LookupEnv("VOCAT_DATABASE_PATH"); !ok {
|
||||
_ = os.Setenv("VOCAT_DATABASE_PATH", defaultDatabasePath)
|
||||
}
|
||||
if data, err := os.ReadFile(envFilePath); err == nil {
|
||||
if data, err := os.ReadFile(menuEnvFilePath()); err == nil {
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
@@ -69,10 +76,20 @@ func loadMenuEnv() {
|
||||
}
|
||||
}
|
||||
|
||||
func menuEnvFilePath() string {
|
||||
if _, err := os.Stat(envFilePath); err == nil {
|
||||
return envFilePath
|
||||
}
|
||||
if _, err := os.Stat(legacyEnvFilePath); err == nil {
|
||||
return legacyEnvFilePath
|
||||
}
|
||||
return envFilePath
|
||||
}
|
||||
|
||||
// runMenu is the interactive lifecycle menu: toggle language, change password,
|
||||
// restart the systemd unit, self-update, or fully uninstall vocat. It must run
|
||||
// as root on the host (needs systemctl + the 0600 env file). Docker deployments
|
||||
// do not use it.
|
||||
// change the Web listener port, restart the systemd unit, self-update, or fully
|
||||
// uninstall vocat. It must run as root on the host (needs systemctl + the 0600
|
||||
// env file). Docker deployments do not use it.
|
||||
func runMenu(logger *slog.Logger) error {
|
||||
if os.Geteuid() != 0 {
|
||||
return errors.New("vocat menu must run as root (needs systemctl and /etc/vocat/env)")
|
||||
@@ -113,10 +130,14 @@ func runMenu(logger *slog.Logger) error {
|
||||
fmt.Println(menu.errorPrefix(err))
|
||||
}
|
||||
case "3":
|
||||
if err := menuRestart(menu); err != nil {
|
||||
if err := menuChangeWebPort(reader, menu); err != nil {
|
||||
fmt.Println(menu.errorPrefix(err))
|
||||
}
|
||||
case "4":
|
||||
if err := menuRestart(menu); err != nil {
|
||||
fmt.Println(menu.errorPrefix(err))
|
||||
}
|
||||
case "5":
|
||||
if err := menuUpdate(menu, logger); err != nil {
|
||||
fmt.Println(menu.errorPrefix(err))
|
||||
}
|
||||
@@ -242,9 +263,18 @@ func readPasswordMasked() (string, error) {
|
||||
// the temp file lives in the same directory so os.Rename stays on one
|
||||
// filesystem.
|
||||
func rewriteEnvPassword(newPassword string) error {
|
||||
const key = "VOCAT_ADMIN_PASSWORD="
|
||||
return rewriteEnvValue(menuEnvFilePath(), "VOCAT_ADMIN_PASSWORD", newPassword)
|
||||
}
|
||||
|
||||
// rewriteEnvValue replaces or appends one systemd EnvironmentFile value. The
|
||||
// write is atomic and rejects line breaks so one setting cannot inject another.
|
||||
func rewriteEnvValue(path, name, value string) error {
|
||||
if name == "" || strings.ContainsAny(name, "=\r\n\x00") || strings.ContainsAny(value, "\r\n\x00") {
|
||||
return errors.New("invalid environment setting")
|
||||
}
|
||||
key := name + "="
|
||||
var lines []string
|
||||
if data, err := os.ReadFile(envFilePath); err == nil {
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
lines = strings.Split(string(data), "\n")
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
@@ -253,27 +283,34 @@ func rewriteEnvPassword(newPassword string) error {
|
||||
replaced := false
|
||||
for i, line := range lines {
|
||||
if strings.HasPrefix(line, key) {
|
||||
lines[i] = key + newPassword
|
||||
lines[i] = key + value
|
||||
replaced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
lines = append(lines, key+newPassword)
|
||||
lines = append(lines, key+value)
|
||||
}
|
||||
content := strings.Join(lines, "\n")
|
||||
if !strings.HasSuffix(content, "\n") {
|
||||
content += "\n"
|
||||
}
|
||||
return writeEnvFileAtomic(path, []byte(content))
|
||||
}
|
||||
|
||||
dir := envFilePath[:strings.LastIndex(envFilePath, "/")]
|
||||
func writeEnvFileAtomic(path string, content []byte) error {
|
||||
dirIndex := strings.LastIndexAny(path, "/\\")
|
||||
if dirIndex < 0 {
|
||||
return errors.New("environment file path has no directory")
|
||||
}
|
||||
dir := path[:dirIndex]
|
||||
tmp, err := os.CreateTemp(dir, ".vocat-env-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
if _, err := tmp.WriteString(content); err != nil {
|
||||
if _, err := tmp.Write(content); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
@@ -284,7 +321,125 @@ func rewriteEnvPassword(newPassword string) error {
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpName, envFilePath)
|
||||
return os.Rename(tmpName, path)
|
||||
}
|
||||
|
||||
func menuChangeWebPort(reader *bufio.Reader, m *menu) error {
|
||||
if _, err := exec.LookPath("systemctl"); err != nil {
|
||||
return errNoSystemctl
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", errMenuConfig, err)
|
||||
}
|
||||
_, currentPortText, err := net.SplitHostPort(strings.TrimSpace(cfg.Address))
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", errMenuConfig, err)
|
||||
}
|
||||
fmt.Println(m.currentWebAddress(cfg.Address))
|
||||
fmt.Println(m.reverseProxyNotice())
|
||||
fmt.Print(m.newWebPort(currentPortText))
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return fmt.Errorf("read Web port: %w", err)
|
||||
}
|
||||
portText := strings.TrimSpace(line)
|
||||
if portText == "" {
|
||||
fmt.Println(m.webPortCancelled())
|
||||
return nil
|
||||
}
|
||||
newAddress, newPort, err := webAddressWithPort(cfg.Address, portText)
|
||||
if err != nil {
|
||||
return errInvalidWebPort
|
||||
}
|
||||
currentPort, _ := strconv.Atoi(currentPortText)
|
||||
if newPort == currentPort {
|
||||
fmt.Println(m.webPortUnchanged())
|
||||
return nil
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", newAddress)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", errWebPortUnavailable, err)
|
||||
}
|
||||
_ = listener.Close()
|
||||
|
||||
environmentPath := menuEnvFilePath()
|
||||
original, readErr := os.ReadFile(environmentPath)
|
||||
originalExisted := readErr == nil
|
||||
if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
|
||||
return fmt.Errorf("%w: %v", errMenuPortWrite, readErr)
|
||||
}
|
||||
if err := rewriteEnvValue(environmentPath, "VOCAT_ADDR", newAddress); err != nil {
|
||||
return fmt.Errorf("%w: %v", errMenuPortWrite, err)
|
||||
}
|
||||
if err := restartVocatService(); err != nil {
|
||||
rollbackErr := restoreMenuEnvFile(environmentPath, original, originalExisted)
|
||||
_ = restartVocatService()
|
||||
if rollbackErr != nil {
|
||||
return fmt.Errorf("%w: %v; rollback failed: %v", errRestartFailed, err, rollbackErr)
|
||||
}
|
||||
return fmt.Errorf("%w: %v", errRestartFailed, err)
|
||||
}
|
||||
if err := waitForWebListener(newAddress, 5*time.Second); err != nil {
|
||||
rollbackErr := restoreMenuEnvFile(environmentPath, original, originalExisted)
|
||||
_ = restartVocatService()
|
||||
if rollbackErr != nil {
|
||||
return fmt.Errorf("%w: %v; rollback failed: %v", errRestartFailed, err, rollbackErr)
|
||||
}
|
||||
return fmt.Errorf("%w: %v", errRestartFailed, err)
|
||||
}
|
||||
_ = os.Setenv("VOCAT_ADDR", newAddress)
|
||||
fmt.Println(m.webPortChanged(newAddress))
|
||||
return nil
|
||||
}
|
||||
|
||||
func webAddressWithPort(address, portText string) (string, int, error) {
|
||||
host, _, err := net.SplitHostPort(strings.TrimSpace(address))
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
port, err := strconv.Atoi(strings.TrimSpace(portText))
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
return "", 0, errInvalidWebPort
|
||||
}
|
||||
return net.JoinHostPort(host, strconv.Itoa(port)), port, nil
|
||||
}
|
||||
|
||||
func waitForWebListener(address string, timeout time.Duration) error {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch host {
|
||||
case "", "0.0.0.0":
|
||||
host = "127.0.0.1"
|
||||
case "::":
|
||||
host = "::1"
|
||||
}
|
||||
target := net.JoinHostPort(host, port)
|
||||
deadline := time.Now().Add(timeout)
|
||||
var lastErr error
|
||||
for time.Now().Before(deadline) {
|
||||
connection, dialErr := net.DialTimeout("tcp", target, 500*time.Millisecond)
|
||||
if dialErr == nil {
|
||||
_ = connection.Close()
|
||||
return nil
|
||||
}
|
||||
lastErr = dialErr
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
return fmt.Errorf("Web listener %s did not become reachable: %w", target, lastErr)
|
||||
}
|
||||
|
||||
func restoreMenuEnvFile(path string, content []byte, existed bool) error {
|
||||
if existed {
|
||||
return writeEnvFileAtomic(path, content)
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// menuToggleLanguage flips the persisted language preference between "zh" and
|
||||
@@ -327,6 +482,14 @@ func menuToggleLanguage(m *menu, logger *slog.Logger) error {
|
||||
}
|
||||
|
||||
func menuRestart(m *menu) error {
|
||||
if err := restartVocatService(); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(m.restarted())
|
||||
return nil
|
||||
}
|
||||
|
||||
func restartVocatService() error {
|
||||
if _, err := exec.LookPath("systemctl"); err != nil {
|
||||
return errNoSystemctl
|
||||
}
|
||||
@@ -334,7 +497,9 @@ func menuRestart(m *menu) error {
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("%w: %s", errRestartFailed, strings.TrimSpace(string(out)))
|
||||
}
|
||||
fmt.Println(m.restarted())
|
||||
if out, err := exec.Command("systemctl", "is-active", "--quiet", "vocat").CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("%w: service is not active: %s", errRestartFailed, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -378,6 +543,7 @@ func menuUninstall(reader *bufio.Reader, m *menu) error {
|
||||
_ = os.Remove(systemdUnitPath)
|
||||
_ = os.RemoveAll("/opt/vocat")
|
||||
_ = os.Remove(envFilePath)
|
||||
_ = os.Remove(legacyEnvFilePath)
|
||||
_ = os.Remove("/etc/vocat") // succeeds only when empty
|
||||
runIgnore("systemctl", "daemon-reload")
|
||||
runIgnore("userdel", "vocat")
|
||||
@@ -388,15 +554,18 @@ func menuUninstall(reader *bufio.Reader, m *menu) error {
|
||||
|
||||
// menu-local sentinel errors so callers can map them to localized messages.
|
||||
var (
|
||||
errCurrentWrong = errors.New("menu: current password is incorrect")
|
||||
errPasswordsDiffer = errors.New("menu: passwords do not match")
|
||||
errNoSystemctl = errors.New("menu: systemctl not found")
|
||||
errRestartFailed = errors.New("menu: restart failed")
|
||||
errUpdateFailed = errors.New("menu: update failed")
|
||||
errMenuConfig = errors.New("menu: load configuration")
|
||||
errMenuStore = errors.New("menu: open database")
|
||||
errMenuAuth = errors.New("menu: auth service")
|
||||
errMenuEnvWrite = errors.New("menu: write env file")
|
||||
errCurrentWrong = errors.New("menu: current password is incorrect")
|
||||
errPasswordsDiffer = errors.New("menu: passwords do not match")
|
||||
errNoSystemctl = errors.New("menu: systemctl not found")
|
||||
errRestartFailed = errors.New("menu: restart failed")
|
||||
errUpdateFailed = errors.New("menu: update failed")
|
||||
errMenuConfig = errors.New("menu: load configuration")
|
||||
errMenuStore = errors.New("menu: open database")
|
||||
errMenuAuth = errors.New("menu: auth service")
|
||||
errMenuEnvWrite = errors.New("menu: write env file")
|
||||
errMenuPortWrite = errors.New("menu: write Web port")
|
||||
errInvalidWebPort = errors.New("menu: invalid Web port")
|
||||
errWebPortUnavailable = errors.New("menu: Web port unavailable")
|
||||
)
|
||||
|
||||
// ---- i18n ----
|
||||
@@ -409,18 +578,28 @@ func newMenu(lang string) *menu { return &menu{lang: lang} }
|
||||
func (m *menu) msg(key string) string {
|
||||
const zh, en = 0, 1
|
||||
table := map[string][2]string{
|
||||
"title": {"vocat 管理菜单", "vocat management menu"},
|
||||
"opt_lang": {"1) 切换中英文", "1) Toggle language"},
|
||||
"opt_change": {"2) 修改账号密码", "2) Change admin password"},
|
||||
"opt_restart": {"3) 重启软件", "3) Restart software"},
|
||||
"opt_update": {"4) 更新软件", "4) Update software"},
|
||||
"opt_uninstall": {"0) 卸载软件", "0) Uninstall software"},
|
||||
"prompt": {"请选择: ", "Select: "},
|
||||
"invalid": {"无效选项,请重试。按 Ctrl+C 退出。", "Invalid choice, try again. Press Ctrl+C to exit."},
|
||||
"cur_pw": {"当前密码: ", "Current password: "},
|
||||
"new_pw": {"新密码 (至少 12 位): ", "New password (min 12 chars): "},
|
||||
"confirm_pw": {"确认新密码: ", "Confirm new password: "},
|
||||
"pw_changed": {"密码已修改。重启后仍然有效。", "Password changed. Survives restart."},
|
||||
"title": {"vocat 管理菜单", "vocat management menu"},
|
||||
"opt_lang": {"1) 切换中英文", "1) Toggle language"},
|
||||
"opt_change": {"2) 修改账号密码", "2) Change admin password"},
|
||||
"opt_port": {"3) 修改 Web 监听端口", "3) Change Web listening port"},
|
||||
"opt_restart": {"4) 重启软件", "4) Restart software"},
|
||||
"opt_update": {"5) 更新软件", "5) Update software"},
|
||||
"opt_uninstall": {"0) 卸载软件", "0) Uninstall software"},
|
||||
"prompt": {"请选择: ", "Select: "},
|
||||
"invalid": {"无效选项,请重试。按 Ctrl+C 退出。", "Invalid choice, try again. Press Ctrl+C to exit."},
|
||||
"cur_pw": {"当前密码: ", "Current password: "},
|
||||
"new_pw": {"新密码 (至少 12 位): ", "New password (min 12 chars): "},
|
||||
"confirm_pw": {"确认新密码: ", "Confirm new password: "},
|
||||
"pw_changed": {"密码已修改。重启后仍然有效。", "Password changed. Survives restart."},
|
||||
"current_web_address": {"当前 Web 监听地址: %s", "Current Web listening address: %s"},
|
||||
"new_web_port": {"新端口 (1-65535,直接回车取消,当前 %s): ", "New port (1-65535, Enter to cancel, current %s): "},
|
||||
"web_port_cancelled": {"已取消修改端口。", "Web port change cancelled."},
|
||||
"web_port_unchanged": {"端口未改变。", "Web port is unchanged."},
|
||||
"web_port_changed": {"Web 监听地址已改为 %s,软件已重启。", "Web listening address changed to %s; software restarted."},
|
||||
"reverse_proxy_notice": {
|
||||
"如使用 Nginx/Caddy 等反向代理,请同步修改其上游端口。",
|
||||
"If you use Nginx, Caddy, or another reverse proxy, update its upstream port too.",
|
||||
},
|
||||
"lang_switched": {
|
||||
"语言已切换。Web 界面下次刷新后同步。",
|
||||
"Language switched. The web UI syncs on next refresh.",
|
||||
@@ -431,9 +610,9 @@ func (m *menu) msg(key string) string {
|
||||
"警告: 将删除程序、数据与配置,且不可恢复!",
|
||||
"WARNING: removes the program, data and config. Irreversible!",
|
||||
},
|
||||
"uninstall_confirm": {"输入 yes 确认卸载: ", "Type yes to confirm uninstall: "},
|
||||
"uninstall_confirm": {"输入 yes 确认卸载: ", "Type yes to confirm uninstall: "},
|
||||
"uninstall_cancelled": {"已取消卸载。", "Uninstall cancelled."},
|
||||
"uninstalled": {"vocat 已卸载。", "vocat uninstalled."},
|
||||
"uninstalled": {"vocat 已卸载。", "vocat uninstalled."},
|
||||
}
|
||||
entry, ok := table[key]
|
||||
if !ok {
|
||||
@@ -445,25 +624,36 @@ func (m *menu) msg(key string) string {
|
||||
return entry[zh]
|
||||
}
|
||||
|
||||
func (m *menu) title() string { return m.msg("title") }
|
||||
func (m *menu) prompt() string { return m.msg("prompt") }
|
||||
func (m *menu) invalid() string { return m.msg("invalid") }
|
||||
func (m *menu) currentPassword() string { return m.msg("cur_pw") }
|
||||
func (m *menu) newPassword() string { return m.msg("new_pw") }
|
||||
func (m *menu) confirmPassword() string { return m.msg("confirm_pw") }
|
||||
func (m *menu) passwordChanged() string { return m.msg("pw_changed") }
|
||||
func (m *menu) languageSwitched() string { return m.msg("lang_switched") }
|
||||
func (m *menu) updateChecking() string { return m.msg("upd_checking") }
|
||||
func (m *menu) restarted() string { return m.msg("restarted") }
|
||||
func (m *menu) uninstallWarn() string { return m.msg("uninstall_warn") }
|
||||
func (m *menu) uninstallConfirm() string { return m.msg("uninstall_confirm") }
|
||||
func (m *menu) title() string { return m.msg("title") }
|
||||
func (m *menu) prompt() string { return m.msg("prompt") }
|
||||
func (m *menu) invalid() string { return m.msg("invalid") }
|
||||
func (m *menu) currentPassword() string { return m.msg("cur_pw") }
|
||||
func (m *menu) newPassword() string { return m.msg("new_pw") }
|
||||
func (m *menu) confirmPassword() string { return m.msg("confirm_pw") }
|
||||
func (m *menu) passwordChanged() string { return m.msg("pw_changed") }
|
||||
func (m *menu) currentWebAddress(address string) string {
|
||||
return fmt.Sprintf(m.msg("current_web_address"), address)
|
||||
}
|
||||
func (m *menu) newWebPort(port string) string { return fmt.Sprintf(m.msg("new_web_port"), port) }
|
||||
func (m *menu) webPortCancelled() string { return m.msg("web_port_cancelled") }
|
||||
func (m *menu) webPortUnchanged() string { return m.msg("web_port_unchanged") }
|
||||
func (m *menu) webPortChanged(address string) string {
|
||||
return fmt.Sprintf(m.msg("web_port_changed"), address)
|
||||
}
|
||||
func (m *menu) reverseProxyNotice() string { return m.msg("reverse_proxy_notice") }
|
||||
func (m *menu) languageSwitched() string { return m.msg("lang_switched") }
|
||||
func (m *menu) updateChecking() string { return m.msg("upd_checking") }
|
||||
func (m *menu) restarted() string { return m.msg("restarted") }
|
||||
func (m *menu) uninstallWarn() string { return m.msg("uninstall_warn") }
|
||||
func (m *menu) uninstallConfirm() string { return m.msg("uninstall_confirm") }
|
||||
func (m *menu) uninstallCancelled() string { return m.msg("uninstall_cancelled") }
|
||||
func (m *menu) uninstalled() string { return m.msg("uninstalled") }
|
||||
func (m *menu) uninstalled() string { return m.msg("uninstalled") }
|
||||
|
||||
func (m *menu) options() []string {
|
||||
return []string{
|
||||
m.msg("opt_lang"),
|
||||
m.msg("opt_change"),
|
||||
m.msg("opt_port"),
|
||||
m.msg("opt_restart"),
|
||||
m.msg("opt_update"),
|
||||
m.msg("opt_uninstall"),
|
||||
@@ -514,9 +704,24 @@ func (m *menu) errorPrefix(err error) string {
|
||||
return "认证服务错误。"
|
||||
case errors.Is(err, errMenuEnvWrite):
|
||||
if m.lang == "en" {
|
||||
return "Password changed in DB, but the env file rewrite failed — restart will revert it. Check " + envFilePath + "."
|
||||
return "Password changed in DB, but the env file rewrite failed — restart will revert it. Check " + menuEnvFilePath() + "."
|
||||
}
|
||||
return "数据库密码已修改,但环境变量文件写入失败——重启后将回滚。请检查 " + envFilePath + "。"
|
||||
return "数据库密码已修改,但环境变量文件写入失败——重启后将回滚。请检查 " + menuEnvFilePath() + "。"
|
||||
case errors.Is(err, errInvalidWebPort):
|
||||
if m.lang == "en" {
|
||||
return "Invalid port. Enter a number from 1 to 65535."
|
||||
}
|
||||
return "端口无效,请输入 1 到 65535。"
|
||||
case errors.Is(err, errWebPortUnavailable):
|
||||
if m.lang == "en" {
|
||||
return "The new Web port is unavailable or already in use."
|
||||
}
|
||||
return "新的 Web 端口不可用或已被占用。"
|
||||
case errors.Is(err, errMenuPortWrite):
|
||||
if m.lang == "en" {
|
||||
return "Failed to save the Web listening port to " + menuEnvFilePath() + "."
|
||||
}
|
||||
return "无法将 Web 监听端口保存到 " + menuEnvFilePath() + "。"
|
||||
default:
|
||||
if m.lang == "en" {
|
||||
return "Error: " + err.Error()
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWebAddressWithPort(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
address string
|
||||
port string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "IPv4", address: "0.0.0.0:7575", port: "8080", want: "0.0.0.0:8080"},
|
||||
{name: "IPv6", address: "[::]:7575", port: "8443", want: "[::]:8443"},
|
||||
{name: "minimum", address: "127.0.0.1:7575", port: "1", want: "127.0.0.1:1"},
|
||||
{name: "maximum", address: "127.0.0.1:7575", port: "65535", want: "127.0.0.1:65535"},
|
||||
{name: "zero", address: "0.0.0.0:7575", port: "0", wantErr: true},
|
||||
{name: "too large", address: "0.0.0.0:7575", port: "65536", wantErr: true},
|
||||
{name: "not numeric", address: "0.0.0.0:7575", port: "http", wantErr: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, _, err := webAddressWithPort(test.address, test.port)
|
||||
if test.wantErr {
|
||||
if !errors.Is(err, errInvalidWebPort) {
|
||||
t.Fatalf("error = %v, want errInvalidWebPort", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || got != test.want {
|
||||
t.Fatalf("webAddressWithPort() = %q, %v; want %q", got, err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteEnvValuePreservesOtherSettings(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "env")
|
||||
if err := os.WriteFile(path, []byte("VOCAT_ADMIN_PASSWORD=secret\nVOCAT_ADDR=0.0.0.0:7575\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := rewriteEnvValue(path, "VOCAT_ADDR", "0.0.0.0:8080"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(content)
|
||||
if !strings.Contains(got, "VOCAT_ADMIN_PASSWORD=secret\n") || !strings.Contains(got, "VOCAT_ADDR=0.0.0.0:8080\n") || strings.Contains(got, ":7575") {
|
||||
t.Fatalf("rewritten env = %q", got)
|
||||
}
|
||||
if err := rewriteEnvValue(path, "VOCAT_ADDR", "0.0.0.0:9000\nVOCAT_ADMIN_PASSWORD=changed"); err == nil {
|
||||
t.Fatal("environment line injection was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMenuIncludesWebPortOptionInBothLanguages(t *testing.T) {
|
||||
for _, lang := range []string{"zh", "en"} {
|
||||
options := strings.Join(newMenu(lang).options(), "\n")
|
||||
if !strings.Contains(options, "3)") || !strings.Contains(strings.ToLower(options), "web") {
|
||||
t.Fatalf("%s menu options do not contain Web port entry: %q", lang, options)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ Restart=on-failure
|
||||
RestartSec=3s
|
||||
TimeoutStartSec=30s
|
||||
TimeoutStopSec=20s
|
||||
Environment=VOCAT_ADDR=0.0.0.0:7575
|
||||
Environment=VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db
|
||||
EnvironmentFile=/etc/vocat/vocat.env
|
||||
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
# 企业微信消息推送实现计划
|
||||
|
||||
> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
|
||||
|
||||
**目标:** 增加可配置 JSON 请求模板的企业微信 Webhook 通知通道,向新短信和自动任务结果发送消息。
|
||||
|
||||
**架构:** 新建专注的企业微信通知模块,统一构建事件变量、JSON 安全替换、Webhook POST 和 `errcode` 响应判定。设置 API 将 `wecom` 纳入白名单、保密 URL 与连通性测试;短信和自动任务分发器只增加该通道分支。前端在现有通知设置表单中新增企业微信页签和请求体编辑器。
|
||||
|
||||
**技术栈:** Go 1.25、标准库 `net/http` 与 `encoding/json`、SQLite 通知设置、React、TypeScript、Vite。
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
- 创建:`internal/server/wecom_notification.go`,渲染企业微信 JSON 模板、创建安全 HTTP 请求并判定企业微信响应。
|
||||
- 创建:`internal/server/wecom_notification_test.go`,覆盖 JSON 转义、模板拒绝和企业微信响应失败。
|
||||
- 修改:`internal/server/settings_api.go`,登记 `wecom` 配置字段、启用连通性测试并调用企业微信发送器。
|
||||
- 修改:`internal/server/settings_api_test.go`,验证企业微信配置 API、敏感 URL 与测试路径。
|
||||
- 修改:`internal/store/settings.go`,将 `wecom.urls` 注册为敏感字段。
|
||||
- 修改:`internal/server/sms_notifications.go`,将新短信事件接入企业微信通道。
|
||||
- 修改:`internal/server/sms_notifications_test.go`,覆盖企业微信短信配置要求和变量数据。
|
||||
- 修改:`internal/server/automatic_task_notifications.go`,将自动任务结果接入企业微信通道。
|
||||
- 修改:`web/src/types.ts`,扩展通知设置类型。
|
||||
- 修改:`web/src/components/settings/model.ts`,增加企业微信表单、默认模板、读取和提交映射。
|
||||
- 修改:`web/src/components/settings/PushTabs.tsx`,新增企业微信配置界面。
|
||||
- 修改:`web/src/pages/SettingsPage.tsx`,增加页签、测试状态与测试请求。
|
||||
|
||||
### 任务 1:企业微信模板与响应判定
|
||||
|
||||
**文件:**
|
||||
- 创建:`internal/server/wecom_notification_test.go`
|
||||
- 创建:`internal/server/wecom_notification.go`
|
||||
|
||||
- [ ] **步骤 1:编写失败的模板与响应测试**
|
||||
|
||||
```go
|
||||
func TestRenderWecomPayloadEscapesTemplateValues(t *testing.T) {
|
||||
payload, err := renderWecomPayload(
|
||||
`{"msgtype":"text","text":{"content":{{message}},"number":{{number}}}}`,
|
||||
wecomTemplateValues{"message": "quote: \\"\\nline", "number": "+447386"},
|
||||
)
|
||||
if err != nil { t.Fatal(err) }
|
||||
if got := string(payload); got != `{"msgtype":"text","text":{"content":"quote: \\"\\nline","number":"+447386"}}` {
|
||||
t.Fatalf("payload = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderWecomPayloadRejectsUnknownVariableAndNonObject(t *testing.T) {
|
||||
for _, template := range []string{`{"text":{{unknown}}}`, `[]`} {
|
||||
if _, err := renderWecomPayload(template, wecomTemplateValues{}); err == nil {
|
||||
t.Fatalf("template %q was accepted", template)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWecomResponseRejectsProviderError(t *testing.T) {
|
||||
if err := validateWecomResponse(http.StatusOK, []byte(`{"errcode":40058,"errmsg":"invalid"}`)); !errors.Is(err, errProviderRejected) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **步骤 2:运行测试验证失败**
|
||||
|
||||
运行:`go test ./internal/server -run 'TestRenderWecomPayload|TestValidateWecomResponse' -count=1`
|
||||
|
||||
预期:FAIL,提示 `renderWecomPayload`、`wecomTemplateValues` 和 `validateWecomResponse` 未定义。
|
||||
|
||||
- [ ] **步骤 3:实现最少的模板与响应代码**
|
||||
|
||||
在 `internal/server/wecom_notification.go` 中定义受支持变量列表,先用 `json.Marshal` 编码每个字符串,再替换精确的 `{{name}}` 标记;若保留任何 `{{` 或 `}}`,或者 `json.Unmarshal` 后不是非空 `map[string]json.RawMessage`,返回错误。响应处理必须要求 HTTP 2xx、可解析 JSON,且 `errcode` 为零。
|
||||
|
||||
```go
|
||||
type wecomTemplateValues map[string]string
|
||||
|
||||
func renderWecomPayload(template string, values wecomTemplateValues) ([]byte, error) {
|
||||
for _, name := range wecomTemplateVariableNames {
|
||||
encoded, _ := json.Marshal(values[name])
|
||||
template = strings.ReplaceAll(template, "{{"+name+"}}", string(encoded))
|
||||
}
|
||||
if strings.Contains(template, "{{") || strings.Contains(template, "}}") {
|
||||
return nil, errors.New("wecom.payload_template contains an unsupported variable")
|
||||
}
|
||||
var payload map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(template), &payload); err != nil || len(payload) == 0 {
|
||||
return nil, errors.New("wecom.payload_template must render to a non-empty JSON object")
|
||||
}
|
||||
return []byte(template), nil
|
||||
}
|
||||
|
||||
func validateWecomResponse(status int, body []byte) error {
|
||||
var result struct { ErrCode int `json:"errcode"` }
|
||||
if status < http.StatusOK || status >= http.StatusMultipleChoices || json.Unmarshal(body, &result) != nil || result.ErrCode != 0 {
|
||||
return fmt.Errorf("%w: WeCom response was not successful", errProviderRejected)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func wecomTestValues(now time.Time) wecomTemplateValues {
|
||||
return wecomTemplateValues{
|
||||
"event": "test", "title": "vocat", "message": "vocat notification test",
|
||||
"timestamp": now.UTC().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
func sendWecomNotification(ctx context.Context, config map[string]any, values wecomTemplateValues) error {
|
||||
payload, err := renderWecomPayload(configString(config, "payload_template"), values)
|
||||
if err != nil { return err }
|
||||
client, err := restrictedHTTPClient(ctx, 8*time.Second, "")
|
||||
if err != nil { return err }
|
||||
for _, destination := range configStrings(config, "urls") {
|
||||
parsed, err := validateOutboundURL(ctx, destination, false)
|
||||
if err != nil { return err }
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), bytes.NewReader(payload))
|
||||
if err != nil { return fmt.Errorf("create WeCom notification request: %w", err) }
|
||||
request.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
request.Header.Set("User-Agent", "vocat-wecom-notification/1")
|
||||
response, err := client.Do(request)
|
||||
if err != nil { return fmt.Errorf("send WeCom notification: %w", err) }
|
||||
body, readErr := io.ReadAll(io.LimitReader(response.Body, 64<<10)); response.Body.Close()
|
||||
if readErr != nil { return fmt.Errorf("read WeCom response: %w", readErr) }
|
||||
if err := validateWecomResponse(response.StatusCode, body); err != nil { return err }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **步骤 4:运行测试验证通过**
|
||||
|
||||
运行:`go test ./internal/server -run 'TestRenderWecomPayload|TestValidateWecomResponse' -count=1`
|
||||
|
||||
预期:PASS。
|
||||
|
||||
- [ ] **步骤 5:提交本任务**
|
||||
|
||||
运行:`git add internal/server/wecom_notification.go internal/server/wecom_notification_test.go && git commit -m "feat: add WeCom payload renderer"`
|
||||
|
||||
预期:创建包含模板渲染和响应判定的提交。若 Git 作者身份仍未配置,停止提交但保留已验证的工作区改动,不自行设置身份。
|
||||
|
||||
### 任务 2:设置 API 与敏感 Webhook URL
|
||||
|
||||
**文件:**
|
||||
- 修改:`internal/server/settings_api_test.go`
|
||||
- 修改:`internal/store/settings.go`
|
||||
- 修改:`internal/server/settings_api.go`
|
||||
|
||||
- [ ] **步骤 1:编写失败的 API 测试**
|
||||
|
||||
```go
|
||||
func TestWecomNotificationSettingsPreserveWebhookURLs(t *testing.T) {
|
||||
test := newSettingsAPITest(t)
|
||||
body := `{"wecom":{"enabled":true,"urls":["https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=secret"],"payload_template":"{\\\"msgtype\\\":\\\"text\\\",\\\"text\\\":{\\\"content\\\":{{message}}}}"}}`
|
||||
recorder := test.request(t, http.MethodPut, "/api/settings/notifications", body)
|
||||
if recorder.Code != http.StatusOK { t.Fatalf("status = %d", recorder.Code) }
|
||||
if bytes.Contains(recorder.Body.Bytes(), []byte("key=secret")) { t.Fatal("response leaked webhook URL") }
|
||||
stored, err := test.database.NotificationSetting(context.Background(), "wecom")
|
||||
if err != nil || !bytes.Contains(stored.Config, []byte("key=secret")) { t.Fatalf("stored = %s, err = %v", stored.Config, err) }
|
||||
}
|
||||
|
||||
func TestWecomNotificationSettingsRejectMalformedTemplate(t *testing.T) {
|
||||
test := newSettingsAPITest(t)
|
||||
recorder := test.request(t, http.MethodPut, "/api/settings/notifications", `{"wecom":{"enabled":true,"urls":["https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=x"],"payload_template":"[]"}}`)
|
||||
if recorder.Code != http.StatusBadRequest { t.Fatalf("status = %d", recorder.Code) }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **步骤 2:运行测试验证失败**
|
||||
|
||||
运行:`go test ./internal/server -run 'TestWecomNotificationSettings' -count=1`
|
||||
|
||||
预期:FAIL,设置 API 返回 `invalid_notification_channel`。
|
||||
|
||||
- [ ] **步骤 3:实现 API 契约、保存和测试端点**
|
||||
|
||||
在 `notificationChannels` 中加入 `wecom`,在 `notificationFields` 中登记 `urls: strings` 和 `payload_template: wecom_template`。将 `urls` 加入 `DefaultNotificationSensitiveFields("wecom")`。在字段验证中对 `wecom_template` 调用 `renderWecomPayload`,以默认测试变量确认模板会生成对象;在 `validateNotificationTestConfig`、`handleNotificationTest` 和发送分支中支持 `wecom`。
|
||||
|
||||
```go
|
||||
"wecom": {"urls": "strings", "payload_template": "wecom_template"},
|
||||
|
||||
case "wecom":
|
||||
return []string{"urls"}
|
||||
|
||||
case "wecom":
|
||||
err = sendWecomNotificationTest(r.Context(), resolved)
|
||||
```
|
||||
|
||||
将上段 `payload_template` 的字段类型实现为 `wecom_template`,避免只按普通字符串检查:
|
||||
|
||||
```go
|
||||
case "wecom_template":
|
||||
var template string
|
||||
if err := json.Unmarshal(raw, &template); err != nil || len(template) > 32768 {
|
||||
return fmt.Errorf("%s must be a template string", field)
|
||||
}
|
||||
_, err := renderWecomPayload(template, wecomTestValues(time.Unix(0, 0)))
|
||||
return err
|
||||
|
||||
case "wecom":
|
||||
if len(configStrings(config, "urls")) == 0 || configString(config, "payload_template") == "" {
|
||||
return errors.New("wecom.urls and wecom.payload_template are required")
|
||||
}
|
||||
```
|
||||
|
||||
测试消息的变量必须为 `event: "test"`、`title: "vocat"`、`message: "vocat notification test"` 和当前 UTC RFC3339 时间;它应经过与生产消息完全相同的渲染和发送路径。
|
||||
|
||||
- [ ] **步骤 4:运行测试验证通过**
|
||||
|
||||
运行:`go test ./internal/server -run 'TestWecomNotificationSettings|TestNotificationSettingsAlwaysReturns' -count=1`
|
||||
|
||||
预期:PASS,GET/PUT 响应不会泄露 `key`,但数据库保留原 URL。
|
||||
|
||||
- [ ] **步骤 5:提交本任务**
|
||||
|
||||
运行:`git add internal/server/settings_api.go internal/server/settings_api_test.go internal/store/settings.go && git commit -m "feat: configure WeCom notifications"`
|
||||
|
||||
预期:创建设置 API 与敏感配置提交;作者身份未配置时遵循任务 1 的处理方式。
|
||||
|
||||
### 任务 3:接入短信与自动任务分发
|
||||
|
||||
**文件:**
|
||||
- 修改:`internal/server/sms_notifications_test.go`
|
||||
- 修改:`internal/server/sms_notifications.go`
|
||||
- 修改:`internal/server/automatic_task_notifications.go`
|
||||
|
||||
- [ ] **步骤 1:编写失败的事件变量测试**
|
||||
|
||||
```go
|
||||
func TestWecomSMSValuesIncludeRenderedSMSFields(t *testing.T) {
|
||||
message := smsNotification{DeviceID: "device-1", DeviceName: "客厅", DeviceLabel: "EC20", Number: "+447386", Time: time.Unix(1700000000, 0), Content: "hello"}
|
||||
values := wecomSMSValues(message)
|
||||
if values["event"] != "sms.received" || values["content"] != "hello" || values["device_label"] != "EC20" {
|
||||
t.Fatalf("values = %#v", values)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWecomAutomaticTaskValuesLeaveSMSFieldsEmpty(t *testing.T) {
|
||||
values := wecomAutomaticTaskValues(automaticTaskNotification{Title: "自动任务执行成功", Text: "任务已完成", Time: time.Unix(1700000000, 0)})
|
||||
if values["event"] != "automatic_task.completed" || values["message"] != "任务已完成" || values["number"] != "" {
|
||||
t.Fatalf("values = %#v", values)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **步骤 2:运行测试验证失败**
|
||||
|
||||
运行:`go test ./internal/server -run 'TestWecomSMSValues|TestWecomAutomaticTaskValues' -count=1`
|
||||
|
||||
预期:FAIL,两个事件变量构建函数未定义。
|
||||
|
||||
- [ ] **步骤 3:实现分发接入**
|
||||
|
||||
在企业微信模块中实现 `wecomSMSValues` 和 `wecomAutomaticTaskValues`,填充全部已声明变量,短信专属字段在自动任务事件中设为空字符串。然后将 `wecom` 加入以下分发列表与 switch:
|
||||
|
||||
```go
|
||||
var smsOnlyNotificationChannels = []string{"bark", "email", "pushplus", "webhook", "wecom"}
|
||||
|
||||
case "wecom":
|
||||
return sendWecomNotification(ctx, config, wecomSMSValues(message))
|
||||
```
|
||||
|
||||
```go
|
||||
channels := []string{"telegram", "bark", "email", "pushplus", "webhook", "wecom"}
|
||||
for _, channel := range channels {
|
||||
setting, err := s.store.NotificationSetting(ctx, channel)
|
||||
if errors.Is(err, store.ErrNotFound) || (err == nil && !setting.Enabled) { continue }
|
||||
if err != nil { s.logger.Warn("read automatic task notification setting", "channel", channel, "error", err); continue }
|
||||
var config map[string]any
|
||||
if err := json.Unmarshal(setting.Config, &config); err != nil { s.logger.Warn("decode automatic task notification setting", "channel", channel, "error", err); continue }
|
||||
if err := sendAutomaticTaskNotification(ctx, channel, config, notification); err != nil { s.logger.Warn("send automatic task notification", "channel", channel, "task_id", task.ID, "error", err) }
|
||||
}
|
||||
|
||||
case "wecom":
|
||||
return sendWecomNotification(ctx, config, wecomAutomaticTaskValues(message))
|
||||
```
|
||||
|
||||
保持既有游标、错误限流日志和其他通道的行为不变。
|
||||
|
||||
- [ ] **步骤 4:运行测试验证通过**
|
||||
|
||||
运行:`go test ./internal/server -run 'TestWecomSMSValues|TestWecomAutomaticTaskValues|TestValidateSMSNotificationConfig' -count=1`
|
||||
|
||||
预期:PASS,`validateSMSNotificationConfig` 也接受包含有效 URL 和模板的 `wecom` 配置。
|
||||
|
||||
- [ ] **步骤 5:提交本任务**
|
||||
|
||||
运行:`git add internal/server/wecom_notification.go internal/server/sms_notifications.go internal/server/sms_notifications_test.go internal/server/automatic_task_notifications.go && git commit -m "feat: dispatch WeCom notifications"`
|
||||
|
||||
预期:创建两类事件分发接入提交;作者身份未配置时遵循任务 1 的处理方式。
|
||||
|
||||
### 任务 4:企业微信配置界面
|
||||
|
||||
**文件:**
|
||||
- 修改:`web/src/types.ts`
|
||||
- 修改:`web/src/components/settings/model.ts`
|
||||
- 修改:`web/src/components/settings/PushTabs.tsx`
|
||||
- 修改:`web/src/pages/SettingsPage.tsx`
|
||||
|
||||
- [ ] **步骤 1:扩展前端类型和表单映射**
|
||||
|
||||
在 `NotificationSettings` 与 `NotifyForms` 中增加 `wecom`。新增以下表单类型和默认请求体;URL 数组保持一项一个输入行的既有 `UrlListEditor` 约定。
|
||||
|
||||
```ts
|
||||
export interface WecomForm {
|
||||
enabled: boolean;
|
||||
urls: string[];
|
||||
payloadTemplate: string;
|
||||
}
|
||||
|
||||
const DEFAULT_WECOM_PAYLOAD_TEMPLATE = `{
|
||||
"msgtype": "text",
|
||||
"text": { "content": {{message}} }
|
||||
}`;
|
||||
```
|
||||
|
||||
`formsFromNotifications` 读取 `payload_template`,`buildNotificationsPayload` 输出 `payload_template`,测试请求则修剪并移除空 URL。
|
||||
|
||||
- [ ] **步骤 2:实现企业微信页签与测试请求**
|
||||
|
||||
在 `PushTabs.tsx` 增加 `WecomTab`,显示启用开关、`UrlListEditor`、JSON `Textarea` 和变量说明。URL 列表文案必须明确“每个 Webhook URL 单独一行,点击添加 URL 增加”,不得提示使用分隔符。
|
||||
|
||||
```tsx
|
||||
<Field label={t("JSON 请求体模板")} hint={<span>变量必须作为 JSON 值使用,例如 <code>{'{{message}}'}</code>。</span>}>
|
||||
<Textarea value={value.payloadTemplate} onChange={(event) => onChange({ payloadTemplate: event.target.value })} disabled={off} rows={12} />
|
||||
</Field>
|
||||
```
|
||||
|
||||
在 `SettingsPage.tsx` 增加 `testingWecom`、`onTestWecom`、企业微信页签与组件渲染。测试请求使用 `POST /settings/notifications/wecom/test` 和企业微信表单 payload;成功与失败消息沿用现有通知测试模式。
|
||||
|
||||
- [ ] **步骤 3:运行前端构建验证**
|
||||
|
||||
运行:`npm run build`
|
||||
|
||||
工作目录:`web`
|
||||
|
||||
预期:Vite 类型检查与生产构建均以退出码 0 完成。
|
||||
|
||||
- [ ] **步骤 4:提交本任务**
|
||||
|
||||
运行:`git add web/src/types.ts web/src/components/settings/model.ts web/src/components/settings/PushTabs.tsx web/src/pages/SettingsPage.tsx && git commit -m "feat: add WeCom notification settings"`
|
||||
|
||||
预期:创建企业微信设置 UI 提交;作者身份未配置时遵循任务 1 的处理方式。
|
||||
|
||||
### 任务 5:完整验证
|
||||
|
||||
**文件:**
|
||||
- 修改:`internal/server/wecom_notification.go`
|
||||
- 修改:`internal/server/wecom_notification_test.go`
|
||||
- 修改:`internal/server/settings_api.go`
|
||||
- 修改:`internal/server/settings_api_test.go`
|
||||
- 修改:`internal/store/settings.go`
|
||||
- 修改:`internal/server/sms_notifications.go`
|
||||
- 修改:`internal/server/sms_notifications_test.go`
|
||||
- 修改:`internal/server/automatic_task_notifications.go`
|
||||
- 修改:`web/src/types.ts`
|
||||
- 修改:`web/src/components/settings/model.ts`
|
||||
- 修改:`web/src/components/settings/PushTabs.tsx`
|
||||
- 修改:`web/src/pages/SettingsPage.tsx`
|
||||
|
||||
- [ ] **步骤 1:格式化 Go 代码**
|
||||
|
||||
运行:`gofmt -w internal/server/wecom_notification.go internal/server/wecom_notification_test.go internal/server/settings_api.go internal/server/settings_api_test.go internal/server/sms_notifications.go internal/server/sms_notifications_test.go internal/server/automatic_task_notifications.go internal/store/settings.go`
|
||||
|
||||
预期:所有修改的 Go 文件采用项目标准格式。
|
||||
|
||||
- [ ] **步骤 2:运行前端生产构建**
|
||||
|
||||
运行:`npm run build`
|
||||
|
||||
工作目录:`web`
|
||||
|
||||
预期:退出码 0,并生成 `web/dist` 供 Go 的嵌入资源使用。
|
||||
|
||||
- [ ] **步骤 3:运行后端回归测试**
|
||||
|
||||
运行:`go test ./...`
|
||||
|
||||
预期:所有目标包通过,无失败测试;`cmd/vocat` 和 `web` 包从步骤 2 生成的 `web/dist` 读取嵌入资源。
|
||||
|
||||
- [ ] **步骤 4:检查最终变更**
|
||||
|
||||
运行:`git diff --check && git status --short`
|
||||
|
||||
预期:无空白错误;变更仅限企业微信通知、其测试与设计/计划文档。
|
||||
@@ -0,0 +1,55 @@
|
||||
# 企业微信消息推送设计
|
||||
|
||||
## 目标
|
||||
|
||||
新增独立的 `wecom` 通知通道,通过企业微信“消息推送(原群机器人)”Webhook 推送新收到的短信和自动任务执行结果。外部 API 契约与既有通知通道保持一致。
|
||||
|
||||
## 配置模型
|
||||
|
||||
`wecom` 配置包含:
|
||||
|
||||
- `enabled`:是否启用通道。
|
||||
- `urls`:一个或多个企业微信消息推送 Webhook URL。Web 设置页将每个 URL
|
||||
显示为独立输入行,通过“添加 URL”按钮新增输入行、通过删除按钮移除输入行;
|
||||
不使用逗号、空格或换行分隔多个 URL。
|
||||
- `payload_template`:完整 JSON 请求体模板。
|
||||
|
||||
Webhook URL 含有企业微信访问密钥,必须作为敏感配置存储、在读取接口中脱敏,并在日志和错误信息中避免泄露。URL 沿用现有出站 URL 校验与 SSRF 防护。
|
||||
|
||||
## 模板语义
|
||||
|
||||
用户在 Web 设置页编辑完整 JSON 请求体,以选择企业微信支持的任意消息格式,例如 `text`、`markdown`、`news` 或 `template_card`。
|
||||
|
||||
模板变量仅能作为 JSON 值出现,服务端使用 JSON 编码后的字符串替换,调用方不得在变量外添加引号。示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"msgtype": "text",
|
||||
"text": {
|
||||
"content": {{message}}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
可用变量:
|
||||
|
||||
- 通用:`{{event}}`、`{{title}}`、`{{message}}`、`{{timestamp}}`。
|
||||
- 短信事件:`{{content}}`、`{{number}}`、`{{device_id}}`、`{{device_name}}`、`{{device_label}}`、`{{time}}`。
|
||||
|
||||
自动任务使用通用变量;短信专属变量在自动任务中替换为空字符串。模板渲染后必须为非空 JSON 对象,不得保留模板变量;无效模板在保存和测试时拒绝。
|
||||
|
||||
## 发送流程
|
||||
|
||||
短信分发器为 `wecom` 维护独立游标,发送失败不会阻塞其他通知渠道。自动任务完成后,和 Telegram、Bark、邮件、PushPlus、通用 Webhook 一样,向已启用的 `wecom` 通道发送结果。
|
||||
|
||||
发送器逐一 POST 渲染后的 JSON 到所有配置 URL,使用现有受限 HTTP 客户端。除 HTTP 2xx 外,企业微信返回 JSON 的 `errcode` 非零也视为服务商拒绝。
|
||||
|
||||
## Web 与 API
|
||||
|
||||
设置 API 将 `wecom` 加入已知通道和配置字段白名单,并提供 `POST /api/settings/notifications/wecom/test`。Web 设置页新增“企业微信”页签、启用开关、逐行编辑的 Webhook URL 列表、JSON 模板编辑器和测试按钮。
|
||||
|
||||
默认模板使用 `text` 消息,发送一条可辨识的测试内容。
|
||||
|
||||
## 验证
|
||||
|
||||
后端测试覆盖:配置字段验证、模板的 JSON 转义和拒绝无效模板、企业微信请求载荷、非零 `errcode` 失败处理、通知设置 API 读写与敏感 Webhook URL 保留。前端构建用于验证新增表单与类型契约。
|
||||
@@ -3,6 +3,7 @@ module vocat
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/ElMostafaIdrassi/goscard v1.0.0
|
||||
github.com/coder/websocket v1.8.15
|
||||
go.bug.st/serial v1.6.4
|
||||
golang.org/x/crypto v0.41.0
|
||||
@@ -14,6 +15,7 @@ require (
|
||||
require (
|
||||
github.com/creack/goselect v0.1.2 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/ebitengine/purego v0.8.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
github.com/ElMostafaIdrassi/goscard v1.0.0 h1:RDG5QrqrQBUoi5MkzM4zILdYf8qDn62daYZszqvdgx0=
|
||||
github.com/ElMostafaIdrassi/goscard v1.0.0/go.mod h1:uGOakQe2fFlW2cVlr9cv6x07uelrf0j0aKPbR7jGgfg=
|
||||
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
||||
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0=
|
||||
@@ -6,6 +8,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
|
||||
github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -18,8 +22,8 @@ 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=
|
||||
|
||||
@@ -25,8 +25,11 @@ func Enabled(ctx context.Context, database *store.Store) bool {
|
||||
const (
|
||||
EnabledSettingKey = "developer.enabled"
|
||||
DeviceLimitSettingKey = "developer.device_limit"
|
||||
SMSHourlyLimitKey = "developer.sms_hourly_limit"
|
||||
DefaultDeviceLimit = 5
|
||||
MaxDeviceLimit = 128
|
||||
DefaultSMSHourlyLimit = 10
|
||||
MaxSMSHourlyLimit = 1000
|
||||
)
|
||||
|
||||
func DeviceLimit(ctx context.Context, database *store.Store, enabled bool) int {
|
||||
@@ -57,6 +60,33 @@ func SetDeviceLimit(ctx context.Context, database *store.Store, limit int) error
|
||||
return database.UpsertAppSetting(ctx, store.AppSetting{Key: DeviceLimitSettingKey, Value: value})
|
||||
}
|
||||
|
||||
// SMSHourlyLimit is enforced regardless of developer mode. Developer mode
|
||||
// only controls whether administrators can see and modify this value.
|
||||
func SMSHourlyLimit(ctx context.Context, database *store.Store) int {
|
||||
setting, err := database.AppSetting(ctx, SMSHourlyLimitKey)
|
||||
if err != nil {
|
||||
return DefaultSMSHourlyLimit
|
||||
}
|
||||
var document struct {
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
if json.Unmarshal(setting.Value, &document) != nil || document.Limit < 1 || document.Limit > MaxSMSHourlyLimit {
|
||||
return DefaultSMSHourlyLimit
|
||||
}
|
||||
return document.Limit
|
||||
}
|
||||
|
||||
func SetSMSHourlyLimit(ctx context.Context, database *store.Store, limit int) error {
|
||||
if limit < 1 || limit > MaxSMSHourlyLimit {
|
||||
return fmt.Errorf("SMS hourly limit must be between 1 and %d", MaxSMSHourlyLimit)
|
||||
}
|
||||
value, err := json.Marshal(map[string]int{"limit": limit})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return database.UpsertAppSetting(ctx, store.AppSetting{Key: SMSHourlyLimitKey, Value: value})
|
||||
}
|
||||
|
||||
// ResetExperimental restores every mutable developer-only setting. It is
|
||||
// called both by `vocat develop off` and at startup whenever developer mode is
|
||||
// disabled, so stale database values cannot silently remain active.
|
||||
@@ -72,6 +102,9 @@ func ResetExperimental(ctx context.Context, database *store.Store) error {
|
||||
if err := SetDeviceLimit(ctx, database, DefaultDeviceLimit); err != nil {
|
||||
resetErrors = append(resetErrors, fmt.Errorf("reset device limit: %w", err))
|
||||
}
|
||||
if err := SetSMSHourlyLimit(ctx, database, DefaultSMSHourlyLimit); err != nil {
|
||||
resetErrors = append(resetErrors, fmt.Errorf("reset SMS hourly limit: %w", err))
|
||||
}
|
||||
if err := database.DeleteAppSetting(ctx, exportproxy.SettingKey); err != nil && !errors.Is(err, store.ErrNotFound) {
|
||||
resetErrors = append(resetErrors, fmt.Errorf("delete export proxy configurations: %w", err))
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ func TestResetExperimentalRestoresDefaults(t *testing.T) {
|
||||
if err := SetDeviceLimit(ctx, database, 24); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SetSMSHourlyLimit(ctx, database, 42); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enabled, _ := json.Marshal(map[string]bool{"enabled": true})
|
||||
if err := database.UpsertAppSetting(ctx, store.AppSetting{Key: httpsmode.SettingKey, Value: enabled}); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -41,6 +44,9 @@ func TestResetExperimentalRestoresDefaults(t *testing.T) {
|
||||
if limit := DeviceLimit(ctx, database, true); limit != DefaultDeviceLimit {
|
||||
t.Fatalf("device limit = %d, want %d", limit, DefaultDeviceLimit)
|
||||
}
|
||||
if limit := SMSHourlyLimit(ctx, database); limit != DefaultSMSHourlyLimit {
|
||||
t.Fatalf("SMS hourly limit = %d, want %d", limit, DefaultSMSHourlyLimit)
|
||||
}
|
||||
setting, err := database.AppSetting(ctx, httpsmode.SettingKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -75,3 +81,21 @@ func TestSetDeviceLimitValidatesRange(t *testing.T) {
|
||||
t.Fatal("out-of-range device limit was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetSMSHourlyLimitValidatesRange(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "vocat.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
if SetSMSHourlyLimit(ctx, database, 0) == nil || SetSMSHourlyLimit(ctx, database, MaxSMSHourlyLimit+1) == nil {
|
||||
t.Fatal("out-of-range SMS hourly limit was accepted")
|
||||
}
|
||||
if err := SetSMSHourlyLimit(ctx, database, 25); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := SMSHourlyLimit(ctx, database); got != 25 {
|
||||
t.Fatalf("SMS hourly limit = %d, want 25", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,3 +42,25 @@ func CarrierForPLMN(plmn string) (name, countryCode string, ok bool) {
|
||||
}
|
||||
return name, countryCode, true
|
||||
}
|
||||
|
||||
// CarrierForIMSI resolves the home PLMN carried by an IMSI. MNCs may contain
|
||||
// either two or three digits, so prefer an exact six-digit database match and
|
||||
// then fall back to the five-digit form. This avoids treating the first three
|
||||
// subscriber digits as a three-digit MNC for networks such as 234-33.
|
||||
func CarrierForIMSI(imsi string) (plmn, name, countryCode string, ok bool) {
|
||||
imsi = strings.TrimSpace(imsi)
|
||||
if !decimalDigits(imsi, 5, 20) {
|
||||
return "", "", "", false
|
||||
}
|
||||
for _, length := range []int{6, 5} {
|
||||
if len(imsi) < length {
|
||||
continue
|
||||
}
|
||||
candidate := imsi[:length]
|
||||
carrier, country, found := CarrierForPLMN(candidate)
|
||||
if found {
|
||||
return candidate, carrier, country, true
|
||||
}
|
||||
}
|
||||
return "", "", "", false
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
+89
-8
@@ -13,6 +13,40 @@ import (
|
||||
|
||||
var apnPattern = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,98}[A-Za-z0-9])?$`)
|
||||
|
||||
// ValidAPN reports whether value can safely be used as a modem PDP-context APN.
|
||||
// An empty value is valid and means that the modem/operator default should be used.
|
||||
func ValidAPN(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
return value == "" || apnPattern.MatchString(value)
|
||||
}
|
||||
|
||||
func validNetworkCredential(value string) bool {
|
||||
if len(value) > 128 || strings.ContainsAny(value, "\r\n\x00\"") {
|
||||
return false
|
||||
}
|
||||
for _, character := range value {
|
||||
if character < 0x20 || character == 0x7f {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizeNetworkAuthentication(value string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(value)) {
|
||||
case "", "NONE":
|
||||
return "NONE"
|
||||
case "PAP":
|
||||
return "PAP"
|
||||
case "CHAP":
|
||||
return "CHAP"
|
||||
case "PAP_OR_CHAP":
|
||||
return "PAP_OR_CHAP"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *Manager) SetNetwork(
|
||||
ctx context.Context,
|
||||
id string,
|
||||
@@ -23,9 +57,16 @@ func (manager *Manager) SetNetwork(
|
||||
return NetworkResult{}, err
|
||||
}
|
||||
apn := strings.TrimSpace(request.APN)
|
||||
if request.Enabled && apn != "" && !apnPattern.MatchString(apn) {
|
||||
if request.Enabled && !ValidAPN(apn) {
|
||||
return NetworkResult{}, ErrInvalidNetworkAPN
|
||||
}
|
||||
if !validNetworkCredential(request.Username) || !validNetworkCredential(request.Password) {
|
||||
return NetworkResult{}, errors.New("APN username or password contains unsupported characters")
|
||||
}
|
||||
authentication := normalizeNetworkAuthentication(request.Authentication)
|
||||
if authentication == "" {
|
||||
return NetworkResult{}, errors.New("authentication type must be NONE, PAP, CHAP, or PAP_OR_CHAP")
|
||||
}
|
||||
ipVersion := normalizeIPVersion(request.IPVersion)
|
||||
if ipVersion == "" {
|
||||
return NetworkResult{}, errors.New("IP version must be IP, IPV6, or IPV4V6")
|
||||
@@ -43,8 +84,29 @@ func (manager *Manager) SetNetwork(
|
||||
}
|
||||
}
|
||||
candidate := manager.candidateFor(state)
|
||||
if candidate.QMIControl != "" && candidate.NetworkInterface != "" {
|
||||
return setQMINetwork(ctx, candidate, request.Enabled, apn, ipVersion)
|
||||
backend := strings.ToLower(strings.TrimSpace(request.Backend))
|
||||
if backend == "" {
|
||||
if candidate.QMIControl != "" && candidate.NetworkInterface != "" {
|
||||
backend = "qmi"
|
||||
} else {
|
||||
backend = "at"
|
||||
}
|
||||
}
|
||||
if backend != "at" && backend != "qmi" {
|
||||
return NetworkResult{}, fmt.Errorf("unsupported cellular data backend %q", request.Backend)
|
||||
}
|
||||
if backend == "qmi" {
|
||||
if candidate.QMIControl == "" || candidate.NetworkInterface == "" {
|
||||
return NetworkResult{}, fmt.Errorf("%w: QMI control device and network interface are required", ErrDataBackendUnavailable)
|
||||
}
|
||||
result, err := setQMINetwork(ctx, candidate, request.Enabled, apn, ipVersion, request.Username, request.Password, authentication)
|
||||
if err != nil && (request.Username != "" || request.Password != "") {
|
||||
// qmi-network output is outside our control and may echo values read
|
||||
// from its temporary profile. Do not return that output when the
|
||||
// profile contains credentials.
|
||||
return NetworkResult{}, errors.New("authenticated QMI cellular data operation failed")
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
client, err := manager.clientLocked(ctx, state, candidate)
|
||||
@@ -53,13 +115,32 @@ func (manager *Manager) SetNetwork(
|
||||
return NetworkResult{}, err
|
||||
}
|
||||
if request.Enabled {
|
||||
commands := []string{
|
||||
fmt.Sprintf(`AT+CGDCONT=1,"%s","%s"`, ipVersion, apn),
|
||||
"AT+CGATT=1",
|
||||
"AT+CGACT=1,1",
|
||||
type networkCommand struct {
|
||||
value string
|
||||
sensitive bool
|
||||
}
|
||||
commands := []networkCommand{
|
||||
{value: fmt.Sprintf(`AT+CGDCONT=1,"%s","%s"`, ipVersion, apn)},
|
||||
}
|
||||
if authentication != "NONE" {
|
||||
authCode := map[string]int{"PAP": 1, "CHAP": 2, "PAP_OR_CHAP": 3}[authentication]
|
||||
commands = append(commands, networkCommand{
|
||||
value: fmt.Sprintf(`AT+CGAUTH=1,%d,"%s","%s"`, authCode, request.Username, request.Password),
|
||||
sensitive: true,
|
||||
})
|
||||
}
|
||||
commands = append(commands,
|
||||
networkCommand{value: "AT+CGATT=1"},
|
||||
networkCommand{value: "AT+CGACT=1,1"},
|
||||
)
|
||||
for _, command := range commands {
|
||||
if _, err := manager.command(ctx, client, command); err != nil {
|
||||
var err error
|
||||
if command.sensitive {
|
||||
_, err = manager.sensitiveCommand(ctx, client, command.value)
|
||||
} else {
|
||||
_, err = manager.command(ctx, client, command.value)
|
||||
}
|
||||
if err != nil {
|
||||
manager.setResult(id, state, nil, err)
|
||||
return NetworkResult{}, err
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@ func setQMINetwork(
|
||||
enabled bool,
|
||||
apn string,
|
||||
ipVersion string,
|
||||
username string,
|
||||
password string,
|
||||
authentication string,
|
||||
) (NetworkResult, error) {
|
||||
qmiNetwork, err := exec.LookPath("qmi-network")
|
||||
if err != nil {
|
||||
@@ -39,6 +42,15 @@ func setQMINetwork(
|
||||
if apn != "" {
|
||||
profileText = "APN=" + apn + "\n" + profileText
|
||||
}
|
||||
if username != "" {
|
||||
profileText += "APN_USER=" + shellProfileValue(username) + "\n"
|
||||
}
|
||||
if password != "" {
|
||||
profileText += "APN_PASS=" + shellProfileValue(password) + "\n"
|
||||
}
|
||||
if authentication != "" && authentication != "NONE" {
|
||||
profileText += "APN_AUTH=" + shellProfileValue(strings.ToLower(authentication)) + "\n"
|
||||
}
|
||||
if _, err := fmt.Fprint(profile, profileText); err != nil {
|
||||
_ = profile.Close()
|
||||
return NetworkResult{}, fmt.Errorf("write temporary QMI profile: %w", err)
|
||||
@@ -110,6 +122,10 @@ func setQMINetwork(
|
||||
}, nil
|
||||
}
|
||||
|
||||
func shellProfileValue(value string) string {
|
||||
return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'"
|
||||
}
|
||||
|
||||
// exportProxyRouteIdentity must stay in sync with the Export Proxy plugin's
|
||||
// Linux socket mark. Unmarked host traffic never sees the cellular default
|
||||
// route; only plugin sockets carrying this mark are policy-routed to it.
|
||||
|
||||
@@ -15,6 +15,9 @@ func setQMINetwork(
|
||||
bool,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
) (NetworkResult, error) {
|
||||
return NetworkResult{}, fmt.Errorf("%w: QMI control is supported only on Linux", ErrDataBackendUnavailable)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@ package device
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"vocat/internal/modem"
|
||||
)
|
||||
|
||||
func TestSetNetworkATBackendActivatesAndDeactivatesPDP(t *testing.T) {
|
||||
@@ -35,6 +38,52 @@ func TestSetNetworkATBackendActivatesAndDeactivatesPDP(t *testing.T) {
|
||||
client.assertDone(t)
|
||||
}
|
||||
|
||||
func TestSetNetworkATBackendAppliesPAPCredentials(t *testing.T) {
|
||||
client := &transcriptClient{steps: []clientStep{
|
||||
{command: `AT+CGDCONT=1,"IPV4V6","giffgaff.com"`, response: okResponse()},
|
||||
{command: `AT+CGAUTH=1,1,"gg","p"`, response: okResponse()},
|
||||
{command: "AT+CGATT=1", response: okResponse()},
|
||||
{command: "AT+CGACT=1,1", response: okResponse()},
|
||||
}}
|
||||
manager, id := newStartedTestManager(t, client)
|
||||
if _, err := manager.SetNetwork(context.Background(), id, NetworkRequest{
|
||||
Enabled: true, APN: "giffgaff.com", IPVersion: "IPV4V6",
|
||||
Username: "gg", Password: "p", Authentication: "PAP",
|
||||
}); err != nil {
|
||||
t.Fatalf("enable authenticated network: %v", err)
|
||||
}
|
||||
client.assertDone(t)
|
||||
}
|
||||
|
||||
func TestSetNetworkDoesNotExposeAPNCredentialsInErrorsOrState(t *testing.T) {
|
||||
const username = "private-user"
|
||||
const password = "private-password"
|
||||
command := `AT+CGAUTH=1,1,"` + username + `","` + password + `"`
|
||||
client := &transcriptClient{steps: []clientStep{
|
||||
{command: `AT+CGDCONT=1,"IPV4V6","giffgaff.com"`, response: okResponse()},
|
||||
{command: command, err: &modem.CommandError{Command: command, Final: "ERROR"}},
|
||||
}}
|
||||
manager, id := newStartedTestManager(t, client)
|
||||
_, err := manager.SetNetwork(context.Background(), id, NetworkRequest{
|
||||
Enabled: true, APN: "giffgaff.com", IPVersion: "IPV4V6",
|
||||
Username: username, Password: password, Authentication: "PAP",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("SetNetwork() error = nil")
|
||||
}
|
||||
if strings.Contains(err.Error(), username) || strings.Contains(err.Error(), password) || strings.Contains(err.Error(), "AT+CGAUTH") {
|
||||
t.Fatalf("SetNetwork() exposed credentials: %q", err)
|
||||
}
|
||||
entry, getErr := manager.Get(id)
|
||||
if getErr != nil {
|
||||
t.Fatal(getErr)
|
||||
}
|
||||
if strings.Contains(entry.LastError, username) || strings.Contains(entry.LastError, password) || strings.Contains(entry.LastError, "AT+CGAUTH") {
|
||||
t.Fatalf("device state exposed credentials: %q", entry.LastError)
|
||||
}
|
||||
client.assertDone(t)
|
||||
}
|
||||
|
||||
func TestSetNetworkRejectsUnsafeAPNBeforeOpeningModem(t *testing.T) {
|
||||
client := &transcriptClient{}
|
||||
manager, id := newStartedTestManager(t, client)
|
||||
|
||||
+86
-49
@@ -3,14 +3,18 @@ package device
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vocat/internal/netguard"
|
||||
)
|
||||
|
||||
// es9pClient speaks SGP.22 ES9+ — JSON over HTTPS — to one SM-DP+. It is the
|
||||
@@ -24,25 +28,33 @@ import (
|
||||
// header.functionExecutionStatus (with statusCodeData.message holding the
|
||||
// human-readable failure, e.g. "The matchingID is not found").
|
||||
type es9pClient struct {
|
||||
smdp string
|
||||
http *http.Client
|
||||
smdp string
|
||||
endpoint *url.URL
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func newES9PClient(smdp string) *es9pClient {
|
||||
// The eUICC — not the host — is the root of trust for RSP: during
|
||||
// AuthenticateServer the card verifies the SM-DP+'s CERT.DPauth.SIG against
|
||||
// its embedded CI root, so a rogue/TLS-MitM server cannot forge a signature
|
||||
// the card will accept. The host TLS layer is transport only, and a minimal
|
||||
// embedded box may ship no CA bundle (this is exactly what broke on the test
|
||||
// machine), so we don't anchor host TLS to system roots. InsecureSkipVerify
|
||||
// is safe here specifically because the card does the authoritative check.
|
||||
transport := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // eUICC is the RSP trust anchor
|
||||
var smdpAddressPattern = regexp.MustCompile(`^(?:[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?|\[[0-9A-Fa-f:.]+\])(?::[0-9]{1,5})?$`)
|
||||
|
||||
func newES9PClient(ctx context.Context, smdp string) (*es9pClient, error) {
|
||||
smdp = strings.TrimSpace(smdp)
|
||||
if !smdpAddressPattern.MatchString(smdp) {
|
||||
return nil, errors.New("esim: SM-DP+ address must be a hostname with an optional port")
|
||||
}
|
||||
candidate, err := url.Parse("https://" + smdp)
|
||||
if err != nil || candidate.Hostname() == "" || candidate.User != nil ||
|
||||
(candidate.Path != "" && candidate.Path != "/") || candidate.RawQuery != "" || candidate.Fragment != "" {
|
||||
return nil, errors.New("esim: SM-DP+ address must be a hostname with an optional port")
|
||||
}
|
||||
candidate.Path = ""
|
||||
validated, err := netguard.ValidatePublicURL(ctx, candidate.String(), true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("esim: unsafe SM-DP+ address: %w", err)
|
||||
}
|
||||
return &es9pClient{
|
||||
smdp: strings.TrimSpace(smdp),
|
||||
http: &http.Client{Timeout: 90 * time.Second, Transport: transport},
|
||||
}
|
||||
smdp: validated.Host,
|
||||
endpoint: validated,
|
||||
http: netguard.NewPublicHTTPClient(90*time.Second, true),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// es9pError is a failed ES9+ functionExecutionStatus. Message is the SM-DP+'s
|
||||
@@ -80,12 +92,13 @@ type es9pStatusCodeData struct {
|
||||
// is decided the way lpac decides it: a non-success execution status, or a
|
||||
// missing required output field, yields an es9pError carrying the SM-DP+ message.
|
||||
func (c *es9pClient) call(ctx context.Context, function string, request map[string]string, requiredOut ...string) (map[string]json.RawMessage, error) {
|
||||
url := "https://" + c.smdp + "/gsma/rsp2/es9plus/" + function
|
||||
endpoint := *c.endpoint
|
||||
endpoint.Path = "/gsma/rsp2/es9plus/" + function
|
||||
body, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -149,31 +162,31 @@ func es9pErrFromStatus(function, status string, scd *es9pStatusCodeData) error {
|
||||
// human-readable failure when the SM-DP+ omits statusCodeData.message. Table
|
||||
// mirrors lpac's euicc/es9p_errors.c.
|
||||
var es9pErrorTable = map[[2]string]string{
|
||||
{"8.1", "4.8"}: "eUICC does not have sufficient space for this Profile",
|
||||
{"8.1", "6.1"}: "eUICC signature is invalid or serverChallenge is invalid",
|
||||
{"8.1.1", "2.2"}: "EID is missing in the context of this order",
|
||||
{"8.1.1", "3.1"}: "a different EID is already associated with this ICCID",
|
||||
{"8.1.1", "3.8"}: "EID doesn't match the expected value",
|
||||
{"8.1.2", "6.1"}: "EUM Certificate is invalid",
|
||||
{"8.1.2", "6.3"}: "EUM Certificate has expired",
|
||||
{"8.1.3", "6.1"}: "eUICC Certificate is invalid",
|
||||
{"8.1.3", "6.3"}: "eUICC Certificate has expired",
|
||||
{"8.2", "1.2"}: "Profile has not yet been released",
|
||||
{"8.2", "3.7"}: "BPP is not available for a new binding",
|
||||
{"8.2.5", "3.7"}: "No more Profile available for the requested Profile Type",
|
||||
{"8.2.5", "4.3"}: "No eligible Profile for this eUICC/Device",
|
||||
{"8.2.6", "3.1"}: "a different MatchingID is associated with this ICCID",
|
||||
{"8.2.6", "3.3"}: "Conflicting MatchingID value",
|
||||
{"8.2.6", "3.8"}: "MatchingID (AC_Token or EventID) is refused",
|
||||
{"8.2.7", "2.2"}: "Confirmation Code is missing",
|
||||
{"8.2.7", "3.8"}: "Confirmation Code is refused",
|
||||
{"8.2.7", "6.4"}: "maximum number of retries for the Confirmation Code exceeded",
|
||||
{"8.8.1", "3.8"}: "Invalid SM-DP+ Address",
|
||||
{"8.8.4", "3.7"}: "The SM-DP+ has no CERT.DPauth.ECDSA signed by one of the CI Public Key supported by the eUICC",
|
||||
{"8.8.5", "4.1"}: "The Download order has expired",
|
||||
{"8.8.5", "6.4"}: "maximum number of retries for the Profile download order exceeded",
|
||||
{"8.10.1", "3.9"}: "The RSP session identified by the TransactionID is unknown",
|
||||
{"8.11.1", "3.9"}: "Unknown CI Public Key. The CI used by the EUM Certificate is not a trusted root.",
|
||||
{"8.1", "4.8"}: "eUICC does not have sufficient space for this Profile",
|
||||
{"8.1", "6.1"}: "eUICC signature is invalid or serverChallenge is invalid",
|
||||
{"8.1.1", "2.2"}: "EID is missing in the context of this order",
|
||||
{"8.1.1", "3.1"}: "a different EID is already associated with this ICCID",
|
||||
{"8.1.1", "3.8"}: "EID doesn't match the expected value",
|
||||
{"8.1.2", "6.1"}: "EUM Certificate is invalid",
|
||||
{"8.1.2", "6.3"}: "EUM Certificate has expired",
|
||||
{"8.1.3", "6.1"}: "eUICC Certificate is invalid",
|
||||
{"8.1.3", "6.3"}: "eUICC Certificate has expired",
|
||||
{"8.2", "1.2"}: "Profile has not yet been released",
|
||||
{"8.2", "3.7"}: "BPP is not available for a new binding",
|
||||
{"8.2.5", "3.7"}: "No more Profile available for the requested Profile Type",
|
||||
{"8.2.5", "4.3"}: "No eligible Profile for this eUICC/Device",
|
||||
{"8.2.6", "3.1"}: "a different MatchingID is associated with this ICCID",
|
||||
{"8.2.6", "3.3"}: "Conflicting MatchingID value",
|
||||
{"8.2.6", "3.8"}: "MatchingID (AC_Token or EventID) is refused",
|
||||
{"8.2.7", "2.2"}: "Confirmation Code is missing",
|
||||
{"8.2.7", "3.8"}: "Confirmation Code is refused",
|
||||
{"8.2.7", "6.4"}: "maximum number of retries for the Confirmation Code exceeded",
|
||||
{"8.8.1", "3.8"}: "Invalid SM-DP+ Address",
|
||||
{"8.8.4", "3.7"}: "The SM-DP+ has no CERT.DPauth.ECDSA signed by one of the CI Public Key supported by the eUICC",
|
||||
{"8.8.5", "4.1"}: "The Download order has expired",
|
||||
{"8.8.5", "6.4"}: "maximum number of retries for the Profile download order exceeded",
|
||||
{"8.10.1", "3.9"}: "The RSP session identified by the TransactionID is unknown",
|
||||
{"8.11.1", "3.9"}: "Unknown CI Public Key. The CI used by the EUM Certificate is not a trusted root.",
|
||||
}
|
||||
|
||||
func es9pErrorMessage(subjectCode, reasonCode string) string {
|
||||
@@ -254,10 +267,10 @@ func (c *es9pClient) initiateAuthentication(ctx context.Context, euiccChallenge,
|
||||
// es9pAuthenticateResult carries the profile metadata and the SM-DP+ download
|
||||
// authorization needed for PrepareDownload.
|
||||
type es9pAuthenticateResult struct {
|
||||
TransactionID string
|
||||
TransactionID string
|
||||
ProfileMetadata []byte
|
||||
SmdpSigned2 []byte
|
||||
SmdpSignature2 []byte
|
||||
SmdpSigned2 []byte
|
||||
SmdpSignature2 []byte
|
||||
SmdpCertificate []byte
|
||||
}
|
||||
|
||||
@@ -287,7 +300,7 @@ func (c *es9pClient) authenticateClient(ctx context.Context, transactionID strin
|
||||
|
||||
func (c *es9pClient) getBoundProfilePackage(ctx context.Context, transactionID string, prepareDownloadResponse []byte) ([]byte, error) {
|
||||
root, err := c.call(ctx, "getBoundProfilePackage", map[string]string{
|
||||
"transactionId": transactionID,
|
||||
"transactionId": transactionID,
|
||||
"prepareDownloadResponse": es9pBase64Encode(prepareDownloadResponse),
|
||||
}, "boundProfilePackage")
|
||||
if err != nil {
|
||||
@@ -300,10 +313,34 @@ func (c *es9pClient) getBoundProfilePackage(ctx context.Context, transactionID s
|
||||
// for the download case). It is best-effort: the profile is already installed, so
|
||||
// a notification failure is reported by the caller as a warning, not a failure.
|
||||
func (c *es9pClient) handleNotification(ctx context.Context, pendingNotification []byte) error {
|
||||
_, err := c.call(ctx, "handleNotification", map[string]string{
|
||||
endpoint := *c.endpoint
|
||||
endpoint.Path = "/gsma/rsp2/es9plus/handleNotification"
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"pendingNotification": es9pBase64Encode(pendingNotification),
|
||||
})
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "gsma-rsp-lpad")
|
||||
request.Header.Set("X-Admin-Protocol", "gsma/rsp/v2.2.2")
|
||||
response, err := c.http.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("es9p handleNotification: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 1<<20))
|
||||
// SGP.22 defines HandleNotification as a notification-handler function:
|
||||
// success is an empty HTTP 204 response, not the JSON envelope returned by
|
||||
// ordinary ES9+ request-response functions.
|
||||
if response.StatusCode != http.StatusNoContent {
|
||||
return fmt.Errorf("es9p handleNotification: receiver returned HTTP %d", response.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// cancelSession aborts an in-flight download so the SM-DP+ releases the
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -16,9 +17,15 @@ func newTestES9P(t *testing.T, handler http.HandlerFunc) *es9pClient {
|
||||
t.Helper()
|
||||
server := httptest.NewTLSServer(handler)
|
||||
t.Cleanup(server.Close)
|
||||
client := newES9PClient(strings.TrimPrefix(server.URL, "https://"))
|
||||
client.http = server.Client()
|
||||
return client
|
||||
endpoint, err := url.Parse(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &es9pClient{
|
||||
smdp: strings.TrimPrefix(server.URL, "https://"),
|
||||
endpoint: endpoint,
|
||||
http: server.Client(),
|
||||
}
|
||||
}
|
||||
|
||||
func successEnvelope(fields map[string]any) map[string]any {
|
||||
@@ -33,6 +40,21 @@ func successEnvelope(fields map[string]any) map[string]any {
|
||||
|
||||
func b64(value []byte) string { return base64.StdEncoding.EncodeToString(value) }
|
||||
|
||||
func TestNewES9PClientRejectsUnsafeAddress(t *testing.T) {
|
||||
for _, address := range []string{
|
||||
"https://rsp.example.com",
|
||||
"127.0.0.1",
|
||||
"169.254.169.254",
|
||||
"rsp.example.com/unexpected/path",
|
||||
"user:[email protected]",
|
||||
"rsp.example.com\r\nX-Injected: yes",
|
||||
} {
|
||||
if _, err := newES9PClient(context.Background(), address); err == nil {
|
||||
t.Errorf("newES9PClient(%q) accepted an unsafe address", address)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitiateAuthenticationSuccess(t *testing.T) {
|
||||
signed1 := []byte{0x30, 0x03, 0x80, 0x01, 0x09}
|
||||
client := newTestES9P(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -141,3 +163,34 @@ func TestGetBoundProfilePackageSuccess(t *testing.T) {
|
||||
t.Fatalf("bpp = %X, want %X", got, pkg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleNotificationRequiresHTTP204(t *testing.T) {
|
||||
pending := []byte{0xBF, 0x37, 0x00}
|
||||
client := newTestES9P(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/gsma/rsp2/es9plus/handleNotification" {
|
||||
t.Errorf("path = %s", r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("X-Admin-Protocol") != "gsma/rsp/v2.2.2" {
|
||||
t.Errorf("X-Admin-Protocol = %q", r.Header.Get("X-Admin-Protocol"))
|
||||
}
|
||||
var request map[string]string
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Errorf("decode request: %v", err)
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(request["pendingNotification"])
|
||||
if err != nil || !bytes.Equal(decoded, pending) {
|
||||
t.Errorf("pendingNotification = %q (%X), err=%v", request["pendingNotification"], decoded, err)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
if err := client.handleNotification(context.Background(), pending); err != nil {
|
||||
t.Fatalf("handleNotification: %v", err)
|
||||
}
|
||||
|
||||
client = newTestES9P(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(successEnvelope(nil))
|
||||
})
|
||||
if err := client.handleNotification(context.Background(), pending); err == nil || !strings.Contains(err.Error(), "HTTP 200") {
|
||||
t.Fatalf("HTTP 200 error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
+180
-49
@@ -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.
|
||||
@@ -26,6 +27,12 @@ import (
|
||||
// isdRAID is the standard ISD-R AID that hosts the LPA functions (ES10).
|
||||
const isdRAID = "A0000005591010FFFFFFFF8900000100"
|
||||
|
||||
// xesimISDRAID is the alternate ISD-R application exposed by XeSIM cards.
|
||||
// It implements the same ES10 interface, but is not selectable through the
|
||||
// standard ...0100 AID. Selecting it is a read-only capability probe; profile
|
||||
// state is never changed during discovery.
|
||||
const xesimISDRAID = "A0000005591010FFFFFFFF8900000177"
|
||||
|
||||
// eSTK multi-SE products expose each eUICC storage through its own vendor
|
||||
// ISD-R AID. The standard GSMA AID aliases one of them, so probing only that
|
||||
// AID silently hides the second storage.
|
||||
@@ -184,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
|
||||
@@ -258,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.
|
||||
@@ -296,20 +313,63 @@ func (manager *Manager) openEuiccOnceAID(ctx context.Context, id, aidHex string)
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
// discoverEuiccAIDs detects eSTK multi-SE cards without changing any profile
|
||||
// state. The vendor product applet is selected only as a read-only capability
|
||||
// probe; when present, both vendor ISD-R AIDs are tried. Per OpenEUICC's eSTK
|
||||
// integration, the generic GSMA AID is not appended after an eSTK SE opens,
|
||||
// because it aliases one of the same storages.
|
||||
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
|
||||
// OpenEUICC's eSTK integration, generic AIDs are not appended after an eSTK SE
|
||||
// opens, because the standard AID aliases one of the same storages.
|
||||
func (manager *Manager) discoverEuiccAIDs(ctx context.Context, id string) []string {
|
||||
product, err := manager.openEuiccAID(ctx, id, estkProductAID)
|
||||
if err != nil {
|
||||
return []string{isdRAID}
|
||||
if err == nil {
|
||||
product.close(context.Background())
|
||||
|
||||
var found []string
|
||||
for _, aid := range []string{estkSE0AID, estkSE1AID} {
|
||||
channel, err := manager.openEuiccAID(ctx, id, aid)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
channel.close(context.Background())
|
||||
found = append(found, aid)
|
||||
}
|
||||
if len(found) > 0 {
|
||||
return found
|
||||
}
|
||||
}
|
||||
product.close(context.Background())
|
||||
|
||||
var found []string
|
||||
for _, aid := range []string{estkSE0AID, estkSE1AID} {
|
||||
for _, aid := range []string{isdRAID, xesimISDRAID} {
|
||||
channel, err := manager.openEuiccAID(ctx, id, aid)
|
||||
if err != nil {
|
||||
continue
|
||||
@@ -317,10 +377,12 @@ func (manager *Manager) discoverEuiccAIDs(ctx context.Context, id string) []stri
|
||||
channel.close(context.Background())
|
||||
found = append(found, aid)
|
||||
}
|
||||
if len(found) == 0 {
|
||||
return []string{isdRAID}
|
||||
if len(found) > 0 {
|
||||
return found
|
||||
}
|
||||
return found
|
||||
// Preserve the old error path for a physical SIM with no eUICC. The caller
|
||||
// retries the standard AID once and returns ErrNoEUICC to the HTTP layer.
|
||||
return []string{isdRAID}
|
||||
}
|
||||
|
||||
func isTransientEuiccCME(err error) bool {
|
||||
@@ -332,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,
|
||||
@@ -340,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
|
||||
}
|
||||
@@ -349,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
|
||||
}
|
||||
@@ -531,18 +609,27 @@ func (manager *Manager) ESIMListProfiles(ctx context.Context, id string) (EsimIn
|
||||
}
|
||||
return EsimInfo{}, errESIMRecovering
|
||||
}
|
||||
channel, err := manager.openEuicc(ctx, id)
|
||||
if err != nil {
|
||||
return EsimInfo{}, err
|
||||
var lastErr error
|
||||
for _, aid := range manager.discoverEuiccAIDs(ctx, id) {
|
||||
channel, err := manager.openEuiccAID(ctx, id, aid)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
payload, err := channel.es10(ctx, []byte{0xBF, 0x2D, 0x00}) // GetProfilesInfo
|
||||
channel.close(context.Background())
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
info := EsimInfo{AID: aid, Profiles: parseProfilesInfo(payload)}
|
||||
manager.cacheESIMInfo(id, info)
|
||||
return info, nil
|
||||
}
|
||||
defer channel.close(context.Background())
|
||||
payload, err := channel.es10(ctx, []byte{0xBF, 0x2D, 0x00}) // GetProfilesInfo
|
||||
if err != nil {
|
||||
return EsimInfo{}, err
|
||||
if lastErr != nil {
|
||||
return EsimInfo{}, lastErr
|
||||
}
|
||||
info := EsimInfo{Profiles: parseProfilesInfo(payload)}
|
||||
manager.cacheESIMInfo(id, info)
|
||||
return info, nil
|
||||
return EsimInfo{}, ErrNoEUICC
|
||||
}
|
||||
|
||||
// ESIMSwitchProfile enables one profile by ICCID via ES10c EnableProfile.
|
||||
@@ -581,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 {
|
||||
@@ -751,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)
|
||||
}
|
||||
|
||||
@@ -766,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
|
||||
@@ -774,7 +878,14 @@ func (manager *Manager) refreshAfterProfileSwitch(id string) {
|
||||
time.Sleep(settle)
|
||||
for attempt := 0; attempt < attempts; attempt++ {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), manager.commandTimeout*4)
|
||||
_, err := manager.Refresh(ctx, id)
|
||||
_, _ = manager.Discover(ctx)
|
||||
_, flightErr := manager.SetFlight(ctx, id, true)
|
||||
var err error
|
||||
if flightErr == nil {
|
||||
_, err = manager.Refresh(ctx, id)
|
||||
} else {
|
||||
err = flightErr
|
||||
}
|
||||
cancel()
|
||||
if err == nil {
|
||||
return
|
||||
@@ -783,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) {
|
||||
@@ -852,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 {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -101,13 +102,24 @@ func (manager *Manager) ESIMDeleteProfile(ctx context.Context, id, iccid, aidHex
|
||||
}
|
||||
|
||||
deleted := &EsimDeleteResult{}
|
||||
var warnings []string
|
||||
if info2, infoErr := channel.getEUICCInfo2(ctx); infoErr == nil {
|
||||
if freeAfter, afterKnown := euiccFreeNVRAM(info2); beforeKnown && afterKnown && freeAfter >= freeBefore {
|
||||
deleted.SpaceDelta = int64(freeAfter - freeBefore)
|
||||
}
|
||||
} else {
|
||||
deleted.Warning = "Profile was deleted, but reclaimed storage could not be read"
|
||||
warnings = append(warnings, "Profile 已删除,但无法读取释放的存储空间")
|
||||
}
|
||||
// DeleteProfile creates a signed notification only when the Profile metadata
|
||||
// configured a receiver. Flush all retained notifications so earlier events
|
||||
// for the same receiver cannot be overtaken by this delete event.
|
||||
notifyContext, cancelNotify := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute)
|
||||
notifyErr := channel.deliverPendingNotifications(notifyContext)
|
||||
cancelNotify()
|
||||
if notifyErr != nil {
|
||||
warnings = append(warnings, "Profile 已删除,但运营商通知发送失败;通知已保留在 eUICC,可稍后重发")
|
||||
}
|
||||
deleted.Warning = strings.Join(warnings, ";")
|
||||
manager.removeCachedProfile(id, strings.TrimSpace(iccid))
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// EsimDownloadParams are the SPA download form fields, mapped from the
|
||||
@@ -74,7 +75,10 @@ func (manager *Manager) ESIMDownloadProfile(ctx context.Context, id string, para
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := newES9PClient(smdp)
|
||||
client, err := newES9PClient(ctx, smdp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
report("auth_client", "正在向 SM-DP+ 进行客户端身份认证...", 30)
|
||||
init, err := client.initiateAuthentication(ctx, challenge, info1)
|
||||
@@ -127,15 +131,24 @@ func (manager *Manager) ESIMDownloadProfile(ctx context.Context, id string, para
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
iccid, err := installationResult(installResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
report("notify", "正在向运营商发送下载通知...", 90)
|
||||
iccid, installErr := installationResult(installResponse)
|
||||
warning := ""
|
||||
if err := client.handleNotification(ctx, installResponse); err != nil {
|
||||
warning = "Profile 已安装,但下载通知发送失败"
|
||||
notification, notificationErr := parsePendingNotification(installResponse)
|
||||
if notificationErr == nil {
|
||||
// Loading the final BPP segment is the commit point. Finish the operator
|
||||
// acknowledgement even if the browser closes its SSE connection now.
|
||||
notifyContext, cancelNotify := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute)
|
||||
notificationErr = channel.deliverNotification(notifyContext, notification)
|
||||
cancelNotify()
|
||||
}
|
||||
if notificationErr != nil {
|
||||
warning = "Profile 安装结果已保留在 eUICC,但向运营商上报失败,可在当前通知列表中重发"
|
||||
}
|
||||
// Error installation results must be reported too. Return the card-side
|
||||
// installation failure only after making that best-effort ES9+ attempt.
|
||||
if installErr != nil {
|
||||
return nil, installErr
|
||||
}
|
||||
|
||||
freeAfter := freeBefore
|
||||
@@ -217,17 +230,26 @@ type EsimChipInfo struct {
|
||||
func (manager *Manager) ESIMChipInfo(ctx context.Context, id string) (*EsimChipInfo, error) {
|
||||
manager.esimMu.Lock()
|
||||
defer manager.esimMu.Unlock()
|
||||
channel, err := manager.openEuicc(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer channel.close(context.Background())
|
||||
|
||||
info, err := readEsimChipInfo(ctx, channel, isdRAID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
var lastErr error
|
||||
for _, aid := range manager.discoverEuiccAIDs(ctx, id) {
|
||||
channel, err := manager.openEuiccAID(ctx, id, aid)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
info, err := readEsimChipInfo(ctx, channel, aid)
|
||||
channel.close(context.Background())
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
return &info, nil
|
||||
}
|
||||
return &info, nil
|
||||
if lastErr != nil {
|
||||
return nil, lastErr
|
||||
}
|
||||
return nil, ErrNoEUICC
|
||||
}
|
||||
|
||||
func readEsimChipInfo(ctx context.Context, channel *euiccChannel, aidHex string) (EsimChipInfo, error) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// EsimNotification is one notification retained by an eUICC until its receiver
|
||||
// acknowledges it through ES9+.HandleNotification.
|
||||
type EsimNotification struct {
|
||||
SequenceNumber uint64 `json:"sequenceNumber"`
|
||||
Event string `json:"event,omitempty"`
|
||||
ICCID string `json:"iccid,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
AIDHex string `json:"aidHex,omitempty"`
|
||||
CanRetry bool `json:"canRetry"`
|
||||
|
||||
raw []byte
|
||||
}
|
||||
|
||||
func encodePositiveInteger(value uint64) []byte {
|
||||
if value == 0 {
|
||||
return []byte{0}
|
||||
}
|
||||
encoded := make([]byte, 8)
|
||||
for index := len(encoded) - 1; index >= 0; index-- {
|
||||
encoded[index] = byte(value & 0xff)
|
||||
value >>= 8
|
||||
}
|
||||
for len(encoded) > 1 && encoded[0] == 0 {
|
||||
encoded = encoded[1:]
|
||||
}
|
||||
if encoded[0]&0x80 != 0 {
|
||||
encoded = append([]byte{0}, encoded...)
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
||||
func decodePositiveInteger(encoded []byte) (uint64, bool) {
|
||||
if len(encoded) == 0 || len(encoded) > 9 || encoded[0]&0x80 != 0 {
|
||||
return 0, false
|
||||
}
|
||||
if len(encoded) == 9 {
|
||||
if encoded[0] != 0 {
|
||||
return 0, false
|
||||
}
|
||||
encoded = encoded[1:]
|
||||
}
|
||||
var value uint64
|
||||
for _, octet := range encoded {
|
||||
value = value<<8 | uint64(octet)
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
func buildRetrieveNotificationsRequest(sequenceNumber *uint64) []byte {
|
||||
if sequenceNumber == nil {
|
||||
return derConstruct(0xBF2B)
|
||||
}
|
||||
return derConstruct(0xBF2B, derEncode(0x80, encodePositiveInteger(*sequenceNumber)))
|
||||
}
|
||||
|
||||
func buildListNotificationsRequest() []byte {
|
||||
return derConstruct(0xBF28)
|
||||
}
|
||||
|
||||
func buildRemoveNotificationRequest(sequenceNumber uint64) []byte {
|
||||
return derConstruct(0xBF30, derEncode(0x80, encodePositiveInteger(sequenceNumber)))
|
||||
}
|
||||
|
||||
func notificationEventName(bitString []byte) string {
|
||||
if len(bitString) < 2 || bitString[0] > 7 {
|
||||
return ""
|
||||
}
|
||||
bitCount := (len(bitString)-1)*8 - int(bitString[0])
|
||||
for bit := 0; bit < bitCount; bit++ {
|
||||
if bitString[1+bit/8]&(0x80>>uint(bit%8)) == 0 {
|
||||
continue
|
||||
}
|
||||
switch bit {
|
||||
case 0:
|
||||
return "install"
|
||||
case 1, 4:
|
||||
return "enable"
|
||||
case 2, 5:
|
||||
return "disable"
|
||||
case 3, 6:
|
||||
return "delete"
|
||||
case 7:
|
||||
return "rpm"
|
||||
default:
|
||||
return fmt.Sprintf("event-%d", bit)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func notificationFromMetadata(metadata *derNode) (EsimNotification, error) {
|
||||
sequenceNumber, ok := decodePositiveInteger(derValue(metadata.children, 0x80))
|
||||
if !ok {
|
||||
return EsimNotification{}, errors.New("esim: pending notification has an invalid sequence number")
|
||||
}
|
||||
address := strings.TrimSpace(string(derValue(metadata.children, 0x0C)))
|
||||
if address == "" {
|
||||
return EsimNotification{}, errors.New("esim: pending notification has no receiver address")
|
||||
}
|
||||
return EsimNotification{
|
||||
SequenceNumber: sequenceNumber,
|
||||
Event: notificationEventName(derValue(metadata.children, 0x81)),
|
||||
ICCID: decodeICCID(derValue(metadata.children, 0x5A)),
|
||||
Address: address,
|
||||
CanRetry: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parsePendingNotification(raw []byte) (EsimNotification, error) {
|
||||
metadataNodes := derFindAll(derParse(raw), 0xBF2F)
|
||||
if len(metadataNodes) == 0 {
|
||||
return EsimNotification{}, errors.New("esim: pending notification has no metadata")
|
||||
}
|
||||
notification, err := notificationFromMetadata(metadataNodes[0])
|
||||
if err != nil {
|
||||
return EsimNotification{}, err
|
||||
}
|
||||
notification.raw = append([]byte(nil), raw...)
|
||||
return notification, nil
|
||||
}
|
||||
|
||||
func parseNotificationMetadataList(payload []byte) ([]EsimNotification, error) {
|
||||
tag, headerLength, totalLength, err := derElementAt(payload, 0)
|
||||
if err != nil || tag != 0xBF28 || totalLength != len(payload) {
|
||||
return nil, fmt.Errorf("esim: unexpected ListNotification response %s", strings.ToUpper(hex.EncodeToString(payload)))
|
||||
}
|
||||
value := payload[headerLength:totalLength]
|
||||
responseNodes := derParse(value)
|
||||
if len(responseNodes) == 1 && (responseNodes[0].tag == 0x81 || responseNodes[0].tag == 0x80 || responseNodes[0].tag == 0x02) {
|
||||
return nil, fmt.Errorf("esim: eUICC could not list notifications (result %X)", responseNodes[0].value)
|
||||
}
|
||||
metadataNodes := derFindAll(responseNodes, 0xBF2F)
|
||||
notifications := make([]EsimNotification, 0, len(metadataNodes))
|
||||
for _, metadata := range metadataNodes {
|
||||
notification, parseErr := notificationFromMetadata(metadata)
|
||||
if parseErr != nil {
|
||||
return nil, parseErr
|
||||
}
|
||||
notifications = append(notifications, notification)
|
||||
}
|
||||
sort.SliceStable(notifications, func(left, right int) bool {
|
||||
if notifications[left].Address == notifications[right].Address {
|
||||
return notifications[left].SequenceNumber < notifications[right].SequenceNumber
|
||||
}
|
||||
return notifications[left].Address < notifications[right].Address
|
||||
})
|
||||
return notifications, nil
|
||||
}
|
||||
|
||||
func parsePendingNotifications(payload []byte) ([]EsimNotification, error) {
|
||||
tag, headerLength, totalLength, err := derElementAt(payload, 0)
|
||||
if err != nil || tag != 0xBF2B || totalLength != len(payload) {
|
||||
return nil, fmt.Errorf("esim: unexpected RetrieveNotificationsList response %s", strings.ToUpper(hex.EncodeToString(payload)))
|
||||
}
|
||||
value := payload[headerLength:totalLength]
|
||||
responseNodes := derParse(value)
|
||||
if len(responseNodes) == 1 && (responseNodes[0].tag == 0x81 || responseNodes[0].tag == 0x80 || responseNodes[0].tag == 0x02) {
|
||||
errorCode := responseNodes[0].value
|
||||
return nil, fmt.Errorf("esim: eUICC could not retrieve notifications (result %X)", errorCode)
|
||||
}
|
||||
// The notificationList CHOICE alternative is encoded as context tag A0 by
|
||||
// AUTOMATIC TAGS on newer eUICCs. Older cards are also seen returning the
|
||||
// SEQUENCE OF contents directly. Accept both without including the list
|
||||
// wrapper in the PendingNotification sent to ES9+.
|
||||
if len(responseNodes) == 1 && responseNodes[0].tag == 0xA0 {
|
||||
value = responseNodes[0].value
|
||||
} else if len(responseNodes) == 1 && responseNodes[0].tag == 0x30 && firstChild(responseNodes[0].children, 0xBF2F) == nil {
|
||||
value = responseNodes[0].value
|
||||
}
|
||||
|
||||
var notifications []EsimNotification
|
||||
for offset := 0; offset < len(value); {
|
||||
_, _, elementLength, elementErr := derElementAt(value, offset)
|
||||
if elementErr != nil {
|
||||
return nil, elementErr
|
||||
}
|
||||
raw := value[offset : offset+elementLength]
|
||||
notification, parseErr := parsePendingNotification(raw)
|
||||
if parseErr != nil {
|
||||
return nil, parseErr
|
||||
}
|
||||
notifications = append(notifications, notification)
|
||||
offset += elementLength
|
||||
}
|
||||
sort.SliceStable(notifications, func(left, right int) bool {
|
||||
if notifications[left].Address == notifications[right].Address {
|
||||
return notifications[left].SequenceNumber < notifications[right].SequenceNumber
|
||||
}
|
||||
return notifications[left].Address < notifications[right].Address
|
||||
})
|
||||
return notifications, nil
|
||||
}
|
||||
|
||||
func removeNotificationResult(payload []byte) error {
|
||||
roots := derParse(payload)
|
||||
if len(roots) != 1 || roots[0].tag != 0xBF30 {
|
||||
return fmt.Errorf("esim: unexpected RemoveNotificationFromList response %s", strings.ToUpper(hex.EncodeToString(payload)))
|
||||
}
|
||||
result := derValue(roots[0].children, 0x80)
|
||||
if len(result) == 0 {
|
||||
result = derValue(roots[0].children, 0x02)
|
||||
}
|
||||
if len(result) != 1 {
|
||||
return fmt.Errorf("esim: malformed RemoveNotificationFromList response %s", strings.ToUpper(hex.EncodeToString(payload)))
|
||||
}
|
||||
switch result[0] {
|
||||
case 0, 1: // ok, or already removed after an earlier acknowledged retry
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("esim: eUICC could not remove notification (result %d)", result[0])
|
||||
}
|
||||
}
|
||||
|
||||
func (channel *euiccChannel) retrieveNotifications(ctx context.Context, sequenceNumber *uint64) ([]EsimNotification, error) {
|
||||
payload, err := channel.es10(ctx, buildRetrieveNotificationsRequest(sequenceNumber))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parsePendingNotifications(payload)
|
||||
}
|
||||
|
||||
func (channel *euiccChannel) listNotifications(ctx context.Context) ([]EsimNotification, error) {
|
||||
payload, err := channel.es10(ctx, buildListNotificationsRequest())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseNotificationMetadataList(payload)
|
||||
}
|
||||
|
||||
func (channel *euiccChannel) removeNotification(ctx context.Context, sequenceNumber uint64) error {
|
||||
payload, err := channel.es10(ctx, buildRemoveNotificationRequest(sequenceNumber))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return removeNotificationResult(payload)
|
||||
}
|
||||
|
||||
func (channel *euiccChannel) deliverNotification(ctx context.Context, notification EsimNotification) error {
|
||||
client, err := newES9PClient(ctx, notification.Address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.handleNotification(ctx, notification.raw); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := channel.removeNotification(ctx, notification.SequenceNumber); err != nil {
|
||||
return fmt.Errorf("notification acknowledged but could not be removed from eUICC: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deliverPendingNotifications sends each receiver's notifications oldest first.
|
||||
// A failed item stops only that receiver's group so a later sequence number can
|
||||
// never overtake it and make the older notification stale.
|
||||
func (channel *euiccChannel) deliverPendingNotifications(ctx context.Context) error {
|
||||
notifications, err := channel.listNotifications(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
blockedAddresses := make(map[string]bool)
|
||||
var failures []error
|
||||
for _, notification := range notifications {
|
||||
if blockedAddresses[notification.Address] {
|
||||
continue
|
||||
}
|
||||
pending, retrieveErr := channel.retrieveNotifications(ctx, ¬ification.SequenceNumber)
|
||||
if retrieveErr == nil {
|
||||
retrieveErr = fmt.Errorf("esim: notification %d was not returned by eUICC", notification.SequenceNumber)
|
||||
for _, candidate := range pending {
|
||||
if candidate.SequenceNumber == notification.SequenceNumber {
|
||||
retrieveErr = channel.deliverNotification(ctx, candidate)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if retrieveErr != nil {
|
||||
blockedAddresses[notification.Address] = true
|
||||
failures = append(failures, fmt.Errorf("notification %d to %s: %w", notification.SequenceNumber, notification.Address, retrieveErr))
|
||||
}
|
||||
}
|
||||
return errors.Join(failures...)
|
||||
}
|
||||
|
||||
// ESIMNotifications returns the notifications retained across every eUICC
|
||||
// storage exposed by the physical card.
|
||||
func (manager *Manager) ESIMNotifications(ctx context.Context, id string) ([]EsimNotification, error) {
|
||||
manager.esimMu.Lock()
|
||||
defer manager.esimMu.Unlock()
|
||||
if err := manager.waitForESIMRecovery(ctx, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var all []EsimNotification
|
||||
var lastErr error
|
||||
succeeded := false
|
||||
for _, aid := range manager.discoverEuiccAIDs(ctx, id) {
|
||||
channel, err := manager.openEuiccAID(ctx, id, aid)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
notifications, retrieveErr := channel.listNotifications(ctx)
|
||||
channel.close(context.Background())
|
||||
if retrieveErr != nil {
|
||||
lastErr = retrieveErr
|
||||
continue
|
||||
}
|
||||
succeeded = true
|
||||
for index := range notifications {
|
||||
notifications[index].AIDHex = aid
|
||||
}
|
||||
all = append(all, notifications...)
|
||||
}
|
||||
if !succeeded && lastErr != nil {
|
||||
return nil, lastErr
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
// ESIMRetryNotification sends one retained notification and removes it from the
|
||||
// eUICC only after the receiver returns the SGP.22 success acknowledgement.
|
||||
func (manager *Manager) ESIMRetryNotification(ctx context.Context, id, aidHex string, sequenceNumber uint64) error {
|
||||
manager.esimMu.Lock()
|
||||
defer manager.esimMu.Unlock()
|
||||
if err := manager.waitForESIMRecovery(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
channel, err := manager.openEuiccAID(ctx, id, targetEuiccAID(aidHex))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer channel.close(context.Background())
|
||||
notifications, err := channel.retrieveNotifications(ctx, &sequenceNumber)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, notification := range notifications {
|
||||
if notification.SequenceNumber == sequenceNumber {
|
||||
return channel.deliverNotification(ctx, notification)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("esim: notification %d was not found", sequenceNumber)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPositiveIntegerEncodingRoundTripsFullUint64Range(t *testing.T) {
|
||||
for _, value := range []uint64{0, 1, 127, 128, 255, 256, ^uint64(0)} {
|
||||
encoded := encodePositiveInteger(value)
|
||||
decoded, ok := decodePositiveInteger(encoded)
|
||||
if !ok || decoded != value {
|
||||
t.Errorf("round trip %d: encoded=%X decoded=%d ok=%t", value, encoded, decoded, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testNotificationMetadata(t *testing.T, sequence byte, event []byte, address, iccid string) []byte {
|
||||
t.Helper()
|
||||
iccidBCD, err := encodeICCID(iccid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return derConstruct(0xBF2F,
|
||||
derEncode(0x80, []byte{sequence}),
|
||||
derEncode(0x81, event),
|
||||
derEncode(0x0C, []byte(address)),
|
||||
derEncode(0x5A, iccidBCD),
|
||||
)
|
||||
}
|
||||
|
||||
func TestParsePendingNotifications(t *testing.T) {
|
||||
installMetadata := testNotificationMetadata(t, 7, []byte{7, 0x80}, "install.example.com", "8944476500017228672")
|
||||
install := derConstruct(0xBF37, derConstruct(0xBF27, installMetadata))
|
||||
deleteMetadata := testNotificationMetadata(t, 9, []byte{4, 0x10}, "delete.example.com", "89441000400128014257")
|
||||
deleted := derConstruct(0x30, deleteMetadata, derEncode(0x5F37, []byte{1, 2, 3}))
|
||||
|
||||
notifications, err := parsePendingNotifications(derConstruct(0xBF2B, derConstruct(0xA0, install, deleted)))
|
||||
if err != nil {
|
||||
t.Fatalf("parsePendingNotifications: %v", err)
|
||||
}
|
||||
if len(notifications) != 2 {
|
||||
t.Fatalf("notifications = %#v", notifications)
|
||||
}
|
||||
// Results are grouped by receiver, then sorted by sequence number.
|
||||
if got := notifications[0]; got.SequenceNumber != 9 || got.Event != "delete" ||
|
||||
got.Address != "delete.example.com" || got.ICCID != "89441000400128014257" || !bytes.Equal(got.raw, deleted) {
|
||||
t.Fatalf("delete notification = %#v, raw=%X", got, got.raw)
|
||||
}
|
||||
if got := notifications[1]; got.SequenceNumber != 7 || got.Event != "install" ||
|
||||
got.Address != "install.example.com" || got.ICCID != "8944476500017228672" || !bytes.Equal(got.raw, install) {
|
||||
t.Fatalf("install notification = %#v, raw=%X", got, got.raw)
|
||||
}
|
||||
|
||||
metadata, err := parseNotificationMetadataList(derConstruct(0xBF28, derConstruct(0xA0, installMetadata, deleteMetadata)))
|
||||
if err != nil || len(metadata) != 2 {
|
||||
t.Fatalf("parseNotificationMetadataList = %#v, %v", metadata, err)
|
||||
}
|
||||
if metadata[0].SequenceNumber != 9 || metadata[0].Event != "delete" || len(metadata[0].raw) != 0 {
|
||||
t.Fatalf("listed metadata = %#v", metadata[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationRequestsAndRemoveResult(t *testing.T) {
|
||||
if got := buildListNotificationsRequest(); !bytes.Equal(got, []byte{0xBF, 0x28, 0x00}) {
|
||||
t.Fatalf("list request = %X", got)
|
||||
}
|
||||
if got := buildRetrieveNotificationsRequest(nil); !bytes.Equal(got, []byte{0xBF, 0x2B, 0x00}) {
|
||||
t.Fatalf("retrieve all request = %X", got)
|
||||
}
|
||||
sequenceNumber := uint64(128)
|
||||
wantRetrieve := []byte{0xBF, 0x2B, 0x04, 0x80, 0x02, 0x00, 0x80}
|
||||
if got := buildRetrieveNotificationsRequest(&sequenceNumber); !bytes.Equal(got, wantRetrieve) {
|
||||
t.Fatalf("retrieve request = %X, want %X", got, wantRetrieve)
|
||||
}
|
||||
wantRemove := []byte{0xBF, 0x30, 0x04, 0x80, 0x02, 0x00, 0x80}
|
||||
if got := buildRemoveNotificationRequest(sequenceNumber); !bytes.Equal(got, wantRemove) {
|
||||
t.Fatalf("remove request = %X, want %X", got, wantRemove)
|
||||
}
|
||||
if err := removeNotificationResult([]byte{0xBF, 0x30, 0x03, 0x80, 0x01, 0x00}); err != nil {
|
||||
t.Fatalf("removeNotificationResult(ok): %v", err)
|
||||
}
|
||||
if err := removeNotificationResult([]byte{0xBF, 0x30, 0x03, 0x80, 0x01, 0x7F}); err == nil {
|
||||
t.Fatal("undefinedError response was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePendingNotificationsRejectsMalformedMetadata(t *testing.T) {
|
||||
missingAddress := derConstruct(0x30, derConstruct(0xBF2F,
|
||||
derEncode(0x80, []byte{1}),
|
||||
derEncode(0x81, []byte{4, 0x10}),
|
||||
))
|
||||
if _, err := parsePendingNotifications(derConstruct(0xBF2B, missingAddress)); err == nil {
|
||||
t.Fatal("notification without receiver address was accepted")
|
||||
}
|
||||
}
|
||||
@@ -244,6 +244,45 @@ func TestTransientEuiccCMEClassification(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverEuiccAIDsFindsXeSIMAlternateISDR(t *testing.T) {
|
||||
manageChannel := clientStep{
|
||||
command: `AT+CSIM=10,"0070000001"`,
|
||||
response: okResponse(`+CSIM: 6,"019000"`),
|
||||
}
|
||||
closeChannel := clientStep{
|
||||
command: `AT+CSIM=10,"0070800100"`,
|
||||
response: okResponse(`+CSIM: 4,"9000"`),
|
||||
}
|
||||
selectStep := func(aid, response string) clientStep {
|
||||
return clientStep{
|
||||
command: fmt.Sprintf(`AT+CSIM=42,"01A4040010%s"`, aid),
|
||||
response: okResponse(fmt.Sprintf(`+CSIM: 4,"%s"`, response)),
|
||||
}
|
||||
}
|
||||
|
||||
client := &transcriptClient{steps: []clientStep{
|
||||
// No eSTK product applet on this card.
|
||||
manageChannel,
|
||||
selectStep(estkProductAID, "6A82"),
|
||||
closeChannel,
|
||||
// XeSIM does not expose the standard GSMA ...0100 application.
|
||||
manageChannel,
|
||||
selectStep(isdRAID, "6A82"),
|
||||
closeChannel,
|
||||
// Its dedicated ...0177 ISD-R is selectable.
|
||||
manageChannel,
|
||||
selectStep(xesimISDRAID, "9000"),
|
||||
closeChannel,
|
||||
}}
|
||||
manager, id := newStartedTestManager(t, client)
|
||||
|
||||
aids := manager.discoverEuiccAIDs(context.Background(), id)
|
||||
if len(aids) != 1 || aids[0] != xesimISDRAID {
|
||||
t.Fatalf("discovered AIDs = %#v, want XeSIM %s", aids, xesimISDRAID)
|
||||
}
|
||||
client.assertDone(t)
|
||||
}
|
||||
|
||||
func TestEUICCChannelStuckWrapsTransientCME(t *testing.T) {
|
||||
cause := &modem.CommandError{
|
||||
Command: `AT+CSIM=10,"0070000001"`,
|
||||
|
||||
+124
-4
@@ -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
|
||||
@@ -50,6 +53,8 @@ type ussdSession struct {
|
||||
type managedDevice struct {
|
||||
opMu sync.Mutex
|
||||
candidate modem.Candidate
|
||||
backend string
|
||||
lastICCID string
|
||||
client modem.Client
|
||||
snapshot *Snapshot
|
||||
lastError string
|
||||
@@ -57,6 +62,7 @@ type managedDevice struct {
|
||||
discovered bool
|
||||
preFlightMode *int
|
||||
resetClientOnLock bool
|
||||
simPIN string
|
||||
}
|
||||
|
||||
func NewManager(options Options) (*Manager, error) {
|
||||
@@ -80,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,
|
||||
@@ -87,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{}),
|
||||
@@ -144,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))
|
||||
|
||||
@@ -358,16 +382,95 @@ 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 {
|
||||
manager.setResult(id, state, nil, err)
|
||||
return Snapshot{}, err
|
||||
}
|
||||
snapshot, err := manager.readSnapshot(ctx, id, candidate, client)
|
||||
previousICCID := state.lastICCID
|
||||
snapshot, err := manager.readSnapshot(ctx, id, candidate, backend, previousICCID, client)
|
||||
if err == nil && strings.TrimSpace(snapshot.ICCID) != "" {
|
||||
state.lastICCID = strings.TrimSpace(snapshot.ICCID)
|
||||
}
|
||||
manager.setResult(id, state, &snapshot, err)
|
||||
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" && backend != "pcsc" {
|
||||
return fmt.Errorf("unsupported device backend %q", backend)
|
||||
}
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
state := manager.devices[id]
|
||||
if state == nil || !state.discovered {
|
||||
return ErrNotFound
|
||||
}
|
||||
state.backend = backend
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manager *Manager) backendFor(state *managedDevice) string {
|
||||
manager.mu.RLock()
|
||||
defer manager.mu.RUnlock()
|
||||
return state.backend
|
||||
}
|
||||
|
||||
func (manager *Manager) ExecuteAT(
|
||||
ctx context.Context,
|
||||
id string,
|
||||
@@ -529,3 +632,20 @@ func (manager *Manager) command(
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// sensitiveCommand executes an AT command containing credentials or other
|
||||
// authentication material. Modem errors commonly echo the complete command,
|
||||
// so neither the returned error nor the retained device state may wrap it.
|
||||
func (manager *Manager) sensitiveCommand(
|
||||
ctx context.Context,
|
||||
client modem.Client,
|
||||
command string,
|
||||
) (modem.Response, error) {
|
||||
commandCtx, cancel := manager.withTimeout(ctx, manager.commandTimeout)
|
||||
defer cancel()
|
||||
response, err := client.Execute(commandCtx, command)
|
||||
if err != nil {
|
||||
return response, errors.New("sensitive modem command failed")
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
{
|
||||
@@ -19,6 +56,14 @@ func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
|
||||
),
|
||||
},
|
||||
{command: "AT+CPIN?", response: okResponse("+CPIN: READY")},
|
||||
{
|
||||
command: "AT+CCID",
|
||||
response: modem.Response{Final: "+CME ERROR: 100"},
|
||||
err: errors.New("CCID unsupported"),
|
||||
},
|
||||
{command: "AT+QCCID", response: okResponse("+QCCID: 8986001234567890123F")},
|
||||
{command: "AT+CIMI", response: okResponse("460001234567890")},
|
||||
{command: "AT+CRSM=176,28486,0,0,17", response: okResponse(`+CRSM: 144,0,"00434D4343FFFFFFFFFFFFFFFFFFFFFFFF"`)},
|
||||
{command: "AT+CSQ", response: okResponse("+CSQ: 20,99")},
|
||||
{
|
||||
command: `AT+QENG="servingcell"`,
|
||||
@@ -29,13 +74,6 @@ func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
|
||||
{command: "AT+COPS?", response: okResponse(`+COPS: 0,0,"China Mobile",7`)},
|
||||
{command: "AT+CEREG?", response: okResponse(`+CEREG: 0,5`)},
|
||||
{command: "AT+CGSN", response: okResponse("867123456789012")},
|
||||
{
|
||||
command: "AT+CCID",
|
||||
response: modem.Response{Final: "+CME ERROR: 100"},
|
||||
err: errors.New("CCID unsupported"),
|
||||
},
|
||||
{command: "AT+QCCID", response: okResponse("+QCCID: 8986001234567890123F")},
|
||||
{command: "AT+CIMI", response: okResponse("460001234567890")},
|
||||
{command: "AT+CFUN?", response: okResponse("+CFUN: 1")},
|
||||
{command: "AT+CNUM", response: okResponse(`+CNUM: "","+8613800138000",145`)},
|
||||
}}
|
||||
@@ -74,7 +112,7 @@ func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
|
||||
}
|
||||
if snapshot.IMEI != "867123456789012" ||
|
||||
snapshot.ICCID != "8986001234567890123" ||
|
||||
snapshot.IMSI != "460001234567890" {
|
||||
snapshot.IMSI != "460001234567890" || snapshot.SPN != "CMCC" {
|
||||
t.Fatalf("subscriber identifiers = %#v", snapshot)
|
||||
}
|
||||
if !snapshot.ModeKnown || snapshot.OperatingMode != 1 ||
|
||||
@@ -96,6 +134,18 @@ func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
|
||||
client.assertDone(t)
|
||||
}
|
||||
|
||||
func TestParseSPNASCIIAndUCS2(t *testing.T) {
|
||||
if got := parseSPN(okResponse(`+CRSM: 144,0,"004C6562617261FFFFFFFFFFFFFFFFFFFF"`)); got != "Lebara" {
|
||||
t.Fatalf("ASCII SPN = %q", got)
|
||||
}
|
||||
if got := parseSPN(okResponse(`+CRSM: 144,0,"0080004C00650062006100720061FFFF"`)); got != "Lebara" {
|
||||
t.Fatalf("UCS2 SPN = %q", got)
|
||||
}
|
||||
if got := parseSPN(okResponse(`+CRSM: 106,130,""`)); got != "" {
|
||||
t.Fatalf("failed CRSM SPN = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseICCIDIdentifierStripsTwoFillerNibbles(t *testing.T) {
|
||||
response := modem.Response{Lines: []string{"+CCID: 894921007608519523FF"}}
|
||||
if got := parseICCIDIdentifier(response, []string{"+CCID:", "+QCCID:"}, 18, 22); got != "894921007608519523" {
|
||||
@@ -122,6 +172,57 @@ func TestManagerRequiresStartAndKnownDevice(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerBackendSelectionIsExplicit(t *testing.T) {
|
||||
manager, id := newStartedTestManager(t, &transcriptClient{})
|
||||
if err := manager.SetBackend(id, "qmi"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state, err := manager.lookup(id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := manager.backendFor(state); got != "qmi" {
|
||||
t.Fatalf("backend = %q, want qmi", got)
|
||||
}
|
||||
if err := manager.SetBackend(id, "mbim"); err == nil {
|
||||
t.Fatal("unsupported backend was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerForcesRFOffBeforeInspectingChangedSIMNetwork(t *testing.T) {
|
||||
client := &transcriptClient{steps: []clientStep{
|
||||
{command: "ATI", response: okResponse("Quectel", "EC20", "Revision: test")},
|
||||
{command: "AT+CPIN?", response: okResponse("+CPIN: READY")},
|
||||
{command: "AT+CCID", response: okResponse("+CCID: 8900000000000000002")},
|
||||
// This must precede CIMI, signal, serving-cell and operator queries.
|
||||
{command: "AT+CFUN=4", response: okResponse()},
|
||||
{command: "AT+CIMI", response: okResponse("234150000000002")},
|
||||
{command: "AT+CRSM=176,28486,0,0,17", response: okResponse(`+CRSM: 144,0,"004C6562617261FFFFFFFFFFFFFFFFFFFF"`)},
|
||||
{command: "AT+CSQ", response: okResponse("+CSQ: 99,99")},
|
||||
{command: `AT+QENG="servingcell"`, response: okResponse(`+QENG: "servingcell","SEARCH"`)},
|
||||
{command: "AT+COPS?", response: okResponse("+COPS: 0")},
|
||||
{command: "AT+CEREG?", response: okResponse("+CEREG: 0,0")},
|
||||
{command: "AT+CGSN", response: okResponse("867123456789012")},
|
||||
{command: "AT+CFUN?", response: okResponse("+CFUN: 4")},
|
||||
{command: "AT+CNUM", response: okResponse(`+CNUM: "","+447700900002",145`)},
|
||||
}}
|
||||
manager, id := newStartedTestManager(t, client)
|
||||
state, err := manager.lookup(id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state.lastICCID = "8900000000000000001"
|
||||
|
||||
snapshot, err := manager.Refresh(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !snapshot.SIMChanged || !snapshot.FlightMode || snapshot.OperatingMode != 4 {
|
||||
t.Fatalf("changed SIM snapshot = %#v", snapshot)
|
||||
}
|
||||
client.assertDone(t)
|
||||
}
|
||||
|
||||
func TestExecuteSensitiveATDoesNotPersistCommandOrModemError(t *testing.T) {
|
||||
const secretCommand = `AT+CSIM=78,"00880081221000112233445566778899AABBCCDDEEFF1000112233445566778899AABBCCDDEEFF00"`
|
||||
client := &transcriptClient{steps: []clientStep{{
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -40,6 +40,7 @@ func TestCarrierNameForPLMNUsesGlobalDatabase(t *testing.T) {
|
||||
func TestCarrierForPLMNReturnsCountryCode(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"23415": "GB",
|
||||
"23487": "GB",
|
||||
"26202": "DE",
|
||||
"310260": "US",
|
||||
"22201": "IT",
|
||||
@@ -53,3 +54,22 @@ func TestCarrierForPLMNReturnsCountryCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCarrierForIMSIHandlesTwoAndThreeDigitMNCs(t *testing.T) {
|
||||
tests := []struct {
|
||||
imsi string
|
||||
wantPLMN string
|
||||
wantCountry string
|
||||
}{
|
||||
{imsi: "234336570710174", wantPLMN: "23433", wantCountry: "GB"},
|
||||
{imsi: "234159609054263", wantPLMN: "23415", wantCountry: "GB"},
|
||||
{imsi: "234870123456789", wantPLMN: "23487", wantCountry: "GB"},
|
||||
{imsi: "310260123456789", wantPLMN: "310260", wantCountry: "US"},
|
||||
}
|
||||
for _, item := range tests {
|
||||
plmn, name, country, ok := CarrierForIMSI(item.imsi)
|
||||
if !ok || plmn != item.wantPLMN || name == "" || country != item.wantCountry {
|
||||
t.Errorf("CarrierForIMSI(%q) = (%q, %q, %q, %v), want PLMN %q and country %q", item.imsi, plmn, name, country, ok, item.wantPLMN, item.wantCountry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+91
-19
@@ -3,12 +3,14 @@ package device
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf16"
|
||||
|
||||
"vocat/internal/modem"
|
||||
)
|
||||
@@ -17,6 +19,8 @@ func (manager *Manager) readSnapshot(
|
||||
ctx context.Context,
|
||||
id string,
|
||||
candidate modem.Candidate,
|
||||
backend string,
|
||||
previousICCID string,
|
||||
client modem.Client,
|
||||
) (Snapshot, error) {
|
||||
snapshot := Snapshot{
|
||||
@@ -47,6 +51,36 @@ func (manager *Manager) readSnapshot(
|
||||
if response, ok := optional("AT+CPIN?"); ok {
|
||||
snapshot.SIMStatus, snapshot.SIMReady = parseCPIN(response)
|
||||
}
|
||||
ccid, ccidErr := manager.command(ctx, client, "AT+CCID")
|
||||
if ccidErr != nil {
|
||||
ccid, ccidErr = manager.command(ctx, client, "AT+QCCID")
|
||||
}
|
||||
if ccidErr != nil {
|
||||
snapshot.Warnings = append(snapshot.Warnings, "read ICCID: "+ccidErr.Error())
|
||||
} else {
|
||||
snapshot.ICCID = parseICCIDIdentifier(ccid, []string{"+CCID:", "+QCCID:"}, 18, 22)
|
||||
}
|
||||
previousICCID = strings.TrimSpace(previousICCID)
|
||||
if previousICCID != "" && snapshot.ICCID != "" && !strings.EqualFold(previousICCID, snapshot.ICCID) {
|
||||
// A different physical SIM must never inherit the previous card's
|
||||
// permission to use cellular RF. Disable RF before reading serving-cell
|
||||
// or operator state; policy reconciliation will then start VoWiFi.
|
||||
if _, err := manager.command(ctx, client, "AT+CFUN=4"); err != nil {
|
||||
return snapshot, fmt.Errorf("protect changed SIM with RF off: %w", err)
|
||||
}
|
||||
snapshot.SIMChanged = true
|
||||
}
|
||||
if response, ok := optional("AT+CIMI"); ok {
|
||||
snapshot.IMSI = parseIdentifier(response, []string{"+CIMI:"}, 10, 18)
|
||||
}
|
||||
// EF_SPN is the SIM-issued brand (for example "Lebara"), which is distinct
|
||||
// from the IMSI sponsor/core PLMN. A Lebara UK subscription may therefore
|
||||
// legitimately carry a Vodafone NL IMSI while still presenting Lebara as
|
||||
// its customer-facing operator. Failure is intentionally silent because
|
||||
// EF_SPN is optional and some physical SIMs deny CRSM access to it.
|
||||
if response, spnErr := manager.command(ctx, client, "AT+CRSM=176,28486,0,0,17"); spnErr == nil {
|
||||
snapshot.SPN = parseSPN(response)
|
||||
}
|
||||
if response, ok := optional("AT+CSQ"); ok {
|
||||
snapshot.SignalRaw, snapshot.SignalPercent, snapshot.RSSIDBm = parseCSQ(response)
|
||||
}
|
||||
@@ -87,13 +121,16 @@ func (manager *Manager) readSnapshot(
|
||||
break
|
||||
}
|
||||
}
|
||||
if registration, found := readPlatformRegistration(ctx, candidate); found {
|
||||
snapshot.RegistrationStatus = registration.Status
|
||||
snapshot.RegistrationSource = "QMI NAS"
|
||||
snapshot.PSAttached = registration.PSAttached
|
||||
if registration.PLMN != "" {
|
||||
snapshot.OperatorCode = registration.PLMN
|
||||
snapshot.OperatorName = carrierNameForPLMN(registration.PLMN, registration.Name)
|
||||
if strings.EqualFold(backend, "qmi") {
|
||||
registration, found := readPlatformRegistration(ctx, candidate)
|
||||
if found {
|
||||
snapshot.RegistrationStatus = registration.Status
|
||||
snapshot.RegistrationSource = "QMI NAS"
|
||||
snapshot.PSAttached = registration.PSAttached
|
||||
if registration.PLMN != "" {
|
||||
snapshot.OperatorCode = registration.PLMN
|
||||
snapshot.OperatorName = carrierNameForPLMN(registration.PLMN, registration.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if snapshot.RegistrationSource == "" && (snapshot.OperatorName != "" || snapshot.OperatorCode != "") {
|
||||
@@ -111,18 +148,6 @@ func (manager *Manager) readSnapshot(
|
||||
)
|
||||
}
|
||||
|
||||
ccid, ccidErr := manager.command(ctx, client, "AT+CCID")
|
||||
if ccidErr != nil {
|
||||
ccid, ccidErr = manager.command(ctx, client, "AT+QCCID")
|
||||
}
|
||||
if ccidErr != nil {
|
||||
snapshot.Warnings = append(snapshot.Warnings, "read ICCID: "+ccidErr.Error())
|
||||
} else {
|
||||
snapshot.ICCID = parseICCIDIdentifier(ccid, []string{"+CCID:", "+QCCID:"}, 18, 22)
|
||||
}
|
||||
if response, ok := optional("AT+CIMI"); ok {
|
||||
snapshot.IMSI = parseIdentifier(response, []string{"+CIMI:"}, 10, 18)
|
||||
}
|
||||
if response, ok := optional("AT+CFUN?"); ok {
|
||||
if mode, found := parseCFUN(response); found {
|
||||
snapshot.OperatingMode = mode
|
||||
@@ -139,6 +164,53 @@ func (manager *Manager) readSnapshot(
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func parseSPN(response modem.Response) string {
|
||||
value := valueAfterPrefix(response, "+CRSM:")
|
||||
fields := csvValues(value)
|
||||
if len(fields) < 3 {
|
||||
return ""
|
||||
}
|
||||
sw1, sw1Err := strconv.Atoi(strings.TrimSpace(fields[0]))
|
||||
sw2, sw2Err := strconv.Atoi(strings.TrimSpace(fields[1]))
|
||||
if sw1Err != nil || sw2Err != nil || (sw1 != 0x90 && sw1 != 0x91 && sw1 != 0x9f) || sw2 < 0 || sw2 > 255 {
|
||||
return ""
|
||||
}
|
||||
raw, err := hex.DecodeString(strings.Trim(strings.TrimSpace(fields[2]), `"`))
|
||||
if err != nil || len(raw) < 2 {
|
||||
return ""
|
||||
}
|
||||
alpha := raw[1:] // byte 0 is the display-condition bit field.
|
||||
for len(alpha) > 0 && (alpha[len(alpha)-1] == 0xff || alpha[len(alpha)-1] == 0x00) {
|
||||
alpha = alpha[:len(alpha)-1]
|
||||
}
|
||||
if len(alpha) == 0 {
|
||||
return ""
|
||||
}
|
||||
if alpha[0] == 0x80 {
|
||||
ucs2 := alpha[1:]
|
||||
if len(ucs2)%2 != 0 {
|
||||
ucs2 = ucs2[:len(ucs2)-1]
|
||||
}
|
||||
units := make([]uint16, 0, len(ucs2)/2)
|
||||
for index := 0; index+1 < len(ucs2); index += 2 {
|
||||
unit := uint16(ucs2[index])<<8 | uint16(ucs2[index+1])
|
||||
if unit != 0xffff && unit != 0 {
|
||||
units = append(units, unit)
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(string(utf16.Decode(units)))
|
||||
}
|
||||
// EF_SPN uses the unpacked GSM default alphabet. Its printable Latin subset
|
||||
// is byte-compatible with UTF-8/ASCII and covers operator brands in practice.
|
||||
printable := make([]byte, 0, len(alpha))
|
||||
for _, value := range alpha {
|
||||
if value >= 0x20 && value <= 0x7e {
|
||||
printable = append(printable, value)
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(string(printable))
|
||||
}
|
||||
|
||||
func parseRegistrationStatus(response modem.Response) (int, bool) {
|
||||
for _, prefix := range []string{"+CEREG:", "+CGREG:", "+CREG:"} {
|
||||
values := csvValues(valueAfterPrefix(response, prefix))
|
||||
|
||||
@@ -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")
|
||||
@@ -24,9 +25,13 @@ var (
|
||||
)
|
||||
|
||||
type NetworkRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APN string `json:"apn"`
|
||||
IPVersion string `json:"ipVersion"`
|
||||
Enabled bool `json:"enabled"`
|
||||
APN string `json:"apn"`
|
||||
IPVersion string `json:"ipVersion"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
Authentication string `json:"authentication,omitempty"`
|
||||
Backend string `json:"backend,omitempty"`
|
||||
}
|
||||
|
||||
type NetworkResult struct {
|
||||
@@ -81,6 +86,7 @@ type Snapshot struct {
|
||||
Firmware string `json:"firmware"`
|
||||
SIMStatus string `json:"simStatus"`
|
||||
SIMReady bool `json:"simReady"`
|
||||
SIMChanged bool `json:"simChanged,omitempty"`
|
||||
SignalRaw *int `json:"signalRaw,omitempty"`
|
||||
SignalPercent *int `json:"signalPercent,omitempty"`
|
||||
RSSIDBm *int `json:"rssiDbm,omitempty"`
|
||||
@@ -98,6 +104,7 @@ type Snapshot struct {
|
||||
IMEI string `json:"imei"`
|
||||
ICCID string `json:"iccid"`
|
||||
IMSI string `json:"imsi"`
|
||||
SPN string `json:"spn,omitempty"`
|
||||
OperatingMode int `json:"operatingMode"`
|
||||
ModeKnown bool `json:"modeKnown"`
|
||||
FlightMode bool `json:"flightMode"`
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"hash/fnv"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
func platformSupported() error { return nil }
|
||||
@@ -54,14 +54,15 @@ func boundResolver(networkInterface string) *net.Resolver {
|
||||
}
|
||||
|
||||
func exportRouteDNSServers(networkInterface string) []string {
|
||||
safeName := strings.Map(func(character rune) rune {
|
||||
if character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' ||
|
||||
character >= '0' && character <= '9' || character == '-' || character == '_' || character == '.' {
|
||||
return character
|
||||
}
|
||||
return '_'
|
||||
}, networkInterface)
|
||||
file, err := os.Open(filepath.Join("/run/vocat", "cellular-"+safeName+".dns"))
|
||||
if !validInterfaceName(networkInterface) {
|
||||
return []string{"1.1.1.1", "8.8.8.8"}
|
||||
}
|
||||
root, err := os.OpenRoot("/run/vocat")
|
||||
if err != nil {
|
||||
return []string{"1.1.1.1", "8.8.8.8"}
|
||||
}
|
||||
defer root.Close()
|
||||
file, err := root.Open("cellular-" + networkInterface + ".dns")
|
||||
if err != nil {
|
||||
return []string{"1.1.1.1", "8.8.8.8"}
|
||||
}
|
||||
@@ -78,3 +79,20 @@ func exportRouteDNSServers(networkInterface string) []string {
|
||||
}
|
||||
return servers
|
||||
}
|
||||
|
||||
// Linux IFNAMSIZ is 16 including the terminator. Restricting names here both
|
||||
// matches kernel interface names and prevents a stored device value from ever
|
||||
// becoming a filesystem path component.
|
||||
func validInterfaceName(value string) bool {
|
||||
if value == "" || len(value) > 15 || value == "." || value == ".." {
|
||||
return false
|
||||
}
|
||||
for _, character := range value {
|
||||
if character > unicode.MaxASCII || !(character >= 'a' && character <= 'z' ||
|
||||
character >= 'A' && character <= 'Z' || character >= '0' && character <= '9' ||
|
||||
character == '-' || character == '_' || character == '.') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
//go:build linux
|
||||
|
||||
package exportproxy
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidInterfaceName(t *testing.T) {
|
||||
for _, value := range []string{"wwan0", "wwp0s20f0u5i4", "rmnet_data0", "usb.1"} {
|
||||
if !validInterfaceName(value) {
|
||||
t.Errorf("validInterfaceName(%q) = false", value)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"", ".", "..", "../wwan0", `..\wwan0`, "wwan0/evil", "interface-name-too-long"} {
|
||||
if validInterfaceName(value) {
|
||||
t.Errorf("validInterfaceName(%q) = true", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -26,10 +27,15 @@ import (
|
||||
"time"
|
||||
|
||||
"vocat/internal/exportproxy"
|
||||
"vocat/internal/netguard"
|
||||
)
|
||||
|
||||
const maxPackageBytes int64 = 64 << 20
|
||||
|
||||
// This syntactic guard gives the request boundary an explicit allowlist. The
|
||||
// resolved addresses are still checked again by netguard before dialing.
|
||||
var publicHTTPSURLPattern = regexp.MustCompile(`^https://(?:[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?|\[[0-9A-Fa-f:.]+\])(?::[0-9]{1,5})?(?:[/?#][^\r\n]*)?$`)
|
||||
|
||||
type Plugin struct {
|
||||
Manifest
|
||||
Enabled bool `json:"enabled"`
|
||||
@@ -73,7 +79,7 @@ func NewManager(root string, logger *slog.Logger) (*Manager, error) {
|
||||
}
|
||||
manager := &Manager{
|
||||
root: root, logger: logger, plugins: make(map[string]*Plugin),
|
||||
client: &http.Client{Timeout: 45 * time.Second},
|
||||
client: netguard.NewPublicHTTPClient(45*time.Second, true),
|
||||
}
|
||||
if err := manager.scan(); err != nil {
|
||||
return nil, err
|
||||
@@ -155,9 +161,13 @@ func (manager *Manager) List() []Plugin {
|
||||
}
|
||||
|
||||
func (manager *Manager) InstallURL(ctx context.Context, rawURL, expectedSHA string) (Plugin, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") || parsed.Host == "" {
|
||||
return Plugin{}, errors.New("plugin URL must be an absolute HTTP or HTTPS URL")
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
if !publicHTTPSURLPattern.MatchString(rawURL) {
|
||||
return Plugin{}, errors.New("plugin URL must be a public absolute HTTPS URL")
|
||||
}
|
||||
parsed, err := netguard.ValidatePublicURL(ctx, rawURL, true)
|
||||
if err != nil {
|
||||
return Plugin{}, fmt.Errorf("plugin URL must be a public absolute HTTPS URL: %w", err)
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
|
||||
if err != nil {
|
||||
@@ -353,12 +363,13 @@ func (manager *Manager) ServeAsset(w http.ResponseWriter, r *http.Request, id, n
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
filename := filepath.Join(plugin.dir, filepath.FromSlash(name))
|
||||
if !strings.HasPrefix(filepath.Clean(filename), filepath.Clean(plugin.dir)+string(os.PathSeparator)) {
|
||||
root, err := os.OpenRoot(plugin.dir)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
file, err := os.Open(filename)
|
||||
defer root.Close()
|
||||
file, err := root.Open(filepath.FromSlash(name))
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
@@ -369,7 +380,7 @@ func (manager *Manager) ServeAsset(w http.ResponseWriter, r *http.Request, id, n
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
contentType := mime.TypeByExtension(filepath.Ext(filename))
|
||||
contentType := mime.TypeByExtension(filepath.Ext(name))
|
||||
if contentType != "" {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
}
|
||||
|
||||
@@ -3,12 +3,32 @@ package extensions
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInstallURLRejectsNonHTTPSAndPrivateDestinations(t *testing.T) {
|
||||
manager, err := NewManager(t.TempDir(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer manager.Close()
|
||||
for _, raw := range []string{
|
||||
"http://example.com/plugin.zip",
|
||||
"https://[email protected]/plugin.zip",
|
||||
"https://example.com/plugin.zip\r\nX-Injected: yes",
|
||||
"https://127.0.0.1/plugin.zip",
|
||||
"https://169.254.169.254/latest/meta-data/",
|
||||
} {
|
||||
if _, err := manager.InstallURL(context.Background(), raw, ""); err == nil {
|
||||
t.Errorf("InstallURL(%q) accepted an unsafe destination", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallListDisableAndUninstall(t *testing.T) {
|
||||
manager, err := NewManager(t.TempDir(), slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
|
||||
@@ -221,7 +221,13 @@ func readSerialAliases(root string) map[string]string {
|
||||
func candidateID(productID, serialNumber, usbName string) string {
|
||||
serialNumber = strings.TrimSpace(serialNumber)
|
||||
if serialNumber != "" && !strings.EqualFold(serialNumber, "android") {
|
||||
return "quectel-" + sanitizeID(serialNumber)
|
||||
// A surprising number of EC20/EC25 carrier boards expose the same
|
||||
// factory/default USB serial number. The device manager is keyed by this
|
||||
// value, so using the serial alone silently collapsed two modems connected
|
||||
// to the same hub into one entry. Include the physical USB topology in the
|
||||
// discovery key; configured devices remain stable through ATMapper's
|
||||
// USB-path/IMEI matching even when Linux renumbers ttyUSB nodes.
|
||||
return "quectel-" + sanitizeID(serialNumber+"-"+usbName)
|
||||
}
|
||||
return "quectel-" + sanitizeID(productID+"-"+usbName)
|
||||
}
|
||||
|
||||
@@ -171,6 +171,51 @@ func TestSysFSDiscoverySelectsATPortForSecondQMIUSBModem(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSysFSDiscoveryDoesNotCollapseModemsWithSharedFactorySerial(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sysRoot := filepath.Join(root, "sys")
|
||||
devRoot := filepath.Join(root, "dev")
|
||||
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
|
||||
|
||||
for index, item := range []struct {
|
||||
usbName string
|
||||
ttyBase int
|
||||
}{
|
||||
{usbName: "1-5.1", ttyBase: 0},
|
||||
{usbName: "1-5.2", ttyBase: 4},
|
||||
} {
|
||||
mustWrite(t, filepath.Join(usbRoot, item.usbName, "idVendor"), "2c7c\n")
|
||||
mustWrite(t, filepath.Join(usbRoot, item.usbName, "idProduct"), "0125\n")
|
||||
mustWrite(t, filepath.Join(usbRoot, item.usbName, "serial"), "0123456789ABCDEF\n")
|
||||
for number := 0; number < 4; number++ {
|
||||
interfaceName := item.usbName + ":1." + strconv.Itoa(number)
|
||||
tty := fmt.Sprintf("ttyUSB%d", item.ttyBase+number)
|
||||
mustWrite(t, filepath.Join(usbRoot, interfaceName, "bInterfaceNumber"), fmt.Sprintf("%02x\n", number))
|
||||
mustMkdir(t, filepath.Join(usbRoot, interfaceName, tty, "tty", tty))
|
||||
}
|
||||
mustMkdir(t, filepath.Join(usbRoot, item.usbName+":1.4", "usbmisc", fmt.Sprintf("cdc-wdm%d", index)))
|
||||
}
|
||||
|
||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Discover: %v", err)
|
||||
}
|
||||
if len(candidates) != 2 {
|
||||
t.Fatalf("got %d candidates, want 2", len(candidates))
|
||||
}
|
||||
if candidates[0].ID == candidates[1].ID {
|
||||
t.Fatalf("shared factory serial collapsed discovery IDs to %q", candidates[0].ID)
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if candidate.SerialNumber != "0123456789ABCDEF" {
|
||||
t.Fatalf("serial = %q", candidate.SerialNumber)
|
||||
}
|
||||
if candidate.ATPort.Role != PortRoleAT {
|
||||
t.Fatalf("AT port = %#v", candidate.ATPort)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSysFSDiscoveryIgnoresNonQuectelUSB(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
usbRoot := filepath.Join(root, "sys", "bus", "usb", "devices")
|
||||
|
||||
@@ -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,170 @@
|
||||
package netguard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ValidatePublicURL accepts an absolute HTTP(S) URL only when every currently
|
||||
// resolved address is publicly routable. The transport returned by
|
||||
// NewPublicHTTPClient repeats the same check when it dials, which also prevents
|
||||
// DNS rebinding between validation and connection establishment.
|
||||
func ValidatePublicURL(ctx context.Context, raw string, requireHTTPS bool) (*url.URL, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || !parsed.IsAbs() || parsed.Hostname() == "" {
|
||||
return nil, errors.New("destination must be an absolute HTTP URL")
|
||||
}
|
||||
if parsed.User != nil {
|
||||
return nil, errors.New("destination URL cannot contain user information")
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return nil, errors.New("destination URL must use HTTP or HTTPS")
|
||||
}
|
||||
if requireHTTPS && parsed.Scheme != "https" {
|
||||
return nil, errors.New("destination URL must use HTTPS")
|
||||
}
|
||||
if port := parsed.Port(); port != "" {
|
||||
value, err := strconv.Atoi(port)
|
||||
if err != nil || value < 1 || value > 65535 {
|
||||
return nil, errors.New("destination URL has an invalid port")
|
||||
}
|
||||
}
|
||||
if _, err := resolvePublic(ctx, parsed.Hostname()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// NewPublicHTTPClient creates a client that never uses environment proxies,
|
||||
// rejects private/special-use destinations at dial time, and validates every
|
||||
// redirect before following it.
|
||||
func NewPublicHTTPClient(timeout time.Duration, requireHTTPS bool) *http.Client {
|
||||
if timeout <= 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
transport := &http.Transport{
|
||||
Proxy: nil,
|
||||
DialContext: PublicDialer(timeout),
|
||||
ForceAttemptHTTP2: true,
|
||||
TLSHandshakeTimeout: timeout,
|
||||
ResponseHeaderTimeout: timeout,
|
||||
ExpectContinueTimeout: time.Second,
|
||||
TLSClientConfig: &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
},
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: timeout,
|
||||
CheckRedirect: func(request *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 4 {
|
||||
return errors.New("too many redirects")
|
||||
}
|
||||
_, err := ValidatePublicURL(request.Context(), request.URL.String(), requireHTTPS)
|
||||
return err
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// PublicDialer resolves the original hostname and connects directly to one of
|
||||
// its validated public addresses. It does not pass the hostname back through a
|
||||
// second resolver, so a DNS rebinding response cannot redirect the connection.
|
||||
func PublicDialer(timeout time.Duration) func(context.Context, string, string) (net.Conn, error) {
|
||||
return func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse outbound address: %w", err)
|
||||
}
|
||||
addresses, err := resolvePublic(ctx, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dialer := net.Dialer{Timeout: timeout}
|
||||
var lastErr error
|
||||
for _, address := range addresses {
|
||||
connection, err := dialer.DialContext(ctx, network, net.JoinHostPort(address.String(), port))
|
||||
if err == nil {
|
||||
return connection, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return nil, fmt.Errorf("connect to public destination: %w", lastErr)
|
||||
}
|
||||
}
|
||||
|
||||
func resolvePublic(ctx context.Context, host string) ([]netip.Addr, error) {
|
||||
if literal, err := netip.ParseAddr(strings.Trim(host, "[]")); err == nil {
|
||||
literal = literal.Unmap()
|
||||
if !publicAddress(literal) {
|
||||
return nil, errors.New("destination resolves to a private or special-use address")
|
||||
}
|
||||
return []netip.Addr{literal}, nil
|
||||
}
|
||||
addresses, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve destination: %w", err)
|
||||
}
|
||||
result := make([]netip.Addr, 0, len(addresses))
|
||||
for _, address := range addresses {
|
||||
address = address.Unmap()
|
||||
if !publicAddress(address) {
|
||||
return nil, errors.New("destination resolves to a private or special-use address")
|
||||
}
|
||||
result = append(result, address)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil, errors.New("destination has no IP address")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var blockedNetworks = []netip.Prefix{
|
||||
netip.MustParsePrefix("0.0.0.0/8"),
|
||||
netip.MustParsePrefix("10.0.0.0/8"),
|
||||
netip.MustParsePrefix("100.64.0.0/10"),
|
||||
netip.MustParsePrefix("127.0.0.0/8"),
|
||||
netip.MustParsePrefix("169.254.0.0/16"),
|
||||
netip.MustParsePrefix("172.16.0.0/12"),
|
||||
netip.MustParsePrefix("192.0.0.0/24"),
|
||||
netip.MustParsePrefix("192.0.2.0/24"),
|
||||
netip.MustParsePrefix("192.88.99.0/24"),
|
||||
netip.MustParsePrefix("192.168.0.0/16"),
|
||||
netip.MustParsePrefix("198.18.0.0/15"),
|
||||
netip.MustParsePrefix("198.51.100.0/24"),
|
||||
netip.MustParsePrefix("203.0.113.0/24"),
|
||||
netip.MustParsePrefix("224.0.0.0/4"),
|
||||
netip.MustParsePrefix("240.0.0.0/4"),
|
||||
netip.MustParsePrefix("::/128"),
|
||||
netip.MustParsePrefix("::1/128"),
|
||||
netip.MustParsePrefix("64:ff9b:1::/48"),
|
||||
netip.MustParsePrefix("100::/64"),
|
||||
netip.MustParsePrefix("2001:db8::/32"),
|
||||
netip.MustParsePrefix("fc00::/7"),
|
||||
netip.MustParsePrefix("fe80::/10"),
|
||||
netip.MustParsePrefix("ff00::/8"),
|
||||
// Block both the well-known and local-use NAT64 prefixes. Otherwise a
|
||||
// public-looking IPv6 literal could translate to a private IPv4 target.
|
||||
netip.MustParsePrefix("64:ff9b::/96"),
|
||||
netip.MustParsePrefix("2002::/16"),
|
||||
}
|
||||
|
||||
func publicAddress(address netip.Addr) bool {
|
||||
if !address.IsValid() || !address.IsGlobalUnicast() {
|
||||
return false
|
||||
}
|
||||
for _, blocked := range blockedNetworks {
|
||||
if blocked.Contains(address) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package netguard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidatePublicURLRejectsUnsafeDestinations(t *testing.T) {
|
||||
tests := []string{
|
||||
"http://127.0.0.1/plugin.zip",
|
||||
"https://[::1]/plugin.zip",
|
||||
"https://169.254.169.254/latest/meta-data/",
|
||||
"https://[64:ff9b::7f00:1]/",
|
||||
"https://[2002:7f00:1::]/",
|
||||
"file:///etc/passwd",
|
||||
"https://user:[email protected]/plugin.zip",
|
||||
}
|
||||
for _, raw := range tests {
|
||||
if _, err := ValidatePublicURL(context.Background(), raw, false); err == nil {
|
||||
t.Errorf("ValidatePublicURL(%q) accepted an unsafe destination", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePublicURLCanRequireHTTPS(t *testing.T) {
|
||||
if _, err := ValidatePublicURL(context.Background(), "http://8.8.8.8/plugin.zip", true); err == nil {
|
||||
t.Fatal("HTTP destination was accepted while HTTPS was required")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
//go:build linux && (amd64 || arm64)
|
||||
|
||||
package pcsc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/ElMostafaIdrassi/goscard"
|
||||
)
|
||||
|
||||
type nativeBackend struct {
|
||||
initializeOnce sync.Once
|
||||
initializeErr error
|
||||
}
|
||||
|
||||
func newNativeBackend() Backend { return &nativeBackend{} }
|
||||
|
||||
func (backend *nativeBackend) initialize() error {
|
||||
backend.initializeOnce.Do(func() {
|
||||
if err := goscard.Initialize(goscard.NewDefaultLogger(goscard.LogLevelNone)); err != nil {
|
||||
backend.initializeErr = fmt.Errorf("%w: pcsc-lite client library could not be loaded", ErrUnavailable)
|
||||
}
|
||||
})
|
||||
return backend.initializeErr
|
||||
}
|
||||
|
||||
func (backend *nativeBackend) Readers(ctx context.Context) ([]Reader, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := backend.initialize(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cardContext, _, err := goscard.NewContext(goscard.SCardScopeSystem, nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: pcscd is not reachable", ErrUnavailable)
|
||||
}
|
||||
defer cardContext.Release()
|
||||
names, _, err := cardContext.ListReaders(nil)
|
||||
if err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "no readers") {
|
||||
return []Reader{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("pcsc: list readers: %w", err)
|
||||
}
|
||||
presentNames, atrs, _, _ := cardContext.ListReadersWithCardPresent(nil)
|
||||
present := make(map[string]string, len(presentNames))
|
||||
for index, name := range presentNames {
|
||||
atr := ""
|
||||
if index < len(atrs) {
|
||||
atr = atrs[index]
|
||||
}
|
||||
present[name] = atr
|
||||
}
|
||||
readers := make([]Reader, 0, len(names))
|
||||
for _, name := range names {
|
||||
reader := Reader{Name: name}
|
||||
reader.ATR, reader.CardPresent = present[name]
|
||||
if path, ok := backend.readerUSBPath(cardContext, 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:" + name
|
||||
}
|
||||
if reader.Product == "" {
|
||||
reader.Product = strings.TrimSpace(strings.TrimSuffix(name, " 00 00"))
|
||||
}
|
||||
readers = append(readers, reader)
|
||||
}
|
||||
return readers, nil
|
||||
}
|
||||
|
||||
func (backend *nativeBackend) readerUSBPath(cardContext goscard.Context, name string) (string, bool) {
|
||||
card, _, err := cardContext.Connect(name, goscard.SCardShareDirect, goscard.SCardProtocolT0|goscard.SCardProtocolT1)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
defer card.Disconnect(goscard.SCardLeaveCard)
|
||||
attribute, _, err := card.GetAttrib(goscard.SCardAttrChannelID)
|
||||
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) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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
|
||||
}
|
||||
cardContext, _, err := goscard.NewContext(goscard.SCardScopeSystem, nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: create context", ErrUnavailable)
|
||||
}
|
||||
card, _, err := cardContext.Connect(reader.Name, goscard.SCardShareShared, goscard.SCardProtocolT0|goscard.SCardProtocolT1)
|
||||
if err != nil {
|
||||
cardContext.Release()
|
||||
return nil, fmt.Errorf("pcsc: connect reader: %w", err)
|
||||
}
|
||||
if _, err := card.BeginTransaction(); err != nil {
|
||||
card.Disconnect(goscard.SCardLeaveCard)
|
||||
cardContext.Release()
|
||||
return nil, fmt.Errorf("pcsc: begin card transaction: %w", err)
|
||||
}
|
||||
return &nativeCard{context: &cardContext, card: &card}, nil
|
||||
}
|
||||
|
||||
type nativeCard struct {
|
||||
context *goscard.Context
|
||||
card *goscard.Card
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (card *nativeCard) Transmit(ctx context.Context, command []byte) ([]byte, uint16, error) {
|
||||
if card == nil || card.card == nil || card.closed {
|
||||
return nil, 0, errors.New("pcsc: card session is closed")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return card.transmit(ctx, append([]byte(nil), command...), 0)
|
||||
}
|
||||
|
||||
// TransmitRaw performs exactly one APDU exchange. Stateful eUICC callers need
|
||||
// to observe 61xx themselves because GET RESPONSE must target their logical
|
||||
// channel rather than the basic channel.
|
||||
func (card *nativeCard) TransmitRaw(ctx context.Context, command []byte) ([]byte, uint16, error) {
|
||||
if card == nil || card.card == nil || card.closed {
|
||||
return nil, 0, errors.New("pcsc: card session is closed")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
pci := goscard.SCardIoRequestT0
|
||||
if card.card.ActiveProtocol() == goscard.SCardProtocolT1 {
|
||||
pci = goscard.SCardIoRequestT1
|
||||
}
|
||||
response, _, err := card.card.Transmit(&pci, append([]byte(nil), command...), nil)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if len(response) < 2 {
|
||||
return nil, 0, errors.New("pcsc: APDU response omitted its status word")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
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")
|
||||
}
|
||||
pci := goscard.SCardIoRequestT0
|
||||
if card.card.ActiveProtocol() == goscard.SCardProtocolT1 {
|
||||
pci = goscard.SCardIoRequestT1
|
||||
}
|
||||
response, _, err := card.card.Transmit(&pci, command, nil)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if len(response) < 2 {
|
||||
return nil, 0, errors.New("pcsc: APDU response omitted its status word")
|
||||
}
|
||||
data := append([]byte(nil), response[:len(response)-2]...)
|
||||
sw1, sw2 := response[len(response)-2], response[len(response)-1]
|
||||
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
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return data, uint16(sw1)<<8 | uint16(sw2), nil
|
||||
}
|
||||
|
||||
func (card *nativeCard) Close() error {
|
||||
return card.close(goscard.SCardLeaveCard)
|
||||
}
|
||||
|
||||
func (card *nativeCard) CloseWithReset() error {
|
||||
return card.close(goscard.SCardResetCard)
|
||||
}
|
||||
|
||||
func (card *nativeCard) close(disposition goscard.SCardDisposition) error {
|
||||
if card == nil || card.closed {
|
||||
return nil
|
||||
}
|
||||
card.closed = true
|
||||
var result []error
|
||||
if card.card != nil {
|
||||
if _, err := card.card.EndTransaction(disposition); err != nil {
|
||||
result = append(result, err)
|
||||
}
|
||||
if _, err := card.card.Disconnect(disposition); err != nil {
|
||||
result = append(result, err)
|
||||
}
|
||||
}
|
||||
if card.context != nil {
|
||||
if _, err := card.context.Release(); 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,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)
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
type automaticTaskNotification struct {
|
||||
Title string
|
||||
Text string
|
||||
Time time.Time
|
||||
Task store.AutomaticTask
|
||||
Run store.AutomaticTaskRun
|
||||
}
|
||||
|
||||
func (s *Server) notifyAutomaticTask(ctx context.Context, task store.AutomaticTask, run store.AutomaticTaskRun) {
|
||||
deviceLabel := task.DeviceID
|
||||
if configured, err := s.store.Device(ctx, task.DeviceID); err == nil {
|
||||
deviceLabel = firstNonEmpty(configured.Name, configured.ID)
|
||||
}
|
||||
status := "成功"
|
||||
detail := firstNonEmpty(run.Output, "任务已完成")
|
||||
if run.Status != "success" {
|
||||
status = "失败"
|
||||
detail = firstNonEmpty(run.Error, "未知错误")
|
||||
}
|
||||
taskType := map[string]string{"sms": "发送短信", "call": "拨打电话", "public_ip": "获取漫游公网 IP"}[task.TaskType]
|
||||
environment := map[string]string{"vowifi": "VoWiFi", "cellular": "基站直连"}[task.Environment]
|
||||
notification := automaticTaskNotification{
|
||||
Title: "自动任务执行" + status,
|
||||
Text: strings.Join([]string{
|
||||
"自动任务执行" + status,
|
||||
"任务 " + task.Name,
|
||||
"设备 " + deviceLabel,
|
||||
"类型 " + firstNonEmpty(taskType, task.TaskType),
|
||||
"环境 " + firstNonEmpty(environment, task.Environment),
|
||||
"时间 " + run.FinishedAt.Local().Format("2006-01-02 15:04:05"),
|
||||
"结果 " + detail,
|
||||
}, "\n"),
|
||||
Time: run.FinishedAt, Task: task, Run: run,
|
||||
}
|
||||
for _, channel := range []string{"telegram", "bark", "email", "pushplus", "webhook", "wecom"} {
|
||||
setting, err := s.store.NotificationSetting(ctx, channel)
|
||||
if errors.Is(err, store.ErrNotFound) || (err == nil && !setting.Enabled) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
s.logger.Warn("read automatic task notification setting", "channel", channel, "error", err)
|
||||
continue
|
||||
}
|
||||
var config map[string]any
|
||||
if err := json.Unmarshal(setting.Config, &config); err != nil {
|
||||
s.logger.Warn("decode automatic task notification setting", "channel", channel, "error", err)
|
||||
continue
|
||||
}
|
||||
if err := sendAutomaticTaskNotification(ctx, channel, config, notification); err != nil {
|
||||
s.logger.Warn("send automatic task notification", "channel", channel, "task_id", task.ID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendAutomaticTaskNotification(ctx context.Context, channel string, config map[string]any, message automaticTaskNotification) error {
|
||||
switch channel {
|
||||
case "telegram":
|
||||
return sendTelegramTextNotification(ctx, config, message.Text)
|
||||
case "bark":
|
||||
return sendBarkTextNotification(ctx, config, message.Title, message.Text)
|
||||
case "email":
|
||||
return sendEmailTextNotification(ctx, config, message.Title, message.Text)
|
||||
case "pushplus":
|
||||
return sendPushplusTextNotification(ctx, config, message.Title, message.Text)
|
||||
case "webhook":
|
||||
return sendAutomaticTaskWebhook(ctx, config, message)
|
||||
case "wecom":
|
||||
return sendWecomNotification(ctx, config, wecomAutomaticTaskValues(message))
|
||||
default:
|
||||
return fmt.Errorf("unsupported notification channel %q", channel)
|
||||
}
|
||||
}
|
||||
|
||||
func sendTelegramTextNotification(ctx context.Context, config map[string]any, text string) error {
|
||||
token := configString(config, "bot_token")
|
||||
parsed, err := validateTelegramAPIURL(ctx, configString(config, "base_url"), token, "sendMessage")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := restrictedHTTPClient(ctx, 8*time.Second, configString(config, "proxy"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]any{"chat_id": configString(config, "chat_id"), "text": text})
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "vocat-automatic-task/1")
|
||||
return performNotificationRequest(client, request, true)
|
||||
}
|
||||
|
||||
func sendBarkTextNotification(ctx context.Context, config map[string]any, title, text string) error {
|
||||
client, err := restrictedHTTPClient(ctx, 8*time.Second, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]any{"title": title, "body": text}
|
||||
for _, field := range []string{"group", "icon", "level"} {
|
||||
if value := configString(config, field); value != "" {
|
||||
payload[field] = value
|
||||
}
|
||||
}
|
||||
encoded, _ := json.Marshal(payload)
|
||||
for _, destination := range configStrings(config, "urls") {
|
||||
parsed, err := validateOutboundURL(ctx, destination, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), bytes.NewReader(encoded))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
request.Header.Set("User-Agent", "vocat-automatic-task/1")
|
||||
if err := performNotificationRequest(client, request, false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendPushplusTextNotification(ctx context.Context, config map[string]any, title, text string) error {
|
||||
destination, err := validateOutboundURL(ctx, "https://www.pushplus.plus/send", true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]any{"token": configString(config, "token"), "title": title, "content": text, "template": "txt", "timestamp": time.Now().UnixMilli()}
|
||||
if topic := configString(config, "topic"); topic != "" {
|
||||
payload["topic"] = topic
|
||||
}
|
||||
if channel := configString(config, "channel"); channel != "" {
|
||||
payload["channel"] = channel
|
||||
}
|
||||
encoded, _ := json.Marshal(payload)
|
||||
client, err := restrictedHTTPClient(ctx, 8*time.Second, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, destination.String(), bytes.NewReader(encoded))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
request.Header.Set("User-Agent", "vocat-automatic-task/1")
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(response.Body, 64<<10))
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 || json.Unmarshal(body, &result) != nil || result.Code != 200 {
|
||||
return fmt.Errorf("%w: Pushplus HTTP %d code %d %s", errProviderRejected, response.StatusCode, result.Code, result.Msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendAutomaticTaskWebhook(ctx context.Context, config map[string]any, message automaticTaskNotification) error {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"event": "automatic_task.completed", "message": message.Text,
|
||||
"timestamp": message.Time.UTC().Format(time.RFC3339), "task_id": message.Task.ID,
|
||||
"task_name": message.Task.Name, "device_id": message.Task.DeviceID,
|
||||
"task_type": message.Task.TaskType, "environment": message.Task.Environment,
|
||||
"status": message.Run.Status, "attempts": message.Run.Attempts,
|
||||
"output": message.Run.Output, "error": message.Run.Error,
|
||||
})
|
||||
client, err := restrictedHTTPClient(ctx, durationMilliseconds(configInt(config, "timeout_ms"), 5*time.Second), "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, destination := range configStrings(config, "urls") {
|
||||
parsed, err := validateOutboundURL(ctx, destination, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for name, value := range configStringMap(config, "headers") {
|
||||
request.Header.Set(name, value)
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "vocat-automatic-task/1")
|
||||
if secret := configString(config, "secret"); secret != "" {
|
||||
signature := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = signature.Write(payload)
|
||||
request.Header.Set("X-vocat-Signature", "sha256="+hex.EncodeToString(signature.Sum(nil)))
|
||||
}
|
||||
if err := performNotificationRequest(client, request, false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendEmailTextNotification(ctx context.Context, config map[string]any, subject, text string) error {
|
||||
host := strings.TrimSpace(configString(config, "smtp_host"))
|
||||
port := configInt(config, "smtp_port")
|
||||
if port == 0 {
|
||||
port = 587
|
||||
}
|
||||
timeout := 8 * time.Second
|
||||
connection, err := dialRestricted(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(port)), timeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer connection.Close()
|
||||
if err := connection.SetDeadline(time.Now().Add(timeout)); err != nil {
|
||||
return err
|
||||
}
|
||||
tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12, ServerName: host}
|
||||
useSSL, _ := config["use_ssl"].(bool)
|
||||
implicitTLS := port == 465 || useSSL
|
||||
if implicitTLS {
|
||||
secure := tls.Client(connection, tlsConfig)
|
||||
if err := secure.HandshakeContext(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
connection = secure
|
||||
}
|
||||
client, err := smtp.NewClient(connection, host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
if !implicitTLS {
|
||||
if available, _ := client.Extension("STARTTLS"); !available {
|
||||
return errors.New("SMTP server does not offer STARTTLS")
|
||||
}
|
||||
if err := client.StartTLS(tlsConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
username, password := configString(config, "username"), configString(config, "password")
|
||||
if username != "" {
|
||||
if err := client.Auth(smtp.PlainAuth("", username, password, host)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
from, err := parseMailAddress(configString(config, "from_address"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var recipients []*mail.Address
|
||||
for _, item := range configStrings(config, "to_addresses") {
|
||||
address, err := parseMailAddress(item)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
recipients = append(recipients, address)
|
||||
}
|
||||
if err := client.Mail(from.Address); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, recipient := range recipients {
|
||||
if err := client.Rcpt(recipient.Address); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
writer, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writePlainTextMail(writer, from, recipients, subject, text); err != nil {
|
||||
_ = writer.Close()
|
||||
return err
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return client.Quit()
|
||||
}
|
||||
@@ -0,0 +1,865 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"vocat/internal/device"
|
||||
"vocat/internal/exportproxy"
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
automaticTaskPollInterval = 5 * time.Second
|
||||
automaticTaskMaxRuntime = 8 * time.Minute
|
||||
)
|
||||
|
||||
type automaticTaskPayload struct {
|
||||
Phone string `json:"phone,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
DurationSeconds int `json:"duration_seconds,omitempty"`
|
||||
}
|
||||
|
||||
type automaticTaskExecutionError struct {
|
||||
err error
|
||||
retryable bool
|
||||
}
|
||||
|
||||
func (value automaticTaskExecutionError) Error() string { return value.err.Error() }
|
||||
func (value automaticTaskExecutionError) Unwrap() error { return value.err }
|
||||
|
||||
type automaticTaskProgress func(string)
|
||||
|
||||
type automaticTaskEnvironmentSnapshot struct {
|
||||
config store.Device
|
||||
policy store.CardPolicy
|
||||
}
|
||||
|
||||
type automaticTaskScheduler struct {
|
||||
server *Server
|
||||
ctx context.Context
|
||||
mu sync.Mutex
|
||||
queues map[string]chan store.AutomaticTaskRun
|
||||
}
|
||||
|
||||
func (s *Server) StartAutomaticTasks(ctx context.Context) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
scheduler := &automaticTaskScheduler{server: s, ctx: ctx, queues: make(map[string]chan store.AutomaticTaskRun)}
|
||||
s.automaticTasks = scheduler
|
||||
queued, err := s.store.RecoverAutomaticTaskRuns(ctx, time.Now().UTC())
|
||||
if err != nil {
|
||||
s.logger.Warn("recover automatic tasks", "error", err)
|
||||
} else {
|
||||
for _, run := range queued {
|
||||
scheduler.enqueue(run)
|
||||
}
|
||||
}
|
||||
go scheduler.run()
|
||||
}
|
||||
|
||||
func (scheduler *automaticTaskScheduler) run() {
|
||||
ticker := time.NewTicker(automaticTaskPollInterval)
|
||||
defer ticker.Stop()
|
||||
scheduler.claim()
|
||||
for {
|
||||
select {
|
||||
case <-scheduler.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
scheduler.claim()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (scheduler *automaticTaskScheduler) claim() {
|
||||
runs, err := scheduler.server.store.ClaimDueAutomaticTasks(scheduler.ctx, time.Now().UTC(), 50)
|
||||
if err != nil {
|
||||
scheduler.server.logger.Warn("claim automatic tasks", "error", err)
|
||||
return
|
||||
}
|
||||
for _, run := range runs {
|
||||
scheduler.enqueue(run)
|
||||
}
|
||||
}
|
||||
|
||||
func (scheduler *automaticTaskScheduler) enqueue(run store.AutomaticTaskRun) {
|
||||
deviceID := strings.TrimSpace(run.DeviceID)
|
||||
scheduler.mu.Lock()
|
||||
queue := scheduler.queues[deviceID]
|
||||
if queue == nil {
|
||||
queue = make(chan store.AutomaticTaskRun, 100)
|
||||
scheduler.queues[deviceID] = queue
|
||||
go scheduler.worker(deviceID, queue)
|
||||
}
|
||||
scheduler.mu.Unlock()
|
||||
select {
|
||||
case queue <- run:
|
||||
case <-scheduler.ctx.Done():
|
||||
}
|
||||
}
|
||||
|
||||
func (scheduler *automaticTaskScheduler) worker(deviceID string, queue <-chan store.AutomaticTaskRun) {
|
||||
for {
|
||||
select {
|
||||
case <-scheduler.ctx.Done():
|
||||
return
|
||||
case run := <-queue:
|
||||
scheduler.execute(run)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (scheduler *automaticTaskScheduler) execute(run store.AutomaticTaskRun) {
|
||||
task, err := scheduler.server.store.AutomaticTask(scheduler.ctx, run.TaskID)
|
||||
if err != nil {
|
||||
run.Status, run.Error, run.FinishedAt = "failed", err.Error(), time.Now().UTC()
|
||||
_ = scheduler.server.store.UpdateAutomaticTaskRun(context.Background(), run)
|
||||
return
|
||||
}
|
||||
run.Status, run.StartedAt = "running", time.Now().UTC()
|
||||
_ = scheduler.server.store.UpdateAutomaticTaskRun(context.Background(), run)
|
||||
var output string
|
||||
for attempt := 1; attempt <= task.RetryCount+1; attempt++ {
|
||||
run.Attempts = attempt
|
||||
run.Output = fmt.Sprintf("第 %d 次尝试:正在检查设备和 eSIM Profile", attempt)
|
||||
_ = scheduler.server.store.UpdateAutomaticTaskRun(context.Background(), run)
|
||||
progress := func(message string) {
|
||||
run.Output = fmt.Sprintf("第 %d 次尝试:%s", attempt, message)
|
||||
_ = scheduler.server.store.UpdateAutomaticTaskRun(context.Background(), run)
|
||||
}
|
||||
operationContext, cancel := context.WithTimeout(scheduler.ctx, automaticTaskMaxRuntime)
|
||||
output, err = scheduler.server.executeAutomaticTask(operationContext, task, progress)
|
||||
cancel()
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
var executionError automaticTaskExecutionError
|
||||
if errors.As(err, &executionError) && !executionError.retryable {
|
||||
break
|
||||
}
|
||||
if attempt <= task.RetryCount {
|
||||
// A device error may contain the full AT command, including APN
|
||||
// credentials. The persisted run retains a user-facing outcome; logs
|
||||
// contain only non-sensitive execution metadata.
|
||||
scheduler.server.logger.Warn("automatic task attempt failed", "task_id", task.ID, "device_id", task.DeviceID, "attempt", attempt)
|
||||
select {
|
||||
case <-scheduler.ctx.Done():
|
||||
break
|
||||
case <-time.After(time.Duration(attempt*5) * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
run.FinishedAt = time.Now().UTC()
|
||||
if err == nil {
|
||||
run.Status, run.Output, run.Error = "success", output, ""
|
||||
} else {
|
||||
run.Status, run.Error = "failed", err.Error()
|
||||
}
|
||||
if updateErr := scheduler.server.store.UpdateAutomaticTaskRun(context.Background(), run); updateErr != nil {
|
||||
scheduler.server.logger.Warn("finish automatic task run", "run_id", run.ID, "error", updateErr)
|
||||
}
|
||||
if task.Notify {
|
||||
go scheduler.server.notifyAutomaticTask(context.Background(), task, run)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) executeAutomaticTask(ctx context.Context, task store.AutomaticTask, progress automaticTaskProgress) (output string, err error) {
|
||||
progress("正在检查设备和 eSIM Profile")
|
||||
config, entry, physicalID, err := s.ensureAutomaticTaskProfile(ctx, task, progress)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
iccid := strings.TrimSpace(task.ProfileICCID)
|
||||
policy, policyErr := s.store.CardPolicy(ctx, iccid)
|
||||
if errors.Is(policyErr, store.ErrNotFound) {
|
||||
policy = defaultCardPolicy(iccid)
|
||||
} else if policyErr != nil {
|
||||
return "", fmt.Errorf("read saved card policy: %w", policyErr)
|
||||
}
|
||||
snapshot := automaticTaskEnvironmentSnapshot{config: config, policy: policy}
|
||||
actionCompleted := false
|
||||
defer func() {
|
||||
progress("正在恢复该 Profile 原先保存的卡策略")
|
||||
if restoreErr := s.restoreAutomaticTaskEnvironment(physicalID, snapshot); restoreErr != nil {
|
||||
if err == nil && actionCompleted {
|
||||
output = ""
|
||||
err = automaticTaskExecutionError{err: fmt.Errorf("task completed but card policy restoration failed: %w", restoreErr), retryable: false}
|
||||
} else if err == nil {
|
||||
err = fmt.Errorf("restore card policy: %w", restoreErr)
|
||||
} else {
|
||||
err = fmt.Errorf("%w; card policy restoration also failed: %v", err, restoreErr)
|
||||
}
|
||||
}
|
||||
}()
|
||||
if err := s.prepareAutomaticTaskEnvironment(ctx, &config, entry, physicalID, task, progress); err != nil {
|
||||
return "", err
|
||||
}
|
||||
var payload automaticTaskPayload
|
||||
if err := json.Unmarshal(task.Payload, &payload); err != nil {
|
||||
return "", fmt.Errorf("decode task payload: %w", err)
|
||||
}
|
||||
switch task.TaskType {
|
||||
case "sms":
|
||||
progress("正在发送短信")
|
||||
output, err = s.executeAutomaticSMS(ctx, task, payload)
|
||||
case "call":
|
||||
progress("正在发起通话")
|
||||
output, err = s.executeAutomaticCall(ctx, task, payload)
|
||||
case "public_ip":
|
||||
progress("蜂窝数据已连接,正在查询漫游公网 IP")
|
||||
output, err = s.executeAutomaticPublicIP(ctx, config, task.ProfileICCID)
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported automatic task type %q", task.TaskType)
|
||||
}
|
||||
actionCompleted = err == nil
|
||||
return output, err
|
||||
}
|
||||
|
||||
func (s *Server) ensureAutomaticTaskProfile(ctx context.Context, task store.AutomaticTask, progress automaticTaskProgress) (store.Device, device.Device, string, error) {
|
||||
config, err := s.store.Device(ctx, task.DeviceID)
|
||||
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")
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(entry.Snapshot.ICCID), strings.TrimSpace(task.ProfileICCID)) {
|
||||
return config, entry, physicalID, nil
|
||||
}
|
||||
progress("正在切换到任务指定的 eSIM Profile")
|
||||
if _, err := s.devices.SetFlight(ctx, physicalID, true); err != nil {
|
||||
return store.Device{}, device.Device{}, "", fmt.Errorf("enter airplane mode before profile switch: %w", err)
|
||||
}
|
||||
if err := s.devices.ESIMSwitchProfile(ctx, physicalID, task.ProfileICCID, task.ProfileAID); err != nil {
|
||||
return store.Device{}, device.Device{}, "", fmt.Errorf("switch eSIM profile: %w", err)
|
||||
}
|
||||
entry, physicalID, present = s.physicalForConfig(config)
|
||||
if !present {
|
||||
return store.Device{}, device.Device{}, "", errors.New("device did not recover after profile switch")
|
||||
}
|
||||
snapshot, err := s.devices.Refresh(ctx, physicalID)
|
||||
if err != nil {
|
||||
return store.Device{}, device.Device{}, "", fmt.Errorf("verify switched profile: %w", err)
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(snapshot.ICCID), strings.TrimSpace(task.ProfileICCID)) {
|
||||
return store.Device{}, device.Device{}, "", fmt.Errorf("profile verification failed: current ICCID is %s", firstNonEmpty(snapshot.ICCID, "unavailable"))
|
||||
}
|
||||
entry.Snapshot = &snapshot
|
||||
return config, entry, physicalID, nil
|
||||
}
|
||||
|
||||
func (s *Server) prepareAutomaticTaskEnvironment(ctx context.Context, config *store.Device, entry device.Device, physicalID string, task store.AutomaticTask, progress automaticTaskProgress) error {
|
||||
iccid := strings.TrimSpace(task.ProfileICCID)
|
||||
if task.Environment == "vowifi" {
|
||||
progress("正在准备 VoWiFi 执行环境")
|
||||
if task.TaskType == "public_ip" {
|
||||
return errors.New("public IP tasks cannot run over VoWiFi")
|
||||
}
|
||||
if _, err := s.devices.SetFlight(ctx, physicalID, true); err != nil {
|
||||
return fmt.Errorf("enable airplane mode for VoWiFi: %w", err)
|
||||
}
|
||||
config.VoWiFiEnabled, config.NetworkEnabled = true, false
|
||||
if err := s.store.UpsertDevice(ctx, *config); err != nil {
|
||||
return err
|
||||
}
|
||||
policy, policyErr := s.store.CardPolicy(ctx, iccid)
|
||||
if errors.Is(policyErr, store.ErrNotFound) {
|
||||
policy = defaultCardPolicy(iccid)
|
||||
policyErr = nil
|
||||
}
|
||||
if policyErr != nil {
|
||||
return policyErr
|
||||
}
|
||||
policy.NetworkEnabled = false
|
||||
policy.VoWiFiEnabled = true
|
||||
policy.AirplaneEnabled = true
|
||||
policy.Source = "automatic_task"
|
||||
if err := s.store.UpsertCardPolicy(ctx, policy); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.vowifi == nil {
|
||||
return errors.New("VoWiFi runtime is unavailable")
|
||||
}
|
||||
state, stateErr := s.vowifi.State(config.ID)
|
||||
stateMatchesCard := state.ICCID == "" || strings.EqualFold(strings.TrimSpace(state.ICCID), iccid)
|
||||
if stateErr == nil && stateMatchesCard && state.IMSReady && (task.TaskType != "sms" || state.SMSReady) {
|
||||
return nil
|
||||
}
|
||||
if stateErr == nil && state.Enabled {
|
||||
_, stateErr = s.vowifi.RequestReconnect(config.ID)
|
||||
} else {
|
||||
_, stateErr = s.vowifi.RequestEnabled(config.ID, true)
|
||||
}
|
||||
if stateErr != nil {
|
||||
return fmt.Errorf("start VoWiFi: %w", stateErr)
|
||||
}
|
||||
return s.waitAutomaticVoWiFi(ctx, config.ID, iccid, task.TaskType == "sms")
|
||||
}
|
||||
if s.vowifi != nil {
|
||||
if state, stateErr := s.vowifi.State(config.ID); stateErr == nil && (state.Enabled || state.Active) {
|
||||
if _, stateErr = s.vowifi.RequestEnabled(config.ID, false); stateErr != nil {
|
||||
return fmt.Errorf("stop VoWiFi: %w", stateErr)
|
||||
}
|
||||
if err := s.waitAutomaticVoWiFiStopped(ctx, config.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
progress("正在开启蜂窝无线并启用自动选网")
|
||||
config.VoWiFiEnabled = false
|
||||
config.NetworkEnabled = task.TaskType == "public_ip"
|
||||
if err := s.store.UpsertDevice(ctx, *config); err != nil {
|
||||
return err
|
||||
}
|
||||
policy, policyErr := s.store.CardPolicy(ctx, iccid)
|
||||
if errors.Is(policyErr, store.ErrNotFound) {
|
||||
policy = defaultCardPolicy(iccid)
|
||||
policy.APN = config.APN
|
||||
} else if policyErr != nil {
|
||||
return policyErr
|
||||
}
|
||||
policy.NetworkEnabled = config.NetworkEnabled
|
||||
policy.VoWiFiEnabled = false
|
||||
policy.AirplaneEnabled = false
|
||||
policy.Source = "automatic_task"
|
||||
if err := s.store.UpsertCardPolicy(ctx, policy); err != nil {
|
||||
return err
|
||||
}
|
||||
if task.TaskType != "public_ip" {
|
||||
if _, err := s.devices.SetNetwork(ctx, physicalID, s.cardNetworkRequest(ctx, physicalID, *config, policy, false)); err != nil {
|
||||
s.logger.Warn("automatic task could not stop unused cellular data", "device_id", config.ID)
|
||||
}
|
||||
}
|
||||
if _, err := s.devices.SetFlight(ctx, physicalID, false); err != nil {
|
||||
return fmt.Errorf("enable cellular radio: %w", err)
|
||||
}
|
||||
if _, err := s.devices.SetOperatorSelection(ctx, physicalID, true, "", nil); err != nil {
|
||||
return fmt.Errorf("enable automatic network selection: %w", err)
|
||||
}
|
||||
if _, err := s.devices.ReRegisterOperator(ctx, physicalID); err != nil {
|
||||
return fmt.Errorf("re-register cellular network: %w", err)
|
||||
}
|
||||
progress("正在搜索并注册蜂窝网络(漫游注册可能需要数分钟)")
|
||||
if err := s.waitAutomaticCellular(ctx, physicalID, task.TaskType == "public_ip"); err != nil {
|
||||
return err
|
||||
}
|
||||
if task.TaskType == "public_ip" {
|
||||
if !s.developerActive(ctx) {
|
||||
return errors.New("roaming public IP tasks require developer mode")
|
||||
}
|
||||
progress("已注册蜂窝网络,正在建立数据连接")
|
||||
if _, err := s.devices.SetNetwork(ctx, physicalID, s.cardNetworkRequest(ctx, physicalID, *config, policy, true)); err != nil {
|
||||
return fmt.Errorf("start roaming data: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) waitAutomaticVoWiFi(ctx context.Context, deviceID, iccid string, requireSMS bool) error {
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
state, err := s.vowifi.State(deviceID)
|
||||
if err == nil && state.IMSReady && (!requireSMS || state.SMSReady) && (state.ICCID == "" || strings.EqualFold(state.ICCID, iccid)) {
|
||||
return nil
|
||||
}
|
||||
if err == nil && state.LastError != "" && !state.Active && !state.Enabled {
|
||||
return errors.New(state.LastError)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if err == nil && state.LastError != "" {
|
||||
return fmt.Errorf("wait for VoWiFi readiness: %s", state.LastError)
|
||||
}
|
||||
return fmt.Errorf("wait for VoWiFi readiness: %w", ctx.Err())
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) waitAutomaticVoWiFiStopped(ctx context.Context, deviceID string) error {
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
state, err := s.vowifi.State(deviceID)
|
||||
if err != nil || (!state.Active && !state.Enabled) {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("wait for VoWiFi shutdown: %w", ctx.Err())
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) waitAutomaticCellular(ctx context.Context, physicalID string, requirePacketAttach bool) error {
|
||||
ticker := time.NewTicker(3 * time.Second)
|
||||
defer ticker.Stop()
|
||||
stableSamples := 0
|
||||
for {
|
||||
snapshot, err := s.devices.Refresh(ctx, physicalID)
|
||||
registered := err == nil && (snapshot.RegistrationStatus == 1 || snapshot.RegistrationStatus == 5)
|
||||
if registered && (!requirePacketAttach || snapshot.PSAttached) {
|
||||
stableSamples++
|
||||
if stableSamples >= 2 {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
stableSamples = 0
|
||||
}
|
||||
if err == nil && snapshot.RegistrationStatus == 3 {
|
||||
return errors.New("cellular network registration was denied")
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("wait for cellular registration: %w", ctx.Err())
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) executeAutomaticSMS(ctx context.Context, task store.AutomaticTask, payload automaticTaskPayload) (string, error) {
|
||||
body, _ := json.Marshal(map[string]any{"device_id": task.DeviceID, "phone": payload.Phone, "message": payload.Message})
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequestWithContext(ctx, http.MethodPost, "/api/sms/send", bytes.NewReader(body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
s.handleSMSSend(recorder, request)
|
||||
if recorder.Code < 200 || recorder.Code >= 300 {
|
||||
failure := fmt.Errorf("send SMS failed (HTTP %d): %s", recorder.Code, compactAutomaticResponse(recorder.Body.Bytes()))
|
||||
// Once any part reached the modem/IMS transaction, retrying the whole
|
||||
// message could deliver a duplicate. Preparation failures remain safe to
|
||||
// retry according to the configured count.
|
||||
return "", automaticTaskExecutionError{err: failure, retryable: automaticSMSRetrySafe(recorder.Body.Bytes())}
|
||||
}
|
||||
return "短信已提交到 " + payload.Phone, nil
|
||||
}
|
||||
|
||||
func automaticSMSRetrySafe(body []byte) bool {
|
||||
var payload struct {
|
||||
Data struct {
|
||||
PartsAttempted int `json:"parts_attempted"`
|
||||
PartsAccepted int `json:"parts_accepted"`
|
||||
RetrySafe *bool `json:"retry_safe"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if json.Unmarshal(body, &payload) != nil {
|
||||
return false
|
||||
}
|
||||
if payload.Data.RetrySafe != nil {
|
||||
return *payload.Data.RetrySafe
|
||||
}
|
||||
return payload.Data.PartsAttempted == 0 && payload.Data.PartsAccepted == 0
|
||||
}
|
||||
|
||||
func (s *Server) executeAutomaticCall(ctx context.Context, task store.AutomaticTask, payload automaticTaskPayload) (string, error) {
|
||||
config, err := s.store.Device(ctx, task.DeviceID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, physicalID, present := s.physicalForConfig(config)
|
||||
if !present {
|
||||
return "", errors.New("configured device is offline")
|
||||
}
|
||||
body, _ := json.Marshal(map[string]any{"number": payload.Phone, "duration_seconds": payload.DurationSeconds})
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequestWithContext(ctx, http.MethodPost, "/api/devices/calls/dial", bytes.NewReader(body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
s.handleCallAction(recorder, request, config, physicalID, "dial")
|
||||
if recorder.Code < 200 || recorder.Code >= 300 {
|
||||
return "", fmt.Errorf("dial failed (HTTP %d): %s", recorder.Code, compactAutomaticResponse(recorder.Body.Bytes()))
|
||||
}
|
||||
return fmt.Sprintf("已拨打 %s,将在 %d 秒后自动挂断", payload.Phone, payload.DurationSeconds), nil
|
||||
}
|
||||
|
||||
func (s *Server) executeAutomaticPublicIP(ctx context.Context, config store.Device, iccid string) (string, error) {
|
||||
if strings.TrimSpace(config.Interface) == "" {
|
||||
return "", errors.New("device has no cellular network interface")
|
||||
}
|
||||
info, err := exportproxy.LookupPublicIP(ctx, config.Interface)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("detect roaming public IP: %w", err)
|
||||
}
|
||||
s.savePublicIP(config.ID, iccid, info)
|
||||
return strings.TrimSpace(fmt.Sprintf("公网 IP %s · %s %s", info.IP, info.CountryCode, info.Region)), nil
|
||||
}
|
||||
|
||||
func (s *Server) restoreAutomaticTaskEnvironment(physicalID string, snapshot automaticTaskEnvironmentSnapshot) error {
|
||||
cleanupContext, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
config, policy := snapshot.config, snapshot.policy
|
||||
desiredNetwork := policy.NetworkEnabled && !policy.VoWiFiEnabled && !policy.AirplaneEnabled
|
||||
config.APN = policy.APN
|
||||
config.NetworkEnabled = desiredNetwork
|
||||
config.VoWiFiEnabled = policy.VoWiFiEnabled
|
||||
var restoreErrors []error
|
||||
if err := s.store.UpsertCardPolicy(cleanupContext, policy); err != nil {
|
||||
restoreErrors = append(restoreErrors, fmt.Errorf("persist card policy: %w", err))
|
||||
}
|
||||
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 {
|
||||
restoreErrors = append(restoreErrors, fmt.Errorf("stop cellular data: %w", err))
|
||||
}
|
||||
if _, err := s.devices.SetFlight(cleanupContext, physicalID, true); err != nil {
|
||||
restoreErrors = append(restoreErrors, fmt.Errorf("restore airplane mode: %w", err))
|
||||
}
|
||||
if s.vowifi == nil {
|
||||
restoreErrors = append(restoreErrors, errors.New("VoWiFi runtime is unavailable"))
|
||||
} else if state, stateErr := s.vowifi.State(config.ID); stateErr == nil && state.Enabled {
|
||||
if _, err := s.vowifi.RequestReconnect(config.ID); err != nil {
|
||||
restoreErrors = append(restoreErrors, fmt.Errorf("restore VoWiFi: %w", err))
|
||||
}
|
||||
} else if _, err := s.vowifi.RequestEnabled(config.ID, true); err != nil {
|
||||
restoreErrors = append(restoreErrors, fmt.Errorf("restore VoWiFi: %w", err))
|
||||
}
|
||||
return errors.Join(restoreErrors...)
|
||||
}
|
||||
if s.vowifi != nil {
|
||||
if state, stateErr := s.vowifi.State(config.ID); stateErr == nil && (state.Enabled || state.Active) {
|
||||
if _, err := s.vowifi.RequestEnabled(config.ID, false); err != nil {
|
||||
restoreErrors = append(restoreErrors, fmt.Errorf("stop VoWiFi: %w", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
if policy.AirplaneEnabled {
|
||||
if _, err := s.devices.SetNetwork(cleanupContext, physicalID, s.cardNetworkRequest(cleanupContext, physicalID, config, policy, false)); err != nil {
|
||||
restoreErrors = append(restoreErrors, fmt.Errorf("stop cellular data: %w", err))
|
||||
}
|
||||
if _, err := s.devices.SetFlight(cleanupContext, physicalID, true); err != nil {
|
||||
restoreErrors = append(restoreErrors, fmt.Errorf("restore airplane mode: %w", err))
|
||||
}
|
||||
return errors.Join(restoreErrors...)
|
||||
}
|
||||
if !desiredNetwork {
|
||||
if _, err := s.devices.SetNetwork(cleanupContext, physicalID, s.cardNetworkRequest(cleanupContext, physicalID, config, policy, false)); err != nil {
|
||||
restoreErrors = append(restoreErrors, fmt.Errorf("stop cellular data: %w", err))
|
||||
}
|
||||
}
|
||||
if _, err := s.devices.SetFlight(cleanupContext, physicalID, false); err != nil {
|
||||
restoreErrors = append(restoreErrors, fmt.Errorf("restore cellular radio: %w", err))
|
||||
}
|
||||
if desiredNetwork {
|
||||
if _, err := s.devices.SetNetwork(cleanupContext, physicalID, s.cardNetworkRequest(cleanupContext, physicalID, config, policy, true)); err != nil {
|
||||
restoreErrors = append(restoreErrors, fmt.Errorf("restore cellular data: %w", err))
|
||||
}
|
||||
}
|
||||
return errors.Join(restoreErrors...)
|
||||
}
|
||||
|
||||
func (s *Server) cardNetworkRequest(
|
||||
ctx context.Context,
|
||||
physicalID string,
|
||||
config store.Device,
|
||||
policy store.CardPolicy,
|
||||
enabled bool,
|
||||
) device.NetworkRequest {
|
||||
request := device.NetworkRequest{
|
||||
Enabled: enabled, APN: policy.APN, IPVersion: policy.IPVersion, Backend: config.DeviceBackend,
|
||||
}
|
||||
if request.IPVersion == "" {
|
||||
request.IPVersion = "IPV4V6"
|
||||
}
|
||||
profile, err := s.store.CardAPNProfileByAPN(ctx, policy.ICCID, policy.APN, policy.IPVersion)
|
||||
if err != nil {
|
||||
return request
|
||||
}
|
||||
request.Username = profile.Username
|
||||
request.Password = profile.Password
|
||||
request.Authentication = profile.AuthType
|
||||
if entry, getErr := s.devices.Get(physicalID); getErr == nil && entry.Snapshot != nil &&
|
||||
entry.Snapshot.RegistrationStatus == 5 && profile.RoamingIPVersion != "" {
|
||||
request.IPVersion = profile.RoamingIPVersion
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
func compactAutomaticResponse(body []byte) string {
|
||||
var payload map[string]any
|
||||
if json.Unmarshal(body, &payload) == nil {
|
||||
if apiErr, ok := payload["error"].(map[string]any); ok {
|
||||
return firstNonEmpty(fmt.Sprint(apiErr["message"]), fmt.Sprint(apiErr["code"]), "request failed")
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(string(body))
|
||||
}
|
||||
|
||||
func (s *Server) routeAutomaticTasksAPI(w http.ResponseWriter, r *http.Request, cleanPath string) bool {
|
||||
segments := splitAPIPath(cleanPath)
|
||||
if len(segments) == 0 || segments[0] != "automatic-tasks" {
|
||||
return false
|
||||
}
|
||||
if len(segments) == 1 {
|
||||
s.handleAutomaticTasks(w, r)
|
||||
return true
|
||||
}
|
||||
if len(segments) == 2 && segments[1] == "runs" {
|
||||
s.handleAutomaticTaskRuns(w, r)
|
||||
return true
|
||||
}
|
||||
id, err := strconv.ParseInt(segments[1], 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_task_id", "automatic task ID is invalid")
|
||||
return true
|
||||
}
|
||||
if len(segments) == 2 {
|
||||
s.handleAutomaticTask(w, r, id)
|
||||
return true
|
||||
}
|
||||
if len(segments) == 3 && segments[2] == "run" {
|
||||
s.handleAutomaticTaskRunNow(w, r, id)
|
||||
return true
|
||||
}
|
||||
writeError(w, http.StatusNotFound, "not_found", "automatic task endpoint not found")
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) handleAutomaticTasks(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
tasks, err := s.store.ListAutomaticTasks(r.Context())
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"tasks": tasks}})
|
||||
case http.MethodPost:
|
||||
task, err := s.decodeAutomaticTask(r, 0)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_automatic_task", err.Error())
|
||||
return
|
||||
}
|
||||
saved, err := s.store.SaveAutomaticTask(r.Context(), task)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"data": saved})
|
||||
default:
|
||||
w.Header().Set("Allow", "GET, POST")
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleAutomaticTask(w http.ResponseWriter, r *http.Request, id int64) {
|
||||
switch r.Method {
|
||||
case http.MethodPut:
|
||||
task, err := s.decodeAutomaticTask(r, id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_automatic_task", err.Error())
|
||||
return
|
||||
}
|
||||
saved, err := s.store.SaveAutomaticTask(r.Context(), task)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": saved})
|
||||
case http.MethodDelete:
|
||||
if err := s.store.DeleteAutomaticTask(r.Context(), id); err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"deleted": true}})
|
||||
default:
|
||||
w.Header().Set("Allow", "PUT, DELETE")
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleAutomaticTaskRuns(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireMethod(w, r, http.MethodGet) {
|
||||
return
|
||||
}
|
||||
query := r.URL.Query()
|
||||
limit, _ := strconv.Atoi(query.Get("limit"))
|
||||
offset, _ := strconv.Atoi(query.Get("offset"))
|
||||
runs, total, err := s.store.ListAutomaticTaskRunsPaginated(r.Context(), limit, offset)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"runs": runs, "total": total}})
|
||||
}
|
||||
|
||||
func (s *Server) handleAutomaticTaskRunNow(w http.ResponseWriter, r *http.Request, id int64) {
|
||||
if !requireMethod(w, r, http.MethodPost) {
|
||||
return
|
||||
}
|
||||
if s.automaticTasks == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "scheduler_unavailable", "automatic task scheduler is unavailable")
|
||||
return
|
||||
}
|
||||
task, err := s.store.AutomaticTask(r.Context(), id)
|
||||
if err != nil {
|
||||
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)
|
||||
return
|
||||
}
|
||||
s.automaticTasks.enqueue(run)
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"data": run})
|
||||
}
|
||||
|
||||
func (s *Server) decodeAutomaticTask(r *http.Request, id int64) (store.AutomaticTask, error) {
|
||||
var request struct {
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
DeviceID string `json:"device_id"`
|
||||
ProfileICCID string `json:"profile_iccid"`
|
||||
ProfileAID string `json:"profile_aid"`
|
||||
TaskType string `json:"task_type"`
|
||||
Environment string `json:"environment"`
|
||||
IntervalDays int `json:"interval_days"`
|
||||
StartDate string `json:"start_date"`
|
||||
RunTime string `json:"run_time"`
|
||||
Timezone string `json:"timezone"`
|
||||
RetryCount int `json:"retry_count"`
|
||||
Notify bool `json:"notify"`
|
||||
Payload automaticTaskPayload `json:"payload"`
|
||||
}
|
||||
if err := s.decodeJSON(nilResponseWriter{}, r, &request); err != nil {
|
||||
return store.AutomaticTask{}, err
|
||||
}
|
||||
request.Name, request.DeviceID = strings.TrimSpace(request.Name), strings.TrimSpace(request.DeviceID)
|
||||
request.ProfileICCID, request.ProfileAID = strings.TrimSpace(request.ProfileICCID), strings.TrimSpace(request.ProfileAID)
|
||||
request.TaskType, request.Environment = strings.ToLower(strings.TrimSpace(request.TaskType)), strings.ToLower(strings.TrimSpace(request.Environment))
|
||||
if request.Name == "" || request.DeviceID == "" || request.ProfileICCID == "" {
|
||||
return store.AutomaticTask{}, errors.New("name, device, and eSIM profile are required")
|
||||
}
|
||||
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" {
|
||||
return store.AutomaticTask{}, errors.New("environment must be vowifi or cellular")
|
||||
}
|
||||
if request.TaskType != "sms" && request.TaskType != "call" && request.TaskType != "public_ip" {
|
||||
return store.AutomaticTask{}, errors.New("unsupported task type")
|
||||
}
|
||||
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")
|
||||
}
|
||||
if request.TaskType == "sms" {
|
||||
if !validDialNumber(request.Payload.Phone) || strings.TrimSpace(request.Payload.Message) == "" {
|
||||
return store.AutomaticTask{}, errors.New("SMS phone and message are required")
|
||||
}
|
||||
if blocked, reason := blockedSMSDestination(request.Payload.Phone); blocked {
|
||||
return store.AutomaticTask{}, errors.New(reason)
|
||||
}
|
||||
}
|
||||
if request.TaskType == "call" && (!validDialNumber(request.Payload.Phone) || request.Payload.DurationSeconds < 1 || request.Payload.DurationSeconds > 600) {
|
||||
return store.AutomaticTask{}, errors.New("call phone is required and automatic hang-up must be 1-600 seconds")
|
||||
}
|
||||
request.Timezone = strings.TrimSpace(request.Timezone)
|
||||
if request.Timezone == "" {
|
||||
request.Timezone = time.Local.String()
|
||||
}
|
||||
location, err := time.LoadLocation(request.Timezone)
|
||||
if err != nil {
|
||||
return store.AutomaticTask{}, errors.New("timezone must be a valid IANA time zone")
|
||||
}
|
||||
nextRun, err := nextAutomaticRun(request.StartDate, request.RunTime, request.IntervalDays, time.Now().In(location))
|
||||
if err != nil {
|
||||
return store.AutomaticTask{}, err
|
||||
}
|
||||
payload, _ := json.Marshal(request.Payload)
|
||||
task := store.AutomaticTask{ID: id, Name: request.Name, Enabled: request.Enabled, DeviceID: request.DeviceID,
|
||||
ProfileICCID: request.ProfileICCID, ProfileAID: request.ProfileAID, TaskType: request.TaskType,
|
||||
Environment: request.Environment, IntervalDays: request.IntervalDays, StartDate: request.StartDate,
|
||||
RunTime: request.RunTime, Timezone: request.Timezone, Payload: payload, RetryCount: request.RetryCount, Notify: request.Notify, NextRunAt: nextRun.UTC()}
|
||||
if id != 0 {
|
||||
if previous, previousErr := s.store.AutomaticTask(r.Context(), id); previousErr == nil {
|
||||
task.CreatedAt, task.LastRunAt, task.LastStatus, task.LastError = previous.CreatedAt, previous.LastRunAt, previous.LastStatus, previous.LastError
|
||||
}
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
return time.Time{}, errors.New("start_date and run_time must use YYYY-MM-DD and HH:MM")
|
||||
}
|
||||
for start.Before(now) {
|
||||
start = start.AddDate(0, 0, intervalDays)
|
||||
}
|
||||
return start, nil
|
||||
}
|
||||
|
||||
// nilResponseWriter is used only because decodeJSON's size/error contract is
|
||||
// shared with HTTP handlers; decode errors are returned to the real handler.
|
||||
type nilResponseWriter struct{}
|
||||
|
||||
func (nilResponseWriter) Header() http.Header { return make(http.Header) }
|
||||
func (nilResponseWriter) Write([]byte) (int, error) { return 0, nil }
|
||||
func (nilResponseWriter) WriteHeader(statusCode int) {}
|
||||
@@ -0,0 +1,51 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
func TestNextAutomaticRunUsesIntervalAndLocalClock(t *testing.T) {
|
||||
location := time.FixedZone("test", 8*60*60)
|
||||
now := time.Date(2026, 8, 10, 12, 0, 0, 0, location)
|
||||
next, err := nextAutomaticRun("2026-08-01", "09:30", 3, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := time.Date(2026, 8, 13, 9, 30, 0, 0, location)
|
||||
if !next.Equal(want) {
|
||||
t.Fatalf("next run = %v, want %v", next, want)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
t.Fatal("partially submitted SMS was considered safe to retry")
|
||||
}
|
||||
safe := []byte(`{"data":{"parts_attempted":0,"parts_accepted":0}}`)
|
||||
if !automaticSMSRetrySafe(safe) {
|
||||
t.Fatal("unattempted SMS was not considered safe to retry")
|
||||
}
|
||||
}
|
||||
@@ -7,37 +7,62 @@ import (
|
||||
)
|
||||
|
||||
func (s *Server) handleDeveloperSettings(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.developerEnabled {
|
||||
if !s.developerActive(r.Context()) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "resource not found")
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
|
||||
"device_limit": developer.DeviceLimit(r.Context(), s.store, true),
|
||||
"default_device_limit": developer.DefaultDeviceLimit,
|
||||
"max_device_limit": developer.MaxDeviceLimit,
|
||||
}})
|
||||
s.writeDeveloperSettings(w, r)
|
||||
case http.MethodPut:
|
||||
var request struct {
|
||||
DeviceLimit int `json:"device_limit"`
|
||||
DeviceLimit *int `json:"device_limit"`
|
||||
SMSHourlyLimit *int `json:"sms_hourly_limit"`
|
||||
}
|
||||
if err := s.decodeJSON(w, r, &request); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
if err := developer.SetDeviceLimit(r.Context(), s.store, request.DeviceLimit); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_device_limit", err.Error())
|
||||
if request.DeviceLimit == nil && request.SMSHourlyLimit == nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "at least one developer setting is required")
|
||||
return
|
||||
}
|
||||
s.recordAudit(r.Context(), "admin", "settings.developer.device_limit", "settings", "developer", "success", "device limit updated")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
|
||||
"device_limit": request.DeviceLimit,
|
||||
"default_device_limit": developer.DefaultDeviceLimit,
|
||||
"max_device_limit": developer.MaxDeviceLimit,
|
||||
}})
|
||||
if request.DeviceLimit != nil && (*request.DeviceLimit < 1 || *request.DeviceLimit > developer.MaxDeviceLimit) {
|
||||
writeError(w, http.StatusBadRequest, "invalid_device_limit", "device limit is outside the supported range")
|
||||
return
|
||||
}
|
||||
if request.SMSHourlyLimit != nil && (*request.SMSHourlyLimit < 1 || *request.SMSHourlyLimit > developer.MaxSMSHourlyLimit) {
|
||||
writeError(w, http.StatusBadRequest, "invalid_sms_hourly_limit", "SMS hourly limit is outside the supported range")
|
||||
return
|
||||
}
|
||||
if request.DeviceLimit != nil {
|
||||
if err := developer.SetDeviceLimit(r.Context(), s.store, *request.DeviceLimit); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_device_limit", err.Error())
|
||||
return
|
||||
}
|
||||
s.recordAudit(r.Context(), "admin", "settings.developer.device_limit", "settings", "developer", "success", "device limit updated")
|
||||
}
|
||||
if request.SMSHourlyLimit != nil {
|
||||
if err := developer.SetSMSHourlyLimit(r.Context(), s.store, *request.SMSHourlyLimit); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_sms_hourly_limit", err.Error())
|
||||
return
|
||||
}
|
||||
s.recordAudit(r.Context(), "admin", "settings.developer.sms_hourly_limit", "settings", "developer", "success", "global SMS hourly limit updated")
|
||||
}
|
||||
s.writeDeveloperSettings(w, r)
|
||||
default:
|
||||
w.Header().Set("Allow", "GET, PUT")
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) writeDeveloperSettings(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
|
||||
"device_limit": developer.DeviceLimit(r.Context(), s.store, true),
|
||||
"default_device_limit": developer.DefaultDeviceLimit,
|
||||
"max_device_limit": developer.MaxDeviceLimit,
|
||||
"sms_hourly_limit": developer.SMSHourlyLimit(r.Context(), s.store),
|
||||
"default_sms_hourly_limit": developer.DefaultSMSHourlyLimit,
|
||||
"max_sms_hourly_limit": developer.MaxSMSHourlyLimit,
|
||||
}})
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"vocat/internal/developer"
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
func TestDeveloperOnlySettingsAreHiddenWhenModeIsOff(t *testing.T) {
|
||||
@@ -20,3 +26,27 @@ func TestDeveloperOnlySettingsAreHiddenWhenModeIsOff(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeveloperSettingsUpdatesGlobalSMSLimit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database, err := store.Open(ctx, ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
enabled, _ := json.Marshal(map[string]bool{"enabled": true})
|
||||
if err := database.UpsertAppSetting(ctx, store.AppSetting{Key: developer.EnabledSettingKey, Value: enabled}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := &Server{store: database, developerEnabled: true, logger: regionTestLogger(), maxRequestBodyBytes: 4096}
|
||||
request := httptest.NewRequest(http.MethodPut, "/api/settings/developer", strings.NewReader(`{"sms_hourly_limit":25}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
server.handleDeveloperSettings(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
if got := developer.SMSHourlyLimit(ctx, database); got != 25 {
|
||||
t.Fatalf("SMS hourly limit = %d, want 25", got)
|
||||
}
|
||||
}
|
||||
|
||||
+399
-39
@@ -2,6 +2,7 @@ package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -65,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"`
|
||||
@@ -96,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,
|
||||
@@ -236,14 +239,56 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) bool {
|
||||
return true
|
||||
}
|
||||
config := payload.toStoreDevice()
|
||||
// Newly added hardware starts fail-closed: RF is disabled immediately and
|
||||
// VoWiFi becomes the desired service. Cellular registration is only
|
||||
// restored by the user's later airplane-mode-off action.
|
||||
config.VoWiFiEnabled = true
|
||||
config.NetworkEnabled = false
|
||||
if !s.developerActive(r.Context()) {
|
||||
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)
|
||||
return true
|
||||
}
|
||||
}
|
||||
if _, err := s.devices.SetFlight(r.Context(), selected.ID, true); err != nil {
|
||||
s.writeDeviceError(w, err)
|
||||
return true
|
||||
}
|
||||
if err := s.store.UpsertDevice(r.Context(), config); err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return true
|
||||
}
|
||||
if selected.Snapshot != nil {
|
||||
iccid := strings.TrimSpace(selected.Snapshot.ICCID)
|
||||
if iccid != "" {
|
||||
_, policyErr := s.store.CardPolicy(r.Context(), iccid)
|
||||
if errors.Is(policyErr, store.ErrNotFound) {
|
||||
policyErr = s.store.UpsertCardPolicy(r.Context(), store.CardPolicy{
|
||||
ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true,
|
||||
IPVersion: "IPV4V6", Source: "default",
|
||||
})
|
||||
}
|
||||
if policyErr != nil {
|
||||
s.writeStoreError(w, policyErr)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.vowifi != nil {
|
||||
if _, err := s.vowifi.RequestEnabled(config.ID, true); err != nil {
|
||||
s.logger.Warn("new device saved in safe airplane mode but VoWiFi start was not queued", "device_id", config.ID, "error", err)
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"data": map[string]any{
|
||||
"status": "created",
|
||||
@@ -326,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,
|
||||
@@ -337,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}})
|
||||
@@ -416,9 +463,29 @@ func (s *Server) handleDevicePath(
|
||||
}
|
||||
next.ID = id
|
||||
next.CreatedAt = config.CreatedAt
|
||||
// VoWiFi/airplane transitions are transactional device actions. A
|
||||
// general config save must not silently bypass their RF-safe ordering.
|
||||
next.VoWiFiEnabled = config.VoWiFiEnabled
|
||||
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)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := s.store.UpsertDevice(r.Context(), next); err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return true
|
||||
@@ -434,8 +501,18 @@ 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)
|
||||
return s.handleESIM(w, r, tail[1:], physicalID, physicalPresent, config.ID)
|
||||
}
|
||||
switch strings.Join(tail, "/") {
|
||||
case "overview":
|
||||
@@ -503,12 +580,17 @@ func (s *Server) handleDevicePath(
|
||||
if !s.requirePhysicalDevice(w, physicalPresent) {
|
||||
return true
|
||||
}
|
||||
return s.handleFlightMode(w, r, physicalID)
|
||||
return s.handleFlightMode(w, r, config, physicalID)
|
||||
case "network":
|
||||
if !s.requirePhysicalDevice(w, physicalPresent) {
|
||||
return true
|
||||
}
|
||||
return s.handleCellularData(w, r, config, physicalID)
|
||||
case "network/apns":
|
||||
if !s.requirePhysicalDevice(w, physicalPresent) {
|
||||
return true
|
||||
}
|
||||
return s.handleAPNProfiles(w, r, physicalID)
|
||||
case "network/public-ip":
|
||||
if !s.requirePhysicalDevice(w, physicalPresent) {
|
||||
return true
|
||||
@@ -756,9 +838,59 @@ func (s *Server) handleVoWiFiEnabled(
|
||||
}
|
||||
}
|
||||
|
||||
// Establish RF-off synchronously before changing the asynchronous VoWiFi
|
||||
// lifecycle. This removes the attach window both when entering VoWiFi and
|
||||
// when leaving it: teardown starts from CFUN=4 and is required to remain
|
||||
// there until the user explicitly disables airplane mode.
|
||||
previous := config.VoWiFiEnabled
|
||||
liveICCID := ""
|
||||
entry, physicalID, present := s.physicalForConfig(config)
|
||||
if present {
|
||||
if _, err := s.devices.SetFlight(r.Context(), physicalID, true); err != nil {
|
||||
s.writeDeviceError(w, err)
|
||||
return true
|
||||
}
|
||||
}
|
||||
if entry.Snapshot != nil {
|
||||
iccid := strings.TrimSpace(entry.Snapshot.ICCID)
|
||||
if iccid != "" {
|
||||
liveICCID = iccid
|
||||
policy, policyErr := s.store.CardPolicy(r.Context(), iccid)
|
||||
if errors.Is(policyErr, store.ErrNotFound) {
|
||||
policy = store.CardPolicy{ICCID: iccid, IPVersion: "IPV4V6"}
|
||||
policyErr = nil
|
||||
}
|
||||
if policyErr != nil {
|
||||
s.writeStoreError(w, policyErr)
|
||||
return true
|
||||
}
|
||||
policy.VoWiFiEnabled = request.Enabled
|
||||
policy.AirplaneEnabled = true
|
||||
policy.NetworkEnabled = false
|
||||
policy.Source = "manual"
|
||||
if err := s.store.UpsertCardPolicy(r.Context(), policy); err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rollbackCardPolicy := func() {
|
||||
if liveICCID == "" {
|
||||
return
|
||||
}
|
||||
policy, policyErr := s.store.CardPolicy(context.Background(), liveICCID)
|
||||
if policyErr != nil {
|
||||
return
|
||||
}
|
||||
policy.VoWiFiEnabled = previous
|
||||
policy.AirplaneEnabled = true
|
||||
policy.NetworkEnabled = false
|
||||
_ = s.store.UpsertCardPolicy(context.Background(), policy)
|
||||
}
|
||||
config.VoWiFiEnabled = request.Enabled
|
||||
if err := s.store.UpsertDevice(r.Context(), config); err != nil {
|
||||
rollbackCardPolicy()
|
||||
s.writeStoreError(w, err)
|
||||
return true
|
||||
}
|
||||
@@ -780,6 +912,7 @@ func (s *Server) handleVoWiFiEnabled(
|
||||
return true
|
||||
}
|
||||
config.VoWiFiEnabled = previous
|
||||
rollbackCardPolicy()
|
||||
if restoreErr := s.store.UpsertDevice(r.Context(), config); restoreErr != nil {
|
||||
s.logger.Error(
|
||||
"restore VoWiFi policy after rejected runtime operation",
|
||||
@@ -983,7 +1116,7 @@ func (s *Server) handleUSSD(w http.ResponseWriter, r *http.Request, id string) b
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) handleFlightMode(w http.ResponseWriter, r *http.Request, id string) bool {
|
||||
func (s *Server) handleFlightMode(w http.ResponseWriter, r *http.Request, config store.Device, physicalID string) bool {
|
||||
if !requireMethod(w, r, http.MethodPatch) {
|
||||
return true
|
||||
}
|
||||
@@ -994,7 +1127,11 @@ func (s *Server) handleFlightMode(w http.ResponseWriter, r *http.Request, id str
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return true
|
||||
}
|
||||
result, err := s.devices.SetFlight(r.Context(), id, request.Enabled)
|
||||
if config.VoWiFiEnabled {
|
||||
writeError(w, http.StatusConflict, "vowifi_owns_airplane_mode", "airplane mode is locked on while VoWiFi is enabled")
|
||||
return true
|
||||
}
|
||||
result, err := s.devices.SetFlight(r.Context(), physicalID, request.Enabled)
|
||||
if err != nil {
|
||||
s.writeDeviceError(w, err)
|
||||
return true
|
||||
@@ -1002,7 +1139,7 @@ func (s *Server) handleFlightMode(w http.ResponseWriter, r *http.Request, id str
|
||||
// Unlike VoWiFi, CFUN airplane state is not represented in the device row.
|
||||
// Persist it against the live ICCID so a restart can distinguish an
|
||||
// intentional airplane policy from an interrupted VoWiFi teardown.
|
||||
if entry, getErr := s.devices.Get(id); getErr == nil && entry.Snapshot != nil {
|
||||
if entry, getErr := s.devices.Get(physicalID); getErr == nil && entry.Snapshot != nil {
|
||||
iccid := strings.TrimSpace(entry.Snapshot.ICCID)
|
||||
if iccid != "" {
|
||||
policy, policyErr := s.store.CardPolicy(r.Context(), iccid)
|
||||
@@ -1029,6 +1166,65 @@ func (s *Server) handleFlightMode(w http.ResponseWriter, r *http.Request, id str
|
||||
return true
|
||||
}
|
||||
|
||||
type modemAPNProfile struct {
|
||||
CID int `json:"cid"`
|
||||
APN string `json:"apn"`
|
||||
IPVersion string `json:"ip_version"`
|
||||
}
|
||||
|
||||
func parseModemAPNProfiles(lines []string) []modemAPNProfile {
|
||||
profiles := make([]modemAPNProfile, 0)
|
||||
seen := make(map[string]bool)
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
prefix := strings.Index(strings.ToUpper(line), "+CGDCONT:")
|
||||
if prefix < 0 {
|
||||
continue
|
||||
}
|
||||
record, err := csv.NewReader(strings.NewReader(strings.TrimSpace(line[prefix+len("+CGDCONT:"):]))).Read()
|
||||
if err != nil || len(record) < 3 {
|
||||
continue
|
||||
}
|
||||
cid, err := strconv.Atoi(strings.TrimSpace(record[0]))
|
||||
if err != nil || cid < 1 {
|
||||
continue
|
||||
}
|
||||
ipVersion := strings.ToUpper(strings.TrimSpace(record[1]))
|
||||
if ipVersion == "IPV4" {
|
||||
ipVersion = "IP"
|
||||
}
|
||||
if ipVersion != "IP" && ipVersion != "IPV6" && ipVersion != "IPV4V6" {
|
||||
continue
|
||||
}
|
||||
apn := strings.TrimSpace(record[2])
|
||||
if apn == "" || !device.ValidAPN(apn) {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(apn) + "\x00" + ipVersion
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
profiles = append(profiles, modemAPNProfile{CID: cid, APN: apn, IPVersion: ipVersion})
|
||||
}
|
||||
return profiles
|
||||
}
|
||||
|
||||
func (s *Server) handleAPNProfiles(w http.ResponseWriter, r *http.Request, physicalID string) bool {
|
||||
if !requireMethod(w, r, http.MethodGet) {
|
||||
return true
|
||||
}
|
||||
response, err := s.devices.ExecuteAT(r.Context(), physicalID, "AT+CGDCONT?")
|
||||
if err != nil {
|
||||
s.writeDeviceError(w, err)
|
||||
return true
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
|
||||
"items": parseModemAPNProfiles(response.Lines),
|
||||
}})
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) handleCellularData(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
@@ -1067,32 +1263,75 @@ func (s *Server) handleCellularData(
|
||||
}
|
||||
}
|
||||
apn := strings.TrimSpace(request.APN)
|
||||
if apn == "" {
|
||||
policyIPVersion := "IPV4V6"
|
||||
activeICCID := ""
|
||||
isRoaming := false
|
||||
var activePolicy store.CardPolicy
|
||||
var activeAPNProfile store.CardAPNProfile
|
||||
if entry, getErr := s.devices.Get(physicalID); getErr == nil && entry.Snapshot != nil {
|
||||
activeICCID = strings.TrimSpace(entry.Snapshot.ICCID)
|
||||
isRoaming = entry.Snapshot.RegistrationStatus == 5
|
||||
if stored, policyErr := s.store.CardPolicy(r.Context(), activeICCID); policyErr == nil {
|
||||
activePolicy = stored
|
||||
if apn == "" {
|
||||
apn = strings.TrimSpace(stored.APN)
|
||||
}
|
||||
if stored.IPVersion != "" {
|
||||
policyIPVersion = stored.IPVersion
|
||||
}
|
||||
}
|
||||
}
|
||||
if apn == "" && activePolicy.ICCID == "" {
|
||||
apn = strings.TrimSpace(config.APN)
|
||||
}
|
||||
if !device.ValidAPN(apn) {
|
||||
writeError(w, http.StatusBadRequest, "invalid_apn", "APN must contain only letters, digits, dots, underscores, or hyphens")
|
||||
return true
|
||||
}
|
||||
if profile, profileErr := s.store.CardAPNProfileByAPN(r.Context(), activeICCID, apn, policyIPVersion); profileErr == nil {
|
||||
activeAPNProfile = profile
|
||||
}
|
||||
effectiveIPVersion := policyIPVersion
|
||||
if isRoaming && activeAPNProfile.RoamingIPVersion != "" {
|
||||
effectiveIPVersion = activeAPNProfile.RoamingIPVersion
|
||||
}
|
||||
networkRequest := device.NetworkRequest{
|
||||
Enabled: request.Enabled, APN: apn, IPVersion: effectiveIPVersion,
|
||||
Username: activeAPNProfile.Username, Password: activeAPNProfile.Password,
|
||||
Authentication: activeAPNProfile.AuthType, Backend: config.DeviceBackend,
|
||||
}
|
||||
controller := http.NewResponseController(w)
|
||||
_ = controller.SetWriteDeadline(time.Time{})
|
||||
result, err := s.devices.SetNetwork(r.Context(), physicalID, device.NetworkRequest{
|
||||
Enabled: request.Enabled, APN: apn, IPVersion: "IPV4V6",
|
||||
})
|
||||
result, err := s.devices.SetNetwork(r.Context(), physicalID, networkRequest)
|
||||
if err != nil {
|
||||
s.writeDeviceError(w, err)
|
||||
return true
|
||||
}
|
||||
previous := config.NetworkEnabled
|
||||
config.NetworkEnabled = request.Enabled
|
||||
if apn != "" {
|
||||
config.APN = apn
|
||||
}
|
||||
config.APN = apn
|
||||
if err := s.store.UpsertDevice(r.Context(), config); err != nil {
|
||||
rollbackContext, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
_, _ = s.devices.SetNetwork(rollbackContext, physicalID, device.NetworkRequest{
|
||||
Enabled: previous, APN: config.APN, IPVersion: "IPV4V6",
|
||||
})
|
||||
networkRequest.Enabled = previous
|
||||
networkRequest.APN = config.APN
|
||||
_, _ = s.devices.SetNetwork(rollbackContext, physicalID, networkRequest)
|
||||
cancel()
|
||||
s.writeStoreError(w, err)
|
||||
return true
|
||||
}
|
||||
if validICCID(activeICCID) {
|
||||
if activePolicy.ICCID == "" {
|
||||
activePolicy = defaultCardPolicy(activeICCID)
|
||||
}
|
||||
activePolicy.APN = apn
|
||||
activePolicy.IPVersion = policyIPVersion
|
||||
if strings.TrimSpace(request.APN) != "" {
|
||||
activePolicy.Source = "manual"
|
||||
}
|
||||
if err := s.store.UpsertCardPolicy(r.Context(), activePolicy); err != nil {
|
||||
s.logger.Warn("cellular APN active but card policy could not be updated", "device_id", config.ID, "iccid", activeICCID, "error", err)
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
|
||||
"enabled": result.Enabled, "interface": result.Interface,
|
||||
"backend": result.Backend, "export_proxy_only": true,
|
||||
@@ -1155,8 +1394,10 @@ func (s *Server) writeDeviceError(w http.ResponseWriter, err error) {
|
||||
case errors.Is(err, context.Canceled):
|
||||
writeError(w, http.StatusRequestTimeout, "request_canceled", "the modem request was canceled")
|
||||
default:
|
||||
s.logger.Warn("device operation failed", "error", err)
|
||||
writeError(w, http.StatusBadGateway, "modem_error", err.Error())
|
||||
// Device errors may echo an AT command. Authentication commands can
|
||||
// contain APN credentials, so keep raw errors out of logs and responses.
|
||||
s.logger.Warn("device operation failed")
|
||||
writeError(w, http.StatusBadGateway, "modem_error", "the device operation failed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1266,28 +1507,46 @@ func (s *Server) configuredDeviceSummary(
|
||||
result["network_connected"] = config.NetworkEnabled
|
||||
result["data_connected"] = config.NetworkEnabled
|
||||
result["vowifi_enabled"] = config.VoWiFiEnabled
|
||||
if runtime, err := s.store.VoWiFiRuntime(context.Background(), config.ID); err == nil {
|
||||
currentICCID := ""
|
||||
var currentSnapshot *device.Snapshot
|
||||
if entry != nil {
|
||||
currentSnapshot = entry.Snapshot
|
||||
if entry.Snapshot != nil {
|
||||
currentICCID = strings.TrimSpace(entry.Snapshot.ICCID)
|
||||
var runtimeResponse map[string]any
|
||||
runtimeMatchesCard := true
|
||||
if s.vowifi != nil {
|
||||
if runtime, err := s.vowifi.State(config.ID); err == nil {
|
||||
runtimeMatchesCard = voWiFiRuntimeMatchesSnapshot(runtime.ICCID, entry)
|
||||
if runtimeMatchesCard {
|
||||
runtimeResponse = liveVoWiFiRuntime(runtime)
|
||||
} else {
|
||||
runtimeResponse = idleVoWiFiRuntime(config.ID, snapshotForEntry(entry))
|
||||
}
|
||||
}
|
||||
runtimeMatchesCard := currentICCID == "" || runtime.ICCID == "" ||
|
||||
strings.EqualFold(currentICCID, strings.TrimSpace(runtime.ICCID))
|
||||
var runtimeResponse map[string]any
|
||||
if runtimeMatchesCard {
|
||||
runtimeResponse = storedVoWiFiRuntime(runtime)
|
||||
} else {
|
||||
// The saved IMS session belongs to a different eSIM profile. Never
|
||||
// project its registration or number onto the currently selected SIM.
|
||||
runtimeResponse = idleVoWiFiRuntime(config.ID, currentSnapshot)
|
||||
}
|
||||
result["vowifi_runtime"] = runtimeResponse
|
||||
result["vowifi_active"] = config.VoWiFiEnabled && runtimeMatchesCard && runtime.TunnelReady
|
||||
}
|
||||
if runtimeResponse == nil {
|
||||
if runtime, err := s.store.VoWiFiRuntime(context.Background(), config.ID); err == nil {
|
||||
currentICCID := ""
|
||||
var currentSnapshot *device.Snapshot
|
||||
if entry != nil {
|
||||
currentSnapshot = entry.Snapshot
|
||||
if entry.Snapshot != nil {
|
||||
currentICCID = strings.TrimSpace(entry.Snapshot.ICCID)
|
||||
}
|
||||
}
|
||||
runtimeMatchesCard = currentICCID == "" || runtime.ICCID == "" ||
|
||||
strings.EqualFold(currentICCID, strings.TrimSpace(runtime.ICCID))
|
||||
if runtimeMatchesCard {
|
||||
runtimeResponse = storedVoWiFiRuntime(runtime)
|
||||
} else {
|
||||
// The saved IMS session belongs to a different eSIM profile. Never
|
||||
// project its registration or number onto the currently selected SIM.
|
||||
runtimeResponse = idleVoWiFiRuntime(config.ID, currentSnapshot)
|
||||
}
|
||||
}
|
||||
}
|
||||
if runtimeResponse == nil {
|
||||
runtimeResponse = idleVoWiFiRuntime(config.ID, snapshotForEntry(entry))
|
||||
}
|
||||
result["vowifi_runtime"] = runtimeResponse
|
||||
runtimeEnabled, _ := runtimeResponse["enabled"].(bool)
|
||||
runtimeTunnelReady, _ := runtimeResponse["tunnel_ready"].(bool)
|
||||
result["vowifi_active"] = config.VoWiFiEnabled && runtimeMatchesCard && runtimeEnabled && runtimeTunnelReady
|
||||
// Numbers are SIM-owned data. Resolve the association by the live ICCID
|
||||
// instead of reusing the last VoWiFi runtime attached to this device ID.
|
||||
if entry != nil && entry.Snapshot != nil {
|
||||
@@ -1390,9 +1649,14 @@ func (s *Server) configuredDeviceStatus(
|
||||
}
|
||||
|
||||
func storedVoWiFiRuntime(runtime store.VoWiFiRuntime) map[string]any {
|
||||
extra, _ := rawJSONObject(runtime.Extra).(map[string]any)
|
||||
enabled, _ := extra["enabled"].(bool)
|
||||
active, _ := extra["active"].(bool)
|
||||
return map[string]any{
|
||||
"device_id": runtime.DeviceID,
|
||||
"phase": runtime.Phase,
|
||||
"enabled": enabled,
|
||||
"active": active,
|
||||
"dataplane_mode": runtime.DataplaneMode,
|
||||
"iccid": runtime.ICCID,
|
||||
"imsi": runtime.IMSI,
|
||||
@@ -1416,6 +1680,63 @@ func storedVoWiFiRuntime(runtime store.VoWiFiRuntime) map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
func liveVoWiFiRuntime(runtime vowifi.State) map[string]any {
|
||||
return map[string]any{
|
||||
"device_id": runtime.DeviceID,
|
||||
"phase": string(runtime.Phase),
|
||||
"enabled": runtime.Enabled,
|
||||
"active": runtime.Active,
|
||||
"dataplane_mode": runtime.DataplaneMode,
|
||||
"iccid": runtime.ICCID,
|
||||
"imsi": runtime.IMSI,
|
||||
"sim_ready": runtime.SIMReady,
|
||||
"access_ready": runtime.AccessReady,
|
||||
"tunnel_ready": runtime.TunnelReady,
|
||||
"ims_ready": runtime.IMSReady,
|
||||
"sms_ready": runtime.SMSReady,
|
||||
"reg_status": map[bool]int{true: 1, false: 0}[runtime.IMSReady],
|
||||
"reg_status_text": map[bool]string{true: "registered", false: "not registered"}[runtime.IMSReady],
|
||||
"network_mode": "Wi-Fi",
|
||||
"local_phone": runtime.PhoneNumber,
|
||||
"phone_number_source": runtime.PhoneNumberSource,
|
||||
"last_error_class": runtime.LastErrorClass,
|
||||
"last_error": runtime.LastError,
|
||||
"last_reason": runtime.LastReason,
|
||||
"updated_at": runtime.UpdatedAt,
|
||||
"tunnel": map[string]any{
|
||||
"established": runtime.TunnelReady,
|
||||
"name": runtime.TunnelName,
|
||||
"dataplane_mode": runtime.DataplaneMode,
|
||||
"epdg": runtime.EPDG,
|
||||
"proxy_mode": runtime.ProxyMode,
|
||||
"proxy_id": runtime.ProxyID,
|
||||
"security_audit": runtime.Security,
|
||||
},
|
||||
"imscore": map[string]any{
|
||||
"registered": runtime.IMSReady,
|
||||
"registration_state": runtime.IMSRegistration,
|
||||
"associated_number": runtime.PhoneNumber,
|
||||
"number_source": runtime.PhoneNumberSource,
|
||||
},
|
||||
"smsip": map[string]any{"ready": runtime.SMSReady},
|
||||
}
|
||||
}
|
||||
|
||||
func snapshotForEntry(entry *device.Device) *device.Snapshot {
|
||||
if entry == nil {
|
||||
return nil
|
||||
}
|
||||
return entry.Snapshot
|
||||
}
|
||||
|
||||
func voWiFiRuntimeMatchesSnapshot(runtimeICCID string, entry *device.Device) bool {
|
||||
current := strings.TrimSpace(snapshotString(snapshotForEntry(entry), func(snapshot *device.Snapshot) string {
|
||||
return snapshot.ICCID
|
||||
}))
|
||||
runtimeICCID = strings.TrimSpace(runtimeICCID)
|
||||
return current == "" || runtimeICCID == "" || strings.EqualFold(current, runtimeICCID)
|
||||
}
|
||||
|
||||
func rawJSONObject(value json.RawMessage) any {
|
||||
var result any
|
||||
if len(value) != 0 && json.Unmarshal(value, &result) == nil {
|
||||
@@ -1511,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,
|
||||
@@ -1521,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,
|
||||
@@ -1540,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
|
||||
}
|
||||
@@ -1569,6 +1906,7 @@ func modemSummary(snapshot *device.Snapshot, phone string, phoneSource string) m
|
||||
"operator": "",
|
||||
"native_mcc": "",
|
||||
"native_mnc": "",
|
||||
"native_spn": "",
|
||||
"operator_country_code": "",
|
||||
"card_mcc": "",
|
||||
"card_mnc": "",
|
||||
@@ -1598,6 +1936,7 @@ func modemSummary(snapshot *device.Snapshot, phone string, phoneSource string) m
|
||||
"operator": snapshot.OperatorName,
|
||||
"native_mcc": mcc,
|
||||
"native_mnc": mnc,
|
||||
"native_spn": snapshot.SPN,
|
||||
"operator_country_code": operatorCountryCode,
|
||||
"card_mcc": cardMCC,
|
||||
"card_mnc": cardMNC,
|
||||
@@ -1620,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 := ""
|
||||
@@ -1643,6 +1998,8 @@ func idleVoWiFiRuntime(id string, snapshot *device.Snapshot) map[string]any {
|
||||
return map[string]any{
|
||||
"device_id": id,
|
||||
"phase": "idle",
|
||||
"enabled": false,
|
||||
"active": false,
|
||||
"dataplane_mode": "",
|
||||
"iccid": iccid,
|
||||
"imsi": imsi,
|
||||
@@ -1674,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"
|
||||
}
|
||||
|
||||
@@ -31,6 +31,46 @@ func decodeData(t *testing.T, recorder *httptest.ResponseRecorder) map[string]an
|
||||
return envelope.Data
|
||||
}
|
||||
|
||||
func TestParseModemAPNProfiles(t *testing.T) {
|
||||
profiles := parseModemAPNProfiles([]string{
|
||||
`+CGDCONT: 1,"IPV4V6","internet","0.0.0.0",0,0`,
|
||||
`+CGDCONT: 2,"IP","ims","0.0.0.0",0,0`,
|
||||
`+CGDCONT: 3,"IPV4V6","internet","0.0.0.0",0,0`,
|
||||
`+CGDCONT: 4,"IP","","0.0.0.0",0,0`,
|
||||
})
|
||||
if len(profiles) != 2 {
|
||||
t.Fatalf("profiles = %#v", profiles)
|
||||
}
|
||||
if profiles[0].CID != 1 || profiles[0].APN != "internet" || profiles[0].IPVersion != "IPV4V6" {
|
||||
t.Fatalf("first profile = %#v", profiles[0])
|
||||
}
|
||||
if profiles[1].CID != 2 || profiles[1].APN != "ims" || profiles[1].IPVersion != "IP" {
|
||||
t.Fatalf("second profile = %#v", profiles[1])
|
||||
}
|
||||
}
|
||||
|
||||
type esimAIDCaptureController struct {
|
||||
fakeDeviceController
|
||||
switchAID string
|
||||
disableAID string
|
||||
renameAID string
|
||||
}
|
||||
|
||||
func (controller *esimAIDCaptureController) ESIMSwitchProfile(_ context.Context, _, _, aidHex string) error {
|
||||
controller.switchAID = aidHex
|
||||
return nil
|
||||
}
|
||||
|
||||
func (controller *esimAIDCaptureController) ESIMDisableProfile(_ context.Context, _, _, aidHex string) error {
|
||||
controller.disableAID = aidHex
|
||||
return nil
|
||||
}
|
||||
|
||||
func (controller *esimAIDCaptureController) ESIMRenameProfile(_ context.Context, _, _, _, aidHex string) error {
|
||||
controller.renameAID = aidHex
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAttachSingleEUICCIdentityFillsProfileGroupMetadataKey(t *testing.T) {
|
||||
groups := []map[string]any{{"eid": "", "aidHex": "", "profiles": []any{}}}
|
||||
chipInfo := map[string]any{
|
||||
@@ -256,9 +296,25 @@ func TestHandleESIMShapes(t *testing.T) {
|
||||
}
|
||||
|
||||
// Switch happy path: a present device + fake controller switches by ICCID.
|
||||
present := &Server{logger: regionTestLogger(), maxRequestBodyBytes: 4096, devices: fakeDeviceController{}}
|
||||
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: "dev1", Name: "dev1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const switchedICCID = "8900000000000000001"
|
||||
if err := database.UpsertCardPolicy(context.Background(), store.CardPolicy{
|
||||
ICCID: switchedICCID, VoWiFiEnabled: false, AirplaneEnabled: false,
|
||||
APN: "profile.apn", IPVersion: "IP", Source: "manual",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
controller := &esimAIDCaptureController{}
|
||||
present := &Server{store: database, logger: regionTestLogger(), maxRequestBodyBytes: 4096, devices: controller}
|
||||
swOK := httptest.NewRecorder()
|
||||
swReq := httptest.NewRequest(http.MethodPost, "/esim/actions/switch", strings.NewReader(`{"iccid":"8900000000000000001","aid_hex":"A0"}`))
|
||||
swReq := httptest.NewRequest(http.MethodPost, "/esim/actions/switch", strings.NewReader(`{"iccid":"8900000000000000001","aidHex":"A0000005591010FFFFFFFF8900000177"}`))
|
||||
swReq.Header.Set("Content-Type", "application/json")
|
||||
present.handleESIM(swOK, swReq, []string{"actions", "switch"}, "dev1", true)
|
||||
if swOK.Code != http.StatusOK {
|
||||
@@ -267,10 +323,21 @@ func TestHandleESIMShapes(t *testing.T) {
|
||||
if data := decodeData(t, swOK); data["status"] != "switched" || data["verified"] != true {
|
||||
t.Fatalf("switch data = %v", data)
|
||||
}
|
||||
if controller.switchAID != "A0000005591010FFFFFFFF8900000177" {
|
||||
t.Fatalf("switch AID = %q, want XeSIM camelCase AID", controller.switchAID)
|
||||
}
|
||||
storedPolicy, err := database.CardPolicy(context.Background(), switchedICCID)
|
||||
if err != nil || storedPolicy.VoWiFiEnabled || storedPolicy.AirplaneEnabled || storedPolicy.APN != "profile.apn" || storedPolicy.IPVersion != "IP" {
|
||||
t.Fatalf("switch overwrote saved policy: %+v, %v", storedPolicy, err)
|
||||
}
|
||||
storedDevice, err := database.Device(context.Background(), "dev1")
|
||||
if err != nil || storedDevice.VoWiFiEnabled || storedDevice.APN != "profile.apn" {
|
||||
t.Fatalf("switch did not restore device policy: %+v, %v", storedDevice, err)
|
||||
}
|
||||
|
||||
// Disable happy path routes the active profile to ES10c DisableProfile.
|
||||
disableOK := httptest.NewRecorder()
|
||||
disableReq := httptest.NewRequest(http.MethodPost, "/esim/actions/disable", strings.NewReader(`{"iccid":"8900000000000000001","aid_hex":"A0000005591010FFFFFFFF8900000100"}`))
|
||||
disableReq := httptest.NewRequest(http.MethodPost, "/esim/actions/disable", strings.NewReader(`{"iccid":"8900000000000000001","aidHex":"A0000005591010FFFFFFFF8900000177"}`))
|
||||
disableReq.Header.Set("Content-Type", "application/json")
|
||||
present.handleESIM(disableOK, disableReq, []string{"actions", "disable"}, "dev1", true)
|
||||
if disableOK.Code != http.StatusOK {
|
||||
@@ -279,10 +346,13 @@ func TestHandleESIMShapes(t *testing.T) {
|
||||
if data := decodeData(t, disableOK); data["status"] != "disabled" || data["recovering"] != true {
|
||||
t.Fatalf("disable data = %v", data)
|
||||
}
|
||||
if controller.disableAID != "A0000005591010FFFFFFFF8900000177" {
|
||||
t.Fatalf("disable AID = %q, want XeSIM camelCase AID", controller.disableAID)
|
||||
}
|
||||
|
||||
// Rename happy path routes PATCH to ES10c SetNickname support.
|
||||
renameOK := httptest.NewRecorder()
|
||||
renameReq := httptest.NewRequest(http.MethodPatch, "/esim/profiles/8900000000000000001", strings.NewReader(`{"name":"Test profile","aid_hex":"A0000005591010FFFFFFFF8900000100"}`))
|
||||
renameReq := httptest.NewRequest(http.MethodPatch, "/esim/profiles/8900000000000000001", strings.NewReader(`{"name":"Test profile","aidHex":"A0000005591010FFFFFFFF8900000177"}`))
|
||||
renameReq.Header.Set("Content-Type", "application/json")
|
||||
present.handleESIM(renameOK, renameReq, []string{"profiles", "8900000000000000001"}, "dev1", true)
|
||||
if renameOK.Code != http.StatusOK {
|
||||
@@ -291,6 +361,9 @@ func TestHandleESIMShapes(t *testing.T) {
|
||||
if data := decodeData(t, renameOK); data["status"] != "renamed" || data["name"] != "Test profile" {
|
||||
t.Fatalf("rename data = %v", data)
|
||||
}
|
||||
if controller.renameAID != "A0000005591010FFFFFFFF8900000177" {
|
||||
t.Fatalf("rename AID = %q, want XeSIM camelCase AID", controller.renameAID)
|
||||
}
|
||||
|
||||
// Download on a present device but with no smdp address reports 400.
|
||||
dlNoSmdp := httptest.NewRecorder()
|
||||
@@ -300,6 +373,64 @@ func TestHandleESIMShapes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type fakeEsimNotificationController struct {
|
||||
fakeDeviceController
|
||||
items []device.EsimNotification
|
||||
listErr error
|
||||
retryErr error
|
||||
retryDeviceID string
|
||||
retryAID string
|
||||
retrySequence uint64
|
||||
}
|
||||
|
||||
func (f *fakeEsimNotificationController) ESIMNotifications(context.Context, string) ([]device.EsimNotification, error) {
|
||||
return f.items, f.listErr
|
||||
}
|
||||
|
||||
func (f *fakeEsimNotificationController) ESIMRetryNotification(_ context.Context, deviceID, aidHex string, sequenceNumber uint64) error {
|
||||
f.retryDeviceID = deviceID
|
||||
f.retryAID = aidHex
|
||||
f.retrySequence = sequenceNumber
|
||||
return f.retryErr
|
||||
}
|
||||
|
||||
func TestHandleESIMNotificationsListAndRetry(t *testing.T) {
|
||||
controller := &fakeEsimNotificationController{items: []device.EsimNotification{{
|
||||
SequenceNumber: 12,
|
||||
Event: "delete",
|
||||
ICCID: "89441000400128014257",
|
||||
Address: "rsp.example.com",
|
||||
AIDHex: "A0000005591010FFFFFFFF8900000100",
|
||||
CanRetry: true,
|
||||
}}}
|
||||
server := &Server{logger: regionTestLogger(), devices: controller}
|
||||
|
||||
list := httptest.NewRecorder()
|
||||
server.handleESIM(list, httptest.NewRequest(http.MethodGet, "/esim/notifications", nil), []string{"notifications"}, "dev1", true)
|
||||
if list.Code != http.StatusOK {
|
||||
t.Fatalf("list status = %d, body=%s", list.Code, list.Body.String())
|
||||
}
|
||||
data := decodeData(t, list)
|
||||
items, ok := data["items"].([]any)
|
||||
if !ok || len(items) != 1 {
|
||||
t.Fatalf("items = %#v", data["items"])
|
||||
}
|
||||
item := items[0].(map[string]any)
|
||||
if item["sequenceNumber"] != float64(12) || item["event"] != "delete" || item["address"] != "rsp.example.com" {
|
||||
t.Fatalf("item = %#v", item)
|
||||
}
|
||||
|
||||
retry := httptest.NewRecorder()
|
||||
retryRequest := httptest.NewRequest(http.MethodPost, "/esim/notifications/12/actions/retry?aid_hex=A000", nil)
|
||||
server.handleESIM(retry, retryRequest, []string{"notifications", "12", "actions", "retry"}, "dev1", true)
|
||||
if retry.Code != http.StatusOK {
|
||||
t.Fatalf("retry status = %d, body=%s", retry.Code, retry.Body.String())
|
||||
}
|
||||
if controller.retryDeviceID != "dev1" || controller.retryAID != "A000" || controller.retrySequence != 12 {
|
||||
t.Fatalf("retry args = (%q, %q, %d)", controller.retryDeviceID, controller.retryAID, controller.retrySequence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleFixUSBNet(t *testing.T) {
|
||||
server := &Server{
|
||||
logger: regionTestLogger(),
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"vocat/internal/device"
|
||||
"vocat/internal/store"
|
||||
"vocat/internal/vowifi"
|
||||
)
|
||||
|
||||
func TestConfiguredDeviceSummaryIgnoresVoWiFiRuntimeFromPreviousSIM(t *testing.T) {
|
||||
@@ -49,3 +50,89 @@ func TestConfiguredDeviceSummaryIgnoresVoWiFiRuntimeFromPreviousSIM(t *testing.T
|
||||
t.Fatalf("runtime = %#v", got["vowifi_runtime"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredDeviceSummaryPrefersLiveVoWiFiStateOverStoredShutdownState(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: "ec20_1", Name: "EC20"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpsertVoWiFiRuntime(context.Background(), store.VoWiFiRuntime{
|
||||
DeviceID: "ec20_1",
|
||||
Phase: "idle",
|
||||
ICCID: "89104100000028106378",
|
||||
LastReason: "disabled",
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
live := vowifi.State{
|
||||
DeviceID: "ec20_1",
|
||||
Phase: vowifi.PhaseTunnelReady,
|
||||
Enabled: true,
|
||||
Active: true,
|
||||
ICCID: "89104100000028106378",
|
||||
SIMReady: true,
|
||||
AccessReady: true,
|
||||
TunnelReady: true,
|
||||
LastReason: "ipsec_tunnel_ready",
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
s := &Server{store: database, vowifi: &fakeVoWiFiController{state: live}}
|
||||
entry := &device.Device{ID: "physical", Snapshot: &device.Snapshot{ICCID: live.ICCID}}
|
||||
got := s.configuredDeviceSummary(store.Device{ID: "ec20_1", VoWiFiEnabled: true}, entry)
|
||||
runtime, ok := got["vowifi_runtime"].(map[string]any)
|
||||
if !ok || runtime["phase"] != string(vowifi.PhaseTunnelReady) || runtime["enabled"] != true {
|
||||
t.Fatalf("runtime = %#v", got["vowifi_runtime"])
|
||||
}
|
||||
if got["vowifi_active"] != true {
|
||||
t.Fatalf("vowifi_active = %#v", got["vowifi_active"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredDeviceSummaryMarksIdleRuntimeAsNotInUse(t *testing.T) {
|
||||
database, err := store.Open(context.Background(), ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
s := &Server{
|
||||
store: database,
|
||||
vowifi: &fakeVoWiFiController{state: vowifi.State{
|
||||
DeviceID: "ec20_1",
|
||||
Phase: vowifi.PhaseIdle,
|
||||
Enabled: false,
|
||||
LastReason: "disabled",
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}},
|
||||
}
|
||||
got := s.configuredDeviceSummary(store.Device{ID: "ec20_1", VoWiFiEnabled: true}, nil)
|
||||
runtime := got["vowifi_runtime"].(map[string]any)
|
||||
if runtime["enabled"] != false || got["vowifi_active"] != false {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// writePlainTextMail constructs one RFC 5322 message without allowing values
|
||||
// supplied by notification configuration or device messages to create new
|
||||
// headers or MIME parts. Mailbox values have already passed net/mail parsing,
|
||||
// the subject is encoded as one encoded-word, and the body is base64 encoded.
|
||||
func writePlainTextMail(
|
||||
writer io.Writer,
|
||||
from *mail.Address,
|
||||
recipients []*mail.Address,
|
||||
subject string,
|
||||
body string,
|
||||
) error {
|
||||
if from == nil || len(recipients) == 0 {
|
||||
return errors.New("email sender and recipient are required")
|
||||
}
|
||||
if strings.ContainsAny(subject, "\r\n\x00") {
|
||||
return errors.New("email subject contains a prohibited control character")
|
||||
}
|
||||
fromHeader, err := validatedMailHeaderAddress(from)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid email sender: %w", err)
|
||||
}
|
||||
recipientHeaders := make([]string, 0, len(recipients))
|
||||
for _, recipient := range recipients {
|
||||
header, err := validatedMailHeaderAddress(recipient)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid email recipient: %w", err)
|
||||
}
|
||||
recipientHeaders = append(recipientHeaders, header)
|
||||
}
|
||||
encodedBody := wrapMIMEBase64(base64.StdEncoding.EncodeToString([]byte(body)))
|
||||
message := strings.Join([]string{
|
||||
"Date: " + time.Now().UTC().Format(time.RFC1123Z),
|
||||
"From: " + fromHeader,
|
||||
"To: " + strings.Join(recipientHeaders, ", "),
|
||||
"Subject: " + mime.QEncoding.Encode("UTF-8", subject),
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: text/plain; charset=UTF-8",
|
||||
"Content-Transfer-Encoding: base64",
|
||||
"",
|
||||
encodedBody,
|
||||
"",
|
||||
}, "\r\n")
|
||||
|
||||
// The only values reaching this sink have been parsed as RFC mailboxes or
|
||||
// encoded as MIME encoded-words/base64 above. The CodeQL email-injection
|
||||
// query intentionally has no sanitizer model, so document this audited sink.
|
||||
// codeql[go/email-injection]
|
||||
if _, err := io.WriteString(writer, message); err != nil {
|
||||
return fmt.Errorf("write email message: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validatedMailHeaderAddress keeps writePlainTextMail safe even if a future
|
||||
// caller constructs mail.Address directly instead of using parseMailAddress.
|
||||
func validatedMailHeaderAddress(address *mail.Address) (string, error) {
|
||||
if address == nil || address.Address == "" || strings.TrimSpace(address.Address) != address.Address ||
|
||||
strings.ContainsAny(address.Address, "\r\n\x00") {
|
||||
return "", errors.New("email address contains a prohibited control character")
|
||||
}
|
||||
parsed, err := mail.ParseAddress(address.Address)
|
||||
if err != nil || parsed.Name != "" || parsed.Address != address.Address {
|
||||
return "", errors.New("invalid email address")
|
||||
}
|
||||
for _, character := range address.Name {
|
||||
if character < 0x20 || character == 0x7f {
|
||||
return "", errors.New("email display name contains a prohibited control character")
|
||||
}
|
||||
}
|
||||
return formatMailAddress(address), nil
|
||||
}
|
||||
|
||||
func wrapMIMEBase64(value string) string {
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
const lineLength = 76
|
||||
lines := make([]string, 0, (len(value)+lineLength-1)/lineLength)
|
||||
for len(value) > lineLength {
|
||||
lines = append(lines, value[:lineLength])
|
||||
value = value[lineLength:]
|
||||
}
|
||||
lines = append(lines, value)
|
||||
return strings.Join(lines, "\r\n")
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWritePlainTextMailEncodesUntrustedContent(t *testing.T) {
|
||||
from, err := parseMailAddress("VoCat Alerts <[email protected]>")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recipient, err := parseMailAddress("Admin <[email protected]>")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := "message\r\nBcc: [email protected]\r\n<script>alert(1)</script>"
|
||||
var output bytes.Buffer
|
||||
if err := writePlainTextMail(&output, from, []*mail.Address{recipient}, "new SMS", body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
message := output.String()
|
||||
if strings.Contains(message, body) || strings.Contains(message, "\r\nBcc: [email protected]") {
|
||||
t.Fatalf("unencoded body reached message: %q", message)
|
||||
}
|
||||
if !strings.Contains(message, "Content-Transfer-Encoding: base64") {
|
||||
t.Fatalf("base64 transfer encoding missing: %q", message)
|
||||
}
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte(body))
|
||||
if !strings.Contains(strings.ReplaceAll(message, "\r\n", ""), encoded) {
|
||||
t.Fatalf("encoded body missing: %q", message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePlainTextMailRejectsInjectedSubject(t *testing.T) {
|
||||
from := &mail.Address{Address: "[email protected]"}
|
||||
recipients := []*mail.Address{{Address: "[email protected]"}}
|
||||
if err := writePlainTextMail(&bytes.Buffer{}, from, recipients, "hello\r\nBcc: [email protected]", "body"); err == nil {
|
||||
t.Fatal("injected subject was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePlainTextMailRejectsDirectlyConstructedInjectedAddresses(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
from *mail.Address
|
||||
recipients []*mail.Address
|
||||
}{
|
||||
{
|
||||
name: "sender address",
|
||||
from: &mail.Address{Address: "[email protected]\r\nBcc: [email protected]"},
|
||||
recipients: []*mail.Address{{Address: "[email protected]"}},
|
||||
},
|
||||
{
|
||||
name: "sender display name",
|
||||
from: &mail.Address{Name: "Alerts\r\nBcc: [email protected]", Address: "[email protected]"},
|
||||
recipients: []*mail.Address{{Address: "[email protected]"}},
|
||||
},
|
||||
{
|
||||
name: "recipient address",
|
||||
from: &mail.Address{Address: "[email protected]"},
|
||||
recipients: []*mail.Address{{Address: "[email protected]\nCc: [email protected]"}},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if err := writePlainTextMail(&bytes.Buffer{}, test.from, test.recipients, "subject", "body"); err == nil {
|
||||
t.Fatal("injected address was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+153
-16
@@ -1,21 +1,33 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vocat/internal/device"
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
func esimUnavailable(w http.ResponseWriter) {
|
||||
writeError(w, http.StatusNotImplemented, "esim_operation_unavailable", "This specific eSIM operation is not implemented.")
|
||||
}
|
||||
|
||||
type esimNotificationController interface {
|
||||
ESIMNotifications(context.Context, string) ([]device.EsimNotification, error)
|
||||
ESIMRetryNotification(context.Context, string, string, uint64) error
|
||||
}
|
||||
|
||||
// handleESIM routes every /devices/{id}/esim* path.
|
||||
func (s *Server) handleESIM(w http.ResponseWriter, r *http.Request, rest []string, physicalID string, physicalPresent bool) bool {
|
||||
func (s *Server) handleESIM(w http.ResponseWriter, r *http.Request, rest []string, physicalID string, physicalPresent bool, configuredIDs ...string) bool {
|
||||
configuredID := physicalID
|
||||
if len(configuredIDs) > 0 && strings.TrimSpace(configuredIDs[0]) != "" {
|
||||
configuredID = strings.TrimSpace(configuredIDs[0])
|
||||
}
|
||||
if len(rest) == 0 || (len(rest) == 1 && strings.TrimSpace(rest[0]) == "") {
|
||||
if !requireMethod(w, r, http.MethodGet) {
|
||||
return true
|
||||
@@ -48,11 +60,16 @@ func (s *Server) handleESIM(w http.ResponseWriter, r *http.Request, rest []strin
|
||||
if !requireMethod(w, r, http.MethodGet) {
|
||||
return true
|
||||
}
|
||||
// No LPA download backend, so there are never pending notifications.
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"items": []any{}}})
|
||||
s.writeEsimNotifications(w, r, physicalID, physicalPresent)
|
||||
return true
|
||||
}
|
||||
if len(rest) == 4 && rest[2] == "actions" && rest[3] == "retry" {
|
||||
if !requireMethod(w, r, http.MethodPost) {
|
||||
return true
|
||||
}
|
||||
s.handleEsimNotificationRetry(w, r, physicalID, physicalPresent, rest[1])
|
||||
return true
|
||||
}
|
||||
// notifications/{id}/actions/retry
|
||||
esimUnavailable(w)
|
||||
return true
|
||||
case "actions":
|
||||
@@ -60,7 +77,7 @@ func (s *Server) handleESIM(w http.ResponseWriter, r *http.Request, rest []strin
|
||||
if !requireMethod(w, r, http.MethodPost) {
|
||||
return true
|
||||
}
|
||||
s.handleEsimSwitch(w, r, physicalID, physicalPresent)
|
||||
s.handleEsimSwitch(w, r, configuredID, physicalID, physicalPresent)
|
||||
return true
|
||||
}
|
||||
if len(rest) == 2 && rest[1] == "disable" {
|
||||
@@ -85,6 +102,48 @@ func (s *Server) handleESIM(w http.ResponseWriter, r *http.Request, rest []strin
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) writeEsimNotifications(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool) {
|
||||
controller, ok := s.devices.(esimNotificationController)
|
||||
if !ok || !physicalPresent {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"items": []any{}}})
|
||||
return
|
||||
}
|
||||
items, err := controller.ESIMNotifications(r.Context(), physicalID)
|
||||
if err != nil {
|
||||
s.writeDeviceError(w, err)
|
||||
return
|
||||
}
|
||||
if items == nil {
|
||||
items = []device.EsimNotification{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"items": items}})
|
||||
}
|
||||
|
||||
func (s *Server) handleEsimNotificationRetry(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool, rawSequenceNumber string) {
|
||||
controller, ok := s.devices.(esimNotificationController)
|
||||
if !ok {
|
||||
esimUnavailable(w)
|
||||
return
|
||||
}
|
||||
if !physicalPresent {
|
||||
writeError(w, http.StatusServiceUnavailable, "physical_device_missing", "the configured modem is not present on this Linux host")
|
||||
return
|
||||
}
|
||||
sequenceNumber, err := strconv.ParseUint(strings.TrimSpace(rawSequenceNumber), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "notification sequence number is invalid")
|
||||
return
|
||||
}
|
||||
if err := controller.ESIMRetryNotification(r.Context(), physicalID, r.URL.Query().Get("aid_hex"), sequenceNumber); err != nil {
|
||||
s.writeDeviceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
|
||||
"status": "sent",
|
||||
"message": "通知已上报运营商并从 eUICC 待处理列表移除",
|
||||
}})
|
||||
}
|
||||
|
||||
// esimInfo loads the eUICC profile list. The string result is "ok" (use info),
|
||||
// "empty" (no usable eUICC — render the empty state), or "error" (an error
|
||||
// response has already been written).
|
||||
@@ -295,8 +354,9 @@ func (s *Server) handleEsimRename(w http.ResponseWriter, r *http.Request, physic
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
Name string `json:"name"`
|
||||
AIDHex string `json:"aid_hex"` // accepted for the multi-eUICC SPA contract; ICCID addresses the profile
|
||||
Name string `json:"name"`
|
||||
AIDHex string `json:"aid_hex"`
|
||||
AIDHexCamel string `json:"aidHex"`
|
||||
}
|
||||
if err := s.decodeJSON(w, r, &request); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
@@ -307,7 +367,8 @@ func (s *Server) handleEsimRename(w http.ResponseWriter, r *http.Request, physic
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "profile nickname is required")
|
||||
return
|
||||
}
|
||||
if err := s.devices.ESIMRenameProfile(r.Context(), physicalID, iccid, nickname, request.AIDHex); err != nil {
|
||||
aidHex := firstNonEmpty(request.AIDHex, request.AIDHexCamel)
|
||||
if err := s.devices.ESIMRenameProfile(r.Context(), physicalID, iccid, nickname, aidHex); err != nil {
|
||||
s.writeDeviceError(w, err)
|
||||
return
|
||||
}
|
||||
@@ -316,7 +377,7 @@ func (s *Server) handleEsimRename(w http.ResponseWriter, r *http.Request, physic
|
||||
|
||||
// handleEsimSwitch enables one already-installed profile by ICCID (切卡). The
|
||||
// eUICC EnableProfile command needs no authentication key.
|
||||
func (s *Server) handleEsimSwitch(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool) {
|
||||
func (s *Server) handleEsimSwitch(w http.ResponseWriter, r *http.Request, configuredID string, physicalID string, physicalPresent bool) {
|
||||
if s.devices == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "device_manager_unavailable", "device manager is unavailable")
|
||||
return
|
||||
@@ -326,8 +387,9 @@ func (s *Server) handleEsimSwitch(w http.ResponseWriter, r *http.Request, physic
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
ICCID string `json:"iccid"`
|
||||
AIDHex string `json:"aid_hex"` // accepted for contract compatibility; switching keys off iccid
|
||||
ICCID string `json:"iccid"`
|
||||
AIDHex string `json:"aid_hex"`
|
||||
AIDHexCamel string `json:"aidHex"`
|
||||
}
|
||||
if err := s.decodeJSON(w, r, &request); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
@@ -338,15 +400,88 @@ func (s *Server) handleEsimSwitch(w http.ResponseWriter, r *http.Request, physic
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "iccid is required")
|
||||
return
|
||||
}
|
||||
// Profile operations run with RF disabled. The eUICC remains accessible in
|
||||
// CFUN=4, and the recovery path reapplies CFUN=4 as soon as the AT port comes
|
||||
// back after the mandatory modem reset.
|
||||
if _, err := s.devices.SetFlight(r.Context(), physicalID, true); err != nil {
|
||||
s.writeDeviceError(w, err)
|
||||
return
|
||||
}
|
||||
// A confirmed profile switch includes the EC20 reset and a live ICCID read,
|
||||
// which normally takes longer than the server's ordinary response deadline.
|
||||
controller := http.NewResponseController(w)
|
||||
_ = controller.SetWriteDeadline(time.Time{})
|
||||
if err := s.devices.ESIMSwitchProfile(r.Context(), physicalID, iccid, request.AIDHex); err != nil {
|
||||
aidHex := firstNonEmpty(request.AIDHex, request.AIDHexCamel)
|
||||
if err := s.devices.ESIMSwitchProfile(r.Context(), physicalID, iccid, aidHex); err != nil {
|
||||
s.writeDeviceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"status": "switched", "iccid": iccid, "verified": true}})
|
||||
if _, err := s.devices.SetFlight(r.Context(), physicalID, true); err != nil {
|
||||
s.writeDeviceError(w, err)
|
||||
return
|
||||
}
|
||||
policy, err := s.store.CardPolicy(r.Context(), iccid)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
policy = defaultCardPolicy(iccid)
|
||||
if err := s.store.UpsertCardPolicy(r.Context(), policy); err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
} else if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
// Never replace a returning profile's policy with defaults. VoWiFi still
|
||||
// implies airplane mode, but every user-selected value and APN belongs to
|
||||
// this ICCID and is restored when the profile becomes active again.
|
||||
if policy.VoWiFiEnabled && (!policy.AirplaneEnabled || policy.NetworkEnabled) {
|
||||
policy.AirplaneEnabled = true
|
||||
policy.NetworkEnabled = false
|
||||
if err := s.store.UpsertCardPolicy(r.Context(), policy); err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
config, err := s.store.Device(r.Context(), configuredID)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
config.VoWiFiEnabled = policy.VoWiFiEnabled
|
||||
config.NetworkEnabled = false
|
||||
config.APN = policy.APN
|
||||
if err := s.store.UpsertDevice(r.Context(), config); err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
canRestoreFlightImmediately := s.vowifi == nil
|
||||
if s.vowifi != nil {
|
||||
state, stateErr := s.vowifi.State(configuredID)
|
||||
if policy.VoWiFiEnabled {
|
||||
switch {
|
||||
case stateErr == nil && state.Enabled:
|
||||
_, err = s.vowifi.RequestReconnect(configuredID)
|
||||
default:
|
||||
_, err = s.vowifi.RequestEnabled(configuredID, true)
|
||||
}
|
||||
} else if stateErr == nil && state.Enabled {
|
||||
_, err = s.vowifi.RequestEnabled(configuredID, false)
|
||||
} else {
|
||||
canRestoreFlightImmediately = true
|
||||
}
|
||||
if err != nil {
|
||||
s.logger.Warn("profile switched but saved VoWiFi state was not queued", "device_id", configuredID, "iccid", iccid, "enabled", policy.VoWiFiEnabled, "error", err)
|
||||
}
|
||||
}
|
||||
if !policy.VoWiFiEnabled && canRestoreFlightImmediately && !policy.AirplaneEnabled {
|
||||
if _, err := s.devices.SetFlight(r.Context(), physicalID, false); err != nil {
|
||||
s.logger.Warn("profile switched but saved airplane state will require reconciliation", "device_id", configuredID, "iccid", iccid, "error", err)
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
|
||||
"status": "switched", "iccid": iccid, "verified": true,
|
||||
"card_policy": cardPolicyResponse(policy),
|
||||
}})
|
||||
}
|
||||
|
||||
func (s *Server) handleEsimDisable(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool) {
|
||||
@@ -359,8 +494,9 @@ func (s *Server) handleEsimDisable(w http.ResponseWriter, r *http.Request, physi
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
ICCID string `json:"iccid"`
|
||||
AIDHex string `json:"aid_hex"` // accepted for the multi-eUICC SPA contract; disabling keys off ICCID
|
||||
ICCID string `json:"iccid"`
|
||||
AIDHex string `json:"aid_hex"`
|
||||
AIDHexCamel string `json:"aidHex"`
|
||||
}
|
||||
if err := s.decodeJSON(w, r, &request); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
@@ -371,7 +507,8 @@ func (s *Server) handleEsimDisable(w http.ResponseWriter, r *http.Request, physi
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "iccid is required")
|
||||
return
|
||||
}
|
||||
if err := s.devices.ESIMDisableProfile(r.Context(), physicalID, iccid, request.AIDHex); err != nil {
|
||||
aidHex := firstNonEmpty(request.AIDHex, request.AIDHexCamel)
|
||||
if err := s.devices.ESIMDisableProfile(r.Context(), physicalID, iccid, aidHex); err != nil {
|
||||
s.writeDeviceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@ func (s *Server) routeExtensionAPI(w http.ResponseWriter, r *http.Request, clean
|
||||
if !requireMethod(w, r, http.MethodPost) {
|
||||
return true
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxPluginUploadBytes+(1<<20))
|
||||
r.Body = http.MaxBytesReader(nil, r.Body, maxPluginUploadBytes+(1<<20))
|
||||
if err := r.ParseMultipartForm(maxPluginUploadBytes); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_plugin_upload", "plugin upload must be multipart/form-data and no larger than 64 MiB")
|
||||
return true
|
||||
|
||||
@@ -24,6 +24,9 @@ import (
|
||||
|
||||
func (s *Server) routeGeneralAPI(w http.ResponseWriter, r *http.Request) bool {
|
||||
cleanPath := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api"), "/")
|
||||
if s.routeAutomaticTasksAPI(w, r, cleanPath) {
|
||||
return true
|
||||
}
|
||||
if s.routeExtensionAPI(w, r, cleanPath) {
|
||||
return true
|
||||
}
|
||||
|
||||
+141
-58
@@ -26,8 +26,8 @@ func (s *Server) routeProxyAPI(w http.ResponseWriter, r *http.Request, cleanPath
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": proxyCountries})
|
||||
case "upstream-proxy-country-rules":
|
||||
s.handleCountryRules(w, r)
|
||||
case "upstream-proxy-device-bindings":
|
||||
s.handleDeviceProxyBindings(w, r)
|
||||
case "upstream-proxy-profile-bindings":
|
||||
s.handleProfileProxyBindings(w, r)
|
||||
default:
|
||||
segments := splitAPIPath(cleanPath)
|
||||
switch {
|
||||
@@ -40,8 +40,6 @@ func (s *Server) routeProxyAPI(w http.ResponseWriter, r *http.Request, cleanPath
|
||||
s.handleUpstreamProbe(w, r, segments[1])
|
||||
case len(segments) == 2 && segments[0] == "upstream-proxy-country-rules":
|
||||
s.handleCountryRule(w, r, segments[1])
|
||||
case len(segments) == 2 && segments[0] == "upstream-proxy-device-bindings":
|
||||
s.handleDeviceProxyBinding(w, r, segments[1])
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -114,7 +112,7 @@ func (s *Server) handleUpstreamProxy(w http.ResponseWriter, r *http.Request, id
|
||||
}
|
||||
for _, binding := range bindings {
|
||||
if binding.UpstreamProxyID == id {
|
||||
s.requestProxyRouteReconnect(binding.DeviceID)
|
||||
s.requestProfileProxyRouteReconnect(binding.DeviceID, binding.ICCID)
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"deleted": true}})
|
||||
@@ -124,36 +122,32 @@ func (s *Server) handleUpstreamProxy(w http.ResponseWriter, r *http.Request, id
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleDeviceProxyBindings(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireMethod(w, r, http.MethodGet) {
|
||||
return
|
||||
}
|
||||
values, err := s.store.ListDeviceProxyBindings(r.Context())
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
result := make([]map[string]any, 0, len(values))
|
||||
for _, value := range values {
|
||||
result = append(result, deviceProxyBindingResponse(value))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": result})
|
||||
type profileProxyBindingPayload struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
ICCID string `json:"iccid"`
|
||||
ProfileName string `json:"profile_name"`
|
||||
// Accepted for compatibility with the first profile-picker bundle, which
|
||||
// sent the read-only display state together with the writable identity.
|
||||
StateText string `json:"state_text,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleDeviceProxyBinding(w http.ResponseWriter, r *http.Request, deviceID string) {
|
||||
deviceID = strings.TrimSpace(deviceID)
|
||||
if !validDeviceID(deviceID) {
|
||||
writeError(w, http.StatusBadRequest, "invalid_device_id", "device ID must use 1-64 safe characters")
|
||||
return
|
||||
}
|
||||
if _, err := s.store.Device(r.Context(), deviceID); err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
func (s *Server) handleProfileProxyBindings(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodPut:
|
||||
case http.MethodGet:
|
||||
values, err := s.store.ListDeviceProxyBindings(r.Context())
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
result := make([]map[string]any, 0, len(values))
|
||||
for _, value := range values {
|
||||
result = append(result, deviceProxyBindingResponse(value))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": result})
|
||||
case http.MethodPost:
|
||||
var request struct {
|
||||
UpstreamProxyID string `json:"upstream_proxy_id"`
|
||||
UpstreamProxyID string `json:"upstream_proxy_id"`
|
||||
Bindings []profileProxyBindingPayload `json:"bindings"`
|
||||
}
|
||||
if err := s.decodeJSON(w, r, &request); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
@@ -166,44 +160,114 @@ func (s *Server) handleDeviceProxyBinding(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
if !upstream.Enabled {
|
||||
writeError(w, http.StatusConflict, "upstream_proxy_disabled", "enable the upstream proxy before binding a device")
|
||||
writeError(w, http.StatusConflict, "upstream_proxy_disabled", "enable the upstream proxy before binding a profile")
|
||||
return
|
||||
}
|
||||
// Once bound, a device may not be silently rebinded to a different
|
||||
// upstream proxy. Force the caller to DELETE first so the change is
|
||||
// intentional. Re-binding the same upstream stays idempotent.
|
||||
if existing, err := s.store.DeviceProxyBinding(r.Context(), deviceID); err == nil && existing.UpstreamProxyID != upstream.ID {
|
||||
writeError(w, http.StatusConflict, "device_already_bound", "device is already bound to another upstream proxy; delete the binding first")
|
||||
return
|
||||
} else if err != nil && !errors.Is(err, store.ErrNotFound) {
|
||||
s.writeStoreError(w, err)
|
||||
if len(request.Bindings) == 0 || len(request.Bindings) > 200 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_bindings", "select between 1 and 200 profiles")
|
||||
return
|
||||
}
|
||||
value := store.DeviceProxyBinding{DeviceID: deviceID, UpstreamProxyID: upstream.ID}
|
||||
if err := s.store.UpsertDeviceProxyBinding(r.Context(), value); err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
values := make([]store.DeviceProxyBinding, 0, len(request.Bindings))
|
||||
seen := make(map[string]struct{}, len(request.Bindings))
|
||||
for _, item := range request.Bindings {
|
||||
deviceID := strings.TrimSpace(item.DeviceID)
|
||||
iccid := strings.TrimSpace(item.ICCID)
|
||||
if !validDeviceID(deviceID) {
|
||||
writeError(w, http.StatusBadRequest, "invalid_device_id", "device ID must use 1-64 safe characters")
|
||||
return
|
||||
}
|
||||
if !validProfileICCID(iccid) {
|
||||
writeError(w, http.StatusBadRequest, "invalid_iccid", "profile ICCID must contain 18 to 22 digits")
|
||||
return
|
||||
}
|
||||
if _, duplicate := seen[iccid]; duplicate {
|
||||
writeError(w, http.StatusBadRequest, "duplicate_iccid", "the same ICCID was selected more than once")
|
||||
return
|
||||
}
|
||||
seen[iccid] = struct{}{}
|
||||
if _, err := s.store.Device(r.Context(), deviceID); err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
if existing, err := s.store.DeviceProxyBinding(r.Context(), iccid); err == nil && existing.UpstreamProxyID != upstream.ID {
|
||||
writeError(w, http.StatusConflict, "profile_already_bound", "this ICCID is already bound to another upstream proxy; delete that binding first")
|
||||
return
|
||||
} else if err != nil && !errors.Is(err, store.ErrNotFound) {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(item.ProfileName)
|
||||
if name == "" {
|
||||
name = iccid
|
||||
}
|
||||
values = append(values, store.DeviceProxyBinding{DeviceID: deviceID, ICCID: iccid, ProfileName: name, UpstreamProxyID: upstream.ID})
|
||||
}
|
||||
reconnected, reconnectErr := s.requestProxyRouteReconnect(deviceID)
|
||||
response := deviceProxyBindingResponse(value)
|
||||
response["reconnect_requested"] = reconnected
|
||||
if reconnectErr != nil {
|
||||
response["reconnect_error"] = reconnectErr.Error()
|
||||
requested := false
|
||||
var reconnectErrors []string
|
||||
for _, value := range values {
|
||||
if err := s.store.UpsertDeviceProxyBinding(r.Context(), value); err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
reconnected, reconnectErr := s.requestProfileProxyRouteReconnect(value.DeviceID, value.ICCID)
|
||||
requested = requested || reconnected
|
||||
if reconnectErr != nil {
|
||||
reconnectErrors = append(reconnectErrors, reconnectErr.Error())
|
||||
}
|
||||
}
|
||||
response := map[string]any{"created": len(values), "reconnect_requested": requested}
|
||||
if len(reconnectErrors) > 0 {
|
||||
response["reconnect_error"] = strings.Join(reconnectErrors, "; ")
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": response})
|
||||
case http.MethodDelete:
|
||||
if err := s.store.DeleteDeviceProxyBinding(r.Context(), deviceID); err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
var request struct {
|
||||
UpstreamProxyID string `json:"upstream_proxy_id"`
|
||||
ICCIDs []string `json:"iccids"`
|
||||
}
|
||||
if err := s.decodeJSON(w, r, &request); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
reconnected, reconnectErr := s.requestProxyRouteReconnect(deviceID)
|
||||
response := map[string]any{"deleted": true, "reconnect_requested": reconnected}
|
||||
if reconnectErr != nil {
|
||||
response["reconnect_error"] = reconnectErr.Error()
|
||||
if len(request.ICCIDs) == 0 || len(request.ICCIDs) > 200 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_bindings", "select between 1 and 200 profiles")
|
||||
return
|
||||
}
|
||||
requested := false
|
||||
deleted := 0
|
||||
var reconnectErrors []string
|
||||
for _, rawICCID := range request.ICCIDs {
|
||||
iccid := strings.TrimSpace(rawICCID)
|
||||
binding, err := s.store.DeviceProxyBinding(r.Context(), iccid)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(request.UpstreamProxyID) != "" && binding.UpstreamProxyID != strings.TrimSpace(request.UpstreamProxyID) {
|
||||
writeError(w, http.StatusConflict, "binding_proxy_mismatch", "selected ICCID is not bound to this upstream proxy")
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteDeviceProxyBinding(r.Context(), iccid); err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
deleted++
|
||||
reconnected, reconnectErr := s.requestProfileProxyRouteReconnect(binding.DeviceID, binding.ICCID)
|
||||
requested = requested || reconnected
|
||||
if reconnectErr != nil {
|
||||
reconnectErrors = append(reconnectErrors, reconnectErr.Error())
|
||||
}
|
||||
}
|
||||
response := map[string]any{"deleted": deleted, "reconnect_requested": requested}
|
||||
if len(reconnectErrors) > 0 {
|
||||
response["reconnect_error"] = strings.Join(reconnectErrors, "; ")
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": response})
|
||||
default:
|
||||
w.Header().Set("Allow", "PUT, DELETE")
|
||||
w.Header().Set("Allow", "GET, POST, DELETE")
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||
}
|
||||
}
|
||||
@@ -211,7 +275,7 @@ func (s *Server) handleDeviceProxyBinding(w http.ResponseWriter, r *http.Request
|
||||
// A binding is already durable before this is called. Reconnect failures are
|
||||
// returned as advisory information: the chosen route will still be used on
|
||||
// the next VoWiFi start/reconnect.
|
||||
func (s *Server) requestProxyRouteReconnect(deviceID string) (bool, error) {
|
||||
func (s *Server) requestProfileProxyRouteReconnect(deviceID, iccid string) (bool, error) {
|
||||
if s.vowifi == nil {
|
||||
return false, nil
|
||||
}
|
||||
@@ -222,6 +286,10 @@ func (s *Server) requestProxyRouteReconnect(deviceID string) (bool, error) {
|
||||
if !config.VoWiFiEnabled {
|
||||
return false, nil
|
||||
}
|
||||
state, stateErr := s.vowifi.State(deviceID)
|
||||
if stateErr != nil || strings.TrimSpace(state.ICCID) == "" || strings.TrimSpace(state.ICCID) != strings.TrimSpace(iccid) {
|
||||
return false, nil
|
||||
}
|
||||
if _, err := s.vowifi.RequestReconnect(deviceID); err != nil {
|
||||
s.logger.Warn("VoWiFi proxy route saved but immediate reconnect was not started", "device_id", deviceID, "error", err)
|
||||
return false, err
|
||||
@@ -229,6 +297,19 @@ func (s *Server) requestProxyRouteReconnect(deviceID string) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func validProfileICCID(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) < 18 || len(value) > 22 {
|
||||
return false
|
||||
}
|
||||
for _, digit := range value {
|
||||
if digit < '0' || digit > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) saveAndProbeUpstream(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
@@ -258,7 +339,7 @@ func (s *Server) saveAndProbeUpstream(
|
||||
}
|
||||
for _, binding := range bindings {
|
||||
if binding.UpstreamProxyID == saved.ID {
|
||||
s.requestProxyRouteReconnect(binding.DeviceID)
|
||||
s.requestProfileProxyRouteReconnect(binding.DeviceID, binding.ICCID)
|
||||
}
|
||||
}
|
||||
probe, probeErr := localproxy.ProbeSOCKS5(
|
||||
@@ -449,6 +530,8 @@ func countryRuleResponse(value store.CountryRule) map[string]any {
|
||||
func deviceProxyBindingResponse(value store.DeviceProxyBinding) map[string]any {
|
||||
return map[string]any{
|
||||
"device_id": value.DeviceID,
|
||||
"iccid": value.ICCID,
|
||||
"profile_name": value.ProfileName,
|
||||
"upstream_proxy_id": value.UpstreamProxyID,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,120 +10,111 @@ import (
|
||||
"testing"
|
||||
|
||||
"vocat/internal/store"
|
||||
"vocat/internal/vowifi"
|
||||
)
|
||||
|
||||
func TestDeviceProxyBindingPersistsAndReconnectsEnabledVoWiFi(t *testing.T) {
|
||||
const testProfileICCID = "89441000400128014257"
|
||||
|
||||
func newProfileBindingTestServer(t *testing.T) (*Server, *store.Store, *fakeVoWiFiController) {
|
||||
t.Helper()
|
||||
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: "ec20", Name: "EC20", VoWiFiEnabled: true,
|
||||
}); err != nil {
|
||||
if err := database.UpsertDevice(context.Background(), store.Device{ID: "ec20", Name: "EC20", VoWiFiEnabled: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpsertUpstreamProxy(context.Background(), store.UpstreamProxy{
|
||||
ID: "route-1", Name: "Route 1", Addr: "127.0.0.1:1080", Enabled: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
controller := &fakeVoWiFiController{}
|
||||
server := &Server{
|
||||
store: database,
|
||||
vowifi: controller,
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
maxRequestBodyBytes: 4096,
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/api/upstream-proxy-device-bindings/ec20",
|
||||
bytes.NewBufferString(`{"upstream_proxy_id":"route-1"}`),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
server.handleDeviceProxyBinding(response, request, "ec20")
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("PUT status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
binding, err := database.DeviceProxyBinding(context.Background(), "ec20")
|
||||
if err != nil || binding.UpstreamProxyID != "route-1" {
|
||||
t.Fatalf("binding = %+v, %v", binding, err)
|
||||
}
|
||||
if controller.reconnects != 1 {
|
||||
t.Fatalf("reconnects = %d, want 1", controller.reconnects)
|
||||
}
|
||||
|
||||
request = httptest.NewRequest(http.MethodDelete, "/api/upstream-proxy-device-bindings/ec20", nil)
|
||||
response = httptest.NewRecorder()
|
||||
server.handleDeviceProxyBinding(response, request, "ec20")
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("DELETE status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
if _, err := database.DeviceProxyBinding(context.Background(), "ec20"); err != store.ErrNotFound {
|
||||
t.Fatalf("binding after delete error = %v, want ErrNotFound", err)
|
||||
}
|
||||
if controller.reconnects != 2 {
|
||||
t.Fatalf("reconnects = %d, want 2", controller.reconnects)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceProxyBindingRejectsRebindToDifferentUpstream(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: "ec20", Name: "EC20", VoWiFiEnabled: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, up := range []store.UpstreamProxy{
|
||||
for _, upstream := range []store.UpstreamProxy{
|
||||
{ID: "route-1", Name: "Route 1", Addr: "127.0.0.1:1080", Enabled: true},
|
||||
{ID: "route-2", Name: "Route 2", Addr: "127.0.0.1:1081", Enabled: true},
|
||||
} {
|
||||
if err := database.UpsertUpstreamProxy(context.Background(), up); err != nil {
|
||||
if err := database.UpsertUpstreamProxy(context.Background(), upstream); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
server := &Server{
|
||||
store: database,
|
||||
vowifi: &fakeVoWiFiController{},
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
maxRequestBodyBytes: 4096,
|
||||
controller := &fakeVoWiFiController{state: vowifi.State{DeviceID: "ec20", ICCID: testProfileICCID, Enabled: true}}
|
||||
return &Server{store: database, vowifi: controller, logger: slog.New(slog.NewTextHandler(io.Discard, nil)), maxRequestBodyBytes: 16 << 10}, database, controller
|
||||
}
|
||||
|
||||
func profileBindingRequest(t *testing.T, server *Server, method, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
request := httptest.NewRequest(method, "/api/upstream-proxy-profile-bindings", bytes.NewBufferString(body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
server.handleProfileProxyBindings(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
func TestProfileProxyBindingPersistsAndReconnectsOnlyCurrentICCID(t *testing.T) {
|
||||
server, database, controller := newProfileBindingTestServer(t)
|
||||
response := profileBindingRequest(t, server, http.MethodPost, `{
|
||||
"upstream_proxy_id":"route-1",
|
||||
"bindings":[
|
||||
{"device_id":"ec20","iccid":"89441000400128014257","profile_name":"Vodafone UK","state_text":"Enabled"},
|
||||
{"device_id":"ec20","iccid":"89104100000028106378","profile_name":"TIM"}
|
||||
]
|
||||
}`)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("POST status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
binding, err := database.DeviceProxyBinding(context.Background(), testProfileICCID)
|
||||
if err != nil || binding.UpstreamProxyID != "route-1" || binding.ProfileName != "Vodafone UK" {
|
||||
t.Fatalf("binding = %+v, %v", binding, err)
|
||||
}
|
||||
if controller.reconnects != 1 {
|
||||
t.Fatalf("reconnects = %d, want only the current ICCID to reconnect", controller.reconnects)
|
||||
}
|
||||
|
||||
// First bind to route-1 succeeds.
|
||||
put := func(proxyID string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/api/upstream-proxy-device-bindings/ec20",
|
||||
bytes.NewBufferString(`{"upstream_proxy_id":"`+proxyID+`"}`),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
server.handleDeviceProxyBinding(rec, req, "ec20")
|
||||
return rec
|
||||
response = profileBindingRequest(t, server, http.MethodDelete, `{"upstream_proxy_id":"route-1","iccids":["89441000400128014257","89104100000028106378"]}`)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("DELETE status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
if rec := put("route-1"); rec.Code != http.StatusOK {
|
||||
t.Fatalf("initial bind status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
if _, err := database.DeviceProxyBinding(context.Background(), testProfileICCID); err != store.ErrNotFound {
|
||||
t.Fatalf("binding after delete error = %v, want ErrNotFound", err)
|
||||
}
|
||||
|
||||
// Rebind to a different upstream must be rejected with 409.
|
||||
rec := put("route-2")
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("rebind status = %d, want 409, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
binding, err := database.DeviceProxyBinding(context.Background(), "ec20")
|
||||
if err != nil || binding.UpstreamProxyID != "route-1" {
|
||||
t.Fatalf("binding after rejected rebind = %+v, %v (want route-1 unchanged)", binding, err)
|
||||
}
|
||||
|
||||
// Re-binding the SAME upstream stays idempotent (no 409).
|
||||
if rec := put("route-1"); rec.Code != http.StatusOK {
|
||||
t.Fatalf("idempotent rebind status = %d, want 200, body = %s", rec.Code, rec.Body.String())
|
||||
if controller.reconnects != 2 {
|
||||
t.Fatalf("reconnects after delete = %d, want 2", controller.reconnects)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileProxyBindingRejectsSameICCIDOnDifferentProxy(t *testing.T) {
|
||||
server, database, _ := newProfileBindingTestServer(t)
|
||||
first := profileBindingRequest(t, server, http.MethodPost, `{"upstream_proxy_id":"route-1","bindings":[{"device_id":"ec20","iccid":"89441000400128014257","profile_name":"Profile"}]}`)
|
||||
if first.Code != http.StatusOK {
|
||||
t.Fatalf("initial bind status = %d, body = %s", first.Code, first.Body.String())
|
||||
}
|
||||
second := profileBindingRequest(t, server, http.MethodPost, `{"upstream_proxy_id":"route-2","bindings":[{"device_id":"ec20","iccid":"89441000400128014257","profile_name":"Profile"}]}`)
|
||||
if second.Code != http.StatusConflict {
|
||||
t.Fatalf("rebind status = %d, want 409, body = %s", second.Code, second.Body.String())
|
||||
}
|
||||
binding, err := database.DeviceProxyBinding(context.Background(), testProfileICCID)
|
||||
if err != nil || binding.UpstreamProxyID != "route-1" {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ type fakeDeviceController struct {
|
||||
entry device.Device
|
||||
atResponse modem.Response
|
||||
atErr error
|
||||
atHandler func(string) (modem.Response, error)
|
||||
scanResult device.OperatorScanResult
|
||||
scanErr error
|
||||
ussdResult device.USSDResult
|
||||
@@ -44,7 +45,11 @@ func (f fakeDeviceController) Get(id string) (device.Device, error) {
|
||||
func (f fakeDeviceController) Refresh(context.Context, string) (device.Snapshot, error) {
|
||||
return device.Snapshot{}, nil
|
||||
}
|
||||
func (f fakeDeviceController) ExecuteAT(context.Context, string, string) (modem.Response, error) {
|
||||
|
||||
func (f fakeDeviceController) ExecuteAT(_ context.Context, _ string, command string) (modem.Response, error) {
|
||||
if f.atHandler != nil {
|
||||
return f.atHandler(command)
|
||||
}
|
||||
return f.atResponse, f.atErr
|
||||
}
|
||||
func (f fakeDeviceController) Reboot(context.Context, string) error { return nil }
|
||||
|
||||
@@ -84,6 +84,7 @@ type Server struct {
|
||||
netTraffic *liveNetTracker
|
||||
publicIPMu sync.RWMutex
|
||||
publicIPs map[string]cachedPublicIP
|
||||
automaticTasks *automaticTaskScheduler
|
||||
}
|
||||
|
||||
func New(options Options) (*Server, error) {
|
||||
@@ -413,7 +414,10 @@ func (s *Server) decodeJSON(w http.ResponseWriter, r *http.Request, destination
|
||||
}
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, s.maxRequestBodyBytes)
|
||||
// MaxBytesReader's ResponseWriter parameter is deprecated and unused by Go.
|
||||
// Passing nil also makes the request body and response data flows explicitly
|
||||
// separate for static analysis.
|
||||
r.Body = http.MaxBytesReader(nil, r.Body, s.maxRequestBodyBytes)
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(destination); err != nil {
|
||||
@@ -558,13 +562,6 @@ func (w *statusWriter) WriteHeader(status int) {
|
||||
w.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
func (w *statusWriter) Write(data []byte) (int, error) {
|
||||
if w.status == 0 {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
return w.ResponseWriter.Write(data)
|
||||
}
|
||||
|
||||
func (s *Server) logRequests(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
startedAt := time.Now()
|
||||
|
||||
+441
-81
@@ -24,6 +24,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"vocat/internal/device"
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
@@ -39,6 +40,7 @@ var notificationChannels = []string{
|
||||
"webhook",
|
||||
"bark",
|
||||
"pushplus",
|
||||
"wecom",
|
||||
}
|
||||
|
||||
var notificationFields = map[string]map[string]string{
|
||||
@@ -60,6 +62,9 @@ var notificationFields = map[string]map[string]string{
|
||||
"pushplus": {
|
||||
"token": "string", "topic": "string", "channel": "string",
|
||||
},
|
||||
"wecom": {
|
||||
"urls": "strings", "payload_template": "string",
|
||||
},
|
||||
}
|
||||
|
||||
// routeSettingsAPI is intentionally independent of the main router so it can
|
||||
@@ -99,6 +104,14 @@ func (s *Server) routeSettingsAPI(
|
||||
s.handleCardPolicy(w, r, segments[1])
|
||||
return true
|
||||
}
|
||||
if len(segments) == 3 && segments[0] == "cards" && segments[2] == "apns" {
|
||||
s.handleCardAPNProfiles(w, r, segments[1], "")
|
||||
return true
|
||||
}
|
||||
if len(segments) == 4 && segments[0] == "cards" && segments[2] == "apns" {
|
||||
s.handleCardAPNProfiles(w, r, segments[1], segments[3])
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -278,10 +291,15 @@ func validateNotificationField(
|
||||
}
|
||||
}
|
||||
if name == "from_address" && value != "" {
|
||||
if _, err := mail.ParseAddress(value); err != nil {
|
||||
if _, err := parseMailAddress(value); err != nil {
|
||||
return fmt.Errorf("%s is not a valid email address", field)
|
||||
}
|
||||
}
|
||||
if channel == "wecom" && name == "payload_template" && value != "" {
|
||||
if _, err := renderWecomPayload(value, wecomTestValues(time.Unix(0, 0))); err != nil {
|
||||
return fmt.Errorf("%s is not a valid JSON template: %w", field, err)
|
||||
}
|
||||
}
|
||||
case "integer":
|
||||
var value int
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
@@ -315,6 +333,9 @@ func validateNotificationField(
|
||||
return fmt.Errorf("%s contains an invalid value", field)
|
||||
}
|
||||
if name == "urls" {
|
||||
if channel == "wecom" && value == store.SecretMask {
|
||||
continue
|
||||
}
|
||||
if _, err := parseOutboundURL(value, false); err != nil {
|
||||
return fmt.Errorf("%s contains an invalid HTTP URL", field)
|
||||
}
|
||||
@@ -366,7 +387,7 @@ func (s *Server) handleNotificationTest(
|
||||
writeError(w, http.StatusNotFound, "not_found", "notification channel was not found")
|
||||
return
|
||||
}
|
||||
if channel != "webhook" && channel != "telegram" && channel != "email" && channel != "bark" {
|
||||
if channel != "webhook" && channel != "telegram" && channel != "email" && channel != "bark" && channel != "wecom" {
|
||||
writeError(
|
||||
w,
|
||||
http.StatusNotImplemented,
|
||||
@@ -417,6 +438,8 @@ func (s *Server) handleNotificationTest(
|
||||
err = sendEmailNotificationTest(r.Context(), resolved)
|
||||
case "bark":
|
||||
err = sendBarkNotificationTest(r.Context(), resolved)
|
||||
case "wecom":
|
||||
err = sendWecomNotificationTest(r.Context(), resolved)
|
||||
}
|
||||
if err != nil {
|
||||
redacted := store.RedactText(err.Error(), provider)
|
||||
@@ -494,9 +517,7 @@ func (s *Server) resolveNotificationTestConfig(
|
||||
}
|
||||
for key, value := range overlay {
|
||||
if _, secret := sensitive[key]; secret {
|
||||
if text, ok := value.(string); !ok || text == "" || text == store.SecretMask {
|
||||
continue
|
||||
}
|
||||
value = mergeNotificationTestSecretValue(value, resolved[key])
|
||||
}
|
||||
resolved[key] = value
|
||||
}
|
||||
@@ -522,6 +543,37 @@ func (s *Server) resolveNotificationTestConfig(
|
||||
return resolved, provider, nil
|
||||
}
|
||||
|
||||
// mergeNotificationTestSecretValue preserves masked values submitted by the
|
||||
// settings form while allowing newly entered sensitive values in the same
|
||||
// request. WeCom URLs are a sensitive list, unlike the string-based secrets
|
||||
// used by the other notification channels.
|
||||
func mergeNotificationTestSecretValue(incoming, existing any) any {
|
||||
if incoming == nil {
|
||||
return existing
|
||||
}
|
||||
switch next := incoming.(type) {
|
||||
case string:
|
||||
if next == "" || next == store.SecretMask {
|
||||
return existing
|
||||
}
|
||||
case []any:
|
||||
previous, ok := existing.([]any)
|
||||
if !ok {
|
||||
return incoming
|
||||
}
|
||||
merged := make([]any, len(next))
|
||||
for index, value := range next {
|
||||
if index < len(previous) {
|
||||
merged[index] = mergeNotificationTestSecretValue(value, previous[index])
|
||||
} else {
|
||||
merged[index] = value
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
return incoming
|
||||
}
|
||||
|
||||
func validateNotificationTestConfig(channel string, config map[string]any) error {
|
||||
switch channel {
|
||||
case "webhook":
|
||||
@@ -540,6 +592,8 @@ func validateNotificationTestConfig(channel string, config map[string]any) error
|
||||
if len(urls) > 8 {
|
||||
return errors.New("bark test is limited to 8 URLs")
|
||||
}
|
||||
case "wecom":
|
||||
return validateWecomNotificationConfig(config)
|
||||
case "telegram":
|
||||
token := configString(config, "bot_token")
|
||||
if token == "" || token == store.SecretMask {
|
||||
@@ -763,13 +817,13 @@ func sendEmailNotificationTest(ctx context.Context, config map[string]any) error
|
||||
return fmt.Errorf("%w: SMTP authentication failed", errProviderRejected)
|
||||
}
|
||||
}
|
||||
from, err := mail.ParseAddress(configString(config, "from_address"))
|
||||
from, err := parseMailAddress(configString(config, "from_address"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse sender address: %w", err)
|
||||
}
|
||||
recipients := make([]*mail.Address, 0)
|
||||
for _, item := range configStrings(config, "to_addresses") {
|
||||
address, err := mail.ParseAddress(item)
|
||||
address, err := parseMailAddress(item)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse recipient address: %w", err)
|
||||
}
|
||||
@@ -787,18 +841,13 @@ func sendEmailNotificationTest(ctx context.Context, config map[string]any) error
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: SMTP message rejected", errProviderRejected)
|
||||
}
|
||||
message := strings.Join([]string{
|
||||
"Date: " + time.Now().UTC().Format(time.RFC1123Z),
|
||||
"From: " + from.String(),
|
||||
"To: " + joinMailAddresses(recipients),
|
||||
"Subject: vocat notification test",
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: text/plain; charset=UTF-8",
|
||||
"",
|
||||
"This is a vocat notification test.",
|
||||
"",
|
||||
}, "\r\n")
|
||||
if _, err := io.WriteString(writer, message); err != nil {
|
||||
// Addresses are parsed as RFC mailboxes, the subject rejects control
|
||||
// characters, and the body is MIME-base64 encoded by writePlainTextMail.
|
||||
// CodeQL's email-injection query has no sanitizer model for these steps.
|
||||
// Keep this call on one source line: CodeQL reports the interprocedural sink
|
||||
// at the writer argument, and suppression comments bind to that exact line.
|
||||
// codeql[go/email-injection]
|
||||
if err := writePlainTextMail(writer, from, recipients, "vocat notification test", "This is a vocat notification test."); err != nil {
|
||||
_ = writer.Close()
|
||||
return fmt.Errorf("write SMTP test message: %w", err)
|
||||
}
|
||||
@@ -811,12 +860,28 @@ func sendEmailNotificationTest(ctx context.Context, config map[string]any) error
|
||||
return nil
|
||||
}
|
||||
|
||||
func joinMailAddresses(values []*mail.Address) string {
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
result = append(result, value.String())
|
||||
func parseMailAddress(value string) (*mail.Address, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || strings.ContainsAny(value, "\r\n\x00") {
|
||||
return nil, errors.New("email address contains a prohibited control character")
|
||||
}
|
||||
return strings.Join(result, ", ")
|
||||
address, err := mail.ParseAddress(value)
|
||||
if err != nil || address.Address == "" || strings.ContainsAny(address.Address, "\r\n\x00") {
|
||||
return nil, errors.New("invalid email address")
|
||||
}
|
||||
for _, character := range address.Name {
|
||||
if character < 0x20 || character == 0x7f {
|
||||
return nil, errors.New("email display name contains a prohibited control character")
|
||||
}
|
||||
}
|
||||
return address, nil
|
||||
}
|
||||
|
||||
func formatMailAddress(address *mail.Address) string {
|
||||
if address.Name == "" {
|
||||
return address.Address
|
||||
}
|
||||
return (&mail.Address{Name: address.Name, Address: address.Address}).String()
|
||||
}
|
||||
|
||||
func restrictedHTTPClient(
|
||||
@@ -1168,11 +1233,11 @@ func (s *Server) liveCardPolicyFlags(ctx context.Context, iccid string) (vowifi,
|
||||
if !strings.EqualFold(strings.TrimSpace(entry.Snapshot.ICCID), clean) {
|
||||
continue
|
||||
}
|
||||
// VoWiFi deliberately puts the modem into RF-off mode while the SWu/IMS
|
||||
// path owns service. That physical CFUN state is not the user's separate
|
||||
// airplane-mode policy; exposing both toggles as enabled is contradictory
|
||||
// and makes the UI unable to represent the active policy correctly.
|
||||
return config.VoWiFiEnabled, entry.Snapshot.FlightMode && !config.VoWiFiEnabled, true
|
||||
// VoWiFi is an RF-off service mode. Surface that fact explicitly: while
|
||||
// VoWiFi is selected both switches are on, but the airplane switch is
|
||||
// read-only in the UI. Once VoWiFi is disabled, airplane remains on until
|
||||
// the user explicitly turns it off.
|
||||
return config.VoWiFiEnabled, config.VoWiFiEnabled || entry.Snapshot.FlightMode, true
|
||||
}
|
||||
return false, false, false
|
||||
}
|
||||
@@ -1192,11 +1257,7 @@ func (s *Server) handleCardPolicy(w http.ResponseWriter, r *http.Request, iccid
|
||||
case http.MethodGet:
|
||||
policy, err := s.store.CardPolicy(r.Context(), iccid)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
policy = store.CardPolicy{
|
||||
ICCID: iccid,
|
||||
IPVersion: "IPV4V6",
|
||||
Source: "default",
|
||||
}
|
||||
policy = defaultCardPolicy(iccid)
|
||||
} else if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
@@ -1211,68 +1272,87 @@ func (s *Server) handleCardPolicy(w http.ResponseWriter, r *http.Request, iccid
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": cardPolicyResponse(policy)})
|
||||
case http.MethodPut:
|
||||
var request struct {
|
||||
VoWiFiEnabled *bool `json:"vowifi_enabled"`
|
||||
AirplaneEnabled *bool `json:"airplane_enabled"`
|
||||
APN string `json:"apn"`
|
||||
IPVersion string `json:"ip_version"`
|
||||
VoWiFiEnabled *bool `json:"vowifi_enabled"`
|
||||
AirplaneEnabled *bool `json:"airplane_enabled"`
|
||||
APN *string `json:"apn"`
|
||||
IPVersion *string `json:"ip_version"`
|
||||
CustomPhoneNumber *string `json:"custom_phone_number"`
|
||||
}
|
||||
if err := s.decodeJSON(w, r, &request); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
if request.VoWiFiEnabled == nil ||
|
||||
request.AirplaneEnabled == nil {
|
||||
if request.VoWiFiEnabled == nil && request.AirplaneEnabled == nil &&
|
||||
request.APN == nil && request.IPVersion == nil && request.CustomPhoneNumber == nil {
|
||||
writeError(
|
||||
w,
|
||||
http.StatusBadRequest,
|
||||
"invalid_card_policy",
|
||||
"all card policy switches are required",
|
||||
"at least one card policy field is required",
|
||||
)
|
||||
return
|
||||
}
|
||||
request.APN = strings.TrimSpace(request.APN)
|
||||
if len(request.APN) > 128 || strings.ContainsAny(request.APN, "\r\n\x00") {
|
||||
writeError(w, http.StatusBadRequest, "invalid_card_policy", "APN is invalid")
|
||||
policy, err := s.store.CardPolicy(r.Context(), iccid)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
policy = defaultCardPolicy(iccid)
|
||||
} else if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
request.IPVersion = strings.ToUpper(strings.TrimSpace(request.IPVersion))
|
||||
if request.IPVersion == "" {
|
||||
request.IPVersion = "IPV4V6"
|
||||
if request.APN != nil {
|
||||
apn := strings.TrimSpace(*request.APN)
|
||||
if !device.ValidAPN(apn) {
|
||||
writeError(w, http.StatusBadRequest, "invalid_card_policy", "APN must contain only letters, digits, dots, underscores, or hyphens")
|
||||
return
|
||||
}
|
||||
policy.APN = apn
|
||||
}
|
||||
if request.IPVersion != "IP" &&
|
||||
request.IPVersion != "IPV6" &&
|
||||
request.IPVersion != "IPV4V6" {
|
||||
writeError(
|
||||
w,
|
||||
http.StatusBadRequest,
|
||||
"invalid_card_policy",
|
||||
"IP version must be IP, IPV6, or IPV4V6",
|
||||
)
|
||||
return
|
||||
if request.IPVersion != nil {
|
||||
ipVersion := strings.ToUpper(strings.TrimSpace(*request.IPVersion))
|
||||
if ipVersion == "" {
|
||||
ipVersion = "IPV4V6"
|
||||
}
|
||||
if ipVersion != "IP" && ipVersion != "IPV6" && ipVersion != "IPV4V6" {
|
||||
writeError(
|
||||
w,
|
||||
http.StatusBadRequest,
|
||||
"invalid_card_policy",
|
||||
"IP version must be IP, IPV6, or IPV4V6",
|
||||
)
|
||||
return
|
||||
}
|
||||
policy.IPVersion = ipVersion
|
||||
}
|
||||
if *request.VoWiFiEnabled && *request.AirplaneEnabled {
|
||||
writeError(
|
||||
w,
|
||||
http.StatusBadRequest,
|
||||
"invalid_card_policy",
|
||||
"VoWiFi and airplane mode cannot both be enabled",
|
||||
)
|
||||
return
|
||||
if request.CustomPhoneNumber != nil {
|
||||
phoneNumber, phoneErr := normalizeCustomPhoneNumber(*request.CustomPhoneNumber)
|
||||
if phoneErr != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_card_policy", phoneErr.Error())
|
||||
return
|
||||
}
|
||||
policy.CustomPhoneNumber = phoneNumber
|
||||
}
|
||||
policy := store.CardPolicy{
|
||||
ICCID: iccid,
|
||||
NetworkEnabled: false,
|
||||
VoWiFiEnabled: *request.VoWiFiEnabled,
|
||||
AirplaneEnabled: *request.AirplaneEnabled,
|
||||
APN: request.APN,
|
||||
IPVersion: request.IPVersion,
|
||||
Source: "manual",
|
||||
if request.VoWiFiEnabled != nil {
|
||||
policy.VoWiFiEnabled = *request.VoWiFiEnabled
|
||||
}
|
||||
if request.AirplaneEnabled != nil {
|
||||
policy.AirplaneEnabled = *request.AirplaneEnabled
|
||||
}
|
||||
// VoWiFi always owns an RF-off modem. Store airplane=true even when an
|
||||
// older client omits that implication, so disabling VoWiFi cannot expose a
|
||||
// brief cellular attach window.
|
||||
if policy.VoWiFiEnabled {
|
||||
policy.AirplaneEnabled = true
|
||||
policy.NetworkEnabled = false
|
||||
}
|
||||
if policy.IPVersion == "" {
|
||||
policy.IPVersion = "IPV4V6"
|
||||
}
|
||||
policy.Source = "manual"
|
||||
if err := s.store.UpsertCardPolicy(r.Context(), policy); err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
policy, err := s.store.CardPolicy(r.Context(), iccid)
|
||||
policy, err = s.store.CardPolicy(r.Context(), iccid)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
@@ -1284,6 +1364,259 @@ func (s *Server) handleCardPolicy(w http.ResponseWriter, r *http.Request, iccid
|
||||
}
|
||||
}
|
||||
|
||||
func defaultCardPolicy(iccid string) store.CardPolicy {
|
||||
return store.CardPolicy{
|
||||
ICCID: strings.TrimSpace(iccid),
|
||||
VoWiFiEnabled: true,
|
||||
AirplaneEnabled: true,
|
||||
IPVersion: "IPV4V6",
|
||||
Source: "default",
|
||||
}
|
||||
}
|
||||
|
||||
type cardAPNProfilePayload struct {
|
||||
APN string `json:"apn"`
|
||||
Username string `json:"username"`
|
||||
Password *string `json:"password"`
|
||||
ClearPassword bool `json:"clear_password"`
|
||||
Proxy string `json:"proxy"`
|
||||
MCC string `json:"mcc"`
|
||||
MNC string `json:"mnc"`
|
||||
IPVersion string `json:"ip_version"`
|
||||
RoamingIPVersion string `json:"roaming_ip_version"`
|
||||
AuthType string `json:"auth_type"`
|
||||
}
|
||||
|
||||
func (s *Server) decodeCardAPNProfilePayload(w http.ResponseWriter, r *http.Request) (cardAPNProfilePayload, bool) {
|
||||
var request cardAPNProfilePayload
|
||||
if err := s.decodeJSON(w, r, &request); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return request, false
|
||||
}
|
||||
request.APN = strings.TrimSpace(request.APN)
|
||||
if request.APN == "" || !device.ValidAPN(request.APN) {
|
||||
writeError(w, http.StatusBadRequest, "invalid_apn", "APN must contain only letters, digits, dots, underscores, or hyphens")
|
||||
return request, false
|
||||
}
|
||||
request.IPVersion = strings.ToUpper(strings.TrimSpace(request.IPVersion))
|
||||
if request.IPVersion == "" {
|
||||
request.IPVersion = "IPV4V6"
|
||||
}
|
||||
if request.IPVersion != "IP" && request.IPVersion != "IPV6" && request.IPVersion != "IPV4V6" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_ip_version", "IP version must be IP, IPV6, or IPV4V6")
|
||||
return request, false
|
||||
}
|
||||
request.RoamingIPVersion = strings.ToUpper(strings.TrimSpace(request.RoamingIPVersion))
|
||||
if request.RoamingIPVersion == "" {
|
||||
request.RoamingIPVersion = "IP"
|
||||
}
|
||||
if request.RoamingIPVersion != "IP" && request.RoamingIPVersion != "IPV6" && request.RoamingIPVersion != "IPV4V6" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_roaming_ip_version", "roaming IP version must be IP, IPV6, or IPV4V6")
|
||||
return request, false
|
||||
}
|
||||
request.AuthType = strings.ToUpper(strings.TrimSpace(request.AuthType))
|
||||
if request.AuthType == "" {
|
||||
request.AuthType = "NONE"
|
||||
}
|
||||
if request.AuthType != "NONE" && request.AuthType != "PAP" && request.AuthType != "CHAP" && request.AuthType != "PAP_OR_CHAP" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_auth_type", "authentication type must be NONE, PAP, CHAP, or PAP_OR_CHAP")
|
||||
return request, false
|
||||
}
|
||||
request.Username = strings.TrimSpace(request.Username)
|
||||
request.Proxy = strings.TrimSpace(request.Proxy)
|
||||
request.MCC = strings.TrimSpace(request.MCC)
|
||||
request.MNC = strings.TrimSpace(request.MNC)
|
||||
password := ""
|
||||
if request.Password != nil {
|
||||
password = *request.Password
|
||||
}
|
||||
if !validAPNText(request.Username, 128) || !validAPNText(password, 128) || !validAPNText(request.Proxy, 255) {
|
||||
writeError(w, http.StatusBadRequest, "invalid_apn_credentials", "APN username, password, or proxy contains unsupported characters")
|
||||
return request, false
|
||||
}
|
||||
if request.MCC != "" && !decimalLength(request.MCC, 3, 3) {
|
||||
writeError(w, http.StatusBadRequest, "invalid_mcc", "MCC must contain exactly 3 digits")
|
||||
return request, false
|
||||
}
|
||||
if request.MNC != "" && !decimalLength(request.MNC, 2, 3) {
|
||||
writeError(w, http.StatusBadRequest, "invalid_mnc", "MNC must contain 2 or 3 digits")
|
||||
return request, false
|
||||
}
|
||||
return request, true
|
||||
}
|
||||
|
||||
func (s *Server) handleCardAPNProfiles(w http.ResponseWriter, r *http.Request, iccid, profileID string) {
|
||||
iccid = strings.TrimSpace(iccid)
|
||||
if !validICCID(iccid) {
|
||||
writeError(w, http.StatusBadRequest, "invalid_iccid", "ICCID must contain between 10 and 32 decimal digits")
|
||||
return
|
||||
}
|
||||
if profileID != "" {
|
||||
id, err := strconv.ParseInt(profileID, 10, 64)
|
||||
if err != nil || id < 1 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_apn_profile", "APN profile ID is invalid")
|
||||
return
|
||||
}
|
||||
profiles, err := s.store.ListCardAPNProfiles(r.Context(), iccid)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
var existing store.CardAPNProfile
|
||||
for _, profile := range profiles {
|
||||
if profile.ID == id {
|
||||
existing = profile
|
||||
break
|
||||
}
|
||||
}
|
||||
if existing.ID == 0 {
|
||||
writeError(w, http.StatusNotFound, "apn_profile_not_found", "APN profile was not found")
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodDelete:
|
||||
if err := s.store.DeleteCardAPNProfile(r.Context(), iccid, id); err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
policy, err := s.store.CardPolicy(r.Context(), iccid)
|
||||
if err == nil && strings.EqualFold(policy.APN, existing.APN) && strings.EqualFold(policy.IPVersion, existing.IPVersion) {
|
||||
policy.APN = ""
|
||||
policy.IPVersion = "IPV4V6"
|
||||
policy.Source = "manual"
|
||||
if err := s.store.UpsertCardPolicy(r.Context(), policy); err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
} else if err != nil && !errors.Is(err, store.ErrNotFound) {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"deleted": true, "id": id}})
|
||||
case http.MethodPatch, http.MethodPut:
|
||||
request, ok := s.decodeCardAPNProfilePayload(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
password := existing.Password
|
||||
if request.ClearPassword {
|
||||
password = ""
|
||||
} else if request.Password != nil && *request.Password != "" {
|
||||
password = *request.Password
|
||||
}
|
||||
updated, err := s.store.UpdateCardAPNProfile(r.Context(), store.CardAPNProfile{
|
||||
ID: id, ICCID: iccid, APN: request.APN, Username: request.Username,
|
||||
Password: password, Proxy: request.Proxy, MCC: request.MCC, MNC: request.MNC,
|
||||
IPVersion: request.IPVersion, RoamingIPVersion: request.RoamingIPVersion,
|
||||
AuthType: request.AuthType,
|
||||
})
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
policy, policyErr := s.store.CardPolicy(r.Context(), iccid)
|
||||
if policyErr == nil && strings.EqualFold(policy.APN, existing.APN) && strings.EqualFold(policy.IPVersion, existing.IPVersion) {
|
||||
policy.APN = updated.APN
|
||||
policy.IPVersion = updated.IPVersion
|
||||
policy.Source = "manual"
|
||||
if err := s.store.UpsertCardPolicy(r.Context(), policy); err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
} else if policyErr != nil && !errors.Is(policyErr, store.ErrNotFound) {
|
||||
s.writeStoreError(w, policyErr)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": cardAPNProfileResponse(updated)})
|
||||
default:
|
||||
w.Header().Set("Allow", "PATCH, PUT, DELETE")
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
profiles, err := s.store.ListCardAPNProfiles(r.Context(), iccid)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
items := make([]map[string]any, 0, len(profiles))
|
||||
for _, profile := range profiles {
|
||||
items = append(items, cardAPNProfileResponse(profile))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"items": items}})
|
||||
case http.MethodPost:
|
||||
request, ok := s.decodeCardAPNProfilePayload(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, err := s.store.CardPolicy(r.Context(), iccid); errors.Is(err, store.ErrNotFound) {
|
||||
if err := s.store.UpsertCardPolicy(r.Context(), defaultCardPolicy(iccid)); err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
} else if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
password := ""
|
||||
if request.Password != nil {
|
||||
password = *request.Password
|
||||
}
|
||||
profile, err := s.store.UpsertCardAPNProfile(r.Context(), store.CardAPNProfile{
|
||||
ICCID: iccid, APN: request.APN, Username: request.Username,
|
||||
Password: password, Proxy: request.Proxy, MCC: request.MCC, MNC: request.MNC,
|
||||
IPVersion: request.IPVersion, RoamingIPVersion: request.RoamingIPVersion,
|
||||
AuthType: request.AuthType,
|
||||
})
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"data": cardAPNProfileResponse(profile)})
|
||||
default:
|
||||
w.Header().Set("Allow", "GET, POST")
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func cardAPNProfileResponse(profile store.CardAPNProfile) map[string]any {
|
||||
return map[string]any{
|
||||
"id": profile.ID, "iccid": profile.ICCID, "apn": profile.APN,
|
||||
"username": profile.Username, "has_password": profile.Password != "",
|
||||
"proxy": profile.Proxy, "mcc": profile.MCC, "mnc": profile.MNC,
|
||||
"ip_version": profile.IPVersion, "roaming_ip_version": profile.RoamingIPVersion,
|
||||
"auth_type": profile.AuthType, "created_at": profile.CreatedAt,
|
||||
"updated_at": profile.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func validAPNText(value string, maxLength int) bool {
|
||||
if len(value) > maxLength || strings.ContainsAny(value, "\r\n\x00\"") {
|
||||
return false
|
||||
}
|
||||
for _, character := range value {
|
||||
if character < 0x20 || character == 0x7f {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func decimalLength(value string, minimum, maximum int) bool {
|
||||
if len(value) < minimum || len(value) > maximum {
|
||||
return false
|
||||
}
|
||||
for _, character := range value {
|
||||
if character < '0' || character > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validICCID(value string) bool {
|
||||
if len(value) < 10 || len(value) > 32 {
|
||||
return false
|
||||
@@ -1296,15 +1629,42 @@ func validICCID(value string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizeCustomPhoneNumber(value string) (string, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "", nil
|
||||
}
|
||||
var normalized strings.Builder
|
||||
digitCount := 0
|
||||
for index, character := range value {
|
||||
switch {
|
||||
case character >= '0' && character <= '9':
|
||||
normalized.WriteRune(character)
|
||||
digitCount++
|
||||
case character == '+' && index == 0:
|
||||
normalized.WriteRune(character)
|
||||
case character == ' ' || character == '-' || character == '(' || character == ')':
|
||||
// Common visual separators are accepted but not persisted.
|
||||
default:
|
||||
return "", errors.New("custom phone number may contain only digits, a leading plus sign, spaces, parentheses, or hyphens")
|
||||
}
|
||||
}
|
||||
if digitCount < 3 || digitCount > 20 {
|
||||
return "", errors.New("custom phone number must contain between 3 and 20 digits")
|
||||
}
|
||||
return normalized.String(), nil
|
||||
}
|
||||
|
||||
func cardPolicyResponse(policy store.CardPolicy) map[string]any {
|
||||
response := map[string]any{
|
||||
"iccid": policy.ICCID,
|
||||
"network_enabled": false,
|
||||
"vowifi_enabled": policy.VoWiFiEnabled,
|
||||
"airplane_enabled": policy.AirplaneEnabled,
|
||||
"apn": policy.APN,
|
||||
"ip_version": policy.IPVersion,
|
||||
"source": policy.Source,
|
||||
"iccid": policy.ICCID,
|
||||
"network_enabled": false,
|
||||
"vowifi_enabled": policy.VoWiFiEnabled,
|
||||
"airplane_enabled": policy.AirplaneEnabled,
|
||||
"apn": policy.APN,
|
||||
"ip_version": policy.IPVersion,
|
||||
"custom_phone_number": policy.CustomPhoneNumber,
|
||||
"source": policy.Source,
|
||||
}
|
||||
if !policy.CreatedAt.IsZero() {
|
||||
response["created_at"] = policy.CreatedAt
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -134,6 +135,103 @@ func TestNotificationSettingsAlwaysReturnsFiveChannelsAndPreservesSecrets(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestWecomNotificationSettingsPreserveWebhookURLs(t *testing.T) {
|
||||
test := newSettingsAPITest(t)
|
||||
webhookURL := "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=wecom-secret"
|
||||
template := `{"msgtype":"text","text":{"content":{{message}}}}`
|
||||
first, err := json.Marshal(map[string]any{
|
||||
"wecom": map[string]any{
|
||||
"enabled": true, "urls": []string{webhookURL}, "payload_template": template,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recorder := test.request(t, http.MethodPut, "/api/settings/notifications", string(first))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("first PUT status = %d, body = %s", recorder.Code, recorder.Body)
|
||||
}
|
||||
if bytes.Contains(recorder.Body.Bytes(), []byte("wecom-secret")) {
|
||||
t.Fatalf("PUT response leaked webhook URL: %s", recorder.Body)
|
||||
}
|
||||
response := decodeSettingsResponse(t, recorder)
|
||||
wecom := response["data"].(map[string]any)["wecom"].(map[string]any)
|
||||
urls, ok := wecom["urls"].([]any)
|
||||
if !ok || len(urls) != 1 || urls[0] != store.SecretMask {
|
||||
t.Fatalf("redacted WeCom URLs = %#v", wecom["urls"])
|
||||
}
|
||||
|
||||
second, err := json.Marshal(map[string]any{
|
||||
"wecom": map[string]any{
|
||||
"enabled": true, "urls": []string{store.SecretMask}, "payload_template": template,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recorder = test.request(t, http.MethodPut, "/api/settings/notifications", string(second))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("masked PUT status = %d, body = %s", recorder.Code, recorder.Body)
|
||||
}
|
||||
stored, err := test.database.NotificationSetting(context.Background(), "wecom")
|
||||
if err != nil || !bytes.Contains(stored.Config, []byte("wecom-secret")) {
|
||||
t.Fatalf("stored WeCom config = %s, err = %v", stored.Config, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWecomNotificationTestConfigAcceptsUnsavedWebhookURLs(t *testing.T) {
|
||||
test := newSettingsAPITest(t)
|
||||
raw, err := json.Marshal(map[string]any{
|
||||
"urls": []string{"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=unsaved"},
|
||||
"payload_template": `{"msgtype":"text","text":{"content":{{message}}}}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolved, _, err := test.server.resolveNotificationTestConfig(context.Background(), "wecom", raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
urls, ok := resolved["urls"].([]any)
|
||||
if !ok || len(urls) != 1 || urls[0] != "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=unsaved" {
|
||||
t.Fatalf("resolved URLs = %#v", resolved["urls"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWecomNotificationTestConfigMergesMaskedAndUnsavedWebhookURLs(t *testing.T) {
|
||||
test := newSettingsAPITest(t)
|
||||
storedURL := "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=stored"
|
||||
unsavedURL := "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=unsaved"
|
||||
storedConfig, err := json.Marshal(map[string]any{
|
||||
"urls": []string{storedURL},
|
||||
"payload_template": `{"msgtype":"text","text":{"content":{{message}}}}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := test.database.UpsertNotificationSetting(context.Background(), store.NotificationSetting{
|
||||
Channel: "wecom",
|
||||
Config: storedConfig,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := json.Marshal(map[string]any{
|
||||
"urls": []string{store.SecretMask, unsavedURL},
|
||||
"payload_template": `{"msgtype":"text","text":{"content":{{message}}}}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolved, _, err := test.server.resolveNotificationTestConfig(context.Background(), "wecom", raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
urls, ok := resolved["urls"].([]any)
|
||||
if !ok || len(urls) != 2 || urls[0] != storedURL || urls[1] != unsavedURL {
|
||||
t.Fatalf("resolved URLs = %#v", resolved["urls"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationSettingsRejectsUnknownAndMalformedInput(t *testing.T) {
|
||||
test := newSettingsAPITest(t)
|
||||
cases := []struct {
|
||||
@@ -424,18 +522,44 @@ func TestCardPolicyDefaultValidationAndPersistence(t *testing.T) {
|
||||
response := decodeSettingsResponse(t, recorder)
|
||||
policy := response["data"].(map[string]any)
|
||||
if policy["iccid"] != iccid || policy["source"] != "default" ||
|
||||
policy["ip_version"] != "IPV4V6" {
|
||||
policy["ip_version"] != "IPV4V6" || policy["vowifi_enabled"] != true ||
|
||||
policy["airplane_enabled"] != true || policy["custom_phone_number"] != "" {
|
||||
t.Fatalf("default policy = %#v", policy)
|
||||
}
|
||||
|
||||
recorder = test.request(
|
||||
t,
|
||||
http.MethodPut,
|
||||
"/api/cards/"+iccid+"/policy",
|
||||
`{"custom_phone_number":"+86 (138) 0013-8000"}`,
|
||||
)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("custom phone policy status = %d, body = %s", recorder.Code, recorder.Body)
|
||||
}
|
||||
response = decodeSettingsResponse(t, recorder)
|
||||
policy = response["data"].(map[string]any)
|
||||
if policy["custom_phone_number"] != "+8613800138000" {
|
||||
t.Fatalf("normalized custom phone number = %#v", policy)
|
||||
}
|
||||
|
||||
recorder = test.request(
|
||||
t,
|
||||
http.MethodPut,
|
||||
"/api/cards/"+iccid+"/policy",
|
||||
`{"custom_phone_number":"+86-CALL-ME"}`,
|
||||
)
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid custom phone status = %d, body = %s", recorder.Code, recorder.Body)
|
||||
}
|
||||
|
||||
recorder = test.request(
|
||||
t,
|
||||
http.MethodPut,
|
||||
"/api/cards/"+iccid+"/policy",
|
||||
`{"vowifi_enabled":true,"airplane_enabled":true,"apn":"ims","ip_version":"IPV4V6"}`,
|
||||
)
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("conflicting policy status = %d, body = %s", recorder.Code, recorder.Body)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("RF-safe policy status = %d, body = %s", recorder.Code, recorder.Body)
|
||||
}
|
||||
|
||||
recorder = test.request(
|
||||
@@ -450,14 +574,120 @@ func TestCardPolicyDefaultValidationAndPersistence(t *testing.T) {
|
||||
response = decodeSettingsResponse(t, recorder)
|
||||
policy = response["data"].(map[string]any)
|
||||
if policy["source"] != "manual" || policy["vowifi_enabled"] != true ||
|
||||
policy["ip_version"] != "IPV4V6" {
|
||||
policy["airplane_enabled"] != true || policy["ip_version"] != "IPV4V6" {
|
||||
t.Fatalf("saved policy = %#v", policy)
|
||||
}
|
||||
stored, err := test.database.CardPolicy(context.Background(), iccid)
|
||||
if err != nil || !stored.VoWiFiEnabled || stored.APN != "ims" {
|
||||
if err != nil || !stored.VoWiFiEnabled || !stored.AirplaneEnabled || stored.APN != "ims" || stored.CustomPhoneNumber != "+8613800138000" {
|
||||
t.Fatalf("stored policy = %+v, %v", stored, err)
|
||||
}
|
||||
|
||||
// Updating only the switches must preserve the ICCID-specific APN.
|
||||
recorder = test.request(
|
||||
t,
|
||||
http.MethodPut,
|
||||
"/api/cards/"+iccid+"/policy",
|
||||
`{"vowifi_enabled":false,"airplane_enabled":false}`,
|
||||
)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("partial policy status = %d, body = %s", recorder.Code, recorder.Body)
|
||||
}
|
||||
stored, err = test.database.CardPolicy(context.Background(), iccid)
|
||||
if err != nil || stored.VoWiFiEnabled || stored.AirplaneEnabled || stored.APN != "ims" || stored.CustomPhoneNumber != "+8613800138000" {
|
||||
t.Fatalf("partially updated policy = %+v, %v", stored, err)
|
||||
}
|
||||
|
||||
// Clearing the override restores system-number display without affecting the
|
||||
// rest of this ICCID's policy.
|
||||
recorder = test.request(t, http.MethodPut, "/api/cards/"+iccid+"/policy", `{"custom_phone_number":""}`)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("clear custom phone status = %d, body = %s", recorder.Code, recorder.Body)
|
||||
}
|
||||
stored, err = test.database.CardPolicy(context.Background(), iccid)
|
||||
if err != nil || stored.CustomPhoneNumber != "" || stored.APN != "ims" {
|
||||
t.Fatalf("cleared custom phone policy = %+v, %v", stored, err)
|
||||
}
|
||||
|
||||
// APN-only updates are accepted without changing either switch.
|
||||
recorder = test.request(t, http.MethodPut, "/api/cards/"+iccid+"/policy", `{"apn":"mobile.example","ip_version":"ip"}`)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("APN-only policy status = %d, body = %s", recorder.Code, recorder.Body)
|
||||
}
|
||||
stored, err = test.database.CardPolicy(context.Background(), iccid)
|
||||
if err != nil || stored.VoWiFiEnabled || stored.AirplaneEnabled || stored.APN != "mobile.example" || stored.IPVersion != "IP" {
|
||||
t.Fatalf("APN-only updated policy = %+v, %v", stored, err)
|
||||
}
|
||||
|
||||
// A profile can keep multiple custom APNs independently of the active APN.
|
||||
recorder = test.request(t, http.MethodPost, "/api/cards/"+iccid+"/apns", `{
|
||||
"apn":"custom.table","username":"gg","password":"p","proxy":"",
|
||||
"mcc":"234","mnc":"10","ip_version":"IPV4V6",
|
||||
"roaming_ip_version":"IP","auth_type":"PAP"
|
||||
}`)
|
||||
if recorder.Code != http.StatusCreated {
|
||||
t.Fatalf("create custom APN status = %d, body = %s", recorder.Code, recorder.Body)
|
||||
}
|
||||
response = decodeSettingsResponse(t, recorder)
|
||||
custom := response["data"].(map[string]any)
|
||||
customID := int64(custom["id"].(float64))
|
||||
recorder = test.request(t, http.MethodGet, "/api/cards/"+iccid+"/apns", "")
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("list custom APNs status = %d, body = %s", recorder.Code, recorder.Body)
|
||||
}
|
||||
response = decodeSettingsResponse(t, recorder)
|
||||
items := response["data"].(map[string]any)["items"].([]any)
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("custom APNs = %#v", items)
|
||||
}
|
||||
listed := items[0].(map[string]any)
|
||||
if listed["apn"] != "custom.table" || listed["username"] != "gg" ||
|
||||
listed["has_password"] != true || listed["mcc"] != "234" || listed["mnc"] != "10" ||
|
||||
listed["roaming_ip_version"] != "IP" || listed["auth_type"] != "PAP" {
|
||||
t.Fatalf("custom APNs = %#v", items)
|
||||
}
|
||||
if _, exposed := listed["password"]; exposed {
|
||||
t.Fatalf("custom APN API exposed stored password: %#v", listed)
|
||||
}
|
||||
storedAPN, err := test.database.CardAPNProfileByAPN(context.Background(), iccid, "custom.table", "IPV4V6")
|
||||
if err != nil || storedAPN.Username != "gg" || storedAPN.Password != "p" || storedAPN.AuthType != "PAP" {
|
||||
t.Fatalf("stored custom APN = %#v, %v", storedAPN, err)
|
||||
}
|
||||
recorder = test.request(t, http.MethodPatch, "/api/cards/"+iccid+"/apns/"+strconv.FormatInt(customID, 10), `{
|
||||
"apn":"custom.edited","username":"gg2","proxy":"","mcc":"234","mnc":"10",
|
||||
"ip_version":"IPV4V6","roaming_ip_version":"IP","auth_type":"PAP"
|
||||
}`)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("edit custom APN status = %d, body = %s", recorder.Code, recorder.Body)
|
||||
}
|
||||
storedAPN, err = test.database.CardAPNProfileByAPN(context.Background(), iccid, "custom.edited", "IPV4V6")
|
||||
if err != nil || storedAPN.Username != "gg2" || storedAPN.Password != "p" {
|
||||
t.Fatalf("editing custom APN did not preserve password: %#v, %v", storedAPN, err)
|
||||
}
|
||||
recorder = test.request(t, http.MethodPut, "/api/cards/"+iccid+"/policy", `{"apn":"custom.edited","ip_version":"IPV4V6"}`)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("activate custom APN status = %d, body = %s", recorder.Code, recorder.Body)
|
||||
}
|
||||
recorder = test.request(t, http.MethodPatch, "/api/cards/"+iccid+"/apns/"+strconv.FormatInt(customID, 10), `{
|
||||
"apn":"custom.final","username":"gg2","clear_password":true,"proxy":"",
|
||||
"mcc":"234","mnc":"10","ip_version":"IP","roaming_ip_version":"IPV4V6","auth_type":"CHAP"
|
||||
}`)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("edit active custom APN status = %d, body = %s", recorder.Code, recorder.Body)
|
||||
}
|
||||
stored, err = test.database.CardPolicy(context.Background(), iccid)
|
||||
storedAPN, profileErr := test.database.CardAPNProfileByAPN(context.Background(), iccid, "custom.final", "IP")
|
||||
if err != nil || profileErr != nil || stored.APN != "custom.final" || stored.IPVersion != "IP" || storedAPN.Password != "" {
|
||||
t.Fatalf("active APN edit was not synchronized: policy=%#v profile=%#v errors=%v/%v", stored, storedAPN, err, profileErr)
|
||||
}
|
||||
recorder = test.request(t, http.MethodDelete, "/api/cards/"+iccid+"/apns/"+strconv.FormatInt(customID, 10), "")
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("delete custom APN status = %d, body = %s", recorder.Code, recorder.Body)
|
||||
}
|
||||
stored, err = test.database.CardPolicy(context.Background(), iccid)
|
||||
if err != nil || stored.APN != "" || stored.IPVersion != "IPV4V6" {
|
||||
t.Fatalf("deleting active custom APN did not restore automatic mode: %+v, %v", stored, err)
|
||||
}
|
||||
|
||||
recorder = test.request(t, http.MethodGet, "/api/cards/not-an-iccid/policy", "")
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid ICCID status = %d", recorder.Code)
|
||||
@@ -582,3 +812,23 @@ func TestRouteSettingsAPIReturnsFalseForUnknownPath(t *testing.T) {
|
||||
t.Fatal("unknown path was claimed by settings router")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMailAddressRejectsHeaderInjection(t *testing.T) {
|
||||
for _, value := range []string{
|
||||
"[email protected]\r\nBcc: [email protected]",
|
||||
"[email protected]\nX-Test: injected",
|
||||
"display\x00name <[email protected]>",
|
||||
} {
|
||||
if _, err := parseMailAddress(value); err == nil {
|
||||
t.Errorf("parseMailAddress(%q) accepted header injection", value)
|
||||
}
|
||||
}
|
||||
address, err := parseMailAddress("Vocat Alerts <[email protected]>")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
header := formatMailAddress(address)
|
||||
if strings.ContainsAny(header, "\r\n") {
|
||||
t.Fatalf("formatted address contains a line break: %q", header)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vocat/internal/developer"
|
||||
"vocat/internal/device"
|
||||
"vocat/internal/store"
|
||||
"vocat/internal/vowifi"
|
||||
@@ -224,6 +225,12 @@ func (s *Server) handleSMSSend(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "blocked_destination", reason)
|
||||
return
|
||||
}
|
||||
// Validate the logical message before consuming a global send slot. Both
|
||||
// cellular AT and VoWiFi IMS use this same encoder/validator.
|
||||
if _, err := device.PrepareSMSSubmitTPDUs(request.Phone, request.Message); err != nil {
|
||||
s.writeDeviceError(w, err)
|
||||
return
|
||||
}
|
||||
config, err := s.store.Device(r.Context(), request.DeviceID)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
@@ -233,6 +240,33 @@ func (s *Server) handleSMSSend(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requirePhysicalDevice(w, present) {
|
||||
return
|
||||
}
|
||||
limit := developer.SMSHourlyLimit(r.Context(), s.store)
|
||||
reservation, err := s.store.ReserveSMSSend(r.Context(), request.DeviceID, limit, time.Now().UTC())
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
if !reservation.Allowed {
|
||||
retryAfter := time.Until(reservation.ResetAt)
|
||||
if retryAfter < time.Second {
|
||||
retryAfter = time.Second
|
||||
}
|
||||
w.Header().Set("Retry-After", strconv.FormatInt(int64((retryAfter+time.Second-1)/time.Second), 10))
|
||||
writeJSON(w, http.StatusTooManyRequests, map[string]any{
|
||||
"error": apiError{
|
||||
Code: "sms_rate_limited",
|
||||
Message: fmt.Sprintf("Global SMS limit reached: at most %d messages may be submitted in a rolling one-hour window.", reservation.Limit),
|
||||
},
|
||||
"data": map[string]any{
|
||||
"limit": reservation.Limit,
|
||||
"used": reservation.Used,
|
||||
"remaining": reservation.Remaining,
|
||||
"reset_at": reservation.ResetAt,
|
||||
"retry_after": int64((retryAfter + time.Second - 1) / time.Second),
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if config.VoWiFiEnabled && s.vowifi != nil {
|
||||
state, stateErr := s.vowifi.State(request.DeviceID)
|
||||
sender, canSendIMS := s.vowifi.(imsSMSController)
|
||||
@@ -512,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
|
||||
@@ -625,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
|
||||
|
||||
@@ -5,9 +5,12 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"vocat/internal/developer"
|
||||
"vocat/internal/device"
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
@@ -105,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
|
||||
@@ -155,3 +167,52 @@ func TestBlockedSMSDestination(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSMSSendEnforcesGlobalHourlyLimit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database, err := store.Open(ctx, ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
if err := developer.SetSMSHourlyLimit(ctx, database, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpsertDevice(ctx, store.Device{ID: "ec20_1", Name: "EC20"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reservation, err := database.ReserveSMSSend(ctx, "another-device", 1, time.Now().UTC()); err != nil || !reservation.Allowed {
|
||||
t.Fatalf("seed global SMS reservation = %+v, %v", reservation, err)
|
||||
}
|
||||
server := &Server{
|
||||
store: database,
|
||||
logger: regionTestLogger(),
|
||||
maxRequestBodyBytes: 4096,
|
||||
devices: fakeDeviceController{entry: device.Device{
|
||||
ID: "ec20_1",
|
||||
Discovered: true,
|
||||
Snapshot: &device.Snapshot{DeviceID: "ec20_1"},
|
||||
}},
|
||||
}
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/sms/send",
|
||||
strings.NewReader(`{"device_id":"ec20_1","phone":"+447700900123","message":"hello"}`),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
server.handleSMSSend(response, request)
|
||||
if response.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("status = %d, want 429; body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
if response.Header().Get("Retry-After") == "" {
|
||||
t.Fatal("Retry-After header is missing")
|
||||
}
|
||||
var envelope errorEnvelope
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if envelope.Error.Code != "sms_rate_limited" {
|
||||
t.Fatalf("error code = %q, want sms_rate_limited", envelope.Error.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
@@ -25,7 +24,7 @@ import (
|
||||
|
||||
const smsNotificationPollInterval = 2 * time.Second
|
||||
|
||||
var smsOnlyNotificationChannels = []string{"bark", "email", "pushplus", "webhook"}
|
||||
var smsOnlyNotificationChannels = []string{"bark", "email", "pushplus", "webhook", "wecom"}
|
||||
|
||||
type smsNotification struct {
|
||||
DeviceID string
|
||||
@@ -144,7 +143,7 @@ func (s *Server) smsNotificationConfig(ctx context.Context, channel string) (map
|
||||
|
||||
func validateSMSNotificationConfig(channel string, config map[string]any) error {
|
||||
switch channel {
|
||||
case "bark", "email", "webhook":
|
||||
case "bark", "email", "webhook", "wecom":
|
||||
if err := validateNotificationTestConfig(channel, config); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -203,6 +202,8 @@ func sendSMSNotification(ctx context.Context, channel string, config map[string]
|
||||
return sendPushplusSMSNotification(ctx, config, message)
|
||||
case "webhook":
|
||||
return sendWebhookSMSNotification(ctx, config, message)
|
||||
case "wecom":
|
||||
return sendWecomNotification(ctx, config, wecomSMSValues(message))
|
||||
default:
|
||||
return fmt.Errorf("unsupported SMS notification channel %q", channel)
|
||||
}
|
||||
@@ -402,13 +403,13 @@ func sendEmailSMSNotification(ctx context.Context, config map[string]any, messag
|
||||
return fmt.Errorf("%w: SMTP authentication failed", errProviderRejected)
|
||||
}
|
||||
}
|
||||
from, err := mail.ParseAddress(configString(config, "from_address"))
|
||||
from, err := parseMailAddress(configString(config, "from_address"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse sender address: %w", err)
|
||||
}
|
||||
recipients := make([]*mail.Address, 0)
|
||||
for _, item := range configStrings(config, "to_addresses") {
|
||||
address, err := mail.ParseAddress(item)
|
||||
address, err := parseMailAddress(item)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse recipient address: %w", err)
|
||||
}
|
||||
@@ -426,19 +427,13 @@ func sendEmailSMSNotification(ctx context.Context, config map[string]any, messag
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: SMTP message rejected", errProviderRejected)
|
||||
}
|
||||
email := strings.Join([]string{
|
||||
"Date: " + time.Now().UTC().Format(time.RFC1123Z),
|
||||
"From: " + from.String(),
|
||||
"To: " + joinMailAddresses(recipients),
|
||||
"Subject: " + mime.QEncoding.Encode("UTF-8", "收到新短信 - "+message.DeviceLabel),
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: text/plain; charset=UTF-8",
|
||||
"Content-Transfer-Encoding: 8bit",
|
||||
"",
|
||||
if err := writePlainTextMail(
|
||||
writer,
|
||||
from,
|
||||
recipients,
|
||||
"收到新短信 - "+message.DeviceLabel,
|
||||
message.Text(),
|
||||
"",
|
||||
}, "\r\n")
|
||||
if _, err := io.WriteString(writer, email); err != nil {
|
||||
); err != nil {
|
||||
_ = writer.Close()
|
||||
return fmt.Errorf("write SMTP notification: %w", err)
|
||||
}
|
||||
|
||||
@@ -36,12 +36,46 @@ func TestRenderSMSWebhookTemplate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWecomSMSValuesIncludeRenderedSMSFields(t *testing.T) {
|
||||
location := time.FixedZone("UTC+8", 8*60*60)
|
||||
message := smsNotification{
|
||||
DeviceID: "device-1", DeviceName: "客厅", DeviceLabel: "EC20",
|
||||
Number: "+447386", Time: time.Date(2026, 8, 8, 17, 25, 35, 0, location), Content: "hello",
|
||||
}
|
||||
values := wecomSMSValues(message)
|
||||
if values["event"] != "sms.received" || values["title"] != "收到新短信" || values["message"] != message.Text() {
|
||||
t.Fatalf("common values = %#v", values)
|
||||
}
|
||||
wantLocalTime := message.Time.Local().Format("2006-01-02 15:04:05")
|
||||
if values["content"] != "hello" || values["number"] != "+447386" || values["device_label"] != "EC20" || values["time"] != wantLocalTime {
|
||||
t.Fatalf("SMS values = %#v", values)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWecomAutomaticTaskValuesLeaveSMSFieldsEmpty(t *testing.T) {
|
||||
values := wecomAutomaticTaskValues(automaticTaskNotification{
|
||||
Title: "自动任务执行成功", Text: "任务已完成", Time: time.Unix(1_700_000_000, 0),
|
||||
})
|
||||
if values["event"] != "automatic_task.completed" || values["title"] != "自动任务执行成功" || values["message"] != "任务已完成" {
|
||||
t.Fatalf("common values = %#v", values)
|
||||
}
|
||||
for _, name := range []string{"content", "number", "device_id", "device_name", "device_label", "time"} {
|
||||
if values[name] != "" {
|
||||
t.Fatalf("%s = %q, want empty", name, values[name])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSMSNotificationConfig(t *testing.T) {
|
||||
valid := map[string]map[string]any{
|
||||
"bark": {"urls": []any{"https://api.day.app/key"}},
|
||||
"email": {"smtp_host": "smtp.example.com", "from_address": "[email protected]", "to_addresses": []any{"[email protected]"}},
|
||||
"pushplus": {"token": "secret"},
|
||||
"webhook": {"urls": []any{"https://example.com/hook"}},
|
||||
"wecom": {
|
||||
"urls": []any{"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=secret"},
|
||||
"payload_template": `{"msgtype":"text","text":{"content":{{message}}}}`,
|
||||
},
|
||||
}
|
||||
for channel, config := range valid {
|
||||
if err := validateSMSNotificationConfig(channel, config); err != nil {
|
||||
|
||||
+1443
-108
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ import (
|
||||
"vocat/internal/device"
|
||||
"vocat/internal/modem"
|
||||
"vocat/internal/store"
|
||||
"vocat/internal/vowifi"
|
||||
)
|
||||
|
||||
func TestTelegramAPIURLSupportsBaseAndTemplate(t *testing.T) {
|
||||
@@ -86,6 +87,59 @@ func TestValidTelegramDialNumber(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTelegramPhoneNumberPrefersCurrentSIMAssociation(t *testing.T) {
|
||||
snapshot := &device.Snapshot{
|
||||
ICCID: "89441000400128013903",
|
||||
Phone: device.PhoneNumber{Number: "00000000000"},
|
||||
}
|
||||
state := &vowifi.State{
|
||||
ICCID: snapshot.ICCID,
|
||||
PhoneNumber: "+447386125520",
|
||||
}
|
||||
if got := resolveTelegramPhoneNumber("+447700900123", state, snapshot); got != "+447700900123" {
|
||||
t.Fatalf("resolved association number = %q", got)
|
||||
}
|
||||
if got := resolveTelegramPhoneNumber("", state, snapshot); got != "+447386125520" {
|
||||
t.Fatalf("resolved IMS number = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTelegramPhoneNumberRejectsPlaceholderAndStaleRuntime(t *testing.T) {
|
||||
snapshot := &device.Snapshot{
|
||||
ICCID: "current-card",
|
||||
Phone: device.PhoneNumber{Number: "00000000000"},
|
||||
}
|
||||
state := &vowifi.State{
|
||||
ICCID: "previous-card",
|
||||
PhoneNumber: "+447386083638",
|
||||
}
|
||||
if got := resolveTelegramPhoneNumber("", state, snapshot); got != "--" {
|
||||
t.Fatalf("stale or placeholder number leaked as %q", got)
|
||||
}
|
||||
for _, placeholder := range []string{"00000000000", "1111111111", "+0000000000", "not-a-number"} {
|
||||
if usableTelegramPhoneNumber(placeholder) {
|
||||
t.Errorf("placeholder %q was accepted", placeholder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramCarrierPresentationSeparatesHomeAndServingNetworks(t *testing.T) {
|
||||
if got := telegramHomeCarrier("234336570710174"); !strings.Contains(got, "🇬🇧") || !strings.Contains(got, "23433") {
|
||||
t.Fatalf("home carrier = %q", got)
|
||||
}
|
||||
if got := telegramHomeCarrier("204040123456789", "Lebara"); !strings.Contains(got, "Lebara") || !strings.Contains(got, "20404") || !strings.Contains(got, "🇬🇧") || strings.Contains(got, "🇳🇱") {
|
||||
t.Fatalf("branded foreign-core carrier = %q", got)
|
||||
}
|
||||
flight := &device.Snapshot{FlightMode: true, OperatorName: "stale network", RegistrationStatus: 1}
|
||||
if got := telegramCurrentNetwork(flight); got != "--(飞行模式)" {
|
||||
t.Fatalf("flight-mode serving network = %q", got)
|
||||
}
|
||||
serving := &device.Snapshot{OperatorCode: "46001", RegistrationStatus: 5, AccessTech: "LTE", Band: "B3"}
|
||||
if got := telegramCurrentNetwork(serving); !strings.Contains(got, "🇨🇳") || !strings.Contains(got, "已驻网(漫游)") {
|
||||
t.Fatalf("serving network = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramPendingActionIsAuthorizedOneShot(t *testing.T) {
|
||||
bot := &telegramBot{pending: make(map[string]telegramPendingAction)}
|
||||
action := telegramPendingAction{Kind: "call", ChatID: -1001, AdminID: 42, CreatedAt: time.Now()}
|
||||
@@ -101,6 +155,61 @@ func TestTelegramPendingActionIsAuthorizedOneShot(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramMenuCallbackParsing(t *testing.T) {
|
||||
prefix, token, operation, ok := parseTelegramMenuCallback("call:0123456789abcdef:answer")
|
||||
if !ok || prefix != "call" || token != "0123456789abcdef" || operation != "answer" {
|
||||
t.Fatalf("parsed callback = %q %q %q %t", prefix, token, operation, ok)
|
||||
}
|
||||
for _, invalid := range []string{"", "call:token", "unknown:token:op", "d::status"} {
|
||||
if _, _, _, ok := parseTelegramMenuCallback(invalid); ok {
|
||||
t.Fatalf("invalid callback %q was accepted", invalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramMenuPendingCanBeReusedButConfirmationCannot(t *testing.T) {
|
||||
bot := &telegramBot{pending: make(map[string]telegramPendingAction)}
|
||||
menuToken, err := bot.putPending(telegramPendingAction{
|
||||
Kind: "menu_device", DeviceID: "EC20", ChatID: 1, AdminID: 2, CreatedAt: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := bot.getPending(menuToken, 1, 2); !ok {
|
||||
t.Fatal("first menu lookup failed")
|
||||
}
|
||||
if _, ok := bot.getPending(menuToken, 1, 2); !ok {
|
||||
t.Fatal("menu token was unexpectedly consumed")
|
||||
}
|
||||
confirmToken, err := bot.putPending(telegramPendingAction{
|
||||
Kind: "sms", ChatID: 1, AdminID: 2, CreatedAt: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := bot.takePending(confirmToken, 1, 2); !ok {
|
||||
t.Fatal("confirmation token lookup failed")
|
||||
}
|
||||
if _, ok := bot.takePending(confirmToken, 1, 2); ok {
|
||||
t.Fatal("confirmation token was reusable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramInputStateIsScopedAndCancelable(t *testing.T) {
|
||||
bot := &telegramBot{inputs: make(map[string]telegramInputState)}
|
||||
bot.setInput(telegramInputState{Kind: "sms_phone", DeviceID: "EC20", ChatID: 10, AdminID: 20})
|
||||
if state, ok := bot.input(10, 20); !ok || state.DeviceID != "EC20" || state.Kind != "sms_phone" {
|
||||
t.Fatalf("input state = %#v, %t", state, ok)
|
||||
}
|
||||
if _, ok := bot.input(10, 21); ok {
|
||||
t.Fatal("another administrator read the input state")
|
||||
}
|
||||
bot.clearInput(10, 20)
|
||||
if _, ok := bot.input(10, 20); ok {
|
||||
t.Fatal("cleared input state remained available")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTelegramATIncludesFinalResult(t *testing.T) {
|
||||
if got := formatTelegramAT(modem.Response{Final: "OK"}); got != "OK" {
|
||||
t.Fatalf("formatTelegramAT(OK) = %q", got)
|
||||
@@ -165,7 +274,7 @@ func TestTelegramExecutesInteractiveUSSDForConfiguredDevice(t *testing.T) {
|
||||
}
|
||||
formatted := formatTelegramUSSD("EC20", result)
|
||||
for _, expected := range []string{
|
||||
"设备:EC20", "状态:awaiting_input", "1. Balance", "/ussd_reply 0123456789abcdef", "/ussd_cancel 0123456789abcdef",
|
||||
"设备:EC20", "状态:awaiting_input", "1. Balance", "请直接发送回复内容",
|
||||
} {
|
||||
if !strings.Contains(formatted, expected) {
|
||||
t.Fatalf("USSD result %q does not contain %q", formatted, expected)
|
||||
@@ -181,3 +290,145 @@ func TestTelegramErrorsRedactBotTokens(t *testing.T) {
|
||||
t.Fatalf("redacted error = %q", redacted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramCallTransportFollowsConfiguredCardMode(t *testing.T) {
|
||||
controller := &telegramTestCallController{state: vowifi.State{IMSReady: true, Phase: vowifi.PhaseIMSReady}}
|
||||
bot := &telegramBot{server: &Server{vowifi: controller}}
|
||||
|
||||
transport, gotController, err := bot.telegramCallTransport(
|
||||
store.Device{ID: "EC20", VoWiFiEnabled: true},
|
||||
device.Device{Snapshot: &device.Snapshot{FlightMode: true}},
|
||||
)
|
||||
if err != nil || transport != "vowifi" || gotController == nil {
|
||||
t.Fatalf("VoWiFi route = %q, %#v, %v", transport, gotController, err)
|
||||
}
|
||||
|
||||
transport, gotController, err = bot.telegramCallTransport(
|
||||
store.Device{ID: "EC20", VoWiFiEnabled: false},
|
||||
device.Device{Snapshot: &device.Snapshot{FlightMode: false}},
|
||||
)
|
||||
if err != nil || transport != "cellular" || gotController != nil {
|
||||
t.Fatalf("cellular route = %q, %#v, %v", transport, gotController, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramCallTransportDoesNotFallBackFromUnreadyVoWiFi(t *testing.T) {
|
||||
controller := &telegramTestCallController{state: vowifi.State{
|
||||
Phase: vowifi.PhaseFailed, LastError: "SIP registration was rejected: SIP 403",
|
||||
}}
|
||||
bot := &telegramBot{server: &Server{vowifi: controller}}
|
||||
_, _, err := bot.telegramCallTransport(
|
||||
store.Device{ID: "EC20", VoWiFiEnabled: true},
|
||||
device.Device{Snapshot: &device.Snapshot{FlightMode: true}},
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "SIP 403") {
|
||||
t.Fatalf("unready VoWiFi route error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramTimedVoWiFiCallUsesIMSAndHangsUpByCallID(t *testing.T) {
|
||||
controller := &telegramTestCallController{state: vowifi.State{IMSReady: true}}
|
||||
controller.dialResult = vowifi.Call{ID: "ims-call-1", Number: "+447700900123", Direction: "outgoing", State: "dialing"}
|
||||
controller.calls = []vowifi.Call{{ID: "ims-call-1", Number: "+447700900123", Direction: "outgoing", State: "active"}}
|
||||
bot := &telegramBot{server: &Server{vowifi: controller}}
|
||||
result, err := bot.executeTimedVoWiFiCall(context.Background(), telegramRuntimeConfig{}, telegramPendingAction{
|
||||
DeviceID: "EC20", Argument: "+447700900123", Duration: 20 * time.Millisecond,
|
||||
}, controller)
|
||||
if err != nil || !strings.Contains(result, "已接通") {
|
||||
t.Fatalf("timed VoWiFi result = %q, %v", result, err)
|
||||
}
|
||||
if controller.dialed != "+447700900123" || len(controller.hungUp) != 1 || controller.hungUp[0] != "ims-call-1" {
|
||||
t.Fatalf("IMS actions dial=%q hangup=%#v", controller.dialed, controller.hungUp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramTimedCellularCallUsesATDCLCCAndATH(t *testing.T) {
|
||||
commands := make([]string, 0, 3)
|
||||
devices := fakeDeviceController{atHandler: func(command string) (modem.Response, error) {
|
||||
commands = append(commands, command)
|
||||
switch {
|
||||
case strings.HasPrefix(command, "ATD"):
|
||||
return modem.Response{Final: "OK"}, nil
|
||||
case command == "AT+CLCC":
|
||||
return modem.Response{Lines: []string{`+CLCC: 1,0,2,0,0,"+447700900123",145`}, Final: "OK"}, nil
|
||||
case command == "ATH":
|
||||
return modem.Response{Final: "OK"}, nil
|
||||
default:
|
||||
return modem.Response{}, errors.New("unexpected command")
|
||||
}
|
||||
}}
|
||||
bot := &telegramBot{server: &Server{devices: devices}}
|
||||
result, err := bot.executeTimedCellularCall(context.Background(), telegramRuntimeConfig{}, telegramPendingAction{
|
||||
DeviceID: "EC20", Argument: "+447700900123", Duration: 20 * time.Millisecond,
|
||||
}, "physical")
|
||||
if err != nil || !strings.Contains(result, "正在拨号") {
|
||||
t.Fatalf("timed cellular result = %q, %v", result, err)
|
||||
}
|
||||
joined := strings.Join(commands, ",")
|
||||
for _, expected := range []string{"ATD+447700900123;", "AT+CLCC", "ATH"} {
|
||||
if !strings.Contains(joined, expected) {
|
||||
t.Fatalf("commands %q omit %q", joined, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramVoWiFiFailureIncludesSIPDiagnostic(t *testing.T) {
|
||||
err := telegramVoWiFiCallFailure(vowifi.Call{State: "failed", SIPCode: 403, Reason: "Forbidden"})
|
||||
if !strings.Contains(err.Error(), "SIP 403") || !strings.Contains(err.Error(), "Forbidden") {
|
||||
t.Fatalf("VoWiFi diagnostic = %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramVoWiFi487IsReportedAsCancelledOutcome(t *testing.T) {
|
||||
result, err := telegramVoWiFiCallOutcome("888", vowifi.Call{
|
||||
State: "failed", SIPCode: 487, Reason: "Request Terminated",
|
||||
})
|
||||
if err != nil || !strings.Contains(result, "取消或终止") || !strings.Contains(result, "SIP 487") {
|
||||
t.Fatalf("487 outcome = %q, %v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
type telegramTestCallController struct {
|
||||
state vowifi.State
|
||||
calls []vowifi.Call
|
||||
dialResult vowifi.Call
|
||||
dialErr error
|
||||
dialed string
|
||||
hungUp []string
|
||||
}
|
||||
|
||||
func (controller *telegramTestCallController) State(string) (vowifi.State, error) {
|
||||
return controller.state, nil
|
||||
}
|
||||
|
||||
func (controller *telegramTestCallController) RequestEnabled(string, bool) (vowifi.State, error) {
|
||||
return controller.state, nil
|
||||
}
|
||||
|
||||
func (controller *telegramTestCallController) RequestReconnect(string) (vowifi.State, error) {
|
||||
return controller.state, nil
|
||||
}
|
||||
|
||||
func (controller *telegramTestCallController) Calls(string) ([]vowifi.Call, error) {
|
||||
return append([]vowifi.Call(nil), controller.calls...), nil
|
||||
}
|
||||
|
||||
func (controller *telegramTestCallController) DialCall(_ context.Context, _ string, number string) (vowifi.Call, error) {
|
||||
controller.dialed = number
|
||||
return controller.dialResult, controller.dialErr
|
||||
}
|
||||
|
||||
func (controller *telegramTestCallController) AnswerCall(_ context.Context, _ string, id string) (vowifi.Call, error) {
|
||||
for _, call := range controller.calls {
|
||||
if call.ID == id {
|
||||
call.State = "active"
|
||||
return call, nil
|
||||
}
|
||||
}
|
||||
return vowifi.Call{}, errors.New("call not found")
|
||||
}
|
||||
|
||||
func (controller *telegramTestCallController) HangupCall(_ context.Context, _ string, id string) error {
|
||||
controller.hungUp = append(controller.hungUp, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var wecomTemplateVariableNames = []string{
|
||||
"event",
|
||||
"title",
|
||||
"message",
|
||||
"timestamp",
|
||||
"content",
|
||||
"number",
|
||||
"device_id",
|
||||
"device_name",
|
||||
"device_label",
|
||||
"time",
|
||||
}
|
||||
|
||||
type wecomTemplateValues map[string]string
|
||||
|
||||
func renderWecomPayload(template string, values wecomTemplateValues) ([]byte, error) {
|
||||
for _, name := range wecomTemplateVariableNames {
|
||||
encoded, err := json.Marshal(values[name])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode WeCom template value %q: %w", name, err)
|
||||
}
|
||||
template = strings.ReplaceAll(template, "{{"+name+"}}", string(encoded))
|
||||
}
|
||||
if strings.Contains(template, "{{") {
|
||||
return nil, errors.New("wecom.payload_template contains an unsupported variable")
|
||||
}
|
||||
|
||||
var payload map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(template), &payload); err != nil || len(payload) == 0 {
|
||||
return nil, errors.New("wecom.payload_template must render to a non-empty JSON object")
|
||||
}
|
||||
return []byte(template), nil
|
||||
}
|
||||
|
||||
func validateWecomResponse(status int, body []byte) error {
|
||||
var result struct {
|
||||
ErrCode *int `json:"errcode"`
|
||||
}
|
||||
if status < http.StatusOK || status >= http.StatusMultipleChoices ||
|
||||
json.Unmarshal(body, &result) != nil || result.ErrCode == nil || *result.ErrCode != 0 {
|
||||
return fmt.Errorf("%w: WeCom response was not successful", errProviderRejected)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func wecomTestValues(now time.Time) wecomTemplateValues {
|
||||
return wecomTemplateValues{
|
||||
"event": "test", "title": "vocat", "message": "vocat notification test",
|
||||
"timestamp": now.UTC().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
func wecomSMSValues(message smsNotification) wecomTemplateValues {
|
||||
return wecomTemplateValues{
|
||||
"event": "sms.received",
|
||||
"title": "收到新短信",
|
||||
"message": message.Text(),
|
||||
"timestamp": message.Time.UTC().Format(time.RFC3339),
|
||||
"content": message.Content,
|
||||
"number": message.Number,
|
||||
"device_id": message.DeviceID,
|
||||
"device_name": message.DeviceName,
|
||||
"device_label": message.DeviceLabel,
|
||||
"time": message.Time.Local().Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
}
|
||||
|
||||
func wecomAutomaticTaskValues(message automaticTaskNotification) wecomTemplateValues {
|
||||
return wecomTemplateValues{
|
||||
"event": "automatic_task.completed",
|
||||
"title": message.Title,
|
||||
"message": message.Text,
|
||||
"timestamp": message.Time.UTC().Format(time.RFC3339),
|
||||
"content": "",
|
||||
"number": "",
|
||||
"device_id": "",
|
||||
"device_name": "",
|
||||
"device_label": "",
|
||||
"time": "",
|
||||
}
|
||||
}
|
||||
|
||||
func validateWecomNotificationConfig(config map[string]any) error {
|
||||
urls := configStrings(config, "urls")
|
||||
if len(urls) == 0 {
|
||||
return errors.New("wecom.urls must contain at least one URL")
|
||||
}
|
||||
if len(urls) > 8 {
|
||||
return errors.New("wecom.urls cannot contain more than 8 URLs")
|
||||
}
|
||||
template := configString(config, "payload_template")
|
||||
if template == "" {
|
||||
return errors.New("wecom.payload_template is required")
|
||||
}
|
||||
_, err := renderWecomPayload(template, wecomTestValues(time.Unix(0, 0)))
|
||||
return err
|
||||
}
|
||||
|
||||
func sendWecomNotification(ctx context.Context, config map[string]any, values wecomTemplateValues) error {
|
||||
payload, err := renderWecomPayload(configString(config, "payload_template"), values)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := restrictedHTTPClient(ctx, 8*time.Second, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, destination := range configStrings(config, "urls") {
|
||||
parsed, err := validateOutboundURL(ctx, destination, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create WeCom notification request: %w", err)
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
request.Header.Set("User-Agent", "vocat-wecom-notification/1")
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send WeCom notification: %w", err)
|
||||
}
|
||||
body, readErr := io.ReadAll(io.LimitReader(response.Body, 64<<10))
|
||||
closeErr := response.Body.Close()
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("read WeCom response: %w", readErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return fmt.Errorf("close WeCom response: %w", closeErr)
|
||||
}
|
||||
if err := validateWecomResponse(response.StatusCode, body); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendWecomNotificationTest(ctx context.Context, config map[string]any) error {
|
||||
return sendWecomNotification(ctx, config, wecomTestValues(time.Now()))
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderWecomPayloadEscapesTemplateValues(t *testing.T) {
|
||||
payload, err := renderWecomPayload(
|
||||
`{"msgtype":"text","text":{"content":{{message}},"number":{{number}}}}`,
|
||||
wecomTemplateValues{
|
||||
"message": "quote: \"\nline",
|
||||
"number": "+447386",
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := string(payload), `{"msgtype":"text","text":{"content":"quote: \"\nline","number":"+447386"}}`; got != want {
|
||||
t.Fatalf("payload = %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderWecomPayloadRejectsInvalidTemplate(t *testing.T) {
|
||||
for _, template := range []string{
|
||||
`{"text":{{unknown}}}`,
|
||||
`[]`,
|
||||
`{"msgtype":"text"`,
|
||||
} {
|
||||
t.Run(template, func(t *testing.T) {
|
||||
if _, err := renderWecomPayload(template, wecomTemplateValues{}); err == nil {
|
||||
t.Fatalf("template %q was accepted", template)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWecomResponse(t *testing.T) {
|
||||
if err := validateWecomResponse(http.StatusOK, []byte(`{"errcode":0,"errmsg":"ok"}`)); err != nil {
|
||||
t.Fatalf("successful response = %v", err)
|
||||
}
|
||||
for _, response := range []struct {
|
||||
status int
|
||||
body string
|
||||
}{
|
||||
{http.StatusBadGateway, `{"errcode":0}`},
|
||||
{http.StatusOK, `{"errcode":40058,"errmsg":"invalid"}`},
|
||||
{http.StatusOK, `{}`},
|
||||
{http.StatusOK, `not-json`},
|
||||
} {
|
||||
if err := validateWecomResponse(response.status, []byte(response.body)); !errors.Is(err, errProviderRejected) {
|
||||
t.Fatalf("validateWecomResponse(%d, %s) = %v", response.status, response.body, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const automaticTaskSelect = `
|
||||
SELECT id, name, enabled, device_id, profile_iccid, profile_aid,
|
||||
task_type, environment, interval_days, start_date, run_time,
|
||||
timezone, payload_json, retry_count, notify, next_run_at, last_run_at,
|
||||
last_status, last_error, created_at, updated_at
|
||||
FROM automatic_tasks`
|
||||
|
||||
func (s *Store) SaveAutomaticTask(ctx context.Context, value AutomaticTask) (AutomaticTask, error) {
|
||||
now := time.Now().UTC()
|
||||
if strings.TrimSpace(value.Timezone) == "" {
|
||||
value.Timezone = time.Local.String()
|
||||
}
|
||||
if value.CreatedAt.IsZero() {
|
||||
value.CreatedAt = now
|
||||
}
|
||||
value.UpdatedAt = now
|
||||
if len(value.Payload) == 0 {
|
||||
value.Payload = []byte(`{}`)
|
||||
}
|
||||
if value.ID == 0 {
|
||||
result, err := s.db.ExecContext(ctx, `INSERT INTO automatic_tasks (
|
||||
name, enabled, device_id, profile_iccid, profile_aid, task_type,
|
||||
environment, interval_days, start_date, run_time, timezone, payload_json,
|
||||
retry_count, notify, next_run_at, last_run_at, last_status,
|
||||
last_error, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
strings.TrimSpace(value.Name), value.Enabled, strings.TrimSpace(value.DeviceID),
|
||||
strings.TrimSpace(value.ProfileICCID), strings.TrimSpace(value.ProfileAID),
|
||||
value.TaskType, value.Environment, value.IntervalDays, value.StartDate,
|
||||
value.RunTime, value.Timezone, string(value.Payload), value.RetryCount, value.Notify,
|
||||
value.NextRunAt.Unix(), unixOrZero(value.LastRunAt), value.LastStatus,
|
||||
value.LastError, value.CreatedAt.Unix(), value.UpdatedAt.Unix())
|
||||
if err != nil {
|
||||
return AutomaticTask{}, fmt.Errorf("create automatic task: %w", err)
|
||||
}
|
||||
value.ID, _ = result.LastInsertId()
|
||||
} else {
|
||||
result, err := s.db.ExecContext(ctx, `UPDATE automatic_tasks SET
|
||||
name = ?, enabled = ?, device_id = ?, profile_iccid = ?, profile_aid = ?,
|
||||
task_type = ?, environment = ?, interval_days = ?, start_date = ?,
|
||||
run_time = ?, timezone = ?, payload_json = ?, retry_count = ?, notify = ?,
|
||||
next_run_at = ?, updated_at = ? WHERE id = ?`,
|
||||
strings.TrimSpace(value.Name), value.Enabled, strings.TrimSpace(value.DeviceID),
|
||||
strings.TrimSpace(value.ProfileICCID), strings.TrimSpace(value.ProfileAID),
|
||||
value.TaskType, value.Environment, value.IntervalDays, value.StartDate,
|
||||
value.RunTime, value.Timezone, string(value.Payload), value.RetryCount, value.Notify,
|
||||
value.NextRunAt.Unix(), value.UpdatedAt.Unix(), value.ID)
|
||||
if err != nil {
|
||||
return AutomaticTask{}, fmt.Errorf("update automatic task %d: %w", value.ID, err)
|
||||
}
|
||||
if count, _ := result.RowsAffected(); count == 0 {
|
||||
return AutomaticTask{}, ErrNotFound
|
||||
}
|
||||
}
|
||||
return s.AutomaticTask(ctx, value.ID)
|
||||
}
|
||||
|
||||
func (s *Store) AutomaticTask(ctx context.Context, id int64) (AutomaticTask, error) {
|
||||
return scanAutomaticTask(s.db.QueryRowContext(ctx, automaticTaskSelect+` WHERE id = ?`, id))
|
||||
}
|
||||
|
||||
func (s *Store) ListAutomaticTasks(ctx context.Context) ([]AutomaticTask, error) {
|
||||
rows, err := s.db.QueryContext(ctx, automaticTaskSelect+` ORDER BY created_at DESC, id DESC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list automatic tasks: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []AutomaticTask
|
||||
for rows.Next() {
|
||||
value, scanErr := scanAutomaticTask(rows)
|
||||
if scanErr != nil {
|
||||
return nil, scanErr
|
||||
}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) DeleteAutomaticTask(ctx context.Context, id int64) error {
|
||||
result, err := s.db.ExecContext(ctx, `DELETE FROM automatic_tasks WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete automatic task %d: %w", id, err)
|
||||
}
|
||||
if count, _ := result.RowsAffected(); count == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ClaimDueAutomaticTasks(ctx context.Context, now time.Time, limit int) ([]AutomaticTaskRun, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
rows, err := tx.QueryContext(ctx, automaticTaskSelect+`
|
||||
WHERE enabled = 1 AND next_run_at <= ? ORDER BY next_run_at, id LIMIT ?`, now.Unix(), limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var tasks []AutomaticTask
|
||||
for rows.Next() {
|
||||
task, scanErr := scanAutomaticTask(rows)
|
||||
if scanErr != nil {
|
||||
rows.Close()
|
||||
return nil, scanErr
|
||||
}
|
||||
tasks = append(tasks, task)
|
||||
}
|
||||
rows.Close()
|
||||
result := make([]AutomaticTaskRun, 0, len(tasks))
|
||||
for _, task := range tasks {
|
||||
next := task.NextRunAt
|
||||
location := time.Local
|
||||
if loaded, loadErr := time.LoadLocation(task.Timezone); loadErr == nil {
|
||||
location = loaded
|
||||
}
|
||||
for !next.After(now) {
|
||||
next = next.In(location).AddDate(0, 0, task.IntervalDays).UTC()
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE automatic_tasks SET next_run_at = ?, updated_at = ? WHERE id = ?`, next.Unix(), now.Unix(), task.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
created, createErr := tx.ExecContext(ctx, `INSERT INTO automatic_task_runs (
|
||||
task_id, device_id, scheduled_at, status, created_at, updated_at
|
||||
) VALUES (?, ?, ?, 'queued', ?, ?)`, task.ID, task.DeviceID, task.NextRunAt.Unix(), now.Unix(), now.Unix())
|
||||
if createErr != nil {
|
||||
return nil, createErr
|
||||
}
|
||||
runID, _ := created.LastInsertId()
|
||||
result = append(result, AutomaticTaskRun{ID: runID, TaskID: task.ID, DeviceID: task.DeviceID, ScheduledAt: task.NextRunAt, Status: "queued", CreatedAt: now, UpdatedAt: now})
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Store) QueueAutomaticTaskNow(ctx context.Context, task AutomaticTask) (AutomaticTaskRun, error) {
|
||||
now := time.Now().UTC()
|
||||
result, err := s.db.ExecContext(ctx, `INSERT INTO automatic_task_runs (
|
||||
task_id, device_id, scheduled_at, status, created_at, updated_at
|
||||
) VALUES (?, ?, ?, 'queued', ?, ?)`, task.ID, task.DeviceID, now.Unix(), now.Unix(), now.Unix())
|
||||
if err != nil {
|
||||
return AutomaticTaskRun{}, fmt.Errorf("queue automatic task: %w", err)
|
||||
}
|
||||
id, _ := result.LastInsertId()
|
||||
return AutomaticTaskRun{ID: id, TaskID: task.ID, DeviceID: task.DeviceID, ScheduledAt: now, Status: "queued", CreatedAt: now, UpdatedAt: now}, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateAutomaticTaskRun(ctx context.Context, run AutomaticTaskRun) error {
|
||||
now := time.Now().UTC()
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE automatic_task_runs SET
|
||||
started_at = ?, finished_at = ?, status = ?, attempts = ?, output = ?, error = ?, updated_at = ?
|
||||
WHERE id = ?`, unixOrZero(run.StartedAt), unixOrZero(run.FinishedAt), run.Status,
|
||||
run.Attempts, run.Output, run.Error, now.Unix(), run.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update automatic task run %d: %w", run.ID, err)
|
||||
}
|
||||
if run.Status == "success" || run.Status == "failed" {
|
||||
_, err = s.db.ExecContext(ctx, `UPDATE automatic_tasks SET
|
||||
last_run_at = ?, last_status = ?, last_error = ?, updated_at = ? WHERE id = ?`,
|
||||
run.FinishedAt.Unix(), run.Status, run.Error, now.Unix(), run.TaskID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// RecoverAutomaticTaskRuns reconciles durable run records with the in-memory
|
||||
// scheduler after a process restart. Running work cannot still be executing,
|
||||
// while queued work is safe to put back onto the per-device queues.
|
||||
func (s *Store) RecoverAutomaticTaskRuns(ctx context.Context, now time.Time) ([]AutomaticTaskRun, error) {
|
||||
const restartError = "service restarted before the automatic task completed"
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE automatic_task_runs SET
|
||||
status = 'failed', finished_at = ?, error = ?, updated_at = ?
|
||||
WHERE status = 'running'`, now.Unix(), restartError, now.Unix()); err != nil {
|
||||
return nil, fmt.Errorf("recover running automatic tasks: %w", err)
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE automatic_tasks SET
|
||||
last_run_at = ?, last_status = 'failed', last_error = ?, updated_at = ?
|
||||
WHERE id IN (
|
||||
SELECT task_id FROM automatic_task_runs
|
||||
WHERE status = 'failed' AND error = ? AND finished_at = ?
|
||||
)`, now.Unix(), restartError, now.Unix(), restartError, now.Unix()); err != nil {
|
||||
return nil, fmt.Errorf("recover automatic task status: %w", err)
|
||||
}
|
||||
rows, err := tx.QueryContext(ctx, automaticTaskRunSelect+` WHERE status = 'queued' ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("recover queued automatic tasks: %w", err)
|
||||
}
|
||||
queued, err := scanAutomaticTaskRuns(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return queued, nil
|
||||
}
|
||||
|
||||
const automaticTaskRunSelect = `
|
||||
SELECT id, task_id, device_id, scheduled_at, started_at, finished_at,
|
||||
status, attempts, output, error, created_at, updated_at
|
||||
FROM automatic_task_runs`
|
||||
|
||||
func (s *Store) ListAutomaticTaskRuns(ctx context.Context, limit int) ([]AutomaticTaskRun, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, automaticTaskRunSelect+` ORDER BY id DESC LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanAutomaticTaskRuns(rows)
|
||||
}
|
||||
|
||||
// ListAutomaticTaskRunsPaginated returns one page of runs (newest first) plus
|
||||
// the total run count, so the UI can page through the full history instead of
|
||||
// a fixed recent window.
|
||||
func (s *Store) ListAutomaticTaskRunsPaginated(ctx context.Context, limit, offset int) ([]AutomaticTaskRun, int, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
total := 0
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM automatic_task_runs`).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count automatic task runs: %w", err)
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, automaticTaskRunSelect+` ORDER BY id DESC LIMIT ? OFFSET ?`, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
runs, err := scanAutomaticTaskRuns(rows)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return runs, total, nil
|
||||
}
|
||||
|
||||
func scanAutomaticTaskRuns(rows *sql.Rows) ([]AutomaticTaskRun, error) {
|
||||
defer rows.Close()
|
||||
var result []AutomaticTaskRun
|
||||
for rows.Next() {
|
||||
var value AutomaticTaskRun
|
||||
var scheduled, started, finished, created, updated int64
|
||||
if err := rows.Scan(&value.ID, &value.TaskID, &value.DeviceID, &scheduled, &started,
|
||||
&finished, &value.Status, &value.Attempts, &value.Output, &value.Error, &created, &updated); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value.ScheduledAt, value.StartedAt, value.FinishedAt = time.Unix(scheduled, 0).UTC(), timeFromUnix(started), timeFromUnix(finished)
|
||||
value.CreatedAt, value.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
|
||||
result = append(result, value)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func scanAutomaticTask(row rowScanner) (AutomaticTask, error) {
|
||||
var value AutomaticTask
|
||||
var enabled, notify bool
|
||||
var payload string
|
||||
var nextRun, lastRun, created, updated int64
|
||||
if err := row.Scan(&value.ID, &value.Name, &enabled, &value.DeviceID, &value.ProfileICCID,
|
||||
&value.ProfileAID, &value.TaskType, &value.Environment, &value.IntervalDays,
|
||||
&value.StartDate, &value.RunTime, &value.Timezone, &payload, &value.RetryCount, ¬ify,
|
||||
&nextRun, &lastRun, &value.LastStatus, &value.LastError, &created, &updated); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return AutomaticTask{}, ErrNotFound
|
||||
}
|
||||
return AutomaticTask{}, err
|
||||
}
|
||||
value.Enabled, value.Notify = enabled, notify
|
||||
value.Payload = []byte(payload)
|
||||
value.NextRunAt, value.LastRunAt = time.Unix(nextRun, 0).UTC(), timeFromUnix(lastRun)
|
||||
value.CreatedAt, value.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func unixOrZero(value time.Time) int64 {
|
||||
if value.IsZero() {
|
||||
return 0
|
||||
}
|
||||
return value.Unix()
|
||||
}
|
||||
|
||||
func timeFromUnix(value int64) time.Time {
|
||||
if value <= 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
return time.Unix(value, 0).UTC()
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAutomaticTasksAreClaimedInDeviceQueueOrderAndAdvanceSchedule(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database := openTestStore(t, filepath.Join(t.TempDir(), "automatic-tasks.db"))
|
||||
mustSaveDevice(t, database, "ec20", "EC20")
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
for index := 0; index < 2; index++ {
|
||||
payload, _ := json.Marshal(map[string]any{"phone": "10086", "message": "test"})
|
||||
if _, err := database.SaveAutomaticTask(ctx, AutomaticTask{
|
||||
Name: "task", Enabled: true, DeviceID: "ec20", ProfileICCID: "8944100000000000000",
|
||||
TaskType: "sms", Environment: "vowifi", IntervalDays: 2,
|
||||
StartDate: "2026-08-10", RunTime: "12:00", Timezone: "Asia/Shanghai", Payload: payload,
|
||||
NextRunAt: now.Add(time.Duration(index-2) * time.Minute),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
runs, err := database.ClaimDueAutomaticTasks(ctx, now, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(runs) != 2 || runs[0].DeviceID != "ec20" || runs[1].DeviceID != "ec20" || runs[0].TaskID >= runs[1].TaskID {
|
||||
t.Fatalf("claimed runs = %+v", runs)
|
||||
}
|
||||
tasks, err := database.ListAutomaticTasks(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, task := range tasks {
|
||||
if !task.NextRunAt.After(now) {
|
||||
t.Fatalf("task %d next run was not advanced: %v", task.ID, task.NextRunAt)
|
||||
}
|
||||
}
|
||||
second, err := database.ClaimDueAutomaticTasks(ctx, now, 10)
|
||||
if err != nil || len(second) != 0 {
|
||||
t.Fatalf("same schedule claimed twice: %+v, %v", second, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletingAutomaticTaskRemovesRunHistory(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database := openTestStore(t, filepath.Join(t.TempDir(), "automatic-task-delete.db"))
|
||||
mustSaveDevice(t, database, "ec20", "EC20")
|
||||
task, err := database.SaveAutomaticTask(ctx, AutomaticTask{
|
||||
Name: "task", Enabled: true, DeviceID: "ec20", ProfileICCID: "one",
|
||||
TaskType: "call", Environment: "cellular", IntervalDays: 1,
|
||||
StartDate: "2026-08-10", RunTime: "12:00", Timezone: "Asia/Shanghai", Payload: []byte(`{"phone":"10086","duration_seconds":10}`),
|
||||
NextRunAt: time.Now().Add(time.Hour),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := database.QueueAutomaticTaskNow(ctx, task); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.DeleteAutomaticTask(ctx, task.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runs, err := database.ListAutomaticTaskRuns(ctx, 10)
|
||||
if err != nil || len(runs) != 0 {
|
||||
t.Fatalf("orphan runs = %+v, %v", runs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAutomaticTaskRunsPaginated(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database := openTestStore(t, filepath.Join(t.TempDir(), "automatic-task-runs-page.db"))
|
||||
mustSaveDevice(t, database, "ec20", "EC20")
|
||||
task, err := database.SaveAutomaticTask(ctx, AutomaticTask{
|
||||
Name: "task", Enabled: true, DeviceID: "ec20", ProfileICCID: "one",
|
||||
TaskType: "call", Environment: "cellular", IntervalDays: 1,
|
||||
StartDate: "2026-08-10", RunTime: "12:00", Timezone: "Asia/Shanghai", Payload: []byte(`{"phone":"10086","duration_seconds":10}`),
|
||||
NextRunAt: time.Now().Add(time.Hour),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for index := 0; index < 5; index++ {
|
||||
if _, err := database.QueueAutomaticTaskNow(ctx, task); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
first, total, err := database.ListAutomaticTaskRunsPaginated(ctx, 2, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 5 || len(first) != 2 {
|
||||
t.Fatalf("first page: total = %d, runs = %+v", total, first)
|
||||
}
|
||||
if first[0].ID <= first[1].ID {
|
||||
t.Fatalf("runs not newest-first: %+v", first)
|
||||
}
|
||||
|
||||
last, total, err := database.ListAutomaticTaskRunsPaginated(ctx, 2, 4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 5 || len(last) != 1 {
|
||||
t.Fatalf("last page: total = %d, runs = %+v", total, last)
|
||||
}
|
||||
|
||||
// Out-of-range paging inputs are clamped to defaults, not errors.
|
||||
all, total, err := database.ListAutomaticTaskRunsPaginated(ctx, 0, -5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 5 || len(all) != 5 {
|
||||
t.Fatalf("clamped page: total = %d, runs = %+v", total, all)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoverAutomaticTaskRunsFailsRunningAndReturnsQueued(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database := openTestStore(t, filepath.Join(t.TempDir(), "automatic-task-recovery.db"))
|
||||
mustSaveDevice(t, database, "ec20", "EC20")
|
||||
task, err := database.SaveAutomaticTask(ctx, AutomaticTask{
|
||||
Name: "task", Enabled: true, DeviceID: "ec20", ProfileICCID: "one",
|
||||
TaskType: "call", Environment: "cellular", IntervalDays: 1,
|
||||
StartDate: "2026-08-10", RunTime: "12:00", Timezone: "Asia/Shanghai", Payload: []byte(`{"phone":"10086","duration_seconds":10}`),
|
||||
NextRunAt: time.Now().Add(time.Hour),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
running, err := database.QueueAutomaticTaskNow(ctx, task)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
running.Status = "running"
|
||||
running.StartedAt = time.Now().UTC().Add(-time.Minute)
|
||||
running.Attempts = 1
|
||||
if err := database.UpdateAutomaticTaskRun(ctx, running); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
queued, err := database.QueueAutomaticTaskNow(ctx, task)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
recoveredAt := time.Now().UTC().Truncate(time.Second)
|
||||
recovered, err := database.RecoverAutomaticTaskRuns(ctx, recoveredAt)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(recovered) != 1 || recovered[0].ID != queued.ID || recovered[0].Status != "queued" {
|
||||
t.Fatalf("recovered queued runs = %+v", recovered)
|
||||
}
|
||||
runs, err := database.ListAutomaticTaskRuns(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
foundRunning := false
|
||||
for _, run := range runs {
|
||||
if run.ID == running.ID {
|
||||
foundRunning = true
|
||||
if run.Status != "failed" || run.FinishedAt.IsZero() || !strings.Contains(run.Error, "service restarted") {
|
||||
t.Fatalf("recovered running run = %+v", run)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundRunning {
|
||||
t.Fatal("running run was not found after recovery")
|
||||
}
|
||||
recoveredTask, err := database.AutomaticTask(ctx, task.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if recoveredTask.LastStatus != "failed" || !strings.Contains(recoveredTask.LastError, "service restarted") {
|
||||
t.Fatalf("recovered task status = %+v", recoveredTask)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+156
-10
@@ -58,7 +58,8 @@ func TestMigrationFromAuthenticationSchema(t *testing.T) {
|
||||
"local_proxy_config", "upstream_proxies", "country_rules",
|
||||
"device_proxy_bindings",
|
||||
"notification_settings", "app_settings", "audit_events",
|
||||
"log_events", "card_policies", "traffic_buckets",
|
||||
"log_events", "card_policies", "card_apn_profiles", "traffic_buckets",
|
||||
"sms_send_attempts",
|
||||
} {
|
||||
var found string
|
||||
err := database.db.QueryRowContext(ctx, `
|
||||
@@ -105,6 +106,85 @@ func TestMigration7BackfillsSMSModemIMEI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigration12ConvertsOnlyKnownActiveDeviceBindingToICCID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "profile-proxy-binding.db")
|
||||
raw, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for version := 1; version <= 11; version++ {
|
||||
for _, statement := range migrationStatements(version) {
|
||||
if _, err := raw.ExecContext(ctx, statement); err != nil {
|
||||
t.Fatalf("create v%d schema: %v", version, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := raw.ExecContext(ctx, `
|
||||
INSERT INTO devices (id, name, created_at, updated_at) VALUES
|
||||
('known', 'Known', 100, 100), ('unknown', 'Unknown', 100, 100);
|
||||
INSERT INTO upstream_proxies (id, name, addr, created_at, updated_at)
|
||||
VALUES ('route', 'Route', '127.0.0.1:1080', 100, 100);
|
||||
INSERT INTO device_proxy_bindings (device_id, upstream_proxy_id, created_at, updated_at) VALUES
|
||||
('known', 'route', 100, 100), ('unknown', 'route', 100, 100);
|
||||
INSERT INTO vowifi_runtime (device_id, iccid, updated_at)
|
||||
VALUES ('known', '89441000400128014257', 100);
|
||||
PRAGMA user_version = 11;
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := raw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
database := openTestStore(t, path)
|
||||
binding, err := database.DeviceProxyBinding(ctx, "89441000400128014257")
|
||||
if err != nil || binding.DeviceID != "known" || binding.UpstreamProxyID != "route" {
|
||||
t.Fatalf("migrated binding = %+v, %v", binding, err)
|
||||
}
|
||||
bindings, err := database.ListDeviceProxyBindings(ctx)
|
||||
if err != nil || len(bindings) != 1 {
|
||||
t.Fatalf("migrated bindings = %+v, %v; unknown ICCID binding must be dropped", bindings, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigration9NormalizesVoWiFiAirplanePolicy(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "rf-safe-policy.db")
|
||||
raw, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for version := 1; version <= 8; version++ {
|
||||
for _, statement := range migrationStatements(version) {
|
||||
if _, err := raw.ExecContext(ctx, statement); err != nil {
|
||||
t.Fatalf("create v%d schema: %v", version, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := raw.ExecContext(ctx, `
|
||||
INSERT INTO card_policies (
|
||||
iccid, network_enabled, vowifi_enabled, airplane_enabled,
|
||||
created_at, updated_at
|
||||
) VALUES ('8900000000000000001', 0, 1, 0, 100, 100);
|
||||
PRAGMA user_version = 8;
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := raw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
database := openTestStore(t, path)
|
||||
policy, err := database.CardPolicy(ctx, "8900000000000000001")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !policy.VoWiFiEnabled || !policy.AirplaneEnabled || policy.NetworkEnabled {
|
||||
t.Fatalf("migrated policy = %#v, want VoWiFi+airplane with data off", policy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigration8DefaultsExistingDevicesToPCIeType(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "device-type.db")
|
||||
@@ -286,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:")
|
||||
@@ -566,12 +671,12 @@ func TestProxyCredentialsAndCountryRules(t *testing.T) {
|
||||
t.Fatalf("CountryRule() = %+v, %v", rule, err)
|
||||
}
|
||||
if err := database.UpsertDeviceProxyBinding(ctx, DeviceProxyBinding{
|
||||
DeviceID: "ec20-1", UpstreamProxyID: "up-1",
|
||||
DeviceID: "ec20-1", ICCID: "89441000400128014257", ProfileName: "Vodafone", UpstreamProxyID: "up-1",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
binding, err := database.DeviceProxyBinding(ctx, "ec20-1")
|
||||
if err != nil || binding.UpstreamProxyID != "up-1" {
|
||||
binding, err := database.DeviceProxyBinding(ctx, "89441000400128014257")
|
||||
if err != nil || binding.UpstreamProxyID != "up-1" || binding.DeviceID != "ec20-1" || binding.ProfileName != "Vodafone" {
|
||||
t.Fatalf("DeviceProxyBinding() = %+v, %v", binding, err)
|
||||
}
|
||||
if err := database.DeleteUpstreamProxy(ctx, "up-1"); err != nil {
|
||||
@@ -580,7 +685,7 @@ func TestProxyCredentialsAndCountryRules(t *testing.T) {
|
||||
if _, err := database.CountryRule(ctx, "CN"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("country rule should cascade with upstream deletion, got %v", err)
|
||||
}
|
||||
if _, err := database.DeviceProxyBinding(ctx, "ec20-1"); !errors.Is(err, ErrNotFound) {
|
||||
if _, err := database.DeviceProxyBinding(ctx, "89441000400128014257"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("device binding should cascade with upstream deletion, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -657,6 +762,43 @@ func TestNotificationAndAppSecretPreservation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationArraySecretPreservation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database := openTestStore(t, ":memory:")
|
||||
originalURL := "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=first-secret"
|
||||
if err := database.UpsertNotificationSetting(ctx, NotificationSetting{
|
||||
Channel: "wecom", Enabled: true,
|
||||
Config: json.RawMessage(`{"urls":["` + originalURL + `"]}`),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setting, err := database.NotificationSetting(ctx, "wecom")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var redacted map[string]any
|
||||
if err := json.Unmarshal(setting.Redacted().Config, &redacted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
urls, ok := redacted["urls"].([]any)
|
||||
if !ok || len(urls) != 1 || urls[0] != SecretMask {
|
||||
t.Fatalf("redacted URLs = %#v", redacted["urls"])
|
||||
}
|
||||
if err := database.UpsertNotificationSetting(ctx, NotificationSetting{
|
||||
Channel: "wecom", Enabled: true,
|
||||
Config: json.RawMessage(`{"urls":["` + SecretMask + `","https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=second-secret"]}`),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setting, err = database.NotificationSetting(ctx, "wecom")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(setting.Config, []byte(originalURL)) || !bytes.Contains(setting.Config, []byte("second-secret")) {
|
||||
t.Fatalf("stored URLs = %s", setting.Config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventsPoliciesAndTraffic(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database := openTestStore(t, ":memory:")
|
||||
@@ -704,19 +846,23 @@ func TestEventsPoliciesAndTraffic(t *testing.T) {
|
||||
|
||||
if err := database.UpsertCardPolicy(ctx, CardPolicy{
|
||||
ICCID: "89860001", NetworkEnabled: true, VoWiFiEnabled: true,
|
||||
APN: "ims", IPVersion: "ipv4v6",
|
||||
APN: "ims", IPVersion: "ipv4v6", CustomPhoneNumber: "+8613800138000",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpsertCardPolicy(ctx, CardPolicy{
|
||||
ICCID: "invalid", VoWiFiEnabled: true, AirplaneEnabled: true,
|
||||
}); err == nil {
|
||||
t.Fatal("invalid mutually exclusive card policy was accepted")
|
||||
ICCID: "89860002", VoWiFiEnabled: true, AirplaneEnabled: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("RF-safe VoWiFi policy was rejected: %v", err)
|
||||
}
|
||||
policy, err := database.CardPolicy(ctx, "89860001")
|
||||
if err != nil || !policy.VoWiFiEnabled {
|
||||
if err != nil || !policy.VoWiFiEnabled || policy.CustomPhoneNumber != "+8613800138000" {
|
||||
t.Fatalf("CardPolicy() = %+v, %v", policy, err)
|
||||
}
|
||||
safePolicy, err := database.CardPolicy(ctx, "89860002")
|
||||
if err != nil || !safePolicy.VoWiFiEnabled || !safePolicy.AirplaneEnabled {
|
||||
t.Fatalf("safe CardPolicy() = %+v, %v", safePolicy, err)
|
||||
}
|
||||
|
||||
period := old.Truncate(time.Hour)
|
||||
if err := database.UpsertTrafficBucket(ctx, TrafficBucket{
|
||||
|
||||
@@ -111,6 +111,159 @@ func migrationStatements(version int) []string {
|
||||
`ALTER TABLE devices
|
||||
ADD COLUMN device_type TEXT NOT NULL DEFAULT 'pcie_ec20_ec25'`,
|
||||
}
|
||||
case 9:
|
||||
return []string{
|
||||
// VoWiFi deliberately owns airplane mode. Earlier schemas treated
|
||||
// these flags as mutually exclusive, which made the RF-safe state
|
||||
// impossible to persist. Rebuild the table without changing rows.
|
||||
`ALTER TABLE card_policies RENAME TO card_policies_v8`,
|
||||
`CREATE TABLE card_policies (
|
||||
iccid TEXT PRIMARY KEY,
|
||||
network_enabled INTEGER NOT NULL DEFAULT 0 CHECK (network_enabled IN (0, 1)),
|
||||
vowifi_enabled INTEGER NOT NULL DEFAULT 0 CHECK (vowifi_enabled IN (0, 1)),
|
||||
airplane_enabled INTEGER NOT NULL DEFAULT 0 CHECK (airplane_enabled IN (0, 1)),
|
||||
apn TEXT NOT NULL DEFAULT '',
|
||||
ip_version TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)`,
|
||||
`INSERT INTO card_policies (
|
||||
iccid, network_enabled, vowifi_enabled, airplane_enabled,
|
||||
apn, ip_version, source, created_at, updated_at
|
||||
) SELECT
|
||||
iccid, network_enabled, vowifi_enabled, airplane_enabled,
|
||||
apn, ip_version, source, created_at, updated_at
|
||||
FROM card_policies_v8`,
|
||||
`UPDATE card_policies
|
||||
SET airplane_enabled = 1, network_enabled = 0
|
||||
WHERE vowifi_enabled = 1`,
|
||||
`DROP TABLE card_policies_v8`,
|
||||
}
|
||||
case 10:
|
||||
return []string{
|
||||
`CREATE TABLE IF NOT EXISTS automatic_tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
|
||||
device_id TEXT NOT NULL,
|
||||
profile_iccid TEXT NOT NULL,
|
||||
profile_aid TEXT NOT NULL DEFAULT '',
|
||||
task_type TEXT NOT NULL CHECK (task_type IN ('sms', 'call', 'public_ip')),
|
||||
environment TEXT NOT NULL CHECK (environment IN ('vowifi', 'cellular')),
|
||||
interval_days INTEGER NOT NULL CHECK (interval_days BETWEEN 1 AND 365),
|
||||
start_date TEXT NOT NULL,
|
||||
run_time TEXT NOT NULL,
|
||||
timezone TEXT NOT NULL DEFAULT 'Local',
|
||||
payload_json TEXT NOT NULL DEFAULT '{}',
|
||||
retry_count INTEGER NOT NULL DEFAULT 0 CHECK (retry_count BETWEEN 0 AND 10),
|
||||
notify INTEGER NOT NULL DEFAULT 0 CHECK (notify IN (0, 1)),
|
||||
next_run_at INTEGER NOT NULL,
|
||||
last_run_at INTEGER NOT NULL DEFAULT 0,
|
||||
last_status TEXT NOT NULL DEFAULT '',
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS automatic_tasks_due_idx ON automatic_tasks(enabled, next_run_at, id)`,
|
||||
`CREATE INDEX IF NOT EXISTS automatic_tasks_device_idx ON automatic_tasks(device_id, next_run_at, id)`,
|
||||
`CREATE TABLE IF NOT EXISTS automatic_task_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id INTEGER NOT NULL,
|
||||
device_id TEXT NOT NULL,
|
||||
scheduled_at INTEGER NOT NULL,
|
||||
started_at INTEGER NOT NULL DEFAULT 0,
|
||||
finished_at INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL CHECK (status IN ('queued', 'running', 'success', 'failed')),
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
output TEXT NOT NULL DEFAULT '',
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (task_id) REFERENCES automatic_tasks(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS automatic_task_runs_task_idx ON automatic_task_runs(task_id, id DESC)`,
|
||||
`CREATE INDEX IF NOT EXISTS automatic_task_runs_status_idx ON automatic_task_runs(status, id)`,
|
||||
}
|
||||
case 11:
|
||||
return []string{
|
||||
`CREATE TABLE IF NOT EXISTS sms_send_attempts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
device_id TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS sms_send_attempts_created_idx
|
||||
ON sms_send_attempts(created_at, id)`,
|
||||
}
|
||||
case 12:
|
||||
return []string{
|
||||
`ALTER TABLE device_proxy_bindings RENAME TO device_proxy_bindings_v11`,
|
||||
`CREATE TABLE device_proxy_bindings (
|
||||
iccid TEXT PRIMARY KEY,
|
||||
device_id TEXT NOT NULL,
|
||||
profile_name TEXT NOT NULL DEFAULT '',
|
||||
upstream_proxy_id TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (upstream_proxy_id) REFERENCES upstream_proxies(id) ON DELETE CASCADE
|
||||
)`,
|
||||
// A legacy device-wide binding is safe to preserve only when the
|
||||
// currently observed ICCID is known. It then becomes one profile binding
|
||||
// instead of leaking onto every future profile used by that device.
|
||||
`INSERT OR IGNORE INTO device_proxy_bindings (
|
||||
iccid, device_id, profile_name, upstream_proxy_id, created_at, updated_at
|
||||
)
|
||||
SELECT COALESCE(NULLIF(v.iccid, ''), NULLIF(d.iccid, '')),
|
||||
b.device_id, '', b.upstream_proxy_id, b.created_at, b.updated_at
|
||||
FROM device_proxy_bindings_v11 b
|
||||
LEFT JOIN vowifi_runtime v ON v.device_id = b.device_id
|
||||
LEFT JOIN device_runtime d ON d.device_id = b.device_id
|
||||
WHERE COALESCE(NULLIF(v.iccid, ''), NULLIF(d.iccid, '')) IS NOT NULL`,
|
||||
`DROP TABLE device_proxy_bindings_v11`,
|
||||
`CREATE INDEX device_proxy_bindings_proxy_idx
|
||||
ON device_proxy_bindings(upstream_proxy_id)`,
|
||||
`CREATE INDEX device_proxy_bindings_device_idx
|
||||
ON device_proxy_bindings(device_id, iccid)`,
|
||||
}
|
||||
case 13:
|
||||
return []string{
|
||||
`CREATE TABLE IF NOT EXISTS card_apn_profiles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
iccid TEXT NOT NULL,
|
||||
apn TEXT NOT NULL,
|
||||
ip_version TEXT NOT NULL DEFAULT 'IPV4V6'
|
||||
CHECK (ip_version IN ('IP', 'IPV6', 'IPV4V6')),
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
UNIQUE (iccid, apn, ip_version),
|
||||
FOREIGN KEY (iccid) REFERENCES card_policies(iccid) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS card_apn_profiles_iccid_idx
|
||||
ON card_apn_profiles(iccid, id)`,
|
||||
}
|
||||
case 14:
|
||||
return []string{
|
||||
`ALTER TABLE card_apn_profiles ADD COLUMN username TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE card_apn_profiles ADD COLUMN password TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE card_apn_profiles ADD COLUMN proxy TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE card_apn_profiles ADD COLUMN mcc TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE card_apn_profiles ADD COLUMN mnc TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE card_apn_profiles ADD COLUMN roaming_ip_version TEXT NOT NULL DEFAULT 'IP'
|
||||
CHECK (roaming_ip_version IN ('IP', 'IPV6', 'IPV4V6'))`,
|
||||
`ALTER TABLE card_apn_profiles ADD COLUMN auth_type TEXT NOT NULL DEFAULT 'NONE'
|
||||
CHECK (auth_type IN ('NONE', 'PAP', 'CHAP', 'PAP_OR_CHAP'))`,
|
||||
}
|
||||
case 15:
|
||||
return []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
|
||||
}
|
||||
|
||||
+118
-24
@@ -24,6 +24,7 @@ type Device struct {
|
||||
USBPath string
|
||||
AudioDevice string
|
||||
ModemIMEI string
|
||||
SIMPIN string
|
||||
APN string
|
||||
ProxyPort int
|
||||
BaudRate int
|
||||
@@ -124,6 +125,45 @@ type PhoneAssociation struct {
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type AutomaticTask struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
DeviceID string `json:"device_id"`
|
||||
ProfileICCID string `json:"profile_iccid"`
|
||||
ProfileAID string `json:"profile_aid"`
|
||||
TaskType string `json:"task_type"`
|
||||
Environment string `json:"environment"`
|
||||
IntervalDays int `json:"interval_days"`
|
||||
StartDate string `json:"start_date"`
|
||||
RunTime string `json:"run_time"`
|
||||
Timezone string `json:"timezone"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
RetryCount int `json:"retry_count"`
|
||||
Notify bool `json:"notify"`
|
||||
NextRunAt time.Time `json:"next_run_at"`
|
||||
LastRunAt time.Time `json:"last_run_at,omitempty"`
|
||||
LastStatus string `json:"last_status"`
|
||||
LastError string `json:"last_error"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type AutomaticTaskRun struct {
|
||||
ID int64 `json:"id"`
|
||||
TaskID int64 `json:"task_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
ScheduledAt time.Time `json:"scheduled_at"`
|
||||
StartedAt time.Time `json:"started_at,omitempty"`
|
||||
FinishedAt time.Time `json:"finished_at,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Attempts int `json:"attempts"`
|
||||
Output string `json:"output"`
|
||||
Error string `json:"error"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type SMSMessage struct {
|
||||
ID int64
|
||||
MessageID string
|
||||
@@ -263,11 +303,12 @@ type CountryRule struct {
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// DeviceProxyBinding selects the SOCKS5 upstream used by one device's whole
|
||||
// VoWiFi runtime. The IKE/IPsec transport uses this route and IMS/SMS then
|
||||
// travel inside that tunnel.
|
||||
// DeviceProxyBinding selects the SOCKS5 upstream for exactly one eSIM profile.
|
||||
// ICCID is globally unique, while one proxy may serve profiles on many devices.
|
||||
type DeviceProxyBinding struct {
|
||||
DeviceID string
|
||||
ICCID string
|
||||
ProfileName string
|
||||
UpstreamProxyID string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
@@ -299,12 +340,9 @@ func (value NotificationSetting) SensitiveValues() []string {
|
||||
}
|
||||
values := make([]string, 0, len(value.SensitiveFields))
|
||||
for _, field := range value.SensitiveFields {
|
||||
if secret, ok := getJSONPath(document, field).(string); ok &&
|
||||
secret != "" && secret != SecretMask {
|
||||
values = append(values, secret)
|
||||
}
|
||||
collectJSONStringValues(getJSONPath(document, field), &values)
|
||||
}
|
||||
return values
|
||||
return uniqueNonemptyStrings(values)
|
||||
}
|
||||
|
||||
type AppSetting struct {
|
||||
@@ -437,15 +475,32 @@ type LogFilter struct {
|
||||
}
|
||||
|
||||
type CardPolicy struct {
|
||||
ICCID string
|
||||
NetworkEnabled bool
|
||||
VoWiFiEnabled bool
|
||||
AirplaneEnabled bool
|
||||
APN string
|
||||
IPVersion string
|
||||
Source string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ICCID string
|
||||
NetworkEnabled bool
|
||||
VoWiFiEnabled bool
|
||||
AirplaneEnabled bool
|
||||
APN string
|
||||
IPVersion string
|
||||
CustomPhoneNumber string
|
||||
Source string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type CardAPNProfile struct {
|
||||
ID int64
|
||||
ICCID string
|
||||
APN string
|
||||
Username string
|
||||
Password string
|
||||
Proxy string
|
||||
MCC string
|
||||
MNC string
|
||||
IPVersion string
|
||||
RoamingIPVersion string
|
||||
AuthType string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type TrafficBucket struct {
|
||||
@@ -519,8 +574,8 @@ func redactJSONFields(value json.RawMessage, fields []string, replacement string
|
||||
return json.RawMessage(`{}`)
|
||||
}
|
||||
for _, field := range fields {
|
||||
if getJSONPath(document, field) != nil {
|
||||
setJSONPath(document, field, replacement)
|
||||
if current := getJSONPath(document, field); current != nil {
|
||||
setJSONPath(document, field, redactJSONValue(current, replacement))
|
||||
}
|
||||
}
|
||||
encoded, err := json.Marshal(document)
|
||||
@@ -545,16 +600,55 @@ func mergeJSONSecrets(
|
||||
}
|
||||
for _, field := range fields {
|
||||
value := getJSONPath(next, field)
|
||||
text, stringValue := value.(string)
|
||||
if value == nil || (stringValue && (text == "" || text == SecretMask)) {
|
||||
if previous := getJSONPath(current, field); previous != nil {
|
||||
setJSONPath(next, field, previous)
|
||||
}
|
||||
if previous := getJSONPath(current, field); previous != nil {
|
||||
setJSONPath(next, field, mergeJSONSecretValue(value, previous))
|
||||
}
|
||||
}
|
||||
return json.Marshal(next)
|
||||
}
|
||||
|
||||
func redactJSONValue(value any, replacement string) any {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return replacement
|
||||
case []any:
|
||||
result := make([]any, len(typed))
|
||||
for index, item := range typed {
|
||||
result[index] = redactJSONValue(item, replacement)
|
||||
}
|
||||
return result
|
||||
default:
|
||||
return replacement
|
||||
}
|
||||
}
|
||||
|
||||
func mergeJSONSecretValue(incoming, existing any) any {
|
||||
if incoming == nil {
|
||||
return existing
|
||||
}
|
||||
switch next := incoming.(type) {
|
||||
case string:
|
||||
if next == "" || next == SecretMask {
|
||||
return existing
|
||||
}
|
||||
case []any:
|
||||
previous, ok := existing.([]any)
|
||||
if !ok {
|
||||
return incoming
|
||||
}
|
||||
merged := make([]any, len(next))
|
||||
for index, value := range next {
|
||||
if index < len(previous) {
|
||||
merged[index] = mergeJSONSecretValue(value, previous[index])
|
||||
} else {
|
||||
merged[index] = value
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
return incoming
|
||||
}
|
||||
|
||||
func getJSONPath(document map[string]any, path string) any {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return nil
|
||||
|
||||
+21
-17
@@ -358,9 +358,11 @@ func upstreamProxy(row rowScanner) (UpstreamProxy, error) {
|
||||
|
||||
func (s *Store) UpsertDeviceProxyBinding(ctx context.Context, value DeviceProxyBinding) error {
|
||||
value.DeviceID = strings.TrimSpace(value.DeviceID)
|
||||
value.ICCID = strings.TrimSpace(value.ICCID)
|
||||
value.ProfileName = strings.TrimSpace(value.ProfileName)
|
||||
value.UpstreamProxyID = strings.TrimSpace(value.UpstreamProxyID)
|
||||
if value.DeviceID == "" || value.UpstreamProxyID == "" {
|
||||
return errors.New("device proxy binding requires device and upstream proxy IDs")
|
||||
if value.DeviceID == "" || value.ICCID == "" || value.UpstreamProxyID == "" {
|
||||
return errors.New("profile proxy binding requires device ID, ICCID, and upstream proxy ID")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
createdAt := value.CreatedAt
|
||||
@@ -373,28 +375,30 @@ func (s *Store) UpsertDeviceProxyBinding(ctx context.Context, value DeviceProxyB
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO device_proxy_bindings (
|
||||
device_id, upstream_proxy_id, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(device_id) DO UPDATE SET
|
||||
iccid, device_id, profile_name, upstream_proxy_id, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(iccid) DO UPDATE SET
|
||||
device_id = excluded.device_id,
|
||||
profile_name = excluded.profile_name,
|
||||
upstream_proxy_id = excluded.upstream_proxy_id,
|
||||
updated_at = excluded.updated_at
|
||||
`, value.DeviceID, value.UpstreamProxyID, createdAt.Unix(), updatedAt.Unix())
|
||||
`, value.ICCID, value.DeviceID, value.ProfileName, value.UpstreamProxyID, createdAt.Unix(), updatedAt.Unix())
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert proxy binding for device %q: %w", value.DeviceID, err)
|
||||
return fmt.Errorf("upsert proxy binding for ICCID %q: %w", value.ICCID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) DeviceProxyBinding(ctx context.Context, deviceID string) (DeviceProxyBinding, error) {
|
||||
func (s *Store) DeviceProxyBinding(ctx context.Context, iccid string) (DeviceProxyBinding, error) {
|
||||
return deviceProxyBinding(s.db.QueryRowContext(
|
||||
ctx,
|
||||
deviceProxyBindingSelect+` WHERE device_id = ?`,
|
||||
strings.TrimSpace(deviceID),
|
||||
deviceProxyBindingSelect+` WHERE iccid = ?`,
|
||||
strings.TrimSpace(iccid),
|
||||
))
|
||||
}
|
||||
|
||||
func (s *Store) ListDeviceProxyBindings(ctx context.Context) ([]DeviceProxyBinding, error) {
|
||||
rows, err := s.db.QueryContext(ctx, deviceProxyBindingSelect+` ORDER BY device_id`)
|
||||
rows, err := s.db.QueryContext(ctx, deviceProxyBindingSelect+` ORDER BY device_id, profile_name COLLATE NOCASE, iccid`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list device proxy bindings: %w", err)
|
||||
}
|
||||
@@ -413,26 +417,26 @@ func (s *Store) ListDeviceProxyBindings(ctx context.Context) ([]DeviceProxyBindi
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteDeviceProxyBinding(ctx context.Context, deviceID string) error {
|
||||
func (s *Store) DeleteDeviceProxyBinding(ctx context.Context, iccid string) error {
|
||||
result, err := s.db.ExecContext(
|
||||
ctx,
|
||||
`DELETE FROM device_proxy_bindings WHERE device_id = ?`,
|
||||
strings.TrimSpace(deviceID),
|
||||
`DELETE FROM device_proxy_bindings WHERE iccid = ?`,
|
||||
strings.TrimSpace(iccid),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete proxy binding for device %q: %w", deviceID, err)
|
||||
return fmt.Errorf("delete proxy binding for ICCID %q: %w", iccid, err)
|
||||
}
|
||||
return requireAffected(result)
|
||||
}
|
||||
|
||||
const deviceProxyBindingSelect = `
|
||||
SELECT device_id, upstream_proxy_id, created_at, updated_at
|
||||
SELECT device_id, iccid, profile_name, upstream_proxy_id, created_at, updated_at
|
||||
FROM device_proxy_bindings`
|
||||
|
||||
func deviceProxyBinding(row rowScanner) (DeviceProxyBinding, error) {
|
||||
var value DeviceProxyBinding
|
||||
var createdAt, updatedAt int64
|
||||
err := row.Scan(&value.DeviceID, &value.UpstreamProxyID, &createdAt, &updatedAt)
|
||||
err := row.Scan(&value.DeviceID, &value.ICCID, &value.ProfileName, &value.UpstreamProxyID, &createdAt, &updatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return DeviceProxyBinding{}, ErrNotFound
|
||||
}
|
||||
|
||||
+164
-8
@@ -22,6 +22,8 @@ func DefaultNotificationSensitiveFields(channel string) []string {
|
||||
return []string{"secret"}
|
||||
case "pushplus":
|
||||
return []string{"token"}
|
||||
case "wecom":
|
||||
return []string{"urls"}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
@@ -360,6 +362,7 @@ func maskedJSONValue(value json.RawMessage) bool {
|
||||
|
||||
func (s *Store) UpsertCardPolicy(ctx context.Context, value CardPolicy) error {
|
||||
value.ICCID = strings.TrimSpace(value.ICCID)
|
||||
value.CustomPhoneNumber = strings.TrimSpace(value.CustomPhoneNumber)
|
||||
if value.ICCID == "" {
|
||||
return errors.New("card policy ICCID is required")
|
||||
}
|
||||
@@ -369,9 +372,6 @@ func (s *Store) UpsertCardPolicy(ctx context.Context, value CardPolicy) error {
|
||||
default:
|
||||
return fmt.Errorf("unsupported card policy IP version %q", value.IPVersion)
|
||||
}
|
||||
if value.VoWiFiEnabled && value.AirplaneEnabled {
|
||||
return errors.New("VoWiFi and airplane mode cannot both be enabled")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
createdAt := value.CreatedAt
|
||||
if createdAt.IsZero() {
|
||||
@@ -384,20 +384,21 @@ func (s *Store) UpsertCardPolicy(ctx context.Context, value CardPolicy) error {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO card_policies (
|
||||
iccid, network_enabled, vowifi_enabled, airplane_enabled,
|
||||
apn, ip_version, source, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
apn, ip_version, custom_phone_number, source, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(iccid) DO UPDATE SET
|
||||
network_enabled = excluded.network_enabled,
|
||||
vowifi_enabled = excluded.vowifi_enabled,
|
||||
airplane_enabled = excluded.airplane_enabled,
|
||||
apn = excluded.apn,
|
||||
ip_version = excluded.ip_version,
|
||||
custom_phone_number = excluded.custom_phone_number,
|
||||
source = excluded.source,
|
||||
updated_at = excluded.updated_at
|
||||
`,
|
||||
value.ICCID, boolInt(value.NetworkEnabled), boolInt(value.VoWiFiEnabled),
|
||||
boolInt(value.AirplaneEnabled), value.APN, value.IPVersion,
|
||||
value.Source, createdAt.Unix(), updatedAt.Unix(),
|
||||
value.CustomPhoneNumber, value.Source, createdAt.Unix(), updatedAt.Unix(),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert card policy %q: %w", value.ICCID, err)
|
||||
@@ -443,7 +444,7 @@ func (s *Store) DeleteCardPolicy(ctx context.Context, iccid string) error {
|
||||
|
||||
const cardPolicySelect = `
|
||||
SELECT iccid, network_enabled, vowifi_enabled, airplane_enabled,
|
||||
apn, ip_version, source, created_at, updated_at
|
||||
apn, ip_version, custom_phone_number, source, created_at, updated_at
|
||||
FROM card_policies`
|
||||
|
||||
func cardPolicy(row rowScanner) (CardPolicy, error) {
|
||||
@@ -452,7 +453,7 @@ func cardPolicy(row rowScanner) (CardPolicy, error) {
|
||||
var createdAt, updatedAt int64
|
||||
err := row.Scan(
|
||||
&value.ICCID, &networkEnabled, &vowifiEnabled, &airplaneEnabled,
|
||||
&value.APN, &value.IPVersion, &value.Source, &createdAt, &updatedAt,
|
||||
&value.APN, &value.IPVersion, &value.CustomPhoneNumber, &value.Source, &createdAt, &updatedAt,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return CardPolicy{}, ErrNotFound
|
||||
@@ -468,6 +469,161 @@ func cardPolicy(row rowScanner) (CardPolicy, error) {
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpsertCardAPNProfile(ctx context.Context, value CardAPNProfile) (CardAPNProfile, error) {
|
||||
value.ICCID = strings.TrimSpace(value.ICCID)
|
||||
value.APN = strings.TrimSpace(value.APN)
|
||||
value.IPVersion = strings.ToUpper(strings.TrimSpace(value.IPVersion))
|
||||
if value.ICCID == "" || value.APN == "" {
|
||||
return CardAPNProfile{}, errors.New("card APN profile ICCID and APN are required")
|
||||
}
|
||||
if value.IPVersion == "" {
|
||||
value.IPVersion = "IPV4V6"
|
||||
}
|
||||
switch value.IPVersion {
|
||||
case "IP", "IPV6", "IPV4V6":
|
||||
default:
|
||||
return CardAPNProfile{}, fmt.Errorf("unsupported card APN profile IP version %q", value.IPVersion)
|
||||
}
|
||||
value.RoamingIPVersion = strings.ToUpper(strings.TrimSpace(value.RoamingIPVersion))
|
||||
if value.RoamingIPVersion == "" {
|
||||
value.RoamingIPVersion = "IP"
|
||||
}
|
||||
switch value.RoamingIPVersion {
|
||||
case "IP", "IPV6", "IPV4V6":
|
||||
default:
|
||||
return CardAPNProfile{}, fmt.Errorf("unsupported card APN roaming IP version %q", value.RoamingIPVersion)
|
||||
}
|
||||
value.AuthType = strings.ToUpper(strings.TrimSpace(value.AuthType))
|
||||
if value.AuthType == "" {
|
||||
value.AuthType = "NONE"
|
||||
}
|
||||
switch value.AuthType {
|
||||
case "NONE", "PAP", "CHAP", "PAP_OR_CHAP":
|
||||
default:
|
||||
return CardAPNProfile{}, fmt.Errorf("unsupported card APN authentication type %q", value.AuthType)
|
||||
}
|
||||
value.Username = strings.TrimSpace(value.Username)
|
||||
value.Proxy = strings.TrimSpace(value.Proxy)
|
||||
value.MCC = strings.TrimSpace(value.MCC)
|
||||
value.MNC = strings.TrimSpace(value.MNC)
|
||||
now := time.Now().UTC().Unix()
|
||||
var createdAt, updatedAt int64
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
INSERT INTO card_apn_profiles (
|
||||
iccid, apn, username, password, proxy, mcc, mnc,
|
||||
ip_version, roaming_ip_version, auth_type, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(iccid, apn, ip_version) DO UPDATE SET
|
||||
username = excluded.username, password = excluded.password,
|
||||
proxy = excluded.proxy, mcc = excluded.mcc, mnc = excluded.mnc,
|
||||
roaming_ip_version = excluded.roaming_ip_version,
|
||||
auth_type = excluded.auth_type, updated_at = excluded.updated_at
|
||||
RETURNING id, iccid, apn, username, password, proxy, mcc, mnc,
|
||||
ip_version, roaming_ip_version, auth_type, created_at, updated_at
|
||||
`, value.ICCID, value.APN, value.Username, value.Password, value.Proxy, value.MCC, value.MNC,
|
||||
value.IPVersion, value.RoamingIPVersion, value.AuthType, now, now).Scan(
|
||||
&value.ID, &value.ICCID, &value.APN, &value.Username, &value.Password,
|
||||
&value.Proxy, &value.MCC, &value.MNC, &value.IPVersion,
|
||||
&value.RoamingIPVersion, &value.AuthType, &createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return CardAPNProfile{}, fmt.Errorf("upsert card APN profile: %w", err)
|
||||
}
|
||||
value.CreatedAt = time.Unix(createdAt, 0).UTC()
|
||||
value.UpdatedAt = time.Unix(updatedAt, 0).UTC()
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListCardAPNProfiles(ctx context.Context, iccid string) ([]CardAPNProfile, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, iccid, apn, username, password, proxy, mcc, mnc,
|
||||
ip_version, roaming_ip_version, auth_type, created_at, updated_at
|
||||
FROM card_apn_profiles WHERE iccid = ? ORDER BY id
|
||||
`, strings.TrimSpace(iccid))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list card APN profiles: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
values := make([]CardAPNProfile, 0)
|
||||
for rows.Next() {
|
||||
var value CardAPNProfile
|
||||
var createdAt, updatedAt int64
|
||||
if err := rows.Scan(&value.ID, &value.ICCID, &value.APN, &value.Username,
|
||||
&value.Password, &value.Proxy, &value.MCC, &value.MNC, &value.IPVersion,
|
||||
&value.RoamingIPVersion, &value.AuthType, &createdAt, &updatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan card APN profile: %w", err)
|
||||
}
|
||||
value.CreatedAt = time.Unix(createdAt, 0).UTC()
|
||||
value.UpdatedAt = time.Unix(updatedAt, 0).UTC()
|
||||
values = append(values, value)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate card APN profiles: %w", err)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func (s *Store) CardAPNProfileByAPN(ctx context.Context, iccid, apn, ipVersion string) (CardAPNProfile, error) {
|
||||
profiles, err := s.ListCardAPNProfiles(ctx, iccid)
|
||||
if err != nil {
|
||||
return CardAPNProfile{}, err
|
||||
}
|
||||
for _, profile := range profiles {
|
||||
if strings.EqualFold(profile.APN, strings.TrimSpace(apn)) &&
|
||||
strings.EqualFold(profile.IPVersion, strings.TrimSpace(ipVersion)) {
|
||||
return profile, nil
|
||||
}
|
||||
}
|
||||
return CardAPNProfile{}, ErrNotFound
|
||||
}
|
||||
|
||||
func (s *Store) UpdateCardAPNProfile(ctx context.Context, value CardAPNProfile) (CardAPNProfile, error) {
|
||||
value.ICCID = strings.TrimSpace(value.ICCID)
|
||||
value.APN = strings.TrimSpace(value.APN)
|
||||
value.Username = strings.TrimSpace(value.Username)
|
||||
value.Proxy = strings.TrimSpace(value.Proxy)
|
||||
value.MCC = strings.TrimSpace(value.MCC)
|
||||
value.MNC = strings.TrimSpace(value.MNC)
|
||||
value.IPVersion = strings.ToUpper(strings.TrimSpace(value.IPVersion))
|
||||
value.RoamingIPVersion = strings.ToUpper(strings.TrimSpace(value.RoamingIPVersion))
|
||||
value.AuthType = strings.ToUpper(strings.TrimSpace(value.AuthType))
|
||||
if value.ID < 1 || value.ICCID == "" || value.APN == "" {
|
||||
return CardAPNProfile{}, errors.New("card APN profile ID, ICCID, and APN are required")
|
||||
}
|
||||
now := time.Now().UTC().Unix()
|
||||
var createdAt, updatedAt int64
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
UPDATE card_apn_profiles SET
|
||||
apn = ?, username = ?, password = ?, proxy = ?, mcc = ?, mnc = ?,
|
||||
ip_version = ?, roaming_ip_version = ?, auth_type = ?, updated_at = ?
|
||||
WHERE id = ? AND iccid = ?
|
||||
RETURNING id, iccid, apn, username, password, proxy, mcc, mnc,
|
||||
ip_version, roaming_ip_version, auth_type, created_at, updated_at
|
||||
`, value.APN, value.Username, value.Password, value.Proxy, value.MCC, value.MNC,
|
||||
value.IPVersion, value.RoamingIPVersion, value.AuthType, now, value.ID, value.ICCID).Scan(
|
||||
&value.ID, &value.ICCID, &value.APN, &value.Username, &value.Password,
|
||||
&value.Proxy, &value.MCC, &value.MNC, &value.IPVersion,
|
||||
&value.RoamingIPVersion, &value.AuthType, &createdAt, &updatedAt,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return CardAPNProfile{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return CardAPNProfile{}, fmt.Errorf("update card APN profile: %w", err)
|
||||
}
|
||||
value.CreatedAt = time.Unix(createdAt, 0).UTC()
|
||||
value.UpdatedAt = time.Unix(updatedAt, 0).UTC()
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteCardAPNProfile(ctx context.Context, iccid string, id int64) error {
|
||||
result, err := s.db.ExecContext(ctx, `DELETE FROM card_apn_profiles WHERE iccid = ? AND id = ?`, strings.TrimSpace(iccid), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete card APN profile: %w", err)
|
||||
}
|
||||
return requireAffected(result)
|
||||
}
|
||||
|
||||
func (s *Store) UpsertTrafficBucket(ctx context.Context, value TrafficBucket) error {
|
||||
return s.writeTrafficBucket(ctx, value, false)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const SMSRateWindow = time.Hour
|
||||
|
||||
// SMSRateReservation is the durable result of claiming one global outbound
|
||||
// SMS slot. The quota is shared by every device, SIM, transport, and caller.
|
||||
type SMSRateReservation struct {
|
||||
Allowed bool
|
||||
Limit int
|
||||
Used int
|
||||
Remaining int
|
||||
ResetAt time.Time
|
||||
}
|
||||
|
||||
// ReserveSMSSend atomically claims one slot in the rolling one-hour window.
|
||||
// It intentionally records submission attempts separately from SMS history so
|
||||
// deleting a conversation cannot reset the global safety limit.
|
||||
func (s *Store) ReserveSMSSend(
|
||||
ctx context.Context,
|
||||
deviceID string,
|
||||
limit int,
|
||||
now time.Time,
|
||||
) (SMSRateReservation, error) {
|
||||
if limit < 1 {
|
||||
return SMSRateReservation{}, errors.New("SMS hourly limit must be positive")
|
||||
}
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
} else {
|
||||
now = now.UTC()
|
||||
}
|
||||
cutoff := now.Add(-SMSRateWindow).Unix()
|
||||
result, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO sms_send_attempts (device_id, created_at)
|
||||
SELECT ?, ?
|
||||
WHERE (
|
||||
SELECT COUNT(*) FROM sms_send_attempts WHERE created_at > ?
|
||||
) < ?
|
||||
`, strings.TrimSpace(deviceID), now.Unix(), cutoff, limit)
|
||||
if err != nil {
|
||||
return SMSRateReservation{}, fmt.Errorf("reserve global SMS send slot: %w", err)
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return SMSRateReservation{}, fmt.Errorf("read global SMS reservation result: %w", err)
|
||||
}
|
||||
|
||||
status, err := s.smsRateStatus(ctx, limit, cutoff)
|
||||
if err != nil {
|
||||
return SMSRateReservation{}, err
|
||||
}
|
||||
status.Allowed = affected == 1
|
||||
if status.Allowed {
|
||||
// Old rows are irrelevant to enforcement. Pruning after the atomic claim
|
||||
// keeps the hot index compact without creating a delete-before-insert race.
|
||||
_, _ = s.db.ExecContext(ctx, `DELETE FROM sms_send_attempts WHERE created_at <= ?`, now.Add(-7*24*time.Hour).Unix())
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func (s *Store) smsRateStatus(ctx context.Context, limit int, cutoff int64) (SMSRateReservation, error) {
|
||||
var used int
|
||||
var earliest *int64
|
||||
if err := s.db.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*), MIN(created_at)
|
||||
FROM sms_send_attempts
|
||||
WHERE created_at > ?
|
||||
`, cutoff).Scan(&used, &earliest); err != nil {
|
||||
return SMSRateReservation{}, fmt.Errorf("read global SMS rate status: %w", err)
|
||||
}
|
||||
remaining := limit - used
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
status := SMSRateReservation{Limit: limit, Used: used, Remaining: remaining}
|
||||
if earliest != nil {
|
||||
status.ResetAt = time.Unix(*earliest, 0).UTC().Add(SMSRateWindow)
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestReserveSMSSendIsGlobalAndRolling(t *testing.T) {
|
||||
database := openTestStore(t, ":memory:")
|
||||
now := time.Unix(1_800_000_000, 0).UTC()
|
||||
|
||||
first, err := database.ReserveSMSSend(context.Background(), "ec20_1", 2, now)
|
||||
if err != nil || !first.Allowed || first.Used != 1 || first.Remaining != 1 {
|
||||
t.Fatalf("first reservation = %+v, %v", first, err)
|
||||
}
|
||||
second, err := database.ReserveSMSSend(context.Background(), "ec20_2", 2, now.Add(time.Second))
|
||||
if err != nil || !second.Allowed || second.Used != 2 || second.Remaining != 0 {
|
||||
t.Fatalf("second reservation = %+v, %v", second, err)
|
||||
}
|
||||
blocked, err := database.ReserveSMSSend(context.Background(), "another-device", 2, now.Add(2*time.Second))
|
||||
if err != nil || blocked.Allowed || blocked.Used != 2 || !blocked.ResetAt.Equal(now.Add(SMSRateWindow)) {
|
||||
t.Fatalf("blocked reservation = %+v, %v", blocked, err)
|
||||
}
|
||||
afterWindow, err := database.ReserveSMSSend(context.Background(), "ec20_1", 2, now.Add(SMSRateWindow+time.Second))
|
||||
if err != nil || !afterWindow.Allowed || afterWindow.Used != 1 {
|
||||
t.Fatalf("reservation after rolling window = %+v, %v", afterWindow, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReserveSMSSendCannotExceedLimitConcurrently(t *testing.T) {
|
||||
database := openTestStore(t, ":memory:")
|
||||
now := time.Unix(1_800_000_000, 0).UTC()
|
||||
const limit = 10
|
||||
const callers = 40
|
||||
var allowed atomic.Int32
|
||||
var wait sync.WaitGroup
|
||||
for index := 0; index < callers; index++ {
|
||||
wait.Add(1)
|
||||
go func(index int) {
|
||||
defer wait.Done()
|
||||
result, err := database.ReserveSMSSend(context.Background(), "device", limit, now)
|
||||
if err != nil {
|
||||
t.Errorf("reservation %d: %v", index, err)
|
||||
return
|
||||
}
|
||||
if result.Allowed {
|
||||
allowed.Add(1)
|
||||
}
|
||||
}(index)
|
||||
}
|
||||
wait.Wait()
|
||||
if got := allowed.Load(); got != limit {
|
||||
t.Fatalf("allowed reservations = %d, want %d", got, limit)
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const schemaVersion = 8
|
||||
const schemaVersion = 16
|
||||
|
||||
var ErrNotFound = errors.New("store: not found")
|
||||
|
||||
@@ -121,7 +121,9 @@ func migrate(ctx context.Context, db *sql.DB) error {
|
||||
// already contain an additive column. Remaining statements in the
|
||||
// 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 == 8 && strings.Contains(statement, "ADD COLUMN device_type")) ||
|
||||
(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
|
||||
}
|
||||
|
||||
@@ -222,14 +222,29 @@ func (adapter *EC20Adapter) readHomePLMN(
|
||||
}
|
||||
// Exact assigned HPLMN prefixes are data, not an MNC-length heuristic. The
|
||||
// target Vodafone UK SIM is 234/15. Unknown assignments remain fail-closed.
|
||||
for prefix, mncLength := range map[string]int{"23415": 2} {
|
||||
if strings.HasPrefix(imsi, prefix) {
|
||||
return imsi[:3], imsi[3 : 3+mncLength], nil
|
||||
}
|
||||
if mcc, mnc, ok := assignedHomePLMN(imsi); ok {
|
||||
return mcc, mnc, nil
|
||||
}
|
||||
return "", "", efErr
|
||||
}
|
||||
|
||||
func assignedHomePLMN(imsi string) (mcc, mnc string, ok bool) {
|
||||
assignments := []struct {
|
||||
prefix string
|
||||
mncLength int
|
||||
}{
|
||||
{prefix: "20404", mncLength: 2}, // Vodafone NL core; some Lebara subscriptions.
|
||||
{prefix: "23415", mncLength: 2}, // Vodafone UK.
|
||||
{prefix: "23487", mncLength: 2}, // Lebara Mobile UK.
|
||||
}
|
||||
for _, assignment := range assignments {
|
||||
if strings.HasPrefix(imsi, assignment.prefix) {
|
||||
return imsi[:3], imsi[3 : 3+assignment.mncLength], true
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func validConfiguredHomePLMN(imsi, mcc, mnc string) bool {
|
||||
if !validDigits(mcc, 3, 3) || !validDigits(mnc, 2, 3) {
|
||||
return false
|
||||
@@ -658,15 +673,22 @@ func (adapter *EC20Adapter) Restore(
|
||||
if snapshot.OperatingMode < 0 {
|
||||
return errors.New("vocat: invalid EC20 radio snapshot")
|
||||
}
|
||||
targetMode := snapshot.OperatingMode
|
||||
if snapshot.PureAirplanePolicy {
|
||||
// VoWiFi teardown is intentionally fail-closed. Even if the modem was in
|
||||
// CFUN=1 before setup, disabling VoWiFi must leave RF off until the user
|
||||
// explicitly turns airplane mode off through the separate control.
|
||||
targetMode = 4
|
||||
}
|
||||
currentMode, err := adapter.readOperatingMode(ctx, deviceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if currentMode != snapshot.OperatingMode {
|
||||
if currentMode != targetMode {
|
||||
if _, err := adapter.execute(
|
||||
ctx,
|
||||
deviceID,
|
||||
fmt.Sprintf("AT+CFUN=%d", snapshot.OperatingMode),
|
||||
fmt.Sprintf("AT+CFUN=%d", targetMode),
|
||||
); err != nil {
|
||||
return fmt.Errorf("restore EC20 operating mode: %w", err)
|
||||
}
|
||||
@@ -675,11 +697,11 @@ func (adapter *EC20Adapter) Restore(
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if currentMode != snapshot.OperatingMode {
|
||||
if currentMode != targetMode {
|
||||
return fmt.Errorf(
|
||||
"vocat: EC20 restore reported CFUN=%d, expected %d",
|
||||
currentMode,
|
||||
snapshot.OperatingMode,
|
||||
targetMode,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -697,7 +719,7 @@ func (adapter *EC20Adapter) Restore(
|
||||
return errors.New("vocat: EC20 snapshot has active data in RF-off mode")
|
||||
}
|
||||
desiredCIDs := checkpoint.activeCIDs
|
||||
if !adapter.options.RestoreCellularData {
|
||||
if !adapter.options.RestoreCellularData || targetMode == 0 || targetMode == 4 {
|
||||
desiredCIDs = nil
|
||||
}
|
||||
if err := adapter.reconcileActiveCIDs(
|
||||
|
||||
@@ -416,6 +416,20 @@ func TestEC20AdapterReadsExplicitHomePLMNAndKnownAssignmentFallback(
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssignedHomePLMNIncludesLebaraUKCores(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"204040123456789": "204/04",
|
||||
"234150123456789": "234/15",
|
||||
"234870123456789": "234/87",
|
||||
}
|
||||
for imsi, want := range tests {
|
||||
mcc, mnc, ok := assignedHomePLMN(imsi)
|
||||
if got := mcc + "/" + mnc; !ok || got != want {
|
||||
t.Errorf("assignedHomePLMN(%q) = %q, %v; want %q", imsi, got, ok, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEC20AdapterRadioTransactionRestoresCFUNAndPDPContexts(
|
||||
t *testing.T,
|
||||
) {
|
||||
@@ -440,16 +454,14 @@ func TestEC20AdapterRadioTransactionRestoresCFUNAndPDPContexts(
|
||||
lines: []string{"+CGACT: 1,0", "+CGACT: 2,0"},
|
||||
},
|
||||
{command: "AT+CFUN?", lines: []string{"+CFUN: 4"}},
|
||||
{command: "AT+CFUN=1"},
|
||||
{command: "AT+CFUN?", lines: []string{"+CFUN: 1"}},
|
||||
{command: "AT+CFUN?", lines: []string{"+CFUN: 4"}},
|
||||
{
|
||||
command: "AT+CGACT?",
|
||||
lines: []string{"+CGACT: 1,0", "+CGACT: 2,0"},
|
||||
},
|
||||
{command: "AT+CGACT=1,1"},
|
||||
{
|
||||
command: "AT+CGACT?",
|
||||
lines: []string{"+CGACT: 1,1", "+CGACT: 2,0"},
|
||||
lines: []string{"+CGACT: 1,0", "+CGACT: 2,0"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
|
||||
}()
|
||||
|
||||
group := uint16(dhMODP2048)
|
||||
legacyFirst := request.Identity.HomeMCC == "234" && request.Identity.HomeMNC == "15"
|
||||
legacyFirst := legacyIKEProfile(request.Identity.HomeMCC, request.Identity.HomeMNC)
|
||||
if legacyFirst {
|
||||
group = dhMODP1024
|
||||
}
|
||||
@@ -544,6 +544,15 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func legacyIKEProfile(mcc, mnc string) bool {
|
||||
// Vodafone's UK and Netherlands ePDGs use the legacy group-2/SHA-1-first
|
||||
// proposal ordering. Some Lebara UK subscriptions carry a 204-04 IMSI from
|
||||
// that Vodafone NL core; treating them as a generic modern network causes
|
||||
// IKE_SA_INIT to fail before EAP-AKA even begins.
|
||||
plmn := strings.TrimSpace(mcc) + strings.TrimLeft(strings.TrimSpace(mnc), "0")
|
||||
return plmn == "23415" || plmn == "2044"
|
||||
}
|
||||
|
||||
func buildInitialEAPOnlyAuth(
|
||||
idi payload,
|
||||
requestedIDr payload,
|
||||
|
||||
@@ -16,6 +16,24 @@ var errFirstAuthObserved = errors.New("test: first IKE_AUTH observed")
|
||||
|
||||
type constantReader struct{ value byte }
|
||||
|
||||
func TestLegacyIKEProfileIncludesVodafoneHostedLebaraCore(t *testing.T) {
|
||||
for _, item := range []struct {
|
||||
mcc string
|
||||
mnc string
|
||||
}{
|
||||
{mcc: "234", mnc: "15"},
|
||||
{mcc: "204", mnc: "04"},
|
||||
{mcc: "204", mnc: "004"},
|
||||
} {
|
||||
if !legacyIKEProfile(item.mcc, item.mnc) {
|
||||
t.Errorf("legacyIKEProfile(%q, %q) = false", item.mcc, item.mnc)
|
||||
}
|
||||
}
|
||||
if legacyIKEProfile("234", "87") {
|
||||
t.Fatal("Lebara's 234-87 core must use the modern IKE profile")
|
||||
}
|
||||
}
|
||||
|
||||
func (reader constantReader) Read(destination []byte) (int, error) {
|
||||
for index := range destination {
|
||||
destination[index] = reader.value
|
||||
|
||||
@@ -205,8 +205,13 @@ func (relay *sessionRelay) terminalError() error {
|
||||
|
||||
func (relay *sessionRelay) Close() error {
|
||||
relay.cancel()
|
||||
// ReceiveSessionPacket implementations normally observe the canceled
|
||||
// context through a short read deadline. Close the transport as an explicit
|
||||
// wake-up as well: a socket implementation that is stuck in Read must not
|
||||
// hold teardown (and the associated TUN interface) indefinitely.
|
||||
transportErr := relay.transport.Close()
|
||||
<-relay.done
|
||||
return relay.terminalErrorIfFailure()
|
||||
return errors.Join(relay.terminalErrorIfFailure(), transportErr)
|
||||
}
|
||||
|
||||
func (relay *sessionRelay) terminalErrorIfFailure() error {
|
||||
|
||||
@@ -22,12 +22,13 @@ type fakeSentPacket struct {
|
||||
}
|
||||
|
||||
type fakeSessionTransport struct {
|
||||
incoming chan fakeSessionPacket
|
||||
sent chan fakeSentPacket
|
||||
closed chan struct{}
|
||||
once sync.Once
|
||||
readers atomic.Int32
|
||||
maxReads atomic.Int32
|
||||
incoming chan fakeSessionPacket
|
||||
sent chan fakeSentPacket
|
||||
closed chan struct{}
|
||||
ignoreContext bool
|
||||
once sync.Once
|
||||
readers atomic.Int32
|
||||
maxReads atomic.Int32
|
||||
}
|
||||
|
||||
func newFakeSessionTransport() *fakeSessionTransport {
|
||||
@@ -81,6 +82,18 @@ func (transport *fakeSessionTransport) ReceiveSessionPacket(
|
||||
}
|
||||
}
|
||||
defer transport.readers.Add(-1)
|
||||
if transport.ignoreContext {
|
||||
select {
|
||||
case packet := <-transport.incoming:
|
||||
if packet.err != nil {
|
||||
return 0, false, packet.err
|
||||
}
|
||||
copy(buffer, packet.data)
|
||||
return len(packet.data), packet.ike, nil
|
||||
case <-transport.closed:
|
||||
return 0, false, net.ErrClosed
|
||||
}
|
||||
}
|
||||
select {
|
||||
case packet := <-transport.incoming:
|
||||
if packet.err != nil {
|
||||
@@ -96,6 +109,37 @@ func (transport *fakeSessionTransport) ReceiveSessionPacket(
|
||||
return 0, false, net.ErrClosed
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionRelayCloseInterruptsStuckTransportRead(t *testing.T) {
|
||||
transport := newFakeSessionTransport()
|
||||
transport.ignoreContext = true
|
||||
relay := newSessionRelay(
|
||||
transport,
|
||||
legacyTestSuite(),
|
||||
ikeKeys{},
|
||||
[8]byte{1},
|
||||
[8]byte{2},
|
||||
true,
|
||||
time.Hour,
|
||||
)
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for transport.readers.Load() == 0 && time.Now().Before(deadline) {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
if transport.readers.Load() == 0 {
|
||||
t.Fatal("relay did not enter the transport read")
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- relay.Close() }()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("close relay: %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("relay Close did not interrupt the transport read")
|
||||
}
|
||||
}
|
||||
func (transport *fakeSessionTransport) Close() error {
|
||||
transport.once.Do(func() { close(transport.closed) })
|
||||
return nil
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -21,6 +20,8 @@ import (
|
||||
|
||||
const userspaceTunnelMTU = 1380
|
||||
|
||||
const userspaceTunnelPollInterval = 100 * time.Millisecond
|
||||
|
||||
type linuxUserspaceInstaller struct {
|
||||
ipCommand string
|
||||
}
|
||||
@@ -30,6 +31,7 @@ type linuxUserspaceHandle struct {
|
||||
config ChildSAConfig
|
||||
tunnel *espTunnel
|
||||
tun *os.File
|
||||
tunFD int
|
||||
relay NATTPacketRelay
|
||||
|
||||
runContext context.Context
|
||||
@@ -93,6 +95,7 @@ func (installer linuxUserspaceInstaller) Install(
|
||||
config: cloneChildSAConfig(config),
|
||||
tunnel: tunnel,
|
||||
tun: tun,
|
||||
tunFD: int(tun.Fd()),
|
||||
relay: config.Relay,
|
||||
runContext: runContext,
|
||||
cancel: cancel,
|
||||
@@ -128,6 +131,15 @@ func openLinuxTUN(name string) (*os.File, string, error) {
|
||||
_ = unix.Close(descriptor)
|
||||
return nil, "", fmt.Errorf("ike: create TUN interface: %w", err)
|
||||
}
|
||||
// A blocking TUN read is not guaranteed to wake when another goroutine
|
||||
// closes the descriptor on Linux. Keep the descriptor non-blocking and use
|
||||
// poll below so cancellation can always drain the data-plane workers before
|
||||
// the interface is released. Without this, a failed session can retain the
|
||||
// TUN forever and every automatic reconnect fails with EBUSY.
|
||||
if err := unix.SetNonblock(descriptor, true); err != nil {
|
||||
_ = unix.Close(descriptor)
|
||||
return nil, "", fmt.Errorf("ike: make TUN interface cancellable: %w", err)
|
||||
}
|
||||
file := os.NewFile(uintptr(descriptor), "/dev/net/tun:"+request.Name())
|
||||
if file == nil {
|
||||
_ = unix.Close(descriptor)
|
||||
@@ -475,7 +487,7 @@ func (handle *linuxUserspaceHandle) copyTUNToRelay() {
|
||||
defer handle.wait.Done()
|
||||
buffer := make([]byte, 65535)
|
||||
for {
|
||||
count, err := handle.tun.Read(buffer)
|
||||
count, err := readTUNPacket(handle.runContext, handle.tunFD, buffer)
|
||||
if err != nil {
|
||||
if handle.runContext.Err() == nil && !errors.Is(err, os.ErrClosed) {
|
||||
handle.fail(fmt.Errorf("ike: read TUN packet: %w", err))
|
||||
@@ -520,7 +532,7 @@ func (handle *linuxUserspaceHandle) copyRelayToTUN() {
|
||||
// without allowing a forged datagram to tear down the CHILD_SA.
|
||||
continue
|
||||
}
|
||||
if err := writeFull(handle.tun, cleartext); err != nil {
|
||||
if err := writeTUNPacket(handle.runContext, handle.tunFD, cleartext); err != nil {
|
||||
if handle.runContext.Err() == nil && !errors.Is(err, os.ErrClosed) {
|
||||
handle.fail(fmt.Errorf("ike: write TUN packet: %w", err))
|
||||
}
|
||||
@@ -529,17 +541,74 @@ func (handle *linuxUserspaceHandle) copyRelayToTUN() {
|
||||
}
|
||||
}
|
||||
|
||||
func writeFull(destination io.Writer, packet []byte) error {
|
||||
count, err := destination.Write(packet)
|
||||
if err != nil {
|
||||
return err
|
||||
func readTUNPacket(ctx context.Context, descriptor int, buffer []byte) (int, error) {
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
ready, err := pollTUN(ctx, descriptor, unix.POLLIN)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !ready {
|
||||
continue
|
||||
}
|
||||
count, err := unix.Read(descriptor, buffer)
|
||||
if errors.Is(err, unix.EINTR) || errors.Is(err, unix.EAGAIN) || errors.Is(err, unix.EWOULDBLOCK) {
|
||||
continue
|
||||
}
|
||||
return count, err
|
||||
}
|
||||
if count != len(packet) {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
|
||||
func writeTUNPacket(ctx context.Context, descriptor int, packet []byte) error {
|
||||
for written := 0; written < len(packet); {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
ready, err := pollTUN(ctx, descriptor, unix.POLLOUT)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ready {
|
||||
continue
|
||||
}
|
||||
count, err := unix.Write(descriptor, packet[written:])
|
||||
if errors.Is(err, unix.EINTR) || errors.Is(err, unix.EAGAIN) || errors.Is(err, unix.EWOULDBLOCK) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return errors.New("ike: zero-length TUN write")
|
||||
}
|
||||
written += count
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pollTUN(ctx context.Context, descriptor int, events int16) (bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
poll := []unix.PollFd{{Fd: int32(descriptor), Events: events}}
|
||||
count, err := unix.Poll(poll, int(userspaceTunnelPollInterval/time.Millisecond))
|
||||
if errors.Is(err, unix.EINTR) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if count == 0 {
|
||||
return false, nil
|
||||
}
|
||||
if poll[0].Revents&(unix.POLLERR|unix.POLLHUP|unix.POLLNVAL) != 0 {
|
||||
return false, os.ErrClosed
|
||||
}
|
||||
return poll[0].Revents&events != 0, nil
|
||||
}
|
||||
|
||||
func (handle *linuxUserspaceHandle) fail(err error) {
|
||||
handle.mu.Lock()
|
||||
notify := false
|
||||
@@ -583,9 +652,12 @@ func (handle *linuxUserspaceHandle) Close(ctx context.Context) error {
|
||||
handle.mu.Unlock()
|
||||
|
||||
handle.cancelRun()
|
||||
// Workers use a non-blocking, polled TUN descriptor and therefore leave on
|
||||
// cancellation without requiring a cross-goroutine close. Wait first so no
|
||||
// blocked syscall can retain the interface after Close returns.
|
||||
handle.wait.Wait()
|
||||
cleanupErr := handle.cleanupNetwork(ctx)
|
||||
handle.closeTUN()
|
||||
handle.wait.Wait()
|
||||
// A terminal data-plane error is delivered exactly once through Failures.
|
||||
// Close reports only teardown errors so the orchestrator does not record
|
||||
// the same runtime cause again as a cleanup failure.
|
||||
|
||||
@@ -156,6 +156,10 @@ func (session *Session) watchOutgoingCall(call *imsCall, key sipTransactionKey)
|
||||
case <-session.refreshContext.Done():
|
||||
return
|
||||
case <-timer.C:
|
||||
if session.callWasTerminated(call.callID) {
|
||||
session.finishCall(call.callID, "ended", 0, "")
|
||||
return
|
||||
}
|
||||
session.finishCall(call.callID, "failed", 0, "SIP INVITE transaction timed out")
|
||||
return
|
||||
case response := <-call.responses:
|
||||
@@ -193,6 +197,11 @@ func (session *Session) watchOutgoingCall(call *imsCall, key sipTransactionKey)
|
||||
}
|
||||
session.setCallMediaReady(call.callID)
|
||||
session.setCallState(call.callID, "active")
|
||||
} else if session.callWasTerminated(call.callID) {
|
||||
// CANCEL normally causes the pending INVITE transaction to finish
|
||||
// with 487 Request Terminated. It is the expected response to our
|
||||
// local hang-up, not a new network rejection.
|
||||
session.finishCall(call.callID, "ended", response.StatusCode, response.Reason)
|
||||
} else {
|
||||
session.finishCall(call.callID, "failed", response.StatusCode, response.Reason)
|
||||
}
|
||||
@@ -241,6 +250,7 @@ func (session *Session) HangupCall(ctx context.Context, id string) error {
|
||||
state := call.public.State
|
||||
direction := call.public.Direction
|
||||
request, respond := call.invite, call.respond
|
||||
call.terminated = true
|
||||
session.callMu.Unlock()
|
||||
if direction == "incoming" && state == "ringing" && request != nil && respond != nil {
|
||||
response, err := buildSIPResponseWithBody(request, 486, session.fromTag, nil)
|
||||
@@ -264,6 +274,13 @@ func (session *Session) HangupCall(ctx context.Context, id string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (session *Session) callWasTerminated(id string) bool {
|
||||
session.callMu.Lock()
|
||||
defer session.callMu.Unlock()
|
||||
call := session.calls[id]
|
||||
return call != nil && call.terminated
|
||||
}
|
||||
|
||||
func (session *Session) handleCallRequest(request *sipRequest, respond func([]byte) error) bool {
|
||||
switch request.Method {
|
||||
case "INVITE":
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"vocat/internal/vowifi"
|
||||
)
|
||||
@@ -80,6 +81,36 @@ func TestRejectedOutgoingCallRetainsSIPReason(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelledOutgoingInviteDoesNotBecomeFailedOn487(t *testing.T) {
|
||||
call := &imsCall{
|
||||
public: vowifi.Call{ID: "cancelled", State: "dialing"},
|
||||
callID: "cancelled",
|
||||
responses: make(chan *sipResponse, 1),
|
||||
terminated: true,
|
||||
}
|
||||
session := &Session{
|
||||
calls: map[string]*imsCall{call.callID: call},
|
||||
transactions: make(map[sipTransactionKey]chan *sipResponse),
|
||||
refreshContext: context.Background(),
|
||||
}
|
||||
key := sipTransactionKey{callID: call.callID, cseq: 1, method: "INVITE"}
|
||||
go session.watchOutgoingCall(call, key)
|
||||
call.responses <- &sipResponse{StatusCode: 487, Reason: "Request Terminated"}
|
||||
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
calls := session.Calls()
|
||||
if len(calls) == 1 && calls[0].EndedAt != nil {
|
||||
if calls[0].State != "ended" || calls[0].SIPCode != 487 {
|
||||
t.Fatalf("cancelled INVITE = %#v", calls[0])
|
||||
}
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
t.Fatal("cancelled INVITE did not reach a terminal state")
|
||||
}
|
||||
|
||||
func TestValidCallNumber(t *testing.T) {
|
||||
if !validCallNumber("+447700900000") || validCallNumber("12\r\nBYE") {
|
||||
t.Fatal("call number validation mismatch")
|
||||
|
||||
@@ -24,13 +24,13 @@ type digestChallenge struct {
|
||||
}
|
||||
|
||||
type digestCredentials struct {
|
||||
Username string
|
||||
Password []byte
|
||||
AUTS string
|
||||
URI string
|
||||
Method string
|
||||
CNonce string
|
||||
NC uint32
|
||||
Username string
|
||||
AKAResponse []byte
|
||||
AUTS string
|
||||
URI string
|
||||
Method string
|
||||
CNonce string
|
||||
NC uint32
|
||||
}
|
||||
|
||||
func parseDigestChallenge(value string, proxy bool) (digestChallenge, error) {
|
||||
@@ -146,7 +146,7 @@ func parseAuthDirectives(value string) (map[string]string, error) {
|
||||
}
|
||||
|
||||
type akaMaterial struct {
|
||||
password []byte
|
||||
response []byte
|
||||
auts []byte
|
||||
ck []byte
|
||||
ik []byte
|
||||
@@ -156,7 +156,7 @@ func clearAKAMaterial(material *akaMaterial) {
|
||||
if material == nil {
|
||||
return
|
||||
}
|
||||
zeroBytes(material.password)
|
||||
zeroBytes(material.response)
|
||||
zeroBytes(material.auts)
|
||||
zeroBytes(material.ck)
|
||||
zeroBytes(material.ik)
|
||||
@@ -193,7 +193,7 @@ func authenticateAKA(
|
||||
return akaMaterial{}, err
|
||||
}
|
||||
return akaMaterial{
|
||||
password: res,
|
||||
response: res,
|
||||
ck: append([]byte(nil), result.CK...),
|
||||
ik: append([]byte(nil), result.IK...),
|
||||
}, nil
|
||||
@@ -231,7 +231,7 @@ func extractRES(result vowifi.AKAResult) ([]byte, error) {
|
||||
|
||||
func newDigestCredentials(
|
||||
username string,
|
||||
password []byte,
|
||||
akaResponse []byte,
|
||||
uri string,
|
||||
method string,
|
||||
nc uint32,
|
||||
@@ -241,12 +241,12 @@ func newDigestCredentials(
|
||||
return digestCredentials{}, fmt.Errorf("ims: create digest cnonce: %w", err)
|
||||
}
|
||||
return digestCredentials{
|
||||
Username: username,
|
||||
Password: password,
|
||||
URI: uri,
|
||||
Method: method,
|
||||
CNonce: hex.EncodeToString(cnonceBytes),
|
||||
NC: nc,
|
||||
Username: username,
|
||||
AKAResponse: akaResponse,
|
||||
URI: uri,
|
||||
Method: method,
|
||||
CNonce: hex.EncodeToString(cnonceBytes),
|
||||
NC: nc,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -255,7 +255,7 @@ func buildDigestAuthorization(challenge digestChallenge, credentials digestCrede
|
||||
response := digestResponse(
|
||||
credentials.Username,
|
||||
challenge.Realm,
|
||||
credentials.Password,
|
||||
credentials.AKAResponse,
|
||||
credentials.Method,
|
||||
credentials.URI,
|
||||
challenge.Nonce,
|
||||
@@ -290,7 +290,7 @@ func buildDigestAuthorization(challenge digestChallenge, credentials digestCrede
|
||||
func digestResponse(
|
||||
username string,
|
||||
realm string,
|
||||
password []byte,
|
||||
akaResponse []byte,
|
||||
method string,
|
||||
uri string,
|
||||
nonce string,
|
||||
@@ -300,7 +300,9 @@ func digestResponse(
|
||||
) string {
|
||||
ha1Hash := md5.New()
|
||||
_, _ = ha1Hash.Write([]byte(username + ":" + realm + ":"))
|
||||
_, _ = ha1Hash.Write(password)
|
||||
// AKAv1-MD5 is mandated by the IMS server challenge (3GPP TS 33.203).
|
||||
// akaResponse is the short-lived USIM RES value, not a stored password.
|
||||
_, _ = ha1Hash.Write(akaResponse)
|
||||
ha1 := hex.EncodeToString(ha1Hash.Sum(nil))
|
||||
ha2 := md5Hex(method + ":" + uri)
|
||||
if qop == "" {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user