5 Commits
Author SHA1 Message Date
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
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
50 changed files with 3182 additions and 326 deletions
+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
+43 -14
View File
@@ -438,13 +438,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)
@@ -472,7 +490,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)
}
}
}
@@ -866,7 +884,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)
@@ -953,11 +971,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
@@ -1044,15 +1070,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
+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)
}
}
+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
+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")
}
}
+17
View File
@@ -559,3 +559,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
}
+7 -4
View File
@@ -24,10 +24,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/",
} {
@@ -11,7 +11,6 @@ import (
"errors"
"fmt"
"io"
"mime"
"net"
"net/http"
"net/mail"
@@ -290,12 +289,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
}
+181 -29
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,17 +211,22 @@ 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)
@@ -188,6 +238,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 +260,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 +274,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 +317,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 +351,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 +359,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 +484,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 +496,97 @@ 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 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 {
+131 -17
View File
@@ -2,6 +2,7 @@ package server
import (
"context"
"encoding/csv"
"encoding/json"
"errors"
"fmt"
@@ -262,11 +263,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
}
}
@@ -552,6 +557,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 +1137,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 +1234,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 +1365,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")
}
}
@@ -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(),
+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) {
-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()
+361 -70
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"
)
@@ -100,6 +100,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
}
@@ -788,18 +796,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 +815,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 +836,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 +1212,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 +1227,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 +1319,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 +1584,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
+134 -2
View File
@@ -9,6 +9,7 @@ import (
"net/http"
"net/http/httptest"
"net/netip"
"strconv"
"strings"
"sync/atomic"
"testing"
@@ -425,10 +426,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 +481,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)
+6 -13
View File
@@ -11,7 +11,6 @@ import (
"errors"
"fmt"
"io"
"mime"
"net"
"net/http"
"net/mail"
@@ -426,19 +425,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)
}
+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)
}
}
+3 -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
@@ -784,7 +784,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 +794,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")
+33
View File
@@ -227,6 +227,39 @@ 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 ''`,
}
default:
return nil
}
+26 -9
View File
@@ -477,15 +477,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 {
+162 -5
View File
@@ -360,6 +360,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 +382,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 +442,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 +451,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 +467,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)
}
+3 -2
View File
@@ -13,7 +13,7 @@ import (
_ "modernc.org/sqlite"
)
const schemaVersion = 12
const schemaVersion = 15
var ErrNotFound = errors.New("store: not found")
@@ -121,7 +121,8 @@ 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"))
if duplicateAdditiveColumn && strings.Contains(strings.ToLower(err.Error()), "duplicate column name") {
continue
}
-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
@@ -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>
);
}
+78 -11
View File
@@ -1,24 +1,30 @@
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>;
}
export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolicyChanged }: 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 +33,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>
@@ -51,12 +79,44 @@ 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
@@ -80,6 +140,13 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
onToggle={toggles.onAirplaneToggle}
/>
</div>
<CardPolicyAPN
deviceId={deviceId}
iccid={iccid}
policy={currentPolicy}
deviceOnline={deviceOnline}
onSaved={onPolicyChanged}
/>
</div>
) : null}
</div>
@@ -12,6 +12,7 @@ import { isVoWiFiInUse } from "./shared";
export interface DeviceOverviewTabProps {
device: DeviceDetail;
simOperatorDisplay: string;
customPhoneNumber?: string;
trafficSpeedRx: string;
trafficSpeedTx: string;
trafficMinuteRx: string;
@@ -39,6 +40,7 @@ export function DeviceOverviewTab(props: DeviceOverviewTabProps) {
<OverviewSimPanel
device={device}
simOperatorDisplay={props.simOperatorDisplay}
customPhoneNumber={props.customPhoneNumber}
e911Starting={props.e911Starting}
onSetupE911={props.onSetupE911}
/>
@@ -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">
@@ -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" });
}
+55
View File
@@ -5,6 +5,52 @@
* /
*/
export const EN_DICT: Record<string, string> = {
// 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",
@@ -939,6 +985,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>;
+1
View File
@@ -589,6 +589,7 @@ export default function DevicesPage() {
<DeviceOverviewTab
device={detail}
simOperatorDisplay={simOperator}
customPhoneNumber={cardPolicy?.iccid === detail.modem?.iccid ? cardPolicy.customPhoneNumber : ""}
trafficSpeedRx={''}
trafficSpeedTx={''}
trafficMinuteRx={''}
+1
View File
@@ -230,6 +230,7 @@ export interface CardPolicy {
airplaneEnabled: boolean;
apn?: string;
ipVersion?: string;
customPhoneNumber?: string;
source?: string;
createdAt?: string;
updatedAt?: string;