mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-13 03:13:43 +08:00
- Introduced a new field `custom_phone_number` in the CardPolicy model and database schema. - Updated the API to handle custom phone number input, including validation and normalization. - Modified the CardPolicyPanel component to allow users to set and save a custom phone number. - Enhanced the settings API to include the custom phone number in responses and updates. - Added tests to ensure the correct functionality of custom phone number handling. - Removed hardcoded environment variable for VOCAT_ADDR in service files.
1141 lines
36 KiB
Go
1141 lines
36 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"golang.org/x/term"
|
|
|
|
"vocat/internal/auth"
|
|
"vocat/internal/config"
|
|
"vocat/internal/developer"
|
|
"vocat/internal/device"
|
|
"vocat/internal/exportproxy"
|
|
"vocat/internal/extensions"
|
|
"vocat/internal/httpsmode"
|
|
"vocat/internal/loghub"
|
|
"vocat/internal/server"
|
|
"vocat/internal/store"
|
|
"vocat/internal/update"
|
|
"vocat/internal/vowifi"
|
|
"vocat/internal/vowifi/ike"
|
|
"vocat/internal/vowifi/ims"
|
|
"vocat/internal/vowifi/integration"
|
|
vowifiruntime "vocat/internal/vowifi/runtime"
|
|
"vocat/web"
|
|
)
|
|
|
|
func main() {
|
|
logs := loghub.New(slog.NewJSONHandler(os.Stdout, nil), 2000)
|
|
logger := slog.New(logs)
|
|
|
|
args := os.Args[1:]
|
|
switch subcommand, rest := splitSubcommand(args); subcommand {
|
|
case "":
|
|
// No subcommand: TTY+root → interactive menu (operator on the host);
|
|
// otherwise run the server. systemd runs vocat with stdin=/dev/null
|
|
// (non-TTY) so the unit keeps starting the server unchanged. Non-root
|
|
// on a TTY also falls through to the server rather than erroring on
|
|
// runMenu's root requirement.
|
|
if term.IsTerminal(int(os.Stdin.Fd())) && os.Geteuid() == 0 {
|
|
if err := runMenu(logger); err != nil {
|
|
logger.Error("menu failed", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
} else {
|
|
if err := run(logger, logs); err != nil {
|
|
logger.Error("server stopped", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
case "serve":
|
|
// Explicit foreground server. Use this when vocat with no arguments
|
|
// would otherwise enter the menu (root on a TTY) but a server is wanted.
|
|
if err := run(logger, logs); err != nil {
|
|
logger.Error("server stopped", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
case "version", "-v", "--version":
|
|
runVersion()
|
|
case "update":
|
|
if err := update.Run(logger, rest); err != nil {
|
|
logger.Error("update failed", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
case "menu":
|
|
if err := runMenu(logger); err != nil {
|
|
logger.Error("menu failed", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
case "develop":
|
|
// Hidden subcommand: intentionally not listed in printUsage or the
|
|
// interactive menu. It toggles the developer-mode flag that gates the
|
|
// entire plugin/extension system; the flag takes effect on next start.
|
|
if err := runDevelop(rest, logger); err != nil {
|
|
logger.Error("develop failed", "error", err)
|
|
os.Exit(2)
|
|
}
|
|
case "help", "-h", "--help":
|
|
printUsage(os.Stdout)
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "vocat: unknown subcommand %q\n\n", subcommand)
|
|
printUsage(os.Stderr)
|
|
os.Exit(2)
|
|
}
|
|
}
|
|
|
|
// splitSubcommand returns the first non-flag token as the subcommand and the
|
|
// remaining args. An empty arg list yields ("", nil) → server mode.
|
|
func splitSubcommand(args []string) (string, []string) {
|
|
if len(args) == 0 {
|
|
return "", nil
|
|
}
|
|
return args[0], args[1:]
|
|
}
|
|
|
|
func run(logger *slog.Logger, logs *loghub.Hub) error {
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
return fmt.Errorf("load configuration: %w", err)
|
|
}
|
|
if cfg.UsesDefaultCredentials() {
|
|
logger.Warn(
|
|
"default admin credentials are active; set VOCAT_ADMIN_PASSWORD before exposing the service",
|
|
)
|
|
}
|
|
|
|
startupContext, cancelStartup := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancelStartup()
|
|
|
|
database, err := store.Open(startupContext, cfg.DatabasePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer database.Close()
|
|
developerEnabled := isDeveloperEnabled(startupContext, database)
|
|
pluginRoot := filepath.Join(filepath.Dir(cfg.DatabasePath), "plugins")
|
|
legacyExportProxyConfig := filepath.Join(pluginRoot, exportproxy.ReservedID, "data", "configs.json")
|
|
if !developerEnabled {
|
|
if err := developer.ResetExperimental(startupContext, database); err != nil {
|
|
return fmt.Errorf("reset disabled developer settings: %w", err)
|
|
}
|
|
if err := exportproxy.RemoveLegacyConfig(legacyExportProxyConfig); err != nil {
|
|
return fmt.Errorf("remove legacy export proxy configuration: %w", err)
|
|
}
|
|
}
|
|
httpsManager, err := httpsmode.New(
|
|
startupContext,
|
|
database,
|
|
filepath.Join(filepath.Dir(cfg.DatabasePath), "tls"),
|
|
cfg.Address,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("configure self-signed HTTPS: %w", err)
|
|
}
|
|
|
|
// The plugin/extension system is gated behind a hidden developer-mode flag.
|
|
// When off (the default) the manager is never created and the server receives
|
|
// a nil Extensions handle, so every /extensions* and /plugin-assets/* route
|
|
// returns 503/404 and the SPA hides the plugin surface.
|
|
var extensionManager *extensions.Manager
|
|
var exportProxyManager *exportproxy.Manager
|
|
if developerEnabled {
|
|
exportProxyManager, err = exportproxy.New(startupContext, database, logger, legacyExportProxyConfig)
|
|
if err != nil {
|
|
return fmt.Errorf("create built-in export proxy: %w", err)
|
|
}
|
|
defer exportProxyManager.Close()
|
|
extensionManager, err = extensions.NewManager(
|
|
pluginRoot,
|
|
logger,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("create plugin manager: %w", err)
|
|
}
|
|
defer extensionManager.Close()
|
|
} else {
|
|
logger.Info("developer mode is off; plugin system disabled")
|
|
}
|
|
|
|
authService, err := auth.New(database, auth.Options{
|
|
SessionTTL: cfg.SessionTTL,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := authService.EnsureAdmin(
|
|
startupContext,
|
|
cfg.AdminUsername,
|
|
cfg.AdminPassword,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
|
|
deviceManager, err := device.NewManager(device.Options{})
|
|
if err != nil {
|
|
return fmt.Errorf("create device manager: %w", err)
|
|
}
|
|
if err := deviceManager.Start(startupContext); err != nil {
|
|
logger.Warn("device discovery is not available at startup", "error", err)
|
|
}
|
|
if err := provisionDiscoveredDevices(startupContext, database, deviceManager); err != nil {
|
|
logger.Warn("automatic first-run device provisioning failed", "error", err)
|
|
}
|
|
configureDeviceBackends(startupContext, logger, database, deviceManager)
|
|
restoreDefaultCellularRadios(startupContext, logger, database, deviceManager)
|
|
defer func() {
|
|
stopContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if err := deviceManager.Stop(stopContext); err != nil {
|
|
logger.Warn("stop device manager", "error", err)
|
|
}
|
|
}()
|
|
pollContext, cancelPolling := context.WithCancel(context.Background())
|
|
defer cancelPolling()
|
|
go pollDeviceSnapshots(pollContext, logger, database, deviceManager)
|
|
go restoreConfiguredCellularData(pollContext, logger, database, deviceManager)
|
|
go collectCellularTraffic(pollContext, logger, database)
|
|
go persistLogsToStore(pollContext, logger, logs, database)
|
|
if !developerEnabled {
|
|
go disableAllDeveloperCellularData(pollContext, logger, database, deviceManager)
|
|
} else {
|
|
go watchDeveloperDisable(pollContext, logger, database, deviceManager, exportProxyManager, legacyExportProxyConfig)
|
|
}
|
|
|
|
vowifiManager, err := configureVoWiFiRuntime(
|
|
startupContext,
|
|
logger,
|
|
database,
|
|
deviceManager,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("configure VoWiFi runtime: %w", err)
|
|
}
|
|
go reconcileCardPolicies(pollContext, logger, database, deviceManager, vowifiManager)
|
|
defer func() {
|
|
stopContext, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
if err := vowifiManager.Close(stopContext); err != nil {
|
|
logger.Warn("stop VoWiFi runtime", "error", err)
|
|
}
|
|
}()
|
|
|
|
handler, err := server.New(server.Options{
|
|
Store: database,
|
|
Auth: authService,
|
|
Devices: deviceManager,
|
|
VoWiFi: vowifiManager,
|
|
Logs: logs,
|
|
Assets: web.Dist,
|
|
Logger: logger,
|
|
SecureCookies: cfg.SecureCookies,
|
|
MaxRequestBodyBytes: cfg.MaxRequestBodyBytes,
|
|
Extensions: extensionManager,
|
|
ExportProxy: exportProxyManager,
|
|
DeveloperEnabled: developerEnabled,
|
|
UpdateRepository: strings.TrimSpace(os.Getenv("VOCAT_REPO")),
|
|
UpdateToken: strings.TrimSpace(os.Getenv("GITHUB_TOKEN")),
|
|
HTTPS: httpsManager,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
go handler.StartLogRetentionLoop(pollContext, time.Minute)
|
|
go handler.StartSMSSyncLoop(pollContext, 15*time.Second)
|
|
handler.StartTelegramBot(pollContext)
|
|
handler.StartSMSNotificationDispatchers(pollContext)
|
|
handler.StartAutomaticTasks(pollContext)
|
|
|
|
serverConfig := func(handler http.Handler) *http.Server {
|
|
return &http.Server{
|
|
Addr: cfg.Address,
|
|
Handler: handler,
|
|
ReadHeaderTimeout: 5 * time.Second,
|
|
ReadTimeout: 15 * time.Second,
|
|
WriteTimeout: 30 * time.Second,
|
|
IdleTimeout: 90 * time.Second,
|
|
MaxHeaderBytes: 1 << 20,
|
|
}
|
|
}
|
|
plainHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if httpsManager.Enabled() {
|
|
host := strings.TrimSpace(r.Host)
|
|
if host == "" {
|
|
host = cfg.Address
|
|
}
|
|
http.Redirect(w, r, "https://"+host+r.URL.RequestURI(), http.StatusPermanentRedirect)
|
|
return
|
|
}
|
|
handler.ServeHTTP(w, r)
|
|
})
|
|
plainServer := serverConfig(plainHandler)
|
|
tlsServer := serverConfig(handler)
|
|
baseListener, err := net.Listen("tcp", cfg.Address)
|
|
if err != nil {
|
|
return fmt.Errorf("listen on %s: %w", cfg.Address, err)
|
|
}
|
|
protocolMux := httpsmode.NewMultiplexer(baseListener, httpsManager)
|
|
|
|
signalContext, stopSignals := signal.NotifyContext(
|
|
context.Background(),
|
|
os.Interrupt,
|
|
syscall.SIGTERM,
|
|
)
|
|
defer stopSignals()
|
|
|
|
serverError := make(chan error, 2)
|
|
go func() {
|
|
logger.Info("HTTP server listening", "address", cfg.Address, "self_signed_https", httpsManager.Enabled())
|
|
err := plainServer.Serve(protocolMux.Plain())
|
|
if errors.Is(err, http.ErrServerClosed) {
|
|
err = nil
|
|
}
|
|
serverError <- err
|
|
}()
|
|
go func() {
|
|
err := tlsServer.Serve(tls.NewListener(protocolMux.TLS(), httpsManager.TLSConfig()))
|
|
if errors.Is(err, http.ErrServerClosed) {
|
|
err = nil
|
|
}
|
|
serverError <- err
|
|
}()
|
|
|
|
select {
|
|
case err := <-serverError:
|
|
_ = protocolMux.Close()
|
|
return err
|
|
case <-signalContext.Done():
|
|
logger.Info("shutdown signal received")
|
|
}
|
|
// Long-lived SSE and polling handlers use this context. Stop them before
|
|
// http.Server.Shutdown so they do not consume the entire graceful-shutdown
|
|
// deadline while waiting for a stream that is intentionally still active.
|
|
cancelPolling()
|
|
|
|
shutdownContext, cancelShutdown := context.WithTimeout(
|
|
context.Background(),
|
|
cfg.ShutdownTimeout,
|
|
)
|
|
defer cancelShutdown()
|
|
shutdownErrors := make(chan error, 2)
|
|
go func() { shutdownErrors <- plainServer.Shutdown(shutdownContext) }()
|
|
go func() { shutdownErrors <- tlsServer.Shutdown(shutdownContext) }()
|
|
time.Sleep(10 * time.Millisecond)
|
|
_ = protocolMux.Close()
|
|
for range 2 {
|
|
if err := <-shutdownErrors; err != nil {
|
|
_ = plainServer.Close()
|
|
_ = tlsServer.Close()
|
|
return fmt.Errorf("graceful HTTP shutdown: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func configureDeviceBackends(
|
|
ctx context.Context,
|
|
logger *slog.Logger,
|
|
database *store.Store,
|
|
manager *device.Manager,
|
|
) {
|
|
configs, err := database.ListDevices(ctx)
|
|
if err != nil {
|
|
logger.Warn("configure device backends: list devices", "error", err)
|
|
return
|
|
}
|
|
mapper := integration.ATMapper{Store: database, Devices: manager}
|
|
for _, config := range configs {
|
|
entry, mapErr := mapper.Get(config.ID)
|
|
if mapErr != nil {
|
|
continue
|
|
}
|
|
if err := manager.SetBackend(entry.ID, config.DeviceBackend); err != nil {
|
|
logger.Warn("configure device backend", "device_id", config.ID, "backend", config.DeviceBackend, "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// restoreDefaultCellularRadios applies an explicitly saved cellular policy
|
|
// after restart. Missing policies remain RF-off and are claimed by the safe
|
|
// default policy; there is no automatic cellular fallback.
|
|
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 == "" {
|
|
continue
|
|
}
|
|
if iccid != "" {
|
|
policy, policyErr := database.CardPolicy(ctx, iccid)
|
|
switch {
|
|
case policyErr == nil && policy.AirplaneEnabled:
|
|
continue
|
|
case errors.Is(policyErr, store.ErrNotFound):
|
|
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
|
|
}
|
|
networkRequest := device.NetworkRequest{
|
|
Enabled: true, APN: config.APN, IPVersion: "IPV4V6", Backend: config.DeviceBackend,
|
|
}
|
|
if entry.Snapshot != nil {
|
|
iccid := strings.TrimSpace(entry.Snapshot.ICCID)
|
|
if policy, policyErr := database.CardPolicy(ctx, iccid); policyErr == nil {
|
|
networkRequest.APN = policy.APN
|
|
if policy.IPVersion != "" {
|
|
networkRequest.IPVersion = policy.IPVersion
|
|
}
|
|
if profile, profileErr := database.CardAPNProfileByAPN(ctx, iccid, policy.APN, policy.IPVersion); profileErr == nil {
|
|
networkRequest.Username = profile.Username
|
|
networkRequest.Password = profile.Password
|
|
networkRequest.Authentication = profile.AuthType
|
|
if entry.Snapshot.RegistrationStatus == 5 && profile.RoamingIPVersion != "" {
|
|
networkRequest.IPVersion = profile.RoamingIPVersion
|
|
}
|
|
}
|
|
}
|
|
}
|
|
dataContext, cancel := context.WithTimeout(ctx, 60*time.Second)
|
|
_, err = manager.SetNetwork(dataContext, entry.ID, networkRequest)
|
|
cancel()
|
|
if err != nil {
|
|
logger.Warn("startup cellular data recovery failed", "device_id", config.ID)
|
|
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, Backend: config.DeviceBackend})
|
|
cancel()
|
|
if err != nil && ctx.Err() == nil {
|
|
logger.Warn("developer cleanup: stop cellular data", "device_id", config.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
func configureVoWiFiRuntime(
|
|
ctx context.Context,
|
|
logger *slog.Logger,
|
|
database *store.Store,
|
|
deviceManager *device.Manager,
|
|
) (*vowifiruntime.Manager, error) {
|
|
mapper := integration.ATMapper{
|
|
Store: database,
|
|
Devices: deviceManager,
|
|
}
|
|
adapter, err := vowifi.NewEC20Adapter(mapper, vowifi.EC20AdapterOptions{
|
|
// The test deployment is deliberately non-cellular. VoWiFi teardown
|
|
// may restore CFUN, but it must never reactivate a PDP context.
|
|
RestoreCellularData: false,
|
|
// VoWiFi is always fail-closed with respect to cellular RF. Its teardown
|
|
// leaves CFUN=4; only the explicit airplane-mode-off endpoint restores
|
|
// CFUN=1.
|
|
PureAirplanePolicy: func(deviceID string) bool {
|
|
deviceConfig, configErr := database.Device(context.Background(), deviceID)
|
|
return configErr == nil && deviceConfig.VoWiFiEnabled
|
|
},
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
projector := integration.StateProjector{
|
|
Store: database,
|
|
Devices: mapper,
|
|
}
|
|
manager := vowifiruntime.New(vowifiruntime.Options{
|
|
Logger: logger,
|
|
OnState: projector.Save,
|
|
Factory: func(factoryContext context.Context, deviceID string) (*vowifi.Orchestrator, error) {
|
|
deviceConfig, err := database.Device(factoryContext, deviceID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load device %q VoWiFi config: %w", deviceID, err)
|
|
}
|
|
return newVoWiFiOrchestrator(deviceConfig, database, adapter)
|
|
},
|
|
})
|
|
|
|
configured, err := database.ListDevices(ctx)
|
|
if err != nil {
|
|
_ = manager.Close(context.Background())
|
|
return nil, err
|
|
}
|
|
for _, deviceConfig := range configured {
|
|
if err := manager.Ensure(ctx, deviceConfig.ID); err != nil {
|
|
_ = manager.Close(context.Background())
|
|
return nil, fmt.Errorf("register device %q VoWiFi runtime: %w", deviceConfig.ID, err)
|
|
}
|
|
if deviceConfig.VoWiFiEnabled {
|
|
if entry, mapErr := mapper.Get(deviceConfig.ID); mapErr == nil {
|
|
flightContext, cancelFlight := context.WithTimeout(ctx, 10*time.Second)
|
|
_, flightErr := deviceManager.SetFlight(flightContext, entry.ID, true)
|
|
cancelFlight()
|
|
if flightErr != nil {
|
|
_ = manager.Close(context.Background())
|
|
return nil, fmt.Errorf("protect device %q before VoWiFi startup: %w", deviceConfig.ID, flightErr)
|
|
}
|
|
}
|
|
if _, err := manager.RequestEnabled(deviceConfig.ID, true); err != nil {
|
|
_ = manager.Close(context.Background())
|
|
return nil, fmt.Errorf("start device %q VoWiFi policy: %w", deviceConfig.ID, err)
|
|
}
|
|
}
|
|
}
|
|
return manager, nil
|
|
}
|
|
|
|
func newVoWiFiOrchestrator(
|
|
deviceConfig store.Device,
|
|
database *store.Store,
|
|
adapter *vowifi.EC20Adapter,
|
|
) (*vowifi.Orchestrator, error) {
|
|
apn := deviceConfig.APN
|
|
if apn == "" {
|
|
apn = "ims"
|
|
}
|
|
tunnelProvider, err := ike.NewProvider(ike.Config{APN: apn})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("device %q IKE provider: %w", deviceConfig.ID, err)
|
|
}
|
|
imsProvider, err := ims.NewProvider(adapter, ims.Config{
|
|
// The userspace SWu data plane currently carries the protected P-CSCF
|
|
// signalling path over TCP.
|
|
Transport: "tcp",
|
|
// Some Vodafone UK SIM profiles leave AT+CSCA empty; Vodafone publishes
|
|
// this service-centre number for manual SMS setup.
|
|
SMSCenter: "+447785016005",
|
|
OnSMS: func(ctx context.Context, message ims.ReceivedSMS) error {
|
|
extra, _ := json.Marshal(map[string]any{
|
|
"transport": "ims",
|
|
"encoding": message.Encoding,
|
|
"concat": message.Concat,
|
|
"rp_reference": message.RPReference,
|
|
"call_id": message.CallID,
|
|
"received_at": message.Timestamp,
|
|
"service_center_timestamp": message.ServiceCenterTimestamp,
|
|
"raw_rpdu": message.RawRPDU,
|
|
"raw_tpdu": message.RawTPDU,
|
|
})
|
|
partsTotal := 1
|
|
if message.Concat != nil && message.Concat.Total > 0 {
|
|
partsTotal = message.Concat.Total
|
|
}
|
|
messageID := message.MessageID
|
|
if message.Concat != nil && message.Concat.Total > 1 {
|
|
// A segment of a carrier-split long SMS over IMS. Address the whole
|
|
// message with a stable id so SaveSMSMessage folds every segment
|
|
// into one progressively merged row instead of one row per segment.
|
|
messageID = store.StableConcatMessageID(
|
|
"ims", deviceConfig.ModemIMEI, message.DeviceID, message.From,
|
|
message.Concat.Reference, message.Concat.Total,
|
|
)
|
|
}
|
|
_, saveErr := database.SaveSMSMessage(ctx, store.SMSMessage{
|
|
MessageID: messageID,
|
|
DeviceID: message.DeviceID,
|
|
ModemIMEI: deviceConfig.ModemIMEI,
|
|
IMSI: message.IMSI,
|
|
Peer: message.From,
|
|
Direction: "inbound",
|
|
Body: message.Text,
|
|
Timestamp: message.Timestamp,
|
|
Status: "received",
|
|
Source: "ims",
|
|
PartsTotal: partsTotal,
|
|
Read: false,
|
|
Extra: extra,
|
|
})
|
|
return saveErr
|
|
},
|
|
OnSMSStatus: func(ctx context.Context, report ims.ReceivedSMSStatus) error {
|
|
deliveryReport := store.SMSDeliveryReport{
|
|
DeviceID: report.DeviceID,
|
|
ModemIMEI: deviceConfig.ModemIMEI,
|
|
IMSI: report.IMSI,
|
|
Peer: report.To,
|
|
Source: "ims",
|
|
MessageReference: report.MessageReference,
|
|
StatusCode: report.StatusCode,
|
|
DeliveryState: report.DeliveryStatus,
|
|
ServiceCenterTime: report.ServiceCenterTimestamp,
|
|
DischargeTime: report.DischargeTimestamp,
|
|
ReceivedAt: report.Timestamp,
|
|
}
|
|
var applyErr error
|
|
for attempt := 0; attempt < 10; attempt++ {
|
|
_, applyErr = database.ApplySMSDeliveryReport(ctx, deliveryReport)
|
|
if !errors.Is(applyErr, store.ErrNotFound) {
|
|
return applyErr
|
|
}
|
|
// A status report can race the API handler persisting the SIP 202
|
|
// result. Give that write a brief chance to complete.
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-time.After(100 * time.Millisecond):
|
|
}
|
|
}
|
|
// A late report from before this process started must still be
|
|
// acknowledged, otherwise the SMSC will keep retransmitting it.
|
|
return nil
|
|
},
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("device %q IMS provider: %w", deviceConfig.ID, err)
|
|
}
|
|
orchestrator, err := vowifi.New(vowifi.Dependencies{
|
|
SIM: adapter,
|
|
AKA: adapter,
|
|
Radio: adapter,
|
|
Proxy: integration.ProxyResolver{Store: database},
|
|
Tunnel: tunnelProvider,
|
|
IMS: imsProvider,
|
|
Phones: integration.PhoneStore{Store: database, DeviceID: deviceConfig.ID},
|
|
}, vowifi.Options{
|
|
DeviceID: deviceConfig.ID,
|
|
AllowIMSWithoutSMS: true,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("device %q VoWiFi orchestrator: %w", deviceConfig.ID, err)
|
|
}
|
|
return orchestrator, nil
|
|
}
|
|
|
|
func provisionDiscoveredDevices(
|
|
ctx context.Context,
|
|
database *store.Store,
|
|
manager *device.Manager,
|
|
) error {
|
|
configured, err := database.ListDevices(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(configured) != 0 {
|
|
return nil
|
|
}
|
|
for _, discovered := range manager.List() {
|
|
candidate := discovered.Candidate
|
|
backend := "at"
|
|
control := candidate.ATPort.OpenPath()
|
|
if candidate.QMIControl != "" {
|
|
backend = "qmi"
|
|
control = candidate.QMIControl
|
|
}
|
|
name := candidate.Product
|
|
if name == "" || strings.EqualFold(name, "Android") {
|
|
name = "Quectel EC20 / EC25"
|
|
}
|
|
if err := database.UpsertDevice(ctx, store.Device{
|
|
ID: discovered.ID,
|
|
Name: name,
|
|
Interface: candidate.NetworkInterface,
|
|
ControlDevice: control,
|
|
ATPort: candidate.ATPort.OpenPath(),
|
|
USBPath: candidate.USBPath,
|
|
ProxyPort: 1080,
|
|
BaudRate: 115200,
|
|
DataBits: 8,
|
|
StopBits: 1,
|
|
Parity: "none",
|
|
DeviceBackend: backend,
|
|
ESIMTransport: backend,
|
|
NetworkEnabled: false,
|
|
SMSEnabled: true,
|
|
VoWiFiEnabled: true,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// persistLogsToStore subscribes to the live log hub and durably appends every
|
|
// entry to the log_events table, so runtime logs survive restarts and can be
|
|
// pruned by the configured retention policy.
|
|
func persistLogsToStore(
|
|
ctx context.Context,
|
|
logger *slog.Logger,
|
|
logs *loghub.Hub,
|
|
database *store.Store,
|
|
) {
|
|
entries, cancel := logs.Subscribe(512)
|
|
defer cancel()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case entry, ok := <-entries:
|
|
if !ok {
|
|
return
|
|
}
|
|
var fields json.RawMessage
|
|
if len(entry.Fields) > 0 {
|
|
if raw, err := json.Marshal(entry.Fields); err == nil {
|
|
fields = raw
|
|
}
|
|
}
|
|
if _, err := database.AppendLogEvent(ctx, store.LogEvent{
|
|
Time: entry.Time,
|
|
Level: entry.Level,
|
|
Message: entry.Message,
|
|
Caller: entry.Caller,
|
|
Fields: fields,
|
|
}); err != nil && ctx.Err() == nil {
|
|
logger.Warn("persist log event failed", "error", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func pollDeviceSnapshots(
|
|
ctx context.Context,
|
|
logger *slog.Logger,
|
|
database *store.Store,
|
|
manager *device.Manager,
|
|
) {
|
|
refresh := func() {
|
|
discoveryContext, cancelDiscovery := context.WithTimeout(ctx, 10*time.Second)
|
|
_, err := manager.Discover(discoveryContext)
|
|
cancelDiscovery()
|
|
if err != nil {
|
|
logger.Debug("periodic modem discovery failed", "error", err)
|
|
return
|
|
}
|
|
// Hotplug can replace the physical discovery ID. Rebind each configured
|
|
// device's selected QMI/AT control plane before collecting its snapshot.
|
|
configureDeviceBackends(ctx, logger, database, manager)
|
|
entries := manager.List()
|
|
// Each physical modem owns its own operation lock. Refresh them in
|
|
// parallel so a slow or wedged EC20 on one hub port cannot delay signal
|
|
// and identity updates for every other modem by 30 seconds at a time.
|
|
var refreshGroup sync.WaitGroup
|
|
refreshSlots := make(chan struct{}, 4)
|
|
for _, entry := range entries {
|
|
if !entry.Discovered {
|
|
continue
|
|
}
|
|
entry := entry
|
|
refreshGroup.Add(1)
|
|
go func() {
|
|
defer refreshGroup.Done()
|
|
select {
|
|
case refreshSlots <- struct{}{}:
|
|
defer func() { <-refreshSlots }()
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
refreshContext, cancelRefresh := context.WithTimeout(ctx, 30*time.Second)
|
|
snapshot, refreshErr := manager.Refresh(refreshContext, entry.ID)
|
|
cancelRefresh()
|
|
if refreshErr != nil && ctx.Err() == nil {
|
|
logger.Warn("modem snapshot refresh failed", "device_id", entry.ID, "error", refreshErr)
|
|
}
|
|
if refreshErr == nil && ctx.Err() == nil {
|
|
enforceCardRegion(ctx, logger, database, manager, entry.ID, &snapshot)
|
|
enforceDefaultSafeCardPolicy(ctx, logger, database, manager, entry.ID, &snapshot)
|
|
}
|
|
}()
|
|
}
|
|
refreshGroup.Wait()
|
|
}
|
|
refresh()
|
|
ticker := time.NewTicker(30 * time.Second)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
refresh()
|
|
}
|
|
}
|
|
}
|
|
|
|
// enforceDefaultSafeCardPolicy handles a newly inserted physical SIM or a
|
|
// profile that has never had a policy. RF is turned off before the default is
|
|
// persisted; the VoWiFi runtime reconciler then starts service asynchronously.
|
|
func enforceDefaultSafeCardPolicy(
|
|
ctx context.Context,
|
|
logger *slog.Logger,
|
|
database *store.Store,
|
|
manager *device.Manager,
|
|
physicalID string,
|
|
snapshot *device.Snapshot,
|
|
) {
|
|
if snapshot == nil || !snapshot.SIMReady || strings.TrimSpace(snapshot.ICCID) == "" ||
|
|
device.RegionBlockReason(snapshot.IMSI) != "" {
|
|
return
|
|
}
|
|
iccid := strings.TrimSpace(snapshot.ICCID)
|
|
if _, err := database.CardPolicy(ctx, iccid); err == nil {
|
|
return
|
|
} else if !errors.Is(err, store.ErrNotFound) {
|
|
logger.Warn("default card policy: read policy", "iccid", iccid, "error", err)
|
|
return
|
|
}
|
|
flightContext, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
_, err := manager.SetFlight(flightContext, physicalID, true)
|
|
cancel()
|
|
if err != nil {
|
|
logger.Warn("default card policy: failed to establish airplane mode", "device_id", physicalID, "iccid", iccid, "error", err)
|
|
return
|
|
}
|
|
if err := database.UpsertCardPolicy(ctx, store.CardPolicy{
|
|
ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true,
|
|
IPVersion: "IPV4V6", Source: "default",
|
|
}); err != nil {
|
|
logger.Warn("default card policy: persist policy", "iccid", iccid, "error", err)
|
|
return
|
|
}
|
|
mapper := integration.ATMapper{Store: database, Devices: manager}
|
|
configs, err := database.ListDevices(ctx)
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, config := range configs {
|
|
entry, mapErr := mapper.Get(config.ID)
|
|
if mapErr != nil || entry.ID != physicalID {
|
|
continue
|
|
}
|
|
config.NetworkEnabled = false
|
|
config.VoWiFiEnabled = true
|
|
if err := database.UpsertDevice(ctx, config); err != nil {
|
|
logger.Warn("default card policy: update device policy", "device_id", config.ID, "error", err)
|
|
}
|
|
break
|
|
}
|
|
logger.Info("new SIM protected by default VoWiFi/airplane policy", "device_id", physicalID, "iccid", iccid)
|
|
}
|
|
|
|
func reconcileCardPolicies(
|
|
ctx context.Context,
|
|
logger *slog.Logger,
|
|
database *store.Store,
|
|
manager *device.Manager,
|
|
vowifiManager *vowifiruntime.Manager,
|
|
) {
|
|
reconcile := func() {
|
|
policies, policyListErr := database.ListCardPolicies(ctx)
|
|
if policyListErr == nil {
|
|
for _, policy := range policies {
|
|
if !policy.VoWiFiEnabled || (policy.AirplaneEnabled && !policy.NetworkEnabled) {
|
|
continue
|
|
}
|
|
policy.AirplaneEnabled = true
|
|
policy.NetworkEnabled = false
|
|
if err := database.UpsertCardPolicy(ctx, policy); err != nil {
|
|
logger.Warn("reconcile card policy: normalize stored RF-safe VoWiFi policy", "iccid", policy.ICCID, "error", err)
|
|
}
|
|
}
|
|
}
|
|
configs, err := database.ListDevices(ctx)
|
|
if err != nil {
|
|
return
|
|
}
|
|
mapper := integration.ATMapper{Store: database, Devices: manager}
|
|
for _, config := range configs {
|
|
entry, mapErr := mapper.Get(config.ID)
|
|
if mapErr != nil || entry.Snapshot == nil {
|
|
continue
|
|
}
|
|
iccid := strings.TrimSpace(entry.Snapshot.ICCID)
|
|
if iccid == "" {
|
|
continue
|
|
}
|
|
policy, policyErr := database.CardPolicy(ctx, iccid)
|
|
if policyErr != nil {
|
|
continue
|
|
}
|
|
if policy.VoWiFiEnabled && (!policy.AirplaneEnabled || policy.NetworkEnabled) {
|
|
policy.AirplaneEnabled = true
|
|
policy.NetworkEnabled = false
|
|
if err := database.UpsertCardPolicy(ctx, policy); err != nil {
|
|
logger.Warn("reconcile card policy: normalize RF-safe VoWiFi policy", "device_id", config.ID, "iccid", iccid, "error", err)
|
|
continue
|
|
}
|
|
}
|
|
deviceChanged := false
|
|
if config.VoWiFiEnabled != policy.VoWiFiEnabled || (policy.VoWiFiEnabled && config.NetworkEnabled) {
|
|
config.VoWiFiEnabled = policy.VoWiFiEnabled
|
|
if policy.VoWiFiEnabled {
|
|
config.NetworkEnabled = false
|
|
}
|
|
deviceChanged = true
|
|
}
|
|
if config.APN != strings.TrimSpace(policy.APN) {
|
|
config.APN = strings.TrimSpace(policy.APN)
|
|
deviceChanged = true
|
|
}
|
|
if deviceChanged {
|
|
if err := database.UpsertDevice(ctx, config); err != nil {
|
|
logger.Warn("reconcile card policy: update device", "device_id", config.ID, "error", err)
|
|
continue
|
|
}
|
|
}
|
|
state, stateErr := vowifiManager.State(config.ID)
|
|
if policy.VoWiFiEnabled {
|
|
if !entry.Snapshot.FlightMode {
|
|
flightContext, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
_, _ = manager.SetFlight(flightContext, entry.ID, true)
|
|
cancel()
|
|
}
|
|
switch {
|
|
case stateErr != nil || !state.Enabled:
|
|
_, _ = vowifiManager.RequestEnabled(config.ID, true)
|
|
case state.ICCID != "" && !strings.EqualFold(strings.TrimSpace(state.ICCID), iccid):
|
|
_, _ = vowifiManager.RequestReconnect(config.ID)
|
|
}
|
|
continue
|
|
}
|
|
if stateErr == nil && state.Enabled {
|
|
_, _ = vowifiManager.RequestEnabled(config.ID, false)
|
|
continue
|
|
}
|
|
if policy.AirplaneEnabled != entry.Snapshot.FlightMode {
|
|
flightContext, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
_, _ = manager.SetFlight(flightContext, entry.ID, policy.AirplaneEnabled)
|
|
cancel()
|
|
}
|
|
}
|
|
}
|
|
reconcile()
|
|
ticker := time.NewTicker(5 * time.Second)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
reconcile()
|
|
}
|
|
}
|
|
}
|
|
|
|
// cardPolicySourceRegionBlock marks a card policy that was written automatically
|
|
// because the inserted SIM belongs to a region the product does not serve. It
|
|
// doubles as the persistent record that the radio was forced off by us, so the
|
|
// block survives restarts and can be lifted when an allowed card is detected.
|
|
const cardPolicySourceRegionBlock = "auto_region_block"
|
|
|
|
// enforceCardRegion applies the regional service policy for one refreshed
|
|
// device. A SIM whose IMSI home MCC is blocked (mainland China, 460/461) is
|
|
// denied service: the radio is forced into airplane mode and a blocking card
|
|
// policy is persisted. The check is fail-open — it only acts on a positively
|
|
// read blocked IMSI — and the lift path only runs once the current card is
|
|
// positively confirmed to be allowed, so an unreadable IMSI never causes a
|
|
// block or a spurious restore.
|
|
func enforceCardRegion(
|
|
ctx context.Context,
|
|
logger *slog.Logger,
|
|
database *store.Store,
|
|
manager *device.Manager,
|
|
id string,
|
|
snapshot *device.Snapshot,
|
|
) {
|
|
if snapshot == nil || !snapshot.SIMReady {
|
|
return
|
|
}
|
|
imsi := strings.TrimSpace(snapshot.IMSI)
|
|
if imsi == "" {
|
|
// Region unknown: hold the current state rather than block or restore.
|
|
return
|
|
}
|
|
if reason := device.RegionBlockReason(imsi); reason != "" {
|
|
if !snapshot.FlightMode {
|
|
flightContext, cancelFlight := context.WithTimeout(ctx, 30*time.Second)
|
|
_, err := manager.SetFlight(flightContext, id, true)
|
|
cancelFlight()
|
|
if err != nil && ctx.Err() == nil {
|
|
logger.Warn(
|
|
"region block: failed to force airplane mode",
|
|
"device_id", id, "error", err,
|
|
)
|
|
}
|
|
}
|
|
if snapshot.ICCID != "" {
|
|
policy, policyErr := database.CardPolicy(ctx, snapshot.ICCID)
|
|
if errors.Is(policyErr, store.ErrNotFound) {
|
|
policy = store.CardPolicy{ICCID: snapshot.ICCID, IPVersion: "IPV4V6"}
|
|
policyErr = nil
|
|
}
|
|
policy.NetworkEnabled = false
|
|
policy.VoWiFiEnabled = false
|
|
policy.AirplaneEnabled = true
|
|
policy.Source = cardPolicySourceRegionBlock
|
|
if policyErr != nil && ctx.Err() == nil {
|
|
logger.Warn("region block: failed to read card policy", "device_id", id, "iccid", snapshot.ICCID, "error", policyErr)
|
|
} else if err := database.UpsertCardPolicy(ctx, policy); err != nil && ctx.Err() == nil {
|
|
logger.Warn(
|
|
"region block: failed to persist card policy",
|
|
"device_id", id, "iccid", snapshot.ICCID, "error", err,
|
|
)
|
|
}
|
|
}
|
|
logger.Warn(
|
|
"blocked SIM detected; service disabled and radio forced off",
|
|
"device_id", id, "iccid", snapshot.ICCID, "imsi", imsi, "reason", reason,
|
|
)
|
|
return
|
|
}
|
|
liftCardRegionBlock(ctx, logger, database, manager, id, snapshot)
|
|
}
|
|
|
|
// liftCardRegionBlock removes the regional marker once an allowed SIM is
|
|
// confirmed. It deliberately does not restore RF: the replacement SIM is
|
|
// picked up by enforceDefaultSafeCardPolicy and remains in airplane/VoWiFi
|
|
// mode until an explicit user action.
|
|
func liftCardRegionBlock(
|
|
ctx context.Context,
|
|
logger *slog.Logger,
|
|
database *store.Store,
|
|
manager *device.Manager,
|
|
id string,
|
|
snapshot *device.Snapshot,
|
|
) {
|
|
policies, err := database.ListCardPolicies(ctx)
|
|
if err != nil {
|
|
if ctx.Err() == nil {
|
|
logger.Warn("region block: failed to list card policies", "error", err)
|
|
}
|
|
return
|
|
}
|
|
outstanding := make([]store.CardPolicy, 0, 1)
|
|
for _, policy := range policies {
|
|
if policy.Source == cardPolicySourceRegionBlock {
|
|
outstanding = append(outstanding, policy)
|
|
}
|
|
}
|
|
if len(outstanding) == 0 {
|
|
return
|
|
}
|
|
for _, policy := range outstanding {
|
|
if err := database.DeleteCardPolicy(ctx, policy.ICCID); err != nil && ctx.Err() == nil {
|
|
logger.Warn(
|
|
"region block: failed to clear auto policy",
|
|
"iccid", policy.ICCID, "error", err,
|
|
)
|
|
}
|
|
}
|
|
logger.Info(
|
|
"region marker removed; allowed SIM remains RF protected",
|
|
"device_id", id, "iccid", snapshot.ICCID, "imsi", snapshot.IMSI,
|
|
)
|
|
}
|