mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-13 03:13:43 +08:00
feat: expand device networking and management
This commit is contained in:
@@ -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
|
||||
@@ -15,6 +15,9 @@
|
||||
vc.jar
|
||||
*.cookies
|
||||
*.session
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# ---- Frontend build products ----
|
||||
web/dist/
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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,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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ const SecretMask = "********"
|
||||
type Device struct {
|
||||
ID string
|
||||
Name string
|
||||
DeviceType string
|
||||
Interface string
|
||||
ControlDevice string
|
||||
ATPort string
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 = ©
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 |
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -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 />} />
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user