15 Commits
Author SHA1 Message Date
MengMengCode 22487dbb1f Add PC/SC backend support for USB SIM readers
- Implemented a new `unsupportedBackend` in `backend_stub.go` to handle unsupported platforms.
- Created a `Service` struct in `service.go` to manage interactions with smart cards, including session management and identity reading.
- Added methods for reading identity, checking readiness, and authenticating with USIM applications.
- Introduced a `scriptedCard` for testing purposes in `service_test.go` to simulate card responses.
- Defined necessary types and error handling in `types.go` for better clarity and usability.
- Developed a `PCSCAdapter` in `vowifi/pcsc_adapter.go` to integrate PC/SC service with Wi-Fi calling functionalities.
- Added a new SVG icon for USB SIM readers in `public/sim-reader.svg`.
- Implemented tests to ensure correct functionality and error handling in various scenarios.
2026-08-12 02:33:32 +08:00
Meng MengandGitHub f9bb38aabe Add star history badge to README 2026-08-11 23:52:46 +08:00
MengMengCode 5bb5808706 fix: update expected local time format in WeCom SMS values test 2026-08-11 23:06:48 +08:00
Meng MengandGitHub d70937cc47 Merge pull request #2 from zAhYAng/feat/wecom-notifications
feat: add WeCom message push notifications
2026-08-11 22:56:19 +08:00
zAhYAng eab658dc90 fix: polish WeCom notification settings 2026-08-11 22:28:36 +08:00
MengMengCode 0d738d4ce4 feat: add custom phone number support to card policies
- Introduced a new field `custom_phone_number` in the CardPolicy model and database schema.
- Updated the API to handle custom phone number input, including validation and normalization.
- Modified the CardPolicyPanel component to allow users to set and save a custom phone number.
- Enhanced the settings API to include the custom phone number in responses and updates.
- Added tests to ensure the correct functionality of custom phone number handling.
- Removed hardcoded environment variable for VOCAT_ADDR in service files.
2026-08-11 21:58:28 +08:00
zAhYAng 962c58fdd1 feat: add WeCom notification settings 2026-08-11 21:41:43 +08:00
zAhYAng 7b2e005b37 feat: dispatch WeCom notifications 2026-08-11 21:39:53 +08:00
zAhYAng f1e70ecee5 feat: configure WeCom notifications 2026-08-11 21:38:09 +08:00
zAhYAng f012c556e9 feat: add WeCom payload renderer 2026-08-11 21:33:38 +08:00
zAhYAng ab8bbbc1ed docs: plan WeCom notifications and worktree setup 2026-08-11 21:19:02 +08:00
MengMengCode 609a591045 feat: enhance email validation to prevent injection attacks and improve related tests 2026-08-11 19:41:06 +08:00
MengMengCode 461054615b feat: implement automatic task recovery logic and enhance related tests 2026-08-11 19:32:22 +08:00
MengMengCode 020fb619a9 feat: enhance security by preventing exposure of sensitive credentials in logs and errors 2026-08-11 19:07:58 +08:00
MengMengCode 3cc73f1885 feat: implement eSIM notification handling and email message construction
- Add eSIM notification management in `esim_notifications.go` with functions to retrieve, list, and remove notifications.
- Implement parsing logic for pending notifications and notification metadata.
- Create tests for eSIM notification parsing and request handling in `esim_notifications_test.go`.
- Introduce email message construction in `email_message.go` to securely format and send emails.
- Add tests for email message encoding and validation in `email_message_test.go`.
- Enhance the CardPolicyAPN component to manage APN configurations, including adding, editing, and deleting custom APNs.
- Implement UI for displaying and managing APN settings with appropriate validation and user feedback.
2026-08-11 18:58:32 +08:00
90 changed files with 6204 additions and 445 deletions
+1
View File
@@ -55,3 +55,4 @@ Thumbs.db
# ---- Claude Code / agent ----
.claude/
.worktrees/
+2
View File
@@ -316,3 +316,5 @@ cd web && npm run build
## License
See [LICENSE](LICENSE).
[![MengMengCode/VoCat Star History](https://mengmeng.meteor-history.com/api/embed/MengMengCode/VoCat.svg?sig=sdeXRVxAoY3yLWgXL7JViY2USYIN3t9neJ6ScPvgUAo&theme=light&style=xkcd&color=dd4528&background=ffffff&textColor=000000&width=900&height=600&lineWidth=3&showTitle=true&showLegend=true&showDots=false&v=0.0.14)](https://meteor-history.com)
+2 -2
View File
@@ -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
+113 -18
View File
@@ -27,6 +27,7 @@ import (
"vocat/internal/extensions"
"vocat/internal/httpsmode"
"vocat/internal/loghub"
"vocat/internal/pcsc"
"vocat/internal/server"
"vocat/internal/store"
"vocat/internal/update"
@@ -184,7 +185,8 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
return err
}
deviceManager, err := device.NewManager(device.Options{})
cardReaders := pcsc.New()
deviceManager, err := device.NewManager(device.Options{CardReaders: cardReaders})
if err != nil {
return fmt.Errorf("create device manager: %w", err)
}
@@ -220,6 +222,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 +365,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 +393,9 @@ func restoreDefaultCellularRadios(
}
mapper := integration.ATMapper{Store: database, Devices: manager}
for _, config := range configs {
if config.DeviceType == store.DeviceTypeUSBSIMReader {
continue
}
if config.VoWiFiEnabled {
continue
}
@@ -431,6 +443,9 @@ func restoreConfiguredCellularData(
}
mapper := integration.ATMapper{Store: database, Devices: manager}
for _, config := range configs {
if config.DeviceType == store.DeviceTypeUSBSIMReader {
continue
}
if !config.NetworkEnabled || config.VoWiFiEnabled {
continue
}
@@ -438,13 +453,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 +497,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 +508,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 +554,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 +575,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 +597,10 @@ func configureVoWiFiRuntime(
if err != nil {
return nil, fmt.Errorf("load device %q VoWiFi config: %w", deviceID, err)
}
adapter := vowifiDeviceAdapter(ec20Adapter)
if deviceConfig.DeviceType == store.DeviceTypeUSBSIMReader {
adapter = pcscAdapter
}
return newVoWiFiOrchestrator(deviceConfig, database, adapter)
},
})
@@ -583,10 +634,16 @@ func configureVoWiFiRuntime(
return manager, nil
}
type vowifiDeviceAdapter interface {
vowifi.SIMIdentityReader
vowifi.AKAProvider
vowifi.RadioController
}
func newVoWiFiOrchestrator(
deviceConfig store.Device,
database *store.Store,
adapter *vowifi.EC20Adapter,
adapter vowifiDeviceAdapter,
) (*vowifi.Orchestrator, error) {
apn := deviceConfig.APN
if apn == "" {
@@ -716,9 +773,18 @@ func provisionDiscoveredDevices(
candidate := discovered.Candidate
backend := "at"
control := candidate.ATPort.OpenPath()
deviceType := store.DeviceTypePCIeEC20EC25
esimTransport := backend
if candidate.QMIControl != "" {
backend = "qmi"
control = candidate.QMIControl
esimTransport = backend
}
if candidate.HardwareKind == pcsc.HardwareKind {
backend = "pcsc"
control = candidate.ReaderName
deviceType = store.DeviceTypeUSBSIMReader
esimTransport = "pcsc"
}
name := candidate.Product
if name == "" || strings.EqualFold(name, "Android") {
@@ -727,6 +793,7 @@ func provisionDiscoveredDevices(
if err := database.UpsertDevice(ctx, store.Device{
ID: discovered.ID,
Name: name,
DeviceType: deviceType,
Interface: candidate.NetworkInterface,
ControlDevice: control,
ATPort: candidate.ATPort.OpenPath(),
@@ -737,7 +804,7 @@ func provisionDiscoveredDevices(
StopBits: 1,
Parity: "none",
DeviceBackend: backend,
ESIMTransport: backend,
ESIMTransport: esimTransport,
NetworkEnabled: false,
SMSEnabled: true,
VoWiFiEnabled: true,
@@ -866,7 +933,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 +980,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 +1003,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 +1035,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 +1065,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 +1136,18 @@ func enforceCardRegion(
}
}
if snapshot.ICCID != "" {
policy := store.CardPolicy{
ICCID: snapshot.ICCID,
NetworkEnabled: false,
VoWiFiEnabled: false,
AirplaneEnabled: true,
IPVersion: "IPV4V6",
Source: cardPolicySourceRegionBlock,
policy, policyErr := database.CardPolicy(ctx, snapshot.ICCID)
if errors.Is(policyErr, store.ErrNotFound) {
policy = store.CardPolicy{ICCID: snapshot.ICCID, IPVersion: "IPV4V6"}
policyErr = nil
}
if err := database.UpsertCardPolicy(ctx, policy); err != nil && ctx.Err() == nil {
policy.NetworkEnabled = false
policy.VoWiFiEnabled = false
policy.AirplaneEnabled = true
policy.Source = cardPolicySourceRegionBlock
if policyErr != nil && ctx.Err() == nil {
logger.Warn("region block: failed to read card policy", "device_id", id, "iccid", snapshot.ICCID, "error", policyErr)
} else if err := database.UpsertCardPolicy(ctx, policy); err != nil && ctx.Err() == nil {
logger.Warn(
"region block: failed to persist card policy",
"device_id", id, "iccid", snapshot.ICCID, "error", err,
+256 -51
View File
@@ -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()
+71
View File
@@ -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)
}
}
}
-1
View File
@@ -13,7 +13,6 @@ Restart=on-failure
RestartSec=3s
TimeoutStartSec=30s
TimeoutStopSec=20s
Environment=VOCAT_ADDR=0.0.0.0:7575
Environment=VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db
EnvironmentFile=/etc/vocat/vocat.env
@@ -0,0 +1,383 @@
# 企业微信消息推送实现计划
> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
**目标:** 增加可配置 JSON 请求模板的企业微信 Webhook 通知通道,向新短信和自动任务结果发送消息。
**架构:** 新建专注的企业微信通知模块,统一构建事件变量、JSON 安全替换、Webhook POST 和 `errcode` 响应判定。设置 API 将 `wecom` 纳入白名单、保密 URL 与连通性测试;短信和自动任务分发器只增加该通道分支。前端在现有通知设置表单中新增企业微信页签和请求体编辑器。
**技术栈:** Go 1.25、标准库 `net/http``encoding/json`、SQLite 通知设置、React、TypeScript、Vite。
---
## 文件结构
- 创建:`internal/server/wecom_notification.go`,渲染企业微信 JSON 模板、创建安全 HTTP 请求并判定企业微信响应。
- 创建:`internal/server/wecom_notification_test.go`,覆盖 JSON 转义、模板拒绝和企业微信响应失败。
- 修改:`internal/server/settings_api.go`,登记 `wecom` 配置字段、启用连通性测试并调用企业微信发送器。
- 修改:`internal/server/settings_api_test.go`,验证企业微信配置 API、敏感 URL 与测试路径。
- 修改:`internal/store/settings.go`,将 `wecom.urls` 注册为敏感字段。
- 修改:`internal/server/sms_notifications.go`,将新短信事件接入企业微信通道。
- 修改:`internal/server/sms_notifications_test.go`,覆盖企业微信短信配置要求和变量数据。
- 修改:`internal/server/automatic_task_notifications.go`,将自动任务结果接入企业微信通道。
- 修改:`web/src/types.ts`,扩展通知设置类型。
- 修改:`web/src/components/settings/model.ts`,增加企业微信表单、默认模板、读取和提交映射。
- 修改:`web/src/components/settings/PushTabs.tsx`,新增企业微信配置界面。
- 修改:`web/src/pages/SettingsPage.tsx`,增加页签、测试状态与测试请求。
### 任务 1:企业微信模板与响应判定
**文件:**
- 创建:`internal/server/wecom_notification_test.go`
- 创建:`internal/server/wecom_notification.go`
- [ ] **步骤 1:编写失败的模板与响应测试**
```go
func TestRenderWecomPayloadEscapesTemplateValues(t *testing.T) {
payload, err := renderWecomPayload(
`{"msgtype":"text","text":{"content":{{message}},"number":{{number}}}}`,
wecomTemplateValues{"message": "quote: \\"\\nline", "number": "+447386"},
)
if err != nil { t.Fatal(err) }
if got := string(payload); got != `{"msgtype":"text","text":{"content":"quote: \\"\\nline","number":"+447386"}}` {
t.Fatalf("payload = %s", got)
}
}
func TestRenderWecomPayloadRejectsUnknownVariableAndNonObject(t *testing.T) {
for _, template := range []string{`{"text":{{unknown}}}`, `[]`} {
if _, err := renderWecomPayload(template, wecomTemplateValues{}); err == nil {
t.Fatalf("template %q was accepted", template)
}
}
}
func TestValidateWecomResponseRejectsProviderError(t *testing.T) {
if err := validateWecomResponse(http.StatusOK, []byte(`{"errcode":40058,"errmsg":"invalid"}`)); !errors.Is(err, errProviderRejected) {
t.Fatalf("error = %v", err)
}
}
```
- [ ] **步骤 2:运行测试验证失败**
运行:`go test ./internal/server -run 'TestRenderWecomPayload|TestValidateWecomResponse' -count=1`
预期:FAIL,提示 `renderWecomPayload``wecomTemplateValues``validateWecomResponse` 未定义。
- [ ] **步骤 3:实现最少的模板与响应代码**
`internal/server/wecom_notification.go` 中定义受支持变量列表,先用 `json.Marshal` 编码每个字符串,再替换精确的 `{{name}}` 标记;若保留任何 `{{``}}`,或者 `json.Unmarshal` 后不是非空 `map[string]json.RawMessage`,返回错误。响应处理必须要求 HTTP 2xx、可解析 JSON,且 `errcode` 为零。
```go
type wecomTemplateValues map[string]string
func renderWecomPayload(template string, values wecomTemplateValues) ([]byte, error) {
for _, name := range wecomTemplateVariableNames {
encoded, _ := json.Marshal(values[name])
template = strings.ReplaceAll(template, "{{"+name+"}}", string(encoded))
}
if strings.Contains(template, "{{") || strings.Contains(template, "}}") {
return nil, errors.New("wecom.payload_template contains an unsupported variable")
}
var payload map[string]json.RawMessage
if err := json.Unmarshal([]byte(template), &payload); err != nil || len(payload) == 0 {
return nil, errors.New("wecom.payload_template must render to a non-empty JSON object")
}
return []byte(template), nil
}
func validateWecomResponse(status int, body []byte) error {
var result struct { ErrCode int `json:"errcode"` }
if status < http.StatusOK || status >= http.StatusMultipleChoices || json.Unmarshal(body, &result) != nil || result.ErrCode != 0 {
return fmt.Errorf("%w: WeCom response was not successful", errProviderRejected)
}
return nil
}
func wecomTestValues(now time.Time) wecomTemplateValues {
return wecomTemplateValues{
"event": "test", "title": "vocat", "message": "vocat notification test",
"timestamp": now.UTC().Format(time.RFC3339),
}
}
func sendWecomNotification(ctx context.Context, config map[string]any, values wecomTemplateValues) error {
payload, err := renderWecomPayload(configString(config, "payload_template"), values)
if err != nil { return err }
client, err := restrictedHTTPClient(ctx, 8*time.Second, "")
if err != nil { return err }
for _, destination := range configStrings(config, "urls") {
parsed, err := validateOutboundURL(ctx, destination, false)
if err != nil { return err }
request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), bytes.NewReader(payload))
if err != nil { return fmt.Errorf("create WeCom notification request: %w", err) }
request.Header.Set("Content-Type", "application/json; charset=utf-8")
request.Header.Set("User-Agent", "vocat-wecom-notification/1")
response, err := client.Do(request)
if err != nil { return fmt.Errorf("send WeCom notification: %w", err) }
body, readErr := io.ReadAll(io.LimitReader(response.Body, 64<<10)); response.Body.Close()
if readErr != nil { return fmt.Errorf("read WeCom response: %w", readErr) }
if err := validateWecomResponse(response.StatusCode, body); err != nil { return err }
}
return nil
}
```
- [ ] **步骤 4:运行测试验证通过**
运行:`go test ./internal/server -run 'TestRenderWecomPayload|TestValidateWecomResponse' -count=1`
预期:PASS。
- [ ] **步骤 5:提交本任务**
运行:`git add internal/server/wecom_notification.go internal/server/wecom_notification_test.go && git commit -m "feat: add WeCom payload renderer"`
预期:创建包含模板渲染和响应判定的提交。若 Git 作者身份仍未配置,停止提交但保留已验证的工作区改动,不自行设置身份。
### 任务 2:设置 API 与敏感 Webhook URL
**文件:**
- 修改:`internal/server/settings_api_test.go`
- 修改:`internal/store/settings.go`
- 修改:`internal/server/settings_api.go`
- [ ] **步骤 1:编写失败的 API 测试**
```go
func TestWecomNotificationSettingsPreserveWebhookURLs(t *testing.T) {
test := newSettingsAPITest(t)
body := `{"wecom":{"enabled":true,"urls":["https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=secret"],"payload_template":"{\\\"msgtype\\\":\\\"text\\\",\\\"text\\\":{\\\"content\\\":{{message}}}}"}}`
recorder := test.request(t, http.MethodPut, "/api/settings/notifications", body)
if recorder.Code != http.StatusOK { t.Fatalf("status = %d", recorder.Code) }
if bytes.Contains(recorder.Body.Bytes(), []byte("key=secret")) { t.Fatal("response leaked webhook URL") }
stored, err := test.database.NotificationSetting(context.Background(), "wecom")
if err != nil || !bytes.Contains(stored.Config, []byte("key=secret")) { t.Fatalf("stored = %s, err = %v", stored.Config, err) }
}
func TestWecomNotificationSettingsRejectMalformedTemplate(t *testing.T) {
test := newSettingsAPITest(t)
recorder := test.request(t, http.MethodPut, "/api/settings/notifications", `{"wecom":{"enabled":true,"urls":["https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=x"],"payload_template":"[]"}}`)
if recorder.Code != http.StatusBadRequest { t.Fatalf("status = %d", recorder.Code) }
}
```
- [ ] **步骤 2:运行测试验证失败**
运行:`go test ./internal/server -run 'TestWecomNotificationSettings' -count=1`
预期:FAIL,设置 API 返回 `invalid_notification_channel`
- [ ] **步骤 3:实现 API 契约、保存和测试端点**
`notificationChannels` 中加入 `wecom`,在 `notificationFields` 中登记 `urls: strings``payload_template: wecom_template`。将 `urls` 加入 `DefaultNotificationSensitiveFields("wecom")`。在字段验证中对 `wecom_template` 调用 `renderWecomPayload`,以默认测试变量确认模板会生成对象;在 `validateNotificationTestConfig``handleNotificationTest` 和发送分支中支持 `wecom`
```go
"wecom": {"urls": "strings", "payload_template": "wecom_template"},
case "wecom":
return []string{"urls"}
case "wecom":
err = sendWecomNotificationTest(r.Context(), resolved)
```
将上段 `payload_template` 的字段类型实现为 `wecom_template`,避免只按普通字符串检查:
```go
case "wecom_template":
var template string
if err := json.Unmarshal(raw, &template); err != nil || len(template) > 32768 {
return fmt.Errorf("%s must be a template string", field)
}
_, err := renderWecomPayload(template, wecomTestValues(time.Unix(0, 0)))
return err
case "wecom":
if len(configStrings(config, "urls")) == 0 || configString(config, "payload_template") == "" {
return errors.New("wecom.urls and wecom.payload_template are required")
}
```
测试消息的变量必须为 `event: "test"``title: "vocat"``message: "vocat notification test"` 和当前 UTC RFC3339 时间;它应经过与生产消息完全相同的渲染和发送路径。
- [ ] **步骤 4:运行测试验证通过**
运行:`go test ./internal/server -run 'TestWecomNotificationSettings|TestNotificationSettingsAlwaysReturns' -count=1`
预期:PASSGET/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 保留。前端构建用于验证新增表单与类型契约。
+2
View File
@@ -3,6 +3,7 @@ module vocat
go 1.25.0
require (
github.com/ElMostafaIdrassi/goscard v1.0.0
github.com/coder/websocket v1.8.15
go.bug.st/serial v1.6.4
golang.org/x/crypto v0.41.0
@@ -14,6 +15,7 @@ require (
require (
github.com/creack/goselect v0.1.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.8.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
+6 -2
View File
@@ -1,3 +1,5 @@
github.com/ElMostafaIdrassi/goscard v1.0.0 h1:RDG5QrqrQBUoi5MkzM4zILdYf8qDn62daYZszqvdgx0=
github.com/ElMostafaIdrassi/goscard v1.0.0/go.mod h1:uGOakQe2fFlW2cVlr9cv6x07uelrf0j0aKPbR7jGgfg=
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0=
@@ -6,6 +8,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -18,8 +22,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A=
go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
+3
View File
@@ -274,6 +274,9 @@ func (manager *Manager) SetFlight(
if err := manager.validateActive(id, state); err != nil {
return FlightResult{}, err
}
if manager.candidateFor(state).HardwareKind == "pcsc" {
return FlightResult{PreviousMode: 4, CurrentMode: 4, FlightMode: true, RadioOff: true}, nil
}
client, err := manager.clientLocked(ctx, state, manager.candidateFor(state))
if err != nil {
manager.setResult(id, state, nil, err)
+74 -7
View File
@@ -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
}
+16
View File
@@ -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.
+3
View File
@@ -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)
}
+49
View File
@@ -3,7 +3,10 @@ package device
import (
"context"
"errors"
"strings"
"testing"
"vocat/internal/modem"
)
func TestSetNetworkATBackendActivatesAndDeactivatesPDP(t *testing.T) {
@@ -35,6 +38,52 @@ func TestSetNetworkATBackendActivatesAndDeactivatesPDP(t *testing.T) {
client.assertDone(t)
}
func TestSetNetworkATBackendAppliesPAPCredentials(t *testing.T) {
client := &transcriptClient{steps: []clientStep{
{command: `AT+CGDCONT=1,"IPV4V6","giffgaff.com"`, response: okResponse()},
{command: `AT+CGAUTH=1,1,"gg","p"`, response: okResponse()},
{command: "AT+CGATT=1", response: okResponse()},
{command: "AT+CGACT=1,1", response: okResponse()},
}}
manager, id := newStartedTestManager(t, client)
if _, err := manager.SetNetwork(context.Background(), id, NetworkRequest{
Enabled: true, APN: "giffgaff.com", IPVersion: "IPV4V6",
Username: "gg", Password: "p", Authentication: "PAP",
}); err != nil {
t.Fatalf("enable authenticated network: %v", err)
}
client.assertDone(t)
}
func TestSetNetworkDoesNotExposeAPNCredentialsInErrorsOrState(t *testing.T) {
const username = "private-user"
const password = "private-password"
command := `AT+CGAUTH=1,1,"` + username + `","` + password + `"`
client := &transcriptClient{steps: []clientStep{
{command: `AT+CGDCONT=1,"IPV4V6","giffgaff.com"`, response: okResponse()},
{command: command, err: &modem.CommandError{Command: command, Final: "ERROR"}},
}}
manager, id := newStartedTestManager(t, client)
_, err := manager.SetNetwork(context.Background(), id, NetworkRequest{
Enabled: true, APN: "giffgaff.com", IPVersion: "IPV4V6",
Username: username, Password: password, Authentication: "PAP",
})
if err == nil {
t.Fatal("SetNetwork() error = nil")
}
if strings.Contains(err.Error(), username) || strings.Contains(err.Error(), password) || strings.Contains(err.Error(), "AT+CGAUTH") {
t.Fatalf("SetNetwork() exposed credentials: %q", err)
}
entry, getErr := manager.Get(id)
if getErr != nil {
t.Fatal(getErr)
}
if strings.Contains(entry.LastError, username) || strings.Contains(entry.LastError, password) || strings.Contains(entry.LastError, "AT+CGAUTH") {
t.Fatalf("device state exposed credentials: %q", entry.LastError)
}
client.assertDone(t)
}
func TestSetNetworkRejectsUnsafeAPNBeforeOpeningModem(t *testing.T) {
client := &transcriptClient{}
manager, id := newStartedTestManager(t, client)
+59 -32
View File
@@ -10,6 +10,7 @@ import (
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
@@ -32,9 +33,11 @@ type es9pClient struct {
http *http.Client
}
var smdpAddressPattern = regexp.MustCompile(`^(?:[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?|\[[0-9A-Fa-f:.]+\])(?::[0-9]{1,5})?$`)
func newES9PClient(ctx context.Context, smdp string) (*es9pClient, error) {
smdp = strings.TrimSpace(smdp)
if smdp == "" || strings.Contains(smdp, "://") {
if !smdpAddressPattern.MatchString(smdp) {
return nil, errors.New("esim: SM-DP+ address must be a hostname with an optional port")
}
candidate, err := url.Parse("https://" + smdp)
@@ -159,31 +162,31 @@ func es9pErrFromStatus(function, status string, scd *es9pStatusCodeData) error {
// human-readable failure when the SM-DP+ omits statusCodeData.message. Table
// mirrors lpac's euicc/es9p_errors.c.
var es9pErrorTable = map[[2]string]string{
{"8.1", "4.8"}: "eUICC does not have sufficient space for this Profile",
{"8.1", "6.1"}: "eUICC signature is invalid or serverChallenge is invalid",
{"8.1.1", "2.2"}: "EID is missing in the context of this order",
{"8.1.1", "3.1"}: "a different EID is already associated with this ICCID",
{"8.1.1", "3.8"}: "EID doesn't match the expected value",
{"8.1.2", "6.1"}: "EUM Certificate is invalid",
{"8.1.2", "6.3"}: "EUM Certificate has expired",
{"8.1.3", "6.1"}: "eUICC Certificate is invalid",
{"8.1.3", "6.3"}: "eUICC Certificate has expired",
{"8.2", "1.2"}: "Profile has not yet been released",
{"8.2", "3.7"}: "BPP is not available for a new binding",
{"8.2.5", "3.7"}: "No more Profile available for the requested Profile Type",
{"8.2.5", "4.3"}: "No eligible Profile for this eUICC/Device",
{"8.2.6", "3.1"}: "a different MatchingID is associated with this ICCID",
{"8.2.6", "3.3"}: "Conflicting MatchingID value",
{"8.2.6", "3.8"}: "MatchingID (AC_Token or EventID) is refused",
{"8.2.7", "2.2"}: "Confirmation Code is missing",
{"8.2.7", "3.8"}: "Confirmation Code is refused",
{"8.2.7", "6.4"}: "maximum number of retries for the Confirmation Code exceeded",
{"8.8.1", "3.8"}: "Invalid SM-DP+ Address",
{"8.8.4", "3.7"}: "The SM-DP+ has no CERT.DPauth.ECDSA signed by one of the CI Public Key supported by the eUICC",
{"8.8.5", "4.1"}: "The Download order has expired",
{"8.8.5", "6.4"}: "maximum number of retries for the Profile download order exceeded",
{"8.10.1", "3.9"}: "The RSP session identified by the TransactionID is unknown",
{"8.11.1", "3.9"}: "Unknown CI Public Key. The CI used by the EUM Certificate is not a trusted root.",
{"8.1", "4.8"}: "eUICC does not have sufficient space for this Profile",
{"8.1", "6.1"}: "eUICC signature is invalid or serverChallenge is invalid",
{"8.1.1", "2.2"}: "EID is missing in the context of this order",
{"8.1.1", "3.1"}: "a different EID is already associated with this ICCID",
{"8.1.1", "3.8"}: "EID doesn't match the expected value",
{"8.1.2", "6.1"}: "EUM Certificate is invalid",
{"8.1.2", "6.3"}: "EUM Certificate has expired",
{"8.1.3", "6.1"}: "eUICC Certificate is invalid",
{"8.1.3", "6.3"}: "eUICC Certificate has expired",
{"8.2", "1.2"}: "Profile has not yet been released",
{"8.2", "3.7"}: "BPP is not available for a new binding",
{"8.2.5", "3.7"}: "No more Profile available for the requested Profile Type",
{"8.2.5", "4.3"}: "No eligible Profile for this eUICC/Device",
{"8.2.6", "3.1"}: "a different MatchingID is associated with this ICCID",
{"8.2.6", "3.3"}: "Conflicting MatchingID value",
{"8.2.6", "3.8"}: "MatchingID (AC_Token or EventID) is refused",
{"8.2.7", "2.2"}: "Confirmation Code is missing",
{"8.2.7", "3.8"}: "Confirmation Code is refused",
{"8.2.7", "6.4"}: "maximum number of retries for the Confirmation Code exceeded",
{"8.8.1", "3.8"}: "Invalid SM-DP+ Address",
{"8.8.4", "3.7"}: "The SM-DP+ has no CERT.DPauth.ECDSA signed by one of the CI Public Key supported by the eUICC",
{"8.8.5", "4.1"}: "The Download order has expired",
{"8.8.5", "6.4"}: "maximum number of retries for the Profile download order exceeded",
{"8.10.1", "3.9"}: "The RSP session identified by the TransactionID is unknown",
{"8.11.1", "3.9"}: "Unknown CI Public Key. The CI used by the EUM Certificate is not a trusted root.",
}
func es9pErrorMessage(subjectCode, reasonCode string) string {
@@ -264,10 +267,10 @@ func (c *es9pClient) initiateAuthentication(ctx context.Context, euiccChallenge,
// es9pAuthenticateResult carries the profile metadata and the SM-DP+ download
// authorization needed for PrepareDownload.
type es9pAuthenticateResult struct {
TransactionID string
TransactionID string
ProfileMetadata []byte
SmdpSigned2 []byte
SmdpSignature2 []byte
SmdpSigned2 []byte
SmdpSignature2 []byte
SmdpCertificate []byte
}
@@ -297,7 +300,7 @@ func (c *es9pClient) authenticateClient(ctx context.Context, transactionID strin
func (c *es9pClient) getBoundProfilePackage(ctx context.Context, transactionID string, prepareDownloadResponse []byte) ([]byte, error) {
root, err := c.call(ctx, "getBoundProfilePackage", map[string]string{
"transactionId": transactionID,
"transactionId": transactionID,
"prepareDownloadResponse": es9pBase64Encode(prepareDownloadResponse),
}, "boundProfilePackage")
if err != nil {
@@ -310,10 +313,34 @@ func (c *es9pClient) getBoundProfilePackage(ctx context.Context, transactionID s
// for the download case). It is best-effort: the profile is already installed, so
// a notification failure is reported by the caller as a warning, not a failure.
func (c *es9pClient) handleNotification(ctx context.Context, pendingNotification []byte) error {
_, err := c.call(ctx, "handleNotification", map[string]string{
endpoint := *c.endpoint
endpoint.Path = "/gsma/rsp2/es9plus/handleNotification"
body, err := json.Marshal(map[string]string{
"pendingNotification": es9pBase64Encode(pendingNotification),
})
return err
if err != nil {
return err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(body))
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "gsma-rsp-lpad")
request.Header.Set("X-Admin-Protocol", "gsma/rsp/v2.2.2")
response, err := c.http.Do(request)
if err != nil {
return fmt.Errorf("es9p handleNotification: %w", err)
}
defer response.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 1<<20))
// SGP.22 defines HandleNotification as a notification-handler function:
// success is an empty HTTP 204 response, not the JSON envelope returned by
// ordinary ES9+ request-response functions.
if response.StatusCode != http.StatusNoContent {
return fmt.Errorf("es9p handleNotification: receiver returned HTTP %d", response.StatusCode)
}
return nil
}
// cancelSession aborts an in-flight download so the SM-DP+ releases the
+32
View File
@@ -47,6 +47,7 @@ func TestNewES9PClientRejectsUnsafeAddress(t *testing.T) {
"169.254.169.254",
"rsp.example.com/unexpected/path",
"user:[email protected]",
"rsp.example.com\r\nX-Injected: yes",
} {
if _, err := newES9PClient(context.Background(), address); err == nil {
t.Errorf("newES9PClient(%q) accepted an unsafe address", address)
@@ -162,3 +163,34 @@ func TestGetBoundProfilePackageSuccess(t *testing.T) {
t.Fatalf("bpp = %X, want %X", got, pkg)
}
}
func TestHandleNotificationRequiresHTTP204(t *testing.T) {
pending := []byte{0xBF, 0x37, 0x00}
client := newTestES9P(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/gsma/rsp2/es9plus/handleNotification" {
t.Errorf("path = %s", r.URL.Path)
}
if r.Header.Get("X-Admin-Protocol") != "gsma/rsp/v2.2.2" {
t.Errorf("X-Admin-Protocol = %q", r.Header.Get("X-Admin-Protocol"))
}
var request map[string]string
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Errorf("decode request: %v", err)
}
decoded, err := base64.StdEncoding.DecodeString(request["pendingNotification"])
if err != nil || !bytes.Equal(decoded, pending) {
t.Errorf("pendingNotification = %q (%X), err=%v", request["pendingNotification"], decoded, err)
}
w.WriteHeader(http.StatusNoContent)
})
if err := client.handleNotification(context.Background(), pending); err != nil {
t.Fatalf("handleNotification: %v", err)
}
client = newTestES9P(t, func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(successEnvelope(nil))
})
if err := client.handleNotification(context.Background(), pending); err == nil || !strings.Contains(err.Error(), "HTTP 200") {
t.Fatalf("HTTP 200 error = %v", err)
}
}
+121 -26
View File
@@ -10,6 +10,7 @@ import (
"vocat/internal/i18n"
"vocat/internal/modem"
"vocat/internal/pcsc"
)
// eUICC / eSIM (LPA, SGP.22) access over the modem's AT+CSIM APDU passthrough.
@@ -190,9 +191,11 @@ func parseCSIM(response modem.Response) ([]byte, int, error) {
// euiccChannel is an open logical channel to the eUICC's ISD-R.
type euiccChannel struct {
manager *Manager
id string
channel int
manager *Manager
id string
channel int
pcscSession *pcsc.Session
resetOnClose bool
}
// csimAPDUTimeout bounds a single AT+CSIM exchange. Loading a BoundProfilePackage
@@ -264,6 +267,14 @@ func (manager *Manager) openEuiccOnce(ctx context.Context, id string) (*euiccCha
}
func (manager *Manager) openEuiccOnceAID(ctx context.Context, id, aidHex string) (*euiccChannel, error) {
state, lookupErr := manager.lookup(id)
if lookupErr != nil {
return nil, lookupErr
}
candidate := manager.candidateFor(state)
if candidate.HardwareKind == pcsc.HardwareKind {
return manager.openPCSCEuiccOnceAID(ctx, id, candidate, aidHex)
}
// MANAGE CHANNEL (open): 00 70 00 00 01 -> "<channel> 90 00". This EC20
// firmware requires the explicit one-byte expected length: Le=00 opens a
// channel but then rejects SELECT ISD-R at the AT+CSIM layer.
@@ -302,6 +313,37 @@ func (manager *Manager) openEuiccOnceAID(ctx context.Context, id, aidHex string)
return channel, nil
}
func (manager *Manager) openPCSCEuiccOnceAID(ctx context.Context, id string, candidate modem.Candidate, aidHex string) (*euiccChannel, error) {
session, err := manager.cardReaders.OpenSession(ctx, pcsc.Selector{
USBPath: candidate.USBPath, ReaderName: candidate.ReaderName,
})
if err != nil {
return nil, err
}
payload, sw, err := session.Transmit(ctx, []byte{0x00, 0x70, 0x00, 0x00, 0x01})
if err != nil || sw != 0x9000 || len(payload) != 1 {
session.Close()
if err != nil {
return nil, fmt.Errorf("esim: PC/SC MANAGE CHANNEL: %w", err)
}
return nil, errNoLogicalChannel
}
channel := &euiccChannel{manager: manager, id: id, channel: int(payload[0]), pcscSession: session}
aidHex = strings.ToUpper(strings.TrimSpace(aidHex))
aid, err := hex.DecodeString(aidHex)
if err != nil || len(aid) == 0 || len(aid) > 255 {
channel.close(context.Background())
return nil, fmt.Errorf("esim: invalid ISD-R AID %q", aidHex)
}
selectAID := append([]byte{byte(channel.channel), 0xA4, 0x04, 0x00, byte(len(aid))}, aid...)
_, selectSW, err := channel.transmit(ctx, selectAID, 0x00)
if err != nil || selectSW != 0x9000 {
channel.close(context.Background())
return nil, errNoEUICC
}
return channel, nil
}
// discoverEuiccAIDs detects eSTK multi-SE and alternate-ISD-R cards without
// changing any profile state. The vendor product applet and candidate ISD-R
// applications are selected only as read-only capability probes. Per
@@ -352,7 +394,23 @@ func isTransientEuiccCME(err error) bool {
// close releases the logical channel (MANAGE CHANNEL close).
func (channel *euiccChannel) close(ctx context.Context) {
closeAPDU := []byte{0x00, 0x70, 0x80, byte(channel.channel), 0x00}
_, _, _ = channel.manager.csim(ctx, channel.id, closeAPDU)
_, _, _ = channel.exchange(ctx, closeAPDU)
if channel.pcscSession != nil {
if channel.resetOnClose {
_ = channel.pcscSession.CloseWithReset()
} else {
_ = channel.pcscSession.Close()
}
channel.pcscSession = nil
}
}
func (channel *euiccChannel) exchange(ctx context.Context, apdu []byte) ([]byte, int, error) {
if channel.pcscSession != nil {
payload, sw, err := channel.pcscSession.Transmit(ctx, apdu)
return payload, int(sw), err
}
return channel.manager.csim(ctx, channel.id, apdu)
}
// transmit sends one APDU on the logical channel (CLA high nibble from insClass,
@@ -360,7 +418,7 @@ func (channel *euiccChannel) close(ctx context.Context) {
// and returns the assembled payload.
func (channel *euiccChannel) transmit(ctx context.Context, apdu []byte, insClass byte) ([]byte, int, error) {
apdu[0] = (apdu[0] & 0xF0) | byte(channel.channel)
payload, sw, err := channel.manager.csim(ctx, channel.id, apdu)
payload, sw, err := channel.exchange(ctx, apdu)
if err != nil {
return nil, 0, err
}
@@ -369,7 +427,7 @@ func (channel *euiccChannel) transmit(ctx context.Context, apdu []byte, insClass
for sw>>8 == 0x61 && guard < 24 {
guard++
getResponse := []byte{0x80 | byte(channel.channel), 0xC0, 0x00, 0x00, byte(sw & 0xFF)}
frag, nextSW, err := channel.manager.csim(ctx, channel.id, getResponse)
frag, nextSW, err := channel.exchange(ctx, getResponse)
if err != nil {
return nil, 0, err
}
@@ -610,6 +668,7 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
// Release the logical channel before any reset: openEuicc's csim holds
// opMu only for the duration of each APDU, so by here the lock is free.
closeContext, cancelClose := context.WithTimeout(context.Background(), csimAPDUTimeout)
channel.resetOnClose = channel.pcscSession != nil
channel.close(closeContext)
cancelClose()
if err != nil {
@@ -780,9 +839,11 @@ func (manager *Manager) renameCachedProfile(id, iccid, nickname string) {
// initiating HTTP request. EC20 commonly drops the AT port while processing
// CFUN=1,1, so the reset error is intentionally followed by discovery retries.
func (manager *Manager) recoverAfterProfileSwitch(id string) {
resetContext, cancelReset := context.WithTimeout(context.Background(), manager.longTimeout)
_ = manager.rebootForProfileSwitch(resetContext, id)
cancelReset()
if !manager.isPCSCDevice(id) {
resetContext, cancelReset := context.WithTimeout(context.Background(), manager.longTimeout)
_ = manager.rebootForProfileSwitch(resetContext, id)
cancelReset()
}
manager.refreshAfterProfileSwitch(id)
}
@@ -795,6 +856,20 @@ func (manager *Manager) recoverAfterProfileSwitch(id string) {
// the next attempt. All errors are swallowed: this is best-effort self-healing
// and setResult already records the last failure for the UI.
func (manager *Manager) refreshAfterProfileSwitch(id string) {
if manager.isPCSCDevice(id) {
time.Sleep(750 * time.Millisecond)
for attempt := 0; attempt < 10; attempt++ {
ctx, cancel := context.WithTimeout(context.Background(), manager.commandTimeout*4)
_, _ = manager.Discover(ctx)
_, err := manager.Refresh(ctx, id)
cancel()
if err == nil {
return
}
time.Sleep(time.Second)
}
return
}
const (
settle = 8 * time.Second
interval = 4 * time.Second
@@ -819,6 +894,14 @@ func (manager *Manager) refreshAfterProfileSwitch(id string) {
}
}
func (manager *Manager) isPCSCDevice(id string) bool {
state, err := manager.lookup(id)
if err != nil {
return false
}
return manager.candidateFor(state).HardwareKind == pcsc.HardwareKind
}
// enableProfileResult extracts the EnableProfile result code (tag 80) from the
// ES10c response body. ok is false when no result code is present.
func enableProfileResult(payload []byte) (int, bool) {
@@ -888,25 +971,37 @@ func (manager *Manager) verifySwitchedICCID(ctx context.Context, id, expected st
var lastICCID string
var lastErr error
for attempt := 0; attempt < attempts; attempt++ {
for _, command := range []string{"AT+CCID", "AT+QCCID"} {
commandContext, cancel := context.WithTimeout(ctx, manager.commandTimeout)
response, err := manager.ExecuteAT(commandContext, id, command)
cancel()
if err != nil {
lastErr = err
continue
if manager.isPCSCDevice(id) {
snapshot, err := manager.Refresh(ctx, id)
if err == nil {
lastICCID = strings.TrimSpace(snapshot.ICCID)
if lastICCID == expected {
return nil
}
err = fmt.Errorf("reader still reports ICCID %s", lastICCID)
}
live := parseICCIDIdentifier(response, []string{"+CCID:", "+QCCID:"}, 18, 22)
if live == "" {
lastErr = errors.New("modem response contained no valid ICCID")
continue
lastErr = err
} else {
for _, command := range []string{"AT+CCID", "AT+QCCID"} {
commandContext, cancel := context.WithTimeout(ctx, manager.commandTimeout)
response, err := manager.ExecuteAT(commandContext, id, command)
cancel()
if err != nil {
lastErr = err
continue
}
live := parseICCIDIdentifier(response, []string{"+CCID:", "+QCCID:"}, 18, 22)
if live == "" {
lastErr = errors.New("modem response contained no valid ICCID")
continue
}
lastICCID = live
if live == expected {
return nil
}
lastErr = fmt.Errorf("modem still reports ICCID %s", live)
break
}
lastICCID = live
if live == expected {
return nil
}
lastErr = fmt.Errorf("modem still reports ICCID %s", live)
break
}
if attempt+1 < attempts {
select {
+13 -1
View File
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"strings"
"time"
)
var (
@@ -101,13 +102,24 @@ func (manager *Manager) ESIMDeleteProfile(ctx context.Context, id, iccid, aidHex
}
deleted := &EsimDeleteResult{}
var warnings []string
if info2, infoErr := channel.getEUICCInfo2(ctx); infoErr == nil {
if freeAfter, afterKnown := euiccFreeNVRAM(info2); beforeKnown && afterKnown && freeAfter >= freeBefore {
deleted.SpaceDelta = int64(freeAfter - freeBefore)
}
} else {
deleted.Warning = "Profile was deleted, but reclaimed storage could not be read"
warnings = append(warnings, "Profile 已删除,但无法读取释放的存储空间")
}
// DeleteProfile creates a signed notification only when the Profile metadata
// configured a receiver. Flush all retained notifications so earlier events
// for the same receiver cannot be overtaken by this delete event.
notifyContext, cancelNotify := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute)
notifyErr := channel.deliverPendingNotifications(notifyContext)
cancelNotify()
if notifyErr != nil {
warnings = append(warnings, "Profile 已删除,但运营商通知发送失败;通知已保留在 eUICC,可稍后重发")
}
deleted.Warning = strings.Join(warnings, "")
manager.removeCachedProfile(id, strings.TrimSpace(iccid))
return deleted, nil
}
+17 -7
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"strings"
"time"
)
// EsimDownloadParams are the SPA download form fields, mapped from the
@@ -130,15 +131,24 @@ func (manager *Manager) ESIMDownloadProfile(ctx context.Context, id string, para
if err != nil {
return nil, err
}
iccid, err := installationResult(installResponse)
if err != nil {
return nil, err
}
report("notify", "正在向运营商发送下载通知...", 90)
iccid, installErr := installationResult(installResponse)
warning := ""
if err := client.handleNotification(ctx, installResponse); err != nil {
warning = "Profile 已安装,但下载通知发送失败"
notification, notificationErr := parsePendingNotification(installResponse)
if notificationErr == nil {
// Loading the final BPP segment is the commit point. Finish the operator
// acknowledgement even if the browser closes its SSE connection now.
notifyContext, cancelNotify := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute)
notificationErr = channel.deliverNotification(notifyContext, notification)
cancelNotify()
}
if notificationErr != nil {
warning = "Profile 安装结果已保留在 eUICC,但向运营商上报失败,可在当前通知列表中重发"
}
// Error installation results must be reported too. Return the card-side
// installation failure only after making that best-effort ES9+ attempt.
if installErr != nil {
return nil, installErr
}
freeAfter := freeBefore
+9 -1
View File
@@ -52,7 +52,7 @@ func (channel *euiccChannel) storeDataChained(ctx context.Context, derRequest []
if err != nil {
return nil, err
}
if sw != 0x9000 {
if !es10StatusOK(sw) {
return nil, fmt.Errorf("%w: SW=%04X", errESIMSW, sw)
}
assembled = append(assembled, payload...)
@@ -62,6 +62,14 @@ func (channel *euiccChannel) storeDataChained(ctx context.Context, derRequest []
return assembled, nil
}
// 91xx is a successful UICC result with a proactive SIM Toolkit command
// pending. EnableProfile commonly returns it on direct PC/SC transports because
// the requested refresh is delivered to the terminal rather than consumed by
// modem firmware. Resetting the card after the operation applies that refresh.
func es10StatusOK(sw int) bool {
return sw == 0x9000 || sw>>8 == 0x91
}
// getEUICCChallenge (ES10c, BF2E) returns the eUICC challenge bytes.
func (channel *euiccChannel) getEUICCChallenge(ctx context.Context) ([]byte, error) {
payload, err := channel.es10(ctx, []byte{0xBF, 0x2E, 0x00})
+13
View File
@@ -155,3 +155,16 @@ func TestEuiccFreeNVRAM(t *testing.T) {
t.Fatalf("expected ok=false when extCardResource absent")
}
}
func TestES10StatusAcceptsProactiveRefresh(t *testing.T) {
for _, status := range []int{0x9000, 0x9100, 0x910B, 0x91FF} {
if !es10StatusOK(status) {
t.Fatalf("status %04X should be successful", status)
}
}
for _, status := range []int{0x6A82, 0x6985, 0x9200} {
if es10StatusOK(status) {
t.Fatalf("status %04X should fail", status)
}
}
}
+354
View File
@@ -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, &notification.SequenceNumber)
if retrieveErr == nil {
retrieveErr = fmt.Errorf("esim: notification %d was not returned by eUICC", notification.SequenceNumber)
for _, candidate := range pending {
if candidate.SequenceNumber == notification.SequenceNumber {
retrieveErr = channel.deliverNotification(ctx, candidate)
break
}
}
}
if retrieveErr != nil {
blockedAddresses[notification.Address] = true
failures = append(failures, fmt.Errorf("notification %d to %s: %w", notification.SequenceNumber, notification.Address, retrieveErr))
}
}
return errors.Join(failures...)
}
// ESIMNotifications returns the notifications retained across every eUICC
// storage exposed by the physical card.
func (manager *Manager) ESIMNotifications(ctx context.Context, id string) ([]EsimNotification, error) {
manager.esimMu.Lock()
defer manager.esimMu.Unlock()
if err := manager.waitForESIMRecovery(ctx, id); err != nil {
return nil, err
}
var all []EsimNotification
var lastErr error
succeeded := false
for _, aid := range manager.discoverEuiccAIDs(ctx, id) {
channel, err := manager.openEuiccAID(ctx, id, aid)
if err != nil {
lastErr = err
continue
}
notifications, retrieveErr := channel.listNotifications(ctx)
channel.close(context.Background())
if retrieveErr != nil {
lastErr = retrieveErr
continue
}
succeeded = true
for index := range notifications {
notifications[index].AIDHex = aid
}
all = append(all, notifications...)
}
if !succeeded && lastErr != nil {
return nil, lastErr
}
return all, nil
}
// ESIMRetryNotification sends one retained notification and removes it from the
// eUICC only after the receiver returns the SGP.22 success acknowledgement.
func (manager *Manager) ESIMRetryNotification(ctx context.Context, id, aidHex string, sequenceNumber uint64) error {
manager.esimMu.Lock()
defer manager.esimMu.Unlock()
if err := manager.waitForESIMRecovery(ctx, id); err != nil {
return err
}
channel, err := manager.openEuiccAID(ctx, id, targetEuiccAID(aidHex))
if err != nil {
return err
}
defer channel.close(context.Background())
notifications, err := channel.retrieveNotifications(ctx, &sequenceNumber)
if err != nil {
return err
}
for _, notification := range notifications {
if notification.SequenceNumber == sequenceNumber {
return channel.deliverNotification(ctx, notification)
}
}
return fmt.Errorf("esim: notification %d was not found", sequenceNumber)
}
@@ -0,0 +1,96 @@
package device
import (
"bytes"
"testing"
)
func TestPositiveIntegerEncodingRoundTripsFullUint64Range(t *testing.T) {
for _, value := range []uint64{0, 1, 127, 128, 255, 256, ^uint64(0)} {
encoded := encodePositiveInteger(value)
decoded, ok := decodePositiveInteger(encoded)
if !ok || decoded != value {
t.Errorf("round trip %d: encoded=%X decoded=%d ok=%t", value, encoded, decoded, ok)
}
}
}
func testNotificationMetadata(t *testing.T, sequence byte, event []byte, address, iccid string) []byte {
t.Helper()
iccidBCD, err := encodeICCID(iccid)
if err != nil {
t.Fatal(err)
}
return derConstruct(0xBF2F,
derEncode(0x80, []byte{sequence}),
derEncode(0x81, event),
derEncode(0x0C, []byte(address)),
derEncode(0x5A, iccidBCD),
)
}
func TestParsePendingNotifications(t *testing.T) {
installMetadata := testNotificationMetadata(t, 7, []byte{7, 0x80}, "install.example.com", "8944476500017228672")
install := derConstruct(0xBF37, derConstruct(0xBF27, installMetadata))
deleteMetadata := testNotificationMetadata(t, 9, []byte{4, 0x10}, "delete.example.com", "89441000400128014257")
deleted := derConstruct(0x30, deleteMetadata, derEncode(0x5F37, []byte{1, 2, 3}))
notifications, err := parsePendingNotifications(derConstruct(0xBF2B, derConstruct(0xA0, install, deleted)))
if err != nil {
t.Fatalf("parsePendingNotifications: %v", err)
}
if len(notifications) != 2 {
t.Fatalf("notifications = %#v", notifications)
}
// Results are grouped by receiver, then sorted by sequence number.
if got := notifications[0]; got.SequenceNumber != 9 || got.Event != "delete" ||
got.Address != "delete.example.com" || got.ICCID != "89441000400128014257" || !bytes.Equal(got.raw, deleted) {
t.Fatalf("delete notification = %#v, raw=%X", got, got.raw)
}
if got := notifications[1]; got.SequenceNumber != 7 || got.Event != "install" ||
got.Address != "install.example.com" || got.ICCID != "8944476500017228672" || !bytes.Equal(got.raw, install) {
t.Fatalf("install notification = %#v, raw=%X", got, got.raw)
}
metadata, err := parseNotificationMetadataList(derConstruct(0xBF28, derConstruct(0xA0, installMetadata, deleteMetadata)))
if err != nil || len(metadata) != 2 {
t.Fatalf("parseNotificationMetadataList = %#v, %v", metadata, err)
}
if metadata[0].SequenceNumber != 9 || metadata[0].Event != "delete" || len(metadata[0].raw) != 0 {
t.Fatalf("listed metadata = %#v", metadata[0])
}
}
func TestNotificationRequestsAndRemoveResult(t *testing.T) {
if got := buildListNotificationsRequest(); !bytes.Equal(got, []byte{0xBF, 0x28, 0x00}) {
t.Fatalf("list request = %X", got)
}
if got := buildRetrieveNotificationsRequest(nil); !bytes.Equal(got, []byte{0xBF, 0x2B, 0x00}) {
t.Fatalf("retrieve all request = %X", got)
}
sequenceNumber := uint64(128)
wantRetrieve := []byte{0xBF, 0x2B, 0x04, 0x80, 0x02, 0x00, 0x80}
if got := buildRetrieveNotificationsRequest(&sequenceNumber); !bytes.Equal(got, wantRetrieve) {
t.Fatalf("retrieve request = %X, want %X", got, wantRetrieve)
}
wantRemove := []byte{0xBF, 0x30, 0x04, 0x80, 0x02, 0x00, 0x80}
if got := buildRemoveNotificationRequest(sequenceNumber); !bytes.Equal(got, wantRemove) {
t.Fatalf("remove request = %X, want %X", got, wantRemove)
}
if err := removeNotificationResult([]byte{0xBF, 0x30, 0x03, 0x80, 0x01, 0x00}); err != nil {
t.Fatalf("removeNotificationResult(ok): %v", err)
}
if err := removeNotificationResult([]byte{0xBF, 0x30, 0x03, 0x80, 0x01, 0x7F}); err == nil {
t.Fatal("undefinedError response was accepted")
}
}
func TestParsePendingNotificationsRejectsMalformedMetadata(t *testing.T) {
missingAddress := derConstruct(0x30, derConstruct(0xBF2F,
derEncode(0x80, []byte{1}),
derEncode(0x81, []byte{4, 0x10}),
))
if _, err := parsePendingNotifications(derConstruct(0xBF2B, missingAddress)); err == nil {
t.Fatal("notification without receiver address was accepted")
}
}
+94 -4
View File
@@ -10,6 +10,7 @@ import (
"time"
"vocat/internal/modem"
"vocat/internal/pcsc"
)
type Options struct {
@@ -19,6 +20,7 @@ type Options struct {
LongTimeout time.Duration
SMSTimeout time.Duration
ScanTimeout time.Duration
CardReaders *pcsc.Service
}
type Manager struct {
@@ -34,6 +36,7 @@ type Manager struct {
longTimeout time.Duration
smsTimeout time.Duration
scanTimeout time.Duration
cardReaders *pcsc.Service
started bool
devices map[string]*managedDevice
ussdSessions map[string]ussdSession
@@ -59,6 +62,7 @@ type managedDevice struct {
discovered bool
preFlightMode *int
resetClientOnLock bool
simPIN string
}
func NewManager(options Options) (*Manager, error) {
@@ -82,6 +86,9 @@ func NewManager(options Options) (*Manager, error) {
// AT+COPS=? can take well over a minute while the modem sweeps every band.
options.ScanTimeout = 150 * time.Second
}
if options.CardReaders == nil {
options.CardReaders = pcsc.New()
}
return &Manager{
discoverer: options.Discoverer,
opener: options.Opener,
@@ -89,6 +96,7 @@ func NewManager(options Options) (*Manager, error) {
longTimeout: options.LongTimeout,
smsTimeout: options.SMSTimeout,
scanTimeout: options.ScanTimeout,
cardReaders: options.CardReaders,
devices: make(map[string]*managedDevice),
ussdSessions: make(map[string]ussdSession),
esimRecoveries: make(map[string]chan struct{}),
@@ -146,9 +154,23 @@ func (manager *Manager) Discover(ctx context.Context) ([]Device, error) {
if ctx == nil {
ctx = context.Background()
}
candidates, err := manager.discoverer.Discover(ctx)
if err != nil {
return nil, err
candidates, modemErr := manager.discoverer.Discover(ctx)
readers, readerErr := manager.cardReaders.Readers(ctx)
if readerErr == nil {
for _, reader := range readers {
candidates = append(candidates, modem.Candidate{
ID: pcsc.DeviceID(reader), HardwareKind: pcsc.HardwareKind,
ReaderName: reader.Name, USBPath: reader.USBPath,
VendorID: reader.VendorID, ProductID: reader.ProductID,
Manufacturer: reader.Manufacturer, Product: reader.Product,
})
}
}
if modemErr != nil && readerErr != nil && !errors.Is(readerErr, pcsc.ErrUnsupported) && !errors.Is(readerErr, pcsc.ErrUnavailable) {
return nil, errors.Join(modemErr, readerErr)
}
if modemErr != nil && len(candidates) == 0 {
return nil, modemErr
}
seen := make(map[string]struct{}, len(candidates))
@@ -360,6 +382,9 @@ func (manager *Manager) Refresh(ctx context.Context, id string) (Snapshot, error
return Snapshot{}, err
}
candidate := manager.candidateFor(state)
if candidate.HardwareKind == pcsc.HardwareKind {
return manager.refreshCardReader(ctx, id, state, candidate)
}
backend := manager.backendFor(state)
client, err := manager.clientLocked(ctx, state, candidate)
if err != nil {
@@ -375,11 +400,59 @@ func (manager *Manager) Refresh(ctx context.Context, id string) (Snapshot, error
return snapshot, err
}
func (manager *Manager) refreshCardReader(ctx context.Context, id string, state *managedDevice, candidate modem.Candidate) (Snapshot, error) {
result := Snapshot{
DeviceID: id, Port: candidate.ReaderName, Responsive: true,
Manufacturer: candidate.Manufacturer, Model: candidate.Product,
AccessTech: "Wi-Fi", RegistrationSource: "pcsc", OperatingMode: 4,
ModeKnown: true, FlightMode: true, RadioOff: true, UpdatedAt: time.Now().UTC(),
}
previousICCID := state.lastICCID
card, err := manager.cardReaders.Snapshot(ctx, pcsc.Selector{USBPath: candidate.USBPath, ReaderName: candidate.ReaderName}, state.simPIN)
if err != nil {
switch {
case errors.Is(err, pcsc.ErrNoCard):
result.SIMStatus = ""
err = nil
case errors.Is(err, pcsc.ErrPINRequired), errors.Is(err, pcsc.ErrPINTriesLow), errors.Is(err, pcsc.ErrPINRejected):
result.SIMStatus = "SIM PIN"
result.Warnings = []string{err.Error()}
err = nil
default:
manager.setResult(id, state, &result, err)
return result, err
}
} else {
result.SIMStatus = "READY"
result.SIMReady = true
result.ICCID = card.Identity.ICCID
result.IMSI = card.Identity.IMSI
result.SPN = card.Identity.SPN
result.SIMChanged = previousICCID != "" && !strings.EqualFold(previousICCID, result.ICCID)
state.lastICCID = result.ICCID
}
manager.setResult(id, state, &result, err)
return result, err
}
// SetSIMPin updates the in-memory PIN used for protected USIM files and AKA.
// It is deliberately never retained in runtime snapshots or logs.
func (manager *Manager) SetSIMPin(id, pin string) error {
manager.mu.Lock()
defer manager.mu.Unlock()
state := manager.devices[id]
if state == nil || !state.discovered {
return ErrNotFound
}
state.simPIN = strings.TrimSpace(pin)
return nil
}
// SetBackend selects which control plane supplies registration and data state.
// AT remains available in either mode for UICC, RF, SMS, voice and diagnostics.
func (manager *Manager) SetBackend(id, backend string) error {
backend = strings.ToLower(strings.TrimSpace(backend))
if backend != "at" && backend != "qmi" {
if backend != "at" && backend != "qmi" && backend != "pcsc" {
return fmt.Errorf("unsupported device backend %q", backend)
}
manager.mu.Lock()
@@ -559,3 +632,20 @@ func (manager *Manager) command(
}
return response, nil
}
// sensitiveCommand executes an AT command containing credentials or other
// authentication material. Modem errors commonly echo the complete command,
// so neither the returned error nor the retained device state may wrap it.
func (manager *Manager) sensitiveCommand(
ctx context.Context,
client modem.Client,
command string,
) (modem.Response, error) {
commandCtx, cancel := manager.withTimeout(ctx, manager.commandTimeout)
defer cancel()
response, err := client.Execute(commandCtx, command)
if err != nil {
return response, errors.New("sensitive modem command failed")
}
return response, nil
}
+37
View File
@@ -6,8 +6,45 @@ import (
"testing"
"vocat/internal/modem"
"vocat/internal/pcsc"
)
type testPCSCBackend struct{ readers []pcsc.Reader }
func (backend testPCSCBackend) Readers(context.Context) ([]pcsc.Reader, error) {
return append([]pcsc.Reader(nil), backend.readers...), nil
}
func (testPCSCBackend) Open(context.Context, pcsc.Selector) (pcsc.Card, error) {
return nil, pcsc.ErrNoCard
}
func TestManagerDiscoversWiFiCallingOnlyReaderWithoutATPort(t *testing.T) {
manager, err := NewManager(Options{
Discoverer: staticDiscoverer{}, Opener: &staticOpener{},
CardReaders: pcsc.NewWithBackend(testPCSCBackend{readers: []pcsc.Reader{{
Name: "Alcor Link AK9563 00 00", USBPath: "1-3", Product: "AK9563",
}}}),
})
if err != nil {
t.Fatal(err)
}
if err := manager.Start(context.Background()); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = manager.Stop(context.Background()) })
items := manager.List()
if len(items) != 1 || items[0].Candidate.HardwareKind != pcsc.HardwareKind || items[0].Candidate.HasATPort() {
t.Fatalf("discovered readers = %#v", items)
}
snapshot, err := manager.Refresh(context.Background(), items[0].ID)
if err != nil {
t.Fatal(err)
}
if !snapshot.Responsive || snapshot.SIMReady || snapshot.SIMStatus != "" || !snapshot.FlightMode {
t.Fatalf("reader snapshot = %#v", snapshot)
}
}
func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
client := &transcriptClient{steps: []clientStep{
{
+8 -4
View File
@@ -11,6 +11,7 @@ var (
ErrNotStarted = errors.New("device manager is not started")
ErrNotFound = errors.New("device not found")
ErrNoATPort = errors.New("device has no usable AT port")
ErrUnsupportedCapability = errors.New("device does not support this capability")
ErrSMSPromptUnsupported = errors.New("device AT client does not support SMS prompt mode")
ErrSMSInvalidRecipient = errors.New("invalid SMS recipient")
ErrSMSEmpty = errors.New("SMS text is empty")
@@ -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 {
+9
View File
@@ -19,6 +19,7 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"sort"
"strings"
@@ -31,6 +32,10 @@ import (
const maxPackageBytes int64 = 64 << 20
// This syntactic guard gives the request boundary an explicit allowlist. The
// resolved addresses are still checked again by netguard before dialing.
var publicHTTPSURLPattern = regexp.MustCompile(`^https://(?:[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?|\[[0-9A-Fa-f:.]+\])(?::[0-9]{1,5})?(?:[/?#][^\r\n]*)?$`)
type Plugin struct {
Manifest
Enabled bool `json:"enabled"`
@@ -156,6 +161,10 @@ func (manager *Manager) List() []Plugin {
}
func (manager *Manager) InstallURL(ctx context.Context, rawURL, expectedSHA string) (Plugin, error) {
rawURL = strings.TrimSpace(rawURL)
if !publicHTTPSURLPattern.MatchString(rawURL) {
return Plugin{}, errors.New("plugin URL must be a public absolute HTTPS URL")
}
parsed, err := netguard.ValidatePublicURL(ctx, rawURL, true)
if err != nil {
return Plugin{}, fmt.Errorf("plugin URL must be a public absolute HTTPS URL: %w", err)
+2
View File
@@ -18,6 +18,8 @@ func TestInstallURLRejectsNonHTTPSAndPrivateDestinations(t *testing.T) {
defer manager.Close()
for _, raw := range []string{
"http://example.com/plugin.zip",
"https://[email protected]/plugin.zip",
"https://example.com/plugin.zip\r\nX-Injected: yes",
"https://127.0.0.1/plugin.zip",
"https://169.254.169.254/latest/meta-data/",
} {
+2
View File
@@ -44,6 +44,8 @@ func (p Port) OpenPath() string {
}
type Candidate struct {
HardwareKind string `json:"hardwareKind,omitempty"`
ReaderName string `json:"readerName,omitempty"`
ID string `json:"id"`
VendorID string `json:"vendorId"`
ProductID string `json:"productId"`
+272
View File
@@ -0,0 +1,272 @@
//go:build linux && (amd64 || arm64)
package pcsc
import (
"context"
"encoding/binary"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"github.com/ElMostafaIdrassi/goscard"
)
type nativeBackend struct {
initializeOnce sync.Once
initializeErr error
}
func newNativeBackend() Backend { return &nativeBackend{} }
func (backend *nativeBackend) initialize() error {
backend.initializeOnce.Do(func() {
if err := goscard.Initialize(goscard.NewDefaultLogger(goscard.LogLevelNone)); err != nil {
backend.initializeErr = fmt.Errorf("%w: pcsc-lite client library could not be loaded", ErrUnavailable)
}
})
return backend.initializeErr
}
func (backend *nativeBackend) Readers(ctx context.Context) ([]Reader, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if err := backend.initialize(); err != nil {
return nil, err
}
cardContext, _, err := goscard.NewContext(goscard.SCardScopeSystem, nil, nil)
if err != nil {
return nil, fmt.Errorf("%w: pcscd is not reachable", ErrUnavailable)
}
defer cardContext.Release()
names, _, err := cardContext.ListReaders(nil)
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "no readers") {
return []Reader{}, nil
}
return nil, fmt.Errorf("pcsc: list readers: %w", err)
}
presentNames, atrs, _, _ := cardContext.ListReadersWithCardPresent(nil)
present := make(map[string]string, len(presentNames))
for index, name := range presentNames {
atr := ""
if index < len(atrs) {
atr = atrs[index]
}
present[name] = atr
}
readers := make([]Reader, 0, len(names))
for _, name := range names {
reader := Reader{Name: name}
reader.ATR, reader.CardPresent = present[name]
if path, ok := backend.readerUSBPath(cardContext, name); ok {
reader.USBPath = path
reader.VendorID = readSysfsText(path, "idVendor")
reader.ProductID = readSysfsText(path, "idProduct")
reader.Manufacturer = readSysfsText(path, "manufacturer")
reader.Product = readSysfsText(path, "product")
} else {
reader.USBPath = "pcsc:" + name
}
if reader.Product == "" {
reader.Product = strings.TrimSpace(strings.TrimSuffix(name, " 00 00"))
}
readers = append(readers, reader)
}
return readers, nil
}
func (backend *nativeBackend) readerUSBPath(cardContext goscard.Context, name string) (string, bool) {
card, _, err := cardContext.Connect(name, goscard.SCardShareDirect, goscard.SCardProtocolT0|goscard.SCardProtocolT1)
if err != nil {
return "", false
}
defer card.Disconnect(goscard.SCardLeaveCard)
attribute, _, err := card.GetAttrib(goscard.SCardAttrChannelID)
if err != nil || len(attribute) < 4 {
return "", false
}
channel := binary.LittleEndian.Uint32(attribute[:4])
if channel>>16 != 0x0020 {
return "", false
}
bus, device := int((channel>>8)&0xFF), int(channel&0xFF)
entries, err := os.ReadDir("/sys/bus/usb/devices")
if err != nil {
return "", false
}
for _, entry := range entries {
if !entry.IsDir() && entry.Type()&os.ModeSymlink == 0 {
continue
}
path := filepath.Join("/sys/bus/usb/devices", entry.Name())
entryBus, busErr := readSysfsInt(path, "busnum")
entryDevice, deviceErr := readSysfsInt(path, "devnum")
if busErr == nil && deviceErr == nil && entryBus == bus && entryDevice == device {
return entry.Name(), true
}
}
return "", false
}
func (backend *nativeBackend) Open(ctx context.Context, selector Selector) (Card, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
readers, err := backend.Readers(ctx)
if err != nil {
return nil, err
}
reader, ok := matchReader(readers, selector)
if !ok {
return nil, ErrReaderNotFound
}
if !reader.CardPresent {
return nil, ErrNoCard
}
cardContext, _, err := goscard.NewContext(goscard.SCardScopeSystem, nil, nil)
if err != nil {
return nil, fmt.Errorf("%w: create context", ErrUnavailable)
}
card, _, err := cardContext.Connect(reader.Name, goscard.SCardShareShared, goscard.SCardProtocolT0|goscard.SCardProtocolT1)
if err != nil {
cardContext.Release()
return nil, fmt.Errorf("pcsc: connect reader: %w", err)
}
if _, err := card.BeginTransaction(); err != nil {
card.Disconnect(goscard.SCardLeaveCard)
cardContext.Release()
return nil, fmt.Errorf("pcsc: begin card transaction: %w", err)
}
return &nativeCard{context: &cardContext, card: &card}, nil
}
type nativeCard struct {
context *goscard.Context
card *goscard.Card
closed bool
}
func (card *nativeCard) Transmit(ctx context.Context, command []byte) ([]byte, uint16, error) {
if card == nil || card.card == nil || card.closed {
return nil, 0, errors.New("pcsc: card session is closed")
}
if err := ctx.Err(); err != nil {
return nil, 0, err
}
return card.transmit(ctx, append([]byte(nil), command...), 0)
}
// TransmitRaw performs exactly one APDU exchange. Stateful eUICC callers need
// to observe 61xx themselves because GET RESPONSE must target their logical
// channel rather than the basic channel.
func (card *nativeCard) TransmitRaw(ctx context.Context, command []byte) ([]byte, uint16, error) {
if card == nil || card.card == nil || card.closed {
return nil, 0, errors.New("pcsc: card session is closed")
}
if err := ctx.Err(); err != nil {
return nil, 0, err
}
pci := goscard.SCardIoRequestT0
if card.card.ActiveProtocol() == goscard.SCardProtocolT1 {
pci = goscard.SCardIoRequestT1
}
response, _, err := card.card.Transmit(&pci, append([]byte(nil), command...), nil)
if err != nil {
return nil, 0, err
}
if len(response) < 2 {
return nil, 0, errors.New("pcsc: APDU response omitted its status word")
}
if err := ctx.Err(); err != nil {
return nil, 0, err
}
last := len(response) - 2
return append([]byte(nil), response[:last]...), uint16(response[last])<<8 | uint16(response[last+1]), nil
}
func (card *nativeCard) transmit(ctx context.Context, command []byte, depth int) ([]byte, uint16, error) {
if depth > 8 {
return nil, 0, errors.New("pcsc: too many APDU continuations")
}
pci := goscard.SCardIoRequestT0
if card.card.ActiveProtocol() == goscard.SCardProtocolT1 {
pci = goscard.SCardIoRequestT1
}
response, _, err := card.card.Transmit(&pci, command, nil)
if err != nil {
return nil, 0, err
}
if len(response) < 2 {
return nil, 0, errors.New("pcsc: APDU response omitted its status word")
}
data := append([]byte(nil), response[:len(response)-2]...)
sw1, sw2 := response[len(response)-2], response[len(response)-1]
if sw1 == 0x6C && len(command) >= 5 {
retry := append([]byte(nil), command...)
retry[len(retry)-1] = sw2
return card.transmit(ctx, retry, depth+1)
}
if sw1 == 0x61 || sw1 == 0x9F {
more, sw, err := card.transmit(ctx, []byte{0x00, 0xC0, 0x00, 0x00, sw2}, depth+1)
if err != nil {
return nil, 0, err
}
return append(data, more...), sw, nil
}
if err := ctx.Err(); err != nil {
return nil, 0, err
}
return data, uint16(sw1)<<8 | uint16(sw2), nil
}
func (card *nativeCard) Close() error {
return card.close(goscard.SCardLeaveCard)
}
func (card *nativeCard) CloseWithReset() error {
return card.close(goscard.SCardResetCard)
}
func (card *nativeCard) close(disposition goscard.SCardDisposition) error {
if card == nil || card.closed {
return nil
}
card.closed = true
var result []error
if card.card != nil {
if _, err := card.card.EndTransaction(disposition); err != nil {
result = append(result, err)
}
if _, err := card.card.Disconnect(disposition); err != nil {
result = append(result, err)
}
}
if card.context != nil {
if _, err := card.context.Release(); err != nil {
result = append(result, err)
}
}
return errors.Join(result...)
}
func readSysfsText(usbPath, name string) string {
value, err := os.ReadFile(filepath.Join("/sys/bus/usb/devices", usbPath, name))
if err != nil {
return ""
}
return strings.TrimSpace(string(value))
}
func readSysfsInt(path, name string) (int, error) {
value, err := os.ReadFile(filepath.Join(path, name))
if err != nil {
return 0, err
}
return strconv.Atoi(strings.TrimSpace(string(value)))
}
+17
View File
@@ -0,0 +1,17 @@
//go:build !linux || (!amd64 && !arm64)
package pcsc
import "context"
type unsupportedBackend struct{}
func newNativeBackend() Backend { return unsupportedBackend{} }
func (unsupportedBackend) Readers(context.Context) ([]Reader, error) {
return nil, ErrUnsupported
}
func (unsupportedBackend) Open(context.Context, Selector) (Card, error) {
return nil, ErrUnsupported
}
+610
View File
@@ -0,0 +1,610 @@
package pcsc
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strings"
"sync"
)
const usimAIDPrefix = "A0000000871002"
type Service struct {
mu sync.Mutex
backend Backend
}
// Session is an exclusive connection to one smart card. It is used by eUICC
// operations which must keep the same PC/SC transaction and logical channel
// alive across a sequence of APDUs.
type Session struct {
service *Service
card Card
closed bool
}
func New() *Service {
return &Service{backend: newNativeBackend()}
}
func NewWithBackend(backend Backend) *Service {
return &Service{backend: backend}
}
func DeviceID(reader Reader) string {
identity := strings.TrimSpace(reader.USBPath)
if identity == "" {
identity = strings.TrimSpace(reader.Name)
}
sum := sha256.Sum256([]byte(identity))
return "reader-" + hex.EncodeToString(sum[:8])
}
func (service *Service) Readers(ctx context.Context) ([]Reader, error) {
if service == nil || service.backend == nil {
return nil, ErrUnavailable
}
service.mu.Lock()
defer service.mu.Unlock()
return service.backend.Readers(ctx)
}
// OpenSession opens one card and holds the service lock until Close. Callers
// must close the returned session; this prevents AKA/identity reads from
// interleaving with a stateful ES10 transaction.
func (service *Service) OpenSession(ctx context.Context, selector Selector) (*Session, error) {
if service == nil || service.backend == nil {
return nil, ErrUnavailable
}
if err := selector.validate(); err != nil {
return nil, err
}
service.mu.Lock()
card, err := service.backend.Open(ctx, selector)
if err != nil {
service.mu.Unlock()
return nil, err
}
return &Session{service: service, card: card}, nil
}
// Transmit sends one raw APDU. Unlike the ordinary SIM helpers, it leaves
// 61xx continuation handling to the eUICC logical-channel implementation so
// GET RESPONSE uses the correct channel CLA.
func (session *Session) Transmit(ctx context.Context, command []byte) ([]byte, uint16, error) {
if session == nil || session.card == nil || session.closed {
return nil, 0, errors.New("pcsc: card session is closed")
}
if raw, ok := session.card.(interface {
TransmitRaw(context.Context, []byte) ([]byte, uint16, error)
}); ok {
return raw.TransmitRaw(ctx, command)
}
return session.card.Transmit(ctx, command)
}
func (session *Session) Close() error {
return session.close(false)
}
// CloseWithReset resets the card while releasing the PC/SC connection. eUICC
// EnableProfile requires this refresh boundary before the newly enabled USIM
// application and ICCID become visible to subsequent callers.
func (session *Session) CloseWithReset() error {
return session.close(true)
}
func (session *Session) close(reset bool) error {
if session == nil || session.closed {
return nil
}
session.closed = true
var err error
if resetter, ok := session.card.(interface{ CloseWithReset() error }); reset && ok {
err = resetter.CloseWithReset()
} else {
err = session.card.Close()
}
session.service.mu.Unlock()
return err
}
func (service *Service) Snapshot(ctx context.Context, selector Selector, pin string) (Snapshot, error) {
readers, err := service.Readers(ctx)
if err != nil {
return Snapshot{}, err
}
reader, ok := matchReader(readers, selector)
if !ok {
return Snapshot{}, ErrReaderNotFound
}
result := Snapshot{Reader: reader}
if !reader.CardPresent {
return result, ErrNoCard
}
identity, err := service.ReadIdentity(ctx, selector, pin)
result.Identity = identity
return result, err
}
func (service *Service) ReadIdentity(ctx context.Context, selector Selector, pin string) (Identity, error) {
if service == nil || service.backend == nil {
return Identity{}, ErrUnavailable
}
if err := selector.validate(); err != nil {
return Identity{}, err
}
service.mu.Lock()
defer service.mu.Unlock()
card, err := service.backend.Open(ctx, selector)
if err != nil {
return Identity{}, err
}
defer card.Close()
return readIdentity(ctx, card, pin)
}
func (service *Service) CheckReady(
ctx context.Context,
selector Selector,
expectedICCID string,
pin string,
) (string, error) {
if service == nil || service.backend == nil {
return "", ErrUnavailable
}
service.mu.Lock()
defer service.mu.Unlock()
card, err := service.backend.Open(ctx, selector)
if err != nil {
return "", err
}
defer card.Close()
iccid, err := readICCID(ctx, card)
if err != nil {
return "", err
}
if expected := strings.TrimSpace(expectedICCID); expected != "" && !strings.EqualFold(expected, iccid) {
return "", ErrCardChanged
}
aid, err := selectUSIM(ctx, card)
if err != nil {
return "", err
}
if err := verifyPIN(ctx, card, pin); err != nil {
return "", err
}
return strings.ToUpper(hex.EncodeToString(aid)), nil
}
func (service *Service) Authenticate(
ctx context.Context,
selector Selector,
expectedICCID string,
pin string,
challenge AKAChallenge,
) (AKAResult, error) {
if service == nil || service.backend == nil {
return AKAResult{}, ErrUnavailable
}
service.mu.Lock()
defer service.mu.Unlock()
card, err := service.backend.Open(ctx, selector)
if err != nil {
return AKAResult{}, err
}
defer card.Close()
iccid, err := readICCID(ctx, card)
if err != nil {
return AKAResult{}, err
}
if expected := strings.TrimSpace(expectedICCID); expected != "" && !strings.EqualFold(expected, iccid) {
return AKAResult{}, ErrCardChanged
}
if _, err := selectUSIM(ctx, card); err != nil {
return AKAResult{}, err
}
if err := verifyPIN(ctx, card, pin); err != nil {
return AKAResult{}, err
}
apdu := make([]byte, 0, 40)
apdu = append(apdu, 0x00, 0x88, 0x00, 0x81, 0x22, 0x10)
apdu = append(apdu, challenge.RAND[:]...)
apdu = append(apdu, 0x10)
apdu = append(apdu, challenge.AUTN[:]...)
apdu = append(apdu, 0x00)
data, sw, err := card.Transmit(ctx, apdu)
if err != nil {
return AKAResult{}, errors.New("pcsc: USIM authentication transport failed")
}
if sw == 0x9862 {
return AKAResult{}, ErrAKARejected
}
if sw != 0x9000 {
return AKAResult{}, fmt.Errorf("pcsc: USIM authentication failed with status %04X", sw)
}
return parseAKAResponse(data)
}
func matchReader(readers []Reader, selector Selector) (Reader, bool) {
path := strings.TrimSpace(selector.USBPath)
name := strings.TrimSpace(selector.ReaderName)
for _, reader := range readers {
if path != "" && reader.USBPath == path {
return reader, true
}
}
for _, reader := range readers {
if name != "" && reader.Name == name {
return reader, true
}
}
return Reader{}, false
}
func readIdentity(ctx context.Context, card Card, pin string) (Identity, error) {
identity := Identity{PINTries: -1}
iccid, err := readICCID(ctx, card)
if err != nil {
return identity, err
}
identity.ICCID = iccid
aid, err := selectUSIM(ctx, card)
if err != nil {
return identity, err
}
identity.USIMAID = append([]byte(nil), aid...)
if err := verifyPIN(ctx, card, pin); err != nil {
identity.PINRequired = errors.Is(err, ErrPINRequired) || errors.Is(err, ErrPINTriesLow)
var pinErr *PINError
if errors.As(err, &pinErr) {
identity.PINTries = pinErr.Tries
}
return identity, err
}
if err := selectFile(ctx, card, []byte{0x6F, 0x07}); err != nil {
return identity, fmt.Errorf("pcsc: select EF_IMSI: %w", err)
}
imsiData, err := readBinary(ctx, card, 9)
if err != nil {
return identity, fmt.Errorf("pcsc: read EF_IMSI: %w", err)
}
identity.IMSI, err = decodeIMSI(imsiData)
if err != nil {
return identity, err
}
if _, selectErr := selectApplication(ctx, card, aid); selectErr == nil {
if selectErr = selectFile(ctx, card, []byte{0x6F, 0xAD}); selectErr == nil {
if data, readErr := readBinary(ctx, card, 4); readErr == nil && len(data) >= 4 {
length := int(data[3] & 0x0f)
if length == 2 || length == 3 {
identity.MNCLength = length
}
}
}
}
if _, selectErr := selectApplication(ctx, card, aid); selectErr == nil {
identity.SPN = readSPN(ctx, card)
}
if _, selectErr := selectApplication(ctx, card, aid); selectErr == nil {
identity.SMSC = readSMSC(ctx, card)
}
return identity, nil
}
func readICCID(ctx context.Context, card Card) (string, error) {
if err := selectMF(ctx, card); err != nil {
return "", err
}
if err := selectFile(ctx, card, []byte{0x2F, 0xE2}); err != nil {
return "", fmt.Errorf("pcsc: select EF_ICCID: %w", err)
}
data, err := readBinary(ctx, card, 10)
if err != nil {
return "", fmt.Errorf("pcsc: read EF_ICCID: %w", err)
}
value := decodeSwappedBCD(data, false)
if len(value) < 18 || len(value) > 22 {
return "", errors.New("pcsc: card returned an invalid ICCID")
}
return value, nil
}
func selectUSIM(ctx context.Context, card Card) ([]byte, error) {
if err := selectMF(ctx, card); err != nil {
return nil, err
}
if err := selectFile(ctx, card, []byte{0x2F, 0x00}); err != nil {
return nil, fmt.Errorf("pcsc: select EF_DIR: %w", err)
}
var usimAID []byte
for record := 1; record <= 32; record++ {
data, sw, err := card.Transmit(ctx, []byte{0x00, 0xB2, byte(record), 0x04, 0x00})
if err != nil {
return nil, err
}
if sw == 0x6A83 || sw == 0x9402 {
break
}
if sw != 0x9000 {
continue
}
aid := findTLV(data, 0x4F)
if len(aid) == 0 {
continue
}
if strings.HasPrefix(strings.ToUpper(hex.EncodeToString(aid)), usimAIDPrefix) {
usimAID = append([]byte(nil), aid...)
break
}
}
if len(usimAID) == 0 {
return nil, ErrUSIMUnavailable
}
if _, err := selectApplication(ctx, card, usimAID); err != nil {
return nil, err
}
return usimAID, nil
}
func selectMF(ctx context.Context, card Card) error {
_, sw, err := card.Transmit(ctx, []byte{0x00, 0xA4, 0x00, 0x04, 0x02, 0x3F, 0x00, 0x00})
return requireStatus("select MF", sw, err)
}
func selectFile(ctx context.Context, card Card, fileID []byte) error {
if len(fileID) != 2 {
return errors.New("pcsc: invalid file identifier")
}
apdu := []byte{0x00, 0xA4, 0x00, 0x04, 0x02, fileID[0], fileID[1], 0x00}
_, sw, err := card.Transmit(ctx, apdu)
return requireStatus("select file", sw, err)
}
func selectApplication(ctx context.Context, card Card, aid []byte) ([]byte, error) {
if len(aid) == 0 || len(aid) > 32 {
return nil, errors.New("pcsc: invalid USIM AID")
}
apdu := []byte{0x00, 0xA4, 0x04, 0x04, byte(len(aid))}
apdu = append(apdu, aid...)
apdu = append(apdu, 0x00)
data, sw, err := card.Transmit(ctx, apdu)
if err := requireStatus("select USIM application", sw, err); err != nil {
return nil, err
}
return data, nil
}
func readBinary(ctx context.Context, card Card, length int) ([]byte, error) {
if length <= 0 || length > 256 {
return nil, errors.New("pcsc: invalid binary read length")
}
le := byte(length)
if length == 256 {
le = 0
}
data, sw, err := card.Transmit(ctx, []byte{0x00, 0xB0, 0x00, 0x00, le})
if err := requireStatus("read binary", sw, err); err != nil {
return nil, err
}
return data, nil
}
func verifyPIN(ctx context.Context, card Card, pin string) error {
pin = strings.TrimSpace(pin)
if pin == "" {
return nil
}
if len(pin) < 4 || len(pin) > 8 || !decimalDigits(pin) {
return errors.New("pcsc: SIM PIN must contain 4 to 8 digits")
}
_, sw, err := card.Transmit(ctx, []byte{0x00, 0x20, 0x00, 0x01, 0x00})
if err != nil {
return errors.New("pcsc: SIM PIN status check failed")
}
if sw == 0x9000 {
return nil
}
tries := -1
if sw&0xFFF0 == 0x63C0 {
tries = int(sw & 0x000F)
if tries <= 2 {
return &PINError{Kind: ErrPINTriesLow, Tries: tries}
}
}
body := bytes.Repeat([]byte{0xFF}, 8)
copy(body, []byte(pin))
apdu := append([]byte{0x00, 0x20, 0x00, 0x01, 0x08}, body...)
_, sw, err = card.Transmit(ctx, apdu)
if err != nil {
return errors.New("pcsc: SIM PIN verification transport failed")
}
if sw == 0x9000 {
return nil
}
if sw&0xFFF0 == 0x63C0 {
return &PINError{Kind: ErrPINRejected, Tries: int(sw & 0x000F)}
}
return ErrPINRejected
}
func requireStatus(operation string, sw uint16, err error) error {
if err != nil {
return fmt.Errorf("pcsc: %s transport failed", operation)
}
if sw == 0x9000 {
return nil
}
if sw == 0x6982 || sw == 0x9804 {
return &PINError{Kind: ErrPINRequired, Tries: -1}
}
return fmt.Errorf("pcsc: %s failed with status %04X", operation, sw)
}
func decodeSwappedBCD(value []byte, dropFirstNibble bool) string {
var result strings.Builder
for _, octet := range value {
for _, nibble := range []byte{octet & 0x0F, octet >> 4} {
if dropFirstNibble {
dropFirstNibble = false
continue
}
if nibble == 0x0F {
return result.String()
}
if nibble > 9 {
return ""
}
result.WriteByte('0' + nibble)
}
}
return result.String()
}
func decodeIMSI(data []byte) (string, error) {
if len(data) < 2 {
return "", errors.New("pcsc: EF_IMSI is too short")
}
length := int(data[0])
if length <= 0 || length > len(data)-1 {
return "", errors.New("pcsc: EF_IMSI has an invalid length")
}
value := decodeSwappedBCD(data[1:1+length], true)
if len(value) < 10 || len(value) > 18 || !decimalDigits(value) {
return "", errors.New("pcsc: card returned an invalid IMSI")
}
return value, nil
}
func decimalDigits(value string) bool {
if value == "" {
return false
}
for _, character := range value {
if character < '0' || character > '9' {
return false
}
}
return true
}
func findTLV(data []byte, wanted byte) []byte {
for len(data) >= 2 {
tag := data[0]
data = data[1:]
length, consumed, ok := decodeTLVLength(data)
if !ok || consumed+length > len(data) {
return nil
}
value := data[consumed : consumed+length]
if tag == wanted {
return append([]byte(nil), value...)
}
if tag&0x20 != 0 {
if nested := findTLV(value, wanted); len(nested) > 0 {
return nested
}
}
data = data[consumed+length:]
}
return nil
}
func decodeTLVLength(data []byte) (length, consumed int, ok bool) {
if len(data) == 0 {
return 0, 0, false
}
if data[0]&0x80 == 0 {
return int(data[0]), 1, true
}
count := int(data[0] & 0x7F)
if count < 1 || count > 2 || len(data) < 1+count {
return 0, 0, false
}
length = 0
for _, octet := range data[1 : 1+count] {
length = length<<8 | int(octet)
}
return length, 1 + count, true
}
func parseAKAResponse(data []byte) (AKAResult, error) {
if len(data) < 2 {
return AKAResult{}, errors.New("pcsc: USIM returned a short AKA response")
}
switch data[0] {
case 0xDB:
res, rest, ok := takeLV(data[1:])
if !ok || len(res) < 4 || len(res) > 16 {
return AKAResult{}, errors.New("pcsc: USIM returned an invalid AKA RES")
}
ck, rest, ok := takeLV(rest)
if !ok || len(ck) != 16 {
return AKAResult{}, errors.New("pcsc: USIM returned an invalid AKA CK")
}
ik, rest, ok := takeLV(rest)
if !ok || len(ik) != 16 {
return AKAResult{}, errors.New("pcsc: USIM returned an invalid AKA IK")
}
if len(rest) > 0 {
kc, tail, valid := takeLV(rest)
if !valid || len(kc) != 8 || len(tail) != 0 {
return AKAResult{}, errors.New("pcsc: USIM returned invalid trailing AKA material")
}
}
return AKAResult{RES: append([]byte(nil), res...), CK: append([]byte(nil), ck...), IK: append([]byte(nil), ik...)}, nil
case 0xDC:
auts, tail, ok := takeLV(data[1:])
if !ok || len(auts) != 14 || len(tail) != 0 {
return AKAResult{}, errors.New("pcsc: USIM returned invalid AKA synchronization evidence")
}
return AKAResult{AUTS: append([]byte(nil), auts...), SynchronizationFailure: true}, nil
default:
return AKAResult{}, errors.New("pcsc: USIM returned an unsupported AKA response")
}
}
func takeLV(data []byte) (value, rest []byte, ok bool) {
if len(data) == 0 || int(data[0]) > len(data)-1 {
return nil, data, false
}
length := int(data[0])
return data[1 : 1+length], data[1+length:], true
}
func readSPN(ctx context.Context, card Card) string {
if err := selectFile(ctx, card, []byte{0x6F, 0x46}); err != nil {
return ""
}
data, err := readBinary(ctx, card, 17)
if err != nil || len(data) < 2 {
return ""
}
return strings.TrimSpace(strings.TrimRight(string(data[1:]), "\x00\xFF"))
}
func readSMSC(ctx context.Context, card Card) string {
if err := selectFile(ctx, card, []byte{0x6F, 0x42}); err != nil {
return ""
}
data, sw, err := card.Transmit(ctx, []byte{0x00, 0xB2, 0x01, 0x04, 0x00})
if err != nil || sw != 0x9000 || len(data) < 15 {
return ""
}
sca := data[len(data)-15 : len(data)-3]
if len(sca) < 2 || sca[0] < 2 || int(sca[0]) > len(sca)-1 {
return ""
}
digits := decodeSwappedBCD(sca[2:1+int(sca[0])], false)
if !decimalDigits(digits) {
return ""
}
if sca[1]&0x70 == 0x10 {
return "+" + digits
}
return digits
}
+84
View File
@@ -0,0 +1,84 @@
package pcsc
import (
"bytes"
"context"
"errors"
"testing"
)
type scriptedReply struct {
data []byte
sw uint16
}
type scriptedCard struct {
replies []scriptedReply
calls [][]byte
}
func (card *scriptedCard) Transmit(_ context.Context, command []byte) ([]byte, uint16, error) {
card.calls = append(card.calls, append([]byte(nil), command...))
if len(card.replies) == 0 {
return nil, 0, errors.New("unexpected APDU")
}
reply := card.replies[0]
card.replies = card.replies[1:]
return append([]byte(nil), reply.data...), reply.sw, nil
}
func (*scriptedCard) Close() error { return nil }
func TestDecodeIdentifiers(t *testing.T) {
if got := decodeSwappedBCD([]byte{0x98, 0x10, 0x32, 0x54, 0xF6}, false); got != "890123456" {
t.Fatalf("ICCID BCD = %q", got)
}
imsi, err := decodeIMSI([]byte{0x08, 0x19, 0x32, 0x54, 0x76, 0x98, 0x10, 0x32, 0x54})
if err != nil {
t.Fatal(err)
}
if imsi != "123456789012345" {
t.Fatalf("IMSI = %q", imsi)
}
}
func TestVerifyPINRefusesLowAttemptCount(t *testing.T) {
card := &scriptedCard{replies: []scriptedReply{{sw: 0x63C2}}}
err := verifyPIN(context.Background(), card, "1234")
if !errors.Is(err, ErrPINTriesLow) {
t.Fatalf("error = %v", err)
}
if len(card.calls) != 1 {
t.Fatalf("APDU calls = %d, PIN must not be submitted", len(card.calls))
}
}
func TestParseAKAResponse(t *testing.T) {
data := []byte{0xDB, 0x08, 1, 2, 3, 4, 5, 6, 7, 8, 0x10}
data = append(data, bytes.Repeat([]byte{0xAA}, 16)...)
data = append(data, 0x10)
data = append(data, bytes.Repeat([]byte{0xBB}, 16)...)
result, err := parseAKAResponse(data)
if err != nil {
t.Fatal(err)
}
if len(result.RES) != 8 || len(result.CK) != 16 || len(result.IK) != 16 || result.SynchronizationFailure {
t.Fatalf("unexpected AKA result: %#v", result)
}
syncResult, err := parseAKAResponse(append([]byte{0xDC, 0x0E}, bytes.Repeat([]byte{0xCC}, 14)...))
if err != nil {
t.Fatal(err)
}
if !syncResult.SynchronizationFailure || len(syncResult.AUTS) != 14 {
t.Fatalf("unexpected sync result: %#v", syncResult)
}
}
func TestDeviceIDUsesStableUSBPath(t *testing.T) {
a := DeviceID(Reader{Name: "reader 00 00", USBPath: "1-3"})
b := DeviceID(Reader{Name: "renamed reader", USBPath: "1-3"})
if a != b || a == "" {
t.Fatalf("device IDs = %q, %q", a, b)
}
}
+107
View File
@@ -0,0 +1,107 @@
package pcsc
import (
"context"
"errors"
"fmt"
"strings"
)
const HardwareKind = "pcsc"
var (
ErrUnsupported = errors.New("pcsc: platform is not supported")
ErrUnavailable = errors.New("pcsc: service is unavailable")
ErrReaderNotFound = errors.New("pcsc: reader not found")
ErrNoCard = errors.New("pcsc: no card is inserted")
ErrPINRequired = errors.New("pcsc: SIM PIN is required")
ErrPINTriesLow = errors.New("pcsc: refusing PIN verification because too few attempts remain")
ErrPINRejected = errors.New("pcsc: SIM PIN was rejected")
ErrUSIMUnavailable = errors.New("pcsc: no usable USIM application was found")
ErrCardChanged = errors.New("pcsc: card identity changed during authentication")
ErrAKARejected = errors.New("pcsc: USIM rejected the network authentication token")
)
type Reader struct {
Name string
USBPath string
VendorID string
ProductID string
Manufacturer string
Product string
CardPresent bool
ATR string
}
type Selector struct {
USBPath string
ReaderName string
}
func (selector Selector) validate() error {
if strings.TrimSpace(selector.USBPath) == "" && strings.TrimSpace(selector.ReaderName) == "" {
return errors.New("pcsc: reader selector is empty")
}
return nil
}
type Identity struct {
ICCID string
IMSI string
MNCLength int
USIMAID []byte
SMSC string
SPN string
PINRequired bool
PINTries int
}
type Snapshot struct {
Reader Reader
Identity Identity
}
type AKAChallenge struct {
RAND [16]byte
AUTN [16]byte
}
type AKAResult struct {
RES []byte
CK []byte
IK []byte
AUTS []byte
SynchronizationFailure bool
}
type PINError struct {
Kind error
Tries int
}
func (err *PINError) Error() string {
if err == nil {
return "pcsc: SIM PIN error"
}
if err.Tries >= 0 {
return fmt.Sprintf("%v (%d attempts remain)", err.Kind, err.Tries)
}
return err.Kind.Error()
}
func (err *PINError) Unwrap() error {
if err == nil {
return nil
}
return err.Kind
}
type Card interface {
Transmit(context.Context, []byte) ([]byte, uint16, error)
Close() error
}
type Backend interface {
Readers(context.Context) ([]Reader, error)
Open(context.Context, Selector) (Card, error)
}
@@ -11,7 +11,6 @@ import (
"errors"
"fmt"
"io"
"mime"
"net"
"net/http"
"net/mail"
@@ -57,7 +56,7 @@ func (s *Server) notifyAutomaticTask(ctx context.Context, task store.AutomaticTa
}, "\n"),
Time: run.FinishedAt, Task: task, Run: run,
}
for _, channel := range []string{"telegram", "bark", "email", "pushplus", "webhook"} {
for _, channel := range []string{"telegram", "bark", "email", "pushplus", "webhook", "wecom"} {
setting, err := s.store.NotificationSetting(ctx, channel)
if errors.Is(err, store.ErrNotFound) || (err == nil && !setting.Enabled) {
continue
@@ -89,6 +88,8 @@ func sendAutomaticTaskNotification(ctx context.Context, channel string, config m
return sendPushplusTextNotification(ctx, config, message.Title, message.Text)
case "webhook":
return sendAutomaticTaskWebhook(ctx, config, message)
case "wecom":
return sendWecomNotification(ctx, config, wecomAutomaticTaskValues(message))
default:
return fmt.Errorf("unsupported notification channel %q", channel)
}
@@ -290,12 +291,7 @@ func sendEmailTextNotification(ctx context.Context, config map[string]any, subje
if err != nil {
return err
}
email := strings.Join([]string{
"Date: " + time.Now().UTC().Format(time.RFC1123Z), "From: " + formatMailAddress(from),
"To: " + joinMailAddresses(recipients), "Subject: " + mime.QEncoding.Encode("UTF-8", subject),
"MIME-Version: 1.0", "Content-Type: text/plain; charset=UTF-8", "Content-Transfer-Encoding: 8bit", "", text, "",
}, "\r\n")
if _, err := io.WriteString(writer, email); err != nil {
if err := writePlainTextMail(writer, from, recipients, subject, text); err != nil {
_ = writer.Close()
return err
}
+230 -30
View File
@@ -37,6 +37,13 @@ type automaticTaskExecutionError struct {
func (value automaticTaskExecutionError) Error() string { return value.err.Error() }
func (value automaticTaskExecutionError) Unwrap() error { return value.err }
type automaticTaskProgress func(string)
type automaticTaskEnvironmentSnapshot struct {
config store.Device
policy store.CardPolicy
}
type automaticTaskScheduler struct {
server *Server
ctx context.Context
@@ -50,6 +57,14 @@ func (s *Server) StartAutomaticTasks(ctx context.Context) {
}
scheduler := &automaticTaskScheduler{server: s, ctx: ctx, queues: make(map[string]chan store.AutomaticTaskRun)}
s.automaticTasks = scheduler
queued, err := s.store.RecoverAutomaticTaskRuns(ctx, time.Now().UTC())
if err != nil {
s.logger.Warn("recover automatic tasks", "error", err)
} else {
for _, run := range queued {
scheduler.enqueue(run)
}
}
go scheduler.run()
}
@@ -117,9 +132,14 @@ func (scheduler *automaticTaskScheduler) execute(run store.AutomaticTaskRun) {
var output string
for attempt := 1; attempt <= task.RetryCount+1; attempt++ {
run.Attempts = attempt
run.Output = fmt.Sprintf("第 %d 次尝试:正在检查设备和 eSIM Profile", attempt)
_ = scheduler.server.store.UpdateAutomaticTaskRun(context.Background(), run)
progress := func(message string) {
run.Output = fmt.Sprintf("第 %d 次尝试:%s", attempt, message)
_ = scheduler.server.store.UpdateAutomaticTaskRun(context.Background(), run)
}
operationContext, cancel := context.WithTimeout(scheduler.ctx, automaticTaskMaxRuntime)
output, err = scheduler.server.executeAutomaticTask(operationContext, task)
output, err = scheduler.server.executeAutomaticTask(operationContext, task, progress)
cancel()
if err == nil {
break
@@ -129,7 +149,10 @@ func (scheduler *automaticTaskScheduler) execute(run store.AutomaticTaskRun) {
break
}
if attempt <= task.RetryCount {
scheduler.server.logger.Warn("automatic task attempt failed", "task_id", task.ID, "device_id", task.DeviceID, "attempt", attempt, "error", err)
// A device error may contain the full AT command, including APN
// credentials. The persisted run retains a user-facing outcome; logs
// contain only non-sensitive execution metadata.
scheduler.server.logger.Warn("automatic task attempt failed", "task_id", task.ID, "device_id", task.DeviceID, "attempt", attempt)
select {
case <-scheduler.ctx.Done():
break
@@ -151,13 +174,35 @@ func (scheduler *automaticTaskScheduler) execute(run store.AutomaticTaskRun) {
}
}
func (s *Server) executeAutomaticTask(ctx context.Context, task store.AutomaticTask) (string, error) {
config, entry, physicalID, err := s.ensureAutomaticTaskProfile(ctx, task)
func (s *Server) executeAutomaticTask(ctx context.Context, task store.AutomaticTask, progress automaticTaskProgress) (output string, err error) {
progress("正在检查设备和 eSIM Profile")
config, entry, physicalID, err := s.ensureAutomaticTaskProfile(ctx, task, progress)
if err != nil {
return "", err
}
networkWasEnabled := config.NetworkEnabled
if err := s.prepareAutomaticTaskEnvironment(ctx, &config, entry, physicalID, task); err != nil {
iccid := strings.TrimSpace(task.ProfileICCID)
policy, policyErr := s.store.CardPolicy(ctx, iccid)
if errors.Is(policyErr, store.ErrNotFound) {
policy = defaultCardPolicy(iccid)
} else if policyErr != nil {
return "", fmt.Errorf("read saved card policy: %w", policyErr)
}
snapshot := automaticTaskEnvironmentSnapshot{config: config, policy: policy}
actionCompleted := false
defer func() {
progress("正在恢复该 Profile 原先保存的卡策略")
if restoreErr := s.restoreAutomaticTaskEnvironment(physicalID, snapshot); restoreErr != nil {
if err == nil && actionCompleted {
output = ""
err = automaticTaskExecutionError{err: fmt.Errorf("task completed but card policy restoration failed: %w", restoreErr), retryable: false}
} else if err == nil {
err = fmt.Errorf("restore card policy: %w", restoreErr)
} else {
err = fmt.Errorf("%w; card policy restoration also failed: %v", err, restoreErr)
}
}
}()
if err := s.prepareAutomaticTaskEnvironment(ctx, &config, entry, physicalID, task, progress); err != nil {
return "", err
}
var payload automaticTaskPayload
@@ -166,21 +211,29 @@ func (s *Server) executeAutomaticTask(ctx context.Context, task store.AutomaticT
}
switch task.TaskType {
case "sms":
return s.executeAutomaticSMS(ctx, task, payload)
progress("正在发送短信")
output, err = s.executeAutomaticSMS(ctx, task, payload)
case "call":
return s.executeAutomaticCall(ctx, task, payload)
progress("正在发起通话")
output, err = s.executeAutomaticCall(ctx, task, payload)
case "public_ip":
return s.executeAutomaticPublicIP(ctx, config, physicalID, task.ProfileICCID, networkWasEnabled)
progress("蜂窝数据已连接,正在查询漫游公网 IP")
output, err = s.executeAutomaticPublicIP(ctx, config, task.ProfileICCID)
default:
return "", fmt.Errorf("unsupported automatic task type %q", task.TaskType)
}
actionCompleted = err == nil
return output, err
}
func (s *Server) ensureAutomaticTaskProfile(ctx context.Context, task store.AutomaticTask) (store.Device, device.Device, string, error) {
func (s *Server) ensureAutomaticTaskProfile(ctx context.Context, task store.AutomaticTask, progress automaticTaskProgress) (store.Device, device.Device, string, error) {
config, err := s.store.Device(ctx, task.DeviceID)
if err != nil {
return store.Device{}, device.Device{}, "", fmt.Errorf("read device: %w", err)
}
if err := validateAutomaticTaskDeviceCapabilities(config, task.TaskType, task.Environment); err != nil {
return store.Device{}, device.Device{}, "", err
}
entry, physicalID, present := s.physicalForConfig(config)
if !present || entry.Snapshot == nil {
return store.Device{}, device.Device{}, "", errors.New("configured device is offline")
@@ -188,6 +241,7 @@ func (s *Server) ensureAutomaticTaskProfile(ctx context.Context, task store.Auto
if strings.EqualFold(strings.TrimSpace(entry.Snapshot.ICCID), strings.TrimSpace(task.ProfileICCID)) {
return config, entry, physicalID, nil
}
progress("正在切换到任务指定的 eSIM Profile")
if _, err := s.devices.SetFlight(ctx, physicalID, true); err != nil {
return store.Device{}, device.Device{}, "", fmt.Errorf("enter airplane mode before profile switch: %w", err)
}
@@ -209,9 +263,10 @@ func (s *Server) ensureAutomaticTaskProfile(ctx context.Context, task store.Auto
return config, entry, physicalID, nil
}
func (s *Server) prepareAutomaticTaskEnvironment(ctx context.Context, config *store.Device, entry device.Device, physicalID string, task store.AutomaticTask) error {
func (s *Server) prepareAutomaticTaskEnvironment(ctx context.Context, config *store.Device, entry device.Device, physicalID string, task store.AutomaticTask, progress automaticTaskProgress) error {
iccid := strings.TrimSpace(task.ProfileICCID)
if task.Environment == "vowifi" {
progress("正在准备 VoWiFi 执行环境")
if task.TaskType == "public_ip" {
return errors.New("public IP tasks cannot run over VoWiFi")
}
@@ -222,7 +277,19 @@ func (s *Server) prepareAutomaticTaskEnvironment(ctx context.Context, config *st
if err := s.store.UpsertDevice(ctx, *config); err != nil {
return err
}
if err := s.store.UpsertCardPolicy(ctx, store.CardPolicy{ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true, IPVersion: "IPV4V6", Source: "automatic_task"}); err != nil {
policy, policyErr := s.store.CardPolicy(ctx, iccid)
if errors.Is(policyErr, store.ErrNotFound) {
policy = defaultCardPolicy(iccid)
policyErr = nil
}
if policyErr != nil {
return policyErr
}
policy.NetworkEnabled = false
policy.VoWiFiEnabled = true
policy.AirplaneEnabled = true
policy.Source = "automatic_task"
if err := s.store.UpsertCardPolicy(ctx, policy); err != nil {
return err
}
if s.vowifi == nil {
@@ -253,17 +320,29 @@ func (s *Server) prepareAutomaticTaskEnvironment(ctx context.Context, config *st
}
}
}
progress("正在开启蜂窝无线并启用自动选网")
config.VoWiFiEnabled = false
config.NetworkEnabled = task.TaskType == "public_ip"
if err := s.store.UpsertDevice(ctx, *config); err != nil {
return err
}
if err := s.store.UpsertCardPolicy(ctx, store.CardPolicy{ICCID: iccid, NetworkEnabled: config.NetworkEnabled, VoWiFiEnabled: false, AirplaneEnabled: false, APN: config.APN, IPVersion: "IPV4V6", Source: "automatic_task"}); err != nil {
policy, policyErr := s.store.CardPolicy(ctx, iccid)
if errors.Is(policyErr, store.ErrNotFound) {
policy = defaultCardPolicy(iccid)
policy.APN = config.APN
} else if policyErr != nil {
return policyErr
}
policy.NetworkEnabled = config.NetworkEnabled
policy.VoWiFiEnabled = false
policy.AirplaneEnabled = false
policy.Source = "automatic_task"
if err := s.store.UpsertCardPolicy(ctx, policy); err != nil {
return err
}
if task.TaskType != "public_ip" {
if _, err := s.devices.SetNetwork(ctx, physicalID, device.NetworkRequest{Enabled: false, APN: config.APN, IPVersion: "IPV4V6", Backend: config.DeviceBackend}); err != nil {
s.logger.Warn("automatic task could not stop unused cellular data", "device_id", config.ID, "error", err)
if _, err := s.devices.SetNetwork(ctx, physicalID, s.cardNetworkRequest(ctx, physicalID, *config, policy, false)); err != nil {
s.logger.Warn("automatic task could not stop unused cellular data", "device_id", config.ID)
}
}
if _, err := s.devices.SetFlight(ctx, physicalID, false); err != nil {
@@ -275,6 +354,7 @@ func (s *Server) prepareAutomaticTaskEnvironment(ctx context.Context, config *st
if _, err := s.devices.ReRegisterOperator(ctx, physicalID); err != nil {
return fmt.Errorf("re-register cellular network: %w", err)
}
progress("正在搜索并注册蜂窝网络(漫游注册可能需要数分钟)")
if err := s.waitAutomaticCellular(ctx, physicalID, task.TaskType == "public_ip"); err != nil {
return err
}
@@ -282,8 +362,8 @@ func (s *Server) prepareAutomaticTaskEnvironment(ctx context.Context, config *st
if !s.developerActive(ctx) {
return errors.New("roaming public IP tasks require developer mode")
}
if _, err := s.devices.SetNetwork(ctx, physicalID, device.NetworkRequest{Enabled: true, APN: config.APN, IPVersion: "IPV4V6", Backend: config.DeviceBackend}); err != nil {
s.rollbackAutomaticNetwork(config.ID, physicalID, iccid, *config)
progress("已注册蜂窝网络,正在建立数据连接")
if _, err := s.devices.SetNetwork(ctx, physicalID, s.cardNetworkRequest(ctx, physicalID, *config, policy, true)); err != nil {
return fmt.Errorf("start roaming data: %w", err)
}
}
@@ -407,10 +487,7 @@ func (s *Server) executeAutomaticCall(ctx context.Context, task store.AutomaticT
return fmt.Sprintf("已拨打 %s,将在 %d 秒后自动挂断", payload.Phone, payload.DurationSeconds), nil
}
func (s *Server) executeAutomaticPublicIP(ctx context.Context, config store.Device, physicalID, iccid string, networkWasEnabled bool) (string, error) {
if !networkWasEnabled {
defer s.rollbackAutomaticNetwork(config.ID, physicalID, iccid, config)
}
func (s *Server) executeAutomaticPublicIP(ctx context.Context, config store.Device, iccid string) (string, error) {
if strings.TrimSpace(config.Interface) == "" {
return "", errors.New("device has no cellular network interface")
}
@@ -422,19 +499,116 @@ func (s *Server) executeAutomaticPublicIP(ctx context.Context, config store.Devi
return strings.TrimSpace(fmt.Sprintf("公网 IP %s · %s %s", info.IP, info.CountryCode, info.Region)), nil
}
func (s *Server) rollbackAutomaticNetwork(deviceID, physicalID, iccid string, config store.Device) {
cleanupContext, cancel := context.WithTimeout(context.Background(), 30*time.Second)
func (s *Server) restoreAutomaticTaskEnvironment(physicalID string, snapshot automaticTaskEnvironmentSnapshot) error {
cleanupContext, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
if _, err := s.devices.SetNetwork(cleanupContext, physicalID, device.NetworkRequest{Enabled: false, APN: config.APN, IPVersion: "IPV4V6", Backend: config.DeviceBackend}); err != nil {
s.logger.Warn("stop one-shot automatic roaming data", "device_id", deviceID, "error", err)
config, policy := snapshot.config, snapshot.policy
desiredNetwork := policy.NetworkEnabled && !policy.VoWiFiEnabled && !policy.AirplaneEnabled
config.APN = policy.APN
config.NetworkEnabled = desiredNetwork
config.VoWiFiEnabled = policy.VoWiFiEnabled
var restoreErrors []error
if err := s.store.UpsertCardPolicy(cleanupContext, policy); err != nil {
restoreErrors = append(restoreErrors, fmt.Errorf("persist card policy: %w", err))
}
config.NetworkEnabled = false
if err := s.store.UpsertDevice(cleanupContext, config); err != nil {
s.logger.Warn("restore automatic roaming data setting", "device_id", deviceID, "error", err)
restoreErrors = append(restoreErrors, fmt.Errorf("persist device policy: %w", err))
}
if err := s.store.UpsertCardPolicy(cleanupContext, store.CardPolicy{ICCID: iccid, NetworkEnabled: false, VoWiFiEnabled: false, AirplaneEnabled: false, APN: config.APN, IPVersion: "IPV4V6", Source: "automatic_task"}); err != nil {
s.logger.Warn("restore automatic roaming card policy", "device_id", deviceID, "error", err)
if config.DeviceType == store.DeviceTypeUSBSIMReader {
if s.vowifi == nil {
return errors.Join(append(restoreErrors, errors.New("VoWiFi runtime is unavailable"))...)
}
state, stateErr := s.vowifi.State(config.ID)
if policy.VoWiFiEnabled {
if stateErr == nil && state.Enabled {
_, stateErr = s.vowifi.RequestReconnect(config.ID)
} else {
_, stateErr = s.vowifi.RequestEnabled(config.ID, true)
}
} else if stateErr == nil && (state.Enabled || state.Active) {
_, stateErr = s.vowifi.RequestEnabled(config.ID, false)
}
if stateErr != nil {
restoreErrors = append(restoreErrors, fmt.Errorf("restore reader VoWiFi: %w", stateErr))
}
return errors.Join(restoreErrors...)
}
if policy.VoWiFiEnabled {
if _, err := s.devices.SetNetwork(cleanupContext, physicalID, s.cardNetworkRequest(cleanupContext, physicalID, config, policy, false)); err != nil {
restoreErrors = append(restoreErrors, fmt.Errorf("stop cellular data: %w", err))
}
if _, err := s.devices.SetFlight(cleanupContext, physicalID, true); err != nil {
restoreErrors = append(restoreErrors, fmt.Errorf("restore airplane mode: %w", err))
}
if s.vowifi == nil {
restoreErrors = append(restoreErrors, errors.New("VoWiFi runtime is unavailable"))
} else if state, stateErr := s.vowifi.State(config.ID); stateErr == nil && state.Enabled {
if _, err := s.vowifi.RequestReconnect(config.ID); err != nil {
restoreErrors = append(restoreErrors, fmt.Errorf("restore VoWiFi: %w", err))
}
} else if _, err := s.vowifi.RequestEnabled(config.ID, true); err != nil {
restoreErrors = append(restoreErrors, fmt.Errorf("restore VoWiFi: %w", err))
}
return errors.Join(restoreErrors...)
}
if s.vowifi != nil {
if state, stateErr := s.vowifi.State(config.ID); stateErr == nil && (state.Enabled || state.Active) {
if _, err := s.vowifi.RequestEnabled(config.ID, false); err != nil {
restoreErrors = append(restoreErrors, fmt.Errorf("stop VoWiFi: %w", err))
}
}
}
if policy.AirplaneEnabled {
if _, err := s.devices.SetNetwork(cleanupContext, physicalID, s.cardNetworkRequest(cleanupContext, physicalID, config, policy, false)); err != nil {
restoreErrors = append(restoreErrors, fmt.Errorf("stop cellular data: %w", err))
}
if _, err := s.devices.SetFlight(cleanupContext, physicalID, true); err != nil {
restoreErrors = append(restoreErrors, fmt.Errorf("restore airplane mode: %w", err))
}
return errors.Join(restoreErrors...)
}
if !desiredNetwork {
if _, err := s.devices.SetNetwork(cleanupContext, physicalID, s.cardNetworkRequest(cleanupContext, physicalID, config, policy, false)); err != nil {
restoreErrors = append(restoreErrors, fmt.Errorf("stop cellular data: %w", err))
}
}
if _, err := s.devices.SetFlight(cleanupContext, physicalID, false); err != nil {
restoreErrors = append(restoreErrors, fmt.Errorf("restore cellular radio: %w", err))
}
if desiredNetwork {
if _, err := s.devices.SetNetwork(cleanupContext, physicalID, s.cardNetworkRequest(cleanupContext, physicalID, config, policy, true)); err != nil {
restoreErrors = append(restoreErrors, fmt.Errorf("restore cellular data: %w", err))
}
}
return errors.Join(restoreErrors...)
}
func (s *Server) cardNetworkRequest(
ctx context.Context,
physicalID string,
config store.Device,
policy store.CardPolicy,
enabled bool,
) device.NetworkRequest {
request := device.NetworkRequest{
Enabled: enabled, APN: policy.APN, IPVersion: policy.IPVersion, Backend: config.DeviceBackend,
}
if request.IPVersion == "" {
request.IPVersion = "IPV4V6"
}
profile, err := s.store.CardAPNProfileByAPN(ctx, policy.ICCID, policy.APN, policy.IPVersion)
if err != nil {
return request
}
request.Username = profile.Username
request.Password = profile.Password
request.Authentication = profile.AuthType
if entry, getErr := s.devices.Get(physicalID); getErr == nil && entry.Snapshot != nil &&
entry.Snapshot.RegistrationStatus == 5 && profile.RoamingIPVersion != "" {
request.IPVersion = profile.RoamingIPVersion
}
return request
}
func compactAutomaticResponse(body []byte) string {
@@ -558,6 +732,15 @@ func (s *Server) handleAutomaticTaskRunNow(w http.ResponseWriter, r *http.Reques
s.writeStoreError(w, err)
return
}
config, err := s.store.Device(r.Context(), task.DeviceID)
if err != nil {
s.writeStoreError(w, err)
return
}
if err := validateAutomaticTaskDeviceCapabilities(config, task.TaskType, task.Environment); err != nil {
writeError(w, http.StatusConflict, "wifi_calling_only_device", err.Error())
return
}
run, err := s.store.QueueAutomaticTaskNow(r.Context(), task)
if err != nil {
s.writeStoreError(w, err)
@@ -593,7 +776,8 @@ func (s *Server) decodeAutomaticTask(r *http.Request, id int64) (store.Automatic
if request.Name == "" || request.DeviceID == "" || request.ProfileICCID == "" {
return store.AutomaticTask{}, errors.New("name, device, and eSIM profile are required")
}
if _, err := s.store.Device(r.Context(), request.DeviceID); err != nil {
selectedDevice, err := s.store.Device(r.Context(), request.DeviceID)
if err != nil {
return store.AutomaticTask{}, errors.New("selected device does not exist")
}
if request.Environment != "vowifi" && request.Environment != "cellular" {
@@ -605,6 +789,9 @@ func (s *Server) decodeAutomaticTask(r *http.Request, id int64) (store.Automatic
if request.TaskType == "public_ip" && request.Environment != "cellular" {
return store.AutomaticTask{}, errors.New("public IP tasks must use cellular direct mode")
}
if err := validateAutomaticTaskDeviceCapabilities(selectedDevice, request.TaskType, request.Environment); err != nil {
return store.AutomaticTask{}, err
}
if request.IntervalDays < 1 || request.IntervalDays > 365 || request.RetryCount < 0 || request.RetryCount > 10 {
return store.AutomaticTask{}, errors.New("interval_days must be 1-365 and retry_count must be 0-10")
}
@@ -644,6 +831,19 @@ func (s *Server) decodeAutomaticTask(r *http.Request, id int64) (store.Automatic
return task, nil
}
func validateAutomaticTaskDeviceCapabilities(config store.Device, taskType, environment string) error {
if config.DeviceType != store.DeviceTypeUSBSIMReader {
return nil
}
if environment != "vowifi" {
return errors.New("USB SIM reader tasks must use the VoWiFi environment")
}
if taskType != "sms" && taskType != "call" {
return errors.New("USB SIM readers support only VoWiFi SMS and call tasks")
}
return nil
}
func nextAutomaticRun(date, clock string, intervalDays int, now time.Time) (time.Time, error) {
location := now.Location()
start, err := time.ParseInLocation("2006-01-02 15:04", strings.TrimSpace(date)+" "+strings.TrimSpace(clock), location)
+21
View File
@@ -3,6 +3,8 @@ package server
import (
"testing"
"time"
"vocat/internal/store"
)
func TestNextAutomaticRunUsesIntervalAndLocalClock(t *testing.T) {
@@ -18,6 +20,25 @@ func TestNextAutomaticRunUsesIntervalAndLocalClock(t *testing.T) {
}
}
func TestUSBSIMReaderAutomaticTasksRequireVoWiFi(t *testing.T) {
reader := store.Device{DeviceType: store.DeviceTypeUSBSIMReader}
for _, test := range []struct {
taskType string
environment string
wantError bool
}{
{taskType: "sms", environment: "vowifi"},
{taskType: "call", environment: "vowifi"},
{taskType: "sms", environment: "cellular", wantError: true},
{taskType: "public_ip", environment: "cellular", wantError: true},
} {
err := validateAutomaticTaskDeviceCapabilities(reader, test.taskType, test.environment)
if (err != nil) != test.wantError {
t.Errorf("type=%s environment=%s error=%v", test.taskType, test.environment, err)
}
}
}
func TestAutomaticSMSRetrySafetyPreventsDuplicateSubmission(t *testing.T) {
unsafe := []byte(`{"data":{"parts_attempted":1,"parts_accepted":1,"retry_safe":false}}`)
if automaticSMSRetrySafe(unsafe) {
+198 -20
View File
@@ -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(),
+21
View File
@@ -115,3 +115,24 @@ func TestConfiguredDeviceSummaryMarksIdleRuntimeAsNotInUse(t *testing.T) {
t.Fatalf("summary = %#v", got)
}
}
func TestSnapshotHasSIMDoesNotTreatUnknownStatusAsInserted(t *testing.T) {
for _, snapshot := range []*device.Snapshot{
{IMEI: "867123456789012"},
{IMEI: "867123456789012", SIMStatus: "unknown"},
{IMEI: "867123456789012", SIMStatus: "not_inserted"},
} {
if snapshotHasSIM(snapshot) {
t.Fatalf("snapshot was reported with a SIM: %#v", snapshot)
}
}
for _, snapshot := range []*device.Snapshot{
{SIMStatus: "pin_required"},
{ICCID: "89441000400128014257"},
{SIMReady: true},
} {
if !snapshotHasSIM(snapshot) {
t.Fatalf("snapshot was reported without a SIM: %#v", snapshot)
}
}
}
+98
View File
@@ -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")
}
+75
View File
@@ -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
View File
@@ -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) {
+21
View File
@@ -1,11 +1,13 @@
package server
import (
"context"
"errors"
"net/http"
"strings"
"vocat/internal/exportproxy"
"vocat/internal/store"
)
func (s *Server) routeExportProxyAPI(w http.ResponseWriter, r *http.Request, cleanPath string) bool {
@@ -33,6 +35,9 @@ func (s *Server) routeExportProxyAPI(w http.ResponseWriter, r *http.Request, cle
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return true
}
if s.rejectUnsupportedExportProxyDevice(w, r.Context(), config.DeviceID) {
return true
}
created, err := s.exportProxy.Create(r.Context(), config)
if err != nil {
s.writeExportProxyError(w, err)
@@ -71,6 +76,9 @@ func (s *Server) routeExportProxyAPI(w http.ResponseWriter, r *http.Request, cle
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return true
}
if s.rejectUnsupportedExportProxyDevice(w, r.Context(), config.DeviceID) {
return true
}
updated, err := s.exportProxy.Update(r.Context(), id, config)
if err != nil {
s.writeExportProxyError(w, err)
@@ -90,6 +98,19 @@ func (s *Server) routeExportProxyAPI(w http.ResponseWriter, r *http.Request, cle
return true
}
func (s *Server) rejectUnsupportedExportProxyDevice(w http.ResponseWriter, ctx context.Context, deviceID string) bool {
config, err := s.store.Device(ctx, strings.TrimSpace(deviceID))
if err != nil {
s.writeStoreError(w, err)
return true
}
if config.DeviceType == store.DeviceTypeUSBSIMReader {
writeError(w, http.StatusConflict, "wifi_calling_only_device", "USB SIM readers cannot export cellular data as a proxy")
return true
}
return false
}
func (s *Server) writeExportProxyError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, exportproxy.ErrDisabled):
@@ -0,0 +1,31 @@
package server
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"vocat/internal/store"
)
func TestExportProxyRejectsUSBSIMReader(t *testing.T) {
database, err := store.Open(context.Background(), ":memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
if err := database.UpsertDevice(context.Background(), store.Device{
ID: "reader-1", Name: "USB SIM Reader", DeviceType: store.DeviceTypeUSBSIMReader,
}); err != nil {
t.Fatal(err)
}
server := &Server{store: database}
response := httptest.NewRecorder()
if !server.rejectUnsupportedExportProxyDevice(response, context.Background(), "reader-1") {
t.Fatal("reader was accepted as an export-proxy device")
}
if response.Code != http.StatusConflict {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
}
+25
View File
@@ -93,3 +93,28 @@ func TestProfileProxyBindingRejectsSameICCIDOnDifferentProxy(t *testing.T) {
t.Fatalf("binding after rejected rebind = %+v, %v", binding, err)
}
}
func TestProfileProxyBindingSupportsPCSCReader(t *testing.T) {
server, database, controller := newProfileBindingTestServer(t)
readerICCID := "89104100000028106378"
if err := database.UpsertDevice(context.Background(), store.Device{
ID: "reader-1", Name: "USB SIM Reader", DeviceType: store.DeviceTypeUSBSIMReader,
}); err != nil {
t.Fatal(err)
}
controller.state = vowifi.State{DeviceID: "reader-1", ICCID: readerICCID, Enabled: true}
response := profileBindingRequest(t, server, http.MethodPost, `{
"upstream_proxy_id":"route-1",
"bindings":[{"device_id":"reader-1","iccid":"89104100000028106378","profile_name":"Reader Profile"}]
}`)
if response.Code != http.StatusOK {
t.Fatalf("POST status = %d, body = %s", response.Code, response.Body.String())
}
binding, err := database.DeviceProxyBinding(context.Background(), readerICCID)
if err != nil || binding.DeviceID != "reader-1" || binding.UpstreamProxyID != "route-1" {
t.Fatalf("reader binding = %+v, %v", binding, err)
}
if controller.reconnects != 1 {
t.Fatalf("reader reconnects = %d, want 1", controller.reconnects)
}
}
-7
View File
@@ -562,13 +562,6 @@ func (w *statusWriter) WriteHeader(status int) {
w.ResponseWriter.WriteHeader(status)
}
func (w *statusWriter) Write(data []byte) (int, error) {
if w.status == 0 {
w.WriteHeader(http.StatusOK)
}
return w.ResponseWriter.Write(data)
}
func (s *Server) logRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
startedAt := time.Now()
+410 -74
View File
@@ -11,7 +11,6 @@ import (
"errors"
"fmt"
"io"
"mime"
"net"
"net/http"
"net/mail"
@@ -25,6 +24,7 @@ import (
"sync"
"time"
"vocat/internal/device"
"vocat/internal/store"
)
@@ -40,6 +40,7 @@ var notificationChannels = []string{
"webhook",
"bark",
"pushplus",
"wecom",
}
var notificationFields = map[string]map[string]string{
@@ -61,6 +62,9 @@ var notificationFields = map[string]map[string]string{
"pushplus": {
"token": "string", "topic": "string", "channel": "string",
},
"wecom": {
"urls": "strings", "payload_template": "string",
},
}
// routeSettingsAPI is intentionally independent of the main router so it can
@@ -100,6 +104,14 @@ func (s *Server) routeSettingsAPI(
s.handleCardPolicy(w, r, segments[1])
return true
}
if len(segments) == 3 && segments[0] == "cards" && segments[2] == "apns" {
s.handleCardAPNProfiles(w, r, segments[1], "")
return true
}
if len(segments) == 4 && segments[0] == "cards" && segments[2] == "apns" {
s.handleCardAPNProfiles(w, r, segments[1], segments[3])
return true
}
return false
}
@@ -283,6 +295,11 @@ func validateNotificationField(
return fmt.Errorf("%s is not a valid email address", field)
}
}
if channel == "wecom" && name == "payload_template" && value != "" {
if _, err := renderWecomPayload(value, wecomTestValues(time.Unix(0, 0))); err != nil {
return fmt.Errorf("%s is not a valid JSON template: %w", field, err)
}
}
case "integer":
var value int
if err := json.Unmarshal(raw, &value); err != nil {
@@ -316,6 +333,9 @@ func validateNotificationField(
return fmt.Errorf("%s contains an invalid value", field)
}
if name == "urls" {
if channel == "wecom" && value == store.SecretMask {
continue
}
if _, err := parseOutboundURL(value, false); err != nil {
return fmt.Errorf("%s contains an invalid HTTP URL", field)
}
@@ -367,7 +387,7 @@ func (s *Server) handleNotificationTest(
writeError(w, http.StatusNotFound, "not_found", "notification channel was not found")
return
}
if channel != "webhook" && channel != "telegram" && channel != "email" && channel != "bark" {
if channel != "webhook" && channel != "telegram" && channel != "email" && channel != "bark" && channel != "wecom" {
writeError(
w,
http.StatusNotImplemented,
@@ -418,6 +438,8 @@ func (s *Server) handleNotificationTest(
err = sendEmailNotificationTest(r.Context(), resolved)
case "bark":
err = sendBarkNotificationTest(r.Context(), resolved)
case "wecom":
err = sendWecomNotificationTest(r.Context(), resolved)
}
if err != nil {
redacted := store.RedactText(err.Error(), provider)
@@ -495,9 +517,7 @@ func (s *Server) resolveNotificationTestConfig(
}
for key, value := range overlay {
if _, secret := sensitive[key]; secret {
if text, ok := value.(string); !ok || text == "" || text == store.SecretMask {
continue
}
value = mergeNotificationTestSecretValue(value, resolved[key])
}
resolved[key] = value
}
@@ -523,6 +543,37 @@ func (s *Server) resolveNotificationTestConfig(
return resolved, provider, nil
}
// mergeNotificationTestSecretValue preserves masked values submitted by the
// settings form while allowing newly entered sensitive values in the same
// request. WeCom URLs are a sensitive list, unlike the string-based secrets
// used by the other notification channels.
func mergeNotificationTestSecretValue(incoming, existing any) any {
if incoming == nil {
return existing
}
switch next := incoming.(type) {
case string:
if next == "" || next == store.SecretMask {
return existing
}
case []any:
previous, ok := existing.([]any)
if !ok {
return incoming
}
merged := make([]any, len(next))
for index, value := range next {
if index < len(previous) {
merged[index] = mergeNotificationTestSecretValue(value, previous[index])
} else {
merged[index] = value
}
}
return merged
}
return incoming
}
func validateNotificationTestConfig(channel string, config map[string]any) error {
switch channel {
case "webhook":
@@ -541,6 +592,8 @@ func validateNotificationTestConfig(channel string, config map[string]any) error
if len(urls) > 8 {
return errors.New("bark test is limited to 8 URLs")
}
case "wecom":
return validateWecomNotificationConfig(config)
case "telegram":
token := configString(config, "bot_token")
if token == "" || token == store.SecretMask {
@@ -788,18 +841,13 @@ func sendEmailNotificationTest(ctx context.Context, config map[string]any) error
if err != nil {
return fmt.Errorf("%w: SMTP message rejected", errProviderRejected)
}
message := strings.Join([]string{
"Date: " + time.Now().UTC().Format(time.RFC1123Z),
"From: " + formatMailAddress(from),
"To: " + joinMailAddresses(recipients),
"Subject: vocat notification test",
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=UTF-8",
"",
"This is a vocat notification test.",
"",
}, "\r\n")
if _, err := io.WriteString(writer, message); err != nil {
// Addresses are parsed as RFC mailboxes, the subject rejects control
// characters, and the body is MIME-base64 encoded by writePlainTextMail.
// CodeQL's email-injection query has no sanitizer model for these steps.
// Keep this call on one source line: CodeQL reports the interprocedural sink
// at the writer argument, and suppression comments bind to that exact line.
// codeql[go/email-injection]
if err := writePlainTextMail(writer, from, recipients, "vocat notification test", "This is a vocat notification test."); err != nil {
_ = writer.Close()
return fmt.Errorf("write SMTP test message: %w", err)
}
@@ -812,14 +860,6 @@ func sendEmailNotificationTest(ctx context.Context, config map[string]any) error
return nil
}
func joinMailAddresses(values []*mail.Address) string {
result := make([]string, 0, len(values))
for _, value := range values {
result = append(result, formatMailAddress(value))
}
return strings.Join(result, ", ")
}
func parseMailAddress(value string) (*mail.Address, error) {
value = strings.TrimSpace(value)
if value == "" || strings.ContainsAny(value, "\r\n\x00") {
@@ -841,7 +881,7 @@ func formatMailAddress(address *mail.Address) string {
if address.Name == "" {
return address.Address
}
return mime.QEncoding.Encode("UTF-8", address.Name) + " <" + address.Address + ">"
return (&mail.Address{Name: address.Name, Address: address.Address}).String()
}
func restrictedHTTPClient(
@@ -1217,13 +1257,7 @@ func (s *Server) handleCardPolicy(w http.ResponseWriter, r *http.Request, iccid
case http.MethodGet:
policy, err := s.store.CardPolicy(r.Context(), iccid)
if errors.Is(err, store.ErrNotFound) {
policy = store.CardPolicy{
ICCID: iccid,
VoWiFiEnabled: true,
AirplaneEnabled: true,
IPVersion: "IPV4V6",
Source: "default",
}
policy = defaultCardPolicy(iccid)
} else if err != nil {
s.writeStoreError(w, err)
return
@@ -1238,65 +1272,87 @@ func (s *Server) handleCardPolicy(w http.ResponseWriter, r *http.Request, iccid
writeJSON(w, http.StatusOK, map[string]any{"data": cardPolicyResponse(policy)})
case http.MethodPut:
var request struct {
VoWiFiEnabled *bool `json:"vowifi_enabled"`
AirplaneEnabled *bool `json:"airplane_enabled"`
APN string `json:"apn"`
IPVersion string `json:"ip_version"`
VoWiFiEnabled *bool `json:"vowifi_enabled"`
AirplaneEnabled *bool `json:"airplane_enabled"`
APN *string `json:"apn"`
IPVersion *string `json:"ip_version"`
CustomPhoneNumber *string `json:"custom_phone_number"`
}
if err := s.decodeJSON(w, r, &request); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
if request.VoWiFiEnabled == nil ||
request.AirplaneEnabled == nil {
if request.VoWiFiEnabled == nil && request.AirplaneEnabled == nil &&
request.APN == nil && request.IPVersion == nil && request.CustomPhoneNumber == nil {
writeError(
w,
http.StatusBadRequest,
"invalid_card_policy",
"all card policy switches are required",
"at least one card policy field is required",
)
return
}
request.APN = strings.TrimSpace(request.APN)
if len(request.APN) > 128 || strings.ContainsAny(request.APN, "\r\n\x00") {
writeError(w, http.StatusBadRequest, "invalid_card_policy", "APN is invalid")
policy, err := s.store.CardPolicy(r.Context(), iccid)
if errors.Is(err, store.ErrNotFound) {
policy = defaultCardPolicy(iccid)
} else if err != nil {
s.writeStoreError(w, err)
return
}
request.IPVersion = strings.ToUpper(strings.TrimSpace(request.IPVersion))
if request.IPVersion == "" {
request.IPVersion = "IPV4V6"
if request.APN != nil {
apn := strings.TrimSpace(*request.APN)
if !device.ValidAPN(apn) {
writeError(w, http.StatusBadRequest, "invalid_card_policy", "APN must contain only letters, digits, dots, underscores, or hyphens")
return
}
policy.APN = apn
}
if request.IPVersion != "IP" &&
request.IPVersion != "IPV6" &&
request.IPVersion != "IPV4V6" {
writeError(
w,
http.StatusBadRequest,
"invalid_card_policy",
"IP version must be IP, IPV6, or IPV4V6",
)
return
if request.IPVersion != nil {
ipVersion := strings.ToUpper(strings.TrimSpace(*request.IPVersion))
if ipVersion == "" {
ipVersion = "IPV4V6"
}
if ipVersion != "IP" && ipVersion != "IPV6" && ipVersion != "IPV4V6" {
writeError(
w,
http.StatusBadRequest,
"invalid_card_policy",
"IP version must be IP, IPV6, or IPV4V6",
)
return
}
policy.IPVersion = ipVersion
}
if request.CustomPhoneNumber != nil {
phoneNumber, phoneErr := normalizeCustomPhoneNumber(*request.CustomPhoneNumber)
if phoneErr != nil {
writeError(w, http.StatusBadRequest, "invalid_card_policy", phoneErr.Error())
return
}
policy.CustomPhoneNumber = phoneNumber
}
if request.VoWiFiEnabled != nil {
policy.VoWiFiEnabled = *request.VoWiFiEnabled
}
if request.AirplaneEnabled != nil {
policy.AirplaneEnabled = *request.AirplaneEnabled
}
// VoWiFi always owns an RF-off modem. Store airplane=true even when an
// older client omits that implication, so disabling VoWiFi cannot expose a
// brief cellular attach window.
if *request.VoWiFiEnabled {
*request.AirplaneEnabled = true
if policy.VoWiFiEnabled {
policy.AirplaneEnabled = true
policy.NetworkEnabled = false
}
policy := store.CardPolicy{
ICCID: iccid,
NetworkEnabled: false,
VoWiFiEnabled: *request.VoWiFiEnabled,
AirplaneEnabled: *request.AirplaneEnabled,
APN: request.APN,
IPVersion: request.IPVersion,
Source: "manual",
if policy.IPVersion == "" {
policy.IPVersion = "IPV4V6"
}
policy.Source = "manual"
if err := s.store.UpsertCardPolicy(r.Context(), policy); err != nil {
s.writeStoreError(w, err)
return
}
policy, err := s.store.CardPolicy(r.Context(), iccid)
policy, err = s.store.CardPolicy(r.Context(), iccid)
if err != nil {
s.writeStoreError(w, err)
return
@@ -1308,6 +1364,259 @@ func (s *Server) handleCardPolicy(w http.ResponseWriter, r *http.Request, iccid
}
}
func defaultCardPolicy(iccid string) store.CardPolicy {
return store.CardPolicy{
ICCID: strings.TrimSpace(iccid),
VoWiFiEnabled: true,
AirplaneEnabled: true,
IPVersion: "IPV4V6",
Source: "default",
}
}
type cardAPNProfilePayload struct {
APN string `json:"apn"`
Username string `json:"username"`
Password *string `json:"password"`
ClearPassword bool `json:"clear_password"`
Proxy string `json:"proxy"`
MCC string `json:"mcc"`
MNC string `json:"mnc"`
IPVersion string `json:"ip_version"`
RoamingIPVersion string `json:"roaming_ip_version"`
AuthType string `json:"auth_type"`
}
func (s *Server) decodeCardAPNProfilePayload(w http.ResponseWriter, r *http.Request) (cardAPNProfilePayload, bool) {
var request cardAPNProfilePayload
if err := s.decodeJSON(w, r, &request); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return request, false
}
request.APN = strings.TrimSpace(request.APN)
if request.APN == "" || !device.ValidAPN(request.APN) {
writeError(w, http.StatusBadRequest, "invalid_apn", "APN must contain only letters, digits, dots, underscores, or hyphens")
return request, false
}
request.IPVersion = strings.ToUpper(strings.TrimSpace(request.IPVersion))
if request.IPVersion == "" {
request.IPVersion = "IPV4V6"
}
if request.IPVersion != "IP" && request.IPVersion != "IPV6" && request.IPVersion != "IPV4V6" {
writeError(w, http.StatusBadRequest, "invalid_ip_version", "IP version must be IP, IPV6, or IPV4V6")
return request, false
}
request.RoamingIPVersion = strings.ToUpper(strings.TrimSpace(request.RoamingIPVersion))
if request.RoamingIPVersion == "" {
request.RoamingIPVersion = "IP"
}
if request.RoamingIPVersion != "IP" && request.RoamingIPVersion != "IPV6" && request.RoamingIPVersion != "IPV4V6" {
writeError(w, http.StatusBadRequest, "invalid_roaming_ip_version", "roaming IP version must be IP, IPV6, or IPV4V6")
return request, false
}
request.AuthType = strings.ToUpper(strings.TrimSpace(request.AuthType))
if request.AuthType == "" {
request.AuthType = "NONE"
}
if request.AuthType != "NONE" && request.AuthType != "PAP" && request.AuthType != "CHAP" && request.AuthType != "PAP_OR_CHAP" {
writeError(w, http.StatusBadRequest, "invalid_auth_type", "authentication type must be NONE, PAP, CHAP, or PAP_OR_CHAP")
return request, false
}
request.Username = strings.TrimSpace(request.Username)
request.Proxy = strings.TrimSpace(request.Proxy)
request.MCC = strings.TrimSpace(request.MCC)
request.MNC = strings.TrimSpace(request.MNC)
password := ""
if request.Password != nil {
password = *request.Password
}
if !validAPNText(request.Username, 128) || !validAPNText(password, 128) || !validAPNText(request.Proxy, 255) {
writeError(w, http.StatusBadRequest, "invalid_apn_credentials", "APN username, password, or proxy contains unsupported characters")
return request, false
}
if request.MCC != "" && !decimalLength(request.MCC, 3, 3) {
writeError(w, http.StatusBadRequest, "invalid_mcc", "MCC must contain exactly 3 digits")
return request, false
}
if request.MNC != "" && !decimalLength(request.MNC, 2, 3) {
writeError(w, http.StatusBadRequest, "invalid_mnc", "MNC must contain 2 or 3 digits")
return request, false
}
return request, true
}
func (s *Server) handleCardAPNProfiles(w http.ResponseWriter, r *http.Request, iccid, profileID string) {
iccid = strings.TrimSpace(iccid)
if !validICCID(iccid) {
writeError(w, http.StatusBadRequest, "invalid_iccid", "ICCID must contain between 10 and 32 decimal digits")
return
}
if profileID != "" {
id, err := strconv.ParseInt(profileID, 10, 64)
if err != nil || id < 1 {
writeError(w, http.StatusBadRequest, "invalid_apn_profile", "APN profile ID is invalid")
return
}
profiles, err := s.store.ListCardAPNProfiles(r.Context(), iccid)
if err != nil {
s.writeStoreError(w, err)
return
}
var existing store.CardAPNProfile
for _, profile := range profiles {
if profile.ID == id {
existing = profile
break
}
}
if existing.ID == 0 {
writeError(w, http.StatusNotFound, "apn_profile_not_found", "APN profile was not found")
return
}
switch r.Method {
case http.MethodDelete:
if err := s.store.DeleteCardAPNProfile(r.Context(), iccid, id); err != nil {
s.writeStoreError(w, err)
return
}
policy, err := s.store.CardPolicy(r.Context(), iccid)
if err == nil && strings.EqualFold(policy.APN, existing.APN) && strings.EqualFold(policy.IPVersion, existing.IPVersion) {
policy.APN = ""
policy.IPVersion = "IPV4V6"
policy.Source = "manual"
if err := s.store.UpsertCardPolicy(r.Context(), policy); err != nil {
s.writeStoreError(w, err)
return
}
} else if err != nil && !errors.Is(err, store.ErrNotFound) {
s.writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"deleted": true, "id": id}})
case http.MethodPatch, http.MethodPut:
request, ok := s.decodeCardAPNProfilePayload(w, r)
if !ok {
return
}
password := existing.Password
if request.ClearPassword {
password = ""
} else if request.Password != nil && *request.Password != "" {
password = *request.Password
}
updated, err := s.store.UpdateCardAPNProfile(r.Context(), store.CardAPNProfile{
ID: id, ICCID: iccid, APN: request.APN, Username: request.Username,
Password: password, Proxy: request.Proxy, MCC: request.MCC, MNC: request.MNC,
IPVersion: request.IPVersion, RoamingIPVersion: request.RoamingIPVersion,
AuthType: request.AuthType,
})
if err != nil {
s.writeStoreError(w, err)
return
}
policy, policyErr := s.store.CardPolicy(r.Context(), iccid)
if policyErr == nil && strings.EqualFold(policy.APN, existing.APN) && strings.EqualFold(policy.IPVersion, existing.IPVersion) {
policy.APN = updated.APN
policy.IPVersion = updated.IPVersion
policy.Source = "manual"
if err := s.store.UpsertCardPolicy(r.Context(), policy); err != nil {
s.writeStoreError(w, err)
return
}
} else if policyErr != nil && !errors.Is(policyErr, store.ErrNotFound) {
s.writeStoreError(w, policyErr)
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": cardAPNProfileResponse(updated)})
default:
w.Header().Set("Allow", "PATCH, PUT, DELETE")
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
}
return
}
switch r.Method {
case http.MethodGet:
profiles, err := s.store.ListCardAPNProfiles(r.Context(), iccid)
if err != nil {
s.writeStoreError(w, err)
return
}
items := make([]map[string]any, 0, len(profiles))
for _, profile := range profiles {
items = append(items, cardAPNProfileResponse(profile))
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"items": items}})
case http.MethodPost:
request, ok := s.decodeCardAPNProfilePayload(w, r)
if !ok {
return
}
if _, err := s.store.CardPolicy(r.Context(), iccid); errors.Is(err, store.ErrNotFound) {
if err := s.store.UpsertCardPolicy(r.Context(), defaultCardPolicy(iccid)); err != nil {
s.writeStoreError(w, err)
return
}
} else if err != nil {
s.writeStoreError(w, err)
return
}
password := ""
if request.Password != nil {
password = *request.Password
}
profile, err := s.store.UpsertCardAPNProfile(r.Context(), store.CardAPNProfile{
ICCID: iccid, APN: request.APN, Username: request.Username,
Password: password, Proxy: request.Proxy, MCC: request.MCC, MNC: request.MNC,
IPVersion: request.IPVersion, RoamingIPVersion: request.RoamingIPVersion,
AuthType: request.AuthType,
})
if err != nil {
s.writeStoreError(w, err)
return
}
writeJSON(w, http.StatusCreated, map[string]any{"data": cardAPNProfileResponse(profile)})
default:
w.Header().Set("Allow", "GET, POST")
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
}
}
func cardAPNProfileResponse(profile store.CardAPNProfile) map[string]any {
return map[string]any{
"id": profile.ID, "iccid": profile.ICCID, "apn": profile.APN,
"username": profile.Username, "has_password": profile.Password != "",
"proxy": profile.Proxy, "mcc": profile.MCC, "mnc": profile.MNC,
"ip_version": profile.IPVersion, "roaming_ip_version": profile.RoamingIPVersion,
"auth_type": profile.AuthType, "created_at": profile.CreatedAt,
"updated_at": profile.UpdatedAt,
}
}
func validAPNText(value string, maxLength int) bool {
if len(value) > maxLength || strings.ContainsAny(value, "\r\n\x00\"") {
return false
}
for _, character := range value {
if character < 0x20 || character == 0x7f {
return false
}
}
return true
}
func decimalLength(value string, minimum, maximum int) bool {
if len(value) < minimum || len(value) > maximum {
return false
}
for _, character := range value {
if character < '0' || character > '9' {
return false
}
}
return true
}
func validICCID(value string) bool {
if len(value) < 10 || len(value) > 32 {
return false
@@ -1320,15 +1629,42 @@ func validICCID(value string) bool {
return true
}
func normalizeCustomPhoneNumber(value string) (string, error) {
value = strings.TrimSpace(value)
if value == "" {
return "", nil
}
var normalized strings.Builder
digitCount := 0
for index, character := range value {
switch {
case character >= '0' && character <= '9':
normalized.WriteRune(character)
digitCount++
case character == '+' && index == 0:
normalized.WriteRune(character)
case character == ' ' || character == '-' || character == '(' || character == ')':
// Common visual separators are accepted but not persisted.
default:
return "", errors.New("custom phone number may contain only digits, a leading plus sign, spaces, parentheses, or hyphens")
}
}
if digitCount < 3 || digitCount > 20 {
return "", errors.New("custom phone number must contain between 3 and 20 digits")
}
return normalized.String(), nil
}
func cardPolicyResponse(policy store.CardPolicy) map[string]any {
response := map[string]any{
"iccid": policy.ICCID,
"network_enabled": false,
"vowifi_enabled": policy.VoWiFiEnabled,
"airplane_enabled": policy.AirplaneEnabled,
"apn": policy.APN,
"ip_version": policy.IPVersion,
"source": policy.Source,
"iccid": policy.ICCID,
"network_enabled": false,
"vowifi_enabled": policy.VoWiFiEnabled,
"airplane_enabled": policy.AirplaneEnabled,
"apn": policy.APN,
"ip_version": policy.IPVersion,
"custom_phone_number": policy.CustomPhoneNumber,
"source": policy.Source,
}
if !policy.CreatedAt.IsZero() {
response["created_at"] = policy.CreatedAt
+231 -2
View File
@@ -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)
+11
View File
@@ -546,6 +546,13 @@ func (s *Server) syncModemSMS(ctx context.Context, onlyDevice string) {
if onlyDevice != "" && config.ID != onlyDevice {
continue
}
// A PC/SC USB reader has no modem storage or AT command channel. Its
// messages are delivered by the active VoWiFi IMS session, so attempting
// an AT+CMGL catch-up scan would only poison the reader's health state
// with ErrNoATPort.
if !supportsModemSMSStorage(config) {
continue
}
// Do not queue CMGL traffic on the same serial actor while VoWiFi is
// reading the SIM or running AKA. Once the session is stable, resume the
// SM/ME scan as a catch-up path: an SMS submitted while the card was
@@ -659,6 +666,10 @@ func (s *Server) syncModemSMS(ctx context.Context, onlyDevice string) {
}
}
func supportsModemSMSStorage(config store.Device) bool {
return store.NormalizeDeviceType(config.DeviceType) != store.DeviceTypeUSBSIMReader
}
func shouldDeferModemSMSSync(state vowifi.State, stateErr error) bool {
if stateErr != nil || !state.Enabled {
return false
+9
View File
@@ -108,6 +108,15 @@ func TestNormalizeSMSDeviceFilter(t *testing.T) {
}
}
func TestSupportsModemSMSStorageRejectsUSBReader(t *testing.T) {
if supportsModemSMSStorage(store.Device{DeviceType: store.DeviceTypeUSBSIMReader}) {
t.Fatal("USB SIM reader must not be polled with modem SMS AT commands")
}
if !supportsModemSMSStorage(store.Device{DeviceType: store.DeviceTypePCIeEC20EC25}) {
t.Fatal("cellular modem should retain modem SMS storage synchronization")
}
}
func TestSMSSendOutcome(t *testing.T) {
tests := []struct {
name string
+10 -15
View File
@@ -11,7 +11,6 @@ import (
"errors"
"fmt"
"io"
"mime"
"net"
"net/http"
"net/mail"
@@ -25,7 +24,7 @@ import (
const smsNotificationPollInterval = 2 * time.Second
var smsOnlyNotificationChannels = []string{"bark", "email", "pushplus", "webhook"}
var smsOnlyNotificationChannels = []string{"bark", "email", "pushplus", "webhook", "wecom"}
type smsNotification struct {
DeviceID string
@@ -144,7 +143,7 @@ func (s *Server) smsNotificationConfig(ctx context.Context, channel string) (map
func validateSMSNotificationConfig(channel string, config map[string]any) error {
switch channel {
case "bark", "email", "webhook":
case "bark", "email", "webhook", "wecom":
if err := validateNotificationTestConfig(channel, config); err != nil {
return err
}
@@ -203,6 +202,8 @@ func sendSMSNotification(ctx context.Context, channel string, config map[string]
return sendPushplusSMSNotification(ctx, config, message)
case "webhook":
return sendWebhookSMSNotification(ctx, config, message)
case "wecom":
return sendWecomNotification(ctx, config, wecomSMSValues(message))
default:
return fmt.Errorf("unsupported SMS notification channel %q", channel)
}
@@ -426,19 +427,13 @@ func sendEmailSMSNotification(ctx context.Context, config map[string]any, messag
if err != nil {
return fmt.Errorf("%w: SMTP message rejected", errProviderRejected)
}
email := strings.Join([]string{
"Date: " + time.Now().UTC().Format(time.RFC1123Z),
"From: " + formatMailAddress(from),
"To: " + joinMailAddresses(recipients),
"Subject: " + mime.QEncoding.Encode("UTF-8", "收到新短信 - "+message.DeviceLabel),
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=UTF-8",
"Content-Transfer-Encoding: 8bit",
"",
if err := writePlainTextMail(
writer,
from,
recipients,
"收到新短信 - "+message.DeviceLabel,
message.Text(),
"",
}, "\r\n")
if _, err := io.WriteString(writer, email); err != nil {
); err != nil {
_ = writer.Close()
return fmt.Errorf("write SMTP notification: %w", err)
}
+34
View File
@@ -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 {
+154
View File
@@ -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)
}
}
}
+37
View File
@@ -179,6 +179,43 @@ func (s *Store) UpdateAutomaticTaskRun(ctx context.Context, run AutomaticTaskRun
return err
}
// RecoverAutomaticTaskRuns reconciles durable run records with the in-memory
// scheduler after a process restart. Running work cannot still be executing,
// while queued work is safe to put back onto the per-device queues.
func (s *Store) RecoverAutomaticTaskRuns(ctx context.Context, now time.Time) ([]AutomaticTaskRun, error) {
const restartError = "service restarted before the automatic task completed"
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
if _, err = tx.ExecContext(ctx, `UPDATE automatic_task_runs SET
status = 'failed', finished_at = ?, error = ?, updated_at = ?
WHERE status = 'running'`, now.Unix(), restartError, now.Unix()); err != nil {
return nil, fmt.Errorf("recover running automatic tasks: %w", err)
}
if _, err = tx.ExecContext(ctx, `UPDATE automatic_tasks SET
last_run_at = ?, last_status = 'failed', last_error = ?, updated_at = ?
WHERE id IN (
SELECT task_id FROM automatic_task_runs
WHERE status = 'failed' AND error = ? AND finished_at = ?
)`, now.Unix(), restartError, now.Unix(), restartError, now.Unix()); err != nil {
return nil, fmt.Errorf("recover automatic task status: %w", err)
}
rows, err := tx.QueryContext(ctx, automaticTaskRunSelect+` WHERE status = 'queued' ORDER BY id`)
if err != nil {
return nil, fmt.Errorf("recover queued automatic tasks: %w", err)
}
queued, err := scanAutomaticTaskRuns(rows)
if err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
return queued, nil
}
const automaticTaskRunSelect = `
SELECT id, task_id, device_id, scheduled_at, started_at, finished_at,
status, attempts, output, error, created_at, updated_at
+62
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"path/filepath"
"strings"
"testing"
"time"
)
@@ -118,3 +119,64 @@ func TestListAutomaticTaskRunsPaginated(t *testing.T) {
t.Fatalf("clamped page: total = %d, runs = %+v", total, all)
}
}
func TestRecoverAutomaticTaskRunsFailsRunningAndReturnsQueued(t *testing.T) {
ctx := context.Background()
database := openTestStore(t, filepath.Join(t.TempDir(), "automatic-task-recovery.db"))
mustSaveDevice(t, database, "ec20", "EC20")
task, err := database.SaveAutomaticTask(ctx, AutomaticTask{
Name: "task", Enabled: true, DeviceID: "ec20", ProfileICCID: "one",
TaskType: "call", Environment: "cellular", IntervalDays: 1,
StartDate: "2026-08-10", RunTime: "12:00", Timezone: "Asia/Shanghai", Payload: []byte(`{"phone":"10086","duration_seconds":10}`),
NextRunAt: time.Now().Add(time.Hour),
})
if err != nil {
t.Fatal(err)
}
running, err := database.QueueAutomaticTaskNow(ctx, task)
if err != nil {
t.Fatal(err)
}
running.Status = "running"
running.StartedAt = time.Now().UTC().Add(-time.Minute)
running.Attempts = 1
if err := database.UpdateAutomaticTaskRun(ctx, running); err != nil {
t.Fatal(err)
}
queued, err := database.QueueAutomaticTaskNow(ctx, task)
if err != nil {
t.Fatal(err)
}
recoveredAt := time.Now().UTC().Truncate(time.Second)
recovered, err := database.RecoverAutomaticTaskRuns(ctx, recoveredAt)
if err != nil {
t.Fatal(err)
}
if len(recovered) != 1 || recovered[0].ID != queued.ID || recovered[0].Status != "queued" {
t.Fatalf("recovered queued runs = %+v", recovered)
}
runs, err := database.ListAutomaticTaskRuns(ctx, 10)
if err != nil {
t.Fatal(err)
}
foundRunning := false
for _, run := range runs {
if run.ID == running.ID {
foundRunning = true
if run.Status != "failed" || run.FinishedAt.IsZero() || !strings.Contains(run.Error, "service restarted") {
t.Fatalf("recovered running run = %+v", run)
}
}
}
if !foundRunning {
t.Fatal("running run was not found after recovery")
}
recoveredTask, err := database.AutomaticTask(ctx, task.ID)
if err != nil {
t.Fatal(err)
}
if recoveredTask.LastStatus != "failed" || !strings.Contains(recoveredTask.LastError, "service restarted") {
t.Fatalf("recovered task status = %+v", recoveredTask)
}
}
+24 -7
View File
@@ -17,6 +17,7 @@ const (
DeviceTypeWiFi410 = "wifi_410"
DeviceTypeDJI4G = "dji_4g"
DeviceTypePCIeEC20EC25 = "pcie_ec20_ec25"
DeviceTypeUSBSIMReader = "usb_sim_reader"
)
// NormalizeDeviceType returns a stable persisted device type identifier.
@@ -27,6 +28,8 @@ func NormalizeDeviceType(value string) string {
return DeviceTypeWiFi410
case DeviceTypeDJI4G:
return DeviceTypeDJI4G
case DeviceTypeUSBSIMReader:
return DeviceTypeUSBSIMReader
case "", DeviceTypePCIeEC20EC25:
return DeviceTypePCIeEC20EC25
default:
@@ -132,16 +135,29 @@ func upsertDevice(ctx context.Context, executor contextExecer, value Device) err
value.DeviceBackend = "at"
}
value.DeviceBackend = strings.ToLower(strings.TrimSpace(value.DeviceBackend))
if value.DeviceBackend != "at" && value.DeviceBackend != "qmi" {
if value.DeviceBackend != "at" && value.DeviceBackend != "qmi" && value.DeviceBackend != "pcsc" {
return fmt.Errorf("unsupported device backend %q", value.DeviceBackend)
}
if value.ESIMTransport == "" {
value.ESIMTransport = "at"
}
value.ESIMTransport = strings.ToLower(strings.TrimSpace(value.ESIMTransport))
if value.ESIMTransport != "at" && value.ESIMTransport != "qmi" {
if value.ESIMTransport != "at" && value.ESIMTransport != "qmi" && value.ESIMTransport != "pcsc" && value.ESIMTransport != "none" {
return fmt.Errorf("unsupported eSIM transport %q", value.ESIMTransport)
}
value.SIMPIN = strings.TrimSpace(value.SIMPIN)
if value.SIMPIN != "" {
if len(value.SIMPIN) < 4 || len(value.SIMPIN) > 8 || strings.Trim(value.SIMPIN, "0123456789") != "" {
return errors.New("SIM PIN must contain 4 to 8 digits")
}
}
if value.DeviceType == DeviceTypeUSBSIMReader {
value.DeviceBackend = "pcsc"
value.ESIMTransport = "pcsc"
value.NetworkEnabled = false
value.SMSEnabled = true
value.VoWiFiEnabled = true
}
extra, err := normalizeJSONObject(value.Extra)
if err != nil {
return fmt.Errorf("normalize device extra data: %w", err)
@@ -159,12 +175,12 @@ func upsertDevice(ctx context.Context, executor contextExecer, value Device) err
_, err = executor.ExecContext(ctx, `
INSERT INTO devices (
id, name, device_type, interface, control_device, at_port, usb_path,
audio_device, modem_imei, apn, proxy_port, baud_rate,
audio_device, modem_imei, sim_pin, apn, proxy_port, baud_rate,
data_bits, stop_bits, parity, device_backend, esim_transport,
qmi_use_proxy, qmi_proxy_path, qmi_proxy_executable,
network_enabled, sms_enabled, vowifi_enabled, extra_json,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
device_type = excluded.device_type,
@@ -174,6 +190,7 @@ func upsertDevice(ctx context.Context, executor contextExecer, value Device) err
usb_path = excluded.usb_path,
audio_device = excluded.audio_device,
modem_imei = excluded.modem_imei,
sim_pin = excluded.sim_pin,
apn = excluded.apn,
proxy_port = excluded.proxy_port,
baud_rate = excluded.baud_rate,
@@ -192,7 +209,7 @@ func upsertDevice(ctx context.Context, executor contextExecer, value Device) err
updated_at = excluded.updated_at
`,
value.ID, value.Name, value.DeviceType, value.Interface, value.ControlDevice, value.ATPort,
value.USBPath, value.AudioDevice, value.ModemIMEI, value.APN,
value.USBPath, value.AudioDevice, value.ModemIMEI, value.SIMPIN, value.APN,
value.ProxyPort, value.BaudRate, value.DataBits, value.StopBits,
value.Parity, value.DeviceBackend, value.ESIMTransport,
boolInt(value.QMIUseProxy), value.QMIProxyPath, value.QMIProxyExecutable,
@@ -282,7 +299,7 @@ func (s *Store) DeleteDevice(ctx context.Context, id string) error {
const deviceSelect = `
SELECT id, name, device_type, interface, control_device, at_port, usb_path,
audio_device, modem_imei, apn, proxy_port, baud_rate, data_bits,
audio_device, modem_imei, sim_pin, apn, proxy_port, baud_rate, data_bits,
stop_bits, parity, device_backend, esim_transport, qmi_use_proxy,
qmi_proxy_path, qmi_proxy_executable, network_enabled, sms_enabled,
vowifi_enabled, extra_json, created_at, updated_at
@@ -295,7 +312,7 @@ func scanDevice(row rowScanner) (Device, error) {
var createdAt, updatedAt int64
err := row.Scan(
&value.ID, &value.Name, &value.DeviceType, &value.Interface, &value.ControlDevice,
&value.ATPort, &value.USBPath, &value.AudioDevice, &value.ModemIMEI,
&value.ATPort, &value.USBPath, &value.AudioDevice, &value.ModemIMEI, &value.SIMPIN,
&value.APN, &value.ProxyPort, &value.BaudRate, &value.DataBits,
&value.StopBits, &value.Parity, &value.DeviceBackend,
&value.ESIMTransport, &qmiUseProxy, &value.QMIProxyPath,
+65 -3
View File
@@ -58,7 +58,7 @@ func TestMigrationFromAuthenticationSchema(t *testing.T) {
"local_proxy_config", "upstream_proxies", "country_rules",
"device_proxy_bindings",
"notification_settings", "app_settings", "audit_events",
"log_events", "card_policies", "traffic_buckets",
"log_events", "card_policies", "card_apn_profiles", "traffic_buckets",
"sms_send_attempts",
} {
var found string
@@ -366,6 +366,31 @@ func TestDeviceStateRoundTripAndCascade(t *testing.T) {
}
}
func TestUSBSIMReaderConfigurationIsWiFiCallingOnly(t *testing.T) {
ctx := context.Background()
database := openTestStore(t, ":memory:")
err := database.UpsertDevice(ctx, Device{
ID: "reader-1", Name: "USB SIM", DeviceType: DeviceTypeUSBSIMReader,
USBPath: "1-3", ControlDevice: "Reader 00 00", SIMPIN: "1234",
DeviceBackend: "at", ESIMTransport: "at", NetworkEnabled: true,
})
if err != nil {
t.Fatal(err)
}
got, err := database.Device(ctx, "reader-1")
if err != nil {
t.Fatal(err)
}
if got.DeviceBackend != "pcsc" || got.ESIMTransport != "pcsc" || got.NetworkEnabled || !got.SMSEnabled || !got.VoWiFiEnabled || got.SIMPIN != "1234" {
t.Fatalf("reader config = %+v", got)
}
bad := got
bad.SIMPIN = "12x4"
if err := database.UpsertDevice(ctx, bad); err == nil {
t.Fatal("non-numeric SIM PIN was accepted")
}
}
func TestSMSPersistenceAndDerivedThreads(t *testing.T) {
ctx := context.Background()
database := openTestStore(t, ":memory:")
@@ -737,6 +762,43 @@ func TestNotificationAndAppSecretPreservation(t *testing.T) {
}
}
func TestNotificationArraySecretPreservation(t *testing.T) {
ctx := context.Background()
database := openTestStore(t, ":memory:")
originalURL := "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=first-secret"
if err := database.UpsertNotificationSetting(ctx, NotificationSetting{
Channel: "wecom", Enabled: true,
Config: json.RawMessage(`{"urls":["` + originalURL + `"]}`),
}); err != nil {
t.Fatal(err)
}
setting, err := database.NotificationSetting(ctx, "wecom")
if err != nil {
t.Fatal(err)
}
var redacted map[string]any
if err := json.Unmarshal(setting.Redacted().Config, &redacted); err != nil {
t.Fatal(err)
}
urls, ok := redacted["urls"].([]any)
if !ok || len(urls) != 1 || urls[0] != SecretMask {
t.Fatalf("redacted URLs = %#v", redacted["urls"])
}
if err := database.UpsertNotificationSetting(ctx, NotificationSetting{
Channel: "wecom", Enabled: true,
Config: json.RawMessage(`{"urls":["` + SecretMask + `","https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=second-secret"]}`),
}); err != nil {
t.Fatal(err)
}
setting, err = database.NotificationSetting(ctx, "wecom")
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(setting.Config, []byte(originalURL)) || !bytes.Contains(setting.Config, []byte("second-secret")) {
t.Fatalf("stored URLs = %s", setting.Config)
}
}
func TestEventsPoliciesAndTraffic(t *testing.T) {
ctx := context.Background()
database := openTestStore(t, ":memory:")
@@ -784,7 +846,7 @@ func TestEventsPoliciesAndTraffic(t *testing.T) {
if err := database.UpsertCardPolicy(ctx, CardPolicy{
ICCID: "89860001", NetworkEnabled: true, VoWiFiEnabled: true,
APN: "ims", IPVersion: "ipv4v6",
APN: "ims", IPVersion: "ipv4v6", CustomPhoneNumber: "+8613800138000",
}); err != nil {
t.Fatal(err)
}
@@ -794,7 +856,7 @@ func TestEventsPoliciesAndTraffic(t *testing.T) {
t.Fatalf("RF-safe VoWiFi policy was rejected: %v", err)
}
policy, err := database.CardPolicy(ctx, "89860001")
if err != nil || !policy.VoWiFiEnabled {
if err != nil || !policy.VoWiFiEnabled || policy.CustomPhoneNumber != "+8613800138000" {
t.Fatalf("CardPolicy() = %+v, %v", policy, err)
}
safePolicy, err := database.CardPolicy(ctx, "89860002")
+37
View File
@@ -227,6 +227,43 @@ func migrationStatements(version int) []string {
`CREATE INDEX device_proxy_bindings_device_idx
ON device_proxy_bindings(device_id, iccid)`,
}
case 13:
return []string{
`CREATE TABLE IF NOT EXISTS card_apn_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
iccid TEXT NOT NULL,
apn TEXT NOT NULL,
ip_version TEXT NOT NULL DEFAULT 'IPV4V6'
CHECK (ip_version IN ('IP', 'IPV6', 'IPV4V6')),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
UNIQUE (iccid, apn, ip_version),
FOREIGN KEY (iccid) REFERENCES card_policies(iccid) ON DELETE CASCADE
)`,
`CREATE INDEX IF NOT EXISTS card_apn_profiles_iccid_idx
ON card_apn_profiles(iccid, id)`,
}
case 14:
return []string{
`ALTER TABLE card_apn_profiles ADD COLUMN username TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE card_apn_profiles ADD COLUMN password TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE card_apn_profiles ADD COLUMN proxy TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE card_apn_profiles ADD COLUMN mcc TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE card_apn_profiles ADD COLUMN mnc TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE card_apn_profiles ADD COLUMN roaming_ip_version TEXT NOT NULL DEFAULT 'IP'
CHECK (roaming_ip_version IN ('IP', 'IPV6', 'IPV4V6'))`,
`ALTER TABLE card_apn_profiles ADD COLUMN auth_type TEXT NOT NULL DEFAULT 'NONE'
CHECK (auth_type IN ('NONE', 'PAP', 'CHAP', 'PAP_OR_CHAP'))`,
}
case 15:
return []string{
`ALTER TABLE card_policies
ADD COLUMN custom_phone_number TEXT NOT NULL DEFAULT ''`,
}
case 16:
return []string{
`ALTER TABLE devices ADD COLUMN sim_pin TEXT NOT NULL DEFAULT ''`,
}
default:
return nil
}
+75 -21
View File
@@ -24,6 +24,7 @@ type Device struct {
USBPath string
AudioDevice string
ModemIMEI string
SIMPIN string
APN string
ProxyPort int
BaudRate int
@@ -339,12 +340,9 @@ func (value NotificationSetting) SensitiveValues() []string {
}
values := make([]string, 0, len(value.SensitiveFields))
for _, field := range value.SensitiveFields {
if secret, ok := getJSONPath(document, field).(string); ok &&
secret != "" && secret != SecretMask {
values = append(values, secret)
}
collectJSONStringValues(getJSONPath(document, field), &values)
}
return values
return uniqueNonemptyStrings(values)
}
type AppSetting struct {
@@ -477,15 +475,32 @@ type LogFilter struct {
}
type CardPolicy struct {
ICCID string
NetworkEnabled bool
VoWiFiEnabled bool
AirplaneEnabled bool
APN string
IPVersion string
Source string
CreatedAt time.Time
UpdatedAt time.Time
ICCID string
NetworkEnabled bool
VoWiFiEnabled bool
AirplaneEnabled bool
APN string
IPVersion string
CustomPhoneNumber string
Source string
CreatedAt time.Time
UpdatedAt time.Time
}
type CardAPNProfile struct {
ID int64
ICCID string
APN string
Username string
Password string
Proxy string
MCC string
MNC string
IPVersion string
RoamingIPVersion string
AuthType string
CreatedAt time.Time
UpdatedAt time.Time
}
type TrafficBucket struct {
@@ -559,8 +574,8 @@ func redactJSONFields(value json.RawMessage, fields []string, replacement string
return json.RawMessage(`{}`)
}
for _, field := range fields {
if getJSONPath(document, field) != nil {
setJSONPath(document, field, replacement)
if current := getJSONPath(document, field); current != nil {
setJSONPath(document, field, redactJSONValue(current, replacement))
}
}
encoded, err := json.Marshal(document)
@@ -585,16 +600,55 @@ func mergeJSONSecrets(
}
for _, field := range fields {
value := getJSONPath(next, field)
text, stringValue := value.(string)
if value == nil || (stringValue && (text == "" || text == SecretMask)) {
if previous := getJSONPath(current, field); previous != nil {
setJSONPath(next, field, previous)
}
if previous := getJSONPath(current, field); previous != nil {
setJSONPath(next, field, mergeJSONSecretValue(value, previous))
}
}
return json.Marshal(next)
}
func redactJSONValue(value any, replacement string) any {
switch typed := value.(type) {
case string:
return replacement
case []any:
result := make([]any, len(typed))
for index, item := range typed {
result[index] = redactJSONValue(item, replacement)
}
return result
default:
return replacement
}
}
func mergeJSONSecretValue(incoming, existing any) any {
if incoming == nil {
return existing
}
switch next := incoming.(type) {
case string:
if next == "" || next == SecretMask {
return existing
}
case []any:
previous, ok := existing.([]any)
if !ok {
return incoming
}
merged := make([]any, len(next))
for index, value := range next {
if index < len(previous) {
merged[index] = mergeJSONSecretValue(value, previous[index])
} else {
merged[index] = value
}
}
return merged
}
return incoming
}
func getJSONPath(document map[string]any, path string) any {
if strings.TrimSpace(path) == "" {
return nil
+164 -5
View File
@@ -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)
}
+4 -2
View File
@@ -13,7 +13,7 @@ import (
_ "modernc.org/sqlite"
)
const schemaVersion = 12
const schemaVersion = 16
var ErrNotFound = errors.New("store: not found")
@@ -121,7 +121,9 @@ func migrate(ctx context.Context, db *sql.DB) error {
// already contain an additive column. Remaining statements in the
// migration are still safe and must be applied.
duplicateAdditiveColumn := (nextVersion == 7 && strings.Contains(statement, "ADD COLUMN modem_imei")) ||
(nextVersion == 8 && strings.Contains(statement, "ADD COLUMN device_type"))
(nextVersion == 8 && strings.Contains(statement, "ADD COLUMN device_type")) ||
(nextVersion == 14 && strings.Contains(statement, "ADD COLUMN")) ||
(nextVersion == 16 && strings.Contains(statement, "ADD COLUMN sim_pin"))
if duplicateAdditiveColumn && strings.Contains(strings.ToLower(err.Error()), "duplicate column name") {
continue
}
+124
View File
@@ -0,0 +1,124 @@
package vowifi
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"vocat/internal/pcsc"
)
type PCSCBindingResolver func(context.Context, string) (pcsc.Selector, string, error)
// PCSCAdapter uses a directly attached USB smart-card reader as the UICC for
// Wi-Fi Calling. It deliberately exposes no cellular-radio behaviour.
type PCSCAdapter struct {
service *pcsc.Service
resolve PCSCBindingResolver
mu sync.RWMutex
bindings map[string]string
}
var (
_ SIMIdentityReader = (*PCSCAdapter)(nil)
_ SMSCenterReader = (*PCSCAdapter)(nil)
_ AKAProvider = (*PCSCAdapter)(nil)
_ RadioController = (*PCSCAdapter)(nil)
)
func NewPCSCAdapter(service *pcsc.Service, resolver PCSCBindingResolver) (*PCSCAdapter, error) {
if service == nil || resolver == nil {
return nil, errors.New("vocat: PC/SC service and reader resolver are required")
}
return &PCSCAdapter{service: service, resolve: resolver, bindings: make(map[string]string)}, nil
}
func (adapter *PCSCAdapter) ReadIdentity(ctx context.Context, deviceID string) (SIMIdentity, error) {
selector, pin, err := adapter.resolve(ctx, strings.TrimSpace(deviceID))
if err != nil {
return SIMIdentity{}, err
}
identity, err := adapter.service.ReadIdentity(ctx, selector, pin)
if err != nil {
return SIMIdentity{}, fmt.Errorf("read USB SIM identity: %w", err)
}
if len(identity.IMSI) < 5 {
return SIMIdentity{}, errors.New("vocat: USB SIM reader returned an invalid IMSI")
}
adapter.mu.Lock()
adapter.bindings[identity.ICCID] = strings.TrimSpace(deviceID)
adapter.mu.Unlock()
mncLength := identity.MNCLength
if mncLength != 2 && mncLength != 3 {
if mcc, mnc, ok := assignedHomePLMN(identity.IMSI); ok {
return SIMIdentity{ICCID: identity.ICCID, IMSI: identity.IMSI, HomeMCC: mcc, HomeMNC: mnc, SMSC: identity.SMSC}, nil
}
return SIMIdentity{}, ErrEC20MNCUnavailable
}
if len(identity.IMSI) < 3+mncLength {
return SIMIdentity{}, errors.New("vocat: USB SIM IMSI is shorter than its EF_AD home PLMN")
}
return SIMIdentity{
ICCID: identity.ICCID, IMSI: identity.IMSI,
HomeMCC: identity.IMSI[:3], HomeMNC: identity.IMSI[3 : 3+mncLength],
SMSC: identity.SMSC,
}, nil
}
func (adapter *PCSCAdapter) ReadSMSCenter(ctx context.Context, deviceID string) (string, error) {
selector, pin, err := adapter.resolve(ctx, strings.TrimSpace(deviceID))
if err != nil {
return "", err
}
identity, err := adapter.service.ReadIdentity(ctx, selector, pin)
if err != nil {
return "", err
}
if strings.TrimSpace(identity.SMSC) == "" {
return "", errors.New("vocat: USB SIM does not expose a service-centre address")
}
return identity.SMSC, nil
}
func (adapter *PCSCAdapter) CheckReady(ctx context.Context, identity SIMIdentity) (AKAEvidence, error) {
selector, pin, err := adapter.resolve(ctx, adapter.deviceID(identity))
if err != nil {
return AKAEvidence{}, err
}
aid, err := adapter.service.CheckReady(ctx, selector, identity.ICCID, pin)
if err != nil {
return AKAEvidence{}, fmt.Errorf("check USB SIM AKA application: %w", err)
}
return AKAEvidence{Ready: true, Application: aid}, nil
}
func (adapter *PCSCAdapter) deviceID(identity SIMIdentity) string {
adapter.mu.RLock()
deviceID := adapter.bindings[identity.ICCID]
adapter.mu.RUnlock()
return deviceID
}
func (adapter *PCSCAdapter) Authenticate(ctx context.Context, identity SIMIdentity, challenge AKAChallenge) (AKAResult, error) {
selector, pin, err := adapter.resolve(ctx, adapter.deviceID(identity))
if err != nil {
return AKAResult{}, err
}
result, err := adapter.service.Authenticate(ctx, selector, identity.ICCID, pin, pcsc.AKAChallenge(challenge))
if err != nil {
if errors.Is(err, pcsc.ErrAKARejected) {
return AKAResult{}, errors.Join(ErrEC20AKAMACFailure, err)
}
return AKAResult{}, fmt.Errorf("authenticate with USB SIM: %w", err)
}
return AKAResult(result), nil
}
func (*PCSCAdapter) Snapshot(context.Context, string) (RadioSnapshot, error) {
return RadioSnapshot{OperatingMode: 4, PureAirplanePolicy: true}, nil
}
func (*PCSCAdapter) StopCellularData(context.Context, string) error { return nil }
func (*PCSCAdapter) EnterVoWiFiRFOff(context.Context, string) error { return nil }
func (*PCSCAdapter) Restore(context.Context, string, RadioSnapshot) error { return nil }
-1
View File
@@ -210,7 +210,6 @@ User=root
Group=root
WorkingDirectory=/opt/vocat
EnvironmentFile=${ENV_FILE}
Environment=VOCAT_ADDR=0.0.0.0:7575
Environment=VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db
ExecStart=${BINARY_PATH}
Restart=on-failure
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="USB SIM reader">
<defs><linearGradient id="a" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#64748b"/><stop offset="1" stop-color="#1e293b"/></linearGradient></defs>
<rect x="12" y="31" width="104" height="68" rx="14" fill="url(#a)"/>
<rect x="25" y="43" width="55" height="42" rx="7" fill="#0f172a" stroke="#94a3b8" stroke-width="2"/>
<path d="M40 51h24l8 8v18H40z" fill="#facc15"/><path d="M44 60h24M50 54v23M60 54v23" stroke="#a16207" stroke-width="2"/>
<rect x="91" y="51" width="25" height="29" rx="4" fill="#cbd5e1"/>
<path d="M98 58h11M98 65h11M98 72h11" stroke="#64748b" stroke-width="3"/>
<circle cx="101" cy="89" r="3" fill="#22c55e"/>
</svg>

After

Width:  |  Height:  |  Size: 752 B

@@ -0,0 +1,334 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { apiMessage } from "../../api";
import type { CardPolicy } from "../../types";
import { Button, Input, Modal, Select, Spinner, Switch, Tag, confirmDialog, message } from "../ui";
import { useI18n } from "../../lib/i18n";
import {
createCardAPN,
deleteCardAPN,
getCardAPNs,
getDeviceAPNs,
updateCardAPN,
updateCardPolicy,
type CardAPNProfile,
type ModemAPNProfile,
} from "./deviceActions";
interface CardPolicyAPNProps {
deviceId: string;
iccid: string;
policy: CardPolicy | null;
deviceOnline: boolean;
onSaved: (policy: CardPolicy) => void;
}
interface APNRow {
key: string;
apn: string;
ipVersion: "IP" | "IPV6" | "IPV4V6";
source: "automatic" | "modem" | "custom";
cid?: number;
customID?: number;
username?: string;
hasPassword?: boolean;
proxy?: string;
mcc?: string;
mnc?: string;
roamingIPVersion?: "IP" | "IPV6" | "IPV4V6";
authType?: "NONE" | "PAP" | "CHAP" | "PAP_OR_CHAP";
}
const APN_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,98}[A-Za-z0-9])?$/;
export function CardPolicyAPN({ deviceId, iccid, policy, deviceOnline, onSaved }: CardPolicyAPNProps) {
const { t } = useI18n();
const [modemProfiles, setModemProfiles] = useState<ModemAPNProfile[]>([]);
const [customProfiles, setCustomProfiles] = useState<CardAPNProfile[]>([]);
const [loading, setLoading] = useState(true);
const [editorOpen, setEditorOpen] = useState(false);
const [editingProfile, setEditingProfile] = useState<CardAPNProfile | null>(null);
const [newAPN, setNewAPN] = useState("");
const [newUsername, setNewUsername] = useState("");
const [newPassword, setNewPassword] = useState("");
const [newProxy, setNewProxy] = useState("");
const [newMCC, setNewMCC] = useState("");
const [newMNC, setNewMNC] = useState("");
const [newIPVersion, setNewIPVersion] = useState<"IP" | "IPV6" | "IPV4V6">("IPV4V6");
const [newRoamingIPVersion, setNewRoamingIPVersion] = useState<"IP" | "IPV6" | "IPV4V6">("IP");
const [newAuthType, setNewAuthType] = useState<"NONE" | "PAP" | "CHAP" | "PAP_OR_CHAP">("NONE");
const [clearPassword, setClearPassword] = useState(false);
const [adding, setAdding] = useState(false);
const [pendingKey, setPendingKey] = useState("");
const load = useCallback(async () => {
setLoading(true);
const [customResult, modemResult] = await Promise.allSettled([
getCardAPNs(iccid),
deviceOnline ? getDeviceAPNs(deviceId) : Promise.resolve({ items: [] as ModemAPNProfile[] }),
]);
setCustomProfiles(customResult.status === "fulfilled" ? customResult.value.items || [] : []);
setModemProfiles(modemResult.status === "fulfilled" ? modemResult.value.items || [] : []);
setLoading(false);
}, [deviceId, deviceOnline, iccid]);
useEffect(() => {
load();
}, [load]);
const rows = useMemo<APNRow[]>(() => {
const result: APNRow[] = [
{ key: "automatic", apn: "", ipVersion: "IPV4V6", source: "automatic" },
];
for (const item of modemProfiles) {
result.push({
key: `modem:${item.cid}:${item.apn}:${item.ipVersion}`,
apn: item.apn,
ipVersion: item.ipVersion,
source: "modem",
cid: item.cid,
});
}
for (const item of customProfiles) {
result.push({
key: `custom:${item.id}`,
apn: item.apn,
ipVersion: item.ipVersion,
source: "custom",
customID: item.id,
username: item.username,
hasPassword: item.hasPassword,
proxy: item.proxy,
mcc: item.mcc,
mnc: item.mnc,
roamingIPVersion: item.roamingIpVersion,
authType: item.authType,
});
}
return result;
}, [customProfiles, modemProfiles]);
function isActive(row: APNRow) {
const activeAPN = policy?.apn || "";
const activeIP = policy?.ipVersion || "IPV4V6";
if (row.source === "automatic") return activeAPN === "";
if (row.apn !== activeAPN || row.ipVersion !== activeIP) return false;
const activeCustom = customProfiles.some((item) => item.apn === activeAPN && item.ipVersion === activeIP);
return row.source === "custom" || !activeCustom;
}
async function enable(row: APNRow) {
setPendingKey(row.key);
try {
const saved = await updateCardPolicy(iccid, { apn: row.apn, ipVersion: row.ipVersion });
onSaved(saved);
message.success(row.source === "automatic" ? t("已使用运营商自动 APN 配置") : t("APN 已启用"));
} catch (error) {
message.error(apiMessage(error) || t("启用 APN 失败"));
} finally {
setPendingKey("");
}
}
function openEditor(profile?: CardAPNProfile) {
setEditingProfile(profile || null);
setNewAPN(profile?.apn || "");
setNewUsername(profile?.username || "");
setNewPassword("");
setNewProxy(profile?.proxy || "");
setNewMCC(profile?.mcc || "");
setNewMNC(profile?.mnc || "");
setNewIPVersion(profile?.ipVersion || "IPV4V6");
setNewRoamingIPVersion(profile?.roamingIpVersion || "IP");
setNewAuthType(profile?.authType || "NONE");
setClearPassword(false);
setEditorOpen(true);
}
async function saveEditor() {
const cleanAPN = newAPN.trim();
if (!cleanAPN || !APN_PATTERN.test(cleanAPN)) {
message.warning(t("APN 只能包含字母、数字、点、下划线或连字符,且最长 100 个字符"));
return;
}
if (newMCC && !/^\d{3}$/.test(newMCC)) {
message.warning(t("MCC 必须是 3 位数字"));
return;
}
if (newMNC && !/^\d{2,3}$/.test(newMNC)) {
message.warning(t("MNC 必须是 2 或 3 位数字"));
return;
}
setAdding(true);
try {
const payload = {
apn: cleanAPN,
username: newUsername.trim(),
proxy: newProxy.trim(),
mcc: newMCC.trim(),
mnc: newMNC.trim(),
ipVersion: newIPVersion,
roamingIpVersion: newRoamingIPVersion,
authType: newAuthType,
};
if (editingProfile) {
await updateCardAPN(iccid, editingProfile.id, {
...payload,
...(newPassword ? { password: newPassword } : {}),
clearPassword,
});
} else {
await createCardAPN(iccid, { ...payload, password: newPassword });
}
setEditorOpen(false);
await load();
message.success(editingProfile ? t("自定义 APN 已修改") : t("自定义 APN 已添加,请点击启用后使用"));
} catch (error) {
message.error(apiMessage(error) || (editingProfile ? t("修改 APN 失败") : t("添加 APN 失败")));
} finally {
setAdding(false);
}
}
async function remove(row: APNRow) {
if (!row.customID) return;
const confirmed = await confirmDialog(
t("确定删除这个自定义 APN 配置吗?"),
t("删除 APN"),
{ type: "danger", confirmVariant: "danger", confirmText: t("删除") },
);
if (!confirmed) return;
setPendingKey(row.key);
try {
const wasActive = isActive(row);
await deleteCardAPN(iccid, row.customID);
if (wasActive && policy) {
onSaved({ ...policy, apn: "", ipVersion: "IPV4V6" });
}
await load();
message.success(wasActive ? t("APN 已删除,并恢复运营商自动配置") : t("自定义 APN 已删除"));
} catch (error) {
message.error(apiMessage(error) || t("删除 APN 失败"));
} finally {
setPendingKey("");
}
}
function edit(row: APNRow) {
const profile = customProfiles.find((item) => item.id === row.customID);
if (profile) openEditor(profile);
}
const sourceLabel = (row: APNRow) => {
if (row.source === "automatic") return t("默认");
if (row.source === "modem") return t("模组已有");
return t("自定义");
};
const protocolLabel = (value?: "IP" | "IPV6" | "IPV4V6") => value === "IP" ? "IPv4" : value === "IPV6" ? "IPv6" : value === "IPV4V6" ? "IPv4 / IPv6" : "—";
return (
<div className="rounded-lg border border-gray-200/70 bg-white/60 p-3 dark:border-white/10 dark:bg-black/10">
<div className="mb-3 flex flex-wrap items-start justify-between gap-2">
<div>
<div className="text-sm font-semibold text-gray-800 dark:text-gray-100">{t("蜂窝 APN")}</div>
<div className="mt-0.5 text-[11px] text-gray-500 dark:text-gray-400">
{t("APN 列表和启用状态跟随当前 ICCID/Profile 保存")}
</div>
</div>
<Button size="small" variant="primary" plain onClick={() => openEditor()}>
{t("新增 APN")}
</Button>
</div>
{loading ? (
<div className="flex items-center justify-center gap-2 py-6 text-xs text-gray-400">
<Spinner className="h-4 w-4 animate-spin" /> {t("正在读取 APN 列表...")}
</div>
) : (
<div className="overflow-x-auto rounded-lg border border-gray-200/70 dark:border-white/10">
<table className="w-full min-w-[1050px] text-left text-xs">
<thead className="bg-gray-50 text-gray-500 dark:bg-white/5 dark:text-gray-400">
<tr>
<th className="px-3 py-2 font-semibold">APN</th>
<th className="px-3 py-2 font-semibold">{t("账号 / 认证")}</th>
<th className="px-3 py-2 font-semibold">MCC / MNC</th>
<th className="px-3 py-2 font-semibold">{t("协议 / 漫游")}</th>
<th className="px-3 py-2 font-semibold">Proxy</th>
<th className="px-3 py-2 font-semibold">{t("来源")}</th>
<th className="px-3 py-2 font-semibold">{t("状态")}</th>
<th className="px-3 py-2 text-right font-semibold">{t("操作")}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-white/10">
{rows.map((row) => {
const active = isActive(row);
return (
<tr key={row.key} className={active ? "bg-sky-50/60 dark:bg-sky-500/5" : "bg-white/40 dark:bg-transparent"}>
<td className="px-3 py-2.5 font-mono text-gray-800 dark:text-gray-100">
{row.source === "automatic" ? t("运营商自动配置") : row.apn}
{row.cid ? <span className="ml-2 text-[10px] text-gray-400">CID {row.cid}</span> : null}
</td>
<td className="px-3 py-2.5 text-gray-600 dark:text-gray-300">
<div>{row.username || "—"}{row.hasPassword ? <span className="ml-1 text-[10px] text-gray-400">{t("已设密码")}</span> : null}</div>
{row.authType && row.authType !== "NONE" ? <div className="mt-0.5 text-[10px] text-gray-400">{row.authType.replace("PAP_OR_CHAP", "PAP / CHAP")}</div> : null}
</td>
<td className="px-3 py-2.5 text-gray-600 dark:text-gray-300">{row.mcc || "—"} / {row.mnc || "—"}</td>
<td className="px-3 py-2.5 text-gray-600 dark:text-gray-300"><div>{protocolLabel(row.ipVersion)}</div><div className="mt-0.5 text-[10px] text-gray-400">{t("漫游")}{protocolLabel(row.roamingIPVersion)}</div></td>
<td className="max-w-[150px] truncate px-3 py-2.5 text-gray-600 dark:text-gray-300">{row.proxy || "—"}</td>
<td className="px-3 py-2.5"><Tag type={row.source === "custom" ? "primary" : "info"}>{sourceLabel(row)}</Tag></td>
<td className="px-3 py-2.5">{active ? <Tag type="success">{t("使用中")}</Tag> : <span className="text-gray-400"></span>}</td>
<td className="whitespace-nowrap px-3 py-2.5 text-right">
<Button size="small" variant={active ? "default" : "primary"} plain={!active} disabled={active} loading={pendingKey === row.key} onClick={() => enable(row)}>
{active ? t("已启用") : t("启用")}
</Button>
{row.source === "custom" ? (
<>
<Button className="ml-1" size="small" onClick={() => edit(row)}>{t("修改")}</Button>
<Button className="ml-1" size="small" variant="danger" plain loading={pendingKey === row.key} onClick={() => remove(row)}>{t("删除")}</Button>
</>
) : null}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{!deviceOnline ? <div className="mt-2 text-[11px] text-amber-600 dark:text-amber-400">{t("设备离线:自定义列表仍可管理,模组已有 APN 将在上线后读取")}</div> : null}
<Modal
open={editorOpen}
onClose={() => setEditorOpen(false)}
title={editingProfile ? t("修改 APN 配置") : t("新增 APN 配置")}
width="max-w-4xl"
closeOnOverlay={!adding}
footer={
<>
<Button disabled={adding} onClick={() => setEditorOpen(false)}>{t("取消")}</Button>
<Button variant="primary" loading={adding} onClick={saveEditor}>{editingProfile ? t("保存修改") : t("添加到列表")}</Button>
</>
}
>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<label className="space-y-1"><span className="text-xs font-semibold text-gray-600 dark:text-gray-300">APN *</span><Input value={newAPN} maxLength={100} onChange={(event) => setNewAPN(event.target.value)} placeholder="giffgaff.com" /></label>
<label className="space-y-1"><span className="text-xs font-semibold text-gray-600 dark:text-gray-300">{t("用户名")}</span><Input value={newUsername} maxLength={128} onChange={(event) => setNewUsername(event.target.value)} placeholder="gg" /></label>
<label className="space-y-1">
<span className="text-xs font-semibold text-gray-600 dark:text-gray-300">{t("密码")}</span>
<Input type="password" value={newPassword} disabled={clearPassword} maxLength={128} onChange={(event) => setNewPassword(event.target.value)} placeholder={editingProfile?.hasPassword ? t("留空表示保持原密码") : "p"} />
</label>
<label className="space-y-1"><span className="text-xs font-semibold text-gray-600 dark:text-gray-300">Proxy</span><Input value={newProxy} maxLength={255} onChange={(event) => setNewProxy(event.target.value)} placeholder={t("留空")} /></label>
<label className="space-y-1"><span className="text-xs font-semibold text-gray-600 dark:text-gray-300">MCC</span><Input inputMode="numeric" value={newMCC} maxLength={3} onChange={(event) => setNewMCC(event.target.value.replace(/\D/g, ""))} placeholder="234" /></label>
<label className="space-y-1"><span className="text-xs font-semibold text-gray-600 dark:text-gray-300">MNC</span><Input inputMode="numeric" value={newMNC} maxLength={3} onChange={(event) => setNewMNC(event.target.value.replace(/\D/g, ""))} placeholder="10" /></label>
<label className="space-y-1"><span className="text-xs font-semibold text-gray-600 dark:text-gray-300">{t("APN 协议")}</span><Select value={newIPVersion} onChange={(value) => setNewIPVersion(value as "IP" | "IPV6" | "IPV4V6")} options={[{ value: "IPV4V6", label: "IPv4 / IPv6" }, { value: "IP", label: "IPv4" }, { value: "IPV6", label: "IPv6" }]} /></label>
<label className="space-y-1"><span className="text-xs font-semibold text-gray-600 dark:text-gray-300">{t("APN 漫游协议")}</span><Select value={newRoamingIPVersion} onChange={(value) => setNewRoamingIPVersion(value as "IP" | "IPV6" | "IPV4V6")} options={[{ value: "IP", label: "IPv4" }, { value: "IPV4V6", label: "IPv4 / IPv6" }, { value: "IPV6", label: "IPv6" }]} /></label>
<label className="space-y-1"><span className="text-xs font-semibold text-gray-600 dark:text-gray-300">{t("认证类型")}</span><Select value={newAuthType} onChange={(value) => setNewAuthType(value as "NONE" | "PAP" | "CHAP" | "PAP_OR_CHAP")} options={[{ value: "NONE", label: t("无") }, { value: "PAP", label: "PAP" }, { value: "CHAP", label: "CHAP" }, { value: "PAP_OR_CHAP", label: "PAP / CHAP" }]} /></label>
</div>
{editingProfile?.hasPassword ? (
<div className="mt-4 flex items-center justify-between rounded-lg bg-gray-50 px-3 py-2 dark:bg-white/5">
<div><div className="text-xs font-semibold text-gray-700 dark:text-gray-200">{t("清除已保存密码")}</div><div className="text-[11px] text-gray-400">{t("关闭时,密码输入框留空会保持原密码")}</div></div>
<Switch checked={clearPassword} onChange={(value) => { setClearPassword(value); if (value) setNewPassword(""); }} />
</div>
) : null}
</Modal>
</div>
);
}
+84 -16
View File
@@ -1,24 +1,31 @@
import { useEffect, useState } from "react";
import { CardUiRegular } from "@fluentui/react-icons";
import { Tag } from "../ui";
import { Button, Input, Tag, message } from "../ui";
import { PolicySwitchCard } from "./PolicySwitchCard";
import { CardPolicyAPN } from "./CardPolicyAPN";
import { useCardPolicyToggles } from "./useCardPolicyToggles";
import { enableVoWiFi, disableVoWiFi, setFlightMode } from "./deviceActions";
import { enableVoWiFi, disableVoWiFi, setFlightMode, updateCardPolicy } from "./deviceActions";
import type { CardPolicy } from "../../types";
import { useI18n } from "../../lib/i18n";
import { apiMessage } from "../../api";
export interface CardPolicyPanelProps {
deviceId: string;
iccid?: string;
policy: CardPolicy | null;
deviceOnline: boolean;
onPolicyChanged: () => void;
onPolicyChanged: () => void | Promise<void>;
wifiCallingOnly?: boolean;
}
export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolicyChanged }: CardPolicyPanelProps) {
export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolicyChanged, wifiCallingOnly = false }: CardPolicyPanelProps) {
const { t } = useI18n();
const operable = deviceOnline && !!iccid;
const flags = policy
? { vowifiEnabled: policy.vowifiEnabled, airplaneEnabled: policy.airplaneEnabled }
const currentPolicy = policy?.iccid === iccid ? policy : null;
const [customPhoneNumber, setCustomPhoneNumber] = useState(currentPolicy?.customPhoneNumber || "");
const [phoneSaving, setPhoneSaving] = useState(false);
const flags = currentPolicy
? { vowifiEnabled: currentPolicy.vowifiEnabled, airplaneEnabled: currentPolicy.airplaneEnabled }
: null;
const toggles = useCardPolicyToggles(flags, {
@@ -27,8 +34,30 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
onChanged: onPolicyChanged,
});
const sourceLabel = policy ? (policy.source === "user" ? t("手动设置") : t("自动默认")) : "";
const isManual = currentPolicy?.source === "user" || currentPolicy?.source === "manual";
const sourceLabel = currentPolicy ? (isManual ? t("手动设置") : t("自动默认")) : "";
const { local } = toggles;
const savedPhoneNumber = currentPolicy?.customPhoneNumber || "";
const phoneChanged = customPhoneNumber.trim() !== savedPhoneNumber;
useEffect(() => {
setCustomPhoneNumber(currentPolicy?.customPhoneNumber || "");
}, [iccid, currentPolicy?.customPhoneNumber]);
const saveCustomPhoneNumber = async () => {
if (!iccid || phoneSaving || !phoneChanged) return;
setPhoneSaving(true);
try {
const saved = await updateCardPolicy(iccid, { customPhoneNumber: customPhoneNumber.trim() });
setCustomPhoneNumber(saved.customPhoneNumber || "");
message.success(saved.customPhoneNumber ? t("自定义手机号已保存") : t("已恢复显示系统读取的号码"));
await onPolicyChanged();
} catch (error) {
message.error(apiMessage(error) || t("保存自定义手机号失败"));
} finally {
setPhoneSaving(false);
}
};
return (
<div>
@@ -38,7 +67,7 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
</div>
<div>
<div className="text-lg font-bold text-gray-900 dark:text-white">{t("卡策略")}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{t("VoWiFi / 飞行模式 开关跟着 SIM 卡走,切换即时生效")}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{wifiCallingOnly ? t("USB SIM 读卡器仅用于 WiFi Calling,策略跟随 ICCID 保存") : t("VoWiFi / 飞行模式 开关跟着 SIM 卡走,切换即时生效")}</div>
</div>
</div>
{!iccid ? (
@@ -51,15 +80,47 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
) : null}
{iccid ? (
<div className="space-y-3">
<div className="ui-panel-muted flex items-center justify-between p-3">
<div>
<div className="mb-0.5 text-xs font-bold uppercase tracking-wider text-gray-500">{t("当前卡 ICCID")}</div>
<div className="font-mono text-sm text-gray-800 dark:text-gray-100">{iccid}</div>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<div className="ui-panel-muted flex min-w-0 items-center justify-between gap-3 p-3">
<div className="min-w-0">
<div className="mb-0.5 text-xs font-bold uppercase tracking-wider text-gray-500">{t("当前卡 ICCID")}</div>
<div className="truncate font-mono text-sm text-gray-800 dark:text-gray-100" title={iccid}>{iccid}</div>
</div>
{sourceLabel ? <Tag type={isManual ? "primary" : "info"}>{sourceLabel}</Tag> : null}
</div>
<div className="ui-panel-muted p-3">
<div className="mb-1.5 text-xs font-bold uppercase tracking-wider text-gray-500">{t("自定义手机号")}</div>
<div className="flex items-center gap-2">
<Input
value={customPhoneNumber}
onChange={(event) => setCustomPhoneNumber(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void saveCustomPhoneNumber();
}}
placeholder={t("请输入手机号(可留空)")}
inputMode="tel"
maxLength={32}
disabled={phoneSaving}
aria-label={t("自定义手机号")}
/>
<Button
variant="primary"
size="small"
className="shrink-0 !border-0"
loading={phoneSaving}
disabled={!phoneChanged}
onClick={() => void saveCustomPhoneNumber()}
>
{t("保存")}
</Button>
</div>
<div className="mt-1.5 text-[11px] leading-4 text-gray-500 dark:text-gray-400">
{t("支持开头的 + 和 3-20 位数字;留空时显示系统从 SIM/网络读取的号码")}
</div>
</div>
{sourceLabel ? <Tag type={policy?.source === "user" ? "primary" : "info"}>{sourceLabel}</Tag> : null}
</div>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<PolicySwitchCard
<PolicySwitchCard
title="VoWiFi"
subtitle={t("启用时强制关闭蜂窝射频;关闭 VoWiFi 后仍保持飞行模式")}
tone="orange"
@@ -69,7 +130,7 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
failed={toggles.vowifiFailed}
onToggle={toggles.onVoWiFiToggle}
/>
<PolicySwitchCard
{!wifiCallingOnly ? <PolicySwitchCard
title={t("飞行模式")}
subtitle={t("只有手动关闭此开关才允许设备连接基站")}
tone="indigo"
@@ -78,8 +139,15 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
pending={toggles.airplanePending}
failed={toggles.airplaneFailed}
onToggle={toggles.onAirplaneToggle}
/>
/> : null}
</div>
{!wifiCallingOnly ? <CardPolicyAPN
deviceId={deviceId}
iccid={iccid}
policy={currentPolicy}
deviceOnline={deviceOnline}
onSaved={onPolicyChanged}
/> : null}
</div>
) : null}
</div>
@@ -30,7 +30,7 @@ function isQmiMode(d?: DiscoveredDevice | null): boolean {
}
function modeLabel(d?: DiscoveredDevice | null): string {
const m = String(d?.mode || "unknown").toLowerCase();
return m === "qmi" ? "QMI" : m === "mbim" ? "MBIM" : m === "ecm" ? "ECM" : m === "rndis" ? "RNDIS" : m === "ncm" ? "NCM" : "UNKNOWN";
return m === "pcsc" ? "PC/SC" : m === "qmi" ? "QMI" : m === "mbim" ? "MBIM" : m === "ecm" ? "ECM" : m === "rndis" ? "RNDIS" : m === "ncm" ? "NCM" : "UNKNOWN";
}
function Field({ label, children }: { label: ReactNode; children: ReactNode }) {
@@ -47,6 +47,7 @@ export function DeviceAddDialog(props: DeviceAddDialogProps) {
const { addSelected, addConfig } = props;
const fixedQmi = isQmiControl(addSelected?.controlPath || addConfig?.controlDevice);
const isMbim = String(addSelected?.mode || "").toLowerCase() === "mbim";
const isReader = addSelected?.hardwareKind === "pcsc" || String(addSelected?.mode || "").toLowerCase() === "pcsc";
useEffect(() => {
if (fixedQmi && addConfig.deviceBackend !== "qmi") props.onConfigChange({ ...addConfig, deviceBackend: "qmi" });
@@ -57,7 +58,7 @@ export function DeviceAddDialog(props: DeviceAddDialogProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMbim]);
const backendOptions = [
const backendOptions = isReader ? [{ value: "pcsc", label: "PC/SC" }] : [
...(isMbim
? []
: [
@@ -161,6 +162,9 @@ export function DeviceAddDialog(props: DeviceAddDialogProps) {
<Field label={t("控制设备")}>
<Input value={addConfig.controlDevice} disabled />
</Field>
{isReader ? <Field label="SIM PIN">
<Input type="password" value={addConfig.simPin} onChange={(e) => set({ simPin: e.target.value })} maxLength={8} inputMode="numeric" placeholder={t("仅在 SIM 启用 PIN 时填写")} />
</Field> : null}
<div className="flex items-center justify-between rounded-xl border border-gray-200 bg-gray-50 p-3">
<div>
<div className="text-sm font-bold text-gray-800">{t("设备后端模式")}</div>
@@ -173,7 +177,7 @@ export function DeviceAddDialog(props: DeviceAddDialogProps) {
onChange={(v) => set({ deviceBackend: v })}
className="w-[110px]"
placeholder="AT"
disabled={fixedQmi || isMbim}
disabled={fixedQmi || isMbim || isReader}
options={backendOptions}
/>
</div>
@@ -34,13 +34,14 @@ export function DeviceConfigTab({ editConfig, deviceStatus, saving, deleting, on
const usbPath = deviceStatus?.usbPath || editConfig?.usbPath;
const isQmi = isQmiControl(controlDevice);
const isMbim = String(editConfig?.deviceBackend || "").toLowerCase() === "mbim";
const isReader = editConfig?.deviceType === "usb_sim_reader";
useEffect(() => {
if (isQmi && editConfig && editConfig.deviceBackend !== "qmi") onEditConfig({ ...editConfig, deviceBackend: "qmi" });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isQmi]);
const backendOptions = [
const backendOptions = isReader ? [{ value: "pcsc", label: "PC/SC" }] : [
...(isMbim
? []
: [
@@ -106,6 +107,9 @@ export function DeviceConfigTab({ editConfig, deviceStatus, saving, deleting, on
<Field label={t("控制设备")}>
<Input value={controlDevice || ""} disabled placeholder={t("由系统自动探测")} />
</Field>
{isReader ? <Field label="SIM PIN">
<Input type="password" value={editConfig.simPin || ""} onChange={(e) => onEditConfig({ ...editConfig, simPin: e.target.value })} maxLength={8} inputMode="numeric" placeholder={t("留空表示不修改;仅在 SIM 启用 PIN 时填写")} />
</Field> : null}
<div className="ui-panel-muted space-y-2 p-3">
<div className="flex items-center justify-between">
<div>
@@ -123,7 +127,7 @@ export function DeviceConfigTab({ editConfig, deviceStatus, saving, deleting, on
onChange={(v) => onEditConfig({ ...editConfig, deviceBackend: v as DeviceConfig["deviceBackend"] })}
className="w-[120px]"
placeholder="AT"
disabled={isQmi || isMbim}
disabled={isQmi || isMbim || isReader}
options={backendOptions}
/>
</div>
@@ -15,6 +15,7 @@ export interface DeviceDetailHeaderProps {
onReconnectVowifi: () => void;
onRebootModem: () => void;
onOpenSms: () => void;
wifiCallingOnly?: boolean;
}
export function DeviceDetailHeader(props: DeviceDetailHeaderProps) {
@@ -42,7 +43,7 @@ export function DeviceDetailHeader(props: DeviceDetailHeaderProps) {
<Button loading={props.reconnectingVoWiFi} onClick={props.onReconnectVowifi} className="ui-glass-border !border-0" icon={<ArrowSyncRegular />}>
{t("重连 VoWiFi")}
</Button>
) : device.developerEnabled ? (
) : device.developerEnabled && !props.wifiCallingOnly ? (
<div
className="ui-glass-border flex h-8 items-center gap-2 rounded-lg px-3 text-sm text-gray-700 dark:text-gray-200"
title={t("蜂窝数据仅进入 Export Proxy 的受保护路由,不会成为主机默认出口")}
@@ -58,9 +59,9 @@ export function DeviceDetailHeader(props: DeviceDetailHeaderProps) {
/>
</div>
) : null}
<Button loading={props.rebooting} onClick={props.onRebootModem} className="ui-glass-border !border-0 hover:!text-red-600" icon={<PowerRegular />}>
{!props.wifiCallingOnly ? <Button loading={props.rebooting} onClick={props.onRebootModem} className="ui-glass-border !border-0 hover:!text-red-600" icon={<PowerRegular />}>
{t("重启模组")}
</Button>
</Button> : null}
<Button onClick={props.onOpenSms} className="ui-glass-border !border-0" icon={<ChatRegular />}>
{t("短信")}
</Button>
@@ -12,6 +12,7 @@ import { isVoWiFiInUse } from "./shared";
export interface DeviceOverviewTabProps {
device: DeviceDetail;
simOperatorDisplay: string;
customPhoneNumber?: string;
trafficSpeedRx: string;
trafficSpeedTx: string;
trafficMinuteRx: string;
@@ -25,12 +26,13 @@ export function DeviceOverviewTab(props: DeviceOverviewTabProps) {
const { t } = useI18n();
const [operatorOpen, setOperatorOpen] = useState(false);
const { device } = props;
const wifiCallingOnly = device.deviceType === "usb_sim_reader";
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<div className={`grid grid-cols-1 gap-4 ${wifiCallingOnly ? "lg:grid-cols-2" : "lg:grid-cols-3"}`}>
<div className="ui-panel-muted p-4">
<div className="mb-3 text-xs font-bold uppercase tracking-wider text-gray-500">{t("运行状态")}</div>
{isVoWiFiInUse(device) ? (
{isVoWiFiInUse(device) && !(device.modem?.imei && device.modem?.simInserted === false) ? (
<OverviewVowifiCard device={device} />
) : (
<OverviewNetworkCard device={device} onOpenOperatorSelection={() => setOperatorOpen(true)} />
@@ -39,19 +41,20 @@ export function DeviceOverviewTab(props: DeviceOverviewTabProps) {
<OverviewSimPanel
device={device}
simOperatorDisplay={props.simOperatorDisplay}
customPhoneNumber={props.customPhoneNumber}
e911Starting={props.e911Starting}
onSetupE911={props.onSetupE911}
/>
<OverviewNetworkPanel
{!wifiCallingOnly ? <OverviewNetworkPanel
device={device}
trafficMinuteRx={props.trafficMinuteRx}
trafficMinuteTx={props.trafficMinuteTx}
trafficSpeedRx={props.trafficSpeedRx}
trafficSpeedTx={props.trafficSpeedTx}
/>
/> : null}
</div>
{device.developerEnabled && device.networkEnabled && device.id ? <OverviewTrafficChart deviceId={device.id} /> : null}
{device?.id ? (
{device?.id && !wifiCallingOnly ? (
<OperatorSelectionDialog
open={operatorOpen}
deviceId={device.id}
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { Button, Spinner } from "../ui";
import { PolicySwitchCard } from "./PolicySwitchCard";
import { CardPolicyAPN } from "./CardPolicyAPN";
import { useCardPolicyToggles } from "./useCardPolicyToggles";
import { getCardPolicy, putCardPolicy, enableVoWiFi, disableVoWiFi, setFlightMode } from "./deviceActions";
import type { CardPolicy } from "../../types";
@@ -87,6 +88,16 @@ export function EsimCardPolicyInline({ deviceId, iccid, isActiveCard, deviceOnli
onToggle={toggles.onAirplaneToggle}
/>
</div>
<CardPolicyAPN
deviceId={deviceId}
iccid={iccid}
policy={policy}
deviceOnline={deviceOnline}
onSaved={(saved) => {
setPolicy(saved);
onPolicyChanged();
}}
/>
</>
)}
</div>
@@ -1,4 +1,5 @@
import { cx } from "../../lib/utils";
import { CountryFlag } from "../CountryFlag";
import { EsimProfileRow } from "./EsimProfileRow";
import type { EsimChipInfo, EsimEid, EsimProfileGroup } from "./types";
import { useI18n } from "../../lib/i18n";
@@ -35,11 +36,12 @@ function normAid(aid?: string): string {
return (aid || "").trim().toUpperCase();
}
function manufacturerFlag(manufacturer?: string): string {
function manufacturerCountryCode(manufacturer?: string): string {
const value = (manufacturer || "").toLowerCase();
if (value.includes("eastcompeace") || value.includes("watchdata")) return "🇨🇳";
if (value.includes("giesecke") || value.includes("g+d")) return "🇩🇪";
if (value.includes("thales") || value.includes("idemia")) return "🇫🇷";
if (value.includes("eastcompeace") || value.includes("watchdata") || value.includes("hutopt")) return "CN";
if (value.includes("giesecke") || value.includes("g+d")) return "DE";
if (value.includes("thales") || value.includes("idemia")) return "FR";
if (value.includes("gemalto")) return "CH";
return "";
}
@@ -56,7 +58,9 @@ function PkiInfo({ eid }: { eid: EsimEid }) {
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-gray-400 dark:text-gray-500">
{eid.manufacturer ? (
<span className="inline-flex items-center gap-1">
<span className="text-[10px]">{t("生产商:")}</span> {eid.manufacturer} {manufacturerFlag(eid.manufacturer)}
<span className="text-[10px]">{t("生产商:")}</span>
<span>{eid.manufacturer}</span>
<CountryFlag countryCode={manufacturerCountryCode(eid.manufacturer)} />
</span>
) : null}
{eid.certificates && eid.certificates.length ? (
@@ -50,7 +50,7 @@ export function EsimNotificationsModal({ open, loading, items, retryingSeq, onCl
<div className="max-h-[420px] space-y-2 overflow-auto pr-1">
{items.map((item) => (
<div
key={item.sequenceNumber}
key={`${item.aidHex || "default"}:${item.sequenceNumber}`}
className="flex flex-col gap-3 rounded-xl border border-gray-200 p-3 dark:border-white/10 sm:flex-row sm:items-start sm:justify-between"
>
<div className="min-w-0 flex-1 space-y-1">
@@ -26,17 +26,20 @@ export function OverviewNetworkCard({ device, onOpenOperatorSelection }: { devic
const modem = device.modem;
const online = isDeviceOnline(device);
const cellularRegistered = isRegistered(device);
const simMissing = !!modem?.imei && modem?.simInserted === false;
// A persisted runtime may briefly describe the old session while disable is
// being cleaned up. Desired policy is authoritative for the overview badge.
const vowifiRegistered = !!device.vowifiEnabled && !!(device.vowifiActive || device.vowifiRuntime?.smsReady);
const registered = cellularRegistered || vowifiRegistered;
const registered = !simMissing && (cellularRegistered || vowifiRegistered);
const radioOffForVowifi = vowifiRegistered && (modem?.operatingMode === 0 || modem?.operatingMode === 4 || device.flightMode);
const tone = isRecoveringPhase(device.lifecyclePhase) ? "warning" : online ? (registered ? "success" : "warning") : "danger";
let statusText: string;
const phaseLabel = lifecycleLabel(device.lifecyclePhase);
if (phaseLabel && device.lifecyclePhase !== "online" && device.lifecyclePhase !== "offline") statusText = phaseLabel;
else if (online) {
else if (online) {
if (simMissing) statusText = t("SIM卡未插入");
else
statusText = registered
? ""
: device.registrationStateLabel === "searching"
@@ -49,7 +52,9 @@ export function OverviewNetworkCard({ device, onOpenOperatorSelection }: { devic
const level = signalLevel(modem?.signalDbm);
const sigTone = signalTone(modem?.signalDbm);
const netMode = [modem?.networkDuplex, modem?.networkMode].filter(Boolean).join(" ");
const cellularRegistrationText = modem?.regStatus === 5
const cellularRegistrationText = simMissing
? t("SIM卡未插入")
: modem?.regStatus === 5
? t("已驻网(漫游)")
: modem?.regStatus === 1
? t("已驻网")
@@ -10,11 +10,12 @@ import { CountryFlag } from "../CountryFlag";
export interface OverviewSimPanelProps {
device: DeviceDetail;
simOperatorDisplay: string;
customPhoneNumber?: string;
e911Starting: boolean;
onSetupE911: () => void;
}
export function OverviewSimPanel({ device, simOperatorDisplay, e911Starting, onSetupE911 }: OverviewSimPanelProps) {
export function OverviewSimPanel({ device, simOperatorDisplay, customPhoneNumber, e911Starting, onSetupE911 }: OverviewSimPanelProps) {
const { t } = useI18n();
const [showSensitive, toggleSensitive] = useShowSensitive();
const modem = device.modem;
@@ -22,6 +23,7 @@ export function OverviewSimPanel({ device, simOperatorDisplay, e911Starting, onS
const activeEsim = (device.activeEsimProfileName || "").trim();
const flightOn = device.vowifiActive || modem?.operatingMode === 0 || modem?.operatingMode === 4;
const carrierCountryCode = carrierBrandIso(modem?.nativeSpn, modem?.imsi);
const displayedPhoneNumber = customPhoneNumber?.trim() || device.localPhone || "--";
const backendLabel =
device.backendMode === "qmi" ? "QMI" : device.backendMode === "mbim" ? "MBIM" : device.backendMode === "at" ? "AT" : "Auto";
@@ -40,7 +42,7 @@ export function OverviewSimPanel({ device, simOperatorDisplay, e911Starting, onS
<FieldRow label="IMEI" value={modem?.imei} sensitive={sensitive} monospace copyable />
<FieldRow label="ICCID" value={modem?.iccid} sensitive={sensitive} monospace copyable />
<FieldRow label="IMSI" value={modem?.imsi} sensitive={sensitive} monospace copyable />
<FieldRow label={t("本机号码")} value={device.localPhone || "--"} sensitive={sensitive} monospace copyable />
<FieldRow label={t("本机号码")} value={displayedPhoneNumber} sensitive={sensitive} monospace copyable />
{device?.e911SetupAvailable ? (
<div className="flex justify-between gap-3">
<span className="text-gray-500">{t("E911地址")}</span>
+59 -1
View File
@@ -22,6 +22,64 @@ export function setFlightMode(deviceId: string, enabled: boolean) {
export function getCardPolicy(iccid: string) {
return api<CardPolicy>(`/cards/${iccid}/policy`);
}
export interface CardPolicyUpdate {
vowifiEnabled?: boolean;
airplaneEnabled?: boolean;
apn?: string;
ipVersion?: "IP" | "IPV6" | "IPV4V6";
customPhoneNumber?: string;
}
export function updateCardPolicy(iccid: string, body: CardPolicyUpdate) {
return api<CardPolicy>(`/cards/${iccid}/policy`, { method: "PUT", body });
}
export function putCardPolicy(iccid: string, body: { vowifiEnabled: boolean; airplaneEnabled: boolean }) {
return ok(api(`/cards/${iccid}/policy`, { method: "PUT", body }));
return ok(updateCardPolicy(iccid, body));
}
export interface ModemAPNProfile {
cid: number;
apn: string;
ipVersion: "IP" | "IPV6" | "IPV4V6";
}
export function getDeviceAPNs(deviceId: string) {
return api<{ items: ModemAPNProfile[] }>(`/devices/${deviceId}/network/apns`);
}
export interface CardAPNProfile {
id: number;
iccid: string;
apn: string;
username: string;
hasPassword: boolean;
proxy: string;
mcc: string;
mnc: string;
ipVersion: "IP" | "IPV6" | "IPV4V6";
roamingIpVersion: "IP" | "IPV6" | "IPV4V6";
authType: "NONE" | "PAP" | "CHAP" | "PAP_OR_CHAP";
createdAt?: string;
updatedAt?: string;
}
export function getCardAPNs(iccid: string) {
return api<{ items: CardAPNProfile[] }>(`/cards/${iccid}/apns`);
}
export interface CardAPNCreate {
apn: string;
username: string;
password: string;
proxy: string;
mcc: string;
mnc: string;
ipVersion: "IP" | "IPV6" | "IPV4V6";
roamingIpVersion: "IP" | "IPV6" | "IPV4V6";
authType: "NONE" | "PAP" | "CHAP" | "PAP_OR_CHAP";
}
export function createCardAPN(iccid: string, body: CardAPNCreate) {
return api<CardAPNProfile>(`/cards/${iccid}/apns`, { method: "POST", body });
}
export function updateCardAPN(iccid: string, id: number, body: Omit<CardAPNCreate, "password"> & { password?: string; clearPassword?: boolean }) {
return api<CardAPNProfile>(`/cards/${iccid}/apns/${id}`, { method: "PATCH", body });
}
export function deleteCardAPN(iccid: string, id: number) {
return api<{ deleted: boolean; id: number }>(`/cards/${iccid}/apns/${id}`, { method: "DELETE" });
}
+1
View File
@@ -44,6 +44,7 @@ export interface AddDeviceForm {
atPort: string;
controlDevice: string;
deviceBackend: string;
simPin: string;
}
export interface LoadError {
+51 -1
View File
@@ -6,7 +6,7 @@ import { Select } from "../ui/Select";
import { Switch } from "../ui/Switch";
import { ChannelHeader, EmptyLine, Field, UrlListEditor } from "./controls";
import { HEADER_NAME_SUGGESTIONS, nextHeaderRowId } from "./model";
import type { BarkForm, EmailForm, HeaderRow, WebhookForm } from "./model";
import type { BarkForm, EmailForm, HeaderRow, WebhookForm, WecomForm } from "./model";
const HEADER_LIST_ID = "vocat-webhook-header-names";
@@ -267,3 +267,53 @@ export function WebhookTab({ value, onChange, testing, onTest }: PushChannelProp
</div>
);
}
export function WecomTab({ value, onChange, testing, onTest }: PushChannelProps<WecomForm>) {
const { t, lang } = useI18n();
const off = !value.enabled;
const complete = hasAnyUrl(value.urls) && !!value.payloadTemplate.trim();
return (
<div className="pt-2">
<ChannelHeader
title={t("启用企业微信消息推送")}
enabled={value.enabled}
onToggle={(enabled) => onChange({ enabled })}
actions={
<Button size="small" variant="primary" plain loading={testing} disabled={off || !complete} onClick={onTest}>
{t("测试通知")}
</Button>
}
/>
<SMSOnlyHint />
<div className="space-y-4">
<div className="rounded-lg bg-gray-50 px-3 py-2 text-xs leading-5 text-gray-500 dark:bg-gray-800/60 dark:text-gray-400">
{t("每个企业微信消息推送 Webhook URL 单独占一行,点击添加 URL 新增一行;不使用逗号、空格或换行分隔多个 URL。")}
</div>
<UrlListEditor
urls={value.urls}
onChange={(urls) => onChange({ urls })}
enabled={value.enabled}
placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..."
emptyText={t("尚未配置任何企业微信消息推送 Webhook URL,点击右侧添加按钮。")}
/>
<Field
label={t("JSON 请求体模板")}
hint={
<>
{t("支持完整企业微信消息推送 JSON。变量必须作为 JSON 值使用,例如")} <code>{"{{message}}"}</code>{lang === "zh" ? "。" : "."}
{t("可用变量:{{event}}、{{title}}、{{message}}、{{timestamp}}、{{content}}、{{number}}、{{device_id}}、{{device_name}}、{{device_label}}、{{time}}。")}
</>
}
>
<Textarea
value={value.payloadTemplate}
onChange={(event) => onChange({ payloadTemplate: event.target.value })}
disabled={off}
rows={12}
className="font-mono text-xs"
/>
</Field>
</div>
</div>
);
}
+47 -17
View File
@@ -47,18 +47,32 @@ export interface EmailForm {
}
export interface PushplusForm {
enabled: boolean;
token: string;
topic: string;
channel: string;
enabled: boolean;
token: string;
topic: string;
channel: string;
}
export interface WecomForm {
enabled: boolean;
urls: string[];
payloadTemplate: string;
}
export const DEFAULT_WECOM_PAYLOAD_TEMPLATE = `{
"msgtype": "text",
"text": {
"content": {{message}}
}
}`;
export interface NotifyForms {
telegram: TelegramForm;
webhook: WebhookForm;
bark: BarkForm;
email: EmailForm;
pushplus: PushplusForm;
email: EmailForm;
pushplus: PushplusForm;
wecom: WecomForm;
}
// 系统保留头,自定义同名头会被忽略(品牌 vocat)
@@ -131,8 +145,9 @@ export function formsFromNotifications(data: Partial<NotificationSettings>): Not
const telegram = asRecord(data.telegram);
const webhook = asRecord(data.webhook);
const bark = asRecord(data.bark);
const email = asRecord(data.email);
const pushplus = asRecord(data.pushplus);
const email = asRecord(data.email);
const pushplus = asRecord(data.pushplus);
const wecom = asRecord(data.wecom);
return {
telegram: {
enabled: !!telegram.enabled,
@@ -171,13 +186,18 @@ export function formsFromNotifications(data: Partial<NotificationSettings>): Not
fromAddress: str(email.fromAddress),
toAddresses: joinList(email.toAddresses),
},
pushplus: {
enabled: !!pushplus.enabled,
token: str(pushplus.token),
topic: str(pushplus.topic),
channel: str(pushplus.channel) || "wechat",
},
};
pushplus: {
enabled: !!pushplus.enabled,
token: str(pushplus.token),
topic: str(pushplus.topic),
channel: str(pushplus.channel) || "wechat",
},
wecom: {
enabled: !!wecom.enabled,
urls: strList(wecom.urls),
payloadTemplate: str(wecom.payloadTemplate ?? wecom.payload_template) || DEFAULT_WECOM_PAYLOAD_TEMPLATE,
},
};
}
function splitList(value: string): string[] {
@@ -226,6 +246,15 @@ export function buildEmailPayload(form: EmailForm, forTest = false) {
};
}
export function buildWecomPayload(form: WecomForm, forTest = false) {
const urls = Array.isArray(form.urls) ? form.urls : [];
return {
enabled: !!form.enabled,
urls: forTest ? urls.map((url) => String(url || "").trim()).filter(Boolean) : urls,
payload_template: String(form.payloadTemplate || ""),
};
}
export function buildNotificationsPayload(forms: NotifyForms) {
return {
telegram: {
@@ -245,6 +274,7 @@ export function buildNotificationsPayload(forms: NotifyForms) {
channel: forms.pushplus.channel || "",
},
webhook: buildWebhookPayload(forms.webhook),
bark: buildBarkPayload(forms.bark),
};
bark: buildBarkPayload(forms.bark),
wecom: buildWecomPayload(forms.wecom),
};
}
+1
View File
@@ -6,6 +6,7 @@ export const DEVICE_TYPES: ReadonlyArray<{ value: DeviceType; label: string; ima
{ value: "wifi_410", label: "410 WiFi 棒(高通芯片)", image: "/410.png" },
{ value: "dji_4g", label: "大疆 4G 模块(移远芯片)", image: "/dj.png" },
{ value: "pcie_ec20_ec25", label: "PCIe EC20/EC25(移远芯片)", image: "/ec20.png" },
{ value: "usb_sim_reader", label: "USB SIM 读卡器(仅 WiFi Calling", image: "/sim-reader.svg" },
];
export function normalizeDeviceType(value?: string | null): DeviceType {
+73 -1
View File
@@ -5,6 +5,56 @@
* /
*/
export const EN_DICT: Record<string, string> = {
"USB SIM 读卡器(仅 WiFi Calling": "USB SIM Reader (WiFi Calling only)",
"仅在 SIM 启用 PIN 时填写": "Only enter this when SIM PIN is enabled",
"留空表示不修改;仅在 SIM 启用 PIN 时填写": "Leave blank to keep unchanged; only enter this when SIM PIN is enabled",
"USB SIM 读卡器仅用于 WiFi Calling,策略跟随 ICCID 保存": "The USB SIM reader is for WiFi Calling only; policy is saved per ICCID",
// Cellular APN profiles.
"蜂窝 APN": "Cellular APN",
"APN 列表和启用状态跟随当前 ICCID/Profile 保存": "APN profiles and the active selection are saved per ICCID/Profile",
"新增 APN": "Add APN",
"正在读取 APN 列表...": "Loading APN profiles...",
"账号 / 认证": "Account / Authentication",
"协议 / 漫游": "Protocol / Roaming",
: "Source",
"运营商自动配置": "Carrier Automatic",
: "Default",
"模组已有": "Modem",
: "Custom",
: "Roaming",
"已设密码": "Password set",
使: "Active",
"APN 已启用": "APN enabled",
"已使用运营商自动 APN 配置": "Carrier automatic APN configuration enabled",
"启用 APN 失败": "Failed to enable APN",
"设备离线:自定义列表仍可管理,模组已有 APN 将在上线后读取":
"Device offline: custom profiles can still be managed; modem APNs will load when the device comes online",
"新增 APN 配置": "Add APN Profile",
"修改 APN 配置": "Edit APN Profile",
"保存修改": "Save Changes",
"添加到列表": "Add to List",
: "Leave blank",
"留空表示保持原密码": "Leave blank to keep the current password",
"APN 协议": "APN Protocol",
"APN 漫游协议": "APN Roaming Protocol",
"认证类型": "Authentication Type",
"清除已保存密码": "Clear Saved Password",
"关闭时,密码输入框留空会保持原密码": "When disabled, leaving the password blank keeps the current password",
"APN 只能包含字母、数字、点、下划线或连字符,且最长 100 个字符":
"APN may contain only letters, numbers, dots, underscores, or hyphens, with a maximum of 100 characters",
"MCC 必须是 3 位数字": "MCC must be exactly 3 digits",
"MNC 必须是 2 或 3 位数字": "MNC must be 2 or 3 digits",
"自定义 APN 已添加,请点击启用后使用": "Custom APN added. Click Enable to use it",
"自定义 APN 已修改": "Custom APN updated",
"添加 APN 失败": "Failed to add APN",
"修改 APN 失败": "Failed to update APN",
"确定删除这个自定义 APN 配置吗?": "Delete this custom APN profile?",
"删除 APN": "Delete APN",
"APN 已删除,并恢复运营商自动配置": "APN deleted; carrier automatic configuration restored",
"自定义 APN 已删除": "Custom APN deleted",
"删除 APN 失败": "Failed to delete APN",
: "Edit",
// External extensions.
"插件": "Plugins",
"通过 URL 或本地插件包扩展 VoCat 功能": "Extend VoCat with a URL or a local plugin package",
@@ -229,6 +279,7 @@ export const EN_DICT: Record<string, string> = {
"Webhook 测试失败": "Webhook test failed",
"Bark 测试失败": "Bark test failed",
"Email 测试失败": "Email test failed",
"企业微信消息推送测试失败": "WeCom message push test failed",
// ---- 设置页:安全卡 ----
: "Security",
@@ -326,10 +377,20 @@ export const EN_DICT: Record<string, string> = {
"启用 Bark 推送": "Enable Bark",
"启用 Email 推送": "Enable Email",
"启用 Webhook 推送": "Enable Webhook",
"企业微信消息推送": "WeCom Message Push",
"启用企业微信消息推送": "Enable WeCom Message Push",
"Telegram / Bark / Email / Pushplus / Webhook / 企业微信消息推送": "Telegram / Bark / Email / Pushplus / Webhook / WeCom Message Push",
"目标 URLs": "Target URLs",
"添加 URL": "Add URL",
"尚未配置任何 Bark URL,点击右侧添加按钮。": "No Bark URLs yet. Click the add button on the right.",
"尚未配置任何 Webhook URL,点击右侧添加按钮。": "No Webhook URLs yet. Click the add button on the right.",
"每个企业微信消息推送 Webhook URL 单独占一行,点击添加 URL 新增一行;不使用逗号、空格或换行分隔多个 URL。":
"Enter one WeCom message push Webhook URL per line. Use Add URL to add another row; do not separate URLs with commas, spaces, or line breaks.",
"尚未配置任何企业微信消息推送 Webhook URL,点击右侧添加按钮。": "No WeCom message push Webhook URLs yet. Click the add button on the right.",
"JSON 请求体模板": "JSON Request Body Template",
"支持完整企业微信消息推送 JSON。变量必须作为 JSON 值使用,例如": "Supports a complete WeCom message push JSON payload. Use variables as JSON values, for example",
"可用变量:{{event}}、{{title}}、{{message}}、{{timestamp}}、{{content}}、{{number}}、{{device_id}}、{{device_name}}、{{device_label}}、{{time}}。":
"Available variables: {{event}}, {{title}}, {{message}}, {{timestamp}}, {{content}}, {{number}}, {{device_id}}, {{device_name}}, {{device_label}}, {{time}}.",
"分组 (Group)": "Group",
"例如 vocat": "e.g. vocat",
"iOS 设备上的通知分组。": "Notification group on iOS devices.",
@@ -708,7 +769,9 @@ export const EN_DICT: Record<string, string> = {
"暂无设备": "No devices",
"最后原因": "Last Reason",
"最后成功:": "Last success: ",
"未检测到 eUICC": "No eUICC detected",
"未检测到 eUICC": "No eUICC detected",
"SIM卡未插入": "No SIM card inserted",
"USB SIM读卡器仅支持VoWiFi短信和通话任务": "USB SIM readers support VoWiFi SMS and call tasks only",
"未生效": "Not in effect",
"未知": "Unknown",
"未知日期": "Unknown date",
@@ -939,6 +1002,15 @@ export const EN_DICT: Record<string, string> = {
"大疆 4G 模块(移远芯片)": "DJI 4G Module (Quectel)",
"PCIe EC20/EC25(移远芯片)": "PCIe EC20/EC25 (Quectel)",
// ---- Per-Profile phone display override ----
"自定义手机号": "Custom Phone Number",
"请输入手机号(可留空)": "Enter a phone number (optional)",
"自定义手机号已保存": "Custom phone number saved",
"已恢复显示系统读取的号码": "Restored the system-detected number",
"保存自定义手机号失败": "Failed to save custom phone number",
"支持开头的 + 和 3-20 位数字;留空时显示系统从 SIM/网络读取的号码":
"Supports a leading + and 320 digits. Leave blank to show the number read from the SIM/network.",
// ---- AT 快捷指令(按 group · item 分组翻译) ----
: "Basics",
: "Network Control",
+3 -7
View File
@@ -69,13 +69,9 @@ export function LanguageProvider({ children }: { children: ReactNode }) {
api("/settings/preferences", { method: "PUT", body: { language: next } }).catch(() => {});
}, []);
const t = useCallback(
(text: string) => {
if (!text || lang === "zh") return text;
return EN_DICT[text] ?? text;
},
[lang],
);
// 委托给模块级 tl(读取实时 activeLang),使 t 的函数身份稳定:
// 否则 useCallback 闭包会捕获到旧语言的 t,切换语言后出现标题/正文语言不一致。
const t = useCallback((text: string) => tl(text), []);
const value = useMemo(() => ({ lang, setLanguage, t }), [lang, setLanguage, t]);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
+26 -4
View File
@@ -251,7 +251,7 @@ export default function AutomaticTasksPage() {
function edit(task?: AutomaticTask) {
const deviceId = task?.deviceId || devices[0]?.id || "";
const next = task ? {
let next = task ? {
id: task.id,
name: task.name,
enabled: task.enabled,
@@ -269,13 +269,21 @@ export default function AutomaticTasksPage() {
message: task.payload?.message || "",
durationSeconds: task.payload?.durationSeconds || 30,
} : emptyForm(deviceId);
if (devices.find((device) => device.id === deviceId)?.deviceType === "usb_sim_reader") {
next = { ...next, taskType: next.taskType === "public_ip" ? "sms" : next.taskType, environment: "vowifi" };
}
setForm(next);
setOpen(true);
void loadProfiles(deviceId, next.profileIccid);
}
function chooseDevice(deviceId: string) {
setForm((current) => ({ ...current, deviceId, profileIccid: "", profileAid: "" }));
const reader = devices.find((device) => device.id === deviceId)?.deviceType === "usb_sim_reader";
setForm((current) => ({
...current, deviceId, profileIccid: "", profileAid: "",
taskType: reader && current.taskType === "public_ip" ? "sms" : current.taskType,
environment: reader ? "vowifi" : current.environment,
}));
void loadProfiles(deviceId);
}
@@ -285,6 +293,7 @@ export default function AutomaticTasksPage() {
}
function chooseTaskType(taskType: TaskType) {
if (deviceByID.get(form.deviceId)?.deviceType === "usb_sim_reader" && taskType === "public_ip") return;
setForm((current) => ({
...current,
taskType,
@@ -296,6 +305,9 @@ export default function AutomaticTasksPage() {
if (!form.name.trim()) return message.warning(t("请输入任务名称"));
if (!form.deviceId) return message.warning(t("请选择设备"));
if (!form.profileIccid) return message.warning(t("请选择 eSIM Profile"));
if (deviceByID.get(form.deviceId)?.deviceType === "usb_sim_reader" && (form.environment !== "vowifi" || form.taskType === "public_ip")) {
return message.warning(t("USB SIM读卡器仅支持VoWiFi短信和通话任务"));
}
if (form.taskType !== "public_ip" && !form.phone.trim()) return message.warning(t("请输入号码"));
if (form.taskType === "sms" && !form.message.trim()) return message.warning(t("请输入短信内容"));
setSaving(true);
@@ -377,6 +389,15 @@ export default function AutomaticTasksPage() {
const taskTypeLabel = (value: TaskType) => ({ sms: t("发送短信"), call: t("拨打电话并自动挂断"), public_ip: t("获取漫游公网 IP") })[value];
const environmentLabel = (value: TaskEnvironment) => value === "vowifi" ? "VoWiFi" : t("基站直连");
const selectedTaskDeviceIsReader = deviceByID.get(form.deviceId)?.deviceType === "usb_sim_reader";
const taskTypeOptions = [
{ value: "sms", label: t("发送短信") },
{ value: "call", label: t("拨打电话并自动挂断") },
...(!selectedTaskDeviceIsReader ? [{ value: "public_ip", label: t("开启漫游流量并获取一次公网 IP") }] : []),
];
const environmentOptions = selectedTaskDeviceIsReader
? [{ value: "vowifi", label: "VoWiFi" }]
: [{ value: "vowifi", label: "VoWiFi" }, { value: "cellular", label: t("基站直连(自动选网)") }];
return (
<div className="mx-auto max-w-7xl">
@@ -473,8 +494,9 @@ export default function AutomaticTasksPage() {
<div className="md:col-span-2"><label className={fieldLabel}>{t("任务名称")}</label><Input value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} placeholder={t("例如:每日短信保活")} /></div>
<div><label className={fieldLabel}>{t("设备")}</label><Select value={form.deviceId} onChange={chooseDevice} options={devices.map((device) => ({ value: device.id, label: `${device.name || device.id} (${device.id})` }))} /></div>
<div><label className={fieldLabel}>{t("eSIM Profile")}</label><Select value={form.profileIccid} onChange={chooseProfile} disabled={profileLoading || !form.deviceId} placeholder={profileLoading ? t("读取 Profile 中...") : t("请选择 Profile")} options={profiles.map((profile) => ({ value: profile.iccid, label: profile.label }))} /></div>
<div><label className={fieldLabel}>{t("任务类型")}</label><Select value={form.taskType} onChange={(value) => chooseTaskType(value as TaskType)} options={[{ value: "sms", label: t("发送短信") }, { value: "call", label: t("拨打电话并自动挂断") }, { value: "public_ip", label: t("开启漫游流量并获取一次公网 IP") }]} /></div>
<div><label className={fieldLabel}>{t("执行环境")}</label><Select value={form.environment} onChange={(value) => setForm({ ...form, environment: value as TaskEnvironment })} disabled={form.taskType === "public_ip"} options={[{ value: "vowifi", label: "VoWiFi" }, { value: "cellular", label: t("基站直连(自动选网)") }]} /></div>
<div><label className={fieldLabel}>{t("任务类型")}</label><Select value={form.taskType} onChange={(value) => chooseTaskType(value as TaskType)} options={taskTypeOptions} /></div>
<div><label className={fieldLabel}>{t("执行环境")}</label><Select value={form.environment} onChange={(value) => setForm({ ...form, environment: value as TaskEnvironment })} disabled={form.taskType === "public_ip" || selectedTaskDeviceIsReader} options={environmentOptions} /></div>
{selectedTaskDeviceIsReader ? <div className="md:col-span-2 rounded-lg border border-sky-200 bg-sky-50 p-3 text-sm text-sky-700 dark:border-sky-500/20 dark:bg-sky-500/10 dark:text-sky-300">{t("USB SIM读卡器仅支持VoWiFi短信和通话任务")}</div> : null}
{form.taskType !== "public_ip" ? <div><label className={fieldLabel}>{t("号码")}</label><Input value={form.phone} onChange={(event) => setForm({ ...form, phone: event.target.value })} placeholder="+447700900123" /></div> : null}
{form.taskType === "call" ? <div><label className={fieldLabel}>{t("自动挂断")}</label><Input type="number" min={1} max={600} value={form.durationSeconds} suffix="s" onChange={(event) => setForm({ ...form, durationSeconds: Number(event.target.value) })} /></div> : null}
+13 -3
View File
@@ -32,6 +32,7 @@ const EMPTY_ADD: AddDeviceForm = {
atPort: "",
controlDevice: "",
deviceBackend: "at",
simPin: "",
};
export default function DevicesPage() {
@@ -373,7 +374,8 @@ export default function DevicesPage() {
setAddSelected(d);
setAddConfig((prev) => {
const mode = String(d.mode || "").toLowerCase();
const backend = mode === "mbim" ? "mbim" : isQmiControl(d.controlPath) || (mode === "qmi" && d.controlPath) ? "qmi" : "at";
const isReader = d.hardwareKind === "pcsc" || mode === "pcsc";
const backend = isReader ? "pcsc" : mode === "mbim" ? "mbim" : isQmiControl(d.controlPath) || (mode === "qmi" && d.controlPath) ? "qmi" : "at";
return {
...prev,
interface: d.netInterface || "",
@@ -382,6 +384,8 @@ export default function DevicesPage() {
modemImei: d.imei || "",
usbPath: d.usbPath || "",
deviceBackend: backend,
deviceType: isReader ? "usb_sim_reader" : prev.deviceType,
esimTransport: isReader ? "pcsc" : backend,
};
});
}, []);
@@ -574,6 +578,10 @@ export default function DevicesPage() {
const unconfiguredDiscovered = useMemo(() => discovered.filter((d) => !d.configured), [discovered]);
const detailOnline = isDeviceOnline(detail);
const isReader = detail?.deviceType === "usb_sim_reader";
useEffect(() => {
if (isReader && ["at", "ussd"].includes(activeTab)) setActiveTab("overview");
}, [isReader, activeTab]);
const addAtLimit = deviceLimit > 0 && list.length >= deviceLimit;
const tabItems = [
{ key: "overview", label: t("概览") },
@@ -582,13 +590,14 @@ export default function DevicesPage() {
{ key: "ussd", label: t("USSD") },
{ key: "config", label: t("配置") },
{ key: "card", label: t("卡策略") },
];
].filter((tab) => !isReader || !["at", "ussd"].includes(tab.key));
const overviewNode = detail ? (
<div className="space-y-4">
<DeviceOverviewTab
device={detail}
simOperatorDisplay={simOperator}
customPhoneNumber={cardPolicy?.iccid === detail.modem?.iccid ? cardPolicy.customPhoneNumber : ""}
trafficSpeedRx={''}
trafficSpeedTx={''}
trafficMinuteRx={''}
@@ -672,6 +681,7 @@ export default function DevicesPage() {
onReconnectVowifi={handleReconnectVoWiFi}
onRebootModem={handleRebootModem}
onOpenSms={handleOpenSms}
wifiCallingOnly={isReader}
/>
<div className="device-detail-tabs ui-card p-6">
<Tabs tabs={tabItems} value={activeTab} onChange={handleTabChange} />
@@ -696,7 +706,7 @@ export default function DevicesPage() {
<DeviceConfigTab editConfig={editConfig} deviceStatus={detail} saving={saving} deleting={deleting} onSave={handleSaveConfig} onDelete={handleDeleteDevice} onEditConfig={setEditConfig} />
) : null}
{activeTab === "card" ? (
<CardPolicyPanel deviceId={detail.id} iccid={detail.modem?.iccid} policy={cardPolicy} deviceOnline={detailOnline} onPolicyChanged={handlePolicyChanged} />
<CardPolicyPanel deviceId={detail.id} iccid={detail.modem?.iccid} policy={cardPolicy} deviceOnline={detailOnline} onPolicyChanged={handlePolicyChanged} wifiCallingOnly={isReader} />
) : null}
</div>
</div>
+23 -2
View File
@@ -14,13 +14,14 @@ import {
buildBarkPayload,
buildEmailPayload,
buildNotificationsPayload,
buildWecomPayload,
buildWebhookPayload,
defaultNotifyForms,
formsFromNotifications,
type NotifyForms,
} from "../components/settings/model";
import { PushplusTab, TelegramTab } from "../components/settings/BotTabs";
import { BarkTab, EmailTab, WebhookTab } from "../components/settings/PushTabs";
import { BarkTab, EmailTab, WebhookTab, WecomTab } from "../components/settings/PushTabs";
import { PluginsCard } from "../components/settings/PluginsCard";
import { HTTPSCard } from "../components/settings/HTTPSCard";
import { DeviceQuotaCard } from "../components/settings/DeviceQuotaCard";
@@ -34,6 +35,7 @@ const NOTIFY_TABS = [
{ key: "email", label: "Email" },
{ key: "pushplus", label: "Pushplus" },
{ key: "webhook", label: "Webhook" },
{ key: "wecom", label: "企业微信消息推送" },
];
const EMPTY_SYSTEM_INFO: SystemInfo = { version: "", buildTime: "", config: "" };
@@ -51,6 +53,7 @@ export default function SettingsPage() {
const [testingWebhook, setTestingWebhook] = useState(false);
const [testingBark, setTestingBark] = useState(false);
const [testingEmail, setTestingEmail] = useState(false);
const [testingWecom, setTestingWecom] = useState(false);
const [changingPassword, setChangingPassword] = useState(false);
const [checkingUpdate, setCheckingUpdate] = useState(false);
const [applyingUpdate, setApplyingUpdate] = useState(false);
@@ -320,6 +323,21 @@ export default function SettingsPage() {
}
}, [forms.email]);
const onTestWecom = useCallback(async () => {
setTestingWecom(true);
try {
await api("/settings/notifications/wecom/test", {
method: "POST",
body: buildWecomPayload(forms.wecom, true),
});
message.success(t("测试通知已发送"));
} catch (error) {
message.error(apiMessage(error) || t("企业微信消息推送测试失败"));
} finally {
setTestingWecom(false);
}
}, [forms.wecom]);
const onCheckUpdate = useCallback(async () => {
setCheckingUpdate(true);
try {
@@ -448,7 +466,7 @@ export default function SettingsPage() {
<CardIcon>
<AlertRegular className="text-[24px]" />
</CardIcon>
<CardTitle title={t("通知")} subtitle={t("Telegram / Bark / Email / Pushplus / Webhook")} />
<CardTitle title={t("通知")} subtitle={t("Telegram / Bark / Email / Pushplus / Webhook / 企业微信消息推送")} />
</div>
<Button variant="primary" loading={savingNotif} disabled={loadingNotif} onClick={onSaveNotifications} className="!border-0" icon={<CheckmarkRegular />}>
{t("保存通知配置")}
@@ -479,6 +497,9 @@ export default function SettingsPage() {
onTest={onTestWebhook}
/>
) : null}
{activeTab === "wecom" ? (
<WecomTab value={forms.wecom} onChange={(p) => updateChannel("wecom", p)} testing={testingWecom} onTest={onTestWecom} />
) : null}
</div>
)}
</div>
+8 -3
View File
@@ -1,6 +1,6 @@
export type ApiStatus = "ok" | "error";
export type DeviceType = "wifi_410" | "dji_4g" | "pcie_ec20_ec25";
export type DeviceType = "wifi_410" | "dji_4g" | "pcie_ec20_ec25" | "usb_sim_reader";
export interface Session {
authenticated: boolean;
@@ -167,6 +167,8 @@ export interface DeviceStatus {
}
export interface DiscoveredDevice {
hardwareKind?: string;
readerName?: string;
discoveryKey: string;
controlPath: string;
netInterface: string;
@@ -196,14 +198,15 @@ export interface DeviceConfig {
usbPath: string;
audioDevice?: string;
modemImei?: string;
simPin?: string;
apn: string;
proxyPort: number;
baudRate: number;
dataBits: number;
stopBits: number;
parity: string;
deviceBackend: "at" | "qmi";
esimTransport: "at" | "qmi";
deviceBackend: "at" | "qmi" | "pcsc";
esimTransport: "at" | "qmi" | "pcsc" | "none";
qmiUseProxy: boolean;
qmiProxyPath?: string;
qmiProxyExecutable?: string;
@@ -230,6 +233,7 @@ export interface CardPolicy {
airplaneEnabled: boolean;
apn?: string;
ipVersion?: string;
customPhoneNumber?: string;
source?: string;
createdAt?: string;
updatedAt?: string;
@@ -371,6 +375,7 @@ export interface NotificationSettings {
bark: Record<string, unknown>;
email: Record<string, unknown>;
pushplus: Record<string, unknown>;
wecom: Record<string, unknown>;
}
// 网络访问控制策略:默认仅放行内网网段,可切换到对公网开放。