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/pcsc" "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) } instanceLock, err := lockServerInstance(cfg.DatabasePath) if err != nil { return err } defer instanceLock.Close() 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 } cardReaders := pcsc.New() deviceManager, err := device.NewManager(device.Options{CardReaders: cardReaders}) 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, cardReaders, ) 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 config.DeviceType == store.DeviceTypeUSBSIMReader { if err := manager.SetSIMPin(entry.ID, config.SIMPIN); err != nil { logger.Warn("configure USB SIM reader", "device_id", config.ID, "error", err) } 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.DeviceType == store.DeviceTypeUSBSIMReader { continue } 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.DeviceType == store.DeviceTypeUSBSIMReader { continue } 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 { if config.DeviceType == store.DeviceTypeUSBSIMReader { continue } 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, cardReaders *pcsc.Service, ) (*vowifiruntime.Manager, error) { mapper := integration.ATMapper{ Store: database, Devices: deviceManager, } ec20Adapter, 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 } pcscAdapter, err := vowifi.NewPCSCAdapter(cardReaders, func(ctx context.Context, deviceID string) (pcsc.Selector, string, error) { config, resolveErr := database.Device(ctx, strings.TrimSpace(deviceID)) if resolveErr != nil { return pcsc.Selector{}, "", resolveErr } return pcsc.Selector{USBPath: config.USBPath, ReaderName: config.ControlDevice}, config.SIMPIN, nil }) 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) } adapter := vowifiDeviceAdapter(ec20Adapter) if deviceConfig.DeviceType == store.DeviceTypeUSBSIMReader { adapter = pcscAdapter } 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 { flightErr := protectVoWiFiStartupRadio(ctx, deviceManager, entry.ID) if flightErr != nil { // A modem can be temporarily unavailable while OpenWrt/procd is // restarting the service (notably after loading XFRM modules). Do // not take the Web/API service down with it: the orchestrator below // remains fail-closed and its runtime manager retries until CFUN=4 // can be established. logger.Warn( "VoWiFi startup radio protection deferred to automatic retry", "device_id", deviceConfig.ID, "error", 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 } const ( vowifiStartupRadioAttempts = 3 vowifiStartupRadioDelay = time.Second ) type flightModeSetter interface { SetFlight(context.Context, string, bool) (device.FlightResult, error) } func protectVoWiFiStartupRadio(ctx context.Context, manager flightModeSetter, physicalID string) error { return protectVoWiFiStartupRadioWithRetry( ctx, manager, physicalID, vowifiStartupRadioAttempts, vowifiStartupRadioDelay, ) } func protectVoWiFiStartupRadioWithRetry( ctx context.Context, manager flightModeSetter, physicalID string, attempts int, delay time.Duration, ) error { var lastErr error for attempt := 0; attempt < attempts; attempt++ { flightContext, cancel := context.WithTimeout(ctx, 10*time.Second) _, lastErr = manager.SetFlight(flightContext, physicalID, true) cancel() if lastErr == nil { return nil } if attempt+1 == attempts { break } timer := time.NewTimer(delay) select { case <-ctx.Done(): if !timer.Stop() { <-timer.C } return errors.Join(lastErr, ctx.Err()) case <-timer.C: } } return lastErr } type vowifiDeviceAdapter interface { vowifi.SIMIdentityReader vowifi.AKAProvider vowifi.RadioController } func newVoWiFiOrchestrator( deviceConfig store.Device, database *store.Store, adapter vowifiDeviceAdapter, ) (*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() deviceType := store.DeviceTypePCIeEC20EC25 esimTransport := backend if candidate.QMIControl != "" { backend = "qmi" control = candidate.QMIControl esimTransport = backend } if candidate.HardwareKind == pcsc.HardwareKind { backend = "pcsc" control = candidate.ReaderName deviceType = store.DeviceTypeUSBSIMReader esimTransport = "pcsc" } 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, DeviceType: deviceType, 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: esimTransport, 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, ) { observedCards := make(map[string]string) 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 { if config.DeviceType == store.DeviceTypeUSBSIMReader && observedCards[config.ID] != "missing" { if state, stateErr := vowifiManager.State(config.ID); stateErr == nil && state.ICCID != "" { _, _ = vowifiManager.RequestReconnect(config.ID) } observedCards[config.ID] = "missing" } continue } iccid := strings.TrimSpace(entry.Snapshot.ICCID) if iccid == "" { if config.DeviceType == store.DeviceTypeUSBSIMReader && observedCards[config.ID] != "missing" { if state, stateErr := vowifiManager.State(config.ID); stateErr == nil && state.ICCID != "" { _, _ = vowifiManager.RequestReconnect(config.ID) } observedCards[config.ID] = "missing" } continue } previousObserved := observedCards[config.ID] observedCards[config.ID] = iccid 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) case config.DeviceType == store.DeviceTypeUSBSIMReader && previousObserved == "missing": _, _ = 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, ) }