mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-13 03:13:43 +08:00
Compare commits
29
Commits
v0.1.1
...
400f08c6c7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
400f08c6c7 | ||
|
|
1380ffb419 | ||
|
|
45b15b245f | ||
|
|
2780dd96de | ||
|
|
2922d6a275 | ||
|
|
288e856fdb | ||
|
|
f17d925c4c | ||
|
|
296f963885 | ||
|
|
1b9546a73d | ||
|
|
22487dbb1f | ||
|
|
f9bb38aabe | ||
|
|
5bb5808706 | ||
|
|
d70937cc47 | ||
|
|
eab658dc90 | ||
|
|
0d738d4ce4 | ||
|
|
962c58fdd1 | ||
|
|
7b2e005b37 | ||
|
|
f1e70ecee5 | ||
|
|
f012c556e9 | ||
|
|
ab8bbbc1ed | ||
|
|
609a591045 | ||
|
|
461054615b | ||
|
|
020fb619a9 | ||
|
|
3cc73f1885 | ||
|
|
48fc4c5ab5 | ||
|
|
707ca3c124 | ||
|
|
a09f9af646 | ||
|
|
928ba7746e | ||
|
|
21f210d219 |
@@ -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/
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<img alt="GitHub Actions" src="https://img.shields.io/badge/GitHub_Actions-Release-2088FF?style=flat-square&logo=githubactions&logoColor=white">
|
||||
</p>
|
||||
|
||||
**English** | [简体中文](docs/README.zh-CN.md)
|
||||
**English** | [العربية](docs/README.ar.md) | [简体中文](docs/README.zh-CN.md) | [繁體中文](docs/README.zh-TW.md) | [Français](docs/README.fr.md) | [Русский](docs/README.ru.md) | [Español](docs/README.es.md) | [日本語](docs/README.ja.md)
|
||||
|
||||
Vocat is an open-source web control panel and engineering toolkit for Quectel EC20/EC25-class cellular modems. It combines modem discovery, live radio status, AT and USSD terminals, SMS, WiFi Calling, eSIM management, network selection, proxy routing, notifications, audit logs, and release automation in one self-contained service.
|
||||
|
||||
@@ -46,7 +46,7 @@ The backend is written in Go, the interface is built with React and TypeScript,
|
||||
| Card policy | ICCID-based WiFi Calling and flight-mode behavior with immediate policy application. |
|
||||
| Proxy routing | Upstream SOCKS routing, device bindings, country rules, TCP reachability checks, and UDP Associate checks for WiFi Calling data paths. |
|
||||
| Notifications | New inbound SMS forwarding through Telegram, Bark, email, Pushplus, and signed webhooks. Each SMS is delivered as an individual notification. |
|
||||
| Telegram bot | Device status, installed-profile listing and switching, WiFi Calling controls, SMS sending, timed dialing with automatic hang-up, call status, answer, and hang-up commands. Sensitive actions require administrator confirmation. |
|
||||
| Telegram bot | Device status, installed-profile listing and switching, WiFi Calling controls, and SMS sending. Sensitive actions require administrator confirmation. |
|
||||
| Operations | Authentication, CSRF protection, access policies, audit events, live logs, log retention, health checks, responsive layout, dark mode, and English/Chinese application UI. |
|
||||
| Distribution | Static Linux binaries, systemd installation script, self-update with SHA-256 verification, Docker image, GHCR publishing, and GitHub Actions release builds. |
|
||||
|
||||
@@ -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`;
|
||||
@@ -187,13 +207,9 @@ When Telegram notifications are enabled and both Chat ID and Admin ID are config
|
||||
/switch <device> <iccid>
|
||||
/wfc <device> <status|on|off|reconnect>
|
||||
/sms <device> <number> <message>
|
||||
/call <device> <number> <seconds>
|
||||
/calls <device>
|
||||
/answer <device>
|
||||
/hangup <device>
|
||||
```
|
||||
|
||||
Profile switching, SMS submission, and dialing use one-time confirmation buttons. Timed dialing performs the modem call action and automatically hangs up after 1–600 seconds; it does not capture or process call audio. The bot does not expose eSIM download, delete, or rename commands.
|
||||
Profile switching and SMS submission use one-time confirmation buttons. The bot does not expose eSIM download, delete, or rename commands.
|
||||
|
||||
## Updating
|
||||
|
||||
@@ -305,6 +321,16 @@ cd web && npm run build
|
||||
- [Linux.do](https://linux.do) — An inspiring tech community
|
||||
- [iniwex5](https://github.com/iniwex5) - Style and Functionality Guidelines
|
||||
|
||||
## Buy me a coffee
|
||||
|
||||
| Network | Address |
|
||||
| ------- | ------- |
|
||||
| USDT-TRON (TRC20) | `TQQAbboBoU8h5xX4YCA1rqWJU2WjK3seSg` |
|
||||
| USDT-BSC (BEP20) | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
|
||||
| USDT-Polygon | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
|
||||
|
||||
## License
|
||||
|
||||
See [LICENSE](LICENSE).
|
||||
|
||||
[](https://meteor-history.com)
|
||||
|
||||
+2
-2
@@ -32,8 +32,8 @@ Usage:
|
||||
GITHUB_TOKEN Optional bearer token for private repos
|
||||
or higher rate limits.
|
||||
vocat menu Interactive lifecycle menu (root on the host):
|
||||
toggle language, change password, restart, update,
|
||||
uninstall.
|
||||
toggle language, change password, change the Web port,
|
||||
restart, update, uninstall.
|
||||
vocat help Show this help message.
|
||||
|
||||
When run without a subcommand on a non-TTY (e.g. systemd), vocat starts the
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func lockServerInstance(databasePath string) (*os.File, error) {
|
||||
// The modem, PC/SC reader, XFRM policies and listener are host resources,
|
||||
// not database resources. Lock per OS user so a diagnostic instance using a
|
||||
// different VOCAT_DATABASE_PATH cannot silently steal the same AT port from
|
||||
// the managed service. Prefer /run because systemd's PrivateTmp would
|
||||
// otherwise hide the managed service's lock from a manually started process.
|
||||
// The UID-specific directory still permits intentionally isolated users to
|
||||
// operate independently; development hosts without writable /run fall back
|
||||
// to TempDir.
|
||||
uid := os.Geteuid()
|
||||
directory := filepath.Join("/run", fmt.Sprintf("vocat-%d", uid))
|
||||
if uid == 0 {
|
||||
directory = "/run/vocat"
|
||||
}
|
||||
if err := os.MkdirAll(directory, 0o700); err != nil {
|
||||
directory = os.TempDir()
|
||||
}
|
||||
path := filepath.Join(directory, "vocat-server.lock")
|
||||
fd, err := unix.Open(path, unix.O_CREAT|unix.O_RDWR|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0o600)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open server instance lock: %w", err)
|
||||
}
|
||||
file := os.NewFile(uintptr(fd), path)
|
||||
if err := unix.Flock(fd, unix.LOCK_EX|unix.LOCK_NB); err != nil {
|
||||
_ = file.Close()
|
||||
if errors.Is(err, unix.EWOULDBLOCK) || errors.Is(err, unix.EAGAIN) {
|
||||
return nil, errors.New("another vocat server already controls this host's modem resources")
|
||||
}
|
||||
return nil, fmt.Errorf("lock server instance: %w", err)
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestServerInstanceLockRejectsSecondProcess(t *testing.T) {
|
||||
firstDatabase := filepath.Join(t.TempDir(), "vocat.db")
|
||||
first, err := lockServerInstance(firstDatabase)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer first.Close()
|
||||
secondDatabase := filepath.Join(t.TempDir(), "other.db")
|
||||
second, err := lockServerInstance(secondDatabase)
|
||||
if second != nil {
|
||||
second.Close()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "already controls this host") {
|
||||
t.Fatalf("second lock error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
+179
-23
@@ -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)
|
||||
},
|
||||
})
|
||||
@@ -566,12 +622,18 @@ func configureVoWiFiRuntime(
|
||||
}
|
||||
if deviceConfig.VoWiFiEnabled {
|
||||
if entry, mapErr := mapper.Get(deviceConfig.ID); mapErr == nil {
|
||||
flightContext, cancelFlight := context.WithTimeout(ctx, 10*time.Second)
|
||||
_, flightErr := deviceManager.SetFlight(flightContext, entry.ID, true)
|
||||
cancelFlight()
|
||||
flightErr := protectVoWiFiStartupRadio(ctx, deviceManager, entry.ID)
|
||||
if flightErr != nil {
|
||||
_ = manager.Close(context.Background())
|
||||
return nil, fmt.Errorf("protect device %q before VoWiFi startup: %w", deviceConfig.ID, flightErr)
|
||||
// A modem can be temporarily unavailable while OpenWrt/procd is
|
||||
// restarting the service (notably after loading XFRM modules). Do
|
||||
// not take the Web/API service down with it: the orchestrator below
|
||||
// remains fail-closed and its runtime manager retries until CFUN=4
|
||||
// can be established.
|
||||
logger.Warn(
|
||||
"VoWiFi startup radio protection deferred to automatic retry",
|
||||
"device_id", deviceConfig.ID,
|
||||
"error", flightErr,
|
||||
)
|
||||
}
|
||||
}
|
||||
if _, err := manager.RequestEnabled(deviceConfig.ID, true); err != nil {
|
||||
@@ -583,10 +645,66 @@ func configureVoWiFiRuntime(
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
const (
|
||||
vowifiStartupRadioAttempts = 3
|
||||
vowifiStartupRadioDelay = time.Second
|
||||
)
|
||||
|
||||
type flightModeSetter interface {
|
||||
SetFlight(context.Context, string, bool) (device.FlightResult, error)
|
||||
}
|
||||
|
||||
func protectVoWiFiStartupRadio(ctx context.Context, manager flightModeSetter, physicalID string) error {
|
||||
return protectVoWiFiStartupRadioWithRetry(
|
||||
ctx,
|
||||
manager,
|
||||
physicalID,
|
||||
vowifiStartupRadioAttempts,
|
||||
vowifiStartupRadioDelay,
|
||||
)
|
||||
}
|
||||
|
||||
func protectVoWiFiStartupRadioWithRetry(
|
||||
ctx context.Context,
|
||||
manager flightModeSetter,
|
||||
physicalID string,
|
||||
attempts int,
|
||||
delay time.Duration,
|
||||
) error {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < attempts; attempt++ {
|
||||
flightContext, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
_, lastErr = manager.SetFlight(flightContext, physicalID, true)
|
||||
cancel()
|
||||
if lastErr == nil {
|
||||
return nil
|
||||
}
|
||||
if attempt+1 == attempts {
|
||||
break
|
||||
}
|
||||
timer := time.NewTimer(delay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if !timer.Stop() {
|
||||
<-timer.C
|
||||
}
|
||||
return errors.Join(lastErr, ctx.Err())
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
type vowifiDeviceAdapter interface {
|
||||
vowifi.SIMIdentityReader
|
||||
vowifi.AKAProvider
|
||||
vowifi.RadioController
|
||||
}
|
||||
|
||||
func newVoWiFiOrchestrator(
|
||||
deviceConfig store.Device,
|
||||
database *store.Store,
|
||||
adapter *vowifi.EC20Adapter,
|
||||
adapter vowifiDeviceAdapter,
|
||||
) (*vowifi.Orchestrator, error) {
|
||||
apn := deviceConfig.APN
|
||||
if apn == "" {
|
||||
@@ -716,9 +834,18 @@ func provisionDiscoveredDevices(
|
||||
candidate := discovered.Candidate
|
||||
backend := "at"
|
||||
control := candidate.ATPort.OpenPath()
|
||||
deviceType := store.DeviceTypePCIeEC20EC25
|
||||
esimTransport := backend
|
||||
if candidate.QMIControl != "" {
|
||||
backend = "qmi"
|
||||
control = candidate.QMIControl
|
||||
esimTransport = backend
|
||||
}
|
||||
if candidate.HardwareKind == pcsc.HardwareKind {
|
||||
backend = "pcsc"
|
||||
control = candidate.ReaderName
|
||||
deviceType = store.DeviceTypeUSBSIMReader
|
||||
esimTransport = "pcsc"
|
||||
}
|
||||
name := candidate.Product
|
||||
if name == "" || strings.EqualFold(name, "Android") {
|
||||
@@ -727,6 +854,7 @@ func provisionDiscoveredDevices(
|
||||
if err := database.UpsertDevice(ctx, store.Device{
|
||||
ID: discovered.ID,
|
||||
Name: name,
|
||||
DeviceType: deviceType,
|
||||
Interface: candidate.NetworkInterface,
|
||||
ControlDevice: control,
|
||||
ATPort: candidate.ATPort.OpenPath(),
|
||||
@@ -737,7 +865,7 @@ func provisionDiscoveredDevices(
|
||||
StopBits: 1,
|
||||
Parity: "none",
|
||||
DeviceBackend: backend,
|
||||
ESIMTransport: backend,
|
||||
ESIMTransport: esimTransport,
|
||||
NetworkEnabled: false,
|
||||
SMSEnabled: true,
|
||||
VoWiFiEnabled: true,
|
||||
@@ -866,7 +994,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 +1041,7 @@ func reconcileCardPolicies(
|
||||
manager *device.Manager,
|
||||
vowifiManager *vowifiruntime.Manager,
|
||||
) {
|
||||
observedCards := make(map[string]string)
|
||||
reconcile := func() {
|
||||
policies, policyListErr := database.ListCardPolicies(ctx)
|
||||
if policyListErr == nil {
|
||||
@@ -935,12 +1064,26 @@ func reconcileCardPolicies(
|
||||
for _, config := range configs {
|
||||
entry, mapErr := mapper.Get(config.ID)
|
||||
if mapErr != nil || entry.Snapshot == nil {
|
||||
if config.DeviceType == store.DeviceTypeUSBSIMReader && observedCards[config.ID] != "missing" {
|
||||
if state, stateErr := vowifiManager.State(config.ID); stateErr == nil && state.ICCID != "" {
|
||||
_, _ = vowifiManager.RequestReconnect(config.ID)
|
||||
}
|
||||
observedCards[config.ID] = "missing"
|
||||
}
|
||||
continue
|
||||
}
|
||||
iccid := strings.TrimSpace(entry.Snapshot.ICCID)
|
||||
if iccid == "" {
|
||||
if config.DeviceType == store.DeviceTypeUSBSIMReader && observedCards[config.ID] != "missing" {
|
||||
if state, stateErr := vowifiManager.State(config.ID); stateErr == nil && state.ICCID != "" {
|
||||
_, _ = vowifiManager.RequestReconnect(config.ID)
|
||||
}
|
||||
observedCards[config.ID] = "missing"
|
||||
}
|
||||
continue
|
||||
}
|
||||
previousObserved := observedCards[config.ID]
|
||||
observedCards[config.ID] = iccid
|
||||
policy, policyErr := database.CardPolicy(ctx, iccid)
|
||||
if policyErr != nil {
|
||||
continue
|
||||
@@ -953,11 +1096,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 +1126,8 @@ func reconcileCardPolicies(
|
||||
_, _ = vowifiManager.RequestEnabled(config.ID, true)
|
||||
case state.ICCID != "" && !strings.EqualFold(strings.TrimSpace(state.ICCID), iccid):
|
||||
_, _ = vowifiManager.RequestReconnect(config.ID)
|
||||
case config.DeviceType == store.DeviceTypeUSBSIMReader && previousObserved == "missing":
|
||||
_, _ = vowifiManager.RequestReconnect(config.ID)
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -1044,15 +1197,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"vocat/internal/device"
|
||||
)
|
||||
|
||||
type startupFlightSetter struct {
|
||||
errors []error
|
||||
calls int
|
||||
id string
|
||||
}
|
||||
|
||||
func (setter *startupFlightSetter) SetFlight(
|
||||
_ context.Context,
|
||||
id string,
|
||||
enabled bool,
|
||||
) (device.FlightResult, error) {
|
||||
setter.calls++
|
||||
setter.id = id
|
||||
if !enabled {
|
||||
return device.FlightResult{}, errors.New("expected flight mode to be enabled")
|
||||
}
|
||||
if setter.calls <= len(setter.errors) {
|
||||
return device.FlightResult{}, setter.errors[setter.calls-1]
|
||||
}
|
||||
return device.FlightResult{CurrentMode: 4, FlightMode: true, RadioOff: true}, nil
|
||||
}
|
||||
|
||||
func TestProtectVoWiFiStartupRadioRetriesTransientFailure(t *testing.T) {
|
||||
transient := errors.New("modem is reopening")
|
||||
setter := &startupFlightSetter{errors: []error{transient, transient}}
|
||||
if err := protectVoWiFiStartupRadioWithRetry(
|
||||
context.Background(), setter, "quectel-1", 3, 0,
|
||||
); err != nil {
|
||||
t.Fatalf("protect startup radio: %v", err)
|
||||
}
|
||||
if setter.calls != 3 || setter.id != "quectel-1" {
|
||||
t.Fatalf("SetFlight calls = %d, id = %q", setter.calls, setter.id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtectVoWiFiStartupRadioReturnsLastFailure(t *testing.T) {
|
||||
first := errors.New("first")
|
||||
last := errors.New("last")
|
||||
setter := &startupFlightSetter{errors: []error{first, last}}
|
||||
err := protectVoWiFiStartupRadioWithRetry(
|
||||
context.Background(), setter, "quectel-1", 2, 0,
|
||||
)
|
||||
if !errors.Is(err, last) || setter.calls != 2 {
|
||||
t.Fatalf("protect startup radio = %v after %d calls", err, setter.calls)
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ Restart=on-failure
|
||||
RestartSec=3s
|
||||
TimeoutStartSec=30s
|
||||
TimeoutStopSec=20s
|
||||
Environment=VOCAT_ADDR=0.0.0.0:7575
|
||||
Environment=VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db
|
||||
EnvironmentFile=/etc/vocat/vocat.env
|
||||
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
<p align="center">
|
||||
<img src="../web/public/favicon.svg" width="96" alt="Vocat">
|
||||
</p>
|
||||
|
||||
<h1 align="center">VoCat</h1>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Go" src="https://img.shields.io/badge/Go-1.25-00ADD8?style=flat-square&logo=go&logoColor=white">
|
||||
<img alt="React" src="https://img.shields.io/badge/React-19-61DAFB?style=flat-square&logo=react&logoColor=111111">
|
||||
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5.8-3178C6?style=flat-square&logo=typescript&logoColor=white">
|
||||
<img alt="Vite" src="https://img.shields.io/badge/Vite-7-646CFF?style=flat-square&logo=vite&logoColor=white">
|
||||
<img alt="Tailwind CSS" src="https://img.shields.io/badge/Tailwind_CSS-3-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white">
|
||||
<img alt="SQLite" src="https://img.shields.io/badge/SQLite-Embedded-003B57?style=flat-square&logo=sqlite&logoColor=white">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Linux" src="https://img.shields.io/badge/Linux-amd64_%7C_386_%7C_arm64_%7C_aarch64_%7C_armv7-FCC624?style=flat-square&logo=linux&logoColor=111111">
|
||||
<img alt="Docker" src="https://img.shields.io/badge/Docker-Multi--Arch-2496ED?style=flat-square&logo=docker&logoColor=white">
|
||||
<img alt="WiFi Calling" src="https://img.shields.io/badge/WiFi_Calling-IMS_SMS-7B1FA2?style=flat-square">
|
||||
<img alt="eSIM" src="https://img.shields.io/badge/eSIM-LPA_%2F_eUICC-009688?style=flat-square">
|
||||
<img alt="Telegram" src="https://img.shields.io/badge/Telegram-Bot-26A5E4?style=flat-square&logo=telegram&logoColor=white">
|
||||
<img alt="GitHub Actions" src="https://img.shields.io/badge/GitHub_Actions-Release-2088FF?style=flat-square&logo=githubactions&logoColor=white">
|
||||
</p>
|
||||
|
||||
[English](../README.md) | **العربية** | [简体中文](README.zh-CN.md) | [繁體中文](README.zh-TW.md) | [Français](README.fr.md) | [Русский](README.ru.md) | [Español](README.es.md) | [日本語](README.ja.md)
|
||||
|
||||
Vocat هي لوحة تحكم ويب مفتوحة المصدر ومجموعة أدوات هندسية لمودمات Quectel الخلوية من فئة EC20/EC25. تجمع في خدمة واحدة مكتفية ذاتيًا بين اكتشاف المودم، وحالة الراديو المباشرة، وطرفيات AT وUSSD، والرسائل القصيرة SMS، وWiFi Calling، وإدارة eSIM، واختيار الشبكة، والتوجيه عبر البروكسي، والإشعارات، وسجلات التدقيق، وأتمتة الإصدارات.
|
||||
|
||||
الواجهة الخلفية مكتوبة بلغة Go، والواجهة مبنية باستخدام React وTypeScript، وتُضمَّن واجهة الإنتاج الأمامية داخل الملف الثنائي لـ Go. يحتوي ملف تنفيذي واحد على تطبيق الويب ويستخدم SQLite للحالة الدائمة.
|
||||
|
||||
<p align="center">
|
||||
<img src="../img/image.png">
|
||||
<img src="../img/image-1.png">
|
||||
</p>
|
||||
|
||||
## الميزات
|
||||
|
||||
| المجال | ما يوفره Vocat |
|
||||
| --- | --- |
|
||||
| إدارة الأجهزة | اكتشاف تلقائي عبر المنفذ التسلسلي/USB، دعم عدة مودمات، أسماء أجهزة مألوفة، تحديثات مباشرة للنظرة العامة، إعادة تشغيل الوحدة، وضع الطيران، وضوابط وضع شبكة USB. |
|
||||
| الراديو والشبكة | حالة التسجيل، المشغّل، مقاييس الإشارة، RSRP/RSRQ/SINR، وضع الشبكة، النطاق، القناة، فحص المشغّلين، والاختيار التلقائي أو اليدوي للشبكة. |
|
||||
| AT وUSSD | طرفية AT تفاعلية، سجل الأوامر، استجابات المودم الخام، تدفقات بدء/متابعة/إلغاء USSD، والإبلاغ الواضح عن أخطاء المودم. |
|
||||
| الرسائل القصيرة | إرسال مباشر للرسائل الخلوية ورسائل IMS، المزامنة الواردة، التعامل مع الرسائل متعددة الأجزاء، تقارير التسليم، سجل المحادثات، حالة عدم القراءة، الطوابع الزمنية، وحالة التسليم لكل رسالة. |
|
||||
| WiFi Calling | إنشاء نفق IKEv2/ePDG، مصادقة EAP-AKA، تسجيل IMS، رسائل IMS القصيرة، ضوابط إعادة الاتصال، تشخيصات الحالة، والتوجيه لكل جهاز. |
|
||||
| eSIM وeUICC | اكتشاف eUICC، ومعلومات EID والإنتاج، والبيانات الوصفية للشهادات، وجرد متعدد لـ eUICC، وقائمة الملفات الشخصية المثبتة، وعمليات التمكين/التعطيل/التبديل، وعمليات التنزيل وإعادة التسمية والحذف عندما تدعمها البطاقة. |
|
||||
| سياسة البطاقة | سلوك WiFi Calling ووضع الطيران بناءً على ICCID مع تطبيق فوري للسياسة. |
|
||||
| التوجيه عبر البروكسي | توجيه SOCKS صاعد، ربط الأجهزة، قواعد الدول، فحوصات الوصول عبر TCP، وفحوصات UDP Associate لمسارات بيانات WiFi Calling. |
|
||||
| الإشعارات | إعادة توجيه الرسائل القصيرة الواردة الجديدة عبر Telegram وBark والبريد الإلكتروني وPushplus وwebhooks الموقّعة. يتم تسليم كل رسالة كإشعار منفصل. |
|
||||
| بوت Telegram | حالة الجهاز، قائمة الملفات الشخصية المثبتة وتبديلها، ضوابط WiFi Calling، وإرسال الرسائل القصيرة. تتطلب الإجراءات الحساسة تأكيد المسؤول. |
|
||||
| العمليات | المصادقة، الحماية من CSRF، سياسات الوصول، أحداث التدقيق، السجلات المباشرة، الاحتفاظ بالسجلات، فحوصات الصحة، تخطيط متجاوب، الوضع الداكن، وواجهة مستخدم بالإنجليزية/الصينية. |
|
||||
| التوزيع | ملفات Linux الثنائية الثابتة، سكربت تثبيت systemd، تحديث ذاتي مع التحقق من SHA-256، صورة Docker، النشر إلى GHCR، وبنى إصدارات GitHub Actions. |
|
||||
|
||||
## الأجهزة المدعومة
|
||||
|
||||
يستهدف Vocat وحدات Quectel المبنية على Qualcomm والتي توفر واجهات AT وQMI والمنفذ التسلسلي وشبكة USB المتوافقة، بما في ذلك:
|
||||
|
||||
- Quectel EC20
|
||||
- Quectel EC25
|
||||
- عائلة Quectel EG25
|
||||
- وحدات EG600 المتوافقة وذات الصلة
|
||||
|
||||
تعتمد الميزات المتاحة على برنامج الوحدة الثابت (firmware)، وتكوين USB، وقدرات SIM/eSIM، وتعريفات المضيف، والشبكة اللاسلكية، وإعدادات المشغّل.
|
||||
|
||||
## التثبيت
|
||||
|
||||
### تثبيت Linux بنقرة واحدة
|
||||
|
||||
بصفتك root (بما في ذلك OpenWrt/Kwrt، حيث يكون `sudo` غير موجود عادةً):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
من مستخدم عادي على توزيعة تحتوي على 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
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh -o install.sh
|
||||
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 من مستودع البرنامج الثابت نفسه.
|
||||
إذا لم تكن وحدات النواة المطابقة متاحة، فاستخدم برنامجًا ثابتًا يتضمنها؛
|
||||
ولا تفرض أبدًا تثبيت kmods مبنية لنواة مختلفة.
|
||||
|
||||
المثبّت:
|
||||
|
||||
- يكتشف `amd64` أو `386` أو `arm64` أو `aarch64` أو `armv7`؛
|
||||
- ينزّل الملف الثنائي المطابق من GitHub Release؛
|
||||
- يتحقق منه مقابل `SHA256SUMS`؛
|
||||
- يثبّت Vocat في `/opt/vocat`؛
|
||||
- ينشئ خدمة systemd محصّنة بوصول الأجهزة والشبكة الذي يتطلبه Vocat؛
|
||||
- يخزّن إعدادات وقت التشغيل في `/etc/vocat/env`؛
|
||||
- يولّد كلمة مرور مسؤول أولية عشوائية عند التثبيت الأول.
|
||||
|
||||
بعد التثبيت، افتح:
|
||||
|
||||
```text
|
||||
http://<عنوان-الخادم>:7575
|
||||
```
|
||||
|
||||
### التثبيت اليدوي للملف الثنائي
|
||||
|
||||
نزّل الملف الثنائي المطابق و`SHA256SUMS` من GitHub Releases:
|
||||
|
||||
| المنصة | ملف الإصدار |
|
||||
| --- | --- |
|
||||
| Linux x86-64 | `vocat-linux-amd64` |
|
||||
| Linux x86 32-بت | `vocat-linux-386` |
|
||||
| Linux ARM64 | `vocat-linux-arm64` |
|
||||
| Linux AArch64 | `vocat-linux-aarch64` |
|
||||
| Linux ARMv7 | `vocat-linux-armv7` |
|
||||
|
||||
تحقق منه وثبّته:
|
||||
|
||||
```bash
|
||||
sha256sum -c SHA256SUMS --ignore-missing
|
||||
sudo install -d -m 0755 /opt/vocat/bin /opt/vocat/data
|
||||
sudo install -m 0755 vocat-linux-amd64 /opt/vocat/bin/vocat
|
||||
sudo env \
|
||||
VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db \
|
||||
VOCAT_ADMIN_PASSWORD=change-this-password \
|
||||
/opt/vocat/bin/vocat serve
|
||||
```
|
||||
|
||||
يشغّل هذا الأمر اليدوي Vocat في المقدمة. استخدم `vocat serve` حتى
|
||||
يبدأ العملية الخادم مباشرةً؛ إن تشغيل `vocat` دون وسائط بصفتك root
|
||||
على TTY يفتح بدلاً من ذلك قائمة الإدارة التفاعلية. استخدم المثبّت بنقرة
|
||||
واحدة عند الحاجة إلى خدمة systemd مُدارة وإعادة تشغيل تلقائية.
|
||||
|
||||
### Docker
|
||||
|
||||
لمضيف Linux الذي يجب أن يكتشف كل مودم Quectel مدعوم متصل ويواصل
|
||||
رؤية أحداث التوصيل الساخن لـ USB، شغّل Vocat في وضع الوصول إلى الأجهزة:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/mengmengcode/vocat:latest
|
||||
|
||||
docker run -d \
|
||||
--name vocat \
|
||||
--restart unless-stopped \
|
||||
--network host \
|
||||
--privileged \
|
||||
--user 0:0 \
|
||||
-e VOCAT_ADMIN_PASSWORD=change-this-password \
|
||||
-v vocat-data:/opt/vocat/data \
|
||||
-v /dev:/dev \
|
||||
-v /sys:/sys:ro \
|
||||
ghcr.io/mengmengcode/vocat:latest
|
||||
```
|
||||
|
||||
افتح `http://<عنوان-الخادم>:7575` بعد بدء الحاوية. شبكة المضيف
|
||||
مطلوبة حتى تبقى واجهات شبكة QMI مرئية لـ Vocat، بينما الوصول المميّز إلى الأجهزة
|
||||
مطلوب للمنافذ التسلسلية، وعقد تحكم QMI، وواجهات TUN، وإعدادات الشبكة، والأجهزة
|
||||
المضافة بعد بدء الحاوية. يجعل تركيب `/dev` العقد الجديدة `ttyUSB*` و`ttyACM*` و`cdc-wdm*`
|
||||
مرئية دون إعادة إنشاء الحاوية.
|
||||
|
||||
يمنح هذا الوضع عمدًا Vocat وصولاً واسعًا إلى أجهزة المضيف ومكدس الشبكة.
|
||||
استخدمه فقط على مضيف Linux موثوق. يتعرف الاكتشاف التلقائي حاليًا على مودمات
|
||||
Quectel USB المدعومة (معرّف الشركة المصنعة USB `2c7c`)، وليس على ماركات مودم عشوائية.
|
||||
إن تركيب العقد الفردية فقط باستخدام `--device`، مثل `/dev/ttyUSB2` و`/dev/cdc-wdm0`،
|
||||
يحصر الحاوية في تلك العقد الثابتة ولا يوفر اكتشافًا كاملاً متعدد الأجهزة أو بالتوصيل الساخن.
|
||||
|
||||
تُنشر صورة GHCR لـ `linux/amd64` و`linux/arm64`.
|
||||
|
||||
## الإعدادات
|
||||
|
||||
يقرأ Vocat ملف إعدادات JSON اختياريًا من `VOCAT_CONFIG`، ثم يطبق متغيرات البيئة `VOCAT_*`. متغيرات البيئة لها الأولوية.
|
||||
|
||||
| متغير البيئة | الافتراضي | الوصف |
|
||||
| --- | --- | --- |
|
||||
| `VOCAT_ADDR` | `0.0.0.0:7575` | عنوان الاستماع HTTP. |
|
||||
| `VOCAT_DATABASE_PATH` | `./data/vocat.db` | مسار قاعدة بيانات SQLite. |
|
||||
| `VOCAT_ADMIN_USERNAME` | `admin` | اسم مستخدم المسؤول الأولي. |
|
||||
| `VOCAT_ADMIN_PASSWORD` | `admin` | كلمة مرور المسؤول الأولية. غيّرها قبل تعريض الخدمة للوصول. |
|
||||
| `VOCAT_SESSION_TTL` | `24h` | مدة صلاحية جلسة المصادقة. |
|
||||
| `VOCAT_SECURE_COOKIES` | `false` | يضع علامة آمنة على ملفات تعريف ارتباط الجلسة عند استخدام HTTPS. |
|
||||
| `VOCAT_SHUTDOWN_TIMEOUT` | `10s` | مهلة الإيقاف السلس. |
|
||||
| `VOCAT_MAX_REQUEST_BODY_BYTES` | `1048576` | الحد الأقصى لحجم جسم طلب API. |
|
||||
| `VOCAT_REPO` | `MengMengCode/VoCat` | مستودع GitHub الموثوق الذي يستخدمه المحدّث الذاتي، بصيغة `owner/name`. |
|
||||
| `GITHUB_TOKEN` | فارغ | رمز GitHub اختياري للمستودعات الخاصة أو حدود API أعلى. |
|
||||
|
||||
لا تخزّن رموز Telegram، أو كلمات مرور SMTP، أو أسرار webhook، أو بيانات اعتماد SIM، أو بيانات خاصة أخرى في المستودع. قم بإعدادها عبر إعدادات التطبيق أو ملفات البيئة المحمية.
|
||||
|
||||
## بوت Telegram
|
||||
|
||||
عند تفعيل إشعارات Telegram وإعداد كلٍّ من Chat ID وAdmin ID، يدعم البوت:
|
||||
|
||||
```text
|
||||
/status [الجهاز]
|
||||
/esim <الجهاز>
|
||||
/switch <الجهاز> <iccid>
|
||||
/wfc <الجهاز> <status|on|off|reconnect>
|
||||
/sms <الجهاز> <الرقم> <الرسالة>
|
||||
```
|
||||
|
||||
تستخدم عمليتا تبديل الملفات الشخصية وإرسال الرسائل القصيرة أزرار تأكيد لمرة واحدة. لا يعرض البوت أوامر تنزيل أو حذف أو إعادة تسمية eSIM.
|
||||
|
||||
## التحديث
|
||||
|
||||
تحقق من وجود GitHub Release أحدث:
|
||||
|
||||
```bash
|
||||
vocat update --check --repo MengMengCode/VoCat
|
||||
```
|
||||
|
||||
ثبّت أحدث إصدار:
|
||||
|
||||
```bash
|
||||
sudo vocat update --repo MengMengCode/VoCat
|
||||
```
|
||||
|
||||
ينزّل المحدّث الملف الثنائي المطابق لبنية Linux الحالية، ويتحقق منه باستخدام `SHA256SUMS` المنشور، ويستبدل الملف التنفيذي بشكل ذري، ويعيد تشغيل خدمة systemd `vocat` عند توفرها.
|
||||
|
||||
لتثبيتات Docker:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/mengmengcode/vocat:latest
|
||||
```
|
||||
|
||||
أعد إنشاء الحاوية بعد سحب الصورة الجديدة.
|
||||
|
||||
## التطوير
|
||||
|
||||
المتطلبات:
|
||||
|
||||
- Go 1.25 أو أحدث
|
||||
- Node.js 20 أو أحدث
|
||||
- npm
|
||||
|
||||
تشغيل خادم تطوير الواجهة الأمامية:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
بناء الواجهة الأمامية المضمّنة وتشغيل الواجهة الخلفية:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm run build
|
||||
cd ..
|
||||
go run ./cmd/vocat
|
||||
```
|
||||
|
||||
تشغيل جميع الاختبارات:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
بناء ملف ثنائي للإنتاج:
|
||||
|
||||
```bash
|
||||
go build -trimpath -ldflags "-s -w" -o vocat ./cmd/vocat
|
||||
```
|
||||
|
||||
## أتمتة الإصدارات
|
||||
|
||||
يؤدي دفع وسم الإصدار إلى بدء سير عملَي GitHub Actions:
|
||||
|
||||
- `release-binaries` يبني وينشر ملفات `amd64` و`386` و`arm64` و`aarch64` و`armv7` الثنائية مع `SHA256SUMS`.
|
||||
- `docker` يبني وينشر صورة متعددة البنى إلى GitHub Container Registry.
|
||||
|
||||
```bash
|
||||
git tag v0.2.0
|
||||
git push origin v0.2.0
|
||||
```
|
||||
|
||||
## بنية المشروع
|
||||
|
||||
```text
|
||||
cmd/vocat/ نقطة دخول التطبيق وCLI
|
||||
internal/device/ اكتشاف المودم والتحكم في الأجهزة
|
||||
internal/modem/ جلسة AT ومعالجة الاستجابات
|
||||
internal/server/ واجهة HTTP API والإشعارات وخادم الويب المضمّن
|
||||
internal/store/ التخزين الدائم SQLite
|
||||
internal/update/ المحدّث الذاتي لـ GitHub Release
|
||||
internal/vowifi/ بيئة تشغيل IKE وEAP-AKA وIMS وWiFi Calling
|
||||
scripts/install.sh مثبّت ومحدّث Linux
|
||||
web/src/ الواجهة الأمامية React وTypeScript
|
||||
.github/workflows/ أتمتة إصدارات الملفات الثنائية وDocker
|
||||
```
|
||||
|
||||
## الاستخدام المسؤول
|
||||
|
||||
يمكن أن تؤثر عمليات المودم الخلوي وeSIM في خدمة المشترك، والملفات الشخصية المخزنة، وتسجيل الشبكة، وحالة الأجهزة. احتفظ بنسخ احتياطية، وراجع الإجراءات المدمّرة بعناية، واستخدم البرنامج فقط في بيئات قانونية يُسمح لك فيها بتشغيل الأجهزة وموارد الشبكة المتصلة.
|
||||
|
||||
لا يتجاوز Vocat مصادقة المشغّل، أو سياسة الشبكة، أو أمان الأجهزة، أو متطلبات الثقة لـ eSIM. إن دعم عملية ما يعني أن Vocat يمكن أن يطلبها من المودم أو eUICC؛ وقد يرفضها الجهاز أو الملف الشخصي أو الشبكة أو المشغّل مع ذلك.
|
||||
|
||||
## المساهمة
|
||||
|
||||
نرحّب بالمسائل (issues) وطلبات السحب (pull requests). حافظ على التغييرات مركّزة، وأضِف الاختبارات حيثما أمكن، وتجنّب إيداع بيانات الاعتماد أو بيانات المشتركين، ووثّق بوضوح السلوك الخاص بالأجهزة.
|
||||
|
||||
قبل إرسال تغيير:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
cd web && npm run build
|
||||
```
|
||||
|
||||
## شكر وتقدير
|
||||
- [Nodeseek.com](https://www.nodeseek.com) — مجتمع مكرّس للخوادم
|
||||
- [Linux.do](https://linux.do) — مجتمع تقني ملهم
|
||||
- [iniwex5](https://github.com/iniwex5) — إرشادات الأسلوب والوظائف
|
||||
|
||||
## ادعُني إلى فنجان قهوة
|
||||
|
||||
| الشبكة | العنوان |
|
||||
| ------- | ------- |
|
||||
| USDT-TRON (TRC20) | `TQQAbboBoU8h5xX4YCA1rqWJU2WjK3seSg` |
|
||||
| USDT-BSC (BEP20) | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
|
||||
| USDT-Polygon | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
|
||||
|
||||
## الرخصة
|
||||
|
||||
انظر [LICENSE](../LICENSE).
|
||||
|
||||
[](https://meteor-history.com)
|
||||
@@ -0,0 +1,336 @@
|
||||
<p align="center">
|
||||
<img src="../web/public/favicon.svg" width="96" alt="Vocat">
|
||||
</p>
|
||||
|
||||
<h1 align="center">VoCat</h1>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Go" src="https://img.shields.io/badge/Go-1.25-00ADD8?style=flat-square&logo=go&logoColor=white">
|
||||
<img alt="React" src="https://img.shields.io/badge/React-19-61DAFB?style=flat-square&logo=react&logoColor=111111">
|
||||
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5.8-3178C6?style=flat-square&logo=typescript&logoColor=white">
|
||||
<img alt="Vite" src="https://img.shields.io/badge/Vite-7-646CFF?style=flat-square&logo=vite&logoColor=white">
|
||||
<img alt="Tailwind CSS" src="https://img.shields.io/badge/Tailwind_CSS-3-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white">
|
||||
<img alt="SQLite" src="https://img.shields.io/badge/SQLite-Embedded-003B57?style=flat-square&logo=sqlite&logoColor=white">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Linux" src="https://img.shields.io/badge/Linux-amd64_%7C_386_%7C_arm64_%7C_aarch64_%7C_armv7-FCC624?style=flat-square&logo=linux&logoColor=111111">
|
||||
<img alt="Docker" src="https://img.shields.io/badge/Docker-Multi--Arch-2496ED?style=flat-square&logo=docker&logoColor=white">
|
||||
<img alt="WiFi Calling" src="https://img.shields.io/badge/WiFi_Calling-IMS_SMS-7B1FA2?style=flat-square">
|
||||
<img alt="eSIM" src="https://img.shields.io/badge/eSIM-LPA_%2F_eUICC-009688?style=flat-square">
|
||||
<img alt="Telegram" src="https://img.shields.io/badge/Telegram-Bot-26A5E4?style=flat-square&logo=telegram&logoColor=white">
|
||||
<img alt="GitHub Actions" src="https://img.shields.io/badge/GitHub_Actions-Release-2088FF?style=flat-square&logo=githubactions&logoColor=white">
|
||||
</p>
|
||||
|
||||
[English](../README.md) | [العربية](README.ar.md) | [简体中文](README.zh-CN.md) | [繁體中文](README.zh-TW.md) | [Français](README.fr.md) | [Русский](README.ru.md) | **Español** | [日本語](README.ja.md)
|
||||
|
||||
Vocat es un panel de control web de código abierto y un conjunto de herramientas de ingeniería para módems celulares Quectel de clase EC20/EC25. Combina, en un único servicio autocontenido, el descubrimiento de módems, el estado de radio en vivo, terminales AT y USSD, SMS, WiFi Calling, gestión de eSIM, selección de red, enrutamiento por proxy, notificaciones, registros de auditoría y automatización de versiones.
|
||||
|
||||
El backend está escrito en Go, la interfaz está construida con React y TypeScript, y el frontend de producción está incrustado en el binario de Go. Un único ejecutable contiene la aplicación web y utiliza SQLite para el estado persistente.
|
||||
|
||||
<p align="center">
|
||||
<img src="../img/image.png">
|
||||
<img src="../img/image-1.png">
|
||||
</p>
|
||||
|
||||
## Funcionalidades
|
||||
|
||||
| Área | Lo que proporciona Vocat |
|
||||
| --- | --- |
|
||||
| Gestión de dispositivos | Descubrimiento serie/USB automático, soporte para múltiples módems, nombres de dispositivo amigables, actualizaciones en vivo de la vista general, reinicio del módulo, modo avión y controles del modo de red USB. |
|
||||
| Radio y red | Estado de registro, operador, métricas de señal, RSRP/RSRQ/SINR, modo de red, banda, canal, búsqueda de operadores y selección de red automática o manual. |
|
||||
| AT y USSD | Terminal AT interactivo, historial de comandos, respuestas sin procesar del módem, flujos de inicio/continuación/cancelación de USSD y reporte claro de errores del módem. |
|
||||
| SMS | Envío directo de SMS celulares e IMS, sincronización entrante, manejo multiparte, informes de entrega, historial de conversaciones, estado de no leído, marcas de tiempo y estado de entrega por mensaje. |
|
||||
| WiFi Calling | Establecimiento de túnel IKEv2/ePDG, autenticación EAP-AKA, registro IMS, SMS IMS, controles de reconexión, diagnósticos de estado y enrutamiento por dispositivo. |
|
||||
| eSIM y eUICC | Descubrimiento de eUICC, EID e información de producción, metadatos de certificados, inventario multi-eUICC, lista de perfiles instalados, operaciones de habilitar/deshabilitar/cambiar, y operaciones de descarga, renombrado y eliminación cuando la tarjeta lo admite. |
|
||||
| Política de tarjeta | Comportamiento de WiFi Calling y modo avión basado en ICCID con aplicación inmediata de la política. |
|
||||
| Enrutamiento por proxy | Enrutamiento SOCKS ascendente, vinculaciones de dispositivos, reglas por país, comprobaciones de accesibilidad TCP y comprobaciones UDP Associate para las rutas de datos de WiFi Calling. |
|
||||
| Notificaciones | Reenvío de nuevos SMS entrantes a través de Telegram, Bark, correo electrónico, Pushplus y webhooks firmados. Cada SMS se entrega como una notificación individual. |
|
||||
| Bot de Telegram | Estado del dispositivo, lista y cambio de perfiles instalados, controles de WiFi Calling y envío de SMS. Las acciones sensibles requieren confirmación del administrador. |
|
||||
| Operaciones | Autenticación, protección CSRF, políticas de acceso, eventos de auditoría, registros en vivo, retención de registros, comprobaciones de salud, diseño adaptable, modo oscuro e interfaz de usuario en inglés/chino. |
|
||||
| Distribución | Binarios estáticos de Linux, script de instalación systemd, autoactualización con verificación SHA-256, imagen Docker, publicación en GHCR y compilaciones de versión de GitHub Actions. |
|
||||
|
||||
## Hardware compatible
|
||||
|
||||
Vocat está dirigido a módulos Quectel basados en Qualcomm que exponen interfaces AT, QMI, serie y de red USB compatibles, incluyendo:
|
||||
|
||||
- Quectel EC20
|
||||
- Quectel EC25
|
||||
- Familia Quectel EG25
|
||||
- Módulos EG600 compatibles y relacionados
|
||||
|
||||
Las funciones disponibles dependen del firmware del módulo, la composición USB, las capacidades SIM/eSIM, los controladores del host, la red de radio y la configuración del operador.
|
||||
|
||||
## Instalación
|
||||
|
||||
### Instalación en Linux con un clic
|
||||
|
||||
Como root (incluyendo OpenWrt/Kwrt, donde `sudo` normalmente está ausente):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
Desde un usuario normal en una distribución con sudo:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | sudo bash
|
||||
```
|
||||
|
||||
Comprobar los prerrequisitos de VoWiFi/XFRM del host sin instalar VoCat:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash -s -- --check-env
|
||||
```
|
||||
|
||||
Instalar una versión específica:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh -o install.sh
|
||||
sudo bash install.sh 0.0.2
|
||||
```
|
||||
|
||||
VoWiFi IMS requiere Linux XFRM/IPsec. En OpenWrt/Kwrt el instalador intenta
|
||||
instalar los paquetes coincidentes `ip-full`, `kmod-ipsec`, `kmod-ipsec4/6`,
|
||||
`kmod-crypto-authenc`, AES-CBC y SHA1 desde el propio feed del firmware.
|
||||
Si no hay módulos de kernel coincidentes disponibles, use un firmware que los incluya;
|
||||
nunca fuerce la instalación de kmods compilados para un kernel diferente.
|
||||
|
||||
El instalador:
|
||||
|
||||
- detecta `amd64`, `386`, `arm64`, `aarch64` o `armv7`;
|
||||
- descarga el binario de GitHub Release correspondiente;
|
||||
- lo verifica contra `SHA256SUMS`;
|
||||
- instala Vocat en `/opt/vocat`;
|
||||
- crea un servicio systemd reforzado con el acceso a hardware y red requerido por Vocat;
|
||||
- almacena la configuración en tiempo de ejecución en `/etc/vocat/env`;
|
||||
- genera una contraseña de administrador inicial aleatoria en la primera instalación.
|
||||
|
||||
Después de la instalación, abra:
|
||||
|
||||
```text
|
||||
http://<dirección-del-servidor>:7575
|
||||
```
|
||||
|
||||
### Instalación manual del binario
|
||||
|
||||
Descargue el binario correspondiente y `SHA256SUMS` desde GitHub Releases:
|
||||
|
||||
| Plataforma | Archivo de versión |
|
||||
| --- | --- |
|
||||
| Linux x86-64 | `vocat-linux-amd64` |
|
||||
| Linux x86 32 bits | `vocat-linux-386` |
|
||||
| Linux ARM64 | `vocat-linux-arm64` |
|
||||
| Linux AArch64 | `vocat-linux-aarch64` |
|
||||
| Linux ARMv7 | `vocat-linux-armv7` |
|
||||
|
||||
Verifíquelo e instálelo:
|
||||
|
||||
```bash
|
||||
sha256sum -c SHA256SUMS --ignore-missing
|
||||
sudo install -d -m 0755 /opt/vocat/bin /opt/vocat/data
|
||||
sudo install -m 0755 vocat-linux-amd64 /opt/vocat/bin/vocat
|
||||
sudo env \
|
||||
VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db \
|
||||
VOCAT_ADMIN_PASSWORD=change-this-password \
|
||||
/opt/vocat/bin/vocat serve
|
||||
```
|
||||
|
||||
Este comando manual ejecuta Vocat en primer plano. Use `vocat serve` para que el
|
||||
proceso inicie el servidor directamente; ejecutar `vocat` sin argumentos como root
|
||||
en un TTY abre en su lugar el menú de gestión interactivo. Use el instalador de
|
||||
un clic cuando se requiera un servicio systemd gestionado y reinicio automático.
|
||||
|
||||
### Docker
|
||||
|
||||
Para un host Linux que debe descubrir cada módem Quectel compatible conectado y
|
||||
seguir viendo los eventos de conexión en caliente USB, ejecute Vocat en modo de acceso a hardware:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/mengmengcode/vocat:latest
|
||||
|
||||
docker run -d \
|
||||
--name vocat \
|
||||
--restart unless-stopped \
|
||||
--network host \
|
||||
--privileged \
|
||||
--user 0:0 \
|
||||
-e VOCAT_ADMIN_PASSWORD=change-this-password \
|
||||
-v vocat-data:/opt/vocat/data \
|
||||
-v /dev:/dev \
|
||||
-v /sys:/sys:ro \
|
||||
ghcr.io/mengmengcode/vocat:latest
|
||||
```
|
||||
|
||||
Abra `http://<dirección-del-servidor>:7575` después de que el contenedor se inicie. La red del host
|
||||
es necesaria para que las interfaces de red QMI permanezcan visibles para Vocat, mientras que el
|
||||
acceso privilegiado a dispositivos es necesario para los puertos serie, los nodos de control QMI,
|
||||
las interfaces TUN, la configuración de red y los dispositivos añadidos después de que el contenedor
|
||||
se inicie. El montaje bind de `/dev` hace visibles los nuevos nodos `ttyUSB*`, `ttyACM*` y `cdc-wdm*`
|
||||
sin recrear el contenedor.
|
||||
|
||||
Este modo otorga intencionadamente a Vocat un amplio acceso a los dispositivos y a la pila de red
|
||||
del host. Úselo solo en un host Linux de confianza. El descubrimiento automático identifica
|
||||
actualmente los módems USB Quectel compatibles (ID de fabricante USB `2c7c`), no marcas de módems
|
||||
arbitrarias. Mapear solo nodos individuales con `--device`, como `/dev/ttyUSB2` y `/dev/cdc-wdm0`,
|
||||
limita el contenedor a esos nodos fijos y no proporciona un descubrimiento completo de múltiples
|
||||
dispositivos o de conexión en caliente.
|
||||
|
||||
La imagen GHCR se publica para `linux/amd64` y `linux/arm64`.
|
||||
|
||||
## Configuración
|
||||
|
||||
Vocat lee un archivo de configuración JSON opcional desde `VOCAT_CONFIG` y luego aplica las variables de entorno `VOCAT_*`. Las variables de entorno tienen prioridad.
|
||||
|
||||
| Variable de entorno | Predeterminado | Descripción |
|
||||
| --- | --- | --- |
|
||||
| `VOCAT_ADDR` | `0.0.0.0:7575` | Dirección de escucha HTTP. |
|
||||
| `VOCAT_DATABASE_PATH` | `./data/vocat.db` | Ruta de la base de datos SQLite. |
|
||||
| `VOCAT_ADMIN_USERNAME` | `admin` | Nombre de usuario administrador inicial. |
|
||||
| `VOCAT_ADMIN_PASSWORD` | `admin` | Contraseña de administrador inicial. Cámbiela antes de exponer el servicio. |
|
||||
| `VOCAT_SESSION_TTL` | `24h` | Duración de la sesión de autenticación. |
|
||||
| `VOCAT_SECURE_COOKIES` | `false` | Marca las cookies de sesión como seguras cuando se usa HTTPS. |
|
||||
| `VOCAT_SHUTDOWN_TIMEOUT` | `10s` | Tiempo de espera de apagado ordenado. |
|
||||
| `VOCAT_MAX_REQUEST_BODY_BYTES` | `1048576` | Tamaño máximo del cuerpo de solicitud de la API. |
|
||||
| `VOCAT_REPO` | `MengMengCode/VoCat` | Repositorio de GitHub de confianza usado por el autoactualizador, en formato `owner/name`. |
|
||||
| `GITHUB_TOKEN` | vacío | Token de GitHub opcional para repositorios privados o límites de API más altos. |
|
||||
|
||||
No almacene tokens de Telegram, contraseñas SMTP, secretos de webhook, credenciales SIM u otros datos privados en el repositorio. Configúrelos a través de los ajustes de la aplicación o archivos de entorno protegidos.
|
||||
|
||||
## Bot de Telegram
|
||||
|
||||
Cuando las notificaciones de Telegram están habilitadas y tanto el Chat ID como el Admin ID están configurados, el bot admite:
|
||||
|
||||
```text
|
||||
/status [dispositivo]
|
||||
/esim <dispositivo>
|
||||
/switch <dispositivo> <iccid>
|
||||
/wfc <dispositivo> <status|on|off|reconnect>
|
||||
/sms <dispositivo> <número> <mensaje>
|
||||
```
|
||||
|
||||
El cambio de perfil y el envío de SMS usan botones de confirmación de un solo uso. El bot no expone comandos de descarga, eliminación o renombrado de eSIM.
|
||||
|
||||
## Actualización
|
||||
|
||||
Comprobar si hay una GitHub Release más reciente:
|
||||
|
||||
```bash
|
||||
vocat update --check --repo MengMengCode/VoCat
|
||||
```
|
||||
|
||||
Instalar la última versión:
|
||||
|
||||
```bash
|
||||
sudo vocat update --repo MengMengCode/VoCat
|
||||
```
|
||||
|
||||
El actualizador descarga el binario que coincide con la arquitectura Linux actual, lo verifica con el `SHA256SUMS` publicado, reemplaza el ejecutable de forma atómica y reinicia el servicio systemd `vocat` cuando está disponible.
|
||||
|
||||
Para instalaciones Docker:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/mengmengcode/vocat:latest
|
||||
```
|
||||
|
||||
Recrear el contenedor después de descargar la nueva imagen.
|
||||
|
||||
## Desarrollo
|
||||
|
||||
Requisitos:
|
||||
|
||||
- Go 1.25 o más reciente
|
||||
- Node.js 20 o más reciente
|
||||
- npm
|
||||
|
||||
Ejecutar el servidor de desarrollo del frontend:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Compilar el frontend incrustado e iniciar el backend:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm run build
|
||||
cd ..
|
||||
go run ./cmd/vocat
|
||||
```
|
||||
|
||||
Ejecutar todas las pruebas:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Compilar un binario de producción:
|
||||
|
||||
```bash
|
||||
go build -trimpath -ldflags "-s -w" -o vocat ./cmd/vocat
|
||||
```
|
||||
|
||||
## Automatización de versiones
|
||||
|
||||
Hacer push de una etiqueta de versión inicia dos flujos de trabajo de GitHub Actions:
|
||||
|
||||
- `release-binaries` compila y publica los binarios `amd64`, `386`, `arm64`, `aarch64` y `armv7` más `SHA256SUMS`.
|
||||
- `docker` compila y publica una imagen multiarquitectura en GitHub Container Registry.
|
||||
|
||||
```bash
|
||||
git tag v0.2.0
|
||||
git push origin v0.2.0
|
||||
```
|
||||
|
||||
## Estructura del proyecto
|
||||
|
||||
```text
|
||||
cmd/vocat/ Punto de entrada de la aplicación y CLI
|
||||
internal/device/ Descubrimiento de módems y control de dispositivos
|
||||
internal/modem/ Sesión AT y manejo de respuestas
|
||||
internal/server/ API HTTP, notificaciones y servidor web incrustado
|
||||
internal/store/ Persistencia SQLite
|
||||
internal/update/ Autoactualizador de GitHub Release
|
||||
internal/vowifi/ Runtime de IKE, EAP-AKA, IMS y WiFi Calling
|
||||
scripts/install.sh Instalador y actualizador de Linux
|
||||
web/src/ Frontend en React y TypeScript
|
||||
.github/workflows/ Automatización de versiones de binarios y Docker
|
||||
```
|
||||
|
||||
## Uso responsable
|
||||
|
||||
Las operaciones con módems celulares y eSIM pueden afectar el servicio del abonado, los perfiles almacenados, el registro de red y el estado del hardware. Mantenga copias de seguridad, revise con cuidado las acciones destructivas y use el software solo en entornos legales donde tenga permiso para operar el hardware y los recursos de red conectados.
|
||||
|
||||
Vocat no elude la autenticación del operador, la política de red, la seguridad del hardware ni los requisitos de confianza de eSIM. El soporte de una operación significa que Vocat puede solicitarla al módem o al eUICC; el dispositivo, el perfil, la red o el operador aún pueden rechazarla.
|
||||
|
||||
## Contribuir
|
||||
|
||||
Las incidencias y pull requests son bienvenidas. Mantenga los cambios enfocados, incluya pruebas cuando sea práctico, evite confirmar credenciales o datos de abonados, y documente claramente el comportamiento específico del hardware.
|
||||
|
||||
Antes de enviar un cambio:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
cd web && npm run build
|
||||
```
|
||||
|
||||
## Agradecimientos
|
||||
- [Nodeseek.com](https://www.nodeseek.com) — Una comunidad dedicada a los servidores
|
||||
- [Linux.do](https://linux.do) — Una comunidad tecnológica inspiradora
|
||||
- [iniwex5](https://github.com/iniwex5) — Guías de estilo y funcionalidad
|
||||
|
||||
## Invítame a un café
|
||||
|
||||
| Red | Dirección |
|
||||
| ------- | ------- |
|
||||
| USDT-TRON (TRC20) | `TQQAbboBoU8h5xX4YCA1rqWJU2WjK3seSg` |
|
||||
| USDT-BSC (BEP20) | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
|
||||
| USDT-Polygon | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
|
||||
|
||||
## Licencia
|
||||
|
||||
Consulte [LICENSE](../LICENSE).
|
||||
|
||||
[](https://meteor-history.com)
|
||||
@@ -0,0 +1,336 @@
|
||||
<p align="center">
|
||||
<img src="../web/public/favicon.svg" width="96" alt="Vocat">
|
||||
</p>
|
||||
|
||||
<h1 align="center">VoCat</h1>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Go" src="https://img.shields.io/badge/Go-1.25-00ADD8?style=flat-square&logo=go&logoColor=white">
|
||||
<img alt="React" src="https://img.shields.io/badge/React-19-61DAFB?style=flat-square&logo=react&logoColor=111111">
|
||||
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5.8-3178C6?style=flat-square&logo=typescript&logoColor=white">
|
||||
<img alt="Vite" src="https://img.shields.io/badge/Vite-7-646CFF?style=flat-square&logo=vite&logoColor=white">
|
||||
<img alt="Tailwind CSS" src="https://img.shields.io/badge/Tailwind_CSS-3-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white">
|
||||
<img alt="SQLite" src="https://img.shields.io/badge/SQLite-Embedded-003B57?style=flat-square&logo=sqlite&logoColor=white">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Linux" src="https://img.shields.io/badge/Linux-amd64_%7C_386_%7C_arm64_%7C_aarch64_%7C_armv7-FCC624?style=flat-square&logo=linux&logoColor=111111">
|
||||
<img alt="Docker" src="https://img.shields.io/badge/Docker-Multi--Arch-2496ED?style=flat-square&logo=docker&logoColor=white">
|
||||
<img alt="WiFi Calling" src="https://img.shields.io/badge/WiFi_Calling-IMS_SMS-7B1FA2?style=flat-square">
|
||||
<img alt="eSIM" src="https://img.shields.io/badge/eSIM-LPA_%2F_eUICC-009688?style=flat-square">
|
||||
<img alt="Telegram" src="https://img.shields.io/badge/Telegram-Bot-26A5E4?style=flat-square&logo=telegram&logoColor=white">
|
||||
<img alt="GitHub Actions" src="https://img.shields.io/badge/GitHub_Actions-Release-2088FF?style=flat-square&logo=githubactions&logoColor=white">
|
||||
</p>
|
||||
|
||||
[English](../README.md) | [العربية](README.ar.md) | [简体中文](README.zh-CN.md) | [繁體中文](README.zh-TW.md) | **Français** | [Русский](README.ru.md) | [Español](README.es.md) | [日本語](README.ja.md)
|
||||
|
||||
Vocat est un panneau de contrôle web open-source et une boîte à outils d'ingénierie pour les modems cellulaires Quectel de classe EC20/EC25. Il réunit, dans un service autonome unique, la découverte de modems, l'état radio en direct, les terminaux AT et USSD, les SMS, la WiFi Calling, la gestion eSIM, la sélection de réseau, le routage par proxy, les notifications, les journaux d'audit et l'automatisation des versions.
|
||||
|
||||
Le backend est écrit en Go, l'interface est construite avec React et TypeScript, et le frontend de production est intégré dans le binaire Go. Un seul exécutable contient l'application web et utilise SQLite pour l'état persistant.
|
||||
|
||||
<p align="center">
|
||||
<img src="../img/image.png">
|
||||
<img src="../img/image-1.png">
|
||||
</p>
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
| Domaine | Ce que Vocat fournit |
|
||||
| --- | --- |
|
||||
| Gestion des appareils | Découverte série/USB automatique, prise en charge de plusieurs modems, noms d'appareils conviviaux, mises à jour en direct de la vue d'ensemble, redémarrage du module, mode avion et contrôles du mode réseau USB. |
|
||||
| Radio et réseau | État d'enregistrement, opérateur, métriques de signal, RSRP/RSRQ/SINR, mode réseau, bande, canal, recherche d'opérateurs et sélection de réseau automatique ou manuelle. |
|
||||
| AT et USSD | Terminal AT interactif, historique des commandes, réponses brutes du modem, flux de démarrage/poursuite/annulation USSD et rapport d'erreurs modem clair. |
|
||||
| SMS | Envoi direct de SMS cellulaires et IMS, synchronisation entrante, gestion des messages multiparties, rapports de livraison, historique des conversations, état non lu, horodatages et statut de livraison par message. |
|
||||
| WiFi Calling | Établissement de tunnel IKEv2/ePDG, authentification EAP-AKA, enregistrement IMS, SMS IMS, contrôles de reconnexion, diagnostics d'état et routage par appareil. |
|
||||
| eSIM et eUICC | Découverte eUICC, EID et informations de production, métadonnées de certificat, inventaire multi-eUICC, liste des profils installés, opérations d'activation/désactivation/commutation, ainsi que téléchargement, renommage et suppression lorsque la carte le permet. |
|
||||
| Politique de carte | Comportement WiFi Calling et mode avion basé sur l'ICCID avec application immédiate de la politique. |
|
||||
| Routage par proxy | Routage SOCKS amont, liaisons d'appareils, règles par pays, vérifications d'accessibilité TCP et vérifications UDP Associate pour les chemins de données WiFi Calling. |
|
||||
| Notifications | Transfert des nouveaux SMS entrants via Telegram, Bark, e-mail, Pushplus et webhooks signés. Chaque SMS est livré comme une notification individuelle. |
|
||||
| Bot Telegram | État de l'appareil, liste et commutation des profils installés, contrôles WiFi Calling et envoi de SMS. Les actions sensibles nécessitent une confirmation de l'administrateur. |
|
||||
| Exploitation | Authentification, protection CSRF, politiques d'accès, événements d'audit, journaux en direct, rétention des journaux, vérifications de santé, mise en page réactive, mode sombre et interface utilisateur en anglais/chinois. |
|
||||
| Distribution | Binaires Linux statiques, script d'installation systemd, auto-mise à jour avec vérification SHA-256, image Docker, publication GHCR et builds de version GitHub Actions. |
|
||||
|
||||
## Matériel pris en charge
|
||||
|
||||
Vocat cible les modules Quectel à base Qualcomm qui exposent des interfaces AT, QMI, série et réseau USB compatibles, notamment :
|
||||
|
||||
- Quectel EC20
|
||||
- Quectel EC25
|
||||
- Famille Quectel EG25
|
||||
- Modules EG600 compatibles et apparentés
|
||||
|
||||
Les fonctionnalités disponibles dépendent du firmware du module, de la composition USB, des capacités SIM/eSIM, des pilotes hôtes, du réseau radio et de la configuration de l'opérateur.
|
||||
|
||||
## Installation
|
||||
|
||||
### Installation Linux en un clic
|
||||
|
||||
En tant que root (y compris OpenWrt/Kwrt, où `sudo` est normalement absent) :
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
Depuis un utilisateur normal sur une distribution disposant de sudo :
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | sudo bash
|
||||
```
|
||||
|
||||
Vérifier les prérequis VoWiFi/XFRM de l'hôte sans installer VoCat :
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash -s -- --check-env
|
||||
```
|
||||
|
||||
Installer une version spécifique :
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh -o install.sh
|
||||
sudo bash install.sh 0.0.2
|
||||
```
|
||||
|
||||
VoWiFi IMS nécessite Linux XFRM/IPsec. Sur OpenWrt/Kwrt, le programme d'installation tente
|
||||
d'installer les paquets correspondants `ip-full`, `kmod-ipsec`, `kmod-ipsec4/6`,
|
||||
`kmod-crypto-authenc`, AES-CBC et SHA1 depuis le dépôt du firmware lui-même.
|
||||
Si des modules noyau correspondants ne sont pas disponibles, utilisez un firmware qui les inclut ;
|
||||
ne forcez jamais l'installation de kmods compilés pour un noyau différent.
|
||||
|
||||
Le programme d'installation :
|
||||
|
||||
- détecte `amd64`, `386`, `arm64`, `aarch64` ou `armv7` ;
|
||||
- télécharge le binaire GitHub Release correspondant ;
|
||||
- le vérifie par rapport à `SHA256SUMS` ;
|
||||
- installe Vocat sous `/opt/vocat` ;
|
||||
- crée un service systemd renforcé disposant des accès matériel et réseau requis par Vocat ;
|
||||
- stocke la configuration d'exécution dans `/etc/vocat/env` ;
|
||||
- génère un mot de passe administrateur initial aléatoire lors de la première installation.
|
||||
|
||||
Après l'installation, ouvrez :
|
||||
|
||||
```text
|
||||
http://<adresse-du-serveur>:7575
|
||||
```
|
||||
|
||||
### Installation manuelle du binaire
|
||||
|
||||
Téléchargez le binaire correspondant et `SHA256SUMS` depuis GitHub Releases :
|
||||
|
||||
| Plateforme | Fichier de version |
|
||||
| --- | --- |
|
||||
| Linux x86-64 | `vocat-linux-amd64` |
|
||||
| Linux x86 32 bits | `vocat-linux-386` |
|
||||
| Linux ARM64 | `vocat-linux-arm64` |
|
||||
| Linux AArch64 | `vocat-linux-aarch64` |
|
||||
| Linux ARMv7 | `vocat-linux-armv7` |
|
||||
|
||||
Vérifiez-le et installez-le :
|
||||
|
||||
```bash
|
||||
sha256sum -c SHA256SUMS --ignore-missing
|
||||
sudo install -d -m 0755 /opt/vocat/bin /opt/vocat/data
|
||||
sudo install -m 0755 vocat-linux-amd64 /opt/vocat/bin/vocat
|
||||
sudo env \
|
||||
VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db \
|
||||
VOCAT_ADMIN_PASSWORD=change-this-password \
|
||||
/opt/vocat/bin/vocat serve
|
||||
```
|
||||
|
||||
Cette commande manuelle exécute Vocat au premier plan. Utilisez `vocat serve` pour que le
|
||||
processus démarre directement le serveur ; exécuter `vocat` sans argument en tant que root
|
||||
sur un TTY ouvre plutôt le menu de gestion interactif. Utilisez le programme d'installation
|
||||
en un clic lorsqu'un service systemd géré et un redémarrage automatique sont requis.
|
||||
|
||||
### Docker
|
||||
|
||||
Pour un hôte Linux qui doit découvrir chaque modem Quectel pris en charge connecté et
|
||||
continuer à voir les événements de branchement à chaud USB, exécutez Vocat en mode d'accès matériel :
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/mengmengcode/vocat:latest
|
||||
|
||||
docker run -d \
|
||||
--name vocat \
|
||||
--restart unless-stopped \
|
||||
--network host \
|
||||
--privileged \
|
||||
--user 0:0 \
|
||||
-e VOCAT_ADMIN_PASSWORD=change-this-password \
|
||||
-v vocat-data:/opt/vocat/data \
|
||||
-v /dev:/dev \
|
||||
-v /sys:/sys:ro \
|
||||
ghcr.io/mengmengcode/vocat:latest
|
||||
```
|
||||
|
||||
Ouvrez `http://<adresse-du-serveur>:7575` après le démarrage du conteneur. Le réseau de l'hôte
|
||||
est requis pour que les interfaces réseau QMI restent visibles par Vocat, tandis que l'accès
|
||||
privilégié aux périphériques est requis pour les ports série, les nœuds de contrôle QMI, les
|
||||
interfaces TUN, la configuration réseau et les périphériques ajoutés après le démarrage du
|
||||
conteneur. Le montage bind de `/dev` rend les nouveaux nœuds `ttyUSB*`, `ttyACM*` et `cdc-wdm*`
|
||||
visibles sans recréer le conteneur.
|
||||
|
||||
Ce mode donne intentionnellement à Vocat un large accès aux périphériques et à la pile réseau
|
||||
de l'hôte. Ne l'utilisez que sur un hôte Linux de confiance. La découverte automatique
|
||||
identifie actuellement les modems USB Quectel pris en charge (ID fabricant USB `2c7c`), et non
|
||||
des marques de modems arbitraires. Le mappage de nœuds individuels uniquement avec `--device`,
|
||||
comme `/dev/ttyUSB2` et `/dev/cdc-wdm0`, limite le conteneur à ces nœuds fixes et ne fournit
|
||||
pas une découverte multi-périphériques ou à chaud complète.
|
||||
|
||||
L'image GHCR est publiée pour `linux/amd64` et `linux/arm64`.
|
||||
|
||||
## Configuration
|
||||
|
||||
Vocat lit un fichier de configuration JSON optionnel depuis `VOCAT_CONFIG`, puis applique les variables d'environnement `VOCAT_*`. Les variables d'environnement ont la priorité.
|
||||
|
||||
| Variable d'environnement | Par défaut | Description |
|
||||
| --- | --- | --- |
|
||||
| `VOCAT_ADDR` | `0.0.0.0:7575` | Adresse d'écoute HTTP. |
|
||||
| `VOCAT_DATABASE_PATH` | `./data/vocat.db` | Chemin de la base de données SQLite. |
|
||||
| `VOCAT_ADMIN_USERNAME` | `admin` | Nom d'utilisateur administrateur initial. |
|
||||
| `VOCAT_ADMIN_PASSWORD` | `admin` | Mot de passe administrateur initial. Modifiez-le avant d'exposer le service. |
|
||||
| `VOCAT_SESSION_TTL` | `24h` | Durée de vie de la session d'authentification. |
|
||||
| `VOCAT_SECURE_COOKIES` | `false` | Marque les cookies de session comme sécurisés lorsque HTTPS est utilisé. |
|
||||
| `VOCAT_SHUTDOWN_TIMEOUT` | `10s` | Délai d'arrêt gracieux. |
|
||||
| `VOCAT_MAX_REQUEST_BODY_BYTES` | `1048576` | Taille maximale du corps de requête API. |
|
||||
| `VOCAT_REPO` | `MengMengCode/VoCat` | Dépôt GitHub de confiance utilisé par l'auto-updater, au format `owner/name`. |
|
||||
| `GITHUB_TOKEN` | vide | Jeton GitHub optionnel pour les dépôts privés ou des limites d'API plus élevées. |
|
||||
|
||||
Ne stockez pas de jetons Telegram, mots de passe SMTP, secrets de webhook, identifiants SIM ou autres données privées dans le dépôt. Configurez-les via les paramètres de l'application ou des fichiers d'environnement protégés.
|
||||
|
||||
## Bot Telegram
|
||||
|
||||
Lorsque les notifications Telegram sont activées et que le Chat ID et l'Admin ID sont configurés, le bot prend en charge :
|
||||
|
||||
```text
|
||||
/status [appareil]
|
||||
/esim <appareil>
|
||||
/switch <appareil> <iccid>
|
||||
/wfc <appareil> <status|on|off|reconnect>
|
||||
/sms <appareil> <numéro> <message>
|
||||
```
|
||||
|
||||
La commutation de profil et l'envoi de SMS utilisent des boutons de confirmation à usage unique. Le bot n'expose pas les commandes de téléchargement, de suppression ou de renommage eSIM.
|
||||
|
||||
## Mise à jour
|
||||
|
||||
Vérifier l'existence d'une GitHub Release plus récente :
|
||||
|
||||
```bash
|
||||
vocat update --check --repo MengMengCode/VoCat
|
||||
```
|
||||
|
||||
Installer la dernière version :
|
||||
|
||||
```bash
|
||||
sudo vocat update --repo MengMengCode/VoCat
|
||||
```
|
||||
|
||||
L'updater télécharge le binaire correspondant à l'architecture Linux actuelle, le vérifie avec le `SHA256SUMS` publié, remplace l'exécutable de manière atomique et redémarre le service systemd `vocat` lorsqu'il est disponible.
|
||||
|
||||
Pour les installations Docker :
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/mengmengcode/vocat:latest
|
||||
```
|
||||
|
||||
Recréez le conteneur après avoir tiré la nouvelle image.
|
||||
|
||||
## Développement
|
||||
|
||||
Prérequis :
|
||||
|
||||
- Go 1.25 ou plus récent
|
||||
- Node.js 20 ou plus récent
|
||||
- npm
|
||||
|
||||
Lancer le serveur de développement frontend :
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Construire le frontend intégré et démarrer le backend :
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm run build
|
||||
cd ..
|
||||
go run ./cmd/vocat
|
||||
```
|
||||
|
||||
Exécuter tous les tests :
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Construire un binaire de production :
|
||||
|
||||
```bash
|
||||
go build -trimpath -ldflags "-s -w" -o vocat ./cmd/vocat
|
||||
```
|
||||
|
||||
## Automatisation des versions
|
||||
|
||||
Pousser un tag de version déclenche deux workflows GitHub Actions :
|
||||
|
||||
- `release-binaries` construit et publie les binaires `amd64`, `386`, `arm64`, `aarch64` et `armv7` ainsi que `SHA256SUMS`.
|
||||
- `docker` construit et publie une image multi-architecture vers GitHub Container Registry.
|
||||
|
||||
```bash
|
||||
git tag v0.2.0
|
||||
git push origin v0.2.0
|
||||
```
|
||||
|
||||
## Structure du projet
|
||||
|
||||
```text
|
||||
cmd/vocat/ Point d'entrée de l'application et CLI
|
||||
internal/device/ Découverte de modems et contrôle des appareils
|
||||
internal/modem/ Session AT et gestion des réponses
|
||||
internal/server/ API HTTP, notifications et serveur web intégré
|
||||
internal/store/ Persistance SQLite
|
||||
internal/update/ Auto-updater GitHub Release
|
||||
internal/vowifi/ Runtime IKE, EAP-AKA, IMS et WiFi Calling
|
||||
scripts/install.sh Installeur et updater Linux
|
||||
web/src/ Frontend React et TypeScript
|
||||
.github/workflows/ Automatisation des versions binaires et Docker
|
||||
```
|
||||
|
||||
## Utilisation responsable
|
||||
|
||||
Les opérations sur les modems cellulaires et les eSIM peuvent affecter le service de l'abonné, les profils stockés, l'enregistrement réseau et l'état du matériel. Effectuez des sauvegardes, examinez attentivement les actions destructrices et n'utilisez le logiciel que dans des environnements légaux où vous êtes autorisé à exploiter le matériel et les ressources réseau connectés.
|
||||
|
||||
Vocat ne contourne ni l'authentification de l'opérateur, ni la politique réseau, ni la sécurité matérielle, ni les exigences de confiance eSIM. La prise en charge d'une opération signifie que Vocat peut la demander au modem ou à l'eUICC ; l'appareil, le profil, le réseau ou l'opérateur peut toujours la refuser.
|
||||
|
||||
## Contribution
|
||||
|
||||
Les issues et pull requests sont les bienvenues. Gardez des changements ciblés, incluez des tests lorsque c'est possible, évitez de committer des identifiants ou des données d'abonnés, et documentez clairement les comportements spécifiques au matériel.
|
||||
|
||||
Avant de soumettre un changement :
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
cd web && npm run build
|
||||
```
|
||||
|
||||
## Remerciements
|
||||
- [Nodeseek.com](https://www.nodeseek.com) — Une communauté dédiée aux serveurs
|
||||
- [Linux.do](https://linux.do) — Une communauté technologique inspirante
|
||||
- [iniwex5](https://github.com/iniwex5) — Directives de style et de fonctionnalité
|
||||
|
||||
## Offrez-moi un café
|
||||
|
||||
| Réseau | Adresse |
|
||||
| ------- | ------- |
|
||||
| USDT-TRON (TRC20) | `TQQAbboBoU8h5xX4YCA1rqWJU2WjK3seSg` |
|
||||
| USDT-BSC (BEP20) | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
|
||||
| USDT-Polygon | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
|
||||
|
||||
## Licence
|
||||
|
||||
Voir [LICENSE](../LICENSE).
|
||||
|
||||
[](https://meteor-history.com)
|
||||
@@ -0,0 +1,318 @@
|
||||
<p align="center">
|
||||
<img src="../web/public/favicon.svg" width="96" alt="Vocat">
|
||||
</p>
|
||||
|
||||
<h1 align="center">VoCat</h1>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Go" src="https://img.shields.io/badge/Go-1.25-00ADD8?style=flat-square&logo=go&logoColor=white">
|
||||
<img alt="React" src="https://img.shields.io/badge/React-19-61DAFB?style=flat-square&logo=react&logoColor=111111">
|
||||
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5.8-3178C6?style=flat-square&logo=typescript&logoColor=white">
|
||||
<img alt="Vite" src="https://img.shields.io/badge/Vite-7-646CFF?style=flat-square&logo=vite&logoColor=white">
|
||||
<img alt="Tailwind CSS" src="https://img.shields.io/badge/Tailwind_CSS-3-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white">
|
||||
<img alt="SQLite" src="https://img.shields.io/badge/SQLite-Embedded-003B57?style=flat-square&logo=sqlite&logoColor=white">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Linux" src="https://img.shields.io/badge/Linux-amd64_%7C_386_%7C_arm64_%7C_aarch64_%7C_armv7-FCC624?style=flat-square&logo=linux&logoColor=111111">
|
||||
<img alt="Docker" src="https://img.shields.io/badge/Docker-Multi--Arch-2496ED?style=flat-square&logo=docker&logoColor=white">
|
||||
<img alt="WiFi Calling" src="https://img.shields.io/badge/WiFi_Calling-IMS_SMS-7B1FA2?style=flat-square">
|
||||
<img alt="eSIM" src="https://img.shields.io/badge/eSIM-LPA_%2F_eUICC-009688?style=flat-square">
|
||||
<img alt="Telegram" src="https://img.shields.io/badge/Telegram-Bot-26A5E4?style=flat-square&logo=telegram&logoColor=white">
|
||||
<img alt="GitHub Actions" src="https://img.shields.io/badge/GitHub_Actions-Release-2088FF?style=flat-square&logo=githubactions&logoColor=white">
|
||||
</p>
|
||||
|
||||
[English](../README.md) | [العربية](README.ar.md) | [简体中文](README.zh-CN.md) | [繁體中文](README.zh-TW.md) | [Français](README.fr.md) | [Русский](README.ru.md) | [Español](README.es.md) | **日本語**
|
||||
|
||||
Vocat は、Quectel EC20/EC25 クラスのセルラーモデム向けのオープンソース Web コントロールパネル兼エンジニアリングツールキットです。モデムの検出、ライブの無線ステータス、AT / USSD ターミナル、SMS、WiFi Calling、eSIM 管理、ネットワーク選択、プロキシルーティング、通知、監査ログ、リリース自動化を、自己完結型の単一サービスに統合しています。
|
||||
|
||||
バックエンドは Go で書かれ、インターフェースは React と TypeScript で構築され、本番フロントエンドは Go バイナリに埋め込まれています。単一の実行ファイルに Web アプリケーション全体が含まれ、永続的な状態には SQLite を使用します。
|
||||
|
||||
<p align="center">
|
||||
<img src="../img/image.png">
|
||||
<img src="../img/image-1.png">
|
||||
</p>
|
||||
|
||||
## 機能
|
||||
|
||||
| 領域 | Vocat が提供する機能 |
|
||||
| --- | --- |
|
||||
| デバイス管理 | シリアル/USB の自動検出、複数モデムのサポート、わかりやすいデバイス名、概要のライブ更新、モジュールの再起動、機内モード、USB ネットワークモードの制御。 |
|
||||
| 無線とネットワーク | 登録ステータス、オペレーター、信号指標、RSRP/RSRQ/SINR、ネットワークモード、バンド、チャネル、オペレータースキャン、自動または手動のネットワーク選択。 |
|
||||
| AT と USSD | 対話型 AT ターミナル、コマンド履歴、モデムの生出力、USSD の開始/継続/キャンセルフロー、明確なモデムエラーレポート。 |
|
||||
| SMS | セルラーおよび IMS SMS の直接送信、受信同期、マルチパート処理、配信レポート、会話履歴、未読状態、タイムスタンプ、メッセージごとの配信ステータス。 |
|
||||
| WiFi Calling | IKEv2/ePDG トンネルの確立、EAP-AKA 認証、IMS 登録、IMS SMS、再接続制御、ステータス診断、デバイスごとのルーティング。 |
|
||||
| eSIM と eUICC | eUICC の検出、EID と製造情報、証明書メタデータ、複数 eUICC のインベントリ、インストール済みプロファイルの一覧、有効化/無効化/切り替え操作、およびカードが対応している場合のダウンロード、名前変更、削除操作。 |
|
||||
| カードポリシー | ICCID ベースの WiFi Calling および機内モードの動作で、ポリシーが即時に適用されます。 |
|
||||
| プロキシルーティング | アップストリーム SOCKS ルーティング、デバイスバインディング、国別ルール、TCP 到達性チェック、WiFi Calling データパス向けの UDP Associate チェック。 |
|
||||
| 通知 | Telegram、Bark、メール、Pushplus、署名付き Webhook を介した新着 SMS の転送。各 SMS は個別の通知として配信されます。 |
|
||||
| Telegram ボット | デバイスステータス、インストール済みプロファイルの一覧と切り替え、WiFi Calling 制御、SMS 送信。機密性の高い操作には管理者の確認が必要です。 |
|
||||
| 運用 | 認証、CSRF 保護、アクセスポリシー、監査イベント、ライブログ、ログ保持、ヘルスチェック、レスポンシブレイアウト、ダークモード、英語/中国語のアプリケーション UI。 |
|
||||
| 配布 | 静的 Linux バイナリ、systemd インストールスクリプト、SHA-256 検証付きの自己更新、Docker イメージ、GHCR 公開、GitHub Actions リリースビルド。 |
|
||||
|
||||
## 対応ハードウェア
|
||||
|
||||
Vocat は、互換性のある AT、QMI、シリアル、USB ネットワークインターフェースを公開する Qualcomm ベースの Quectel モジュールを対象としています。対象には以下が含まれます:
|
||||
|
||||
- Quectel EC20
|
||||
- Quectel EC25
|
||||
- Quectel EG25 ファミリー
|
||||
- 互換性のある EG600 および関連モジュール
|
||||
|
||||
利用可能な機能は、モジュールのファームウェア、USB 構成、SIM/eSIM の機能、ホストドライバー、無線ネットワーク、キャリア設定によって異なります。
|
||||
|
||||
## インストール
|
||||
|
||||
### ワンクリック Linux インストール
|
||||
|
||||
root として(`sudo` が通常存在しない OpenWrt/Kwrt を含む):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
sudo を持つディストリビューションの一般ユーザーから:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | sudo bash
|
||||
```
|
||||
|
||||
VoCat をインストールせずに、ホストの VoWiFi/XFRM 前提条件を確認する:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash -s -- --check-env
|
||||
```
|
||||
|
||||
特定のバージョンをインストールする:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh -o install.sh
|
||||
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`、`aarch64`、`armv7` を検出します;
|
||||
- 一致する GitHub Release バイナリをダウンロードします;
|
||||
- `SHA256SUMS` と照合して検証します;
|
||||
- Vocat を `/opt/vocat` にインストールします;
|
||||
- Vocat が必要とするハードウェアおよびネットワークアクセスを持つ強化された systemd サービスを作成します;
|
||||
- 実行時設定を `/etc/vocat/env` に保存します;
|
||||
- 初回インストール時にランダムな初期管理者パスワードを生成します。
|
||||
|
||||
インストール後、次を開きます:
|
||||
|
||||
```text
|
||||
http://<サーバーアドレス>:7575
|
||||
```
|
||||
|
||||
### 手動バイナリインストール
|
||||
|
||||
一致するバイナリと `SHA256SUMS` を GitHub Releases からダウンロードします:
|
||||
|
||||
| プラットフォーム | リリースファイル |
|
||||
| --- | --- |
|
||||
| Linux x86-64 | `vocat-linux-amd64` |
|
||||
| Linux x86 32 ビット | `vocat-linux-386` |
|
||||
| Linux ARM64 | `vocat-linux-arm64` |
|
||||
| Linux AArch64 | `vocat-linux-aarch64` |
|
||||
| Linux ARMv7 | `vocat-linux-armv7` |
|
||||
|
||||
検証してインストールします:
|
||||
|
||||
```bash
|
||||
sha256sum -c SHA256SUMS --ignore-missing
|
||||
sudo install -d -m 0755 /opt/vocat/bin /opt/vocat/data
|
||||
sudo install -m 0755 vocat-linux-amd64 /opt/vocat/bin/vocat
|
||||
sudo env \
|
||||
VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db \
|
||||
VOCAT_ADMIN_PASSWORD=change-this-password \
|
||||
/opt/vocat/bin/vocat serve
|
||||
```
|
||||
|
||||
この手動コマンドは Vocat をフォアグラウンドで実行します。プロセスがサーバーを直接起動するように `vocat serve` を使用してください。TTY で root として引数なしで `vocat` を実行すると、代わりに対話型管理メニューが開きます。管理対象の systemd サービスと自動再起動が必要な場合は、ワンクリックインストーラーを使用してください。
|
||||
|
||||
### Docker
|
||||
|
||||
接続されているすべてのサポート対象 Quectel モデムを検出し、USB ホットプラグイベントを継続的に認識する必要がある Linux ホストでは、Vocat をハードウェアアクセスモードで実行します:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/mengmengcode/vocat:latest
|
||||
|
||||
docker run -d \
|
||||
--name vocat \
|
||||
--restart unless-stopped \
|
||||
--network host \
|
||||
--privileged \
|
||||
--user 0:0 \
|
||||
-e VOCAT_ADMIN_PASSWORD=change-this-password \
|
||||
-v vocat-data:/opt/vocat/data \
|
||||
-v /dev:/dev \
|
||||
-v /sys:/sys:ro \
|
||||
ghcr.io/mengmengcode/vocat:latest
|
||||
```
|
||||
|
||||
コンテナの起動後に `http://<サーバーアドレス>:7575` を開きます。QMI ネットワークインターフェースが Vocat から見えるようにするにはホストネットワークが必要であり、シリアルポート、QMI 制御ノード、TUN インターフェース、ネットワーク設定、コンテナ起動後に追加されたデバイスには特権デバイスアクセスが必要です。`/dev` バインドマウントにより、コンテナを再作成せずに新しい `ttyUSB*`、`ttyACM*`、`cdc-wdm*` ノードが見えるようになります。
|
||||
|
||||
このモードは意図的に Vocat にホストのデバイスとネットワークスタックへの広範なアクセスを付与します。信頼できる Linux ホストでのみ使用してください。自動検出は現在、サポート対象の Quectel USB モデム(USB ベンダー ID `2c7c`)のみを識別し、任意のモデムブランドは識別しません。`--device` で `/dev/ttyUSB2` や `/dev/cdc-wdm0` などの個別ノードのみをマッピングすると、コンテナはそれらの固定ノードに限定され、完全なマルチデバイスまたはホットプラグ検出は提供されません。
|
||||
|
||||
GHCR イメージは `linux/amd64` と `linux/arm64` 向けに公開されています。
|
||||
|
||||
## 設定
|
||||
|
||||
Vocat は `VOCAT_CONFIG` からオプションの JSON 設定ファイルを読み込み、次に `VOCAT_*` 環境変数を適用します。環境変数が優先されます。
|
||||
|
||||
| 環境変数 | デフォルト | 説明 |
|
||||
| --- | --- | --- |
|
||||
| `VOCAT_ADDR` | `0.0.0.0:7575` | HTTP リッスンアドレス。 |
|
||||
| `VOCAT_DATABASE_PATH` | `./data/vocat.db` | SQLite データベースパス。 |
|
||||
| `VOCAT_ADMIN_USERNAME` | `admin` | 初期管理者ユーザー名。 |
|
||||
| `VOCAT_ADMIN_PASSWORD` | `admin` | 初期管理者パスワード。サービスを公開する前に変更してください。 |
|
||||
| `VOCAT_SESSION_TTL` | `24h` | 認証セッションの有効期間。 |
|
||||
| `VOCAT_SECURE_COOKIES` | `false` | HTTPS 使用時にセッション Cookie をセキュアとしてマークします。 |
|
||||
| `VOCAT_SHUTDOWN_TIMEOUT` | `10s` | グレースフルシャットダウンのタイムアウト。 |
|
||||
| `VOCAT_MAX_REQUEST_BODY_BYTES` | `1048576` | API リクエストボディの最大サイズ。 |
|
||||
| `VOCAT_REPO` | `MengMengCode/VoCat` | 自己更新機能が使用する信頼された GitHub リポジトリ(`owner/name` 形式)。 |
|
||||
| `GITHUB_TOKEN` | 空 | プライベートリポジトリやより高い API レート制限のためのオプションの GitHub トークン。 |
|
||||
|
||||
Telegram トークン、SMTP パスワード、Webhook シークレット、SIM 認証情報、その他のプライベートデータをリポジトリに保存しないでください。アプリケーション設定または保護された環境ファイルを通じて設定してください。
|
||||
|
||||
## Telegram ボット
|
||||
|
||||
Telegram 通知が有効で、Chat ID と Admin ID の両方が設定されている場合、ボットは以下をサポートします:
|
||||
|
||||
```text
|
||||
/status [デバイス]
|
||||
/esim <デバイス>
|
||||
/switch <デバイス> <iccid>
|
||||
/wfc <デバイス> <status|on|off|reconnect>
|
||||
/sms <デバイス> <番号> <メッセージ>
|
||||
```
|
||||
|
||||
プロファイルの切り替えと SMS の送信には、ワンタイム確認ボタンが使用されます。ボットは eSIM のダウンロード、削除、名前変更コマンドを公開しません。
|
||||
|
||||
## 更新
|
||||
|
||||
より新しい GitHub Release を確認する:
|
||||
|
||||
```bash
|
||||
vocat update --check --repo MengMengCode/VoCat
|
||||
```
|
||||
|
||||
最新リリースをインストールする:
|
||||
|
||||
```bash
|
||||
sudo vocat update --repo MengMengCode/VoCat
|
||||
```
|
||||
|
||||
アップデーターは、現在の Linux アーキテクチャに一致するバイナリをダウンロードし、公開された `SHA256SUMS` で検証し、実行ファイルをアトミックに置き換え、利用可能な場合は `vocat` systemd サービスを再起動します。
|
||||
|
||||
Docker インストールの場合:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/mengmengcode/vocat:latest
|
||||
```
|
||||
|
||||
新しいイメージをプルした後、コンテナを再作成します。
|
||||
|
||||
## 開発
|
||||
|
||||
要件:
|
||||
|
||||
- Go 1.25 以降
|
||||
- Node.js 20 以降
|
||||
- npm
|
||||
|
||||
フロントエンド開発サーバーを実行する:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
埋め込みフロントエンドをビルドしてバックエンドを起動する:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm run build
|
||||
cd ..
|
||||
go run ./cmd/vocat
|
||||
```
|
||||
|
||||
すべてのテストを実行する:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
本番バイナリをビルドする:
|
||||
|
||||
```bash
|
||||
go build -trimpath -ldflags "-s -w" -o vocat ./cmd/vocat
|
||||
```
|
||||
|
||||
## リリース自動化
|
||||
|
||||
バージョンタグをプッシュすると、2 つの GitHub Actions ワークフローが開始されます:
|
||||
|
||||
- `release-binaries` は `amd64`、`386`、`arm64`、`aarch64`、`armv7` バイナリと `SHA256SUMS` をビルドして公開します。
|
||||
- `docker` はマルチアーキテクチャイメージをビルドして GitHub Container Registry に公開します。
|
||||
|
||||
```bash
|
||||
git tag v0.2.0
|
||||
git push origin v0.2.0
|
||||
```
|
||||
|
||||
## プロジェクト構成
|
||||
|
||||
```text
|
||||
cmd/vocat/ アプリケーションのエントリポイントと CLI
|
||||
internal/device/ モデム検出とデバイス制御
|
||||
internal/modem/ AT セッションと応答処理
|
||||
internal/server/ HTTP API、通知、埋め込み Web サーバー
|
||||
internal/store/ SQLite 永続化
|
||||
internal/update/ GitHub Release 自己更新機能
|
||||
internal/vowifi/ IKE、EAP-AKA、IMS、WiFi Calling ランタイム
|
||||
scripts/install.sh Linux インストーラーとアップデーター
|
||||
web/src/ React と TypeScript のフロントエンド
|
||||
.github/workflows/ バイナリと Docker のリリース自動化
|
||||
```
|
||||
|
||||
## 責任ある使用
|
||||
|
||||
セルラーモデムおよび eSIM の操作は、加入者サービス、保存されたプロファイル、ネットワーク登録、ハードウェア状態に影響を与える可能性があります。バックアップを保持し、破壊的な操作を慎重に確認し、接続されたハードウェアとネットワークリソースを操作することが許可されている合法的な環境でのみソフトウェアを使用してください。
|
||||
|
||||
Vocat は、キャリア認証、ネットワークポリシー、ハードウェアセキュリティ、eSIM の信頼要件をバイパスしません。操作のサポートは、Vocat がモデムまたは eUICC にそれを要求できることを意味します。デバイス、プロファイル、ネットワーク、またはキャリアがそれを拒否する場合があります。
|
||||
|
||||
## コントリビューション
|
||||
|
||||
Issue や Pull Request を歓迎します。変更は焦点を絞り、可能な場合はテストを含め、認証情報や加入者データをコミットしないようにし、ハードウェア固有の動作を明確に文書化してください。
|
||||
|
||||
変更を提出する前に:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
cd web && npm run build
|
||||
```
|
||||
|
||||
## 謝辞
|
||||
- [Nodeseek.com](https://www.nodeseek.com) — サーバーに特化したコミュニティ
|
||||
- [Linux.do](https://linux.do) — 刺激的なテックコミュニティ
|
||||
- [iniwex5](https://github.com/iniwex5) — スタイルと機能のガイドライン
|
||||
|
||||
## コーヒーをおごってください
|
||||
|
||||
| ネットワーク | アドレス |
|
||||
| ------- | ------- |
|
||||
| USDT-TRON (TRC20) | `TQQAbboBoU8h5xX4YCA1rqWJU2WjK3seSg` |
|
||||
| USDT-BSC (BEP20) | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
|
||||
| USDT-Polygon | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
|
||||
|
||||
## ライセンス
|
||||
|
||||
[LICENSE](../LICENSE) を参照してください。
|
||||
|
||||
[](https://meteor-history.com)
|
||||
@@ -0,0 +1,335 @@
|
||||
<p align="center">
|
||||
<img src="../web/public/favicon.svg" width="96" alt="Vocat">
|
||||
</p>
|
||||
|
||||
<h1 align="center">VoCat</h1>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Go" src="https://img.shields.io/badge/Go-1.25-00ADD8?style=flat-square&logo=go&logoColor=white">
|
||||
<img alt="React" src="https://img.shields.io/badge/React-19-61DAFB?style=flat-square&logo=react&logoColor=111111">
|
||||
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5.8-3178C6?style=flat-square&logo=typescript&logoColor=white">
|
||||
<img alt="Vite" src="https://img.shields.io/badge/Vite-7-646CFF?style=flat-square&logo=vite&logoColor=white">
|
||||
<img alt="Tailwind CSS" src="https://img.shields.io/badge/Tailwind_CSS-3-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white">
|
||||
<img alt="SQLite" src="https://img.shields.io/badge/SQLite-Embedded-003B57?style=flat-square&logo=sqlite&logoColor=white">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Linux" src="https://img.shields.io/badge/Linux-amd64_%7C_386_%7C_arm64_%7C_aarch64_%7C_armv7-FCC624?style=flat-square&logo=linux&logoColor=111111">
|
||||
<img alt="Docker" src="https://img.shields.io/badge/Docker-Multi--Arch-2496ED?style=flat-square&logo=docker&logoColor=white">
|
||||
<img alt="WiFi Calling" src="https://img.shields.io/badge/WiFi_Calling-IMS_SMS-7B1FA2?style=flat-square">
|
||||
<img alt="eSIM" src="https://img.shields.io/badge/eSIM-LPA_%2F_eUICC-009688?style=flat-square">
|
||||
<img alt="Telegram" src="https://img.shields.io/badge/Telegram-Bot-26A5E4?style=flat-square&logo=telegram&logoColor=white">
|
||||
<img alt="GitHub Actions" src="https://img.shields.io/badge/GitHub_Actions-Release-2088FF?style=flat-square&logo=githubactions&logoColor=white">
|
||||
</p>
|
||||
|
||||
[English](../README.md) | [العربية](README.ar.md) | [简体中文](README.zh-CN.md) | [繁體中文](README.zh-TW.md) | [Français](README.fr.md) | **Русский** | [Español](README.es.md) | [日本語](README.ja.md)
|
||||
|
||||
Vocat — это веб-панель управления с открытым исходным кодом и набор инженерных инструментов для сотовых модемов Quectel класса EC20/EC25. Она объединяет в одном автономном сервисе обнаружение модемов, состояние радиосвязи в реальном времени, терминалы AT и USSD, SMS, WiFi Calling, управление eSIM, выбор сети, маршрутизацию через прокси, уведомления, журналы аудита и автоматизацию релизов.
|
||||
|
||||
Бэкенд написан на Go, интерфейс построен на React и TypeScript, а производственный фронтенд встроен в бинарный файл Go. Один исполняемый файл содержит веб-приложение и использует SQLite для постоянного хранения состояния.
|
||||
|
||||
<p align="center">
|
||||
<img src="../img/image.png">
|
||||
<img src="../img/image-1.png">
|
||||
</p>
|
||||
|
||||
## Возможности
|
||||
|
||||
| Область | Что предоставляет Vocat |
|
||||
| --- | --- |
|
||||
| Управление устройствами | Автоматическое обнаружение по последовательному порту/USB, поддержка нескольких модемов, понятные имена устройств, обновление обзора в реальном времени, перезапуск модуля, авиарежим и управление режимом USB-сети. |
|
||||
| Радио и сеть | Статус регистрации, оператор, метрики сигнала, RSRP/RSRQ/SINR, режим сети, диапазон, канал, сканирование операторов и автоматический или ручной выбор сети. |
|
||||
| AT и USSD | Интерактивный AT-терминал, история команд, необработанные ответы модема, потоки запуска/продолжения/отмены USSD и понятные сообщения об ошибках модема. |
|
||||
| SMS | Прямая отправка сотовых и IMS SMS, входящая синхронизация, обработка составных сообщений, отчёты о доставке, история диалогов, статус непрочитанных, метки времени и статус доставки каждого сообщения. |
|
||||
| WiFi Calling | Установка туннеля IKEv2/ePDG, аутентификация EAP-AKA, регистрация IMS, IMS SMS, управление переподключением, диагностика состояния и маршрутизация по устройствам. |
|
||||
| eSIM и eUICC | Обнаружение eUICC, EID и производственная информация, метаданные сертификатов, инвентарь нескольких eUICC, список установленных профилей, операции включения/отключения/переключения, а также загрузка, переименование и удаление при поддержке картой. |
|
||||
| Политика карты | Поведение WiFi Calling и авиарежима на основе ICCID с немедленным применением политики. |
|
||||
| Маршрутизация через прокси | Восходящая маршрутизация SOCKS, привязки устройств, правила по странам, проверки доступности TCP и проверки UDP Associate для путей передачи данных WiFi Calling. |
|
||||
| Уведомления | Пересылка новых входящих SMS через Telegram, Bark, электронную почту, Pushplus и подписанные вебхуки. Каждое SMS доставляется как отдельное уведомление. |
|
||||
| Telegram-бот | Статус устройства, список и переключение установленных профилей, управление WiFi Calling и отправка SMS. Чувствительные действия требуют подтверждения администратора. |
|
||||
| Эксплуатация | Аутентификация, защита CSRF, политики доступа, события аудита, журналы в реальном времени, хранение журналов, проверки работоспособности, адаптивная вёрстка, тёмный режим и интерфейс на английском/китайском. |
|
||||
| Дистрибуция | Статические бинарные файлы Linux, скрипт установки systemd, самообновление с проверкой SHA-256, образ Docker, публикация в GHCR и сборки релизов GitHub Actions. |
|
||||
|
||||
## Поддерживаемое оборудование
|
||||
|
||||
Vocat ориентирован на модули Quectel на базе Qualcomm, которые предоставляют совместимые интерфейсы AT, QMI, последовательный порт и USB-сеть, включая:
|
||||
|
||||
- Quectel EC20
|
||||
- Quectel EC25
|
||||
- Семейство Quectel EG25
|
||||
- Совместимые модули EG600 и родственные
|
||||
|
||||
Доступные функции зависят от прошивки модуля, конфигурации USB, возможностей SIM/eSIM, драйверов хоста, радиосети и настроек оператора.
|
||||
|
||||
## Установка
|
||||
|
||||
### Установка в Linux одной командой
|
||||
|
||||
От имени root (включая OpenWrt/Kwrt, где `sudo` обычно отсутствует):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
От обычного пользователя в дистрибутиве с 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
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh -o install.sh
|
||||
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`, `aarch64` или `armv7`;
|
||||
- загружает соответствующий бинарный файл GitHub Release;
|
||||
- проверяет его по `SHA256SUMS`;
|
||||
- устанавливает Vocat в `/opt/vocat`;
|
||||
- создаёт усиленный сервис systemd с доступом к оборудованию и сети, необходимым Vocat;
|
||||
- хранит конфигурацию времени выполнения в `/etc/vocat/env`;
|
||||
- генерирует случайный начальный пароль администратора при первой установке.
|
||||
|
||||
После установки откройте:
|
||||
|
||||
```text
|
||||
http://<адрес-сервера>:7575
|
||||
```
|
||||
|
||||
### Ручная установка бинарного файла
|
||||
|
||||
Загрузите соответствующий бинарный файл и `SHA256SUMS` из GitHub Releases:
|
||||
|
||||
| Платформа | Файл релиза |
|
||||
| --- | --- |
|
||||
| Linux x86-64 | `vocat-linux-amd64` |
|
||||
| Linux x86 32-бит | `vocat-linux-386` |
|
||||
| Linux ARM64 | `vocat-linux-arm64` |
|
||||
| Linux AArch64 | `vocat-linux-aarch64` |
|
||||
| Linux ARMv7 | `vocat-linux-armv7` |
|
||||
|
||||
Проверьте и установите его:
|
||||
|
||||
```bash
|
||||
sha256sum -c SHA256SUMS --ignore-missing
|
||||
sudo install -d -m 0755 /opt/vocat/bin /opt/vocat/data
|
||||
sudo install -m 0755 vocat-linux-amd64 /opt/vocat/bin/vocat
|
||||
sudo env \
|
||||
VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db \
|
||||
VOCAT_ADMIN_PASSWORD=change-this-password \
|
||||
/opt/vocat/bin/vocat serve
|
||||
```
|
||||
|
||||
Эта ручная команда запускает Vocat в переднем плане. Используйте `vocat serve`, чтобы
|
||||
процесс сразу запустил сервер; запуск `vocat` без аргументов от имени root
|
||||
в TTY вместо этого открывает интерактивное меню управления. Используйте установку
|
||||
одной командой, когда требуется управляемый сервис systemd и автоматический перезапуск.
|
||||
|
||||
### Docker
|
||||
|
||||
Для хоста Linux, который должен обнаруживать каждый подключённый поддерживаемый модем Quectel и
|
||||
продолжать видеть события горячего подключения USB, запустите Vocat в режиме доступа к оборудованию:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/mengmengcode/vocat:latest
|
||||
|
||||
docker run -d \
|
||||
--name vocat \
|
||||
--restart unless-stopped \
|
||||
--network host \
|
||||
--privileged \
|
||||
--user 0:0 \
|
||||
-e VOCAT_ADMIN_PASSWORD=change-this-password \
|
||||
-v vocat-data:/opt/vocat/data \
|
||||
-v /dev:/dev \
|
||||
-v /sys:/sys:ro \
|
||||
ghcr.io/mengmengcode/vocat:latest
|
||||
```
|
||||
|
||||
Откройте `http://<адрес-сервера>:7575` после запуска контейнера. Сеть хоста
|
||||
необходима, чтобы сетевые интерфейсы QMI оставались видимыми для Vocat, а привилегированный
|
||||
доступ к устройствам необходим для последовательных портов, узлов управления QMI, интерфейсов
|
||||
TUN, настройки сети и устройств, добавленных после запуска контейнера. Монтирование `/dev`
|
||||
делает новые узлы `ttyUSB*`, `ttyACM*` и `cdc-wdm*` видимыми без пересоздания контейнера.
|
||||
|
||||
Этот режим намеренно предоставляет Vocat широкий доступ к устройствам и сетевому стеку
|
||||
хоста. Используйте его только на доверенном хосте Linux. Автоматическое обнаружение
|
||||
в настоящее время определяет поддерживаемые USB-модемы Quectel (USB vendor ID `2c7c`), а не
|
||||
произвольные марки модемов. Монтирование только отдельных узлов с помощью `--device`, таких как
|
||||
`/dev/ttyUSB2` и `/dev/cdc-wdm0`, ограничивает контейнер этими фиксированными узлами и не
|
||||
обеспечивает полное обнаружение нескольких устройств или горячего подключения.
|
||||
|
||||
Образ GHCR публикуется для `linux/amd64` и `linux/arm64`.
|
||||
|
||||
## Конфигурация
|
||||
|
||||
Vocat читает необязательный JSON-файл конфигурации из `VOCAT_CONFIG`, затем применяет переменные окружения `VOCAT_*`. Переменные окружения имеют приоритет.
|
||||
|
||||
| Переменная окружения | По умолчанию | Описание |
|
||||
| --- | --- | --- |
|
||||
| `VOCAT_ADDR` | `0.0.0.0:7575` | Адрес прослушивания HTTP. |
|
||||
| `VOCAT_DATABASE_PATH` | `./data/vocat.db` | Путь к базе данных SQLite. |
|
||||
| `VOCAT_ADMIN_USERNAME` | `admin` | Начальное имя пользователя администратора. |
|
||||
| `VOCAT_ADMIN_PASSWORD` | `admin` | Начальный пароль администратора. Смените его перед публикацией сервиса. |
|
||||
| `VOCAT_SESSION_TTL` | `24h` | Время жизни сессии аутентификации. |
|
||||
| `VOCAT_SECURE_COOKIES` | `false` | Помечает cookie сессии как безопасные при использовании HTTPS. |
|
||||
| `VOCAT_SHUTDOWN_TIMEOUT` | `10s` | Тайм-аут корректного завершения работы. |
|
||||
| `VOCAT_MAX_REQUEST_BODY_BYTES` | `1048576` | Максимальный размер тела запроса API. |
|
||||
| `VOCAT_REPO` | `MengMengCode/VoCat` | Доверенный репозиторий GitHub, используемый самообновлятором, в формате `owner/name`. |
|
||||
| `GITHUB_TOKEN` | пусто | Необязательный токен GitHub для приватных репозиториев или более высоких лимитов API. |
|
||||
|
||||
Не храните токены Telegram, пароли SMTP, секреты вебхуков, учётные данные SIM или другие приватные данные в репозитории. Настраивайте их через параметры приложения или защищённые файлы окружения.
|
||||
|
||||
## Telegram-бот
|
||||
|
||||
Когда уведомления Telegram включены и настроены Chat ID и Admin ID, бот поддерживает:
|
||||
|
||||
```text
|
||||
/status [устройство]
|
||||
/esim <устройство>
|
||||
/switch <устройство> <iccid>
|
||||
/wfc <устройство> <status|on|off|reconnect>
|
||||
/sms <устройство> <номер> <сообщение>
|
||||
```
|
||||
|
||||
Переключение профилей и отправка SMS используют одноразовые кнопки подтверждения. Бот не предоставляет команды загрузки, удаления или переименования eSIM.
|
||||
|
||||
## Обновление
|
||||
|
||||
Проверить наличие более нового GitHub Release:
|
||||
|
||||
```bash
|
||||
vocat update --check --repo MengMengCode/VoCat
|
||||
```
|
||||
|
||||
Установить последний релиз:
|
||||
|
||||
```bash
|
||||
sudo vocat update --repo MengMengCode/VoCat
|
||||
```
|
||||
|
||||
Обновлятор загружает бинарный файл, соответствующий текущей архитектуре Linux, проверяет его по опубликованному `SHA256SUMS`, атомарно заменяет исполняемый файл и перезапускает сервис systemd `vocat`, когда он доступен.
|
||||
|
||||
Для установок Docker:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/mengmengcode/vocat:latest
|
||||
```
|
||||
|
||||
Пересоздайте контейнер после загрузки нового образа.
|
||||
|
||||
## Разработка
|
||||
|
||||
Требования:
|
||||
|
||||
- Go 1.25 или новее
|
||||
- Node.js 20 или новее
|
||||
- npm
|
||||
|
||||
Запустить сервер разработки фронтенда:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Собрать встроенный фронтенд и запустить бэкенд:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm run build
|
||||
cd ..
|
||||
go run ./cmd/vocat
|
||||
```
|
||||
|
||||
Запустить все тесты:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Собрать производственный бинарный файл:
|
||||
|
||||
```bash
|
||||
go build -trimpath -ldflags "-s -w" -o vocat ./cmd/vocat
|
||||
```
|
||||
|
||||
## Автоматизация релизов
|
||||
|
||||
Отправка тега версии запускает два рабочих процесса GitHub Actions:
|
||||
|
||||
- `release-binaries` собирает и публикует бинарные файлы `amd64`, `386`, `arm64`, `aarch64` и `armv7` вместе с `SHA256SUMS`.
|
||||
- `docker` собирает и публикует мультиархитектурный образ в GitHub Container Registry.
|
||||
|
||||
```bash
|
||||
git tag v0.2.0
|
||||
git push origin v0.2.0
|
||||
```
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```text
|
||||
cmd/vocat/ Точка входа приложения и CLI
|
||||
internal/device/ Обнаружение модемов и управление устройствами
|
||||
internal/modem/ Сессия AT и обработка ответов
|
||||
internal/server/ HTTP API, уведомления и встроенный веб-сервер
|
||||
internal/store/ Постоянное хранение SQLite
|
||||
internal/update/ Самообновлятор GitHub Release
|
||||
internal/vowifi/ Среда выполнения IKE, EAP-AKA, IMS и WiFi Calling
|
||||
scripts/install.sh Установщик и обновлятор Linux
|
||||
web/src/ Фронтенд на React и TypeScript
|
||||
.github/workflows/ Автоматизация релизов бинарных файлов и Docker
|
||||
```
|
||||
|
||||
## Ответственное использование
|
||||
|
||||
Операции с сотовыми модемами и eSIM могут влиять на обслуживание абонента, сохранённые профили, регистрацию в сети и состояние оборудования. Делайте резервные копии, внимательно проверяйте деструктивные действия и используйте программное обеспечение только в законных средах, где вам разрешено работать с подключённым оборудованием и сетевыми ресурсами.
|
||||
|
||||
Vocat не обходит аутентификацию оператора, сетевую политику, аппаратную безопасность или требования доверия eSIM. Поддержка операции означает, что Vocat может запросить её у модема или eUICC; устройство, профиль, сеть или оператор всё равно могут её отклонить.
|
||||
|
||||
## Участие в разработке
|
||||
|
||||
Мы приветствуем issues и pull request'ы. Делайте изменения сфокусированными, по возможности добавляйте тесты, избегайте коммита учётных данных или данных абонентов и чётко документируйте поведение, специфичное для оборудования.
|
||||
|
||||
Перед отправкой изменения:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
cd web && npm run build
|
||||
```
|
||||
|
||||
## Благодарности
|
||||
- [Nodeseek.com](https://www.nodeseek.com) — Сообщество, посвящённое серверам
|
||||
- [Linux.do](https://linux.do) — Вдохновляющее технологическое сообщество
|
||||
- [iniwex5](https://github.com/iniwex5) — Руководства по стилю и функциональности
|
||||
|
||||
## Угостите меня кофе
|
||||
|
||||
| Сеть | Адрес |
|
||||
| ------- | ------- |
|
||||
| USDT-TRON (TRC20) | `TQQAbboBoU8h5xX4YCA1rqWJU2WjK3seSg` |
|
||||
| USDT-BSC (BEP20) | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
|
||||
| USDT-Polygon | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
|
||||
|
||||
## Лицензия
|
||||
|
||||
См. [LICENSE](../LICENSE).
|
||||
|
||||
[](https://meteor-history.com)
|
||||
+19
-7
@@ -22,7 +22,7 @@
|
||||
<img alt="GitHub Actions" src="https://img.shields.io/badge/GitHub_Actions-Release-2088FF?style=flat-square&logo=githubactions&logoColor=white">
|
||||
</p>
|
||||
|
||||
[English](../README.md) | **简体中文**
|
||||
[English](../README.md) | [العربية](README.ar.md) | **简体中文** | [繁體中文](README.zh-TW.md) | [Français](README.fr.md) | [Русский](README.ru.md) | [Español](README.es.md) | [日本語](README.ja.md)
|
||||
|
||||
Vocat 是一款面向 Quectel EC20/EC25 系列蜂窝模组的开源 Web 控制面板与工程工具套件。它在一个自包含的服务中整合了模组发现、实时射频状态、AT 与 USSD 终端、短信、WiFi Calling(WiFi 通话)、eSIM 管理、网络选择、代理路由、通知、审计日志以及发布自动化。
|
||||
|
||||
@@ -46,7 +46,7 @@ Vocat 是一款面向 Quectel EC20/EC25 系列蜂窝模组的开源 Web 控制
|
||||
| 卡策略 | 基于 ICCID 的 WiFi Calling 与飞行模式行为,策略即时应用。 |
|
||||
| 代理路由 | 上游 SOCKS 路由、设备绑定、国家规则、TCP 可达性检查以及面向 WiFi Calling 数据路径的 UDP Associate 检查。 |
|
||||
| 通知 | 通过 Telegram、Bark、邮件、Pushplus 以及签名 Webhook 转发新入站短信,每条短信单独推送。 |
|
||||
| Telegram 机器人 | 设备状态、已安装配置文件列表与切换、WiFi Calling 控制、短信发送、定时拨号并自动挂断、通话状态、接听与挂断命令。敏感操作需要管理员确认。 |
|
||||
| Telegram 机器人 | 设备状态、已安装配置文件列表与切换、WiFi Calling 控制以及短信发送。敏感操作需要管理员确认。 |
|
||||
| 运维 | 鉴权、CSRF 防护、访问策略、审计事件、实时日志、日志留存、健康检查、响应式布局、深色模式以及中英文应用界面。 |
|
||||
| 分发 | 静态 Linux 二进制、systemd 安装脚本、带 SHA-256 校验的自更新、Docker 镜像、GHCR 发布以及 GitHub Actions 发布构建。 |
|
||||
|
||||
@@ -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` 架构;
|
||||
@@ -172,13 +188,9 @@ Vocat 先从 `VOCAT_CONFIG` 读取可选的 JSON 配置文件,再应用 `VOCAT_*
|
||||
/switch <设备> <iccid>
|
||||
/wfc <设备> <status|on|off|reconnect>
|
||||
/sms <设备> <号码> <内容>
|
||||
/call <设备> <号码> <秒数>
|
||||
/calls <设备>
|
||||
/answer <设备>
|
||||
/hangup <设备>
|
||||
```
|
||||
|
||||
配置文件切换、短信提交与拨号使用一次性确认按钮。定时拨号会执行模组拨号动作,并在 1–600 秒后自动挂断;不会捕获或处理通话音频。机器人不暴露 eSIM 下载、删除或重命名命令。
|
||||
配置文件切换与短信提交使用一次性确认按钮。机器人不暴露 eSIM 下载、删除或重命名命令。
|
||||
|
||||
## 更新
|
||||
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
<p align="center">
|
||||
<img src="../web/public/favicon.svg" width="96" alt="Vocat">
|
||||
</p>
|
||||
|
||||
<h1 align="center">VoCat</h1>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Go" src="https://img.shields.io/badge/Go-1.25-00ADD8?style=flat-square&logo=go&logoColor=white">
|
||||
<img alt="React" src="https://img.shields.io/badge/React-19-61DAFB?style=flat-square&logo=react&logoColor=111111">
|
||||
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5.8-3178C6?style=flat-square&logo=typescript&logoColor=white">
|
||||
<img alt="Vite" src="https://img.shields.io/badge/Vite-7-646CFF?style=flat-square&logo=vite&logoColor=white">
|
||||
<img alt="Tailwind CSS" src="https://img.shields.io/badge/Tailwind_CSS-3-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white">
|
||||
<img alt="SQLite" src="https://img.shields.io/badge/SQLite-Embedded-003B57?style=flat-square&logo=sqlite&logoColor=white">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Linux" src="https://img.shields.io/badge/Linux-amd64_%7C_386_%7C_arm64_%7C_aarch64_%7C_armv7-FCC624?style=flat-square&logo=linux&logoColor=111111">
|
||||
<img alt="Docker" src="https://img.shields.io/badge/Docker-Multi--Arch-2496ED?style=flat-square&logo=docker&logoColor=white">
|
||||
<img alt="WiFi Calling" src="https://img.shields.io/badge/WiFi_Calling-IMS_SMS-7B1FA2?style=flat-square">
|
||||
<img alt="eSIM" src="https://img.shields.io/badge/eSIM-LPA_%2F_eUICC-009688?style=flat-square">
|
||||
<img alt="Telegram" src="https://img.shields.io/badge/Telegram-Bot-26A5E4?style=flat-square&logo=telegram&logoColor=white">
|
||||
<img alt="GitHub Actions" src="https://img.shields.io/badge/GitHub_Actions-Release-2088FF?style=flat-square&logo=githubactions&logoColor=white">
|
||||
</p>
|
||||
|
||||
[English](../README.md) | [العربية](README.ar.md) | [简体中文](README.zh-CN.md) | **繁體中文** | [Français](README.fr.md) | [Русский](README.ru.md) | [Español](README.es.md) | [日本語](README.ja.md)
|
||||
|
||||
Vocat 是一款面向 Quectel EC20/EC25 系列行動通訊模組的開源 Web 控制面板與工程工具套件。它在單一自包含的服務中整合了模組探索、即時射頻狀態、AT 與 USSD 終端、簡訊、WiFi Calling(WiFi 通話)、eSIM 管理、網路選擇、代理路由、通知、稽核日誌以及發佈自動化。
|
||||
|
||||
後端使用 Go 撰寫,介面採用 React 與 TypeScript 建構,生產環境前端被嵌入進 Go 二進位檔中。單一可執行檔即包含完整的 Web 應用,並使用 SQLite 進行持久化儲存。
|
||||
|
||||
<p align="center">
|
||||
<img src="../img/image.png">
|
||||
<img src="../img/image-1.png">
|
||||
</p>
|
||||
|
||||
## 功能
|
||||
|
||||
| 領域 | Vocat 提供的能力 |
|
||||
| --- | --- |
|
||||
| 裝置管理 | 自動序列埠/USB 探索、多模組支援、裝置友善名稱、概覽即時更新、模組重新啟動、飛航模式以及 USB 網路卡模式控制。 |
|
||||
| 射頻與網路 | 註冊狀態、電信業者、訊號指標、RSRP/RSRQ/SINR、網路模式、頻段、通道、電信業者掃描以及自動/手動選網。 |
|
||||
| AT 與 USSD | 互動式 AT 終端、指令歷史、原始模組回應、USSD 發起/繼續/取消流程以及清晰的模組錯誤回報。 |
|
||||
| 簡訊 | 行動通訊與 IMS 簡訊直接傳送、接收同步、長簡訊合併、送達報告、對話歷史、未讀狀態、時間戳以及逐則訊息的送達狀態。 |
|
||||
| WiFi Calling | IKEv2/ePDG 隧道建立、EAP-AKA 驗證、IMS 註冊、IMS 簡訊、重新連線控制、狀態診斷以及依裝置路由。 |
|
||||
| eSIM 與 eUICC | eUICC 探索、EID 與生產資訊、憑證中繼資料、多 eUICC 清單、已安裝設定檔列表、啟用/停用/切換操作,以及在卡片支援時進行下載、重新命名與刪除。 |
|
||||
| 卡片策略 | 基於 ICCID 的 WiFi Calling 與飛航模式行為,策略即時套用。 |
|
||||
| 代理路由 | 上游 SOCKS 路由、裝置綁定、國家規則、TCP 可達性檢查以及面向 WiFi Calling 資料路徑的 UDP Associate 檢查。 |
|
||||
| 通知 | 透過 Telegram、Bark、電子郵件、Pushplus 以及簽章 Webhook 轉發新接收簡訊,每則簡訊個別推送。 |
|
||||
| Telegram 機器人 | 裝置狀態、已安裝設定檔列表與切換、WiFi Calling 控制以及簡訊傳送。敏感操作需要管理員確認。 |
|
||||
| 維運 | 驗證、CSRF 防護、存取策略、稽核事件、即時日誌、日誌保留、健康檢查、響應式版面、深色模式以及中英文應用介面。 |
|
||||
| 發佈 | 靜態 Linux 二進位檔、systemd 安裝腳本、具 SHA-256 校驗的自我更新、Docker 映像、GHCR 發佈以及 GitHub Actions 發佈建置。 |
|
||||
|
||||
## 支援的硬體
|
||||
|
||||
Vocat 面向基於高通晶片、並暴露相容 AT、QMI、序列埠與 USB 網路介面的 Quectel 模組,包括:
|
||||
|
||||
- Quectel EC20
|
||||
- Quectel EC25
|
||||
- Quectel EG25 系列
|
||||
- 相容的 EG600 及相關模組
|
||||
|
||||
可用功能取決於模組韌體、USB 複合裝置配置、SIM/eSIM 能力、主機驅動、無線網路以及電信業者配置。
|
||||
|
||||
## 安裝
|
||||
|
||||
### 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
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh -o install.sh
|
||||
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`、`aarch64` 或 `armv7` 架構;
|
||||
- 下載對應的 GitHub Release 二進位檔;
|
||||
- 對照 `SHA256SUMS` 進行校驗;
|
||||
- 將 Vocat 安裝到 `/opt/vocat`;
|
||||
- 建立具有 Vocat 所需硬體與網路存取權限的強化版 systemd 服務;
|
||||
- 將執行時配置存放在 `/etc/vocat/env`;
|
||||
- 首次安裝時產生隨機初始管理員密碼。
|
||||
|
||||
安裝完成後開啟:
|
||||
|
||||
```text
|
||||
http://<伺服器位址>:7575
|
||||
```
|
||||
|
||||
### 手動二進位安裝
|
||||
|
||||
從 GitHub Releases 下載對應的二進位檔與 `SHA256SUMS`:
|
||||
|
||||
| 平台 | 發佈檔案 |
|
||||
| --- | --- |
|
||||
| Linux x86-64 | `vocat-linux-amd64` |
|
||||
| Linux x86 32 位元 | `vocat-linux-386` |
|
||||
| Linux ARM64 | `vocat-linux-arm64` |
|
||||
| Linux AArch64 | `vocat-linux-aarch64` |
|
||||
| Linux ARMv7 | `vocat-linux-armv7` |
|
||||
|
||||
校驗並安裝:
|
||||
|
||||
```bash
|
||||
sha256sum -c SHA256SUMS --ignore-missing
|
||||
sudo install -d -m 0755 /opt/vocat/bin /opt/vocat/data
|
||||
sudo install -m 0755 vocat-linux-amd64 /opt/vocat/bin/vocat
|
||||
sudo env \
|
||||
VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db \
|
||||
VOCAT_ADMIN_PASSWORD=change-this-password \
|
||||
/opt/vocat/bin/vocat serve
|
||||
```
|
||||
|
||||
該手動指令會在前台執行 Vocat。請使用 `vocat serve` 以直接啟動伺服器;在 TTY 下以 root 執行無參數的 `vocat` 會進入互動式管理選單。如需託管的 systemd 服務與自動重新啟動,請使用一鍵安裝腳本。
|
||||
|
||||
### Docker
|
||||
|
||||
如果 Linux 主機需要探索每一個接入的受支援 Quectel 模組,並持續感知 USB 熱插拔事件,請以硬體存取模式執行 Vocat:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/mengmengcode/vocat:latest
|
||||
|
||||
docker run -d \
|
||||
--name vocat \
|
||||
--restart unless-stopped \
|
||||
--network host \
|
||||
--privileged \
|
||||
--user 0:0 \
|
||||
-e VOCAT_ADMIN_PASSWORD=change-this-password \
|
||||
-v vocat-data:/opt/vocat/data \
|
||||
-v /dev:/dev \
|
||||
-v /sys:/sys:ro \
|
||||
ghcr.io/mengmengcode/vocat:latest
|
||||
```
|
||||
|
||||
容器啟動後開啟 `http://<伺服器位址>:7575`。主機網路是必需的,這樣 QMI 網路介面才能對 Vocat 可見;而特權裝置存取是序列埠、QMI 控制節點、TUN 介面、網路配置以及容器啟動後新增裝置所必需的。`/dev` 掛載使新的 `ttyUSB*`、`ttyACM*` 和 `cdc-wdm*` 節點無需重建容器即可見。
|
||||
|
||||
該模式有意賦予 Vocat 對主機裝置與網路堆疊的廣泛存取權限,僅在受信任的 Linux 主機上使用。自動探索目前僅識別受支援的 Quectel USB 模組(USB 廠商 ID `2c7c`),不識別任意品牌的模組。僅用 `--device` 映射單一節點(例如 `/dev/ttyUSB2` 與 `/dev/cdc-wdm0`)會將容器限定在這些固定節點上,無法提供完整的多裝置或熱插拔探索。
|
||||
|
||||
GHCR 映像發佈為 `linux/amd64` 與 `linux/arm64`。
|
||||
|
||||
## 配置
|
||||
|
||||
Vocat 先從 `VOCAT_CONFIG` 讀取可選的 JSON 配置檔,再套用 `VOCAT_*` 環境變數。環境變數優先級更高。
|
||||
|
||||
| 環境變數 | 預設值 | 說明 |
|
||||
| --- | --- | --- |
|
||||
| `VOCAT_ADDR` | `0.0.0.0:7575` | HTTP 監聽位址。 |
|
||||
| `VOCAT_DATABASE_PATH` | `./data/vocat.db` | SQLite 資料庫路徑。 |
|
||||
| `VOCAT_ADMIN_USERNAME` | `admin` | 初始管理員使用者名稱。 |
|
||||
| `VOCAT_ADMIN_PASSWORD` | `admin` | 初始管理員密碼。暴露服務前請務必修改。 |
|
||||
| `VOCAT_SESSION_TTL` | `24h` | 驗證工作階段有效期。 |
|
||||
| `VOCAT_SECURE_COOKIES` | `false` | 在使用 HTTPS 時將工作階段 Cookie 標記為安全。 |
|
||||
| `VOCAT_SHUTDOWN_TIMEOUT` | `10s` | 優雅關閉逾時時間。 |
|
||||
| `VOCAT_MAX_REQUEST_BODY_BYTES` | `1048576` | API 請求主體最大位元組數。 |
|
||||
| `VOCAT_REPO` | `MengMengCode/VoCat` | 自我更新器使用的受信任 GitHub 倉庫,格式為 `owner/name`。 |
|
||||
| `GITHUB_TOKEN` | 空 | 可選的 GitHub token,用於私有倉庫或更高的 API 限額。 |
|
||||
|
||||
請勿將 Telegram token、SMTP 密碼、Webhook 金鑰、SIM 憑證或其他私密資料存放在倉庫中。請透過應用設定或受保護的環境檔來配置它們。
|
||||
|
||||
## Telegram 機器人
|
||||
|
||||
啟用 Telegram 通知並配置好 Chat ID 與 Admin ID 後,機器人支援:
|
||||
|
||||
```text
|
||||
/status [裝置]
|
||||
/esim <裝置>
|
||||
/switch <裝置> <iccid>
|
||||
/wfc <裝置> <status|on|off|reconnect>
|
||||
/sms <裝置> <號碼> <內容>
|
||||
```
|
||||
|
||||
設定檔切換與簡訊提交使用一次性確認按鈕。機器人不暴露 eSIM 下載、刪除或重新命名命令。
|
||||
|
||||
## 更新
|
||||
|
||||
檢查是否有更新的 GitHub Release:
|
||||
|
||||
```bash
|
||||
vocat update --check --repo MengMengCode/VoCat
|
||||
```
|
||||
|
||||
安裝最新發佈版:
|
||||
|
||||
```bash
|
||||
sudo vocat update --repo MengMengCode/VoCat
|
||||
```
|
||||
|
||||
更新器會下載與目前 Linux 架構匹配的二進位檔,使用已發佈的 `SHA256SUMS` 進行校驗,原子性地替換可執行檔,並在可用時重新啟動 `vocat` systemd 服務。
|
||||
|
||||
Docker 安裝的更新方式:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/mengmengcode/vocat:latest
|
||||
```
|
||||
|
||||
拉取新映像後重建容器。
|
||||
|
||||
## 開發
|
||||
|
||||
依賴要求:
|
||||
|
||||
- Go 1.25 或更新版本
|
||||
- Node.js 20 或更新版本
|
||||
- npm
|
||||
|
||||
執行前端開發伺服器:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
建構嵌入的前端並啟動後端:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm run build
|
||||
cd ..
|
||||
go run ./cmd/vocat
|
||||
```
|
||||
|
||||
執行全部測試:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
建構生產二進位檔:
|
||||
|
||||
```bash
|
||||
go build -trimpath -ldflags "-s -w" -o vocat ./cmd/vocat
|
||||
```
|
||||
|
||||
## 發佈自動化
|
||||
|
||||
推送版本標籤會觸發兩個 GitHub Actions 工作流程:
|
||||
|
||||
- `release-binaries` 建構並發佈 `amd64`、`386`、`arm64`、`aarch64` 與 `armv7` 二進位檔及 `SHA256SUMS`。
|
||||
- `docker` 建構並向 GitHub Container Registry 發佈多架構映像。
|
||||
|
||||
```bash
|
||||
git tag v0.2.0
|
||||
git push origin v0.2.0
|
||||
```
|
||||
|
||||
## 專案結構
|
||||
|
||||
```text
|
||||
cmd/vocat/ 應用入口與 CLI
|
||||
internal/device/ 模組探索與裝置控制
|
||||
internal/modem/ AT 工作階段與回應處理
|
||||
internal/server/ HTTP API、通知與內嵌 Web 伺服器
|
||||
internal/store/ SQLite 持久化
|
||||
internal/update/ GitHub Release 自我更新器
|
||||
internal/vowifi/ IKE、EAP-AKA、IMS 與 WiFi Calling 執行時
|
||||
scripts/install.sh Linux 安裝與更新腳本
|
||||
web/src/ React 與 TypeScript 前端
|
||||
.github/workflows/ 二進位檔與 Docker 發佈自動化
|
||||
```
|
||||
|
||||
## 合規使用
|
||||
|
||||
行動通訊模組與 eSIM 操作可能影響用戶服務、已儲存的設定檔、網路註冊以及硬體狀態。請做好備份,謹慎審視破壞性操作,並僅在您被允許操作所連接的硬體與網路資源的合法環境中使用本軟體。
|
||||
|
||||
Vocat 不會繞過電信業者驗證、網路策略、硬體安全或 eSIM 信任要求。支援某項操作意味著 Vocat 能夠向模組或 eUICC 發起該請求;但裝置、設定檔、網路或電信業者仍可能拒絕。
|
||||
|
||||
## 貢獻
|
||||
|
||||
歡迎提交 Issue 與 Pull Request。請保持改動聚焦,在可行處附帶測試,避免提交憑證或用戶資料,並清晰地說明硬體相關行為。
|
||||
|
||||
提交改動前:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
cd web && npm run build
|
||||
```
|
||||
|
||||
## 致謝
|
||||
- [Nodeseek.com](https://www.nodeseek.com) — 專注伺服器的社群
|
||||
- [Linux.do](https://linux.do) — 富有啟發的技術社群
|
||||
- [iniwex5](https://github.com/iniwex5) — 風格與功能指南
|
||||
|
||||
## 請我喝杯咖啡
|
||||
|
||||
| 網路 | 位址 |
|
||||
| ------- | ------- |
|
||||
| USDT-TRON (TRC20) | `TQQAbboBoU8h5xX4YCA1rqWJU2WjK3seSg` |
|
||||
| USDT-BSC (BEP20) | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
|
||||
| USDT-Polygon | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
|
||||
|
||||
## 授權條款
|
||||
|
||||
參見 [LICENSE](../LICENSE)。
|
||||
|
||||
[](https://meteor-history.com)
|
||||
@@ -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=
|
||||
|
||||
@@ -25,8 +25,11 @@ func Enabled(ctx context.Context, database *store.Store) bool {
|
||||
const (
|
||||
EnabledSettingKey = "developer.enabled"
|
||||
DeviceLimitSettingKey = "developer.device_limit"
|
||||
SMSHourlyLimitKey = "developer.sms_hourly_limit"
|
||||
DefaultDeviceLimit = 5
|
||||
MaxDeviceLimit = 128
|
||||
MaxDeviceLimit = 10
|
||||
DefaultSMSHourlyLimit = 10
|
||||
MaxSMSHourlyLimit = 20
|
||||
)
|
||||
|
||||
func DeviceLimit(ctx context.Context, database *store.Store, enabled bool) int {
|
||||
@@ -40,9 +43,12 @@ func DeviceLimit(ctx context.Context, database *store.Store, enabled bool) int {
|
||||
var document struct {
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
if json.Unmarshal(setting.Value, &document) != nil || document.Limit < 1 || document.Limit > MaxDeviceLimit {
|
||||
if json.Unmarshal(setting.Value, &document) != nil || document.Limit < 1 {
|
||||
return DefaultDeviceLimit
|
||||
}
|
||||
if document.Limit > MaxDeviceLimit {
|
||||
return MaxDeviceLimit
|
||||
}
|
||||
return document.Limit
|
||||
}
|
||||
|
||||
@@ -57,6 +63,36 @@ func SetDeviceLimit(ctx context.Context, database *store.Store, limit int) error
|
||||
return database.UpsertAppSetting(ctx, store.AppSetting{Key: DeviceLimitSettingKey, Value: value})
|
||||
}
|
||||
|
||||
// SMSHourlyLimit is enforced regardless of developer mode. Developer mode
|
||||
// only controls whether administrators can see and modify this value.
|
||||
func SMSHourlyLimit(ctx context.Context, database *store.Store) int {
|
||||
setting, err := database.AppSetting(ctx, SMSHourlyLimitKey)
|
||||
if err != nil {
|
||||
return DefaultSMSHourlyLimit
|
||||
}
|
||||
var document struct {
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
if json.Unmarshal(setting.Value, &document) != nil || document.Limit < 1 {
|
||||
return DefaultSMSHourlyLimit
|
||||
}
|
||||
if document.Limit > MaxSMSHourlyLimit {
|
||||
return MaxSMSHourlyLimit
|
||||
}
|
||||
return document.Limit
|
||||
}
|
||||
|
||||
func SetSMSHourlyLimit(ctx context.Context, database *store.Store, limit int) error {
|
||||
if limit < 1 || limit > MaxSMSHourlyLimit {
|
||||
return fmt.Errorf("SMS hourly limit must be between 1 and %d", MaxSMSHourlyLimit)
|
||||
}
|
||||
value, err := json.Marshal(map[string]int{"limit": limit})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return database.UpsertAppSetting(ctx, store.AppSetting{Key: SMSHourlyLimitKey, Value: value})
|
||||
}
|
||||
|
||||
// ResetExperimental restores every mutable developer-only setting. It is
|
||||
// called both by `vocat develop off` and at startup whenever developer mode is
|
||||
// disabled, so stale database values cannot silently remain active.
|
||||
@@ -72,6 +108,9 @@ func ResetExperimental(ctx context.Context, database *store.Store) error {
|
||||
if err := SetDeviceLimit(ctx, database, DefaultDeviceLimit); err != nil {
|
||||
resetErrors = append(resetErrors, fmt.Errorf("reset device limit: %w", err))
|
||||
}
|
||||
if err := SetSMSHourlyLimit(ctx, database, DefaultSMSHourlyLimit); err != nil {
|
||||
resetErrors = append(resetErrors, fmt.Errorf("reset SMS hourly limit: %w", err))
|
||||
}
|
||||
if err := database.DeleteAppSetting(ctx, exportproxy.SettingKey); err != nil && !errors.Is(err, store.ErrNotFound) {
|
||||
resetErrors = append(resetErrors, fmt.Errorf("delete export proxy configurations: %w", err))
|
||||
}
|
||||
|
||||
@@ -19,7 +19,10 @@ func TestResetExperimentalRestoresDefaults(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
if err := SetDeviceLimit(ctx, database, 24); err != nil {
|
||||
if err := SetDeviceLimit(ctx, database, 8); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SetSMSHourlyLimit(ctx, database, 18); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enabled, _ := json.Marshal(map[string]bool{"enabled": true})
|
||||
@@ -41,6 +44,9 @@ func TestResetExperimentalRestoresDefaults(t *testing.T) {
|
||||
if limit := DeviceLimit(ctx, database, true); limit != DefaultDeviceLimit {
|
||||
t.Fatalf("device limit = %d, want %d", limit, DefaultDeviceLimit)
|
||||
}
|
||||
if limit := SMSHourlyLimit(ctx, database); limit != DefaultSMSHourlyLimit {
|
||||
t.Fatalf("SMS hourly limit = %d, want %d", limit, DefaultSMSHourlyLimit)
|
||||
}
|
||||
setting, err := database.AppSetting(ctx, httpsmode.SettingKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -75,3 +81,45 @@ func TestSetDeviceLimitValidatesRange(t *testing.T) {
|
||||
t.Fatal("out-of-range device limit was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetSMSHourlyLimitValidatesRange(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "vocat.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
if SetSMSHourlyLimit(ctx, database, 0) == nil || SetSMSHourlyLimit(ctx, database, MaxSMSHourlyLimit+1) == nil {
|
||||
t.Fatal("out-of-range SMS hourly limit was accepted")
|
||||
}
|
||||
if err := SetSMSHourlyLimit(ctx, database, 15); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := SMSHourlyLimit(ctx, database); got != 15 {
|
||||
t.Fatalf("SMS hourly limit = %d, want 15", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoredLimitsAboveHardMaximumAreClamped(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "vocat.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
for key, limit := range map[string]int{
|
||||
DeviceLimitSettingKey: 99,
|
||||
SMSHourlyLimitKey: 99,
|
||||
} {
|
||||
value, _ := json.Marshal(map[string]int{"limit": limit})
|
||||
if err := database.UpsertAppSetting(ctx, store.AppSetting{Key: key, Value: value}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if got := DeviceLimit(ctx, database, true); got != MaxDeviceLimit {
|
||||
t.Fatalf("device limit = %d, want %d", got, MaxDeviceLimit)
|
||||
}
|
||||
if got := SMSHourlyLimit(ctx, database); got != MaxSMSHourlyLimit {
|
||||
t.Fatalf("SMS hourly limit = %d, want %d", got, MaxSMSHourlyLimit)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
+86
-49
@@ -3,14 +3,18 @@ package device
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vocat/internal/netguard"
|
||||
)
|
||||
|
||||
// es9pClient speaks SGP.22 ES9+ — JSON over HTTPS — to one SM-DP+. It is the
|
||||
@@ -24,25 +28,33 @@ import (
|
||||
// header.functionExecutionStatus (with statusCodeData.message holding the
|
||||
// human-readable failure, e.g. "The matchingID is not found").
|
||||
type es9pClient struct {
|
||||
smdp string
|
||||
http *http.Client
|
||||
smdp string
|
||||
endpoint *url.URL
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func newES9PClient(smdp string) *es9pClient {
|
||||
// The eUICC — not the host — is the root of trust for RSP: during
|
||||
// AuthenticateServer the card verifies the SM-DP+'s CERT.DPauth.SIG against
|
||||
// its embedded CI root, so a rogue/TLS-MitM server cannot forge a signature
|
||||
// the card will accept. The host TLS layer is transport only, and a minimal
|
||||
// embedded box may ship no CA bundle (this is exactly what broke on the test
|
||||
// machine), so we don't anchor host TLS to system roots. InsecureSkipVerify
|
||||
// is safe here specifically because the card does the authoritative check.
|
||||
transport := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // eUICC is the RSP trust anchor
|
||||
var smdpAddressPattern = regexp.MustCompile(`^(?:[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?|\[[0-9A-Fa-f:.]+\])(?::[0-9]{1,5})?$`)
|
||||
|
||||
func newES9PClient(ctx context.Context, smdp string) (*es9pClient, error) {
|
||||
smdp = strings.TrimSpace(smdp)
|
||||
if !smdpAddressPattern.MatchString(smdp) {
|
||||
return nil, errors.New("esim: SM-DP+ address must be a hostname with an optional port")
|
||||
}
|
||||
candidate, err := url.Parse("https://" + smdp)
|
||||
if err != nil || candidate.Hostname() == "" || candidate.User != nil ||
|
||||
(candidate.Path != "" && candidate.Path != "/") || candidate.RawQuery != "" || candidate.Fragment != "" {
|
||||
return nil, errors.New("esim: SM-DP+ address must be a hostname with an optional port")
|
||||
}
|
||||
candidate.Path = ""
|
||||
validated, err := netguard.ValidatePublicURL(ctx, candidate.String(), true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("esim: unsafe SM-DP+ address: %w", err)
|
||||
}
|
||||
return &es9pClient{
|
||||
smdp: strings.TrimSpace(smdp),
|
||||
http: &http.Client{Timeout: 90 * time.Second, Transport: transport},
|
||||
}
|
||||
smdp: validated.Host,
|
||||
endpoint: validated,
|
||||
http: netguard.NewPublicHTTPClient(90*time.Second, true),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// es9pError is a failed ES9+ functionExecutionStatus. Message is the SM-DP+'s
|
||||
@@ -80,12 +92,13 @@ type es9pStatusCodeData struct {
|
||||
// is decided the way lpac decides it: a non-success execution status, or a
|
||||
// missing required output field, yields an es9pError carrying the SM-DP+ message.
|
||||
func (c *es9pClient) call(ctx context.Context, function string, request map[string]string, requiredOut ...string) (map[string]json.RawMessage, error) {
|
||||
url := "https://" + c.smdp + "/gsma/rsp2/es9plus/" + function
|
||||
endpoint := *c.endpoint
|
||||
endpoint.Path = "/gsma/rsp2/es9plus/" + function
|
||||
body, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -149,31 +162,31 @@ func es9pErrFromStatus(function, status string, scd *es9pStatusCodeData) error {
|
||||
// human-readable failure when the SM-DP+ omits statusCodeData.message. Table
|
||||
// mirrors lpac's euicc/es9p_errors.c.
|
||||
var es9pErrorTable = map[[2]string]string{
|
||||
{"8.1", "4.8"}: "eUICC does not have sufficient space for this Profile",
|
||||
{"8.1", "6.1"}: "eUICC signature is invalid or serverChallenge is invalid",
|
||||
{"8.1.1", "2.2"}: "EID is missing in the context of this order",
|
||||
{"8.1.1", "3.1"}: "a different EID is already associated with this ICCID",
|
||||
{"8.1.1", "3.8"}: "EID doesn't match the expected value",
|
||||
{"8.1.2", "6.1"}: "EUM Certificate is invalid",
|
||||
{"8.1.2", "6.3"}: "EUM Certificate has expired",
|
||||
{"8.1.3", "6.1"}: "eUICC Certificate is invalid",
|
||||
{"8.1.3", "6.3"}: "eUICC Certificate has expired",
|
||||
{"8.2", "1.2"}: "Profile has not yet been released",
|
||||
{"8.2", "3.7"}: "BPP is not available for a new binding",
|
||||
{"8.2.5", "3.7"}: "No more Profile available for the requested Profile Type",
|
||||
{"8.2.5", "4.3"}: "No eligible Profile for this eUICC/Device",
|
||||
{"8.2.6", "3.1"}: "a different MatchingID is associated with this ICCID",
|
||||
{"8.2.6", "3.3"}: "Conflicting MatchingID value",
|
||||
{"8.2.6", "3.8"}: "MatchingID (AC_Token or EventID) is refused",
|
||||
{"8.2.7", "2.2"}: "Confirmation Code is missing",
|
||||
{"8.2.7", "3.8"}: "Confirmation Code is refused",
|
||||
{"8.2.7", "6.4"}: "maximum number of retries for the Confirmation Code exceeded",
|
||||
{"8.8.1", "3.8"}: "Invalid SM-DP+ Address",
|
||||
{"8.8.4", "3.7"}: "The SM-DP+ has no CERT.DPauth.ECDSA signed by one of the CI Public Key supported by the eUICC",
|
||||
{"8.8.5", "4.1"}: "The Download order has expired",
|
||||
{"8.8.5", "6.4"}: "maximum number of retries for the Profile download order exceeded",
|
||||
{"8.10.1", "3.9"}: "The RSP session identified by the TransactionID is unknown",
|
||||
{"8.11.1", "3.9"}: "Unknown CI Public Key. The CI used by the EUM Certificate is not a trusted root.",
|
||||
{"8.1", "4.8"}: "eUICC does not have sufficient space for this Profile",
|
||||
{"8.1", "6.1"}: "eUICC signature is invalid or serverChallenge is invalid",
|
||||
{"8.1.1", "2.2"}: "EID is missing in the context of this order",
|
||||
{"8.1.1", "3.1"}: "a different EID is already associated with this ICCID",
|
||||
{"8.1.1", "3.8"}: "EID doesn't match the expected value",
|
||||
{"8.1.2", "6.1"}: "EUM Certificate is invalid",
|
||||
{"8.1.2", "6.3"}: "EUM Certificate has expired",
|
||||
{"8.1.3", "6.1"}: "eUICC Certificate is invalid",
|
||||
{"8.1.3", "6.3"}: "eUICC Certificate has expired",
|
||||
{"8.2", "1.2"}: "Profile has not yet been released",
|
||||
{"8.2", "3.7"}: "BPP is not available for a new binding",
|
||||
{"8.2.5", "3.7"}: "No more Profile available for the requested Profile Type",
|
||||
{"8.2.5", "4.3"}: "No eligible Profile for this eUICC/Device",
|
||||
{"8.2.6", "3.1"}: "a different MatchingID is associated with this ICCID",
|
||||
{"8.2.6", "3.3"}: "Conflicting MatchingID value",
|
||||
{"8.2.6", "3.8"}: "MatchingID (AC_Token or EventID) is refused",
|
||||
{"8.2.7", "2.2"}: "Confirmation Code is missing",
|
||||
{"8.2.7", "3.8"}: "Confirmation Code is refused",
|
||||
{"8.2.7", "6.4"}: "maximum number of retries for the Confirmation Code exceeded",
|
||||
{"8.8.1", "3.8"}: "Invalid SM-DP+ Address",
|
||||
{"8.8.4", "3.7"}: "The SM-DP+ has no CERT.DPauth.ECDSA signed by one of the CI Public Key supported by the eUICC",
|
||||
{"8.8.5", "4.1"}: "The Download order has expired",
|
||||
{"8.8.5", "6.4"}: "maximum number of retries for the Profile download order exceeded",
|
||||
{"8.10.1", "3.9"}: "The RSP session identified by the TransactionID is unknown",
|
||||
{"8.11.1", "3.9"}: "Unknown CI Public Key. The CI used by the EUM Certificate is not a trusted root.",
|
||||
}
|
||||
|
||||
func es9pErrorMessage(subjectCode, reasonCode string) string {
|
||||
@@ -254,10 +267,10 @@ func (c *es9pClient) initiateAuthentication(ctx context.Context, euiccChallenge,
|
||||
// es9pAuthenticateResult carries the profile metadata and the SM-DP+ download
|
||||
// authorization needed for PrepareDownload.
|
||||
type es9pAuthenticateResult struct {
|
||||
TransactionID string
|
||||
TransactionID string
|
||||
ProfileMetadata []byte
|
||||
SmdpSigned2 []byte
|
||||
SmdpSignature2 []byte
|
||||
SmdpSigned2 []byte
|
||||
SmdpSignature2 []byte
|
||||
SmdpCertificate []byte
|
||||
}
|
||||
|
||||
@@ -287,7 +300,7 @@ func (c *es9pClient) authenticateClient(ctx context.Context, transactionID strin
|
||||
|
||||
func (c *es9pClient) getBoundProfilePackage(ctx context.Context, transactionID string, prepareDownloadResponse []byte) ([]byte, error) {
|
||||
root, err := c.call(ctx, "getBoundProfilePackage", map[string]string{
|
||||
"transactionId": transactionID,
|
||||
"transactionId": transactionID,
|
||||
"prepareDownloadResponse": es9pBase64Encode(prepareDownloadResponse),
|
||||
}, "boundProfilePackage")
|
||||
if err != nil {
|
||||
@@ -300,10 +313,34 @@ func (c *es9pClient) getBoundProfilePackage(ctx context.Context, transactionID s
|
||||
// for the download case). It is best-effort: the profile is already installed, so
|
||||
// a notification failure is reported by the caller as a warning, not a failure.
|
||||
func (c *es9pClient) handleNotification(ctx context.Context, pendingNotification []byte) error {
|
||||
_, err := c.call(ctx, "handleNotification", map[string]string{
|
||||
endpoint := *c.endpoint
|
||||
endpoint.Path = "/gsma/rsp2/es9plus/handleNotification"
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"pendingNotification": es9pBase64Encode(pendingNotification),
|
||||
})
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "gsma-rsp-lpad")
|
||||
request.Header.Set("X-Admin-Protocol", "gsma/rsp/v2.2.2")
|
||||
response, err := c.http.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("es9p handleNotification: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 1<<20))
|
||||
// SGP.22 defines HandleNotification as a notification-handler function:
|
||||
// success is an empty HTTP 204 response, not the JSON envelope returned by
|
||||
// ordinary ES9+ request-response functions.
|
||||
if response.StatusCode != http.StatusNoContent {
|
||||
return fmt.Errorf("es9p handleNotification: receiver returned HTTP %d", response.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// cancelSession aborts an in-flight download so the SM-DP+ releases the
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -16,9 +17,15 @@ func newTestES9P(t *testing.T, handler http.HandlerFunc) *es9pClient {
|
||||
t.Helper()
|
||||
server := httptest.NewTLSServer(handler)
|
||||
t.Cleanup(server.Close)
|
||||
client := newES9PClient(strings.TrimPrefix(server.URL, "https://"))
|
||||
client.http = server.Client()
|
||||
return client
|
||||
endpoint, err := url.Parse(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &es9pClient{
|
||||
smdp: strings.TrimPrefix(server.URL, "https://"),
|
||||
endpoint: endpoint,
|
||||
http: server.Client(),
|
||||
}
|
||||
}
|
||||
|
||||
func successEnvelope(fields map[string]any) map[string]any {
|
||||
@@ -33,6 +40,21 @@ func successEnvelope(fields map[string]any) map[string]any {
|
||||
|
||||
func b64(value []byte) string { return base64.StdEncoding.EncodeToString(value) }
|
||||
|
||||
func TestNewES9PClientRejectsUnsafeAddress(t *testing.T) {
|
||||
for _, address := range []string{
|
||||
"https://rsp.example.com",
|
||||
"127.0.0.1",
|
||||
"169.254.169.254",
|
||||
"rsp.example.com/unexpected/path",
|
||||
"user:[email protected]",
|
||||
"rsp.example.com\r\nX-Injected: yes",
|
||||
} {
|
||||
if _, err := newES9PClient(context.Background(), address); err == nil {
|
||||
t.Errorf("newES9PClient(%q) accepted an unsafe address", address)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitiateAuthenticationSuccess(t *testing.T) {
|
||||
signed1 := []byte{0x30, 0x03, 0x80, 0x01, 0x09}
|
||||
client := newTestES9P(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -141,3 +163,34 @@ func TestGetBoundProfilePackageSuccess(t *testing.T) {
|
||||
t.Fatalf("bpp = %X, want %X", got, pkg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleNotificationRequiresHTTP204(t *testing.T) {
|
||||
pending := []byte{0xBF, 0x37, 0x00}
|
||||
client := newTestES9P(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/gsma/rsp2/es9plus/handleNotification" {
|
||||
t.Errorf("path = %s", r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("X-Admin-Protocol") != "gsma/rsp/v2.2.2" {
|
||||
t.Errorf("X-Admin-Protocol = %q", r.Header.Get("X-Admin-Protocol"))
|
||||
}
|
||||
var request map[string]string
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Errorf("decode request: %v", err)
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(request["pendingNotification"])
|
||||
if err != nil || !bytes.Equal(decoded, pending) {
|
||||
t.Errorf("pendingNotification = %q (%X), err=%v", request["pendingNotification"], decoded, err)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
if err := client.handleNotification(context.Background(), pending); err != nil {
|
||||
t.Fatalf("handleNotification: %v", err)
|
||||
}
|
||||
|
||||
client = newTestES9P(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(successEnvelope(nil))
|
||||
})
|
||||
if err := client.handleNotification(context.Background(), pending); err == nil || !strings.Contains(err.Error(), "HTTP 200") {
|
||||
t.Fatalf("HTTP 200 error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
+144
-35
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"vocat/internal/i18n"
|
||||
"vocat/internal/modem"
|
||||
"vocat/internal/pcsc"
|
||||
)
|
||||
|
||||
// eUICC / eSIM (LPA, SGP.22) access over the modem's AT+CSIM APDU passthrough.
|
||||
@@ -190,9 +191,11 @@ func parseCSIM(response modem.Response) ([]byte, int, error) {
|
||||
|
||||
// euiccChannel is an open logical channel to the eUICC's ISD-R.
|
||||
type euiccChannel struct {
|
||||
manager *Manager
|
||||
id string
|
||||
channel int
|
||||
manager *Manager
|
||||
id string
|
||||
channel int
|
||||
pcscSession *pcsc.Session
|
||||
resetOnClose bool
|
||||
}
|
||||
|
||||
// csimAPDUTimeout bounds a single AT+CSIM exchange. Loading a BoundProfilePackage
|
||||
@@ -243,6 +246,15 @@ func (manager *Manager) openEuiccAID(ctx context.Context, id, aidHex string) (*e
|
||||
return channel, nil
|
||||
}
|
||||
lastErr = err
|
||||
if attempt == 0 && errors.Is(err, errNoLogicalChannel) &&
|
||||
manager.releaseStaleEuiccChannel(ctx, id) {
|
||||
// EC20 firmware exposes only one MANAGE CHANNEL slot. A canceled or
|
||||
// interrupted APDU transaction can leave channel 1 allocated, after
|
||||
// which every eSIM page load returns 6A81 until reboot. Closing the
|
||||
// orphan while holding the shared UICC transaction lock makes the
|
||||
// operation self-healing without disturbing an active AKA exchange.
|
||||
continue
|
||||
}
|
||||
if !isTransientEuiccCME(err) {
|
||||
return nil, err
|
||||
}
|
||||
@@ -259,11 +271,24 @@ func (manager *Manager) openEuiccAID(ctx context.Context, id, aidHex string) (*e
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func (manager *Manager) releaseStaleEuiccChannel(ctx context.Context, id string) bool {
|
||||
_, sw, err := manager.csim(ctx, id, []byte{0x00, 0x70, 0x80, 0x01, 0x00})
|
||||
return err == nil && sw == 0x9000
|
||||
}
|
||||
|
||||
func (manager *Manager) openEuiccOnce(ctx context.Context, id string) (*euiccChannel, error) {
|
||||
return manager.openEuiccOnceAID(ctx, id, isdRAID)
|
||||
}
|
||||
|
||||
func (manager *Manager) openEuiccOnceAID(ctx context.Context, id, aidHex string) (*euiccChannel, error) {
|
||||
state, lookupErr := manager.lookup(id)
|
||||
if lookupErr != nil {
|
||||
return nil, lookupErr
|
||||
}
|
||||
candidate := manager.candidateFor(state)
|
||||
if candidate.HardwareKind == pcsc.HardwareKind {
|
||||
return manager.openPCSCEuiccOnceAID(ctx, id, candidate, aidHex)
|
||||
}
|
||||
// MANAGE CHANNEL (open): 00 70 00 00 01 -> "<channel> 90 00". This EC20
|
||||
// firmware requires the explicit one-byte expected length: Le=00 opens a
|
||||
// channel but then rejects SELECT ISD-R at the AT+CSIM layer.
|
||||
@@ -302,6 +327,37 @@ func (manager *Manager) openEuiccOnceAID(ctx context.Context, id, aidHex string)
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
func (manager *Manager) openPCSCEuiccOnceAID(ctx context.Context, id string, candidate modem.Candidate, aidHex string) (*euiccChannel, error) {
|
||||
session, err := manager.cardReaders.OpenSession(ctx, pcsc.Selector{
|
||||
USBPath: candidate.USBPath, ReaderName: candidate.ReaderName,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload, sw, err := session.Transmit(ctx, []byte{0x00, 0x70, 0x00, 0x00, 0x01})
|
||||
if err != nil || sw != 0x9000 || len(payload) != 1 {
|
||||
session.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("esim: PC/SC MANAGE CHANNEL: %w", err)
|
||||
}
|
||||
return nil, errNoLogicalChannel
|
||||
}
|
||||
channel := &euiccChannel{manager: manager, id: id, channel: int(payload[0]), pcscSession: session}
|
||||
aidHex = strings.ToUpper(strings.TrimSpace(aidHex))
|
||||
aid, err := hex.DecodeString(aidHex)
|
||||
if err != nil || len(aid) == 0 || len(aid) > 255 {
|
||||
channel.close(context.Background())
|
||||
return nil, fmt.Errorf("esim: invalid ISD-R AID %q", aidHex)
|
||||
}
|
||||
selectAID := append([]byte{byte(channel.channel), 0xA4, 0x04, 0x00, byte(len(aid))}, aid...)
|
||||
_, selectSW, err := channel.transmit(ctx, selectAID, 0x00)
|
||||
if err != nil || selectSW != 0x9000 {
|
||||
channel.close(context.Background())
|
||||
return nil, errNoEUICC
|
||||
}
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
// discoverEuiccAIDs detects eSTK multi-SE and alternate-ISD-R cards without
|
||||
// changing any profile state. The vendor product applet and candidate ISD-R
|
||||
// applications are selected only as read-only capability probes. Per
|
||||
@@ -352,7 +408,23 @@ func isTransientEuiccCME(err error) bool {
|
||||
// close releases the logical channel (MANAGE CHANNEL close).
|
||||
func (channel *euiccChannel) close(ctx context.Context) {
|
||||
closeAPDU := []byte{0x00, 0x70, 0x80, byte(channel.channel), 0x00}
|
||||
_, _, _ = channel.manager.csim(ctx, channel.id, closeAPDU)
|
||||
_, _, _ = channel.exchange(ctx, closeAPDU)
|
||||
if channel.pcscSession != nil {
|
||||
if channel.resetOnClose {
|
||||
_ = channel.pcscSession.CloseWithReset()
|
||||
} else {
|
||||
_ = channel.pcscSession.Close()
|
||||
}
|
||||
channel.pcscSession = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (channel *euiccChannel) exchange(ctx context.Context, apdu []byte) ([]byte, int, error) {
|
||||
if channel.pcscSession != nil {
|
||||
payload, sw, err := channel.pcscSession.Transmit(ctx, apdu)
|
||||
return payload, int(sw), err
|
||||
}
|
||||
return channel.manager.csim(ctx, channel.id, apdu)
|
||||
}
|
||||
|
||||
// transmit sends one APDU on the logical channel (CLA high nibble from insClass,
|
||||
@@ -360,7 +432,7 @@ func (channel *euiccChannel) close(ctx context.Context) {
|
||||
// and returns the assembled payload.
|
||||
func (channel *euiccChannel) transmit(ctx context.Context, apdu []byte, insClass byte) ([]byte, int, error) {
|
||||
apdu[0] = (apdu[0] & 0xF0) | byte(channel.channel)
|
||||
payload, sw, err := channel.manager.csim(ctx, channel.id, apdu)
|
||||
payload, sw, err := channel.exchange(ctx, apdu)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@@ -369,7 +441,7 @@ func (channel *euiccChannel) transmit(ctx context.Context, apdu []byte, insClass
|
||||
for sw>>8 == 0x61 && guard < 24 {
|
||||
guard++
|
||||
getResponse := []byte{0x80 | byte(channel.channel), 0xC0, 0x00, 0x00, byte(sw & 0xFF)}
|
||||
frag, nextSW, err := channel.manager.csim(ctx, channel.id, getResponse)
|
||||
frag, nextSW, err := channel.exchange(ctx, getResponse)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@@ -543,8 +615,8 @@ func validProfileICCID(iccid string) bool {
|
||||
|
||||
// ESIMListProfiles reads the eUICC profile list via ES10c GetProfilesInfo.
|
||||
func (manager *Manager) ESIMListProfiles(ctx context.Context, id string) (EsimInfo, error) {
|
||||
manager.esimMu.Lock()
|
||||
defer manager.esimMu.Unlock()
|
||||
manager.lockESIM()
|
||||
defer manager.unlockESIM()
|
||||
if manager.esimRecoveryActive(id) {
|
||||
if cached, ok := manager.cachedESIMInfo(id); ok {
|
||||
return cached, nil
|
||||
@@ -584,14 +656,14 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
manager.esimMu.Lock()
|
||||
manager.lockESIM()
|
||||
if err := manager.waitForESIMRecovery(ctx, id); err != nil {
|
||||
manager.esimMu.Unlock()
|
||||
manager.unlockESIM()
|
||||
return err
|
||||
}
|
||||
channel, err := manager.openEuiccAID(ctx, id, targetEuiccAID(aidHex))
|
||||
if err != nil {
|
||||
manager.esimMu.Unlock()
|
||||
manager.unlockESIM()
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -610,6 +682,7 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
|
||||
// Release the logical channel before any reset: openEuicc's csim holds
|
||||
// opMu only for the duration of each APDU, so by here the lock is free.
|
||||
closeContext, cancelClose := context.WithTimeout(context.Background(), csimAPDUTimeout)
|
||||
channel.resetOnClose = channel.pcscSession != nil
|
||||
channel.close(closeContext)
|
||||
cancelClose()
|
||||
if err != nil {
|
||||
@@ -617,7 +690,7 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
|
||||
// detached reset is safe in either case and prevents an uncertain switch
|
||||
// from leaving the modem's SIM cache unusable.
|
||||
manager.startProfileSwitchRecovery(id)
|
||||
manager.esimMu.Unlock()
|
||||
manager.unlockESIM()
|
||||
return err
|
||||
}
|
||||
// A transport SW 9000 only means the APDU reached the eUICC. The real outcome
|
||||
@@ -626,11 +699,11 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
|
||||
result, ok := enableProfileResult(payload)
|
||||
if !ok {
|
||||
manager.startProfileSwitchRecovery(id)
|
||||
manager.esimMu.Unlock()
|
||||
manager.unlockESIM()
|
||||
return fmt.Errorf("esim: unexpected EnableProfile response %s", strings.ToUpper(hex.EncodeToString(payload)))
|
||||
}
|
||||
if err := enableProfileResponseError(byte(result), payload); err != nil {
|
||||
manager.esimMu.Unlock()
|
||||
manager.unlockESIM()
|
||||
return err
|
||||
}
|
||||
manager.markCachedProfileEnabled(id, iccid)
|
||||
@@ -638,7 +711,7 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
|
||||
// a detached recovery so it survives an HTTP disconnect, but keep this API
|
||||
// call pending until the live modem ICCID proves that the switch took effect.
|
||||
manager.startProfileSwitchRecovery(id)
|
||||
manager.esimMu.Unlock()
|
||||
manager.unlockESIM()
|
||||
|
||||
verifyContext, cancelVerify := context.WithTimeout(context.WithoutCancel(ctx), profileSwitchVerificationTimeout(manager))
|
||||
defer cancelVerify()
|
||||
@@ -780,9 +853,11 @@ func (manager *Manager) renameCachedProfile(id, iccid, nickname string) {
|
||||
// initiating HTTP request. EC20 commonly drops the AT port while processing
|
||||
// CFUN=1,1, so the reset error is intentionally followed by discovery retries.
|
||||
func (manager *Manager) recoverAfterProfileSwitch(id string) {
|
||||
resetContext, cancelReset := context.WithTimeout(context.Background(), manager.longTimeout)
|
||||
_ = manager.rebootForProfileSwitch(resetContext, id)
|
||||
cancelReset()
|
||||
if !manager.isPCSCDevice(id) {
|
||||
resetContext, cancelReset := context.WithTimeout(context.Background(), manager.longTimeout)
|
||||
_ = manager.rebootForProfileSwitch(resetContext, id)
|
||||
cancelReset()
|
||||
}
|
||||
manager.refreshAfterProfileSwitch(id)
|
||||
}
|
||||
|
||||
@@ -795,6 +870,20 @@ func (manager *Manager) recoverAfterProfileSwitch(id string) {
|
||||
// the next attempt. All errors are swallowed: this is best-effort self-healing
|
||||
// and setResult already records the last failure for the UI.
|
||||
func (manager *Manager) refreshAfterProfileSwitch(id string) {
|
||||
if manager.isPCSCDevice(id) {
|
||||
time.Sleep(750 * time.Millisecond)
|
||||
for attempt := 0; attempt < 10; attempt++ {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), manager.commandTimeout*4)
|
||||
_, _ = manager.Discover(ctx)
|
||||
_, err := manager.Refresh(ctx, id)
|
||||
cancel()
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
return
|
||||
}
|
||||
const (
|
||||
settle = 8 * time.Second
|
||||
interval = 4 * time.Second
|
||||
@@ -819,6 +908,14 @@ func (manager *Manager) refreshAfterProfileSwitch(id string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *Manager) isPCSCDevice(id string) bool {
|
||||
state, err := manager.lookup(id)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return manager.candidateFor(state).HardwareKind == pcsc.HardwareKind
|
||||
}
|
||||
|
||||
// enableProfileResult extracts the EnableProfile result code (tag 80) from the
|
||||
// ES10c response body. ok is false when no result code is present.
|
||||
func enableProfileResult(payload []byte) (int, bool) {
|
||||
@@ -888,25 +985,37 @@ func (manager *Manager) verifySwitchedICCID(ctx context.Context, id, expected st
|
||||
var lastICCID string
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < attempts; attempt++ {
|
||||
for _, command := range []string{"AT+CCID", "AT+QCCID"} {
|
||||
commandContext, cancel := context.WithTimeout(ctx, manager.commandTimeout)
|
||||
response, err := manager.ExecuteAT(commandContext, id, command)
|
||||
cancel()
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
if manager.isPCSCDevice(id) {
|
||||
snapshot, err := manager.Refresh(ctx, id)
|
||||
if err == nil {
|
||||
lastICCID = strings.TrimSpace(snapshot.ICCID)
|
||||
if lastICCID == expected {
|
||||
return nil
|
||||
}
|
||||
err = fmt.Errorf("reader still reports ICCID %s", lastICCID)
|
||||
}
|
||||
live := parseICCIDIdentifier(response, []string{"+CCID:", "+QCCID:"}, 18, 22)
|
||||
if live == "" {
|
||||
lastErr = errors.New("modem response contained no valid ICCID")
|
||||
continue
|
||||
lastErr = err
|
||||
} else {
|
||||
for _, command := range []string{"AT+CCID", "AT+QCCID"} {
|
||||
commandContext, cancel := context.WithTimeout(ctx, manager.commandTimeout)
|
||||
response, err := manager.ExecuteAT(commandContext, id, command)
|
||||
cancel()
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
live := parseICCIDIdentifier(response, []string{"+CCID:", "+QCCID:"}, 18, 22)
|
||||
if live == "" {
|
||||
lastErr = errors.New("modem response contained no valid ICCID")
|
||||
continue
|
||||
}
|
||||
lastICCID = live
|
||||
if live == expected {
|
||||
return nil
|
||||
}
|
||||
lastErr = fmt.Errorf("modem still reports ICCID %s", live)
|
||||
break
|
||||
}
|
||||
lastICCID = live
|
||||
if live == expected {
|
||||
return nil
|
||||
}
|
||||
lastErr = fmt.Errorf("modem still reports ICCID %s", live)
|
||||
break
|
||||
}
|
||||
if attempt+1 < attempts {
|
||||
select {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -68,8 +69,8 @@ func (manager *Manager) ESIMDeleteProfile(ctx context.Context, id, iccid, aidHex
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
manager.esimMu.Lock()
|
||||
defer manager.esimMu.Unlock()
|
||||
manager.lockESIM()
|
||||
defer manager.unlockESIM()
|
||||
if err := manager.waitForESIMRecovery(ctx, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -63,8 +63,8 @@ func (manager *Manager) ESIMDisableProfile(ctx context.Context, id, iccid, aidHe
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
manager.esimMu.Lock()
|
||||
defer manager.esimMu.Unlock()
|
||||
manager.lockESIM()
|
||||
defer manager.unlockESIM()
|
||||
if err := manager.waitForESIMRecovery(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// EsimDownloadParams are the SPA download form fields, mapped from the
|
||||
@@ -47,8 +48,8 @@ func (manager *Manager) ESIMDownloadProfile(ctx context.Context, id string, para
|
||||
}
|
||||
}
|
||||
|
||||
manager.esimMu.Lock()
|
||||
defer manager.esimMu.Unlock()
|
||||
manager.lockESIM()
|
||||
defer manager.unlockESIM()
|
||||
|
||||
report("preflight", "正在检查 eUICC 剩余空间...", 10)
|
||||
channel, err := manager.openEuiccAID(ctx, id, targetEuiccAID(params.AIDHex))
|
||||
@@ -74,7 +75,10 @@ func (manager *Manager) ESIMDownloadProfile(ctx context.Context, id string, para
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := newES9PClient(smdp)
|
||||
client, err := newES9PClient(ctx, smdp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
report("auth_client", "正在向 SM-DP+ 进行客户端身份认证...", 30)
|
||||
init, err := client.initiateAuthentication(ctx, challenge, info1)
|
||||
@@ -127,15 +131,24 @@ func (manager *Manager) ESIMDownloadProfile(ctx context.Context, id string, para
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
iccid, err := installationResult(installResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
report("notify", "正在向运营商发送下载通知...", 90)
|
||||
iccid, installErr := installationResult(installResponse)
|
||||
warning := ""
|
||||
if err := client.handleNotification(ctx, installResponse); err != nil {
|
||||
warning = "Profile 已安装,但下载通知发送失败"
|
||||
notification, notificationErr := parsePendingNotification(installResponse)
|
||||
if notificationErr == nil {
|
||||
// Loading the final BPP segment is the commit point. Finish the operator
|
||||
// acknowledgement even if the browser closes its SSE connection now.
|
||||
notifyContext, cancelNotify := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute)
|
||||
notificationErr = channel.deliverNotification(notifyContext, notification)
|
||||
cancelNotify()
|
||||
}
|
||||
if notificationErr != nil {
|
||||
warning = "Profile 安装结果已保留在 eUICC,但向运营商上报失败,可在当前通知列表中重发"
|
||||
}
|
||||
// Error installation results must be reported too. Return the card-side
|
||||
// installation failure only after making that best-effort ES9+ attempt.
|
||||
if installErr != nil {
|
||||
return nil, installErr
|
||||
}
|
||||
|
||||
freeAfter := freeBefore
|
||||
@@ -215,8 +228,8 @@ type EsimChipInfo struct {
|
||||
// ESIMChipInfo reads the eUICC's EID, EUICCInfo2, and configured addresses for
|
||||
// the chip header. It takes the eSIM lock like the other card ops.
|
||||
func (manager *Manager) ESIMChipInfo(ctx context.Context, id string) (*EsimChipInfo, error) {
|
||||
manager.esimMu.Lock()
|
||||
defer manager.esimMu.Unlock()
|
||||
manager.lockESIM()
|
||||
defer manager.unlockESIM()
|
||||
|
||||
var lastErr error
|
||||
for _, aid := range manager.discoverEuiccAIDs(ctx, id) {
|
||||
@@ -273,8 +286,8 @@ func readEsimChipInfo(ctx context.Context, channel *euiccChannel, aidHex string)
|
||||
// the inserted card. It is entirely read-only: only SELECT, GetProfilesInfo,
|
||||
// GetEuiccData, GetEuiccInfo2 and GetEuiccConfiguredAddresses are issued.
|
||||
func (manager *Manager) ESIMInventory(ctx context.Context, id string) ([]EsimInventoryEntry, error) {
|
||||
manager.esimMu.Lock()
|
||||
defer manager.esimMu.Unlock()
|
||||
manager.lockESIM()
|
||||
defer manager.unlockESIM()
|
||||
if manager.esimRecoveryActive(id) {
|
||||
return nil, errESIMRecovering
|
||||
}
|
||||
|
||||
@@ -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.lockESIM()
|
||||
defer manager.unlockESIM()
|
||||
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.lockESIM()
|
||||
defer manager.unlockESIM()
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -49,8 +49,8 @@ func (manager *Manager) ESIMRenameProfile(ctx context.Context, id, iccid, nickna
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
manager.esimMu.Lock()
|
||||
defer manager.esimMu.Unlock()
|
||||
manager.lockESIM()
|
||||
defer manager.unlockESIM()
|
||||
if err := manager.waitForESIMRecovery(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -294,6 +294,46 @@ func TestEUICCChannelStuckWrapsTransientCME(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenEuiccRecoversOrphanedSingleLogicalChannel(t *testing.T) {
|
||||
client := &transcriptClient{steps: []clientStep{
|
||||
{
|
||||
command: `AT+CSIM=10,"0070000001"`,
|
||||
response: okResponse(`+CSIM: 6,"006A81"`),
|
||||
},
|
||||
{
|
||||
command: `AT+CSIM=10,"0070800100"`,
|
||||
response: okResponse(`+CSIM: 4,"9000"`),
|
||||
},
|
||||
{
|
||||
command: `AT+CSIM=10,"0070000001"`,
|
||||
response: okResponse(`+CSIM: 6,"019000"`),
|
||||
},
|
||||
{
|
||||
command: fmt.Sprintf(
|
||||
`AT+CSIM=42,"01A4040010%s"`,
|
||||
isdRAID,
|
||||
),
|
||||
response: okResponse(`+CSIM: 4,"9000"`),
|
||||
},
|
||||
{
|
||||
command: `AT+CSIM=10,"0070800100"`,
|
||||
response: okResponse(`+CSIM: 4,"9000"`),
|
||||
},
|
||||
}}
|
||||
manager, id := newStartedTestManager(t, client)
|
||||
|
||||
manager.lockESIM()
|
||||
channel, err := manager.openEuiccAID(context.Background(), id, isdRAID)
|
||||
if err == nil {
|
||||
channel.close(context.Background())
|
||||
}
|
||||
manager.unlockESIM()
|
||||
if err != nil {
|
||||
t.Fatalf("open eUICC after orphaned channel: %v", err)
|
||||
}
|
||||
client.assertDone(t)
|
||||
}
|
||||
|
||||
func TestWaitForESIMRecovery(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
manager := &Manager{esimRecoveries: map[string]chan struct{}{"dev": done}}
|
||||
|
||||
+112
-4
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"vocat/internal/modem"
|
||||
"vocat/internal/pcsc"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
@@ -19,10 +20,12 @@ type Options struct {
|
||||
LongTimeout time.Duration
|
||||
SMSTimeout time.Duration
|
||||
ScanTimeout time.Duration
|
||||
CardReaders *pcsc.Service
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
uiccMu sync.Mutex // serializes all multi-command UICC/APDU transactions
|
||||
esimMu sync.Mutex // serializes eSIM card access (list/switch/download)
|
||||
esimRecoveryMu sync.Mutex
|
||||
esimRecoveries map[string]chan struct{}
|
||||
@@ -34,11 +37,29 @@ type Manager struct {
|
||||
longTimeout time.Duration
|
||||
smsTimeout time.Duration
|
||||
scanTimeout time.Duration
|
||||
cardReaders *pcsc.Service
|
||||
started bool
|
||||
devices map[string]*managedDevice
|
||||
ussdSessions map[string]ussdSession
|
||||
}
|
||||
|
||||
// LockUICC and UnlockUICC allow another in-process UICC client (currently the
|
||||
// VoWiFi AKA adapter) to share the same transaction boundary as eSIM ES10.
|
||||
// Individual AT commands are already serialized per modem, but a logical-
|
||||
// channel transaction spans several commands and must not be interleaved.
|
||||
func (manager *Manager) LockUICC() { manager.uiccMu.Lock() }
|
||||
func (manager *Manager) UnlockUICC() { manager.uiccMu.Unlock() }
|
||||
|
||||
func (manager *Manager) lockESIM() {
|
||||
manager.esimMu.Lock()
|
||||
manager.uiccMu.Lock()
|
||||
}
|
||||
|
||||
func (manager *Manager) unlockESIM() {
|
||||
manager.uiccMu.Unlock()
|
||||
manager.esimMu.Unlock()
|
||||
}
|
||||
|
||||
// ussdSession tracks an open USSD dialog on a device so a follow-up Continue or
|
||||
// Cancel can be routed back to the right modem. The modem owns the actual
|
||||
// network session; this map only records which device a session id belongs to.
|
||||
@@ -59,6 +80,7 @@ type managedDevice struct {
|
||||
discovered bool
|
||||
preFlightMode *int
|
||||
resetClientOnLock bool
|
||||
simPIN string
|
||||
}
|
||||
|
||||
func NewManager(options Options) (*Manager, error) {
|
||||
@@ -82,6 +104,9 @@ func NewManager(options Options) (*Manager, error) {
|
||||
// AT+COPS=? can take well over a minute while the modem sweeps every band.
|
||||
options.ScanTimeout = 150 * time.Second
|
||||
}
|
||||
if options.CardReaders == nil {
|
||||
options.CardReaders = pcsc.New()
|
||||
}
|
||||
return &Manager{
|
||||
discoverer: options.Discoverer,
|
||||
opener: options.Opener,
|
||||
@@ -89,6 +114,7 @@ func NewManager(options Options) (*Manager, error) {
|
||||
longTimeout: options.LongTimeout,
|
||||
smsTimeout: options.SMSTimeout,
|
||||
scanTimeout: options.ScanTimeout,
|
||||
cardReaders: options.CardReaders,
|
||||
devices: make(map[string]*managedDevice),
|
||||
ussdSessions: make(map[string]ussdSession),
|
||||
esimRecoveries: make(map[string]chan struct{}),
|
||||
@@ -146,9 +172,23 @@ func (manager *Manager) Discover(ctx context.Context) ([]Device, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
candidates, err := manager.discoverer.Discover(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
candidates, modemErr := manager.discoverer.Discover(ctx)
|
||||
readers, readerErr := manager.cardReaders.Readers(ctx)
|
||||
if readerErr == nil {
|
||||
for _, reader := range readers {
|
||||
candidates = append(candidates, modem.Candidate{
|
||||
ID: pcsc.DeviceID(reader), HardwareKind: pcsc.HardwareKind,
|
||||
ReaderName: reader.Name, USBPath: reader.USBPath,
|
||||
VendorID: reader.VendorID, ProductID: reader.ProductID,
|
||||
Manufacturer: reader.Manufacturer, Product: reader.Product,
|
||||
})
|
||||
}
|
||||
}
|
||||
if modemErr != nil && readerErr != nil && !errors.Is(readerErr, pcsc.ErrUnsupported) && !errors.Is(readerErr, pcsc.ErrUnavailable) {
|
||||
return nil, errors.Join(modemErr, readerErr)
|
||||
}
|
||||
if modemErr != nil && len(candidates) == 0 {
|
||||
return nil, modemErr
|
||||
}
|
||||
seen := make(map[string]struct{}, len(candidates))
|
||||
|
||||
@@ -360,6 +400,9 @@ func (manager *Manager) Refresh(ctx context.Context, id string) (Snapshot, error
|
||||
return Snapshot{}, err
|
||||
}
|
||||
candidate := manager.candidateFor(state)
|
||||
if candidate.HardwareKind == pcsc.HardwareKind {
|
||||
return manager.refreshCardReader(ctx, id, state, candidate)
|
||||
}
|
||||
backend := manager.backendFor(state)
|
||||
client, err := manager.clientLocked(ctx, state, candidate)
|
||||
if err != nil {
|
||||
@@ -375,11 +418,59 @@ func (manager *Manager) Refresh(ctx context.Context, id string) (Snapshot, error
|
||||
return snapshot, err
|
||||
}
|
||||
|
||||
func (manager *Manager) refreshCardReader(ctx context.Context, id string, state *managedDevice, candidate modem.Candidate) (Snapshot, error) {
|
||||
result := Snapshot{
|
||||
DeviceID: id, Port: candidate.ReaderName, Responsive: true,
|
||||
Manufacturer: candidate.Manufacturer, Model: candidate.Product,
|
||||
AccessTech: "Wi-Fi", RegistrationSource: "pcsc", OperatingMode: 4,
|
||||
ModeKnown: true, FlightMode: true, RadioOff: true, UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
previousICCID := state.lastICCID
|
||||
card, err := manager.cardReaders.Snapshot(ctx, pcsc.Selector{USBPath: candidate.USBPath, ReaderName: candidate.ReaderName}, state.simPIN)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, pcsc.ErrNoCard):
|
||||
result.SIMStatus = ""
|
||||
err = nil
|
||||
case errors.Is(err, pcsc.ErrPINRequired), errors.Is(err, pcsc.ErrPINTriesLow), errors.Is(err, pcsc.ErrPINRejected):
|
||||
result.SIMStatus = "SIM PIN"
|
||||
result.Warnings = []string{err.Error()}
|
||||
err = nil
|
||||
default:
|
||||
manager.setResult(id, state, &result, err)
|
||||
return result, err
|
||||
}
|
||||
} else {
|
||||
result.SIMStatus = "READY"
|
||||
result.SIMReady = true
|
||||
result.ICCID = card.Identity.ICCID
|
||||
result.IMSI = card.Identity.IMSI
|
||||
result.SPN = card.Identity.SPN
|
||||
result.SIMChanged = previousICCID != "" && !strings.EqualFold(previousICCID, result.ICCID)
|
||||
state.lastICCID = result.ICCID
|
||||
}
|
||||
manager.setResult(id, state, &result, err)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// SetSIMPin updates the in-memory PIN used for protected USIM files and AKA.
|
||||
// It is deliberately never retained in runtime snapshots or logs.
|
||||
func (manager *Manager) SetSIMPin(id, pin string) error {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
state := manager.devices[id]
|
||||
if state == nil || !state.discovered {
|
||||
return ErrNotFound
|
||||
}
|
||||
state.simPIN = strings.TrimSpace(pin)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetBackend selects which control plane supplies registration and data state.
|
||||
// AT remains available in either mode for UICC, RF, SMS, voice and diagnostics.
|
||||
func (manager *Manager) SetBackend(id, backend string) error {
|
||||
backend = strings.ToLower(strings.TrimSpace(backend))
|
||||
if backend != "at" && backend != "qmi" {
|
||||
if backend != "at" && backend != "qmi" && backend != "pcsc" {
|
||||
return fmt.Errorf("unsupported device backend %q", backend)
|
||||
}
|
||||
manager.mu.Lock()
|
||||
@@ -559,3 +650,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 {
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"hash/fnv"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
func platformSupported() error { return nil }
|
||||
@@ -54,14 +54,15 @@ func boundResolver(networkInterface string) *net.Resolver {
|
||||
}
|
||||
|
||||
func exportRouteDNSServers(networkInterface string) []string {
|
||||
safeName := strings.Map(func(character rune) rune {
|
||||
if character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' ||
|
||||
character >= '0' && character <= '9' || character == '-' || character == '_' || character == '.' {
|
||||
return character
|
||||
}
|
||||
return '_'
|
||||
}, networkInterface)
|
||||
file, err := os.Open(filepath.Join("/run/vocat", "cellular-"+safeName+".dns"))
|
||||
if !validInterfaceName(networkInterface) {
|
||||
return []string{"1.1.1.1", "8.8.8.8"}
|
||||
}
|
||||
root, err := os.OpenRoot("/run/vocat")
|
||||
if err != nil {
|
||||
return []string{"1.1.1.1", "8.8.8.8"}
|
||||
}
|
||||
defer root.Close()
|
||||
file, err := root.Open("cellular-" + networkInterface + ".dns")
|
||||
if err != nil {
|
||||
return []string{"1.1.1.1", "8.8.8.8"}
|
||||
}
|
||||
@@ -78,3 +79,20 @@ func exportRouteDNSServers(networkInterface string) []string {
|
||||
}
|
||||
return servers
|
||||
}
|
||||
|
||||
// Linux IFNAMSIZ is 16 including the terminator. Restricting names here both
|
||||
// matches kernel interface names and prevents a stored device value from ever
|
||||
// becoming a filesystem path component.
|
||||
func validInterfaceName(value string) bool {
|
||||
if value == "" || len(value) > 15 || value == "." || value == ".." {
|
||||
return false
|
||||
}
|
||||
for _, character := range value {
|
||||
if character > unicode.MaxASCII || !(character >= 'a' && character <= 'z' ||
|
||||
character >= 'A' && character <= 'Z' || character >= '0' && character <= '9' ||
|
||||
character == '-' || character == '_' || character == '.') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
//go:build linux
|
||||
|
||||
package exportproxy
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidInterfaceName(t *testing.T) {
|
||||
for _, value := range []string{"wwan0", "wwp0s20f0u5i4", "rmnet_data0", "usb.1"} {
|
||||
if !validInterfaceName(value) {
|
||||
t.Errorf("validInterfaceName(%q) = false", value)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"", ".", "..", "../wwan0", `..\wwan0`, "wwan0/evil", "interface-name-too-long"} {
|
||||
if validInterfaceName(value) {
|
||||
t.Errorf("validInterfaceName(%q) = true", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -26,10 +27,15 @@ import (
|
||||
"time"
|
||||
|
||||
"vocat/internal/exportproxy"
|
||||
"vocat/internal/netguard"
|
||||
)
|
||||
|
||||
const maxPackageBytes int64 = 64 << 20
|
||||
|
||||
// This syntactic guard gives the request boundary an explicit allowlist. The
|
||||
// resolved addresses are still checked again by netguard before dialing.
|
||||
var publicHTTPSURLPattern = regexp.MustCompile(`^https://(?:[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?|\[[0-9A-Fa-f:.]+\])(?::[0-9]{1,5})?(?:[/?#][^\r\n]*)?$`)
|
||||
|
||||
type Plugin struct {
|
||||
Manifest
|
||||
Enabled bool `json:"enabled"`
|
||||
@@ -73,7 +79,7 @@ func NewManager(root string, logger *slog.Logger) (*Manager, error) {
|
||||
}
|
||||
manager := &Manager{
|
||||
root: root, logger: logger, plugins: make(map[string]*Plugin),
|
||||
client: &http.Client{Timeout: 45 * time.Second},
|
||||
client: netguard.NewPublicHTTPClient(45*time.Second, true),
|
||||
}
|
||||
if err := manager.scan(); err != nil {
|
||||
return nil, err
|
||||
@@ -155,9 +161,13 @@ func (manager *Manager) List() []Plugin {
|
||||
}
|
||||
|
||||
func (manager *Manager) InstallURL(ctx context.Context, rawURL, expectedSHA string) (Plugin, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") || parsed.Host == "" {
|
||||
return Plugin{}, errors.New("plugin URL must be an absolute HTTP or HTTPS URL")
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
if !publicHTTPSURLPattern.MatchString(rawURL) {
|
||||
return Plugin{}, errors.New("plugin URL must be a public absolute HTTPS URL")
|
||||
}
|
||||
parsed, err := netguard.ValidatePublicURL(ctx, rawURL, true)
|
||||
if err != nil {
|
||||
return Plugin{}, fmt.Errorf("plugin URL must be a public absolute HTTPS URL: %w", err)
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
|
||||
if err != nil {
|
||||
@@ -353,12 +363,13 @@ func (manager *Manager) ServeAsset(w http.ResponseWriter, r *http.Request, id, n
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
filename := filepath.Join(plugin.dir, filepath.FromSlash(name))
|
||||
if !strings.HasPrefix(filepath.Clean(filename), filepath.Clean(plugin.dir)+string(os.PathSeparator)) {
|
||||
root, err := os.OpenRoot(plugin.dir)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
file, err := os.Open(filename)
|
||||
defer root.Close()
|
||||
file, err := root.Open(filepath.FromSlash(name))
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
@@ -369,7 +380,7 @@ func (manager *Manager) ServeAsset(w http.ResponseWriter, r *http.Request, id, n
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
contentType := mime.TypeByExtension(filepath.Ext(filename))
|
||||
contentType := mime.TypeByExtension(filepath.Ext(name))
|
||||
if contentType != "" {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
}
|
||||
|
||||
@@ -3,12 +3,32 @@ package extensions
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInstallURLRejectsNonHTTPSAndPrivateDestinations(t *testing.T) {
|
||||
manager, err := NewManager(t.TempDir(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer manager.Close()
|
||||
for _, raw := range []string{
|
||||
"http://example.com/plugin.zip",
|
||||
"https://[email protected]/plugin.zip",
|
||||
"https://example.com/plugin.zip\r\nX-Injected: yes",
|
||||
"https://127.0.0.1/plugin.zip",
|
||||
"https://169.254.169.254/latest/meta-data/",
|
||||
} {
|
||||
if _, err := manager.InstallURL(context.Background(), raw, ""); err == nil {
|
||||
t.Errorf("InstallURL(%q) accepted an unsafe destination", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallListDisableAndUninstall(t *testing.T) {
|
||||
manager, err := NewManager(t.TempDir(), slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
|
||||
@@ -44,6 +44,8 @@ func (p Port) OpenPath() string {
|
||||
}
|
||||
|
||||
type Candidate struct {
|
||||
HardwareKind string `json:"hardwareKind,omitempty"`
|
||||
ReaderName string `json:"readerName,omitempty"`
|
||||
ID string `json:"id"`
|
||||
VendorID string `json:"vendorId"`
|
||||
ProductID string `json:"productId"`
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package netguard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ValidatePublicURL accepts an absolute HTTP(S) URL only when every currently
|
||||
// resolved address is publicly routable. The transport returned by
|
||||
// NewPublicHTTPClient repeats the same check when it dials, which also prevents
|
||||
// DNS rebinding between validation and connection establishment.
|
||||
func ValidatePublicURL(ctx context.Context, raw string, requireHTTPS bool) (*url.URL, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || !parsed.IsAbs() || parsed.Hostname() == "" {
|
||||
return nil, errors.New("destination must be an absolute HTTP URL")
|
||||
}
|
||||
if parsed.User != nil {
|
||||
return nil, errors.New("destination URL cannot contain user information")
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return nil, errors.New("destination URL must use HTTP or HTTPS")
|
||||
}
|
||||
if requireHTTPS && parsed.Scheme != "https" {
|
||||
return nil, errors.New("destination URL must use HTTPS")
|
||||
}
|
||||
if port := parsed.Port(); port != "" {
|
||||
value, err := strconv.Atoi(port)
|
||||
if err != nil || value < 1 || value > 65535 {
|
||||
return nil, errors.New("destination URL has an invalid port")
|
||||
}
|
||||
}
|
||||
if _, err := resolvePublic(ctx, parsed.Hostname()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// NewPublicHTTPClient creates a client that never uses environment proxies,
|
||||
// rejects private/special-use destinations at dial time, and validates every
|
||||
// redirect before following it.
|
||||
func NewPublicHTTPClient(timeout time.Duration, requireHTTPS bool) *http.Client {
|
||||
if timeout <= 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
transport := &http.Transport{
|
||||
Proxy: nil,
|
||||
DialContext: PublicDialer(timeout),
|
||||
ForceAttemptHTTP2: true,
|
||||
TLSHandshakeTimeout: timeout,
|
||||
ResponseHeaderTimeout: timeout,
|
||||
ExpectContinueTimeout: time.Second,
|
||||
TLSClientConfig: &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
},
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: timeout,
|
||||
CheckRedirect: func(request *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 4 {
|
||||
return errors.New("too many redirects")
|
||||
}
|
||||
_, err := ValidatePublicURL(request.Context(), request.URL.String(), requireHTTPS)
|
||||
return err
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// PublicDialer resolves the original hostname and connects directly to one of
|
||||
// its validated public addresses. It does not pass the hostname back through a
|
||||
// second resolver, so a DNS rebinding response cannot redirect the connection.
|
||||
func PublicDialer(timeout time.Duration) func(context.Context, string, string) (net.Conn, error) {
|
||||
return func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse outbound address: %w", err)
|
||||
}
|
||||
addresses, err := resolvePublic(ctx, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dialer := net.Dialer{Timeout: timeout}
|
||||
var lastErr error
|
||||
for _, address := range addresses {
|
||||
connection, err := dialer.DialContext(ctx, network, net.JoinHostPort(address.String(), port))
|
||||
if err == nil {
|
||||
return connection, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return nil, fmt.Errorf("connect to public destination: %w", lastErr)
|
||||
}
|
||||
}
|
||||
|
||||
func resolvePublic(ctx context.Context, host string) ([]netip.Addr, error) {
|
||||
if literal, err := netip.ParseAddr(strings.Trim(host, "[]")); err == nil {
|
||||
literal = literal.Unmap()
|
||||
if !publicAddress(literal) {
|
||||
return nil, errors.New("destination resolves to a private or special-use address")
|
||||
}
|
||||
return []netip.Addr{literal}, nil
|
||||
}
|
||||
addresses, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve destination: %w", err)
|
||||
}
|
||||
result := make([]netip.Addr, 0, len(addresses))
|
||||
for _, address := range addresses {
|
||||
address = address.Unmap()
|
||||
if !publicAddress(address) {
|
||||
return nil, errors.New("destination resolves to a private or special-use address")
|
||||
}
|
||||
result = append(result, address)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil, errors.New("destination has no IP address")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var blockedNetworks = []netip.Prefix{
|
||||
netip.MustParsePrefix("0.0.0.0/8"),
|
||||
netip.MustParsePrefix("10.0.0.0/8"),
|
||||
netip.MustParsePrefix("100.64.0.0/10"),
|
||||
netip.MustParsePrefix("127.0.0.0/8"),
|
||||
netip.MustParsePrefix("169.254.0.0/16"),
|
||||
netip.MustParsePrefix("172.16.0.0/12"),
|
||||
netip.MustParsePrefix("192.0.0.0/24"),
|
||||
netip.MustParsePrefix("192.0.2.0/24"),
|
||||
netip.MustParsePrefix("192.88.99.0/24"),
|
||||
netip.MustParsePrefix("192.168.0.0/16"),
|
||||
netip.MustParsePrefix("198.18.0.0/15"),
|
||||
netip.MustParsePrefix("198.51.100.0/24"),
|
||||
netip.MustParsePrefix("203.0.113.0/24"),
|
||||
netip.MustParsePrefix("224.0.0.0/4"),
|
||||
netip.MustParsePrefix("240.0.0.0/4"),
|
||||
netip.MustParsePrefix("::/128"),
|
||||
netip.MustParsePrefix("::1/128"),
|
||||
netip.MustParsePrefix("64:ff9b:1::/48"),
|
||||
netip.MustParsePrefix("100::/64"),
|
||||
netip.MustParsePrefix("2001:db8::/32"),
|
||||
netip.MustParsePrefix("fc00::/7"),
|
||||
netip.MustParsePrefix("fe80::/10"),
|
||||
netip.MustParsePrefix("ff00::/8"),
|
||||
// Block both the well-known and local-use NAT64 prefixes. Otherwise a
|
||||
// public-looking IPv6 literal could translate to a private IPv4 target.
|
||||
netip.MustParsePrefix("64:ff9b::/96"),
|
||||
netip.MustParsePrefix("2002::/16"),
|
||||
}
|
||||
|
||||
func publicAddress(address netip.Addr) bool {
|
||||
if !address.IsValid() || !address.IsGlobalUnicast() {
|
||||
return false
|
||||
}
|
||||
for _, blocked := range blockedNetworks {
|
||||
if blocked.Contains(address) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package netguard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidatePublicURLRejectsUnsafeDestinations(t *testing.T) {
|
||||
tests := []string{
|
||||
"http://127.0.0.1/plugin.zip",
|
||||
"https://[::1]/plugin.zip",
|
||||
"https://169.254.169.254/latest/meta-data/",
|
||||
"https://[64:ff9b::7f00:1]/",
|
||||
"https://[2002:7f00:1::]/",
|
||||
"file:///etc/passwd",
|
||||
"https://user:[email protected]/plugin.zip",
|
||||
}
|
||||
for _, raw := range tests {
|
||||
if _, err := ValidatePublicURL(context.Background(), raw, false); err == nil {
|
||||
t.Errorf("ValidatePublicURL(%q) accepted an unsafe destination", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePublicURLCanRequireHTTPS(t *testing.T) {
|
||||
if _, err := ValidatePublicURL(context.Background(), "http://8.8.8.8/plugin.zip", true); err == nil {
|
||||
t.Fatal("HTTP destination was accepted while HTTPS was required")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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)
|
||||
}
|
||||
@@ -266,13 +267,13 @@ func sendEmailTextNotification(ctx context.Context, config map[string]any, subje
|
||||
return err
|
||||
}
|
||||
}
|
||||
from, err := mail.ParseAddress(configString(config, "from_address"))
|
||||
from, err := parseMailAddress(configString(config, "from_address"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var recipients []*mail.Address
|
||||
for _, item := range configStrings(config, "to_addresses") {
|
||||
address, err := mail.ParseAddress(item)
|
||||
address, err := parseMailAddress(item)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -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: " + from.String(),
|
||||
"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()
|
||||
}
|
||||
|
||||
@@ -68,7 +83,13 @@ func (scheduler *automaticTaskScheduler) run() {
|
||||
}
|
||||
|
||||
func (scheduler *automaticTaskScheduler) claim() {
|
||||
runs, err := scheduler.server.store.ClaimDueAutomaticTasks(scheduler.ctx, time.Now().UTC(), 50)
|
||||
var runs []store.AutomaticTaskRun
|
||||
var err error
|
||||
if scheduler.server.developerActive(scheduler.ctx) {
|
||||
runs, err = scheduler.server.store.ClaimDueAutomaticTasks(scheduler.ctx, time.Now().UTC(), 50)
|
||||
} else {
|
||||
runs, err = scheduler.server.store.ClaimDueAvailableAutomaticTasks(scheduler.ctx, time.Now().UTC(), 50)
|
||||
}
|
||||
if err != nil {
|
||||
scheduler.server.logger.Warn("claim automatic tasks", "error", err)
|
||||
return
|
||||
@@ -112,14 +133,24 @@ func (scheduler *automaticTaskScheduler) execute(run store.AutomaticTaskRun) {
|
||||
_ = scheduler.server.store.UpdateAutomaticTaskRun(context.Background(), run)
|
||||
return
|
||||
}
|
||||
if err := validateAutomaticTaskAvailability(scheduler.server.developerActive(scheduler.ctx), task.TaskType, task.Environment); err != nil {
|
||||
run.Status, run.Error, run.FinishedAt = "failed", err.Error(), time.Now().UTC()
|
||||
_ = scheduler.server.store.UpdateAutomaticTaskRun(context.Background(), run)
|
||||
return
|
||||
}
|
||||
run.Status, run.StartedAt = "running", time.Now().UTC()
|
||||
_ = scheduler.server.store.UpdateAutomaticTaskRun(context.Background(), run)
|
||||
var output string
|
||||
for attempt := 1; attempt <= task.RetryCount+1; attempt++ {
|
||||
run.Attempts = attempt
|
||||
run.Output = fmt.Sprintf("第 %d 次尝试:正在检查设备和 eSIM Profile", attempt)
|
||||
_ = scheduler.server.store.UpdateAutomaticTaskRun(context.Background(), run)
|
||||
progress := func(message string) {
|
||||
run.Output = fmt.Sprintf("第 %d 次尝试:%s", attempt, message)
|
||||
_ = scheduler.server.store.UpdateAutomaticTaskRun(context.Background(), run)
|
||||
}
|
||||
operationContext, cancel := context.WithTimeout(scheduler.ctx, automaticTaskMaxRuntime)
|
||||
output, err = scheduler.server.executeAutomaticTask(operationContext, task)
|
||||
output, err = scheduler.server.executeAutomaticTask(operationContext, task, progress)
|
||||
cancel()
|
||||
if err == nil {
|
||||
break
|
||||
@@ -129,7 +160,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 +185,38 @@ 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) {
|
||||
if err := validateAutomaticTaskAvailability(s.developerActive(ctx), task.TaskType, task.Environment); err != nil {
|
||||
return "", automaticTaskExecutionError{err: err, retryable: false}
|
||||
}
|
||||
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 +225,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 +255,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 +277,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 +291,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 +334,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,15 +368,13 @@ 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
|
||||
}
|
||||
if task.TaskType == "public_ip" {
|
||||
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 +498,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 +510,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 {
|
||||
@@ -456,6 +641,10 @@ func (s *Server) routeAutomaticTasksAPI(w http.ResponseWriter, r *http.Request,
|
||||
s.handleAutomaticTasks(w, r)
|
||||
return true
|
||||
}
|
||||
if len(segments) == 2 && segments[1] == "runs" {
|
||||
s.handleAutomaticTaskRuns(w, r)
|
||||
return true
|
||||
}
|
||||
id, err := strconv.ParseInt(segments[1], 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_task_id", "automatic task ID is invalid")
|
||||
@@ -481,12 +670,16 @@ func (s *Server) handleAutomaticTasks(w http.ResponseWriter, r *http.Request) {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
runs, err := s.store.ListAutomaticTaskRuns(r.Context(), 100)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
if !s.developerActive(r.Context()) {
|
||||
visible := tasks[:0]
|
||||
for _, task := range tasks {
|
||||
if validateAutomaticTaskAvailability(false, task.TaskType, task.Environment) == nil {
|
||||
visible = append(visible, task)
|
||||
}
|
||||
}
|
||||
tasks = visible
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"tasks": tasks, "runs": runs}})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"tasks": tasks}})
|
||||
case http.MethodPost:
|
||||
task, err := s.decodeAutomaticTask(r, 0)
|
||||
if err != nil {
|
||||
@@ -531,6 +724,28 @@ func (s *Server) handleAutomaticTask(w http.ResponseWriter, r *http.Request, id
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleAutomaticTaskRuns(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireMethod(w, r, http.MethodGet) {
|
||||
return
|
||||
}
|
||||
query := r.URL.Query()
|
||||
limit, _ := strconv.Atoi(query.Get("limit"))
|
||||
offset, _ := strconv.Atoi(query.Get("offset"))
|
||||
var runs []store.AutomaticTaskRun
|
||||
var total int
|
||||
var err error
|
||||
if s.developerActive(r.Context()) {
|
||||
runs, total, err = s.store.ListAutomaticTaskRunsPaginated(r.Context(), limit, offset)
|
||||
} else {
|
||||
runs, total, err = s.store.ListAvailableAutomaticTaskRunsPaginated(r.Context(), limit, offset)
|
||||
}
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"runs": runs, "total": total}})
|
||||
}
|
||||
|
||||
func (s *Server) handleAutomaticTaskRunNow(w http.ResponseWriter, r *http.Request, id int64) {
|
||||
if !requireMethod(w, r, http.MethodPost) {
|
||||
return
|
||||
@@ -544,6 +759,19 @@ 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
|
||||
}
|
||||
if err := validateAutomaticTaskAvailability(s.developerActive(r.Context()), task.TaskType, task.Environment); err != nil {
|
||||
writeError(w, http.StatusNotFound, "task_unavailable", err.Error())
|
||||
return
|
||||
}
|
||||
run, err := s.store.QueueAutomaticTaskNow(r.Context(), task)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
@@ -579,7 +807,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" {
|
||||
@@ -591,6 +820,12 @@ 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 := validateAutomaticTaskAvailability(s.developerActive(r.Context()), request.TaskType, request.Environment); err != nil {
|
||||
return store.AutomaticTask{}, err
|
||||
}
|
||||
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")
|
||||
}
|
||||
@@ -630,6 +865,26 @@ func (s *Server) decodeAutomaticTask(r *http.Request, id int64) (store.Automatic
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func validateAutomaticTaskAvailability(available bool, taskType, environment string) error {
|
||||
if !available && (taskType == "public_ip" || environment == "cellular") {
|
||||
return errors.New("unsupported task type or environment")
|
||||
}
|
||||
return 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,45 @@ 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 TestAutomaticTaskAvailabilityHidesRestrictedPaths(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
available bool
|
||||
taskType string
|
||||
environment string
|
||||
wantError bool
|
||||
}{
|
||||
{false, "sms", "vowifi", false},
|
||||
{false, "call", "vowifi", false},
|
||||
{false, "sms", "cellular", true},
|
||||
{false, "public_ip", "cellular", true},
|
||||
{true, "public_ip", "cellular", false},
|
||||
} {
|
||||
err := validateAutomaticTaskAvailability(test.available, test.taskType, test.environment)
|
||||
if (err != nil) != test.wantError {
|
||||
t.Fatalf("availability(%v, %q, %q) = %v", test.available, 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) {
|
||||
|
||||
@@ -7,37 +7,62 @@ import (
|
||||
)
|
||||
|
||||
func (s *Server) handleDeveloperSettings(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.developerEnabled {
|
||||
if !s.developerActive(r.Context()) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "resource not found")
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
|
||||
"device_limit": developer.DeviceLimit(r.Context(), s.store, true),
|
||||
"default_device_limit": developer.DefaultDeviceLimit,
|
||||
"max_device_limit": developer.MaxDeviceLimit,
|
||||
}})
|
||||
s.writeDeveloperSettings(w, r)
|
||||
case http.MethodPut:
|
||||
var request struct {
|
||||
DeviceLimit int `json:"device_limit"`
|
||||
DeviceLimit *int `json:"device_limit"`
|
||||
SMSHourlyLimit *int `json:"sms_hourly_limit"`
|
||||
}
|
||||
if err := s.decodeJSON(w, r, &request); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
if err := developer.SetDeviceLimit(r.Context(), s.store, request.DeviceLimit); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_device_limit", err.Error())
|
||||
if request.DeviceLimit == nil && request.SMSHourlyLimit == nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "at least one developer setting is required")
|
||||
return
|
||||
}
|
||||
s.recordAudit(r.Context(), "admin", "settings.developer.device_limit", "settings", "developer", "success", "device limit updated")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
|
||||
"device_limit": request.DeviceLimit,
|
||||
"default_device_limit": developer.DefaultDeviceLimit,
|
||||
"max_device_limit": developer.MaxDeviceLimit,
|
||||
}})
|
||||
if request.DeviceLimit != nil && (*request.DeviceLimit < 1 || *request.DeviceLimit > developer.MaxDeviceLimit) {
|
||||
writeError(w, http.StatusBadRequest, "invalid_device_limit", "device limit is outside the supported range")
|
||||
return
|
||||
}
|
||||
if request.SMSHourlyLimit != nil && (*request.SMSHourlyLimit < 1 || *request.SMSHourlyLimit > developer.MaxSMSHourlyLimit) {
|
||||
writeError(w, http.StatusBadRequest, "invalid_sms_hourly_limit", "SMS hourly limit is outside the supported range")
|
||||
return
|
||||
}
|
||||
if request.DeviceLimit != nil {
|
||||
if err := developer.SetDeviceLimit(r.Context(), s.store, *request.DeviceLimit); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_device_limit", err.Error())
|
||||
return
|
||||
}
|
||||
s.recordAudit(r.Context(), "admin", "settings.developer.device_limit", "settings", "developer", "success", "device limit updated")
|
||||
}
|
||||
if request.SMSHourlyLimit != nil {
|
||||
if err := developer.SetSMSHourlyLimit(r.Context(), s.store, *request.SMSHourlyLimit); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_sms_hourly_limit", err.Error())
|
||||
return
|
||||
}
|
||||
s.recordAudit(r.Context(), "admin", "settings.developer.sms_hourly_limit", "settings", "developer", "success", "global SMS hourly limit updated")
|
||||
}
|
||||
s.writeDeveloperSettings(w, r)
|
||||
default:
|
||||
w.Header().Set("Allow", "GET, PUT")
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) writeDeveloperSettings(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
|
||||
"device_limit": developer.DeviceLimit(r.Context(), s.store, true),
|
||||
"default_device_limit": developer.DefaultDeviceLimit,
|
||||
"max_device_limit": developer.MaxDeviceLimit,
|
||||
"sms_hourly_limit": developer.SMSHourlyLimit(r.Context(), s.store),
|
||||
"default_sms_hourly_limit": developer.DefaultSMSHourlyLimit,
|
||||
"max_sms_hourly_limit": developer.MaxSMSHourlyLimit,
|
||||
}})
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"vocat/internal/developer"
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
func TestDeveloperOnlySettingsAreHiddenWhenModeIsOff(t *testing.T) {
|
||||
@@ -20,3 +26,27 @@ func TestDeveloperOnlySettingsAreHiddenWhenModeIsOff(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeveloperSettingsUpdatesGlobalSMSLimit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database, err := store.Open(ctx, ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
enabled, _ := json.Marshal(map[string]bool{"enabled": true})
|
||||
if err := database.UpsertAppSetting(ctx, store.AppSetting{Key: developer.EnabledSettingKey, Value: enabled}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := &Server{store: database, developerEnabled: true, logger: regionTestLogger(), maxRequestBodyBytes: 4096}
|
||||
request := httptest.NewRequest(http.MethodPut, "/api/settings/developer", strings.NewReader(`{"sms_hourly_limit":17}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
server.handleDeveloperSettings(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
if got := developer.SMSHourlyLimit(ctx, database); got != 17 {
|
||||
t.Fatalf("SMS hourly limit = %d, want 17", got)
|
||||
}
|
||||
}
|
||||
|
||||
+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())
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@ func (s *Server) routeExtensionAPI(w http.ResponseWriter, r *http.Request, clean
|
||||
if !requireMethod(w, r, http.MethodPost) {
|
||||
return true
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxPluginUploadBytes+(1<<20))
|
||||
r.Body = http.MaxBytesReader(nil, r.Body, maxPluginUploadBytes+(1<<20))
|
||||
if err := r.ParseMultipartForm(maxPluginUploadBytes); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_plugin_upload", "plugin upload must be multipart/form-data and no larger than 64 MiB")
|
||||
return true
|
||||
|
||||
+141
-58
@@ -26,8 +26,8 @@ func (s *Server) routeProxyAPI(w http.ResponseWriter, r *http.Request, cleanPath
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": proxyCountries})
|
||||
case "upstream-proxy-country-rules":
|
||||
s.handleCountryRules(w, r)
|
||||
case "upstream-proxy-device-bindings":
|
||||
s.handleDeviceProxyBindings(w, r)
|
||||
case "upstream-proxy-profile-bindings":
|
||||
s.handleProfileProxyBindings(w, r)
|
||||
default:
|
||||
segments := splitAPIPath(cleanPath)
|
||||
switch {
|
||||
@@ -40,8 +40,6 @@ func (s *Server) routeProxyAPI(w http.ResponseWriter, r *http.Request, cleanPath
|
||||
s.handleUpstreamProbe(w, r, segments[1])
|
||||
case len(segments) == 2 && segments[0] == "upstream-proxy-country-rules":
|
||||
s.handleCountryRule(w, r, segments[1])
|
||||
case len(segments) == 2 && segments[0] == "upstream-proxy-device-bindings":
|
||||
s.handleDeviceProxyBinding(w, r, segments[1])
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -114,7 +112,7 @@ func (s *Server) handleUpstreamProxy(w http.ResponseWriter, r *http.Request, id
|
||||
}
|
||||
for _, binding := range bindings {
|
||||
if binding.UpstreamProxyID == id {
|
||||
s.requestProxyRouteReconnect(binding.DeviceID)
|
||||
s.requestProfileProxyRouteReconnect(binding.DeviceID, binding.ICCID)
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"deleted": true}})
|
||||
@@ -124,36 +122,32 @@ func (s *Server) handleUpstreamProxy(w http.ResponseWriter, r *http.Request, id
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleDeviceProxyBindings(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireMethod(w, r, http.MethodGet) {
|
||||
return
|
||||
}
|
||||
values, err := s.store.ListDeviceProxyBindings(r.Context())
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
result := make([]map[string]any, 0, len(values))
|
||||
for _, value := range values {
|
||||
result = append(result, deviceProxyBindingResponse(value))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": result})
|
||||
type profileProxyBindingPayload struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
ICCID string `json:"iccid"`
|
||||
ProfileName string `json:"profile_name"`
|
||||
// Accepted for compatibility with the first profile-picker bundle, which
|
||||
// sent the read-only display state together with the writable identity.
|
||||
StateText string `json:"state_text,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleDeviceProxyBinding(w http.ResponseWriter, r *http.Request, deviceID string) {
|
||||
deviceID = strings.TrimSpace(deviceID)
|
||||
if !validDeviceID(deviceID) {
|
||||
writeError(w, http.StatusBadRequest, "invalid_device_id", "device ID must use 1-64 safe characters")
|
||||
return
|
||||
}
|
||||
if _, err := s.store.Device(r.Context(), deviceID); err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
func (s *Server) handleProfileProxyBindings(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodPut:
|
||||
case http.MethodGet:
|
||||
values, err := s.store.ListDeviceProxyBindings(r.Context())
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
result := make([]map[string]any, 0, len(values))
|
||||
for _, value := range values {
|
||||
result = append(result, deviceProxyBindingResponse(value))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": result})
|
||||
case http.MethodPost:
|
||||
var request struct {
|
||||
UpstreamProxyID string `json:"upstream_proxy_id"`
|
||||
UpstreamProxyID string `json:"upstream_proxy_id"`
|
||||
Bindings []profileProxyBindingPayload `json:"bindings"`
|
||||
}
|
||||
if err := s.decodeJSON(w, r, &request); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
@@ -166,44 +160,114 @@ func (s *Server) handleDeviceProxyBinding(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
if !upstream.Enabled {
|
||||
writeError(w, http.StatusConflict, "upstream_proxy_disabled", "enable the upstream proxy before binding a device")
|
||||
writeError(w, http.StatusConflict, "upstream_proxy_disabled", "enable the upstream proxy before binding a profile")
|
||||
return
|
||||
}
|
||||
// Once bound, a device may not be silently rebinded to a different
|
||||
// upstream proxy. Force the caller to DELETE first so the change is
|
||||
// intentional. Re-binding the same upstream stays idempotent.
|
||||
if existing, err := s.store.DeviceProxyBinding(r.Context(), deviceID); err == nil && existing.UpstreamProxyID != upstream.ID {
|
||||
writeError(w, http.StatusConflict, "device_already_bound", "device is already bound to another upstream proxy; delete the binding first")
|
||||
return
|
||||
} else if err != nil && !errors.Is(err, store.ErrNotFound) {
|
||||
s.writeStoreError(w, err)
|
||||
if len(request.Bindings) == 0 || len(request.Bindings) > 200 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_bindings", "select between 1 and 200 profiles")
|
||||
return
|
||||
}
|
||||
value := store.DeviceProxyBinding{DeviceID: deviceID, UpstreamProxyID: upstream.ID}
|
||||
if err := s.store.UpsertDeviceProxyBinding(r.Context(), value); err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
values := make([]store.DeviceProxyBinding, 0, len(request.Bindings))
|
||||
seen := make(map[string]struct{}, len(request.Bindings))
|
||||
for _, item := range request.Bindings {
|
||||
deviceID := strings.TrimSpace(item.DeviceID)
|
||||
iccid := strings.TrimSpace(item.ICCID)
|
||||
if !validDeviceID(deviceID) {
|
||||
writeError(w, http.StatusBadRequest, "invalid_device_id", "device ID must use 1-64 safe characters")
|
||||
return
|
||||
}
|
||||
if !validProfileICCID(iccid) {
|
||||
writeError(w, http.StatusBadRequest, "invalid_iccid", "profile ICCID must contain 18 to 22 digits")
|
||||
return
|
||||
}
|
||||
if _, duplicate := seen[iccid]; duplicate {
|
||||
writeError(w, http.StatusBadRequest, "duplicate_iccid", "the same ICCID was selected more than once")
|
||||
return
|
||||
}
|
||||
seen[iccid] = struct{}{}
|
||||
if _, err := s.store.Device(r.Context(), deviceID); err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
if existing, err := s.store.DeviceProxyBinding(r.Context(), iccid); err == nil && existing.UpstreamProxyID != upstream.ID {
|
||||
writeError(w, http.StatusConflict, "profile_already_bound", "this ICCID is already bound to another upstream proxy; delete that binding first")
|
||||
return
|
||||
} else if err != nil && !errors.Is(err, store.ErrNotFound) {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(item.ProfileName)
|
||||
if name == "" {
|
||||
name = iccid
|
||||
}
|
||||
values = append(values, store.DeviceProxyBinding{DeviceID: deviceID, ICCID: iccid, ProfileName: name, UpstreamProxyID: upstream.ID})
|
||||
}
|
||||
reconnected, reconnectErr := s.requestProxyRouteReconnect(deviceID)
|
||||
response := deviceProxyBindingResponse(value)
|
||||
response["reconnect_requested"] = reconnected
|
||||
if reconnectErr != nil {
|
||||
response["reconnect_error"] = reconnectErr.Error()
|
||||
requested := false
|
||||
var reconnectErrors []string
|
||||
for _, value := range values {
|
||||
if err := s.store.UpsertDeviceProxyBinding(r.Context(), value); err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
reconnected, reconnectErr := s.requestProfileProxyRouteReconnect(value.DeviceID, value.ICCID)
|
||||
requested = requested || reconnected
|
||||
if reconnectErr != nil {
|
||||
reconnectErrors = append(reconnectErrors, reconnectErr.Error())
|
||||
}
|
||||
}
|
||||
response := map[string]any{"created": len(values), "reconnect_requested": requested}
|
||||
if len(reconnectErrors) > 0 {
|
||||
response["reconnect_error"] = strings.Join(reconnectErrors, "; ")
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": response})
|
||||
case http.MethodDelete:
|
||||
if err := s.store.DeleteDeviceProxyBinding(r.Context(), deviceID); err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
var request struct {
|
||||
UpstreamProxyID string `json:"upstream_proxy_id"`
|
||||
ICCIDs []string `json:"iccids"`
|
||||
}
|
||||
if err := s.decodeJSON(w, r, &request); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
reconnected, reconnectErr := s.requestProxyRouteReconnect(deviceID)
|
||||
response := map[string]any{"deleted": true, "reconnect_requested": reconnected}
|
||||
if reconnectErr != nil {
|
||||
response["reconnect_error"] = reconnectErr.Error()
|
||||
if len(request.ICCIDs) == 0 || len(request.ICCIDs) > 200 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_bindings", "select between 1 and 200 profiles")
|
||||
return
|
||||
}
|
||||
requested := false
|
||||
deleted := 0
|
||||
var reconnectErrors []string
|
||||
for _, rawICCID := range request.ICCIDs {
|
||||
iccid := strings.TrimSpace(rawICCID)
|
||||
binding, err := s.store.DeviceProxyBinding(r.Context(), iccid)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(request.UpstreamProxyID) != "" && binding.UpstreamProxyID != strings.TrimSpace(request.UpstreamProxyID) {
|
||||
writeError(w, http.StatusConflict, "binding_proxy_mismatch", "selected ICCID is not bound to this upstream proxy")
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteDeviceProxyBinding(r.Context(), iccid); err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
deleted++
|
||||
reconnected, reconnectErr := s.requestProfileProxyRouteReconnect(binding.DeviceID, binding.ICCID)
|
||||
requested = requested || reconnected
|
||||
if reconnectErr != nil {
|
||||
reconnectErrors = append(reconnectErrors, reconnectErr.Error())
|
||||
}
|
||||
}
|
||||
response := map[string]any{"deleted": deleted, "reconnect_requested": requested}
|
||||
if len(reconnectErrors) > 0 {
|
||||
response["reconnect_error"] = strings.Join(reconnectErrors, "; ")
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": response})
|
||||
default:
|
||||
w.Header().Set("Allow", "PUT, DELETE")
|
||||
w.Header().Set("Allow", "GET, POST, DELETE")
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||
}
|
||||
}
|
||||
@@ -211,7 +275,7 @@ func (s *Server) handleDeviceProxyBinding(w http.ResponseWriter, r *http.Request
|
||||
// A binding is already durable before this is called. Reconnect failures are
|
||||
// returned as advisory information: the chosen route will still be used on
|
||||
// the next VoWiFi start/reconnect.
|
||||
func (s *Server) requestProxyRouteReconnect(deviceID string) (bool, error) {
|
||||
func (s *Server) requestProfileProxyRouteReconnect(deviceID, iccid string) (bool, error) {
|
||||
if s.vowifi == nil {
|
||||
return false, nil
|
||||
}
|
||||
@@ -222,6 +286,10 @@ func (s *Server) requestProxyRouteReconnect(deviceID string) (bool, error) {
|
||||
if !config.VoWiFiEnabled {
|
||||
return false, nil
|
||||
}
|
||||
state, stateErr := s.vowifi.State(deviceID)
|
||||
if stateErr != nil || strings.TrimSpace(state.ICCID) == "" || strings.TrimSpace(state.ICCID) != strings.TrimSpace(iccid) {
|
||||
return false, nil
|
||||
}
|
||||
if _, err := s.vowifi.RequestReconnect(deviceID); err != nil {
|
||||
s.logger.Warn("VoWiFi proxy route saved but immediate reconnect was not started", "device_id", deviceID, "error", err)
|
||||
return false, err
|
||||
@@ -229,6 +297,19 @@ func (s *Server) requestProxyRouteReconnect(deviceID string) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func validProfileICCID(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) < 18 || len(value) > 22 {
|
||||
return false
|
||||
}
|
||||
for _, digit := range value {
|
||||
if digit < '0' || digit > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) saveAndProbeUpstream(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
@@ -258,7 +339,7 @@ func (s *Server) saveAndProbeUpstream(
|
||||
}
|
||||
for _, binding := range bindings {
|
||||
if binding.UpstreamProxyID == saved.ID {
|
||||
s.requestProxyRouteReconnect(binding.DeviceID)
|
||||
s.requestProfileProxyRouteReconnect(binding.DeviceID, binding.ICCID)
|
||||
}
|
||||
}
|
||||
probe, probeErr := localproxy.ProbeSOCKS5(
|
||||
@@ -449,6 +530,8 @@ func countryRuleResponse(value store.CountryRule) map[string]any {
|
||||
func deviceProxyBindingResponse(value store.DeviceProxyBinding) map[string]any {
|
||||
return map[string]any{
|
||||
"device_id": value.DeviceID,
|
||||
"iccid": value.ICCID,
|
||||
"profile_name": value.ProfileName,
|
||||
"upstream_proxy_id": value.UpstreamProxyID,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,120 +10,111 @@ import (
|
||||
"testing"
|
||||
|
||||
"vocat/internal/store"
|
||||
"vocat/internal/vowifi"
|
||||
)
|
||||
|
||||
func TestDeviceProxyBindingPersistsAndReconnectsEnabledVoWiFi(t *testing.T) {
|
||||
const testProfileICCID = "89441000400128014257"
|
||||
|
||||
func newProfileBindingTestServer(t *testing.T) (*Server, *store.Store, *fakeVoWiFiController) {
|
||||
t.Helper()
|
||||
database, err := store.Open(context.Background(), ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
if err := database.UpsertDevice(context.Background(), store.Device{
|
||||
ID: "ec20", Name: "EC20", VoWiFiEnabled: true,
|
||||
}); err != nil {
|
||||
if err := database.UpsertDevice(context.Background(), store.Device{ID: "ec20", Name: "EC20", VoWiFiEnabled: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpsertUpstreamProxy(context.Background(), store.UpstreamProxy{
|
||||
ID: "route-1", Name: "Route 1", Addr: "127.0.0.1:1080", Enabled: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
controller := &fakeVoWiFiController{}
|
||||
server := &Server{
|
||||
store: database,
|
||||
vowifi: controller,
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
maxRequestBodyBytes: 4096,
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/api/upstream-proxy-device-bindings/ec20",
|
||||
bytes.NewBufferString(`{"upstream_proxy_id":"route-1"}`),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
server.handleDeviceProxyBinding(response, request, "ec20")
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("PUT status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
binding, err := database.DeviceProxyBinding(context.Background(), "ec20")
|
||||
if err != nil || binding.UpstreamProxyID != "route-1" {
|
||||
t.Fatalf("binding = %+v, %v", binding, err)
|
||||
}
|
||||
if controller.reconnects != 1 {
|
||||
t.Fatalf("reconnects = %d, want 1", controller.reconnects)
|
||||
}
|
||||
|
||||
request = httptest.NewRequest(http.MethodDelete, "/api/upstream-proxy-device-bindings/ec20", nil)
|
||||
response = httptest.NewRecorder()
|
||||
server.handleDeviceProxyBinding(response, request, "ec20")
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("DELETE status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
if _, err := database.DeviceProxyBinding(context.Background(), "ec20"); err != store.ErrNotFound {
|
||||
t.Fatalf("binding after delete error = %v, want ErrNotFound", err)
|
||||
}
|
||||
if controller.reconnects != 2 {
|
||||
t.Fatalf("reconnects = %d, want 2", controller.reconnects)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceProxyBindingRejectsRebindToDifferentUpstream(t *testing.T) {
|
||||
database, err := store.Open(context.Background(), ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
if err := database.UpsertDevice(context.Background(), store.Device{
|
||||
ID: "ec20", Name: "EC20", VoWiFiEnabled: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, up := range []store.UpstreamProxy{
|
||||
for _, upstream := range []store.UpstreamProxy{
|
||||
{ID: "route-1", Name: "Route 1", Addr: "127.0.0.1:1080", Enabled: true},
|
||||
{ID: "route-2", Name: "Route 2", Addr: "127.0.0.1:1081", Enabled: true},
|
||||
} {
|
||||
if err := database.UpsertUpstreamProxy(context.Background(), up); err != nil {
|
||||
if err := database.UpsertUpstreamProxy(context.Background(), upstream); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
server := &Server{
|
||||
store: database,
|
||||
vowifi: &fakeVoWiFiController{},
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
maxRequestBodyBytes: 4096,
|
||||
controller := &fakeVoWiFiController{state: vowifi.State{DeviceID: "ec20", ICCID: testProfileICCID, Enabled: true}}
|
||||
return &Server{store: database, vowifi: controller, logger: slog.New(slog.NewTextHandler(io.Discard, nil)), maxRequestBodyBytes: 16 << 10}, database, controller
|
||||
}
|
||||
|
||||
func profileBindingRequest(t *testing.T, server *Server, method, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
request := httptest.NewRequest(method, "/api/upstream-proxy-profile-bindings", bytes.NewBufferString(body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
server.handleProfileProxyBindings(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
func TestProfileProxyBindingPersistsAndReconnectsOnlyCurrentICCID(t *testing.T) {
|
||||
server, database, controller := newProfileBindingTestServer(t)
|
||||
response := profileBindingRequest(t, server, http.MethodPost, `{
|
||||
"upstream_proxy_id":"route-1",
|
||||
"bindings":[
|
||||
{"device_id":"ec20","iccid":"89441000400128014257","profile_name":"Vodafone UK","state_text":"Enabled"},
|
||||
{"device_id":"ec20","iccid":"89104100000028106378","profile_name":"TIM"}
|
||||
]
|
||||
}`)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("POST status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
binding, err := database.DeviceProxyBinding(context.Background(), testProfileICCID)
|
||||
if err != nil || binding.UpstreamProxyID != "route-1" || binding.ProfileName != "Vodafone UK" {
|
||||
t.Fatalf("binding = %+v, %v", binding, err)
|
||||
}
|
||||
if controller.reconnects != 1 {
|
||||
t.Fatalf("reconnects = %d, want only the current ICCID to reconnect", controller.reconnects)
|
||||
}
|
||||
|
||||
// First bind to route-1 succeeds.
|
||||
put := func(proxyID string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/api/upstream-proxy-device-bindings/ec20",
|
||||
bytes.NewBufferString(`{"upstream_proxy_id":"`+proxyID+`"}`),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
server.handleDeviceProxyBinding(rec, req, "ec20")
|
||||
return rec
|
||||
response = profileBindingRequest(t, server, http.MethodDelete, `{"upstream_proxy_id":"route-1","iccids":["89441000400128014257","89104100000028106378"]}`)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("DELETE status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
if rec := put("route-1"); rec.Code != http.StatusOK {
|
||||
t.Fatalf("initial bind status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
if _, err := database.DeviceProxyBinding(context.Background(), testProfileICCID); err != store.ErrNotFound {
|
||||
t.Fatalf("binding after delete error = %v, want ErrNotFound", err)
|
||||
}
|
||||
|
||||
// Rebind to a different upstream must be rejected with 409.
|
||||
rec := put("route-2")
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("rebind status = %d, want 409, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
binding, err := database.DeviceProxyBinding(context.Background(), "ec20")
|
||||
if err != nil || binding.UpstreamProxyID != "route-1" {
|
||||
t.Fatalf("binding after rejected rebind = %+v, %v (want route-1 unchanged)", binding, err)
|
||||
}
|
||||
|
||||
// Re-binding the SAME upstream stays idempotent (no 409).
|
||||
if rec := put("route-1"); rec.Code != http.StatusOK {
|
||||
t.Fatalf("idempotent rebind status = %d, want 200, body = %s", rec.Code, rec.Body.String())
|
||||
if controller.reconnects != 2 {
|
||||
t.Fatalf("reconnects after delete = %d, want 2", controller.reconnects)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileProxyBindingRejectsSameICCIDOnDifferentProxy(t *testing.T) {
|
||||
server, database, _ := newProfileBindingTestServer(t)
|
||||
first := profileBindingRequest(t, server, http.MethodPost, `{"upstream_proxy_id":"route-1","bindings":[{"device_id":"ec20","iccid":"89441000400128014257","profile_name":"Profile"}]}`)
|
||||
if first.Code != http.StatusOK {
|
||||
t.Fatalf("initial bind status = %d, body = %s", first.Code, first.Body.String())
|
||||
}
|
||||
second := profileBindingRequest(t, server, http.MethodPost, `{"upstream_proxy_id":"route-2","bindings":[{"device_id":"ec20","iccid":"89441000400128014257","profile_name":"Profile"}]}`)
|
||||
if second.Code != http.StatusConflict {
|
||||
t.Fatalf("rebind status = %d, want 409, body = %s", second.Code, second.Body.String())
|
||||
}
|
||||
binding, err := database.DeviceProxyBinding(context.Background(), testProfileICCID)
|
||||
if err != nil || binding.UpstreamProxyID != "route-1" {
|
||||
t.Fatalf("binding after rejected rebind = %+v, %v", binding, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileProxyBindingSupportsPCSCReader(t *testing.T) {
|
||||
server, database, controller := newProfileBindingTestServer(t)
|
||||
readerICCID := "89104100000028106378"
|
||||
if err := database.UpsertDevice(context.Background(), store.Device{
|
||||
ID: "reader-1", Name: "USB SIM Reader", DeviceType: store.DeviceTypeUSBSIMReader,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
controller.state = vowifi.State{DeviceID: "reader-1", ICCID: readerICCID, Enabled: true}
|
||||
response := profileBindingRequest(t, server, http.MethodPost, `{
|
||||
"upstream_proxy_id":"route-1",
|
||||
"bindings":[{"device_id":"reader-1","iccid":"89104100000028106378","profile_name":"Reader Profile"}]
|
||||
}`)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("POST status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
binding, err := database.DeviceProxyBinding(context.Background(), readerICCID)
|
||||
if err != nil || binding.DeviceID != "reader-1" || binding.UpstreamProxyID != "route-1" {
|
||||
t.Fatalf("reader binding = %+v, %v", binding, err)
|
||||
}
|
||||
if controller.reconnects != 1 {
|
||||
t.Fatalf("reader reconnects = %d, want 1", controller.reconnects)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,7 +414,10 @@ func (s *Server) decodeJSON(w http.ResponseWriter, r *http.Request, destination
|
||||
}
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, s.maxRequestBodyBytes)
|
||||
// MaxBytesReader's ResponseWriter parameter is deprecated and unused by Go.
|
||||
// Passing nil also makes the request body and response data flows explicitly
|
||||
// separate for static analysis.
|
||||
r.Body = http.MaxBytesReader(nil, r.Body, s.maxRequestBodyBytes)
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(destination); err != nil {
|
||||
@@ -559,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()
|
||||
|
||||
+433
-72
@@ -24,6 +24,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"vocat/internal/device"
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
@@ -39,6 +40,7 @@ var notificationChannels = []string{
|
||||
"webhook",
|
||||
"bark",
|
||||
"pushplus",
|
||||
"wecom",
|
||||
}
|
||||
|
||||
var notificationFields = map[string]map[string]string{
|
||||
@@ -60,6 +62,9 @@ var notificationFields = map[string]map[string]string{
|
||||
"pushplus": {
|
||||
"token": "string", "topic": "string", "channel": "string",
|
||||
},
|
||||
"wecom": {
|
||||
"urls": "strings", "payload_template": "string",
|
||||
},
|
||||
}
|
||||
|
||||
// routeSettingsAPI is intentionally independent of the main router so it can
|
||||
@@ -99,6 +104,14 @@ func (s *Server) routeSettingsAPI(
|
||||
s.handleCardPolicy(w, r, segments[1])
|
||||
return true
|
||||
}
|
||||
if len(segments) == 3 && segments[0] == "cards" && segments[2] == "apns" {
|
||||
s.handleCardAPNProfiles(w, r, segments[1], "")
|
||||
return true
|
||||
}
|
||||
if len(segments) == 4 && segments[0] == "cards" && segments[2] == "apns" {
|
||||
s.handleCardAPNProfiles(w, r, segments[1], segments[3])
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -278,10 +291,15 @@ func validateNotificationField(
|
||||
}
|
||||
}
|
||||
if name == "from_address" && value != "" {
|
||||
if _, err := mail.ParseAddress(value); err != nil {
|
||||
if _, err := parseMailAddress(value); err != nil {
|
||||
return fmt.Errorf("%s is not a valid email address", field)
|
||||
}
|
||||
}
|
||||
if channel == "wecom" && name == "payload_template" && value != "" {
|
||||
if _, err := renderWecomPayload(value, wecomTestValues(time.Unix(0, 0))); err != nil {
|
||||
return fmt.Errorf("%s is not a valid JSON template: %w", field, err)
|
||||
}
|
||||
}
|
||||
case "integer":
|
||||
var value int
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
@@ -315,6 +333,9 @@ func validateNotificationField(
|
||||
return fmt.Errorf("%s contains an invalid value", field)
|
||||
}
|
||||
if name == "urls" {
|
||||
if channel == "wecom" && value == store.SecretMask {
|
||||
continue
|
||||
}
|
||||
if _, err := parseOutboundURL(value, false); err != nil {
|
||||
return fmt.Errorf("%s contains an invalid HTTP URL", field)
|
||||
}
|
||||
@@ -366,7 +387,7 @@ func (s *Server) handleNotificationTest(
|
||||
writeError(w, http.StatusNotFound, "not_found", "notification channel was not found")
|
||||
return
|
||||
}
|
||||
if channel != "webhook" && channel != "telegram" && channel != "email" && channel != "bark" {
|
||||
if channel != "webhook" && channel != "telegram" && channel != "email" && channel != "bark" && channel != "wecom" {
|
||||
writeError(
|
||||
w,
|
||||
http.StatusNotImplemented,
|
||||
@@ -417,6 +438,8 @@ func (s *Server) handleNotificationTest(
|
||||
err = sendEmailNotificationTest(r.Context(), resolved)
|
||||
case "bark":
|
||||
err = sendBarkNotificationTest(r.Context(), resolved)
|
||||
case "wecom":
|
||||
err = sendWecomNotificationTest(r.Context(), resolved)
|
||||
}
|
||||
if err != nil {
|
||||
redacted := store.RedactText(err.Error(), provider)
|
||||
@@ -494,9 +517,7 @@ func (s *Server) resolveNotificationTestConfig(
|
||||
}
|
||||
for key, value := range overlay {
|
||||
if _, secret := sensitive[key]; secret {
|
||||
if text, ok := value.(string); !ok || text == "" || text == store.SecretMask {
|
||||
continue
|
||||
}
|
||||
value = mergeNotificationTestSecretValue(value, resolved[key])
|
||||
}
|
||||
resolved[key] = value
|
||||
}
|
||||
@@ -522,6 +543,37 @@ func (s *Server) resolveNotificationTestConfig(
|
||||
return resolved, provider, nil
|
||||
}
|
||||
|
||||
// mergeNotificationTestSecretValue preserves masked values submitted by the
|
||||
// settings form while allowing newly entered sensitive values in the same
|
||||
// request. WeCom URLs are a sensitive list, unlike the string-based secrets
|
||||
// used by the other notification channels.
|
||||
func mergeNotificationTestSecretValue(incoming, existing any) any {
|
||||
if incoming == nil {
|
||||
return existing
|
||||
}
|
||||
switch next := incoming.(type) {
|
||||
case string:
|
||||
if next == "" || next == store.SecretMask {
|
||||
return existing
|
||||
}
|
||||
case []any:
|
||||
previous, ok := existing.([]any)
|
||||
if !ok {
|
||||
return incoming
|
||||
}
|
||||
merged := make([]any, len(next))
|
||||
for index, value := range next {
|
||||
if index < len(previous) {
|
||||
merged[index] = mergeNotificationTestSecretValue(value, previous[index])
|
||||
} else {
|
||||
merged[index] = value
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
return incoming
|
||||
}
|
||||
|
||||
func validateNotificationTestConfig(channel string, config map[string]any) error {
|
||||
switch channel {
|
||||
case "webhook":
|
||||
@@ -540,6 +592,8 @@ func validateNotificationTestConfig(channel string, config map[string]any) error
|
||||
if len(urls) > 8 {
|
||||
return errors.New("bark test is limited to 8 URLs")
|
||||
}
|
||||
case "wecom":
|
||||
return validateWecomNotificationConfig(config)
|
||||
case "telegram":
|
||||
token := configString(config, "bot_token")
|
||||
if token == "" || token == store.SecretMask {
|
||||
@@ -763,13 +817,13 @@ func sendEmailNotificationTest(ctx context.Context, config map[string]any) error
|
||||
return fmt.Errorf("%w: SMTP authentication failed", errProviderRejected)
|
||||
}
|
||||
}
|
||||
from, err := mail.ParseAddress(configString(config, "from_address"))
|
||||
from, err := parseMailAddress(configString(config, "from_address"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse sender address: %w", err)
|
||||
}
|
||||
recipients := make([]*mail.Address, 0)
|
||||
for _, item := range configStrings(config, "to_addresses") {
|
||||
address, err := mail.ParseAddress(item)
|
||||
address, err := parseMailAddress(item)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse recipient address: %w", err)
|
||||
}
|
||||
@@ -787,18 +841,13 @@ func sendEmailNotificationTest(ctx context.Context, config map[string]any) error
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: SMTP message rejected", errProviderRejected)
|
||||
}
|
||||
message := strings.Join([]string{
|
||||
"Date: " + time.Now().UTC().Format(time.RFC1123Z),
|
||||
"From: " + from.String(),
|
||||
"To: " + joinMailAddresses(recipients),
|
||||
"Subject: vocat notification test",
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: text/plain; charset=UTF-8",
|
||||
"",
|
||||
"This is a vocat notification test.",
|
||||
"",
|
||||
}, "\r\n")
|
||||
if _, err := io.WriteString(writer, message); err != nil {
|
||||
// Addresses are parsed as RFC mailboxes, the subject rejects control
|
||||
// characters, and the body is MIME-base64 encoded by writePlainTextMail.
|
||||
// CodeQL's email-injection query has no sanitizer model for these steps.
|
||||
// Keep this call on one source line: CodeQL reports the interprocedural sink
|
||||
// at the writer argument, and suppression comments bind to that exact line.
|
||||
// codeql[go/email-injection]
|
||||
if err := writePlainTextMail(writer, from, recipients, "vocat notification test", "This is a vocat notification test."); err != nil {
|
||||
_ = writer.Close()
|
||||
return fmt.Errorf("write SMTP test message: %w", err)
|
||||
}
|
||||
@@ -811,12 +860,28 @@ func sendEmailNotificationTest(ctx context.Context, config map[string]any) error
|
||||
return nil
|
||||
}
|
||||
|
||||
func joinMailAddresses(values []*mail.Address) string {
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
result = append(result, value.String())
|
||||
func parseMailAddress(value string) (*mail.Address, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || strings.ContainsAny(value, "\r\n\x00") {
|
||||
return nil, errors.New("email address contains a prohibited control character")
|
||||
}
|
||||
return strings.Join(result, ", ")
|
||||
address, err := mail.ParseAddress(value)
|
||||
if err != nil || address.Address == "" || strings.ContainsAny(address.Address, "\r\n\x00") {
|
||||
return nil, errors.New("invalid email address")
|
||||
}
|
||||
for _, character := range address.Name {
|
||||
if character < 0x20 || character == 0x7f {
|
||||
return nil, errors.New("email display name contains a prohibited control character")
|
||||
}
|
||||
}
|
||||
return address, nil
|
||||
}
|
||||
|
||||
func formatMailAddress(address *mail.Address) string {
|
||||
if address.Name == "" {
|
||||
return address.Address
|
||||
}
|
||||
return (&mail.Address{Name: address.Name, Address: address.Address}).String()
|
||||
}
|
||||
|
||||
func restrictedHTTPClient(
|
||||
@@ -1192,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
|
||||
@@ -1213,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
|
||||
@@ -1283,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
|
||||
@@ -1295,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)
|
||||
@@ -583,3 +812,23 @@ func TestRouteSettingsAPIReturnsFalseForUnknownPath(t *testing.T) {
|
||||
t.Fatal("unknown path was claimed by settings router")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMailAddressRejectsHeaderInjection(t *testing.T) {
|
||||
for _, value := range []string{
|
||||
"[email protected]\r\nBcc: [email protected]",
|
||||
"[email protected]\nX-Test: injected",
|
||||
"display\x00name <[email protected]>",
|
||||
} {
|
||||
if _, err := parseMailAddress(value); err == nil {
|
||||
t.Errorf("parseMailAddress(%q) accepted header injection", value)
|
||||
}
|
||||
}
|
||||
address, err := parseMailAddress("Vocat Alerts <[email protected]>")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
header := formatMailAddress(address)
|
||||
if strings.ContainsAny(header, "\r\n") {
|
||||
t.Fatalf("formatted address contains a line break: %q", header)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vocat/internal/developer"
|
||||
"vocat/internal/device"
|
||||
"vocat/internal/store"
|
||||
"vocat/internal/vowifi"
|
||||
@@ -224,6 +225,12 @@ func (s *Server) handleSMSSend(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "blocked_destination", reason)
|
||||
return
|
||||
}
|
||||
// Validate the logical message before consuming a global send slot. Both
|
||||
// cellular AT and VoWiFi IMS use this same encoder/validator.
|
||||
if _, err := device.PrepareSMSSubmitTPDUs(request.Phone, request.Message); err != nil {
|
||||
s.writeDeviceError(w, err)
|
||||
return
|
||||
}
|
||||
config, err := s.store.Device(r.Context(), request.DeviceID)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
@@ -233,6 +240,33 @@ func (s *Server) handleSMSSend(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requirePhysicalDevice(w, present) {
|
||||
return
|
||||
}
|
||||
limit := developer.SMSHourlyLimit(r.Context(), s.store)
|
||||
reservation, err := s.store.ReserveSMSSend(r.Context(), request.DeviceID, limit, time.Now().UTC())
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
if !reservation.Allowed {
|
||||
retryAfter := time.Until(reservation.ResetAt)
|
||||
if retryAfter < time.Second {
|
||||
retryAfter = time.Second
|
||||
}
|
||||
w.Header().Set("Retry-After", strconv.FormatInt(int64((retryAfter+time.Second-1)/time.Second), 10))
|
||||
writeJSON(w, http.StatusTooManyRequests, map[string]any{
|
||||
"error": apiError{
|
||||
Code: "sms_rate_limited",
|
||||
Message: fmt.Sprintf("Global SMS limit reached: at most %d messages may be submitted in a rolling one-hour window.", reservation.Limit),
|
||||
},
|
||||
"data": map[string]any{
|
||||
"limit": reservation.Limit,
|
||||
"used": reservation.Used,
|
||||
"remaining": reservation.Remaining,
|
||||
"reset_at": reservation.ResetAt,
|
||||
"retry_after": int64((retryAfter + time.Second - 1) / time.Second),
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if config.VoWiFiEnabled && s.vowifi != nil {
|
||||
state, stateErr := s.vowifi.State(request.DeviceID)
|
||||
sender, canSendIMS := s.vowifi.(imsSMSController)
|
||||
@@ -512,6 +546,13 @@ func (s *Server) syncModemSMS(ctx context.Context, onlyDevice string) {
|
||||
if onlyDevice != "" && config.ID != onlyDevice {
|
||||
continue
|
||||
}
|
||||
// A PC/SC USB reader has no modem storage or AT command channel. Its
|
||||
// messages are delivered by the active VoWiFi IMS session, so attempting
|
||||
// an AT+CMGL catch-up scan would only poison the reader's health state
|
||||
// with ErrNoATPort.
|
||||
if !supportsModemSMSStorage(config) {
|
||||
continue
|
||||
}
|
||||
// Do not queue CMGL traffic on the same serial actor while VoWiFi is
|
||||
// reading the SIM or running AKA. Once the session is stable, resume the
|
||||
// SM/ME scan as a catch-up path: an SMS submitted while the card was
|
||||
@@ -625,6 +666,10 @@ func (s *Server) syncModemSMS(ctx context.Context, onlyDevice string) {
|
||||
}
|
||||
}
|
||||
|
||||
func supportsModemSMSStorage(config store.Device) bool {
|
||||
return store.NormalizeDeviceType(config.DeviceType) != store.DeviceTypeUSBSIMReader
|
||||
}
|
||||
|
||||
func shouldDeferModemSMSSync(state vowifi.State, stateErr error) bool {
|
||||
if stateErr != nil || !state.Enabled {
|
||||
return false
|
||||
|
||||
@@ -5,9 +5,12 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"vocat/internal/developer"
|
||||
"vocat/internal/device"
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
@@ -105,6 +108,15 @@ func TestNormalizeSMSDeviceFilter(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportsModemSMSStorageRejectsUSBReader(t *testing.T) {
|
||||
if supportsModemSMSStorage(store.Device{DeviceType: store.DeviceTypeUSBSIMReader}) {
|
||||
t.Fatal("USB SIM reader must not be polled with modem SMS AT commands")
|
||||
}
|
||||
if !supportsModemSMSStorage(store.Device{DeviceType: store.DeviceTypePCIeEC20EC25}) {
|
||||
t.Fatal("cellular modem should retain modem SMS storage synchronization")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMSSendOutcome(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -155,3 +167,52 @@ func TestBlockedSMSDestination(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSMSSendEnforcesGlobalHourlyLimit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database, err := store.Open(ctx, ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
if err := developer.SetSMSHourlyLimit(ctx, database, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpsertDevice(ctx, store.Device{ID: "ec20_1", Name: "EC20"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reservation, err := database.ReserveSMSSend(ctx, "another-device", 1, time.Now().UTC()); err != nil || !reservation.Allowed {
|
||||
t.Fatalf("seed global SMS reservation = %+v, %v", reservation, err)
|
||||
}
|
||||
server := &Server{
|
||||
store: database,
|
||||
logger: regionTestLogger(),
|
||||
maxRequestBodyBytes: 4096,
|
||||
devices: fakeDeviceController{entry: device.Device{
|
||||
ID: "ec20_1",
|
||||
Discovered: true,
|
||||
Snapshot: &device.Snapshot{DeviceID: "ec20_1"},
|
||||
}},
|
||||
}
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/sms/send",
|
||||
strings.NewReader(`{"device_id":"ec20_1","phone":"+447700900123","message":"hello"}`),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
server.handleSMSSend(response, request)
|
||||
if response.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("status = %d, want 429; body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
if response.Header().Get("Retry-After") == "" {
|
||||
t.Fatal("Retry-After header is missing")
|
||||
}
|
||||
var envelope errorEnvelope
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if envelope.Error.Code != "sms_rate_limited" {
|
||||
t.Fatalf("error code = %q, want sms_rate_limited", envelope.Error.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
@@ -25,7 +24,7 @@ import (
|
||||
|
||||
const smsNotificationPollInterval = 2 * time.Second
|
||||
|
||||
var smsOnlyNotificationChannels = []string{"bark", "email", "pushplus", "webhook"}
|
||||
var smsOnlyNotificationChannels = []string{"bark", "email", "pushplus", "webhook", "wecom"}
|
||||
|
||||
type smsNotification struct {
|
||||
DeviceID string
|
||||
@@ -144,7 +143,7 @@ func (s *Server) smsNotificationConfig(ctx context.Context, channel string) (map
|
||||
|
||||
func validateSMSNotificationConfig(channel string, config map[string]any) error {
|
||||
switch channel {
|
||||
case "bark", "email", "webhook":
|
||||
case "bark", "email", "webhook", "wecom":
|
||||
if err := validateNotificationTestConfig(channel, config); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -203,6 +202,8 @@ func sendSMSNotification(ctx context.Context, channel string, config map[string]
|
||||
return sendPushplusSMSNotification(ctx, config, message)
|
||||
case "webhook":
|
||||
return sendWebhookSMSNotification(ctx, config, message)
|
||||
case "wecom":
|
||||
return sendWecomNotification(ctx, config, wecomSMSValues(message))
|
||||
default:
|
||||
return fmt.Errorf("unsupported SMS notification channel %q", channel)
|
||||
}
|
||||
@@ -402,13 +403,13 @@ func sendEmailSMSNotification(ctx context.Context, config map[string]any, messag
|
||||
return fmt.Errorf("%w: SMTP authentication failed", errProviderRejected)
|
||||
}
|
||||
}
|
||||
from, err := mail.ParseAddress(configString(config, "from_address"))
|
||||
from, err := parseMailAddress(configString(config, "from_address"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse sender address: %w", err)
|
||||
}
|
||||
recipients := make([]*mail.Address, 0)
|
||||
for _, item := range configStrings(config, "to_addresses") {
|
||||
address, err := mail.ParseAddress(item)
|
||||
address, err := parseMailAddress(item)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse recipient address: %w", err)
|
||||
}
|
||||
@@ -426,19 +427,13 @@ func sendEmailSMSNotification(ctx context.Context, config map[string]any, messag
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: SMTP message rejected", errProviderRejected)
|
||||
}
|
||||
email := strings.Join([]string{
|
||||
"Date: " + time.Now().UTC().Format(time.RFC1123Z),
|
||||
"From: " + from.String(),
|
||||
"To: " + joinMailAddresses(recipients),
|
||||
"Subject: " + mime.QEncoding.Encode("UTF-8", "收到新短信 - "+message.DeviceLabel),
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: text/plain; charset=UTF-8",
|
||||
"Content-Transfer-Encoding: 8bit",
|
||||
"",
|
||||
if err := writePlainTextMail(
|
||||
writer,
|
||||
from,
|
||||
recipients,
|
||||
"收到新短信 - "+message.DeviceLabel,
|
||||
message.Text(),
|
||||
"",
|
||||
}, "\r\n")
|
||||
if _, err := io.WriteString(writer, email); err != nil {
|
||||
); err != nil {
|
||||
_ = writer.Close()
|
||||
return fmt.Errorf("write SMTP notification: %w", err)
|
||||
}
|
||||
|
||||
@@ -36,12 +36,46 @@ func TestRenderSMSWebhookTemplate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWecomSMSValuesIncludeRenderedSMSFields(t *testing.T) {
|
||||
location := time.FixedZone("UTC+8", 8*60*60)
|
||||
message := smsNotification{
|
||||
DeviceID: "device-1", DeviceName: "客厅", DeviceLabel: "EC20",
|
||||
Number: "+447386", Time: time.Date(2026, 8, 8, 17, 25, 35, 0, location), Content: "hello",
|
||||
}
|
||||
values := wecomSMSValues(message)
|
||||
if values["event"] != "sms.received" || values["title"] != "收到新短信" || values["message"] != message.Text() {
|
||||
t.Fatalf("common values = %#v", values)
|
||||
}
|
||||
wantLocalTime := message.Time.Local().Format("2006-01-02 15:04:05")
|
||||
if values["content"] != "hello" || values["number"] != "+447386" || values["device_label"] != "EC20" || values["time"] != wantLocalTime {
|
||||
t.Fatalf("SMS values = %#v", values)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWecomAutomaticTaskValuesLeaveSMSFieldsEmpty(t *testing.T) {
|
||||
values := wecomAutomaticTaskValues(automaticTaskNotification{
|
||||
Title: "自动任务执行成功", Text: "任务已完成", Time: time.Unix(1_700_000_000, 0),
|
||||
})
|
||||
if values["event"] != "automatic_task.completed" || values["title"] != "自动任务执行成功" || values["message"] != "任务已完成" {
|
||||
t.Fatalf("common values = %#v", values)
|
||||
}
|
||||
for _, name := range []string{"content", "number", "device_id", "device_name", "device_label", "time"} {
|
||||
if values[name] != "" {
|
||||
t.Fatalf("%s = %q, want empty", name, values[name])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSMSNotificationConfig(t *testing.T) {
|
||||
valid := map[string]map[string]any{
|
||||
"bark": {"urls": []any{"https://api.day.app/key"}},
|
||||
"email": {"smtp_host": "smtp.example.com", "from_address": "[email protected]", "to_addresses": []any{"[email protected]"}},
|
||||
"pushplus": {"token": "secret"},
|
||||
"webhook": {"urls": []any{"https://example.com/hook"}},
|
||||
"wecom": {
|
||||
"urls": []any{"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=secret"},
|
||||
"payload_template": `{"msgtype":"text","text":{"content":{{message}}}}`,
|
||||
},
|
||||
}
|
||||
for channel, config := range valid {
|
||||
if err := validateSMSNotificationConfig(channel, config); err != nil {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,16 @@ func (s *Store) DeleteAutomaticTask(ctx context.Context, id int64) error {
|
||||
}
|
||||
|
||||
func (s *Store) ClaimDueAutomaticTasks(ctx context.Context, now time.Time, limit int) ([]AutomaticTaskRun, error) {
|
||||
return s.claimDueAutomaticTasks(ctx, now, limit, false)
|
||||
}
|
||||
|
||||
// ClaimDueAvailableAutomaticTasks excludes task types and environments that
|
||||
// are not exposed in the standard product surface.
|
||||
func (s *Store) ClaimDueAvailableAutomaticTasks(ctx context.Context, now time.Time, limit int) ([]AutomaticTaskRun, error) {
|
||||
return s.claimDueAutomaticTasks(ctx, now, limit, true)
|
||||
}
|
||||
|
||||
func (s *Store) claimDueAutomaticTasks(ctx context.Context, now time.Time, limit int, availableOnly bool) ([]AutomaticTaskRun, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
@@ -107,8 +117,12 @@ func (s *Store) ClaimDueAutomaticTasks(ctx context.Context, now time.Time, limit
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
availability := ""
|
||||
if availableOnly {
|
||||
availability = " AND task_type <> 'public_ip' AND environment <> 'cellular'"
|
||||
}
|
||||
rows, err := tx.QueryContext(ctx, automaticTaskSelect+`
|
||||
WHERE enabled = 1 AND next_run_at <= ? ORDER BY next_run_at, id LIMIT ?`, now.Unix(), limit)
|
||||
WHERE enabled = 1 AND next_run_at <= ?`+availability+` ORDER BY next_run_at, id LIMIT ?`, now.Unix(), limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -179,16 +193,101 @@ 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
|
||||
FROM automatic_task_runs`
|
||||
|
||||
func (s *Store) ListAutomaticTaskRuns(ctx context.Context, limit int) ([]AutomaticTaskRun, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, task_id, device_id, scheduled_at,
|
||||
started_at, finished_at, status, attempts, output, error, created_at, updated_at
|
||||
FROM automatic_task_runs ORDER BY id DESC LIMIT ?`, limit)
|
||||
rows, err := s.db.QueryContext(ctx, automaticTaskRunSelect+` ORDER BY id DESC LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanAutomaticTaskRuns(rows)
|
||||
}
|
||||
|
||||
// ListAutomaticTaskRunsPaginated returns one page of runs (newest first) plus
|
||||
// the total run count, so the UI can page through the full history instead of
|
||||
// a fixed recent window.
|
||||
func (s *Store) ListAutomaticTaskRunsPaginated(ctx context.Context, limit, offset int) ([]AutomaticTaskRun, int, error) {
|
||||
return s.listAutomaticTaskRunsPaginated(ctx, limit, offset, "")
|
||||
}
|
||||
|
||||
// ListAvailableAutomaticTaskRunsPaginated omits history belonging to task
|
||||
// types and environments that are not exposed in the standard product surface.
|
||||
func (s *Store) ListAvailableAutomaticTaskRunsPaginated(ctx context.Context, limit, offset int) ([]AutomaticTaskRun, int, error) {
|
||||
const where = ` WHERE task_id IN (
|
||||
SELECT id FROM automatic_tasks WHERE task_type <> 'public_ip' AND environment <> 'cellular'
|
||||
)`
|
||||
return s.listAutomaticTaskRunsPaginated(ctx, limit, offset, where)
|
||||
}
|
||||
|
||||
func (s *Store) listAutomaticTaskRunsPaginated(ctx context.Context, limit, offset int, where string) ([]AutomaticTaskRun, int, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
total := 0
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM automatic_task_runs`+where).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count automatic task runs: %w", err)
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, automaticTaskRunSelect+where+` ORDER BY id DESC LIMIT ? OFFSET ?`, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
runs, err := scanAutomaticTaskRuns(rows)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return runs, total, nil
|
||||
}
|
||||
|
||||
func scanAutomaticTaskRuns(rows *sql.Rows) ([]AutomaticTaskRun, error) {
|
||||
defer rows.Close()
|
||||
var result []AutomaticTaskRun
|
||||
for rows.Next() {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -70,3 +71,160 @@ func TestDeletingAutomaticTaskRemovesRunHistory(t *testing.T) {
|
||||
t.Fatalf("orphan runs = %+v, %v", runs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAutomaticTaskRunsPaginated(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database := openTestStore(t, filepath.Join(t.TempDir(), "automatic-task-runs-page.db"))
|
||||
mustSaveDevice(t, database, "ec20", "EC20")
|
||||
task, err := database.SaveAutomaticTask(ctx, AutomaticTask{
|
||||
Name: "task", Enabled: true, DeviceID: "ec20", ProfileICCID: "one",
|
||||
TaskType: "call", Environment: "cellular", IntervalDays: 1,
|
||||
StartDate: "2026-08-10", RunTime: "12:00", Timezone: "Asia/Shanghai", Payload: []byte(`{"phone":"10086","duration_seconds":10}`),
|
||||
NextRunAt: time.Now().Add(time.Hour),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for index := 0; index < 5; index++ {
|
||||
if _, err := database.QueueAutomaticTaskNow(ctx, task); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
first, total, err := database.ListAutomaticTaskRunsPaginated(ctx, 2, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 5 || len(first) != 2 {
|
||||
t.Fatalf("first page: total = %d, runs = %+v", total, first)
|
||||
}
|
||||
if first[0].ID <= first[1].ID {
|
||||
t.Fatalf("runs not newest-first: %+v", first)
|
||||
}
|
||||
|
||||
last, total, err := database.ListAutomaticTaskRunsPaginated(ctx, 2, 4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 5 || len(last) != 1 {
|
||||
t.Fatalf("last page: total = %d, runs = %+v", total, last)
|
||||
}
|
||||
|
||||
// Out-of-range paging inputs are clamped to defaults, not errors.
|
||||
all, total, err := database.ListAutomaticTaskRunsPaginated(ctx, 0, -5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 5 || len(all) != 5 {
|
||||
t.Fatalf("clamped page: total = %d, runs = %+v", total, all)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailableAutomaticTasksExcludeRestrictedTaskAndRunHistory(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database := openTestStore(t, filepath.Join(t.TempDir(), "automatic-task-availability.db"))
|
||||
mustSaveDevice(t, database, "ec20", "EC20")
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
save := func(name, taskType, environment string) AutomaticTask {
|
||||
t.Helper()
|
||||
task, err := database.SaveAutomaticTask(ctx, AutomaticTask{
|
||||
Name: name, Enabled: true, DeviceID: "ec20", ProfileICCID: "one",
|
||||
TaskType: taskType, Environment: environment, IntervalDays: 1,
|
||||
StartDate: "2026-08-10", RunTime: "12:00", Timezone: "Asia/Shanghai",
|
||||
Payload: []byte(`{"phone":"10086","message":"test"}`), NextRunAt: now.Add(-time.Minute),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return task
|
||||
}
|
||||
visible := save("visible", "sms", "vowifi")
|
||||
hidden := save("hidden", "public_ip", "cellular")
|
||||
for _, task := range []AutomaticTask{visible, hidden} {
|
||||
if _, err := database.QueueAutomaticTaskNow(ctx, task); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
runs, total, err := database.ListAvailableAutomaticTaskRunsPaginated(ctx, 20, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 1 || len(runs) != 1 || runs[0].TaskID != visible.ID {
|
||||
t.Fatalf("available history total=%d runs=%+v", total, runs)
|
||||
}
|
||||
claimed, err := database.ClaimDueAvailableAutomaticTasks(ctx, now, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(claimed) != 1 || claimed[0].TaskID != visible.ID {
|
||||
t.Fatalf("available claims = %+v", claimed)
|
||||
}
|
||||
storedHidden, err := database.AutomaticTask(ctx, hidden.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if storedHidden.NextRunAt.After(now) {
|
||||
t.Fatalf("restricted task schedule advanced unexpectedly: %v", storedHidden.NextRunAt)
|
||||
}
|
||||
}
|
||||
|
||||
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,8 @@ func TestMigrationFromAuthenticationSchema(t *testing.T) {
|
||||
"local_proxy_config", "upstream_proxies", "country_rules",
|
||||
"device_proxy_bindings",
|
||||
"notification_settings", "app_settings", "audit_events",
|
||||
"log_events", "card_policies", "traffic_buckets",
|
||||
"log_events", "card_policies", "card_apn_profiles", "traffic_buckets",
|
||||
"sms_send_attempts",
|
||||
} {
|
||||
var found string
|
||||
err := database.db.QueryRowContext(ctx, `
|
||||
@@ -105,6 +106,48 @@ func TestMigration7BackfillsSMSModemIMEI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigration12ConvertsOnlyKnownActiveDeviceBindingToICCID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "profile-proxy-binding.db")
|
||||
raw, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for version := 1; version <= 11; version++ {
|
||||
for _, statement := range migrationStatements(version) {
|
||||
if _, err := raw.ExecContext(ctx, statement); err != nil {
|
||||
t.Fatalf("create v%d schema: %v", version, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := raw.ExecContext(ctx, `
|
||||
INSERT INTO devices (id, name, created_at, updated_at) VALUES
|
||||
('known', 'Known', 100, 100), ('unknown', 'Unknown', 100, 100);
|
||||
INSERT INTO upstream_proxies (id, name, addr, created_at, updated_at)
|
||||
VALUES ('route', 'Route', '127.0.0.1:1080', 100, 100);
|
||||
INSERT INTO device_proxy_bindings (device_id, upstream_proxy_id, created_at, updated_at) VALUES
|
||||
('known', 'route', 100, 100), ('unknown', 'route', 100, 100);
|
||||
INSERT INTO vowifi_runtime (device_id, iccid, updated_at)
|
||||
VALUES ('known', '89441000400128014257', 100);
|
||||
PRAGMA user_version = 11;
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := raw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
database := openTestStore(t, path)
|
||||
binding, err := database.DeviceProxyBinding(ctx, "89441000400128014257")
|
||||
if err != nil || binding.DeviceID != "known" || binding.UpstreamProxyID != "route" {
|
||||
t.Fatalf("migrated binding = %+v, %v", binding, err)
|
||||
}
|
||||
bindings, err := database.ListDeviceProxyBindings(ctx)
|
||||
if err != nil || len(bindings) != 1 {
|
||||
t.Fatalf("migrated bindings = %+v, %v; unknown ICCID binding must be dropped", bindings, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigration9NormalizesVoWiFiAirplanePolicy(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "rf-safe-policy.db")
|
||||
@@ -323,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:")
|
||||
@@ -603,12 +671,12 @@ func TestProxyCredentialsAndCountryRules(t *testing.T) {
|
||||
t.Fatalf("CountryRule() = %+v, %v", rule, err)
|
||||
}
|
||||
if err := database.UpsertDeviceProxyBinding(ctx, DeviceProxyBinding{
|
||||
DeviceID: "ec20-1", UpstreamProxyID: "up-1",
|
||||
DeviceID: "ec20-1", ICCID: "89441000400128014257", ProfileName: "Vodafone", UpstreamProxyID: "up-1",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
binding, err := database.DeviceProxyBinding(ctx, "ec20-1")
|
||||
if err != nil || binding.UpstreamProxyID != "up-1" {
|
||||
binding, err := database.DeviceProxyBinding(ctx, "89441000400128014257")
|
||||
if err != nil || binding.UpstreamProxyID != "up-1" || binding.DeviceID != "ec20-1" || binding.ProfileName != "Vodafone" {
|
||||
t.Fatalf("DeviceProxyBinding() = %+v, %v", binding, err)
|
||||
}
|
||||
if err := database.DeleteUpstreamProxy(ctx, "up-1"); err != nil {
|
||||
@@ -617,7 +685,7 @@ func TestProxyCredentialsAndCountryRules(t *testing.T) {
|
||||
if _, err := database.CountryRule(ctx, "CN"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("country rule should cascade with upstream deletion, got %v", err)
|
||||
}
|
||||
if _, err := database.DeviceProxyBinding(ctx, "ec20-1"); !errors.Is(err, ErrNotFound) {
|
||||
if _, err := database.DeviceProxyBinding(ctx, "89441000400128014257"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("device binding should cascade with upstream deletion, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -694,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:")
|
||||
@@ -741,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)
|
||||
}
|
||||
@@ -751,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")
|
||||
|
||||
@@ -186,6 +186,84 @@ func migrationStatements(version int) []string {
|
||||
`CREATE INDEX IF NOT EXISTS automatic_task_runs_task_idx ON automatic_task_runs(task_id, id DESC)`,
|
||||
`CREATE INDEX IF NOT EXISTS automatic_task_runs_status_idx ON automatic_task_runs(status, id)`,
|
||||
}
|
||||
case 11:
|
||||
return []string{
|
||||
`CREATE TABLE IF NOT EXISTS sms_send_attempts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
device_id TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS sms_send_attempts_created_idx
|
||||
ON sms_send_attempts(created_at, id)`,
|
||||
}
|
||||
case 12:
|
||||
return []string{
|
||||
`ALTER TABLE device_proxy_bindings RENAME TO device_proxy_bindings_v11`,
|
||||
`CREATE TABLE device_proxy_bindings (
|
||||
iccid TEXT PRIMARY KEY,
|
||||
device_id TEXT NOT NULL,
|
||||
profile_name TEXT NOT NULL DEFAULT '',
|
||||
upstream_proxy_id TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (upstream_proxy_id) REFERENCES upstream_proxies(id) ON DELETE CASCADE
|
||||
)`,
|
||||
// A legacy device-wide binding is safe to preserve only when the
|
||||
// currently observed ICCID is known. It then becomes one profile binding
|
||||
// instead of leaking onto every future profile used by that device.
|
||||
`INSERT OR IGNORE INTO device_proxy_bindings (
|
||||
iccid, device_id, profile_name, upstream_proxy_id, created_at, updated_at
|
||||
)
|
||||
SELECT COALESCE(NULLIF(v.iccid, ''), NULLIF(d.iccid, '')),
|
||||
b.device_id, '', b.upstream_proxy_id, b.created_at, b.updated_at
|
||||
FROM device_proxy_bindings_v11 b
|
||||
LEFT JOIN vowifi_runtime v ON v.device_id = b.device_id
|
||||
LEFT JOIN device_runtime d ON d.device_id = b.device_id
|
||||
WHERE COALESCE(NULLIF(v.iccid, ''), NULLIF(d.iccid, '')) IS NOT NULL`,
|
||||
`DROP TABLE device_proxy_bindings_v11`,
|
||||
`CREATE INDEX device_proxy_bindings_proxy_idx
|
||||
ON device_proxy_bindings(upstream_proxy_id)`,
|
||||
`CREATE INDEX device_proxy_bindings_device_idx
|
||||
ON device_proxy_bindings(device_id, iccid)`,
|
||||
}
|
||||
case 13:
|
||||
return []string{
|
||||
`CREATE TABLE IF NOT EXISTS card_apn_profiles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
iccid TEXT NOT NULL,
|
||||
apn TEXT NOT NULL,
|
||||
ip_version TEXT NOT NULL DEFAULT 'IPV4V6'
|
||||
CHECK (ip_version IN ('IP', 'IPV6', 'IPV4V6')),
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
UNIQUE (iccid, apn, ip_version),
|
||||
FOREIGN KEY (iccid) REFERENCES card_policies(iccid) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS card_apn_profiles_iccid_idx
|
||||
ON card_apn_profiles(iccid, id)`,
|
||||
}
|
||||
case 14:
|
||||
return []string{
|
||||
`ALTER TABLE card_apn_profiles ADD COLUMN username TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE card_apn_profiles ADD COLUMN password TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE card_apn_profiles ADD COLUMN proxy TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE card_apn_profiles ADD COLUMN mcc TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE card_apn_profiles ADD COLUMN mnc TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE card_apn_profiles ADD COLUMN roaming_ip_version TEXT NOT NULL DEFAULT 'IP'
|
||||
CHECK (roaming_ip_version IN ('IP', 'IPV6', 'IPV4V6'))`,
|
||||
`ALTER TABLE card_apn_profiles ADD COLUMN auth_type TEXT NOT NULL DEFAULT 'NONE'
|
||||
CHECK (auth_type IN ('NONE', 'PAP', 'CHAP', 'PAP_OR_CHAP'))`,
|
||||
}
|
||||
case 15:
|
||||
return []string{
|
||||
`ALTER TABLE card_policies
|
||||
ADD COLUMN custom_phone_number TEXT NOT NULL DEFAULT ''`,
|
||||
}
|
||||
case 16:
|
||||
return []string{
|
||||
`ALTER TABLE devices ADD COLUMN sim_pin TEXT NOT NULL DEFAULT ''`,
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
+79
-24
@@ -24,6 +24,7 @@ type Device struct {
|
||||
USBPath string
|
||||
AudioDevice string
|
||||
ModemIMEI string
|
||||
SIMPIN string
|
||||
APN string
|
||||
ProxyPort int
|
||||
BaudRate int
|
||||
@@ -302,11 +303,12 @@ type CountryRule struct {
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// DeviceProxyBinding selects the SOCKS5 upstream used by one device's whole
|
||||
// VoWiFi runtime. The IKE/IPsec transport uses this route and IMS/SMS then
|
||||
// travel inside that tunnel.
|
||||
// DeviceProxyBinding selects the SOCKS5 upstream for exactly one eSIM profile.
|
||||
// ICCID is globally unique, while one proxy may serve profiles on many devices.
|
||||
type DeviceProxyBinding struct {
|
||||
DeviceID string
|
||||
ICCID string
|
||||
ProfileName string
|
||||
UpstreamProxyID string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
@@ -338,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 {
|
||||
@@ -476,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 {
|
||||
@@ -558,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)
|
||||
@@ -584,16 +600,55 @@ func mergeJSONSecrets(
|
||||
}
|
||||
for _, field := range fields {
|
||||
value := getJSONPath(next, field)
|
||||
text, stringValue := value.(string)
|
||||
if value == nil || (stringValue && (text == "" || text == SecretMask)) {
|
||||
if previous := getJSONPath(current, field); previous != nil {
|
||||
setJSONPath(next, field, previous)
|
||||
}
|
||||
if previous := getJSONPath(current, field); previous != nil {
|
||||
setJSONPath(next, field, mergeJSONSecretValue(value, previous))
|
||||
}
|
||||
}
|
||||
return json.Marshal(next)
|
||||
}
|
||||
|
||||
func redactJSONValue(value any, replacement string) any {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return replacement
|
||||
case []any:
|
||||
result := make([]any, len(typed))
|
||||
for index, item := range typed {
|
||||
result[index] = redactJSONValue(item, replacement)
|
||||
}
|
||||
return result
|
||||
default:
|
||||
return replacement
|
||||
}
|
||||
}
|
||||
|
||||
func mergeJSONSecretValue(incoming, existing any) any {
|
||||
if incoming == nil {
|
||||
return existing
|
||||
}
|
||||
switch next := incoming.(type) {
|
||||
case string:
|
||||
if next == "" || next == SecretMask {
|
||||
return existing
|
||||
}
|
||||
case []any:
|
||||
previous, ok := existing.([]any)
|
||||
if !ok {
|
||||
return incoming
|
||||
}
|
||||
merged := make([]any, len(next))
|
||||
for index, value := range next {
|
||||
if index < len(previous) {
|
||||
merged[index] = mergeJSONSecretValue(value, previous[index])
|
||||
} else {
|
||||
merged[index] = value
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
return incoming
|
||||
}
|
||||
|
||||
func getJSONPath(document map[string]any, path string) any {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return nil
|
||||
|
||||
+21
-17
@@ -358,9 +358,11 @@ func upstreamProxy(row rowScanner) (UpstreamProxy, error) {
|
||||
|
||||
func (s *Store) UpsertDeviceProxyBinding(ctx context.Context, value DeviceProxyBinding) error {
|
||||
value.DeviceID = strings.TrimSpace(value.DeviceID)
|
||||
value.ICCID = strings.TrimSpace(value.ICCID)
|
||||
value.ProfileName = strings.TrimSpace(value.ProfileName)
|
||||
value.UpstreamProxyID = strings.TrimSpace(value.UpstreamProxyID)
|
||||
if value.DeviceID == "" || value.UpstreamProxyID == "" {
|
||||
return errors.New("device proxy binding requires device and upstream proxy IDs")
|
||||
if value.DeviceID == "" || value.ICCID == "" || value.UpstreamProxyID == "" {
|
||||
return errors.New("profile proxy binding requires device ID, ICCID, and upstream proxy ID")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
createdAt := value.CreatedAt
|
||||
@@ -373,28 +375,30 @@ func (s *Store) UpsertDeviceProxyBinding(ctx context.Context, value DeviceProxyB
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO device_proxy_bindings (
|
||||
device_id, upstream_proxy_id, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(device_id) DO UPDATE SET
|
||||
iccid, device_id, profile_name, upstream_proxy_id, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(iccid) DO UPDATE SET
|
||||
device_id = excluded.device_id,
|
||||
profile_name = excluded.profile_name,
|
||||
upstream_proxy_id = excluded.upstream_proxy_id,
|
||||
updated_at = excluded.updated_at
|
||||
`, value.DeviceID, value.UpstreamProxyID, createdAt.Unix(), updatedAt.Unix())
|
||||
`, value.ICCID, value.DeviceID, value.ProfileName, value.UpstreamProxyID, createdAt.Unix(), updatedAt.Unix())
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert proxy binding for device %q: %w", value.DeviceID, err)
|
||||
return fmt.Errorf("upsert proxy binding for ICCID %q: %w", value.ICCID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) DeviceProxyBinding(ctx context.Context, deviceID string) (DeviceProxyBinding, error) {
|
||||
func (s *Store) DeviceProxyBinding(ctx context.Context, iccid string) (DeviceProxyBinding, error) {
|
||||
return deviceProxyBinding(s.db.QueryRowContext(
|
||||
ctx,
|
||||
deviceProxyBindingSelect+` WHERE device_id = ?`,
|
||||
strings.TrimSpace(deviceID),
|
||||
deviceProxyBindingSelect+` WHERE iccid = ?`,
|
||||
strings.TrimSpace(iccid),
|
||||
))
|
||||
}
|
||||
|
||||
func (s *Store) ListDeviceProxyBindings(ctx context.Context) ([]DeviceProxyBinding, error) {
|
||||
rows, err := s.db.QueryContext(ctx, deviceProxyBindingSelect+` ORDER BY device_id`)
|
||||
rows, err := s.db.QueryContext(ctx, deviceProxyBindingSelect+` ORDER BY device_id, profile_name COLLATE NOCASE, iccid`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list device proxy bindings: %w", err)
|
||||
}
|
||||
@@ -413,26 +417,26 @@ func (s *Store) ListDeviceProxyBindings(ctx context.Context) ([]DeviceProxyBindi
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteDeviceProxyBinding(ctx context.Context, deviceID string) error {
|
||||
func (s *Store) DeleteDeviceProxyBinding(ctx context.Context, iccid string) error {
|
||||
result, err := s.db.ExecContext(
|
||||
ctx,
|
||||
`DELETE FROM device_proxy_bindings WHERE device_id = ?`,
|
||||
strings.TrimSpace(deviceID),
|
||||
`DELETE FROM device_proxy_bindings WHERE iccid = ?`,
|
||||
strings.TrimSpace(iccid),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete proxy binding for device %q: %w", deviceID, err)
|
||||
return fmt.Errorf("delete proxy binding for ICCID %q: %w", iccid, err)
|
||||
}
|
||||
return requireAffected(result)
|
||||
}
|
||||
|
||||
const deviceProxyBindingSelect = `
|
||||
SELECT device_id, upstream_proxy_id, created_at, updated_at
|
||||
SELECT device_id, iccid, profile_name, upstream_proxy_id, created_at, updated_at
|
||||
FROM device_proxy_bindings`
|
||||
|
||||
func deviceProxyBinding(row rowScanner) (DeviceProxyBinding, error) {
|
||||
var value DeviceProxyBinding
|
||||
var createdAt, updatedAt int64
|
||||
err := row.Scan(&value.DeviceID, &value.UpstreamProxyID, &createdAt, &updatedAt)
|
||||
err := row.Scan(&value.DeviceID, &value.ICCID, &value.ProfileName, &value.UpstreamProxyID, &createdAt, &updatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return DeviceProxyBinding{}, ErrNotFound
|
||||
}
|
||||
|
||||
+164
-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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const SMSRateWindow = time.Hour
|
||||
|
||||
// SMSRateReservation is the durable result of claiming one global outbound
|
||||
// SMS slot. The quota is shared by every device, SIM, transport, and caller.
|
||||
type SMSRateReservation struct {
|
||||
Allowed bool
|
||||
Limit int
|
||||
Used int
|
||||
Remaining int
|
||||
ResetAt time.Time
|
||||
}
|
||||
|
||||
// ReserveSMSSend atomically claims one slot in the rolling one-hour window.
|
||||
// It intentionally records submission attempts separately from SMS history so
|
||||
// deleting a conversation cannot reset the global safety limit.
|
||||
func (s *Store) ReserveSMSSend(
|
||||
ctx context.Context,
|
||||
deviceID string,
|
||||
limit int,
|
||||
now time.Time,
|
||||
) (SMSRateReservation, error) {
|
||||
if limit < 1 {
|
||||
return SMSRateReservation{}, errors.New("SMS hourly limit must be positive")
|
||||
}
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
} else {
|
||||
now = now.UTC()
|
||||
}
|
||||
cutoff := now.Add(-SMSRateWindow).Unix()
|
||||
result, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO sms_send_attempts (device_id, created_at)
|
||||
SELECT ?, ?
|
||||
WHERE (
|
||||
SELECT COUNT(*) FROM sms_send_attempts WHERE created_at > ?
|
||||
) < ?
|
||||
`, strings.TrimSpace(deviceID), now.Unix(), cutoff, limit)
|
||||
if err != nil {
|
||||
return SMSRateReservation{}, fmt.Errorf("reserve global SMS send slot: %w", err)
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return SMSRateReservation{}, fmt.Errorf("read global SMS reservation result: %w", err)
|
||||
}
|
||||
|
||||
status, err := s.smsRateStatus(ctx, limit, cutoff)
|
||||
if err != nil {
|
||||
return SMSRateReservation{}, err
|
||||
}
|
||||
status.Allowed = affected == 1
|
||||
if status.Allowed {
|
||||
// Old rows are irrelevant to enforcement. Pruning after the atomic claim
|
||||
// keeps the hot index compact without creating a delete-before-insert race.
|
||||
_, _ = s.db.ExecContext(ctx, `DELETE FROM sms_send_attempts WHERE created_at <= ?`, now.Add(-7*24*time.Hour).Unix())
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func (s *Store) smsRateStatus(ctx context.Context, limit int, cutoff int64) (SMSRateReservation, error) {
|
||||
var used int
|
||||
var earliest *int64
|
||||
if err := s.db.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*), MIN(created_at)
|
||||
FROM sms_send_attempts
|
||||
WHERE created_at > ?
|
||||
`, cutoff).Scan(&used, &earliest); err != nil {
|
||||
return SMSRateReservation{}, fmt.Errorf("read global SMS rate status: %w", err)
|
||||
}
|
||||
remaining := limit - used
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
status := SMSRateReservation{Limit: limit, Used: used, Remaining: remaining}
|
||||
if earliest != nil {
|
||||
status.ResetAt = time.Unix(*earliest, 0).UTC().Add(SMSRateWindow)
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestReserveSMSSendIsGlobalAndRolling(t *testing.T) {
|
||||
database := openTestStore(t, ":memory:")
|
||||
now := time.Unix(1_800_000_000, 0).UTC()
|
||||
|
||||
first, err := database.ReserveSMSSend(context.Background(), "ec20_1", 2, now)
|
||||
if err != nil || !first.Allowed || first.Used != 1 || first.Remaining != 1 {
|
||||
t.Fatalf("first reservation = %+v, %v", first, err)
|
||||
}
|
||||
second, err := database.ReserveSMSSend(context.Background(), "ec20_2", 2, now.Add(time.Second))
|
||||
if err != nil || !second.Allowed || second.Used != 2 || second.Remaining != 0 {
|
||||
t.Fatalf("second reservation = %+v, %v", second, err)
|
||||
}
|
||||
blocked, err := database.ReserveSMSSend(context.Background(), "another-device", 2, now.Add(2*time.Second))
|
||||
if err != nil || blocked.Allowed || blocked.Used != 2 || !blocked.ResetAt.Equal(now.Add(SMSRateWindow)) {
|
||||
t.Fatalf("blocked reservation = %+v, %v", blocked, err)
|
||||
}
|
||||
afterWindow, err := database.ReserveSMSSend(context.Background(), "ec20_1", 2, now.Add(SMSRateWindow+time.Second))
|
||||
if err != nil || !afterWindow.Allowed || afterWindow.Used != 1 {
|
||||
t.Fatalf("reservation after rolling window = %+v, %v", afterWindow, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReserveSMSSendCannotExceedLimitConcurrently(t *testing.T) {
|
||||
database := openTestStore(t, ":memory:")
|
||||
now := time.Unix(1_800_000_000, 0).UTC()
|
||||
const limit = 10
|
||||
const callers = 40
|
||||
var allowed atomic.Int32
|
||||
var wait sync.WaitGroup
|
||||
for index := 0; index < callers; index++ {
|
||||
wait.Add(1)
|
||||
go func(index int) {
|
||||
defer wait.Done()
|
||||
result, err := database.ReserveSMSSend(context.Background(), "device", limit, now)
|
||||
if err != nil {
|
||||
t.Errorf("reservation %d: %v", index, err)
|
||||
return
|
||||
}
|
||||
if result.Allowed {
|
||||
allowed.Add(1)
|
||||
}
|
||||
}(index)
|
||||
}
|
||||
wait.Wait()
|
||||
if got := allowed.Load(); got != limit {
|
||||
t.Fatalf("allowed reservations = %d, want %d", got, limit)
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const schemaVersion = 10
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,11 @@ type EC20SensitiveATExecutor interface {
|
||||
ExecuteSensitiveAT(context.Context, string, string) (modem.Response, error)
|
||||
}
|
||||
|
||||
type EC20UICCLocker interface {
|
||||
LockUICC()
|
||||
UnlockUICC()
|
||||
}
|
||||
|
||||
type EC20AdapterOptions struct {
|
||||
// PureAirplanePolicy reports the independent user policy. The adapter only
|
||||
// changes the transactional CFUN projection used by VoWiFi and never
|
||||
@@ -339,6 +344,10 @@ func (adapter *EC20Adapter) CheckReady(
|
||||
// cannot insert an APDU between a 61xx response and GET RESPONSE.
|
||||
adapter.apduMu.Lock()
|
||||
defer adapter.apduMu.Unlock()
|
||||
if locker, ok := adapter.executor.(EC20UICCLocker); ok {
|
||||
locker.LockUICC()
|
||||
defer locker.UnlockUICC()
|
||||
}
|
||||
|
||||
aid, application, err := adapter.discoverAKAApplication(ctx, binding.deviceID)
|
||||
if err != nil {
|
||||
@@ -402,6 +411,10 @@ func (adapter *EC20Adapter) Authenticate(
|
||||
|
||||
adapter.apduMu.Lock()
|
||||
defer adapter.apduMu.Unlock()
|
||||
if locker, ok := adapter.executor.(EC20UICCLocker); ok {
|
||||
locker.LockUICC()
|
||||
defer locker.UnlockUICC()
|
||||
}
|
||||
|
||||
apdu := buildUSIMAuthenticateAPDU(challenge)
|
||||
var raw []byte
|
||||
|
||||
@@ -205,8 +205,13 @@ func (relay *sessionRelay) terminalError() error {
|
||||
|
||||
func (relay *sessionRelay) Close() error {
|
||||
relay.cancel()
|
||||
// ReceiveSessionPacket implementations normally observe the canceled
|
||||
// context through a short read deadline. Close the transport as an explicit
|
||||
// wake-up as well: a socket implementation that is stuck in Read must not
|
||||
// hold teardown (and the associated TUN interface) indefinitely.
|
||||
transportErr := relay.transport.Close()
|
||||
<-relay.done
|
||||
return relay.terminalErrorIfFailure()
|
||||
return errors.Join(relay.terminalErrorIfFailure(), transportErr)
|
||||
}
|
||||
|
||||
func (relay *sessionRelay) terminalErrorIfFailure() error {
|
||||
|
||||
@@ -22,12 +22,13 @@ type fakeSentPacket struct {
|
||||
}
|
||||
|
||||
type fakeSessionTransport struct {
|
||||
incoming chan fakeSessionPacket
|
||||
sent chan fakeSentPacket
|
||||
closed chan struct{}
|
||||
once sync.Once
|
||||
readers atomic.Int32
|
||||
maxReads atomic.Int32
|
||||
incoming chan fakeSessionPacket
|
||||
sent chan fakeSentPacket
|
||||
closed chan struct{}
|
||||
ignoreContext bool
|
||||
once sync.Once
|
||||
readers atomic.Int32
|
||||
maxReads atomic.Int32
|
||||
}
|
||||
|
||||
func newFakeSessionTransport() *fakeSessionTransport {
|
||||
@@ -81,6 +82,18 @@ func (transport *fakeSessionTransport) ReceiveSessionPacket(
|
||||
}
|
||||
}
|
||||
defer transport.readers.Add(-1)
|
||||
if transport.ignoreContext {
|
||||
select {
|
||||
case packet := <-transport.incoming:
|
||||
if packet.err != nil {
|
||||
return 0, false, packet.err
|
||||
}
|
||||
copy(buffer, packet.data)
|
||||
return len(packet.data), packet.ike, nil
|
||||
case <-transport.closed:
|
||||
return 0, false, net.ErrClosed
|
||||
}
|
||||
}
|
||||
select {
|
||||
case packet := <-transport.incoming:
|
||||
if packet.err != nil {
|
||||
@@ -96,6 +109,37 @@ func (transport *fakeSessionTransport) ReceiveSessionPacket(
|
||||
return 0, false, net.ErrClosed
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionRelayCloseInterruptsStuckTransportRead(t *testing.T) {
|
||||
transport := newFakeSessionTransport()
|
||||
transport.ignoreContext = true
|
||||
relay := newSessionRelay(
|
||||
transport,
|
||||
legacyTestSuite(),
|
||||
ikeKeys{},
|
||||
[8]byte{1},
|
||||
[8]byte{2},
|
||||
true,
|
||||
time.Hour,
|
||||
)
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for transport.readers.Load() == 0 && time.Now().Before(deadline) {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
if transport.readers.Load() == 0 {
|
||||
t.Fatal("relay did not enter the transport read")
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- relay.Close() }()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("close relay: %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("relay Close did not interrupt the transport read")
|
||||
}
|
||||
}
|
||||
func (transport *fakeSessionTransport) Close() error {
|
||||
transport.once.Do(func() { close(transport.closed) })
|
||||
return nil
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user