mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-13 03:13:43 +08:00
Compare commits
18
Commits
48fc4c5ab5
...
v0.1.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f17d925c4c | ||
|
|
296f963885 | ||
|
|
1b9546a73d | ||
|
|
22487dbb1f | ||
|
|
f9bb38aabe | ||
|
|
5bb5808706 | ||
|
|
d70937cc47 | ||
|
|
eab658dc90 | ||
|
|
0d738d4ce4 | ||
|
|
962c58fdd1 | ||
|
|
7b2e005b37 | ||
|
|
f1e70ecee5 | ||
|
|
f012c556e9 | ||
|
|
ab8bbbc1ed | ||
|
|
609a591045 | ||
|
|
461054615b | ||
|
|
020fb619a9 | ||
|
|
3cc73f1885 |
@@ -110,6 +110,14 @@ jobs:
|
||||
-o "$OUTPUT" \
|
||||
./cmd/vocat
|
||||
chmod 0755 "$OUTPUT"
|
||||
if readelf -l "$OUTPUT" | grep -q 'Requesting program interpreter'; then
|
||||
echo "ERROR: $OUTPUT unexpectedly requires a dynamic loader" >&2
|
||||
readelf -l "$OUTPUT" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "${{ matrix.goarch }}" = "amd64" ]; then
|
||||
"$OUTPUT" version
|
||||
fi
|
||||
- name: Upload ${{ matrix.target }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
||||
@@ -55,3 +55,4 @@ Thumbs.db
|
||||
|
||||
# ---- Claude Code / agent ----
|
||||
.claude/
|
||||
.worktrees/
|
||||
|
||||
@@ -65,10 +65,24 @@ Available features depend on the module firmware, USB composition, SIM/eSIM capa
|
||||
|
||||
### One-click Linux installation
|
||||
|
||||
As root (including OpenWrt/Kwrt, where `sudo` is normally absent):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
From a normal user on a distribution with sudo:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | sudo bash
|
||||
```
|
||||
|
||||
Check the host's VoWiFi/XFRM prerequisites without installing VoCat:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash -s -- --check-env
|
||||
```
|
||||
|
||||
Install a specific version:
|
||||
|
||||
```bash
|
||||
@@ -76,6 +90,12 @@ curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/i
|
||||
sudo bash install.sh 0.0.2
|
||||
```
|
||||
|
||||
VoWiFi IMS requires Linux XFRM/IPsec. On OpenWrt/Kwrt the installer attempts
|
||||
to install matching `ip-full`, `kmod-ipsec`, `kmod-ipsec4/6`,
|
||||
`kmod-crypto-authenc`, AES-CBC and SHA1 packages from the firmware's own feed.
|
||||
If matching kernel modules are unavailable, use a firmware that includes them;
|
||||
never force-install kmods built for a different kernel.
|
||||
|
||||
The installer:
|
||||
|
||||
- detects `amd64`, `386`, `arm64`, `aarch64`, or `armv7`;
|
||||
@@ -316,3 +336,5 @@ cd web && npm run build
|
||||
## License
|
||||
|
||||
See [LICENSE](LICENSE).
|
||||
|
||||
[](https://meteor-history.com)
|
||||
|
||||
+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
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func lockServerInstance(databasePath string) (*os.File, error) {
|
||||
directory := filepath.Dir(databasePath)
|
||||
if err := os.MkdirAll(directory, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create data directory for instance lock: %w", err)
|
||||
}
|
||||
path := filepath.Join(directory, ".vocat.lock")
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open server instance lock: %w", err)
|
||||
}
|
||||
if err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil {
|
||||
_ = file.Close()
|
||||
if errors.Is(err, unix.EWOULDBLOCK) || errors.Is(err, unix.EAGAIN) {
|
||||
return nil, fmt.Errorf("another vocat server is already using database %s", databasePath)
|
||||
}
|
||||
return nil, fmt.Errorf("lock server instance: %w", err)
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestServerInstanceLockRejectsSecondProcess(t *testing.T) {
|
||||
database := filepath.Join(t.TempDir(), "vocat.db")
|
||||
first, err := lockServerInstance(database)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer first.Close()
|
||||
second, err := lockServerInstance(database)
|
||||
if second != nil {
|
||||
second.Close()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "already using database") {
|
||||
t.Fatalf("second lock error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//go:build !linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func lockServerInstance(databasePath string) (*os.File, error) {
|
||||
return os.OpenFile(filepath.Join(filepath.Dir(databasePath), ".vocat.lock"), os.O_CREATE|os.O_RDWR, 0o600)
|
||||
}
|
||||
+118
-18
@@ -27,6 +27,7 @@ import (
|
||||
"vocat/internal/extensions"
|
||||
"vocat/internal/httpsmode"
|
||||
"vocat/internal/loghub"
|
||||
"vocat/internal/pcsc"
|
||||
"vocat/internal/server"
|
||||
"vocat/internal/store"
|
||||
"vocat/internal/update"
|
||||
@@ -111,6 +112,11 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("load configuration: %w", err)
|
||||
}
|
||||
instanceLock, err := lockServerInstance(cfg.DatabasePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer instanceLock.Close()
|
||||
if cfg.UsesDefaultCredentials() {
|
||||
logger.Warn(
|
||||
"default admin credentials are active; set VOCAT_ADMIN_PASSWORD before exposing the service",
|
||||
@@ -184,7 +190,8 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
||||
return err
|
||||
}
|
||||
|
||||
deviceManager, err := device.NewManager(device.Options{})
|
||||
cardReaders := pcsc.New()
|
||||
deviceManager, err := device.NewManager(device.Options{CardReaders: cardReaders})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create device manager: %w", err)
|
||||
}
|
||||
@@ -220,6 +227,7 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
||||
logger,
|
||||
database,
|
||||
deviceManager,
|
||||
cardReaders,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("configure VoWiFi runtime: %w", err)
|
||||
@@ -362,6 +370,12 @@ func configureDeviceBackends(
|
||||
if mapErr != nil {
|
||||
continue
|
||||
}
|
||||
if config.DeviceType == store.DeviceTypeUSBSIMReader {
|
||||
if err := manager.SetSIMPin(entry.ID, config.SIMPIN); err != nil {
|
||||
logger.Warn("configure USB SIM reader", "device_id", config.ID, "error", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := manager.SetBackend(entry.ID, config.DeviceBackend); err != nil {
|
||||
logger.Warn("configure device backend", "device_id", config.ID, "backend", config.DeviceBackend, "error", err)
|
||||
}
|
||||
@@ -384,6 +398,9 @@ func restoreDefaultCellularRadios(
|
||||
}
|
||||
mapper := integration.ATMapper{Store: database, Devices: manager}
|
||||
for _, config := range configs {
|
||||
if config.DeviceType == store.DeviceTypeUSBSIMReader {
|
||||
continue
|
||||
}
|
||||
if config.VoWiFiEnabled {
|
||||
continue
|
||||
}
|
||||
@@ -431,6 +448,9 @@ func restoreConfiguredCellularData(
|
||||
}
|
||||
mapper := integration.ATMapper{Store: database, Devices: manager}
|
||||
for _, config := range configs {
|
||||
if config.DeviceType == store.DeviceTypeUSBSIMReader {
|
||||
continue
|
||||
}
|
||||
if !config.NetworkEnabled || config.VoWiFiEnabled {
|
||||
continue
|
||||
}
|
||||
@@ -438,13 +458,31 @@ func restoreConfiguredCellularData(
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dataContext, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
_, err = manager.SetNetwork(dataContext, entry.ID, device.NetworkRequest{
|
||||
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, 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)
|
||||
@@ -464,6 +502,9 @@ func disableAllDeveloperCellularData(
|
||||
}
|
||||
mapper := integration.ATMapper{Store: database, Devices: manager}
|
||||
for _, config := range configs {
|
||||
if config.DeviceType == store.DeviceTypeUSBSIMReader {
|
||||
continue
|
||||
}
|
||||
entry, err := mapper.Get(config.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
@@ -472,7 +513,7 @@ func disableAllDeveloperCellularData(
|
||||
_, 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -518,12 +559,13 @@ func configureVoWiFiRuntime(
|
||||
logger *slog.Logger,
|
||||
database *store.Store,
|
||||
deviceManager *device.Manager,
|
||||
cardReaders *pcsc.Service,
|
||||
) (*vowifiruntime.Manager, error) {
|
||||
mapper := integration.ATMapper{
|
||||
Store: database,
|
||||
Devices: deviceManager,
|
||||
}
|
||||
adapter, err := vowifi.NewEC20Adapter(mapper, vowifi.EC20AdapterOptions{
|
||||
ec20Adapter, err := vowifi.NewEC20Adapter(mapper, vowifi.EC20AdapterOptions{
|
||||
// The test deployment is deliberately non-cellular. VoWiFi teardown
|
||||
// may restore CFUN, but it must never reactivate a PDP context.
|
||||
RestoreCellularData: false,
|
||||
@@ -538,6 +580,16 @@ func configureVoWiFiRuntime(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pcscAdapter, err := vowifi.NewPCSCAdapter(cardReaders, func(ctx context.Context, deviceID string) (pcsc.Selector, string, error) {
|
||||
config, resolveErr := database.Device(ctx, strings.TrimSpace(deviceID))
|
||||
if resolveErr != nil {
|
||||
return pcsc.Selector{}, "", resolveErr
|
||||
}
|
||||
return pcsc.Selector{USBPath: config.USBPath, ReaderName: config.ControlDevice}, config.SIMPIN, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
projector := integration.StateProjector{
|
||||
Store: database,
|
||||
Devices: mapper,
|
||||
@@ -550,6 +602,10 @@ func configureVoWiFiRuntime(
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load device %q VoWiFi config: %w", deviceID, err)
|
||||
}
|
||||
adapter := vowifiDeviceAdapter(ec20Adapter)
|
||||
if deviceConfig.DeviceType == store.DeviceTypeUSBSIMReader {
|
||||
adapter = pcscAdapter
|
||||
}
|
||||
return newVoWiFiOrchestrator(deviceConfig, database, adapter)
|
||||
},
|
||||
})
|
||||
@@ -583,10 +639,16 @@ func configureVoWiFiRuntime(
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
type vowifiDeviceAdapter interface {
|
||||
vowifi.SIMIdentityReader
|
||||
vowifi.AKAProvider
|
||||
vowifi.RadioController
|
||||
}
|
||||
|
||||
func newVoWiFiOrchestrator(
|
||||
deviceConfig store.Device,
|
||||
database *store.Store,
|
||||
adapter *vowifi.EC20Adapter,
|
||||
adapter vowifiDeviceAdapter,
|
||||
) (*vowifi.Orchestrator, error) {
|
||||
apn := deviceConfig.APN
|
||||
if apn == "" {
|
||||
@@ -716,9 +778,18 @@ func provisionDiscoveredDevices(
|
||||
candidate := discovered.Candidate
|
||||
backend := "at"
|
||||
control := candidate.ATPort.OpenPath()
|
||||
deviceType := store.DeviceTypePCIeEC20EC25
|
||||
esimTransport := backend
|
||||
if candidate.QMIControl != "" {
|
||||
backend = "qmi"
|
||||
control = candidate.QMIControl
|
||||
esimTransport = backend
|
||||
}
|
||||
if candidate.HardwareKind == pcsc.HardwareKind {
|
||||
backend = "pcsc"
|
||||
control = candidate.ReaderName
|
||||
deviceType = store.DeviceTypeUSBSIMReader
|
||||
esimTransport = "pcsc"
|
||||
}
|
||||
name := candidate.Product
|
||||
if name == "" || strings.EqualFold(name, "Android") {
|
||||
@@ -727,6 +798,7 @@ func provisionDiscoveredDevices(
|
||||
if err := database.UpsertDevice(ctx, store.Device{
|
||||
ID: discovered.ID,
|
||||
Name: name,
|
||||
DeviceType: deviceType,
|
||||
Interface: candidate.NetworkInterface,
|
||||
ControlDevice: control,
|
||||
ATPort: candidate.ATPort.OpenPath(),
|
||||
@@ -737,7 +809,7 @@ func provisionDiscoveredDevices(
|
||||
StopBits: 1,
|
||||
Parity: "none",
|
||||
DeviceBackend: backend,
|
||||
ESIMTransport: backend,
|
||||
ESIMTransport: esimTransport,
|
||||
NetworkEnabled: false,
|
||||
SMSEnabled: true,
|
||||
VoWiFiEnabled: true,
|
||||
@@ -866,7 +938,7 @@ func enforceDefaultSafeCardPolicy(
|
||||
return
|
||||
}
|
||||
iccid := strings.TrimSpace(snapshot.ICCID)
|
||||
if _, err := database.CardPolicy(ctx, iccid); err == nil && !snapshot.SIMChanged {
|
||||
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)
|
||||
@@ -913,6 +985,7 @@ func reconcileCardPolicies(
|
||||
manager *device.Manager,
|
||||
vowifiManager *vowifiruntime.Manager,
|
||||
) {
|
||||
observedCards := make(map[string]string)
|
||||
reconcile := func() {
|
||||
policies, policyListErr := database.ListCardPolicies(ctx)
|
||||
if policyListErr == nil {
|
||||
@@ -935,12 +1008,26 @@ func reconcileCardPolicies(
|
||||
for _, config := range configs {
|
||||
entry, mapErr := mapper.Get(config.ID)
|
||||
if mapErr != nil || entry.Snapshot == nil {
|
||||
if config.DeviceType == store.DeviceTypeUSBSIMReader && observedCards[config.ID] != "missing" {
|
||||
if state, stateErr := vowifiManager.State(config.ID); stateErr == nil && state.ICCID != "" {
|
||||
_, _ = vowifiManager.RequestReconnect(config.ID)
|
||||
}
|
||||
observedCards[config.ID] = "missing"
|
||||
}
|
||||
continue
|
||||
}
|
||||
iccid := strings.TrimSpace(entry.Snapshot.ICCID)
|
||||
if iccid == "" {
|
||||
if config.DeviceType == store.DeviceTypeUSBSIMReader && observedCards[config.ID] != "missing" {
|
||||
if state, stateErr := vowifiManager.State(config.ID); stateErr == nil && state.ICCID != "" {
|
||||
_, _ = vowifiManager.RequestReconnect(config.ID)
|
||||
}
|
||||
observedCards[config.ID] = "missing"
|
||||
}
|
||||
continue
|
||||
}
|
||||
previousObserved := observedCards[config.ID]
|
||||
observedCards[config.ID] = iccid
|
||||
policy, policyErr := database.CardPolicy(ctx, iccid)
|
||||
if policyErr != nil {
|
||||
continue
|
||||
@@ -953,11 +1040,19 @@ func reconcileCardPolicies(
|
||||
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
|
||||
@@ -975,6 +1070,8 @@ func reconcileCardPolicies(
|
||||
_, _ = vowifiManager.RequestEnabled(config.ID, true)
|
||||
case state.ICCID != "" && !strings.EqualFold(strings.TrimSpace(state.ICCID), iccid):
|
||||
_, _ = vowifiManager.RequestReconnect(config.ID)
|
||||
case config.DeviceType == store.DeviceTypeUSBSIMReader && previousObserved == "missing":
|
||||
_, _ = vowifiManager.RequestReconnect(config.ID)
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -1044,15 +1141,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,
|
||||
|
||||
+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
|
||||
|
||||
|
||||
@@ -65,10 +65,24 @@ Vocat 面向基于高通芯片、并暴露兼容 AT、QMI、串口与 USB 网络
|
||||
|
||||
### Linux 一键安装
|
||||
|
||||
已是 root(包括默认没有 `sudo` 的 OpenWrt/Kwrt):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
普通 Linux 用户且系统装有 sudo:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | sudo bash
|
||||
```
|
||||
|
||||
只检查 VoWiFi/XFRM 环境,不安装 VoCat:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash -s -- --check-env
|
||||
```
|
||||
|
||||
安装指定版本:
|
||||
|
||||
```bash
|
||||
@@ -76,6 +90,8 @@ curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/i
|
||||
sudo bash install.sh 0.0.2
|
||||
```
|
||||
|
||||
VoWiFi IMS 必须使用 Linux XFRM/IPsec。OpenWrt/Kwrt 上安装脚本会从当前固件自己的软件源尝试安装严格匹配的 `ip-full`、`kmod-ipsec`、`kmod-ipsec4/6`、`kmod-crypto-authenc`、AES-CBC 和 SHA1 组件。若软件源没有与当前内核匹配的模块,必须更换包含这些组件的固件,禁止强装其他内核版本的 kmod。
|
||||
|
||||
安装程序会:
|
||||
|
||||
- 检测 `amd64`、`386`、`arm64` 或 `armv7` 架构;
|
||||
|
||||
@@ -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 保留。前端构建用于验证新增表单与类型契约。
|
||||
@@ -5,9 +5,9 @@ go 1.25.0
|
||||
require (
|
||||
github.com/coder/websocket v1.8.15
|
||||
go.bug.st/serial v1.6.4
|
||||
golang.org/x/crypto v0.41.0
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/sys v0.47.0
|
||||
golang.org/x/term v0.34.0
|
||||
golang.org/x/term v0.43.0
|
||||
modernc.org/sqlite v1.38.2
|
||||
)
|
||||
|
||||
|
||||
@@ -18,12 +18,12 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A=
|
||||
go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI=
|
||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
@@ -33,8 +33,8 @@ golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
|
||||
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -274,6 +274,9 @@ func (manager *Manager) SetFlight(
|
||||
if err := manager.validateActive(id, state); err != nil {
|
||||
return FlightResult{}, err
|
||||
}
|
||||
if manager.candidateFor(state).HardwareKind == "pcsc" {
|
||||
return FlightResult{PreviousMode: 4, CurrentMode: 4, FlightMode: true, RadioOff: true}, nil
|
||||
}
|
||||
client, err := manager.clientLocked(ctx, state, manager.candidateFor(state))
|
||||
if err != nil {
|
||||
manager.setResult(id, state, nil, err)
|
||||
|
||||
+74
-7
@@ -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")
|
||||
@@ -58,7 +99,14 @@ func (manager *Manager) SetNetwork(
|
||||
if candidate.QMIControl == "" || candidate.NetworkInterface == "" {
|
||||
return NetworkResult{}, fmt.Errorf("%w: QMI control device and network interface are required", ErrDataBackendUnavailable)
|
||||
}
|
||||
return setQMINetwork(ctx, candidate, request.Enabled, apn, ipVersion)
|
||||
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)
|
||||
@@ -67,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)
|
||||
|
||||
+59
-32
@@ -10,6 +10,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -32,9 +33,11 @@ type es9pClient struct {
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
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 smdp == "" || strings.Contains(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)
|
||||
@@ -159,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 {
|
||||
@@ -264,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
|
||||
}
|
||||
|
||||
@@ -297,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 {
|
||||
@@ -310,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
|
||||
|
||||
@@ -47,6 +47,7 @@ func TestNewES9PClientRejectsUnsafeAddress(t *testing.T) {
|
||||
"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)
|
||||
@@ -162,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)
|
||||
}
|
||||
}
|
||||
|
||||
+121
-26
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"vocat/internal/i18n"
|
||||
"vocat/internal/modem"
|
||||
"vocat/internal/pcsc"
|
||||
)
|
||||
|
||||
// eUICC / eSIM (LPA, SGP.22) access over the modem's AT+CSIM APDU passthrough.
|
||||
@@ -190,9 +191,11 @@ func parseCSIM(response modem.Response) ([]byte, int, error) {
|
||||
|
||||
// euiccChannel is an open logical channel to the eUICC's ISD-R.
|
||||
type euiccChannel struct {
|
||||
manager *Manager
|
||||
id string
|
||||
channel int
|
||||
manager *Manager
|
||||
id string
|
||||
channel int
|
||||
pcscSession *pcsc.Session
|
||||
resetOnClose bool
|
||||
}
|
||||
|
||||
// csimAPDUTimeout bounds a single AT+CSIM exchange. Loading a BoundProfilePackage
|
||||
@@ -264,6 +267,14 @@ func (manager *Manager) openEuiccOnce(ctx context.Context, id string) (*euiccCha
|
||||
}
|
||||
|
||||
func (manager *Manager) openEuiccOnceAID(ctx context.Context, id, aidHex string) (*euiccChannel, error) {
|
||||
state, lookupErr := manager.lookup(id)
|
||||
if lookupErr != nil {
|
||||
return nil, lookupErr
|
||||
}
|
||||
candidate := manager.candidateFor(state)
|
||||
if candidate.HardwareKind == pcsc.HardwareKind {
|
||||
return manager.openPCSCEuiccOnceAID(ctx, id, candidate, aidHex)
|
||||
}
|
||||
// MANAGE CHANNEL (open): 00 70 00 00 01 -> "<channel> 90 00". This EC20
|
||||
// firmware requires the explicit one-byte expected length: Le=00 opens a
|
||||
// channel but then rejects SELECT ISD-R at the AT+CSIM layer.
|
||||
@@ -302,6 +313,37 @@ func (manager *Manager) openEuiccOnceAID(ctx context.Context, id, aidHex string)
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
func (manager *Manager) openPCSCEuiccOnceAID(ctx context.Context, id string, candidate modem.Candidate, aidHex string) (*euiccChannel, error) {
|
||||
session, err := manager.cardReaders.OpenSession(ctx, pcsc.Selector{
|
||||
USBPath: candidate.USBPath, ReaderName: candidate.ReaderName,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload, sw, err := session.Transmit(ctx, []byte{0x00, 0x70, 0x00, 0x00, 0x01})
|
||||
if err != nil || sw != 0x9000 || len(payload) != 1 {
|
||||
session.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("esim: PC/SC MANAGE CHANNEL: %w", err)
|
||||
}
|
||||
return nil, errNoLogicalChannel
|
||||
}
|
||||
channel := &euiccChannel{manager: manager, id: id, channel: int(payload[0]), pcscSession: session}
|
||||
aidHex = strings.ToUpper(strings.TrimSpace(aidHex))
|
||||
aid, err := hex.DecodeString(aidHex)
|
||||
if err != nil || len(aid) == 0 || len(aid) > 255 {
|
||||
channel.close(context.Background())
|
||||
return nil, fmt.Errorf("esim: invalid ISD-R AID %q", aidHex)
|
||||
}
|
||||
selectAID := append([]byte{byte(channel.channel), 0xA4, 0x04, 0x00, byte(len(aid))}, aid...)
|
||||
_, selectSW, err := channel.transmit(ctx, selectAID, 0x00)
|
||||
if err != nil || selectSW != 0x9000 {
|
||||
channel.close(context.Background())
|
||||
return nil, errNoEUICC
|
||||
}
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
// discoverEuiccAIDs detects eSTK multi-SE and alternate-ISD-R cards without
|
||||
// changing any profile state. The vendor product applet and candidate ISD-R
|
||||
// applications are selected only as read-only capability probes. Per
|
||||
@@ -352,7 +394,23 @@ func isTransientEuiccCME(err error) bool {
|
||||
// close releases the logical channel (MANAGE CHANNEL close).
|
||||
func (channel *euiccChannel) close(ctx context.Context) {
|
||||
closeAPDU := []byte{0x00, 0x70, 0x80, byte(channel.channel), 0x00}
|
||||
_, _, _ = channel.manager.csim(ctx, channel.id, closeAPDU)
|
||||
_, _, _ = channel.exchange(ctx, closeAPDU)
|
||||
if channel.pcscSession != nil {
|
||||
if channel.resetOnClose {
|
||||
_ = channel.pcscSession.CloseWithReset()
|
||||
} else {
|
||||
_ = channel.pcscSession.Close()
|
||||
}
|
||||
channel.pcscSession = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (channel *euiccChannel) exchange(ctx context.Context, apdu []byte) ([]byte, int, error) {
|
||||
if channel.pcscSession != nil {
|
||||
payload, sw, err := channel.pcscSession.Transmit(ctx, apdu)
|
||||
return payload, int(sw), err
|
||||
}
|
||||
return channel.manager.csim(ctx, channel.id, apdu)
|
||||
}
|
||||
|
||||
// transmit sends one APDU on the logical channel (CLA high nibble from insClass,
|
||||
@@ -360,7 +418,7 @@ func (channel *euiccChannel) close(ctx context.Context) {
|
||||
// and returns the assembled payload.
|
||||
func (channel *euiccChannel) transmit(ctx context.Context, apdu []byte, insClass byte) ([]byte, int, error) {
|
||||
apdu[0] = (apdu[0] & 0xF0) | byte(channel.channel)
|
||||
payload, sw, err := channel.manager.csim(ctx, channel.id, apdu)
|
||||
payload, sw, err := channel.exchange(ctx, apdu)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@@ -369,7 +427,7 @@ func (channel *euiccChannel) transmit(ctx context.Context, apdu []byte, insClass
|
||||
for sw>>8 == 0x61 && guard < 24 {
|
||||
guard++
|
||||
getResponse := []byte{0x80 | byte(channel.channel), 0xC0, 0x00, 0x00, byte(sw & 0xFF)}
|
||||
frag, nextSW, err := channel.manager.csim(ctx, channel.id, getResponse)
|
||||
frag, nextSW, err := channel.exchange(ctx, getResponse)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@@ -610,6 +668,7 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
|
||||
// Release the logical channel before any reset: openEuicc's csim holds
|
||||
// opMu only for the duration of each APDU, so by here the lock is free.
|
||||
closeContext, cancelClose := context.WithTimeout(context.Background(), csimAPDUTimeout)
|
||||
channel.resetOnClose = channel.pcscSession != nil
|
||||
channel.close(closeContext)
|
||||
cancelClose()
|
||||
if err != nil {
|
||||
@@ -780,9 +839,11 @@ func (manager *Manager) renameCachedProfile(id, iccid, nickname string) {
|
||||
// initiating HTTP request. EC20 commonly drops the AT port while processing
|
||||
// CFUN=1,1, so the reset error is intentionally followed by discovery retries.
|
||||
func (manager *Manager) recoverAfterProfileSwitch(id string) {
|
||||
resetContext, cancelReset := context.WithTimeout(context.Background(), manager.longTimeout)
|
||||
_ = manager.rebootForProfileSwitch(resetContext, id)
|
||||
cancelReset()
|
||||
if !manager.isPCSCDevice(id) {
|
||||
resetContext, cancelReset := context.WithTimeout(context.Background(), manager.longTimeout)
|
||||
_ = manager.rebootForProfileSwitch(resetContext, id)
|
||||
cancelReset()
|
||||
}
|
||||
manager.refreshAfterProfileSwitch(id)
|
||||
}
|
||||
|
||||
@@ -795,6 +856,20 @@ func (manager *Manager) recoverAfterProfileSwitch(id string) {
|
||||
// the next attempt. All errors are swallowed: this is best-effort self-healing
|
||||
// and setResult already records the last failure for the UI.
|
||||
func (manager *Manager) refreshAfterProfileSwitch(id string) {
|
||||
if manager.isPCSCDevice(id) {
|
||||
time.Sleep(750 * time.Millisecond)
|
||||
for attempt := 0; attempt < 10; attempt++ {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), manager.commandTimeout*4)
|
||||
_, _ = manager.Discover(ctx)
|
||||
_, err := manager.Refresh(ctx, id)
|
||||
cancel()
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
return
|
||||
}
|
||||
const (
|
||||
settle = 8 * time.Second
|
||||
interval = 4 * time.Second
|
||||
@@ -819,6 +894,14 @@ func (manager *Manager) refreshAfterProfileSwitch(id string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *Manager) isPCSCDevice(id string) bool {
|
||||
state, err := manager.lookup(id)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return manager.candidateFor(state).HardwareKind == pcsc.HardwareKind
|
||||
}
|
||||
|
||||
// enableProfileResult extracts the EnableProfile result code (tag 80) from the
|
||||
// ES10c response body. ok is false when no result code is present.
|
||||
func enableProfileResult(payload []byte) (int, bool) {
|
||||
@@ -888,25 +971,37 @@ func (manager *Manager) verifySwitchedICCID(ctx context.Context, id, expected st
|
||||
var lastICCID string
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < attempts; attempt++ {
|
||||
for _, command := range []string{"AT+CCID", "AT+QCCID"} {
|
||||
commandContext, cancel := context.WithTimeout(ctx, manager.commandTimeout)
|
||||
response, err := manager.ExecuteAT(commandContext, id, command)
|
||||
cancel()
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
if manager.isPCSCDevice(id) {
|
||||
snapshot, err := manager.Refresh(ctx, id)
|
||||
if err == nil {
|
||||
lastICCID = strings.TrimSpace(snapshot.ICCID)
|
||||
if lastICCID == expected {
|
||||
return nil
|
||||
}
|
||||
err = fmt.Errorf("reader still reports ICCID %s", lastICCID)
|
||||
}
|
||||
live := parseICCIDIdentifier(response, []string{"+CCID:", "+QCCID:"}, 18, 22)
|
||||
if live == "" {
|
||||
lastErr = errors.New("modem response contained no valid ICCID")
|
||||
continue
|
||||
lastErr = err
|
||||
} else {
|
||||
for _, command := range []string{"AT+CCID", "AT+QCCID"} {
|
||||
commandContext, cancel := context.WithTimeout(ctx, manager.commandTimeout)
|
||||
response, err := manager.ExecuteAT(commandContext, id, command)
|
||||
cancel()
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
live := parseICCIDIdentifier(response, []string{"+CCID:", "+QCCID:"}, 18, 22)
|
||||
if live == "" {
|
||||
lastErr = errors.New("modem response contained no valid ICCID")
|
||||
continue
|
||||
}
|
||||
lastICCID = live
|
||||
if live == expected {
|
||||
return nil
|
||||
}
|
||||
lastErr = fmt.Errorf("modem still reports ICCID %s", live)
|
||||
break
|
||||
}
|
||||
lastICCID = live
|
||||
if live == expected {
|
||||
return nil
|
||||
}
|
||||
lastErr = fmt.Errorf("modem still reports ICCID %s", live)
|
||||
break
|
||||
}
|
||||
if attempt+1 < attempts {
|
||||
select {
|
||||
|
||||
@@ -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
|
||||
@@ -130,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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"vocat/internal/modem"
|
||||
"vocat/internal/pcsc"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
@@ -19,6 +20,7 @@ type Options struct {
|
||||
LongTimeout time.Duration
|
||||
SMSTimeout time.Duration
|
||||
ScanTimeout time.Duration
|
||||
CardReaders *pcsc.Service
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
@@ -34,6 +36,7 @@ type Manager struct {
|
||||
longTimeout time.Duration
|
||||
smsTimeout time.Duration
|
||||
scanTimeout time.Duration
|
||||
cardReaders *pcsc.Service
|
||||
started bool
|
||||
devices map[string]*managedDevice
|
||||
ussdSessions map[string]ussdSession
|
||||
@@ -59,6 +62,7 @@ type managedDevice struct {
|
||||
discovered bool
|
||||
preFlightMode *int
|
||||
resetClientOnLock bool
|
||||
simPIN string
|
||||
}
|
||||
|
||||
func NewManager(options Options) (*Manager, error) {
|
||||
@@ -82,6 +86,9 @@ func NewManager(options Options) (*Manager, error) {
|
||||
// AT+COPS=? can take well over a minute while the modem sweeps every band.
|
||||
options.ScanTimeout = 150 * time.Second
|
||||
}
|
||||
if options.CardReaders == nil {
|
||||
options.CardReaders = pcsc.New()
|
||||
}
|
||||
return &Manager{
|
||||
discoverer: options.Discoverer,
|
||||
opener: options.Opener,
|
||||
@@ -89,6 +96,7 @@ func NewManager(options Options) (*Manager, error) {
|
||||
longTimeout: options.LongTimeout,
|
||||
smsTimeout: options.SMSTimeout,
|
||||
scanTimeout: options.ScanTimeout,
|
||||
cardReaders: options.CardReaders,
|
||||
devices: make(map[string]*managedDevice),
|
||||
ussdSessions: make(map[string]ussdSession),
|
||||
esimRecoveries: make(map[string]chan struct{}),
|
||||
@@ -146,9 +154,23 @@ func (manager *Manager) Discover(ctx context.Context) ([]Device, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
candidates, err := manager.discoverer.Discover(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
candidates, modemErr := manager.discoverer.Discover(ctx)
|
||||
readers, readerErr := manager.cardReaders.Readers(ctx)
|
||||
if readerErr == nil {
|
||||
for _, reader := range readers {
|
||||
candidates = append(candidates, modem.Candidate{
|
||||
ID: pcsc.DeviceID(reader), HardwareKind: pcsc.HardwareKind,
|
||||
ReaderName: reader.Name, USBPath: reader.USBPath,
|
||||
VendorID: reader.VendorID, ProductID: reader.ProductID,
|
||||
Manufacturer: reader.Manufacturer, Product: reader.Product,
|
||||
})
|
||||
}
|
||||
}
|
||||
if modemErr != nil && readerErr != nil && !errors.Is(readerErr, pcsc.ErrUnsupported) && !errors.Is(readerErr, pcsc.ErrUnavailable) {
|
||||
return nil, errors.Join(modemErr, readerErr)
|
||||
}
|
||||
if modemErr != nil && len(candidates) == 0 {
|
||||
return nil, modemErr
|
||||
}
|
||||
seen := make(map[string]struct{}, len(candidates))
|
||||
|
||||
@@ -360,6 +382,9 @@ func (manager *Manager) Refresh(ctx context.Context, id string) (Snapshot, error
|
||||
return Snapshot{}, err
|
||||
}
|
||||
candidate := manager.candidateFor(state)
|
||||
if candidate.HardwareKind == pcsc.HardwareKind {
|
||||
return manager.refreshCardReader(ctx, id, state, candidate)
|
||||
}
|
||||
backend := manager.backendFor(state)
|
||||
client, err := manager.clientLocked(ctx, state, candidate)
|
||||
if err != nil {
|
||||
@@ -375,11 +400,59 @@ func (manager *Manager) Refresh(ctx context.Context, id string) (Snapshot, error
|
||||
return snapshot, err
|
||||
}
|
||||
|
||||
func (manager *Manager) refreshCardReader(ctx context.Context, id string, state *managedDevice, candidate modem.Candidate) (Snapshot, error) {
|
||||
result := Snapshot{
|
||||
DeviceID: id, Port: candidate.ReaderName, Responsive: true,
|
||||
Manufacturer: candidate.Manufacturer, Model: candidate.Product,
|
||||
AccessTech: "Wi-Fi", RegistrationSource: "pcsc", OperatingMode: 4,
|
||||
ModeKnown: true, FlightMode: true, RadioOff: true, UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
previousICCID := state.lastICCID
|
||||
card, err := manager.cardReaders.Snapshot(ctx, pcsc.Selector{USBPath: candidate.USBPath, ReaderName: candidate.ReaderName}, state.simPIN)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, pcsc.ErrNoCard):
|
||||
result.SIMStatus = ""
|
||||
err = nil
|
||||
case errors.Is(err, pcsc.ErrPINRequired), errors.Is(err, pcsc.ErrPINTriesLow), errors.Is(err, pcsc.ErrPINRejected):
|
||||
result.SIMStatus = "SIM PIN"
|
||||
result.Warnings = []string{err.Error()}
|
||||
err = nil
|
||||
default:
|
||||
manager.setResult(id, state, &result, err)
|
||||
return result, err
|
||||
}
|
||||
} else {
|
||||
result.SIMStatus = "READY"
|
||||
result.SIMReady = true
|
||||
result.ICCID = card.Identity.ICCID
|
||||
result.IMSI = card.Identity.IMSI
|
||||
result.SPN = card.Identity.SPN
|
||||
result.SIMChanged = previousICCID != "" && !strings.EqualFold(previousICCID, result.ICCID)
|
||||
state.lastICCID = result.ICCID
|
||||
}
|
||||
manager.setResult(id, state, &result, err)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// SetSIMPin updates the in-memory PIN used for protected USIM files and AKA.
|
||||
// It is deliberately never retained in runtime snapshots or logs.
|
||||
func (manager *Manager) SetSIMPin(id, pin string) error {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
state := manager.devices[id]
|
||||
if state == nil || !state.discovered {
|
||||
return ErrNotFound
|
||||
}
|
||||
state.simPIN = strings.TrimSpace(pin)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetBackend selects which control plane supplies registration and data state.
|
||||
// AT remains available in either mode for UICC, RF, SMS, voice and diagnostics.
|
||||
func (manager *Manager) SetBackend(id, backend string) error {
|
||||
backend = strings.ToLower(strings.TrimSpace(backend))
|
||||
if backend != "at" && backend != "qmi" {
|
||||
if backend != "at" && backend != "qmi" && backend != "pcsc" {
|
||||
return fmt.Errorf("unsupported device backend %q", backend)
|
||||
}
|
||||
manager.mu.Lock()
|
||||
@@ -559,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{
|
||||
{
|
||||
|
||||
@@ -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,10 +25,13 @@ var (
|
||||
)
|
||||
|
||||
type NetworkRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APN string `json:"apn"`
|
||||
IPVersion string `json:"ipVersion"`
|
||||
Backend string `json:"backend,omitempty"`
|
||||
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 {
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -31,6 +32,10 @@ import (
|
||||
|
||||
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"`
|
||||
@@ -156,6 +161,10 @@ func (manager *Manager) List() []Plugin {
|
||||
}
|
||||
|
||||
func (manager *Manager) InstallURL(ctx context.Context, rawURL, expectedSHA string) (Plugin, error) {
|
||||
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)
|
||||
|
||||
@@ -18,6 +18,8 @@ func TestInstallURLRejectsNonHTTPSAndPrivateDestinations(t *testing.T) {
|
||||
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/",
|
||||
} {
|
||||
|
||||
@@ -44,6 +44,8 @@ func (p Port) OpenPath() string {
|
||||
}
|
||||
|
||||
type Candidate struct {
|
||||
HardwareKind string `json:"hardwareKind,omitempty"`
|
||||
ReaderName string `json:"readerName,omitempty"`
|
||||
ID string `json:"id"`
|
||||
VendorID string `json:"vendorId"`
|
||||
ProductID string `json:"productId"`
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
//go:build linux && (amd64 || arm64)
|
||||
|
||||
package pcsc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type nativeBackend struct{}
|
||||
|
||||
func newNativeBackend() Backend { return &nativeBackend{} }
|
||||
|
||||
func (backend *nativeBackend) dial(ctx context.Context) (*pcscdClient, error) {
|
||||
paths := []string{strings.TrimSpace(os.Getenv("PCSCLITE_CSOCK_NAME")), "/run/pcscd/pcscd.comm", "/var/run/pcscd/pcscd.comm"}
|
||||
var failures []error
|
||||
seen := make(map[string]bool)
|
||||
for _, path := range paths {
|
||||
if path == "" || seen[path] {
|
||||
continue
|
||||
}
|
||||
seen[path] = true
|
||||
conn, err := (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, "unix", path)
|
||||
if err != nil {
|
||||
failures = append(failures, err)
|
||||
continue
|
||||
}
|
||||
client, err := establishPCSCD(ctx, conn)
|
||||
if err == nil {
|
||||
return client, nil
|
||||
}
|
||||
_ = conn.Close()
|
||||
failures = append(failures, err)
|
||||
}
|
||||
return nil, fmt.Errorf("%w: pcscd socket is not reachable: %w", ErrUnavailable, errors.Join(failures...))
|
||||
}
|
||||
|
||||
func (backend *nativeBackend) Readers(ctx context.Context) ([]Reader, error) {
|
||||
client, err := backend.dial(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer client.closeContext(context.Background())
|
||||
states, err := client.readers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
readers := make([]Reader, 0, len(states))
|
||||
for _, state := range states {
|
||||
reader := Reader{
|
||||
Name: state.name,
|
||||
CardPresent: state.state&pcscCardPresent != 0,
|
||||
ATR: strings.ToUpper(hex.EncodeToString(state.atr)),
|
||||
}
|
||||
if path, ok := backend.readerUSBPath(ctx, client, state.name); ok {
|
||||
reader.USBPath = path
|
||||
reader.VendorID = readSysfsText(path, "idVendor")
|
||||
reader.ProductID = readSysfsText(path, "idProduct")
|
||||
reader.Manufacturer = readSysfsText(path, "manufacturer")
|
||||
reader.Product = readSysfsText(path, "product")
|
||||
} else {
|
||||
reader.USBPath = "pcsc:" + state.name
|
||||
}
|
||||
if reader.Product == "" {
|
||||
reader.Product = strings.TrimSpace(strings.TrimSuffix(state.name, " 00 00"))
|
||||
}
|
||||
readers = append(readers, reader)
|
||||
}
|
||||
return readers, nil
|
||||
}
|
||||
|
||||
func (backend *nativeBackend) readerUSBPath(ctx context.Context, client *pcscdClient, name string) (string, bool) {
|
||||
card, _, err := client.connect(ctx, name, pcscShareDirect, 0)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
disposition := uint32(pcscLeaveCard)
|
||||
defer client.simpleCardCommand(context.Background(), pcscCmdDisconnect, card, &disposition)
|
||||
attribute, err := client.getAttrib(ctx, card, pcscAttrChannelID)
|
||||
if err != nil || len(attribute) < 4 {
|
||||
return "", false
|
||||
}
|
||||
channel := binary.LittleEndian.Uint32(attribute[:4])
|
||||
if channel>>16 != 0x0020 {
|
||||
return "", false
|
||||
}
|
||||
bus, device := int((channel>>8)&0xff), int(channel&0xff)
|
||||
entries, err := os.ReadDir("/sys/bus/usb/devices")
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && entry.Type()&os.ModeSymlink == 0 {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join("/sys/bus/usb/devices", entry.Name())
|
||||
entryBus, busErr := readSysfsInt(path, "busnum")
|
||||
entryDevice, deviceErr := readSysfsInt(path, "devnum")
|
||||
if busErr == nil && deviceErr == nil && entryBus == bus && entryDevice == device {
|
||||
return entry.Name(), true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (backend *nativeBackend) Open(ctx context.Context, selector Selector) (Card, error) {
|
||||
readers, err := backend.Readers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reader, ok := matchReader(readers, selector)
|
||||
if !ok {
|
||||
return nil, ErrReaderNotFound
|
||||
}
|
||||
if !reader.CardPresent {
|
||||
return nil, ErrNoCard
|
||||
}
|
||||
client, err := backend.dial(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
handle, protocol, err := client.connect(ctx, reader.Name, pcscShareShared, pcscProtocolAny)
|
||||
if err != nil {
|
||||
_ = client.closeContext(context.Background())
|
||||
return nil, err
|
||||
}
|
||||
if err := client.simpleCardCommand(ctx, pcscCmdBeginTransaction, handle, nil); err != nil {
|
||||
disposition := uint32(pcscLeaveCard)
|
||||
_ = client.simpleCardCommand(context.Background(), pcscCmdDisconnect, handle, &disposition)
|
||||
_ = client.closeContext(context.Background())
|
||||
return nil, fmt.Errorf("pcsc: begin card transaction: %w", err)
|
||||
}
|
||||
return &nativeCard{client: client, handle: handle, protocol: protocol}, nil
|
||||
}
|
||||
|
||||
type nativeCard struct {
|
||||
client *pcscdClient
|
||||
handle int32
|
||||
protocol uint32
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (card *nativeCard) Transmit(ctx context.Context, command []byte) ([]byte, uint16, error) {
|
||||
if card == nil || card.client == nil || card.closed {
|
||||
return nil, 0, errors.New("pcsc: card session is closed")
|
||||
}
|
||||
return card.transmit(ctx, append([]byte(nil), command...), 0)
|
||||
}
|
||||
|
||||
func (card *nativeCard) TransmitRaw(ctx context.Context, command []byte) ([]byte, uint16, error) {
|
||||
if card == nil || card.client == nil || card.closed {
|
||||
return nil, 0, errors.New("pcsc: card session is closed")
|
||||
}
|
||||
response, err := card.client.transmit(ctx, card.handle, card.protocol, append([]byte(nil), command...))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if len(response) < 2 {
|
||||
return nil, 0, errors.New("pcsc: APDU response omitted its status word")
|
||||
}
|
||||
last := len(response) - 2
|
||||
return append([]byte(nil), response[:last]...), uint16(response[last])<<8 | uint16(response[last+1]), nil
|
||||
}
|
||||
|
||||
func (card *nativeCard) transmit(ctx context.Context, command []byte, depth int) ([]byte, uint16, error) {
|
||||
if depth > 8 {
|
||||
return nil, 0, errors.New("pcsc: too many APDU continuations")
|
||||
}
|
||||
data, status, err := card.TransmitRaw(ctx, command)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
sw1, sw2 := byte(status>>8), byte(status)
|
||||
if sw1 == 0x6c && len(command) >= 5 {
|
||||
retry := append([]byte(nil), command...)
|
||||
retry[len(retry)-1] = sw2
|
||||
return card.transmit(ctx, retry, depth+1)
|
||||
}
|
||||
if sw1 == 0x61 || sw1 == 0x9f {
|
||||
more, sw, err := card.transmit(ctx, []byte{0x00, 0xc0, 0x00, 0x00, sw2}, depth+1)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return append(data, more...), sw, nil
|
||||
}
|
||||
return data, status, ctx.Err()
|
||||
}
|
||||
|
||||
func (card *nativeCard) Close() error { return card.close(pcscLeaveCard) }
|
||||
|
||||
func (card *nativeCard) CloseWithReset() error { return card.close(pcscResetCard) }
|
||||
|
||||
func (card *nativeCard) close(disposition uint32) error {
|
||||
if card == nil || card.closed {
|
||||
return nil
|
||||
}
|
||||
card.closed = true
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
var result []error
|
||||
if card.client != nil {
|
||||
if err := card.client.simpleCardCommand(ctx, pcscCmdEndTransaction, card.handle, &disposition); err != nil {
|
||||
result = append(result, err)
|
||||
}
|
||||
if err := card.client.simpleCardCommand(ctx, pcscCmdDisconnect, card.handle, &disposition); err != nil {
|
||||
result = append(result, err)
|
||||
}
|
||||
if err := card.client.closeContext(ctx); err != nil {
|
||||
result = append(result, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(result...)
|
||||
}
|
||||
|
||||
func readSysfsText(usbPath, name string) string {
|
||||
value, err := os.ReadFile(filepath.Join("/sys/bus/usb/devices", usbPath, name))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(value))
|
||||
}
|
||||
|
||||
func readSysfsInt(path, name string) (int, error) {
|
||||
value, err := os.ReadFile(filepath.Join(path, name))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return strconv.Atoi(strings.TrimSpace(string(value)))
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//go:build !linux || (!amd64 && !arm64)
|
||||
|
||||
package pcsc
|
||||
|
||||
import "context"
|
||||
|
||||
type unsupportedBackend struct{}
|
||||
|
||||
func newNativeBackend() Backend { return unsupportedBackend{} }
|
||||
|
||||
func (unsupportedBackend) Readers(context.Context) ([]Reader, error) {
|
||||
return nil, ErrUnsupported
|
||||
}
|
||||
|
||||
func (unsupportedBackend) Open(context.Context, Selector) (Card, error) {
|
||||
return nil, ErrUnsupported
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package pcsc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
// pcsc-lite exposes a small, versioned protocol over its local Unix socket.
|
||||
// Speaking that protocol directly keeps VoCat's Linux binaries fully static;
|
||||
// loading libpcsclite through dlopen would pull a glibc interpreter into an
|
||||
// otherwise CGO-free build and make it unusable on musl-based routers.
|
||||
const (
|
||||
pcscProtocolMajor = 4
|
||||
pcscProtocolCurrentMinor = 6
|
||||
pcscProtocolOldestMinor = 4
|
||||
|
||||
pcscCmdEstablishContext = 0x01
|
||||
pcscCmdReleaseContext = 0x02
|
||||
pcscCmdConnect = 0x04
|
||||
pcscCmdDisconnect = 0x06
|
||||
pcscCmdBeginTransaction = 0x07
|
||||
pcscCmdEndTransaction = 0x08
|
||||
pcscCmdTransmit = 0x09
|
||||
pcscCmdGetAttrib = 0x0f
|
||||
pcscCmdVersion = 0x11
|
||||
pcscCmdGetReadersState = 0x12
|
||||
|
||||
pcscScopeSystem = 0x0002
|
||||
pcscProtocolT0 = 0x0001
|
||||
pcscProtocolT1 = 0x0002
|
||||
pcscProtocolAny = pcscProtocolT0 | pcscProtocolT1
|
||||
pcscShareShared = 0x0002
|
||||
pcscShareDirect = 0x0003
|
||||
pcscLeaveCard = 0x0000
|
||||
pcscResetCard = 0x0001
|
||||
pcscCardPresent = 0x0004
|
||||
pcscAttrChannelID = 0x00020110
|
||||
pcscMaxReaderName = 128
|
||||
pcscMaxATR = 33
|
||||
pcscMaxReaders = 16
|
||||
pcscReaderStateSize = 184
|
||||
pcscGetSetBodySize = 280
|
||||
pcscMaxAttribute = 264
|
||||
pcscMaxAPDUResponse = 65548
|
||||
pcscDefaultIOTimeout = 30 * time.Second
|
||||
pcscSuccess = uint32(0)
|
||||
pcscNoSmartcard = uint32(0x8010000c)
|
||||
pcscNoService = uint32(0x8010001d)
|
||||
pcscServiceStopped = uint32(0x8010001e)
|
||||
pcscNoReaders = uint32(0x8010002e)
|
||||
)
|
||||
|
||||
type pcscdClient struct {
|
||||
conn net.Conn
|
||||
contextID uint32
|
||||
serverMinor int32
|
||||
}
|
||||
|
||||
type pcscdReaderState struct {
|
||||
name string
|
||||
state uint32
|
||||
atr []byte
|
||||
protocol uint32
|
||||
}
|
||||
|
||||
func establishPCSCD(ctx context.Context, conn net.Conn) (*pcscdClient, error) {
|
||||
client := &pcscdClient{conn: conn}
|
||||
version := make([]byte, 12)
|
||||
binary.LittleEndian.PutUint32(version[0:4], pcscProtocolMajor)
|
||||
binary.LittleEndian.PutUint32(version[4:8], pcscProtocolCurrentMinor)
|
||||
for {
|
||||
if err := client.exchange(ctx, pcscCmdVersion, version); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
major := int32(binary.LittleEndian.Uint32(version[0:4]))
|
||||
client.serverMinor = int32(binary.LittleEndian.Uint32(version[4:8]))
|
||||
rv := binary.LittleEndian.Uint32(version[8:12])
|
||||
if rv == pcscSuccess {
|
||||
break
|
||||
}
|
||||
if rv != pcscServiceStopped || major != pcscProtocolMajor || client.serverMinor < pcscProtocolOldestMinor || client.serverMinor >= pcscProtocolCurrentMinor {
|
||||
return nil, pcscError("negotiate protocol", rv)
|
||||
}
|
||||
// pcsc-lite answers a newer client's first probe with its own
|
||||
// compatible minor version. Retry on the same connection with that
|
||||
// value, matching libpcsclite's official fallback behavior.
|
||||
binary.LittleEndian.PutUint32(version[0:4], pcscProtocolMajor)
|
||||
binary.LittleEndian.PutUint32(version[4:8], uint32(client.serverMinor))
|
||||
binary.LittleEndian.PutUint32(version[8:12], pcscSuccess)
|
||||
}
|
||||
if client.serverMinor < pcscProtocolOldestMinor {
|
||||
return nil, fmt.Errorf("pcsc: unsupported pcscd protocol %d.%d", pcscProtocolMajor, client.serverMinor)
|
||||
}
|
||||
body := make([]byte, 12)
|
||||
binary.LittleEndian.PutUint32(body[0:4], pcscScopeSystem)
|
||||
if err := client.exchange(ctx, pcscCmdEstablishContext, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rv := binary.LittleEndian.Uint32(body[8:12]); rv != pcscSuccess {
|
||||
return nil, pcscError("establish context", rv)
|
||||
}
|
||||
client.contextID = binary.LittleEndian.Uint32(body[4:8])
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (client *pcscdClient) exchange(ctx context.Context, command uint32, body []byte) error {
|
||||
if client == nil || client.conn == nil {
|
||||
return errors.New("pcsc: pcscd connection is closed")
|
||||
}
|
||||
if err := client.setDeadline(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
header := make([]byte, 8)
|
||||
binary.LittleEndian.PutUint32(header[0:4], uint32(len(body)))
|
||||
binary.LittleEndian.PutUint32(header[4:8], command)
|
||||
if err := writeAll(client.conn, header); err != nil {
|
||||
return fmt.Errorf("pcsc: send command %02x: %w", command, err)
|
||||
}
|
||||
if len(body) > 0 {
|
||||
if err := writeAll(client.conn, body); err != nil {
|
||||
return fmt.Errorf("pcsc: send command body %02x: %w", command, err)
|
||||
}
|
||||
if _, err := io.ReadFull(client.conn, body); err != nil {
|
||||
return fmt.Errorf("pcsc: receive command %02x: %w", command, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (client *pcscdClient) send(ctx context.Context, command uint32, body, extra []byte) error {
|
||||
if client == nil || client.conn == nil {
|
||||
return errors.New("pcsc: pcscd connection is closed")
|
||||
}
|
||||
if err := client.setDeadline(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
header := make([]byte, 8)
|
||||
binary.LittleEndian.PutUint32(header[0:4], uint32(len(body)))
|
||||
binary.LittleEndian.PutUint32(header[4:8], command)
|
||||
if err := writeAll(client.conn, header); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeAll(client.conn, body); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeAll(client.conn, extra)
|
||||
}
|
||||
|
||||
func (client *pcscdClient) setDeadline(ctx context.Context) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
deadline := time.Now().Add(pcscDefaultIOTimeout)
|
||||
if value, ok := ctx.Deadline(); ok && value.Before(deadline) {
|
||||
deadline = value
|
||||
}
|
||||
return client.conn.SetDeadline(deadline)
|
||||
}
|
||||
|
||||
func (client *pcscdClient) readers(ctx context.Context) ([]pcscdReaderState, error) {
|
||||
if err := client.setDeadline(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
header := make([]byte, 8)
|
||||
binary.LittleEndian.PutUint32(header[4:8], pcscCmdGetReadersState)
|
||||
if err := writeAll(client.conn, header); err != nil {
|
||||
return nil, fmt.Errorf("pcsc: request reader states: %w", err)
|
||||
}
|
||||
raw := make([]byte, pcscMaxReaders*pcscReaderStateSize)
|
||||
if _, err := io.ReadFull(client.conn, raw); err != nil {
|
||||
return nil, fmt.Errorf("pcsc: read reader states: %w", err)
|
||||
}
|
||||
result := make([]pcscdReaderState, 0, pcscMaxReaders)
|
||||
for offset := 0; offset < len(raw); offset += pcscReaderStateSize {
|
||||
state := raw[offset : offset+pcscReaderStateSize]
|
||||
name := cString(state[:pcscMaxReaderName])
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
atrLen := int(binary.LittleEndian.Uint32(state[176:180]))
|
||||
if atrLen < 0 || atrLen > pcscMaxATR {
|
||||
atrLen = 0
|
||||
}
|
||||
result = append(result, pcscdReaderState{
|
||||
name: name,
|
||||
state: binary.LittleEndian.Uint32(state[132:136]),
|
||||
atr: append([]byte(nil), state[140:140+atrLen]...),
|
||||
protocol: binary.LittleEndian.Uint32(state[180:184]),
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (client *pcscdClient) connect(ctx context.Context, reader string, share, protocols uint32) (int32, uint32, error) {
|
||||
if len(reader) >= pcscMaxReaderName {
|
||||
return 0, 0, errors.New("pcsc: reader name is too long")
|
||||
}
|
||||
body := make([]byte, 152)
|
||||
binary.LittleEndian.PutUint32(body[0:4], client.contextID)
|
||||
copy(body[4:132], reader)
|
||||
binary.LittleEndian.PutUint32(body[132:136], share)
|
||||
binary.LittleEndian.PutUint32(body[136:140], protocols)
|
||||
if err := client.exchange(ctx, pcscCmdConnect, body); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if rv := binary.LittleEndian.Uint32(body[148:152]); rv != pcscSuccess {
|
||||
return 0, 0, pcscError("connect reader", rv)
|
||||
}
|
||||
return int32(binary.LittleEndian.Uint32(body[140:144])), binary.LittleEndian.Uint32(body[144:148]), nil
|
||||
}
|
||||
|
||||
func (client *pcscdClient) simpleCardCommand(ctx context.Context, command uint32, card int32, disposition *uint32) error {
|
||||
size := 8
|
||||
if disposition != nil {
|
||||
size = 12
|
||||
}
|
||||
body := make([]byte, size)
|
||||
binary.LittleEndian.PutUint32(body[0:4], uint32(card))
|
||||
if disposition != nil {
|
||||
binary.LittleEndian.PutUint32(body[4:8], *disposition)
|
||||
}
|
||||
if err := client.exchange(ctx, command, body); err != nil {
|
||||
return err
|
||||
}
|
||||
if rv := binary.LittleEndian.Uint32(body[size-4:]); rv != pcscSuccess {
|
||||
return pcscError("card command", rv)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (client *pcscdClient) transmit(ctx context.Context, card int32, protocol uint32, command []byte) ([]byte, error) {
|
||||
body := make([]byte, 32)
|
||||
binary.LittleEndian.PutUint32(body[0:4], uint32(card))
|
||||
binary.LittleEndian.PutUint32(body[4:8], protocol)
|
||||
binary.LittleEndian.PutUint32(body[8:12], 8)
|
||||
binary.LittleEndian.PutUint32(body[12:16], uint32(len(command)))
|
||||
binary.LittleEndian.PutUint32(body[16:20], pcscProtocolAny)
|
||||
binary.LittleEndian.PutUint32(body[20:24], 8)
|
||||
binary.LittleEndian.PutUint32(body[24:28], pcscMaxAPDUResponse)
|
||||
if err := client.send(ctx, pcscCmdTransmit, body, command); err != nil {
|
||||
return nil, fmt.Errorf("pcsc: transmit APDU: %w", err)
|
||||
}
|
||||
if _, err := io.ReadFull(client.conn, body); err != nil {
|
||||
return nil, fmt.Errorf("pcsc: receive APDU result: %w", err)
|
||||
}
|
||||
if rv := binary.LittleEndian.Uint32(body[28:32]); rv != pcscSuccess {
|
||||
return nil, pcscError("transmit APDU", rv)
|
||||
}
|
||||
length := binary.LittleEndian.Uint32(body[24:28])
|
||||
if length > pcscMaxAPDUResponse {
|
||||
return nil, errors.New("pcsc: pcscd returned an oversized APDU")
|
||||
}
|
||||
response := make([]byte, length)
|
||||
if _, err := io.ReadFull(client.conn, response); err != nil {
|
||||
return nil, fmt.Errorf("pcsc: receive APDU: %w", err)
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (client *pcscdClient) getAttrib(ctx context.Context, card int32, attribute uint32) ([]byte, error) {
|
||||
body := make([]byte, pcscGetSetBodySize)
|
||||
binary.LittleEndian.PutUint32(body[0:4], uint32(card))
|
||||
binary.LittleEndian.PutUint32(body[4:8], attribute)
|
||||
binary.LittleEndian.PutUint32(body[272:276], pcscMaxAttribute)
|
||||
if err := client.exchange(ctx, pcscCmdGetAttrib, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rv := binary.LittleEndian.Uint32(body[276:280]); rv != pcscSuccess {
|
||||
return nil, pcscError("get reader attribute", rv)
|
||||
}
|
||||
length := binary.LittleEndian.Uint32(body[272:276])
|
||||
if length > pcscMaxAttribute {
|
||||
return nil, errors.New("pcsc: pcscd returned an oversized attribute")
|
||||
}
|
||||
return append([]byte(nil), body[8:8+length]...), nil
|
||||
}
|
||||
|
||||
func (client *pcscdClient) closeContext(ctx context.Context) error {
|
||||
if client == nil || client.conn == nil {
|
||||
return nil
|
||||
}
|
||||
body := make([]byte, 8)
|
||||
binary.LittleEndian.PutUint32(body[0:4], client.contextID)
|
||||
err := client.exchange(ctx, pcscCmdReleaseContext, body)
|
||||
if err == nil {
|
||||
if rv := binary.LittleEndian.Uint32(body[4:8]); rv != pcscSuccess {
|
||||
err = pcscError("release context", rv)
|
||||
}
|
||||
}
|
||||
closeErr := client.conn.Close()
|
||||
client.conn = nil
|
||||
return errors.Join(err, closeErr)
|
||||
}
|
||||
|
||||
func pcscError(operation string, code uint32) error {
|
||||
switch code {
|
||||
case pcscNoSmartcard:
|
||||
return ErrNoCard
|
||||
case pcscNoService, pcscServiceStopped:
|
||||
return fmt.Errorf("%w: %s failed with PC/SC status %08X", ErrUnavailable, operation, code)
|
||||
case pcscNoReaders:
|
||||
return ErrReaderNotFound
|
||||
default:
|
||||
return fmt.Errorf("pcsc: %s failed with status %08X", operation, code)
|
||||
}
|
||||
}
|
||||
|
||||
func cString(value []byte) string {
|
||||
for index, current := range value {
|
||||
if current == 0 {
|
||||
return string(value[:index])
|
||||
}
|
||||
}
|
||||
return string(value)
|
||||
}
|
||||
|
||||
func writeAll(writer io.Writer, value []byte) error {
|
||||
for len(value) > 0 {
|
||||
written, err := writer.Write(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if written == 0 {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
value = value[written:]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package pcsc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPCSCDClientLifecycleAndTransmit(t *testing.T) {
|
||||
clientConn, serverConn := net.Pipe()
|
||||
serverDone := make(chan error, 1)
|
||||
go func() {
|
||||
defer serverConn.Close()
|
||||
serverDone <- servePCSCDTestSession(serverConn)
|
||||
}()
|
||||
|
||||
client, err := establishPCSCD(context.Background(), clientConn)
|
||||
if err != nil {
|
||||
t.Fatalf("establishPCSCD: %v", err)
|
||||
}
|
||||
states, err := client.readers(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("readers: %v", err)
|
||||
}
|
||||
if len(states) != 1 || states[0].name != "VoCat Test Reader 00 00" || states[0].state&pcscCardPresent == 0 {
|
||||
t.Fatalf("states = %#v", states)
|
||||
}
|
||||
handle, protocol, err := client.connect(context.Background(), states[0].name, pcscShareShared, pcscProtocolAny)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
if handle != 42 || protocol != pcscProtocolT1 {
|
||||
t.Fatalf("handle/protocol = %d/%d", handle, protocol)
|
||||
}
|
||||
if err := client.simpleCardCommand(context.Background(), pcscCmdBeginTransaction, handle, nil); err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
response, err := client.transmit(context.Background(), handle, protocol, []byte{0x00, 0xa4, 0x00, 0x00})
|
||||
if err != nil {
|
||||
t.Fatalf("transmit: %v", err)
|
||||
}
|
||||
if !bytes.Equal(response, []byte{0x62, 0x02, 0x90, 0x00}) {
|
||||
t.Fatalf("response = %x", response)
|
||||
}
|
||||
disposition := uint32(pcscLeaveCard)
|
||||
if err := client.simpleCardCommand(context.Background(), pcscCmdEndTransaction, handle, &disposition); err != nil {
|
||||
t.Fatalf("end: %v", err)
|
||||
}
|
||||
if err := client.simpleCardCommand(context.Background(), pcscCmdDisconnect, handle, &disposition); err != nil {
|
||||
t.Fatalf("disconnect: %v", err)
|
||||
}
|
||||
if err := client.closeContext(context.Background()); err != nil {
|
||||
t.Fatalf("close context: %v", err)
|
||||
}
|
||||
if err := <-serverDone; err != nil {
|
||||
t.Fatalf("fake pcscd: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func servePCSCDTestSession(conn net.Conn) error {
|
||||
for {
|
||||
header := make([]byte, 8)
|
||||
if _, err := io.ReadFull(conn, header); err != nil {
|
||||
return err
|
||||
}
|
||||
size := binary.LittleEndian.Uint32(header[0:4])
|
||||
command := binary.LittleEndian.Uint32(header[4:8])
|
||||
body := make([]byte, size)
|
||||
if _, err := io.ReadFull(conn, body); err != nil {
|
||||
return err
|
||||
}
|
||||
switch command {
|
||||
case pcscCmdVersion:
|
||||
binary.LittleEndian.PutUint32(body[0:4], pcscProtocolMajor)
|
||||
binary.LittleEndian.PutUint32(body[4:8], pcscProtocolCurrentMinor)
|
||||
if err := writeAll(conn, body); err != nil {
|
||||
return err
|
||||
}
|
||||
case pcscCmdEstablishContext:
|
||||
binary.LittleEndian.PutUint32(body[4:8], 7)
|
||||
if err := writeAll(conn, body); err != nil {
|
||||
return err
|
||||
}
|
||||
case pcscCmdGetReadersState:
|
||||
states := make([]byte, pcscMaxReaders*pcscReaderStateSize)
|
||||
copy(states, "VoCat Test Reader 00 00")
|
||||
binary.LittleEndian.PutUint32(states[132:136], pcscCardPresent)
|
||||
copy(states[140:143], []byte{0x3b, 0x00, 0x00})
|
||||
binary.LittleEndian.PutUint32(states[176:180], 3)
|
||||
binary.LittleEndian.PutUint32(states[180:184], pcscProtocolT1)
|
||||
if err := writeAll(conn, states); err != nil {
|
||||
return err
|
||||
}
|
||||
case pcscCmdConnect:
|
||||
binary.LittleEndian.PutUint32(body[140:144], 42)
|
||||
binary.LittleEndian.PutUint32(body[144:148], pcscProtocolT1)
|
||||
if err := writeAll(conn, body); err != nil {
|
||||
return err
|
||||
}
|
||||
case pcscCmdBeginTransaction, pcscCmdEndTransaction, pcscCmdDisconnect:
|
||||
if err := writeAll(conn, body); err != nil {
|
||||
return err
|
||||
}
|
||||
case pcscCmdTransmit:
|
||||
commandBody := make([]byte, binary.LittleEndian.Uint32(body[12:16]))
|
||||
if _, err := io.ReadFull(conn, commandBody); err != nil {
|
||||
return err
|
||||
}
|
||||
response := []byte{0x62, 0x02, 0x90, 0x00}
|
||||
binary.LittleEndian.PutUint32(body[24:28], uint32(len(response)))
|
||||
if err := writeAll(conn, body); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeAll(conn, response); err != nil {
|
||||
return err
|
||||
}
|
||||
case pcscCmdReleaseContext:
|
||||
return writeAll(conn, body)
|
||||
default:
|
||||
return errors.New("unexpected fake pcscd command")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
package pcsc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const usimAIDPrefix = "A0000000871002"
|
||||
|
||||
type Service struct {
|
||||
mu sync.Mutex
|
||||
backend Backend
|
||||
}
|
||||
|
||||
// Session is an exclusive connection to one smart card. It is used by eUICC
|
||||
// operations which must keep the same PC/SC transaction and logical channel
|
||||
// alive across a sequence of APDUs.
|
||||
type Session struct {
|
||||
service *Service
|
||||
card Card
|
||||
closed bool
|
||||
}
|
||||
|
||||
func New() *Service {
|
||||
return &Service{backend: newNativeBackend()}
|
||||
}
|
||||
|
||||
func NewWithBackend(backend Backend) *Service {
|
||||
return &Service{backend: backend}
|
||||
}
|
||||
|
||||
func DeviceID(reader Reader) string {
|
||||
identity := strings.TrimSpace(reader.USBPath)
|
||||
if identity == "" {
|
||||
identity = strings.TrimSpace(reader.Name)
|
||||
}
|
||||
sum := sha256.Sum256([]byte(identity))
|
||||
return "reader-" + hex.EncodeToString(sum[:8])
|
||||
}
|
||||
|
||||
func (service *Service) Readers(ctx context.Context) ([]Reader, error) {
|
||||
if service == nil || service.backend == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
service.mu.Lock()
|
||||
defer service.mu.Unlock()
|
||||
return service.backend.Readers(ctx)
|
||||
}
|
||||
|
||||
// OpenSession opens one card and holds the service lock until Close. Callers
|
||||
// must close the returned session; this prevents AKA/identity reads from
|
||||
// interleaving with a stateful ES10 transaction.
|
||||
func (service *Service) OpenSession(ctx context.Context, selector Selector) (*Session, error) {
|
||||
if service == nil || service.backend == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
if err := selector.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
service.mu.Lock()
|
||||
card, err := service.backend.Open(ctx, selector)
|
||||
if err != nil {
|
||||
service.mu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
return &Session{service: service, card: card}, nil
|
||||
}
|
||||
|
||||
// Transmit sends one raw APDU. Unlike the ordinary SIM helpers, it leaves
|
||||
// 61xx continuation handling to the eUICC logical-channel implementation so
|
||||
// GET RESPONSE uses the correct channel CLA.
|
||||
func (session *Session) Transmit(ctx context.Context, command []byte) ([]byte, uint16, error) {
|
||||
if session == nil || session.card == nil || session.closed {
|
||||
return nil, 0, errors.New("pcsc: card session is closed")
|
||||
}
|
||||
if raw, ok := session.card.(interface {
|
||||
TransmitRaw(context.Context, []byte) ([]byte, uint16, error)
|
||||
}); ok {
|
||||
return raw.TransmitRaw(ctx, command)
|
||||
}
|
||||
return session.card.Transmit(ctx, command)
|
||||
}
|
||||
|
||||
func (session *Session) Close() error {
|
||||
return session.close(false)
|
||||
}
|
||||
|
||||
// CloseWithReset resets the card while releasing the PC/SC connection. eUICC
|
||||
// EnableProfile requires this refresh boundary before the newly enabled USIM
|
||||
// application and ICCID become visible to subsequent callers.
|
||||
func (session *Session) CloseWithReset() error {
|
||||
return session.close(true)
|
||||
}
|
||||
|
||||
func (session *Session) close(reset bool) error {
|
||||
if session == nil || session.closed {
|
||||
return nil
|
||||
}
|
||||
session.closed = true
|
||||
var err error
|
||||
if resetter, ok := session.card.(interface{ CloseWithReset() error }); reset && ok {
|
||||
err = resetter.CloseWithReset()
|
||||
} else {
|
||||
err = session.card.Close()
|
||||
}
|
||||
session.service.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
func (service *Service) Snapshot(ctx context.Context, selector Selector, pin string) (Snapshot, error) {
|
||||
readers, err := service.Readers(ctx)
|
||||
if err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
reader, ok := matchReader(readers, selector)
|
||||
if !ok {
|
||||
return Snapshot{}, ErrReaderNotFound
|
||||
}
|
||||
result := Snapshot{Reader: reader}
|
||||
if !reader.CardPresent {
|
||||
return result, ErrNoCard
|
||||
}
|
||||
identity, err := service.ReadIdentity(ctx, selector, pin)
|
||||
result.Identity = identity
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (service *Service) ReadIdentity(ctx context.Context, selector Selector, pin string) (Identity, error) {
|
||||
if service == nil || service.backend == nil {
|
||||
return Identity{}, ErrUnavailable
|
||||
}
|
||||
if err := selector.validate(); err != nil {
|
||||
return Identity{}, err
|
||||
}
|
||||
service.mu.Lock()
|
||||
defer service.mu.Unlock()
|
||||
card, err := service.backend.Open(ctx, selector)
|
||||
if err != nil {
|
||||
return Identity{}, err
|
||||
}
|
||||
defer card.Close()
|
||||
return readIdentity(ctx, card, pin)
|
||||
}
|
||||
|
||||
func (service *Service) CheckReady(
|
||||
ctx context.Context,
|
||||
selector Selector,
|
||||
expectedICCID string,
|
||||
pin string,
|
||||
) (string, error) {
|
||||
if service == nil || service.backend == nil {
|
||||
return "", ErrUnavailable
|
||||
}
|
||||
service.mu.Lock()
|
||||
defer service.mu.Unlock()
|
||||
card, err := service.backend.Open(ctx, selector)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer card.Close()
|
||||
iccid, err := readICCID(ctx, card)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if expected := strings.TrimSpace(expectedICCID); expected != "" && !strings.EqualFold(expected, iccid) {
|
||||
return "", ErrCardChanged
|
||||
}
|
||||
aid, err := selectUSIM(ctx, card)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := verifyPIN(ctx, card, pin); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.ToUpper(hex.EncodeToString(aid)), nil
|
||||
}
|
||||
|
||||
func (service *Service) Authenticate(
|
||||
ctx context.Context,
|
||||
selector Selector,
|
||||
expectedICCID string,
|
||||
pin string,
|
||||
challenge AKAChallenge,
|
||||
) (AKAResult, error) {
|
||||
if service == nil || service.backend == nil {
|
||||
return AKAResult{}, ErrUnavailable
|
||||
}
|
||||
service.mu.Lock()
|
||||
defer service.mu.Unlock()
|
||||
card, err := service.backend.Open(ctx, selector)
|
||||
if err != nil {
|
||||
return AKAResult{}, err
|
||||
}
|
||||
defer card.Close()
|
||||
iccid, err := readICCID(ctx, card)
|
||||
if err != nil {
|
||||
return AKAResult{}, err
|
||||
}
|
||||
if expected := strings.TrimSpace(expectedICCID); expected != "" && !strings.EqualFold(expected, iccid) {
|
||||
return AKAResult{}, ErrCardChanged
|
||||
}
|
||||
if _, err := selectUSIM(ctx, card); err != nil {
|
||||
return AKAResult{}, err
|
||||
}
|
||||
if err := verifyPIN(ctx, card, pin); err != nil {
|
||||
return AKAResult{}, err
|
||||
}
|
||||
apdu := make([]byte, 0, 40)
|
||||
apdu = append(apdu, 0x00, 0x88, 0x00, 0x81, 0x22, 0x10)
|
||||
apdu = append(apdu, challenge.RAND[:]...)
|
||||
apdu = append(apdu, 0x10)
|
||||
apdu = append(apdu, challenge.AUTN[:]...)
|
||||
apdu = append(apdu, 0x00)
|
||||
data, sw, err := card.Transmit(ctx, apdu)
|
||||
if err != nil {
|
||||
return AKAResult{}, errors.New("pcsc: USIM authentication transport failed")
|
||||
}
|
||||
if sw == 0x9862 {
|
||||
return AKAResult{}, ErrAKARejected
|
||||
}
|
||||
if sw != 0x9000 {
|
||||
return AKAResult{}, fmt.Errorf("pcsc: USIM authentication failed with status %04X", sw)
|
||||
}
|
||||
return parseAKAResponse(data)
|
||||
}
|
||||
|
||||
func matchReader(readers []Reader, selector Selector) (Reader, bool) {
|
||||
path := strings.TrimSpace(selector.USBPath)
|
||||
name := strings.TrimSpace(selector.ReaderName)
|
||||
for _, reader := range readers {
|
||||
if path != "" && reader.USBPath == path {
|
||||
return reader, true
|
||||
}
|
||||
}
|
||||
for _, reader := range readers {
|
||||
if name != "" && reader.Name == name {
|
||||
return reader, true
|
||||
}
|
||||
}
|
||||
return Reader{}, false
|
||||
}
|
||||
|
||||
func readIdentity(ctx context.Context, card Card, pin string) (Identity, error) {
|
||||
identity := Identity{PINTries: -1}
|
||||
iccid, err := readICCID(ctx, card)
|
||||
if err != nil {
|
||||
return identity, err
|
||||
}
|
||||
identity.ICCID = iccid
|
||||
aid, err := selectUSIM(ctx, card)
|
||||
if err != nil {
|
||||
return identity, err
|
||||
}
|
||||
identity.USIMAID = append([]byte(nil), aid...)
|
||||
if err := verifyPIN(ctx, card, pin); err != nil {
|
||||
identity.PINRequired = errors.Is(err, ErrPINRequired) || errors.Is(err, ErrPINTriesLow)
|
||||
var pinErr *PINError
|
||||
if errors.As(err, &pinErr) {
|
||||
identity.PINTries = pinErr.Tries
|
||||
}
|
||||
return identity, err
|
||||
}
|
||||
if err := selectFile(ctx, card, []byte{0x6F, 0x07}); err != nil {
|
||||
return identity, fmt.Errorf("pcsc: select EF_IMSI: %w", err)
|
||||
}
|
||||
imsiData, err := readBinary(ctx, card, 9)
|
||||
if err != nil {
|
||||
return identity, fmt.Errorf("pcsc: read EF_IMSI: %w", err)
|
||||
}
|
||||
identity.IMSI, err = decodeIMSI(imsiData)
|
||||
if err != nil {
|
||||
return identity, err
|
||||
}
|
||||
if _, selectErr := selectApplication(ctx, card, aid); selectErr == nil {
|
||||
if selectErr = selectFile(ctx, card, []byte{0x6F, 0xAD}); selectErr == nil {
|
||||
if data, readErr := readBinary(ctx, card, 4); readErr == nil && len(data) >= 4 {
|
||||
length := int(data[3] & 0x0f)
|
||||
if length == 2 || length == 3 {
|
||||
identity.MNCLength = length
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, selectErr := selectApplication(ctx, card, aid); selectErr == nil {
|
||||
identity.SPN = readSPN(ctx, card)
|
||||
}
|
||||
if _, selectErr := selectApplication(ctx, card, aid); selectErr == nil {
|
||||
identity.SMSC = readSMSC(ctx, card)
|
||||
}
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
func readICCID(ctx context.Context, card Card) (string, error) {
|
||||
if err := selectMF(ctx, card); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := selectFile(ctx, card, []byte{0x2F, 0xE2}); err != nil {
|
||||
return "", fmt.Errorf("pcsc: select EF_ICCID: %w", err)
|
||||
}
|
||||
data, err := readBinary(ctx, card, 10)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pcsc: read EF_ICCID: %w", err)
|
||||
}
|
||||
value := decodeSwappedBCD(data, false)
|
||||
if len(value) < 18 || len(value) > 22 {
|
||||
return "", errors.New("pcsc: card returned an invalid ICCID")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func selectUSIM(ctx context.Context, card Card) ([]byte, error) {
|
||||
if err := selectMF(ctx, card); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := selectFile(ctx, card, []byte{0x2F, 0x00}); err != nil {
|
||||
return nil, fmt.Errorf("pcsc: select EF_DIR: %w", err)
|
||||
}
|
||||
var usimAID []byte
|
||||
for record := 1; record <= 32; record++ {
|
||||
data, sw, err := card.Transmit(ctx, []byte{0x00, 0xB2, byte(record), 0x04, 0x00})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sw == 0x6A83 || sw == 0x9402 {
|
||||
break
|
||||
}
|
||||
if sw != 0x9000 {
|
||||
continue
|
||||
}
|
||||
aid := findTLV(data, 0x4F)
|
||||
if len(aid) == 0 {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(strings.ToUpper(hex.EncodeToString(aid)), usimAIDPrefix) {
|
||||
usimAID = append([]byte(nil), aid...)
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(usimAID) == 0 {
|
||||
return nil, ErrUSIMUnavailable
|
||||
}
|
||||
if _, err := selectApplication(ctx, card, usimAID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return usimAID, nil
|
||||
}
|
||||
|
||||
func selectMF(ctx context.Context, card Card) error {
|
||||
_, sw, err := card.Transmit(ctx, []byte{0x00, 0xA4, 0x00, 0x04, 0x02, 0x3F, 0x00, 0x00})
|
||||
return requireStatus("select MF", sw, err)
|
||||
}
|
||||
|
||||
func selectFile(ctx context.Context, card Card, fileID []byte) error {
|
||||
if len(fileID) != 2 {
|
||||
return errors.New("pcsc: invalid file identifier")
|
||||
}
|
||||
apdu := []byte{0x00, 0xA4, 0x00, 0x04, 0x02, fileID[0], fileID[1], 0x00}
|
||||
_, sw, err := card.Transmit(ctx, apdu)
|
||||
return requireStatus("select file", sw, err)
|
||||
}
|
||||
|
||||
func selectApplication(ctx context.Context, card Card, aid []byte) ([]byte, error) {
|
||||
if len(aid) == 0 || len(aid) > 32 {
|
||||
return nil, errors.New("pcsc: invalid USIM AID")
|
||||
}
|
||||
apdu := []byte{0x00, 0xA4, 0x04, 0x04, byte(len(aid))}
|
||||
apdu = append(apdu, aid...)
|
||||
apdu = append(apdu, 0x00)
|
||||
data, sw, err := card.Transmit(ctx, apdu)
|
||||
if err := requireStatus("select USIM application", sw, err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func readBinary(ctx context.Context, card Card, length int) ([]byte, error) {
|
||||
if length <= 0 || length > 256 {
|
||||
return nil, errors.New("pcsc: invalid binary read length")
|
||||
}
|
||||
le := byte(length)
|
||||
if length == 256 {
|
||||
le = 0
|
||||
}
|
||||
data, sw, err := card.Transmit(ctx, []byte{0x00, 0xB0, 0x00, 0x00, le})
|
||||
if err := requireStatus("read binary", sw, err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func verifyPIN(ctx context.Context, card Card, pin string) error {
|
||||
pin = strings.TrimSpace(pin)
|
||||
if pin == "" {
|
||||
return nil
|
||||
}
|
||||
if len(pin) < 4 || len(pin) > 8 || !decimalDigits(pin) {
|
||||
return errors.New("pcsc: SIM PIN must contain 4 to 8 digits")
|
||||
}
|
||||
_, sw, err := card.Transmit(ctx, []byte{0x00, 0x20, 0x00, 0x01, 0x00})
|
||||
if err != nil {
|
||||
return errors.New("pcsc: SIM PIN status check failed")
|
||||
}
|
||||
if sw == 0x9000 {
|
||||
return nil
|
||||
}
|
||||
tries := -1
|
||||
if sw&0xFFF0 == 0x63C0 {
|
||||
tries = int(sw & 0x000F)
|
||||
if tries <= 2 {
|
||||
return &PINError{Kind: ErrPINTriesLow, Tries: tries}
|
||||
}
|
||||
}
|
||||
body := bytes.Repeat([]byte{0xFF}, 8)
|
||||
copy(body, []byte(pin))
|
||||
apdu := append([]byte{0x00, 0x20, 0x00, 0x01, 0x08}, body...)
|
||||
_, sw, err = card.Transmit(ctx, apdu)
|
||||
if err != nil {
|
||||
return errors.New("pcsc: SIM PIN verification transport failed")
|
||||
}
|
||||
if sw == 0x9000 {
|
||||
return nil
|
||||
}
|
||||
if sw&0xFFF0 == 0x63C0 {
|
||||
return &PINError{Kind: ErrPINRejected, Tries: int(sw & 0x000F)}
|
||||
}
|
||||
return ErrPINRejected
|
||||
}
|
||||
|
||||
func requireStatus(operation string, sw uint16, err error) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("pcsc: %s transport failed", operation)
|
||||
}
|
||||
if sw == 0x9000 {
|
||||
return nil
|
||||
}
|
||||
if sw == 0x6982 || sw == 0x9804 {
|
||||
return &PINError{Kind: ErrPINRequired, Tries: -1}
|
||||
}
|
||||
return fmt.Errorf("pcsc: %s failed with status %04X", operation, sw)
|
||||
}
|
||||
|
||||
func decodeSwappedBCD(value []byte, dropFirstNibble bool) string {
|
||||
var result strings.Builder
|
||||
for _, octet := range value {
|
||||
for _, nibble := range []byte{octet & 0x0F, octet >> 4} {
|
||||
if dropFirstNibble {
|
||||
dropFirstNibble = false
|
||||
continue
|
||||
}
|
||||
if nibble == 0x0F {
|
||||
return result.String()
|
||||
}
|
||||
if nibble > 9 {
|
||||
return ""
|
||||
}
|
||||
result.WriteByte('0' + nibble)
|
||||
}
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func decodeIMSI(data []byte) (string, error) {
|
||||
if len(data) < 2 {
|
||||
return "", errors.New("pcsc: EF_IMSI is too short")
|
||||
}
|
||||
length := int(data[0])
|
||||
if length <= 0 || length > len(data)-1 {
|
||||
return "", errors.New("pcsc: EF_IMSI has an invalid length")
|
||||
}
|
||||
value := decodeSwappedBCD(data[1:1+length], true)
|
||||
if len(value) < 10 || len(value) > 18 || !decimalDigits(value) {
|
||||
return "", errors.New("pcsc: card returned an invalid IMSI")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func decimalDigits(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, character := range value {
|
||||
if character < '0' || character > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func findTLV(data []byte, wanted byte) []byte {
|
||||
for len(data) >= 2 {
|
||||
tag := data[0]
|
||||
data = data[1:]
|
||||
length, consumed, ok := decodeTLVLength(data)
|
||||
if !ok || consumed+length > len(data) {
|
||||
return nil
|
||||
}
|
||||
value := data[consumed : consumed+length]
|
||||
if tag == wanted {
|
||||
return append([]byte(nil), value...)
|
||||
}
|
||||
if tag&0x20 != 0 {
|
||||
if nested := findTLV(value, wanted); len(nested) > 0 {
|
||||
return nested
|
||||
}
|
||||
}
|
||||
data = data[consumed+length:]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeTLVLength(data []byte) (length, consumed int, ok bool) {
|
||||
if len(data) == 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
if data[0]&0x80 == 0 {
|
||||
return int(data[0]), 1, true
|
||||
}
|
||||
count := int(data[0] & 0x7F)
|
||||
if count < 1 || count > 2 || len(data) < 1+count {
|
||||
return 0, 0, false
|
||||
}
|
||||
length = 0
|
||||
for _, octet := range data[1 : 1+count] {
|
||||
length = length<<8 | int(octet)
|
||||
}
|
||||
return length, 1 + count, true
|
||||
}
|
||||
|
||||
func parseAKAResponse(data []byte) (AKAResult, error) {
|
||||
if len(data) < 2 {
|
||||
return AKAResult{}, errors.New("pcsc: USIM returned a short AKA response")
|
||||
}
|
||||
switch data[0] {
|
||||
case 0xDB:
|
||||
res, rest, ok := takeLV(data[1:])
|
||||
if !ok || len(res) < 4 || len(res) > 16 {
|
||||
return AKAResult{}, errors.New("pcsc: USIM returned an invalid AKA RES")
|
||||
}
|
||||
ck, rest, ok := takeLV(rest)
|
||||
if !ok || len(ck) != 16 {
|
||||
return AKAResult{}, errors.New("pcsc: USIM returned an invalid AKA CK")
|
||||
}
|
||||
ik, rest, ok := takeLV(rest)
|
||||
if !ok || len(ik) != 16 {
|
||||
return AKAResult{}, errors.New("pcsc: USIM returned an invalid AKA IK")
|
||||
}
|
||||
if len(rest) > 0 {
|
||||
kc, tail, valid := takeLV(rest)
|
||||
if !valid || len(kc) != 8 || len(tail) != 0 {
|
||||
return AKAResult{}, errors.New("pcsc: USIM returned invalid trailing AKA material")
|
||||
}
|
||||
}
|
||||
return AKAResult{RES: append([]byte(nil), res...), CK: append([]byte(nil), ck...), IK: append([]byte(nil), ik...)}, nil
|
||||
case 0xDC:
|
||||
auts, tail, ok := takeLV(data[1:])
|
||||
if !ok || len(auts) != 14 || len(tail) != 0 {
|
||||
return AKAResult{}, errors.New("pcsc: USIM returned invalid AKA synchronization evidence")
|
||||
}
|
||||
return AKAResult{AUTS: append([]byte(nil), auts...), SynchronizationFailure: true}, nil
|
||||
default:
|
||||
return AKAResult{}, errors.New("pcsc: USIM returned an unsupported AKA response")
|
||||
}
|
||||
}
|
||||
|
||||
func takeLV(data []byte) (value, rest []byte, ok bool) {
|
||||
if len(data) == 0 || int(data[0]) > len(data)-1 {
|
||||
return nil, data, false
|
||||
}
|
||||
length := int(data[0])
|
||||
return data[1 : 1+length], data[1+length:], true
|
||||
}
|
||||
|
||||
func readSPN(ctx context.Context, card Card) string {
|
||||
if err := selectFile(ctx, card, []byte{0x6F, 0x46}); err != nil {
|
||||
return ""
|
||||
}
|
||||
data, err := readBinary(ctx, card, 17)
|
||||
if err != nil || len(data) < 2 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(strings.TrimRight(string(data[1:]), "\x00\xFF"))
|
||||
}
|
||||
|
||||
func readSMSC(ctx context.Context, card Card) string {
|
||||
if err := selectFile(ctx, card, []byte{0x6F, 0x42}); err != nil {
|
||||
return ""
|
||||
}
|
||||
data, sw, err := card.Transmit(ctx, []byte{0x00, 0xB2, 0x01, 0x04, 0x00})
|
||||
if err != nil || sw != 0x9000 || len(data) < 15 {
|
||||
return ""
|
||||
}
|
||||
sca := data[len(data)-15 : len(data)-3]
|
||||
if len(sca) < 2 || sca[0] < 2 || int(sca[0]) > len(sca)-1 {
|
||||
return ""
|
||||
}
|
||||
digits := decodeSwappedBCD(sca[2:1+int(sca[0])], false)
|
||||
if !decimalDigits(digits) {
|
||||
return ""
|
||||
}
|
||||
if sca[1]&0x70 == 0x10 {
|
||||
return "+" + digits
|
||||
}
|
||||
return digits
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package pcsc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type scriptedReply struct {
|
||||
data []byte
|
||||
sw uint16
|
||||
}
|
||||
|
||||
type scriptedCard struct {
|
||||
replies []scriptedReply
|
||||
calls [][]byte
|
||||
}
|
||||
|
||||
func (card *scriptedCard) Transmit(_ context.Context, command []byte) ([]byte, uint16, error) {
|
||||
card.calls = append(card.calls, append([]byte(nil), command...))
|
||||
if len(card.replies) == 0 {
|
||||
return nil, 0, errors.New("unexpected APDU")
|
||||
}
|
||||
reply := card.replies[0]
|
||||
card.replies = card.replies[1:]
|
||||
return append([]byte(nil), reply.data...), reply.sw, nil
|
||||
}
|
||||
|
||||
func (*scriptedCard) Close() error { return nil }
|
||||
|
||||
func TestDecodeIdentifiers(t *testing.T) {
|
||||
if got := decodeSwappedBCD([]byte{0x98, 0x10, 0x32, 0x54, 0xF6}, false); got != "890123456" {
|
||||
t.Fatalf("ICCID BCD = %q", got)
|
||||
}
|
||||
imsi, err := decodeIMSI([]byte{0x08, 0x19, 0x32, 0x54, 0x76, 0x98, 0x10, 0x32, 0x54})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if imsi != "123456789012345" {
|
||||
t.Fatalf("IMSI = %q", imsi)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPINRefusesLowAttemptCount(t *testing.T) {
|
||||
card := &scriptedCard{replies: []scriptedReply{{sw: 0x63C2}}}
|
||||
err := verifyPIN(context.Background(), card, "1234")
|
||||
if !errors.Is(err, ErrPINTriesLow) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if len(card.calls) != 1 {
|
||||
t.Fatalf("APDU calls = %d, PIN must not be submitted", len(card.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAKAResponse(t *testing.T) {
|
||||
data := []byte{0xDB, 0x08, 1, 2, 3, 4, 5, 6, 7, 8, 0x10}
|
||||
data = append(data, bytes.Repeat([]byte{0xAA}, 16)...)
|
||||
data = append(data, 0x10)
|
||||
data = append(data, bytes.Repeat([]byte{0xBB}, 16)...)
|
||||
result, err := parseAKAResponse(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.RES) != 8 || len(result.CK) != 16 || len(result.IK) != 16 || result.SynchronizationFailure {
|
||||
t.Fatalf("unexpected AKA result: %#v", result)
|
||||
}
|
||||
|
||||
syncResult, err := parseAKAResponse(append([]byte{0xDC, 0x0E}, bytes.Repeat([]byte{0xCC}, 14)...))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !syncResult.SynchronizationFailure || len(syncResult.AUTS) != 14 {
|
||||
t.Fatalf("unexpected sync result: %#v", syncResult)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceIDUsesStableUSBPath(t *testing.T) {
|
||||
a := DeviceID(Reader{Name: "reader 00 00", USBPath: "1-3"})
|
||||
b := DeviceID(Reader{Name: "renamed reader", USBPath: "1-3"})
|
||||
if a != b || a == "" {
|
||||
t.Fatalf("device IDs = %q, %q", a, b)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package pcsc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const HardwareKind = "pcsc"
|
||||
|
||||
var (
|
||||
ErrUnsupported = errors.New("pcsc: platform is not supported")
|
||||
ErrUnavailable = errors.New("pcsc: service is unavailable")
|
||||
ErrReaderNotFound = errors.New("pcsc: reader not found")
|
||||
ErrNoCard = errors.New("pcsc: no card is inserted")
|
||||
ErrPINRequired = errors.New("pcsc: SIM PIN is required")
|
||||
ErrPINTriesLow = errors.New("pcsc: refusing PIN verification because too few attempts remain")
|
||||
ErrPINRejected = errors.New("pcsc: SIM PIN was rejected")
|
||||
ErrUSIMUnavailable = errors.New("pcsc: no usable USIM application was found")
|
||||
ErrCardChanged = errors.New("pcsc: card identity changed during authentication")
|
||||
ErrAKARejected = errors.New("pcsc: USIM rejected the network authentication token")
|
||||
)
|
||||
|
||||
type Reader struct {
|
||||
Name string
|
||||
USBPath string
|
||||
VendorID string
|
||||
ProductID string
|
||||
Manufacturer string
|
||||
Product string
|
||||
CardPresent bool
|
||||
ATR string
|
||||
}
|
||||
|
||||
type Selector struct {
|
||||
USBPath string
|
||||
ReaderName string
|
||||
}
|
||||
|
||||
func (selector Selector) validate() error {
|
||||
if strings.TrimSpace(selector.USBPath) == "" && strings.TrimSpace(selector.ReaderName) == "" {
|
||||
return errors.New("pcsc: reader selector is empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Identity struct {
|
||||
ICCID string
|
||||
IMSI string
|
||||
MNCLength int
|
||||
USIMAID []byte
|
||||
SMSC string
|
||||
SPN string
|
||||
PINRequired bool
|
||||
PINTries int
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
Reader Reader
|
||||
Identity Identity
|
||||
}
|
||||
|
||||
type AKAChallenge struct {
|
||||
RAND [16]byte
|
||||
AUTN [16]byte
|
||||
}
|
||||
|
||||
type AKAResult struct {
|
||||
RES []byte
|
||||
CK []byte
|
||||
IK []byte
|
||||
AUTS []byte
|
||||
SynchronizationFailure bool
|
||||
}
|
||||
|
||||
type PINError struct {
|
||||
Kind error
|
||||
Tries int
|
||||
}
|
||||
|
||||
func (err *PINError) Error() string {
|
||||
if err == nil {
|
||||
return "pcsc: SIM PIN error"
|
||||
}
|
||||
if err.Tries >= 0 {
|
||||
return fmt.Sprintf("%v (%d attempts remain)", err.Kind, err.Tries)
|
||||
}
|
||||
return err.Kind.Error()
|
||||
}
|
||||
|
||||
func (err *PINError) Unwrap() error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return err.Kind
|
||||
}
|
||||
|
||||
type Card interface {
|
||||
Transmit(context.Context, []byte) ([]byte, uint16, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
type Backend interface {
|
||||
Readers(context.Context) ([]Reader, error)
|
||||
Open(context.Context, Selector) (Card, error)
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
@@ -57,7 +56,7 @@ func (s *Server) notifyAutomaticTask(ctx context.Context, task store.AutomaticTa
|
||||
}, "\n"),
|
||||
Time: run.FinishedAt, Task: task, Run: run,
|
||||
}
|
||||
for _, channel := range []string{"telegram", "bark", "email", "pushplus", "webhook"} {
|
||||
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
|
||||
@@ -89,6 +88,8 @@ func sendAutomaticTaskNotification(ctx context.Context, channel string, config m
|
||||
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)
|
||||
}
|
||||
@@ -290,12 +291,7 @@ func sendEmailTextNotification(ctx context.Context, config map[string]any, subje
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
email := strings.Join([]string{
|
||||
"Date: " + time.Now().UTC().Format(time.RFC1123Z), "From: " + formatMailAddress(from),
|
||||
"To: " + joinMailAddresses(recipients), "Subject: " + mime.QEncoding.Encode("UTF-8", subject),
|
||||
"MIME-Version: 1.0", "Content-Type: text/plain; charset=UTF-8", "Content-Transfer-Encoding: 8bit", "", text, "",
|
||||
}, "\r\n")
|
||||
if _, err := io.WriteString(writer, email); err != nil {
|
||||
if err := writePlainTextMail(writer, from, recipients, subject, text); err != nil {
|
||||
_ = writer.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -37,6 +37,13 @@ type automaticTaskExecutionError struct {
|
||||
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
|
||||
@@ -50,6 +57,14 @@ func (s *Server) StartAutomaticTasks(ctx context.Context) {
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -117,9 +132,14 @@ func (scheduler *automaticTaskScheduler) execute(run store.AutomaticTaskRun) {
|
||||
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)
|
||||
output, err = scheduler.server.executeAutomaticTask(operationContext, task, progress)
|
||||
cancel()
|
||||
if err == nil {
|
||||
break
|
||||
@@ -129,7 +149,10 @@ func (scheduler *automaticTaskScheduler) execute(run store.AutomaticTaskRun) {
|
||||
break
|
||||
}
|
||||
if attempt <= task.RetryCount {
|
||||
scheduler.server.logger.Warn("automatic task attempt failed", "task_id", task.ID, "device_id", task.DeviceID, "attempt", attempt, "error", err)
|
||||
// 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
|
||||
@@ -151,13 +174,35 @@ func (scheduler *automaticTaskScheduler) execute(run store.AutomaticTaskRun) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) executeAutomaticTask(ctx context.Context, task store.AutomaticTask) (string, error) {
|
||||
config, entry, physicalID, err := s.ensureAutomaticTaskProfile(ctx, task)
|
||||
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
|
||||
}
|
||||
networkWasEnabled := config.NetworkEnabled
|
||||
if err := s.prepareAutomaticTaskEnvironment(ctx, &config, entry, physicalID, task); err != nil {
|
||||
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
|
||||
@@ -166,21 +211,29 @@ func (s *Server) executeAutomaticTask(ctx context.Context, task store.AutomaticT
|
||||
}
|
||||
switch task.TaskType {
|
||||
case "sms":
|
||||
return s.executeAutomaticSMS(ctx, task, payload)
|
||||
progress("正在发送短信")
|
||||
output, err = s.executeAutomaticSMS(ctx, task, payload)
|
||||
case "call":
|
||||
return s.executeAutomaticCall(ctx, task, payload)
|
||||
progress("正在发起通话")
|
||||
output, err = s.executeAutomaticCall(ctx, task, payload)
|
||||
case "public_ip":
|
||||
return s.executeAutomaticPublicIP(ctx, config, physicalID, task.ProfileICCID, networkWasEnabled)
|
||||
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) (store.Device, device.Device, string, error) {
|
||||
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")
|
||||
@@ -188,6 +241,7 @@ func (s *Server) ensureAutomaticTaskProfile(ctx context.Context, task store.Auto
|
||||
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)
|
||||
}
|
||||
@@ -209,9 +263,10 @@ func (s *Server) ensureAutomaticTaskProfile(ctx context.Context, task store.Auto
|
||||
return config, entry, physicalID, nil
|
||||
}
|
||||
|
||||
func (s *Server) prepareAutomaticTaskEnvironment(ctx context.Context, config *store.Device, entry device.Device, physicalID string, task store.AutomaticTask) error {
|
||||
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")
|
||||
}
|
||||
@@ -222,7 +277,19 @@ func (s *Server) prepareAutomaticTaskEnvironment(ctx context.Context, config *st
|
||||
if err := s.store.UpsertDevice(ctx, *config); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.store.UpsertCardPolicy(ctx, store.CardPolicy{ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true, IPVersion: "IPV4V6", Source: "automatic_task"}); err != nil {
|
||||
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 {
|
||||
@@ -253,17 +320,29 @@ func (s *Server) prepareAutomaticTaskEnvironment(ctx context.Context, config *st
|
||||
}
|
||||
}
|
||||
}
|
||||
progress("正在开启蜂窝无线并启用自动选网")
|
||||
config.VoWiFiEnabled = false
|
||||
config.NetworkEnabled = task.TaskType == "public_ip"
|
||||
if err := s.store.UpsertDevice(ctx, *config); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.store.UpsertCardPolicy(ctx, store.CardPolicy{ICCID: iccid, NetworkEnabled: config.NetworkEnabled, VoWiFiEnabled: false, AirplaneEnabled: false, APN: config.APN, IPVersion: "IPV4V6", Source: "automatic_task"}); err != nil {
|
||||
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, device.NetworkRequest{Enabled: false, APN: config.APN, IPVersion: "IPV4V6", Backend: config.DeviceBackend}); err != nil {
|
||||
s.logger.Warn("automatic task could not stop unused cellular data", "device_id", config.ID, "error", err)
|
||||
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 {
|
||||
@@ -275,6 +354,7 @@ func (s *Server) prepareAutomaticTaskEnvironment(ctx context.Context, config *st
|
||||
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
|
||||
}
|
||||
@@ -282,8 +362,8 @@ func (s *Server) prepareAutomaticTaskEnvironment(ctx context.Context, config *st
|
||||
if !s.developerActive(ctx) {
|
||||
return errors.New("roaming public IP tasks require developer mode")
|
||||
}
|
||||
if _, err := s.devices.SetNetwork(ctx, physicalID, device.NetworkRequest{Enabled: true, APN: config.APN, IPVersion: "IPV4V6", Backend: config.DeviceBackend}); err != nil {
|
||||
s.rollbackAutomaticNetwork(config.ID, physicalID, iccid, *config)
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -407,10 +487,7 @@ func (s *Server) executeAutomaticCall(ctx context.Context, task store.AutomaticT
|
||||
return fmt.Sprintf("已拨打 %s,将在 %d 秒后自动挂断", payload.Phone, payload.DurationSeconds), nil
|
||||
}
|
||||
|
||||
func (s *Server) executeAutomaticPublicIP(ctx context.Context, config store.Device, physicalID, iccid string, networkWasEnabled bool) (string, error) {
|
||||
if !networkWasEnabled {
|
||||
defer s.rollbackAutomaticNetwork(config.ID, physicalID, iccid, config)
|
||||
}
|
||||
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")
|
||||
}
|
||||
@@ -422,19 +499,116 @@ func (s *Server) executeAutomaticPublicIP(ctx context.Context, config store.Devi
|
||||
return strings.TrimSpace(fmt.Sprintf("公网 IP %s · %s %s", info.IP, info.CountryCode, info.Region)), nil
|
||||
}
|
||||
|
||||
func (s *Server) rollbackAutomaticNetwork(deviceID, physicalID, iccid string, config store.Device) {
|
||||
cleanupContext, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
func (s *Server) restoreAutomaticTaskEnvironment(physicalID string, snapshot automaticTaskEnvironmentSnapshot) error {
|
||||
cleanupContext, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
if _, err := s.devices.SetNetwork(cleanupContext, physicalID, device.NetworkRequest{Enabled: false, APN: config.APN, IPVersion: "IPV4V6", Backend: config.DeviceBackend}); err != nil {
|
||||
s.logger.Warn("stop one-shot automatic roaming data", "device_id", deviceID, "error", err)
|
||||
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))
|
||||
}
|
||||
config.NetworkEnabled = false
|
||||
if err := s.store.UpsertDevice(cleanupContext, config); err != nil {
|
||||
s.logger.Warn("restore automatic roaming data setting", "device_id", deviceID, "error", err)
|
||||
restoreErrors = append(restoreErrors, fmt.Errorf("persist device policy: %w", err))
|
||||
}
|
||||
if err := s.store.UpsertCardPolicy(cleanupContext, store.CardPolicy{ICCID: iccid, NetworkEnabled: false, VoWiFiEnabled: false, AirplaneEnabled: false, APN: config.APN, IPVersion: "IPV4V6", Source: "automatic_task"}); err != nil {
|
||||
s.logger.Warn("restore automatic roaming card policy", "device_id", deviceID, "error", 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 {
|
||||
@@ -558,6 +732,15 @@ func (s *Server) handleAutomaticTaskRunNow(w http.ResponseWriter, r *http.Reques
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
config, err := s.store.Device(r.Context(), task.DeviceID)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
if err := validateAutomaticTaskDeviceCapabilities(config, task.TaskType, task.Environment); err != nil {
|
||||
writeError(w, http.StatusConflict, "wifi_calling_only_device", err.Error())
|
||||
return
|
||||
}
|
||||
run, err := s.store.QueueAutomaticTaskNow(r.Context(), task)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
@@ -593,7 +776,8 @@ func (s *Server) decodeAutomaticTask(r *http.Request, id int64) (store.Automatic
|
||||
if request.Name == "" || request.DeviceID == "" || request.ProfileICCID == "" {
|
||||
return store.AutomaticTask{}, errors.New("name, device, and eSIM profile are required")
|
||||
}
|
||||
if _, err := s.store.Device(r.Context(), request.DeviceID); err != nil {
|
||||
selectedDevice, err := s.store.Device(r.Context(), request.DeviceID)
|
||||
if err != nil {
|
||||
return store.AutomaticTask{}, errors.New("selected device does not exist")
|
||||
}
|
||||
if request.Environment != "vowifi" && request.Environment != "cellular" {
|
||||
@@ -605,6 +789,9 @@ func (s *Server) decodeAutomaticTask(r *http.Request, id int64) (store.Automatic
|
||||
if request.TaskType == "public_ip" && request.Environment != "cellular" {
|
||||
return store.AutomaticTask{}, errors.New("public IP tasks must use cellular direct mode")
|
||||
}
|
||||
if err := validateAutomaticTaskDeviceCapabilities(selectedDevice, request.TaskType, request.Environment); err != nil {
|
||||
return store.AutomaticTask{}, err
|
||||
}
|
||||
if request.IntervalDays < 1 || request.IntervalDays > 365 || request.RetryCount < 0 || request.RetryCount > 10 {
|
||||
return store.AutomaticTask{}, errors.New("interval_days must be 1-365 and retry_count must be 0-10")
|
||||
}
|
||||
@@ -644,6 +831,19 @@ func (s *Server) decodeAutomaticTask(r *http.Request, id int64) (store.Automatic
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func validateAutomaticTaskDeviceCapabilities(config store.Device, taskType, environment string) error {
|
||||
if config.DeviceType != store.DeviceTypeUSBSIMReader {
|
||||
return nil
|
||||
}
|
||||
if environment != "vowifi" {
|
||||
return errors.New("USB SIM reader tasks must use the VoWiFi environment")
|
||||
}
|
||||
if taskType != "sms" && taskType != "call" {
|
||||
return errors.New("USB SIM readers support only VoWiFi SMS and call tasks")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func nextAutomaticRun(date, clock string, intervalDays int, now time.Time) (time.Time, error) {
|
||||
location := now.Location()
|
||||
start, err := time.ParseInLocation("2006-01-02 15:04", strings.TrimSpace(date)+" "+strings.TrimSpace(clock), location)
|
||||
|
||||
@@ -3,6 +3,8 @@ package server
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
func TestNextAutomaticRunUsesIntervalAndLocalClock(t *testing.T) {
|
||||
@@ -18,6 +20,25 @@ func TestNextAutomaticRunUsesIntervalAndLocalClock(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUSBSIMReaderAutomaticTasksRequireVoWiFi(t *testing.T) {
|
||||
reader := store.Device{DeviceType: store.DeviceTypeUSBSIMReader}
|
||||
for _, test := range []struct {
|
||||
taskType string
|
||||
environment string
|
||||
wantError bool
|
||||
}{
|
||||
{taskType: "sms", environment: "vowifi"},
|
||||
{taskType: "call", environment: "vowifi"},
|
||||
{taskType: "sms", environment: "cellular", wantError: true},
|
||||
{taskType: "public_ip", environment: "cellular", wantError: true},
|
||||
} {
|
||||
err := validateAutomaticTaskDeviceCapabilities(reader, test.taskType, test.environment)
|
||||
if (err != nil) != test.wantError {
|
||||
t.Errorf("type=%s environment=%s error=%v", test.taskType, test.environment, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomaticSMSRetrySafetyPreventsDuplicateSubmission(t *testing.T) {
|
||||
unsafe := []byte(`{"data":{"parts_attempted":1,"parts_accepted":1,"retry_safe":false}}`)
|
||||
if automaticSMSRetrySafe(unsafe) {
|
||||
|
||||
+198
-20
@@ -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,
|
||||
@@ -245,6 +248,12 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) bool {
|
||||
config.NetworkEnabled = false
|
||||
}
|
||||
fillConfigFromPhysical(&config, *selected)
|
||||
if pinSetter, ok := s.devices.(interface{ SetSIMPin(string, string) error }); ok {
|
||||
if err := pinSetter.SetSIMPin(selected.ID, config.SIMPIN); err != nil {
|
||||
s.writeDeviceError(w, err)
|
||||
return true
|
||||
}
|
||||
}
|
||||
if selector, ok := s.devices.(interface{ SetBackend(string, string) error }); ok {
|
||||
if err := selector.SetBackend(selected.ID, config.DeviceBackend); err != nil {
|
||||
s.writeDeviceError(w, err)
|
||||
@@ -262,11 +271,15 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) bool {
|
||||
if selected.Snapshot != nil {
|
||||
iccid := strings.TrimSpace(selected.Snapshot.ICCID)
|
||||
if iccid != "" {
|
||||
if err := s.store.UpsertCardPolicy(r.Context(), store.CardPolicy{
|
||||
ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true,
|
||||
IPVersion: "IPV4V6", Source: "default",
|
||||
}); err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
_, 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
|
||||
}
|
||||
}
|
||||
@@ -358,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,
|
||||
@@ -369,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}})
|
||||
@@ -454,7 +469,16 @@ func (s *Server) handleDevicePath(
|
||||
if next.Name == id && strings.TrimSpace(payload.Name) == "" {
|
||||
next.Name = config.Name
|
||||
}
|
||||
if next.SIMPIN == "" || next.SIMPIN == store.SecretMask {
|
||||
next.SIMPIN = config.SIMPIN
|
||||
}
|
||||
if _, physicalID, present := s.physicalForConfig(next); present {
|
||||
if pinSetter, ok := s.devices.(interface{ SetSIMPin(string, string) error }); ok {
|
||||
if err := pinSetter.SetSIMPin(physicalID, next.SIMPIN); err != nil {
|
||||
s.writeDeviceError(w, err)
|
||||
return true
|
||||
}
|
||||
}
|
||||
if selector, ok := s.devices.(interface{ SetBackend(string, string) error }); ok {
|
||||
if err := selector.SetBackend(physicalID, next.DeviceBackend); err != nil {
|
||||
s.writeDeviceError(w, err)
|
||||
@@ -477,6 +501,16 @@ func (s *Server) handleDevicePath(
|
||||
}
|
||||
|
||||
entry, physicalID, physicalPresent := s.physicalForConfig(config)
|
||||
if config.DeviceType == store.DeviceTypeUSBSIMReader && len(tail) > 0 {
|
||||
operation := strings.Join(tail, "/")
|
||||
unsupported := tail[0] == "network" || tail[0] == "operator_selection" ||
|
||||
operation == "actions/at" || operation == "actions/ussd" || operation == "actions/ussd/continue" ||
|
||||
operation == "actions/ussd/cancel" || operation == "actions/reboot" || operation == "usbnet-mode"
|
||||
if unsupported {
|
||||
writeError(w, http.StatusConflict, "wifi_calling_only_device", "USB SIM readers support WiFi Calling, IMS SMS and calls only")
|
||||
return true
|
||||
}
|
||||
}
|
||||
if len(tail) > 0 && tail[0] == "esim" {
|
||||
return s.handleESIM(w, r, tail[1:], physicalID, physicalPresent, config.ID)
|
||||
}
|
||||
@@ -552,6 +586,11 @@ func (s *Server) handleDevicePath(
|
||||
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
|
||||
@@ -1127,6 +1166,65 @@ func (s *Server) handleFlightMode(w http.ResponseWriter, r *http.Request, config
|
||||
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,
|
||||
@@ -1165,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", Backend: config.DeviceBackend,
|
||||
})
|
||||
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", Backend: config.DeviceBackend,
|
||||
})
|
||||
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,
|
||||
@@ -1253,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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1689,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,
|
||||
@@ -1699,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,
|
||||
@@ -1718,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
|
||||
}
|
||||
@@ -1800,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 := ""
|
||||
@@ -1856,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,24 @@ 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
|
||||
@@ -286,6 +304,13 @@ func TestHandleESIMShapes(t *testing.T) {
|
||||
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()
|
||||
@@ -301,6 +326,14 @@ func TestHandleESIMShapes(t *testing.T) {
|
||||
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()
|
||||
@@ -340,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(),
|
||||
|
||||
@@ -115,3 +115,24 @@ func TestConfiguredDeviceSummaryMarksIdleRuntimeAsNotInUse(t *testing.T) {
|
||||
t.Fatalf("summary = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotHasSIMDoesNotTreatUnknownStatusAsInserted(t *testing.T) {
|
||||
for _, snapshot := range []*device.Snapshot{
|
||||
{IMEI: "867123456789012"},
|
||||
{IMEI: "867123456789012", SIMStatus: "unknown"},
|
||||
{IMEI: "867123456789012", SIMStatus: "not_inserted"},
|
||||
} {
|
||||
if snapshotHasSIM(snapshot) {
|
||||
t.Fatalf("snapshot was reported with a SIM: %#v", snapshot)
|
||||
}
|
||||
}
|
||||
for _, snapshot := range []*device.Snapshot{
|
||||
{SIMStatus: "pin_required"},
|
||||
{ICCID: "89441000400128014257"},
|
||||
{SIMReady: true},
|
||||
} {
|
||||
if !snapshotHasSIM(snapshot) {
|
||||
t.Fatalf("snapshot was reported without a SIM: %#v", snapshot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+100
-15
@@ -1,9 +1,11 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -15,6 +17,11 @@ 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, configuredIDs ...string) bool {
|
||||
configuredID := physicalID
|
||||
@@ -53,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":
|
||||
@@ -90,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).
|
||||
@@ -366,37 +420,68 @@ func (s *Server) handleEsimSwitch(w http.ResponseWriter, r *http.Request, config
|
||||
s.writeDeviceError(w, err)
|
||||
return
|
||||
}
|
||||
if err := s.store.UpsertCardPolicy(r.Context(), store.CardPolicy{
|
||||
ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true,
|
||||
IPVersion: "IPV4V6", Source: "default",
|
||||
}); err != nil {
|
||||
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 = true
|
||||
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)
|
||||
switch {
|
||||
case stateErr == nil && state.Enabled:
|
||||
_, err = s.vowifi.RequestReconnect(configuredID)
|
||||
default:
|
||||
_, err = s.vowifi.RequestEnabled(configuredID, true)
|
||||
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 in safe airplane mode but VoWiFi start was not queued", "device_id", configuredID, "iccid", iccid, "error", err)
|
||||
s.logger.Warn("profile switched but saved VoWiFi state was not queued", "device_id", configuredID, "iccid", iccid, "enabled", policy.VoWiFiEnabled, "error", err)
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"status": "switched", "iccid": iccid, "verified": true}})
|
||||
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) {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"vocat/internal/exportproxy"
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) routeExportProxyAPI(w http.ResponseWriter, r *http.Request, cleanPath string) bool {
|
||||
@@ -33,6 +35,9 @@ func (s *Server) routeExportProxyAPI(w http.ResponseWriter, r *http.Request, cle
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return true
|
||||
}
|
||||
if s.rejectUnsupportedExportProxyDevice(w, r.Context(), config.DeviceID) {
|
||||
return true
|
||||
}
|
||||
created, err := s.exportProxy.Create(r.Context(), config)
|
||||
if err != nil {
|
||||
s.writeExportProxyError(w, err)
|
||||
@@ -71,6 +76,9 @@ func (s *Server) routeExportProxyAPI(w http.ResponseWriter, r *http.Request, cle
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return true
|
||||
}
|
||||
if s.rejectUnsupportedExportProxyDevice(w, r.Context(), config.DeviceID) {
|
||||
return true
|
||||
}
|
||||
updated, err := s.exportProxy.Update(r.Context(), id, config)
|
||||
if err != nil {
|
||||
s.writeExportProxyError(w, err)
|
||||
@@ -90,6 +98,19 @@ func (s *Server) routeExportProxyAPI(w http.ResponseWriter, r *http.Request, cle
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) rejectUnsupportedExportProxyDevice(w http.ResponseWriter, ctx context.Context, deviceID string) bool {
|
||||
config, err := s.store.Device(ctx, strings.TrimSpace(deviceID))
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return true
|
||||
}
|
||||
if config.DeviceType == store.DeviceTypeUSBSIMReader {
|
||||
writeError(w, http.StatusConflict, "wifi_calling_only_device", "USB SIM readers cannot export cellular data as a proxy")
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Server) writeExportProxyError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, exportproxy.ErrDisabled):
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
func TestExportProxyRejectsUSBSIMReader(t *testing.T) {
|
||||
database, err := store.Open(context.Background(), ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
if err := database.UpsertDevice(context.Background(), store.Device{
|
||||
ID: "reader-1", Name: "USB SIM Reader", DeviceType: store.DeviceTypeUSBSIMReader,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := &Server{store: database}
|
||||
response := httptest.NewRecorder()
|
||||
if !server.rejectUnsupportedExportProxyDevice(response, context.Background(), "reader-1") {
|
||||
t.Fatal("reader was accepted as an export-proxy device")
|
||||
}
|
||||
if response.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -93,3 +93,28 @@ func TestProfileProxyBindingRejectsSameICCIDOnDifferentProxy(t *testing.T) {
|
||||
t.Fatalf("binding after rejected rebind = %+v, %v", binding, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileProxyBindingSupportsPCSCReader(t *testing.T) {
|
||||
server, database, controller := newProfileBindingTestServer(t)
|
||||
readerICCID := "89104100000028106378"
|
||||
if err := database.UpsertDevice(context.Background(), store.Device{
|
||||
ID: "reader-1", Name: "USB SIM Reader", DeviceType: store.DeviceTypeUSBSIMReader,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
controller.state = vowifi.State{DeviceID: "reader-1", ICCID: readerICCID, Enabled: true}
|
||||
response := profileBindingRequest(t, server, http.MethodPost, `{
|
||||
"upstream_proxy_id":"route-1",
|
||||
"bindings":[{"device_id":"reader-1","iccid":"89104100000028106378","profile_name":"Reader Profile"}]
|
||||
}`)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("POST status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
binding, err := database.DeviceProxyBinding(context.Background(), readerICCID)
|
||||
if err != nil || binding.DeviceID != "reader-1" || binding.UpstreamProxyID != "route-1" {
|
||||
t.Fatalf("reader binding = %+v, %v", binding, err)
|
||||
}
|
||||
if controller.reconnects != 1 {
|
||||
t.Fatalf("reader reconnects = %d, want 1", controller.reconnects)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -562,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()
|
||||
|
||||
+410
-74
@@ -11,7 +11,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
@@ -25,6 +24,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"vocat/internal/device"
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
@@ -40,6 +40,7 @@ var notificationChannels = []string{
|
||||
"webhook",
|
||||
"bark",
|
||||
"pushplus",
|
||||
"wecom",
|
||||
}
|
||||
|
||||
var notificationFields = map[string]map[string]string{
|
||||
@@ -61,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
|
||||
@@ -100,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
|
||||
}
|
||||
|
||||
@@ -283,6 +295,11 @@ func validateNotificationField(
|
||||
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 {
|
||||
@@ -316,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)
|
||||
}
|
||||
@@ -367,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,
|
||||
@@ -418,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)
|
||||
@@ -495,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
|
||||
}
|
||||
@@ -523,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":
|
||||
@@ -541,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 {
|
||||
@@ -788,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: " + formatMailAddress(from),
|
||||
"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)
|
||||
}
|
||||
@@ -812,14 +860,6 @@ 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, formatMailAddress(value))
|
||||
}
|
||||
return strings.Join(result, ", ")
|
||||
}
|
||||
|
||||
func parseMailAddress(value string) (*mail.Address, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || strings.ContainsAny(value, "\r\n\x00") {
|
||||
@@ -841,7 +881,7 @@ func formatMailAddress(address *mail.Address) string {
|
||||
if address.Name == "" {
|
||||
return address.Address
|
||||
}
|
||||
return mime.QEncoding.Encode("UTF-8", address.Name) + " <" + address.Address + ">"
|
||||
return (&mail.Address{Name: address.Name, Address: address.Address}).String()
|
||||
}
|
||||
|
||||
func restrictedHTTPClient(
|
||||
@@ -1217,13 +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,
|
||||
VoWiFiEnabled: true,
|
||||
AirplaneEnabled: true,
|
||||
IPVersion: "IPV4V6",
|
||||
Source: "default",
|
||||
}
|
||||
policy = defaultCardPolicy(iccid)
|
||||
} else if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
@@ -1238,65 +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.CustomPhoneNumber != nil {
|
||||
phoneNumber, phoneErr := normalizeCustomPhoneNumber(*request.CustomPhoneNumber)
|
||||
if phoneErr != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_card_policy", phoneErr.Error())
|
||||
return
|
||||
}
|
||||
policy.CustomPhoneNumber = phoneNumber
|
||||
}
|
||||
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 *request.VoWiFiEnabled {
|
||||
*request.AirplaneEnabled = true
|
||||
if policy.VoWiFiEnabled {
|
||||
policy.AirplaneEnabled = true
|
||||
policy.NetworkEnabled = false
|
||||
}
|
||||
policy := store.CardPolicy{
|
||||
ICCID: iccid,
|
||||
NetworkEnabled: false,
|
||||
VoWiFiEnabled: *request.VoWiFiEnabled,
|
||||
AirplaneEnabled: *request.AirplaneEnabled,
|
||||
APN: request.APN,
|
||||
IPVersion: request.IPVersion,
|
||||
Source: "manual",
|
||||
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
|
||||
@@ -1308,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
|
||||
@@ -1320,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 {
|
||||
@@ -425,10 +523,35 @@ func TestCardPolicyDefaultValidationAndPersistence(t *testing.T) {
|
||||
policy := response["data"].(map[string]any)
|
||||
if policy["iccid"] != iccid || policy["source"] != "default" ||
|
||||
policy["ip_version"] != "IPV4V6" || policy["vowifi_enabled"] != true ||
|
||||
policy["airplane_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,
|
||||
@@ -455,10 +578,116 @@ func TestCardPolicyDefaultValidationAndPersistence(t *testing.T) {
|
||||
t.Fatalf("saved policy = %#v", policy)
|
||||
}
|
||||
stored, err := test.database.CardPolicy(context.Background(), iccid)
|
||||
if err != nil || !stored.VoWiFiEnabled || !stored.AirplaneEnabled || 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)
|
||||
|
||||
@@ -546,6 +546,13 @@ func (s *Server) syncModemSMS(ctx context.Context, onlyDevice string) {
|
||||
if onlyDevice != "" && config.ID != onlyDevice {
|
||||
continue
|
||||
}
|
||||
// A PC/SC USB reader has no modem storage or AT command channel. Its
|
||||
// messages are delivered by the active VoWiFi IMS session, so attempting
|
||||
// an AT+CMGL catch-up scan would only poison the reader's health state
|
||||
// with ErrNoATPort.
|
||||
if !supportsModemSMSStorage(config) {
|
||||
continue
|
||||
}
|
||||
// Do not queue CMGL traffic on the same serial actor while VoWiFi is
|
||||
// reading the SIM or running AKA. Once the session is stable, resume the
|
||||
// SM/ME scan as a catch-up path: an SMS submitted while the card was
|
||||
@@ -659,6 +666,10 @@ func (s *Server) syncModemSMS(ctx context.Context, onlyDevice string) {
|
||||
}
|
||||
}
|
||||
|
||||
func supportsModemSMSStorage(config store.Device) bool {
|
||||
return store.NormalizeDeviceType(config.DeviceType) != store.DeviceTypeUSBSIMReader
|
||||
}
|
||||
|
||||
func shouldDeferModemSMSSync(state vowifi.State, stateErr error) bool {
|
||||
if stateErr != nil || !state.Enabled {
|
||||
return false
|
||||
|
||||
@@ -108,6 +108,15 @@ func TestNormalizeSMSDeviceFilter(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportsModemSMSStorageRejectsUSBReader(t *testing.T) {
|
||||
if supportsModemSMSStorage(store.Device{DeviceType: store.DeviceTypeUSBSIMReader}) {
|
||||
t.Fatal("USB SIM reader must not be polled with modem SMS AT commands")
|
||||
}
|
||||
if !supportsModemSMSStorage(store.Device{DeviceType: store.DeviceTypePCIeEC20EC25}) {
|
||||
t.Fatal("cellular modem should retain modem SMS storage synchronization")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMSSendOutcome(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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: " + formatMailAddress(from),
|
||||
"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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,6 +179,43 @@ func (s *Store) UpdateAutomaticTaskRun(ctx context.Context, run AutomaticTaskRun
|
||||
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
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -118,3 +119,64 @@ func TestListAutomaticTaskRunsPaginated(t *testing.T) {
|
||||
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,
|
||||
|
||||
@@ -58,7 +58,7 @@ 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
|
||||
@@ -366,6 +366,31 @@ func TestDeviceStateRoundTripAndCascade(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUSBSIMReaderConfigurationIsWiFiCallingOnly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database := openTestStore(t, ":memory:")
|
||||
err := database.UpsertDevice(ctx, Device{
|
||||
ID: "reader-1", Name: "USB SIM", DeviceType: DeviceTypeUSBSIMReader,
|
||||
USBPath: "1-3", ControlDevice: "Reader 00 00", SIMPIN: "1234",
|
||||
DeviceBackend: "at", ESIMTransport: "at", NetworkEnabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := database.Device(ctx, "reader-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.DeviceBackend != "pcsc" || got.ESIMTransport != "pcsc" || got.NetworkEnabled || !got.SMSEnabled || !got.VoWiFiEnabled || got.SIMPIN != "1234" {
|
||||
t.Fatalf("reader config = %+v", got)
|
||||
}
|
||||
bad := got
|
||||
bad.SIMPIN = "12x4"
|
||||
if err := database.UpsertDevice(ctx, bad); err == nil {
|
||||
t.Fatal("non-numeric SIM PIN was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMSPersistenceAndDerivedThreads(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database := openTestStore(t, ":memory:")
|
||||
@@ -737,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:")
|
||||
@@ -784,7 +846,7 @@ 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)
|
||||
}
|
||||
@@ -794,7 +856,7 @@ func TestEventsPoliciesAndTraffic(t *testing.T) {
|
||||
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")
|
||||
|
||||
@@ -227,6 +227,43 @@ func migrationStatements(version int) []string {
|
||||
`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
|
||||
}
|
||||
|
||||
+75
-21
@@ -24,6 +24,7 @@ type Device struct {
|
||||
USBPath string
|
||||
AudioDevice string
|
||||
ModemIMEI string
|
||||
SIMPIN string
|
||||
APN string
|
||||
ProxyPort int
|
||||
BaudRate int
|
||||
@@ -339,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 {
|
||||
@@ -477,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 {
|
||||
@@ -559,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)
|
||||
@@ -585,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
|
||||
|
||||
+164
-5
@@ -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")
|
||||
}
|
||||
@@ -381,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)
|
||||
@@ -440,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) {
|
||||
@@ -449,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
|
||||
@@ -465,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)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const schemaVersion = 12
|
||||
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
|
||||
}
|
||||
|
||||
@@ -184,6 +184,10 @@ func applyUpdate(ctx context.Context, logger *slog.Logger, opts Options, release
|
||||
cleanup()
|
||||
return fmt.Errorf("update: chmod temp binary: %w", err)
|
||||
}
|
||||
if err := validateExecutable(ctx, tmpPath); err != nil {
|
||||
cleanup()
|
||||
return err
|
||||
}
|
||||
if err := backupAndReplace(opts.Target, tmpPath); err != nil {
|
||||
cleanup()
|
||||
return err
|
||||
@@ -202,10 +206,25 @@ func applyUpdate(ctx context.Context, logger *slog.Logger, opts Options, release
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateExecutable catches incompatible architectures and missing dynamic
|
||||
// loaders before the working installation is touched. A valid checksum alone
|
||||
// cannot detect those packaging errors.
|
||||
func validateExecutable(ctx context.Context, path string) error {
|
||||
checkCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
output, err := exec.CommandContext(checkCtx, path, "version").CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("update: downloaded binary cannot run on this host: %w (%s)", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(string(output)), "vocat") {
|
||||
return fmt.Errorf("update: downloaded binary returned an unexpected version response: %q", strings.TrimSpace(string(output)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// backupAndReplace renames the current binary aside, then moves the verified
|
||||
// temp file into place. Both renames are atomic on the same filesystem. On
|
||||
// Linux the kernel holds the running binary's inode, so replacing it mid-flight
|
||||
// is safe.
|
||||
// temp file into place. Both renames are atomic on the same filesystem. The
|
||||
// previous working binary is retained for service-level or manual rollback.
|
||||
func backupAndReplace(target, tmp string) error {
|
||||
backup := target + ".previous"
|
||||
if _, err := os.Stat(target); err == nil {
|
||||
@@ -221,16 +240,21 @@ func backupAndReplace(target, tmp string) error {
|
||||
}
|
||||
return fmt.Errorf("update: move new binary into place: %w", err)
|
||||
}
|
||||
_ = os.Remove(backup)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RestartService restarts the vocat systemd unit. If systemctl is unavailable
|
||||
// (non-systemd hosts, containers), it returns an error the caller surfaces as
|
||||
// a non-fatal warning.
|
||||
// RestartService supports both systemd hosts and OpenWrt/procd routers.
|
||||
func RestartService(logger *slog.Logger) error {
|
||||
if _, err := os.Stat("/etc/init.d/vocat"); err == nil {
|
||||
cmd := exec.Command("/etc/init.d/vocat", "restart")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
logger.Warn("OpenWrt service restart failed", "error", err, "output", string(out))
|
||||
return fmt.Errorf("restart OpenWrt vocat service: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if _, err := exec.LookPath("systemctl"); err != nil {
|
||||
return fmt.Errorf("systemctl not found in PATH")
|
||||
return fmt.Errorf("neither /etc/init.d/vocat nor systemctl is available")
|
||||
}
|
||||
// Queue the restart and let systemctl exit before systemd stops this unit.
|
||||
// A blocking restart command becomes part of vocat.service's own cgroup and
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateExecutableRejectsNonExecutableFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "not-vocat")
|
||||
if err := os.WriteFile(path, []byte("not an executable"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateExecutable(context.Background(), path); err == nil {
|
||||
t.Fatal("validateExecutable accepted invalid file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupAndReplaceRetainsPreviousBinary(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Linux replacement behavior")
|
||||
}
|
||||
directory := t.TempDir()
|
||||
target := filepath.Join(directory, "vocat")
|
||||
replacement := filepath.Join(directory, "replacement")
|
||||
if err := os.WriteFile(target, []byte("old"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(replacement, []byte("new"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := backupAndReplace(target, replacement); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old, err := os.ReadFile(target + ".previous")
|
||||
if err != nil {
|
||||
t.Fatalf("read retained backup: %v", err)
|
||||
}
|
||||
if string(old) != "old" {
|
||||
t.Fatalf("backup = %q", old)
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,14 @@ func runIPCommand(ctx context.Context, command string, operation xfrmOperation)
|
||||
if message == "" {
|
||||
message = err.Error()
|
||||
}
|
||||
if strings.Contains(strings.ToLower(message), "protocol not supported") ||
|
||||
strings.Contains(strings.ToLower(message), "operation not supported") {
|
||||
return fmt.Errorf(
|
||||
"%s: host kernel lacks XFRM/IPsec support; install matching kmod-ipsec and kmod-ipsec4/6 (OpenWrt), or enable CONFIG_XFRM_USER and ESP in the kernel: %s",
|
||||
operation.description,
|
||||
message,
|
||||
)
|
||||
}
|
||||
// Operation descriptions contain no SPI keys or subscriber identity.
|
||||
return fmt.Errorf("%s: %s", operation.description, message)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,19 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRunIPCommandExplainsMissingKernelXFRM(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
command := directory + "/ip"
|
||||
script := "#!/bin/sh\necho 'Cannot open netlink socket: Protocol not supported' >&2\nexit 1\n"
|
||||
if err := os.WriteFile(command, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := runIPCommand(context.Background(), command, xfrmOperation{description: "test state"})
|
||||
if err == nil || !strings.Contains(err.Error(), "kmod-ipsec") || !strings.Contains(err.Error(), "CONFIG_XFRM_USER") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinuxIPSecInstallerLifecycle(t *testing.T) {
|
||||
if os.Getenv("VOCAT_NETNS_TEST") != "1" {
|
||||
t.Skip("set VOCAT_NETNS_TEST=1 inside an isolated Linux network namespace")
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package vowifi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"vocat/internal/pcsc"
|
||||
)
|
||||
|
||||
type PCSCBindingResolver func(context.Context, string) (pcsc.Selector, string, error)
|
||||
|
||||
// PCSCAdapter uses a directly attached USB smart-card reader as the UICC for
|
||||
// Wi-Fi Calling. It deliberately exposes no cellular-radio behaviour.
|
||||
type PCSCAdapter struct {
|
||||
service *pcsc.Service
|
||||
resolve PCSCBindingResolver
|
||||
mu sync.RWMutex
|
||||
bindings map[string]string
|
||||
}
|
||||
|
||||
var (
|
||||
_ SIMIdentityReader = (*PCSCAdapter)(nil)
|
||||
_ SMSCenterReader = (*PCSCAdapter)(nil)
|
||||
_ AKAProvider = (*PCSCAdapter)(nil)
|
||||
_ RadioController = (*PCSCAdapter)(nil)
|
||||
)
|
||||
|
||||
func NewPCSCAdapter(service *pcsc.Service, resolver PCSCBindingResolver) (*PCSCAdapter, error) {
|
||||
if service == nil || resolver == nil {
|
||||
return nil, errors.New("vocat: PC/SC service and reader resolver are required")
|
||||
}
|
||||
return &PCSCAdapter{service: service, resolve: resolver, bindings: make(map[string]string)}, nil
|
||||
}
|
||||
|
||||
func (adapter *PCSCAdapter) ReadIdentity(ctx context.Context, deviceID string) (SIMIdentity, error) {
|
||||
selector, pin, err := adapter.resolve(ctx, strings.TrimSpace(deviceID))
|
||||
if err != nil {
|
||||
return SIMIdentity{}, err
|
||||
}
|
||||
identity, err := adapter.service.ReadIdentity(ctx, selector, pin)
|
||||
if err != nil {
|
||||
return SIMIdentity{}, fmt.Errorf("read USB SIM identity: %w", err)
|
||||
}
|
||||
if len(identity.IMSI) < 5 {
|
||||
return SIMIdentity{}, errors.New("vocat: USB SIM reader returned an invalid IMSI")
|
||||
}
|
||||
adapter.mu.Lock()
|
||||
adapter.bindings[identity.ICCID] = strings.TrimSpace(deviceID)
|
||||
adapter.mu.Unlock()
|
||||
mncLength := identity.MNCLength
|
||||
if mncLength != 2 && mncLength != 3 {
|
||||
if mcc, mnc, ok := assignedHomePLMN(identity.IMSI); ok {
|
||||
return SIMIdentity{ICCID: identity.ICCID, IMSI: identity.IMSI, HomeMCC: mcc, HomeMNC: mnc, SMSC: identity.SMSC}, nil
|
||||
}
|
||||
return SIMIdentity{}, ErrEC20MNCUnavailable
|
||||
}
|
||||
if len(identity.IMSI) < 3+mncLength {
|
||||
return SIMIdentity{}, errors.New("vocat: USB SIM IMSI is shorter than its EF_AD home PLMN")
|
||||
}
|
||||
return SIMIdentity{
|
||||
ICCID: identity.ICCID, IMSI: identity.IMSI,
|
||||
HomeMCC: identity.IMSI[:3], HomeMNC: identity.IMSI[3 : 3+mncLength],
|
||||
SMSC: identity.SMSC,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (adapter *PCSCAdapter) ReadSMSCenter(ctx context.Context, deviceID string) (string, error) {
|
||||
selector, pin, err := adapter.resolve(ctx, strings.TrimSpace(deviceID))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
identity, err := adapter.service.ReadIdentity(ctx, selector, pin)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(identity.SMSC) == "" {
|
||||
return "", errors.New("vocat: USB SIM does not expose a service-centre address")
|
||||
}
|
||||
return identity.SMSC, nil
|
||||
}
|
||||
|
||||
func (adapter *PCSCAdapter) CheckReady(ctx context.Context, identity SIMIdentity) (AKAEvidence, error) {
|
||||
selector, pin, err := adapter.resolve(ctx, adapter.deviceID(identity))
|
||||
if err != nil {
|
||||
return AKAEvidence{}, err
|
||||
}
|
||||
aid, err := adapter.service.CheckReady(ctx, selector, identity.ICCID, pin)
|
||||
if err != nil {
|
||||
return AKAEvidence{}, fmt.Errorf("check USB SIM AKA application: %w", err)
|
||||
}
|
||||
return AKAEvidence{Ready: true, Application: aid}, nil
|
||||
}
|
||||
|
||||
func (adapter *PCSCAdapter) deviceID(identity SIMIdentity) string {
|
||||
adapter.mu.RLock()
|
||||
deviceID := adapter.bindings[identity.ICCID]
|
||||
adapter.mu.RUnlock()
|
||||
return deviceID
|
||||
}
|
||||
|
||||
func (adapter *PCSCAdapter) Authenticate(ctx context.Context, identity SIMIdentity, challenge AKAChallenge) (AKAResult, error) {
|
||||
selector, pin, err := adapter.resolve(ctx, adapter.deviceID(identity))
|
||||
if err != nil {
|
||||
return AKAResult{}, err
|
||||
}
|
||||
result, err := adapter.service.Authenticate(ctx, selector, identity.ICCID, pin, pcsc.AKAChallenge(challenge))
|
||||
if err != nil {
|
||||
if errors.Is(err, pcsc.ErrAKARejected) {
|
||||
return AKAResult{}, errors.Join(ErrEC20AKAMACFailure, err)
|
||||
}
|
||||
return AKAResult{}, fmt.Errorf("authenticate with USB SIM: %w", err)
|
||||
}
|
||||
return AKAResult(result), nil
|
||||
}
|
||||
|
||||
func (*PCSCAdapter) Snapshot(context.Context, string) (RadioSnapshot, error) {
|
||||
return RadioSnapshot{OperatingMode: 4, PureAirplanePolicy: true}, nil
|
||||
}
|
||||
func (*PCSCAdapter) StopCellularData(context.Context, string) error { return nil }
|
||||
func (*PCSCAdapter) EnterVoWiFiRFOff(context.Context, string) error { return nil }
|
||||
func (*PCSCAdapter) Restore(context.Context, string, RadioSnapshot) error { return nil }
|
||||
+197
-12
@@ -1,12 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# vocat install / update script for binary + systemd deployments.
|
||||
# vocat install / update script for systemd and OpenWrt/procd deployments.
|
||||
#
|
||||
# Usage:
|
||||
# sudo bash install.sh [version] # install a specific version
|
||||
# sudo bash install.sh # install latest release
|
||||
# sudo bash install.sh --force # reinstall even at the same version
|
||||
# curl -fsSL <raw url> | sudo bash # one-liner (latest)
|
||||
# bash install.sh [version] # run directly when already root
|
||||
# sudo bash install.sh [version] # run through sudo as a normal user
|
||||
# bash install.sh --check-env # check VoWiFi host prerequisites
|
||||
#
|
||||
# Behavior:
|
||||
# - Prompts for script language (中文 / English) as soon as it runs.
|
||||
@@ -14,7 +13,9 @@
|
||||
# - On first install, generates a random 32-char admin password, writes it to
|
||||
# /etc/vocat/env (0600, loaded by the systemd unit), and prints it ONCE.
|
||||
# - On update, preserves the existing env file and credentials.
|
||||
# - (Re)writes the systemd unit and restarts the service.
|
||||
# - Verifies Linux XFRM/IPsec support required by IMS; on OpenWrt it tries
|
||||
# the matching opkg packages first.
|
||||
# - (Re)writes a systemd or OpenWrt/procd service and restarts it.
|
||||
#
|
||||
# Published script: must contain no secrets, IPs, or passwords.
|
||||
|
||||
@@ -31,6 +32,7 @@ LINK_PATH="/usr/local/bin/vocat"
|
||||
ENV_DIR="/etc/vocat"
|
||||
ENV_FILE="${ENV_DIR}/env"
|
||||
UNIT_PATH="/etc/systemd/system/vocat.service"
|
||||
OPENWRT_INIT_PATH="/etc/init.d/vocat"
|
||||
|
||||
# --- Language ----------------------------------------------------------------
|
||||
LANG_CHOICE=""
|
||||
@@ -76,14 +78,44 @@ die() {
|
||||
|
||||
prompt_language
|
||||
|
||||
# BusyBox/OpenWrt images often omit coreutils' install(1). Provide the small
|
||||
# subset used by this script so the same installer works on router firmware.
|
||||
if ! command -v install >/dev/null 2>&1; then
|
||||
install() {
|
||||
if [ "${1:-}" = "-d" ]; then
|
||||
shift
|
||||
local mode="0755"
|
||||
if [ "${1:-}" = "-m" ]; then
|
||||
mode="$2"
|
||||
shift 2
|
||||
fi
|
||||
mkdir -p "$@"
|
||||
chmod "$mode" "$@"
|
||||
return
|
||||
fi
|
||||
local mode="0755"
|
||||
if [ "${1:-}" = "-m" ]; then
|
||||
mode="$2"
|
||||
shift 2
|
||||
fi
|
||||
[ "$#" -eq 2 ] || return 2
|
||||
cp "$1" "$2"
|
||||
chmod "$mode" "$2"
|
||||
}
|
||||
fi
|
||||
|
||||
# --- Parse args --------------------------------------------------------------
|
||||
FORCE=0
|
||||
CHECK_ENV=0
|
||||
SKIP_VOWIFI_CHECK="${VOCAT_SKIP_VOWIFI_CHECK:-0}"
|
||||
TARGET_VERSION=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--force) FORCE=1 ;;
|
||||
--check-env) CHECK_ENV=1 ;;
|
||||
--skip-vowifi-check) SKIP_VOWIFI_CHECK=1 ;;
|
||||
-h|--help)
|
||||
msg "用法: sudo bash install.sh [--force] [版本]" "Usage: sudo bash install.sh [--force] [version]"
|
||||
msg "用法: bash install.sh [--force] [--check-env] [--skip-vowifi-check] [版本]" "Usage: bash install.sh [--force] [--check-env] [--skip-vowifi-check] [version]"
|
||||
exit 0
|
||||
;;
|
||||
*) TARGET_VERSION="${arg#v}" ;;
|
||||
@@ -110,6 +142,89 @@ resolve_target_version() {
|
||||
TARGET_VERSION="${tag#v}"
|
||||
}
|
||||
|
||||
# --- Host prerequisites ------------------------------------------------------
|
||||
is_openwrt() {
|
||||
[ -f /etc/openwrt_release ] || [ -x /sbin/procd ]
|
||||
}
|
||||
|
||||
xfrm_works() {
|
||||
command -v ip >/dev/null 2>&1 && ip xfrm state list >/dev/null 2>&1
|
||||
}
|
||||
|
||||
opkg_has_package() {
|
||||
opkg list "$1" 2>/dev/null | grep -q "^$1 -"
|
||||
}
|
||||
|
||||
install_openwrt_vowifi_packages() {
|
||||
msg "正在检查 OpenWrt/Kwrt 的 VoWiFi 内核组件..." "Checking OpenWrt/Kwrt VoWiFi kernel components..."
|
||||
opkg update >/dev/null 2>&1 || msg \
|
||||
"警告:opkg 软件源更新失败,将使用现有索引继续检查。" \
|
||||
"Warning: opkg feed update failed; checking the existing index."
|
||||
|
||||
local packages=""
|
||||
local package
|
||||
for package in \
|
||||
ip-full \
|
||||
kmod-ipsec kmod-ipsec4 kmod-ipsec6 \
|
||||
kmod-crypto-authenc kmod-crypto-cbc kmod-crypto-aes \
|
||||
kmod-crypto-hmac kmod-crypto-sha1; do
|
||||
if opkg_has_package "$package"; then
|
||||
packages="$packages $package"
|
||||
fi
|
||||
done
|
||||
if [ -n "$packages" ]; then
|
||||
# Kernel packages must come from this firmware's own feed. opkg checks
|
||||
# the kernel ABI and refuses mismatched modules; never bypass that check.
|
||||
# shellcheck disable=SC2086
|
||||
opkg install $packages >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
install_linux_ip_tool() {
|
||||
command -v ip >/dev/null 2>&1 && return 0
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
apt-get update -qq && apt-get install -y iproute2
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
dnf install -y iproute
|
||||
elif command -v yum >/dev/null 2>&1; then
|
||||
yum install -y iproute
|
||||
elif command -v pacman >/dev/null 2>&1; then
|
||||
pacman -Sy --noconfirm iproute2
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
apk add --no-cache iproute2
|
||||
fi
|
||||
}
|
||||
|
||||
check_vowifi_environment() {
|
||||
if [ "$SKIP_VOWIFI_CHECK" = "1" ]; then
|
||||
msg \
|
||||
"已跳过 VoWiFi 内核环境检查;IMS 通话和短信可能不可用。" \
|
||||
"Skipped the VoWiFi kernel check; IMS calls and SMS may not work."
|
||||
return
|
||||
fi
|
||||
|
||||
if is_openwrt && command -v opkg >/dev/null 2>&1; then
|
||||
# Install the crypto algorithms even when NETLINK_XFRM already works;
|
||||
# some minimal images provide xfrm_user but omit AES-CBC/authenc.
|
||||
install_openwrt_vowifi_packages
|
||||
elif ! xfrm_works; then
|
||||
install_linux_ip_tool
|
||||
fi
|
||||
if xfrm_works; then
|
||||
msg "VoWiFi XFRM/IPsec 环境安装并验证成功。" "VoWiFi XFRM/IPsec environment installed and verified."
|
||||
return
|
||||
fi
|
||||
|
||||
if is_openwrt; then
|
||||
die \
|
||||
"当前 OpenWrt/Kwrt 内核 $(uname -r) 不支持 NETLINK_XFRM,且软件源没有匹配的 kmod-ipsec。请使用包含 kmod-ipsec、kmod-ipsec4、kmod-ipsec6、kmod-crypto-authenc、kmod-crypto-cbc、kmod-crypto-aes 和 kmod-crypto-sha1 的同版本固件;严禁安装其他内核版本的 kmod。仅使用非 VoWiFi 功能时可加 --skip-vowifi-check。" \
|
||||
"The OpenWrt/Kwrt kernel $(uname -r) lacks NETLINK_XFRM and its feed has no matching kmod-ipsec. Use a firmware built with matching kmod-ipsec, kmod-ipsec4/6, crypto-authenc, CBC, AES and SHA1 modules. Never force kmods from another kernel. Use --skip-vowifi-check only for non-VoWiFi operation."
|
||||
fi
|
||||
die \
|
||||
"当前 Linux 内核不支持 XFRM/IPsec,VoWiFi IMS 无法工作。请启用 CONFIG_XFRM、CONFIG_XFRM_USER、CONFIG_INET_ESP、CONFIG_INET6_ESP、AES-CBC 和 HMAC-SHA1。" \
|
||||
"This Linux kernel lacks XFRM/IPsec required by VoWiFi IMS. Enable CONFIG_XFRM, CONFIG_XFRM_USER, CONFIG_INET_ESP, CONFIG_INET6_ESP, AES-CBC and HMAC-SHA1."
|
||||
}
|
||||
|
||||
# --- Skip if already installed at the same version ---------------------------
|
||||
skip_if_equal() {
|
||||
[ -x "$BINARY_PATH" ] || return 0
|
||||
@@ -159,6 +274,10 @@ download_and_verify() {
|
||||
[ -n "$expected" ] || die "SHA256SUMS 中找不到 $asset 的校验行。" "$asset not found in SHA256SUMS."
|
||||
actual=$(sha256sum "${VOCAT_TMP}/vocat" | awk '{print $1}')
|
||||
[ "$actual" = "$expected" ] || die "SHA-256 校验失败。" "SHA-256 verification failed."
|
||||
chmod 0755 "${VOCAT_TMP}/vocat"
|
||||
"${VOCAT_TMP}/vocat" version >/dev/null 2>&1 || die \
|
||||
"Downloaded binary cannot run on this system; keeping the installed version." \
|
||||
"The downloaded binary cannot run on this host; the installed version was not changed."
|
||||
}
|
||||
|
||||
# --- Install binary ----------------------------------------------------------
|
||||
@@ -210,7 +329,6 @@ User=root
|
||||
Group=root
|
||||
WorkingDirectory=/opt/vocat
|
||||
EnvironmentFile=${ENV_FILE}
|
||||
Environment=VOCAT_ADDR=0.0.0.0:7575
|
||||
Environment=VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db
|
||||
ExecStart=${BINARY_PATH}
|
||||
Restart=on-failure
|
||||
@@ -247,11 +365,73 @@ EOF
|
||||
chmod 0644 "$UNIT_PATH"
|
||||
}
|
||||
|
||||
write_openwrt_init() {
|
||||
cat > "$OPENWRT_INIT_PATH" <<'EOF'
|
||||
#!/bin/sh /etc/rc.common
|
||||
START=95
|
||||
STOP=10
|
||||
USE_PROCD=1
|
||||
PROCD_TERM_TIMEOUT=40
|
||||
PROGRAM=/opt/vocat/bin/vocat
|
||||
ENV_FILE=/etc/vocat/env
|
||||
start_service() {
|
||||
procd_open_instance
|
||||
procd_set_param command "$PROGRAM" serve
|
||||
procd_set_param env VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db
|
||||
if [ -r "$ENV_FILE" ]; then
|
||||
while IFS='=' read -r name value; do
|
||||
case "$name" in VOCAT_*) procd_append_param env "$name=$value" ;; esac
|
||||
done < "$ENV_FILE"
|
||||
fi
|
||||
procd_set_param respawn 3600 5 5
|
||||
procd_set_param stdout 1
|
||||
procd_set_param stderr 1
|
||||
procd_close_instance
|
||||
}
|
||||
service_triggers() { procd_add_reload_trigger vocat; }
|
||||
EOF
|
||||
chmod 0755 "$OPENWRT_INIT_PATH"
|
||||
}
|
||||
|
||||
write_service() {
|
||||
if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then
|
||||
write_unit
|
||||
return
|
||||
fi
|
||||
if [ -x /sbin/procd ] || [ -x /sbin/ubusd ]; then
|
||||
write_openwrt_init
|
||||
return
|
||||
fi
|
||||
die "Unsupported service manager." "Neither systemd nor OpenWrt procd was detected."
|
||||
}
|
||||
|
||||
enable_and_start() {
|
||||
if [ -x "$OPENWRT_INIT_PATH" ] && { [ -x /sbin/procd ] || [ -x /sbin/ubusd ]; }; then
|
||||
"$OPENWRT_INIT_PATH" enable
|
||||
if "$OPENWRT_INIT_PATH" restart; then
|
||||
sleep 2
|
||||
if "$OPENWRT_INIT_PATH" running; then
|
||||
rm -f "${BINARY_PATH}.bak"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
if [ -e "${BINARY_PATH}.bak" ]; then
|
||||
cp -a "${BINARY_PATH}.bak" "$BINARY_PATH"
|
||||
"$OPENWRT_INIT_PATH" restart || true
|
||||
fi
|
||||
die "OpenWrt vocat service failed to start." "The OpenWrt vocat service failed to start."
|
||||
fi
|
||||
systemctl daemon-reload
|
||||
systemctl enable vocat
|
||||
if systemctl restart vocat; then
|
||||
return
|
||||
local attempt
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if systemctl is-active --quiet vocat; then
|
||||
rm -f "${BINARY_PATH}.bak"
|
||||
return
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
fi
|
||||
if [ -e "${BINARY_PATH}.bak" ]; then
|
||||
msg "新版本启动失败,正在恢复旧二进制。" "The new version failed to start; restoring the previous binary."
|
||||
@@ -262,14 +442,19 @@ enable_and_start() {
|
||||
}
|
||||
|
||||
# --- Main --------------------------------------------------------------------
|
||||
resolve_target_version
|
||||
detect_arch
|
||||
check_vowifi_environment
|
||||
if [ "$CHECK_ENV" -eq 1 ]; then
|
||||
msg "VoCat 运行环境检查完成。" "VoCat host environment check completed."
|
||||
exit 0
|
||||
fi
|
||||
resolve_target_version
|
||||
skip_if_equal
|
||||
download_and_verify
|
||||
install_binary
|
||||
ensure_data_dir
|
||||
setup_env
|
||||
write_unit
|
||||
write_service
|
||||
enable_and_start
|
||||
|
||||
if [ "$FIRST_INSTALL" -eq 1 ]; then
|
||||
@@ -282,7 +467,7 @@ if [ "$FIRST_INSTALL" -eq 1 ]; then
|
||||
echo
|
||||
msg "用户名为 admin。请立即记录此密码。" "Username is admin. Record this password now."
|
||||
msg "登录后或运行以下命令修改密码:" "Change it via the web UI or run:"
|
||||
echo " sudo vocat menu"
|
||||
echo " vocat menu"
|
||||
msg "==========================================" "=============================================="
|
||||
else
|
||||
echo
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/bin/sh /etc/rc.common
|
||||
|
||||
START=95
|
||||
STOP=10
|
||||
USE_PROCD=1
|
||||
PROCD_TERM_TIMEOUT=40
|
||||
|
||||
PROGRAM=/opt/vocat/bin/vocat
|
||||
ENV_FILE=/etc/vocat/env
|
||||
|
||||
start_service() {
|
||||
procd_open_instance
|
||||
procd_set_param command "$PROGRAM" serve
|
||||
procd_set_param env VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db
|
||||
if [ -r "$ENV_FILE" ]; then
|
||||
while IFS='=' read -r name value; do
|
||||
case "$name" in
|
||||
VOCAT_*) procd_append_param env "$name=$value" ;;
|
||||
esac
|
||||
done < "$ENV_FILE"
|
||||
fi
|
||||
procd_set_param respawn 3600 5 5
|
||||
procd_set_param stdout 1
|
||||
procd_set_param stderr 1
|
||||
procd_close_instance
|
||||
}
|
||||
|
||||
service_triggers() {
|
||||
procd_add_reload_trigger vocat
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="USB SIM reader">
|
||||
<defs><linearGradient id="a" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#64748b"/><stop offset="1" stop-color="#1e293b"/></linearGradient></defs>
|
||||
<rect x="12" y="31" width="104" height="68" rx="14" fill="url(#a)"/>
|
||||
<rect x="25" y="43" width="55" height="42" rx="7" fill="#0f172a" stroke="#94a3b8" stroke-width="2"/>
|
||||
<path d="M40 51h24l8 8v18H40z" fill="#facc15"/><path d="M44 60h24M50 54v23M60 54v23" stroke="#a16207" stroke-width="2"/>
|
||||
<rect x="91" y="51" width="25" height="29" rx="4" fill="#cbd5e1"/>
|
||||
<path d="M98 58h11M98 65h11M98 72h11" stroke="#64748b" stroke-width="3"/>
|
||||
<circle cx="101" cy="89" r="3" fill="#22c55e"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 752 B |
@@ -0,0 +1,334 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { apiMessage } from "../../api";
|
||||
import type { CardPolicy } from "../../types";
|
||||
import { Button, Input, Modal, Select, Spinner, Switch, Tag, confirmDialog, message } from "../ui";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
import {
|
||||
createCardAPN,
|
||||
deleteCardAPN,
|
||||
getCardAPNs,
|
||||
getDeviceAPNs,
|
||||
updateCardAPN,
|
||||
updateCardPolicy,
|
||||
type CardAPNProfile,
|
||||
type ModemAPNProfile,
|
||||
} from "./deviceActions";
|
||||
|
||||
interface CardPolicyAPNProps {
|
||||
deviceId: string;
|
||||
iccid: string;
|
||||
policy: CardPolicy | null;
|
||||
deviceOnline: boolean;
|
||||
onSaved: (policy: CardPolicy) => void;
|
||||
}
|
||||
|
||||
interface APNRow {
|
||||
key: string;
|
||||
apn: string;
|
||||
ipVersion: "IP" | "IPV6" | "IPV4V6";
|
||||
source: "automatic" | "modem" | "custom";
|
||||
cid?: number;
|
||||
customID?: number;
|
||||
username?: string;
|
||||
hasPassword?: boolean;
|
||||
proxy?: string;
|
||||
mcc?: string;
|
||||
mnc?: string;
|
||||
roamingIPVersion?: "IP" | "IPV6" | "IPV4V6";
|
||||
authType?: "NONE" | "PAP" | "CHAP" | "PAP_OR_CHAP";
|
||||
}
|
||||
|
||||
const APN_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,98}[A-Za-z0-9])?$/;
|
||||
|
||||
export function CardPolicyAPN({ deviceId, iccid, policy, deviceOnline, onSaved }: CardPolicyAPNProps) {
|
||||
const { t } = useI18n();
|
||||
const [modemProfiles, setModemProfiles] = useState<ModemAPNProfile[]>([]);
|
||||
const [customProfiles, setCustomProfiles] = useState<CardAPNProfile[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editingProfile, setEditingProfile] = useState<CardAPNProfile | null>(null);
|
||||
const [newAPN, setNewAPN] = useState("");
|
||||
const [newUsername, setNewUsername] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [newProxy, setNewProxy] = useState("");
|
||||
const [newMCC, setNewMCC] = useState("");
|
||||
const [newMNC, setNewMNC] = useState("");
|
||||
const [newIPVersion, setNewIPVersion] = useState<"IP" | "IPV6" | "IPV4V6">("IPV4V6");
|
||||
const [newRoamingIPVersion, setNewRoamingIPVersion] = useState<"IP" | "IPV6" | "IPV4V6">("IP");
|
||||
const [newAuthType, setNewAuthType] = useState<"NONE" | "PAP" | "CHAP" | "PAP_OR_CHAP">("NONE");
|
||||
const [clearPassword, setClearPassword] = useState(false);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [pendingKey, setPendingKey] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const [customResult, modemResult] = await Promise.allSettled([
|
||||
getCardAPNs(iccid),
|
||||
deviceOnline ? getDeviceAPNs(deviceId) : Promise.resolve({ items: [] as ModemAPNProfile[] }),
|
||||
]);
|
||||
setCustomProfiles(customResult.status === "fulfilled" ? customResult.value.items || [] : []);
|
||||
setModemProfiles(modemResult.status === "fulfilled" ? modemResult.value.items || [] : []);
|
||||
setLoading(false);
|
||||
}, [deviceId, deviceOnline, iccid]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const rows = useMemo<APNRow[]>(() => {
|
||||
const result: APNRow[] = [
|
||||
{ key: "automatic", apn: "", ipVersion: "IPV4V6", source: "automatic" },
|
||||
];
|
||||
for (const item of modemProfiles) {
|
||||
result.push({
|
||||
key: `modem:${item.cid}:${item.apn}:${item.ipVersion}`,
|
||||
apn: item.apn,
|
||||
ipVersion: item.ipVersion,
|
||||
source: "modem",
|
||||
cid: item.cid,
|
||||
});
|
||||
}
|
||||
for (const item of customProfiles) {
|
||||
result.push({
|
||||
key: `custom:${item.id}`,
|
||||
apn: item.apn,
|
||||
ipVersion: item.ipVersion,
|
||||
source: "custom",
|
||||
customID: item.id,
|
||||
username: item.username,
|
||||
hasPassword: item.hasPassword,
|
||||
proxy: item.proxy,
|
||||
mcc: item.mcc,
|
||||
mnc: item.mnc,
|
||||
roamingIPVersion: item.roamingIpVersion,
|
||||
authType: item.authType,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}, [customProfiles, modemProfiles]);
|
||||
|
||||
function isActive(row: APNRow) {
|
||||
const activeAPN = policy?.apn || "";
|
||||
const activeIP = policy?.ipVersion || "IPV4V6";
|
||||
if (row.source === "automatic") return activeAPN === "";
|
||||
if (row.apn !== activeAPN || row.ipVersion !== activeIP) return false;
|
||||
const activeCustom = customProfiles.some((item) => item.apn === activeAPN && item.ipVersion === activeIP);
|
||||
return row.source === "custom" || !activeCustom;
|
||||
}
|
||||
|
||||
async function enable(row: APNRow) {
|
||||
setPendingKey(row.key);
|
||||
try {
|
||||
const saved = await updateCardPolicy(iccid, { apn: row.apn, ipVersion: row.ipVersion });
|
||||
onSaved(saved);
|
||||
message.success(row.source === "automatic" ? t("已使用运营商自动 APN 配置") : t("APN 已启用"));
|
||||
} catch (error) {
|
||||
message.error(apiMessage(error) || t("启用 APN 失败"));
|
||||
} finally {
|
||||
setPendingKey("");
|
||||
}
|
||||
}
|
||||
|
||||
function openEditor(profile?: CardAPNProfile) {
|
||||
setEditingProfile(profile || null);
|
||||
setNewAPN(profile?.apn || "");
|
||||
setNewUsername(profile?.username || "");
|
||||
setNewPassword("");
|
||||
setNewProxy(profile?.proxy || "");
|
||||
setNewMCC(profile?.mcc || "");
|
||||
setNewMNC(profile?.mnc || "");
|
||||
setNewIPVersion(profile?.ipVersion || "IPV4V6");
|
||||
setNewRoamingIPVersion(profile?.roamingIpVersion || "IP");
|
||||
setNewAuthType(profile?.authType || "NONE");
|
||||
setClearPassword(false);
|
||||
setEditorOpen(true);
|
||||
}
|
||||
|
||||
async function saveEditor() {
|
||||
const cleanAPN = newAPN.trim();
|
||||
if (!cleanAPN || !APN_PATTERN.test(cleanAPN)) {
|
||||
message.warning(t("APN 只能包含字母、数字、点、下划线或连字符,且最长 100 个字符"));
|
||||
return;
|
||||
}
|
||||
if (newMCC && !/^\d{3}$/.test(newMCC)) {
|
||||
message.warning(t("MCC 必须是 3 位数字"));
|
||||
return;
|
||||
}
|
||||
if (newMNC && !/^\d{2,3}$/.test(newMNC)) {
|
||||
message.warning(t("MNC 必须是 2 或 3 位数字"));
|
||||
return;
|
||||
}
|
||||
setAdding(true);
|
||||
try {
|
||||
const payload = {
|
||||
apn: cleanAPN,
|
||||
username: newUsername.trim(),
|
||||
proxy: newProxy.trim(),
|
||||
mcc: newMCC.trim(),
|
||||
mnc: newMNC.trim(),
|
||||
ipVersion: newIPVersion,
|
||||
roamingIpVersion: newRoamingIPVersion,
|
||||
authType: newAuthType,
|
||||
};
|
||||
if (editingProfile) {
|
||||
await updateCardAPN(iccid, editingProfile.id, {
|
||||
...payload,
|
||||
...(newPassword ? { password: newPassword } : {}),
|
||||
clearPassword,
|
||||
});
|
||||
} else {
|
||||
await createCardAPN(iccid, { ...payload, password: newPassword });
|
||||
}
|
||||
setEditorOpen(false);
|
||||
await load();
|
||||
message.success(editingProfile ? t("自定义 APN 已修改") : t("自定义 APN 已添加,请点击启用后使用"));
|
||||
} catch (error) {
|
||||
message.error(apiMessage(error) || (editingProfile ? t("修改 APN 失败") : t("添加 APN 失败")));
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(row: APNRow) {
|
||||
if (!row.customID) return;
|
||||
const confirmed = await confirmDialog(
|
||||
t("确定删除这个自定义 APN 配置吗?"),
|
||||
t("删除 APN"),
|
||||
{ type: "danger", confirmVariant: "danger", confirmText: t("删除") },
|
||||
);
|
||||
if (!confirmed) return;
|
||||
setPendingKey(row.key);
|
||||
try {
|
||||
const wasActive = isActive(row);
|
||||
await deleteCardAPN(iccid, row.customID);
|
||||
if (wasActive && policy) {
|
||||
onSaved({ ...policy, apn: "", ipVersion: "IPV4V6" });
|
||||
}
|
||||
await load();
|
||||
message.success(wasActive ? t("APN 已删除,并恢复运营商自动配置") : t("自定义 APN 已删除"));
|
||||
} catch (error) {
|
||||
message.error(apiMessage(error) || t("删除 APN 失败"));
|
||||
} finally {
|
||||
setPendingKey("");
|
||||
}
|
||||
}
|
||||
|
||||
function edit(row: APNRow) {
|
||||
const profile = customProfiles.find((item) => item.id === row.customID);
|
||||
if (profile) openEditor(profile);
|
||||
}
|
||||
|
||||
const sourceLabel = (row: APNRow) => {
|
||||
if (row.source === "automatic") return t("默认");
|
||||
if (row.source === "modem") return t("模组已有");
|
||||
return t("自定义");
|
||||
};
|
||||
const protocolLabel = (value?: "IP" | "IPV6" | "IPV4V6") => value === "IP" ? "IPv4" : value === "IPV6" ? "IPv6" : value === "IPV4V6" ? "IPv4 / IPv6" : "—";
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200/70 bg-white/60 p-3 dark:border-white/10 dark:bg-black/10">
|
||||
<div className="mb-3 flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-gray-800 dark:text-gray-100">{t("蜂窝 APN")}</div>
|
||||
<div className="mt-0.5 text-[11px] text-gray-500 dark:text-gray-400">
|
||||
{t("APN 列表和启用状态跟随当前 ICCID/Profile 保存")}
|
||||
</div>
|
||||
</div>
|
||||
<Button size="small" variant="primary" plain onClick={() => openEditor()}>
|
||||
{t("新增 APN")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-6 text-xs text-gray-400">
|
||||
<Spinner className="h-4 w-4 animate-spin" /> {t("正在读取 APN 列表...")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-gray-200/70 dark:border-white/10">
|
||||
<table className="w-full min-w-[1050px] text-left text-xs">
|
||||
<thead className="bg-gray-50 text-gray-500 dark:bg-white/5 dark:text-gray-400">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-semibold">APN</th>
|
||||
<th className="px-3 py-2 font-semibold">{t("账号 / 认证")}</th>
|
||||
<th className="px-3 py-2 font-semibold">MCC / MNC</th>
|
||||
<th className="px-3 py-2 font-semibold">{t("协议 / 漫游")}</th>
|
||||
<th className="px-3 py-2 font-semibold">Proxy</th>
|
||||
<th className="px-3 py-2 font-semibold">{t("来源")}</th>
|
||||
<th className="px-3 py-2 font-semibold">{t("状态")}</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">{t("操作")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-white/10">
|
||||
{rows.map((row) => {
|
||||
const active = isActive(row);
|
||||
return (
|
||||
<tr key={row.key} className={active ? "bg-sky-50/60 dark:bg-sky-500/5" : "bg-white/40 dark:bg-transparent"}>
|
||||
<td className="px-3 py-2.5 font-mono text-gray-800 dark:text-gray-100">
|
||||
{row.source === "automatic" ? t("运营商自动配置") : row.apn}
|
||||
{row.cid ? <span className="ml-2 text-[10px] text-gray-400">CID {row.cid}</span> : null}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-gray-600 dark:text-gray-300">
|
||||
<div>{row.username || "—"}{row.hasPassword ? <span className="ml-1 text-[10px] text-gray-400">{t("已设密码")}</span> : null}</div>
|
||||
{row.authType && row.authType !== "NONE" ? <div className="mt-0.5 text-[10px] text-gray-400">{row.authType.replace("PAP_OR_CHAP", "PAP / CHAP")}</div> : null}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-gray-600 dark:text-gray-300">{row.mcc || "—"} / {row.mnc || "—"}</td>
|
||||
<td className="px-3 py-2.5 text-gray-600 dark:text-gray-300"><div>{protocolLabel(row.ipVersion)}</div><div className="mt-0.5 text-[10px] text-gray-400">{t("漫游")}:{protocolLabel(row.roamingIPVersion)}</div></td>
|
||||
<td className="max-w-[150px] truncate px-3 py-2.5 text-gray-600 dark:text-gray-300">{row.proxy || "—"}</td>
|
||||
<td className="px-3 py-2.5"><Tag type={row.source === "custom" ? "primary" : "info"}>{sourceLabel(row)}</Tag></td>
|
||||
<td className="px-3 py-2.5">{active ? <Tag type="success">{t("使用中")}</Tag> : <span className="text-gray-400">—</span>}</td>
|
||||
<td className="whitespace-nowrap px-3 py-2.5 text-right">
|
||||
<Button size="small" variant={active ? "default" : "primary"} plain={!active} disabled={active} loading={pendingKey === row.key} onClick={() => enable(row)}>
|
||||
{active ? t("已启用") : t("启用")}
|
||||
</Button>
|
||||
{row.source === "custom" ? (
|
||||
<>
|
||||
<Button className="ml-1" size="small" onClick={() => edit(row)}>{t("修改")}</Button>
|
||||
<Button className="ml-1" size="small" variant="danger" plain loading={pendingKey === row.key} onClick={() => remove(row)}>{t("删除")}</Button>
|
||||
</>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{!deviceOnline ? <div className="mt-2 text-[11px] text-amber-600 dark:text-amber-400">{t("设备离线:自定义列表仍可管理,模组已有 APN 将在上线后读取")}</div> : null}
|
||||
<Modal
|
||||
open={editorOpen}
|
||||
onClose={() => setEditorOpen(false)}
|
||||
title={editingProfile ? t("修改 APN 配置") : t("新增 APN 配置")}
|
||||
width="max-w-4xl"
|
||||
closeOnOverlay={!adding}
|
||||
footer={
|
||||
<>
|
||||
<Button disabled={adding} onClick={() => setEditorOpen(false)}>{t("取消")}</Button>
|
||||
<Button variant="primary" loading={adding} onClick={saveEditor}>{editingProfile ? t("保存修改") : t("添加到列表")}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<label className="space-y-1"><span className="text-xs font-semibold text-gray-600 dark:text-gray-300">APN *</span><Input value={newAPN} maxLength={100} onChange={(event) => setNewAPN(event.target.value)} placeholder="giffgaff.com" /></label>
|
||||
<label className="space-y-1"><span className="text-xs font-semibold text-gray-600 dark:text-gray-300">{t("用户名")}</span><Input value={newUsername} maxLength={128} onChange={(event) => setNewUsername(event.target.value)} placeholder="gg" /></label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs font-semibold text-gray-600 dark:text-gray-300">{t("密码")}</span>
|
||||
<Input type="password" value={newPassword} disabled={clearPassword} maxLength={128} onChange={(event) => setNewPassword(event.target.value)} placeholder={editingProfile?.hasPassword ? t("留空表示保持原密码") : "p"} />
|
||||
</label>
|
||||
<label className="space-y-1"><span className="text-xs font-semibold text-gray-600 dark:text-gray-300">Proxy</span><Input value={newProxy} maxLength={255} onChange={(event) => setNewProxy(event.target.value)} placeholder={t("留空")} /></label>
|
||||
<label className="space-y-1"><span className="text-xs font-semibold text-gray-600 dark:text-gray-300">MCC</span><Input inputMode="numeric" value={newMCC} maxLength={3} onChange={(event) => setNewMCC(event.target.value.replace(/\D/g, ""))} placeholder="234" /></label>
|
||||
<label className="space-y-1"><span className="text-xs font-semibold text-gray-600 dark:text-gray-300">MNC</span><Input inputMode="numeric" value={newMNC} maxLength={3} onChange={(event) => setNewMNC(event.target.value.replace(/\D/g, ""))} placeholder="10" /></label>
|
||||
<label className="space-y-1"><span className="text-xs font-semibold text-gray-600 dark:text-gray-300">{t("APN 协议")}</span><Select value={newIPVersion} onChange={(value) => setNewIPVersion(value as "IP" | "IPV6" | "IPV4V6")} options={[{ value: "IPV4V6", label: "IPv4 / IPv6" }, { value: "IP", label: "IPv4" }, { value: "IPV6", label: "IPv6" }]} /></label>
|
||||
<label className="space-y-1"><span className="text-xs font-semibold text-gray-600 dark:text-gray-300">{t("APN 漫游协议")}</span><Select value={newRoamingIPVersion} onChange={(value) => setNewRoamingIPVersion(value as "IP" | "IPV6" | "IPV4V6")} options={[{ value: "IP", label: "IPv4" }, { value: "IPV4V6", label: "IPv4 / IPv6" }, { value: "IPV6", label: "IPv6" }]} /></label>
|
||||
<label className="space-y-1"><span className="text-xs font-semibold text-gray-600 dark:text-gray-300">{t("认证类型")}</span><Select value={newAuthType} onChange={(value) => setNewAuthType(value as "NONE" | "PAP" | "CHAP" | "PAP_OR_CHAP")} options={[{ value: "NONE", label: t("无") }, { value: "PAP", label: "PAP" }, { value: "CHAP", label: "CHAP" }, { value: "PAP_OR_CHAP", label: "PAP / CHAP" }]} /></label>
|
||||
</div>
|
||||
{editingProfile?.hasPassword ? (
|
||||
<div className="mt-4 flex items-center justify-between rounded-lg bg-gray-50 px-3 py-2 dark:bg-white/5">
|
||||
<div><div className="text-xs font-semibold text-gray-700 dark:text-gray-200">{t("清除已保存密码")}</div><div className="text-[11px] text-gray-400">{t("关闭时,密码输入框留空会保持原密码")}</div></div>
|
||||
<Switch checked={clearPassword} onChange={(value) => { setClearPassword(value); if (value) setNewPassword(""); }} />
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,31 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { CardUiRegular } from "@fluentui/react-icons";
|
||||
import { Tag } from "../ui";
|
||||
import { Button, Input, Tag, message } from "../ui";
|
||||
import { PolicySwitchCard } from "./PolicySwitchCard";
|
||||
import { CardPolicyAPN } from "./CardPolicyAPN";
|
||||
import { useCardPolicyToggles } from "./useCardPolicyToggles";
|
||||
import { enableVoWiFi, disableVoWiFi, setFlightMode } from "./deviceActions";
|
||||
import { enableVoWiFi, disableVoWiFi, setFlightMode, updateCardPolicy } from "./deviceActions";
|
||||
import type { CardPolicy } from "../../types";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
import { apiMessage } from "../../api";
|
||||
|
||||
export interface CardPolicyPanelProps {
|
||||
deviceId: string;
|
||||
iccid?: string;
|
||||
policy: CardPolicy | null;
|
||||
deviceOnline: boolean;
|
||||
onPolicyChanged: () => void;
|
||||
onPolicyChanged: () => void | Promise<void>;
|
||||
wifiCallingOnly?: boolean;
|
||||
}
|
||||
|
||||
export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolicyChanged }: CardPolicyPanelProps) {
|
||||
export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolicyChanged, wifiCallingOnly = false }: CardPolicyPanelProps) {
|
||||
const { t } = useI18n();
|
||||
const operable = deviceOnline && !!iccid;
|
||||
const flags = policy
|
||||
? { vowifiEnabled: policy.vowifiEnabled, airplaneEnabled: policy.airplaneEnabled }
|
||||
const currentPolicy = policy?.iccid === iccid ? policy : null;
|
||||
const [customPhoneNumber, setCustomPhoneNumber] = useState(currentPolicy?.customPhoneNumber || "");
|
||||
const [phoneSaving, setPhoneSaving] = useState(false);
|
||||
const flags = currentPolicy
|
||||
? { vowifiEnabled: currentPolicy.vowifiEnabled, airplaneEnabled: currentPolicy.airplaneEnabled }
|
||||
: null;
|
||||
|
||||
const toggles = useCardPolicyToggles(flags, {
|
||||
@@ -27,8 +34,30 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
|
||||
onChanged: onPolicyChanged,
|
||||
});
|
||||
|
||||
const sourceLabel = policy ? (policy.source === "user" ? t("手动设置") : t("自动默认")) : "";
|
||||
const isManual = currentPolicy?.source === "user" || currentPolicy?.source === "manual";
|
||||
const sourceLabel = currentPolicy ? (isManual ? t("手动设置") : t("自动默认")) : "";
|
||||
const { local } = toggles;
|
||||
const savedPhoneNumber = currentPolicy?.customPhoneNumber || "";
|
||||
const phoneChanged = customPhoneNumber.trim() !== savedPhoneNumber;
|
||||
|
||||
useEffect(() => {
|
||||
setCustomPhoneNumber(currentPolicy?.customPhoneNumber || "");
|
||||
}, [iccid, currentPolicy?.customPhoneNumber]);
|
||||
|
||||
const saveCustomPhoneNumber = async () => {
|
||||
if (!iccid || phoneSaving || !phoneChanged) return;
|
||||
setPhoneSaving(true);
|
||||
try {
|
||||
const saved = await updateCardPolicy(iccid, { customPhoneNumber: customPhoneNumber.trim() });
|
||||
setCustomPhoneNumber(saved.customPhoneNumber || "");
|
||||
message.success(saved.customPhoneNumber ? t("自定义手机号已保存") : t("已恢复显示系统读取的号码"));
|
||||
await onPolicyChanged();
|
||||
} catch (error) {
|
||||
message.error(apiMessage(error) || t("保存自定义手机号失败"));
|
||||
} finally {
|
||||
setPhoneSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -38,7 +67,7 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-gray-900 dark:text-white">{t("卡策略")}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">{t("VoWiFi / 飞行模式 开关跟着 SIM 卡走,切换即时生效")}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">{wifiCallingOnly ? t("USB SIM 读卡器仅用于 WiFi Calling,策略跟随 ICCID 保存") : t("VoWiFi / 飞行模式 开关跟着 SIM 卡走,切换即时生效")}</div>
|
||||
</div>
|
||||
</div>
|
||||
{!iccid ? (
|
||||
@@ -51,15 +80,47 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
|
||||
) : null}
|
||||
{iccid ? (
|
||||
<div className="space-y-3">
|
||||
<div className="ui-panel-muted flex items-center justify-between p-3">
|
||||
<div>
|
||||
<div className="mb-0.5 text-xs font-bold uppercase tracking-wider text-gray-500">{t("当前卡 ICCID")}</div>
|
||||
<div className="font-mono text-sm text-gray-800 dark:text-gray-100">{iccid}</div>
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
<div className="ui-panel-muted flex min-w-0 items-center justify-between gap-3 p-3">
|
||||
<div className="min-w-0">
|
||||
<div className="mb-0.5 text-xs font-bold uppercase tracking-wider text-gray-500">{t("当前卡 ICCID")}</div>
|
||||
<div className="truncate font-mono text-sm text-gray-800 dark:text-gray-100" title={iccid}>{iccid}</div>
|
||||
</div>
|
||||
{sourceLabel ? <Tag type={isManual ? "primary" : "info"}>{sourceLabel}</Tag> : null}
|
||||
</div>
|
||||
<div className="ui-panel-muted p-3">
|
||||
<div className="mb-1.5 text-xs font-bold uppercase tracking-wider text-gray-500">{t("自定义手机号")}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={customPhoneNumber}
|
||||
onChange={(event) => setCustomPhoneNumber(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void saveCustomPhoneNumber();
|
||||
}}
|
||||
placeholder={t("请输入手机号(可留空)")}
|
||||
inputMode="tel"
|
||||
maxLength={32}
|
||||
disabled={phoneSaving}
|
||||
aria-label={t("自定义手机号")}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
className="shrink-0 !border-0"
|
||||
loading={phoneSaving}
|
||||
disabled={!phoneChanged}
|
||||
onClick={() => void saveCustomPhoneNumber()}
|
||||
>
|
||||
{t("保存")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-1.5 text-[11px] leading-4 text-gray-500 dark:text-gray-400">
|
||||
{t("支持开头的 + 和 3-20 位数字;留空时显示系统从 SIM/网络读取的号码")}
|
||||
</div>
|
||||
</div>
|
||||
{sourceLabel ? <Tag type={policy?.source === "user" ? "primary" : "info"}>{sourceLabel}</Tag> : null}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
<PolicySwitchCard
|
||||
<PolicySwitchCard
|
||||
title="VoWiFi"
|
||||
subtitle={t("启用时强制关闭蜂窝射频;关闭 VoWiFi 后仍保持飞行模式")}
|
||||
tone="orange"
|
||||
@@ -69,7 +130,7 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
|
||||
failed={toggles.vowifiFailed}
|
||||
onToggle={toggles.onVoWiFiToggle}
|
||||
/>
|
||||
<PolicySwitchCard
|
||||
{!wifiCallingOnly ? <PolicySwitchCard
|
||||
title={t("飞行模式")}
|
||||
subtitle={t("只有手动关闭此开关才允许设备连接基站")}
|
||||
tone="indigo"
|
||||
@@ -78,8 +139,15 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
|
||||
pending={toggles.airplanePending}
|
||||
failed={toggles.airplaneFailed}
|
||||
onToggle={toggles.onAirplaneToggle}
|
||||
/>
|
||||
/> : null}
|
||||
</div>
|
||||
{!wifiCallingOnly ? <CardPolicyAPN
|
||||
deviceId={deviceId}
|
||||
iccid={iccid}
|
||||
policy={currentPolicy}
|
||||
deviceOnline={deviceOnline}
|
||||
onSaved={onPolicyChanged}
|
||||
/> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,7 @@ function isQmiMode(d?: DiscoveredDevice | null): boolean {
|
||||
}
|
||||
function modeLabel(d?: DiscoveredDevice | null): string {
|
||||
const m = String(d?.mode || "unknown").toLowerCase();
|
||||
return m === "qmi" ? "QMI" : m === "mbim" ? "MBIM" : m === "ecm" ? "ECM" : m === "rndis" ? "RNDIS" : m === "ncm" ? "NCM" : "UNKNOWN";
|
||||
return m === "pcsc" ? "PC/SC" : m === "qmi" ? "QMI" : m === "mbim" ? "MBIM" : m === "ecm" ? "ECM" : m === "rndis" ? "RNDIS" : m === "ncm" ? "NCM" : "UNKNOWN";
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: ReactNode; children: ReactNode }) {
|
||||
@@ -47,6 +47,7 @@ export function DeviceAddDialog(props: DeviceAddDialogProps) {
|
||||
const { addSelected, addConfig } = props;
|
||||
const fixedQmi = isQmiControl(addSelected?.controlPath || addConfig?.controlDevice);
|
||||
const isMbim = String(addSelected?.mode || "").toLowerCase() === "mbim";
|
||||
const isReader = addSelected?.hardwareKind === "pcsc" || String(addSelected?.mode || "").toLowerCase() === "pcsc";
|
||||
|
||||
useEffect(() => {
|
||||
if (fixedQmi && addConfig.deviceBackend !== "qmi") props.onConfigChange({ ...addConfig, deviceBackend: "qmi" });
|
||||
@@ -57,7 +58,7 @@ export function DeviceAddDialog(props: DeviceAddDialogProps) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isMbim]);
|
||||
|
||||
const backendOptions = [
|
||||
const backendOptions = isReader ? [{ value: "pcsc", label: "PC/SC" }] : [
|
||||
...(isMbim
|
||||
? []
|
||||
: [
|
||||
@@ -161,6 +162,9 @@ export function DeviceAddDialog(props: DeviceAddDialogProps) {
|
||||
<Field label={t("控制设备")}>
|
||||
<Input value={addConfig.controlDevice} disabled />
|
||||
</Field>
|
||||
{isReader ? <Field label="SIM PIN">
|
||||
<Input type="password" value={addConfig.simPin} onChange={(e) => set({ simPin: e.target.value })} maxLength={8} inputMode="numeric" placeholder={t("仅在 SIM 启用 PIN 时填写")} />
|
||||
</Field> : null}
|
||||
<div className="flex items-center justify-between rounded-xl border border-gray-200 bg-gray-50 p-3">
|
||||
<div>
|
||||
<div className="text-sm font-bold text-gray-800">{t("设备后端模式")}</div>
|
||||
@@ -173,7 +177,7 @@ export function DeviceAddDialog(props: DeviceAddDialogProps) {
|
||||
onChange={(v) => set({ deviceBackend: v })}
|
||||
className="w-[110px]"
|
||||
placeholder="AT"
|
||||
disabled={fixedQmi || isMbim}
|
||||
disabled={fixedQmi || isMbim || isReader}
|
||||
options={backendOptions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -34,13 +34,14 @@ export function DeviceConfigTab({ editConfig, deviceStatus, saving, deleting, on
|
||||
const usbPath = deviceStatus?.usbPath || editConfig?.usbPath;
|
||||
const isQmi = isQmiControl(controlDevice);
|
||||
const isMbim = String(editConfig?.deviceBackend || "").toLowerCase() === "mbim";
|
||||
const isReader = editConfig?.deviceType === "usb_sim_reader";
|
||||
|
||||
useEffect(() => {
|
||||
if (isQmi && editConfig && editConfig.deviceBackend !== "qmi") onEditConfig({ ...editConfig, deviceBackend: "qmi" });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isQmi]);
|
||||
|
||||
const backendOptions = [
|
||||
const backendOptions = isReader ? [{ value: "pcsc", label: "PC/SC" }] : [
|
||||
...(isMbim
|
||||
? []
|
||||
: [
|
||||
@@ -106,6 +107,9 @@ export function DeviceConfigTab({ editConfig, deviceStatus, saving, deleting, on
|
||||
<Field label={t("控制设备")}>
|
||||
<Input value={controlDevice || ""} disabled placeholder={t("由系统自动探测")} />
|
||||
</Field>
|
||||
{isReader ? <Field label="SIM PIN">
|
||||
<Input type="password" value={editConfig.simPin || ""} onChange={(e) => onEditConfig({ ...editConfig, simPin: e.target.value })} maxLength={8} inputMode="numeric" placeholder={t("留空表示不修改;仅在 SIM 启用 PIN 时填写")} />
|
||||
</Field> : null}
|
||||
<div className="ui-panel-muted space-y-2 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -123,7 +127,7 @@ export function DeviceConfigTab({ editConfig, deviceStatus, saving, deleting, on
|
||||
onChange={(v) => onEditConfig({ ...editConfig, deviceBackend: v as DeviceConfig["deviceBackend"] })}
|
||||
className="w-[120px]"
|
||||
placeholder="AT"
|
||||
disabled={isQmi || isMbim}
|
||||
disabled={isQmi || isMbim || isReader}
|
||||
options={backendOptions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface DeviceDetailHeaderProps {
|
||||
onReconnectVowifi: () => void;
|
||||
onRebootModem: () => void;
|
||||
onOpenSms: () => void;
|
||||
wifiCallingOnly?: boolean;
|
||||
}
|
||||
|
||||
export function DeviceDetailHeader(props: DeviceDetailHeaderProps) {
|
||||
@@ -42,7 +43,7 @@ export function DeviceDetailHeader(props: DeviceDetailHeaderProps) {
|
||||
<Button loading={props.reconnectingVoWiFi} onClick={props.onReconnectVowifi} className="ui-glass-border !border-0" icon={<ArrowSyncRegular />}>
|
||||
{t("重连 VoWiFi")}
|
||||
</Button>
|
||||
) : device.developerEnabled ? (
|
||||
) : device.developerEnabled && !props.wifiCallingOnly ? (
|
||||
<div
|
||||
className="ui-glass-border flex h-8 items-center gap-2 rounded-lg px-3 text-sm text-gray-700 dark:text-gray-200"
|
||||
title={t("蜂窝数据仅进入 Export Proxy 的受保护路由,不会成为主机默认出口")}
|
||||
@@ -58,9 +59,9 @@ export function DeviceDetailHeader(props: DeviceDetailHeaderProps) {
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<Button loading={props.rebooting} onClick={props.onRebootModem} className="ui-glass-border !border-0 hover:!text-red-600" icon={<PowerRegular />}>
|
||||
{!props.wifiCallingOnly ? <Button loading={props.rebooting} onClick={props.onRebootModem} className="ui-glass-border !border-0 hover:!text-red-600" icon={<PowerRegular />}>
|
||||
{t("重启模组")}
|
||||
</Button>
|
||||
</Button> : null}
|
||||
<Button onClick={props.onOpenSms} className="ui-glass-border !border-0" icon={<ChatRegular />}>
|
||||
{t("短信")}
|
||||
</Button>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { isVoWiFiInUse } from "./shared";
|
||||
export interface DeviceOverviewTabProps {
|
||||
device: DeviceDetail;
|
||||
simOperatorDisplay: string;
|
||||
customPhoneNumber?: string;
|
||||
trafficSpeedRx: string;
|
||||
trafficSpeedTx: string;
|
||||
trafficMinuteRx: string;
|
||||
@@ -25,12 +26,13 @@ export function DeviceOverviewTab(props: DeviceOverviewTabProps) {
|
||||
const { t } = useI18n();
|
||||
const [operatorOpen, setOperatorOpen] = useState(false);
|
||||
const { device } = props;
|
||||
const wifiCallingOnly = device.deviceType === "usb_sim_reader";
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<div className={`grid grid-cols-1 gap-4 ${wifiCallingOnly ? "lg:grid-cols-2" : "lg:grid-cols-3"}`}>
|
||||
<div className="ui-panel-muted p-4">
|
||||
<div className="mb-3 text-xs font-bold uppercase tracking-wider text-gray-500">{t("运行状态")}</div>
|
||||
{isVoWiFiInUse(device) ? (
|
||||
{isVoWiFiInUse(device) && !(device.modem?.imei && device.modem?.simInserted === false) ? (
|
||||
<OverviewVowifiCard device={device} />
|
||||
) : (
|
||||
<OverviewNetworkCard device={device} onOpenOperatorSelection={() => setOperatorOpen(true)} />
|
||||
@@ -39,19 +41,20 @@ export function DeviceOverviewTab(props: DeviceOverviewTabProps) {
|
||||
<OverviewSimPanel
|
||||
device={device}
|
||||
simOperatorDisplay={props.simOperatorDisplay}
|
||||
customPhoneNumber={props.customPhoneNumber}
|
||||
e911Starting={props.e911Starting}
|
||||
onSetupE911={props.onSetupE911}
|
||||
/>
|
||||
<OverviewNetworkPanel
|
||||
{!wifiCallingOnly ? <OverviewNetworkPanel
|
||||
device={device}
|
||||
trafficMinuteRx={props.trafficMinuteRx}
|
||||
trafficMinuteTx={props.trafficMinuteTx}
|
||||
trafficSpeedRx={props.trafficSpeedRx}
|
||||
trafficSpeedTx={props.trafficSpeedTx}
|
||||
/>
|
||||
/> : null}
|
||||
</div>
|
||||
{device.developerEnabled && device.networkEnabled && device.id ? <OverviewTrafficChart deviceId={device.id} /> : null}
|
||||
{device?.id ? (
|
||||
{device?.id && !wifiCallingOnly ? (
|
||||
<OperatorSelectionDialog
|
||||
open={operatorOpen}
|
||||
deviceId={device.id}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button, Spinner } from "../ui";
|
||||
import { PolicySwitchCard } from "./PolicySwitchCard";
|
||||
import { CardPolicyAPN } from "./CardPolicyAPN";
|
||||
import { useCardPolicyToggles } from "./useCardPolicyToggles";
|
||||
import { getCardPolicy, putCardPolicy, enableVoWiFi, disableVoWiFi, setFlightMode } from "./deviceActions";
|
||||
import type { CardPolicy } from "../../types";
|
||||
@@ -87,6 +88,16 @@ export function EsimCardPolicyInline({ deviceId, iccid, isActiveCard, deviceOnli
|
||||
onToggle={toggles.onAirplaneToggle}
|
||||
/>
|
||||
</div>
|
||||
<CardPolicyAPN
|
||||
deviceId={deviceId}
|
||||
iccid={iccid}
|
||||
policy={policy}
|
||||
deviceOnline={deviceOnline}
|
||||
onSaved={(saved) => {
|
||||
setPolicy(saved);
|
||||
onPolicyChanged();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cx } from "../../lib/utils";
|
||||
import { CountryFlag } from "../CountryFlag";
|
||||
import { EsimProfileRow } from "./EsimProfileRow";
|
||||
import type { EsimChipInfo, EsimEid, EsimProfileGroup } from "./types";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
@@ -35,11 +36,12 @@ function normAid(aid?: string): string {
|
||||
return (aid || "").trim().toUpperCase();
|
||||
}
|
||||
|
||||
function manufacturerFlag(manufacturer?: string): string {
|
||||
function manufacturerCountryCode(manufacturer?: string): string {
|
||||
const value = (manufacturer || "").toLowerCase();
|
||||
if (value.includes("eastcompeace") || value.includes("watchdata")) return "🇨🇳";
|
||||
if (value.includes("giesecke") || value.includes("g+d")) return "🇩🇪";
|
||||
if (value.includes("thales") || value.includes("idemia")) return "🇫🇷";
|
||||
if (value.includes("eastcompeace") || value.includes("watchdata") || value.includes("hutopt")) return "CN";
|
||||
if (value.includes("giesecke") || value.includes("g+d")) return "DE";
|
||||
if (value.includes("thales") || value.includes("idemia")) return "FR";
|
||||
if (value.includes("gemalto")) return "CH";
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -56,7 +58,9 @@ function PkiInfo({ eid }: { eid: EsimEid }) {
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-gray-400 dark:text-gray-500">
|
||||
{eid.manufacturer ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="text-[10px]">{t("生产商:")}</span> {eid.manufacturer} {manufacturerFlag(eid.manufacturer)}
|
||||
<span className="text-[10px]">{t("生产商:")}</span>
|
||||
<span>{eid.manufacturer}</span>
|
||||
<CountryFlag countryCode={manufacturerCountryCode(eid.manufacturer)} />
|
||||
</span>
|
||||
) : null}
|
||||
{eid.certificates && eid.certificates.length ? (
|
||||
|
||||
@@ -50,7 +50,7 @@ export function EsimNotificationsModal({ open, loading, items, retryingSeq, onCl
|
||||
<div className="max-h-[420px] space-y-2 overflow-auto pr-1">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.sequenceNumber}
|
||||
key={`${item.aidHex || "default"}:${item.sequenceNumber}`}
|
||||
className="flex flex-col gap-3 rounded-xl border border-gray-200 p-3 dark:border-white/10 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
|
||||
@@ -26,17 +26,20 @@ export function OverviewNetworkCard({ device, onOpenOperatorSelection }: { devic
|
||||
const modem = device.modem;
|
||||
const online = isDeviceOnline(device);
|
||||
const cellularRegistered = isRegistered(device);
|
||||
const simMissing = !!modem?.imei && modem?.simInserted === false;
|
||||
// A persisted runtime may briefly describe the old session while disable is
|
||||
// being cleaned up. Desired policy is authoritative for the overview badge.
|
||||
const vowifiRegistered = !!device.vowifiEnabled && !!(device.vowifiActive || device.vowifiRuntime?.smsReady);
|
||||
const registered = cellularRegistered || vowifiRegistered;
|
||||
const registered = !simMissing && (cellularRegistered || vowifiRegistered);
|
||||
const radioOffForVowifi = vowifiRegistered && (modem?.operatingMode === 0 || modem?.operatingMode === 4 || device.flightMode);
|
||||
const tone = isRecoveringPhase(device.lifecyclePhase) ? "warning" : online ? (registered ? "success" : "warning") : "danger";
|
||||
|
||||
let statusText: string;
|
||||
const phaseLabel = lifecycleLabel(device.lifecyclePhase);
|
||||
if (phaseLabel && device.lifecyclePhase !== "online" && device.lifecyclePhase !== "offline") statusText = phaseLabel;
|
||||
else if (online) {
|
||||
else if (online) {
|
||||
if (simMissing) statusText = t("SIM卡未插入");
|
||||
else
|
||||
statusText = registered
|
||||
? ""
|
||||
: device.registrationStateLabel === "searching"
|
||||
@@ -49,7 +52,9 @@ export function OverviewNetworkCard({ device, onOpenOperatorSelection }: { devic
|
||||
const level = signalLevel(modem?.signalDbm);
|
||||
const sigTone = signalTone(modem?.signalDbm);
|
||||
const netMode = [modem?.networkDuplex, modem?.networkMode].filter(Boolean).join(" ");
|
||||
const cellularRegistrationText = modem?.regStatus === 5
|
||||
const cellularRegistrationText = simMissing
|
||||
? t("SIM卡未插入")
|
||||
: modem?.regStatus === 5
|
||||
? t("已驻网(漫游)")
|
||||
: modem?.regStatus === 1
|
||||
? t("已驻网")
|
||||
|
||||
@@ -10,11 +10,12 @@ import { CountryFlag } from "../CountryFlag";
|
||||
export interface OverviewSimPanelProps {
|
||||
device: DeviceDetail;
|
||||
simOperatorDisplay: string;
|
||||
customPhoneNumber?: string;
|
||||
e911Starting: boolean;
|
||||
onSetupE911: () => void;
|
||||
}
|
||||
|
||||
export function OverviewSimPanel({ device, simOperatorDisplay, e911Starting, onSetupE911 }: OverviewSimPanelProps) {
|
||||
export function OverviewSimPanel({ device, simOperatorDisplay, customPhoneNumber, e911Starting, onSetupE911 }: OverviewSimPanelProps) {
|
||||
const { t } = useI18n();
|
||||
const [showSensitive, toggleSensitive] = useShowSensitive();
|
||||
const modem = device.modem;
|
||||
@@ -22,6 +23,7 @@ export function OverviewSimPanel({ device, simOperatorDisplay, e911Starting, onS
|
||||
const activeEsim = (device.activeEsimProfileName || "").trim();
|
||||
const flightOn = device.vowifiActive || modem?.operatingMode === 0 || modem?.operatingMode === 4;
|
||||
const carrierCountryCode = carrierBrandIso(modem?.nativeSpn, modem?.imsi);
|
||||
const displayedPhoneNumber = customPhoneNumber?.trim() || device.localPhone || "--";
|
||||
const backendLabel =
|
||||
device.backendMode === "qmi" ? "QMI" : device.backendMode === "mbim" ? "MBIM" : device.backendMode === "at" ? "AT" : "Auto";
|
||||
|
||||
@@ -40,7 +42,7 @@ export function OverviewSimPanel({ device, simOperatorDisplay, e911Starting, onS
|
||||
<FieldRow label="IMEI" value={modem?.imei} sensitive={sensitive} monospace copyable />
|
||||
<FieldRow label="ICCID" value={modem?.iccid} sensitive={sensitive} monospace copyable />
|
||||
<FieldRow label="IMSI" value={modem?.imsi} sensitive={sensitive} monospace copyable />
|
||||
<FieldRow label={t("本机号码")} value={device.localPhone || "--"} sensitive={sensitive} monospace copyable />
|
||||
<FieldRow label={t("本机号码")} value={displayedPhoneNumber} sensitive={sensitive} monospace copyable />
|
||||
{device?.e911SetupAvailable ? (
|
||||
<div className="flex justify-between gap-3">
|
||||
<span className="text-gray-500">{t("E911地址")}</span>
|
||||
|
||||
@@ -22,6 +22,64 @@ export function setFlightMode(deviceId: string, enabled: boolean) {
|
||||
export function getCardPolicy(iccid: string) {
|
||||
return api<CardPolicy>(`/cards/${iccid}/policy`);
|
||||
}
|
||||
export interface CardPolicyUpdate {
|
||||
vowifiEnabled?: boolean;
|
||||
airplaneEnabled?: boolean;
|
||||
apn?: string;
|
||||
ipVersion?: "IP" | "IPV6" | "IPV4V6";
|
||||
customPhoneNumber?: string;
|
||||
}
|
||||
export function updateCardPolicy(iccid: string, body: CardPolicyUpdate) {
|
||||
return api<CardPolicy>(`/cards/${iccid}/policy`, { method: "PUT", body });
|
||||
}
|
||||
export function putCardPolicy(iccid: string, body: { vowifiEnabled: boolean; airplaneEnabled: boolean }) {
|
||||
return ok(api(`/cards/${iccid}/policy`, { method: "PUT", body }));
|
||||
return ok(updateCardPolicy(iccid, body));
|
||||
}
|
||||
|
||||
export interface ModemAPNProfile {
|
||||
cid: number;
|
||||
apn: string;
|
||||
ipVersion: "IP" | "IPV6" | "IPV4V6";
|
||||
}
|
||||
export function getDeviceAPNs(deviceId: string) {
|
||||
return api<{ items: ModemAPNProfile[] }>(`/devices/${deviceId}/network/apns`);
|
||||
}
|
||||
|
||||
export interface CardAPNProfile {
|
||||
id: number;
|
||||
iccid: string;
|
||||
apn: string;
|
||||
username: string;
|
||||
hasPassword: boolean;
|
||||
proxy: string;
|
||||
mcc: string;
|
||||
mnc: string;
|
||||
ipVersion: "IP" | "IPV6" | "IPV4V6";
|
||||
roamingIpVersion: "IP" | "IPV6" | "IPV4V6";
|
||||
authType: "NONE" | "PAP" | "CHAP" | "PAP_OR_CHAP";
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
export function getCardAPNs(iccid: string) {
|
||||
return api<{ items: CardAPNProfile[] }>(`/cards/${iccid}/apns`);
|
||||
}
|
||||
export interface CardAPNCreate {
|
||||
apn: string;
|
||||
username: string;
|
||||
password: string;
|
||||
proxy: string;
|
||||
mcc: string;
|
||||
mnc: string;
|
||||
ipVersion: "IP" | "IPV6" | "IPV4V6";
|
||||
roamingIpVersion: "IP" | "IPV6" | "IPV4V6";
|
||||
authType: "NONE" | "PAP" | "CHAP" | "PAP_OR_CHAP";
|
||||
}
|
||||
export function createCardAPN(iccid: string, body: CardAPNCreate) {
|
||||
return api<CardAPNProfile>(`/cards/${iccid}/apns`, { method: "POST", body });
|
||||
}
|
||||
export function updateCardAPN(iccid: string, id: number, body: Omit<CardAPNCreate, "password"> & { password?: string; clearPassword?: boolean }) {
|
||||
return api<CardAPNProfile>(`/cards/${iccid}/apns/${id}`, { method: "PATCH", body });
|
||||
}
|
||||
export function deleteCardAPN(iccid: string, id: number) {
|
||||
return api<{ deleted: boolean; id: number }>(`/cards/${iccid}/apns/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface AddDeviceForm {
|
||||
atPort: string;
|
||||
controlDevice: string;
|
||||
deviceBackend: string;
|
||||
simPin: string;
|
||||
}
|
||||
|
||||
export interface LoadError {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Select } from "../ui/Select";
|
||||
import { Switch } from "../ui/Switch";
|
||||
import { ChannelHeader, EmptyLine, Field, UrlListEditor } from "./controls";
|
||||
import { HEADER_NAME_SUGGESTIONS, nextHeaderRowId } from "./model";
|
||||
import type { BarkForm, EmailForm, HeaderRow, WebhookForm } from "./model";
|
||||
import type { BarkForm, EmailForm, HeaderRow, WebhookForm, WecomForm } from "./model";
|
||||
|
||||
const HEADER_LIST_ID = "vocat-webhook-header-names";
|
||||
|
||||
@@ -267,3 +267,53 @@ export function WebhookTab({ value, onChange, testing, onTest }: PushChannelProp
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WecomTab({ value, onChange, testing, onTest }: PushChannelProps<WecomForm>) {
|
||||
const { t, lang } = useI18n();
|
||||
const off = !value.enabled;
|
||||
const complete = hasAnyUrl(value.urls) && !!value.payloadTemplate.trim();
|
||||
return (
|
||||
<div className="pt-2">
|
||||
<ChannelHeader
|
||||
title={t("启用企业微信消息推送")}
|
||||
enabled={value.enabled}
|
||||
onToggle={(enabled) => onChange({ enabled })}
|
||||
actions={
|
||||
<Button size="small" variant="primary" plain loading={testing} disabled={off || !complete} onClick={onTest}>
|
||||
{t("测试通知")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<SMSOnlyHint />
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg bg-gray-50 px-3 py-2 text-xs leading-5 text-gray-500 dark:bg-gray-800/60 dark:text-gray-400">
|
||||
{t("每个企业微信消息推送 Webhook URL 单独占一行,点击添加 URL 新增一行;不使用逗号、空格或换行分隔多个 URL。")}
|
||||
</div>
|
||||
<UrlListEditor
|
||||
urls={value.urls}
|
||||
onChange={(urls) => onChange({ urls })}
|
||||
enabled={value.enabled}
|
||||
placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..."
|
||||
emptyText={t("尚未配置任何企业微信消息推送 Webhook URL,点击右侧添加按钮。")}
|
||||
/>
|
||||
<Field
|
||||
label={t("JSON 请求体模板")}
|
||||
hint={
|
||||
<>
|
||||
{t("支持完整企业微信消息推送 JSON。变量必须作为 JSON 值使用,例如")} <code>{"{{message}}"}</code>{lang === "zh" ? "。" : "."}
|
||||
{t("可用变量:{{event}}、{{title}}、{{message}}、{{timestamp}}、{{content}}、{{number}}、{{device_id}}、{{device_name}}、{{device_label}}、{{time}}。")}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Textarea
|
||||
value={value.payloadTemplate}
|
||||
onChange={(event) => onChange({ payloadTemplate: event.target.value })}
|
||||
disabled={off}
|
||||
rows={12}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,18 +47,32 @@ export interface EmailForm {
|
||||
}
|
||||
|
||||
export interface PushplusForm {
|
||||
enabled: boolean;
|
||||
token: string;
|
||||
topic: string;
|
||||
channel: string;
|
||||
enabled: boolean;
|
||||
token: string;
|
||||
topic: string;
|
||||
channel: string;
|
||||
}
|
||||
|
||||
export interface WecomForm {
|
||||
enabled: boolean;
|
||||
urls: string[];
|
||||
payloadTemplate: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_WECOM_PAYLOAD_TEMPLATE = `{
|
||||
"msgtype": "text",
|
||||
"text": {
|
||||
"content": {{message}}
|
||||
}
|
||||
}`;
|
||||
|
||||
export interface NotifyForms {
|
||||
telegram: TelegramForm;
|
||||
webhook: WebhookForm;
|
||||
bark: BarkForm;
|
||||
email: EmailForm;
|
||||
pushplus: PushplusForm;
|
||||
email: EmailForm;
|
||||
pushplus: PushplusForm;
|
||||
wecom: WecomForm;
|
||||
}
|
||||
|
||||
// 系统保留头,自定义同名头会被忽略(品牌 vocat)
|
||||
@@ -131,8 +145,9 @@ export function formsFromNotifications(data: Partial<NotificationSettings>): Not
|
||||
const telegram = asRecord(data.telegram);
|
||||
const webhook = asRecord(data.webhook);
|
||||
const bark = asRecord(data.bark);
|
||||
const email = asRecord(data.email);
|
||||
const pushplus = asRecord(data.pushplus);
|
||||
const email = asRecord(data.email);
|
||||
const pushplus = asRecord(data.pushplus);
|
||||
const wecom = asRecord(data.wecom);
|
||||
return {
|
||||
telegram: {
|
||||
enabled: !!telegram.enabled,
|
||||
@@ -171,13 +186,18 @@ export function formsFromNotifications(data: Partial<NotificationSettings>): Not
|
||||
fromAddress: str(email.fromAddress),
|
||||
toAddresses: joinList(email.toAddresses),
|
||||
},
|
||||
pushplus: {
|
||||
enabled: !!pushplus.enabled,
|
||||
token: str(pushplus.token),
|
||||
topic: str(pushplus.topic),
|
||||
channel: str(pushplus.channel) || "wechat",
|
||||
},
|
||||
};
|
||||
pushplus: {
|
||||
enabled: !!pushplus.enabled,
|
||||
token: str(pushplus.token),
|
||||
topic: str(pushplus.topic),
|
||||
channel: str(pushplus.channel) || "wechat",
|
||||
},
|
||||
wecom: {
|
||||
enabled: !!wecom.enabled,
|
||||
urls: strList(wecom.urls),
|
||||
payloadTemplate: str(wecom.payloadTemplate ?? wecom.payload_template) || DEFAULT_WECOM_PAYLOAD_TEMPLATE,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function splitList(value: string): string[] {
|
||||
@@ -226,6 +246,15 @@ export function buildEmailPayload(form: EmailForm, forTest = false) {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWecomPayload(form: WecomForm, forTest = false) {
|
||||
const urls = Array.isArray(form.urls) ? form.urls : [];
|
||||
return {
|
||||
enabled: !!form.enabled,
|
||||
urls: forTest ? urls.map((url) => String(url || "").trim()).filter(Boolean) : urls,
|
||||
payload_template: String(form.payloadTemplate || ""),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildNotificationsPayload(forms: NotifyForms) {
|
||||
return {
|
||||
telegram: {
|
||||
@@ -245,6 +274,7 @@ export function buildNotificationsPayload(forms: NotifyForms) {
|
||||
channel: forms.pushplus.channel || "",
|
||||
},
|
||||
webhook: buildWebhookPayload(forms.webhook),
|
||||
bark: buildBarkPayload(forms.bark),
|
||||
};
|
||||
bark: buildBarkPayload(forms.bark),
|
||||
wecom: buildWecomPayload(forms.wecom),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export const DEVICE_TYPES: ReadonlyArray<{ value: DeviceType; label: string; ima
|
||||
{ value: "wifi_410", label: "410 WiFi 棒(高通芯片)", image: "/410.png" },
|
||||
{ value: "dji_4g", label: "大疆 4G 模块(移远芯片)", image: "/dj.png" },
|
||||
{ value: "pcie_ec20_ec25", label: "PCIe EC20/EC25(移远芯片)", image: "/ec20.png" },
|
||||
{ value: "usb_sim_reader", label: "USB SIM 读卡器(仅 WiFi Calling)", image: "/sim-reader.svg" },
|
||||
];
|
||||
|
||||
export function normalizeDeviceType(value?: string | null): DeviceType {
|
||||
|
||||
+73
-1
@@ -5,6 +5,56 @@
|
||||
* 富文本片段(嵌套链接/代码块的说明框)不走字典,在组件里按语言分支渲染。
|
||||
*/
|
||||
export const EN_DICT: Record<string, string> = {
|
||||
"USB SIM 读卡器(仅 WiFi Calling)": "USB SIM Reader (WiFi Calling only)",
|
||||
"仅在 SIM 启用 PIN 时填写": "Only enter this when SIM PIN is enabled",
|
||||
"留空表示不修改;仅在 SIM 启用 PIN 时填写": "Leave blank to keep unchanged; only enter this when SIM PIN is enabled",
|
||||
"USB SIM 读卡器仅用于 WiFi Calling,策略跟随 ICCID 保存": "The USB SIM reader is for WiFi Calling only; policy is saved per ICCID",
|
||||
// Cellular APN profiles.
|
||||
"蜂窝 APN": "Cellular APN",
|
||||
"APN 列表和启用状态跟随当前 ICCID/Profile 保存": "APN profiles and the active selection are saved per ICCID/Profile",
|
||||
"新增 APN": "Add APN",
|
||||
"正在读取 APN 列表...": "Loading APN profiles...",
|
||||
"账号 / 认证": "Account / Authentication",
|
||||
"协议 / 漫游": "Protocol / Roaming",
|
||||
来源: "Source",
|
||||
"运营商自动配置": "Carrier Automatic",
|
||||
默认: "Default",
|
||||
"模组已有": "Modem",
|
||||
自定义: "Custom",
|
||||
漫游: "Roaming",
|
||||
"已设密码": "Password set",
|
||||
使用中: "Active",
|
||||
"APN 已启用": "APN enabled",
|
||||
"已使用运营商自动 APN 配置": "Carrier automatic APN configuration enabled",
|
||||
"启用 APN 失败": "Failed to enable APN",
|
||||
"设备离线:自定义列表仍可管理,模组已有 APN 将在上线后读取":
|
||||
"Device offline: custom profiles can still be managed; modem APNs will load when the device comes online",
|
||||
"新增 APN 配置": "Add APN Profile",
|
||||
"修改 APN 配置": "Edit APN Profile",
|
||||
"保存修改": "Save Changes",
|
||||
"添加到列表": "Add to List",
|
||||
留空: "Leave blank",
|
||||
"留空表示保持原密码": "Leave blank to keep the current password",
|
||||
"APN 协议": "APN Protocol",
|
||||
"APN 漫游协议": "APN Roaming Protocol",
|
||||
"认证类型": "Authentication Type",
|
||||
"清除已保存密码": "Clear Saved Password",
|
||||
"关闭时,密码输入框留空会保持原密码": "When disabled, leaving the password blank keeps the current password",
|
||||
"APN 只能包含字母、数字、点、下划线或连字符,且最长 100 个字符":
|
||||
"APN may contain only letters, numbers, dots, underscores, or hyphens, with a maximum of 100 characters",
|
||||
"MCC 必须是 3 位数字": "MCC must be exactly 3 digits",
|
||||
"MNC 必须是 2 或 3 位数字": "MNC must be 2 or 3 digits",
|
||||
"自定义 APN 已添加,请点击启用后使用": "Custom APN added. Click Enable to use it",
|
||||
"自定义 APN 已修改": "Custom APN updated",
|
||||
"添加 APN 失败": "Failed to add APN",
|
||||
"修改 APN 失败": "Failed to update APN",
|
||||
"确定删除这个自定义 APN 配置吗?": "Delete this custom APN profile?",
|
||||
"删除 APN": "Delete APN",
|
||||
"APN 已删除,并恢复运营商自动配置": "APN deleted; carrier automatic configuration restored",
|
||||
"自定义 APN 已删除": "Custom APN deleted",
|
||||
"删除 APN 失败": "Failed to delete APN",
|
||||
修改: "Edit",
|
||||
|
||||
// External extensions.
|
||||
"插件": "Plugins",
|
||||
"通过 URL 或本地插件包扩展 VoCat 功能": "Extend VoCat with a URL or a local plugin package",
|
||||
@@ -229,6 +279,7 @@ export const EN_DICT: Record<string, string> = {
|
||||
"Webhook 测试失败": "Webhook test failed",
|
||||
"Bark 测试失败": "Bark test failed",
|
||||
"Email 测试失败": "Email test failed",
|
||||
"企业微信消息推送测试失败": "WeCom message push test failed",
|
||||
|
||||
// ---- 设置页:安全卡 ----
|
||||
安全: "Security",
|
||||
@@ -326,10 +377,20 @@ export const EN_DICT: Record<string, string> = {
|
||||
"启用 Bark 推送": "Enable Bark",
|
||||
"启用 Email 推送": "Enable Email",
|
||||
"启用 Webhook 推送": "Enable Webhook",
|
||||
"企业微信消息推送": "WeCom Message Push",
|
||||
"启用企业微信消息推送": "Enable WeCom Message Push",
|
||||
"Telegram / Bark / Email / Pushplus / Webhook / 企业微信消息推送": "Telegram / Bark / Email / Pushplus / Webhook / WeCom Message Push",
|
||||
"目标 URLs": "Target URLs",
|
||||
"添加 URL": "Add URL",
|
||||
"尚未配置任何 Bark URL,点击右侧添加按钮。": "No Bark URLs yet. Click the add button on the right.",
|
||||
"尚未配置任何 Webhook URL,点击右侧添加按钮。": "No Webhook URLs yet. Click the add button on the right.",
|
||||
"每个企业微信消息推送 Webhook URL 单独占一行,点击添加 URL 新增一行;不使用逗号、空格或换行分隔多个 URL。":
|
||||
"Enter one WeCom message push Webhook URL per line. Use Add URL to add another row; do not separate URLs with commas, spaces, or line breaks.",
|
||||
"尚未配置任何企业微信消息推送 Webhook URL,点击右侧添加按钮。": "No WeCom message push Webhook URLs yet. Click the add button on the right.",
|
||||
"JSON 请求体模板": "JSON Request Body Template",
|
||||
"支持完整企业微信消息推送 JSON。变量必须作为 JSON 值使用,例如": "Supports a complete WeCom message push JSON payload. Use variables as JSON values, for example",
|
||||
"可用变量:{{event}}、{{title}}、{{message}}、{{timestamp}}、{{content}}、{{number}}、{{device_id}}、{{device_name}}、{{device_label}}、{{time}}。":
|
||||
"Available variables: {{event}}, {{title}}, {{message}}, {{timestamp}}, {{content}}, {{number}}, {{device_id}}, {{device_name}}, {{device_label}}, {{time}}.",
|
||||
"分组 (Group)": "Group",
|
||||
"例如 vocat": "e.g. vocat",
|
||||
"iOS 设备上的通知分组。": "Notification group on iOS devices.",
|
||||
@@ -708,7 +769,9 @@ export const EN_DICT: Record<string, string> = {
|
||||
"暂无设备": "No devices",
|
||||
"最后原因": "Last Reason",
|
||||
"最后成功:": "Last success: ",
|
||||
"未检测到 eUICC": "No eUICC detected",
|
||||
"未检测到 eUICC": "No eUICC detected",
|
||||
"SIM卡未插入": "No SIM card inserted",
|
||||
"USB SIM读卡器仅支持VoWiFi短信和通话任务": "USB SIM readers support VoWiFi SMS and call tasks only",
|
||||
"未生效": "Not in effect",
|
||||
"未知": "Unknown",
|
||||
"未知日期": "Unknown date",
|
||||
@@ -939,6 +1002,15 @@ export const EN_DICT: Record<string, string> = {
|
||||
"大疆 4G 模块(移远芯片)": "DJI 4G Module (Quectel)",
|
||||
"PCIe EC20/EC25(移远芯片)": "PCIe EC20/EC25 (Quectel)",
|
||||
|
||||
// ---- Per-Profile phone display override ----
|
||||
"自定义手机号": "Custom Phone Number",
|
||||
"请输入手机号(可留空)": "Enter a phone number (optional)",
|
||||
"自定义手机号已保存": "Custom phone number saved",
|
||||
"已恢复显示系统读取的号码": "Restored the system-detected number",
|
||||
"保存自定义手机号失败": "Failed to save custom phone number",
|
||||
"支持开头的 + 和 3-20 位数字;留空时显示系统从 SIM/网络读取的号码":
|
||||
"Supports a leading + and 3–20 digits. Leave blank to show the number read from the SIM/network.",
|
||||
|
||||
// ---- AT 快捷指令(按 group · item 分组翻译) ----
|
||||
基础: "Basics",
|
||||
网络控制: "Network Control",
|
||||
|
||||
@@ -69,13 +69,9 @@ export function LanguageProvider({ children }: { children: ReactNode }) {
|
||||
api("/settings/preferences", { method: "PUT", body: { language: next } }).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const t = useCallback(
|
||||
(text: string) => {
|
||||
if (!text || lang === "zh") return text;
|
||||
return EN_DICT[text] ?? text;
|
||||
},
|
||||
[lang],
|
||||
);
|
||||
// 委托给模块级 tl(读取实时 activeLang),使 t 的函数身份稳定:
|
||||
// 否则 useCallback 闭包会捕获到旧语言的 t,切换语言后出现标题/正文语言不一致。
|
||||
const t = useCallback((text: string) => tl(text), []);
|
||||
|
||||
const value = useMemo(() => ({ lang, setLanguage, t }), [lang, setLanguage, t]);
|
||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||
|
||||
@@ -251,7 +251,7 @@ export default function AutomaticTasksPage() {
|
||||
|
||||
function edit(task?: AutomaticTask) {
|
||||
const deviceId = task?.deviceId || devices[0]?.id || "";
|
||||
const next = task ? {
|
||||
let next = task ? {
|
||||
id: task.id,
|
||||
name: task.name,
|
||||
enabled: task.enabled,
|
||||
@@ -269,13 +269,21 @@ export default function AutomaticTasksPage() {
|
||||
message: task.payload?.message || "",
|
||||
durationSeconds: task.payload?.durationSeconds || 30,
|
||||
} : emptyForm(deviceId);
|
||||
if (devices.find((device) => device.id === deviceId)?.deviceType === "usb_sim_reader") {
|
||||
next = { ...next, taskType: next.taskType === "public_ip" ? "sms" : next.taskType, environment: "vowifi" };
|
||||
}
|
||||
setForm(next);
|
||||
setOpen(true);
|
||||
void loadProfiles(deviceId, next.profileIccid);
|
||||
}
|
||||
|
||||
function chooseDevice(deviceId: string) {
|
||||
setForm((current) => ({ ...current, deviceId, profileIccid: "", profileAid: "" }));
|
||||
const reader = devices.find((device) => device.id === deviceId)?.deviceType === "usb_sim_reader";
|
||||
setForm((current) => ({
|
||||
...current, deviceId, profileIccid: "", profileAid: "",
|
||||
taskType: reader && current.taskType === "public_ip" ? "sms" : current.taskType,
|
||||
environment: reader ? "vowifi" : current.environment,
|
||||
}));
|
||||
void loadProfiles(deviceId);
|
||||
}
|
||||
|
||||
@@ -285,6 +293,7 @@ export default function AutomaticTasksPage() {
|
||||
}
|
||||
|
||||
function chooseTaskType(taskType: TaskType) {
|
||||
if (deviceByID.get(form.deviceId)?.deviceType === "usb_sim_reader" && taskType === "public_ip") return;
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
taskType,
|
||||
@@ -296,6 +305,9 @@ export default function AutomaticTasksPage() {
|
||||
if (!form.name.trim()) return message.warning(t("请输入任务名称"));
|
||||
if (!form.deviceId) return message.warning(t("请选择设备"));
|
||||
if (!form.profileIccid) return message.warning(t("请选择 eSIM Profile"));
|
||||
if (deviceByID.get(form.deviceId)?.deviceType === "usb_sim_reader" && (form.environment !== "vowifi" || form.taskType === "public_ip")) {
|
||||
return message.warning(t("USB SIM读卡器仅支持VoWiFi短信和通话任务"));
|
||||
}
|
||||
if (form.taskType !== "public_ip" && !form.phone.trim()) return message.warning(t("请输入号码"));
|
||||
if (form.taskType === "sms" && !form.message.trim()) return message.warning(t("请输入短信内容"));
|
||||
setSaving(true);
|
||||
@@ -377,6 +389,15 @@ export default function AutomaticTasksPage() {
|
||||
|
||||
const taskTypeLabel = (value: TaskType) => ({ sms: t("发送短信"), call: t("拨打电话并自动挂断"), public_ip: t("获取漫游公网 IP") })[value];
|
||||
const environmentLabel = (value: TaskEnvironment) => value === "vowifi" ? "VoWiFi" : t("基站直连");
|
||||
const selectedTaskDeviceIsReader = deviceByID.get(form.deviceId)?.deviceType === "usb_sim_reader";
|
||||
const taskTypeOptions = [
|
||||
{ value: "sms", label: t("发送短信") },
|
||||
{ value: "call", label: t("拨打电话并自动挂断") },
|
||||
...(!selectedTaskDeviceIsReader ? [{ value: "public_ip", label: t("开启漫游流量并获取一次公网 IP") }] : []),
|
||||
];
|
||||
const environmentOptions = selectedTaskDeviceIsReader
|
||||
? [{ value: "vowifi", label: "VoWiFi" }]
|
||||
: [{ value: "vowifi", label: "VoWiFi" }, { value: "cellular", label: t("基站直连(自动选网)") }];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl">
|
||||
@@ -473,8 +494,9 @@ export default function AutomaticTasksPage() {
|
||||
<div className="md:col-span-2"><label className={fieldLabel}>{t("任务名称")}</label><Input value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} placeholder={t("例如:每日短信保活")} /></div>
|
||||
<div><label className={fieldLabel}>{t("设备")}</label><Select value={form.deviceId} onChange={chooseDevice} options={devices.map((device) => ({ value: device.id, label: `${device.name || device.id} (${device.id})` }))} /></div>
|
||||
<div><label className={fieldLabel}>{t("eSIM Profile")}</label><Select value={form.profileIccid} onChange={chooseProfile} disabled={profileLoading || !form.deviceId} placeholder={profileLoading ? t("读取 Profile 中...") : t("请选择 Profile")} options={profiles.map((profile) => ({ value: profile.iccid, label: profile.label }))} /></div>
|
||||
<div><label className={fieldLabel}>{t("任务类型")}</label><Select value={form.taskType} onChange={(value) => chooseTaskType(value as TaskType)} options={[{ value: "sms", label: t("发送短信") }, { value: "call", label: t("拨打电话并自动挂断") }, { value: "public_ip", label: t("开启漫游流量并获取一次公网 IP") }]} /></div>
|
||||
<div><label className={fieldLabel}>{t("执行环境")}</label><Select value={form.environment} onChange={(value) => setForm({ ...form, environment: value as TaskEnvironment })} disabled={form.taskType === "public_ip"} options={[{ value: "vowifi", label: "VoWiFi" }, { value: "cellular", label: t("基站直连(自动选网)") }]} /></div>
|
||||
<div><label className={fieldLabel}>{t("任务类型")}</label><Select value={form.taskType} onChange={(value) => chooseTaskType(value as TaskType)} options={taskTypeOptions} /></div>
|
||||
<div><label className={fieldLabel}>{t("执行环境")}</label><Select value={form.environment} onChange={(value) => setForm({ ...form, environment: value as TaskEnvironment })} disabled={form.taskType === "public_ip" || selectedTaskDeviceIsReader} options={environmentOptions} /></div>
|
||||
{selectedTaskDeviceIsReader ? <div className="md:col-span-2 rounded-lg border border-sky-200 bg-sky-50 p-3 text-sm text-sky-700 dark:border-sky-500/20 dark:bg-sky-500/10 dark:text-sky-300">{t("USB SIM读卡器仅支持VoWiFi短信和通话任务")}</div> : null}
|
||||
|
||||
{form.taskType !== "public_ip" ? <div><label className={fieldLabel}>{t("号码")}</label><Input value={form.phone} onChange={(event) => setForm({ ...form, phone: event.target.value })} placeholder="+447700900123" /></div> : null}
|
||||
{form.taskType === "call" ? <div><label className={fieldLabel}>{t("自动挂断")}</label><Input type="number" min={1} max={600} value={form.durationSeconds} suffix="s" onChange={(event) => setForm({ ...form, durationSeconds: Number(event.target.value) })} /></div> : null}
|
||||
|
||||
@@ -32,6 +32,7 @@ const EMPTY_ADD: AddDeviceForm = {
|
||||
atPort: "",
|
||||
controlDevice: "",
|
||||
deviceBackend: "at",
|
||||
simPin: "",
|
||||
};
|
||||
|
||||
export default function DevicesPage() {
|
||||
@@ -373,7 +374,8 @@ export default function DevicesPage() {
|
||||
setAddSelected(d);
|
||||
setAddConfig((prev) => {
|
||||
const mode = String(d.mode || "").toLowerCase();
|
||||
const backend = mode === "mbim" ? "mbim" : isQmiControl(d.controlPath) || (mode === "qmi" && d.controlPath) ? "qmi" : "at";
|
||||
const isReader = d.hardwareKind === "pcsc" || mode === "pcsc";
|
||||
const backend = isReader ? "pcsc" : mode === "mbim" ? "mbim" : isQmiControl(d.controlPath) || (mode === "qmi" && d.controlPath) ? "qmi" : "at";
|
||||
return {
|
||||
...prev,
|
||||
interface: d.netInterface || "",
|
||||
@@ -382,6 +384,8 @@ export default function DevicesPage() {
|
||||
modemImei: d.imei || "",
|
||||
usbPath: d.usbPath || "",
|
||||
deviceBackend: backend,
|
||||
deviceType: isReader ? "usb_sim_reader" : prev.deviceType,
|
||||
esimTransport: isReader ? "pcsc" : backend,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
@@ -574,6 +578,10 @@ export default function DevicesPage() {
|
||||
const unconfiguredDiscovered = useMemo(() => discovered.filter((d) => !d.configured), [discovered]);
|
||||
|
||||
const detailOnline = isDeviceOnline(detail);
|
||||
const isReader = detail?.deviceType === "usb_sim_reader";
|
||||
useEffect(() => {
|
||||
if (isReader && ["at", "ussd"].includes(activeTab)) setActiveTab("overview");
|
||||
}, [isReader, activeTab]);
|
||||
const addAtLimit = deviceLimit > 0 && list.length >= deviceLimit;
|
||||
const tabItems = [
|
||||
{ key: "overview", label: t("概览") },
|
||||
@@ -582,13 +590,14 @@ export default function DevicesPage() {
|
||||
{ key: "ussd", label: t("USSD") },
|
||||
{ key: "config", label: t("配置") },
|
||||
{ key: "card", label: t("卡策略") },
|
||||
];
|
||||
].filter((tab) => !isReader || !["at", "ussd"].includes(tab.key));
|
||||
|
||||
const overviewNode = detail ? (
|
||||
<div className="space-y-4">
|
||||
<DeviceOverviewTab
|
||||
device={detail}
|
||||
simOperatorDisplay={simOperator}
|
||||
customPhoneNumber={cardPolicy?.iccid === detail.modem?.iccid ? cardPolicy.customPhoneNumber : ""}
|
||||
trafficSpeedRx={''}
|
||||
trafficSpeedTx={''}
|
||||
trafficMinuteRx={''}
|
||||
@@ -672,6 +681,7 @@ export default function DevicesPage() {
|
||||
onReconnectVowifi={handleReconnectVoWiFi}
|
||||
onRebootModem={handleRebootModem}
|
||||
onOpenSms={handleOpenSms}
|
||||
wifiCallingOnly={isReader}
|
||||
/>
|
||||
<div className="device-detail-tabs ui-card p-6">
|
||||
<Tabs tabs={tabItems} value={activeTab} onChange={handleTabChange} />
|
||||
@@ -696,7 +706,7 @@ export default function DevicesPage() {
|
||||
<DeviceConfigTab editConfig={editConfig} deviceStatus={detail} saving={saving} deleting={deleting} onSave={handleSaveConfig} onDelete={handleDeleteDevice} onEditConfig={setEditConfig} />
|
||||
) : null}
|
||||
{activeTab === "card" ? (
|
||||
<CardPolicyPanel deviceId={detail.id} iccid={detail.modem?.iccid} policy={cardPolicy} deviceOnline={detailOnline} onPolicyChanged={handlePolicyChanged} />
|
||||
<CardPolicyPanel deviceId={detail.id} iccid={detail.modem?.iccid} policy={cardPolicy} deviceOnline={detailOnline} onPolicyChanged={handlePolicyChanged} wifiCallingOnly={isReader} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user