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
|
vc.jar
|
||||||
*.cookies
|
*.cookies
|
||||||
*.session
|
*.session
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# ---- Frontend build products ----
|
# ---- Frontend build products ----
|
||||||
web/dist/
|
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
|
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
|
USER vocat
|
||||||
VOLUME ["/opt/vocat/data"]
|
VOLUME ["/opt/vocat/data"]
|
||||||
EXPOSE 7575
|
EXPOSE 7575
|
||||||
|
|||||||
+8
-16
@@ -6,11 +6,11 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"vocat/internal/config"
|
"vocat/internal/config"
|
||||||
|
"vocat/internal/developer"
|
||||||
"vocat/internal/store"
|
"vocat/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ import (
|
|||||||
// plugin/extension system. When absent the developer mode defaults to off, so
|
// plugin/extension system. When absent the developer mode defaults to off, so
|
||||||
// a fresh install exposes no plugin surface until an operator explicitly turns
|
// a fresh install exposes no plugin surface until an operator explicitly turns
|
||||||
// it on with `vocat develop on` and restarts the service.
|
// 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
|
// runDevelop handles the hidden `vocat develop on|off` subcommand. It is
|
||||||
// intentionally excluded from printUsage and the interactive menu: the plugin
|
// intentionally excluded from printUsage and the interactive menu: the plugin
|
||||||
@@ -65,6 +65,11 @@ func runDevelop(args []string, logger *slog.Logger) error {
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
return fmt.Errorf("persist developer flag: %w", err)
|
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 {
|
if enabled {
|
||||||
fmt.Printf("开发者模式已开启。重启 vocat 服务后插件功能生效。\n数据库:%s\n", cfg.DatabasePath)
|
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
|
// or an unparseable value resolves to false — the system defaults closed, so
|
||||||
// any read failure keeps plugins off rather than exposing them by accident.
|
// any read failure keeps plugins off rather than exposing them by accident.
|
||||||
func isDeveloperEnabled(ctx context.Context, database *store.Store) bool {
|
func isDeveloperEnabled(ctx context.Context, database *store.Store) bool {
|
||||||
setting, err := database.AppSetting(ctx, developerEnabledSettingKey)
|
return developer.Enabled(ctx, database)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
+246
-18
@@ -2,10 +2,12 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
@@ -18,8 +20,11 @@ import (
|
|||||||
|
|
||||||
"vocat/internal/auth"
|
"vocat/internal/auth"
|
||||||
"vocat/internal/config"
|
"vocat/internal/config"
|
||||||
|
"vocat/internal/developer"
|
||||||
"vocat/internal/device"
|
"vocat/internal/device"
|
||||||
|
"vocat/internal/exportproxy"
|
||||||
"vocat/internal/extensions"
|
"vocat/internal/extensions"
|
||||||
|
"vocat/internal/httpsmode"
|
||||||
"vocat/internal/loghub"
|
"vocat/internal/loghub"
|
||||||
"vocat/internal/server"
|
"vocat/internal/server"
|
||||||
"vocat/internal/store"
|
"vocat/internal/store"
|
||||||
@@ -119,16 +124,41 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer database.Close()
|
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.
|
// 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
|
// When off (the default) the manager is never created and the server receives
|
||||||
// a nil Extensions handle, so every /extensions* and /plugin-assets/* route
|
// a nil Extensions handle, so every /extensions* and /plugin-assets/* route
|
||||||
// returns 503/404 and the SPA hides the plugin surface.
|
// returns 503/404 and the SPA hides the plugin surface.
|
||||||
developerEnabled := isDeveloperEnabled(startupContext, database)
|
|
||||||
var extensionManager *extensions.Manager
|
var extensionManager *extensions.Manager
|
||||||
|
var exportProxyManager *exportproxy.Manager
|
||||||
if developerEnabled {
|
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(
|
extensionManager, err = extensions.NewManager(
|
||||||
filepath.Join(filepath.Dir(cfg.DatabasePath), "plugins"),
|
pluginRoot,
|
||||||
logger,
|
logger,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -163,6 +193,7 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
|||||||
if err := provisionDiscoveredDevices(startupContext, database, deviceManager); err != nil {
|
if err := provisionDiscoveredDevices(startupContext, database, deviceManager); err != nil {
|
||||||
logger.Warn("automatic first-run device provisioning failed", "error", err)
|
logger.Warn("automatic first-run device provisioning failed", "error", err)
|
||||||
}
|
}
|
||||||
|
restoreDefaultCellularRadios(startupContext, logger, database, deviceManager)
|
||||||
defer func() {
|
defer func() {
|
||||||
stopContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
stopContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -173,7 +204,14 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
|||||||
pollContext, cancelPolling := context.WithCancel(context.Background())
|
pollContext, cancelPolling := context.WithCancel(context.Background())
|
||||||
defer cancelPolling()
|
defer cancelPolling()
|
||||||
go pollDeviceSnapshots(pollContext, logger, database, deviceManager)
|
go pollDeviceSnapshots(pollContext, logger, database, deviceManager)
|
||||||
|
go restoreConfiguredCellularData(pollContext, logger, database, deviceManager)
|
||||||
|
go collectCellularTraffic(pollContext, logger, database)
|
||||||
go persistLogsToStore(pollContext, logger, logs, 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(
|
vowifiManager, err := configureVoWiFiRuntime(
|
||||||
startupContext,
|
startupContext,
|
||||||
@@ -203,9 +241,11 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
|||||||
SecureCookies: cfg.SecureCookies,
|
SecureCookies: cfg.SecureCookies,
|
||||||
MaxRequestBodyBytes: cfg.MaxRequestBodyBytes,
|
MaxRequestBodyBytes: cfg.MaxRequestBodyBytes,
|
||||||
Extensions: extensionManager,
|
Extensions: extensionManager,
|
||||||
|
ExportProxy: exportProxyManager,
|
||||||
DeveloperEnabled: developerEnabled,
|
DeveloperEnabled: developerEnabled,
|
||||||
UpdateRepository: strings.TrimSpace(os.Getenv("VOCAT_REPO")),
|
UpdateRepository: strings.TrimSpace(os.Getenv("VOCAT_REPO")),
|
||||||
UpdateToken: strings.TrimSpace(os.Getenv("GITHUB_TOKEN")),
|
UpdateToken: strings.TrimSpace(os.Getenv("GITHUB_TOKEN")),
|
||||||
|
HTTPS: httpsManager,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -215,15 +255,35 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
|||||||
handler.StartTelegramBot(pollContext)
|
handler.StartTelegramBot(pollContext)
|
||||||
handler.StartSMSNotificationDispatchers(pollContext)
|
handler.StartSMSNotificationDispatchers(pollContext)
|
||||||
|
|
||||||
httpServer := &http.Server{
|
serverConfig := func(handler http.Handler) *http.Server {
|
||||||
Addr: cfg.Address,
|
return &http.Server{
|
||||||
Handler: handler,
|
Addr: cfg.Address,
|
||||||
ReadHeaderTimeout: 5 * time.Second,
|
Handler: handler,
|
||||||
ReadTimeout: 15 * time.Second,
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
WriteTimeout: 30 * time.Second,
|
ReadTimeout: 15 * time.Second,
|
||||||
IdleTimeout: 90 * time.Second,
|
WriteTimeout: 30 * time.Second,
|
||||||
MaxHeaderBytes: 1 << 20,
|
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(
|
signalContext, stopSignals := signal.NotifyContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
@@ -232,10 +292,17 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
|||||||
)
|
)
|
||||||
defer stopSignals()
|
defer stopSignals()
|
||||||
|
|
||||||
serverError := make(chan error, 1)
|
serverError := make(chan error, 2)
|
||||||
go func() {
|
go func() {
|
||||||
logger.Info("HTTP server listening", "address", cfg.Address)
|
logger.Info("HTTP server listening", "address", cfg.Address, "self_signed_https", httpsManager.Enabled())
|
||||||
err := httpServer.ListenAndServe()
|
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) {
|
if errors.Is(err, http.ErrServerClosed) {
|
||||||
err = nil
|
err = nil
|
||||||
}
|
}
|
||||||
@@ -244,6 +311,7 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
|||||||
|
|
||||||
select {
|
select {
|
||||||
case err := <-serverError:
|
case err := <-serverError:
|
||||||
|
_ = protocolMux.Close()
|
||||||
return err
|
return err
|
||||||
case <-signalContext.Done():
|
case <-signalContext.Done():
|
||||||
logger.Info("shutdown signal received")
|
logger.Info("shutdown signal received")
|
||||||
@@ -258,11 +326,161 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
|||||||
cfg.ShutdownTimeout,
|
cfg.ShutdownTimeout,
|
||||||
)
|
)
|
||||||
defer cancelShutdown()
|
defer cancelShutdown()
|
||||||
if err := httpServer.Shutdown(shutdownContext); err != nil {
|
shutdownErrors := make(chan error, 2)
|
||||||
_ = httpServer.Close()
|
go func() { shutdownErrors <- plainServer.Shutdown(shutdownContext) }()
|
||||||
return fmt.Errorf("graceful HTTP shutdown: %w", err)
|
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(
|
func configureVoWiFiRuntime(
|
||||||
@@ -355,8 +573,18 @@ func newVoWiFiOrchestrator(
|
|||||||
if message.Concat != nil && message.Concat.Total > 0 {
|
if message.Concat != nil && message.Concat.Total > 0 {
|
||||||
partsTotal = message.Concat.Total
|
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{
|
_, saveErr := database.SaveSMSMessage(ctx, store.SMSMessage{
|
||||||
MessageID: message.MessageID,
|
MessageID: messageID,
|
||||||
DeviceID: message.DeviceID,
|
DeviceID: message.DeviceID,
|
||||||
ModemIMEI: deviceConfig.ModemIMEI,
|
ModemIMEI: deviceConfig.ModemIMEI,
|
||||||
IMSI: message.IMSI,
|
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
|
ProtectKernelTunables=true
|
||||||
ProtectControlGroups=true
|
ProtectControlGroups=true
|
||||||
ReadWritePaths=/opt/vocat/data
|
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
|
RestrictRealtime=true
|
||||||
LockPersonality=true
|
LockPersonality=true
|
||||||
MemoryDenyWriteExecute=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
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/coder/websocket v1.8.15
|
||||||
go.bug.st/serial v1.6.4
|
go.bug.st/serial v1.6.4
|
||||||
golang.org/x/crypto v0.41.0
|
golang.org/x/crypto v0.41.0
|
||||||
golang.org/x/sys v0.47.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 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0=
|
||||||
github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY=
|
github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
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
|
return NetworkResult{}, err
|
||||||
}
|
}
|
||||||
apn := strings.TrimSpace(request.APN)
|
apn := strings.TrimSpace(request.APN)
|
||||||
if request.Enabled && !apnPattern.MatchString(apn) {
|
if request.Enabled && apn != "" && !apnPattern.MatchString(apn) {
|
||||||
return NetworkResult{}, ErrInvalidNetworkAPN
|
return NetworkResult{}, ErrInvalidNetworkAPN
|
||||||
}
|
}
|
||||||
ipVersion := normalizeIPVersion(request.IPVersion)
|
ipVersion := normalizeIPVersion(request.IPVersion)
|
||||||
@@ -225,7 +225,7 @@ func (manager *Manager) SetOperatorSelection(
|
|||||||
accessTechnologyValue *int,
|
accessTechnologyValue *int,
|
||||||
) (OperatorSelection, error) {
|
) (OperatorSelection, error) {
|
||||||
result := OperatorSelection{Mode: 0}
|
result := OperatorSelection{Mode: 0}
|
||||||
command := "AT+COPS=0"
|
command := ""
|
||||||
if !automatic {
|
if !automatic {
|
||||||
plmn = strings.TrimSpace(plmn)
|
plmn = strings.TrimSpace(plmn)
|
||||||
if len(plmn) < 5 || len(plmn) > 6 || strings.IndexFunc(plmn, func(r rune) bool { return r < '0' || r > '9' }) >= 0 {
|
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.
|
// the lock is not aborted while registration is still in progress.
|
||||||
lockCtx, cancel := manager.withTimeout(ctx, manager.scanTimeout)
|
lockCtx, cancel := manager.withTimeout(ctx, manager.scanTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if _, err := client.Execute(lockCtx, command); err != nil {
|
if automatic {
|
||||||
manager.setResult(id, state, nil, errors.New("operator selection command failed"))
|
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
|
return OperatorSelection{}, err
|
||||||
}
|
}
|
||||||
if !automatic {
|
result = actual
|
||||||
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
|
|
||||||
}
|
|
||||||
manager.setResult(id, state, nil, nil)
|
manager.setResult(id, state, nil, nil)
|
||||||
return result, 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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"hash/fnv"
|
||||||
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -31,7 +35,11 @@ func setQMINetwork(
|
|||||||
profilePath := profile.Name()
|
profilePath := profile.Name()
|
||||||
defer os.Remove(profilePath)
|
defer os.Remove(profilePath)
|
||||||
ipType := map[string]string{"IP": "4", "IPV6": "6", "IPV4V6": "4"}[ipVersion]
|
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()
|
_ = profile.Close()
|
||||||
return NetworkResult{}, fmt.Errorf("write temporary QMI profile: %w", err)
|
return NetworkResult{}, fmt.Errorf("write temporary QMI profile: %w", err)
|
||||||
}
|
}
|
||||||
@@ -54,36 +62,42 @@ func setQMINetwork(
|
|||||||
lowerDetail := strings.ToLower(detail)
|
lowerDetail := strings.ToLower(detail)
|
||||||
idempotentStop := !enabled && (strings.Contains(lowerDetail, "already stopped") ||
|
idempotentStop := !enabled && (strings.Contains(lowerDetail, "already stopped") ||
|
||||||
strings.Contains(lowerDetail, "not started") || strings.Contains(lowerDetail, "no network"))
|
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)
|
return NetworkResult{}, fmt.Errorf("qmi-network %s failed: %w: %s", action, err, detail)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ipCommand, lookErr := exec.LookPath("ip"); lookErr == nil {
|
ipCommand, lookErr := exec.LookPath("ip")
|
||||||
linkAction := "down"
|
if lookErr != nil {
|
||||||
if enabled {
|
return NetworkResult{}, fmt.Errorf("%w: install iproute2 to control %s", ErrDataBackendUnavailable, candidate.NetworkInterface)
|
||||||
linkAction = "up"
|
}
|
||||||
}
|
linkAction := "down"
|
||||||
linkOutput, linkErr := exec.CommandContext(ctx, ipCommand, "link", "set", "dev", candidate.NetworkInterface, linkAction).CombinedOutput()
|
if enabled {
|
||||||
if linkErr != nil {
|
linkAction = "up"
|
||||||
return NetworkResult{}, fmt.Errorf("set %s %s: %w: %s", candidate.NetworkInterface, linkAction, linkErr, strings.TrimSpace(string(linkOutput)))
|
}
|
||||||
}
|
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 enabled {
|
||||||
if busybox, lookErr := exec.LookPath("busybox"); lookErr == nil {
|
busybox, busyboxErr := exec.LookPath("busybox")
|
||||||
dhcpOutput, dhcpErr := exec.CommandContext(ctx, busybox, "udhcpc", "-q", "-n", "-t", "5", "-T", "3", "-i", candidate.NetworkInterface).CombinedOutput()
|
if busyboxErr != nil {
|
||||||
if dhcpErr != nil {
|
return NetworkResult{}, fmt.Errorf("%w: busybox udhcpc is required for %s", ErrDataBackendUnavailable, candidate.NetworkInterface)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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{
|
return NetworkResult{
|
||||||
Enabled: enabled,
|
Enabled: enabled,
|
||||||
@@ -96,4 +110,173 @@ func setQMINetwork(
|
|||||||
}, nil
|
}, 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
|
const managerCommandCleanupTimeout = 15 * time.Second
|
||||||
|
|||||||
@@ -74,7 +74,10 @@ func TestOperatorSelectionManualAndAutomatic(t *testing.T) {
|
|||||||
client := &transcriptClient{steps: []clientStep{
|
client := &transcriptClient{steps: []clientStep{
|
||||||
{command: `AT+COPS=1,2,"46000",7`, response: okResponse()},
|
{command: `AT+COPS=1,2,"46000",7`, response: okResponse()},
|
||||||
{command: "AT+COPS?", response: okResponse(`+COPS: 1,2,"46000",7`)},
|
{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=0", response: okResponse()},
|
||||||
|
{command: "AT+COPS?", response: okResponse(`+COPS: 0,2,"46001",7`)},
|
||||||
}}
|
}}
|
||||||
manager, id := newStartedTestManager(t, client)
|
manager, id := newStartedTestManager(t, client)
|
||||||
act := 7
|
act := 7
|
||||||
@@ -89,7 +92,7 @@ func TestOperatorSelectionManualAndAutomatic(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("automatic selection: %v", err)
|
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)
|
t.Fatalf("automatic selection = %#v", selection)
|
||||||
}
|
}
|
||||||
client.assertDone(t)
|
client.assertDone(t)
|
||||||
@@ -99,6 +102,10 @@ func TestOperatorSelectionRejectsAutomaticFallbackAsSuccess(t *testing.T) {
|
|||||||
client := &transcriptClient{steps: []clientStep{
|
client := &transcriptClient{steps: []clientStep{
|
||||||
{command: `AT+COPS=1,2,"46000",7`, response: okResponse()},
|
{command: `AT+COPS=1,2,"46000",7`, response: okResponse()},
|
||||||
{command: "AT+COPS?", response: okResponse("+COPS: 0")},
|
{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)
|
manager, id := newStartedTestManager(t, client)
|
||||||
act := 7
|
act := 7
|
||||||
@@ -107,3 +114,77 @@ func TestOperatorSelectionRejectsAutomaticFallbackAsSuccess(t *testing.T) {
|
|||||||
}
|
}
|
||||||
client.assertDone(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+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+CGSN", response: okResponse("867123456789012")},
|
||||||
{
|
{
|
||||||
command: "AT+CCID",
|
command: "AT+CCID",
|
||||||
@@ -66,7 +67,9 @@ func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
|
|||||||
t.Fatalf("signal metrics = %#v", snapshot)
|
t.Fatalf("signal metrics = %#v", snapshot)
|
||||||
}
|
}
|
||||||
if snapshot.AccessTech != "LTE" || snapshot.Band != "B3" ||
|
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)
|
t.Fatalf("network = %#v", snapshot)
|
||||||
}
|
}
|
||||||
if snapshot.IMEI != "867123456789012" ||
|
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"`
|
Name string `json:"name"`
|
||||||
Short string `json:"shortName,omitempty"`
|
Short string `json:"shortName,omitempty"`
|
||||||
Numeric string `json:"numeric"`
|
Numeric string `json:"numeric"`
|
||||||
|
Country string `json:"countryCode,omitempty"`
|
||||||
Act string `json:"act,omitempty"`
|
Act string `json:"act,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,11 +76,19 @@ func parseOperatorScan(response modem.Response) []ScannedOperator {
|
|||||||
if len(fields) < 4 {
|
if len(fields) < 4 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
name, country, _ := CarrierForPLMN(fields[3])
|
||||||
|
if name == "" {
|
||||||
|
name = strings.TrimSpace(fields[1])
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = strings.TrimSpace(fields[3])
|
||||||
|
}
|
||||||
operator := ScannedOperator{
|
operator := ScannedOperator{
|
||||||
Status: operatorScanStatus(fields[0]),
|
Status: operatorScanStatus(fields[0]),
|
||||||
Name: fields[1],
|
Name: name,
|
||||||
Short: fields[2],
|
Short: fields[2],
|
||||||
Numeric: fields[3],
|
Numeric: fields[3],
|
||||||
|
Country: country,
|
||||||
}
|
}
|
||||||
if len(fields) >= 5 {
|
if len(fields) >= 5 {
|
||||||
operator.Act = accessTechnology(fields[4])
|
operator.Act = accessTechnology(fields[4])
|
||||||
@@ -90,6 +99,20 @@ func parseOperatorScan(response modem.Response) []ScannedOperator {
|
|||||||
return operators
|
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,
|
// extractScanTuples returns the contents of each top-level parenthesised group,
|
||||||
// ignoring parentheses inside quoted strings.
|
// ignoring parentheses inside quoted strings.
|
||||||
func extractScanTuples(payload string) []string {
|
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 {
|
if response, ok := optional("AT+CSQ"); ok {
|
||||||
snapshot.SignalRaw, snapshot.SignalPercent, snapshot.RSSIDBm = parseCSQ(response)
|
snapshot.SignalRaw, snapshot.SignalPercent, snapshot.RSSIDBm = parseCSQ(response)
|
||||||
}
|
}
|
||||||
|
servingPLMN := ""
|
||||||
if response, ok := optional(`AT+QENG="servingcell"`); ok {
|
if response, ok := optional(`AT+QENG="servingcell"`); ok {
|
||||||
metrics := parseQENG(response)
|
metrics := parseQENG(response)
|
||||||
|
servingPLMN = metrics.PLMN
|
||||||
snapshot.AccessTech = metrics.AccessTech
|
snapshot.AccessTech = metrics.AccessTech
|
||||||
snapshot.Band = metrics.Band
|
snapshot.Band = metrics.Band
|
||||||
snapshot.Channel = metrics.Channel
|
snapshot.Channel = metrics.Channel
|
||||||
@@ -64,12 +66,42 @@ func (manager *Manager) readSnapshot(
|
|||||||
}
|
}
|
||||||
if response, ok := optional("AT+COPS?"); ok {
|
if response, ok := optional("AT+COPS?"); ok {
|
||||||
operator := parseCOPS(response)
|
operator := parseCOPS(response)
|
||||||
snapshot.OperatorName = operator.Name
|
if operator.Code != "" {
|
||||||
snapshot.OperatorCode = operator.Code
|
snapshot.OperatorCode = operator.Code
|
||||||
|
} else {
|
||||||
|
snapshot.OperatorCode = servingPLMN
|
||||||
|
}
|
||||||
|
snapshot.OperatorName = carrierNameForPLMN(snapshot.OperatorCode, operator.Name)
|
||||||
if snapshot.AccessTech == "" {
|
if snapshot.AccessTech == "" {
|
||||||
snapshot.AccessTech = operator.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 {
|
if response, ok := optional("AT+CGSN"); ok {
|
||||||
snapshot.IMEI = parseIdentifier(
|
snapshot.IMEI = parseIdentifier(
|
||||||
response,
|
response,
|
||||||
@@ -107,6 +139,25 @@ func (manager *Manager) readSnapshot(
|
|||||||
return snapshot, nil
|
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) {
|
func parseATI(lines []string) (manufacturer, model, firmware string) {
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
line = strings.TrimSpace(line)
|
line = strings.TrimSpace(line)
|
||||||
@@ -159,6 +210,7 @@ func parseCSQ(response modem.Response) (raw, percent, dbm *int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type qengMetrics struct {
|
type qengMetrics struct {
|
||||||
|
PLMN string
|
||||||
AccessTech string
|
AccessTech string
|
||||||
Band string
|
Band string
|
||||||
Channel string
|
Channel string
|
||||||
@@ -179,6 +231,9 @@ func parseQENG(response modem.Response) qengMetrics {
|
|||||||
}
|
}
|
||||||
result := qengMetrics{AccessTech: strings.ToUpper(values[2])}
|
result := qengMetrics{AccessTech: strings.ToUpper(values[2])}
|
||||||
if strings.EqualFold(values[2], "LTE") && len(values) >= 17 {
|
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]
|
result.Channel = values[8]
|
||||||
if values[9] != "" {
|
if values[9] != "" {
|
||||||
result.Band = "B" + values[9]
|
result.Band = "B" + values[9]
|
||||||
@@ -193,6 +248,13 @@ func parseQENG(response modem.Response) qengMetrics {
|
|||||||
return 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 {
|
type operatorInfo struct {
|
||||||
Name string
|
Name string
|
||||||
Code string
|
Code string
|
||||||
|
|||||||
+32
-29
@@ -73,35 +73,38 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Snapshot struct {
|
type Snapshot struct {
|
||||||
DeviceID string `json:"deviceId"`
|
DeviceID string `json:"deviceId"`
|
||||||
Port string `json:"port"`
|
Port string `json:"port"`
|
||||||
Responsive bool `json:"responsive"`
|
Responsive bool `json:"responsive"`
|
||||||
Manufacturer string `json:"manufacturer"`
|
Manufacturer string `json:"manufacturer"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
Firmware string `json:"firmware"`
|
Firmware string `json:"firmware"`
|
||||||
SIMStatus string `json:"simStatus"`
|
SIMStatus string `json:"simStatus"`
|
||||||
SIMReady bool `json:"simReady"`
|
SIMReady bool `json:"simReady"`
|
||||||
SignalRaw *int `json:"signalRaw,omitempty"`
|
SignalRaw *int `json:"signalRaw,omitempty"`
|
||||||
SignalPercent *int `json:"signalPercent,omitempty"`
|
SignalPercent *int `json:"signalPercent,omitempty"`
|
||||||
RSSIDBm *int `json:"rssiDbm,omitempty"`
|
RSSIDBm *int `json:"rssiDbm,omitempty"`
|
||||||
RSRP *int `json:"rsrp,omitempty"`
|
RSRP *int `json:"rsrp,omitempty"`
|
||||||
RSRQ *int `json:"rsrq,omitempty"`
|
RSRQ *int `json:"rsrq,omitempty"`
|
||||||
SINR *int `json:"sinr,omitempty"`
|
SINR *int `json:"sinr,omitempty"`
|
||||||
AccessTech string `json:"accessTech"`
|
AccessTech string `json:"accessTech"`
|
||||||
Band string `json:"band"`
|
Band string `json:"band"`
|
||||||
Channel string `json:"channel"`
|
Channel string `json:"channel"`
|
||||||
OperatorName string `json:"operatorName"`
|
OperatorName string `json:"operatorName"`
|
||||||
OperatorCode string `json:"operatorCode"`
|
OperatorCode string `json:"operatorCode"`
|
||||||
IMEI string `json:"imei"`
|
RegistrationStatus int `json:"registrationStatus"`
|
||||||
ICCID string `json:"iccid"`
|
RegistrationSource string `json:"registrationSource"`
|
||||||
IMSI string `json:"imsi"`
|
PSAttached bool `json:"psAttached"`
|
||||||
OperatingMode int `json:"operatingMode"`
|
IMEI string `json:"imei"`
|
||||||
ModeKnown bool `json:"modeKnown"`
|
ICCID string `json:"iccid"`
|
||||||
FlightMode bool `json:"flightMode"`
|
IMSI string `json:"imsi"`
|
||||||
RadioOff bool `json:"radioOff"`
|
OperatingMode int `json:"operatingMode"`
|
||||||
Phone PhoneNumber `json:"phone"`
|
ModeKnown bool `json:"modeKnown"`
|
||||||
Warnings []string `json:"warnings,omitempty"`
|
FlightMode bool `json:"flightMode"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
RadioOff bool `json:"radioOff"`
|
||||||
|
Phone PhoneNumber `json:"phone"`
|
||||||
|
Warnings []string `json:"warnings,omitempty"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type USSDResult struct {
|
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"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"vocat/internal/exportproxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxPackageBytes int64 = 64 << 20
|
const maxPackageBytes int64 = 64 << 20
|
||||||
@@ -94,6 +96,10 @@ func (manager *Manager) scan() error {
|
|||||||
manager.logger.Warn("skip invalid plugin", "directory", dir, "error", err)
|
manager.logger.Warn("skip invalid plugin", "directory", dir, "error", err)
|
||||||
continue
|
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
|
manager.plugins[plugin.ID] = plugin
|
||||||
if plugin.Enabled {
|
if plugin.Enabled {
|
||||||
manager.startLocked(plugin)
|
manager.startLocked(plugin)
|
||||||
@@ -201,6 +207,9 @@ func (manager *Manager) Install(reader io.Reader, expectedSHA string) (Plugin, e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Plugin{}, err
|
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+"-")
|
staging, err := os.MkdirTemp(manager.root, ".install-"+manifest.ID+"-")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Plugin{}, err
|
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 ----
|
// ---- devices ----
|
||||||
"设备数量已达上限,最多只能添加 %d 台设备": "Device limit reached; at most %d devices can be added.",
|
"设备数量已达上限,最多只能添加 %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.",
|
"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 ----
|
// ---- settings / update ----
|
||||||
"未配置受信任的软件更新源;不会从未知地址下载或执行文件。": "No trusted update source is configured; no files will be downloaded or executed from unknown addresses.",
|
"未配置受信任的软件更新源;不会从未知地址下载或执行文件。": "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"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"vocat/internal/developer"
|
||||||
"vocat/internal/device"
|
"vocat/internal/device"
|
||||||
"vocat/internal/i18n"
|
"vocat/internal/i18n"
|
||||||
"vocat/internal/modem"
|
"vocat/internal/modem"
|
||||||
@@ -38,6 +39,7 @@ type DeviceController interface {
|
|||||||
SetUSBNetModeByPort(context.Context, string, int) (device.USBNetMode, error)
|
SetUSBNetModeByPort(context.Context, string, int) (device.USBNetMode, error)
|
||||||
OperatorSelection(context.Context, string) (device.OperatorSelection, error)
|
OperatorSelection(context.Context, string) (device.OperatorSelection, error)
|
||||||
SetOperatorSelection(context.Context, string, bool, string, *int) (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)
|
ScanOperators(context.Context, string) (device.OperatorScanResult, error)
|
||||||
SendSMS(context.Context, string, string, string) (device.SMSSendResult, error)
|
SendSMS(context.Context, string, string, string) (device.SMSSendResult, error)
|
||||||
ListSMS(context.Context, string) ([]device.SMSMessage, error)
|
ListSMS(context.Context, string) ([]device.SMSMessage, error)
|
||||||
@@ -56,6 +58,7 @@ type DeviceController interface {
|
|||||||
type deviceConfigPayload struct {
|
type deviceConfigPayload struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
DeviceType string `json:"device_type"`
|
||||||
Interface string `json:"interface"`
|
Interface string `json:"interface"`
|
||||||
ControlDevice string `json:"control_device"`
|
ControlDevice string `json:"control_device"`
|
||||||
ATPort string `json:"at_port"`
|
ATPort string `json:"at_port"`
|
||||||
@@ -86,6 +89,7 @@ func (payload deviceConfigPayload) toStoreDevice() store.Device {
|
|||||||
return store.Device{
|
return store.Device{
|
||||||
ID: strings.TrimSpace(payload.ID),
|
ID: strings.TrimSpace(payload.ID),
|
||||||
Name: name,
|
Name: name,
|
||||||
|
DeviceType: store.NormalizeDeviceType(payload.DeviceType),
|
||||||
Interface: strings.TrimSpace(payload.Interface),
|
Interface: strings.TrimSpace(payload.Interface),
|
||||||
ControlDevice: strings.TrimSpace(payload.ControlDevice),
|
ControlDevice: strings.TrimSpace(payload.ControlDevice),
|
||||||
ATPort: strings.TrimSpace(payload.ATPort),
|
ATPort: strings.TrimSpace(payload.ATPort),
|
||||||
@@ -170,15 +174,13 @@ func splitAPIPath(value string) []string {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// maxDeviceLimit 是设备数量的软上限:达到上限后禁止再添加新设备。
|
|
||||||
const maxDeviceLimit = 5
|
|
||||||
|
|
||||||
func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) bool {
|
func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) bool {
|
||||||
|
deviceLimit := developer.DeviceLimit(r.Context(), s.store, s.developerEnabled)
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"data": map[string]any{
|
"data": map[string]any{
|
||||||
"device_limit": maxDeviceLimit,
|
"device_limit": deviceLimit,
|
||||||
"devices": s.deviceSummaries(),
|
"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")
|
writeError(w, http.StatusBadRequest, "invalid_device_id", "device ID must use 1-64 letters, digits, dots, underscores, or hyphens")
|
||||||
return true
|
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 {
|
if _, err := s.store.Device(r.Context(), payload.ID); err == nil {
|
||||||
writeError(w, http.StatusConflict, "device_exists", "a device with this ID already exists")
|
writeError(w, http.StatusConflict, "device_exists", "a device with this ID already exists")
|
||||||
return true
|
return true
|
||||||
@@ -215,8 +221,8 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) bool {
|
|||||||
s.writeStoreError(w, err)
|
s.writeStoreError(w, err)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if len(configured) >= maxDeviceLimit {
|
if len(configured) >= deviceLimit {
|
||||||
writeError(w, http.StatusConflict, "device_limit_reached", i18n.Tf("设备数量已达上限,最多只能添加 %d 台设备", maxDeviceLimit))
|
writeError(w, http.StatusConflict, "device_limit_reached", i18n.Tf("设备数量已达上限,最多只能添加 %d 台设备", deviceLimit))
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
devices, err := s.devices.Discover(r.Context())
|
devices, err := s.devices.Discover(r.Context())
|
||||||
@@ -230,6 +236,9 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
config := payload.toStoreDevice()
|
config := payload.toStoreDevice()
|
||||||
|
if !s.developerActive(r.Context()) {
|
||||||
|
config.NetworkEnabled = false
|
||||||
|
}
|
||||||
fillConfigFromPhysical(&config, *selected)
|
fillConfigFromPhysical(&config, *selected)
|
||||||
if err := s.store.UpsertDevice(r.Context(), config); err != nil {
|
if err := s.store.UpsertDevice(r.Context(), config); err != nil {
|
||||||
s.writeStoreError(w, err)
|
s.writeStoreError(w, err)
|
||||||
@@ -402,6 +411,9 @@ func (s *Server) handleDevicePath(
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
next := payload.toStoreDevice()
|
next := payload.toStoreDevice()
|
||||||
|
if !s.developerActive(r.Context()) {
|
||||||
|
next.NetworkEnabled = false
|
||||||
|
}
|
||||||
next.ID = id
|
next.ID = id
|
||||||
next.CreatedAt = config.CreatedAt
|
next.CreatedAt = config.CreatedAt
|
||||||
if next.Name == id && strings.TrimSpace(payload.Name) == "" {
|
if next.Name == id && strings.TrimSpace(payload.Name) == "" {
|
||||||
@@ -485,12 +497,27 @@ func (s *Server) handleDevicePath(
|
|||||||
s.writeDeviceError(w, err)
|
s.writeDeviceError(w, err)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
s.clearPublicIP(config.ID)
|
||||||
writeJSON(w, http.StatusAccepted, map[string]any{"data": map[string]any{"status": "rebooting"}})
|
writeJSON(w, http.StatusAccepted, map[string]any{"data": map[string]any{"status": "rebooting"}})
|
||||||
case "flight-mode":
|
case "flight-mode":
|
||||||
if !s.requirePhysicalDevice(w, physicalPresent) {
|
if !s.requirePhysicalDevice(w, physicalPresent) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return s.handleFlightMode(w, r, physicalID)
|
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":
|
case "usbnet-mode":
|
||||||
if !s.requirePhysicalDevice(w, physicalPresent) {
|
if !s.requirePhysicalDevice(w, physicalPresent) {
|
||||||
return true
|
return true
|
||||||
@@ -501,6 +528,11 @@ func (s *Server) handleDevicePath(
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return s.handleOperatorSelection(w, r, physicalID)
|
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":
|
case "operator_selection/scan":
|
||||||
if !s.requirePhysicalDevice(w, physicalPresent) {
|
if !s.requirePhysicalDevice(w, physicalPresent) {
|
||||||
return true
|
return true
|
||||||
@@ -527,6 +559,11 @@ func (s *Server) handleDevicePath(
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return s.handleCallAction(w, r, config, physicalID, tail[1])
|
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:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -667,6 +704,21 @@ func (s *Server) handleOperatorSelection(w http.ResponseWriter, r *http.Request,
|
|||||||
return true
|
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(
|
func (s *Server) handleVoWiFiEnabled(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
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")
|
writeError(w, http.StatusServiceUnavailable, "physical_device_missing", "the configured modem is not present on this Linux host")
|
||||||
return true
|
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 {
|
if request.Enabled {
|
||||||
entry, _, _ := s.physicalForConfig(config)
|
entry, _, _ := s.physicalForConfig(config)
|
||||||
imsi := snapshotString(entry.Snapshot, func(snapshot *device.Snapshot) string { return snapshot.IMSI })
|
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)
|
s.writeDeviceError(w, err)
|
||||||
return true
|
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})
|
writeJSON(w, http.StatusOK, map[string]any{"data": result})
|
||||||
return true
|
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 {
|
func (s *Server) requirePhysicalDevice(w http.ResponseWriter, present bool) bool {
|
||||||
if s.devices == nil {
|
if s.devices == nil {
|
||||||
writeError(w, http.StatusServiceUnavailable, "device_manager_unavailable", "device manager is unavailable")
|
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{
|
result = append(result, map[string]any{
|
||||||
"id": entry["id"],
|
"id": entry["id"],
|
||||||
"name": entry["name"],
|
"name": entry["name"],
|
||||||
|
"device_type": entry["device_type"],
|
||||||
"interface": entry["interface"],
|
"interface": entry["interface"],
|
||||||
"proxy_port": entry["proxy_port"],
|
"proxy_port": entry["proxy_port"],
|
||||||
"public_ip": entry["public_ip"],
|
"public_ip": entry["public_ip"],
|
||||||
@@ -1041,7 +1199,7 @@ func (s *Server) dashboardDevices() []map[string]any {
|
|||||||
"network_duplex": modemStatus["network_duplex"],
|
"network_duplex": modemStatus["network_duplex"],
|
||||||
"vowifi_active": vowifiActive,
|
"vowifi_active": vowifiActive,
|
||||||
"vowifi_runtime": runtime,
|
"vowifi_runtime": runtime,
|
||||||
"network_connected": false,
|
"network_connected": entry["network_connected"],
|
||||||
"model": modemStatus["model"],
|
"model": modemStatus["model"],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1098,24 +1256,50 @@ func (s *Server) configuredDeviceSummary(
|
|||||||
}
|
}
|
||||||
result["id"] = config.ID
|
result["id"] = config.ID
|
||||||
result["name"] = config.Name
|
result["name"] = config.Name
|
||||||
|
result["device_type"] = store.NormalizeDeviceType(config.DeviceType)
|
||||||
result["interface"] = config.Interface
|
result["interface"] = config.Interface
|
||||||
result["proxy_port"] = config.ProxyPort
|
result["proxy_port"] = config.ProxyPort
|
||||||
result["esim_transport"] = config.ESIMTransport
|
result["esim_transport"] = config.ESIMTransport
|
||||||
result["sms_enabled"] = config.SMSEnabled
|
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
|
result["vowifi_enabled"] = config.VoWiFiEnabled
|
||||||
if runtime, err := s.store.VoWiFiRuntime(context.Background(), config.ID); err == nil {
|
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_runtime"] = runtimeResponse
|
||||||
result["vowifi_active"] = runtime.TunnelReady
|
result["vowifi_active"] = config.VoWiFiEnabled && runtimeMatchesCard && runtime.TunnelReady
|
||||||
if runtime.LocalPhone != "" {
|
}
|
||||||
// The SIM panel reads the top-level local_phone; keep modem.phone_number
|
// Numbers are SIM-owned data. Resolve the association by the live ICCID
|
||||||
// in sync for the summary/overview consumers that read it there.
|
// instead of reusing the last VoWiFi runtime attached to this device ID.
|
||||||
result["local_phone"] = runtime.LocalPhone
|
if entry != nil && entry.Snapshot != nil {
|
||||||
result["phone_number_source"] = runtime.PhoneNumberSource
|
currentICCID := strings.TrimSpace(entry.Snapshot.ICCID)
|
||||||
if modemStatus, ok := result["modem"].(map[string]any); ok {
|
if currentICCID != "" {
|
||||||
modemStatus["phone_number"] = runtime.LocalPhone
|
if association, err := s.store.PhoneAssociation(context.Background(), currentICCID); err == nil {
|
||||||
modemStatus["phone_number_source"] = runtime.PhoneNumberSource
|
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,
|
entry device.Device,
|
||||||
present bool,
|
present bool,
|
||||||
) map[string]any {
|
) map[string]any {
|
||||||
|
developerActive := s.developerActive(context.Background())
|
||||||
var physical *device.Device
|
var physical *device.Device
|
||||||
if present {
|
if present {
|
||||||
physical = &entry
|
physical = &entry
|
||||||
@@ -1141,12 +1326,34 @@ func (s *Server) configuredDeviceOverview(
|
|||||||
result["control_device"] = config.ControlDevice
|
result["control_device"] = config.ControlDevice
|
||||||
result["esim_transport"] = config.ESIMTransport
|
result["esim_transport"] = config.ESIMTransport
|
||||||
result["sms_enabled"] = config.SMSEnabled
|
result["sms_enabled"] = config.SMSEnabled
|
||||||
result["network_enabled"] = false
|
result["network_enabled"] = developerActive && config.NetworkEnabled
|
||||||
result["vowifi_enabled"] = config.VoWiFiEnabled
|
result["vowifi_enabled"] = config.VoWiFiEnabled
|
||||||
result["radio_live_ok"] = present && entry.Snapshot != nil && entry.Snapshot.Responsive
|
result["radio_live_ok"] = present && entry.Snapshot != nil && entry.Snapshot.Responsive
|
||||||
result["traffic"] = map[string]string{}
|
|
||||||
result["traffic_raw"] = map[string]int64{}
|
// Live network state: on-demand sample of the cellular interface counters,
|
||||||
result["traffic_meta"] = map[string]any{}
|
// 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
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1167,7 +1374,7 @@ func (s *Server) configuredDeviceStatus(
|
|||||||
result := map[string]any{
|
result := map[string]any{
|
||||||
"healthy": summary["healthy"],
|
"healthy": summary["healthy"],
|
||||||
"public_ip": summary["public_ip"],
|
"public_ip": summary["public_ip"],
|
||||||
"network_connected": false,
|
"network_connected": config.NetworkEnabled,
|
||||||
"modem": summary["modem"],
|
"modem": summary["modem"],
|
||||||
"vowifi": summary["vowifi_runtime"],
|
"vowifi": summary["vowifi_runtime"],
|
||||||
"sim_service_table": map[string]any{},
|
"sim_service_table": map[string]any{},
|
||||||
@@ -1250,7 +1457,7 @@ func deviceSummary(entry device.Device) map[string]any {
|
|||||||
"physical_present": entry.Discovered,
|
"physical_present": entry.Discovered,
|
||||||
"worker_running": entry.Discovered,
|
"worker_running": entry.Discovered,
|
||||||
"data_connected": false,
|
"data_connected": false,
|
||||||
"radio_registered": snapshot != nil && snapshot.OperatorName != "",
|
"radio_registered": snapshot != nil && (snapshot.RegistrationStatus == 1 || snapshot.RegistrationStatus == 5),
|
||||||
"lifecycle_phase": lifecyclePhase(entry),
|
"lifecycle_phase": lifecyclePhase(entry),
|
||||||
"lifecycle_reason": entry.LastError,
|
"lifecycle_reason": entry.LastError,
|
||||||
"public_ip": "",
|
"public_ip": "",
|
||||||
@@ -1307,6 +1514,7 @@ func storedDeviceConfig(config store.Device) map[string]any {
|
|||||||
return map[string]any{
|
return map[string]any{
|
||||||
"id": config.ID,
|
"id": config.ID,
|
||||||
"name": config.Name,
|
"name": config.Name,
|
||||||
|
"device_type": store.NormalizeDeviceType(config.DeviceType),
|
||||||
"interface": config.Interface,
|
"interface": config.Interface,
|
||||||
"control_device": config.ControlDevice,
|
"control_device": config.ControlDevice,
|
||||||
"at_port": config.ATPort,
|
"at_port": config.ATPort,
|
||||||
@@ -1324,7 +1532,7 @@ func storedDeviceConfig(config store.Device) map[string]any {
|
|||||||
"qmi_use_proxy": config.QMIUseProxy,
|
"qmi_use_proxy": config.QMIUseProxy,
|
||||||
"qmi_proxy_path": config.QMIProxyPath,
|
"qmi_proxy_path": config.QMIProxyPath,
|
||||||
"qmi_proxy_executable": config.QMIProxyExecutable,
|
"qmi_proxy_executable": config.QMIProxyExecutable,
|
||||||
"network_enabled": false,
|
"network_enabled": config.NetworkEnabled,
|
||||||
"sms_enabled": config.SMSEnabled,
|
"sms_enabled": config.SMSEnabled,
|
||||||
"vowifi_enabled": config.VoWiFiEnabled,
|
"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 {
|
func modemSummary(snapshot *device.Snapshot, phone string, phoneSource string) map[string]any {
|
||||||
if snapshot == nil {
|
if snapshot == nil {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"operator": "",
|
"operator": "",
|
||||||
"native_mcc": "",
|
"native_mcc": "",
|
||||||
"native_mnc": "",
|
"native_mnc": "",
|
||||||
"card_mcc": "",
|
"operator_country_code": "",
|
||||||
"card_mnc": "",
|
"card_mcc": "",
|
||||||
"card_country": "",
|
"card_mnc": "",
|
||||||
"service_blocked": false,
|
"card_country": "",
|
||||||
"blocked_reason": "",
|
"service_blocked": false,
|
||||||
"network_mode": "",
|
"blocked_reason": "",
|
||||||
"radio_band": "",
|
"network_mode": "",
|
||||||
"radio_channel": 0,
|
"radio_band": "",
|
||||||
"signal_dbm": 0,
|
"radio_channel": 0,
|
||||||
"signal_sinr": 0,
|
"signal_dbm": 0,
|
||||||
"imei": "",
|
"signal_sinr": 0,
|
||||||
"iccid": "",
|
"imei": "",
|
||||||
"reg_status": 0,
|
"iccid": "",
|
||||||
"reg_status_text": "not refreshed",
|
"reg_status": 0,
|
||||||
"sim_inserted": false,
|
"reg_status_text": "not refreshed",
|
||||||
"phone_number": phone,
|
"sim_inserted": false,
|
||||||
"phone_number_source": phoneSource,
|
"phone_number": phone,
|
||||||
"model": "",
|
"phone_number_source": phoneSource,
|
||||||
|
"model": "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mcc, mnc := splitPLMN(snapshot.OperatorCode)
|
mcc, mnc := splitPLMN(snapshot.OperatorCode)
|
||||||
|
_, operatorCountryCode, _ := device.CarrierForPLMN(snapshot.OperatorCode)
|
||||||
cardMCC, cardMNC := device.CardMCCMNC(snapshot.IMSI)
|
cardMCC, cardMNC := device.CardMCCMNC(snapshot.IMSI)
|
||||||
blockedReason := device.RegionBlockReason(snapshot.IMSI)
|
blockedReason := device.RegionBlockReason(snapshot.IMSI)
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"operator": snapshot.OperatorName,
|
"operator": snapshot.OperatorName,
|
||||||
"native_mcc": mcc,
|
"native_mcc": mcc,
|
||||||
"native_mnc": mnc,
|
"native_mnc": mnc,
|
||||||
"card_mcc": cardMCC,
|
"operator_country_code": operatorCountryCode,
|
||||||
"card_mnc": cardMNC,
|
"card_mcc": cardMCC,
|
||||||
"card_country": countryNameForMCC(cardMCC),
|
"card_mnc": cardMNC,
|
||||||
"service_blocked": blockedReason != "",
|
"card_country": countryNameForMCC(cardMCC),
|
||||||
"blocked_reason": blockedReason,
|
"service_blocked": blockedReason != "",
|
||||||
"network_mode": snapshot.AccessTech,
|
"blocked_reason": blockedReason,
|
||||||
"network_duplex": "",
|
"network_mode": snapshot.AccessTech,
|
||||||
"radio_band": snapshot.Band,
|
"network_duplex": "",
|
||||||
"radio_channel": parseDecimal(snapshot.Channel),
|
"radio_band": snapshot.Band,
|
||||||
"signal_dbm": pointerInt(snapshot.RSSIDBm),
|
"radio_channel": parseDecimal(snapshot.Channel),
|
||||||
"signal_rsrp": pointerInt(snapshot.RSRP),
|
"signal_dbm": pointerInt(snapshot.RSSIDBm),
|
||||||
"signal_rsrq": pointerInt(snapshot.RSRQ),
|
"signal_rsrp": pointerInt(snapshot.RSRP),
|
||||||
"signal_sinr": pointerInt(snapshot.SINR),
|
"signal_rsrq": pointerInt(snapshot.RSRQ),
|
||||||
"imei": snapshot.IMEI,
|
"signal_sinr": pointerInt(snapshot.SINR),
|
||||||
"iccid": snapshot.ICCID,
|
"imei": snapshot.IMEI,
|
||||||
"imsi": snapshot.IMSI,
|
"iccid": snapshot.ICCID,
|
||||||
"firmware": snapshot.Firmware,
|
"imsi": snapshot.IMSI,
|
||||||
"model": snapshot.Model,
|
"firmware": snapshot.Firmware,
|
||||||
"reg_status": boolInt(snapshot.OperatorName != ""),
|
"model": snapshot.Model,
|
||||||
"reg_status_text": registrationText(snapshot),
|
"reg_status": snapshot.RegistrationStatus,
|
||||||
"ps_attached": false,
|
"reg_status_text": registrationText(snapshot),
|
||||||
"sim_inserted": snapshot.SIMStatus != "",
|
"ps_attached": snapshot.PSAttached,
|
||||||
"operating_mode": snapshot.OperatingMode,
|
"sim_inserted": snapshot.SIMStatus != "",
|
||||||
"phone_number": phone,
|
"operating_mode": snapshot.OperatingMode,
|
||||||
"phone_number_source": phoneSource,
|
"phone_number": phone,
|
||||||
|
"phone_number_source": phoneSource,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1485,17 +1696,39 @@ func lifecyclePhase(entry device.Device) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func registrationLabel(snapshot *device.Snapshot) 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 "unknown"
|
||||||
}
|
}
|
||||||
return "registered"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func registrationText(snapshot *device.Snapshot) string {
|
func registrationText(snapshot *device.Snapshot) string {
|
||||||
if snapshot.OperatorName != "" {
|
if snapshot == nil {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
switch snapshot.RegistrationStatus {
|
||||||
|
case 1:
|
||||||
return "registered"
|
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) {
|
func splitPLMN(value string) (string, string) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
@@ -10,6 +11,10 @@ import (
|
|||||||
"vocat/internal/store"
|
"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
|
// beginSSE prepares a response for Server-Sent Events and returns its response
|
||||||
// controller for explicit flushes.
|
// controller for explicit flushes.
|
||||||
func beginSSE(w http.ResponseWriter) *http.ResponseController {
|
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 {
|
if err := writeSSEEvent(w, controller, "connected", map[string]any{}); err != nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
ticker := time.NewTicker(2 * time.Second)
|
ticker := time.NewTicker(overviewStreamInterval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-r.Context().Done():
|
case <-r.Context().Done():
|
||||||
return true
|
return true
|
||||||
case <-ticker.C:
|
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)
|
currentEntry, _, present := s.physicalForConfig(config)
|
||||||
overview := s.configuredDeviceOverview(config, currentEntry, present)
|
overview := s.configuredDeviceOverview(config, currentEntry, present)
|
||||||
if err := writeSSEEvent(w, controller, "overview", overview); err != nil {
|
if err := writeSSEEvent(w, controller, "overview", overview); err != nil {
|
||||||
@@ -81,6 +100,7 @@ func operatorCandidateWire(op device.ScannedOperator) map[string]any {
|
|||||||
"operatorName": op.Name,
|
"operatorName": op.Name,
|
||||||
"shortName": op.Short,
|
"shortName": op.Short,
|
||||||
"plmn": op.Numeric,
|
"plmn": op.Numeric,
|
||||||
|
"countryCode": op.Country,
|
||||||
"rats": rats,
|
"rats": rats,
|
||||||
"includesPcsDigit": false,
|
"includesPcsDigit": false,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -11,7 +12,9 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"vocat/internal/developer"
|
||||||
"vocat/internal/device"
|
"vocat/internal/device"
|
||||||
|
"vocat/internal/exportproxy"
|
||||||
"vocat/internal/modem"
|
"vocat/internal/modem"
|
||||||
"vocat/internal/store"
|
"vocat/internal/store"
|
||||||
"vocat/internal/update"
|
"vocat/internal/update"
|
||||||
@@ -467,3 +470,230 @@ func TestE911WebsheetRejectsBadToken(t *testing.T) {
|
|||||||
t.Fatalf("bad token status = %d, want 403", recorder.Code)
|
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/auth"
|
||||||
"vocat/internal/buildinfo"
|
"vocat/internal/buildinfo"
|
||||||
|
"vocat/internal/developer"
|
||||||
"vocat/internal/i18n"
|
"vocat/internal/i18n"
|
||||||
"vocat/internal/loghub"
|
"vocat/internal/loghub"
|
||||||
"vocat/internal/store"
|
"vocat/internal/store"
|
||||||
@@ -26,6 +27,9 @@ func (s *Server) routeGeneralAPI(w http.ResponseWriter, r *http.Request) bool {
|
|||||||
if s.routeExtensionAPI(w, r, cleanPath) {
|
if s.routeExtensionAPI(w, r, cleanPath) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if s.routeExportProxyAPI(w, r, cleanPath) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
if s.routeSMSAPI(w, r, cleanPath) {
|
if s.routeSMSAPI(w, r, cleanPath) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -50,6 +54,12 @@ func (s *Server) routeGeneralAPI(w http.ResponseWriter, r *http.Request) bool {
|
|||||||
s.handlePasswordChange(w, r)
|
s.handlePasswordChange(w, r)
|
||||||
case "settings/preferences":
|
case "settings/preferences":
|
||||||
s.handleUIPreferences(w, r)
|
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:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -314,11 +324,15 @@ func (s *Server) handleSystemInfo(w http.ResponseWriter, r *http.Request) {
|
|||||||
"os": runtime.GOOS,
|
"os": runtime.GOOS,
|
||||||
"architecture": runtime.GOARCH,
|
"architecture": runtime.GOARCH,
|
||||||
"uptime": formatDuration(time.Since(s.startedAt)),
|
"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) {
|
func (s *Server) handleUpdateCheck(w http.ResponseWriter, r *http.Request) {
|
||||||
if !requireMethod(w, r, http.MethodGet) {
|
if !requireMethod(w, r, http.MethodGet) {
|
||||||
return
|
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) {
|
func (f fakeDeviceController) SetOperatorSelection(context.Context, string, bool, string, *int) (device.OperatorSelection, error) {
|
||||||
return device.OperatorSelection{}, nil
|
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) {
|
func (f fakeDeviceController) ScanOperators(context.Context, string) (device.OperatorScanResult, error) {
|
||||||
return f.scanResult, f.scanErr
|
return f.scanResult, f.scanErr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"vocat/internal/auth"
|
"vocat/internal/auth"
|
||||||
|
"vocat/internal/exportproxy"
|
||||||
"vocat/internal/extensions"
|
"vocat/internal/extensions"
|
||||||
|
"vocat/internal/httpsmode"
|
||||||
"vocat/internal/loghub"
|
"vocat/internal/loghub"
|
||||||
"vocat/internal/store"
|
"vocat/internal/store"
|
||||||
"vocat/internal/update"
|
"vocat/internal/update"
|
||||||
@@ -42,9 +44,11 @@ type Options struct {
|
|||||||
SecureCookies bool
|
SecureCookies bool
|
||||||
MaxRequestBodyBytes int64
|
MaxRequestBodyBytes int64
|
||||||
Extensions *extensions.Manager
|
Extensions *extensions.Manager
|
||||||
|
ExportProxy *exportproxy.Manager
|
||||||
DeveloperEnabled bool
|
DeveloperEnabled bool
|
||||||
UpdateRepository string
|
UpdateRepository string
|
||||||
UpdateToken string
|
UpdateToken string
|
||||||
|
HTTPS *httpsmode.Manager
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server is the single HTTP handler for the JSON API and embedded SPA.
|
// Server is the single HTTP handler for the JSON API and embedded SPA.
|
||||||
@@ -67,6 +71,7 @@ type Server struct {
|
|||||||
access parsedAccessConfig
|
access parsedAccessConfig
|
||||||
loginLimiter *loginRateLimiter
|
loginLimiter *loginRateLimiter
|
||||||
extensions *extensions.Manager
|
extensions *extensions.Manager
|
||||||
|
exportProxy *exportproxy.Manager
|
||||||
developerEnabled bool
|
developerEnabled bool
|
||||||
updateRepository string
|
updateRepository string
|
||||||
updateToken string
|
updateToken string
|
||||||
@@ -75,6 +80,10 @@ type Server struct {
|
|||||||
updateRestart func(*slog.Logger) error
|
updateRestart func(*slog.Logger) error
|
||||||
updateMu sync.Mutex
|
updateMu sync.Mutex
|
||||||
updateApplying bool
|
updateApplying bool
|
||||||
|
https *httpsmode.Manager
|
||||||
|
netTraffic *liveNetTracker
|
||||||
|
publicIPMu sync.RWMutex
|
||||||
|
publicIPs map[string]cachedPublicIP
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(options Options) (*Server, error) {
|
func New(options Options) (*Server, error) {
|
||||||
@@ -117,9 +126,13 @@ func New(options Options) (*Server, error) {
|
|||||||
websheets: newWebsheetManager(),
|
websheets: newWebsheetManager(),
|
||||||
loginLimiter: newLoginRateLimiter(),
|
loginLimiter: newLoginRateLimiter(),
|
||||||
extensions: options.Extensions,
|
extensions: options.Extensions,
|
||||||
|
exportProxy: options.ExportProxy,
|
||||||
developerEnabled: options.DeveloperEnabled,
|
developerEnabled: options.DeveloperEnabled,
|
||||||
updateRepository: strings.TrimSpace(options.UpdateRepository),
|
updateRepository: strings.TrimSpace(options.UpdateRepository),
|
||||||
updateToken: strings.TrimSpace(options.UpdateToken),
|
updateToken: strings.TrimSpace(options.UpdateToken),
|
||||||
|
https: options.HTTPS,
|
||||||
|
netTraffic: newLiveNetTracker(),
|
||||||
|
publicIPs: make(map[string]cachedPublicIP),
|
||||||
updateCheck: update.CheckLatest,
|
updateCheck: update.CheckLatest,
|
||||||
updateApply: update.ApplyLatest,
|
updateApply: update.ApplyLatest,
|
||||||
updateRestart: update.RestartService,
|
updateRestart: update.RestartService,
|
||||||
@@ -160,6 +173,10 @@ type VoWiFiCallController interface {
|
|||||||
HangupCall(context.Context, string, string) error
|
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) {
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
s.handler.ServeHTTP(w, r)
|
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) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
w.Header().Set("Referrer-Policy", "same-origin")
|
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/") {
|
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
|
// 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.
|
// 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'",
|
"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")
|
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
||||||
}
|
}
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"vocat/internal/store"
|
"vocat/internal/store"
|
||||||
@@ -932,18 +933,77 @@ func dialRestricted(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
dialer := net.Dialer{Timeout: clampNotificationTimeout(timeout)}
|
perAddress := clampNotificationTimeout(timeout)
|
||||||
var failures []error
|
stagger := 300 * time.Millisecond
|
||||||
for _, ip := range addresses {
|
if perAddress < stagger {
|
||||||
connection, err := dialer.DialContext(
|
stagger = perAddress / 2
|
||||||
ctx,
|
}
|
||||||
network,
|
|
||||||
net.JoinHostPort(ip.String(), port),
|
raceContext, cancel := context.WithCancel(ctx)
|
||||||
)
|
defer cancel()
|
||||||
if err == nil {
|
|
||||||
return connection, nil
|
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...))
|
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) {
|
if !requireMethod(w, r, http.MethodGet) {
|
||||||
return
|
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")))
|
rangeName := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("range")))
|
||||||
if rangeName == "" {
|
if rangeName == "" {
|
||||||
rangeName = "day"
|
rangeName = "day"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"vocat/internal/developer"
|
||||||
"vocat/internal/store"
|
"vocat/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -465,6 +466,12 @@ func TestCardPolicyDefaultValidationAndPersistence(t *testing.T) {
|
|||||||
|
|
||||||
func TestTrafficAnalysisUsesAndAggregatesStoredBuckets(t *testing.T) {
|
func TestTrafficAnalysisUsesAndAggregatesStoredBuckets(t *testing.T) {
|
||||||
test := newSettingsAPITest(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)
|
period := time.Now().UTC().Add(-time.Hour).Truncate(time.Minute)
|
||||||
for _, bucket := range []store.TrafficBucket{
|
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) {
|
func TestNotificationDestinationAddressPolicy(t *testing.T) {
|
||||||
blocked := []string{
|
blocked := []string{
|
||||||
"0.0.0.0", "10.0.0.1", "100.100.100.200", "127.0.0.1",
|
"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,
|
message.Index,
|
||||||
hex.EncodeToString(digest[:8]),
|
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{
|
extra, _ := json.Marshal(map[string]any{
|
||||||
"modem_index": message.Index,
|
"modem_index": message.Index,
|
||||||
"storage": message.Storage,
|
"storage": message.Storage,
|
||||||
|
|||||||
@@ -902,6 +902,15 @@ func (bot *telegramBot) notifyInboundSMS(ctx context.Context) {
|
|||||||
bot.warn("list Telegram SMS notifications", listErr)
|
bot.warn("list Telegram SMS notifications", listErr)
|
||||||
} else {
|
} else {
|
||||||
for _, message := range messages {
|
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)
|
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 {
|
if sendErr := bot.sendText(ctx, config, 0, text, nil); sendErr != nil {
|
||||||
bot.warn("send Telegram SMS notification", sendErr)
|
bot.warn("send Telegram SMS notification", sendErr)
|
||||||
|
|||||||
@@ -13,6 +13,27 @@ type contextExecer interface {
|
|||||||
ExecContext(context.Context, string, ...any) (sql.Result, error)
|
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 {
|
func (s *Store) UpsertDevice(ctx context.Context, value Device) error {
|
||||||
return upsertDevice(ctx, s.db, value)
|
return upsertDevice(ctx, s.db, value)
|
||||||
}
|
}
|
||||||
@@ -73,6 +94,10 @@ func upsertDevice(ctx context.Context, executor contextExecer, value Device) err
|
|||||||
if value.Name == "" {
|
if value.Name == "" {
|
||||||
return errors.New("device name is required")
|
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 {
|
if value.ProxyPort < 0 || value.ProxyPort > 65535 {
|
||||||
return errors.New("device proxy port must be between 0 and 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, `
|
_, err = executor.ExecContext(ctx, `
|
||||||
INSERT INTO devices (
|
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,
|
audio_device, modem_imei, apn, proxy_port, baud_rate,
|
||||||
data_bits, stop_bits, parity, device_backend, esim_transport,
|
data_bits, stop_bits, parity, device_backend, esim_transport,
|
||||||
qmi_use_proxy, qmi_proxy_path, qmi_proxy_executable,
|
qmi_use_proxy, qmi_proxy_path, qmi_proxy_executable,
|
||||||
network_enabled, sms_enabled, vowifi_enabled, extra_json,
|
network_enabled, sms_enabled, vowifi_enabled, extra_json,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
name = excluded.name,
|
name = excluded.name,
|
||||||
|
device_type = excluded.device_type,
|
||||||
interface = excluded.interface,
|
interface = excluded.interface,
|
||||||
control_device = excluded.control_device,
|
control_device = excluded.control_device,
|
||||||
at_port = excluded.at_port,
|
at_port = excluded.at_port,
|
||||||
@@ -165,7 +191,7 @@ func upsertDevice(ctx context.Context, executor contextExecer, value Device) err
|
|||||||
extra_json = excluded.extra_json,
|
extra_json = excluded.extra_json,
|
||||||
updated_at = excluded.updated_at
|
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.USBPath, value.AudioDevice, value.ModemIMEI, value.APN,
|
||||||
value.ProxyPort, value.BaudRate, value.DataBits, value.StopBits,
|
value.ProxyPort, value.BaudRate, value.DataBits, value.StopBits,
|
||||||
value.Parity, value.DeviceBackend, value.ESIMTransport,
|
value.Parity, value.DeviceBackend, value.ESIMTransport,
|
||||||
@@ -255,7 +281,7 @@ func (s *Store) DeleteDevice(ctx context.Context, id string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const deviceSelect = `
|
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,
|
audio_device, modem_imei, apn, proxy_port, baud_rate, data_bits,
|
||||||
stop_bits, parity, device_backend, esim_transport, qmi_use_proxy,
|
stop_bits, parity, device_backend, esim_transport, qmi_use_proxy,
|
||||||
qmi_proxy_path, qmi_proxy_executable, network_enabled, sms_enabled,
|
qmi_proxy_path, qmi_proxy_executable, network_enabled, sms_enabled,
|
||||||
@@ -268,7 +294,7 @@ func scanDevice(row rowScanner) (Device, error) {
|
|||||||
var extra string
|
var extra string
|
||||||
var createdAt, updatedAt int64
|
var createdAt, updatedAt int64
|
||||||
err := row.Scan(
|
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.ATPort, &value.USBPath, &value.AudioDevice, &value.ModemIMEI,
|
||||||
&value.APN, &value.ProxyPort, &value.BaudRate, &value.DataBits,
|
&value.APN, &value.ProxyPort, &value.BaudRate, &value.DataBits,
|
||||||
&value.StopBits, &value.Parity, &value.DeviceBackend,
|
&value.StopBits, &value.Parity, &value.DeviceBackend,
|
||||||
@@ -286,6 +312,7 @@ func scanDevice(row rowScanner) (Device, error) {
|
|||||||
value.NetworkEnabled = networkEnabled != 0
|
value.NetworkEnabled = networkEnabled != 0
|
||||||
value.SMSEnabled = smsEnabled != 0
|
value.SMSEnabled = smsEnabled != 0
|
||||||
value.VoWiFiEnabled = vowifiEnabled != 0
|
value.VoWiFiEnabled = vowifiEnabled != 0
|
||||||
|
value.DeviceType = NormalizeDeviceType(value.DeviceType)
|
||||||
value.Extra = []byte(extra)
|
value.Extra = []byte(extra)
|
||||||
value.CreatedAt = time.Unix(createdAt, 0).UTC()
|
value.CreatedAt = time.Unix(createdAt, 0).UTC()
|
||||||
value.UpdatedAt = time.Unix(updatedAt, 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) {
|
func TestMigration4PreservesIMSRedeliveryAndUsesReceiptTime(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
path := filepath.Join(t.TempDir(), "ims-redelivery.db")
|
path := filepath.Join(t.TempDir(), "ims-redelivery.db")
|
||||||
@@ -170,6 +205,7 @@ func TestDeviceStateRoundTripAndCascade(t *testing.T) {
|
|||||||
device := Device{
|
device := Device{
|
||||||
ID: "ec20-1",
|
ID: "ec20-1",
|
||||||
Name: "EC20 一号",
|
Name: "EC20 一号",
|
||||||
|
DeviceType: DeviceTypeDJI4G,
|
||||||
Interface: "wwan0",
|
Interface: "wwan0",
|
||||||
ControlDevice: "/dev/cdc-wdm0",
|
ControlDevice: "/dev/cdc-wdm0",
|
||||||
ATPort: "/dev/ttyUSB2",
|
ATPort: "/dev/ttyUSB2",
|
||||||
@@ -219,7 +255,7 @@ func TestDeviceStateRoundTripAndCascade(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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" {
|
gotDevice.StopBits != 1 || gotDevice.DeviceBackend != "at" {
|
||||||
t.Fatalf("device defaults not applied: %+v", gotDevice)
|
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
|
`CREATE INDEX IF NOT EXISTS sms_messages_hardware_thread_idx
|
||||||
ON sms_messages(modem_imei, imsi, peer, message_time DESC, id DESC)`,
|
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:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const SecretMask = "********"
|
|||||||
type Device struct {
|
type Device struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
|
DeviceType string
|
||||||
Interface string
|
Interface string
|
||||||
ControlDevice string
|
ControlDevice string
|
||||||
ATPort string
|
ATPort string
|
||||||
|
|||||||
@@ -65,6 +65,58 @@ func saveSMSMessage(
|
|||||||
return SMSMessage{}, fmt.Errorf("normalize SMS extra data: %w", err)
|
return SMSMessage{}, fmt.Errorf("normalize SMS extra data: %w", err)
|
||||||
}
|
}
|
||||||
now := time.Now().UTC()
|
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() {
|
if value.Timestamp.IsZero() {
|
||||||
value.Timestamp = now
|
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"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
const schemaVersion = 7
|
const schemaVersion = 8
|
||||||
|
|
||||||
var ErrNotFound = errors.New("store: not found")
|
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) {
|
for _, statement := range migrationStatements(nextVersion) {
|
||||||
if _, err := tx.ExecContext(ctx, statement); err != nil {
|
if _, err := tx.ExecContext(ctx, statement); err != nil {
|
||||||
// A database whose user_version was repaired or rolled back may
|
// A database whose user_version was repaired or rolled back may
|
||||||
// already contain this additive v7 column. The remaining v7 data
|
// already contain an additive column. Remaining statements in the
|
||||||
// backfill and indexes are still safe and must be applied.
|
// migration are still safe and must be applied.
|
||||||
if nextVersion == 7 &&
|
duplicateAdditiveColumn := (nextVersion == 7 && strings.Contains(statement, "ADD COLUMN modem_imei")) ||
|
||||||
strings.Contains(statement, "ADD COLUMN modem_imei") &&
|
(nextVersion == 8 && strings.Contains(statement, "ADD COLUMN device_type"))
|
||||||
strings.Contains(strings.ToLower(err.Error()), "duplicate column name") {
|
if duplicateAdditiveColumn && strings.Contains(strings.ToLower(err.Error()), "duplicate column name") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
_ = tx.Rollback()
|
_ = tx.Rollback()
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ type imsCall struct {
|
|||||||
remoteTag string
|
remoteTag string
|
||||||
routes []string
|
routes []string
|
||||||
terminated bool
|
terminated bool
|
||||||
|
media *rtpMedia
|
||||||
}
|
}
|
||||||
|
|
||||||
func (session *Session) Calls() []vowifi.Call {
|
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...)
|
routes := append([]string(nil), session.evidence.ServiceRoute...)
|
||||||
securityHeaders := runtimeSecurityHeaders(session.securityActive, session.securityAgreement.verifyValue)
|
securityHeaders := runtimeSecurityHeaders(session.securityActive, session.securityAgreement.verifyValue)
|
||||||
session.mu.Unlock()
|
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)
|
transportUpper := strings.ToUpper(session.transport)
|
||||||
from := "<" + session.identity.public + ">;tag=" + session.fromTag
|
from := "<" + session.identity.public + ">;tag=" + session.fromTag
|
||||||
to := "<" + target + ">"
|
to := "<" + target + ">"
|
||||||
@@ -108,6 +113,7 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal
|
|||||||
session.transactionsMu.Lock()
|
session.transactionsMu.Lock()
|
||||||
if _, duplicate := session.transactions[key]; duplicate {
|
if _, duplicate := session.transactions[key]; duplicate {
|
||||||
session.transactionsMu.Unlock()
|
session.transactionsMu.Unlock()
|
||||||
|
_ = media.Close()
|
||||||
return vowifi.Call{}, errors.New("ims: duplicate call transaction")
|
return vowifi.Call{}, errors.New("ims: duplicate call transaction")
|
||||||
}
|
}
|
||||||
session.transactions[key] = responses
|
session.transactions[key] = responses
|
||||||
@@ -115,7 +121,7 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal
|
|||||||
call := &imsCall{
|
call := &imsCall{
|
||||||
public: vowifi.Call{ID: callID, Number: number, Direction: "outgoing", State: "dialing", StartedAt: time.Now().UTC()},
|
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,
|
callID: callID, target: target, from: from, to: to, branch: branch, cseq: cseq, responses: responses,
|
||||||
routes: routes,
|
routes: routes, media: media,
|
||||||
}
|
}
|
||||||
session.callMu.Lock()
|
session.callMu.Lock()
|
||||||
session.calls[callID] = call
|
session.calls[callID] = call
|
||||||
@@ -124,6 +130,7 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal
|
|||||||
_, err = session.conn.Write(request)
|
_, err = session.conn.Write(request)
|
||||||
session.writeMu.Unlock()
|
session.writeMu.Unlock()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
_ = media.Close()
|
||||||
session.transactionsMu.Lock()
|
session.transactionsMu.Lock()
|
||||||
delete(session.transactions, key)
|
delete(session.transactions, key)
|
||||||
session.transactionsMu.Unlock()
|
session.transactionsMu.Unlock()
|
||||||
@@ -173,7 +180,18 @@ func (session *Session) watchOutgoingCall(call *imsCall, key sipTransactionKey)
|
|||||||
call.routes = reverseStrings(recordRoutes)
|
call.routes = reverseStrings(recordRoutes)
|
||||||
}
|
}
|
||||||
session.callMu.Unlock()
|
session.callMu.Unlock()
|
||||||
|
mediaErr := call.media.configureRemote(response.Body)
|
||||||
_ = session.sendACK(call)
|
_ = 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")
|
session.setCallState(call.callID, "active")
|
||||||
} else {
|
} else {
|
||||||
session.finishCall(call.callID, "failed", response.StatusCode, response.Reason)
|
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
|
request, respond := call.invite, call.respond
|
||||||
session.callMu.Unlock()
|
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 {
|
if err != nil {
|
||||||
return vowifi.Call{}, err
|
return vowifi.Call{}, err
|
||||||
}
|
}
|
||||||
@@ -204,6 +222,9 @@ func (session *Session) AnswerCall(_ context.Context, id string) (vowifi.Call, e
|
|||||||
return vowifi.Call{}, err
|
return vowifi.Call{}, err
|
||||||
}
|
}
|
||||||
session.setCallState(id, "active")
|
session.setCallState(id, "active")
|
||||||
|
if call.media.ready() {
|
||||||
|
session.setCallMediaReady(id)
|
||||||
|
}
|
||||||
session.callMu.Lock()
|
session.callMu.Lock()
|
||||||
result := call.public
|
result := call.public
|
||||||
session.callMu.Unlock()
|
session.callMu.Unlock()
|
||||||
@@ -255,10 +276,26 @@ func (session *Session) handleCallRequest(request *sipRequest, respond func([]by
|
|||||||
if target == "" {
|
if target == "" {
|
||||||
target = request.URI
|
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{
|
call := &imsCall{
|
||||||
public: vowifi.Call{ID: callID, Number: number, Direction: "incoming", State: "ringing", StartedAt: time.Now().UTC()},
|
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,
|
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.callMu.Lock()
|
||||||
session.calls[callID] = call
|
session.calls[callID] = call
|
||||||
@@ -269,6 +306,17 @@ func (session *Session) handleCallRequest(request *sipRequest, respond func([]by
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
case "ACK":
|
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
|
return true
|
||||||
case "CANCEL", "BYE":
|
case "CANCEL", "BYE":
|
||||||
response, err := buildSIPResponseWithBody(request, 200, session.fromTag, nil)
|
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"))
|
return []byte(strings.Join(lines, "\r\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (session *Session) inactiveSDP() []byte {
|
func (session *Session) localMediaIP() net.IP {
|
||||||
var localAddress net.Addr
|
var localAddress net.Addr
|
||||||
if session.conn != nil {
|
if session.conn != nil {
|
||||||
localAddress = session.conn.LocalAddr()
|
localAddress = session.conn.LocalAddr()
|
||||||
}
|
}
|
||||||
local := addressIP(localAddress)
|
return 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildSIPResponseWithBody(request *sipRequest, status int, tag string, body []byte) ([]byte, error) {
|
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]
|
reason := reasons[status]
|
||||||
if reason == "" {
|
if reason == "" {
|
||||||
return nil, errors.New("ims: unsupported call response status")
|
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()
|
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) {
|
func (session *Session) finishCall(id, state string, code int, reason string) {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
|
var media *rtpMedia
|
||||||
session.callMu.Lock()
|
session.callMu.Lock()
|
||||||
if call := session.calls[id]; call != nil {
|
if call := session.calls[id]; call != nil {
|
||||||
|
media = call.media
|
||||||
call.public.State = state
|
call.public.State = state
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
call.public.SIPCode = code
|
call.public.SIPCode = code
|
||||||
@@ -431,6 +494,9 @@ func (session *Session) finishCall(id, state string, code int, reason string) {
|
|||||||
call.public.EndedAt = &now
|
call.public.EndedAt = &now
|
||||||
}
|
}
|
||||||
session.callMu.Unlock()
|
session.callMu.Unlock()
|
||||||
|
if media != nil {
|
||||||
|
_ = media.Close()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func validCallNumber(value string) bool {
|
func validCallNumber(value string) bool {
|
||||||
@@ -500,3 +566,4 @@ func reverseStrings(values []string) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var _ vowifi.CallController = (*Session)(nil)
|
var _ vowifi.CallController = (*Session)(nil)
|
||||||
|
var _ vowifi.CallMediaController = (*Session)(nil)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"vocat/internal/vowifi"
|
"vocat/internal/vowifi"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestIncomingCallCanRingAndAnswerWithoutAudio(t *testing.T) {
|
func TestIncomingCallCanRingAndAnswerWithMediaOffer(t *testing.T) {
|
||||||
session := &Session{fromTag: "local-tag", calls: make(map[string]*imsCall)}
|
session := &Session{fromTag: "local-tag", calls: make(map[string]*imsCall)}
|
||||||
packet, err := parseSIPPacket([]byte(strings.Join([]string{
|
packet, err := parseSIPPacket([]byte(strings.Join([]string{
|
||||||
"INVITE sip:[email protected] SIP/2.0",
|
"INVITE sip:[email protected] SIP/2.0",
|
||||||
@@ -38,7 +38,7 @@ func TestIncomingCallCanRingAndAnswerWithoutAudio(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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])
|
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.smsContactConfirmed = false
|
||||||
session.clearAuthentication()
|
session.clearAuthentication()
|
||||||
session.mu.Unlock()
|
session.mu.Unlock()
|
||||||
|
session.callMu.Lock()
|
||||||
|
for _, call := range session.calls {
|
||||||
|
if call.media != nil {
|
||||||
|
_ = call.media.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
session.callMu.Unlock()
|
||||||
var cleanupErrors []error
|
var cleanupErrors []error
|
||||||
if unregisterErr != nil {
|
if unregisterErr != nil {
|
||||||
cleanupErrors = append(cleanupErrors, unregisterErr)
|
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 {
|
if err := session.conn.Close(); err != nil {
|
||||||
cleanupErrors = append(cleanupErrors, err)
|
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{
|
runtime := store.VoWiFiRuntime{
|
||||||
DeviceID: state.DeviceID,
|
DeviceID: state.DeviceID,
|
||||||
|
ICCID: strings.TrimSpace(state.ICCID),
|
||||||
|
IMSI: strings.TrimSpace(state.IMSI),
|
||||||
Phase: string(state.Phase),
|
Phase: string(state.Phase),
|
||||||
DataplaneMode: dataplaneMode(state),
|
DataplaneMode: dataplaneMode(state),
|
||||||
SIMReady: state.SIMReady,
|
SIMReady: state.SIMReady,
|
||||||
@@ -125,8 +127,15 @@ func (projector StateProjector) Save(
|
|||||||
}
|
}
|
||||||
if projector.Devices != nil {
|
if projector.Devices != nil {
|
||||||
if entry, err := projector.Devices.Get(state.DeviceID); err == nil && entry.Snapshot != nil {
|
if entry, err := projector.Devices.Get(state.DeviceID); err == nil && entry.Snapshot != nil {
|
||||||
runtime.ICCID = strings.TrimSpace(entry.Snapshot.ICCID)
|
// An active VoWiFi session belongs to the identity captured when it
|
||||||
runtime.IMSI = strings.TrimSpace(entry.Snapshot.IMSI)
|
// 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 != "" {
|
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 {
|
type staticDeviceReader struct {
|
||||||
iccid string
|
iccid string
|
||||||
imsi string
|
imsi string
|
||||||
|
|||||||
@@ -204,6 +204,8 @@ func (orchestrator *Orchestrator) Enable(ctx context.Context) (State, error) {
|
|||||||
}
|
}
|
||||||
orchestrator.mutate(func(state *State) {
|
orchestrator.mutate(func(state *State) {
|
||||||
state.Phase = PhaseSIMReady
|
state.Phase = PhaseSIMReady
|
||||||
|
state.ICCID = strings.TrimSpace(identity.ICCID)
|
||||||
|
state.IMSI = strings.TrimSpace(identity.IMSI)
|
||||||
state.SIMReady = true
|
state.SIMReady = true
|
||||||
state.HomeMCC = strings.TrimSpace(identity.HomeMCC)
|
state.HomeMCC = strings.TrimSpace(identity.HomeMCC)
|
||||||
state.HomeMNC = strings.TrimSpace(identity.HomeMNC)
|
state.HomeMNC = strings.TrimSpace(identity.HomeMNC)
|
||||||
@@ -404,6 +406,14 @@ func (orchestrator *Orchestrator) Disable(ctx context.Context) (State, error) {
|
|||||||
orchestrator.mutate(func(state *State) {
|
orchestrator.mutate(func(state *State) {
|
||||||
state.Phase = PhaseStopping
|
state.Phase = PhaseStopping
|
||||||
state.Enabled = false
|
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"
|
state.LastReason = "disable_requested"
|
||||||
})
|
})
|
||||||
if resources != nil && resources.cancel != nil {
|
if resources != nil && resources.cancel != nil {
|
||||||
@@ -546,6 +556,21 @@ func (orchestrator *Orchestrator) HangupCall(ctx context.Context, id string) err
|
|||||||
return 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(
|
func (orchestrator *Orchestrator) callAction(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
action func(CallController) (Call, error),
|
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 {
|
func (orchestrator *Orchestrator) cleanupCall(call func(context.Context) error) error {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), orchestrator.options.CleanupTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), orchestrator.options.CleanupTimeout)
|
||||||
defer cancel()
|
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() {
|
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 {
|
type fakeEnvironment struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
|
||||||
|
|||||||
@@ -320,6 +320,19 @@ func (manager *Manager) HangupCall(ctx context.Context, deviceID, id string) err
|
|||||||
return item.orchestrator.HangupCall(ctx, id)
|
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(
|
func (manager *Manager) startOperation(
|
||||||
deviceID string,
|
deviceID string,
|
||||||
coalesceReconnect bool,
|
coalesceReconnect bool,
|
||||||
|
|||||||
+30
-12
@@ -83,6 +83,8 @@ type SecurityAudit struct {
|
|||||||
// exists. Neither is proof of IMS registration.
|
// exists. Neither is proof of IMS registration.
|
||||||
type State struct {
|
type State struct {
|
||||||
DeviceID string `json:"device_id"`
|
DeviceID string `json:"device_id"`
|
||||||
|
ICCID string `json:"iccid,omitempty"`
|
||||||
|
IMSI string `json:"imsi,omitempty"`
|
||||||
Phase Phase `json:"phase"`
|
Phase Phase `json:"phase"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Active bool `json:"active"`
|
Active bool `json:"active"`
|
||||||
@@ -365,21 +367,23 @@ type SMSSender interface {
|
|||||||
SendSMS(context.Context, SMSSubmitRequest) (SMSSubmitResult, error)
|
SendSMS(context.Context, SMSSubmitRequest) (SMSSubmitResult, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call describes one signalling-only IMS call. VoCat intentionally does not
|
// Call describes one IMS call and reports whether an RTP media stream is
|
||||||
// open, capture, or relay an RTP media stream for extension call tests.
|
// available to an authenticated extension.
|
||||||
type Call struct {
|
type Call struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Number string `json:"number"`
|
Number string `json:"number"`
|
||||||
Direction string `json:"direction"`
|
Direction string `json:"direction"`
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
StartedAt time.Time `json:"started_at"`
|
StartedAt time.Time `json:"started_at"`
|
||||||
SIPCode int `json:"sip_code,omitempty"`
|
SIPCode int `json:"sip_code,omitempty"`
|
||||||
Reason string `json:"reason,omitempty"`
|
Reason string `json:"reason,omitempty"`
|
||||||
EndedAt *time.Time `json:"ended_at,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
|
// CallController is an optional capability of an IMS session. Media remains a
|
||||||
// manage SIP signalling only; audio handling is explicitly outside this API.
|
// separate optional interface so call signalling does not depend on a codec.
|
||||||
type CallController interface {
|
type CallController interface {
|
||||||
Calls() []Call
|
Calls() []Call
|
||||||
DialCall(context.Context, string) (Call, error)
|
DialCall(context.Context, string) (Call, error)
|
||||||
@@ -387,6 +391,20 @@ type CallController interface {
|
|||||||
HangupCall(context.Context, string) error
|
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.
|
// PhoneStore persists a number only after it was explicitly associated by IMS.
|
||||||
type PhoneStore interface {
|
type PhoneStore interface {
|
||||||
SaveAssociatedNumber(context.Context, PhoneRecord) error
|
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
|
# 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.
|
# renames it over the running binary. Keep the rest of the host read-only.
|
||||||
ReadWritePaths=/opt/vocat/data /opt/vocat/bin
|
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
|
RestrictRealtime=true
|
||||||
LockPersonality=true
|
LockPersonality=true
|
||||||
MemoryDenyWriteExecute=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 DashboardPage from "./pages/DashboardPage";
|
||||||
import DevicesPage from "./pages/DevicesPage";
|
import DevicesPage from "./pages/DevicesPage";
|
||||||
import ProxyPage from "./pages/ProxyPage";
|
import ProxyPage from "./pages/ProxyPage";
|
||||||
|
import ExportProxyPage from "./pages/ExportProxyPage";
|
||||||
import SmsPage from "./pages/SmsPage";
|
import SmsPage from "./pages/SmsPage";
|
||||||
import LogsPage from "./pages/LogsPage";
|
import LogsPage from "./pages/LogsPage";
|
||||||
import SettingsPage from "./pages/SettingsPage";
|
import SettingsPage from "./pages/SettingsPage";
|
||||||
@@ -110,6 +111,7 @@ function AppRoot() {
|
|||||||
<Route index element={<DashboardPage />} />
|
<Route index element={<DashboardPage />} />
|
||||||
<Route path="devices/*" element={<DevicesPage />} />
|
<Route path="devices/*" element={<DevicesPage />} />
|
||||||
<Route path="proxy" element={<ProxyPage />} />
|
<Route path="proxy" element={<ProxyPage />} />
|
||||||
|
<Route path="export-proxy" element={<ExportProxyPage />} />
|
||||||
<Route path="sms" element={<SmsPage />} />
|
<Route path="sms" element={<SmsPage />} />
|
||||||
<Route path="extensions/:pluginId/:contributionId" element={<ExtensionPage />} />
|
<Route path="extensions/:pluginId/:contributionId" element={<ExtensionPage />} />
|
||||||
<Route path="logs" element={<LogsPage />} />
|
<Route path="logs" element={<LogsPage />} />
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
Cellular3GRegular, Cellular4GRegular, Cellular5GRegular, CellularData1Regular,
|
Cellular3GRegular, Cellular4GRegular, Cellular5GRegular, CellularData1Regular,
|
||||||
RouterRegular, Wifi1Regular,
|
Wifi1Regular,
|
||||||
} from "@fluentui/react-icons";
|
} from "@fluentui/react-icons";
|
||||||
import type { DashboardDevice } from "../types";
|
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 { StatusDot } from "./ui/StatusDot";
|
||||||
import { useI18n } from "../lib/i18n";
|
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 second = words.length > 1 ? words[1] : words[0] || "";
|
||||||
const isLte = second.toUpperCase() === "LTE";
|
const isLte = second.toUpperCase() === "LTE";
|
||||||
const bars = signalBars(device.signalDbm);
|
const bars = signalBars(device.signalDbm);
|
||||||
const brandImg = isEC20Model(device.model);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -41,13 +41,7 @@ export function DeviceCard({ device, onOpen }: { device: DashboardDevice; onOpen
|
|||||||
<div className="relative z-10 p-6">
|
<div className="relative z-10 p-6">
|
||||||
<div className="mb-6 flex items-start justify-between">
|
<div className="mb-6 flex items-start justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{brandImg ? (
|
<img src={deviceTypeImage(device.deviceType)} alt="" className="h-10 w-10 flex-shrink-0 object-contain" />
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-bold text-gray-800 dark:text-gray-100">{device.name || device.id}</h3>
|
<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">
|
<div className="mt-0.5 flex items-center gap-1.5">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Button } from "../ui";
|
import { Button } from "../ui";
|
||||||
import type { OperatorCandidate } from "./types";
|
import type { OperatorCandidate } from "./types";
|
||||||
import { useI18n } from "../../lib/i18n";
|
import { useI18n } from "../../lib/i18n";
|
||||||
|
import { flagEmoji } from "../../lib/carrier";
|
||||||
|
|
||||||
function ratsText(c: OperatorCandidate): string {
|
function ratsText(c: OperatorCandidate): string {
|
||||||
const list = (c.rats || []).filter(Boolean) as string[];
|
const list = (c.rats || []).filter(Boolean) as string[];
|
||||||
@@ -20,6 +21,7 @@ export function CandidateRow({ candidate, onLock }: { candidate: OperatorCandida
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 font-medium text-gray-900 dark:text-white">
|
<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.operatorName || c.shortName || t("未知网络")}{" "}
|
||||||
{c.status === "current" ? (
|
{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">
|
<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 { DiscoveredDevice } from "../../types";
|
||||||
import type { AddDeviceForm } from "./types";
|
import type { AddDeviceForm } from "./types";
|
||||||
import { useI18n } from "../../lib/i18n";
|
import { useI18n } from "../../lib/i18n";
|
||||||
|
import { DEVICE_TYPES, deviceTypeImage } from "../../lib/deviceTypes";
|
||||||
|
|
||||||
export interface DeviceAddDialogProps {
|
export interface DeviceAddDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -123,6 +124,22 @@ export function DeviceAddDialog(props: DeviceAddDialogProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<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">
|
<Field label="ID">
|
||||||
<Input value={addConfig.id} onChange={(e) => set({ id: e.target.value })} placeholder={t("例如 ec20_3")} />
|
<Input value={addConfig.id} onChange={(e) => set({ id: e.target.value })} placeholder={t("例如 ec20_3")} />
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { isQmiControl } from "./shared";
|
|||||||
import type { DeviceConfig } from "../../types";
|
import type { DeviceConfig } from "../../types";
|
||||||
import type { DeviceDetail } from "./types";
|
import type { DeviceDetail } from "./types";
|
||||||
import { useI18n } from "../../lib/i18n";
|
import { useI18n } from "../../lib/i18n";
|
||||||
|
import { DEVICE_TYPES, deviceTypeImage } from "../../lib/deviceTypes";
|
||||||
|
|
||||||
export interface DeviceConfigTabProps {
|
export interface DeviceConfigTabProps {
|
||||||
editConfig: DeviceConfig | null;
|
editConfig: DeviceConfig | null;
|
||||||
@@ -78,6 +79,18 @@ export function DeviceConfigTab({ editConfig, deviceStatus, saving, deleting, on
|
|||||||
<Field label={t("名称")}>
|
<Field label={t("名称")}>
|
||||||
<Input value={editConfig.name} onChange={(e) => onEditConfig({ ...editConfig, name: e.target.value })} placeholder={t("显示名称")} />
|
<Input value={editConfig.name} onChange={(e) => onEditConfig({ ...editConfig, name: e.target.value })} placeholder={t("显示名称")} />
|
||||||
</Field>
|
</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 绑定")}>
|
<Field label={t("IMEI 绑定")}>
|
||||||
<Input value={editConfig.modemImei || ""} disabled placeholder={t("自动识别(添加时绑定)")} />
|
<Input value={editConfig.modemImei || ""} disabled placeholder={t("自动识别(添加时绑定)")} />
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { ArrowSyncRegular, PowerRegular, ChatRegular } from "@fluentui/react-icons";
|
import { ArrowSyncRegular, PowerRegular, ChatRegular } from "@fluentui/react-icons";
|
||||||
import { Button } from "../ui";
|
import { Button, Switch } from "../ui";
|
||||||
import type { DeviceDetail } from "./types";
|
import type { DeviceDetail } from "./types";
|
||||||
import { useI18n } from "../../lib/i18n";
|
import { useI18n } from "../../lib/i18n";
|
||||||
import { isEC20Model } from "../../lib/utils";
|
import { deviceTypeImage } from "../../lib/deviceTypes";
|
||||||
|
|
||||||
export interface DeviceDetailHeaderProps {
|
export interface DeviceDetailHeaderProps {
|
||||||
device: DeviceDetail;
|
device: DeviceDetail;
|
||||||
rotating: boolean;
|
dataToggling: boolean;
|
||||||
rebooting: boolean;
|
rebooting: boolean;
|
||||||
reconnectingVoWiFi: boolean;
|
reconnectingVoWiFi: boolean;
|
||||||
onCopyText: (text: string) => void;
|
onCopyText: (text: string) => void;
|
||||||
onRotateIp: () => void;
|
onToggleRoamingData: (enabled: boolean) => void;
|
||||||
onReconnectVowifi: () => void;
|
onReconnectVowifi: () => void;
|
||||||
onRebootModem: () => void;
|
onRebootModem: () => void;
|
||||||
onOpenSms: () => void;
|
onOpenSms: () => void;
|
||||||
@@ -19,23 +19,13 @@ export interface DeviceDetailHeaderProps {
|
|||||||
export function DeviceDetailHeader(props: DeviceDetailHeaderProps) {
|
export function DeviceDetailHeader(props: DeviceDetailHeaderProps) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { device } = props;
|
const { device } = props;
|
||||||
const vowifiInUse = device.vowifiEnabled || device.vowifiActive || device.vowifiRuntime?.smsReady;
|
const vowifiInUse = !!device.vowifiEnabled;
|
||||||
const brandImg = isEC20Model(device.modem?.model);
|
|
||||||
return (
|
return (
|
||||||
<div className="ui-card p-6">
|
<div className="ui-card p-6">
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{brandImg ? (
|
<img src={deviceTypeImage(device.deviceType)} alt="" className="h-11 w-11 flex-shrink-0 object-contain" />
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
<div className="min-w-0">
|
<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="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">
|
<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 />}>
|
<Button loading={props.reconnectingVoWiFi} onClick={props.onReconnectVowifi} className="ui-glass-border !border-0" icon={<ArrowSyncRegular />}>
|
||||||
{t("重连 VoWiFi")}
|
{t("重连 VoWiFi")}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : device.developerEnabled ? (
|
||||||
<Button
|
<div
|
||||||
loading={props.rotating}
|
className="ui-glass-border flex h-8 items-center gap-2 rounded-lg px-3 text-sm text-gray-700 dark:text-gray-200"
|
||||||
disabled={!device?.networkConnected}
|
title={t("蜂窝数据仅进入 Export Proxy 的受保护路由,不会成为主机默认出口")}
|
||||||
onClick={props.onRotateIp}
|
>
|
||||||
className="ui-glass-border !border-0"
|
<span>{t("漫游数据")}</span>
|
||||||
icon={<ArrowSyncRegular />}
|
<Switch
|
||||||
>
|
checked={!!device.networkEnabled}
|
||||||
{t("切换 IP")}
|
loading={props.dataToggling}
|
||||||
</Button>
|
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 />}>
|
<Button loading={props.rebooting} onClick={props.onRebootModem} className="ui-glass-border !border-0 hover:!text-red-600" icon={<PowerRegular />}>
|
||||||
{t("重启模组")}
|
{t("重启模组")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { DeviceListItem } from "../../types";
|
|||||||
import { cx } from "../../lib/utils";
|
import { cx } from "../../lib/utils";
|
||||||
import { Tag, StatusDot } from "../ui";
|
import { Tag, StatusDot } from "../ui";
|
||||||
import { deviceStatusMeta } from "./shared";
|
import { deviceStatusMeta } from "./shared";
|
||||||
|
import { deviceTypeImage } from "../../lib/deviceTypes";
|
||||||
|
|
||||||
export interface DeviceListItemCardProps {
|
export interface DeviceListItemCardProps {
|
||||||
device: DeviceListItem;
|
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="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="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">
|
<div className="mt-0.5 truncate text-xs text-gray-500">
|
||||||
{device.id} · {device.interface || "--"}
|
{device.id} · {device.interface || "--"}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { OverviewNetworkCard } from "./OverviewNetworkCard";
|
|||||||
import { OverviewVowifiCard } from "./OverviewVowifiCard";
|
import { OverviewVowifiCard } from "./OverviewVowifiCard";
|
||||||
import { OverviewSimPanel } from "./OverviewSimPanel";
|
import { OverviewSimPanel } from "./OverviewSimPanel";
|
||||||
import { OverviewNetworkPanel } from "./OverviewNetworkPanel";
|
import { OverviewNetworkPanel } from "./OverviewNetworkPanel";
|
||||||
|
import { OverviewTrafficChart } from "./OverviewTrafficChart";
|
||||||
import { OperatorSelectionDialog } from "./OperatorSelectionDialog";
|
import { OperatorSelectionDialog } from "./OperatorSelectionDialog";
|
||||||
import type { DeviceDetail } from "./types";
|
import type { DeviceDetail } from "./types";
|
||||||
import { useI18n } from "../../lib/i18n";
|
import { useI18n } from "../../lib/i18n";
|
||||||
@@ -24,28 +25,31 @@ export function DeviceOverviewTab(props: DeviceOverviewTabProps) {
|
|||||||
const [operatorOpen, setOperatorOpen] = useState(false);
|
const [operatorOpen, setOperatorOpen] = useState(false);
|
||||||
const { device } = props;
|
const { device } = props;
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
<div className="space-y-4">
|
||||||
<div className="ui-panel-muted p-4">
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||||
<div className="mb-3 text-xs font-bold uppercase tracking-wider text-gray-500">{t("运行状态")}</div>
|
<div className="ui-panel-muted p-4">
|
||||||
{device?.vowifiEnabled ? (
|
<div className="mb-3 text-xs font-bold uppercase tracking-wider text-gray-500">{t("运行状态")}</div>
|
||||||
<OverviewVowifiCard device={device} />
|
{device?.vowifiEnabled ? (
|
||||||
) : (
|
<OverviewVowifiCard device={device} />
|
||||||
<OverviewNetworkCard device={device} onOpenOperatorSelection={() => setOperatorOpen(true)} />
|
) : (
|
||||||
)}
|
<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>
|
</div>
|
||||||
<OverviewSimPanel
|
{device.developerEnabled && device.networkEnabled && device.id ? <OverviewTrafficChart deviceId={device.id} /> : null}
|
||||||
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?.id ? (
|
{device?.id ? (
|
||||||
<OperatorSelectionDialog
|
<OperatorSelectionDialog
|
||||||
open={operatorOpen}
|
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) {
|
async function lock(c: OperatorCandidate) {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
registerAbortRef.current = controller;
|
registerAbortRef.current = controller;
|
||||||
@@ -203,10 +223,13 @@ export function OperatorSelectionDialog({ open, deviceId, scanBlockedReason = ""
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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">
|
<Button variant="primary" plain onClick={startStream} loading={scanning} disabled={busy || !!scanBlockedReason} className="flex-1">
|
||||||
{scanning ? t("扫描中...") : t("扫描可用网络")}
|
{scanning ? t("扫描中...") : t("扫描可用网络")}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button onClick={reRegister} disabled={busy} className="flex-1">
|
||||||
|
{t("重新驻网")}
|
||||||
|
</Button>
|
||||||
<Button onClick={restoreAuto} disabled={busy || current?.mode === "automatic"} className="flex-1">
|
<Button onClick={restoreAuto} disabled={busy || current?.mode === "automatic"} className="flex-1">
|
||||||
{t("恢复自动选网")}
|
{t("恢复自动选网")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -239,7 +262,7 @@ export function OperatorSelectionDialog({ open, deviceId, scanBlockedReason = ""
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{candidates.length > 0 ? (
|
{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) => (
|
{candidates.map((c) => (
|
||||||
<CandidateRow key={`${c.plmn}-${ratsText(c)}`} candidate={c} onLock={lock} />
|
<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 { isDeviceOnline, isRegistered, isRecoveringPhase, lifecycleLabel, signalLevel, signalTone } from "./shared";
|
||||||
import type { DeviceDetail } from "./types";
|
import type { DeviceDetail } from "./types";
|
||||||
import { useI18n } from "../../lib/i18n";
|
import { useI18n } from "../../lib/i18n";
|
||||||
|
import { flagEmoji } from "../../lib/carrier";
|
||||||
|
|
||||||
const BAR_HEIGHTS = ["h-[28%]", "h-[46%]", "h-[64%]", "h-[82%]", "h-full"];
|
const BAR_HEIGHTS = ["h-[28%]", "h-[46%]", "h-[64%]", "h-[82%]", "h-full"];
|
||||||
const TEXT_TONE = {
|
const TEXT_TONE = {
|
||||||
@@ -25,7 +26,9 @@ export function OverviewNetworkCard({ device, onOpenOperatorSelection }: { devic
|
|||||||
const modem = device.modem;
|
const modem = device.modem;
|
||||||
const online = isDeviceOnline(device);
|
const online = isDeviceOnline(device);
|
||||||
const cellularRegistered = isRegistered(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 registered = cellularRegistered || vowifiRegistered;
|
||||||
const radioOffForVowifi = vowifiRegistered && (modem?.operatingMode === 0 || modem?.operatingMode === 4 || device.flightMode);
|
const radioOffForVowifi = vowifiRegistered && (modem?.operatingMode === 0 || modem?.operatingMode === 4 || device.flightMode);
|
||||||
const tone = isRecoveringPhase(device.lifecyclePhase) ? "warning" : online ? (registered ? "success" : "warning") : "danger";
|
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 level = signalLevel(modem?.signalDbm);
|
||||||
const sigTone = signalTone(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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -71,7 +83,7 @@ export function OverviewNetworkCard({ device, onOpenOperatorSelection }: { devic
|
|||||||
<>{t("WiFi Calling 已注册")}</>
|
<>{t("WiFi Calling 已注册")}</>
|
||||||
) : registered ? (
|
) : registered ? (
|
||||||
<>
|
<>
|
||||||
{modem?.operator || "--"}{" "}
|
{modem?.operatorCountryCode ? `${flagEmoji(modem.operatorCountryCode)} ` : ""}{modem?.operator || "--"}{" "}
|
||||||
{modem?.networkMode ? <span className="opacity-70">· {netMode}</span> : null}
|
{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={netMode || "--"} monospace />
|
||||||
<FieldRow label={t("频段")} value={modem?.radioBand || "--"} monospace />
|
<FieldRow label={t("频段")} value={modem?.radioBand || "--"} monospace />
|
||||||
<FieldRow label={t("信道")} value={modem?.radioChannel ? String(modem.radioChannel) : "--"} 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>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
import { FieldRow } from "./FieldRow";
|
import { FieldRow } from "./FieldRow";
|
||||||
import type { DeviceDetail } from "./types";
|
import type { DeviceDetail } from "./types";
|
||||||
import { useI18n } from "../../lib/i18n";
|
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 {
|
export interface OverviewNetworkPanelProps {
|
||||||
device: DeviceDetail;
|
device: DeviceDetail;
|
||||||
@@ -11,32 +24,90 @@ export interface OverviewNetworkPanelProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function OverviewNetworkPanel({ device, trafficMinuteRx, trafficMinuteTx, trafficSpeedRx, trafficSpeedTx }: 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 traffic = device.traffic || {};
|
||||||
const metaStatus = device.trafficMeta?.status;
|
const metaStatus = device.trafficMeta?.status;
|
||||||
const sampleNote = metaStatus === "waiting_sample" ? t("等待采样") : metaStatus === "stale" ? t("采样中断") : "";
|
const sampleNote = metaStatus === "waiting_sample" ? t("等待采样") : metaStatus === "stale" ? t("采样中断") : "";
|
||||||
const off = t("数据未开启");
|
const off = !device.networkEnabled;
|
||||||
|
|
||||||
const minuteRx = trafficMinuteRx || sampleNote || traffic.rx;
|
const minuteRx = trafficMinuteRx || sampleNote || traffic.rx;
|
||||||
const minuteTx = trafficMinuteTx || sampleNote || traffic.tx;
|
const minuteTx = trafficMinuteTx || sampleNote || traffic.tx;
|
||||||
const speedRx = trafficSpeedRx || sampleNote || traffic.rate || "--";
|
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 (
|
return (
|
||||||
<div className="ui-panel-muted p-4">
|
<div className="ui-panel-muted p-4">
|
||||||
<div className="mb-2 text-xs font-bold uppercase tracking-wider text-gray-500">{t("网络")}</div>
|
<div className="mb-2 text-xs font-bold uppercase tracking-wider text-gray-500">{t("网络")}</div>
|
||||||
{off ? (
|
<div className="space-y-1.5 text-sm text-gray-700 dark:text-gray-200">
|
||||||
<div className="flex items-center justify-center p-6 text-sm text-gray-400">{off}</div>
|
<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="space-y-1.5 text-sm text-gray-700 dark:text-gray-200">
|
<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("内网 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={minuteTx} monospace />
|
||||||
<FieldRow label={t("近1分钟下载")} value={minuteRx} monospace />
|
<FieldRow label={t("近1分钟下载")} value={minuteRx} monospace />
|
||||||
<FieldRow label={t("实时下载速率")} value={speedRx} monospace />
|
<FieldRow label={t("实时下载速率")} value={speedRx} monospace />
|
||||||
<FieldRow label={t("实时上传速率")} value={speedTx} monospace />
|
<FieldRow label={t("实时上传速率")} value={speedTx} monospace />
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</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