feat: expand device networking and management

This commit is contained in:
MengMengCode
2026-08-10 03:27:43 +08:00
parent d8828ff26a
commit 4df0ae0c7d
117 changed files with 7085 additions and 380 deletions
+6
View File
@@ -0,0 +1,6 @@
# Copy this file to .env and fill in real values before `docker compose up -d`.
# .env is gitignored; .env.example is tracked as a template.
# Admin password for the web UI. REQUIRED — the server refuses to start safely
# without it once exposed. Pick a strong password.
VOCAT_ADMIN_PASSWORD=change-me-to-a-strong-password
+3
View File
@@ -15,6 +15,9 @@
vc.jar
*.cookies
*.session
.env
.env.*
!.env.example
# ---- Frontend build products ----
web/dist/
+3
View File
@@ -45,6 +45,9 @@ RUN mkdir -p /opt/vocat/bin /opt/vocat/data && \
COPY --from=go-builder /out/vocat /opt/vocat/bin/vocat
# Symlink into /usr/local/bin so `docker exec <ctr> vocat ...` finds it via $PATH.
RUN ln -s /opt/vocat/bin/vocat /usr/local/bin/vocat
USER vocat
VOLUME ["/opt/vocat/data"]
EXPOSE 7575
+8 -16
View File
@@ -6,11 +6,11 @@ import (
"errors"
"fmt"
"log/slog"
"os"
"strings"
"time"
"vocat/internal/config"
"vocat/internal/developer"
"vocat/internal/store"
)
@@ -18,7 +18,7 @@ import (
// plugin/extension system. When absent the developer mode defaults to off, so
// a fresh install exposes no plugin surface until an operator explicitly turns
// it on with `vocat develop on` and restarts the service.
const developerEnabledSettingKey = "developer.enabled"
const developerEnabledSettingKey = developer.EnabledSettingKey
// runDevelop handles the hidden `vocat develop on|off` subcommand. It is
// intentionally excluded from printUsage and the interactive menu: the plugin
@@ -65,6 +65,11 @@ func runDevelop(args []string, logger *slog.Logger) error {
}); err != nil {
return fmt.Errorf("persist developer flag: %w", err)
}
if !enabled {
if err := developer.ResetExperimental(ctx, database); err != nil {
return fmt.Errorf("reset developer settings: %w", err)
}
}
if enabled {
fmt.Printf("开发者模式已开启。重启 vocat 服务后插件功能生效。\n数据库:%s\n", cfg.DatabasePath)
@@ -91,18 +96,5 @@ func parseDevelopArg(arg string) (bool, bool) {
// or an unparseable value resolves to false — the system defaults closed, so
// any read failure keeps plugins off rather than exposing them by accident.
func isDeveloperEnabled(ctx context.Context, database *store.Store) bool {
setting, err := database.AppSetting(ctx, developerEnabledSettingKey)
if err != nil {
if !errors.Is(err, store.ErrNotFound) {
fmt.Fprintf(os.Stderr, "vocat: read developer flag failed; plugin system stays off: %v\n", err)
}
return false
}
var document struct {
Enabled bool `json:"enabled"`
}
if err := json.Unmarshal(setting.Value, &document); err != nil {
return false
}
return document.Enabled
return developer.Enabled(ctx, database)
}
+246 -18
View File
@@ -2,10 +2,12 @@ package main
import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
@@ -18,8 +20,11 @@ import (
"vocat/internal/auth"
"vocat/internal/config"
"vocat/internal/developer"
"vocat/internal/device"
"vocat/internal/exportproxy"
"vocat/internal/extensions"
"vocat/internal/httpsmode"
"vocat/internal/loghub"
"vocat/internal/server"
"vocat/internal/store"
@@ -119,16 +124,41 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
return err
}
defer database.Close()
developerEnabled := isDeveloperEnabled(startupContext, database)
pluginRoot := filepath.Join(filepath.Dir(cfg.DatabasePath), "plugins")
legacyExportProxyConfig := filepath.Join(pluginRoot, exportproxy.ReservedID, "data", "configs.json")
if !developerEnabled {
if err := developer.ResetExperimental(startupContext, database); err != nil {
return fmt.Errorf("reset disabled developer settings: %w", err)
}
if err := exportproxy.RemoveLegacyConfig(legacyExportProxyConfig); err != nil {
return fmt.Errorf("remove legacy export proxy configuration: %w", err)
}
}
httpsManager, err := httpsmode.New(
startupContext,
database,
filepath.Join(filepath.Dir(cfg.DatabasePath), "tls"),
cfg.Address,
)
if err != nil {
return fmt.Errorf("configure self-signed HTTPS: %w", err)
}
// The plugin/extension system is gated behind a hidden developer-mode flag.
// When off (the default) the manager is never created and the server receives
// a nil Extensions handle, so every /extensions* and /plugin-assets/* route
// returns 503/404 and the SPA hides the plugin surface.
developerEnabled := isDeveloperEnabled(startupContext, database)
var extensionManager *extensions.Manager
var exportProxyManager *exportproxy.Manager
if developerEnabled {
exportProxyManager, err = exportproxy.New(startupContext, database, logger, legacyExportProxyConfig)
if err != nil {
return fmt.Errorf("create built-in export proxy: %w", err)
}
defer exportProxyManager.Close()
extensionManager, err = extensions.NewManager(
filepath.Join(filepath.Dir(cfg.DatabasePath), "plugins"),
pluginRoot,
logger,
)
if err != nil {
@@ -163,6 +193,7 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
if err := provisionDiscoveredDevices(startupContext, database, deviceManager); err != nil {
logger.Warn("automatic first-run device provisioning failed", "error", err)
}
restoreDefaultCellularRadios(startupContext, logger, database, deviceManager)
defer func() {
stopContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -173,7 +204,14 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
pollContext, cancelPolling := context.WithCancel(context.Background())
defer cancelPolling()
go pollDeviceSnapshots(pollContext, logger, database, deviceManager)
go restoreConfiguredCellularData(pollContext, logger, database, deviceManager)
go collectCellularTraffic(pollContext, logger, database)
go persistLogsToStore(pollContext, logger, logs, database)
if !developerEnabled {
go disableAllDeveloperCellularData(pollContext, logger, database, deviceManager)
} else {
go watchDeveloperDisable(pollContext, logger, database, deviceManager, exportProxyManager, legacyExportProxyConfig)
}
vowifiManager, err := configureVoWiFiRuntime(
startupContext,
@@ -203,9 +241,11 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
SecureCookies: cfg.SecureCookies,
MaxRequestBodyBytes: cfg.MaxRequestBodyBytes,
Extensions: extensionManager,
ExportProxy: exportProxyManager,
DeveloperEnabled: developerEnabled,
UpdateRepository: strings.TrimSpace(os.Getenv("VOCAT_REPO")),
UpdateToken: strings.TrimSpace(os.Getenv("GITHUB_TOKEN")),
HTTPS: httpsManager,
})
if err != nil {
return err
@@ -215,15 +255,35 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
handler.StartTelegramBot(pollContext)
handler.StartSMSNotificationDispatchers(pollContext)
httpServer := &http.Server{
Addr: cfg.Address,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 90 * time.Second,
MaxHeaderBytes: 1 << 20,
serverConfig := func(handler http.Handler) *http.Server {
return &http.Server{
Addr: cfg.Address,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 90 * time.Second,
MaxHeaderBytes: 1 << 20,
}
}
plainHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if httpsManager.Enabled() {
host := strings.TrimSpace(r.Host)
if host == "" {
host = cfg.Address
}
http.Redirect(w, r, "https://"+host+r.URL.RequestURI(), http.StatusPermanentRedirect)
return
}
handler.ServeHTTP(w, r)
})
plainServer := serverConfig(plainHandler)
tlsServer := serverConfig(handler)
baseListener, err := net.Listen("tcp", cfg.Address)
if err != nil {
return fmt.Errorf("listen on %s: %w", cfg.Address, err)
}
protocolMux := httpsmode.NewMultiplexer(baseListener, httpsManager)
signalContext, stopSignals := signal.NotifyContext(
context.Background(),
@@ -232,10 +292,17 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
)
defer stopSignals()
serverError := make(chan error, 1)
serverError := make(chan error, 2)
go func() {
logger.Info("HTTP server listening", "address", cfg.Address)
err := httpServer.ListenAndServe()
logger.Info("HTTP server listening", "address", cfg.Address, "self_signed_https", httpsManager.Enabled())
err := plainServer.Serve(protocolMux.Plain())
if errors.Is(err, http.ErrServerClosed) {
err = nil
}
serverError <- err
}()
go func() {
err := tlsServer.Serve(tls.NewListener(protocolMux.TLS(), httpsManager.TLSConfig()))
if errors.Is(err, http.ErrServerClosed) {
err = nil
}
@@ -244,6 +311,7 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
select {
case err := <-serverError:
_ = protocolMux.Close()
return err
case <-signalContext.Done():
logger.Info("shutdown signal received")
@@ -258,11 +326,161 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
cfg.ShutdownTimeout,
)
defer cancelShutdown()
if err := httpServer.Shutdown(shutdownContext); err != nil {
_ = httpServer.Close()
return fmt.Errorf("graceful HTTP shutdown: %w", err)
shutdownErrors := make(chan error, 2)
go func() { shutdownErrors <- plainServer.Shutdown(shutdownContext) }()
go func() { shutdownErrors <- tlsServer.Shutdown(shutdownContext) }()
time.Sleep(10 * time.Millisecond)
_ = protocolMux.Close()
for range 2 {
if err := <-shutdownErrors; err != nil {
_ = plainServer.Close()
_ = tlsServer.Close()
return fmt.Errorf("graceful HTTP shutdown: %w", err)
}
}
return nil
}
// restoreDefaultCellularRadios repairs an interrupted VoWiFi teardown. CFUN=4
// survives process restarts, while the in-memory radio checkpoint does not. If
// VoWiFi is disabled and the current SIM has no explicit airplane policy, the
// automatic/default policy is cellular service and the modem must return to
// CFUN=1.
func restoreDefaultCellularRadios(
ctx context.Context,
logger *slog.Logger,
database *store.Store,
manager *device.Manager,
) {
configs, err := database.ListDevices(ctx)
if err != nil {
logger.Warn("startup cellular recovery: list devices", "error", err)
return
}
mapper := integration.ATMapper{Store: database, Devices: manager}
for _, config := range configs {
if config.VoWiFiEnabled {
continue
}
entry, err := mapper.Get(config.ID)
if err != nil || entry.Snapshot == nil || !entry.Snapshot.FlightMode {
continue
}
iccid := strings.TrimSpace(entry.Snapshot.ICCID)
if iccid != "" {
policy, policyErr := database.CardPolicy(ctx, iccid)
switch {
case policyErr == nil && policy.AirplaneEnabled:
continue
case policyErr != nil && !errors.Is(policyErr, store.ErrNotFound):
logger.Warn("startup cellular recovery: read card policy", "device_id", config.ID, "error", policyErr)
continue
}
}
restoreContext, cancel := context.WithTimeout(ctx, 10*time.Second)
_, err = manager.SetFlight(restoreContext, entry.ID, false)
cancel()
if err != nil {
logger.Warn("startup cellular recovery failed", "device_id", config.ID, "error", err)
continue
}
logger.Info("restored cellular radio after disabled VoWiFi", "device_id", config.ID, "iccid", iccid)
}
}
func restoreConfiguredCellularData(
ctx context.Context,
logger *slog.Logger,
database *store.Store,
manager *device.Manager,
) {
configs, err := database.ListDevices(ctx)
if err != nil {
logger.Warn("startup cellular data recovery: list devices", "error", err)
return
}
mapper := integration.ATMapper{Store: database, Devices: manager}
for _, config := range configs {
if !config.NetworkEnabled || config.VoWiFiEnabled {
continue
}
entry, err := mapper.Get(config.ID)
if err != nil {
continue
}
dataContext, cancel := context.WithTimeout(ctx, 60*time.Second)
_, err = manager.SetNetwork(dataContext, entry.ID, device.NetworkRequest{
Enabled: true, APN: config.APN, IPVersion: "IPV4V6",
})
cancel()
if err != nil {
logger.Warn("startup cellular data recovery failed", "device_id", config.ID, "error", err)
continue
}
logger.Info("restored protected cellular data route", "device_id", config.ID, "interface", config.Interface)
}
}
func disableAllDeveloperCellularData(
ctx context.Context,
logger *slog.Logger,
database *store.Store,
manager *device.Manager,
) {
configs, err := database.ListDevices(ctx)
if err != nil {
logger.Warn("developer cleanup: list devices", "error", err)
return
}
mapper := integration.ATMapper{Store: database, Devices: manager}
for _, config := range configs {
entry, err := mapper.Get(config.ID)
if err != nil {
continue
}
disableContext, cancel := context.WithTimeout(ctx, 30*time.Second)
_, err = manager.SetNetwork(disableContext, entry.ID, device.NetworkRequest{Enabled: false})
cancel()
if err != nil && ctx.Err() == nil {
logger.Warn("developer cleanup: stop cellular data", "device_id", config.ID, "error", err)
}
}
}
func watchDeveloperDisable(
ctx context.Context,
logger *slog.Logger,
database *store.Store,
manager *device.Manager,
exportProxy *exportproxy.Manager,
legacyConfigPath string,
) {
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if developer.Enabled(ctx, database) {
continue
}
if exportProxy != nil {
if err := exportProxy.DeleteAllAndDisable(ctx); err != nil && ctx.Err() == nil {
logger.Warn("developer cleanup: delete export proxies", "error", err)
}
}
if err := exportproxy.RemoveLegacyConfig(legacyConfigPath); err != nil {
logger.Warn("developer cleanup: remove legacy export proxy configuration", "error", err)
}
if err := developer.ResetExperimental(ctx, database); err != nil && ctx.Err() == nil {
logger.Warn("developer cleanup: reset settings", "error", err)
}
disableAllDeveloperCellularData(ctx, logger, database, manager)
logger.Info("developer mode disabled; roaming data and export proxies were removed")
return
}
}
return <-serverError
}
func configureVoWiFiRuntime(
@@ -355,8 +573,18 @@ func newVoWiFiOrchestrator(
if message.Concat != nil && message.Concat.Total > 0 {
partsTotal = message.Concat.Total
}
messageID := message.MessageID
if message.Concat != nil && message.Concat.Total > 1 {
// A segment of a carrier-split long SMS over IMS. Address the whole
// message with a stable id so SaveSMSMessage folds every segment
// into one progressively merged row instead of one row per segment.
messageID = store.StableConcatMessageID(
"ims", deviceConfig.ModemIMEI, message.DeviceID, message.From,
message.Concat.Reference, message.Concat.Total,
)
}
_, saveErr := database.SaveSMSMessage(ctx, store.SMSMessage{
MessageID: message.MessageID,
MessageID: messageID,
DeviceID: message.DeviceID,
ModemIMEI: deviceConfig.ModemIMEI,
IMSI: message.IMSI,
+123
View File
@@ -0,0 +1,123 @@
package main
import (
"context"
"log/slog"
"math"
"strings"
"time"
"vocat/internal/store"
)
const cellularTrafficSampleInterval = 30 * time.Second
type interfaceTrafficSample struct {
interfaceName string
rxBytes uint64
txBytes uint64
}
func collectCellularTraffic(ctx context.Context, logger *slog.Logger, database *store.Store) {
previous := make(map[string]interfaceTrafficSample)
var lastPrune time.Time
collect := func() {
now := time.Now()
if lastPrune.IsZero() || now.Sub(lastPrune) >= 24*time.Hour {
lastPrune = now
if _, err := database.DeleteTrafficBefore(ctx, now.Add(-35*24*time.Hour)); err != nil && ctx.Err() == nil {
logger.Warn("prune old cellular traffic", "error", err)
}
}
configs, err := database.ListDevices(ctx)
if err != nil {
if ctx.Err() == nil {
logger.Warn("list devices for cellular traffic collection", "error", err)
}
return
}
active := make(map[string]struct{}, len(configs))
for _, config := range configs {
interfaceName := strings.TrimSpace(config.Interface)
if !config.NetworkEnabled || interfaceName == "" {
delete(previous, config.ID)
continue
}
active[config.ID] = struct{}{}
rxBytes, txBytes, err := readInterfaceTrafficCounters(interfaceName)
if err != nil {
// Interfaces can briefly disappear while QMI reconnects. The next
// successful read establishes a fresh baseline, so no reconnect
// traffic is accidentally counted twice.
delete(previous, config.ID)
continue
}
rxDelta, txDelta, ok := trafficCounterDelta(previous[config.ID], interfaceName, rxBytes, txBytes)
previous[config.ID] = interfaceTrafficSample{
interfaceName: interfaceName,
rxBytes: rxBytes,
txBytes: txBytes,
}
if !ok || (rxDelta == 0 && txDelta == 0) {
continue
}
for bucket, periodStart := range trafficBucketPeriods(time.Now()) {
if err := database.AddTrafficBucket(ctx, store.TrafficBucket{
DeviceID: config.ID,
Bucket: bucket,
PeriodStart: periodStart,
RXBytes: rxDelta,
TXBytes: txDelta,
}); err != nil && ctx.Err() == nil {
logger.Warn("record cellular traffic", "device", config.ID, "bucket", bucket, "error", err)
}
}
}
for deviceID := range previous {
if _, ok := active[deviceID]; !ok {
delete(previous, deviceID)
}
}
}
collect()
ticker := time.NewTicker(cellularTrafficSampleInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
collect()
}
}
}
func trafficCounterDelta(previous interfaceTrafficSample, interfaceName string, rxBytes, txBytes uint64) (int64, int64, bool) {
if previous.interfaceName == "" || previous.interfaceName != interfaceName || rxBytes < previous.rxBytes || txBytes < previous.txBytes {
return 0, 0, false
}
rxDelta := rxBytes - previous.rxBytes
txDelta := txBytes - previous.txBytes
if rxDelta > math.MaxInt64 || txDelta > math.MaxInt64 {
return 0, 0, false
}
return int64(rxDelta), int64(txDelta), true
}
func trafficBucketPeriods(now time.Time) map[string]time.Time {
local := now.In(time.Local)
year, month, day := local.Date()
dayStart := time.Date(year, month, day, 0, 0, 0, 0, time.Local).UTC()
return map[string]time.Time{
"hour": now.UTC().Truncate(time.Minute),
"day": now.UTC().Truncate(time.Hour),
"week": dayStart,
"month": dayStart,
}
}
+39
View File
@@ -0,0 +1,39 @@
//go:build linux
package main
import (
"fmt"
"net"
"os"
"path/filepath"
"strconv"
"strings"
)
func readInterfaceTrafficCounters(interfaceName string) (uint64, uint64, error) {
iface, err := net.InterfaceByName(interfaceName)
if err != nil {
return 0, 0, err
}
read := func(counter string) (uint64, error) {
value, err := os.ReadFile(filepath.Join("/sys/class/net", iface.Name, "statistics", counter))
if err != nil {
return 0, err
}
parsed, err := strconv.ParseUint(strings.TrimSpace(string(value)), 10, 64)
if err != nil {
return 0, fmt.Errorf("parse %s %s counter: %w", iface.Name, counter, err)
}
return parsed, nil
}
rxBytes, err := read("rx_bytes")
if err != nil {
return 0, 0, err
}
txBytes, err := read("tx_bytes")
if err != nil {
return 0, 0, err
}
return rxBytes, txBytes, nil
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !linux
package main
import "errors"
func readInterfaceTrafficCounters(string) (uint64, uint64, error) {
return 0, 0, errors.New("interface traffic counters are only available on Linux")
}
+38
View File
@@ -0,0 +1,38 @@
package main
import (
"testing"
"time"
)
func TestTrafficCounterDelta(t *testing.T) {
previous := interfaceTrafficSample{interfaceName: "wwan0", rxBytes: 100, txBytes: 50}
rx, tx, ok := trafficCounterDelta(previous, "wwan0", 175, 90)
if !ok || rx != 75 || tx != 40 {
t.Fatalf("delta = (%d, %d, %v), want (75, 40, true)", rx, tx, ok)
}
if _, _, ok := trafficCounterDelta(previous, "wwan1", 175, 90); ok {
t.Fatal("interface change must establish a new baseline")
}
if _, _, ok := trafficCounterDelta(previous, "wwan0", 90, 40); ok {
t.Fatal("counter reset must establish a new baseline")
}
}
func TestTrafficBucketPeriods(t *testing.T) {
now := time.Date(2026, 8, 10, 12, 34, 56, 0, time.Local)
periods := trafficBucketPeriods(now)
if got := periods["hour"]; !got.Equal(now.UTC().Truncate(time.Minute)) {
t.Fatalf("hour period = %s", got)
}
if got := periods["day"]; !got.Equal(now.UTC().Truncate(time.Hour)) {
t.Fatalf("day period = %s", got)
}
localDay := periods["week"].In(time.Local)
if localDay.Hour() != 0 || localDay.Minute() != 0 || localDay.Day() != 10 {
t.Fatalf("week period = %s, want local day start", periods["week"])
}
if !periods["month"].Equal(periods["week"]) {
t.Fatal("week and month should share daily periods")
}
}
+1 -1
View File
@@ -29,7 +29,7 @@ ProtectKernelModules=true
ProtectKernelTunables=true
ProtectControlGroups=true
ReadWritePaths=/opt/vocat/data
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK AF_PACKET
RestrictRealtime=true
LockPersonality=true
MemoryDenyWriteExecute=true
+60
View File
@@ -0,0 +1,60 @@
# VoCat Docker Compose deployment.
#
# First-time setup:
# cp .env.example .env # then edit VOCAT_ADMIN_PASSWORD
# docker compose pull # fetch the prebuilt GHCR image
# docker compose up -d # start
#
# Build locally from this repo instead of using the GHCR image:
# docker compose up -d --build
#
# In-container binary self-update is intentionally disabled (VOCAT_CONTAINER=docker
# makes the server return 409 on the apply endpoint). Update by pulling a new
# image and recreating the container:
# docker compose pull && docker compose up -d
services:
vocat:
# Use the prebuilt multi-arch image from GHCR. Override with
# --build to compile from the local Dockerfile instead.
image: ghcr.io/mengmengcode/vocat:latest
pull_policy: missing
build:
context: .
dockerfile: Dockerfile
container_name: vocat
restart: unless-stopped
# Host network mode: the export-proxy plugin uses SO_BINDTODEVICE to pin
# outbound proxy traffic to the modem interface (wwan0) so roaming data
# egresses only the module — never the host's default route. That syscall
# needs the host network namespace visible inside the container, which
# network_mode: host provides directly. Port publishing is therefore
# meaningless (the container shares the host stack and vocat binds
# 0.0.0.0:7575 itself); proxy ports opened by the plugin are likewise
# reachable on the host IP without explicit mapping.
network_mode: host
# VoWiFi / eSIM / IMS paths need raw sockets (IPsec, netlink). The systemd
# unit grants CAP_NET_ADMIN + CAP_NET_RAW; mirror that here.
cap_add:
- NET_ADMIN
- NET_RAW
environment:
# Marks the process as containerized: the web UI then advertises
# "pull new image" instead of attempting an in-place binary update.
VOCAT_CONTAINER: docker
# VOCAT_ADDR / VOCAT_DATABASE_PATH are set in the Dockerfile; override
# only if you want non-default values. Sensitive values come from .env.
VOCAT_ADMIN_PASSWORD: ${VOCAT_ADMIN_PASSWORD:?set VOCAT_ADMIN_PASSWORD in .env}
volumes:
# SQLite database + persistent state. Named volume (not a bind mount)
# because the container runs as uid 1000 (vocat) while a bind-mounted
# host dir would be root-owned and unwritable. Docker gives the named
# volume the image's uid 1000 ownership automatically.
- vocat-data:/opt/vocat/data
volumes:
vocat-data:
+1
View File
@@ -3,6 +3,7 @@ module vocat
go 1.25.0
require (
github.com/coder/websocket v1.8.15
go.bug.st/serial v1.6.4
golang.org/x/crypto v0.41.0
golang.org/x/sys v0.47.0
+2
View File
@@ -1,3 +1,5 @@
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0=
github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+107
View File
@@ -0,0 +1,107 @@
package developer
import (
"context"
"encoding/json"
"errors"
"fmt"
"vocat/internal/exportproxy"
"vocat/internal/httpsmode"
"vocat/internal/store"
)
func Enabled(ctx context.Context, database *store.Store) bool {
setting, err := database.AppSetting(ctx, EnabledSettingKey)
if err != nil {
return false
}
var document struct {
Enabled bool `json:"enabled"`
}
return json.Unmarshal(setting.Value, &document) == nil && document.Enabled
}
const (
EnabledSettingKey = "developer.enabled"
DeviceLimitSettingKey = "developer.device_limit"
DefaultDeviceLimit = 5
MaxDeviceLimit = 128
)
func DeviceLimit(ctx context.Context, database *store.Store, enabled bool) int {
if !enabled {
return DefaultDeviceLimit
}
setting, err := database.AppSetting(ctx, DeviceLimitSettingKey)
if err != nil {
return DefaultDeviceLimit
}
var document struct {
Limit int `json:"limit"`
}
if json.Unmarshal(setting.Value, &document) != nil || document.Limit < 1 || document.Limit > MaxDeviceLimit {
return DefaultDeviceLimit
}
return document.Limit
}
func SetDeviceLimit(ctx context.Context, database *store.Store, limit int) error {
if limit < 1 || limit > MaxDeviceLimit {
return fmt.Errorf("device limit must be between 1 and %d", MaxDeviceLimit)
}
value, err := json.Marshal(map[string]int{"limit": limit})
if err != nil {
return err
}
return database.UpsertAppSetting(ctx, store.AppSetting{Key: DeviceLimitSettingKey, Value: value})
}
// ResetExperimental restores every mutable developer-only setting. It is
// called both by `vocat develop off` and at startup whenever developer mode is
// disabled, so stale database values cannot silently remain active.
func ResetExperimental(ctx context.Context, database *store.Store) error {
httpsValue, err := json.Marshal(map[string]bool{"enabled": false})
if err != nil {
return err
}
var resetErrors []error
if err := database.UpsertAppSetting(ctx, store.AppSetting{Key: httpsmode.SettingKey, Value: httpsValue}); err != nil {
resetErrors = append(resetErrors, fmt.Errorf("reset self-signed HTTPS: %w", err))
}
if err := SetDeviceLimit(ctx, database, DefaultDeviceLimit); err != nil {
resetErrors = append(resetErrors, fmt.Errorf("reset device limit: %w", err))
}
if err := database.DeleteAppSetting(ctx, exportproxy.SettingKey); err != nil && !errors.Is(err, store.ErrNotFound) {
resetErrors = append(resetErrors, fmt.Errorf("delete export proxy configurations: %w", err))
}
devices, err := database.ListDevices(ctx)
if err != nil {
resetErrors = append(resetErrors, fmt.Errorf("list devices while disabling roaming data: %w", err))
} else {
for _, device := range devices {
if !device.NetworkEnabled {
continue
}
device.NetworkEnabled = false
if err := database.UpsertDevice(ctx, device); err != nil {
resetErrors = append(resetErrors, fmt.Errorf("disable roaming data for device %s: %w", device.ID, err))
}
}
}
policies, err := database.ListCardPolicies(ctx)
if err != nil {
resetErrors = append(resetErrors, fmt.Errorf("list card policies while disabling roaming data: %w", err))
} else {
for _, policy := range policies {
if !policy.NetworkEnabled {
continue
}
policy.NetworkEnabled = false
if err := database.UpsertCardPolicy(ctx, policy); err != nil {
resetErrors = append(resetErrors, fmt.Errorf("disable roaming policy for card %s: %w", policy.ICCID, err))
}
}
}
return errors.Join(resetErrors...)
}
+77
View File
@@ -0,0 +1,77 @@
package developer
import (
"context"
"encoding/json"
"errors"
"path/filepath"
"testing"
"vocat/internal/exportproxy"
"vocat/internal/httpsmode"
"vocat/internal/store"
)
func TestResetExperimentalRestoresDefaults(t *testing.T) {
ctx := context.Background()
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "vocat.db"))
if err != nil {
t.Fatal(err)
}
defer database.Close()
if err := SetDeviceLimit(ctx, database, 24); err != nil {
t.Fatal(err)
}
enabled, _ := json.Marshal(map[string]bool{"enabled": true})
if err := database.UpsertAppSetting(ctx, store.AppSetting{Key: httpsmode.SettingKey, Value: enabled}); err != nil {
t.Fatal(err)
}
if err := database.UpsertDevice(ctx, store.Device{ID: "modem-1", Name: "modem-1", NetworkEnabled: true}); err != nil {
t.Fatal(err)
}
if err := database.UpsertCardPolicy(ctx, store.CardPolicy{ICCID: "8901000000000000001", NetworkEnabled: true, IPVersion: "IPV4V6"}); err != nil {
t.Fatal(err)
}
if err := database.UpsertAppSetting(ctx, store.AppSetting{Key: exportproxy.SettingKey, Value: json.RawMessage(`[]`)}); err != nil {
t.Fatal(err)
}
if err := ResetExperimental(ctx, database); err != nil {
t.Fatal(err)
}
if limit := DeviceLimit(ctx, database, true); limit != DefaultDeviceLimit {
t.Fatalf("device limit = %d, want %d", limit, DefaultDeviceLimit)
}
setting, err := database.AppSetting(ctx, httpsmode.SettingKey)
if err != nil {
t.Fatal(err)
}
var document struct {
Enabled bool `json:"enabled"`
}
if err := json.Unmarshal(setting.Value, &document); err != nil || document.Enabled {
t.Fatalf("HTTPS setting = %s, error = %v", setting.Value, err)
}
device, err := database.Device(ctx, "modem-1")
if err != nil || device.NetworkEnabled {
t.Fatalf("device roaming data was not disabled: %+v, %v", device, err)
}
policy, err := database.CardPolicy(ctx, "8901000000000000001")
if err != nil || policy.NetworkEnabled {
t.Fatalf("card roaming policy was not disabled: %+v, %v", policy, err)
}
if _, err := database.AppSetting(ctx, exportproxy.SettingKey); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("export proxy configurations were not deleted: %v", err)
}
}
func TestSetDeviceLimitValidatesRange(t *testing.T) {
ctx := context.Background()
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "vocat.db"))
if err != nil {
t.Fatal(err)
}
defer database.Close()
if SetDeviceLimit(ctx, database, 0) == nil || SetDeviceLimit(ctx, database, MaxDeviceLimit+1) == nil {
t.Fatal("out-of-range device limit was accepted")
}
}
+44
View File
@@ -0,0 +1,44 @@
package device
import (
_ "embed"
"encoding/json"
"strings"
)
// The offline table is generated by scripts/update-carriers.py from Android's
// versioned carrier ID database, with the previous global table retained as a
// fallback for PLMNs that Android does not yet catalogue.
//
//go:embed mccmnc.json
var carrierDatabaseJSON []byte
type carrierDatabase struct {
Carriers map[string][]string `json:"c"`
}
var globalCarrierDatabase = func() carrierDatabase {
var database carrierDatabase
if err := json.Unmarshal(carrierDatabaseJSON, &database); err != nil {
panic("device: invalid embedded MCC/MNC database: " + err.Error())
}
return database
}()
// CarrierForPLMN returns the offline carrier display name and ISO alpha-2
// country/territory code for a numeric five- or six-digit PLMN.
func CarrierForPLMN(plmn string) (name, countryCode string, ok bool) {
plmn = strings.TrimSpace(plmn)
if !decimalDigits(plmn, 5, 6) {
return "", "", false
}
entry, ok := globalCarrierDatabase.Carriers[plmn]
if !ok || len(entry) == 0 || strings.TrimSpace(entry[0]) == "" {
return "", "", false
}
name = strings.TrimSpace(entry[0])
if len(entry) > 1 {
countryCode = strings.ToUpper(strings.TrimSpace(entry[1]))
}
return name, countryCode, true
}
+181 -22
View File
@@ -23,7 +23,7 @@ func (manager *Manager) SetNetwork(
return NetworkResult{}, err
}
apn := strings.TrimSpace(request.APN)
if request.Enabled && !apnPattern.MatchString(apn) {
if request.Enabled && apn != "" && !apnPattern.MatchString(apn) {
return NetworkResult{}, ErrInvalidNetworkAPN
}
ipVersion := normalizeIPVersion(request.IPVersion)
@@ -225,7 +225,7 @@ func (manager *Manager) SetOperatorSelection(
accessTechnologyValue *int,
) (OperatorSelection, error) {
result := OperatorSelection{Mode: 0}
command := "AT+COPS=0"
command := ""
if !automatic {
plmn = strings.TrimSpace(plmn)
if len(plmn) < 5 || len(plmn) > 6 || strings.IndexFunc(plmn, func(r rune) bool { return r < '0' || r > '9' }) >= 0 {
@@ -265,28 +265,187 @@ func (manager *Manager) SetOperatorSelection(
// the lock is not aborted while registration is still in progress.
lockCtx, cancel := manager.withTimeout(ctx, manager.scanTimeout)
defer cancel()
if _, err := client.Execute(lockCtx, command); err != nil {
manager.setResult(id, state, nil, errors.New("operator selection command failed"))
if automatic {
result, err = restoreAutomaticOperatorSelection(lockCtx, client)
manager.setResult(id, state, nil, err)
return result, err
}
response, err := client.Execute(lockCtx, command)
if err != nil || !response.OK() {
if err == nil {
err = &modem.CommandError{Command: response.Command, Final: response.Final, Lines: response.Lines}
}
rollbackOperatorSelection(manager, client)
wrapped := fmt.Errorf("manual operator selection failed and automatic selection was restored: %w", err)
manager.setResult(id, state, nil, wrapped)
return OperatorSelection{}, wrapped
}
actual, err := queryOperatorSelection(lockCtx, client)
if err != nil {
rollbackOperatorSelection(manager, client)
manager.setResult(id, state, nil, err)
return OperatorSelection{}, fmt.Errorf("verify manual operator selection: %w", err)
}
if actual.Mode != 1 || actual.Operator != plmn {
rollbackOperatorSelection(manager, client)
err := fmt.Errorf("network %s did not accept registration; automatic selection was restored (modem reported mode=%d operator=%q)", plmn, actual.Mode, actual.Operator)
manager.setResult(id, state, nil, err)
return OperatorSelection{}, err
}
if !automatic {
response, err := client.Execute(lockCtx, "AT+COPS?")
if err != nil {
manager.setResult(id, state, nil, err)
return OperatorSelection{}, fmt.Errorf("verify manual operator selection: %w", err)
}
actual, err := parseOperatorSelection(response)
if err != nil {
manager.setResult(id, state, nil, err)
return OperatorSelection{}, err
}
if actual.Mode != 1 || actual.Operator != plmn {
err := fmt.Errorf("network %s did not accept registration; modem reports mode=%d operator=%q", plmn, actual.Mode, actual.Operator)
manager.setResult(id, state, nil, err)
return OperatorSelection{}, err
}
result = actual
}
result = actual
manager.setResult(id, state, nil, nil)
return result, nil
}
func queryOperatorSelection(ctx context.Context, client modem.Client) (OperatorSelection, error) {
response, err := client.Execute(ctx, "AT+COPS?")
if err != nil {
return OperatorSelection{}, err
}
if !response.OK() {
return OperatorSelection{}, &modem.CommandError{Command: response.Command, Final: response.Final, Lines: response.Lines}
}
return parseOperatorSelection(response)
}
// restoreAutomaticOperatorSelection clears both a manual PLMN latch and an
// old RAT-only scan restriction. The latter is important on EC20 modules:
// COPS=0 alone can remain effectively LTE-only after an earlier lock, unlike a
// phone's normal automatic GSM/WCDMA/LTE acquisition policy.
func restoreAutomaticOperatorSelection(ctx context.Context, client modem.Client) (OperatorSelection, error) {
// Older firmware may not implement nwscanmode; COPS auto is still useful in
// that case, so this compatibility reset is best effort.
_, _ = client.Execute(ctx, `AT+QCFG="nwscanmode",0,1`)
_, _ = client.Execute(ctx, "AT+COPS=2")
response, err := client.Execute(ctx, "AT+COPS=0")
if err != nil {
return OperatorSelection{}, err
}
if !response.OK() {
return OperatorSelection{}, &modem.CommandError{Command: response.Command, Final: response.Final, Lines: response.Lines}
}
actual, err := queryOperatorSelection(ctx, client)
if err != nil {
return OperatorSelection{}, fmt.Errorf("verify automatic operator selection: %w", err)
}
if actual.Mode != 0 {
return OperatorSelection{}, fmt.Errorf("modem did not enter automatic operator selection (mode=%d operator=%q)", actual.Mode, actual.Operator)
}
return actual, nil
}
func rollbackOperatorSelection(manager *Manager, client modem.Client) {
rollbackCtx, cancel := context.WithTimeout(context.Background(), manager.longTimeout)
defer cancel()
_, _ = restoreAutomaticOperatorSelection(rollbackCtx, client)
}
// ReRegisterOperator detaches from the network and reapplies the modem's
// current automatic/manual selection. This is intentionally different from a
// passive refresh: it forces a new registration attempt without changing the
// user's lock policy.
func (manager *Manager) ReRegisterOperator(ctx context.Context, id string) (OperatorSelection, error) {
state, err := manager.lookup(id)
if err != nil {
return OperatorSelection{}, err
}
state.opMu.Lock()
defer state.opMu.Unlock()
if err := manager.validateActive(id, state); err != nil {
return OperatorSelection{}, err
}
client, err := manager.clientLocked(ctx, state, manager.candidateFor(state))
if err != nil {
manager.setResult(id, state, nil, err)
return OperatorSelection{}, err
}
longCtx, cancel := manager.withTimeout(ctx, manager.scanTimeout)
defer cancel()
current, err := queryOperatorSelection(longCtx, client)
if err != nil {
manager.setResult(id, state, nil, err)
return OperatorSelection{}, err
}
manual := current.Mode == 1 || current.Mode == 4
if manual && !decimalPLMN(current.Operator) {
response, formatErr := client.Execute(longCtx, "AT+COPS=3,2")
if formatErr != nil || !response.OK() {
if formatErr == nil {
formatErr = &modem.CommandError{Command: response.Command, Final: response.Final, Lines: response.Lines}
}
manager.setResult(id, state, nil, formatErr)
return OperatorSelection{}, formatErr
}
current, err = queryOperatorSelection(longCtx, client)
if err != nil {
manager.setResult(id, state, nil, err)
return OperatorSelection{}, err
}
manual = current.Mode == 1 || current.Mode == 4
}
if !manual {
result, restoreErr := restoreAutomaticOperatorSelection(longCtx, client)
manager.setResult(id, state, nil, restoreErr)
return result, restoreErr
}
desired := ""
if manual {
if !decimalPLMN(current.Operator) {
return OperatorSelection{}, errors.New("current manual operator is not available as a numeric PLMN")
}
desired = fmt.Sprintf(`AT+COPS=1,2,"%s"`, current.Operator)
if code, ok := accessTechnologyCode(current.AccessTechnology); ok {
desired += fmt.Sprintf(",%d", code)
}
}
for _, command := range []string{"AT+COPS=2", desired} {
response, executeErr := client.Execute(longCtx, command)
if executeErr != nil {
manager.setResult(id, state, nil, executeErr)
return OperatorSelection{}, executeErr
}
if !response.OK() {
executeErr = &modem.CommandError{Command: response.Command, Final: response.Final, Lines: response.Lines}
manager.setResult(id, state, nil, executeErr)
return OperatorSelection{}, executeErr
}
}
result, err := queryOperatorSelection(longCtx, client)
manager.setResult(id, state, nil, err)
if err != nil {
return OperatorSelection{}, err
}
return result, nil
}
func decimalPLMN(value string) bool {
value = strings.TrimSpace(value)
return (len(value) == 5 || len(value) == 6) && strings.IndexFunc(value, func(r rune) bool {
return r < '0' || r > '9'
}) < 0
}
func accessTechnologyCode(name string) (int, bool) {
switch strings.ToUpper(strings.TrimSpace(name)) {
case "GSM":
return 0, true
case "UTRAN":
return 2, true
case "EDGE":
return 3, true
case "HSDPA":
return 4, true
case "HSUPA":
return 5, true
case "HSPA":
return 6, true
case "LTE":
return 7, true
case "NR5G":
return 9, true
default:
return 0, false
}
}
+208 -25
View File
@@ -4,9 +4,13 @@ package device
import (
"context"
"errors"
"fmt"
"hash/fnv"
"net"
"os"
"os/exec"
"strconv"
"strings"
"time"
@@ -31,7 +35,11 @@ func setQMINetwork(
profilePath := profile.Name()
defer os.Remove(profilePath)
ipType := map[string]string{"IP": "4", "IPV6": "6", "IPV4V6": "4"}[ipVersion]
if _, err := fmt.Fprintf(profile, "APN=%s\nIP_TYPE=%s\nPROXY=yes\n", apn, ipType); err != nil {
profileText := fmt.Sprintf("IP_TYPE=%s\nPROXY=yes\n", ipType)
if apn != "" {
profileText = "APN=" + apn + "\n" + profileText
}
if _, err := fmt.Fprint(profile, profileText); err != nil {
_ = profile.Close()
return NetworkResult{}, fmt.Errorf("write temporary QMI profile: %w", err)
}
@@ -54,36 +62,42 @@ func setQMINetwork(
lowerDetail := strings.ToLower(detail)
idempotentStop := !enabled && (strings.Contains(lowerDetail, "already stopped") ||
strings.Contains(lowerDetail, "not started") || strings.Contains(lowerDetail, "no network"))
if !idempotentStop {
idempotentStart := enabled && (strings.Contains(lowerDetail, "already started") ||
strings.Contains(lowerDetail, "already connected"))
if !idempotentStop && !idempotentStart {
return NetworkResult{}, fmt.Errorf("qmi-network %s failed: %w: %s", action, err, detail)
}
}
if ipCommand, lookErr := exec.LookPath("ip"); lookErr == nil {
linkAction := "down"
if enabled {
linkAction = "up"
}
linkOutput, linkErr := exec.CommandContext(ctx, ipCommand, "link", "set", "dev", candidate.NetworkInterface, linkAction).CombinedOutput()
if linkErr != nil {
return NetworkResult{}, fmt.Errorf("set %s %s: %w: %s", candidate.NetworkInterface, linkAction, linkErr, strings.TrimSpace(string(linkOutput)))
}
ipCommand, lookErr := exec.LookPath("ip")
if lookErr != nil {
return NetworkResult{}, fmt.Errorf("%w: install iproute2 to control %s", ErrDataBackendUnavailable, candidate.NetworkInterface)
}
linkAction := "down"
if enabled {
linkAction = "up"
}
linkOutput, linkErr := exec.CommandContext(ctx, ipCommand, "link", "set", "dev", candidate.NetworkInterface, linkAction).CombinedOutput()
if linkErr != nil {
return NetworkResult{}, fmt.Errorf("set %s %s: %w: %s", candidate.NetworkInterface, linkAction, linkErr, strings.TrimSpace(string(linkOutput)))
}
if enabled {
if busybox, lookErr := exec.LookPath("busybox"); lookErr == nil {
dhcpOutput, dhcpErr := exec.CommandContext(ctx, busybox, "udhcpc", "-q", "-n", "-t", "5", "-T", "3", "-i", candidate.NetworkInterface).CombinedOutput()
if dhcpErr != nil {
rollbackCtx, cancelRollback := context.WithTimeout(context.Background(), managerCommandCleanupTimeout)
defer cancelRollback()
_, _ = exec.CommandContext(rollbackCtx, qmiNetwork, "--profile="+profilePath, candidate.QMIControl, "stop").CombinedOutput()
if ipCommand, lookErr := exec.LookPath("ip"); lookErr == nil {
_, _ = exec.CommandContext(rollbackCtx, ipCommand, "link", "set", "dev", candidate.NetworkInterface, "down").CombinedOutput()
}
return NetworkResult{}, fmt.Errorf("QMI session started but DHCP failed: %w: %s", dhcpErr, strings.TrimSpace(string(dhcpOutput)))
}
if value := strings.TrimSpace(string(dhcpOutput)); value != "" {
detail = strings.TrimSpace(detail + "\n" + value)
}
busybox, busyboxErr := exec.LookPath("busybox")
if busyboxErr != nil {
return NetworkResult{}, fmt.Errorf("%w: busybox udhcpc is required for %s", ErrDataBackendUnavailable, candidate.NetworkInterface)
}
dhcpDetail, dhcpErr := configureExportProxyDHCP(ctx, busybox, ipCommand, candidate.NetworkInterface)
if dhcpErr != nil {
rollbackCtx, cancelRollback := context.WithTimeout(context.Background(), managerCommandCleanupTimeout)
defer cancelRollback()
clearExportProxyRoute(rollbackCtx, candidate.NetworkInterface)
_, _ = exec.CommandContext(rollbackCtx, qmiNetwork, "--profile="+profilePath, candidate.QMIControl, "stop").CombinedOutput()
_, _ = exec.CommandContext(rollbackCtx, ipCommand, "link", "set", "dev", candidate.NetworkInterface, "down").CombinedOutput()
return NetworkResult{}, fmt.Errorf("QMI session started but protected DHCP failed: %w", dhcpErr)
}
detail = strings.TrimSpace(detail + "\n" + dhcpDetail)
} else {
clearExportProxyRoute(ctx, candidate.NetworkInterface)
_, _ = exec.CommandContext(ctx, ipCommand, "-4", "addr", "flush", "dev", candidate.NetworkInterface, "scope", "global").CombinedOutput()
}
return NetworkResult{
Enabled: enabled,
@@ -96,4 +110,173 @@ func setQMINetwork(
}, nil
}
// 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.
func exportProxyRouteIdentity(networkInterface string) (mark uint32, table, priority int) {
hash := fnv.New32a()
_, _ = hash.Write([]byte(networkInterface))
value := hash.Sum32()
mark = 0x56000000 | (value & 0x00ffffff)
table = 20000 + int(value%10000)
priority = 20000 + int(value%10000)
return
}
func configureExportProxyDHCP(ctx context.Context, busybox, ipCommand, networkInterface string) (string, error) {
lease, err := os.CreateTemp("", "vocat-dhcp-lease-*.env")
if err != nil {
return "", err
}
leasePath := lease.Name()
_ = lease.Close()
_ = os.Remove(leasePath)
defer os.Remove(leasePath)
script, err := os.CreateTemp("", "vocat-udhcpc-*.sh")
if err != nil {
return "", err
}
scriptPath := script.Name()
defer os.Remove(scriptPath)
scriptText := fmt.Sprintf(`#!/bin/sh
case "$1" in
bound|renew)
(umask 077; printf 'ip=%%s\nsubnet=%%s\nrouter=%%s\ndns=%%s\n' "$ip" "$subnet" "$router" "$dns" > %q)
;;
esac
exit 0
`, leasePath)
if _, err := script.WriteString(scriptText); err != nil {
_ = script.Close()
return "", err
}
if err := script.Chmod(0o700); err != nil {
_ = script.Close()
return "", err
}
if err := script.Close(); err != nil {
return "", err
}
output, err := exec.CommandContext(ctx, busybox, "udhcpc", "-q", "-n", "-t", "5", "-T", "3", "-i", networkInterface, "-s", scriptPath).CombinedOutput()
if err != nil {
if strings.Contains(strings.ToLower(string(output)), "address family not supported") {
return "", fmt.Errorf("udhcpc cannot open its link-layer socket: allow AF_PACKET in the vocat systemd service RestrictAddressFamilies setting: %w", err)
}
return "", fmt.Errorf("udhcpc: %w: %s", err, strings.TrimSpace(string(output)))
}
raw, err := os.ReadFile(leasePath)
if err != nil {
return "", fmt.Errorf("read DHCP lease: %w", err)
}
values := make(map[string]string)
for _, line := range strings.Split(string(raw), "\n") {
key, value, found := strings.Cut(line, "=")
if found {
values[strings.TrimSpace(key)] = strings.TrimSpace(value)
}
}
address := net.ParseIP(values["ip"]).To4()
maskIP := net.ParseIP(values["subnet"]).To4()
if address == nil || maskIP == nil {
return "", errors.New("DHCP returned no valid IPv4 address/subnet")
}
mask := net.IPMask(maskIP)
ones, bits := mask.Size()
if bits != 32 || ones < 0 {
return "", errors.New("DHCP returned an invalid IPv4 subnet")
}
network := address.Mask(mask)
routers := strings.Fields(values["router"])
if len(routers) > 0 && net.ParseIP(routers[0]).To4() == nil {
return "", errors.New("DHCP returned an invalid IPv4 gateway")
}
if result, addrErr := exec.CommandContext(ctx, ipCommand, "-4", "addr", "replace", fmt.Sprintf("%s/%d", address.String(), ones), "dev", networkInterface).CombinedOutput(); addrErr != nil {
return "", fmt.Errorf("configure cellular address: %w: %s", addrErr, strings.TrimSpace(string(result)))
}
mark, table, priority := exportProxyRouteIdentity(networkInterface)
clearExportProxyRoute(ctx, networkInterface)
connectedCIDR := fmt.Sprintf("%s/%d", network.String(), ones)
if result, routeErr := exec.CommandContext(ctx, ipCommand, "-4", "route", "replace", "table", strconv.Itoa(table), connectedCIDR, "dev", networkInterface, "scope", "link", "src", address.String()).CombinedOutput(); routeErr != nil {
clearExportProxyRoute(ctx, networkInterface)
return "", fmt.Errorf("install protected connected route: %w: %s", routeErr, strings.TrimSpace(string(result)))
}
defaultArgs := []string{"-4", "route", "replace", "table", strconv.Itoa(table), "default"}
if len(routers) > 0 {
defaultArgs = append(defaultArgs, "via", routers[0])
}
defaultArgs = append(defaultArgs, "dev", networkInterface, "onlink")
if result, routeErr := exec.CommandContext(ctx, ipCommand, defaultArgs...).CombinedOutput(); routeErr != nil {
clearExportProxyRoute(ctx, networkInterface)
return "", fmt.Errorf("install protected default route: %w: %s", routeErr, strings.TrimSpace(string(result)))
}
markText := fmt.Sprintf("0x%x", mark)
result, err := exec.CommandContext(ctx, ipCommand, "rule", "add", "priority", strconv.Itoa(priority), "fwmark", markText, "lookup", strconv.Itoa(table)).CombinedOutput()
if err != nil {
clearExportProxyRoute(ctx, networkInterface)
return "", fmt.Errorf("install protected routing rule: %w: %s", err, strings.TrimSpace(string(result)))
}
if err := writeExportProxyDNS(networkInterface, strings.Fields(values["dns"])); err != nil {
clearExportProxyRoute(ctx, networkInterface)
return "", fmt.Errorf("publish protected DNS configuration: %w", err)
}
return fmt.Sprintf("protected DHCP lease %s/%d", address.String(), ones), nil
}
func exportProxyDNSPath(networkInterface string) string {
safeName := strings.Map(func(character rune) rune {
if character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' ||
character >= '0' && character <= '9' || character == '-' || character == '_' || character == '.' {
return character
}
return '_'
}, networkInterface)
return "/run/vocat/cellular-" + safeName + ".dns"
}
func writeExportProxyDNS(networkInterface string, servers []string) error {
valid := make([]string, 0, len(servers))
for _, server := range servers {
if address := net.ParseIP(server); address != nil {
valid = append(valid, address.String())
}
}
if len(valid) == 0 {
// This is used only by marked Export Proxy sockets. It never changes the
// host resolver and is merely a fallback for carriers omitting DHCP DNS.
valid = []string{"1.1.1.1", "8.8.8.8"}
}
if err := os.MkdirAll("/run/vocat", 0o755); err != nil {
return err
}
temporary, err := os.CreateTemp("/run/vocat", ".cellular-dns-*")
if err != nil {
return err
}
temporaryPath := temporary.Name()
defer os.Remove(temporaryPath)
if _, err := temporary.WriteString(strings.Join(valid, "\n") + "\n"); err != nil {
_ = temporary.Close()
return err
}
if err := temporary.Chmod(0o644); err != nil {
_ = temporary.Close()
return err
}
if err := temporary.Close(); err != nil {
return err
}
return os.Rename(temporaryPath, exportProxyDNSPath(networkInterface))
}
func clearExportProxyRoute(ctx context.Context, networkInterface string) {
_ = os.Remove(exportProxyDNSPath(networkInterface))
ipCommand, err := exec.LookPath("ip")
if err != nil {
return
}
mark, table, priority := exportProxyRouteIdentity(networkInterface)
_, _ = exec.CommandContext(ctx, ipCommand, "rule", "del", "priority", strconv.Itoa(priority), "fwmark", fmt.Sprintf("0x%x", mark), "lookup", strconv.Itoa(table)).CombinedOutput()
_, _ = exec.CommandContext(ctx, ipCommand, "-4", "route", "flush", "table", strconv.Itoa(table)).CombinedOutput()
}
const managerCommandCleanupTimeout = 15 * time.Second
+82 -1
View File
@@ -74,7 +74,10 @@ func TestOperatorSelectionManualAndAutomatic(t *testing.T) {
client := &transcriptClient{steps: []clientStep{
{command: `AT+COPS=1,2,"46000",7`, response: okResponse()},
{command: "AT+COPS?", response: okResponse(`+COPS: 1,2,"46000",7`)},
{command: `AT+QCFG="nwscanmode",0,1`, response: okResponse()},
{command: "AT+COPS=2", response: okResponse()},
{command: "AT+COPS=0", response: okResponse()},
{command: "AT+COPS?", response: okResponse(`+COPS: 0,2,"46001",7`)},
}}
manager, id := newStartedTestManager(t, client)
act := 7
@@ -89,7 +92,7 @@ func TestOperatorSelectionManualAndAutomatic(t *testing.T) {
if err != nil {
t.Fatalf("automatic selection: %v", err)
}
if selection.Mode != 0 || selection.Operator != "" {
if selection.Mode != 0 || selection.Operator != "46001" {
t.Fatalf("automatic selection = %#v", selection)
}
client.assertDone(t)
@@ -99,6 +102,10 @@ func TestOperatorSelectionRejectsAutomaticFallbackAsSuccess(t *testing.T) {
client := &transcriptClient{steps: []clientStep{
{command: `AT+COPS=1,2,"46000",7`, response: okResponse()},
{command: "AT+COPS?", response: okResponse("+COPS: 0")},
{command: `AT+QCFG="nwscanmode",0,1`, response: okResponse()},
{command: "AT+COPS=2", response: okResponse()},
{command: "AT+COPS=0", response: okResponse()},
{command: "AT+COPS?", response: okResponse(`+COPS: 0,2,"46001",7`)},
}}
manager, id := newStartedTestManager(t, client)
act := 7
@@ -107,3 +114,77 @@ func TestOperatorSelectionRejectsAutomaticFallbackAsSuccess(t *testing.T) {
}
client.assertDone(t)
}
func TestOperatorSelectionCommandFailureRestoresAutomaticMode(t *testing.T) {
selectionErr := errors.New("+CME ERROR: 30")
client := &transcriptClient{steps: []clientStep{
{command: `AT+COPS=1,2,"46000",7`, err: selectionErr},
{command: `AT+QCFG="nwscanmode",0,1`, response: okResponse()},
{command: "AT+COPS=2", response: okResponse()},
{command: "AT+COPS=0", response: okResponse()},
{command: "AT+COPS?", response: okResponse(`+COPS: 0,2,"46001",7`)},
}}
manager, id := newStartedTestManager(t, client)
act := 7
_, err := manager.SetOperatorSelection(context.Background(), id, false, "46000", &act)
if !errors.Is(err, selectionErr) {
t.Fatalf("error = %v, want wrapped selection error", err)
}
client.assertDone(t)
}
func TestReRegisterOperatorReappliesAutomaticMode(t *testing.T) {
client := &transcriptClient{steps: []clientStep{
{command: "AT+COPS?", response: okResponse(`+COPS: 0,2,"46001",7`)},
{command: `AT+QCFG="nwscanmode",0,1`, response: okResponse()},
{command: "AT+COPS=2", response: okResponse()},
{command: "AT+COPS=0", response: okResponse()},
{command: "AT+COPS?", response: okResponse(`+COPS: 0,2,"46001",7`)},
}}
manager, id := newStartedTestManager(t, client)
selection, err := manager.ReRegisterOperator(context.Background(), id)
if err != nil {
t.Fatal(err)
}
if selection.Mode != 0 || selection.Operator != "46001" {
t.Fatalf("selection = %#v", selection)
}
client.assertDone(t)
}
func TestReRegisterOperatorPreservesManualLock(t *testing.T) {
client := &transcriptClient{steps: []clientStep{
{command: "AT+COPS?", response: okResponse(`+COPS: 1,2,"46003",7`)},
{command: "AT+COPS=2", response: okResponse()},
{command: `AT+COPS=1,2,"46003",7`, response: okResponse()},
{command: "AT+COPS?", response: okResponse(`+COPS: 1,2,"46003",7`)},
}}
manager, id := newStartedTestManager(t, client)
selection, err := manager.ReRegisterOperator(context.Background(), id)
if err != nil {
t.Fatal(err)
}
if selection.Mode != 1 || selection.Operator != "46003" || selection.AccessTechnology != "LTE" {
t.Fatalf("selection = %#v", selection)
}
client.assertDone(t)
}
func TestReRegisterOperatorRecoversDeregisteredModeWithAutomaticSelection(t *testing.T) {
client := &transcriptClient{steps: []clientStep{
{command: "AT+COPS?", response: okResponse(`+COPS: 2`)},
{command: `AT+QCFG="nwscanmode",0,1`, response: okResponse()},
{command: "AT+COPS=2", response: okResponse()},
{command: "AT+COPS=0", response: okResponse()},
{command: "AT+COPS?", response: okResponse(`+COPS: 0,2,"46001",7`)},
}}
manager, id := newStartedTestManager(t, client)
selection, err := manager.ReRegisterOperator(context.Background(), id)
if err != nil {
t.Fatal(err)
}
if selection.Mode != 0 || selection.Operator != "46001" {
t.Fatalf("selection = %#v", selection)
}
client.assertDone(t)
}
+4 -1
View File
@@ -27,6 +27,7 @@ func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
),
},
{command: "AT+COPS?", response: okResponse(`+COPS: 0,0,"China Mobile",7`)},
{command: "AT+CEREG?", response: okResponse(`+CEREG: 0,5`)},
{command: "AT+CGSN", response: okResponse("867123456789012")},
{
command: "AT+CCID",
@@ -66,7 +67,9 @@ func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
t.Fatalf("signal metrics = %#v", snapshot)
}
if snapshot.AccessTech != "LTE" || snapshot.Band != "B3" ||
snapshot.Channel != "1650" || snapshot.OperatorName != "China Mobile" {
snapshot.Channel != "1650" || snapshot.OperatorName != "China Unicom" ||
snapshot.OperatorCode != "46001" ||
snapshot.RegistrationStatus != 5 || snapshot.RegistrationSource != "CEREG" {
t.Fatalf("network = %#v", snapshot)
}
if snapshot.IMEI != "867123456789012" ||
File diff suppressed because one or more lines are too long
+36
View File
@@ -0,0 +1,36 @@
//go:build linux
package device
import (
"context"
"os/exec"
"strings"
"time"
"vocat/internal/modem"
)
func readPlatformRegistration(ctx context.Context, candidate modem.Candidate) (platformRegistration, bool) {
control := strings.TrimSpace(candidate.QMIControl)
if control == "" {
return platformRegistration{}, false
}
qmicli, err := exec.LookPath("qmicli")
if err != nil {
return platformRegistration{}, false
}
queryContext, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
output, err := exec.CommandContext(
queryContext,
qmicli,
"-d", control,
"--device-open-proxy",
"--nas-get-serving-system",
).CombinedOutput()
if err != nil {
return platformRegistration{}, false
}
return parseQMIRegistration(string(output))
}
+13
View File
@@ -0,0 +1,13 @@
//go:build !linux
package device
import (
"context"
"vocat/internal/modem"
)
func readPlatformRegistration(context.Context, modem.Candidate) (platformRegistration, bool) {
return platformRegistration{}, false
}
+80
View File
@@ -0,0 +1,80 @@
package device
import (
"regexp"
"strings"
)
type platformRegistration struct {
Status int
PLMN string
Name string
PSAttached bool
}
var qmiQuotedFieldPattern = regexp.MustCompile(`(?i)^\s*([^:]+):\s*'([^']*)'\s*$`)
func parseQMIRegistration(output string) (platformRegistration, bool) {
result := platformRegistration{}
registrationState := ""
roaming := false
mcc := ""
mnc := ""
pcsDigit := false
for _, rawLine := range strings.Split(output, "\n") {
match := qmiQuotedFieldPattern.FindStringSubmatch(strings.TrimSpace(rawLine))
if len(match) != 3 {
continue
}
key := strings.ToLower(strings.TrimSpace(match[1]))
value := strings.TrimSpace(match[2])
switch key {
case "registration state":
registrationState = strings.ToLower(value)
case "roaming status":
roaming = strings.EqualFold(value, "on")
case "ps":
result.PSAttached = strings.EqualFold(value, "attached")
case "mcc":
if mcc == "" {
mcc = value
}
case "mnc":
if mnc == "" {
mnc = value
}
case "description":
if result.Name == "" {
result.Name = value
}
case "mnc with pcs digit":
pcsDigit = strings.EqualFold(value, "yes")
}
}
switch registrationState {
case "registered":
result.Status = 1
if roaming {
result.Status = 5
}
case "not-registered-searching", "searching":
result.Status = 2
case "registration-denied", "denied":
result.Status = 3
case "not-registered":
result.Status = 0
default:
return platformRegistration{}, false
}
if decimalDigits(mcc, 3, 3) && decimalDigits(mnc, 1, 3) {
width := 2
if pcsDigit {
width = 3
}
for len(mnc) < width {
mnc = "0" + mnc
}
result.PLMN = mcc + mnc
}
return result, true
}
+31
View File
@@ -0,0 +1,31 @@
package device
import "testing"
func TestParseQMIRegistrationRegisteredRoaming(t *testing.T) {
output := `
Registration state: 'registered'
CS: 'attached'
PS: 'attached'
Roaming status: 'on'
Current PLMN:
MCC: '460'
MNC: '1'
Description: 'UNICOM'
Full operator code info:
MCC: '460'
MNC: '1'
MNC with PCS digit: 'no'
`
result, found := parseQMIRegistration(output)
if !found || result.Status != 5 || !result.PSAttached || result.PLMN != "46001" || result.Name != "UNICOM" {
t.Fatalf("registration = %#v, found=%v", result, found)
}
}
func TestParseQMIRegistrationSearching(t *testing.T) {
result, found := parseQMIRegistration("Registration state: 'not-registered-searching'\nPS: 'detached'")
if !found || result.Status != 2 || result.PSAttached {
t.Fatalf("registration = %#v, found=%v", result, found)
}
}
+24
View File
@@ -0,0 +1,24 @@
package device
import (
"testing"
"vocat/internal/modem"
)
func TestParseRegistrationStatus(t *testing.T) {
tests := []struct {
line string
want int
}{
{line: "+CEREG: 0,5", want: 5},
{line: "+CGREG: 2,1,\"FFFE\",\"06698D06\",7", want: 1},
{line: "+CREG: 2", want: 2},
}
for _, test := range tests {
got, ok := parseRegistrationStatus(modem.Response{Lines: []string{test.line}})
if !ok || got != test.want {
t.Fatalf("parseRegistrationStatus(%q) = %d, %v", test.line, got, ok)
}
}
}
+24 -1
View File
@@ -14,6 +14,7 @@ type ScannedOperator struct {
Name string `json:"name"`
Short string `json:"shortName,omitempty"`
Numeric string `json:"numeric"`
Country string `json:"countryCode,omitempty"`
Act string `json:"act,omitempty"`
}
@@ -75,11 +76,19 @@ func parseOperatorScan(response modem.Response) []ScannedOperator {
if len(fields) < 4 {
continue
}
name, country, _ := CarrierForPLMN(fields[3])
if name == "" {
name = strings.TrimSpace(fields[1])
}
if name == "" {
name = strings.TrimSpace(fields[3])
}
operator := ScannedOperator{
Status: operatorScanStatus(fields[0]),
Name: fields[1],
Name: name,
Short: fields[2],
Numeric: fields[3],
Country: country,
}
if len(fields) >= 5 {
operator.Act = accessTechnology(fields[4])
@@ -90,6 +99,20 @@ func parseOperatorScan(response modem.Response) []ScannedOperator {
return operators
}
// carrierNameForPLMN resolves the numeric serving PLMN through the bundled
// global carrier database. Some EC20 firmware returns an empty, localized, or
// stale long name even though the MCC/MNC is correct. The numeric identity is
// the authoritative value used for network selection.
func carrierNameForPLMN(plmn, fallback string) string {
if name, _, ok := CarrierForPLMN(plmn); ok {
return name
}
if fallback = strings.TrimSpace(fallback); fallback != "" {
return fallback
}
return strings.TrimSpace(plmn)
}
// extractScanTuples returns the contents of each top-level parenthesised group,
// ignoring parentheses inside quoted strings.
func extractScanTuples(payload string) []string {
+55
View File
@@ -0,0 +1,55 @@
package device
import (
"testing"
"vocat/internal/modem"
)
func TestParseOperatorScanNormalizesMainlandCarrierNamesByPLMN(t *testing.T) {
response := modem.Response{Lines: []string{
`+COPS: (1,"CMCC","CMCC","46000",7),(1,"wrong modem name","CU","46001",7),(1,"","CT","46011",7),(1,"CBN","CBN","46015",7)`,
}}
operators := parseOperatorScan(response)
if len(operators) != 4 {
t.Fatalf("operators = %#v", operators)
}
want := []string{"China Mobile", "China Unicom", "China Telecom", "China Broadnet"}
for index := range want {
if operators[index].Name != want[index] {
t.Fatalf("operator %d name = %q, want %q", index, operators[index].Name, want[index])
}
}
}
func TestCarrierNameForPLMNUsesGlobalDatabase(t *testing.T) {
if got := carrierNameForPLMN("23415", "stale modem name"); got != "Vodafone" {
t.Fatalf("carrier name = %q", got)
}
if got := carrierNameForPLMN("26202", ""); got != "Vodafone" {
t.Fatalf("German carrier name = %q", got)
}
if got := carrierNameForPLMN("310260", ""); got != "T-Mobile - US" {
t.Fatalf("US carrier name = %q", got)
}
if got := carrierNameForPLMN("99999", "Test Network"); got != "Test Network" {
t.Fatalf("unknown carrier fallback = %q", got)
}
}
func TestCarrierForPLMNReturnsCountryCode(t *testing.T) {
tests := map[string]string{
"23415": "GB",
"26202": "DE",
"310260": "US",
"22201": "IT",
"72405": "BR",
"46015": "CN",
}
for plmn, wantCountry := range tests {
name, country, ok := CarrierForPLMN(plmn)
if !ok || name == "" || country != wantCountry {
t.Errorf("CarrierForPLMN(%q) = (%q, %q, %v), want a name and country %q", plmn, name, country, ok, wantCountry)
}
}
}
+64 -2
View File
@@ -50,8 +50,10 @@ func (manager *Manager) readSnapshot(
if response, ok := optional("AT+CSQ"); ok {
snapshot.SignalRaw, snapshot.SignalPercent, snapshot.RSSIDBm = parseCSQ(response)
}
servingPLMN := ""
if response, ok := optional(`AT+QENG="servingcell"`); ok {
metrics := parseQENG(response)
servingPLMN = metrics.PLMN
snapshot.AccessTech = metrics.AccessTech
snapshot.Band = metrics.Band
snapshot.Channel = metrics.Channel
@@ -64,12 +66,42 @@ func (manager *Manager) readSnapshot(
}
if response, ok := optional("AT+COPS?"); ok {
operator := parseCOPS(response)
snapshot.OperatorName = operator.Name
snapshot.OperatorCode = operator.Code
if operator.Code != "" {
snapshot.OperatorCode = operator.Code
} else {
snapshot.OperatorCode = servingPLMN
}
snapshot.OperatorName = carrierNameForPLMN(snapshot.OperatorCode, operator.Name)
if snapshot.AccessTech == "" {
snapshot.AccessTech = operator.AccessTech
}
}
for _, command := range []string{"AT+CEREG?", "AT+CGREG?", "AT+CREG?"} {
response, registrationErr := manager.command(ctx, client, command)
if registrationErr != nil {
continue
}
if status, found := parseRegistrationStatus(response); found {
snapshot.RegistrationStatus = status
snapshot.RegistrationSource = strings.TrimSuffix(strings.TrimPrefix(command, "AT+"), "?")
break
}
}
if registration, found := readPlatformRegistration(ctx, candidate); found {
snapshot.RegistrationStatus = registration.Status
snapshot.RegistrationSource = "QMI NAS"
snapshot.PSAttached = registration.PSAttached
if registration.PLMN != "" {
snapshot.OperatorCode = registration.PLMN
snapshot.OperatorName = carrierNameForPLMN(registration.PLMN, registration.Name)
}
}
if snapshot.RegistrationSource == "" && (snapshot.OperatorName != "" || snapshot.OperatorCode != "") {
// Older firmware can omit registration queries while COPS still proves
// that an operator is selected.
snapshot.RegistrationStatus = 1
snapshot.RegistrationSource = "COPS"
}
if response, ok := optional("AT+CGSN"); ok {
snapshot.IMEI = parseIdentifier(
response,
@@ -107,6 +139,25 @@ func (manager *Manager) readSnapshot(
return snapshot, nil
}
func parseRegistrationStatus(response modem.Response) (int, bool) {
for _, prefix := range []string{"+CEREG:", "+CGREG:", "+CREG:"} {
values := csvValues(valueAfterPrefix(response, prefix))
if len(values) == 0 {
continue
}
index := 0
// Query responses are <n>,<stat>; unsolicited responses are <stat>.
if len(values) >= 2 {
index = 1
}
status, err := strconv.Atoi(strings.TrimSpace(values[index]))
if err == nil && status >= 0 && status <= 10 {
return status, true
}
}
return 0, false
}
func parseATI(lines []string) (manufacturer, model, firmware string) {
for _, line := range lines {
line = strings.TrimSpace(line)
@@ -159,6 +210,7 @@ func parseCSQ(response modem.Response) (raw, percent, dbm *int) {
}
type qengMetrics struct {
PLMN string
AccessTech string
Band string
Channel string
@@ -179,6 +231,9 @@ func parseQENG(response modem.Response) qengMetrics {
}
result := qengMetrics{AccessTech: strings.ToUpper(values[2])}
if strings.EqualFold(values[2], "LTE") && len(values) >= 17 {
if decimalDigits(values[4], 3, 3) && decimalDigits(values[5], 2, 3) {
result.PLMN = values[4] + values[5]
}
result.Channel = values[8]
if values[9] != "" {
result.Band = "B" + values[9]
@@ -193,6 +248,13 @@ func parseQENG(response modem.Response) qengMetrics {
return qengMetrics{}
}
func decimalDigits(value string, minimum, maximum int) bool {
value = strings.TrimSpace(value)
return len(value) >= minimum && len(value) <= maximum && strings.IndexFunc(value, func(character rune) bool {
return character < '0' || character > '9'
}) < 0
}
type operatorInfo struct {
Name string
Code string
+32 -29
View File
@@ -73,35 +73,38 @@ const (
)
type Snapshot struct {
DeviceID string `json:"deviceId"`
Port string `json:"port"`
Responsive bool `json:"responsive"`
Manufacturer string `json:"manufacturer"`
Model string `json:"model"`
Firmware string `json:"firmware"`
SIMStatus string `json:"simStatus"`
SIMReady bool `json:"simReady"`
SignalRaw *int `json:"signalRaw,omitempty"`
SignalPercent *int `json:"signalPercent,omitempty"`
RSSIDBm *int `json:"rssiDbm,omitempty"`
RSRP *int `json:"rsrp,omitempty"`
RSRQ *int `json:"rsrq,omitempty"`
SINR *int `json:"sinr,omitempty"`
AccessTech string `json:"accessTech"`
Band string `json:"band"`
Channel string `json:"channel"`
OperatorName string `json:"operatorName"`
OperatorCode string `json:"operatorCode"`
IMEI string `json:"imei"`
ICCID string `json:"iccid"`
IMSI string `json:"imsi"`
OperatingMode int `json:"operatingMode"`
ModeKnown bool `json:"modeKnown"`
FlightMode bool `json:"flightMode"`
RadioOff bool `json:"radioOff"`
Phone PhoneNumber `json:"phone"`
Warnings []string `json:"warnings,omitempty"`
UpdatedAt time.Time `json:"updatedAt"`
DeviceID string `json:"deviceId"`
Port string `json:"port"`
Responsive bool `json:"responsive"`
Manufacturer string `json:"manufacturer"`
Model string `json:"model"`
Firmware string `json:"firmware"`
SIMStatus string `json:"simStatus"`
SIMReady bool `json:"simReady"`
SignalRaw *int `json:"signalRaw,omitempty"`
SignalPercent *int `json:"signalPercent,omitempty"`
RSSIDBm *int `json:"rssiDbm,omitempty"`
RSRP *int `json:"rsrp,omitempty"`
RSRQ *int `json:"rsrq,omitempty"`
SINR *int `json:"sinr,omitempty"`
AccessTech string `json:"accessTech"`
Band string `json:"band"`
Channel string `json:"channel"`
OperatorName string `json:"operatorName"`
OperatorCode string `json:"operatorCode"`
RegistrationStatus int `json:"registrationStatus"`
RegistrationSource string `json:"registrationSource"`
PSAttached bool `json:"psAttached"`
IMEI string `json:"imei"`
ICCID string `json:"iccid"`
IMSI string `json:"imsi"`
OperatingMode int `json:"operatingMode"`
ModeKnown bool `json:"modeKnown"`
FlightMode bool `json:"flightMode"`
RadioOff bool `json:"radioOff"`
Phone PhoneNumber `json:"phone"`
Warnings []string `json:"warnings,omitempty"`
UpdatedAt time.Time `json:"updatedAt"`
}
type USSDResult struct {
+80
View File
@@ -0,0 +1,80 @@
//go:build linux
package exportproxy
import (
"bufio"
"context"
"hash/fnv"
"net"
"os"
"path/filepath"
"strings"
"syscall"
)
func platformSupported() error { return nil }
func boundDialer(networkInterface string) net.Dialer {
return net.Dialer{Control: func(_, _ string, raw syscall.RawConn) error {
var bindError error
err := raw.Control(func(fd uintptr) {
if err := syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_MARK, int(exportRouteMark(networkInterface))); err != nil {
bindError = err
return
}
bindError = syscall.SetsockoptString(int(fd), syscall.SOL_SOCKET, syscall.SO_BINDTODEVICE, networkInterface)
})
if err != nil {
return err
}
return bindError
}}
}
func exportRouteMark(networkInterface string) uint32 {
hash := fnv.New32a()
_, _ = hash.Write([]byte(networkInterface))
return 0x56000000 | (hash.Sum32() & 0x00ffffff)
}
func boundResolver(networkInterface string) *net.Resolver {
dialer := boundDialer(networkInterface)
return &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
var lastError error
for _, server := range exportRouteDNSServers(networkInterface) {
connection, err := dialer.DialContext(ctx, network, net.JoinHostPort(server, "53"))
if err == nil {
return connection, nil
}
lastError = err
}
return nil, lastError
}}
}
func exportRouteDNSServers(networkInterface string) []string {
safeName := strings.Map(func(character rune) rune {
if character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' ||
character >= '0' && character <= '9' || character == '-' || character == '_' || character == '.' {
return character
}
return '_'
}, networkInterface)
file, err := os.Open(filepath.Join("/run/vocat", "cellular-"+safeName+".dns"))
if err != nil {
return []string{"1.1.1.1", "8.8.8.8"}
}
defer file.Close()
servers := make([]string, 0, 2)
scanner := bufio.NewScanner(file)
for scanner.Scan() {
if value := strings.TrimSpace(scanner.Text()); net.ParseIP(value) != nil {
servers = append(servers, value)
}
}
if len(servers) == 0 {
return []string{"1.1.1.1", "8.8.8.8"}
}
return servers
}
+12
View File
@@ -0,0 +1,12 @@
//go:build !linux
package exportproxy
import (
"errors"
"net"
)
func platformSupported() error { return errors.New("built-in export proxy is only available on Linux") }
func boundDialer(string) net.Dialer { return net.Dialer{} }
func boundResolver(string) *net.Resolver { return net.DefaultResolver }
+88
View File
@@ -0,0 +1,88 @@
package exportproxy
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
)
const ipInfoURL = "https://ipinfo.io/json"
type PublicIPInfo struct {
IP string `json:"ip"`
CountryCode string `json:"country_code"`
Region string `json:"region"`
City string `json:"city"`
Organization string `json:"organization,omitempty"`
}
// LookupPublicIP sends the lookup through the same marked, interface-bound
// dialer and isolated DNS resolver as Export Proxy. It therefore reports the
// modem's roaming exit rather than the host or browser's default connection.
func LookupPublicIP(ctx context.Context, networkInterface string) (PublicIPInfo, error) {
networkInterface = strings.TrimSpace(networkInterface)
if networkInterface == "" {
return PublicIPInfo{}, errors.New("cellular network interface is required")
}
if err := platformSupported(); err != nil {
return PublicIPInfo{}, err
}
dialer := boundDialer(networkInterface)
resolver := boundResolver(networkInterface)
transport := &http.Transport{
DialContext: func(ctx context.Context, _, address string) (net.Conn, error) {
return dialTarget(ctx, address, &dialer, resolver)
},
DisableKeepAlives: true,
ResponseHeaderTimeout: 12 * time.Second,
}
defer transport.CloseIdleConnections()
request, err := http.NewRequestWithContext(ctx, http.MethodGet, ipInfoURL, nil)
if err != nil {
return PublicIPInfo{}, err
}
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", "VoCat/1.0")
response, err := transport.RoundTrip(request)
if err != nil {
return PublicIPInfo{}, fmt.Errorf("query ipinfo.io through %s: %w", networkInterface, err)
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4<<10))
return PublicIPInfo{}, fmt.Errorf("ipinfo.io returned HTTP %d", response.StatusCode)
}
return decodePublicIPInfo(io.LimitReader(response.Body, 64<<10))
}
func decodePublicIPInfo(reader io.Reader) (PublicIPInfo, error) {
var response struct {
IP string `json:"ip"`
Country string `json:"country"`
Region string `json:"region"`
City string `json:"city"`
Org string `json:"org"`
}
if err := json.NewDecoder(reader).Decode(&response); err != nil {
return PublicIPInfo{}, fmt.Errorf("decode ipinfo.io response: %w", err)
}
response.IP = strings.TrimSpace(response.IP)
response.Country = strings.ToUpper(strings.TrimSpace(response.Country))
if net.ParseIP(response.IP) == nil {
return PublicIPInfo{}, errors.New("ipinfo.io response contained no valid IP address")
}
if len(response.Country) != 2 {
return PublicIPInfo{}, errors.New("ipinfo.io response contained no valid country code")
}
return PublicIPInfo{
IP: response.IP, CountryCode: response.Country,
Region: strings.TrimSpace(response.Region), City: strings.TrimSpace(response.City),
Organization: strings.TrimSpace(response.Org),
}, nil
}
+22
View File
@@ -0,0 +1,22 @@
package exportproxy
import (
"strings"
"testing"
)
func TestDecodePublicIPInfo(t *testing.T) {
info, err := decodePublicIPInfo(strings.NewReader(`{"ip":"203.0.113.8","city":"London","region":"England","country":"gb","org":"AS64500 Test"}`))
if err != nil {
t.Fatal(err)
}
if info.IP != "203.0.113.8" || info.CountryCode != "GB" || info.Region != "England" || info.City != "London" {
t.Fatalf("info = %+v", info)
}
}
func TestDecodePublicIPInfoRejectsInvalidResponse(t *testing.T) {
if _, err := decodePublicIPInfo(strings.NewReader(`{"ip":"not-an-ip","country":"GB"}`)); err == nil {
t.Fatal("invalid IP was accepted")
}
}
+494
View File
@@ -0,0 +1,494 @@
package exportproxy
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
"os"
"strconv"
"strings"
"sync"
"time"
"vocat/internal/store"
)
const (
SettingKey = "developer.export_proxy.configs"
PasswordMask = "••••••••"
ReservedID = "export-proxy"
)
var (
ErrNotFound = errors.New("export proxy configuration not found")
ErrDisabled = errors.New("export proxy is disabled")
)
type Config struct {
ID string `json:"id"`
Name string `json:"name"`
DeviceID string `json:"device_id"`
Interface string `json:"interface"`
Mode string `json:"mode"`
ListenHost string `json:"listen_host"`
ListenPort int `json:"listen_port"`
Enabled bool `json:"enabled"`
AuthEnabled bool `json:"auth_enabled"`
Username string `json:"username"`
Password string `json:"password"`
}
type Status struct {
ID string `json:"id"`
Name string `json:"name"`
Mode string `json:"mode"`
Enabled bool `json:"enabled"`
Running bool `json:"running"`
Listen string `json:"listen"`
Error string `json:"error,omitempty"`
StartedAt time.Time `json:"started_at,omitempty"`
}
type Manager struct {
mu sync.Mutex
store *store.Store
logger *slog.Logger
configs []Config
listeners map[string]net.Listener
started map[string]time.Time
lastError map[string]string
disabled bool
}
func New(ctx context.Context, database *store.Store, logger *slog.Logger, legacyConfigPath string) (*Manager, error) {
if database == nil {
return nil, errors.New("export proxy store is required")
}
if logger == nil {
logger = slog.Default()
}
manager := &Manager{
store: database, logger: logger,
listeners: make(map[string]net.Listener),
started: make(map[string]time.Time),
lastError: make(map[string]string),
}
migrated, err := manager.load(ctx, legacyConfigPath)
if err != nil {
return nil, err
}
if migrated {
if err := manager.saveLocked(ctx); err != nil {
return nil, fmt.Errorf("migrate legacy export proxy configurations: %w", err)
}
_ = RemoveLegacyConfig(legacyConfigPath)
}
for _, config := range manager.configs {
if config.Enabled {
if err := manager.start(ctx, config.ID); err != nil {
manager.logger.Warn("start built-in export proxy", "id", config.ID, "error", err)
}
}
}
return manager, nil
}
func RemoveLegacyConfig(path string) error {
path = strings.TrimSpace(path)
if path == "" {
return nil
}
err := os.Remove(path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
func (manager *Manager) load(ctx context.Context, legacyConfigPath string) (bool, error) {
setting, err := manager.store.AppSetting(ctx, SettingKey)
if err == nil {
if err := json.Unmarshal(setting.Value, &manager.configs); err != nil {
return false, fmt.Errorf("decode export proxy configurations: %w", err)
}
return false, nil
}
if !errors.Is(err, store.ErrNotFound) {
return false, err
}
legacy, err := os.ReadFile(strings.TrimSpace(legacyConfigPath))
if err != nil {
if errors.Is(err, os.ErrNotExist) || strings.TrimSpace(legacyConfigPath) == "" {
return false, nil
}
return false, err
}
if err := json.Unmarshal(legacy, &manager.configs); err != nil {
return false, fmt.Errorf("decode legacy export proxy configurations: %w", err)
}
return true, nil
}
func (manager *Manager) saveLocked(ctx context.Context) error {
raw, err := json.Marshal(manager.configs)
if err != nil {
return err
}
return manager.store.UpsertAppSetting(ctx, store.AppSetting{Key: SettingKey, Value: raw, Sensitive: true})
}
func (manager *Manager) Configs() ([]Config, error) {
manager.mu.Lock()
defer manager.mu.Unlock()
if manager.disabled {
return nil, ErrDisabled
}
result := make([]Config, len(manager.configs))
for index, config := range manager.configs {
result[index] = redact(config)
}
return result, nil
}
// EnabledConfigForDevice returns the first enabled configuration bound to the
// given device, reporting whether one exists. It is used to block turning off a
// device's roaming data while one of its export proxies is still running.
func (manager *Manager) EnabledConfigForDevice(deviceID string) (Config, bool) {
manager.mu.Lock()
defer manager.mu.Unlock()
if manager.disabled {
return Config{}, false
}
for _, config := range manager.configs {
if config.DeviceID == deviceID && config.Enabled {
return redact(config), true
}
}
return Config{}, false
}
func (manager *Manager) Status() ([]Status, error) {
manager.mu.Lock()
defer manager.mu.Unlock()
if manager.disabled {
return nil, ErrDisabled
}
result := make([]Status, 0, len(manager.configs))
for _, config := range manager.configs {
status := Status{ID: config.ID, Name: config.Name, Mode: config.Mode, Enabled: config.Enabled, Error: manager.lastError[config.ID]}
if listener := manager.listeners[config.ID]; listener != nil {
status.Running = true
status.Listen = listener.Addr().String()
status.StartedAt = manager.started[config.ID]
}
result = append(result, status)
}
return result, nil
}
func (manager *Manager) Create(ctx context.Context, config Config) (Config, error) {
config.ID = generateID()
if err := manager.prepareConfig(ctx, &config); err != nil {
return Config{}, err
}
manager.mu.Lock()
if manager.disabled {
manager.mu.Unlock()
return Config{}, ErrDisabled
}
if err := manager.checkPortLocked(config, ""); err != nil {
manager.mu.Unlock()
return Config{}, err
}
manager.configs = append(manager.configs, config)
if err := manager.saveLocked(ctx); err != nil {
manager.configs = manager.configs[:len(manager.configs)-1]
manager.mu.Unlock()
return Config{}, err
}
manager.mu.Unlock()
if config.Enabled {
if err := manager.start(ctx, config.ID); err != nil {
_ = manager.Delete(context.Background(), config.ID)
return Config{}, err
}
}
return redact(config), nil
}
func (manager *Manager) Update(ctx context.Context, id string, incoming Config) (Config, error) {
incoming.ID = strings.TrimSpace(id)
manager.mu.Lock()
if manager.disabled {
manager.mu.Unlock()
return Config{}, ErrDisabled
}
existing, index := manager.configByIDLocked(incoming.ID)
manager.mu.Unlock()
if index < 0 {
return Config{}, ErrNotFound
}
if incoming.Password == "" || incoming.Password == PasswordMask {
incoming.Password = existing.Password
}
if err := manager.prepareConfig(ctx, &incoming); err != nil {
return Config{}, err
}
manager.mu.Lock()
if manager.disabled {
manager.mu.Unlock()
return Config{}, ErrDisabled
}
existing, index = manager.configByIDLocked(incoming.ID)
if index < 0 {
manager.mu.Unlock()
return Config{}, ErrNotFound
}
if err := manager.checkPortLocked(incoming, incoming.ID); err != nil {
manager.mu.Unlock()
return Config{}, err
}
wasRunning := manager.listeners[incoming.ID] != nil
runtimeChanged := existing.Mode != incoming.Mode || existing.Interface != incoming.Interface ||
existing.ListenHost != incoming.ListenHost || existing.ListenPort != incoming.ListenPort ||
existing.AuthEnabled != incoming.AuthEnabled || existing.Username != incoming.Username || existing.Password != incoming.Password
manager.configs[index] = incoming
if err := manager.saveLocked(ctx); err != nil {
manager.configs[index] = existing
manager.mu.Unlock()
return Config{}, err
}
manager.mu.Unlock()
switch {
case !incoming.Enabled:
manager.stop(incoming.ID)
case !wasRunning || runtimeChanged || !existing.Enabled:
if err := manager.start(ctx, incoming.ID); err != nil {
return redact(incoming), err
}
}
return redact(incoming), nil
}
func (manager *Manager) Delete(ctx context.Context, id string) error {
manager.mu.Lock()
defer manager.mu.Unlock()
if manager.disabled {
return ErrDisabled
}
_, index := manager.configByIDLocked(strings.TrimSpace(id))
if index < 0 {
return ErrNotFound
}
manager.stopLocked(id)
manager.configs = append(manager.configs[:index], manager.configs[index+1:]...)
return manager.saveLocked(ctx)
}
// DeleteAllAndDisable is irreversible for the active developer-mode session:
// it closes every listener, removes every saved proxy, and rejects new work.
func (manager *Manager) DeleteAllAndDisable(ctx context.Context) error {
manager.mu.Lock()
for id := range manager.listeners {
manager.stopLocked(id)
}
manager.configs = nil
manager.disabled = true
manager.mu.Unlock()
err := manager.store.DeleteAppSetting(ctx, SettingKey)
if errors.Is(err, store.ErrNotFound) {
return nil
}
return err
}
func (manager *Manager) Close() error {
manager.mu.Lock()
defer manager.mu.Unlock()
manager.disabled = true
for id := range manager.listeners {
manager.stopLocked(id)
}
return nil
}
func (manager *Manager) prepareConfig(ctx context.Context, config *Config) error {
config.Name = strings.TrimSpace(config.Name)
config.DeviceID = strings.TrimSpace(config.DeviceID)
config.Interface = strings.TrimSpace(config.Interface)
config.Mode = strings.ToLower(strings.TrimSpace(config.Mode))
config.ListenHost = strings.TrimSpace(config.ListenHost)
config.Username = strings.TrimSpace(config.Username)
if config.Name == "" {
config.Name = "proxy-" + config.ID[:4]
}
if config.DeviceID == "" {
return errors.New("device is required")
}
device, err := manager.store.Device(ctx, config.DeviceID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return errors.New("configured device was not found")
}
return err
}
if strings.TrimSpace(device.Interface) == "" {
return errors.New("the selected device has no cellular interface")
}
if config.Interface != "" && config.Interface != device.Interface {
return errors.New("proxy interface does not match the selected device")
}
config.Interface = device.Interface
if config.Enabled && !device.NetworkEnabled {
return errors.New("enable roaming data on the selected device before starting its export proxy")
}
if config.Mode != "http" && config.Mode != "socks5" {
return errors.New("mode must be http or socks5")
}
if config.ListenHost == "" {
config.ListenHost = "0.0.0.0"
}
if net.ParseIP(config.ListenHost) == nil && config.ListenHost != "localhost" {
return errors.New("listen host must be an IP address")
}
if config.ListenPort < 0 || config.ListenPort > 65535 {
return errors.New("listen port must be between 0 and 65535")
}
if config.AuthEnabled {
if config.Username == "" {
return errors.New("username is required when authentication is enabled")
}
if len(config.Username) > 128 || len(config.Password) > 128 {
return errors.New("proxy credentials are too long")
}
}
return nil
}
func (manager *Manager) checkPortLocked(config Config, excludeID string) error {
if config.ListenPort == 0 {
return nil
}
for _, current := range manager.configs {
if current.ID != excludeID && current.ListenPort == config.ListenPort && current.ListenHost == config.ListenHost {
return fmt.Errorf("port %d is already used by another export proxy", config.ListenPort)
}
}
if existing, _ := manager.configByIDLocked(excludeID); excludeID != "" &&
existing.ListenHost == config.ListenHost && existing.ListenPort == config.ListenPort {
return nil
}
listener, err := net.Listen("tcp", net.JoinHostPort(config.ListenHost, strconv.Itoa(config.ListenPort)))
if err != nil {
return fmt.Errorf("port %d is already in use", config.ListenPort)
}
_ = listener.Close()
return nil
}
func (manager *Manager) start(ctx context.Context, id string) error {
manager.mu.Lock()
if manager.disabled {
manager.mu.Unlock()
return ErrDisabled
}
config, index := manager.configByIDLocked(id)
if index < 0 || !config.Enabled {
manager.mu.Unlock()
return ErrNotFound
}
if err := platformSupported(); err != nil {
manager.lastError[id] = err.Error()
manager.mu.Unlock()
return err
}
manager.stopLocked(id)
listener, err := net.Listen("tcp", net.JoinHostPort(config.ListenHost, strconv.Itoa(config.ListenPort)))
if err != nil {
manager.lastError[id] = err.Error()
manager.mu.Unlock()
return err
}
if config.ListenPort == 0 {
config.ListenPort = listener.Addr().(*net.TCPAddr).Port
manager.configs[index] = config
if err := manager.saveLocked(ctx); err != nil {
_ = listener.Close()
manager.mu.Unlock()
return err
}
}
delete(manager.lastError, id)
manager.listeners[id] = listener
manager.started[id] = time.Now().UTC()
manager.mu.Unlock()
go manager.serve(listener, config)
return nil
}
func (manager *Manager) stop(id string) {
manager.mu.Lock()
defer manager.mu.Unlock()
manager.stopLocked(id)
}
func (manager *Manager) stopLocked(id string) {
if listener := manager.listeners[id]; listener != nil {
_ = listener.Close()
delete(manager.listeners, id)
}
delete(manager.started, id)
}
func (manager *Manager) serve(listener net.Listener, config Config) {
dialer := boundDialer(config.Interface)
resolver := boundResolver(config.Interface)
for {
connection, err := listener.Accept()
if err != nil {
return
}
go func(client net.Conn) {
defer client.Close()
var err error
if config.Mode == "http" {
err = serveHTTP(client, config, &dialer, resolver)
} else {
err = serveSOCKS(client, config, &dialer, resolver)
}
if err != nil {
manager.logger.Debug("export proxy connection closed", "id", config.ID, "error", err)
}
}(connection)
}
}
func (manager *Manager) configByIDLocked(id string) (Config, int) {
for index, config := range manager.configs {
if config.ID == id {
return config, index
}
}
return Config{}, -1
}
func redact(config Config) Config {
if config.Password != "" {
config.Password = PasswordMask
}
return config
}
func generateID() string {
value := make([]byte, 4)
_, _ = rand.Read(value)
return hex.EncodeToString(value)
}
+123
View File
@@ -0,0 +1,123 @@
package exportproxy
import (
"context"
"errors"
"io"
"log/slog"
"path/filepath"
"testing"
"vocat/internal/store"
)
func TestManagerPersistsAndDeletesDisabledConfig(t *testing.T) {
ctx := context.Background()
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "vocat.db"))
if err != nil {
t.Fatal(err)
}
defer database.Close()
if err := database.UpsertDevice(ctx, store.Device{ID: "modem-1", Name: "modem-1", Interface: "wwan0"}); err != nil {
t.Fatal(err)
}
manager, err := New(ctx, database, slog.New(slog.NewTextHandler(io.Discard, nil)), "")
if err != nil {
t.Fatal(err)
}
created, err := manager.Create(ctx, Config{DeviceID: "modem-1", Mode: "socks5", ListenHost: "127.0.0.1", ListenPort: 1080})
if err != nil {
t.Fatal(err)
}
if created.ID == "" || created.Interface != "wwan0" {
t.Fatalf("created = %+v", created)
}
configs, err := manager.Configs()
if err != nil || len(configs) != 1 {
t.Fatalf("configs = %+v, %v", configs, err)
}
if err := manager.DeleteAllAndDisable(ctx); err != nil {
t.Fatal(err)
}
if _, err := manager.Configs(); !errors.Is(err, ErrDisabled) {
t.Fatalf("Configs after disable = %v", err)
}
if _, err := database.AppSetting(ctx, SettingKey); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("setting remains: %v", err)
}
}
func TestManagerRequiresRoamingDataForEnabledProxy(t *testing.T) {
ctx := context.Background()
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "vocat.db"))
if err != nil {
t.Fatal(err)
}
defer database.Close()
if err := database.UpsertDevice(ctx, store.Device{ID: "modem-1", Name: "modem-1", Interface: "wwan0"}); err != nil {
t.Fatal(err)
}
manager, err := New(ctx, database, nil, "")
if err != nil {
t.Fatal(err)
}
defer manager.Close()
_, err = manager.Create(ctx, Config{DeviceID: "modem-1", Mode: "socks5", ListenHost: "127.0.0.1", ListenPort: 1080, Enabled: true})
if err == nil {
t.Fatal("enabled proxy was accepted while roaming data was disabled")
}
}
func TestManagerEnabledConfigForDevice(t *testing.T) {
ctx := context.Background()
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "vocat.db"))
if err != nil {
t.Fatal(err)
}
defer database.Close()
if err := database.UpsertDevice(ctx, store.Device{ID: "modem-1", Name: "modem-1", Interface: "wwan0", NetworkEnabled: true}); err != nil {
t.Fatal(err)
}
if err := database.UpsertDevice(ctx, store.Device{ID: "modem-2", Name: "modem-2", Interface: "wwan1", NetworkEnabled: true}); err != nil {
t.Fatal(err)
}
manager, err := New(ctx, database, nil, "")
if err != nil {
t.Fatal(err)
}
defer manager.Close()
if _, ok := manager.EnabledConfigForDevice("modem-1"); ok {
t.Fatal("reported an enabled config before any was created")
}
// A disabled config bound to modem-1 must not count.
if _, err := manager.Create(ctx, Config{DeviceID: "modem-1", Mode: "socks5", ListenHost: "127.0.0.1", ListenPort: 1080}); err != nil {
t.Fatal(err)
}
if _, ok := manager.EnabledConfigForDevice("modem-1"); ok {
t.Fatal("disabled config counted as enabled")
}
// An enabled config bound to modem-2 counts only for modem-2. The listener start
// is Linux-only, so the config is created disabled and flipped on in memory to
// exercise the query without binding a port.
created, err := manager.Create(ctx, Config{DeviceID: "modem-2", Mode: "socks5", ListenHost: "127.0.0.1", ListenPort: 0, AuthEnabled: true, Username: "u", Password: "secret"})
if err != nil {
t.Fatal(err)
}
manager.mu.Lock()
for index := range manager.configs {
if manager.configs[index].ID == created.ID {
manager.configs[index].Enabled = true
}
}
manager.mu.Unlock()
if _, ok := manager.EnabledConfigForDevice("modem-1"); ok {
t.Fatal("config bound to another device counted")
}
found, ok := manager.EnabledConfigForDevice("modem-2")
if !ok {
t.Fatal("enabled config not found for its device")
}
if found.Password != PasswordMask {
t.Fatalf("password not redacted: %+v", found)
}
}
+72
View File
@@ -0,0 +1,72 @@
package exportproxy
import (
"bufio"
"context"
"encoding/base64"
"errors"
"fmt"
"net"
"net/http"
"strings"
)
func serveHTTP(client net.Conn, config Config, dialer *net.Dialer, resolver *net.Resolver) error {
reader := bufio.NewReader(client)
request, err := http.ReadRequest(reader)
if err != nil {
return err
}
if config.AuthEnabled && !httpAuthorized(request, config) {
_, _ = client.Write([]byte("HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm=\"vocat-export-proxy\"\r\n\r\n"))
return errors.New("HTTP proxy authentication required")
}
if request.Method == http.MethodConnect {
ctx, cancel := context.WithTimeout(context.Background(), proxyTimeout)
target, err := dialTarget(ctx, request.URL.Host, dialer, resolver)
cancel()
if err != nil {
_, _ = fmt.Fprint(client, "HTTP/1.1 502 Bad Gateway\r\n\r\n")
return err
}
defer target.Close()
if _, err := client.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")); err != nil {
return err
}
if buffered := reader.Buffered(); buffered > 0 {
if value, err := reader.Peek(buffered); err == nil {
_, _ = target.Write(value)
_, _ = reader.Discard(buffered)
}
}
pipe(client, target)
return nil
}
request.Header.Del("Proxy-Authorization")
request.Header.Del("Proxy-Connection")
request.RequestURI = ""
transport := &http.Transport{
DialContext: func(ctx context.Context, _, address string) (net.Conn, error) {
return dialTarget(ctx, address, dialer, resolver)
},
DisableKeepAlives: true,
}
response, err := transport.RoundTrip(request)
if err != nil {
_, _ = fmt.Fprint(client, "HTTP/1.1 502 Bad Gateway\r\n\r\n")
return err
}
defer response.Body.Close()
return response.Write(client)
}
func httpAuthorized(request *http.Request, config Config) bool {
header := strings.TrimSpace(strings.TrimPrefix(request.Header.Get("Proxy-Authorization"), "Basic "))
decoded, err := base64.StdEncoding.DecodeString(header)
if err != nil {
return false
}
parts := strings.SplitN(string(decoded), ":", 2)
return len(parts) == 2 && parts[0] == config.Username && parts[1] == config.Password
}
+55
View File
@@ -0,0 +1,55 @@
package exportproxy
import (
"context"
"errors"
"fmt"
"io"
"net"
"time"
)
const proxyTimeout = 30 * time.Second
func dialTarget(ctx context.Context, address string, dialer *net.Dialer, resolver *net.Resolver) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, err
}
if ip := net.ParseIP(host); ip != nil {
return dialer.DialContext(ctx, "tcp", net.JoinHostPort(ip.String(), port))
}
ips, err := resolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, err
}
var lastErr error
for _, ip := range ips {
connection, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(ip.IP.String(), port))
if err == nil {
return connection, nil
}
lastErr = err
}
if lastErr == nil {
lastErr = fmt.Errorf("%w: no addresses for %s", errors.ErrUnsupported, host)
}
return nil, lastErr
}
func pipe(left, right net.Conn) {
done := make(chan struct{}, 2)
go func() { _, _ = copyConnection(right, left); done <- struct{}{} }()
go func() { _, _ = copyConnection(left, right); done <- struct{}{} }()
<-done
}
func copyConnection(destination net.Conn, source net.Conn) (int64, error) {
written, err := io.CopyBuffer(destination, source, make([]byte, 32*1024))
if err == nil && written > 0 {
if connection, ok := destination.(interface{ CloseWrite() error }); ok {
_ = connection.CloseWrite()
}
}
return written, err
}
+148
View File
@@ -0,0 +1,148 @@
package exportproxy
import (
"bufio"
"context"
"encoding/binary"
"errors"
"fmt"
"io"
"net"
"strconv"
)
func serveSOCKS(client net.Conn, config Config, dialer *net.Dialer, resolver *net.Resolver) error {
reader := bufio.NewReader(client)
version, err := reader.ReadByte()
if err != nil || version != 5 {
return errors.New("unsupported SOCKS version")
}
methodCount, err := reader.ReadByte()
if err != nil {
return err
}
methods := make([]byte, methodCount)
if _, err := io.ReadFull(reader, methods); err != nil {
return err
}
chosen := byte(0xff)
if config.AuthEnabled && hasMethod(methods, 2) {
chosen = 2
} else if !config.AuthEnabled && hasMethod(methods, 0) {
chosen = 0
}
if _, err := client.Write([]byte{5, chosen}); err != nil || chosen == 0xff {
return errors.New("no acceptable SOCKS authentication method")
}
if chosen == 2 {
if err := socksAuthenticate(reader, client, config); err != nil {
return err
}
}
header := make([]byte, 4)
if _, err := io.ReadFull(reader, header); err != nil {
return err
}
if header[0] != 5 || header[1] != 1 {
_ = writeSocksReply(client, 7)
return errors.New("only SOCKS5 CONNECT is supported")
}
host, port, err := readSocksAddress(reader, header[3])
if err != nil {
_ = writeSocksReply(client, 1)
return err
}
ctx, cancel := context.WithTimeout(context.Background(), proxyTimeout)
target, err := dialTarget(ctx, net.JoinHostPort(host, strconv.Itoa(port)), dialer, resolver)
cancel()
if err != nil {
_ = writeSocksReply(client, 5)
return err
}
defer target.Close()
if err := writeSocksReply(client, 0); err != nil {
return err
}
if buffered := reader.Buffered(); buffered > 0 {
if value, err := reader.Peek(buffered); err == nil {
_, _ = target.Write(value)
_, _ = reader.Discard(buffered)
}
}
pipe(client, target)
return nil
}
func socksAuthenticate(reader *bufio.Reader, writer io.Writer, config Config) error {
header := make([]byte, 2)
if _, err := io.ReadFull(reader, header); err != nil || header[0] != 1 {
return errors.New("invalid SOCKS authentication request")
}
username := make([]byte, int(header[1]))
if _, err := io.ReadFull(reader, username); err != nil {
return err
}
length, err := reader.ReadByte()
if err != nil {
return err
}
password := make([]byte, int(length))
if _, err := io.ReadFull(reader, password); err != nil {
return err
}
if string(username) != config.Username || string(password) != config.Password {
_, _ = writer.Write([]byte{1, 1})
return errors.New("SOCKS authentication failed")
}
_, err = writer.Write([]byte{1, 0})
return err
}
func hasMethod(methods []byte, wanted byte) bool {
for _, method := range methods {
if method == wanted {
return true
}
}
return false
}
func readSocksAddress(reader *bufio.Reader, kind byte) (string, int, error) {
var host string
switch kind {
case 1:
value := make([]byte, 4)
if _, err := io.ReadFull(reader, value); err != nil {
return "", 0, err
}
host = net.IP(value).String()
case 3:
length, err := reader.ReadByte()
if err != nil {
return "", 0, err
}
value := make([]byte, int(length))
if _, err := io.ReadFull(reader, value); err != nil {
return "", 0, err
}
host = string(value)
case 4:
value := make([]byte, 16)
if _, err := io.ReadFull(reader, value); err != nil {
return "", 0, err
}
host = net.IP(value).String()
default:
return "", 0, fmt.Errorf("unsupported SOCKS address type %d", kind)
}
value := make([]byte, 2)
if _, err := io.ReadFull(reader, value); err != nil {
return "", 0, err
}
return host, int(binary.BigEndian.Uint16(value)), nil
}
func writeSocksReply(connection net.Conn, code byte) error {
_, err := connection.Write([]byte{5, code, 0, 1, 0, 0, 0, 0, 0, 0})
return err
}
+9
View File
@@ -24,6 +24,8 @@ import (
"strings"
"sync"
"time"
"vocat/internal/exportproxy"
)
const maxPackageBytes int64 = 64 << 20
@@ -94,6 +96,10 @@ func (manager *Manager) scan() error {
manager.logger.Warn("skip invalid plugin", "directory", dir, "error", err)
continue
}
if plugin.ID == exportproxy.ReservedID {
manager.logger.Info("skip legacy Export Proxy plugin; functionality is built in", "directory", dir)
continue
}
manager.plugins[plugin.ID] = plugin
if plugin.Enabled {
manager.startLocked(plugin)
@@ -201,6 +207,9 @@ func (manager *Manager) Install(reader io.Reader, expectedSHA string) (Plugin, e
if err != nil {
return Plugin{}, err
}
if manifest.ID == exportproxy.ReservedID {
return Plugin{}, errors.New("plugin ID export-proxy is reserved by the built-in Export Proxy feature")
}
staging, err := os.MkdirTemp(manager.root, ".install-"+manifest.ID+"-")
if err != nil {
return Plugin{}, err
+106
View File
@@ -0,0 +1,106 @@
package httpsmode
import (
"bufio"
"errors"
"net"
"sync"
"time"
)
type bufferedConn struct {
net.Conn
reader *bufio.Reader
}
func (conn *bufferedConn) Read(buffer []byte) (int, error) { return conn.reader.Read(buffer) }
type channelListener struct {
address net.Addr
conns chan net.Conn
done chan struct{}
}
func (listener *channelListener) Accept() (net.Conn, error) {
select {
case conn := <-listener.conns:
if conn == nil {
return nil, net.ErrClosed
}
return conn, nil
case <-listener.done:
return nil, net.ErrClosed
}
}
func (listener *channelListener) Close() error { return nil }
func (listener *channelListener) Addr() net.Addr { return listener.address }
type Multiplexer struct {
base net.Listener
manager *Manager
plain *channelListener
tls *channelListener
done chan struct{}
closeOnce sync.Once
}
func NewMultiplexer(base net.Listener, manager *Manager) *Multiplexer {
done := make(chan struct{})
mux := &Multiplexer{
base: base, manager: manager, done: done,
plain: &channelListener{address: base.Addr(), conns: make(chan net.Conn, 64), done: done},
tls: &channelListener{address: base.Addr(), conns: make(chan net.Conn, 64), done: done},
}
go mux.accept()
return mux
}
func (mux *Multiplexer) Plain() net.Listener { return mux.plain }
func (mux *Multiplexer) TLS() net.Listener { return mux.tls }
func (mux *Multiplexer) Close() error {
var err error
mux.closeOnce.Do(func() {
close(mux.done)
err = mux.base.Close()
})
return err
}
func (mux *Multiplexer) accept() {
for {
conn, err := mux.base.Accept()
if err != nil {
if !errors.Is(err, net.ErrClosed) {
_ = mux.Close()
}
return
}
go mux.classify(conn)
}
}
func (mux *Multiplexer) classify(conn net.Conn) {
reader := bufio.NewReaderSize(conn, 4096)
_ = conn.SetReadDeadline(time.Now().Add(10 * time.Second))
first, err := reader.Peek(1)
_ = conn.SetReadDeadline(time.Time{})
if err != nil {
_ = conn.Close()
return
}
wrapped := &bufferedConn{Conn: conn, reader: reader}
listener := mux.plain
if first[0] == 0x16 {
if !mux.manager.Enabled() {
_ = conn.Close()
return
}
listener = mux.tls
}
select {
case listener.conns <- wrapped:
case <-mux.done:
_ = conn.Close()
}
}
+259
View File
@@ -0,0 +1,259 @@
package httpsmode
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"math/big"
"net"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"vocat/internal/store"
)
const SettingKey = "transport.self_signed_https"
type State struct {
Enabled bool `json:"enabled"`
HTTPURL string `json:"http_url"`
HTTPSURL string `json:"https_url"`
Fingerprint string `json:"fingerprint,omitempty"`
NotAfter time.Time `json:"not_after,omitempty"`
}
type Manager struct {
store *store.Store
dir string
address string
enabled atomic.Bool
mu sync.RWMutex
cert *tls.Certificate
}
func New(ctx context.Context, database *store.Store, dir, address string) (*Manager, error) {
manager := &Manager{store: database, dir: dir, address: address}
setting, err := database.AppSetting(ctx, SettingKey)
if err == nil {
var document struct {
Enabled bool `json:"enabled"`
}
if json.Unmarshal(setting.Value, &document) == nil && document.Enabled {
if err := manager.ensureCertificate(); err != nil {
return nil, err
}
manager.enabled.Store(true)
}
} else if !errors.Is(err, store.ErrNotFound) {
return nil, err
}
return manager, nil
}
func (manager *Manager) Enabled() bool { return manager != nil && manager.enabled.Load() }
func (manager *Manager) SetEnabled(ctx context.Context, enabled bool) (State, error) {
if enabled {
if err := manager.ensureCertificate(); err != nil {
return State{}, err
}
}
raw, err := json.Marshal(map[string]bool{"enabled": enabled})
if err != nil {
return State{}, err
}
if err := manager.store.UpsertAppSetting(ctx, store.AppSetting{Key: SettingKey, Value: raw}); err != nil {
return State{}, err
}
manager.enabled.Store(enabled)
return manager.State(""), nil
}
func (manager *Manager) State(host string) State {
host = strings.TrimSpace(host)
if host == "" {
host = manager.address
}
state := State{
Enabled: manager.Enabled(),
HTTPURL: "http://" + host,
HTTPSURL: "https://" + host,
}
manager.mu.RLock()
if manager.cert != nil && manager.cert.Leaf != nil {
digest := sha256.Sum256(manager.cert.Leaf.Raw)
encoded := strings.ToUpper(hex.EncodeToString(digest[:]))
parts := make([]string, 0, len(encoded)/2)
for len(encoded) >= 2 {
parts = append(parts, encoded[:2])
encoded = encoded[2:]
}
state.Fingerprint = strings.Join(parts, ":")
state.NotAfter = manager.cert.Leaf.NotAfter
}
manager.mu.RUnlock()
return state
}
func (manager *Manager) TLSConfig() *tls.Config {
return &tls.Config{
MinVersion: tls.VersionTLS12,
NextProtos: []string{"h2", "http/1.1"},
GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
manager.mu.RLock()
defer manager.mu.RUnlock()
if manager.cert == nil {
return nil, errors.New("self-signed certificate is unavailable")
}
return manager.cert, nil
},
}
}
func (manager *Manager) CertificatePEM() ([]byte, error) {
if err := manager.ensureCertificate(); err != nil {
return nil, err
}
return os.ReadFile(filepath.Join(manager.dir, "selfsigned.crt"))
}
func (manager *Manager) ensureCertificate() error {
manager.mu.Lock()
defer manager.mu.Unlock()
if manager.cert != nil && manager.cert.Leaf != nil && time.Until(manager.cert.Leaf.NotAfter) > 30*24*time.Hour {
return nil
}
if err := os.MkdirAll(manager.dir, 0o750); err != nil {
return fmt.Errorf("create TLS directory: %w", err)
}
certPath := filepath.Join(manager.dir, "selfsigned.crt")
keyPath := filepath.Join(manager.dir, "selfsigned.key")
if cert, err := loadCertificate(certPath, keyPath); err == nil && time.Until(cert.Leaf.NotAfter) > 30*24*time.Hour {
manager.cert = cert
return nil
}
certPEM, keyPEM, err := generateCertificate(manager.address)
if err != nil {
return err
}
if err := writePrivateFile(keyPath, keyPEM, 0o600); err != nil {
return err
}
if err := writePrivateFile(certPath, certPEM, 0o644); err != nil {
return err
}
cert, err := loadCertificate(certPath, keyPath)
if err != nil {
return err
}
manager.cert = cert
return nil
}
func loadCertificate(certPath, keyPath string) (*tls.Certificate, error) {
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
if err != nil {
return nil, err
}
if len(cert.Certificate) == 0 {
return nil, errors.New("certificate chain is empty")
}
cert.Leaf, err = x509.ParseCertificate(cert.Certificate[0])
if err != nil {
return nil, err
}
return &cert, nil
}
func generateCertificate(address string) ([]byte, []byte, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, nil, err
}
limit := new(big.Int).Lsh(big.NewInt(1), 128)
serial, err := rand.Int(rand.Reader, limit)
if err != nil {
return nil, nil, err
}
now := time.Now().UTC()
template := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: "VoCat self-signed local certificate", Organization: []string{"VoCat"}},
NotBefore: now.Add(-5 * time.Minute), NotAfter: now.AddDate(5, 0, 0),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
DNSNames: []string{"localhost"},
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback},
}
if hostname, hostnameErr := os.Hostname(); hostnameErr == nil && strings.TrimSpace(hostname) != "" {
template.DNSNames = append(template.DNSNames, strings.TrimSpace(hostname))
}
if host, _, splitErr := net.SplitHostPort(address); splitErr == nil {
if ip := net.ParseIP(host); ip != nil && !ip.IsUnspecified() {
template.IPAddresses = append(template.IPAddresses, ip)
} else if host != "" && host != "0.0.0.0" && host != "::" {
template.DNSNames = append(template.DNSNames, host)
}
}
if interfaces, interfaceErr := net.InterfaceAddrs(); interfaceErr == nil {
for _, item := range interfaces {
text := item.String()
if slash := strings.IndexByte(text, '/'); slash >= 0 {
text = text[:slash]
}
if ip := net.ParseIP(strings.TrimSpace(text)); ip != nil && !ip.IsUnspecified() {
template.IPAddresses = append(template.IPAddresses, ip)
}
}
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
return nil, nil, err
}
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
return nil, nil, err
}
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}), nil
}
func writePrivateFile(path string, data []byte, mode os.FileMode) error {
temp, err := os.CreateTemp(filepath.Dir(path), ".tls-*")
if err != nil {
return err
}
tempName := temp.Name()
defer os.Remove(tempName)
if err := temp.Chmod(mode); err != nil {
_ = temp.Close()
return err
}
if _, err := temp.Write(data); err != nil {
_ = temp.Close()
return err
}
if err := temp.Sync(); err != nil {
_ = temp.Close()
return err
}
if err := temp.Close(); err != nil {
return err
}
return os.Rename(tempName, path)
}
+99
View File
@@ -0,0 +1,99 @@
package httpsmode
import (
"context"
"crypto/tls"
"net"
"path/filepath"
"testing"
"vocat/internal/store"
)
func TestManagerPersistsToggleAndCertificate(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
database, err := store.Open(ctx, filepath.Join(dir, "vocat.db"))
if err != nil {
t.Fatal(err)
}
defer database.Close()
manager, err := New(ctx, database, filepath.Join(dir, "tls"), "0.0.0.0:7575")
if err != nil {
t.Fatal(err)
}
state, err := manager.SetEnabled(ctx, true)
if err != nil {
t.Fatal(err)
}
if !state.Enabled || state.Fingerprint == "" || state.NotAfter.IsZero() {
t.Fatalf("enabled state = %#v", state)
}
certificate, err := manager.CertificatePEM()
if err != nil || len(certificate) == 0 {
t.Fatalf("certificate = %d bytes, %v", len(certificate), err)
}
reloaded, err := New(ctx, database, filepath.Join(dir, "tls"), "0.0.0.0:7575")
if err != nil || !reloaded.Enabled() {
t.Fatalf("reloaded manager enabled=%v error=%v", reloaded.Enabled(), err)
}
if _, err := reloaded.SetEnabled(ctx, false); err != nil || reloaded.Enabled() {
t.Fatalf("disable enabled=%v error=%v", reloaded.Enabled(), err)
}
}
func TestMultiplexerRoutesPlainAndTLS(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
database, err := store.Open(ctx, filepath.Join(dir, "vocat.db"))
if err != nil {
t.Fatal(err)
}
defer database.Close()
manager, err := New(ctx, database, filepath.Join(dir, "tls"), "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
if _, err := manager.SetEnabled(ctx, true); err != nil {
t.Fatal(err)
}
base, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
mux := NewMultiplexer(base, manager)
defer mux.Close()
plainClient, err := net.Dial("tcp", base.Addr().String())
if err != nil {
t.Fatal(err)
}
defer plainClient.Close()
if _, err := plainClient.Write([]byte("GET / HTTP/1.1\r\nHost: local\r\n\r\n")); err != nil {
t.Fatal(err)
}
plainServer, err := mux.Plain().Accept()
if err != nil {
t.Fatal(err)
}
defer plainServer.Close()
tlsResult := make(chan error, 1)
go func() {
serverConn, acceptErr := mux.TLS().Accept()
if acceptErr != nil {
tlsResult <- acceptErr
return
}
defer serverConn.Close()
tlsResult <- tls.Server(serverConn, manager.TLSConfig()).Handshake()
}()
tlsClient, err := tls.Dial("tcp", base.Addr().String(), &tls.Config{InsecureSkipVerify: true}) // test-only local certificate
if err != nil {
t.Fatal(err)
}
_ = tlsClient.Close()
if err := <-tlsResult; err != nil {
t.Fatal(err)
}
}
+1
View File
@@ -74,6 +74,7 @@ var zhToEn = map[string]string{
// ---- devices ----
"设备数量已达上限,最多只能添加 %d 台设备": "Device limit reached; at most %d devices can be added.",
"SIM 卡归属地为%s(MCC %s),本服务不向该地区卡片提供数据/短信/VoWiFi": "The SIM's home region is %s (MCC %s); this service does not provide data, SMS, or VoWiFi to cards from that region.",
"请先禁用该设备已绑定的导出代理,再关闭漫游数据": "Disable the export proxy bound to this device before turning off roaming data.",
// ---- settings / update ----
"未配置受信任的软件更新源;不会从未知地址下载或执行文件。": "No trusted update source is configured; no files will be downloaded or executed from unknown addresses.",
+99
View File
@@ -0,0 +1,99 @@
package server
import (
"context"
"encoding/binary"
"errors"
"io"
"net/http"
"strings"
"github.com/coder/websocket"
"vocat/internal/store"
)
const maxCallMediaMessage = 16 << 10
// handleCallMedia upgrades an authenticated same-origin request to a binary
// PCM bridge. Each WebSocket message contains little-endian signed 16-bit,
// 8 kHz, mono samples. RTP and codec details remain inside the IMS provider.
func (s *Server) handleCallMedia(w http.ResponseWriter, r *http.Request, config store.Device) bool {
if !requireMethod(w, r, http.MethodGet) {
return true
}
if s.callTransport(config.ID) != "vowifi" {
writeError(w, http.StatusNotImplemented, "call_media_unavailable", "browser audio is only available for an active VoWiFi IMS call")
return true
}
callID := strings.TrimSpace(r.URL.Query().Get("call_id"))
if callID == "" || len(callID) > 256 {
writeError(w, http.StatusBadRequest, "invalid_call_id", "call_id is required")
return true
}
controller, ok := s.vowifi.(VoWiFiCallMediaController)
if !ok {
writeError(w, http.StatusNotImplemented, "call_media_unavailable", "the active IMS session does not expose RTP media")
return true
}
media, err := controller.CallMedia(r.Context(), config.ID, callID)
if err != nil {
writeError(w, http.StatusConflict, "call_media_unavailable", err.Error())
return true
}
connection, err := websocket.Accept(w, r, &websocket.AcceptOptions{
CompressionMode: websocket.CompressionDisabled,
})
if err != nil {
return true
}
connection.SetReadLimit(maxCallMediaMessage)
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
defer connection.Close(websocket.StatusNormalClosure, "call media closed")
downlink := make(chan error, 1)
go func() {
defer cancel()
for {
samples, readErr := media.ReadPCM(ctx)
if readErr != nil {
downlink <- readErr
return
}
payload := make([]byte, len(samples)*2)
for index, sample := range samples {
binary.LittleEndian.PutUint16(payload[index*2:], uint16(sample))
}
if writeErr := connection.Write(ctx, websocket.MessageBinary, payload); writeErr != nil {
downlink <- writeErr
return
}
}
}()
for {
select {
case err := <-downlink:
if !errors.Is(err, context.Canceled) && !errors.Is(err, io.EOF) {
s.logger.Debug("call media downlink closed", "device_id", config.ID, "call_id", callID, "error", err)
}
return true
default:
}
messageType, payload, readErr := connection.Read(ctx)
if readErr != nil {
return true
}
if messageType != websocket.MessageBinary || len(payload) == 0 || len(payload)%2 != 0 {
continue
}
samples := make([]int16, len(payload)/2)
for index := range samples {
samples[index] = int16(binary.LittleEndian.Uint16(payload[index*2:]))
}
if err := media.WritePCM(samples); err != nil {
return true
}
}
}
+43
View File
@@ -0,0 +1,43 @@
package server
import (
"net/http"
"vocat/internal/developer"
)
func (s *Server) handleDeveloperSettings(w http.ResponseWriter, r *http.Request) {
if !s.developerEnabled {
writeError(w, http.StatusNotFound, "not_found", "resource not found")
return
}
switch r.Method {
case http.MethodGet:
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
"device_limit": developer.DeviceLimit(r.Context(), s.store, true),
"default_device_limit": developer.DefaultDeviceLimit,
"max_device_limit": developer.MaxDeviceLimit,
}})
case http.MethodPut:
var request struct {
DeviceLimit int `json:"device_limit"`
}
if err := s.decodeJSON(w, r, &request); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
if err := developer.SetDeviceLimit(r.Context(), s.store, request.DeviceLimit); err != nil {
writeError(w, http.StatusBadRequest, "invalid_device_limit", err.Error())
return
}
s.recordAudit(r.Context(), "admin", "settings.developer.device_limit", "settings", "developer", "success", "device limit updated")
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
"device_limit": request.DeviceLimit,
"default_device_limit": developer.DefaultDeviceLimit,
"max_device_limit": developer.MaxDeviceLimit,
}})
default:
w.Header().Set("Allow", "GET, PUT")
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
}
}
@@ -0,0 +1,22 @@
package server
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestDeveloperOnlySettingsAreHiddenWhenModeIsOff(t *testing.T) {
server := &Server{developerEnabled: false}
for _, handler := range []func(http.ResponseWriter, *http.Request){
server.handleDeveloperSettings,
server.handleHTTPSSettings,
server.handleHTTPSCertificate,
} {
response := httptest.NewRecorder()
handler(response, httptest.NewRequest(http.MethodGet, "/api/settings/developer", nil))
if response.Code != http.StatusNotFound {
t.Fatalf("developer-only endpoint status = %d, want 404", response.Code)
}
}
}
+311 -78
View File
@@ -11,6 +11,7 @@ import (
"strings"
"time"
"vocat/internal/developer"
"vocat/internal/device"
"vocat/internal/i18n"
"vocat/internal/modem"
@@ -38,6 +39,7 @@ type DeviceController interface {
SetUSBNetModeByPort(context.Context, string, int) (device.USBNetMode, error)
OperatorSelection(context.Context, string) (device.OperatorSelection, error)
SetOperatorSelection(context.Context, string, bool, string, *int) (device.OperatorSelection, error)
ReRegisterOperator(context.Context, string) (device.OperatorSelection, error)
ScanOperators(context.Context, string) (device.OperatorScanResult, error)
SendSMS(context.Context, string, string, string) (device.SMSSendResult, error)
ListSMS(context.Context, string) ([]device.SMSMessage, error)
@@ -56,6 +58,7 @@ type DeviceController interface {
type deviceConfigPayload struct {
ID string `json:"id"`
Name string `json:"name"`
DeviceType string `json:"device_type"`
Interface string `json:"interface"`
ControlDevice string `json:"control_device"`
ATPort string `json:"at_port"`
@@ -86,6 +89,7 @@ func (payload deviceConfigPayload) toStoreDevice() store.Device {
return store.Device{
ID: strings.TrimSpace(payload.ID),
Name: name,
DeviceType: store.NormalizeDeviceType(payload.DeviceType),
Interface: strings.TrimSpace(payload.Interface),
ControlDevice: strings.TrimSpace(payload.ControlDevice),
ATPort: strings.TrimSpace(payload.ATPort),
@@ -170,15 +174,13 @@ func splitAPIPath(value string) []string {
return result
}
// maxDeviceLimit 是设备数量的软上限:达到上限后禁止再添加新设备。
const maxDeviceLimit = 5
func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) bool {
deviceLimit := developer.DeviceLimit(r.Context(), s.store, s.developerEnabled)
switch r.Method {
case http.MethodGet:
writeJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"device_limit": maxDeviceLimit,
"device_limit": deviceLimit,
"devices": s.deviceSummaries(),
},
})
@@ -203,6 +205,10 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) bool {
writeError(w, http.StatusBadRequest, "invalid_device_id", "device ID must use 1-64 letters, digits, dots, underscores, or hyphens")
return true
}
if strings.TrimSpace(payload.DeviceType) == "" || store.NormalizeDeviceType(payload.DeviceType) == "" {
writeError(w, http.StatusBadRequest, "invalid_device_type", "select a supported device type")
return true
}
if _, err := s.store.Device(r.Context(), payload.ID); err == nil {
writeError(w, http.StatusConflict, "device_exists", "a device with this ID already exists")
return true
@@ -215,8 +221,8 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) bool {
s.writeStoreError(w, err)
return true
}
if len(configured) >= maxDeviceLimit {
writeError(w, http.StatusConflict, "device_limit_reached", i18n.Tf("设备数量已达上限,最多只能添加 %d 台设备", maxDeviceLimit))
if len(configured) >= deviceLimit {
writeError(w, http.StatusConflict, "device_limit_reached", i18n.Tf("设备数量已达上限,最多只能添加 %d 台设备", deviceLimit))
return true
}
devices, err := s.devices.Discover(r.Context())
@@ -230,6 +236,9 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) bool {
return true
}
config := payload.toStoreDevice()
if !s.developerActive(r.Context()) {
config.NetworkEnabled = false
}
fillConfigFromPhysical(&config, *selected)
if err := s.store.UpsertDevice(r.Context(), config); err != nil {
s.writeStoreError(w, err)
@@ -402,6 +411,9 @@ func (s *Server) handleDevicePath(
return true
}
next := payload.toStoreDevice()
if !s.developerActive(r.Context()) {
next.NetworkEnabled = false
}
next.ID = id
next.CreatedAt = config.CreatedAt
if next.Name == id && strings.TrimSpace(payload.Name) == "" {
@@ -485,12 +497,27 @@ func (s *Server) handleDevicePath(
s.writeDeviceError(w, err)
return true
}
s.clearPublicIP(config.ID)
writeJSON(w, http.StatusAccepted, map[string]any{"data": map[string]any{"status": "rebooting"}})
case "flight-mode":
if !s.requirePhysicalDevice(w, physicalPresent) {
return true
}
return s.handleFlightMode(w, r, physicalID)
case "network":
if !s.requirePhysicalDevice(w, physicalPresent) {
return true
}
return s.handleCellularData(w, r, config, physicalID)
case "network/public-ip":
if !s.requirePhysicalDevice(w, physicalPresent) {
return true
}
iccid := ""
if entry.Snapshot != nil {
iccid = entry.Snapshot.ICCID
}
return s.handleCellularPublicIP(w, r, config, iccid)
case "usbnet-mode":
if !s.requirePhysicalDevice(w, physicalPresent) {
return true
@@ -501,6 +528,11 @@ func (s *Server) handleDevicePath(
return true
}
return s.handleOperatorSelection(w, r, physicalID)
case "operator_selection/reregister":
if !s.requirePhysicalDevice(w, physicalPresent) {
return true
}
return s.handleOperatorReRegister(w, r, physicalID)
case "operator_selection/scan":
if !s.requirePhysicalDevice(w, physicalPresent) {
return true
@@ -527,6 +559,11 @@ func (s *Server) handleDevicePath(
return true
}
return s.handleCallAction(w, r, config, physicalID, tail[1])
case "calls/media":
if !s.requirePhysicalDevice(w, physicalPresent) {
return true
}
return s.handleCallMedia(w, r, config)
default:
return false
}
@@ -667,6 +704,21 @@ func (s *Server) handleOperatorSelection(w http.ResponseWriter, r *http.Request,
return true
}
func (s *Server) handleOperatorReRegister(w http.ResponseWriter, r *http.Request, physicalID string) bool {
if !requireMethod(w, r, http.MethodPost) {
return true
}
controller := http.NewResponseController(w)
_ = controller.SetWriteDeadline(time.Time{})
result, err := s.devices.ReRegisterOperator(r.Context(), physicalID)
if err != nil {
s.writeDeviceError(w, err)
return true
}
writeJSON(w, http.StatusOK, map[string]any{"data": operatorSelectionWire(result)})
return true
}
func (s *Server) handleVoWiFiEnabled(
w http.ResponseWriter,
r *http.Request,
@@ -691,6 +743,10 @@ func (s *Server) handleVoWiFiEnabled(
writeError(w, http.StatusServiceUnavailable, "physical_device_missing", "the configured modem is not present on this Linux host")
return true
}
if request.Enabled && config.NetworkEnabled {
writeError(w, http.StatusConflict, "cellular_data_active", "disable roaming data before enabling VoWiFi")
return true
}
if request.Enabled {
entry, _, _ := s.physicalForConfig(config)
imsi := snapshotString(entry.Snapshot, func(snapshot *device.Snapshot) string { return snapshot.IMSI })
@@ -943,10 +999,111 @@ func (s *Server) handleFlightMode(w http.ResponseWriter, r *http.Request, id str
s.writeDeviceError(w, err)
return true
}
// Unlike VoWiFi, CFUN airplane state is not represented in the device row.
// Persist it against the live ICCID so a restart can distinguish an
// intentional airplane policy from an interrupted VoWiFi teardown.
if entry, getErr := s.devices.Get(id); getErr == nil && entry.Snapshot != nil {
iccid := strings.TrimSpace(entry.Snapshot.ICCID)
if iccid != "" {
policy, policyErr := s.store.CardPolicy(r.Context(), iccid)
if errors.Is(policyErr, store.ErrNotFound) {
policy = store.CardPolicy{ICCID: iccid, IPVersion: "IPV4V6"}
policyErr = nil
}
if policyErr != nil {
s.writeStoreError(w, policyErr)
return true
}
policy.AirplaneEnabled = request.Enabled
if request.Enabled {
policy.VoWiFiEnabled = false
}
policy.Source = "manual"
if err := s.store.UpsertCardPolicy(r.Context(), policy); err != nil {
s.writeStoreError(w, err)
return true
}
}
}
writeJSON(w, http.StatusOK, map[string]any{"data": result})
return true
}
func (s *Server) handleCellularData(
w http.ResponseWriter,
r *http.Request,
config store.Device,
physicalID string,
) bool {
if !s.developerActive(r.Context()) {
writeError(w, http.StatusForbidden, "developer_mode_required", "roaming data is available only in developer mode")
return true
}
switch r.Method {
case http.MethodGet:
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
"enabled": config.NetworkEnabled,
"interface": config.Interface,
"apn": config.APN,
"export_proxy_only": true,
}})
case http.MethodPatch, http.MethodPut:
var request struct {
Enabled bool `json:"enabled"`
APN string `json:"apn"`
}
if err := s.decodeJSON(w, r, &request); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return true
}
if request.Enabled && config.VoWiFiEnabled {
writeError(w, http.StatusConflict, "vowifi_owns_radio", "disable VoWiFi before enabling cellular roaming data")
return true
}
if !request.Enabled && s.exportProxy != nil {
if _, active := s.exportProxy.EnabledConfigForDevice(config.ID); active {
writeError(w, http.StatusConflict, "export_proxy_active", i18n.T("请先禁用该设备已绑定的导出代理,再关闭漫游数据"))
return true
}
}
apn := strings.TrimSpace(request.APN)
if apn == "" {
apn = strings.TrimSpace(config.APN)
}
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",
})
if err != nil {
s.writeDeviceError(w, err)
return true
}
previous := config.NetworkEnabled
config.NetworkEnabled = request.Enabled
if 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",
})
cancel()
s.writeStoreError(w, err)
return true
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
"enabled": result.Enabled, "interface": result.Interface,
"backend": result.Backend, "export_proxy_only": true,
}})
default:
w.Header().Set("Allow", "GET, PATCH, PUT")
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
}
return true
}
func (s *Server) requirePhysicalDevice(w http.ResponseWriter, present bool) bool {
if s.devices == nil {
writeError(w, http.StatusServiceUnavailable, "device_manager_unavailable", "device manager is unavailable")
@@ -1031,6 +1188,7 @@ func (s *Server) dashboardDevices() []map[string]any {
result = append(result, map[string]any{
"id": entry["id"],
"name": entry["name"],
"device_type": entry["device_type"],
"interface": entry["interface"],
"proxy_port": entry["proxy_port"],
"public_ip": entry["public_ip"],
@@ -1041,7 +1199,7 @@ func (s *Server) dashboardDevices() []map[string]any {
"network_duplex": modemStatus["network_duplex"],
"vowifi_active": vowifiActive,
"vowifi_runtime": runtime,
"network_connected": false,
"network_connected": entry["network_connected"],
"model": modemStatus["model"],
})
}
@@ -1098,24 +1256,50 @@ func (s *Server) configuredDeviceSummary(
}
result["id"] = config.ID
result["name"] = config.Name
result["device_type"] = store.NormalizeDeviceType(config.DeviceType)
result["interface"] = config.Interface
result["proxy_port"] = config.ProxyPort
result["esim_transport"] = config.ESIMTransport
result["sms_enabled"] = config.SMSEnabled
result["network_enabled"] = false
result["network_enabled"] = config.NetworkEnabled
result["developer_enabled"] = s.developerActive(context.Background())
result["network_connected"] = config.NetworkEnabled
result["data_connected"] = config.NetworkEnabled
result["vowifi_enabled"] = config.VoWiFiEnabled
if runtime, err := s.store.VoWiFiRuntime(context.Background(), config.ID); err == nil {
runtimeResponse := storedVoWiFiRuntime(runtime)
currentICCID := ""
var currentSnapshot *device.Snapshot
if entry != nil {
currentSnapshot = entry.Snapshot
if entry.Snapshot != nil {
currentICCID = strings.TrimSpace(entry.Snapshot.ICCID)
}
}
runtimeMatchesCard := currentICCID == "" || runtime.ICCID == "" ||
strings.EqualFold(currentICCID, strings.TrimSpace(runtime.ICCID))
var runtimeResponse map[string]any
if runtimeMatchesCard {
runtimeResponse = storedVoWiFiRuntime(runtime)
} else {
// The saved IMS session belongs to a different eSIM profile. Never
// project its registration or number onto the currently selected SIM.
runtimeResponse = idleVoWiFiRuntime(config.ID, currentSnapshot)
}
result["vowifi_runtime"] = runtimeResponse
result["vowifi_active"] = runtime.TunnelReady
if runtime.LocalPhone != "" {
// The SIM panel reads the top-level local_phone; keep modem.phone_number
// in sync for the summary/overview consumers that read it there.
result["local_phone"] = runtime.LocalPhone
result["phone_number_source"] = runtime.PhoneNumberSource
if modemStatus, ok := result["modem"].(map[string]any); ok {
modemStatus["phone_number"] = runtime.LocalPhone
modemStatus["phone_number_source"] = runtime.PhoneNumberSource
result["vowifi_active"] = config.VoWiFiEnabled && runtimeMatchesCard && runtime.TunnelReady
}
// Numbers are SIM-owned data. Resolve the association by the live ICCID
// instead of reusing the last VoWiFi runtime attached to this device ID.
if entry != nil && entry.Snapshot != nil {
currentICCID := strings.TrimSpace(entry.Snapshot.ICCID)
if currentICCID != "" {
if association, err := s.store.PhoneAssociation(context.Background(), currentICCID); err == nil {
result["local_phone"] = association.Number
result["phone_number_source"] = association.Source
if modemStatus, ok := result["modem"].(map[string]any); ok {
modemStatus["phone_number"] = association.Number
modemStatus["phone_number_source"] = association.Source
}
}
}
}
@@ -1127,6 +1311,7 @@ func (s *Server) configuredDeviceOverview(
entry device.Device,
present bool,
) map[string]any {
developerActive := s.developerActive(context.Background())
var physical *device.Device
if present {
physical = &entry
@@ -1141,12 +1326,34 @@ func (s *Server) configuredDeviceOverview(
result["control_device"] = config.ControlDevice
result["esim_transport"] = config.ESIMTransport
result["sms_enabled"] = config.SMSEnabled
result["network_enabled"] = false
result["network_enabled"] = developerActive && config.NetworkEnabled
result["vowifi_enabled"] = config.VoWiFiEnabled
result["radio_live_ok"] = present && entry.Snapshot != nil && entry.Snapshot.Responsive
result["traffic"] = map[string]string{}
result["traffic_raw"] = map[string]int64{}
result["traffic_meta"] = map[string]any{}
// Live network state: on-demand sample of the cellular interface counters,
// kept warm by the 2s overview SSE cadence. Only meaningful when the modem
// data path is enabled and an interface is configured.
if developerActive && config.NetworkEnabled && strings.TrimSpace(config.Interface) != "" {
live := s.netTraffic.sample(config.ID, config.Interface, time.Now())
result["private_ip"] = live.ipv4
result["traffic"] = map[string]string{
"rx": formatLiveBytes(float64(live.minuteRx)),
"tx": formatLiveBytes(float64(live.minuteTx)),
"rate": formatLiveBytes(live.rxRate) + "/s",
"rate_tx": formatLiveBytes(live.txRate) + "/s",
}
result["traffic_raw"] = map[string]int64{
"rx": live.minuteRx,
"tx": live.minuteTx,
"rate": int64(live.rxRate),
"rate_tx": int64(live.txRate),
}
result["traffic_meta"] = map[string]any{"status": live.status}
} else {
result["traffic"] = map[string]string{}
result["traffic_raw"] = map[string]int64{}
result["traffic_meta"] = map[string]any{}
}
return result
}
@@ -1167,7 +1374,7 @@ func (s *Server) configuredDeviceStatus(
result := map[string]any{
"healthy": summary["healthy"],
"public_ip": summary["public_ip"],
"network_connected": false,
"network_connected": config.NetworkEnabled,
"modem": summary["modem"],
"vowifi": summary["vowifi_runtime"],
"sim_service_table": map[string]any{},
@@ -1250,7 +1457,7 @@ func deviceSummary(entry device.Device) map[string]any {
"physical_present": entry.Discovered,
"worker_running": entry.Discovered,
"data_connected": false,
"radio_registered": snapshot != nil && snapshot.OperatorName != "",
"radio_registered": snapshot != nil && (snapshot.RegistrationStatus == 1 || snapshot.RegistrationStatus == 5),
"lifecycle_phase": lifecyclePhase(entry),
"lifecycle_reason": entry.LastError,
"public_ip": "",
@@ -1307,6 +1514,7 @@ func storedDeviceConfig(config store.Device) map[string]any {
return map[string]any{
"id": config.ID,
"name": config.Name,
"device_type": store.NormalizeDeviceType(config.DeviceType),
"interface": config.Interface,
"control_device": config.ControlDevice,
"at_port": config.ATPort,
@@ -1324,7 +1532,7 @@ func storedDeviceConfig(config store.Device) map[string]any {
"qmi_use_proxy": config.QMIUseProxy,
"qmi_proxy_path": config.QMIProxyPath,
"qmi_proxy_executable": config.QMIProxyExecutable,
"network_enabled": false,
"network_enabled": config.NetworkEnabled,
"sms_enabled": config.SMSEnabled,
"vowifi_enabled": config.VoWiFiEnabled,
}
@@ -1358,61 +1566,64 @@ func fillConfigFromPhysical(config *store.Device, entry device.Device) {
func modemSummary(snapshot *device.Snapshot, phone string, phoneSource string) map[string]any {
if snapshot == nil {
return map[string]any{
"operator": "",
"native_mcc": "",
"native_mnc": "",
"card_mcc": "",
"card_mnc": "",
"card_country": "",
"service_blocked": false,
"blocked_reason": "",
"network_mode": "",
"radio_band": "",
"radio_channel": 0,
"signal_dbm": 0,
"signal_sinr": 0,
"imei": "",
"iccid": "",
"reg_status": 0,
"reg_status_text": "not refreshed",
"sim_inserted": false,
"phone_number": phone,
"phone_number_source": phoneSource,
"model": "",
"operator": "",
"native_mcc": "",
"native_mnc": "",
"operator_country_code": "",
"card_mcc": "",
"card_mnc": "",
"card_country": "",
"service_blocked": false,
"blocked_reason": "",
"network_mode": "",
"radio_band": "",
"radio_channel": 0,
"signal_dbm": 0,
"signal_sinr": 0,
"imei": "",
"iccid": "",
"reg_status": 0,
"reg_status_text": "not refreshed",
"sim_inserted": false,
"phone_number": phone,
"phone_number_source": phoneSource,
"model": "",
}
}
mcc, mnc := splitPLMN(snapshot.OperatorCode)
_, operatorCountryCode, _ := device.CarrierForPLMN(snapshot.OperatorCode)
cardMCC, cardMNC := device.CardMCCMNC(snapshot.IMSI)
blockedReason := device.RegionBlockReason(snapshot.IMSI)
return map[string]any{
"operator": snapshot.OperatorName,
"native_mcc": mcc,
"native_mnc": mnc,
"card_mcc": cardMCC,
"card_mnc": cardMNC,
"card_country": countryNameForMCC(cardMCC),
"service_blocked": blockedReason != "",
"blocked_reason": blockedReason,
"network_mode": snapshot.AccessTech,
"network_duplex": "",
"radio_band": snapshot.Band,
"radio_channel": parseDecimal(snapshot.Channel),
"signal_dbm": pointerInt(snapshot.RSSIDBm),
"signal_rsrp": pointerInt(snapshot.RSRP),
"signal_rsrq": pointerInt(snapshot.RSRQ),
"signal_sinr": pointerInt(snapshot.SINR),
"imei": snapshot.IMEI,
"iccid": snapshot.ICCID,
"imsi": snapshot.IMSI,
"firmware": snapshot.Firmware,
"model": snapshot.Model,
"reg_status": boolInt(snapshot.OperatorName != ""),
"reg_status_text": registrationText(snapshot),
"ps_attached": false,
"sim_inserted": snapshot.SIMStatus != "",
"operating_mode": snapshot.OperatingMode,
"phone_number": phone,
"phone_number_source": phoneSource,
"operator": snapshot.OperatorName,
"native_mcc": mcc,
"native_mnc": mnc,
"operator_country_code": operatorCountryCode,
"card_mcc": cardMCC,
"card_mnc": cardMNC,
"card_country": countryNameForMCC(cardMCC),
"service_blocked": blockedReason != "",
"blocked_reason": blockedReason,
"network_mode": snapshot.AccessTech,
"network_duplex": "",
"radio_band": snapshot.Band,
"radio_channel": parseDecimal(snapshot.Channel),
"signal_dbm": pointerInt(snapshot.RSSIDBm),
"signal_rsrp": pointerInt(snapshot.RSRP),
"signal_rsrq": pointerInt(snapshot.RSRQ),
"signal_sinr": pointerInt(snapshot.SINR),
"imei": snapshot.IMEI,
"iccid": snapshot.ICCID,
"imsi": snapshot.IMSI,
"firmware": snapshot.Firmware,
"model": snapshot.Model,
"reg_status": snapshot.RegistrationStatus,
"reg_status_text": registrationText(snapshot),
"ps_attached": snapshot.PSAttached,
"sim_inserted": snapshot.SIMStatus != "",
"operating_mode": snapshot.OperatingMode,
"phone_number": phone,
"phone_number_source": phoneSource,
}
}
@@ -1485,17 +1696,39 @@ func lifecyclePhase(entry device.Device) string {
}
func registrationLabel(snapshot *device.Snapshot) string {
if snapshot == nil || snapshot.OperatorName == "" {
if snapshot == nil {
return "unknown"
}
switch snapshot.RegistrationStatus {
case 1, 5:
return "registered"
case 2:
return "searching"
case 3:
return "denied"
default:
return "unknown"
}
return "registered"
}
func registrationText(snapshot *device.Snapshot) string {
if snapshot.OperatorName != "" {
if snapshot == nil {
return "unknown"
}
switch snapshot.RegistrationStatus {
case 1:
return "registered"
case 5:
return "registered (roaming)"
case 2:
return "searching"
case 3:
return "registration denied"
case 0:
return "not registered"
default:
return "unknown"
}
return "unknown"
}
func splitPLMN(value string) (string, string) {
+21 -1
View File
@@ -2,6 +2,7 @@ package server
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
@@ -10,6 +11,10 @@ import (
"vocat/internal/store"
)
// overviewStreamInterval is the cadence at which the overview SSE stream pushes
// a fresh snapshot. It is a package var so tests can shorten it.
var overviewStreamInterval = 2 * time.Second
// beginSSE prepares a response for Server-Sent Events and returns its response
// controller for explicit flushes.
func beginSSE(w http.ResponseWriter) *http.ResponseController {
@@ -50,13 +55,27 @@ func (s *Server) handleOverviewStream(
if err := writeSSEEvent(w, controller, "connected", map[string]any{}); err != nil {
return true
}
ticker := time.NewTicker(2 * time.Second)
ticker := time.NewTicker(overviewStreamInterval)
defer ticker.Stop()
for {
select {
case <-r.Context().Done():
return true
case <-ticker.C:
// The config passed in was read when the stream opened. Re-read it on
// every tick so edits made while watching (roaming data, APN, VoWiFi,
// name…) take effect; otherwise the stream keeps replaying the stale
// snapshot and the UI flaps between SSE-old and REST-new values.
fresh, err := s.store.Device(r.Context(), config.ID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
// The device was deleted while streaming; end the stream.
return true
}
// Transient store hiccup: keep the last known config for this tick.
} else {
config = fresh
}
currentEntry, _, present := s.physicalForConfig(config)
overview := s.configuredDeviceOverview(config, currentEntry, present)
if err := writeSSEEvent(w, controller, "overview", overview); err != nil {
@@ -81,6 +100,7 @@ func operatorCandidateWire(op device.ScannedOperator) map[string]any {
"operatorName": op.Name,
"shortName": op.Short,
"plmn": op.Numeric,
"countryCode": op.Country,
"rats": rats,
"includesPcsDigit": false,
}
+230
View File
@@ -1,6 +1,7 @@
package server
import (
"bufio"
"context"
"encoding/json"
"errors"
@@ -11,7 +12,9 @@ import (
"testing"
"time"
"vocat/internal/developer"
"vocat/internal/device"
"vocat/internal/exportproxy"
"vocat/internal/modem"
"vocat/internal/store"
"vocat/internal/update"
@@ -467,3 +470,230 @@ func TestE911WebsheetRejectsBadToken(t *testing.T) {
t.Fatalf("bad token status = %d, want 403", recorder.Code)
}
}
// readSSEEvent reads one Server-Sent-Events frame ("event:"/"data:" lines
// terminated by a blank line) and returns the event name and data payload.
func readSSEEvent(reader *bufio.Reader) (string, []byte, error) {
var event string
var data []byte
for {
line, err := reader.ReadString('\n')
if err != nil {
return "", nil, err
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
if event != "" || data != nil {
return event, data, nil
}
continue
}
if rest, ok := strings.CutPrefix(line, "event: "); ok {
event = rest
} else if rest, ok := strings.CutPrefix(line, "data: "); ok {
data = append(data, rest...)
}
}
}
// awaitOverviewNetworkEnabled reads overview SSE events until one reports the
// requested network_enabled value, or the stream ends / the request times out.
func awaitOverviewNetworkEnabled(reader *bufio.Reader, want bool) error {
for {
event, data, err := readSSEEvent(reader)
if err != nil {
return err
}
if event != "overview" {
continue
}
var overview struct {
NetworkEnabled bool `json:"network_enabled"`
}
if err := json.Unmarshal(data, &overview); err != nil {
return err
}
if overview.NetworkEnabled == want {
return nil
}
}
}
// The overview SSE stream must reflect edits made after it opened. Before the
// fix it rebuilt every tick from the config snapshot captured when the stream
// opened, so toggling roaming data off was immediately overwritten by the stale
// "on" snapshot and the switch flapped. This test opens the stream with roaming
// data on, turns it off in the store, and requires the stream to keep reporting
// the new "off" state.
func TestHandleOverviewStreamReflectsConfigChanges(t *testing.T) {
previousInterval := overviewStreamInterval
overviewStreamInterval = 10 * time.Millisecond
t.Cleanup(func() { overviewStreamInterval = previousInterval })
ctx := context.Background()
database, err := store.Open(ctx, ":memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
if err := database.UpsertAppSetting(ctx, store.AppSetting{
Key: developer.EnabledSettingKey,
Value: []byte(`{"enabled":true}`),
}); err != nil {
t.Fatal(err)
}
if err := database.UpsertDevice(ctx, store.Device{ID: "dev1", Name: "Test device", NetworkEnabled: true}); err != nil {
t.Fatal(err)
}
server := &Server{store: database, logger: regionTestLogger(), developerEnabled: true}
mux := http.NewServeMux()
mux.HandleFunc("/stream", func(w http.ResponseWriter, r *http.Request) {
config, err := database.Device(r.Context(), "dev1")
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
server.handleOverviewStream(w, r, config, device.Device{}, false)
})
testServer := httptest.NewServer(mux)
t.Cleanup(testServer.Close)
requestCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
t.Cleanup(cancel)
request, err := http.NewRequestWithContext(requestCtx, http.MethodGet, testServer.URL+"/stream", nil)
if err != nil {
t.Fatal(err)
}
response, err := http.DefaultClient.Do(request)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = response.Body.Close() })
if response.StatusCode != http.StatusOK {
t.Fatalf("stream status = %d", response.StatusCode)
}
reader := bufio.NewReader(response.Body)
// The stream opens with roaming data enabled.
if err := awaitOverviewNetworkEnabled(reader, true); err != nil {
t.Fatalf("initial overview never reported network_enabled=true: %v", err)
}
// Turn roaming data off; the very next ticks must report the new state
// instead of replaying the stale enabled snapshot.
config, err := database.Device(ctx, "dev1")
if err != nil {
t.Fatal(err)
}
config.NetworkEnabled = false
if err := database.UpsertDevice(ctx, config); err != nil {
t.Fatal(err)
}
if err := awaitOverviewNetworkEnabled(reader, false); err != nil {
t.Fatalf("overview kept replaying stale network_enabled=true after the edit: %v", err)
}
}
// Turning roaming data off must be refused while an enabled export proxy is
// bound to the device; the user has to disable that binding first.
func TestHandleCellularDataRejectsDisableWhileExportProxyActive(t *testing.T) {
ctx := context.Background()
database, err := store.Open(ctx, ":memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
if err := database.UpsertAppSetting(ctx, store.AppSetting{
Key: developer.EnabledSettingKey, Value: json.RawMessage(`{"enabled":true}`),
}); err != nil {
t.Fatal(err)
}
deviceConfig := store.Device{ID: "modem-1", Name: "modem-1", Interface: "wwan0", NetworkEnabled: true}
if err := database.UpsertDevice(ctx, deviceConfig); err != nil {
t.Fatal(err)
}
// Seed an already-enabled export proxy bound to the device. New only logs a
// warning when the Linux-only listener cannot start on this platform, so the
// enabled config still loads and the interlock sees it.
seeded, err := json.Marshal([]exportproxy.Config{{
ID: "proxy-1", Name: "proxy-1", DeviceID: "modem-1", Interface: "wwan0",
Mode: "socks5", ListenHost: "127.0.0.1", ListenPort: 1080, Enabled: true,
}})
if err != nil {
t.Fatal(err)
}
if err := database.UpsertAppSetting(ctx, store.AppSetting{Key: exportproxy.SettingKey, Value: seeded, Sensitive: true}); err != nil {
t.Fatal(err)
}
proxyManager, err := exportproxy.New(ctx, database, regionTestLogger(), "")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = proxyManager.Close() })
server := &Server{
store: database,
logger: regionTestLogger(),
developerEnabled: true,
exportProxy: proxyManager,
devices: fakeDeviceController{},
maxRequestBodyBytes: 1 << 20,
}
patchOff := func() *httptest.ResponseRecorder {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPatch, "/api/devices/modem-1/cellular-data", strings.NewReader(`{"enabled":false}`))
request.Header.Set("Content-Type", "application/json")
if !server.handleCellularData(recorder, request, deviceConfig, "physical-1") {
t.Fatal("handleCellularData did not handle the request")
}
return recorder
}
// While the export proxy is enabled, turning roaming data off is rejected and
// the stored config keeps roaming data on.
recorder := patchOff()
if recorder.Code != http.StatusConflict {
t.Fatalf("disable with active proxy status = %d, body = %s", recorder.Code, recorder.Body)
}
var failure struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &failure); err != nil {
t.Fatal(err)
}
if failure.Error.Code != "export_proxy_active" {
t.Fatalf("error code = %q, body = %s", failure.Error.Code, recorder.Body)
}
stored, err := database.Device(ctx, "modem-1")
if err != nil {
t.Fatal(err)
}
if !stored.NetworkEnabled {
t.Fatal("roaming data was turned off despite the active export proxy")
}
// Once the binding is disabled, the same request goes through.
proxies, err := proxyManager.Configs()
if err != nil || len(proxies) != 1 {
t.Fatalf("configs = %+v, %v", proxies, err)
}
disabled := proxies[0]
disabled.Enabled = false
if _, err := proxyManager.Update(ctx, disabled.ID, disabled); err != nil {
t.Fatal(err)
}
recorder = patchOff()
if recorder.Code != http.StatusOK {
t.Fatalf("disable after proxy off status = %d, body = %s", recorder.Code, recorder.Body)
}
stored, err = database.Device(ctx, "modem-1")
if err != nil {
t.Fatal(err)
}
if stored.NetworkEnabled {
t.Fatal("roaming data was not turned off after the export proxy was disabled")
}
}
+51
View File
@@ -0,0 +1,51 @@
package server
import (
"context"
"testing"
"time"
"vocat/internal/device"
"vocat/internal/store"
)
func TestConfiguredDeviceSummaryIgnoresVoWiFiRuntimeFromPreviousSIM(t *testing.T) {
database, err := store.Open(context.Background(), ":memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
if err := database.UpsertDevice(context.Background(), store.Device{ID: "ec20_1", Name: "EC20"}); err != nil {
t.Fatal(err)
}
if err := database.UpsertVoWiFiRuntime(context.Background(), store.VoWiFiRuntime{
DeviceID: "ec20_1",
Phase: "stopping",
ICCID: "89441000400128014257",
IMSI: "234159608751160",
TunnelReady: true,
IMSReady: true,
SMSReady: true,
LocalPhone: "+447386083638",
PhoneNumberSource: "ims_p_associated_uri",
UpdatedAt: time.Now().UTC(),
}); err != nil {
t.Fatal(err)
}
s := &Server{store: database}
entry := &device.Device{ID: "physical", Snapshot: &device.Snapshot{
ICCID: "89104100000028106378",
IMSI: "310380500712483",
}}
got := s.configuredDeviceSummary(store.Device{ID: "ec20_1"}, entry)
if got["vowifi_active"] != false {
t.Fatalf("vowifi_active = %#v", got["vowifi_active"])
}
if got["local_phone"] == "+447386083638" {
t.Fatalf("old phone leaked into current SIM summary: %#v", got)
}
runtime, ok := got["vowifi_runtime"].(map[string]any)
if !ok || runtime["phase"] != "idle" || runtime["iccid"] != "89104100000028106378" {
t.Fatalf("runtime = %#v", got["vowifi_runtime"])
}
}
+102
View File
@@ -0,0 +1,102 @@
package server
import (
"errors"
"net/http"
"strings"
"vocat/internal/exportproxy"
)
func (s *Server) routeExportProxyAPI(w http.ResponseWriter, r *http.Request, cleanPath string) bool {
if cleanPath != "export-proxies" && !strings.HasPrefix(cleanPath, "export-proxies/") {
return false
}
if !s.developerActive(r.Context()) || s.exportProxy == nil {
writeError(w, http.StatusForbidden, "developer_mode_required", "Export Proxy is available only in developer mode")
return true
}
segments := splitAPIPath(cleanPath)
if len(segments) == 1 {
switch r.Method {
case http.MethodGet:
configs, err := s.exportProxy.Configs()
if err != nil {
s.writeExportProxyError(w, err)
return true
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"configs": configs}})
case http.MethodPost:
var config exportproxy.Config
if err := s.decodeJSON(w, r, &config); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return true
}
created, err := s.exportProxy.Create(r.Context(), config)
if err != nil {
s.writeExportProxyError(w, err)
return true
}
writeJSON(w, http.StatusCreated, map[string]any{"data": created})
default:
w.Header().Set("Allow", "GET, POST")
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
}
return true
}
if len(segments) == 2 && segments[1] == "status" {
if !requireMethod(w, r, http.MethodGet) {
return true
}
statuses, err := s.exportProxy.Status()
if err != nil {
s.writeExportProxyError(w, err)
return true
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"configs": statuses}})
return true
}
if len(segments) != 2 || strings.TrimSpace(segments[1]) == "" {
writeError(w, http.StatusNotFound, "not_found", "Export Proxy endpoint not found")
return true
}
id := segments[1]
switch r.Method {
case http.MethodPut:
var config exportproxy.Config
if err := s.decodeJSON(w, r, &config); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return true
}
updated, err := s.exportProxy.Update(r.Context(), id, config)
if err != nil {
s.writeExportProxyError(w, err)
return true
}
writeJSON(w, http.StatusOK, map[string]any{"data": updated})
case http.MethodDelete:
if err := s.exportProxy.Delete(r.Context(), id); err != nil {
s.writeExportProxyError(w, err)
return true
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]bool{"deleted": true}})
default:
w.Header().Set("Allow", "PUT, DELETE")
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
}
return true
}
func (s *Server) writeExportProxyError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, exportproxy.ErrDisabled):
writeError(w, http.StatusForbidden, "developer_mode_required", "Export Proxy is disabled")
case errors.Is(err, exportproxy.ErrNotFound):
writeError(w, http.StatusNotFound, "export_proxy_not_found", err.Error())
default:
writeError(w, http.StatusBadRequest, "export_proxy_invalid", err.Error())
}
}
+15 -1
View File
@@ -15,6 +15,7 @@ import (
"vocat/internal/auth"
"vocat/internal/buildinfo"
"vocat/internal/developer"
"vocat/internal/i18n"
"vocat/internal/loghub"
"vocat/internal/store"
@@ -26,6 +27,9 @@ func (s *Server) routeGeneralAPI(w http.ResponseWriter, r *http.Request) bool {
if s.routeExtensionAPI(w, r, cleanPath) {
return true
}
if s.routeExportProxyAPI(w, r, cleanPath) {
return true
}
if s.routeSMSAPI(w, r, cleanPath) {
return true
}
@@ -50,6 +54,12 @@ func (s *Server) routeGeneralAPI(w http.ResponseWriter, r *http.Request) bool {
s.handlePasswordChange(w, r)
case "settings/preferences":
s.handleUIPreferences(w, r)
case "settings/https":
s.handleHTTPSSettings(w, r)
case "settings/https/certificate":
s.handleHTTPSCertificate(w, r)
case "settings/developer":
s.handleDeveloperSettings(w, r)
default:
return false
}
@@ -314,11 +324,15 @@ func (s *Server) handleSystemInfo(w http.ResponseWriter, r *http.Request) {
"os": runtime.GOOS,
"architecture": runtime.GOARCH,
"uptime": formatDuration(time.Since(s.startedAt)),
"developer": s.developerEnabled,
"developer": s.developerActive(r.Context()),
},
})
}
func (s *Server) developerActive(ctx context.Context) bool {
return s.developerEnabled && developer.Enabled(ctx, s.store)
}
func (s *Server) handleUpdateCheck(w http.ResponseWriter, r *http.Request) {
if !requireMethod(w, r, http.MethodGet) {
return
+64
View File
@@ -0,0 +1,64 @@
package server
import (
"net/http"
"strconv"
)
func (s *Server) handleHTTPSSettings(w http.ResponseWriter, r *http.Request) {
if !s.developerEnabled {
writeError(w, http.StatusNotFound, "not_found", "resource not found")
return
}
if s.https == nil {
writeError(w, http.StatusServiceUnavailable, "https_unavailable", "self-signed HTTPS is unavailable")
return
}
switch r.Method {
case http.MethodGet:
writeJSON(w, http.StatusOK, map[string]any{"data": s.https.State(r.Host)})
case http.MethodPut:
var request struct {
Enabled bool `json:"enabled"`
}
if err := s.decodeJSON(w, r, &request); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
state, err := s.https.SetEnabled(r.Context(), request.Enabled)
if err != nil {
writeError(w, http.StatusInternalServerError, "https_update_failed", err.Error())
return
}
state = s.https.State(r.Host)
s.recordAudit(r.Context(), "admin", "settings.https.update", "settings", "https", "success", map[bool]string{true: "enabled", false: "disabled"}[request.Enabled])
writeJSON(w, http.StatusOK, map[string]any{"data": state})
default:
w.Header().Set("Allow", "GET, PUT")
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
}
}
func (s *Server) handleHTTPSCertificate(w http.ResponseWriter, r *http.Request) {
if !s.developerEnabled {
writeError(w, http.StatusNotFound, "not_found", "resource not found")
return
}
if !requireMethod(w, r, http.MethodGet) {
return
}
if s.https == nil {
writeError(w, http.StatusServiceUnavailable, "https_unavailable", "self-signed HTTPS is unavailable")
return
}
certificate, err := s.https.CertificatePEM()
if err != nil {
writeError(w, http.StatusInternalServerError, "certificate_unavailable", err.Error())
return
}
w.Header().Set("Content-Type", "application/x-pem-file")
w.Header().Set("Content-Disposition", `attachment; filename="vocat-selfsigned.crt"`)
w.Header().Set("Content-Length", strconv.Itoa(len(certificate)))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(certificate)
}
+177
View File
@@ -0,0 +1,177 @@
package server
import (
"fmt"
"math"
"net"
"sync"
"time"
)
// liveNetWindow is how far back the "last minute" byte totals reach.
const liveNetWindow = time.Minute
// liveNetMaxGap bounds how far apart two samples may be before a rate computed
// across them stops being "live". The overview SSE ticks every two seconds, so
// a gap beyond this means the tab was closed or the device was idle; treat it
// as a fresh baseline instead of averaging a long dead interval.
const liveNetMaxGap = 15 * time.Second
// netIfSample is one cumulative counter reading for an interface.
type netIfSample struct {
at time.Time
rxCum uint64
txCum uint64
}
// liveNetDevice holds the per-device sampling state used to derive rates and
// trailing-window totals from cumulative interface counters.
type liveNetDevice struct {
prev netIfSample
hasPrev bool
window []netIfSample
}
// liveNetResult is one rendered snapshot of a device's live network state.
type liveNetResult struct {
ipv4 string
rxRate float64 // bytes/sec over the trailing sample interval
txRate float64
minuteRx int64 // bytes over the trailing liveNetWindow
minuteTx int64
status string // "", "waiting_sample", or "stale"
}
// liveNetTracker derives live rates and last-minute totals from cumulative
// /sys interface counters. It is driven on demand by the overview builders, so
// no separate goroutine is required; the SSE overview cadence keeps it warm.
type liveNetTracker struct {
mu sync.Mutex
devices map[string]*liveNetDevice
}
func newLiveNetTracker() *liveNetTracker {
return &liveNetTracker{devices: map[string]*liveNetDevice{}}
}
// sample reads the interface's current counters and addresses and returns the
// device's live network state. Interface addresses resolve even on the first
// call; rates and totals need a second reading, reported as waiting_sample.
func (t *liveNetTracker) sample(deviceID, iface string, now time.Time) liveNetResult {
ipv4 := netIfAddrs(iface)
rxCum, txCum, err := netIfCounters(iface)
if err != nil {
// The interface briefly disappears while QMI reconnects. Drop the
// baseline so the next good read starts fresh rather than counting the
// reconnect as one giant delta.
t.mu.Lock()
delete(t.devices, deviceID)
t.mu.Unlock()
return liveNetResult{ipv4: ipv4, status: "stale"}
}
rxRate, txRate, minuteRx, minuteTx, status := t.record(deviceID, rxCum, txCum, now)
return liveNetResult{
ipv4: ipv4,
rxRate: rxRate, txRate: txRate,
minuteRx: minuteRx, minuteTx: minuteTx,
status: status,
}
}
// record folds one cumulative counter reading into the device's sampling state
// and returns the derived rates and trailing-window totals. It is pure (no
// interface I/O) so the rate/window logic is unit-testable.
func (t *liveNetTracker) record(deviceID string, rxCum, txCum uint64, now time.Time) (rxRate, txRate float64, minuteRx, minuteTx int64, status string) {
t.mu.Lock()
defer t.mu.Unlock()
d := t.devices[deviceID]
if d == nil {
d = &liveNetDevice{}
t.devices[deviceID] = d
}
current := netIfSample{at: now, rxCum: rxCum, txCum: txCum}
// First sighting, a counter reset (interface reconnected), or a gap too
// long to average honestly: establish a baseline and wait for the next
// reading before reporting a rate.
if !d.hasPrev || rxCum < d.prev.rxCum || txCum < d.prev.txCum || now.Sub(d.prev.at) > liveNetMaxGap {
d.prev = current
d.hasPrev = true
d.window = []netIfSample{current}
return 0, 0, 0, 0, "waiting_sample"
}
if elapsed := now.Sub(d.prev.at).Seconds(); elapsed > 0 {
rxRate = float64(rxCum-d.prev.rxCum) / elapsed
txRate = float64(txCum-d.prev.txCum) / elapsed
}
d.prev = current
d.window = append(d.window, current)
// Drop samples outside the trailing window, then measure totals against
// the oldest surviving reading.
cutoff := now.Add(-liveNetWindow)
kept := d.window[:0]
for _, s := range d.window {
if !s.at.Before(cutoff) {
kept = append(kept, s)
}
}
d.window = kept
minuteRx = int64(rxCum - d.window[0].rxCum)
minuteTx = int64(txCum - d.window[0].txCum)
return rxRate, txRate, minuteRx, minuteTx, ""
}
// netIfAddrs returns the interface's first global IPv4 address. It uses only
// the net package, so it compiles on every platform; on hosts without the
// interface it returns an empty string.
func netIfAddrs(iface string) (ipv4 string) {
if iface == "" {
return ""
}
netIf, err := net.InterfaceByName(iface)
if err != nil {
return ""
}
addrs, err := netIf.Addrs()
if err != nil {
return ""
}
for _, addr := range addrs {
var ip net.IP
switch a := addr.(type) {
case *net.IPNet:
ip = a.IP
case *net.IPAddr:
ip = a.IP
}
if ip == nil || ip.IsLoopback() {
continue
}
if v4 := ip.To4(); v4 != nil {
return v4.String()
}
}
return ""
}
// formatLiveBytes mirrors the SPA's formatBytes so the live strings match the
// chart's formatting: 1024-based units, rounded once the value reaches 100.
func formatLiveBytes(value float64) string {
if math.IsNaN(value) || math.IsInf(value, 0) || value < 0 {
value = 0
}
units := []string{"B", "KB", "MB", "GB", "TB"}
size := value
unit := 0
for size >= 1024 && unit < len(units)-1 {
size /= 1024
unit++
}
if size >= 100 {
return fmt.Sprintf("%.0f %s", size, units[unit])
}
return fmt.Sprintf("%.1f %s", size, units[unit])
}
+143
View File
@@ -0,0 +1,143 @@
package server
import (
"testing"
"time"
)
// record is the pure rate/window core of the tracker; these tests drive it
// directly with synthetic cumulative counters, no interface I/O involved.
func TestLiveNetRecordFirstSampleWaits(t *testing.T) {
tracker := newLiveNetTracker()
now := time.Unix(1_700_000_000, 0)
_, _, _, _, status := tracker.record("dev1", 1000, 500, now)
if status != "waiting_sample" {
t.Fatalf("first sample status = %q, want waiting_sample", status)
}
}
func TestLiveNetRecordComputesRateAndMinute(t *testing.T) {
tracker := newLiveNetTracker()
base := time.Unix(1_700_000_000, 0)
tracker.record("dev1", 1000, 500, base)
rxRate, txRate, minuteRx, minuteTx, status := tracker.record("dev1", 2000, 700, base.Add(2*time.Second))
if status != "" {
t.Fatalf("second sample status = %q, want empty", status)
}
// 1000 rx bytes and 200 tx bytes over 2s.
if rxRate != 500 {
t.Errorf("rxRate = %v, want 500", rxRate)
}
if txRate != 100 {
t.Errorf("txRate = %v, want 100", txRate)
}
if minuteRx != 1000 {
t.Errorf("minuteRx = %v, want 1000", minuteRx)
}
if minuteTx != 200 {
t.Errorf("minuteTx = %v, want 200", minuteTx)
}
}
func TestLiveNetRecordUsesActualElapsed(t *testing.T) {
tracker := newLiveNetTracker()
base := time.Unix(1_700_000_000, 0)
tracker.record("dev1", 0, 0, base)
// A 4s gap (not the usual 2s tick) must divide by 4, not 2.
rxRate, _, _, _, status := tracker.record("dev1", 400, 0, base.Add(4*time.Second))
if status != "" {
t.Fatalf("status = %q, want empty", status)
}
if rxRate != 100 {
t.Errorf("rxRate = %v, want 100", rxRate)
}
}
func TestLiveNetRecordCounterResetRebaselines(t *testing.T) {
tracker := newLiveNetTracker()
base := time.Unix(1_700_000_000, 0)
tracker.record("dev1", 5000, 5000, base)
tracker.record("dev1", 6000, 6000, base.Add(2*time.Second))
// Counter drops (interface reconnected): must re-baseline, not go negative.
_, _, _, _, status := tracker.record("dev1", 100, 100, base.Add(4*time.Second))
if status != "waiting_sample" {
t.Fatalf("after reset status = %q, want waiting_sample", status)
}
}
func TestLiveNetRecordLongGapRebaselines(t *testing.T) {
tracker := newLiveNetTracker()
base := time.Unix(1_700_000_000, 0)
tracker.record("dev1", 1000, 1000, base)
// Gap beyond liveNetMaxGap (tab closed / idle): treat as fresh baseline.
_, _, _, _, status := tracker.record("dev1", 2000, 2000, base.Add(liveNetMaxGap+time.Second))
if status != "waiting_sample" {
t.Fatalf("after long gap status = %q, want waiting_sample", status)
}
}
func TestLiveNetRecordSlidesWindow(t *testing.T) {
tracker := newLiveNetTracker()
base := time.Unix(1_700_000_000, 0)
// One sample every 2s, rx climbing 100 bytes each tick (50 B/s).
tracker.record("dev1", 0, 0, base)
var minuteRx int64
var status string
for i := 1; i <= 31; i++ {
now := base.Add(time.Duration(2*i) * time.Second) // t=2s .. t=62s
_, _, minuteRx, _, status = tracker.record("dev1", uint64(100*i), 0, now)
}
if status != "" {
t.Fatalf("status = %q, want empty", status)
}
// At t=62s the cutoff is t=2s, so the t=0 baseline has slid out. The window
// now spans t=2s..t=62s = 60s and 30 ticks of 100 bytes.
if minuteRx != 3000 {
t.Errorf("minuteRx = %v, want 3000 (only trailing window)", minuteRx)
}
}
func TestLiveNetRecordTracksDevicesIndependently(t *testing.T) {
tracker := newLiveNetTracker()
base := time.Unix(1_700_000_000, 0)
tracker.record("a", 1000, 0, base)
tracker.record("b", 9000, 0, base)
rxRateA, _, _, _, _ := tracker.record("a", 2000, 0, base.Add(2*time.Second))
rxRateB, _, _, _, _ := tracker.record("b", 9100, 0, base.Add(2*time.Second))
if rxRateA != 500 {
t.Errorf("device a rxRate = %v, want 500", rxRateA)
}
if rxRateB != 50 {
t.Errorf("device b rxRate = %v, want 50", rxRateB)
}
}
func TestFormatLiveBytes(t *testing.T) {
cases := []struct {
in float64
want string
}{
{0, "0.0 B"},
{512, "512 B"},
{1023, "1023 B"},
{1024, "1.0 KB"},
{1536, "1.5 KB"},
{100 * 1024, "100 KB"},
{5 * 1024 * 1024, "5.0 MB"},
{3 * 1024 * 1024 * 1024, "3.0 GB"},
{-5, "0.0 B"},
}
for _, c := range cases {
if got := formatLiveBytes(c.in); got != c.want {
t.Errorf("formatLiveBytes(%v) = %q, want %q", c.in, got, c.want)
}
}
}
+40
View File
@@ -0,0 +1,40 @@
//go:build linux
package server
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
// netIfCounters reads the interface's cumulative rx/tx byte counters from
// /sys/class/net. The interface briefly disappears while QMI reconnects, in
// which case an error is returned and the caller re-baselines.
func netIfCounters(iface string) (uint64, uint64, error) {
if strings.TrimSpace(iface) == "" {
return 0, 0, fmt.Errorf("interface name is empty")
}
read := func(counter string) (uint64, error) {
raw, err := os.ReadFile(filepath.Join("/sys/class/net", iface, "statistics", counter))
if err != nil {
return 0, err
}
parsed, err := strconv.ParseUint(strings.TrimSpace(string(raw)), 10, 64)
if err != nil {
return 0, fmt.Errorf("parse %s %s counter: %w", iface, counter, err)
}
return parsed, nil
}
rxBytes, err := read("rx_bytes")
if err != nil {
return 0, 0, err
}
txBytes, err := read("tx_bytes")
if err != nil {
return 0, 0, err
}
return rxBytes, txBytes, nil
}
+11
View File
@@ -0,0 +1,11 @@
//go:build !linux
package server
import "fmt"
// netIfCounters is only meaningful on the Linux deployment target; elsewhere
// there is no cellular /sys interface to read.
func netIfCounters(string) (uint64, uint64, error) {
return 0, 0, fmt.Errorf("interface counters are only available on Linux")
}
+96
View File
@@ -0,0 +1,96 @@
package server
import (
"context"
"net/http"
"strings"
"time"
"vocat/internal/exportproxy"
"vocat/internal/store"
)
type cachedPublicIP struct {
ICCID string
Info exportproxy.PublicIPInfo
}
type publicIPResponse struct {
Detected bool `json:"detected"`
exportproxy.PublicIPInfo
}
func (s *Server) clearPublicIP(deviceID string) {
s.publicIPMu.Lock()
delete(s.publicIPs, strings.TrimSpace(deviceID))
s.publicIPMu.Unlock()
}
func (s *Server) loadPublicIP(deviceID, iccid string) (exportproxy.PublicIPInfo, bool) {
deviceID = strings.TrimSpace(deviceID)
iccid = strings.TrimSpace(iccid)
s.publicIPMu.RLock()
entry, ok := s.publicIPs[deviceID]
s.publicIPMu.RUnlock()
if !ok {
return exportproxy.PublicIPInfo{}, false
}
// A missing live ICCID means the modem is resetting or no card is present.
// A different ICCID means the SIM/eSIM profile changed. Either transition
// invalidates the old cellular exit immediately.
if iccid == "" || !strings.EqualFold(strings.TrimSpace(entry.ICCID), iccid) {
s.clearPublicIP(deviceID)
return exportproxy.PublicIPInfo{}, false
}
return entry.Info, true
}
func (s *Server) savePublicIP(deviceID, iccid string, info exportproxy.PublicIPInfo) {
s.publicIPMu.Lock()
s.publicIPs[strings.TrimSpace(deviceID)] = cachedPublicIP{
ICCID: strings.TrimSpace(iccid),
Info: info,
}
s.publicIPMu.Unlock()
}
func (s *Server) handleCellularPublicIP(w http.ResponseWriter, r *http.Request, config store.Device, iccid string) bool {
if r.Method != http.MethodGet && r.Method != http.MethodPost {
w.Header().Set("Allow", "GET, POST")
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return true
}
if !s.developerActive(r.Context()) {
writeError(w, http.StatusForbidden, "developer_mode_required", "public IP detection through roaming data is available only in developer mode")
return true
}
w.Header().Set("Cache-Control", "no-store")
if r.Method == http.MethodGet {
info, ok := s.loadPublicIP(config.ID, iccid)
writeJSON(w, http.StatusOK, map[string]any{"data": publicIPResponse{Detected: ok, PublicIPInfo: info}})
return true
}
if !config.NetworkEnabled {
writeError(w, http.StatusConflict, "cellular_data_disabled", "enable roaming data before detecting its public IP")
return true
}
if strings.TrimSpace(iccid) == "" {
writeError(w, http.StatusConflict, "sim_identity_unavailable", "the modem has no current ICCID; refresh it before detecting the public IP")
return true
}
if strings.TrimSpace(config.Interface) == "" {
writeError(w, http.StatusConflict, "cellular_interface_missing", "the device has no cellular network interface")
return true
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
info, err := exportproxy.LookupPublicIP(ctx, config.Interface)
if err != nil {
s.logger.Warn("detect roaming public IP failed", "device_id", config.ID, "interface", config.Interface, "error", err)
writeError(w, http.StatusBadGateway, "public_ip_lookup_failed", err.Error())
return true
}
s.savePublicIP(config.ID, iccid, info)
writeJSON(w, http.StatusOK, map[string]any{"data": publicIPResponse{Detected: true, PublicIPInfo: info}})
return true
}
+33
View File
@@ -0,0 +1,33 @@
package server
import (
"testing"
"vocat/internal/exportproxy"
)
func TestPublicIPCacheFollowsCurrentICCID(t *testing.T) {
server := &Server{publicIPs: make(map[string]cachedPublicIP)}
want := exportproxy.PublicIPInfo{IP: "203.0.113.8", CountryCode: "GB"}
server.savePublicIP("ec20", "8944100001", want)
got, ok := server.loadPublicIP("ec20", "8944100001")
if !ok || got != want {
t.Fatalf("loadPublicIP() = (%+v, %v), want (%+v, true)", got, ok, want)
}
if _, ok := server.loadPublicIP("ec20", "8944100002"); ok {
t.Fatal("cache survived an ICCID change")
}
if _, ok := server.loadPublicIP("ec20", "8944100001"); ok {
t.Fatal("stale cache was not deleted after an ICCID change")
}
}
func TestPublicIPCacheClearsWhileModemIsResetting(t *testing.T) {
server := &Server{publicIPs: make(map[string]cachedPublicIP)}
server.savePublicIP("ec20", "8944100001", exportproxy.PublicIPInfo{IP: "203.0.113.8", CountryCode: "GB"})
if _, ok := server.loadPublicIP("ec20", ""); ok {
t.Fatal("cache survived a missing live ICCID")
}
}
+4
View File
@@ -76,6 +76,10 @@ func (f fakeDeviceController) OperatorSelection(context.Context, string) (device
func (f fakeDeviceController) SetOperatorSelection(context.Context, string, bool, string, *int) (device.OperatorSelection, error) {
return device.OperatorSelection{}, nil
}
func (f fakeDeviceController) ReRegisterOperator(context.Context, string) (device.OperatorSelection, error) {
return device.OperatorSelection{}, nil
}
func (f fakeDeviceController) ScanOperators(context.Context, string) (device.OperatorScanResult, error) {
return f.scanResult, f.scanErr
}
+19 -2
View File
@@ -18,7 +18,9 @@ import (
"time"
"vocat/internal/auth"
"vocat/internal/exportproxy"
"vocat/internal/extensions"
"vocat/internal/httpsmode"
"vocat/internal/loghub"
"vocat/internal/store"
"vocat/internal/update"
@@ -42,9 +44,11 @@ type Options struct {
SecureCookies bool
MaxRequestBodyBytes int64
Extensions *extensions.Manager
ExportProxy *exportproxy.Manager
DeveloperEnabled bool
UpdateRepository string
UpdateToken string
HTTPS *httpsmode.Manager
}
// Server is the single HTTP handler for the JSON API and embedded SPA.
@@ -67,6 +71,7 @@ type Server struct {
access parsedAccessConfig
loginLimiter *loginRateLimiter
extensions *extensions.Manager
exportProxy *exportproxy.Manager
developerEnabled bool
updateRepository string
updateToken string
@@ -75,6 +80,10 @@ type Server struct {
updateRestart func(*slog.Logger) error
updateMu sync.Mutex
updateApplying bool
https *httpsmode.Manager
netTraffic *liveNetTracker
publicIPMu sync.RWMutex
publicIPs map[string]cachedPublicIP
}
func New(options Options) (*Server, error) {
@@ -117,9 +126,13 @@ func New(options Options) (*Server, error) {
websheets: newWebsheetManager(),
loginLimiter: newLoginRateLimiter(),
extensions: options.Extensions,
exportProxy: options.ExportProxy,
developerEnabled: options.DeveloperEnabled,
updateRepository: strings.TrimSpace(options.UpdateRepository),
updateToken: strings.TrimSpace(options.UpdateToken),
https: options.HTTPS,
netTraffic: newLiveNetTracker(),
publicIPs: make(map[string]cachedPublicIP),
updateCheck: update.CheckLatest,
updateApply: update.ApplyLatest,
updateRestart: update.RestartService,
@@ -160,6 +173,10 @@ type VoWiFiCallController interface {
HangupCall(context.Context, string, string) error
}
type VoWiFiCallMediaController interface {
CallMedia(context.Context, string, string) (vowifi.CallMedia, error)
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.handler.ServeHTTP(w, r)
}
@@ -571,7 +588,7 @@ func (s *Server) securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "same-origin")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(self), geolocation=()")
if strings.HasPrefix(r.URL.Path, "/websheets/") || strings.HasPrefix(r.URL.Path, "/plugin-assets/") {
// The self-hosted E911 websheet is embedded in an iframe by the SPA, so
// it must be frameable same-origin. Every other route stays DENY.
@@ -593,7 +610,7 @@ func (s *Server) securityHeaders(next http.Handler) http.Handler {
"img-src 'self' data:; connect-src 'self'",
)
}
if s.secureCookies {
if s.secureCookies && s.https == nil {
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
next.ServeHTTP(w, r)
+75 -11
View File
@@ -21,6 +21,7 @@ import (
"sort"
"strconv"
"strings"
"sync"
"time"
"vocat/internal/store"
@@ -932,18 +933,77 @@ func dialRestricted(
if err != nil {
return nil, err
}
dialer := net.Dialer{Timeout: clampNotificationTimeout(timeout)}
var failures []error
for _, ip := range addresses {
connection, err := dialer.DialContext(
ctx,
network,
net.JoinHostPort(ip.String(), port),
)
if err == nil {
return connection, nil
perAddress := clampNotificationTimeout(timeout)
stagger := 300 * time.Millisecond
if perAddress < stagger {
stagger = perAddress / 2
}
raceContext, cancel := context.WithCancel(ctx)
defer cancel()
type attempt struct {
conn net.Conn
err error
}
resultCh := make(chan attempt, len(addresses))
var wg sync.WaitGroup
launcher := time.NewTicker(stagger)
defer launcher.Stop()
for index, ip := range addresses {
if index > 0 {
select {
case <-raceContext.Done():
break
case <-launcher.C:
}
}
failures = append(failures, err)
if raceContext.Err() != nil {
break
}
ip := ip
wg.Add(1)
go func() {
defer wg.Done()
dialer := net.Dialer{Timeout: perAddress}
connection, dialErr := dialer.DialContext(
raceContext,
network,
net.JoinHostPort(ip.String(), port),
)
if dialErr != nil {
resultCh <- attempt{err: dialErr}
return
}
if raceContext.Err() != nil {
connection.Close()
resultCh <- attempt{err: raceContext.Err()}
return
}
resultCh <- attempt{conn: connection}
}()
}
go func() {
wg.Wait()
close(resultCh)
}()
var failures []error
for result := range resultCh {
if result.conn != nil {
cancel()
return result.conn, nil
}
if result.err != nil && !errors.Is(result.err, context.Canceled) {
failures = append(failures, result.err)
}
if ctx.Err() != nil {
return nil, ctx.Err()
}
}
if len(failures) == 0 {
return nil, ctx.Err()
}
return nil, fmt.Errorf("dial public notification destination: %w", errors.Join(failures...))
}
@@ -1259,6 +1319,10 @@ func (s *Server) handleTrafficAnalysis(w http.ResponseWriter, r *http.Request) {
if !requireMethod(w, r, http.MethodGet) {
return
}
if !s.developerActive(r.Context()) {
writeError(w, http.StatusForbidden, "developer_mode_required", "traffic analysis is available only in developer mode")
return
}
rangeName := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("range")))
if rangeName == "" {
rangeName = "day"
+15
View File
@@ -14,6 +14,7 @@ import (
"testing"
"time"
"vocat/internal/developer"
"vocat/internal/store"
)
@@ -465,6 +466,12 @@ func TestCardPolicyDefaultValidationAndPersistence(t *testing.T) {
func TestTrafficAnalysisUsesAndAggregatesStoredBuckets(t *testing.T) {
test := newSettingsAPITest(t)
test.server.developerEnabled = true
if err := test.database.UpsertAppSetting(context.Background(), store.AppSetting{
Key: developer.EnabledSettingKey, Value: json.RawMessage(`{"enabled":true}`),
}); err != nil {
t.Fatal(err)
}
period := time.Now().UTC().Add(-time.Hour).Truncate(time.Minute)
for _, bucket := range []store.TrafficBucket{
{
@@ -517,6 +524,14 @@ func TestTrafficAnalysisUsesAndAggregatesStoredBuckets(t *testing.T) {
}
}
func TestTrafficAnalysisIsUnavailableOutsideDeveloperMode(t *testing.T) {
test := newSettingsAPITest(t)
recorder := test.request(t, http.MethodGet, "/api/traffic/analysis?range=week", "")
if recorder.Code != http.StatusForbidden {
t.Fatalf("traffic status = %d, want %d; body = %s", recorder.Code, http.StatusForbidden, recorder.Body)
}
}
func TestNotificationDestinationAddressPolicy(t *testing.T) {
blocked := []string{
"0.0.0.0", "10.0.0.1", "100.100.100.200", "127.0.0.1",
+9
View File
@@ -581,6 +581,15 @@ func (s *Server) syncModemSMS(ctx context.Context, onlyDevice string) {
message.Index,
hex.EncodeToString(digest[:8]),
)
if message.Concat != nil && message.Concat.Total > 1 {
// A segment of a carrier-split long SMS. Address the whole message
// with a stable id so SaveSMSMessage folds every segment into one
// progressively merged row instead of one row per segment.
messageID = store.StableConcatMessageID(
"cellular_at", modemIMEI, config.ID, peer,
message.Concat.Reference, message.Concat.Total,
)
}
extra, _ := json.Marshal(map[string]any{
"modem_index": message.Index,
"storage": message.Storage,
+9
View File
@@ -902,6 +902,15 @@ func (bot *telegramBot) notifyInboundSMS(ctx context.Context) {
bot.warn("list Telegram SMS notifications", listErr)
} else {
for _, message := range messages {
if !store.ConcatSMSReadyToNotify(message.MessageID, message.Extra) {
// A carrier-split long SMS still waiting for segments. Hold
// the notification but advance the cursor so the partial row
// is not reconsidered every poll; when the final segment
// merges, the row re-enters with a fresh id and is pushed
// here as one complete message.
cursor = message.ID
continue
}
text := fmt.Sprintf("📩 新短信\n设备:%s\n来自:%s\n时间:%s\n\n%s", message.DeviceID, message.Peer, message.Timestamp.Local().Format("2006-01-02 15:04:05"), message.Body)
if sendErr := bot.sendText(ctx, config, 0, text, nil); sendErr != nil {
bot.warn("send Telegram SMS notification", sendErr)
+32 -5
View File
@@ -13,6 +13,27 @@ type contextExecer interface {
ExecContext(context.Context, string, ...any) (sql.Result, error)
}
const (
DeviceTypeWiFi410 = "wifi_410"
DeviceTypeDJI4G = "dji_4g"
DeviceTypePCIeEC20EC25 = "pcie_ec20_ec25"
)
// NormalizeDeviceType returns a stable persisted device type identifier.
// Empty values use the legacy EC20/EC25 type for backwards compatibility.
func NormalizeDeviceType(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case DeviceTypeWiFi410:
return DeviceTypeWiFi410
case DeviceTypeDJI4G:
return DeviceTypeDJI4G
case "", DeviceTypePCIeEC20EC25:
return DeviceTypePCIeEC20EC25
default:
return ""
}
}
func (s *Store) UpsertDevice(ctx context.Context, value Device) error {
return upsertDevice(ctx, s.db, value)
}
@@ -73,6 +94,10 @@ func upsertDevice(ctx context.Context, executor contextExecer, value Device) err
if value.Name == "" {
return errors.New("device name is required")
}
value.DeviceType = NormalizeDeviceType(value.DeviceType)
if value.DeviceType == "" {
return errors.New("unsupported device type")
}
if value.ProxyPort < 0 || value.ProxyPort > 65535 {
return errors.New("device proxy port must be between 0 and 65535")
}
@@ -133,15 +158,16 @@ func upsertDevice(ctx context.Context, executor contextExecer, value Device) err
_, err = executor.ExecContext(ctx, `
INSERT INTO devices (
id, name, interface, control_device, at_port, usb_path,
id, name, device_type, interface, control_device, at_port, usb_path,
audio_device, modem_imei, apn, proxy_port, baud_rate,
data_bits, stop_bits, parity, device_backend, esim_transport,
qmi_use_proxy, qmi_proxy_path, qmi_proxy_executable,
network_enabled, sms_enabled, vowifi_enabled, extra_json,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
device_type = excluded.device_type,
interface = excluded.interface,
control_device = excluded.control_device,
at_port = excluded.at_port,
@@ -165,7 +191,7 @@ func upsertDevice(ctx context.Context, executor contextExecer, value Device) err
extra_json = excluded.extra_json,
updated_at = excluded.updated_at
`,
value.ID, value.Name, value.Interface, value.ControlDevice, value.ATPort,
value.ID, value.Name, value.DeviceType, value.Interface, value.ControlDevice, value.ATPort,
value.USBPath, value.AudioDevice, value.ModemIMEI, value.APN,
value.ProxyPort, value.BaudRate, value.DataBits, value.StopBits,
value.Parity, value.DeviceBackend, value.ESIMTransport,
@@ -255,7 +281,7 @@ func (s *Store) DeleteDevice(ctx context.Context, id string) error {
}
const deviceSelect = `
SELECT id, name, interface, control_device, at_port, usb_path,
SELECT id, name, device_type, interface, control_device, at_port, usb_path,
audio_device, modem_imei, apn, proxy_port, baud_rate, data_bits,
stop_bits, parity, device_backend, esim_transport, qmi_use_proxy,
qmi_proxy_path, qmi_proxy_executable, network_enabled, sms_enabled,
@@ -268,7 +294,7 @@ func scanDevice(row rowScanner) (Device, error) {
var extra string
var createdAt, updatedAt int64
err := row.Scan(
&value.ID, &value.Name, &value.Interface, &value.ControlDevice,
&value.ID, &value.Name, &value.DeviceType, &value.Interface, &value.ControlDevice,
&value.ATPort, &value.USBPath, &value.AudioDevice, &value.ModemIMEI,
&value.APN, &value.ProxyPort, &value.BaudRate, &value.DataBits,
&value.StopBits, &value.Parity, &value.DeviceBackend,
@@ -286,6 +312,7 @@ func scanDevice(row rowScanner) (Device, error) {
value.NetworkEnabled = networkEnabled != 0
value.SMSEnabled = smsEnabled != 0
value.VoWiFiEnabled = vowifiEnabled != 0
value.DeviceType = NormalizeDeviceType(value.DeviceType)
value.Extra = []byte(extra)
value.CreatedAt = time.Unix(createdAt, 0).UTC()
value.UpdatedAt = time.Unix(updatedAt, 0).UTC()
+37 -1
View File
@@ -105,6 +105,41 @@ func TestMigration7BackfillsSMSModemIMEI(t *testing.T) {
}
}
func TestMigration8DefaultsExistingDevicesToPCIeType(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "device-type.db")
raw, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
for version := 1; version <= 7; version++ {
for _, statement := range migrationStatements(version) {
if _, err := raw.ExecContext(ctx, statement); err != nil {
t.Fatalf("create v%d schema: %v", version, err)
}
}
}
if _, err := raw.ExecContext(ctx, `
INSERT INTO devices (id, name, created_at, updated_at)
VALUES ('legacy', 'Legacy modem', 100, 100);
PRAGMA user_version = 7;
`); err != nil {
t.Fatal(err)
}
if err := raw.Close(); err != nil {
t.Fatal(err)
}
database := openTestStore(t, path)
got, err := database.Device(ctx, "legacy")
if err != nil {
t.Fatal(err)
}
if got.DeviceType != DeviceTypePCIeEC20EC25 {
t.Fatalf("legacy device type = %q", got.DeviceType)
}
}
func TestMigration4PreservesIMSRedeliveryAndUsesReceiptTime(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "ims-redelivery.db")
@@ -170,6 +205,7 @@ func TestDeviceStateRoundTripAndCascade(t *testing.T) {
device := Device{
ID: "ec20-1",
Name: "EC20 一号",
DeviceType: DeviceTypeDJI4G,
Interface: "wwan0",
ControlDevice: "/dev/cdc-wdm0",
ATPort: "/dev/ttyUSB2",
@@ -219,7 +255,7 @@ func TestDeviceStateRoundTripAndCascade(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if gotDevice.BaudRate != 115200 || gotDevice.DataBits != 8 ||
if gotDevice.DeviceType != DeviceTypeDJI4G || gotDevice.BaudRate != 115200 || gotDevice.DataBits != 8 ||
gotDevice.StopBits != 1 || gotDevice.DeviceBackend != "at" {
t.Fatalf("device defaults not applied: %+v", gotDevice)
}
+5
View File
@@ -106,6 +106,11 @@ func migrationStatements(version int) []string {
`CREATE INDEX IF NOT EXISTS sms_messages_hardware_thread_idx
ON sms_messages(modem_imei, imsi, peer, message_time DESC, id DESC)`,
}
case 8:
return []string{
`ALTER TABLE devices
ADD COLUMN device_type TEXT NOT NULL DEFAULT 'pcie_ec20_ec25'`,
}
default:
return nil
}
+1
View File
@@ -17,6 +17,7 @@ const SecretMask = "********"
type Device struct {
ID string
Name string
DeviceType string
Interface string
ControlDevice string
ATPort string
+52
View File
@@ -65,6 +65,58 @@ func saveSMSMessage(
return SMSMessage{}, fmt.Errorf("normalize SMS extra data: %w", err)
}
now := time.Now().UTC()
// Concatenated (long) SMS arrive as one segment per delivery. Ingest points
// address the whole message with a stable "concat:" message id and carry the
// segment text plus its UDH sequence in Extra. Fold each segment into a single
// stored row so history, the web thread, and Telegram show one progressive
// message that fills in as the remaining segments arrive.
if isConcatSMSMessageID(value.MessageID) {
hardwareKey := smsHardwareKey(value.ModemIMEI, value.DeviceID)
existing, existingErr := scanSMSMessage(executor.QueryRowContext(
ctx,
smsMessageSelect+` WHERE
COALESCE(NULLIF(modem_imei, ''), 'device:' || device_id) = ?
AND message_id = ?`,
hardwareKey,
value.MessageID,
))
if existingErr != nil && !errors.Is(existingErr, ErrNotFound) {
return SMSMessage{}, fmt.Errorf("read existing concatenated SMS: %w", existingErr)
}
var existingExtra json.RawMessage
if existingErr == nil {
existingExtra = existing.Extra
}
mergedBody, mergedExtra, changed, mergeErr := mergeConcatSegment(existingExtra, value.Body, extra)
if mergeErr != nil {
return SMSMessage{}, fmt.Errorf("merge concatenated SMS segment: %w", mergeErr)
}
if existingErr == nil && !changed {
// This segment is already folded into the stored row (a periodic modem
// rescan redelivers every segment). Leave the row untouched so the
// durable id stays put and Telegram does not re-notify.
return existing, nil
}
value.Body = mergedBody
extra = mergedExtra
if existingErr == nil {
// A new segment advanced the message. Replace the stale partial row so
// the merged row receives a fresh durable id; the Telegram id-cursor
// then surfaces the now-more-complete message exactly once. Carry
// forward identity and history fields.
if _, delErr := executor.ExecContext(ctx, `DELETE FROM sms_messages WHERE id = ?`, existing.ID); delErr != nil {
return SMSMessage{}, fmt.Errorf("replace concatenated SMS: %w", delErr)
}
value.ID = 0
value.CreatedAt = existing.CreatedAt
value.Read = value.Read || existing.Read
if !existing.Timestamp.IsZero() &&
(value.Timestamp.IsZero() || existing.Timestamp.Before(value.Timestamp)) {
value.Timestamp = existing.Timestamp
}
}
}
if value.Timestamp.IsZero() {
value.Timestamp = now
}
+132
View File
@@ -0,0 +1,132 @@
package store
import (
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
)
// ConcatMessageIDPrefix marks the stable message id that ingest points assign to
// every segment of one concatenated (long) SMS. Unlike the per-segment modem/IMS
// ids (which embed a storage slot, PDU hash, or RP reference), this id is shared
// by all segments of the message, so SaveSMSMessage folds them into a single row.
const ConcatMessageIDPrefix = "concat:"
// isConcatSMSMessageID reports whether a message id addresses a whole
// concatenated SMS rather than one physical segment.
func isConcatSMSMessageID(messageID string) bool {
return strings.HasPrefix(messageID, ConcatMessageIDPrefix)
}
// ConcatSMSReadyToNotify reports whether an inbound SMS row is ready to surface
// to a notification consumer. A plain message is always ready; a concatenated
// (long) SMS row is ready only once every segment has merged (concat_complete).
// Until then consumers should hold the notification but still advance their
// cursor — the completed message re-enters as a fresh durable id.
func ConcatSMSReadyToNotify(messageID string, extra json.RawMessage) bool {
if !isConcatSMSMessageID(messageID) {
return true
}
document, err := decodeJSONObject(extra)
if err != nil {
return false
}
complete, _ := document["concat_complete"].(bool)
return complete
}
// StableConcatMessageID builds the message id shared by every segment of one
// concatenated SMS. The UDH concat reference is only unique per sender, so the
// hardware identity and peer scope it; total is folded in to further separate the
// rare reference reuse between two different long messages from the same peer.
// The hardware identity matches the row lookup in saveSMSMessage, so a segment
// always finds the row its siblings started.
func StableConcatMessageID(source, modemIMEI, deviceID, peer string, reference, total int) string {
return ConcatMessageIDPrefix + source + ":" + smsHardwareKey(modemIMEI, deviceID) + ":" + peer + ":" +
strconv.Itoa(reference) + ":" + strconv.Itoa(total)
}
// mergeConcatSegment folds one incoming segment into the progressively merged
// body of a concatenated SMS. existingExtra is the stored row's Extra (empty for
// the first segment); segmentBody/segmentExtra are the incoming segment's text
// and Extra, the latter carrying "concat" ({reference,total,sequence}).
//
// Each segment's text is kept under "concat_parts" keyed by its UDH sequence and
// the body is rebuilt by joining the parts in ascending sequence order with no
// separator — exactly how a phone reassembles a long message, and correct for any
// arrival order. The merge is idempotent: redelivering an already-folded sequence
// reports changed=false so callers can skip the write and avoid id churn.
// "concat_complete" flips true once Total segments are present.
func mergeConcatSegment(
existingExtra json.RawMessage,
segmentBody string,
segmentExtra json.RawMessage,
) (body string, extra json.RawMessage, changed bool, err error) {
segment, err := decodeJSONObject(segmentExtra)
if err != nil {
return "", nil, false, fmt.Errorf("decode segment extra: %w", err)
}
concat, _ := segment["concat"].(map[string]any)
sequence := numberAsInt(concat["sequence"])
total := numberAsInt(concat["total"])
if sequence < 1 {
// No usable UDH sequence: keep the incoming segment as the whole body.
return segmentBody, json.RawMessage(segmentExtra), true, nil
}
// Seed the per-segment texts from the previously stored parts so an
// out-of-order arrival always rebuilds in sequence order.
parts := map[int]string{}
if len(existingExtra) > 0 {
if existing, derr := decodeJSONObject(existingExtra); derr == nil {
if stored, ok := existing["concat_parts"].(map[string]any); ok {
for key, value := range stored {
n, aerr := strconv.Atoi(key)
if aerr != nil || n < 1 {
continue
}
if text, ok := value.(string); ok {
parts[n] = text
}
}
}
}
}
prior, alreadyHad := parts[sequence]
changed = !alreadyHad || prior != segmentBody
parts[sequence] = segmentBody
sequences := make([]int, 0, len(parts))
for n := range parts {
sequences = append(sequences, n)
}
sort.Ints(sequences)
var joined strings.Builder
stored := make(map[string]string, len(parts))
for _, n := range sequences {
joined.WriteString(parts[n])
stored[strconv.Itoa(n)] = parts[n]
}
complete := total > 0 && len(parts) >= total
merged := map[string]any{
"concat": concat,
"concat_parts": stored,
"concat_received": len(parts),
"concat_complete": complete,
}
// Preserve non-concat metadata from the latest segment for context.
for _, key := range []string{"encoding", "storage", "transport", "source"} {
if value, ok := segment[key]; ok {
merged[key] = value
}
}
encoded, err := json.Marshal(merged)
if err != nil {
return "", nil, false, fmt.Errorf("encode merged concat extra: %w", err)
}
return joined.String(), json.RawMessage(encoded), changed, nil
}
+229
View File
@@ -0,0 +1,229 @@
package store
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
)
func concatExtra(t *testing.T, reference, total, sequence int) json.RawMessage {
t.Helper()
extra, err := json.Marshal(map[string]any{
"concat": map[string]any{"reference": reference, "total": total, "sequence": sequence},
})
if err != nil {
t.Fatalf("marshal concat extra: %v", err)
}
return extra
}
func TestMergeConcatSegmentJoinsOutOfOrderInSequenceOrder(t *testing.T) {
// UCS-2 long SMS (the customer case) whose segments arrive 2, 1, 3.
var body string
var extra json.RawMessage
var changed, complete bool
body, extra, changed, err := mergeConcatSegment(extra, "中段", concatExtra(t, 9, 3, 2))
if err != nil || !changed {
t.Fatalf("segment 2: body=%q changed=%v err=%v", body, changed, err)
}
if body != "中段" {
t.Fatalf("after segment 2 body = %q, want partial %q", body, "中段")
}
body, extra, changed, err = mergeConcatSegment(extra, "前段", concatExtra(t, 9, 3, 1))
if err != nil || !changed {
t.Fatalf("segment 1: body=%q changed=%v err=%v", body, changed, err)
}
if body != "前段中段" {
t.Fatalf("after segment 1 body = %q, want %q", body, "前段中段")
}
body, extra, changed, err = mergeConcatSegment(extra, "尾段", concatExtra(t, 9, 3, 3))
if err != nil || !changed {
t.Fatalf("segment 3: body=%q changed=%v err=%v", body, changed, err)
}
if body != "前段中段尾段" {
t.Fatalf("complete body = %q, want %q", body, "前段中段尾段")
}
document, err := decodeJSONObject(extra)
if err != nil {
t.Fatalf("decode merged extra: %v", err)
}
complete, _ = document["concat_complete"].(bool)
if !complete {
t.Fatalf("concat_complete = %v, want true; extra=%s", complete, extra)
}
if got := numberAsInt(document["concat_received"]); got != 3 {
t.Fatalf("concat_received = %d, want 3", got)
}
}
func TestMergeConcatSegmentKeepsURLContiguous(t *testing.T) {
// A GSM-7 tracking link split mid-token (the OFCA "garbled" report) must
// reassemble with no break.
first := "https://ofca.gov.hk/track?tok=ab"
second := "cdef1234&lang=zh"
_, extra, _, err := mergeConcatSegment(nil, first, concatExtra(t, 4, 2, 1))
if err != nil {
t.Fatal(err)
}
body, _, _, err := mergeConcatSegment(extra, second, concatExtra(t, 4, 2, 2))
if err != nil {
t.Fatal(err)
}
want := "https://ofca.gov.hk/track?tok=abcdef1234&lang=zh"
if body != want {
t.Fatalf("body = %q, want %q", body, want)
}
}
func TestMergeConcatSegmentRedeliveryIsIdempotent(t *testing.T) {
_, extra, _, err := mergeConcatSegment(nil, "甲", concatExtra(t, 3, 2, 1))
if err != nil {
t.Fatal(err)
}
// A modem rescan redelivers the identical segment: no change, no growth.
body, extra2, changed, err := mergeConcatSegment(extra, "甲", concatExtra(t, 3, 2, 1))
if err != nil {
t.Fatal(err)
}
if changed {
t.Fatalf("redelivered segment reported changed=true; body=%q", body)
}
if body != "甲" {
t.Fatalf("body = %q, want %q", body, "甲")
}
if string(extra2) == "" {
t.Fatal("merged extra lost on idempotent redelivery")
}
}
func TestMergeConcatSegmentWithoutHeaderPassesThrough(t *testing.T) {
extra, err := json.Marshal(map[string]any{"encoding": "gsm7"})
if err != nil {
t.Fatal(err)
}
body, _, changed, err := mergeConcatSegment(nil, "plain", extra)
if err != nil || !changed || body != "plain" {
t.Fatalf("body=%q changed=%v err=%v, want passthrough", body, changed, err)
}
}
func TestStableConcatMessageIDScopesByPeerReferenceTotal(t *testing.T) {
a := StableConcatMessageID("cellular_at", "imei-1", "ec20", "+10086", 7, 2)
if !isConcatSMSMessageID(a) {
t.Fatalf("id %q missing concat prefix", a)
}
if again := StableConcatMessageID("cellular_at", "imei-1", "ec20", "+10086", 7, 2); again != a {
t.Fatalf("id unstable: %q vs %q", a, again)
}
for _, different := range []string{
StableConcatMessageID("cellular_at", "imei-1", "ec20", "+10086", 8, 2), // other reference
StableConcatMessageID("cellular_at", "imei-1", "ec20", "+10010", 7, 2), // other peer
StableConcatMessageID("cellular_at", "imei-1", "ec20", "+10086", 7, 3), // other total
StableConcatMessageID("ims", "imei-1", "ec20", "+10086", 7, 2), // other source
} {
if different == a {
t.Fatalf("id %q collides across distinct concat groups", a)
}
}
}
func TestConcatSMSReadyToNotify(t *testing.T) {
if !ConcatSMSReadyToNotify("modem:SM:3:abcd", json.RawMessage(`{}`)) {
t.Fatal("plain message should always be ready")
}
incomplete := StableConcatMessageID("cellular_at", "imei", "ec20", "peer", 1, 2)
if ConcatSMSReadyToNotify(incomplete, json.RawMessage(`{"concat_complete":false}`)) {
t.Fatal("incomplete long SMS must not notify")
}
if ConcatSMSReadyToNotify(incomplete, json.RawMessage(`not-json`)) {
t.Fatal("unparseable concat extra must not notify")
}
if !ConcatSMSReadyToNotify(incomplete, json.RawMessage(`{"concat_complete":true}`)) {
t.Fatal("complete long SMS should notify")
}
}
func TestSaveConcatSMSFoldsSegmentsIntoOneRow(t *testing.T) {
ctx := context.Background()
database := openTestStore(t, ":memory:")
mustSaveDevice(t, database, "ec20-1", "测试设备")
const imei = "867394042309830"
base := time.Unix(1_700_000_000, 0).UTC()
save := func(sequence int, text string, at time.Time) SMSMessage {
t.Helper()
saved, err := database.SaveSMSMessage(ctx, SMSMessage{
MessageID: StableConcatMessageID("cellular_at", imei, "ec20-1", "+8520000", 5, 2),
DeviceID: "ec20-1", ModemIMEI: imei, IMSI: "45400",
Peer: "+8520000", Direction: "inbound", Body: text,
Timestamp: at, Status: "received", Source: "cellular_at",
PartsTotal: 2,
Extra: concatExtra(t, 5, 2, sequence),
})
if err != nil {
t.Fatalf("SaveSMSMessage(seq=%d) error = %v", sequence, err)
}
return saved
}
first := save(1, "【检测】您的结果为", base)
if first.PartsTotal != 2 {
t.Fatalf("PartsTotal = %d, want 2", first.PartsTotal)
}
if ConcatSMSReadyToNotify(first.MessageID, first.Extra) {
t.Fatal("first segment alone should not be ready to notify")
}
second := save(2, "合格,请查收报告", base.Add(30*time.Second))
if second.ID <= first.ID {
t.Fatalf("completed row id = %d, want a fresh id greater than %d", second.ID, first.ID)
}
if second.Body != "【检测】您的结果为合格,请查收报告" {
t.Fatalf("merged body = %q", second.Body)
}
if !second.Timestamp.Equal(base) {
t.Fatalf("merged timestamp = %v, want earliest segment %v", second.Timestamp, base)
}
if !ConcatSMSReadyToNotify(second.MessageID, second.Extra) {
t.Fatal("completed long SMS should be ready to notify")
}
// Exactly one stored row represents the whole long SMS.
messages, err := database.ListSMSMessages(ctx, SMSFilter{DeviceID: "ec20-1"})
if err != nil {
t.Fatal(err)
}
if len(messages) != 1 {
t.Fatalf("stored rows = %d, want 1 merged row: %+v", len(messages), messages)
}
// The completed row re-enters after the earlier partial id, so the Telegram
// id-cursor surfaces it once, complete.
fresh, err := database.ListInboundSMSAfterID(ctx, first.ID, 10)
if err != nil {
t.Fatal(err)
}
if len(fresh) != 1 || fresh[0].ID != second.ID || !strings.Contains(fresh[0].Body, "合格") {
t.Fatalf("ListInboundSMSAfterID = %+v, want the completed row", fresh)
}
// A modem rescan redelivers an already-folded segment: no write, no id churn.
rescan := save(1, "【检测】您的结果为", base)
if rescan.ID != second.ID {
t.Fatalf("rescan churned the row id: got %d, want stable %d", rescan.ID, second.ID)
}
if rescan.Body != second.Body {
t.Fatalf("rescan changed body to %q", rescan.Body)
}
if after, err := database.ListInboundSMSAfterID(ctx, second.ID, 10); err != nil || len(after) != 0 {
t.Fatalf("rescan produced new rows: %+v, %v", after, err)
}
if count, err := database.ListSMSMessages(ctx, SMSFilter{DeviceID: "ec20-1"}); err != nil || len(count) != 1 {
t.Fatalf("rows after rescan = %d, %v; want still 1", len(count), err)
}
}
+6 -6
View File
@@ -13,7 +13,7 @@ import (
_ "modernc.org/sqlite"
)
const schemaVersion = 7
const schemaVersion = 8
var ErrNotFound = errors.New("store: not found")
@@ -118,11 +118,11 @@ func migrate(ctx context.Context, db *sql.DB) error {
for _, statement := range migrationStatements(nextVersion) {
if _, err := tx.ExecContext(ctx, statement); err != nil {
// A database whose user_version was repaired or rolled back may
// already contain this additive v7 column. The remaining v7 data
// backfill and indexes are still safe and must be applied.
if nextVersion == 7 &&
strings.Contains(statement, "ADD COLUMN modem_imei") &&
strings.Contains(strings.ToLower(err.Error()), "duplicate column name") {
// 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"))
if duplicateAdditiveColumn && strings.Contains(strings.ToLower(err.Error()), "duplicate column name") {
continue
}
_ = tx.Rollback()
+83 -16
View File
@@ -34,6 +34,7 @@ type imsCall struct {
remoteTag string
routes []string
terminated bool
media *rtpMedia
}
func (session *Session) Calls() []vowifi.Call {
@@ -73,7 +74,11 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal
routes := append([]string(nil), session.evidence.ServiceRoute...)
securityHeaders := runtimeSecurityHeaders(session.securityActive, session.securityAgreement.verifyValue)
session.mu.Unlock()
body := session.inactiveSDP()
media, err := newRTPMedia(session.localMediaIP())
if err != nil {
return vowifi.Call{}, err
}
body := media.offerSDP(session.localMediaIP())
transportUpper := strings.ToUpper(session.transport)
from := "<" + session.identity.public + ">;tag=" + session.fromTag
to := "<" + target + ">"
@@ -108,6 +113,7 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal
session.transactionsMu.Lock()
if _, duplicate := session.transactions[key]; duplicate {
session.transactionsMu.Unlock()
_ = media.Close()
return vowifi.Call{}, errors.New("ims: duplicate call transaction")
}
session.transactions[key] = responses
@@ -115,7 +121,7 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal
call := &imsCall{
public: vowifi.Call{ID: callID, Number: number, Direction: "outgoing", State: "dialing", StartedAt: time.Now().UTC()},
callID: callID, target: target, from: from, to: to, branch: branch, cseq: cseq, responses: responses,
routes: routes,
routes: routes, media: media,
}
session.callMu.Lock()
session.calls[callID] = call
@@ -124,6 +130,7 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal
_, err = session.conn.Write(request)
session.writeMu.Unlock()
if err != nil {
_ = media.Close()
session.transactionsMu.Lock()
delete(session.transactions, key)
session.transactionsMu.Unlock()
@@ -173,7 +180,18 @@ func (session *Session) watchOutgoingCall(call *imsCall, key sipTransactionKey)
call.routes = reverseStrings(recordRoutes)
}
session.callMu.Unlock()
mediaErr := call.media.configureRemote(response.Body)
_ = session.sendACK(call)
if mediaErr != nil {
session.finishCall(call.callID, "failed", response.StatusCode, mediaErr.Error())
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = session.sendDialogRequest(ctx, call, "BYE")
}()
return
}
session.setCallMediaReady(call.callID)
session.setCallState(call.callID, "active")
} else {
session.finishCall(call.callID, "failed", response.StatusCode, response.Reason)
@@ -196,7 +214,7 @@ func (session *Session) AnswerCall(_ context.Context, id string) (vowifi.Call, e
}
request, respond := call.invite, call.respond
session.callMu.Unlock()
response, err := buildSIPResponseWithBody(request, 200, session.fromTag, session.inactiveSDP())
response, err := buildSIPResponseWithBody(request, 200, session.fromTag, call.media.answerSDP(session.localMediaIP()))
if err != nil {
return vowifi.Call{}, err
}
@@ -204,6 +222,9 @@ func (session *Session) AnswerCall(_ context.Context, id string) (vowifi.Call, e
return vowifi.Call{}, err
}
session.setCallState(id, "active")
if call.media.ready() {
session.setCallMediaReady(id)
}
session.callMu.Lock()
result := call.public
session.callMu.Unlock()
@@ -255,10 +276,26 @@ func (session *Session) handleCallRequest(request *sipRequest, respond func([]by
if target == "" {
target = request.URI
}
media, err := newRTPMedia(session.localMediaIP())
if err != nil {
if response, buildErr := buildSIPResponseWithBody(request, 488, session.fromTag, nil); buildErr == nil {
_ = respond(response)
}
return true
}
if len(request.Body) > 0 {
if err := media.configureRemote(request.Body); err != nil {
_ = media.Close()
if response, buildErr := buildSIPResponseWithBody(request, 488, session.fromTag, nil); buildErr == nil {
_ = respond(response)
}
return true
}
}
call := &imsCall{
public: vowifi.Call{ID: callID, Number: number, Direction: "incoming", State: "ringing", StartedAt: time.Now().UTC()},
callID: callID, target: target, from: request.value("To") + ";tag=" + session.fromTag,
to: request.value("From"), invite: request, respond: respond, routes: request.values("Record-Route"),
to: request.value("From"), invite: request, respond: respond, routes: request.values("Record-Route"), media: media,
}
session.callMu.Lock()
session.calls[callID] = call
@@ -269,6 +306,17 @@ func (session *Session) handleCallRequest(request *sipRequest, respond func([]by
}
return true
case "ACK":
callID := strings.TrimSpace(request.value("Call-ID"))
session.callMu.Lock()
call := session.calls[callID]
session.callMu.Unlock()
if call != nil && call.media != nil && !call.media.ready() && len(request.Body) > 0 {
if err := call.media.configureRemote(request.Body); err != nil {
session.finishCall(callID, "failed", 0, err.Error())
} else {
session.setCallMediaReady(callID)
}
}
return true
case "CANCEL", "BYE":
response, err := buildSIPResponseWithBody(request, 200, session.fromTag, nil)
@@ -353,25 +401,16 @@ func (session *Session) buildDialogRequest(call *imsCall, method string, cseq ui
return []byte(strings.Join(lines, "\r\n"))
}
func (session *Session) inactiveSDP() []byte {
func (session *Session) localMediaIP() net.IP {
var localAddress net.Addr
if session.conn != nil {
localAddress = session.conn.LocalAddr()
}
local := addressIP(localAddress)
if local == nil {
local = net.IPv4zero
}
family := "IP4"
if local.To4() == nil {
family = "IP6"
}
text := fmt.Sprintf("v=0\r\no=- %d %d IN %s %s\r\ns=VoCat Calling Test\r\nc=IN %s %s\r\nt=0 0\r\nm=audio 9 RTP/AVP 0 8\r\na=inactive\r\n", time.Now().Unix(), time.Now().Unix(), family, local.String(), family, local.String())
return []byte(text)
return addressIP(localAddress)
}
func buildSIPResponseWithBody(request *sipRequest, status int, tag string, body []byte) ([]byte, error) {
reasons := map[int]string{180: "Ringing", 200: "OK", 486: "Busy Here", 487: "Request Terminated"}
reasons := map[int]string{180: "Ringing", 200: "OK", 486: "Busy Here", 487: "Request Terminated", 488: "Not Acceptable Here"}
reason := reasons[status]
if reason == "" {
return nil, errors.New("ims: unsupported call response status")
@@ -417,10 +456,34 @@ func (session *Session) setCallDiagnostic(id string, code int, reason string) {
session.callMu.Unlock()
}
func (session *Session) setCallMediaReady(id string) {
session.callMu.Lock()
if call := session.calls[id]; call != nil && call.media != nil {
call.public.MediaReady = call.media.ready()
call.public.Codec = call.media.Codec()
}
session.callMu.Unlock()
}
func (session *Session) CallMedia(_ context.Context, id string) (vowifi.CallMedia, error) {
session.callMu.Lock()
defer session.callMu.Unlock()
call := session.calls[id]
if call == nil {
return nil, ErrCallNotFound
}
if call.public.State != "active" || call.media == nil || !call.media.ready() {
return nil, ErrCallState
}
return call.media, nil
}
func (session *Session) finishCall(id, state string, code int, reason string) {
now := time.Now().UTC()
var media *rtpMedia
session.callMu.Lock()
if call := session.calls[id]; call != nil {
media = call.media
call.public.State = state
if code != 0 {
call.public.SIPCode = code
@@ -431,6 +494,9 @@ func (session *Session) finishCall(id, state string, code int, reason string) {
call.public.EndedAt = &now
}
session.callMu.Unlock()
if media != nil {
_ = media.Close()
}
}
func validCallNumber(value string) bool {
@@ -500,3 +566,4 @@ func reverseStrings(values []string) []string {
}
var _ vowifi.CallController = (*Session)(nil)
var _ vowifi.CallMediaController = (*Session)(nil)
+2 -2
View File
@@ -8,7 +8,7 @@ import (
"vocat/internal/vowifi"
)
func TestIncomingCallCanRingAndAnswerWithoutAudio(t *testing.T) {
func TestIncomingCallCanRingAndAnswerWithMediaOffer(t *testing.T) {
session := &Session{fromTag: "local-tag", calls: make(map[string]*imsCall)}
packet, err := parseSIPPacket([]byte(strings.Join([]string{
"INVITE sip:[email protected] SIP/2.0",
@@ -38,7 +38,7 @@ func TestIncomingCallCanRingAndAnswerWithoutAudio(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if answered.State != "active" || len(responses) != 2 || !strings.Contains(string(responses[1]), "a=inactive") {
if answered.State != "active" || len(responses) != 2 || !strings.Contains(string(responses[1]), "a=sendrecv") {
t.Fatalf("answered = %#v, response = %q", answered, responses[1])
}
}
+10
View File
@@ -1141,10 +1141,20 @@ func (session *Session) Close(ctx context.Context) error {
session.smsContactConfirmed = false
session.clearAuthentication()
session.mu.Unlock()
session.callMu.Lock()
for _, call := range session.calls {
if call.media != nil {
_ = call.media.Close()
}
}
session.callMu.Unlock()
var cleanupErrors []error
if unregisterErr != nil {
cleanupErrors = append(cleanupErrors, unregisterErr)
}
// Runtime receive loops block in Read/Accept. Close every socket before
// waiting for those goroutines; waiting first deadlocks VoWiFi shutdown and
// leaves the modem permanently in CFUN=4.
if err := session.conn.Close(); err != nil {
cleanupErrors = append(cleanupErrors, err)
}
+374
View File
@@ -0,0 +1,374 @@
package ims
import (
"context"
cryptorand "crypto/rand"
"encoding/binary"
"errors"
"fmt"
"io"
"net"
"strconv"
"strings"
"sync"
"time"
)
const (
rtpClockRate = 8000
rtpPacketSamples = 160
)
type rtpMedia struct {
conn *net.UDPConn
mu sync.RWMutex
remote *net.UDPAddr
codec string
payloadType byte
writeMu sync.Mutex
pending []int16
sequence uint16
timestamp uint32
ssrc uint32
downlink chan []int16
closed chan struct{}
close sync.Once
}
func newRTPMedia(local net.IP) (*rtpMedia, error) {
address := &net.UDPAddr{IP: local, Port: 0}
connection, err := net.ListenUDP("udp", address)
if err != nil {
return nil, fmt.Errorf("ims: open RTP socket: %w", err)
}
seed := make([]byte, 10)
if _, err := io.ReadFull(cryptorand.Reader, seed); err != nil {
_ = connection.Close()
return nil, fmt.Errorf("ims: initialize RTP state: %w", err)
}
media := &rtpMedia{
conn: connection, sequence: binary.BigEndian.Uint16(seed[:2]),
timestamp: binary.BigEndian.Uint32(seed[2:6]), ssrc: binary.BigEndian.Uint32(seed[6:]),
downlink: make(chan []int16, 64), closed: make(chan struct{}),
}
go media.receive()
return media, nil
}
func (media *rtpMedia) Codec() string {
media.mu.RLock()
defer media.mu.RUnlock()
return media.codec
}
func (media *rtpMedia) ready() bool {
media.mu.RLock()
defer media.mu.RUnlock()
return media.remote != nil && media.codec != ""
}
func (media *rtpMedia) offerSDP(local net.IP) []byte {
return media.buildSDP(local, "8 0", nil)
}
func (media *rtpMedia) answerSDP(local net.IP) []byte {
media.mu.RLock()
codec, payload := media.codec, media.payloadType
media.mu.RUnlock()
if codec == "" {
return media.offerSDP(local)
}
return media.buildSDP(local, strconv.Itoa(int(payload)), []string{
fmt.Sprintf("a=rtpmap:%d %s/8000", payload, codec),
})
}
func (media *rtpMedia) buildSDP(local net.IP, formats string, attributes []string) []byte {
if local == nil || local.IsUnspecified() {
if udp, ok := media.conn.LocalAddr().(*net.UDPAddr); ok {
local = udp.IP
}
}
if local == nil || local.IsUnspecified() {
local = net.IPv4zero
}
family := "IP4"
if local.To4() == nil {
family = "IP6"
}
port := media.conn.LocalAddr().(*net.UDPAddr).Port
sessionID := time.Now().UnixNano()
lines := []string{
"v=0",
fmt.Sprintf("o=- %d %d IN %s %s", sessionID, sessionID, family, local.String()),
"s=VoCat",
fmt.Sprintf("c=IN %s %s", family, local.String()),
"t=0 0",
fmt.Sprintf("m=audio %d RTP/AVP %s", port, formats),
}
if attributes == nil {
lines = append(lines, "a=rtpmap:8 PCMA/8000", "a=rtpmap:0 PCMU/8000")
} else {
lines = append(lines, attributes...)
}
lines = append(lines, "a=ptime:20", "a=sendrecv", "")
return []byte(strings.Join(lines, "\r\n"))
}
func (media *rtpMedia) configureRemote(body []byte) error {
address, port, formats, mappings, err := parseAudioSDP(body)
if err != nil {
return err
}
var codec string
var payload byte
for _, value := range formats {
parsed, parseErr := strconv.Atoi(value)
if parseErr != nil || parsed < 0 || parsed > 127 {
continue
}
name := strings.ToUpper(mappings[parsed])
if name == "" {
switch parsed {
case 0:
name = "PCMU"
case 8:
name = "PCMA"
}
}
if name == "PCMA" || name == "PCMU" {
codec, payload = name, byte(parsed)
break
}
}
if codec == "" {
return errors.New("ims: remote endpoint did not accept PCMA or PCMU audio")
}
media.mu.Lock()
media.remote = &net.UDPAddr{IP: address, Port: port}
media.codec = codec
media.payloadType = payload
media.mu.Unlock()
return nil
}
func parseAudioSDP(body []byte) (net.IP, int, []string, map[int]string, error) {
var sessionIP, mediaIP net.IP
var port int
var formats []string
mappings := make(map[int]string)
inAudio := false
for _, raw := range strings.Split(strings.ReplaceAll(string(body), "\r\n", "\n"), "\n") {
line := strings.TrimSpace(raw)
switch {
case strings.HasPrefix(line, "m="):
fields := strings.Fields(strings.TrimPrefix(line, "m="))
inAudio = len(fields) >= 4 && strings.EqualFold(fields[0], "audio") && strings.HasPrefix(strings.ToUpper(fields[2]), "RTP/AVP")
if inAudio {
port, _ = strconv.Atoi(strings.Split(fields[1], "/")[0])
formats = append([]string(nil), fields[3:]...)
}
case strings.HasPrefix(line, "c="):
fields := strings.Fields(strings.TrimPrefix(line, "c="))
if len(fields) >= 3 {
ip := net.ParseIP(strings.Split(fields[2], "/")[0])
if inAudio {
mediaIP = ip
} else {
sessionIP = ip
}
}
case inAudio && strings.HasPrefix(strings.ToLower(line), "a=rtpmap:"):
fields := strings.Fields(strings.TrimPrefix(line, "a=rtpmap:"))
if len(fields) == 2 {
pt, parseErr := strconv.Atoi(fields[0])
if parseErr == nil {
mappings[pt] = strings.Split(fields[1], "/")[0]
}
}
}
}
if mediaIP == nil {
mediaIP = sessionIP
}
if mediaIP == nil || port < 1 || port > 65535 || len(formats) == 0 {
return nil, 0, nil, nil, errors.New("ims: remote SDP has no usable audio endpoint")
}
return mediaIP, port, formats, mappings, nil
}
func (media *rtpMedia) ReadPCM(ctx context.Context) ([]int16, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-media.closed:
return nil, io.EOF
case samples := <-media.downlink:
return samples, nil
}
}
func (media *rtpMedia) WritePCM(samples []int16) error {
media.mu.RLock()
var remote *net.UDPAddr
if media.remote != nil {
copy := *media.remote
remote = &copy
}
codec, payload := media.codec, media.payloadType
media.mu.RUnlock()
if remote == nil || codec == "" {
return errors.New("ims: RTP media is not negotiated")
}
media.writeMu.Lock()
defer media.writeMu.Unlock()
media.pending = append(media.pending, samples...)
for len(media.pending) >= rtpPacketSamples {
packet := make([]byte, 12+rtpPacketSamples)
packet[0], packet[1] = 0x80, payload
binary.BigEndian.PutUint16(packet[2:4], media.sequence)
binary.BigEndian.PutUint32(packet[4:8], media.timestamp)
binary.BigEndian.PutUint32(packet[8:12], media.ssrc)
for index, sample := range media.pending[:rtpPacketSamples] {
if codec == "PCMA" {
packet[12+index] = linearToALaw(sample)
} else {
packet[12+index] = linearToMuLaw(sample)
}
}
if _, err := media.conn.WriteToUDP(packet, remote); err != nil {
return fmt.Errorf("ims: send RTP: %w", err)
}
media.pending = media.pending[rtpPacketSamples:]
media.sequence++
media.timestamp += rtpPacketSamples
}
return nil
}
func (media *rtpMedia) receive() {
packet := make([]byte, 2048)
for {
count, source, err := media.conn.ReadFromUDP(packet)
if err != nil {
return
}
media.mu.Lock()
remote, codec, payload := media.remote, media.codec, media.payloadType
if remote != nil && remote.IP.Equal(source.IP) && remote.Port != source.Port {
remote.Port = source.Port // symmetric RTP/NAT port learning
}
media.mu.Unlock()
if remote == nil || !remote.IP.Equal(source.IP) || count < 12 || packet[0]>>6 != 2 || packet[1]&0x7f != payload {
continue
}
header := 12 + int(packet[0]&0x0f)*4
if packet[0]&0x10 != 0 {
if count < header+4 {
continue
}
header += 4 + int(binary.BigEndian.Uint16(packet[header+2:header+4]))*4
}
if header >= count {
continue
}
samples := make([]int16, count-header)
for index, encoded := range packet[header:count] {
if codec == "PCMA" {
samples[index] = aLawToLinear(encoded)
} else {
samples[index] = muLawToLinear(encoded)
}
}
select {
case media.downlink <- samples:
default:
// Keep real-time behavior by dropping the oldest queued packet.
select {
case <-media.downlink:
default:
}
select {
case media.downlink <- samples:
default:
}
}
}
}
func (media *rtpMedia) Close() error {
media.close.Do(func() {
close(media.closed)
_ = media.conn.Close()
})
return nil
}
func linearToMuLaw(sample int16) byte {
value := int(sample)
sign := byte(0)
if value < 0 {
sign, value = 0x80, -value
if value > 32767 {
value = 32767
}
}
value += 132
if value > 32635 {
value = 32635
}
exponent := 7
for mask := 0x4000; exponent > 0 && value&mask == 0; mask >>= 1 {
exponent--
}
mantissa := (value >> (exponent + 3)) & 0x0f
return ^(sign | byte(exponent<<4) | byte(mantissa))
}
func muLawToLinear(value byte) int16 {
value = ^value
magnitude := ((int(value)&0x0f)<<3 + 132) << ((value & 0x70) >> 4)
magnitude -= 132
if value&0x80 != 0 {
return int16(-magnitude)
}
return int16(magnitude)
}
func linearToALaw(sample int16) byte {
value := int(sample)
mask := byte(0xd5)
if value < 0 {
mask, value = 0x55, -value-1
}
if value > 32767 {
value = 32767
}
var encoded byte
if value < 256 {
encoded = byte(value >> 4)
} else {
exponent := 1
for threshold := 512; exponent < 7 && value >= threshold; threshold <<= 1 {
exponent++
}
encoded = byte(exponent<<4) | byte((value>>(exponent+3))&0x0f)
}
return encoded ^ mask
}
func aLawToLinear(value byte) int16 {
value ^= 0x55
magnitude := int(value&0x0f)<<4 + 8
exponent := int((value & 0x70) >> 4)
if exponent != 0 {
magnitude = (magnitude + 0x100) << (exponent - 1)
}
if value&0x80 == 0 {
return int16(-magnitude)
}
return int16(magnitude)
}
+55
View File
@@ -0,0 +1,55 @@
package ims
import (
"context"
"math"
"net"
"testing"
"time"
)
func TestRTPMediaCarriesPCMOverPCMA(t *testing.T) {
left, err := newRTPMedia(net.IPv4(127, 0, 0, 1))
if err != nil {
t.Fatal(err)
}
defer left.Close()
right, err := newRTPMedia(net.IPv4(127, 0, 0, 1))
if err != nil {
t.Fatal(err)
}
defer right.Close()
if err := left.configureRemote(right.offerSDP(net.IPv4(127, 0, 0, 1))); err != nil {
t.Fatal(err)
}
if err := right.configureRemote(left.answerSDP(net.IPv4(127, 0, 0, 1))); err != nil {
t.Fatal(err)
}
want := make([]int16, rtpPacketSamples)
for index := range want {
want[index] = int16(9000 * math.Sin(float64(index)*2*math.Pi/40))
}
if err := left.WritePCM(want); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
got, err := right.ReadPCM(ctx)
if err != nil {
t.Fatal(err)
}
if len(got) != len(want) {
t.Fatalf("received %d samples, want %d", len(got), len(want))
}
for index := range got {
if difference := math.Abs(float64(got[index]) - float64(want[index])); difference > 700 {
t.Fatalf("sample %d difference %.0f exceeds G.711 tolerance", index, difference)
}
}
}
func TestParseAudioSDPRejectsMissingEndpoint(t *testing.T) {
if _, _, _, _, err := parseAudioSDP([]byte("v=0\r\nm=audio 0 RTP/AVP 8\r\n")); err == nil {
t.Fatal("expected unusable SDP error")
}
}
+11 -2
View File
@@ -106,6 +106,8 @@ func (projector StateProjector) Save(
}
runtime := store.VoWiFiRuntime{
DeviceID: state.DeviceID,
ICCID: strings.TrimSpace(state.ICCID),
IMSI: strings.TrimSpace(state.IMSI),
Phase: string(state.Phase),
DataplaneMode: dataplaneMode(state),
SIMReady: state.SIMReady,
@@ -125,8 +127,15 @@ func (projector StateProjector) Save(
}
if projector.Devices != nil {
if entry, err := projector.Devices.Get(state.DeviceID); err == nil && entry.Snapshot != nil {
runtime.ICCID = strings.TrimSpace(entry.Snapshot.ICCID)
runtime.IMSI = strings.TrimSpace(entry.Snapshot.IMSI)
// An active VoWiFi session belongs to the identity captured when it
// was established. Do not relabel its phone number with a newly
// selected eSIM profile while teardown is still in progress.
if runtime.ICCID == "" {
runtime.ICCID = strings.TrimSpace(entry.Snapshot.ICCID)
}
if runtime.IMSI == "" {
runtime.IMSI = strings.TrimSpace(entry.Snapshot.IMSI)
}
}
}
if runtime.LocalPhone == "" && runtime.ICCID != "" {
+35
View File
@@ -186,6 +186,41 @@ func TestStateProjectorPreservesConcreteDataplaneMode(t *testing.T) {
}
}
func TestStateProjectorDoesNotAttachOldSessionNumberToNewLiveSIM(t *testing.T) {
database := testStore(t)
if err := database.UpsertDevice(context.Background(), store.Device{ID: "ec20", Name: "EC20"}); err != nil {
t.Fatal(err)
}
projector := StateProjector{
Store: database,
Devices: staticDeviceReader{
iccid: "89104100000028106378",
imsi: "310380500712483",
},
}
if err := projector.Save(context.Background(), vowifi.State{
DeviceID: "ec20",
ICCID: "89441000400128014257",
IMSI: "234159608751160",
Phase: vowifi.PhaseStopping,
PhoneNumber: "+447386083638",
PhoneNumberSource: vowifi.PhoneSourcePAssociatedURI,
UpdatedAt: time.Now().UTC(),
}); err != nil {
t.Fatal(err)
}
runtime, err := database.VoWiFiRuntime(context.Background(), "ec20")
if err != nil {
t.Fatal(err)
}
if runtime.ICCID != "89441000400128014257" || runtime.IMSI != "234159608751160" {
t.Fatalf("runtime identity = %q/%q", runtime.ICCID, runtime.IMSI)
}
if runtime.LocalPhone != "+447386083638" {
t.Fatalf("runtime phone = %q", runtime.LocalPhone)
}
}
type staticDeviceReader struct {
iccid string
imsi string
+36 -1
View File
@@ -204,6 +204,8 @@ func (orchestrator *Orchestrator) Enable(ctx context.Context) (State, error) {
}
orchestrator.mutate(func(state *State) {
state.Phase = PhaseSIMReady
state.ICCID = strings.TrimSpace(identity.ICCID)
state.IMSI = strings.TrimSpace(identity.IMSI)
state.SIMReady = true
state.HomeMCC = strings.TrimSpace(identity.HomeMCC)
state.HomeMNC = strings.TrimSpace(identity.HomeMNC)
@@ -404,6 +406,14 @@ func (orchestrator *Orchestrator) Disable(ctx context.Context) (State, error) {
orchestrator.mutate(func(state *State) {
state.Phase = PhaseStopping
state.Enabled = false
// Stop advertising readiness as soon as disable is accepted. Network
// cleanup is best-effort and can take several seconds, but callers must
// not continue to present the old IMS registration as usable.
state.Active = false
state.TunnelReady = false
state.IMSReady = false
state.SMSReady = false
state.IMSRegistration = ""
state.LastReason = "disable_requested"
})
if resources != nil && resources.cancel != nil {
@@ -546,6 +556,21 @@ func (orchestrator *Orchestrator) HangupCall(ctx context.Context, id string) err
return err
}
func (orchestrator *Orchestrator) CallMedia(ctx context.Context, id string) (CallMedia, error) {
orchestrator.mu.Lock()
resources := orchestrator.resources
ready := orchestrator.state.IMSReady
orchestrator.mu.Unlock()
if resources == nil || resources.ims == nil || !ready {
return nil, ErrNotRunning
}
controller, ok := resources.ims.(CallMediaController)
if !ok {
return nil, ErrNotRunning
}
return controller.CallMedia(ctx, id)
}
func (orchestrator *Orchestrator) callAction(
ctx context.Context,
action func(CallController) (Call, error),
@@ -684,7 +709,17 @@ func (orchestrator *Orchestrator) cleanup(resources *runtimeResources) []string
func (orchestrator *Orchestrator) cleanupCall(call func(context.Context) error) error {
ctx, cancel := context.WithTimeout(context.Background(), orchestrator.options.CleanupTimeout)
defer cancel()
return call(ctx)
done := make(chan error, 1)
go func() { done <- call(ctx) }()
select {
case err := <-done:
return err
case <-ctx.Done():
// Providers receive the same deadline and should normally return on it.
// The outer select is a final containment boundary: a defective network
// close must never prevent the following tunnel/radio cleanup.
return ctx.Err()
}
}
func (orchestrator *Orchestrator) cancelCurrentRuntime() {
+17
View File
@@ -18,6 +18,23 @@ func TestClassifyErrorEAPAuthenticationRejected(t *testing.T) {
}
}
func TestCleanupCallContainsProviderThatIgnoresContext(t *testing.T) {
orchestrator := &Orchestrator{options: Options{CleanupTimeout: 20 * time.Millisecond}}
release := make(chan struct{})
defer close(release)
started := time.Now()
err := orchestrator.cleanupCall(func(context.Context) error {
<-release
return nil
})
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("cleanupCall() error = %v", err)
}
if elapsed := time.Since(started); elapsed > 250*time.Millisecond {
t.Fatalf("cleanupCall() took %v", elapsed)
}
}
type fakeEnvironment struct {
mu sync.Mutex
+13
View File
@@ -320,6 +320,19 @@ func (manager *Manager) HangupCall(ctx context.Context, deviceID, id string) err
return item.orchestrator.HangupCall(ctx, id)
}
func (manager *Manager) CallMedia(ctx context.Context, deviceID, id string) (vowifi.CallMedia, error) {
if err := manager.Ensure(ctx, deviceID); err != nil {
return nil, err
}
manager.mu.Lock()
item := manager.entries[deviceID]
manager.mu.Unlock()
if item == nil {
return nil, ErrNotRegistered
}
return item.orchestrator.CallMedia(ctx, id)
}
func (manager *Manager) startOperation(
deviceID string,
coalesceReconnect bool,
+30 -12
View File
@@ -83,6 +83,8 @@ type SecurityAudit struct {
// exists. Neither is proof of IMS registration.
type State struct {
DeviceID string `json:"device_id"`
ICCID string `json:"iccid,omitempty"`
IMSI string `json:"imsi,omitempty"`
Phase Phase `json:"phase"`
Enabled bool `json:"enabled"`
Active bool `json:"active"`
@@ -365,21 +367,23 @@ type SMSSender interface {
SendSMS(context.Context, SMSSubmitRequest) (SMSSubmitResult, error)
}
// Call describes one signalling-only IMS call. VoCat intentionally does not
// open, capture, or relay an RTP media stream for extension call tests.
// Call describes one IMS call and reports whether an RTP media stream is
// available to an authenticated extension.
type Call struct {
ID string `json:"id"`
Number string `json:"number"`
Direction string `json:"direction"`
State string `json:"state"`
StartedAt time.Time `json:"started_at"`
SIPCode int `json:"sip_code,omitempty"`
Reason string `json:"reason,omitempty"`
EndedAt *time.Time `json:"ended_at,omitempty"`
ID string `json:"id"`
Number string `json:"number"`
Direction string `json:"direction"`
State string `json:"state"`
StartedAt time.Time `json:"started_at"`
SIPCode int `json:"sip_code,omitempty"`
Reason string `json:"reason,omitempty"`
MediaReady bool `json:"media_ready,omitempty"`
Codec string `json:"codec,omitempty"`
EndedAt *time.Time `json:"ended_at,omitempty"`
}
// CallController is an optional capability of an IMS session. Implementations
// manage SIP signalling only; audio handling is explicitly outside this API.
// CallController is an optional capability of an IMS session. Media remains a
// separate optional interface so call signalling does not depend on a codec.
type CallController interface {
Calls() []Call
DialCall(context.Context, string) (Call, error)
@@ -387,6 +391,20 @@ type CallController interface {
HangupCall(context.Context, string) error
}
// CallMedia is a narrow, codec-independent bridge between an IMS RTP stream
// and a trusted local extension. Samples are signed 16-bit mono PCM at 8 kHz.
type CallMedia interface {
Codec() string
ReadPCM(context.Context) ([]int16, error)
WritePCM([]int16) error
}
// CallMediaController is optional so signalling-only IMS implementations stay
// compatible. Media is only exposed for a specific active call.
type CallMediaController interface {
CallMedia(context.Context, string) (CallMedia, error)
}
// PhoneStore persists a number only after it was explicitly associated by IMS.
type PhoneStore interface {
SaveAssociatedNumber(context.Context, PhoneRecord) error
+1 -1
View File
@@ -234,7 +234,7 @@ ProtectControlGroups=true
# The web/CLI self-updater verifies a release in this directory and atomically
# renames it over the running binary. Keep the rest of the host read-only.
ReadWritePaths=/opt/vocat/data /opt/vocat/bin
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK AF_PACKET
RestrictRealtime=true
LockPersonality=true
MemoryDenyWriteExecute=true
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""Refresh VoCat's offline PLMN name table from Android's carrier database.
The AOSP carrier ID table is maintained for Android's own carrier recognition.
Only unconstrained MCC/MNC records are used here: MVNO matches that also require
an SPN, IMSI prefix, GID or ICCID prefix must not rename the serving MNO.
"""
from __future__ import annotations
import base64
import json
import re
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
FRONTEND_TABLE = ROOT / "web" / "src" / "lib" / "mccmnc.json"
BACKEND_TABLE = ROOT / "internal" / "device" / "mccmnc.json"
SOURCE_URL = (
"https://android.googlesource.com/platform/packages/providers/"
"TelephonyProvider/+/master/assets/latest_carrier_id/"
"carrier_list.textpb?format=TEXT"
)
# PLMNs for which modem firmware and older public tables commonly expose stale
# or blank names. These are kept as small, explicit corrections on top of the
# global AOSP dataset.
MANUAL_CARRIERS = {
"46000": "China Mobile",
"46002": "China Mobile",
"46004": "China Mobile",
"46007": "China Mobile",
"46008": "China Mobile",
"46020": "China Mobile",
"46001": "China Unicom",
"46006": "China Unicom",
"46009": "China Unicom",
"46010": "China Unicom",
"46003": "China Telecom",
"46005": "China Telecom",
"46011": "China Telecom",
"46012": "China Telecom",
"46015": "China Broadnet",
}
# Some territories share an MCC. Keep the PLMN-level ISO assignment where an
# MCC-only fallback cannot distinguish them.
ISO_OVERRIDES = {
"36251": "an",
"36269": "cw",
"36291": "an",
"64700": "re",
"64702": "re",
"64703": "re",
"64704": "re",
}
def braced_blocks(text: str, marker: str) -> list[str]:
result: list[str] = []
offset = 0
while True:
start = text.find(marker, offset)
if start < 0:
return result
brace = text.find("{", start + len(marker))
if brace < 0:
return result
depth = 0
quoted = False
escaped = False
for index in range(brace, len(text)):
char = text[index]
if quoted:
if escaped:
escaped = False
elif char == "\\":
escaped = True
elif char == '"':
quoted = False
continue
if char == '"':
quoted = True
elif char == "{":
depth += 1
elif char == "}":
depth -= 1
if depth == 0:
result.append(text[brace + 1 : index])
offset = index + 1
break
else:
raise ValueError(f"unterminated {marker} block")
def textproto_string(block: str, field: str) -> str:
match = re.search(rf"^\s*{re.escape(field)}:\s*(\"(?:\\.|[^\"\\])*\")", block, re.M)
return json.loads(match.group(1)) if match else ""
def aosp_carriers(text: str) -> dict[str, str]:
carriers: dict[str, str] = {}
for carrier in braced_blocks(text, "carrier_id"):
name = textproto_string(carrier, "carrier_name").strip()
if not name:
continue
attributes = braced_blocks(carrier, "carrier_attribute")
for attribute in attributes:
fields = set(re.findall(r"^\s*([a-zA-Z0-9_]+)\s*:", attribute, re.M))
if fields - {"mccmnc_tuple"}:
continue
for plmn in re.findall(r'^\s*mccmnc_tuple:\s*"(\d{5,6})"', attribute, re.M):
carriers.setdefault(plmn, name)
# A few legacy entries put MCC/MNC directly in carrier_id. Remove the
# nested attributes before checking so constrained MVNO tuples do not
# leak into the generic map.
direct = carrier
for attribute in attributes:
direct = direct.replace("carrier_attribute {" + attribute + "}", "")
for plmn in re.findall(r'^\s*mccmnc_tuple:\s*"(\d{5,6})"', direct, re.M):
carriers.setdefault(plmn, name)
return carriers
def main() -> None:
with urllib.request.urlopen(SOURCE_URL, timeout=30) as response:
source = base64.b64decode(response.read()).decode("utf-8")
names = aosp_carriers(source)
table = json.loads(FRONTEND_TABLE.read_text(encoding="utf-8"))
countries: dict[str, str] = table["i"]
countries.update({str(mcc): "us" for mcc in range(310, 317)})
countries.update({"406": "in", "461": "cn"})
carriers: dict[str, list[str]] = table["c"]
for plmn, name in names.items():
previous = carriers.get(plmn)
iso = previous[1] if previous and len(previous) > 1 else countries.get(plmn[:3], "")
if plmn[:3] in {str(mcc) for mcc in range(310, 317)}:
iso = "us"
iso = ISO_OVERRIDES.get(plmn, iso)
carriers[plmn] = [name, iso]
for plmn, name in MANUAL_CARRIERS.items():
carriers[plmn] = [name, countries.get(plmn[:3], "")]
version_match = re.search(r"^version:\s*(\d+)", source, re.M)
output = {
"c": dict(sorted(carriers.items())),
"i": dict(sorted(countries.items())),
"t": sorted(set(table["t"])),
"meta": {
"source": "Android Open Source Project carrier_list.textpb",
"source_url": SOURCE_URL.removesuffix("?format=TEXT"),
"aosp_version": version_match.group(1) if version_match else "unknown",
"aosp_generic_records": len(names),
},
}
encoded = json.dumps(output, ensure_ascii=False, separators=(",", ":")) + "\n"
FRONTEND_TABLE.write_text(encoded, encoding="utf-8", newline="\n")
BACKEND_TABLE.write_text(encoded, encoding="utf-8", newline="\n")
print(
f"updated {len(carriers)} PLMN records "
f"({len(names)} generic AOSP records, version {output['meta']['aosp_version']})"
)
if __name__ == "__main__":
main()
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+2
View File
@@ -12,6 +12,7 @@ import LoginPage from "./pages/LoginPage";
import DashboardPage from "./pages/DashboardPage";
import DevicesPage from "./pages/DevicesPage";
import ProxyPage from "./pages/ProxyPage";
import ExportProxyPage from "./pages/ExportProxyPage";
import SmsPage from "./pages/SmsPage";
import LogsPage from "./pages/LogsPage";
import SettingsPage from "./pages/SettingsPage";
@@ -110,6 +111,7 @@ function AppRoot() {
<Route index element={<DashboardPage />} />
<Route path="devices/*" element={<DevicesPage />} />
<Route path="proxy" element={<ProxyPage />} />
<Route path="export-proxy" element={<ExportProxyPage />} />
<Route path="sms" element={<SmsPage />} />
<Route path="extensions/:pluginId/:contributionId" element={<ExtensionPage />} />
<Route path="logs" element={<LogsPage />} />
+4 -10
View File
@@ -1,9 +1,10 @@
import {
Cellular3GRegular, Cellular4GRegular, Cellular5GRegular, CellularData1Regular,
RouterRegular, Wifi1Regular,
Wifi1Regular,
} from "@fluentui/react-icons";
import type { DashboardDevice } from "../types";
import { cx, signalBars, signalColor, isEC20Model } from "../lib/utils";
import { cx, signalBars, signalColor } from "../lib/utils";
import { deviceTypeImage } from "../lib/deviceTypes";
import { StatusDot } from "./ui/StatusDot";
import { useI18n } from "../lib/i18n";
@@ -29,7 +30,6 @@ export function DeviceCard({ device, onOpen }: { device: DashboardDevice; onOpen
const second = words.length > 1 ? words[1] : words[0] || "";
const isLte = second.toUpperCase() === "LTE";
const bars = signalBars(device.signalDbm);
const brandImg = isEC20Model(device.model);
return (
<button
@@ -41,13 +41,7 @@ export function DeviceCard({ device, onOpen }: { device: DashboardDevice; onOpen
<div className="relative z-10 p-6">
<div className="mb-6 flex items-start justify-between">
<div className="flex items-center gap-3">
{brandImg ? (
<img src="/ec20.png" alt="" className="h-10 w-10 flex-shrink-0 object-contain" />
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gray-50 text-indigo-600 shadow-inner dark:bg-white/5 dark:text-indigo-400">
<RouterRegular className="h-5 w-5" />
</div>
)}
<img src={deviceTypeImage(device.deviceType)} alt="" className="h-10 w-10 flex-shrink-0 object-contain" />
<div>
<h3 className="text-base font-bold text-gray-800 dark:text-gray-100">{device.name || device.id}</h3>
<div className="mt-0.5 flex items-center gap-1.5">
@@ -1,6 +1,7 @@
import { Button } from "../ui";
import type { OperatorCandidate } from "./types";
import { useI18n } from "../../lib/i18n";
import { flagEmoji } from "../../lib/carrier";
function ratsText(c: OperatorCandidate): string {
const list = (c.rats || []).filter(Boolean) as string[];
@@ -20,6 +21,7 @@ export function CandidateRow({ candidate, onLock }: { candidate: OperatorCandida
>
<div>
<div className="flex items-center gap-2 font-medium text-gray-900 dark:text-white">
{c.countryCode ? <span aria-hidden="true">{flagEmoji(c.countryCode)}</span> : null}
{c.operatorName || c.shortName || t("未知网络")}{" "}
{c.status === "current" ? (
<span className="rounded-full border border-emerald-200 bg-emerald-100 px-1.5 py-0.5 text-[10px] font-bold text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-500/20 dark:text-emerald-300">
@@ -7,6 +7,7 @@ import { DiscoveredDeviceRow } from "./DiscoveredDeviceRow";
import type { DiscoveredDevice } from "../../types";
import type { AddDeviceForm } from "./types";
import { useI18n } from "../../lib/i18n";
import { DEVICE_TYPES, deviceTypeImage } from "../../lib/deviceTypes";
export interface DeviceAddDialogProps {
open: boolean;
@@ -123,6 +124,22 @@ export function DeviceAddDialog(props: DeviceAddDialogProps) {
</div>
) : null}
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<Field label={t("设备类型")}>
<div className="flex items-center gap-3">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-xl border border-gray-200 bg-white p-1.5">
{addConfig.deviceType ? <img src={deviceTypeImage(addConfig.deviceType)} alt="" className="h-full w-full object-contain" /> : null}
</div>
<Select
value={addConfig.deviceType}
onChange={(v) => set({ deviceType: v as AddDeviceForm["deviceType"] })}
placeholder={t("请选择设备类型")}
size="large"
options={DEVICE_TYPES.map((item) => ({ value: item.value, label: t(item.label) }))}
/>
</div>
</Field>
</div>
<Field label="ID">
<Input value={addConfig.id} onChange={(e) => set({ id: e.target.value })} placeholder={t("例如 ec20_3")} />
</Field>
@@ -5,6 +5,7 @@ import { isQmiControl } from "./shared";
import type { DeviceConfig } from "../../types";
import type { DeviceDetail } from "./types";
import { useI18n } from "../../lib/i18n";
import { DEVICE_TYPES, deviceTypeImage } from "../../lib/deviceTypes";
export interface DeviceConfigTabProps {
editConfig: DeviceConfig | null;
@@ -78,6 +79,18 @@ export function DeviceConfigTab({ editConfig, deviceStatus, saving, deleting, on
<Field label={t("名称")}>
<Input value={editConfig.name} onChange={(e) => onEditConfig({ ...editConfig, name: e.target.value })} placeholder={t("显示名称")} />
</Field>
<Field label={t("设备类型")}>
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border border-gray-200 bg-white p-1.5 dark:border-white/10 dark:bg-white/5">
<img src={deviceTypeImage(editConfig.deviceType)} alt="" className="h-full w-full object-contain" />
</div>
<Select
value={editConfig.deviceType}
onChange={(v) => onEditConfig({ ...editConfig, deviceType: v as DeviceConfig["deviceType"] })}
options={DEVICE_TYPES.map((item) => ({ value: item.value, label: t(item.label) }))}
/>
</div>
</Field>
<Field label={t("IMEI 绑定")}>
<Input value={editConfig.modemImei || ""} disabled placeholder={t("自动识别(添加时绑定)")} />
</Field>
@@ -1,16 +1,16 @@
import { ArrowSyncRegular, PowerRegular, ChatRegular } from "@fluentui/react-icons";
import { Button } from "../ui";
import { Button, Switch } from "../ui";
import type { DeviceDetail } from "./types";
import { useI18n } from "../../lib/i18n";
import { isEC20Model } from "../../lib/utils";
import { deviceTypeImage } from "../../lib/deviceTypes";
export interface DeviceDetailHeaderProps {
device: DeviceDetail;
rotating: boolean;
dataToggling: boolean;
rebooting: boolean;
reconnectingVoWiFi: boolean;
onCopyText: (text: string) => void;
onRotateIp: () => void;
onToggleRoamingData: (enabled: boolean) => void;
onReconnectVowifi: () => void;
onRebootModem: () => void;
onOpenSms: () => void;
@@ -19,23 +19,13 @@ export interface DeviceDetailHeaderProps {
export function DeviceDetailHeader(props: DeviceDetailHeaderProps) {
const { t } = useI18n();
const { device } = props;
const vowifiInUse = device.vowifiEnabled || device.vowifiActive || device.vowifiRuntime?.smsReady;
const brandImg = isEC20Model(device.modem?.model);
const vowifiInUse = !!device.vowifiEnabled;
return (
<div className="ui-card p-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0">
<div className="flex items-center gap-3">
{brandImg ? (
<img src="/ec20.png" alt="" className="h-11 w-11 flex-shrink-0 object-contain" />
) : (
<div className="device-header-brand-icon">
<svg viewBox="0 0 1025 1024" width="200" height="200" className="device-header-brand-svg" aria-hidden="true">
<path d="M512.473172 1023.995242A511.814852 511.814852 0 0 1 313.545134 40.351073a512.244696 512.244696 0 0 1 398.855715 943.658633 508.815937 508.815937 0 0 1-199.927677 39.985536z m0-943.658634C274.559237 80.336608 80.629391 274.266455 80.629391 512.18039s193.929846 431.843781 431.843781 431.843781 431.843781-193.929846 431.843781-431.843781S751.386745 80.336608 512.473172 80.336608z" />
<path d="M506.475342 716.10662a39.985535 39.985535 0 0 1-39.985536-39.985535v-76.972156c0-79.971071 64.976495-144.947566 144.947566-144.947565a77.971794 77.971794 0 0 0 0-155.943588H445.4974a56.979388 56.979388 0 0 0-56.979387 56.979388 39.985535 39.985535 0 0 1-79.971071 0c0-74.972879 60.977941-136.950458 136.950458-136.950459h164.940333c86.968539 0 157.942864 70.974325 157.942865 157.942865s-69.974687 157.942864-157.942865 157.942864a64.976495 64.976495 0 0 0-64.976494 64.976495v76.972156a39.985535 39.985535 0 0 1-38.985897 39.985535zM505.475703 742.097218a48.982281 48.982281 0 1 0 48.982281 48.982281 48.982281 48.982281 0 0 0-48.982281-48.982281z" />
</svg>
</div>
)}
<img src={deviceTypeImage(device.deviceType)} alt="" className="h-11 w-11 flex-shrink-0 object-contain" />
<div className="min-w-0">
<div className="truncate text-xl font-extrabold text-gray-900 dark:text-white">{device.name || device.id}</div>
<div className="mt-0.5 truncate text-xs text-gray-500 dark:text-gray-400">
@@ -51,17 +41,22 @@ export function DeviceDetailHeader(props: DeviceDetailHeaderProps) {
<Button loading={props.reconnectingVoWiFi} onClick={props.onReconnectVowifi} className="ui-glass-border !border-0" icon={<ArrowSyncRegular />}>
{t("重连 VoWiFi")}
</Button>
) : (
<Button
loading={props.rotating}
disabled={!device?.networkConnected}
onClick={props.onRotateIp}
className="ui-glass-border !border-0"
icon={<ArrowSyncRegular />}
>
{t("切换 IP")}
</Button>
)}
) : device.developerEnabled ? (
<div
className="ui-glass-border flex h-8 items-center gap-2 rounded-lg px-3 text-sm text-gray-700 dark:text-gray-200"
title={t("蜂窝数据仅进入 Export Proxy 的受保护路由,不会成为主机默认出口")}
>
<span>{t("漫游数据")}</span>
<Switch
checked={!!device.networkEnabled}
loading={props.dataToggling}
disabled={props.dataToggling || !device.interface}
onChange={props.onToggleRoamingData}
size="small"
ariaLabel={t("漫游数据")}
/>
</div>
) : null}
<Button loading={props.rebooting} onClick={props.onRebootModem} className="ui-glass-border !border-0 hover:!text-red-600" icon={<PowerRegular />}>
{t("重启模组")}
</Button>
@@ -2,6 +2,7 @@ import type { DeviceListItem } from "../../types";
import { cx } from "../../lib/utils";
import { Tag, StatusDot } from "../ui";
import { deviceStatusMeta } from "./shared";
import { deviceTypeImage } from "../../lib/deviceTypes";
export interface DeviceListItemCardProps {
device: DeviceListItem;
@@ -25,7 +26,8 @@ export function DeviceListItemCard({ device, selected, statusText, onSelect }: D
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<img src={deviceTypeImage(device.deviceType)} alt="" className="h-10 w-10 shrink-0 object-contain" />
<div className="min-w-0 flex-1">
<div className="truncate font-bold text-gray-800 dark:text-gray-100">{device.name || device.id}</div>
<div className="mt-0.5 truncate text-xs text-gray-500">
{device.id} · {device.interface || "--"}
@@ -3,6 +3,7 @@ import { OverviewNetworkCard } from "./OverviewNetworkCard";
import { OverviewVowifiCard } from "./OverviewVowifiCard";
import { OverviewSimPanel } from "./OverviewSimPanel";
import { OverviewNetworkPanel } from "./OverviewNetworkPanel";
import { OverviewTrafficChart } from "./OverviewTrafficChart";
import { OperatorSelectionDialog } from "./OperatorSelectionDialog";
import type { DeviceDetail } from "./types";
import { useI18n } from "../../lib/i18n";
@@ -24,28 +25,31 @@ export function DeviceOverviewTab(props: DeviceOverviewTabProps) {
const [operatorOpen, setOperatorOpen] = useState(false);
const { device } = props;
return (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<div className="ui-panel-muted p-4">
<div className="mb-3 text-xs font-bold uppercase tracking-wider text-gray-500">{t("运行状态")}</div>
{device?.vowifiEnabled ? (
<OverviewVowifiCard device={device} />
) : (
<OverviewNetworkCard device={device} onOpenOperatorSelection={() => setOperatorOpen(true)} />
)}
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<div className="ui-panel-muted p-4">
<div className="mb-3 text-xs font-bold uppercase tracking-wider text-gray-500">{t("运行状态")}</div>
{device?.vowifiEnabled ? (
<OverviewVowifiCard device={device} />
) : (
<OverviewNetworkCard device={device} onOpenOperatorSelection={() => setOperatorOpen(true)} />
)}
</div>
<OverviewSimPanel
device={device}
simOperatorDisplay={props.simOperatorDisplay}
e911Starting={props.e911Starting}
onSetupE911={props.onSetupE911}
/>
<OverviewNetworkPanel
device={device}
trafficMinuteRx={props.trafficMinuteRx}
trafficMinuteTx={props.trafficMinuteTx}
trafficSpeedRx={props.trafficSpeedRx}
trafficSpeedTx={props.trafficSpeedTx}
/>
</div>
<OverviewSimPanel
device={device}
simOperatorDisplay={props.simOperatorDisplay}
e911Starting={props.e911Starting}
onSetupE911={props.onSetupE911}
/>
<OverviewNetworkPanel
device={device}
trafficMinuteRx={props.trafficMinuteRx}
trafficMinuteTx={props.trafficMinuteTx}
trafficSpeedRx={props.trafficSpeedRx}
trafficSpeedTx={props.trafficSpeedTx}
/>
{device.developerEnabled && device.networkEnabled && device.id ? <OverviewTrafficChart deviceId={device.id} /> : null}
{device?.id ? (
<OperatorSelectionDialog
open={operatorOpen}
@@ -124,6 +124,26 @@ export function OperatorSelectionDialog({ open, deviceId, scanBlockedReason = ""
}
}
async function reRegister() {
const controller = new AbortController();
registerAbortRef.current = controller;
setRegistering(t("正在按当前选网配置重新驻网,请稍候..."));
setBusy(true);
try {
await api(`/devices/${deviceId}/operator_selection/reregister`, { method: "POST", signal: controller.signal });
message.success(t("已重新发起驻网"));
onUpdated();
await loadCurrent();
} catch (e) {
if (controller.signal.aborted) message.info(t("已取消"));
else message.error(apiMessage(e) || t("重新驻网失败"));
} finally {
setRegistering(null);
registerAbortRef.current = null;
setBusy(false);
}
}
async function lock(c: OperatorCandidate) {
const controller = new AbortController();
registerAbortRef.current = controller;
@@ -203,10 +223,13 @@ export function OperatorSelectionDialog({ open, deviceId, scanBlockedReason = ""
</div>
) : null}
</div>
<div className="mb-4 flex gap-3">
<div className="mb-4 grid grid-cols-1 gap-3 sm:grid-cols-3">
<Button variant="primary" plain onClick={startStream} loading={scanning} disabled={busy || !!scanBlockedReason} className="flex-1">
{scanning ? t("扫描中...") : t("扫描可用网络")}
</Button>
<Button onClick={reRegister} disabled={busy} className="flex-1">
{t("重新驻网")}
</Button>
<Button onClick={restoreAuto} disabled={busy || current?.mode === "automatic"} className="flex-1">
{t("恢复自动选网")}
</Button>
@@ -239,7 +262,7 @@ export function OperatorSelectionDialog({ open, deviceId, scanBlockedReason = ""
</div>
) : null}
{candidates.length > 0 ? (
<div className={cx("max-h-[300px] divide-y divide-gray-200 overflow-y-auto rounded-lg border border-gray-200 dark:divide-white/10 dark:border-white/10", (!!registering || busy) && "pointer-events-none opacity-60")}>
<div className={cx("max-h-[min(55vh,440px)] divide-y divide-gray-200 overflow-y-auto rounded-lg border border-gray-200 dark:divide-white/10 dark:border-white/10", (!!registering || busy) && "pointer-events-none opacity-60")}>
{candidates.map((c) => (
<CandidateRow key={`${c.plmn}-${ratsText(c)}`} candidate={c} onLock={lock} />
))}
@@ -5,6 +5,7 @@ import { FieldRow } from "./FieldRow";
import { isDeviceOnline, isRegistered, isRecoveringPhase, lifecycleLabel, signalLevel, signalTone } from "./shared";
import type { DeviceDetail } from "./types";
import { useI18n } from "../../lib/i18n";
import { flagEmoji } from "../../lib/carrier";
const BAR_HEIGHTS = ["h-[28%]", "h-[46%]", "h-[64%]", "h-[82%]", "h-full"];
const TEXT_TONE = {
@@ -25,7 +26,9 @@ export function OverviewNetworkCard({ device, onOpenOperatorSelection }: { devic
const modem = device.modem;
const online = isDeviceOnline(device);
const cellularRegistered = isRegistered(device);
const vowifiRegistered = !!(device.vowifiActive || device.vowifiRuntime?.smsReady);
// A persisted runtime may briefly describe the old session while disable is
// being cleaned up. Desired policy is authoritative for the overview badge.
const vowifiRegistered = !!device.vowifiEnabled && !!(device.vowifiActive || device.vowifiRuntime?.smsReady);
const registered = cellularRegistered || vowifiRegistered;
const radioOffForVowifi = vowifiRegistered && (modem?.operatingMode === 0 || modem?.operatingMode === 4 || device.flightMode);
const tone = isRecoveringPhase(device.lifecyclePhase) ? "warning" : online ? (registered ? "success" : "warning") : "danger";
@@ -45,7 +48,16 @@ export function OverviewNetworkCard({ device, onOpenOperatorSelection }: { devic
const level = signalLevel(modem?.signalDbm);
const sigTone = signalTone(modem?.signalDbm);
const netMode = [modem?.networkDuplex, modem?.networkMode].filter(Boolean).join(" ");
const netMode = [modem?.networkDuplex, modem?.networkMode].filter(Boolean).join(" ");
const cellularRegistrationText = modem?.regStatus === 5
? t("已驻网(漫游)")
: modem?.regStatus === 1
? t("已驻网")
: device.registrationStateLabel === "searching"
? t("正在搜索网络")
: device.registrationStateLabel === "denied"
? t("驻网被拒")
: t("未驻网");
return (
<>
@@ -71,7 +83,7 @@ export function OverviewNetworkCard({ device, onOpenOperatorSelection }: { devic
<>{t("WiFi Calling 已注册")}</>
) : registered ? (
<>
{modem?.operator || "--"}{" "}
{modem?.operatorCountryCode ? `${flagEmoji(modem.operatorCountryCode)} ` : ""}{modem?.operator || "--"}{" "}
{modem?.networkMode ? <span className="opacity-70">· {netMode}</span> : null}
</>
) : (
@@ -116,7 +128,7 @@ export function OverviewNetworkCard({ device, onOpenOperatorSelection }: { devic
<FieldRow label={t("网络模式")} value={netMode || "--"} monospace />
<FieldRow label={t("频段")} value={modem?.radioBand || "--"} monospace />
<FieldRow label={t("信道")} value={modem?.radioChannel ? String(modem.radioChannel) : "--"} monospace />
<FieldRow label={t("注册状态")} value={vowifiRegistered ? t("WiFi Calling 已注册") : (modem?.regStatusText || "--")} monospace />
<FieldRow label={t("注册状态")} value={vowifiRegistered ? t("WiFi Calling 已注册") : cellularRegistrationText} monospace />
</div>
</>
);
@@ -1,6 +1,19 @@
import { useEffect, useState } from "react";
import { FieldRow } from "./FieldRow";
import type { DeviceDetail } from "./types";
import { useI18n } from "../../lib/i18n";
import { api, apiMessage } from "../../api";
import { Button, message } from "../ui";
import { flagEmoji } from "../../lib/carrier";
interface PublicIPInfo {
detected?: boolean;
ip: string;
countryCode: string;
region?: string;
city?: string;
organization?: string;
}
export interface OverviewNetworkPanelProps {
device: DeviceDetail;
@@ -11,32 +24,90 @@ export interface OverviewNetworkPanelProps {
}
export function OverviewNetworkPanel({ device, trafficMinuteRx, trafficMinuteTx, trafficSpeedRx, trafficSpeedTx }: OverviewNetworkPanelProps) {
const { t } = useI18n();
const { t, lang } = useI18n();
const developerActive = !!device.developerEnabled;
const [publicIP, setPublicIP] = useState<PublicIPInfo | null>(null);
const [detectingIP, setDetectingIP] = useState(false);
const traffic = device.traffic || {};
const metaStatus = device.trafficMeta?.status;
const sampleNote = metaStatus === "waiting_sample" ? t("等待采样") : metaStatus === "stale" ? t("采样中断") : "";
const off = t("数据未开启");
const off = !device.networkEnabled;
const minuteRx = trafficMinuteRx || sampleNote || traffic.rx;
const minuteTx = trafficMinuteTx || sampleNote || traffic.tx;
const speedRx = trafficSpeedRx || sampleNote || traffic.rate || "--";
const speedTx = trafficSpeedTx || sampleNote || "--";
const speedTx = trafficSpeedTx || sampleNote || traffic.rateTx || "--";
useEffect(() => {
let cancelled = false;
setPublicIP(null);
if (!developerActive) return () => { cancelled = true; };
api<PublicIPInfo>(`/devices/${encodeURIComponent(device.id)}/network/public-ip`)
.then((info) => {
if (!cancelled) setPublicIP(info.detected ? info : null);
})
.catch(() => {
if (!cancelled) setPublicIP(null);
});
return () => { cancelled = true; };
}, [developerActive, device.id, device.interface, device.networkEnabled, device.modem?.iccid]);
async function detectPublicIP() {
setDetectingIP(true);
try {
const info = await api<PublicIPInfo>(`/devices/${encodeURIComponent(device.id)}/network/public-ip`, { method: "POST", body: {} });
setPublicIP(info);
} catch (error) {
message.error(apiMessage(error) || t("公网 IP 检测失败"));
} finally {
setDetectingIP(false);
}
}
let countryName = publicIP?.countryCode || "";
if (publicIP?.countryCode) {
try {
countryName = new Intl.DisplayNames([lang === "zh" ? "zh-CN" : "en"], { type: "region" }).of(publicIP.countryCode) || publicIP.countryCode;
} catch {
countryName = publicIP.countryCode;
}
}
const location = publicIP
? [countryName, publicIP.region, publicIP.city].filter((value, index, values) => value && values.indexOf(value) === index).join(" · ")
: "";
if (!developerActive) {
return (
<div className="ui-panel-muted p-4">
<div className="text-xs font-bold uppercase tracking-wider text-gray-500">{t("网络")}</div>
</div>
);
}
return (
<div className="ui-panel-muted p-4">
<div className="mb-2 text-xs font-bold uppercase tracking-wider text-gray-500">{t("网络")}</div>
{off ? (
<div className="flex items-center justify-center p-6 text-sm text-gray-400">{off}</div>
) : (
<div className="space-y-1.5 text-sm text-gray-700 dark:text-gray-200">
<div className="space-y-1.5 text-sm text-gray-700 dark:text-gray-200">
<div className="flex w-full min-w-0 items-center justify-between gap-3">
<span className="shrink-0 whitespace-nowrap text-gray-500">{t("公网 IP")}</span>
<div className="flex min-w-0 items-center justify-end gap-2">
<span className="truncate font-mono" title={publicIP?.ip || ""}>{publicIP?.ip || "-"}</span>
<Button size="small" loading={detectingIP} disabled={off} onClick={() => void detectPublicIP()}>{t("检测")}</Button>
</div>
</div>
<FieldRow label={t("国家/地区")} value={publicIP ? `${flagEmoji(publicIP.countryCode)} ${location}`.trim() : "-"} />
{off ? (
<div className="flex items-center justify-center p-6 text-sm text-gray-400">{t("数据未开启")}</div>
) : (
<>
<FieldRow label={t("内网 IPv4")} value={device.privateIp} monospace copyable />
<FieldRow label={t("内网 IPv6")} value={device.privateIpv6} monospace copyable />
<FieldRow label={t("近1分钟上传")} value={minuteTx} monospace />
<FieldRow label={t("近1分钟下载")} value={minuteRx} monospace />
<FieldRow label={t("实时下载速率")} value={speedRx} monospace />
<FieldRow label={t("实时上传速率")} value={speedTx} monospace />
</div>
)}
</>
)}
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More