diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f52f6fb --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 9f23bf7..39f314a 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,9 @@ vc.jar *.cookies *.session +.env +.env.* +!.env.example # ---- Frontend build products ---- web/dist/ diff --git a/Dockerfile b/Dockerfile index cee8145..a83ee73 100644 --- a/Dockerfile +++ b/Dockerfile @@ -45,6 +45,9 @@ RUN mkdir -p /opt/vocat/bin /opt/vocat/data && \ COPY --from=go-builder /out/vocat /opt/vocat/bin/vocat +# Symlink into /usr/local/bin so `docker exec vocat ...` finds it via $PATH. +RUN ln -s /opt/vocat/bin/vocat /usr/local/bin/vocat + USER vocat VOLUME ["/opt/vocat/data"] EXPOSE 7575 diff --git a/cmd/vocat/develop.go b/cmd/vocat/develop.go index 21c7063..61a9b82 100644 --- a/cmd/vocat/develop.go +++ b/cmd/vocat/develop.go @@ -6,11 +6,11 @@ import ( "errors" "fmt" "log/slog" - "os" "strings" "time" "vocat/internal/config" + "vocat/internal/developer" "vocat/internal/store" ) @@ -18,7 +18,7 @@ import ( // plugin/extension system. When absent the developer mode defaults to off, so // a fresh install exposes no plugin surface until an operator explicitly turns // it on with `vocat develop on` and restarts the service. -const developerEnabledSettingKey = "developer.enabled" +const developerEnabledSettingKey = developer.EnabledSettingKey // runDevelop handles the hidden `vocat develop on|off` subcommand. It is // intentionally excluded from printUsage and the interactive menu: the plugin @@ -65,6 +65,11 @@ func runDevelop(args []string, logger *slog.Logger) error { }); err != nil { return fmt.Errorf("persist developer flag: %w", err) } + if !enabled { + if err := developer.ResetExperimental(ctx, database); err != nil { + return fmt.Errorf("reset developer settings: %w", err) + } + } if enabled { fmt.Printf("开发者模式已开启。重启 vocat 服务后插件功能生效。\n数据库:%s\n", cfg.DatabasePath) @@ -91,18 +96,5 @@ func parseDevelopArg(arg string) (bool, bool) { // or an unparseable value resolves to false — the system defaults closed, so // any read failure keeps plugins off rather than exposing them by accident. func isDeveloperEnabled(ctx context.Context, database *store.Store) bool { - setting, err := database.AppSetting(ctx, developerEnabledSettingKey) - if err != nil { - if !errors.Is(err, store.ErrNotFound) { - fmt.Fprintf(os.Stderr, "vocat: read developer flag failed; plugin system stays off: %v\n", err) - } - return false - } - var document struct { - Enabled bool `json:"enabled"` - } - if err := json.Unmarshal(setting.Value, &document); err != nil { - return false - } - return document.Enabled + return developer.Enabled(ctx, database) } diff --git a/cmd/vocat/main.go b/cmd/vocat/main.go index a39976c..0351dfd 100644 --- a/cmd/vocat/main.go +++ b/cmd/vocat/main.go @@ -2,10 +2,12 @@ package main import ( "context" + "crypto/tls" "encoding/json" "errors" "fmt" "log/slog" + "net" "net/http" "os" "os/signal" @@ -18,8 +20,11 @@ import ( "vocat/internal/auth" "vocat/internal/config" + "vocat/internal/developer" "vocat/internal/device" + "vocat/internal/exportproxy" "vocat/internal/extensions" + "vocat/internal/httpsmode" "vocat/internal/loghub" "vocat/internal/server" "vocat/internal/store" @@ -119,16 +124,41 @@ func run(logger *slog.Logger, logs *loghub.Hub) error { return err } defer database.Close() + developerEnabled := isDeveloperEnabled(startupContext, database) + pluginRoot := filepath.Join(filepath.Dir(cfg.DatabasePath), "plugins") + legacyExportProxyConfig := filepath.Join(pluginRoot, exportproxy.ReservedID, "data", "configs.json") + if !developerEnabled { + if err := developer.ResetExperimental(startupContext, database); err != nil { + return fmt.Errorf("reset disabled developer settings: %w", err) + } + if err := exportproxy.RemoveLegacyConfig(legacyExportProxyConfig); err != nil { + return fmt.Errorf("remove legacy export proxy configuration: %w", err) + } + } + httpsManager, err := httpsmode.New( + startupContext, + database, + filepath.Join(filepath.Dir(cfg.DatabasePath), "tls"), + cfg.Address, + ) + if err != nil { + return fmt.Errorf("configure self-signed HTTPS: %w", err) + } // The plugin/extension system is gated behind a hidden developer-mode flag. // When off (the default) the manager is never created and the server receives // a nil Extensions handle, so every /extensions* and /plugin-assets/* route // returns 503/404 and the SPA hides the plugin surface. - developerEnabled := isDeveloperEnabled(startupContext, database) var extensionManager *extensions.Manager + var exportProxyManager *exportproxy.Manager if developerEnabled { + exportProxyManager, err = exportproxy.New(startupContext, database, logger, legacyExportProxyConfig) + if err != nil { + return fmt.Errorf("create built-in export proxy: %w", err) + } + defer exportProxyManager.Close() extensionManager, err = extensions.NewManager( - filepath.Join(filepath.Dir(cfg.DatabasePath), "plugins"), + pluginRoot, logger, ) if err != nil { @@ -163,6 +193,7 @@ func run(logger *slog.Logger, logs *loghub.Hub) error { if err := provisionDiscoveredDevices(startupContext, database, deviceManager); err != nil { logger.Warn("automatic first-run device provisioning failed", "error", err) } + restoreDefaultCellularRadios(startupContext, logger, database, deviceManager) defer func() { stopContext, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -173,7 +204,14 @@ func run(logger *slog.Logger, logs *loghub.Hub) error { pollContext, cancelPolling := context.WithCancel(context.Background()) defer cancelPolling() go pollDeviceSnapshots(pollContext, logger, database, deviceManager) + go restoreConfiguredCellularData(pollContext, logger, database, deviceManager) + go collectCellularTraffic(pollContext, logger, database) go persistLogsToStore(pollContext, logger, logs, database) + if !developerEnabled { + go disableAllDeveloperCellularData(pollContext, logger, database, deviceManager) + } else { + go watchDeveloperDisable(pollContext, logger, database, deviceManager, exportProxyManager, legacyExportProxyConfig) + } vowifiManager, err := configureVoWiFiRuntime( startupContext, @@ -203,9 +241,11 @@ func run(logger *slog.Logger, logs *loghub.Hub) error { SecureCookies: cfg.SecureCookies, MaxRequestBodyBytes: cfg.MaxRequestBodyBytes, Extensions: extensionManager, + ExportProxy: exportProxyManager, DeveloperEnabled: developerEnabled, UpdateRepository: strings.TrimSpace(os.Getenv("VOCAT_REPO")), UpdateToken: strings.TrimSpace(os.Getenv("GITHUB_TOKEN")), + HTTPS: httpsManager, }) if err != nil { return err @@ -215,15 +255,35 @@ func run(logger *slog.Logger, logs *loghub.Hub) error { handler.StartTelegramBot(pollContext) handler.StartSMSNotificationDispatchers(pollContext) - httpServer := &http.Server{ - Addr: cfg.Address, - Handler: handler, - ReadHeaderTimeout: 5 * time.Second, - ReadTimeout: 15 * time.Second, - WriteTimeout: 30 * time.Second, - IdleTimeout: 90 * time.Second, - MaxHeaderBytes: 1 << 20, + serverConfig := func(handler http.Handler) *http.Server { + return &http.Server{ + Addr: cfg.Address, + Handler: handler, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 15 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 90 * time.Second, + MaxHeaderBytes: 1 << 20, + } } + plainHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if httpsManager.Enabled() { + host := strings.TrimSpace(r.Host) + if host == "" { + host = cfg.Address + } + http.Redirect(w, r, "https://"+host+r.URL.RequestURI(), http.StatusPermanentRedirect) + return + } + handler.ServeHTTP(w, r) + }) + plainServer := serverConfig(plainHandler) + tlsServer := serverConfig(handler) + baseListener, err := net.Listen("tcp", cfg.Address) + if err != nil { + return fmt.Errorf("listen on %s: %w", cfg.Address, err) + } + protocolMux := httpsmode.NewMultiplexer(baseListener, httpsManager) signalContext, stopSignals := signal.NotifyContext( context.Background(), @@ -232,10 +292,17 @@ func run(logger *slog.Logger, logs *loghub.Hub) error { ) defer stopSignals() - serverError := make(chan error, 1) + serverError := make(chan error, 2) go func() { - logger.Info("HTTP server listening", "address", cfg.Address) - err := httpServer.ListenAndServe() + logger.Info("HTTP server listening", "address", cfg.Address, "self_signed_https", httpsManager.Enabled()) + err := plainServer.Serve(protocolMux.Plain()) + if errors.Is(err, http.ErrServerClosed) { + err = nil + } + serverError <- err + }() + go func() { + err := tlsServer.Serve(tls.NewListener(protocolMux.TLS(), httpsManager.TLSConfig())) if errors.Is(err, http.ErrServerClosed) { err = nil } @@ -244,6 +311,7 @@ func run(logger *slog.Logger, logs *loghub.Hub) error { select { case err := <-serverError: + _ = protocolMux.Close() return err case <-signalContext.Done(): logger.Info("shutdown signal received") @@ -258,11 +326,161 @@ func run(logger *slog.Logger, logs *loghub.Hub) error { cfg.ShutdownTimeout, ) defer cancelShutdown() - if err := httpServer.Shutdown(shutdownContext); err != nil { - _ = httpServer.Close() - return fmt.Errorf("graceful HTTP shutdown: %w", err) + shutdownErrors := make(chan error, 2) + go func() { shutdownErrors <- plainServer.Shutdown(shutdownContext) }() + go func() { shutdownErrors <- tlsServer.Shutdown(shutdownContext) }() + time.Sleep(10 * time.Millisecond) + _ = protocolMux.Close() + for range 2 { + if err := <-shutdownErrors; err != nil { + _ = plainServer.Close() + _ = tlsServer.Close() + return fmt.Errorf("graceful HTTP shutdown: %w", err) + } + } + return nil +} + +// restoreDefaultCellularRadios repairs an interrupted VoWiFi teardown. CFUN=4 +// survives process restarts, while the in-memory radio checkpoint does not. If +// VoWiFi is disabled and the current SIM has no explicit airplane policy, the +// automatic/default policy is cellular service and the modem must return to +// CFUN=1. +func restoreDefaultCellularRadios( + ctx context.Context, + logger *slog.Logger, + database *store.Store, + manager *device.Manager, +) { + configs, err := database.ListDevices(ctx) + if err != nil { + logger.Warn("startup cellular recovery: list devices", "error", err) + return + } + mapper := integration.ATMapper{Store: database, Devices: manager} + for _, config := range configs { + if config.VoWiFiEnabled { + continue + } + entry, err := mapper.Get(config.ID) + if err != nil || entry.Snapshot == nil || !entry.Snapshot.FlightMode { + continue + } + iccid := strings.TrimSpace(entry.Snapshot.ICCID) + if iccid != "" { + policy, policyErr := database.CardPolicy(ctx, iccid) + switch { + case policyErr == nil && policy.AirplaneEnabled: + continue + case policyErr != nil && !errors.Is(policyErr, store.ErrNotFound): + logger.Warn("startup cellular recovery: read card policy", "device_id", config.ID, "error", policyErr) + continue + } + } + restoreContext, cancel := context.WithTimeout(ctx, 10*time.Second) + _, err = manager.SetFlight(restoreContext, entry.ID, false) + cancel() + if err != nil { + logger.Warn("startup cellular recovery failed", "device_id", config.ID, "error", err) + continue + } + logger.Info("restored cellular radio after disabled VoWiFi", "device_id", config.ID, "iccid", iccid) + } +} + +func restoreConfiguredCellularData( + ctx context.Context, + logger *slog.Logger, + database *store.Store, + manager *device.Manager, +) { + configs, err := database.ListDevices(ctx) + if err != nil { + logger.Warn("startup cellular data recovery: list devices", "error", err) + return + } + mapper := integration.ATMapper{Store: database, Devices: manager} + for _, config := range configs { + if !config.NetworkEnabled || config.VoWiFiEnabled { + continue + } + entry, err := mapper.Get(config.ID) + if err != nil { + continue + } + dataContext, cancel := context.WithTimeout(ctx, 60*time.Second) + _, err = manager.SetNetwork(dataContext, entry.ID, device.NetworkRequest{ + Enabled: true, APN: config.APN, IPVersion: "IPV4V6", + }) + cancel() + if err != nil { + logger.Warn("startup cellular data recovery failed", "device_id", config.ID, "error", err) + continue + } + logger.Info("restored protected cellular data route", "device_id", config.ID, "interface", config.Interface) + } +} + +func disableAllDeveloperCellularData( + ctx context.Context, + logger *slog.Logger, + database *store.Store, + manager *device.Manager, +) { + configs, err := database.ListDevices(ctx) + if err != nil { + logger.Warn("developer cleanup: list devices", "error", err) + return + } + mapper := integration.ATMapper{Store: database, Devices: manager} + for _, config := range configs { + entry, err := mapper.Get(config.ID) + if err != nil { + continue + } + disableContext, cancel := context.WithTimeout(ctx, 30*time.Second) + _, err = manager.SetNetwork(disableContext, entry.ID, device.NetworkRequest{Enabled: false}) + cancel() + if err != nil && ctx.Err() == nil { + logger.Warn("developer cleanup: stop cellular data", "device_id", config.ID, "error", err) + } + } +} + +func watchDeveloperDisable( + ctx context.Context, + logger *slog.Logger, + database *store.Store, + manager *device.Manager, + exportProxy *exportproxy.Manager, + legacyConfigPath string, +) { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if developer.Enabled(ctx, database) { + continue + } + if exportProxy != nil { + if err := exportProxy.DeleteAllAndDisable(ctx); err != nil && ctx.Err() == nil { + logger.Warn("developer cleanup: delete export proxies", "error", err) + } + } + if err := exportproxy.RemoveLegacyConfig(legacyConfigPath); err != nil { + logger.Warn("developer cleanup: remove legacy export proxy configuration", "error", err) + } + if err := developer.ResetExperimental(ctx, database); err != nil && ctx.Err() == nil { + logger.Warn("developer cleanup: reset settings", "error", err) + } + disableAllDeveloperCellularData(ctx, logger, database, manager) + logger.Info("developer mode disabled; roaming data and export proxies were removed") + return + } } - return <-serverError } func configureVoWiFiRuntime( @@ -355,8 +573,18 @@ func newVoWiFiOrchestrator( if message.Concat != nil && message.Concat.Total > 0 { partsTotal = message.Concat.Total } + messageID := message.MessageID + if message.Concat != nil && message.Concat.Total > 1 { + // A segment of a carrier-split long SMS over IMS. Address the whole + // message with a stable id so SaveSMSMessage folds every segment + // into one progressively merged row instead of one row per segment. + messageID = store.StableConcatMessageID( + "ims", deviceConfig.ModemIMEI, message.DeviceID, message.From, + message.Concat.Reference, message.Concat.Total, + ) + } _, saveErr := database.SaveSMSMessage(ctx, store.SMSMessage{ - MessageID: message.MessageID, + MessageID: messageID, DeviceID: message.DeviceID, ModemIMEI: deviceConfig.ModemIMEI, IMSI: message.IMSI, diff --git a/cmd/vocat/traffic.go b/cmd/vocat/traffic.go new file mode 100644 index 0000000..ee214fc --- /dev/null +++ b/cmd/vocat/traffic.go @@ -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, + } +} diff --git a/cmd/vocat/traffic_linux.go b/cmd/vocat/traffic_linux.go new file mode 100644 index 0000000..e8767c6 --- /dev/null +++ b/cmd/vocat/traffic_linux.go @@ -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 +} diff --git a/cmd/vocat/traffic_other.go b/cmd/vocat/traffic_other.go new file mode 100644 index 0000000..cf4c4d0 --- /dev/null +++ b/cmd/vocat/traffic_other.go @@ -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") +} diff --git a/cmd/vocat/traffic_test.go b/cmd/vocat/traffic_test.go new file mode 100644 index 0000000..0168bbc --- /dev/null +++ b/cmd/vocat/traffic_test.go @@ -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") + } +} diff --git a/deploy/vocat.service b/deploy/vocat.service index 3914072..8019e92 100644 --- a/deploy/vocat.service +++ b/deploy/vocat.service @@ -29,7 +29,7 @@ ProtectKernelModules=true ProtectKernelTunables=true ProtectControlGroups=true ReadWritePaths=/opt/vocat/data -RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK AF_PACKET RestrictRealtime=true LockPersonality=true MemoryDenyWriteExecute=true diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9ac9d02 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/go.mod b/go.mod index 7f0e4bd..f36fc27 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module vocat go 1.25.0 require ( + github.com/coder/websocket v1.8.15 go.bug.st/serial v1.6.4 golang.org/x/crypto v0.41.0 golang.org/x/sys v0.47.0 diff --git a/go.sum b/go.sum index 8b50492..f7cb006 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0= github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= diff --git a/internal/developer/settings.go b/internal/developer/settings.go new file mode 100644 index 0000000..a8e5273 --- /dev/null +++ b/internal/developer/settings.go @@ -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...) +} diff --git a/internal/developer/settings_test.go b/internal/developer/settings_test.go new file mode 100644 index 0000000..89fdd96 --- /dev/null +++ b/internal/developer/settings_test.go @@ -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") + } +} diff --git a/internal/device/carrier_db.go b/internal/device/carrier_db.go new file mode 100644 index 0000000..7cb912c --- /dev/null +++ b/internal/device/carrier_db.go @@ -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 +} diff --git a/internal/device/data.go b/internal/device/data.go index eff8731..8dc6fbc 100644 --- a/internal/device/data.go +++ b/internal/device/data.go @@ -23,7 +23,7 @@ func (manager *Manager) SetNetwork( return NetworkResult{}, err } apn := strings.TrimSpace(request.APN) - if request.Enabled && !apnPattern.MatchString(apn) { + if request.Enabled && apn != "" && !apnPattern.MatchString(apn) { return NetworkResult{}, ErrInvalidNetworkAPN } ipVersion := normalizeIPVersion(request.IPVersion) @@ -225,7 +225,7 @@ func (manager *Manager) SetOperatorSelection( accessTechnologyValue *int, ) (OperatorSelection, error) { result := OperatorSelection{Mode: 0} - command := "AT+COPS=0" + command := "" if !automatic { plmn = strings.TrimSpace(plmn) if len(plmn) < 5 || len(plmn) > 6 || strings.IndexFunc(plmn, func(r rune) bool { return r < '0' || r > '9' }) >= 0 { @@ -265,28 +265,187 @@ func (manager *Manager) SetOperatorSelection( // the lock is not aborted while registration is still in progress. lockCtx, cancel := manager.withTimeout(ctx, manager.scanTimeout) defer cancel() - if _, err := client.Execute(lockCtx, command); err != nil { - manager.setResult(id, state, nil, errors.New("operator selection command failed")) + if automatic { + result, err = restoreAutomaticOperatorSelection(lockCtx, client) + manager.setResult(id, state, nil, err) + return result, err + } + response, err := client.Execute(lockCtx, command) + if err != nil || !response.OK() { + if err == nil { + err = &modem.CommandError{Command: response.Command, Final: response.Final, Lines: response.Lines} + } + rollbackOperatorSelection(manager, client) + wrapped := fmt.Errorf("manual operator selection failed and automatic selection was restored: %w", err) + manager.setResult(id, state, nil, wrapped) + return OperatorSelection{}, wrapped + } + actual, err := queryOperatorSelection(lockCtx, client) + if err != nil { + rollbackOperatorSelection(manager, client) + manager.setResult(id, state, nil, err) + return OperatorSelection{}, fmt.Errorf("verify manual operator selection: %w", err) + } + if actual.Mode != 1 || actual.Operator != plmn { + rollbackOperatorSelection(manager, client) + err := fmt.Errorf("network %s did not accept registration; automatic selection was restored (modem reported mode=%d operator=%q)", plmn, actual.Mode, actual.Operator) + manager.setResult(id, state, nil, err) return OperatorSelection{}, err } - if !automatic { - response, err := client.Execute(lockCtx, "AT+COPS?") - if err != nil { - manager.setResult(id, state, nil, err) - return OperatorSelection{}, fmt.Errorf("verify manual operator selection: %w", err) - } - actual, err := parseOperatorSelection(response) - if err != nil { - manager.setResult(id, state, nil, err) - return OperatorSelection{}, err - } - if actual.Mode != 1 || actual.Operator != plmn { - err := fmt.Errorf("network %s did not accept registration; modem reports mode=%d operator=%q", plmn, actual.Mode, actual.Operator) - manager.setResult(id, state, nil, err) - return OperatorSelection{}, err - } - result = actual - } + result = actual manager.setResult(id, state, nil, nil) return result, nil } + +func queryOperatorSelection(ctx context.Context, client modem.Client) (OperatorSelection, error) { + response, err := client.Execute(ctx, "AT+COPS?") + if err != nil { + return OperatorSelection{}, err + } + if !response.OK() { + return OperatorSelection{}, &modem.CommandError{Command: response.Command, Final: response.Final, Lines: response.Lines} + } + return parseOperatorSelection(response) +} + +// restoreAutomaticOperatorSelection clears both a manual PLMN latch and an +// old RAT-only scan restriction. The latter is important on EC20 modules: +// COPS=0 alone can remain effectively LTE-only after an earlier lock, unlike a +// phone's normal automatic GSM/WCDMA/LTE acquisition policy. +func restoreAutomaticOperatorSelection(ctx context.Context, client modem.Client) (OperatorSelection, error) { + // Older firmware may not implement nwscanmode; COPS auto is still useful in + // that case, so this compatibility reset is best effort. + _, _ = client.Execute(ctx, `AT+QCFG="nwscanmode",0,1`) + _, _ = client.Execute(ctx, "AT+COPS=2") + response, err := client.Execute(ctx, "AT+COPS=0") + if err != nil { + return OperatorSelection{}, err + } + if !response.OK() { + return OperatorSelection{}, &modem.CommandError{Command: response.Command, Final: response.Final, Lines: response.Lines} + } + actual, err := queryOperatorSelection(ctx, client) + if err != nil { + return OperatorSelection{}, fmt.Errorf("verify automatic operator selection: %w", err) + } + if actual.Mode != 0 { + return OperatorSelection{}, fmt.Errorf("modem did not enter automatic operator selection (mode=%d operator=%q)", actual.Mode, actual.Operator) + } + return actual, nil +} + +func rollbackOperatorSelection(manager *Manager, client modem.Client) { + rollbackCtx, cancel := context.WithTimeout(context.Background(), manager.longTimeout) + defer cancel() + _, _ = restoreAutomaticOperatorSelection(rollbackCtx, client) +} + +// ReRegisterOperator detaches from the network and reapplies the modem's +// current automatic/manual selection. This is intentionally different from a +// passive refresh: it forces a new registration attempt without changing the +// user's lock policy. +func (manager *Manager) ReRegisterOperator(ctx context.Context, id string) (OperatorSelection, error) { + state, err := manager.lookup(id) + if err != nil { + return OperatorSelection{}, err + } + state.opMu.Lock() + defer state.opMu.Unlock() + if err := manager.validateActive(id, state); err != nil { + return OperatorSelection{}, err + } + client, err := manager.clientLocked(ctx, state, manager.candidateFor(state)) + if err != nil { + manager.setResult(id, state, nil, err) + return OperatorSelection{}, err + } + longCtx, cancel := manager.withTimeout(ctx, manager.scanTimeout) + defer cancel() + + current, err := queryOperatorSelection(longCtx, client) + if err != nil { + manager.setResult(id, state, nil, err) + return OperatorSelection{}, err + } + manual := current.Mode == 1 || current.Mode == 4 + if manual && !decimalPLMN(current.Operator) { + response, formatErr := client.Execute(longCtx, "AT+COPS=3,2") + if formatErr != nil || !response.OK() { + if formatErr == nil { + formatErr = &modem.CommandError{Command: response.Command, Final: response.Final, Lines: response.Lines} + } + manager.setResult(id, state, nil, formatErr) + return OperatorSelection{}, formatErr + } + current, err = queryOperatorSelection(longCtx, client) + if err != nil { + manager.setResult(id, state, nil, err) + return OperatorSelection{}, err + } + manual = current.Mode == 1 || current.Mode == 4 + } + + if !manual { + result, restoreErr := restoreAutomaticOperatorSelection(longCtx, client) + manager.setResult(id, state, nil, restoreErr) + return result, restoreErr + } + desired := "" + if manual { + if !decimalPLMN(current.Operator) { + return OperatorSelection{}, errors.New("current manual operator is not available as a numeric PLMN") + } + desired = fmt.Sprintf(`AT+COPS=1,2,"%s"`, current.Operator) + if code, ok := accessTechnologyCode(current.AccessTechnology); ok { + desired += fmt.Sprintf(",%d", code) + } + } + for _, command := range []string{"AT+COPS=2", desired} { + response, executeErr := client.Execute(longCtx, command) + if executeErr != nil { + manager.setResult(id, state, nil, executeErr) + return OperatorSelection{}, executeErr + } + if !response.OK() { + executeErr = &modem.CommandError{Command: response.Command, Final: response.Final, Lines: response.Lines} + manager.setResult(id, state, nil, executeErr) + return OperatorSelection{}, executeErr + } + } + result, err := queryOperatorSelection(longCtx, client) + manager.setResult(id, state, nil, err) + if err != nil { + return OperatorSelection{}, err + } + return result, nil +} + +func decimalPLMN(value string) bool { + value = strings.TrimSpace(value) + return (len(value) == 5 || len(value) == 6) && strings.IndexFunc(value, func(r rune) bool { + return r < '0' || r > '9' + }) < 0 +} + +func accessTechnologyCode(name string) (int, bool) { + switch strings.ToUpper(strings.TrimSpace(name)) { + case "GSM": + return 0, true + case "UTRAN": + return 2, true + case "EDGE": + return 3, true + case "HSDPA": + return 4, true + case "HSUPA": + return 5, true + case "HSPA": + return 6, true + case "LTE": + return 7, true + case "NR5G": + return 9, true + default: + return 0, false + } +} diff --git a/internal/device/data_linux.go b/internal/device/data_linux.go index 2afdbef..2aaf335 100644 --- a/internal/device/data_linux.go +++ b/internal/device/data_linux.go @@ -4,9 +4,13 @@ package device import ( "context" + "errors" "fmt" + "hash/fnv" + "net" "os" "os/exec" + "strconv" "strings" "time" @@ -31,7 +35,11 @@ func setQMINetwork( profilePath := profile.Name() defer os.Remove(profilePath) ipType := map[string]string{"IP": "4", "IPV6": "6", "IPV4V6": "4"}[ipVersion] - if _, err := fmt.Fprintf(profile, "APN=%s\nIP_TYPE=%s\nPROXY=yes\n", apn, ipType); err != nil { + profileText := fmt.Sprintf("IP_TYPE=%s\nPROXY=yes\n", ipType) + if apn != "" { + profileText = "APN=" + apn + "\n" + profileText + } + if _, err := fmt.Fprint(profile, profileText); err != nil { _ = profile.Close() return NetworkResult{}, fmt.Errorf("write temporary QMI profile: %w", err) } @@ -54,36 +62,42 @@ func setQMINetwork( lowerDetail := strings.ToLower(detail) idempotentStop := !enabled && (strings.Contains(lowerDetail, "already stopped") || strings.Contains(lowerDetail, "not started") || strings.Contains(lowerDetail, "no network")) - if !idempotentStop { + idempotentStart := enabled && (strings.Contains(lowerDetail, "already started") || + strings.Contains(lowerDetail, "already connected")) + if !idempotentStop && !idempotentStart { return NetworkResult{}, fmt.Errorf("qmi-network %s failed: %w: %s", action, err, detail) } } - if ipCommand, lookErr := exec.LookPath("ip"); lookErr == nil { - linkAction := "down" - if enabled { - linkAction = "up" - } - linkOutput, linkErr := exec.CommandContext(ctx, ipCommand, "link", "set", "dev", candidate.NetworkInterface, linkAction).CombinedOutput() - if linkErr != nil { - return NetworkResult{}, fmt.Errorf("set %s %s: %w: %s", candidate.NetworkInterface, linkAction, linkErr, strings.TrimSpace(string(linkOutput))) - } + ipCommand, lookErr := exec.LookPath("ip") + if lookErr != nil { + return NetworkResult{}, fmt.Errorf("%w: install iproute2 to control %s", ErrDataBackendUnavailable, candidate.NetworkInterface) + } + linkAction := "down" + if enabled { + linkAction = "up" + } + linkOutput, linkErr := exec.CommandContext(ctx, ipCommand, "link", "set", "dev", candidate.NetworkInterface, linkAction).CombinedOutput() + if linkErr != nil { + return NetworkResult{}, fmt.Errorf("set %s %s: %w: %s", candidate.NetworkInterface, linkAction, linkErr, strings.TrimSpace(string(linkOutput))) } if enabled { - if busybox, lookErr := exec.LookPath("busybox"); lookErr == nil { - dhcpOutput, dhcpErr := exec.CommandContext(ctx, busybox, "udhcpc", "-q", "-n", "-t", "5", "-T", "3", "-i", candidate.NetworkInterface).CombinedOutput() - if dhcpErr != nil { - rollbackCtx, cancelRollback := context.WithTimeout(context.Background(), managerCommandCleanupTimeout) - defer cancelRollback() - _, _ = exec.CommandContext(rollbackCtx, qmiNetwork, "--profile="+profilePath, candidate.QMIControl, "stop").CombinedOutput() - if ipCommand, lookErr := exec.LookPath("ip"); lookErr == nil { - _, _ = exec.CommandContext(rollbackCtx, ipCommand, "link", "set", "dev", candidate.NetworkInterface, "down").CombinedOutput() - } - return NetworkResult{}, fmt.Errorf("QMI session started but DHCP failed: %w: %s", dhcpErr, strings.TrimSpace(string(dhcpOutput))) - } - if value := strings.TrimSpace(string(dhcpOutput)); value != "" { - detail = strings.TrimSpace(detail + "\n" + value) - } + busybox, busyboxErr := exec.LookPath("busybox") + if busyboxErr != nil { + return NetworkResult{}, fmt.Errorf("%w: busybox udhcpc is required for %s", ErrDataBackendUnavailable, candidate.NetworkInterface) } + dhcpDetail, dhcpErr := configureExportProxyDHCP(ctx, busybox, ipCommand, candidate.NetworkInterface) + if dhcpErr != nil { + rollbackCtx, cancelRollback := context.WithTimeout(context.Background(), managerCommandCleanupTimeout) + defer cancelRollback() + clearExportProxyRoute(rollbackCtx, candidate.NetworkInterface) + _, _ = exec.CommandContext(rollbackCtx, qmiNetwork, "--profile="+profilePath, candidate.QMIControl, "stop").CombinedOutput() + _, _ = exec.CommandContext(rollbackCtx, ipCommand, "link", "set", "dev", candidate.NetworkInterface, "down").CombinedOutput() + return NetworkResult{}, fmt.Errorf("QMI session started but protected DHCP failed: %w", dhcpErr) + } + detail = strings.TrimSpace(detail + "\n" + dhcpDetail) + } else { + clearExportProxyRoute(ctx, candidate.NetworkInterface) + _, _ = exec.CommandContext(ctx, ipCommand, "-4", "addr", "flush", "dev", candidate.NetworkInterface, "scope", "global").CombinedOutput() } return NetworkResult{ Enabled: enabled, @@ -96,4 +110,173 @@ func setQMINetwork( }, nil } +// exportProxyRouteIdentity must stay in sync with the Export Proxy plugin's +// Linux socket mark. Unmarked host traffic never sees the cellular default +// route; only plugin sockets carrying this mark are policy-routed to it. +func exportProxyRouteIdentity(networkInterface string) (mark uint32, table, priority int) { + hash := fnv.New32a() + _, _ = hash.Write([]byte(networkInterface)) + value := hash.Sum32() + mark = 0x56000000 | (value & 0x00ffffff) + table = 20000 + int(value%10000) + priority = 20000 + int(value%10000) + return +} + +func configureExportProxyDHCP(ctx context.Context, busybox, ipCommand, networkInterface string) (string, error) { + lease, err := os.CreateTemp("", "vocat-dhcp-lease-*.env") + if err != nil { + return "", err + } + leasePath := lease.Name() + _ = lease.Close() + _ = os.Remove(leasePath) + defer os.Remove(leasePath) + script, err := os.CreateTemp("", "vocat-udhcpc-*.sh") + if err != nil { + return "", err + } + scriptPath := script.Name() + defer os.Remove(scriptPath) + scriptText := fmt.Sprintf(`#!/bin/sh +case "$1" in + bound|renew) + (umask 077; printf 'ip=%%s\nsubnet=%%s\nrouter=%%s\ndns=%%s\n' "$ip" "$subnet" "$router" "$dns" > %q) + ;; +esac +exit 0 +`, leasePath) + if _, err := script.WriteString(scriptText); err != nil { + _ = script.Close() + return "", err + } + if err := script.Chmod(0o700); err != nil { + _ = script.Close() + return "", err + } + if err := script.Close(); err != nil { + return "", err + } + output, err := exec.CommandContext(ctx, busybox, "udhcpc", "-q", "-n", "-t", "5", "-T", "3", "-i", networkInterface, "-s", scriptPath).CombinedOutput() + if err != nil { + if strings.Contains(strings.ToLower(string(output)), "address family not supported") { + return "", fmt.Errorf("udhcpc cannot open its link-layer socket: allow AF_PACKET in the vocat systemd service RestrictAddressFamilies setting: %w", err) + } + return "", fmt.Errorf("udhcpc: %w: %s", err, strings.TrimSpace(string(output))) + } + raw, err := os.ReadFile(leasePath) + if err != nil { + return "", fmt.Errorf("read DHCP lease: %w", err) + } + values := make(map[string]string) + for _, line := range strings.Split(string(raw), "\n") { + key, value, found := strings.Cut(line, "=") + if found { + values[strings.TrimSpace(key)] = strings.TrimSpace(value) + } + } + address := net.ParseIP(values["ip"]).To4() + maskIP := net.ParseIP(values["subnet"]).To4() + if address == nil || maskIP == nil { + return "", errors.New("DHCP returned no valid IPv4 address/subnet") + } + mask := net.IPMask(maskIP) + ones, bits := mask.Size() + if bits != 32 || ones < 0 { + return "", errors.New("DHCP returned an invalid IPv4 subnet") + } + network := address.Mask(mask) + routers := strings.Fields(values["router"]) + if len(routers) > 0 && net.ParseIP(routers[0]).To4() == nil { + return "", errors.New("DHCP returned an invalid IPv4 gateway") + } + if result, addrErr := exec.CommandContext(ctx, ipCommand, "-4", "addr", "replace", fmt.Sprintf("%s/%d", address.String(), ones), "dev", networkInterface).CombinedOutput(); addrErr != nil { + return "", fmt.Errorf("configure cellular address: %w: %s", addrErr, strings.TrimSpace(string(result))) + } + mark, table, priority := exportProxyRouteIdentity(networkInterface) + clearExportProxyRoute(ctx, networkInterface) + connectedCIDR := fmt.Sprintf("%s/%d", network.String(), ones) + if result, routeErr := exec.CommandContext(ctx, ipCommand, "-4", "route", "replace", "table", strconv.Itoa(table), connectedCIDR, "dev", networkInterface, "scope", "link", "src", address.String()).CombinedOutput(); routeErr != nil { + clearExportProxyRoute(ctx, networkInterface) + return "", fmt.Errorf("install protected connected route: %w: %s", routeErr, strings.TrimSpace(string(result))) + } + defaultArgs := []string{"-4", "route", "replace", "table", strconv.Itoa(table), "default"} + if len(routers) > 0 { + defaultArgs = append(defaultArgs, "via", routers[0]) + } + defaultArgs = append(defaultArgs, "dev", networkInterface, "onlink") + if result, routeErr := exec.CommandContext(ctx, ipCommand, defaultArgs...).CombinedOutput(); routeErr != nil { + clearExportProxyRoute(ctx, networkInterface) + return "", fmt.Errorf("install protected default route: %w: %s", routeErr, strings.TrimSpace(string(result))) + } + markText := fmt.Sprintf("0x%x", mark) + result, err := exec.CommandContext(ctx, ipCommand, "rule", "add", "priority", strconv.Itoa(priority), "fwmark", markText, "lookup", strconv.Itoa(table)).CombinedOutput() + if err != nil { + clearExportProxyRoute(ctx, networkInterface) + return "", fmt.Errorf("install protected routing rule: %w: %s", err, strings.TrimSpace(string(result))) + } + if err := writeExportProxyDNS(networkInterface, strings.Fields(values["dns"])); err != nil { + clearExportProxyRoute(ctx, networkInterface) + return "", fmt.Errorf("publish protected DNS configuration: %w", err) + } + return fmt.Sprintf("protected DHCP lease %s/%d", address.String(), ones), nil +} + +func exportProxyDNSPath(networkInterface string) string { + safeName := strings.Map(func(character rune) rune { + if character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || + character >= '0' && character <= '9' || character == '-' || character == '_' || character == '.' { + return character + } + return '_' + }, networkInterface) + return "/run/vocat/cellular-" + safeName + ".dns" +} + +func writeExportProxyDNS(networkInterface string, servers []string) error { + valid := make([]string, 0, len(servers)) + for _, server := range servers { + if address := net.ParseIP(server); address != nil { + valid = append(valid, address.String()) + } + } + if len(valid) == 0 { + // This is used only by marked Export Proxy sockets. It never changes the + // host resolver and is merely a fallback for carriers omitting DHCP DNS. + valid = []string{"1.1.1.1", "8.8.8.8"} + } + if err := os.MkdirAll("/run/vocat", 0o755); err != nil { + return err + } + temporary, err := os.CreateTemp("/run/vocat", ".cellular-dns-*") + if err != nil { + return err + } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + if _, err := temporary.WriteString(strings.Join(valid, "\n") + "\n"); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Chmod(0o644); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + return os.Rename(temporaryPath, exportProxyDNSPath(networkInterface)) +} + +func clearExportProxyRoute(ctx context.Context, networkInterface string) { + _ = os.Remove(exportProxyDNSPath(networkInterface)) + ipCommand, err := exec.LookPath("ip") + if err != nil { + return + } + mark, table, priority := exportProxyRouteIdentity(networkInterface) + _, _ = exec.CommandContext(ctx, ipCommand, "rule", "del", "priority", strconv.Itoa(priority), "fwmark", fmt.Sprintf("0x%x", mark), "lookup", strconv.Itoa(table)).CombinedOutput() + _, _ = exec.CommandContext(ctx, ipCommand, "-4", "route", "flush", "table", strconv.Itoa(table)).CombinedOutput() +} + const managerCommandCleanupTimeout = 15 * time.Second diff --git a/internal/device/data_test.go b/internal/device/data_test.go index 27c424c..47a0a24 100644 --- a/internal/device/data_test.go +++ b/internal/device/data_test.go @@ -74,7 +74,10 @@ func TestOperatorSelectionManualAndAutomatic(t *testing.T) { client := &transcriptClient{steps: []clientStep{ {command: `AT+COPS=1,2,"46000",7`, response: okResponse()}, {command: "AT+COPS?", response: okResponse(`+COPS: 1,2,"46000",7`)}, + {command: `AT+QCFG="nwscanmode",0,1`, response: okResponse()}, + {command: "AT+COPS=2", response: okResponse()}, {command: "AT+COPS=0", response: okResponse()}, + {command: "AT+COPS?", response: okResponse(`+COPS: 0,2,"46001",7`)}, }} manager, id := newStartedTestManager(t, client) act := 7 @@ -89,7 +92,7 @@ func TestOperatorSelectionManualAndAutomatic(t *testing.T) { if err != nil { t.Fatalf("automatic selection: %v", err) } - if selection.Mode != 0 || selection.Operator != "" { + if selection.Mode != 0 || selection.Operator != "46001" { t.Fatalf("automatic selection = %#v", selection) } client.assertDone(t) @@ -99,6 +102,10 @@ func TestOperatorSelectionRejectsAutomaticFallbackAsSuccess(t *testing.T) { client := &transcriptClient{steps: []clientStep{ {command: `AT+COPS=1,2,"46000",7`, response: okResponse()}, {command: "AT+COPS?", response: okResponse("+COPS: 0")}, + {command: `AT+QCFG="nwscanmode",0,1`, response: okResponse()}, + {command: "AT+COPS=2", response: okResponse()}, + {command: "AT+COPS=0", response: okResponse()}, + {command: "AT+COPS?", response: okResponse(`+COPS: 0,2,"46001",7`)}, }} manager, id := newStartedTestManager(t, client) act := 7 @@ -107,3 +114,77 @@ func TestOperatorSelectionRejectsAutomaticFallbackAsSuccess(t *testing.T) { } client.assertDone(t) } + +func TestOperatorSelectionCommandFailureRestoresAutomaticMode(t *testing.T) { + selectionErr := errors.New("+CME ERROR: 30") + client := &transcriptClient{steps: []clientStep{ + {command: `AT+COPS=1,2,"46000",7`, err: selectionErr}, + {command: `AT+QCFG="nwscanmode",0,1`, response: okResponse()}, + {command: "AT+COPS=2", response: okResponse()}, + {command: "AT+COPS=0", response: okResponse()}, + {command: "AT+COPS?", response: okResponse(`+COPS: 0,2,"46001",7`)}, + }} + manager, id := newStartedTestManager(t, client) + act := 7 + _, err := manager.SetOperatorSelection(context.Background(), id, false, "46000", &act) + if !errors.Is(err, selectionErr) { + t.Fatalf("error = %v, want wrapped selection error", err) + } + client.assertDone(t) +} + +func TestReRegisterOperatorReappliesAutomaticMode(t *testing.T) { + client := &transcriptClient{steps: []clientStep{ + {command: "AT+COPS?", response: okResponse(`+COPS: 0,2,"46001",7`)}, + {command: `AT+QCFG="nwscanmode",0,1`, response: okResponse()}, + {command: "AT+COPS=2", response: okResponse()}, + {command: "AT+COPS=0", response: okResponse()}, + {command: "AT+COPS?", response: okResponse(`+COPS: 0,2,"46001",7`)}, + }} + manager, id := newStartedTestManager(t, client) + selection, err := manager.ReRegisterOperator(context.Background(), id) + if err != nil { + t.Fatal(err) + } + if selection.Mode != 0 || selection.Operator != "46001" { + t.Fatalf("selection = %#v", selection) + } + client.assertDone(t) +} + +func TestReRegisterOperatorPreservesManualLock(t *testing.T) { + client := &transcriptClient{steps: []clientStep{ + {command: "AT+COPS?", response: okResponse(`+COPS: 1,2,"46003",7`)}, + {command: "AT+COPS=2", response: okResponse()}, + {command: `AT+COPS=1,2,"46003",7`, response: okResponse()}, + {command: "AT+COPS?", response: okResponse(`+COPS: 1,2,"46003",7`)}, + }} + manager, id := newStartedTestManager(t, client) + selection, err := manager.ReRegisterOperator(context.Background(), id) + if err != nil { + t.Fatal(err) + } + if selection.Mode != 1 || selection.Operator != "46003" || selection.AccessTechnology != "LTE" { + t.Fatalf("selection = %#v", selection) + } + client.assertDone(t) +} + +func TestReRegisterOperatorRecoversDeregisteredModeWithAutomaticSelection(t *testing.T) { + client := &transcriptClient{steps: []clientStep{ + {command: "AT+COPS?", response: okResponse(`+COPS: 2`)}, + {command: `AT+QCFG="nwscanmode",0,1`, response: okResponse()}, + {command: "AT+COPS=2", response: okResponse()}, + {command: "AT+COPS=0", response: okResponse()}, + {command: "AT+COPS?", response: okResponse(`+COPS: 0,2,"46001",7`)}, + }} + manager, id := newStartedTestManager(t, client) + selection, err := manager.ReRegisterOperator(context.Background(), id) + if err != nil { + t.Fatal(err) + } + if selection.Mode != 0 || selection.Operator != "46001" { + t.Fatalf("selection = %#v", selection) + } + client.assertDone(t) +} diff --git a/internal/device/manager_test.go b/internal/device/manager_test.go index 4e258f7..5f7de2a 100644 --- a/internal/device/manager_test.go +++ b/internal/device/manager_test.go @@ -27,6 +27,7 @@ func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) { ), }, {command: "AT+COPS?", response: okResponse(`+COPS: 0,0,"China Mobile",7`)}, + {command: "AT+CEREG?", response: okResponse(`+CEREG: 0,5`)}, {command: "AT+CGSN", response: okResponse("867123456789012")}, { command: "AT+CCID", @@ -66,7 +67,9 @@ func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) { t.Fatalf("signal metrics = %#v", snapshot) } if snapshot.AccessTech != "LTE" || snapshot.Band != "B3" || - snapshot.Channel != "1650" || snapshot.OperatorName != "China Mobile" { + snapshot.Channel != "1650" || snapshot.OperatorName != "China Unicom" || + snapshot.OperatorCode != "46001" || + snapshot.RegistrationStatus != 5 || snapshot.RegistrationSource != "CEREG" { t.Fatalf("network = %#v", snapshot) } if snapshot.IMEI != "867123456789012" || diff --git a/internal/device/mccmnc.json b/internal/device/mccmnc.json new file mode 100644 index 0000000..f622126 --- /dev/null +++ b/internal/device/mccmnc.json @@ -0,0 +1 @@ +{"c":{"00101":["Test Network, Used by GSM test equipment",""],"20201":["Cosmote","gr"],"20202":["Cosmote","gr"],"20203":["OTE","gr"],"20204":["OSE","gr"],"20205":["Vodafone","gr"],"20207":["AMD Telecom","gr"],"20209":["Info Quest S.A.","gr"],"20210":["Telestet","gr"],"20212":["Yuboto","gr"],"20214":["CyTa Mobile","gr"],"20215":["BWS","gr"],"20216":["Inter Telecom","gr"],"202299":["AMD Telecom","gr"],"202999":["Fix Line","gr"],"20400":["Intovoice","nl"],"20402":["T-Mobile","nl"],"20403":["Voiceworks NL","nl"],"20404":["Vodafone","nl"],"20405":["ElephantTalk","nl"],"20406":["Vectone Mobile","nl"],"20407":["Move / Teleena","nl"],"20408":["KPN Mobiel","nl"],"20409":["Lycamobile","nl"],"20410":["KPN","nl"],"20412":["KPN Mobiel","nl"],"20414":["6GMOBILE BV","nl"],"20415":["Ziggo","nl"],"20416":["Odido","nl"],"20417":["Intercity Mobile Communications BV","nl"],"20418":["Ziggo Services","nl"],"20420":["T-Mobile","nl"],"20421":["NS Railinfrabeheer B.V.","nl"],"20423":["KORE","nl"],"20424":["Private Mobility","nl"],"20426":["SpeakUp","nl"],"20427":["L-mobi","nl"],"20428":["Lancelot","nl"],"20429":["Tismi","nl"],"204299":["88 mobile","nl"],"20430":["ASPIDER Solutions","nl"],"20433":["Truphone","nl"],"20463":["MessageBird","nl"],"20465":["AGMS","nl"],"20468":["Unify Mobile","nl"],"20469":["KPN Lab","nl"],"20498":["Lancelot","nl"],"204999":["Fix Line","nl"],"20600":["Proximus","be"],"20601":["Proximus","be"],"20602":["Infrabel","be"],"20604":["Proximus","be"],"20605":["Telenet","be"],"20606":["Lycamobile","be"],"20607":["Vectone Mobile","be"],"20608":["VOOmobile","be"],"20610":["Orange","be"],"20620":["BASE","be"],"20623":["Dust Mobile","be"],"20625":["Dense Air","be"],"20628":["Bics","be"],"206299":["FEBO","be"],"20630":["Unleashed","be"],"20633":["Ericsson","be"],"20634":["onoff","be"],"20699":["Lancelot","be"],"206999":["Fix Line","be"],"20800":["Tel/Te","fr"],"20801":["Orange","fr"],"20802":["Orange","fr"],"20803":["MobiquiThings","fr"],"20804":["Netcom Group","fr"],"20805":["Globalstar Europe","fr"],"20806":["Globalstar Europe","fr"],"20807":["Globalstar Europe","fr"],"20808":["SFR","fr"],"20809":["SFR","fr"],"20810":["SFR","fr"],"20811":["SFR","fr"],"20812":["Truphone","fr"],"20813":["SFR","fr"],"20814":["Free Mobile","fr"],"20815":["Free","fr"],"20816":["Free Mobile","fr"],"20817":["Legos","fr"],"208180":["Private FR","fr"],"20820":["Bouygues Telecom","fr"],"20821":["Bouygues Telecom","fr"],"20822":["Transatel","fr"],"20823":["Virgin","fr"],"20824":["MobiquiThings","fr"],"20825":["Lycamobile","fr"],"20826":["NRJ","fr"],"20827":["Coriolis","fr"],"20828":["Airmob","fr"],"20829":["Orange","fr"],"208299":["Add-On Multimedia","fr"],"20830":["Syma Mobile","fr"],"20831":["Vectone Mobile","fr"],"20832":["Orange","fr"],"20834":["Cellhire","fr"],"20835":["Free Mobile","fr"],"20836":["Free Mobile","fr"],"20837":["IP Directions","fr"],"20838":["Lebara","fr"],"20839":["Networth Telecom","fr"],"208506":["Airbus FR","fr"],"20888":["Bouygues Telecom","fr"],"20889":["Hub One","fr"],"20891":["Orange","fr"],"20892":["IP Directions","fr"],"20894":["Halys","fr"],"208999":["Fix Line","fr"],"21201":["Monaco Telecom","mc"],"21210":["MONACO TELECOM","mc"],"21303":["Mobiland","ad"],"21401":["Vodafone","es"],"21402":["Altecom","es"],"21403":["Orange","es"],"21404":["Yoigo","es"],"21405":["Movistar","es"],"21406":["Euskaltel","es"],"21407":["Movistar","es"],"21408":["Euskaltel","es"],"21409":["Orange","es"],"21410":["Zinnia","es"],"21411":["Orange","es"],"21412":["Venus Movil","es"],"21414":["Avatel Movil","es"],"21415":["BT Espana SAU","es"],"21416":["mobil R","es"],"21417":["mobil R","es"],"21418":["ONO","es"],"21419":["Simyo","es"],"21420":["Fonyou Telecom","es"],"21421":["Jazz Telecom SAU","es"],"21422":["Digi Spain","es"],"21423":["Yoigo","es"],"21425":["Lycamobile","es"],"21426":["Lleida","es"],"21427":["Truphone","es"],"21429":["Yoigo","es"],"214299":["ACN","es"],"21432":["ION Mobile","es"],"21433":["Yoigo","es"],"21434":["ION Mobile","es"],"21435":["SUMA movil","es"],"21436":["Alai","es"],"21437":["Vodafone","es"],"21438":["Movistar","es"],"214999":["Fix Line","es"],"21601":["Yettel","hu"],"21602":["MVM NET","hu"],"21603":["Digi","hu"],"216299":["Antenna","hu"],"21630":["Magyar Telekom","hu"],"21670":["Vodafone","hu"],"21671":["UPC Magyarorszag Kft.","hu"],"216999":["Fix line","hu"],"21803":["Eronet Mobile Communications Ltd.","ba"],"21805":["MOBI'S (Mobilina Srpske)","ba"],"21890":["GSMBIH","ba"],"21901":["Hrvatski Telekom","hr"],"21902":["Telemach","hr"],"21910":["A1/Tomato","hr"],"21912":["TELE FOCUS","hr"],"21920":["Hrvatski Telekom","hr"],"219999":["Fix Line","hr"],"22001":["Yettel","rs"],"22002":["Yettel","rs"],"22003":["Telekom Srbija a.d.","rs"],"22005":["A1 SRB","rs"],"22011":["Globaltel","rs"],"22020":["VIP","rs"],"220299":["Failed Calls","rs"],"22101":["Vala","xk"],"22102":["IPKO","xk"],"22103":["MTS","xk"],"22106":["Dardafon.Net LLC","xk"],"22107":["D3 mobile","xk"],"221299":["MTS","xk"],"22200":["Premium Numbers","it"],"22201":["TIM","it"],"22202":["Elsacom","it"],"22206":["Vodafone","it"],"22207":["Kena","it"],"22208":["Fastweb SpA","it"],"22210":["Vodafone","it"],"222299":["A-Tono","it"],"22230":["RFI","it"],"22233":["Poste Mobile","it"],"22234":["BT mobile","it"],"22235":["Lycamobile","it"],"22236":["Digi Italy","it"],"22237":["WindTre / Hi3G","it"],"22239":["SMS.it / LINK Mobility","it"],"22240":["Agile Telecom","it"],"22242":["Enel","it"],"22243":["Telecom Italia Mobile","it"],"22244":["Mundio","it"],"22248":["Telecom Italia Mobile","it"],"22249":["Vianova Mobile","it"],"22250":["Iliad","it"],"22251":["ho.","it"],"22253":["WEB CoopVoce","it"],"22254":["Plintron","it"],"22256":["Spusu IT","it"],"22258":["rdcom","it"],"22277":["IPSE 2000","it"],"22288":["WINDTRE","it"],"22298":["Blu","it"],"22299":["WINDTRE","it"],"222999":["Fix Line","it"],"225299":["Failed Calls","va"],"22601":["Vodafone","ro"],"22602":["Romtelecom SA","ro"],"22603":["Telekom","ro"],"22604":["Telekom Romania","ro"],"22605":["Digi.Mobil","ro"],"22606":["Telekom Romania","ro"],"22610":["Orange","ro"],"22611":["Enigma Systems","ro"],"22616":["Lycamobile","ro"],"226299":["Iristel","ro"],"22801":["Swisscom","ch"],"22802":["Sunrise","ch"],"22803":["Salt","ch"],"22805":["Comfone AG","ch"],"22806":["SBB AG","ch"],"22807":["IN&Phone SA","ch"],"22808":["Tele2 Telecommunications AG","ch"],"22809":["Comfone","ch"],"22812":["Sunrise","ch"],"22851":["Bebbicell AG","ch"],"22852":["Mundio Mobile AG","ch"],"22853":["Sunrise","ch"],"22854":["Lycamobile","ch"],"22858":["Beeone","ch"],"22859":["Vectone Mobile","ch"],"22860":["Sunrise","ch"],"22862":["Telecom26","ch"],"22865":["Nexphone","ch"],"22866":["Inovia","ch"],"22869":["MTEL","ch"],"22870":["Tismi","ch"],"22871":["Spusu CH","ch"],"228999":["Fix Line","ch"],"23001":["T-Mobile","cz"],"23002":["O2","cz"],"23003":["Vodafone","cz"],"23004":["Mobilkom a.s.","cz"],"23005":["PODA","cz"],"23007":["T-Mobile","cz"],"23008":["Compatel","cz"],"23009":["Uniphone","cz"],"230299":["+4U Mobile","cz"],"23098":["Sprava Zeleznicni Dopravni Cesty","cz"],"23099":["Vodafone","cz"],"230999":["Fix Line","cz"],"23101":["Orange","sk"],"23102":["Slovak Telekom","sk"],"23103":["4ka SK","sk"],"23104":["Eurotel, UMTS","sk"],"23105":["Orange, UMTS","sk"],"23106":["O2","sk"],"23107":["Orange","sk"],"23108":["Uniphone","sk"],"23115":["Orange","sk"],"231299":["Vonage","sk"],"23150":["Telekom","sk"],"23199":["ZSR","sk"],"23201":["A1 Telekom","at"],"23202":["A1 Telekom","at"],"23203":["Magenta Telekom","at"],"23204":["T-Mobile / Magenta","at"],"23205":["Drei","at"],"23206":["Hutchison Drei / 3","at"],"23207":["Magenta Telekom","at"],"23208":["Telefonica Austria","at"],"23209":["A1 Telekom","at"],"23210":["Drei","at"],"23211":["A1 Telekom","at"],"23212":["A1 Telekom","at"],"23213":["T-Mobile / Magenta","at"],"23214":["Hutchinson Drei","at"],"23215":["T-Mobile / Magenta","at"],"23216":["Hutchinson Drei","at"],"23217":["Spusu AT","at"],"23218":["smartspace","at"],"23219":["Hutchinson Drei","at"],"23220":["Mtel","at"],"23222":["Plintron","at"],"23223":["T-Mobile / Magenta","at"],"23224":["Smartel Services","at"],"23225":["Holding Graz","at"],"23226":["LIWEST Mobil","at"],"23227":["Tismi","at"],"232299":["ArgoNET","at"],"23291":["OBB Infrastruktur","at"],"232999":["Fix Line","at"],"23400":["British Telecom","gb"],"23401":["Mapesbury Communications Ltd.","gb"],"23402":["O2","gb"],"23403":["Jersey Telenet Ltd","gb"],"23404":["FMS Solutions Ltd","gb"],"23405":["Spitfire Network Services Ltd","gb"],"23406":["Internet One Ltd","gb"],"23407":["Cable and Wireless plc","gb"],"23408":["BT OnePhone","gb"],"23409":["Wire9 Telecom plc","gb"],"23410":["O2","gb"],"23411":["O2","gb"],"23412":["Ntework Rail Infrastructure Ltd","gb"],"23413":["Ntework Rail Infrastructure Ltd","gb"],"23414":["Hay Systems Ltd","gb"],"23415":["Vodafone","gb"],"23416":["Opal Telecom Ltd","gb"],"23417":["Flextel Ltd","gb"],"23418":["Wire9 Telecom plc","gb"],"23419":["Teleware plc","gb"],"23420":["Three Mobile","gb"],"23422":["Telesign Mobile","gb"],"23423":["Icron Network","gb"],"23424":["Greenfone","gb"],"23425":["Truphone","gb"],"23426":["Lycamobile","gb"],"23427":["Tata Communications Ltd","gb"],"23428":["Marathon Telecom","gb"],"23429":["aql","gb"],"23430":["EE","gb"],"23431":["EE","gb"],"23432":["EE","gb"],"23433":["EE","gb"],"23434":["Orange","gb"],"23435":["JSC Ingenicum","gb"],"23436":["Sure Isle of Man","gb"],"23437":["Synectiv","gb"],"23438":["Virgin Mobile","gb"],"23439":["Gamma","gb"],"23440":["Spusu GB","gb"],"23450":["Jersey Telecom","gb"],"23451":["now broadband","gb"],"23453":["TANGO","gb"],"23455":["Cable and Wireless Guensey Ltd","gb"],"23456":["NCSC","gb"],"23457":["Sky","gb"],"23458":["Manx Telecom","gb"],"23471":["Emergency Services Network","gb"],"23472":["Hanhaa Mobile","gb"],"23474":["Pareteum","gb"],"23475":["Inquam Telecom (Holdings) Ltd.","gb"],"23476":["British Telecom","gb"],"23477":["Vodafone","gb"],"23478":["Airwave mmO2 Ltd","gb"],"23486":["EE","gb"],"23489":["Vodafone","gb"],"23491":["Vodafone","gb"],"23492":["Vodafone","gb"],"23494":["Three Mobile","gb"],"23495":["Network Rail","gb"],"23499":["08Direct","gb"],"234998":["Virgin Mobile","gb"],"234999":["Fix Line","gb"],"23502":["Everyth. Ev.wh.","gb"],"23594":["Three Mobile","gb"],"23801":["TDC Mobil","dk"],"23802":["Telenor","dk"],"23803":["MIGway A/S","dk"],"23804":["Nexcon.io","dk"],"23806":["3","dk"],"23807":["Barablu Mobile Ltd.","dk"],"23808":["Voxbone / Bandwidth","dk"],"23810":["TDC Mobil","dk"],"23812":["Lycamobile","dk"],"23813":["Compatel","dk"],"23814":["Monty Mobile","dk"],"23815":["Net 1","dk"],"23816":["Tismi","dk"],"23817":["Gotanet","dk"],"23820":["Telia","dk"],"23823":["Banedanmark","dk"],"23825":["Viahub","dk"],"23828":["LINK Mobility","dk"],"23830":["Telia","dk"],"23842":["Greenwave","dk"],"23866":["Telenor","dk"],"23873":["Onomondo","dk"],"23877":["Tele2","dk"],"23888":["Cobira","dk"],"23896":["Telia","dk"],"238999":["Fix Line","dk"],"24001":["Telia Sverige AB","se"],"24002":["3 (Hi3G Access AB)","se"],"24003":["Nordisk Mobiltelefon AS","se"],"24004":["3G Infrastructure Services AB","se"],"24005":["Svenska UMTS-Nät AB","se"],"24006":["Vimla","se"],"24007":["Tele2/Comviq Sverige/Com Hem","se"],"24008":["Telenor Sverige AB","se"],"24009":["Telenor Sweden (not used)","se"],"24010":["Spring Mobil AB","se"],"24011":["Linholmen Science Park AB","se"],"24012":["Barablu Mobile Scandinavia Ltd","se"],"24013":["Ventelo Sverige AB","se"],"24014":["TDC Mobil A/S","se"],"24015":["Wireless Maingate Nordic AB","se"],"24016":["42IT AB","se"],"24017":["Gotanet","se"],"24018":["Messit / Minicall","se"],"24019":["Vectone Mobile","se"],"24020":["Wireless Maingate Message Services AB","se"],"24021":["Banverket","se"],"24022":["EUtel","se"],"24023":["Infobip","se"],"24024":["Telenor","se"],"24025":["Monty Mobile","se"],"24026":["Twilio","se"],"24027":["Globetouch","se"],"24028":["LINK Mobility","se"],"24029":["MI Carrier Services","se"],"24030":["NextGen Mobile Ltd (CardBoardFish)","se"],"24031":["Rebtel","se"],"24032":["Compatel","se"],"24033":["Mobile Arts","se"],"24035":["42 Telecom","se"],"24036":["interactive digital media / IDM","se"],"24037":["Sinch","se"],"24038":["Voxbone / Bandwidth","se"],"24039":["Primlight","se"],"24040":["Netmore","se"],"24042":["Telenor Connexion","se"],"24043":["MobiWeb","se"],"24044":["Telenabler","se"],"24045":["Spirius","se"],"24046":["Viahub","se"],"24047":["Viatel","se"],"24048":["Tismi","se"],"24050":["Telavox","se"],"24063":["Fink Telecom","se"],"240999":["Fix Line","se"],"24201":["Telenor","no"],"242017":["Ventelo AS","no"],"24202":["Telia","no"],"24203":["Teletopia Mobile Communications AS","no"],"24204":["Tele2 Norge AS","no"],"24205":["OneCall","no"],"24206":["ICE","no"],"24207":["Ventelo AS","no"],"24208":["TDC Mobil A/S","no"],"24209":["com4","no"],"24210":["Nkom","no"],"24212":["Telenor","no"],"24214":["Ice Norway","no"],"24215":["eRate","no"],"24216":["Iristel","no"],"24220":["BANE NOR","no"],"24221":["BANE NOR","no"],"24222":["Altibox Mobil","no"],"24223":["Lycamobile","no"],"242299":["bigblu","no"],"242999":["Fix Line","no"],"24403":["DNA","fi"],"24404":["Finnet Networks Ltd.","fi"],"24405":["Elisa","fi"],"24406":["Elisa","fi"],"24407":["Nokia Test Network","fi"],"24408":["Unknown","fi"],"24409":["Finnet Group","fi"],"24410":["TDC","fi"],"24411":["Viahub","fi"],"24412":["DNA","fi"],"24413":["DNA","fi"],"24414":["Alands Mobiltelefon AB","fi"],"24415":["Telit","fi"],"24416":["Oy Finland Tele2 AB","fi"],"24421":["Elisa","fi"],"24424":["Nord Connect","fi"],"24426":["Compatel","fi"],"24429":["Scnl Truphone","fi"],"244299":["Benemen","fi"],"24432":["Voxbone / Bandwidth","fi"],"24433":["VIRVE","fi"],"24435":["Ukko Mobile","fi"],"24436":["Telia","fi"],"24437":["Tismi","fi"],"24438":["NSN","fi"],"24439":["NSN","fi"],"24440":["NSN","fi"],"24441":["NSN","fi"],"24442":["Viahub","fi"],"24443":["Telavox","fi"],"24445":["VIRVE","fi"],"24446":["VIRVE","fi"],"24447":["VIRVE","fi"],"24482":["interactive digital media / IDM","fi"],"24491":["Telia","fi"],"24601":["Telia","lt"],"24602":["BITĖ","lt"],"24603":["Tele2","lt"],"24605":["LTG","lt"],"24606":["Mediafon","lt"],"246299":["SkyCall","lt"],"24701":["LMT","lv"],"24702":["Tele2/ZZ","lv"],"24703":["Telekom Baltija","lv"],"24704":["Beta Telecom","lv"],"24705":["Bite","lv"],"24706":["SIA Rigatta","lv"],"24707":["SIA Master Telecom","lv"],"24708":["VENTA Mobile","lv"],"24709":["XOmobile","lv"],"24710":["LMT","lv"],"247299":["Premium Numbers","lv"],"24801":["Telia","ee"],"24802":["Elisa","ee"],"24803":["Tele2","ee"],"24804":["OY Top Connect","ee"],"24805":["AS Bravocom Mobiil","ee"],"24806":["OY ViaTel","ee"],"24807":["Televõrgu AS","ee"],"24813":["Telia","ee"],"24871":["Siseministeerium (Ministry of Interior)","ee"],"25001":["МТС","ru"],"25002":["MegaFon","ru"],"25003":["Tele2","ru"],"25004":["Sibchallenge","ru"],"25005":["Tele2","ru"],"250050":["Sberbank-Telecom","ru"],"25007":["BM Telecom","ru"],"25009":["Skylink","ru"],"25010":["Don Telecom","ru"],"25011":["Orensot","ru"],"25012":["Tele2","ru"],"25013":["Kuban GSM","ru"],"25015":["ZAO SMARTS","ru"],"25016":["New Telephone Company","ru"],"25017":["Tele2","ru"],"25019":["Volgograd Mobile","ru"],"25020":["Tele2","ru"],"25026":["VTB Mobile","ru"],"25028":["Extel","ru"],"250299":["A-Mobile","ru"],"25032":["Win Mobile","ru"],"25033":["SEVTELECOM","ru"],"25034":["Krymtelecom","ru"],"25035":["Motiv","ru"],"25039":["Tele2","ru"],"25042":["MTT","ru"],"25044":["Stuvtelesot","ru"],"25047":["Next Mobile","ru"],"25048":["Global Telecom","ru"],"25050":["Sberbank","ru"],"25054":["Letai Mobile","ru"],"25055":["Glonass","ru"],"25057":["Matrix Mobile","ru"],"25060":["Volna Mobile","ru"],"25062":["Tinkoff","ru"],"25077":["Glonass","ru"],"25092":["Printelefone","ru"],"25093":["Telecom XXI","ru"],"25097":["Phoenix","ru"],"25099":["Билайн","ru"],"250999":["Fix Line","ru"],"25501":["Ukrainian Mobile Communication, UMC","ua"],"25502":["T-Mobile - UA","ua"],"25503":["Kyivstar GSM","ua"],"25504":["International Telecommunications Ltd.","ua"],"25505":["Golden Telecom","ua"],"25506":["Astelit","ua"],"25507":["Ukrtelecom","ua"],"25521":["CJSC - Telesystems of Ukraine","ua"],"25539":["Golden Telecom","ua"],"25550":["Vodafone","ua"],"25567":["KyivStar","ua"],"25568":["Kyivstar","ua"],"25599":["Phoenix","ua"],"25701":["A1 BY","by"],"25702":["MTS","by"],"25703":["BelCel JV","by"],"25704":["life:)","by"],"25901":["Orange Moldova GSM","md"],"25902":["Moldcell","md"],"25903":["Unite","md"],"25904":["Eventis Mobile GSM","md"],"25905":["Unité","md"],"25999":["Unite","md"],"26001":["Plus","pl"],"26002":["T-Mobile","pl"],"26003":["Orange","pl"],"26004":["Tele2 Polska (Tele2 Polska Sp. Z.o.o.)","pl"],"26005":["IDEA (UMTS)/PTK Centertel sp. Z.o.o.","pl"],"26006":["PLAY","pl"],"26007":["Premium internet","pl"],"26008":["E-Telko","pl"],"26009":["Telekomunikacja Kolejowa (GSM-R)","pl"],"26010":["Telefony Opalenickie","pl"],"26011":["NORDISK Polska","pl"],"26012":["Cyfrowy Polsat","pl"],"26013":["Move","pl"],"26014":["Move","pl"],"26015":["Aero2","pl"],"26016":["Aero2","pl"],"26017":["Aero2","pl"],"26018":["AMD Telecom","pl"],"26019":["NetBalt","pl"],"26020":["Tismi","pl"],"26022":["Twilio","pl"],"26027":["Ntel Solutions","pl"],"260299":["3S","pl"],"26032":["Compatel","pl"],"26034":["T-Mobile","pl"],"26035":["PKP","pl"],"26036":["Mundio Mobile Sp. z o.o.","pl"],"26038":["CallFreedom Sp. z o.o.","pl"],"26039":["Voxbone / Bandwidth","pl"],"26041":["EZ Mobile","pl"],"26042":["MobiWeb","pl"],"26044":["Rebtel","pl"],"26045":["Virgin Mobile","pl"],"26047":["SMSHIGHWAY","pl"],"26048":["Agile Telecom","pl"],"26049":["Messagebird","pl"],"26090":["Polska Spolka Gazownictwa","pl"],"26097":["Politechnika Lodzka Uczelniane","pl"],"26098":["Play","pl"],"260999":["Fix Line","pl"],"26201":["Telekom","de"],"26202":["Vodafone","de"],"26203":["O2","de"],"26204":["Vodafone","de"],"26205":["Telefonica / E-Plus","de"],"26206":["Telekom","de"],"26207":["O2","de"],"26208":["Telefonica / O2","de"],"26209":["Vodafone Lab","de"],"26210":["Arcor AG & Co.","de"],"26211":["O2","de"],"26212":["Dolphin Telecom (Deutschland) GmbH","de"],"26213":["Mobilcom Multimedia GmbH","de"],"26214":["Group 3G UMTS GmbH (Quam)","de"],"26215":["Airdata AG","de"],"26216":["Telefonica / O2","de"],"26217":["Telefonica / E-Plus","de"],"26220":["Voiceworks DE","de"],"26221":["Multiconnect","de"],"26222":["sipgate","de"],"26223":["1&1","de"],"26224":["TelcoVillage","de"],"262299":["1&1","de"],"26233":["sipgate","de"],"26242":["Vodafone","de"],"26243":["Lycamobile","de"],"26276":["Siemens AG, ICMNPGUSTA","de"],"26277":["Telefonica / E-Plus","de"],"26278":["Telekom / T-mobile","de"],"262999":["Fix Line","de"],"26601":["Gibtelecom GSM","gi"],"26606":["CTS Mobile","gi"],"26609":["Cloud9 Mobile Communications","gi"],"266299":["GibFibreSpeed","gi"],"266999":["Fix Line","gi"],"26801":["Vodafone","pt"],"26802":["Digi Portugal","pt"],"26803":["NOS","pt"],"26804":["Lycamobile","pt"],"26805":["Oniway - Inforcomunicaçôes, S.A.","pt"],"26806":["MEO","pt"],"26807":["NOS","pt"],"26808":["MEO","pt"],"268299":["NOWO","pt"],"26880":["MEO","pt"],"26891":["Vodafone","pt"],"26893":["NOS","pt"],"268999":["Fix Line","pt"],"27001":["P&T Luxembourg","lu"],"27002":["MTX","lu"],"27005":["Luxembourg Online","lu"],"27010":["Blue Communications","lu"],"270299":["Bouygues Telecom","lu"],"27077":["Tango","lu"],"27081":["e-LUX Mobile","lu"],"27099":["Orange","lu"],"270999":["Fix Line","lu"],"27201":["Vodafone","ie"],"27202":["3","ie"],"27203":["Meteor Mobile Communications Ltd.","ie"],"27204":["Access Telecom","ie"],"27205":["3","ie"],"27207":["Eircom","ie"],"27208":["Meteor / eir mobile","ie"],"27209":["Clever Communications Ltd.","ie"],"27211":["Tesco Mobile","ie"],"27213":["Lycamobile","ie"],"27215":["Virgin Media","ie"],"27217":["3","ie"],"27225":["Sky IE","ie"],"27401":["Iceland Telecom Ltd.","is"],"27402":["Tal hf","is"],"27403":["Islandssimi GSM ehf","is"],"27404":["IMC Islande ehf","is"],"27405":["Vodafone","is"],"27407":["IceCell ehf","is"],"27408":["Siminn","is"],"27409":["Amitelo","is"],"27411":["Nova","is"],"27412":["Vodafone","is"],"27416":["Tismi","is"],"27431":["Siminn","is"],"27601":["One / AMC","al"],"27602":["Vodafone","al"],"27603":["Eagle Mobile","al"],"27604":["PLUS Communication Sh.a","al"],"27801":["Epic","mt"],"27821":["go mobile","mt"],"27830":["GO Mobile","mt"],"27877":["Melita","mt"],"278999":["Fix Line","mt"],"28001":["CYTA","cy"],"28002":["Cytamobile-Vodafone","cy"],"28010":["epic","cy"],"28020":["PrimeTel","cy"],"28022":["Cablenet","cy"],"280999":["Fix Line","cy"],"28201":["Geocell Ltd.","ge"],"28202":["Magti GSM Ltd.","ge"],"28203":["Iberiatel Ltd.","ge"],"28204":["Mobitel Ltd.","ge"],"28205":["Silknet","ge"],"28207":["GlobalCell","ge"],"28208":["Silknet","ge"],"28210":["Premium Net","ge"],"28211":["Mobilive","ge"],"28212":["Telecom 1","ge"],"28222":["MyPhone","ge"],"28301":["ArmenTel","am"],"28304":["Karabakh Telecom","am"],"28305":["K Telecom CJSC","am"],"28310":["Orange","am"],"28401":["A1","bg"],"28403":["VIVACOM","bg"],"28405":["Yettel","bg"],"28406":["Vivacom","bg"],"28411":["bulsatcom","bg"],"28413":["MAX TELECOM","bg"],"28601":["Paycell | Turkcell","tr"],"28602":["Vodafone","tr"],"28603":["Türk Telekom","tr"],"28604":["Türk Telekom","tr"],"286299":["Asistan Telekom","tr"],"286999":["Fix Line","tr"],"28801":["Faroese Telecom - GSM","fo"],"28802":["Kall GSM","fo"],"28803":["Tosa","fo"],"28967":["Aquafon","ge"],"28968":["A-Mobile","ge"],"28988":["A-Mobile","ge"],"29001":["Tele Greenland","gl"],"29201":["SMT - San Marino Telecom","sm"],"292299":["TeleneT","sm"],"29310":["Slovenske zeleznice","si"],"29320":["Compatel","si"],"293299":["HOT mobil","si"],"29340":["SI Mobil","si"],"29341":["Telekom Slovenije","si"],"29364":["T-2 d.o.o.","si"],"29370":["Telemach","si"],"29386":["Elektro Gorenjska","si"],"293999":["Fix Line","si"],"29401":["Mkedonski Telecom AD Skopje","mk"],"29402":["Cosmofon","mk"],"29403":["Nov Operator","mk"],"29404":["Lycamobile","mk"],"29411":["Mobik","mk"],"294299":["Failed Calls","mk"],"29475":["A1","mk"],"29501":["Telecom FL AG","li"],"29502":["Viag Europlatform AG","li"],"29505":["Mobilkom (Liechstein) AG","li"],"29506":["CUBIC","li"],"29507":["First Mobile AG","li"],"29509":["EMnify","li"],"295299":["Datamobile","li"],"29577":["Tele2 AG","li"],"29701":["ONE","me"],"29702":["Crnogorski Telekom","me"],"29703":["MTEL d.o.o. Podgorica","me"],"302130":["Xplornet","ca"],"302131":["Xplornet","ca"],"302220":["Telus Mobility","ca"],"302270":["EastLink","ca"],"302290":["Airtel Wireless","ca"],"302320":["Chatr Mobile","ca"],"30236":["Clearnet","ca"],"302360":["Clearnet","ca"],"302361":["Clearnet","ca"],"302370":["FIDO (Rogers AT&T/ Microcell)","ca"],"302380":["DMTS Mobility","ca"],"302490":["Freedom Mobile","ca"],"302500":["Videotron","ca"],"302510":["Videotron","ca"],"302520":["Videotron","ca"],"302610":["Bell Mobility","ca"],"30262":["Ice Wireless","ca"],"30263":["Aliant Mobility","ca"],"302630":["Bell Mobility","ca"],"30264":["Bell Mobility","ca"],"302640":["Bell Mobility","ca"],"302651":["Bell Mobility","ca"],"302652":["BC Tel Mobility","ca"],"302653":["Telus Mobility","ca"],"302654":["Sask Tel Mobility","ca"],"302655":["MTS Mobility","ca"],"302656":["Tbay Mobility","ca"],"302657":["Quebectel Mobility","ca"],"302660":["MTS Mobility","ca"],"30267":["CityTel Mobility","ca"],"302670":["CityWest Mobility","ca"],"30268":["Sask Tel Mobility","ca"],"302680":["Sask Tel Mobility","ca"],"302681":["Sask Tel Mobility","ca"],"302701":["NB Tel Mobility","ca"],"302702":["MT&T Mobility","ca"],"302703":["New Tel Mobility","ca"],"30271":["Globalstar","ca"],"302710":["Globalstar Canada","ca"],"30272":["Rogers","ca"],"302720":["Rogers","ca"],"302760":["Public Mobile","ca"],"302780":["Sask Tel Mobility","ca"],"302781":["Sask Tel Mobility","ca"],"30801":["St. Pierre-et-Miquelon Télécom","pm"],"30808":["St. Pierre-et-Miquelon Télécom","pm"],"310003":["Unknown","us"],"310004":["Verizon Wireless","us"],"310010":["MCI","us"],"310011":["Northstar","us"],"310012":["Verizon Wireless","us"],"310013":["Mobile Tel Inc.","us"],"310014":["Testing US","us"],"310016":["Leap Wireless International Inc.","us"],"310017":["North Sight Communications Inc.","us"],"310020":["Union Telephone Company","us"],"310023":["C Spire","us"],"310026":["T-Mobile - US","us"],"310028":["ALU Test-SIM","us"],"310030":["AT&T","us"],"310032":["IT&E OverSeas","gu"],"310033":["Guam Teleph. Auth","gu"],"310034":["Nevada Wireless LLC","us"],"310040":["MTA Communications dba MTA Wireless","us"],"310050":["ACS Wireless Inc.","us"],"31006":["Consolidated Telcom","us"],"310060":["Consolidated Telcom","us"],"310070":["AT&T","us"],"310080":["Corr Wireless Communications LLC","us"],"310090":["Edge Wireless LLC","us"],"310100":["New Mexico RSA 4 East Ltd. Partnership","us"],"310110":["Pacific Telecom Inc","us"],"310120":["Sprint","us"],"310130":["Carolina West Wireless","us"],"31014":["Testing","us"],"310140":["GTA Wireless LLC","us"],"31015":["Unknown","us"],"310150":["Cricket Wireless","us"],"310160":["T-Mobile - US","us"],"310170":["AT&T","us"],"310180":["West Central Wireless","us"],"310190":["Alaska Wireless Communications LLC","us"],"310200":["T-Mobile - US","us"],"310210":["T-Mobile - US","us"],"310220":["T-Mobile - US","us"],"31023":["Unknown","us"],"310230":["T-Mobile - US","us"],"31024":["Unknown","us"],"310240":["T-Mobile - US","us"],"31025":["Unknown","us"],"310250":["T-Mobile - US","us"],"31026":["T-Mobile - US","us"],"310260":["T-Mobile - US","us"],"310270":["T-Mobile - US","us"],"310280":["AT&T","us"],"310290":["Nep Cellcorp Inc.","us"],"310300":["T-Mobile - US","us"],"31031":["T-Mobile","us"],"310310":["T-Mobile - US","us"],"310320":["Smith Bagley Inc, dba Cellular One","us"],"310330":["AN Subsidiary LLC","us"],"31034":["Nevada Wireless LLC","us"],"310340":["High Plains Midwest LLC, dba Wetlink Communications","us"],"310350":["Mohave Cellular L.P.","us"],"310360":["Cellular Network Partnership dba Pioneer Cellular","us"],"310370":["Guamcell Cellular and Paging","us"],"31038":["USA 3650 AT&T","us"],"310380":["AT&T","us"],"310390":["TX-11 Acquistion LLC","us"],"310400":["Wave Runner LLC","us"],"310410":["AT&T","us"],"310420":["Cincinnati Bell Wireless LLC","us"],"310430":["Alaska Digitel LLC","us"],"310440":["Numerex Corp.","us"],"310450":["North East Cellular Inc.","us"],"31046":["SIMMETRY","us"],"310460":["TMP Corporation","us"],"310470":["nTelos","us"],"310480":["Choice Phone LLC","us"],"310490":["T-Mobile - US","us"],"310500":["Public Service Cellular, Inc.","us"],"310510":["Airtel Wireless LLC","us"],"310520":["VeriSign","us"],"310530":["T-Mobile - US","us"],"310540":["Oklahoma Western Telephone Company","us"],"310550":["Wireless Solutions International","us"],"310560":["AT&T","us"],"310570":["MTPCS LLC","us"],"310580":["Inland Cellular","us"],"310590":["Verizon Wireless","us"],"310591":["Verizon Wireless","us"],"310592":["Verizon Wireless","us"],"310593":["Verizon Wireless","us"],"310594":["Verizon Wireless","us"],"310595":["Verizon Wireless","us"],"310596":["Verizon Wireless","us"],"310597":["Verizon Wireless","us"],"310598":["Verizon Wireless","us"],"310599":["Verizon Wireless","us"],"31060":["Consolidated Telcom","us"],"310600":["New-Cell Inc.","us"],"310610":["Elkhart Telephone Co. Inc. dba Epic Touch Co.","us"],"310620":["Coleman County Telecommunications Inc. (Trans Texas PCS)","us"],"310640":["T-Mobile - US","us"],"310650":["Jasper Wireless Inc.","us"],"310660":["T-Mobile - US","us"],"310670":["AT&T Mobility Vanguard Services","us"],"310680":["AT&T","us"],"310690":["Limitless Mobile","us"],"310700":["Cross Valiant Cellular Partnership","us"],"310710":["Arctic Slopo Telephone Association Cooperative","us"],"310720":["Wireless Solutions International Inc.","us"],"310730":["Sea Mobile","us"],"310740":["Telemetrix Inc.","us"],"310750":["East Kentucky Network LLC dba Appalachian Wireless","us"],"310760":["Panhandle Telecommunications Systems Inc.","us"],"310770":["Iowa Wireless Services LLC dba I Wireless","us"],"310780":["Connect Net Inc","us"],"310790":["PinPoint Communications Inc.","us"],"310800":["T-Mobile - US","us"],"310810":["Brazos Cellular Communications Ltd.","us"],"310820":["South Canaan Cellular Communications Co. LP","us"],"310830":["Caprock Cellular Ltd. Partnership","us"],"310840":["Edge Mobile LLC","us"],"310850":["Aeris Communications, Inc.","us"],"310860":["TX RSA 15B2, LP dba Five Star Wireless","us"],"310870":["Kaplan Telephone Company Inc.","us"],"310880":["Advantage Cellular Systems, Inc.","us"],"310890":["Verizon Wireless","us"],"310900":["Mid-Rivers","us"],"310910":["Southern IL RSA Partnership dba First Cellular of Southern Illinois","us"],"310920":["James Valley","us"],"310930":["Copper Valley Wireless","us"],"310940":["Poka Lambro Telco Ltd.","us"],"310950":["AT&T","us"],"310960":["UBET Wireless","us"],"310970":["Globalstar USA","us"],"310980":["AT&T Wireless Inc.","us"],"310990":["Evolve","us"],"310995":["Android Emulator","us"],"310999":["Various Networks","us"],"311000":["Mid-Tex Cellular Ltd.","us"],"311010":["Chariton Valley Communications Corp., Inc.","us"],"311020":["Missouri RSA No. 5 Partnership","us"],"311030":["Indigo Wireless, Inc.","us"],"311040":["Commet Wireless, LLC","us"],"311050":["Thumb Cellular Limited Partnership","us"],"311060":["Space Data Corporation","us"],"311070":["Easterbrooke Cellular Corporation","us"],"311080":["Pine Telephone Company dba Pine Cellular","us"],"311090":["Siouxland PCS","us"],"311100":["NexTech Wireless","us"],"311110":["Alltel Communications Inc.","us"],"311120":["Choice Phone LLC","us"],"311140":["MBO Wireless Inc./Cross Telephone Company","us"],"311150":["Wilkes Cellular Inc.","us"],"311170":["PetroCom LLC","us"],"311180":["AT&T","us"],"311190":["Cellular Properties Inc.","us"],"311200":["ARINC","us"],"311210":["Farmers Cellular Telephone","us"],"311220":["U.S. Cellular","us"],"311221":["U.S. Cellular","us"],"311222":["U.S. Cellular","us"],"311223":["U.S. Cellular","us"],"311224":["U.S. Cellular","us"],"311225":["U.S. Cellular","us"],"311226":["U.S. Cellular","us"],"311227":["U.S. Cellular","us"],"311228":["U.S. Cellular","us"],"311229":["U.S. Cellular","us"],"311230":["C Spire","us"],"311240":["Cordova Wireless Communications Inc","us"],"311250":["Wave Runner LLC","us"],"311260":["SLO Cellular Inc. dba CellularOne of San Luis Obispo","us"],"311270":["Verizon Wireless","us"],"311271":["Alltel Communications Inc.","us"],"311272":["Alltel Communications Inc.","us"],"311273":["Alltel Communications Inc.","us"],"311274":["Alltel Communications Inc.","us"],"311275":["Alltel Communications Inc.","us"],"311276":["Alltel Communications Inc.","us"],"311277":["Alltel Communications Inc.","us"],"311278":["Alltel Communications Inc.","us"],"311279":["Alltel Communications Inc.","us"],"311280":["Verizon Wireless","us"],"311281":["Verizon Wireless","us"],"311282":["Verizon Wireless","us"],"311283":["Verizon Wireless","us"],"311284":["Verizon Wireless","us"],"311285":["Verizon Wireless","us"],"311286":["Verizon Wireless","us"],"311287":["Verizon Wireless","us"],"311288":["Verizon Wireless","us"],"311289":["Verizon Wireless","us"],"311290":["Pinpoint Wireless Inc.","us"],"311300":["Rutal Cellular Corporation","us"],"311310":["Leaco Rural Telephone Company Inc","us"],"311311":["Farmers","us"],"311320":["Commnet Wireless LLC","us"],"311330":["Bag Tussel Wireless LLC","us"],"311340":["Illinois Valley Cellular","us"],"311350":["Torrestar Networks Inc","us"],"311360":["Stelera Wireless LLC","us"],"311370":["GCI Communications Corp.","us"],"311380":["GreenFly LLC","us"],"311390":["Midwest Wireless Holdings LLC","us"],"311400":["Testing US","us"],"311410":["Iowa RSA No.2 Ltd Partnership","us"],"311420":["northwestcell","us"],"311430":["Chat Mobility","us"],"311440":["Bluegrass Cellular LLC","us"],"311450":["PTCI","us"],"311460":["Fisher Wireless Services Inc","us"],"311470":["Vitelcom Cellular Inc dba Innovative Wireless","us"],"311480":["Verizon Wireless","us"],"311481":["Verizon Wireless","us"],"311482":["Verizon Wireless","us"],"311483":["Verizon Wireless","us"],"311484":["Verizon Wireless","us"],"311485":["Verizon Wireless","us"],"311486":["Verizon Wireless","us"],"311487":["Verizon Wireless","us"],"311488":["Verizon Wireless","us"],"311489":["Verizon Wireless","us"],"311490":["T-Mobile - US","us"],"311500":["CTC Telecom Inc","us"],"311510":["Benton-Lian Wireless","us"],"311520":["Crossroads Wireless Inc","us"],"311530":["Wireless Communications Venture","us"],"311540":["Keystone Wireless Inc","us"],"311550":["Commnet Midwest LLC","us"],"311580":["U.S. Cellular","us"],"311581":["U.S. Cellular","us"],"311582":["U.S. Cellular","us"],"311583":["U.S. Cellular","us"],"311584":["U.S. Cellular","us"],"311585":["U.S. Cellular","us"],"311586":["U.S. Cellular","us"],"311587":["U.S. Cellular","us"],"311588":["U.S. Cellular","us"],"311589":["U.S. Cellular","us"],"311590":["California RSA No. 3 Limited Partnership","us"],"311600":["COX","us"],"311610":["North Dakota Network Company","us"],"311650":["United Wireless Communications Inc.","us"],"311660":["T-Mobile - Private 5G","us"],"311670":["Pine Belt Cellular, Inc.","us"],"311710":["Northeast Wireless Networks LLC","us"],"311740":["TelAlaska Cellular","us"],"311750":["Cleartalk","us"],"311780":["ASTCA","us"],"311800":["Bluegrass Wireless LLC","us"],"311810":["Bluegrass Wireless LLC","us"],"311830":["Thumb Cellular Limited Partnership","us"],"311860":["Uintah Basin Electronics Telecommunications Inc.","us"],"311870":["Boost","us"],"311880":["Sprint Spectrum","us"],"311882":["T-Mobile - US","us"],"311910":["MobileNation","us"],"311920":["Missouri RSA No 5 Partnership","us"],"311930":["Syringa","us"],"312010":["Missouri RSA No 5 Partnership","us"],"312030":["Cross Wireless Telephone Co.","us"],"312040":["Custer Telephone Cooperative Inc.","us"],"312090":["Allied Wireless Communications Corporation","us"],"312120":["East Kentucky Network LLC","us"],"312130":["East Kentucky Network LLC","us"],"312160":["Chat Mobility","us"],"312170":["Iowa RSA No. 2 Limited Partnership","us"],"312180":["Keystone Wireless LLC","us"],"312190":["Sprint Spectrum","us"],"312220":["Missouri RSA No 5 Partnership","us"],"312230":["North Dakota Network Company","us"],"312250":["T-Mobile - US","us"],"312270":["Cellular Network Partnership LLC","us"],"312280":["Cellular Network Partnership LLC","us"],"312290":["strata","us"],"312380":["Copper Valley Wireless","us"],"312420":["NexTech Ota","us"],"312530":["Sprint","us"],"312570":["Blue Wireless","us"],"312580":["Google CBRS","us"],"312670":["FirstNet (Lab)","us"],"312870":["GigSky","us"],"313100":["FirstNet","us"],"313110":["FirstNet","us"],"313120":["FirstNet","us"],"313130":["FirstNet","us"],"313140":["FirstNet","us"],"313380":["OptimERA Wireless","us"],"313390":["Optimum","us"],"313450":["Spectrum Mobile","us"],"313460":["Mobi","us"],"313770":["TANGO","us"],"313790":["Liberty Mobile","us"],"314020":["Spectrum+","us"],"314200":["Xfinity MSO","us"],"314240":["Xfinity Mobile 2.0","us"],"314420":["Cox MSO","us"],"314720":["OXIO","us"],"314730":["TextNow Wireless","us"],"315010":["CBRS","us"],"316010":["Nextel Communications Inc.","us"],"316011":["Southern Communications Services Inc.","us"],"33000":["Open Mobile","pr"],"33011":["Claro PR","pr"],"330110":["Claro PR","pr"],"33401":["AT&T MX","mx"],"334010":["NEXTEL","mx"],"33402":["Telcel","mx"],"334020":["Telcel","mx"],"33403":["Movistar","mx"],"334030":["Movistar","mx"],"33404":["AT&T/IUSACell","mx"],"334040":["AT&T MX","mx"],"33405":["AT&T/IUSACell","mx"],"334050":["AT&T MX","mx"],"334060":["SAI PCS","mx"],"334070":["AT&T MX","mx"],"334080":["AT&T MX","mx"],"33409":["AT&T MX","mx"],"334090":["AT&T MX","mx"],"334130":["Alestra Servicios Moviles","mx"],"334140":["ALTAN - Internal Use","mx"],"334170":["OXIO","mx"],"33450":["AT&T/IUSACell","mx"],"338020":["Cable & Wireless Jamaica Ltd.","jm"],"33805":["Mossel (Jamaica) Ltd.","jm"],"338050":["Mossel (Jamaica) Ltd.","jm"],"338070":["Claro","jm"],"338110":["Cable & Wireless","jm"],"33818":["Cable & Wireless","jm"],"338180":["Cable & Wireless","jm"],"34001":["Orange Caraïbe Mobiles","gf"],"34002":["Outremer Telecom","gf"],"34003":["Saint Martin et Saint Barthelemy Telcell Sarl","gf"],"34008":["Dauphin Telecom SU (Guadeloupe Telecom)","gp"],"34011":["TelCell GSM","gf"],"34012":["UTS Caraibe","mq"],"34020":["Digicel","gf"],"34080":["Dauphin Telecom","gf"],"342050":["Digicel","bb"],"342299":["Failed Calls","bb"],"342600":["Cable & Wireless (Barbados) Ltd.","bb"],"342750":["Digicel","bb"],"342810":["Cingular Wireless","bb"],"342820":["Sunbeach Communications","bb"],"34403":["APUA PCS","ag"],"344030":["imobile / APUA","ag"],"34492":["Flow","ag"],"344920":["Cable & Wireless (Antigua)","ag"],"344921":["FLOW","ag"],"34493":["Digicel","ag"],"344930":["AT&T Wireless (Antigua)","ag"],"346001":["Logic","ky"],"346006":["Digicel Ltd.","ky"],"346050":["Digicel","ky"],"346140":["Cable & Wireless (Cayman)","ky"],"348170":["Cable & Wireless","vg"],"348570":["Caribbean Cellular Telephone, Boatphone Ltd.","vg"],"34877":["Digicel","vg"],"348770":["Digicel","vg"],"350000":["Bermuda Digital Communications Ltd (BDC)","bm"],"350007":["Paradise Mobile","bm"],"35001":["Digicel","bm"],"35002":["M3 Wireless Ltd","bm"],"350299":["Failed Calls","bm"],"35099":["CellOne Ltd","bm"],"352030":["Digicel","gd"],"352050":["Digicel","gd"],"352110":["Grenada:Lime","gd"],"354860":["Cable & Wireless","ms"],"356110":["FLOW","kn"],"35650":["Digicel","kn"],"35670":["UTS Cariglobe","kn"],"358110":["Cable & Wireless","lc"],"35830":["Cingular Wireless","lc"],"35850":["Digicel (St Lucia) Limited","lc"],"360050":["Digicel","vc"],"36010":["Cingular","vc"],"360100":["Cingular","vc"],"360110":["Cable & Wireless (St. Vincent & the Grenadines) Ltd","vc"],"36070":["Digicel","vc"],"36251":["TELCELL GSM","an"],"362630":["Cingular Wireless","an"],"36269":["CT GSM","cw"],"36291":["SETEL GSM","an"],"36295":["EOCG Wireless NV","cw"],"362951":["UTS Wireless","an"],"362999":["Fix Line","bq"],"36301":["SETAR","aw"],"36302":["Digicel","aw"],"363020":["Digicel","aw"],"36320":["Digicel","aw"],"363299":["MIO","aw"],"36403":["Smart Communications","bs"],"364039":["BTC","bs"],"36430":["Cybercell / BaTelCo","bs"],"36439":["Cybercell / BaTelCo","bs"],"364390":["Bahamas Telecommunications","bs"],"36449":["ALIV BS","bs"],"364490":["Aliv","bs"],"365010":["Weblinks Limited","ai"],"365840":["Cable & Wireless","ai"],"365850":["Digicel","ai"],"366020":["Cingular Wireless/Digicel","dm"],"366050":["Wireless Ventures (Dominica) Ltd (Digicel Dominica)","dm"],"366110":["Cable & Wireless","dm"],"36801":["ETECSA","cu"],"368999":["Fix Line","cu"],"37001":["Altice Dominicana","do"],"37002":["Claro RD","do"],"370020":["Claro RD","do"],"37003":["Tricom S.A.","do"],"37004":["CentennialDominicana","do"],"37005":["Wind Telecom","do"],"37201":["Comcel","ht"],"37202":["Digicel","ht"],"37203":["Rectel","ht"],"37412":["TSTT Mobile","tt"],"374120":["Bmobile/TSTT","tt"],"374122":["TSTT Mobile","tt"],"374123":["TSTT Mobile","tt"],"374124":["TSTT Mobile","tt"],"374125":["TSTT Mobile","tt"],"374126":["TSTT Mobile","tt"],"374127":["TSTT Mobile","tt"],"374128":["TSTT Mobile","tt"],"374129":["TSTT Mobile","tt"],"37413":["Digicel Trinidad and Tobago Ltd.","tt"],"374130":["Digicel Trinidad and Tobago Ltd.","tt"],"374140":["LaqTel Ltd.","tt"],"376050":["Digicel TCI Ltd","tc"],"376350":["Cable & Wireless West Indies Ltd (Turks & Caicos)","tc"],"376352":["IslandCom Communications Ltd.","tc"],"37650":["Digicel","vi"],"40001":["Azercell Limited Liability Joint Venture","az"],"40002":["Bakcell Limited Liabil ity Company","az"],"40003":["Catel JV","az"],"40004":["Azerphone LLC","az"],"40006":["Naxtel","az"],"40101":["Beeline","kz"],"40102":["Kcell/activ","kz"],"40107":["Tele2/Altel","kz"],"40177":["Tele2/Altel","kz"],"40211":["Bhutan Telecom Ltd","bt"],"40217":["B-Mobile of Bhutan Telecom","bt"],"40277":["TashiCell","bt"],"40401":["Vi","in"],"40402":["Airtel","in"],"40403":["Airtel","in"],"40404":["Vi","in"],"404045":["Bharti Airtel Limited (Karnataka) (India)","in"],"40405":["Vi","in"],"40407":["Vi","in"],"40409":["Reliance","in"],"40410":["Airtel","in"],"40411":["Vi","in"],"40412":["Vi","in"],"40413":["Vi","in"],"40414":["Vi","in"],"40415":["Vi","in"],"40416":["Airtel","in"],"40417":["Aircel","in"],"40418":["Reliance","in"],"40419":["Vi","in"],"40420":["Vi","in"],"40421":["BPL Mobile Communications Ltd.","in"],"40422":["Vi","in"],"40424":["Vi","in"],"40425":["Aircel Ltd.","in"],"40427":["Vi","in"],"40428":["Aircel Ltd.","in"],"40429":["Aircel Ltd.","in"],"40430":["Vi","in"],"40431":["Airtel","in"],"40433":["Aircel","in"],"40434":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40436":["Reliance","in"],"40437":["Aircel Ltd.","in"],"40438":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40439":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40440":["Airtel","in"],"40441":["RPG Cellular","in"],"40442":["Aircel Ltd.","in"],"40443":["Vi","in"],"40444":["Vi","in"],"40445":["Airtel","in"],"40446":["Vi","in"],"40448":["Dishnet Wireless","in"],"40449":["Airtel","in"],"40450":["Reliance","in"],"40451":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40452":["Reliance","in"],"40453":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40454":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40455":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40456":["Vi","in"],"40457":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40458":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40459":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40460":["Vi","in"],"40462":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40464":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40465":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40466":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40467":["Reliance","in"],"40468":["Mahanagar Telephone Nigam Ltd.","in"],"40469":["Mahanagar Telephone Nigam Ltd.","in"],"40470":["Airtel","in"],"40471":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40472":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40473":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40474":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40475":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40476":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40477":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40478":["Vi","in"],"40479":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40480":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40481":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40482":["Vi","in"],"40483":["Reliable Internet Services Ltd.","in"],"40484":["Vi","in"],"40485":["Reliance","in"],"40486":["Vi","in"],"40487":["Vi","in"],"40488":["Vi","in"],"40489":["Vi","in"],"40490":["Airtel","in"],"40491":["Aircel Ltd.","in"],"40492":["Airtel","in"],"40493":["Airtel","in"],"40494":["Airtel","in"],"40495":["Airtel","in"],"40496":["Airtel","in"],"40497":["Airtel","in"],"40498":["Airtel","in"],"404998":["Fix Line","in"],"404999":["Various Networks","in"],"40501":["Reliance","in"],"405025":["TATA DOCOMO","in"],"405026":["TATA DOCOMO","in"],"405027":["TATA DOCOMO","in"],"405028":["TATA DOCOMO","in"],"405029":["TATA DOCOMO","in"],"40503":["Reliance","in"],"405030":["TATA DOCOMO","in"],"405031":["TATA DOCOMO","in"],"405032":["TATA DOCOMO","in"],"405033":["TATA DOCOMO","in"],"405034":["TATA DOCOMO","in"],"405035":["TATA DOCOMO","in"],"405036":["TATA DOCOMO","in"],"405037":["TATA DOCOMO","in"],"405038":["TATA DOCOMO","in"],"405039":["TATA DOCOMO","in"],"40504":["Reliance","in"],"405040":["TATA DOCOMO","in"],"405041":["TATA DOCOMO","in"],"405042":["TATA DOCOMO","in"],"405043":["TATA DOCOMO","in"],"405044":["TATA DOCOMO","in"],"405045":["TATA DOCOMO","in"],"405046":["TATA DOCOMO","in"],"405047":["TATA DOCOMO","in"],"40505":["Reliance","in"],"40506":["Reliance","in"],"40507":["Reliance","in"],"40508":["Reliance","in"],"40509":["Reliance","in"],"40510":["Reliance","in"],"40511":["Reliance","in"],"40512":["Reliance","in"],"40513":["Reliance","in"],"40514":["Reliance","in"],"40515":["Reliance","in"],"40517":["Reliance","in"],"40518":["Reliance","in"],"40519":["Reliance","in"],"40520":["Reliance","in"],"40521":["Reliance","in"],"40522":["Reliance","in"],"40523":["Reliance","in"],"40545":["Vi","in"],"40551":["Airtel","in"],"40552":["Airtel","in"],"40553":["Airtel","in"],"40554":["Airtel","in"],"40555":["Airtel","in"],"40556":["Airtel","in"],"40566":["Vi","in"],"40567":["Vi","in"],"40570":["Vi","in"],"405750":["Vi","in"],"405751":["Vi","in"],"405752":["Vi","in"],"405753":["Vi","in"],"405754":["Vi","in"],"405755":["Vi","in"],"405756":["Vi","in"],"405799":["Vi","in"],"405800":["Aircel Ltd.","in"],"405801":["Aircel Ltd.","in"],"405802":["Aircel Ltd.","in"],"405803":["Aircel Ltd.","in"],"405804":["Aircel Ltd.","in"],"405805":["Aircel Ltd.","in"],"405806":["Aircel Ltd.","in"],"405807":["Aircel Ltd.","in"],"405808":["Aircel Ltd.","in"],"405809":["Aircel Ltd.","in"],"405810":["Aircel Ltd.","in"],"405811":["Aircel Ltd.","in"],"405812":["Aircel Ltd.","in"],"405813":["Uninor","in"],"405814":["Uninor","in"],"405815":["Uninor","in"],"405816":["Uninor","in"],"405817":["Uninor","in"],"405818":["Uninor","in"],"405819":["Uninor","in"],"405820":["Uninor","in"],"405821":["Uninor","in"],"405822":["Uninor","in"],"405823":["Videocon","in"],"405824":["Videocon","in"],"405825":["Videocon","in"],"405826":["Videocon","in"],"405827":["Videocon","in"],"405828":["Videocon","in"],"405829":["Videocon","in"],"405830":["Videocon","in"],"405832":["Videocon","in"],"405833":["Videocon","in"],"405834":["Videocon","in"],"405835":["Videocon","in"],"405836":["Videocon","in"],"405837":["Videocon","in"],"405838":["Videocon","in"],"405840":["Reliance Jio","in"],"405841":["Videocon","in"],"405842":["Videocon","in"],"405843":["Videocon","in"],"405844":["Uninor","in"],"405845":["Vi","in"],"405846":["Vi","in"],"405847":["Vi","in"],"405848":["Vi","in"],"405849":["Vi","in"],"405850":["Vi","in"],"405851":["Vi","in"],"405852":["Vi","in"],"405853":["Vi","in"],"405854":["Reliance Jio","in"],"405855":["Reliance Jio","in"],"405856":["Reliance Jio","in"],"405857":["Reliance Jio","in"],"405858":["Reliance Jio","in"],"405859":["Reliance Jio","in"],"405860":["Reliance Jio","in"],"405861":["Reliance Jio","in"],"405862":["Reliance Jio","in"],"405863":["Reliance Jio","in"],"405864":["Reliance Jio","in"],"405865":["Reliance Jio","in"],"405866":["Reliance Jio","in"],"405867":["Reliance Jio","in"],"405868":["Reliance Jio","in"],"405869":["Reliance Jio","in"],"40587":["Reliance Telecom Private","in"],"405870":["Reliance Jio","in"],"405871":["Reliance Jio","in"],"405872":["Reliance Jio","in"],"405873":["Reliance Jio","in"],"405874":["Reliance Jio","in"],"405875":["Uninor","in"],"405876":["Uninor","in"],"405877":["Uninor","in"],"405878":["Uninor","in"],"405879":["Uninor","in"],"405880":["Uninor","in"],"405881":["STEL","in"],"405882":["STEL","in"],"405883":["STEL","in"],"405884":["STEL","in"],"405885":["STEL","in"],"405886":["STEL","in"],"405908":["Vi","in"],"405909":["Vi","in"],"405910":["Vi","in"],"405911":["Vi","in"],"405912":["Cheers","in"],"405913":["Cheers","in"],"405914":["Cheers","in"],"405915":["Cheers","in"],"405916":["Cheers","in"],"405917":["Cheers","in"],"405918":["Cheers","in"],"405919":["Cheers","in"],"405920":["Cheers","in"],"405921":["Cheers","in"],"405922":["Cheers","in"],"405923":["Cheers","in"],"405925":["Uninor","in"],"405926":["Uninor","in"],"405927":["Uninor","in"],"405928":["Uninor","in"],"405929":["Uninor","in"],"405930":["Cheers","in"],"405932":["Videocon","in"],"41001":["Jazz","pk"],"41003":["PAK Telecom Mobile Ltd. (UFONE)","pk"],"41004":["Zong","pk"],"41005":["SCOM","pk"],"41006":["Telenor","pk"],"41007":["Jazz","pk"],"41008":["Instaphone","pk"],"410299":["Failed Calls","pk"],"41201":["AWCC","af"],"41203":["WaselTelecom (WT)","af"],"41220":["Roshan","af"],"41230":["New1","af"],"41240":["Areeba Afghanistan","af"],"41250":["Etisalat","af"],"41280":["Mobifone","af"],"41288":["Afghan Telecom","af"],"41301":["Sri Lanka Telecom Mobitel","lk"],"41302":["Dialog Sri Lanka","lk"],"41303":["Celtel Lanka Ltd.","lk"],"41305":["Airtel Lanka","lk"],"41308":["Hutchison Telecommunications Lanka","lk"],"41401":["Myanmar Post and Telecommunication","mm"],"41405":["Ooredoo Myanmar","mm"],"41406":["Telenor","mm"],"41409":["Mytel","mm"],"414999":["Fix Line (Myanmar","mm"],"41501":["Alfa","lb"],"41503":["MTC Touch","lb"],"41505":["Ogero Mobile","lb"],"41515":["Connect","lb"],"41532":["Cellis","lb"],"41533":["Cellis","lb"],"41534":["Cellis","lb"],"41535":["Cellis","lb"],"41536":["Libancell","lb"],"41537":["Libancell","lb"],"41538":["Libancell","lb"],"41539":["Libancell","lb"],"41601":["Fastlink","jo"],"41602":["Xpress","jo"],"41603":["Umniah","jo"],"41677":["Orange Jordan","jo"],"416770":["Orange Jordan","jo"],"416999":["Fix Line","jo"],"41701":["Syriatel","sy"],"41702":["Spacetel Syria","sy"],"41709":["Syrian Telecom","sy"],"41750":["Rcell","sy"],"41805":["Asiacell","iq"],"41808":["SanaTel","iq"],"41820":["Zain Iraq","iq"],"41830":["Zain Iraq","iq"],"41840":["Korek","iq"],"41845":["Mobitel","iq"],"41862":["Itisaluna","iq"],"41866":["Fastlink","iq"],"41877":["SevenNet Layers","iq"],"41882":["Korek","iq"],"41892":["Omnnea","iq"],"41902":["Zain","kw"],"41903":["Ooredoo","kw"],"41904":["STC","kw"],"419999":["Fix Line","kw"],"42001":["STC","sa"],"42003":["Mobily","sa"],"42004":["Zain Saudi Arabia","sa"],"42005":["Virgin","sa"],"42006":["Lebara Mobile","sa"],"42007":["Zain","sa"],"42101":["SabaFon","ye"],"42102":["Spacetel Yemen","ye"],"42103":["YemenMobile","ye"],"42104":["HiTS-UNITEL","ye"],"42111":["YemenMobile","ye"],"42122":["YemenMobile","ye"],"421999":["Fix Line","ye"],"42202":["Omantel","om"],"42203":["Ooredoo","om"],"42204":["Omantel","om"],"42206":["Vodafone Oman","om"],"42402":["e& UAE","ae"],"42403":["du","ae"],"42501":["Partner Communications Co. Ltd.","il"],"42502":["Cellcom Israel Ltd.","il"],"42503":["Pelephone Communications Ltd.","il"],"42505":["Jawwal","ps"],"42506":["Ooredoo","ps"],"42507":["Hot Mobile","il"],"42508":["Golan Telecom","il"],"42509":["We4G","il"],"42510":["Partner Communications Co. Ltd.","il"],"42512":["Pelephone","il"],"42513":["Ituran","il"],"42514":["Alon Cellular Ltd","il"],"42515":["Home Cellular","il"],"42516":["Rami Levy","il"],"42517":["Von waves","il"],"42519":["019 Mobile","il"],"42522":["Maskyoo","il"],"42523":["Beezz","il"],"42526":["Annatel","il"],"425299":["Annatel Mobile","il"],"42577":["Hot Mobile","il"],"42601":["Batelco","bh"],"42602":["Zain Bahrain","bh"],"42604":["stc BH","bh"],"42605":["Batelco","bh"],"426299":["Failed Calls","bh"],"426999":["Fix Line","bh"],"42701":["Ooredoo","qa"],"42702":["Vodafone","qa"],"42800":["Skytel Co. Ltd","mn"],"42888":["Unitel","mn"],"42891":["Skytel","mn"],"42898":["G.Mobile","mn"],"42899":["Mobicom","mn"],"42901":["Nepal Telecommunications","np"],"42902":["Ncell","np"],"42903":["Nepal Telecommunications","np"],"42904":["Smart Telecom","np"],"429999":["Fix Line","np"],"43002":["Etisalat","ae"],"43102":["Etisalat","ae"],"43211":["IR-MCI (Hamrahe Avval)","ir"],"43214":["Telecommunication Kish Co. (KIFZO)","ir"],"43219":["MTCE (Espadan)","ir"],"43220":["Rightel","ir"],"43232":["Taliya","ir"],"43235":["Irancell","ir"],"43270":["MTCE","ir"],"43293":["Farzanegan Pars","ir"],"432999":["Fix Line","ir"],"43401":["Buztel","uz"],"43402":["Uzmacom","uz"],"43404":["Daewoo Unitel","uz"],"43405":["Coscom","uz"],"43406":["Perfectum Mobile","uz"],"43407":["Uzdunrobita","uz"],"43601":["JC Somoncom","tj"],"43602":["CJSC Indigo Tajikistan","tj"],"43603":["TT mobile","tj"],"43604":["Babilon-Mobile","tj"],"43605":["CTJTHSC Tajik-tel","tj"],"43612":["Tcell","tj"],"43701":["Beeline","kg"],"43702":["KT Mobile","kg"],"43703":["AkTel LLC","kg"],"43705":["MegaCom","kg"],"43709":["O!","kg"],"43710":["Saima","kg"],"437299":["Failed Calls","kg"],"43801":["Barash Communication Technologies (BCTI)","tm"],"43802":["TM-Cell","tm"],"44000":["eMobile","jp"],"44001":["NTT DoCoMo","jp"],"44002":["NTT DoCoMo","jp"],"44003":["IIJmio","jp"],"44004":["SoftBank","jp"],"44005":["SoftBank","jp"],"44006":["SoftBank","jp"],"44007":["KDDI","jp"],"44008":["KDDI","jp"],"44009":["NTT DoCoMo","jp"],"44010":["DOCOMO MVNO","jp"],"44011":["Rakuten Mobile(MNO)","jp"],"44012":["NTT DoCoMo","jp"],"44013":["OCN MOBILE ONE","jp"],"44014":["NTT DoCoMo","jp"],"44015":["NTT DoCoMo","jp"],"44016":["NTT DoCoMo","jp"],"44017":["NTT DoCoMo","jp"],"44018":["NTT DoCoMo","jp"],"44019":["NTT DoCoMo","jp"],"44020":["SoftBank","jp"],"44021":["NTT DoCoMo","jp"],"44022":["NTT DoCoMo","jp"],"44023":["NTT DoCoMo","jp"],"44024":["NTT DoCoMo","jp"],"44025":["NTT DoCoMo","jp"],"44026":["NTT DoCoMo","jp"],"44027":["NTT DoCoMo","jp"],"44028":["NTT DoCoMo","jp"],"44029":["NTT DoCoMo","jp"],"44030":["NTT DoCoMo","jp"],"44031":["NTT DoCoMo","jp"],"44032":["NTT DoCoMo","jp"],"44033":["NTT DoCoMo","jp"],"44034":["NTT DoCoMo","jp"],"44035":["NTT DoCoMo","jp"],"44036":["NTT DoCoMo","jp"],"44037":["NTT DoCoMo","jp"],"44038":["NTT DoCoMo","jp"],"44039":["NTT DoCoMo","jp"],"44040":["SoftBank","jp"],"44041":["SoftBank","jp"],"44042":["SoftBank","jp"],"44043":["SoftBank","jp"],"44044":["SoftBank","jp"],"44045":["SoftBank","jp"],"44046":["SoftBank","jp"],"44047":["SoftBank","jp"],"44048":["SoftBank","jp"],"44049":["NTT DoCoMo","jp"],"44050":["KDDI","jp"],"44051":["KDDI","jp"],"44052":["KDDI","jp"],"44053":["KDDI","jp"],"44054":["KDDI","jp"],"44055":["KDDI","jp"],"44056":["KDDI","jp"],"44058":["NTT DoCoMo","jp"],"44060":["NTT DoCoMo","jp"],"44061":["NTT DoCoMo","jp"],"44062":["NTT DoCoMo","jp"],"44063":["NTT DoCoMo","jp"],"44064":["NTT DoCoMo","jp"],"44065":["NTT DoCoMo","jp"],"44066":["NTT DoCoMo","jp"],"44067":["NTT DoCoMo","jp"],"44068":["NTT DoCoMo","jp"],"44069":["NTT DoCoMo","jp"],"44070":["KDDI","jp"],"44071":["KDDI","jp"],"44072":["KDDI","jp"],"44073":["KDDI","jp"],"44074":["KDDI","jp"],"44075":["KDDI","jp"],"44076":["KDDI","jp"],"44077":["KDDI","jp"],"44078":["Okinawa Cellular","jp"],"44079":["KDDI","jp"],"44080":["KDDI","jp"],"44081":["KDDI","jp"],"44082":["KDDI","jp"],"44083":["KDDI","jp"],"44084":["KDDI","jp"],"44085":["KDDI","jp"],"44086":["KDDI","jp"],"44087":["NTT DoCoMo","jp"],"44088":["KDDI","jp"],"44089":["KDDI","jp"],"44090":["SoftBank","jp"],"44092":["SoftBank","jp"],"44093":["SoftBank","jp"],"44094":["SoftBank","jp"],"44095":["SoftBank","jp"],"44096":["SoftBank","jp"],"44097":["SoftBank","jp"],"44098":["SoftBank","jp"],"44099":["NTT DoCoMo","jp"],"44100":["Wireless City Planning","jp"],"44140":["NTT DoCoMo","jp"],"44141":["NTT DoCoMo","jp"],"44142":["NTT DoCoMo","jp"],"44143":["NTT DoCoMo","jp"],"44144":["NTT DoCoMo","jp"],"44145":["NTT DoCoMo","jp"],"44161":["SoftBank","jp"],"44162":["SoftBank","jp"],"44163":["SoftBank","jp"],"44164":["SoftBank","jp"],"44165":["SoftBank","jp"],"44170":["KDDI","jp"],"44190":["NTT DoCoMo","jp"],"44191":["NTT DoCoMo","jp"],"44192":["NTT DoCoMo","jp"],"44193":["NTT DoCoMo","jp"],"44194":["NTT DoCoMo","jp"],"44198":["NTT DoCoMo","jp"],"44199":["NTT DoCoMo","jp"],"450006":["LG U+","kr"],"45002":["KT","kr"],"45003":["SK Telecom","kr"],"45004":["KT","kr"],"45005":["SK Telecom","kr"],"45006":["LG U+","kr"],"45007":["KT Powertel","kr"],"45008":["KT","kr"],"45011":["SK Telink","kr"],"45012":["SK Telecom","kr"],"450299":["Failed Calls","kr"],"45201":["Mobifone","vn"],"45202":["Vinaphone","vn"],"45203":["S-Fone/Telecom","vn"],"45204":["Viettel Telecom","vn"],"45205":["Vietnamobile","vn"],"45206":["Viettel","vn"],"45207":["Gmobile","vn"],"45208":["Viettel Mobile","vn"],"45209":["Wintel","vn"],"45400":["1O1O / csl / Club Sim","hk"],"45401":["MVNO/CITIC","hk"],"45402":["3G Radio System/HKCSL3G","hk"],"45403":["Hutchison HK","hk"],"45404":["Hutchison 2G","hk"],"45405":["Hutchison 2G","hk"],"45406":["SmarTone HK","hk"],"45407":["MVNO/China Unicom International Ltd.","hk"],"45408":["MVNO/Trident","hk"],"45409":["MVNO/China Motion Telecom (HK) Ltd.","hk"],"45410":["GSM1800New World PCS Ltd.","hk"],"45411":["MVNO/CHKTL","hk"],"45412":["中國移動香港 China Mobile HK","hk"],"45413":["中國移動香港 China Mobile HK","hk"],"45414":["H3G/Hutchinson","hk"],"45415":["SmarTone HK","hk"],"45416":["PCCW","hk"],"45417":["SmarTone HK","hk"],"45418":["GSM7800/Hong Kong CSL Ltd.","hk"],"45419":["1O1O / csl / Club Sim","hk"],"45420":["Public Mobile Networks/Reserved","hk"],"45421":["Public Mobile Networks/Reserved","hk"],"45422":["Public Mobile Networks/Reserved","hk"],"45423":["Public Mobile Networks/Reserved","hk"],"45424":["Public Mobile Networks/Reserved","hk"],"45425":["Public Mobile Networks/Reserved","hk"],"45426":["Public Mobile Networks/Reserved","hk"],"45427":["Public Mobile Networks/Reserved","hk"],"45428":["Public Mobile Networks/Reserved","hk"],"45429":["Public Mobile Networks/Reserved","hk"],"45430":["Public Mobile Networks/Reserved","hk"],"45431":["Public Mobile Networks/Reserved","hk"],"45432":["Public Mobile Networks/Reserved","hk"],"45433":["Public Mobile Networks/Reserved","hk"],"45434":["Public Mobile Networks/Reserved","hk"],"45435":["Public Mobile Networks/Reserved","hk"],"45436":["Public Mobile Networks/Reserved","hk"],"45437":["Public Mobile Networks/Reserved","hk"],"45438":["Public Mobile Networks/Reserved","hk"],"45439":["Public Mobile Networks/Reserved","hk"],"45440":["shared by private TETRA systems","hk"],"45447":["shared by private TETRA systems","hk"],"45500":["Smartone Mobile Communications (Macao) Ltd.","mo"],"45501":["CTM","mo"],"45502":["China Telecom","mo"],"45503":["Hutchison Telecom","mo"],"45504":["CTM","mo"],"45505":["Hutchison Telephone Co. Ltd","mo"],"45506":["Smartone Mobile","mo"],"45601":["Mobitel (Cam GSM)","kh"],"45602":["Smart","kh"],"45603":["S Telecom (CDMA) (reserved)","kh"],"45604":["qb","kh"],"45605":["Smart","kh"],"45606":["Smart","kh"],"45608":["Metfone","kh"],"45609":["Sotelco/Beeline","kh"],"45611":["SEATEL","kh"],"45618":["Camshin (Shinawatra)","kh"],"456299":["CooTel","kh"],"45701":["Lao Telecommunications","la"],"45702":["ETL Mobile","la"],"45703":["Unitel","la"],"45708":["Millicom","la"],"46000":["China Mobile","cn"],"46001":["China Unicom","cn"],"46002":["China Mobile","cn"],"46003":["China Telecom","cn"],"46004":["China Mobile","cn"],"46005":["China Telecom","cn"],"46006":["China Unicom","cn"],"46007":["China Mobile","cn"],"46008":["China Mobile","cn"],"46009":["China Unicom","cn"],"46010":["China Unicom","cn"],"46011":["China Telecom","cn"],"46012":["China Telecom","cn"],"46015":["China Broadnet","cn"],"46020":["China Mobile","cn"],"460999":["Fix Line","cn"],"46601":["遠傳電信 Far EasTone Telecom","tw"],"46602":["遠傳電信 Far EasTone Telecom","tw"],"46603":["遠傳電信 Far EasTone Telecom","tw"],"46605":["遠傳電信Far EasTone Telecom(原亞太電信)","tw"],"46606":["Tuntex Telecom","tw"],"46607":["Far EasTone","tw"],"46609":["Vmax Telecom","tw"],"46610":["Global Mobile Corp.","tw"],"46611":["中華電信_Chunghwa Telecom","tw"],"46656":["International Telecom Co. Ltd (FITEL)","tw"],"46668":["ACeS Taiwan - ACeS Taiwan Telecommunications Co Ltd","tw"],"46688":["KG Telecom","tw"],"46689":["台灣大哥大(原台灣之星) Taiwan Mobile Telecom","tw"],"46690":["T-Star/VIBO","tw"],"46692":["中華電信_Chunghwa Telecom","tw"],"46693":["MobiTai Communications","tw"],"46697":["台灣大哥大 Taiwan Mobile Telecom","tw"],"46699":["TransAsia Telecoms","tw"],"467192":["Koryolink","kp"],"467193":["Sun Net","kp"],"467299":["Failed Calls","kp"],"47001":["Grameenphone","bd"],"47002":["Aktel","bd"],"47003":["Mobile 2000","bd"],"47004":["TeleTalk","bd"],"47005":["Citycell","bd"],"47006":["Citycell","bd"],"47007":["Airtel BD","bd"],"47201":["DhiMobile","mv"],"47202":["Ooredoo","mv"],"50200":["Art900","my"],"50201":["Art900","my"],"50210":["Digi","my"],"50211":["unifi mobile","my"],"50212":["Maxis/Hotlink","my"],"50213":["Celcom","my"],"50214":["Telekom Malaysia","my"],"502143":["Digi","my"],"502146":["Digi","my"],"502150":["Tune Talk","my"],"502151":["Baraka Telecom Sdn Bhd","my"],"502152":["Yes 5G","my"],"502153":["unifi mobile","my"],"502154":["TT dotCom","my"],"502155":["Samata Communications Sdn Bhd","my"],"502156":["Altel Communications","my"],"50216":["Digi","my"],"50217":["TimeCel","my"],"50218":["U Mobile","my"],"50219":["Celcom","my"],"502195":["XOX Com","my"],"502198":["Celcom","my"],"50220":["Electcoms Wireless Sdn Bhd","my"],"502299":["MKN","my"],"502999":["Fix Line","my"],"50501":["Telstra","au"],"50502":["Optus","au"],"50503":["Vodafone","au"],"50504":["Department of Defence","au"],"50505":["The Ozitel Network Pty. Ltd.","au"],"50506":["Hutchison 3G Australia Pty. Ltd.","au"],"50507":["Vodafone","au"],"50508":["One.Tel GSM 1800 Pty. Ltd.","au"],"50509":["Airnet Commercial Australia Ltd.","au"],"50510":["Norfolk Telecom","au"],"50511":["Telstra","au"],"50512":["Hutchison Telecommunications (Australia) Pty. Ltd.","au"],"50513":["RailCorp","au"],"50514":["AAPT Ltd.","au"],"50516":["VicTrack","au"],"50519":["Lycamobile","au"],"50524":["Advanced Communications Technologies Pty. Ltd.","au"],"50526":["Sinch","au"],"505299":["ACMA","au"],"50530":["Compatel","au"],"50535":["MessageBird","au"],"50539":["Telstra","au"],"50550":["Pivotel","au"],"50552":["OptiTel","au"],"50557":["CiFi","au"],"50571":["Telstra","au"],"50572":["Telstra","au"],"50588":["Pivotel","au"],"50590":["Optus","au"],"50599":["One.Tel GSM 1800 Pty. Ltd.","au"],"505999":["Fix Line","au"],"51000":["PSN","id"],"51001":["Indosat","id"],"51007":["Flexi (PT Telkom) (CDMA)","id"],"51008":["XL/AXIS","id"],"51009":["Smartfren","id"],"51010":["Telkomsel","id"],"51011":["XL/AXIS","id"],"51021":["Indosat - M3","id"],"51027":["PT Sampoerna Telekomunikasi Indonesia (STI)","id"],"51028":["Smartfren","id"],"51089":["3","id"],"51099":["Esia (PT Bakrie Telecom) (CDMA)","id"],"510999":["Fix Line","id"],"51401":["Telkomcel","tl"],"51402":["Timor Telecom","tl"],"51403":["Viettel","tl"],"514299":["Failed Calls","tl"],"514999":["Fix Line","tl"],"51501":["Islacom","ph"],"51502":["Globe Telecom","ph"],"51503":["Smart Communications","ph"],"51505":["Smart","ph"],"51518":["Redinternet","ph"],"51588":["Next Mobile","ph"],"515999":["Fix Line","ph"],"52000":["CAT CDMA","th"],"52001":["AIS GSM","th"],"52002":["CAT CDMA","th"],"52003":["AIS","th"],"52004":["TrueMove H 4G LTE","th"],"52005":["dtac","th"],"52015":["ACT Mobile","th"],"52018":["dtac","th"],"52020":["ACeS","th"],"52023":["Digital Phone Co.","th"],"52047":["TOT","th"],"52099":["True Move","th"],"520999":["Fix Line","th"],"52501":["Singtel","sg"],"52502":["Singtel","sg"],"52503":["M1","sg"],"52504":["Sunsurf","sg"],"52505":["StarHub","sg"],"52506":["Starhub","sg"],"52507":["Singtel","sg"],"52512":["Digital Trunked Radio Network","sg"],"525999":["Fix Line","sg"],"52801":["Telekom Brunei Bhd (TelBru)","bn"],"52802":["B-Mobile","bn"],"52811":["DST Com","bn"],"53000":["Reserved for AMPS MIN based IMSI's","nz"],"53001":["Vodafone","nz"],"53002":["Teleom New Zealand CDMA Network","nz"],"53003":["Woosh Wireless - CDMA Network","nz"],"53004":["Telstra","nz"],"53005":["Spark","nz"],"53024":["2degrees","nz"],"53028":["2degrees","nz"],"530999":["Fix Line","nz"],"53701":["Vodafone","pg"],"53702":["Vodafone","pg"],"53703":["Digicel Ltd","pg"],"537999":["Fix Line","pg"],"53901":["Tonga Communications Corporation","to"],"53943":["Shoreline Communication","to"],"53988":["Digicel","to"],"539999":["Fix Line","to"],"54001":["BREEZE","sb"],"54002":["Vodafone","sb"],"54010":["BREEZE","sb"],"54100":["AIL","vu"],"54101":["SMILE","vu"],"54105":["Digicel","vu"],"54201":["Vodafone","fj"],"54202":["Digicel","fj"],"54301":["Manuia","wf"],"543299":["Failed Calls","wf"],"54411":["Bluesky","as"],"544780":["ASTCA Mobile","as"],"54501":["Kiribati - TSKL","ki"],"54509":["Kiribati Frigate","ki"],"54601":["OPT Mobilis","nc"],"54705":["Viti","pf"],"54715":["Pacific Mobile Telecom (PMT)","pf"],"54720":["Tikiphone","pf"],"54801":["Telecom Cook","ck"],"54901":["Telecom Samoa Cellular Ltd.","ws"],"54927":["GoMobile SamoaTel Ltd","ws"],"549999":["Fix Line","ws"],"55001":["FSM Telecom","fm"],"551299":["Failed Calls","mh"],"55201":["Palau National Communications Corp. (a.k.a. PNCC)","pw"],"55202":["PECI/PalauTel (Palau","pw"],"55280":["Palau Mobile","pw"],"55301":["Tuvalu Telecommunication Corporation (TTC)","tv"],"55501":["Niue Telecom","nu"],"60201":["Orange Egypt","eg"],"60202":["Vodafone","eg"],"60203":["Etisalat","eg"],"60204":["WE","eg"],"602299":["Failed Calls","eg"],"60301":["Algérie Telecom","dz"],"60302":["Orascom Telecom Algérie","dz"],"60303":["Ooredoo","dz"],"60400":["Méditélécom","ma"],"60401":["Maroc","ma"],"60402":["inwi","ma"],"60404":["Al Houria Telecom","ma"],"60405":["inwi","ma"],"60406":["IAM","ma"],"60499":["Al Houria Telecom","ma"],"60501":["Orange Tunisie","tn"],"60502":["Tunisie Telecom","tn"],"60503":["Ooredoo Tunisia","tn"],"60506":["Lycamobile","tn"],"605999":["Fix Line","tn"],"60600":["Libyana","ly"],"60601":["Madar","ly"],"60602":["Al-Jeel","ly"],"60603":["Libya Phone","ly"],"60606":["Hatef","ly"],"60701":["Gamcel","gm"],"60702":["Africell","gm"],"60703":["Comium Services Ltd","gm"],"60704":["QCell","gm"],"60801":["Orange Senegal","sn"],"60802":["Sentel GSM","sn"],"60803":["Expresso","sn"],"60804":["HAYO","sn"],"608299":["2s Mobile","sn"],"60901":["Mattel S.A.","mr"],"60902":["Chinguitel S.A.","mr"],"60910":["Mauritel Mobiles","mr"],"61001":["Malitel","ml"],"61002":["Orange Mali","ml"],"61003":["Telecel","ml"],"61101":["Orange","gn"],"61102":["Sotelgui","gn"],"61103":["Intercel","gn"],"61104":["MTN/Areeba","gn"],"61105":["Cellcom Guinée SA","gn"],"61201":["Comstar","ci"],"61202":["Atlantique Cellulaire","ci"],"61203":["Orange Côte d'Ivoire","ci"],"61204":["Comium Côte d'Ivoire","ci"],"61205":["Loteny Telecom","ci"],"61206":["Oricel Côte d'Ivoire","ci"],"61207":["Aircomm Côte d'Ivoire","ci"],"61301":["Onatal (Telmob)","bf"],"61302":["Orange","bf"],"61303":["Telecel","bf"],"61401":["Sahel.Com","ne"],"61402":["Airtel Niger","ne"],"61403":["Telecel","ne"],"61404":["Orange Niger","ne"],"61501":["Togo Telecom","tg"],"61502":["Telecel/MOOV","tg"],"61503":["Moov Togo","tg"],"61601":["Libercom","bj"],"61602":["Telecel","bj"],"61603":["Spacetel Benin","bj"],"61604":["Bell Benin Communications","bj"],"61605":["Glo Communications Benin","bj"],"61701":["Orange Mauritius","mu"],"61702":["Mahanagar Telephone (Mauritius) Ltd.","mu"],"61703":["Chili","mu"],"61710":["Emtel","mu"],"61801":["Lonestar","lr"],"61802":["Libercell","lr"],"61804":["Comium Liberia","lr"],"61807":["Celcom","lr"],"61820":["LIBTELCO","lr"],"61901":["Orange","sl"],"61902":["Millicom","sl"],"61903":["Africell","sl"],"61904":["Comium (Sierra Leone) Ltd.","sl"],"61905":["Lintel (Sierra Leone) Ltd.","sl"],"61907":["Qcell","sl"],"61925":["Mobitel","sl"],"619299":["IPTel","sl"],"61940":["Datatel (SL) Ltd GSM","sl"],"61950":["Dtatel (SL) Ltd CDMA","sl"],"62001":["MTN","gh"],"62002":["Vodafone","gh"],"62003":["AirtelTigo","gh"],"62004":["Kasapa Telecom Ltd.","gh"],"62005":["National Security","gh"],"62006":["AirtelTigo","gh"],"62007":["Globacom","gh"],"62008":["Surfline","gh"],"620299":["Comsys","gh"],"62101":["Visafone","ng"],"62120":["Airtel Nigeria","ng"],"62125":["Visafone","ng"],"62127":["Smile","ng"],"621299":["Alpha Technologies","ng"],"62130":["MTN Nigeria Communications","ng"],"62140":["Nigeria Telecommunications Ltd.","ng"],"62150":["Glo","ng"],"62160":["9Pay","ng"],"62199":["Starcomms","ng"],"62201":["Airtel Chad","td"],"62202":["Tchad Mobile","td"],"62203":["Tigo/Milicom/Tchad Mobile","td"],"62204":["Salam","td"],"62301":["Centrafrique Telecom Plus (CTP)","cf"],"62302":["Telecel Centrafrique (TC)","cf"],"62303":["Orange Centrafricaine","cf"],"62304":["Nationlink","cf"],"623299":["Failed Calls","cf"],"62401":["Mobile Telephone Networks Cameroon","cm"],"62402":["Orange Cameroun","cm"],"62404":["Nexttel","cm"],"62501":["Cabo Verde Telecom","cv"],"62502":["T+Telecomunicaçôes","cv"],"62601":["Companhia Santomese de Telecomunicaçôes","st"],"62602":["Unitel","st"],"62701":["Orange","gq"],"62703":["Hits-GE","gq"],"627299":["Failed Calls","gq"],"62801":["Libertis S.A.","ga"],"62802":["Telecel Gabon S.A.","ga"],"62803":["Airtel Gabon","ga"],"62804":["Azur","ga"],"628299":["Failed Calls","ga"],"62901":["Airtel Congo","cg"],"62902":["Azur SA (ETC)","cg"],"62907":["Warid","cg"],"62910":["Libertis Telecom","cg"],"63001":["Vodacom Congo RDC sprl","cd"],"63002":["Airtel","cd"],"63005":["Supercell Sprl","cd"],"630299":["Failed Calls","cd"],"63086":["Orange RDC","cd"],"63088":["Yozma Timeturns","cd"],"63089":["Tigo","cd"],"63090":["Africell","cd"],"63102":["Unitel","ao"],"63104":["MOVICEL","ao"],"63201":["Guinétel S.A.","gw"],"63202":["Spacetel Guiné-Bissau S.A.","gw"],"63203":["Orange","gw"],"63207":["Guinetel","gw"],"632999":["Fix\tLine","gw"],"63301":["Cable & Wireless (Seychelles) Ltd.","sc"],"63302":["Mediatech International Ltd.","sc"],"63305":["Intelvision","sc"],"63310":["Airtel Seychelles","sc"],"63400":["Canar Telecom","sd"],"63401":["SD Mobitel","sd"],"63402":["Areeba-Sudan","sd"],"63403":["MTN","sd"],"63405":["Canar Telecom","sd"],"63406":["Zain","sd"],"63407":["Sudani","sd"],"63408":["Canar Telecom","sd"],"63409":["Privet","sd"],"63415":["Sudani One","sd"],"63422":["MTN","sd"],"634999":["Fix Line","sd"],"63510":["MTN Rwandacell","rw"],"63512":["Rwandatel","rw"],"63513":["Airtel Rwanda","rw"],"63514":["Airtel Rwanda","rw"],"63601":["ETH MTN","et"],"63602":["Safaricom Telecommunications Ethiopia","et"],"63701":["Telesom","so"],"63704":["Somafone","so"],"63710":["Nationlink","so"],"63719":["Hormuud","so"],"63725":["Hormuud","so"],"637299":["AirSom","so"],"63730":["Golis Telecommunications Company","so"],"63750":["Hormuud","so"],"63757":["Unitel","so"],"63760":["Nationlink","so"],"63770":["Onkod","so"],"63771":["Somtel","so"],"63782":["Telcom","so"],"63801":["Evatis","dj"],"63901":["Safaricom","ke"],"63902":["Safaricom","ke"],"63903":["Airtel Kenya","ke"],"63904":["Mobile Pay","ke"],"63905":["Yu","ke"],"63906":["Finserve Africa","ke"],"63907":["Telkom","ke"],"63909":["Homeland Media","ke"],"63910":["Jamii Telecommunications","ke"],"63911":["Jambo Telcoms","ke"],"63912":["Infura","ke"],"639299":["eferio","ke"],"64001":["Tri Telecomm. Ltd.","tz"],"64002":["TIGO","tz"],"64003":["Zantel","tz"],"64004":["Vodacom","tz"],"64005":["Airtel","tz"],"64006":["Sasatel Tanzania","tz"],"64007":["Life Tanzania","tz"],"64008":["Benson Informatics Ltd","tz"],"64009":["Halotel / Viettel","tz"],"64011":["Smile Communications","tz"],"64013":["WiAfrica","tz"],"64014":["MO Mobile","tz"],"64099":["Mkulima African Telecommunication","tz"],"64101":["Airtel Uganda","ug"],"64104":["Lycamobile","ug"],"64110":["MTN Uganda Ltd.","ug"],"64111":["Uganda Telecom Ltd.","ug"],"64114":["House of Integrated Technology and Systems Uganda Ltd","ug"],"64118":["Suretelecom Uganda Ltd","ug"],"64122":["Airtel Uganda","ug"],"64130":["K2 Telecom Ltd","ug"],"64133":["Smile","ug"],"64166":["i-Tel Ltd","ug"],"641999":["Fix Line","ug"],"64201":["Spacetel Burundi","bi"],"64202":["Safaris","bi"],"64203":["Telecel Burundi Company","bi"],"64207":["Smart Mobile","bi"],"64208":["Lumitel/Viettel","bi"],"64282":["Leo","bi"],"642999":["Fix\tLine","bi"],"64301":["T.D.M. GSM","mz"],"64303":["Movitel","mz"],"64304":["Vodacom","mz"],"64501":["Airtel Zambia","zm"],"64502":["Telecel Zambia Ltd.","zm"],"64503":["Zamtel","zm"],"645299":["Failed Calls","zm"],"64601":["Airtel Madagascar","mg"],"64602":["Orange Madagascar","mg"],"64603":["Sacel","mg"],"64604":["Telecom Malagasy Mobile","mg"],"646299":["Bip","mg"],"64700":["Orange La Réunion","re"],"64701":["Maore Mobile","yt"],"64702":["Telco OI","re"],"64703":["Free RE","re"],"64704":["Zeop_RE","re"],"64710":["Société Réunionnaise du Radiotéléphone","yt"],"64801":["Net One","zw"],"64803":["Telecel","zw"],"64804":["Econet","zw"],"64901":["Mobile Telecommunications Ltd.","na"],"64902":["switch","na"],"64903":["Powercom Pty Ltd","na"],"649299":["Demshi","na"],"65001":["Telekom Network Ltd.","mw"],"65002":["ZERO2","mw"],"65010":["Airtel Malawi","mw"],"65101":["VCL","ls"],"65102":["Econet Ezin-cel","ls"],"65201":["Mascom Wireless (Pty) Ltd.","bw"],"65202":["Orange Botswana (Pty) Ltd.","bw"],"65204":["beMobile","bw"],"65301":["EswatiniTelecom","sz"],"65302":["Eswatini Mobile","sz"],"65310":["Swazi MTN","sz"],"65401":["HURI - SNPT","km"],"65402":["Telma","km"],"654299":["Failed Calls","km"],"65501":["Vodacom","za"],"65502":["Telkom","za"],"65505":["Telkom","za"],"65506":["Sentech (Pty) Ltd.","za"],"65507":["Cell C (Pty) Ltd.","za"],"65510":["MTN","za"],"65511":["SAPS Gauteng","za"],"65512":["MTN","za"],"65519":["rain","za"],"65521":["Cape Town Metropolitan Council","za"],"655299":["Lycamobile","za"],"65530":["Bokamoso Consortium","za"],"65531":["Karabo Telecoms (Pty) Ltd.","za"],"65532":["Ilizwi Telecommunications","za"],"65533":["Thinta Thinta Telecommunications","za"],"65534":["Bokone Telecoms","za"],"65535":["Kingdom Communications","za"],"65536":["Amatole Telecommunication Services","za"],"65538":["rain","za"],"65573":["rain","za"],"65574":["rain","za"],"65701":["Eritel","er"],"658299":["Failed Calls","sh"],"65902":["MTN","ss"],"65903":["Gemtel Ltd (South Sudan","ss"],"65904":["Network of The World Ltd (NOW) (South Sudan","ss"],"65906":["Zain","ss"],"659299":["Digitel","ss"],"702099":["Smart","bz"],"702299":["Failed Calls","bz"],"70267":["Belize Telecommunications Ltd.","bz"],"70268":["International Telecommunications Ltd. (INTELCO)","bz"],"70269":["Smart","bz"],"70299":["Smart","bz"],"70401":["Claro GT","gt"],"70402":["Comunicaciones Celulares S.A.","gt"],"70403":["Movistar","gt"],"704030":["Movistar","gt"],"70601":["Claro SV","sv"],"70602":["Digicel, S.A. de C.V.","sv"],"70603":["Tigo","sv"],"70604":["Movistar","sv"],"706040":["Movistar","sv"],"70605":["INTELFON SA de CV","sv"],"708001":["Claro HN","hn"],"708002":["Celtel","hn"],"70801":["Claro HN","hn"],"70802":["Celtel","hn"],"708020":["Celtel","hn"],"708030":["HonduTel","hn"],"70804":["Digicel","hn"],"708040":["Digicel","hn"],"70830":["Hondutel","hn"],"70840":["Digicel","hn"],"71021":["Claro NI","ni"],"71030":["Movistar (Telefonía Celular de Nicaragua)","ni"],"710300":["Movistar (Telefonía Celular de Nicaragua)","ni"],"71070":["Yota Nicaragua","ni"],"71073":["Servicios de Comunicaciones, S.A. (SERCOM)","ni"],"710730":["Servicios de Comunicaciones, S.A. (SERCOM)","ni"],"710999":["Fix Line","ni"],"71201":["KOLBI ICE","cr"],"712019":["Tuyo","cr"],"71202":["KOLBI ICE","cr"],"71203":["Claro CR","cr"],"71204":["Liberty","cr"],"712190":["Tuyo","cr"],"71220":["Virtualis","cr"],"712999":["Fix Line","cr"],"71401":["Cable & Wireless Panama S.A.","pa"],"71402":["Movistar","pa"],"714020":["Movistar","pa"],"71403":["Claro PA","pa"],"71404":["Digicel","pa"],"714040":["Digicel","pa"],"714999":["Fix Line","pa"],"71601":["GlobalStar","pe"],"71602":["GlobalStar","pe"],"71606":["Movistar","pe"],"71607":["Nextel","pe"],"71610":["Claro PE","pe"],"71615":["Bitel","pe"],"71617":["Entel","pe"],"71620":["Claro /Amer.Mov./TIM","pe"],"722007":["Movistar","ar"],"722010":["Movistar","ar"],"722020":["Nextel Argentina srl","ar"],"722031":["Claro","ar"],"722034":["Personal","ar"],"72207":["Movistar","ar"],"722070":["Movistar","ar"],"722210":["IMOWI","ar"],"722299":["Express","ar"],"72231":["Claro AR","ar"],"722310":["Claro AR","ar"],"722320":["Compañía de Telefonos del Interior Norte S.A.","ar"],"722330":["Compañía de Telefonos del Interior S.A.","ar"],"72234":["Telecom Personal S.A.","ar"],"722340":["Telecom Personal S.A.","ar"],"722341":["Telecom Personal S.A.","ar"],"72236":["Argentina:Nuestro","ar"],"722999":["Fix Line","ar"],"72400":["Nextel","br"],"72401":["CRT Cellular","br"],"72402":["TIM","br"],"72403":["TIM","br"],"72404":["TIM","br"],"72405":["Claro BR","br"],"72406":["Vivo","br"],"72407":["Sercontel Cel","br"],"72408":["Maxitel MG","br"],"72409":["Telepar Cel","br"],"72410":["Vivo","br"],"72411":["Vivo","br"],"72412":["Americel","br"],"72413":["Telesp Cel","br"],"72414":["Maxitel BA","br"],"72415":["Sercomtel","br"],"72416":["Brasil Telecom GSM","br"],"72417":["Ceterp Cel","br"],"72418":["Datora","br"],"72419":["Telemig Cel","br"],"72421":["Telerj Cel","br"],"72423":["Vivo","br"],"72424":["Oi","br"],"72425":["Telebrasilia Cel","br"],"72426":["AmericaNet","br"],"72427":["Telegoias Cel","br"],"72429":["Unifique","br"],"72430":["Oi","br"],"72431":["Oi","br"],"72432":["Algar Telecom","br"],"72433":["Algar Telecom","br"],"72434":["Algar Telecom","br"],"72435":["Telebahia Cel","br"],"72437":["Telergipe Cel","br"],"72438":["Claro BR","br"],"72439":["Nextel","br"],"72441":["Telpe Cel","br"],"72443":["Telepisa Cel","br"],"72445":["Telpa Cel","br"],"72447":["Telern Cel","br"],"72448":["Teleceara Cel","br"],"72451":["Telma Cel","br"],"72453":["Telepara Cel","br"],"72454":["TIM","br"],"72455":["Teleamazon Cel","br"],"72457":["Teleamapa Cel","br"],"72459":["Telaima Cel","br"],"72477":["Brisanet","br"],"73000":["TESAM SA","cl"],"73001":["Entel","cl"],"73002":["Movistar","cl"],"73003":["Claro CL","cl"],"73004":["WOM","cl"],"73005":["Multikom S.A.","cl"],"73006":["Blue Two Chile SA","cl"],"73007":["Movistar","cl"],"73008":["VTR Banda Ancha SA","cl"],"73009":["WOM","cl"],"73010":["Entel","cl"],"73011":["Celupago SA","cl"],"73012":["Telestar Movil SA","cl"],"73013":["Tribe Mobile SPA","cl"],"73014":["Netline Telefonica Movil Ltda","cl"],"73015":["Cibeles Telecom SA","cl"],"73019":["Sociedad Falabella Movil SPA","cl"],"73026":["Entel","cl"],"732001":["Colombia Telecomunicaciones S.A. - Telecom","co"],"732002":["Edatel S.A.","co"],"732020":["Emtelsa","co"],"732099":["Emcali","co"],"732101":["Claro CO","co"],"732102":["Bellsouth Colombia S.A.","co"],"732103":["Colombia Móvil S.A.","co"],"732111":["Colombia Móvil S.A.","co"],"732123":["Movistar","co"],"732130":["WOM","co"],"732142":["UNE","co"],"732154":["Virgin Mobile","co"],"732165":["Tigo","co"],"732187":["ETB 4G","co"],"732199":["SUMA movil","co"],"732220":["Libre Tecnologias","co"],"732230":["Setroc Mobile","co"],"732240":["Flash Mobile","co"],"732299":["ATnet","co"],"732360":["WOM","co"],"732666":["Claro","co"],"732999":["Fix Line","co"],"73401":["Infonet","ve"],"73402":["Corporación Digitel","ve"],"73403":["Digicel","ve"],"73404":["Movistar","ve"],"73406":["Telecomunicaciones Movilnet, C.A.","ve"],"73601":["Nuevatel S.A.","bo"],"73602":["ENTEL S.A.","bo"],"73603":["Telecel S.A.","bo"],"738002":["GT&T Cellink Plus","gy"],"73801":["Cel*Star (Guyana) Inc.","gy"],"73802":["GT&T Cellink Plus","gy"],"74000":["Movistar","ec"],"740000":["Failed Call(s)","ec"],"74001":["Claro EC","ec"],"740010":["Claro EC","ec"],"74002":["Telecsa S.A.","ec"],"74003":["Tuenti","ec"],"74401":["Hola Paraguay S.A.","py"],"74402":["Claro PY","py"],"74403":["Compañia Privada de Comunicaciones S.A.","py"],"74404":["Telecel","py"],"74405":["Personal","py"],"74406":["Hola Paraguay S.A.","py"],"74601":["Telesur","sr"],"74602":["Telesur","sr"],"74603":["Digicel","sr"],"74604":["Intelsur","sr"],"746999":["Fix Line","sr"],"74800":["Ancel","uy"],"74801":["Ancel","uy"],"74803":["Ancel","uy"],"74807":["Movistar","uy"],"74810":["Claro UY","uy"],"750001":["Sure","fk"],"90101":["ICO Global Communications","n/a"],"90102":["Sense Communications International AS","n/a"],"90103":["Iridium Satellite, LLC (GMSS)","n/a"],"90104":["Globalstar","n/a"],"90105":["Thuraya RMSS Network","n/a"],"90106":["Thuraya Satellite Telecommunications Company","n/a"],"90107":["Ellipso","n/a"],"90109":["Tele1 Europe","n/a"],"90110":["Asia Cellular Satellite (AceS)","n/a"],"90111":["Inmarsat Ltd.","n/a"],"90112":["Maritime Communications Partner AS (MCP network)","n/a"],"90113":["Global Networks, Inc.","n/a"],"90114":["Telenor GSM - services in aircraft","n/a"],"90115":["SITA GSM services in aircraft (On Air)","n/a"],"90116":["Jasper Systems, Inc.","n/a"],"90117":["Jersey Telecom","n/a"],"90118":["AT&T Mobility (Wireless Maritime Services)","n/a"],"90119":["Vodafone","n/a"],"90120":["Intermatica","n/a"],"90121":["Seanet Maritime Communications","n/a"],"90122":["Denver Consultants Ltd","n/a"],"90128":["Vodafone GDSP","n/a"],"90137":["Transatel","n/a"],"90158":["Bics","n/a"],"90188":["Telecommunications for Disaster Relief (TDR) (OCHA)","n/a"],"90198":["Skylo","n/a"]},"i":{"202":"gr","204":"nl","206":"be","208":"fr","212":"mc","213":"ad","214":"es","216":"hu","218":"ba","219":"hr","220":"rs","221":"xk","222":"it","225":"va","226":"ro","228":"ch","230":"cz","231":"sk","232":"at","234":"gb","235":"gb","238":"dk","240":"se","242":"no","244":"fi","246":"lt","247":"lv","248":"ee","250":"ru","255":"ua","257":"by","259":"md","260":"pl","262":"de","266":"gi","268":"pt","270":"lu","272":"ie","274":"is","276":"al","278":"mt","280":"cy","282":"ge","283":"am","284":"bg","286":"tr","288":"fo","289":"ge","290":"gl","292":"sm","293":"si","294":"mk","295":"li","297":"me","302":"ca","308":"pm","310":"us","311":"us","312":"us","313":"us","314":"us","315":"us","316":"us","330":"pr","334":"mx","338":"jm","340":"gf","342":"bb","344":"ag","346":"ky","348":"vg","350":"bm","352":"gd","354":"ms","356":"kn","358":"lc","360":"vc","362":"bq","363":"aw","364":"bs","365":"ai","366":"dm","368":"cu","370":"do","372":"ht","374":"tt","376":"tc","400":"az","401":"kz","402":"bt","404":"in","405":"in","406":"in","410":"pk","412":"af","413":"lk","414":"mm","415":"lb","416":"jo","417":"sy","418":"iq","419":"kw","420":"sa","421":"ye","422":"om","424":"ae","425":"il","426":"bh","427":"qa","428":"mn","429":"np","430":"ae","431":"ae","432":"ir","434":"uz","436":"tj","437":"kg","438":"tm","440":"jp","441":"jp","450":"kr","452":"vn","454":"hk","455":"mo","456":"kh","457":"la","460":"cn","461":"cn","466":"tw","467":"kp","470":"bd","472":"mv","502":"my","505":"au","510":"id","514":"tl","515":"ph","520":"th","525":"sg","528":"bn","530":"nz","537":"pg","539":"to","540":"sb","541":"vu","542":"fj","543":"wf","544":"as","545":"ki","546":"nc","547":"pf","548":"ck","549":"ws","550":"fm","551":"mh","552":"pw","553":"tv","555":"nu","602":"eg","603":"dz","604":"ma","605":"tn","606":"ly","607":"gm","608":"sn","609":"mr","610":"ml","611":"gn","612":"ci","613":"bf","614":"ne","615":"tg","616":"bj","617":"mu","618":"lr","619":"sl","620":"gh","621":"ng","622":"td","623":"cf","624":"cm","625":"cv","626":"st","627":"gq","628":"ga","629":"cg","630":"cd","631":"ao","632":"gw","633":"sc","634":"sd","635":"rw","636":"et","637":"so","638":"dj","639":"ke","640":"tz","641":"ug","642":"bi","643":"mz","645":"zm","646":"mg","647":"yt","648":"zw","649":"na","650":"mw","651":"ls","652":"bw","653":"sz","654":"km","655":"za","657":"er","658":"sh","659":"ss","702":"bz","704":"gt","706":"sv","708":"hn","710":"ni","712":"cr","714":"pa","716":"pe","722":"ar","724":"br","730":"cl","732":"co","734":"ve","736":"bo","738":"gy","740":"ec","744":"py","746":"sr","748":"uy","750":"fk","901":"n/a"},"t":["302","310","311","312","313","314","315","316","334","338"],"meta":{"source":"Android Open Source Project carrier_list.textpb","source_url":"https://android.googlesource.com/platform/packages/providers/TelephonyProvider/+/master/assets/latest_carrier_id/carrier_list.textpb","aosp_version":"134217771","aosp_generic_records":1672}} diff --git a/internal/device/registration_linux.go b/internal/device/registration_linux.go new file mode 100644 index 0000000..869b8dd --- /dev/null +++ b/internal/device/registration_linux.go @@ -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)) +} diff --git a/internal/device/registration_other.go b/internal/device/registration_other.go new file mode 100644 index 0000000..017e8ce --- /dev/null +++ b/internal/device/registration_other.go @@ -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 +} diff --git a/internal/device/registration_qmi.go b/internal/device/registration_qmi.go new file mode 100644 index 0000000..b8a2611 --- /dev/null +++ b/internal/device/registration_qmi.go @@ -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 +} diff --git a/internal/device/registration_qmi_test.go b/internal/device/registration_qmi_test.go new file mode 100644 index 0000000..c373084 --- /dev/null +++ b/internal/device/registration_qmi_test.go @@ -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) + } +} diff --git a/internal/device/registration_test.go b/internal/device/registration_test.go new file mode 100644 index 0000000..bc92378 --- /dev/null +++ b/internal/device/registration_test.go @@ -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) + } + } +} diff --git a/internal/device/scan.go b/internal/device/scan.go index d60a83e..8d8c4ac 100644 --- a/internal/device/scan.go +++ b/internal/device/scan.go @@ -14,6 +14,7 @@ type ScannedOperator struct { Name string `json:"name"` Short string `json:"shortName,omitempty"` Numeric string `json:"numeric"` + Country string `json:"countryCode,omitempty"` Act string `json:"act,omitempty"` } @@ -75,11 +76,19 @@ func parseOperatorScan(response modem.Response) []ScannedOperator { if len(fields) < 4 { continue } + name, country, _ := CarrierForPLMN(fields[3]) + if name == "" { + name = strings.TrimSpace(fields[1]) + } + if name == "" { + name = strings.TrimSpace(fields[3]) + } operator := ScannedOperator{ Status: operatorScanStatus(fields[0]), - Name: fields[1], + Name: name, Short: fields[2], Numeric: fields[3], + Country: country, } if len(fields) >= 5 { operator.Act = accessTechnology(fields[4]) @@ -90,6 +99,20 @@ func parseOperatorScan(response modem.Response) []ScannedOperator { return operators } +// carrierNameForPLMN resolves the numeric serving PLMN through the bundled +// global carrier database. Some EC20 firmware returns an empty, localized, or +// stale long name even though the MCC/MNC is correct. The numeric identity is +// the authoritative value used for network selection. +func carrierNameForPLMN(plmn, fallback string) string { + if name, _, ok := CarrierForPLMN(plmn); ok { + return name + } + if fallback = strings.TrimSpace(fallback); fallback != "" { + return fallback + } + return strings.TrimSpace(plmn) +} + // extractScanTuples returns the contents of each top-level parenthesised group, // ignoring parentheses inside quoted strings. func extractScanTuples(payload string) []string { diff --git a/internal/device/scan_test.go b/internal/device/scan_test.go new file mode 100644 index 0000000..cfbb081 --- /dev/null +++ b/internal/device/scan_test.go @@ -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) + } + } +} diff --git a/internal/device/snapshot.go b/internal/device/snapshot.go index 9e0f2ec..b50b24b 100644 --- a/internal/device/snapshot.go +++ b/internal/device/snapshot.go @@ -50,8 +50,10 @@ func (manager *Manager) readSnapshot( if response, ok := optional("AT+CSQ"); ok { snapshot.SignalRaw, snapshot.SignalPercent, snapshot.RSSIDBm = parseCSQ(response) } + servingPLMN := "" if response, ok := optional(`AT+QENG="servingcell"`); ok { metrics := parseQENG(response) + servingPLMN = metrics.PLMN snapshot.AccessTech = metrics.AccessTech snapshot.Band = metrics.Band snapshot.Channel = metrics.Channel @@ -64,12 +66,42 @@ func (manager *Manager) readSnapshot( } if response, ok := optional("AT+COPS?"); ok { operator := parseCOPS(response) - snapshot.OperatorName = operator.Name - snapshot.OperatorCode = operator.Code + if operator.Code != "" { + snapshot.OperatorCode = operator.Code + } else { + snapshot.OperatorCode = servingPLMN + } + snapshot.OperatorName = carrierNameForPLMN(snapshot.OperatorCode, operator.Name) if snapshot.AccessTech == "" { snapshot.AccessTech = operator.AccessTech } } + for _, command := range []string{"AT+CEREG?", "AT+CGREG?", "AT+CREG?"} { + response, registrationErr := manager.command(ctx, client, command) + if registrationErr != nil { + continue + } + if status, found := parseRegistrationStatus(response); found { + snapshot.RegistrationStatus = status + snapshot.RegistrationSource = strings.TrimSuffix(strings.TrimPrefix(command, "AT+"), "?") + break + } + } + if registration, found := readPlatformRegistration(ctx, candidate); found { + snapshot.RegistrationStatus = registration.Status + snapshot.RegistrationSource = "QMI NAS" + snapshot.PSAttached = registration.PSAttached + if registration.PLMN != "" { + snapshot.OperatorCode = registration.PLMN + snapshot.OperatorName = carrierNameForPLMN(registration.PLMN, registration.Name) + } + } + if snapshot.RegistrationSource == "" && (snapshot.OperatorName != "" || snapshot.OperatorCode != "") { + // Older firmware can omit registration queries while COPS still proves + // that an operator is selected. + snapshot.RegistrationStatus = 1 + snapshot.RegistrationSource = "COPS" + } if response, ok := optional("AT+CGSN"); ok { snapshot.IMEI = parseIdentifier( response, @@ -107,6 +139,25 @@ func (manager *Manager) readSnapshot( return snapshot, nil } +func parseRegistrationStatus(response modem.Response) (int, bool) { + for _, prefix := range []string{"+CEREG:", "+CGREG:", "+CREG:"} { + values := csvValues(valueAfterPrefix(response, prefix)) + if len(values) == 0 { + continue + } + index := 0 + // Query responses are ,; unsolicited responses are . + if len(values) >= 2 { + index = 1 + } + status, err := strconv.Atoi(strings.TrimSpace(values[index])) + if err == nil && status >= 0 && status <= 10 { + return status, true + } + } + return 0, false +} + func parseATI(lines []string) (manufacturer, model, firmware string) { for _, line := range lines { line = strings.TrimSpace(line) @@ -159,6 +210,7 @@ func parseCSQ(response modem.Response) (raw, percent, dbm *int) { } type qengMetrics struct { + PLMN string AccessTech string Band string Channel string @@ -179,6 +231,9 @@ func parseQENG(response modem.Response) qengMetrics { } result := qengMetrics{AccessTech: strings.ToUpper(values[2])} if strings.EqualFold(values[2], "LTE") && len(values) >= 17 { + if decimalDigits(values[4], 3, 3) && decimalDigits(values[5], 2, 3) { + result.PLMN = values[4] + values[5] + } result.Channel = values[8] if values[9] != "" { result.Band = "B" + values[9] @@ -193,6 +248,13 @@ func parseQENG(response modem.Response) qengMetrics { return qengMetrics{} } +func decimalDigits(value string, minimum, maximum int) bool { + value = strings.TrimSpace(value) + return len(value) >= minimum && len(value) <= maximum && strings.IndexFunc(value, func(character rune) bool { + return character < '0' || character > '9' + }) < 0 +} + type operatorInfo struct { Name string Code string diff --git a/internal/device/types.go b/internal/device/types.go index 54fe878..36e2617 100644 --- a/internal/device/types.go +++ b/internal/device/types.go @@ -73,35 +73,38 @@ const ( ) type Snapshot struct { - DeviceID string `json:"deviceId"` - Port string `json:"port"` - Responsive bool `json:"responsive"` - Manufacturer string `json:"manufacturer"` - Model string `json:"model"` - Firmware string `json:"firmware"` - SIMStatus string `json:"simStatus"` - SIMReady bool `json:"simReady"` - SignalRaw *int `json:"signalRaw,omitempty"` - SignalPercent *int `json:"signalPercent,omitempty"` - RSSIDBm *int `json:"rssiDbm,omitempty"` - RSRP *int `json:"rsrp,omitempty"` - RSRQ *int `json:"rsrq,omitempty"` - SINR *int `json:"sinr,omitempty"` - AccessTech string `json:"accessTech"` - Band string `json:"band"` - Channel string `json:"channel"` - OperatorName string `json:"operatorName"` - OperatorCode string `json:"operatorCode"` - IMEI string `json:"imei"` - ICCID string `json:"iccid"` - IMSI string `json:"imsi"` - OperatingMode int `json:"operatingMode"` - ModeKnown bool `json:"modeKnown"` - FlightMode bool `json:"flightMode"` - RadioOff bool `json:"radioOff"` - Phone PhoneNumber `json:"phone"` - Warnings []string `json:"warnings,omitempty"` - UpdatedAt time.Time `json:"updatedAt"` + DeviceID string `json:"deviceId"` + Port string `json:"port"` + Responsive bool `json:"responsive"` + Manufacturer string `json:"manufacturer"` + Model string `json:"model"` + Firmware string `json:"firmware"` + SIMStatus string `json:"simStatus"` + SIMReady bool `json:"simReady"` + SignalRaw *int `json:"signalRaw,omitempty"` + SignalPercent *int `json:"signalPercent,omitempty"` + RSSIDBm *int `json:"rssiDbm,omitempty"` + RSRP *int `json:"rsrp,omitempty"` + RSRQ *int `json:"rsrq,omitempty"` + SINR *int `json:"sinr,omitempty"` + AccessTech string `json:"accessTech"` + Band string `json:"band"` + Channel string `json:"channel"` + OperatorName string `json:"operatorName"` + OperatorCode string `json:"operatorCode"` + RegistrationStatus int `json:"registrationStatus"` + RegistrationSource string `json:"registrationSource"` + PSAttached bool `json:"psAttached"` + IMEI string `json:"imei"` + ICCID string `json:"iccid"` + IMSI string `json:"imsi"` + OperatingMode int `json:"operatingMode"` + ModeKnown bool `json:"modeKnown"` + FlightMode bool `json:"flightMode"` + RadioOff bool `json:"radioOff"` + Phone PhoneNumber `json:"phone"` + Warnings []string `json:"warnings,omitempty"` + UpdatedAt time.Time `json:"updatedAt"` } type USSDResult struct { diff --git a/internal/exportproxy/bind_linux.go b/internal/exportproxy/bind_linux.go new file mode 100644 index 0000000..74390a3 --- /dev/null +++ b/internal/exportproxy/bind_linux.go @@ -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 +} diff --git a/internal/exportproxy/bind_other.go b/internal/exportproxy/bind_other.go new file mode 100644 index 0000000..b81dc13 --- /dev/null +++ b/internal/exportproxy/bind_other.go @@ -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 } diff --git a/internal/exportproxy/ipinfo.go b/internal/exportproxy/ipinfo.go new file mode 100644 index 0000000..ed4abe7 --- /dev/null +++ b/internal/exportproxy/ipinfo.go @@ -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 +} diff --git a/internal/exportproxy/ipinfo_test.go b/internal/exportproxy/ipinfo_test.go new file mode 100644 index 0000000..a347777 --- /dev/null +++ b/internal/exportproxy/ipinfo_test.go @@ -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") + } +} diff --git a/internal/exportproxy/manager.go b/internal/exportproxy/manager.go new file mode 100644 index 0000000..bd99b55 --- /dev/null +++ b/internal/exportproxy/manager.go @@ -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) +} diff --git a/internal/exportproxy/manager_test.go b/internal/exportproxy/manager_test.go new file mode 100644 index 0000000..d10c77f --- /dev/null +++ b/internal/exportproxy/manager_test.go @@ -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) + } +} diff --git a/internal/exportproxy/proxy_http.go b/internal/exportproxy/proxy_http.go new file mode 100644 index 0000000..a218418 --- /dev/null +++ b/internal/exportproxy/proxy_http.go @@ -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 +} diff --git a/internal/exportproxy/proxy_shared.go b/internal/exportproxy/proxy_shared.go new file mode 100644 index 0000000..977a736 --- /dev/null +++ b/internal/exportproxy/proxy_shared.go @@ -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 +} diff --git a/internal/exportproxy/proxy_socks.go b/internal/exportproxy/proxy_socks.go new file mode 100644 index 0000000..d2aeed0 --- /dev/null +++ b/internal/exportproxy/proxy_socks.go @@ -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 +} diff --git a/internal/extensions/manager.go b/internal/extensions/manager.go index 1ea4f3d..33c2621 100644 --- a/internal/extensions/manager.go +++ b/internal/extensions/manager.go @@ -24,6 +24,8 @@ import ( "strings" "sync" "time" + + "vocat/internal/exportproxy" ) const maxPackageBytes int64 = 64 << 20 @@ -94,6 +96,10 @@ func (manager *Manager) scan() error { manager.logger.Warn("skip invalid plugin", "directory", dir, "error", err) continue } + if plugin.ID == exportproxy.ReservedID { + manager.logger.Info("skip legacy Export Proxy plugin; functionality is built in", "directory", dir) + continue + } manager.plugins[plugin.ID] = plugin if plugin.Enabled { manager.startLocked(plugin) @@ -201,6 +207,9 @@ func (manager *Manager) Install(reader io.Reader, expectedSHA string) (Plugin, e if err != nil { return Plugin{}, err } + if manifest.ID == exportproxy.ReservedID { + return Plugin{}, errors.New("plugin ID export-proxy is reserved by the built-in Export Proxy feature") + } staging, err := os.MkdirTemp(manager.root, ".install-"+manifest.ID+"-") if err != nil { return Plugin{}, err diff --git a/internal/httpsmode/listener.go b/internal/httpsmode/listener.go new file mode 100644 index 0000000..795cca7 --- /dev/null +++ b/internal/httpsmode/listener.go @@ -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() + } +} diff --git a/internal/httpsmode/manager.go b/internal/httpsmode/manager.go new file mode 100644 index 0000000..82d1024 --- /dev/null +++ b/internal/httpsmode/manager.go @@ -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) +} diff --git a/internal/httpsmode/manager_test.go b/internal/httpsmode/manager_test.go new file mode 100644 index 0000000..1f96e88 --- /dev/null +++ b/internal/httpsmode/manager_test.go @@ -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) + } +} diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go index 71b499c..1132d00 100644 --- a/internal/i18n/i18n.go +++ b/internal/i18n/i18n.go @@ -74,6 +74,7 @@ var zhToEn = map[string]string{ // ---- devices ---- "设备数量已达上限,最多只能添加 %d 台设备": "Device limit reached; at most %d devices can be added.", "SIM 卡归属地为%s(MCC %s),本服务不向该地区卡片提供数据/短信/VoWiFi": "The SIM's home region is %s (MCC %s); this service does not provide data, SMS, or VoWiFi to cards from that region.", + "请先禁用该设备已绑定的导出代理,再关闭漫游数据": "Disable the export proxy bound to this device before turning off roaming data.", // ---- settings / update ---- "未配置受信任的软件更新源;不会从未知地址下载或执行文件。": "No trusted update source is configured; no files will be downloaded or executed from unknown addresses.", diff --git a/internal/server/call_media_api.go b/internal/server/call_media_api.go new file mode 100644 index 0000000..5786043 --- /dev/null +++ b/internal/server/call_media_api.go @@ -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 + } + } +} diff --git a/internal/server/developer_settings.go b/internal/server/developer_settings.go new file mode 100644 index 0000000..f347277 --- /dev/null +++ b/internal/server/developer_settings.go @@ -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") + } +} diff --git a/internal/server/developer_settings_test.go b/internal/server/developer_settings_test.go new file mode 100644 index 0000000..4e40fa0 --- /dev/null +++ b/internal/server/developer_settings_test.go @@ -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) + } + } +} diff --git a/internal/server/device_api.go b/internal/server/device_api.go index 50a2d82..6b3e060 100644 --- a/internal/server/device_api.go +++ b/internal/server/device_api.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "vocat/internal/developer" "vocat/internal/device" "vocat/internal/i18n" "vocat/internal/modem" @@ -38,6 +39,7 @@ type DeviceController interface { SetUSBNetModeByPort(context.Context, string, int) (device.USBNetMode, error) OperatorSelection(context.Context, string) (device.OperatorSelection, error) SetOperatorSelection(context.Context, string, bool, string, *int) (device.OperatorSelection, error) + ReRegisterOperator(context.Context, string) (device.OperatorSelection, error) ScanOperators(context.Context, string) (device.OperatorScanResult, error) SendSMS(context.Context, string, string, string) (device.SMSSendResult, error) ListSMS(context.Context, string) ([]device.SMSMessage, error) @@ -56,6 +58,7 @@ type DeviceController interface { type deviceConfigPayload struct { ID string `json:"id"` Name string `json:"name"` + DeviceType string `json:"device_type"` Interface string `json:"interface"` ControlDevice string `json:"control_device"` ATPort string `json:"at_port"` @@ -86,6 +89,7 @@ func (payload deviceConfigPayload) toStoreDevice() store.Device { return store.Device{ ID: strings.TrimSpace(payload.ID), Name: name, + DeviceType: store.NormalizeDeviceType(payload.DeviceType), Interface: strings.TrimSpace(payload.Interface), ControlDevice: strings.TrimSpace(payload.ControlDevice), ATPort: strings.TrimSpace(payload.ATPort), @@ -170,15 +174,13 @@ func splitAPIPath(value string) []string { return result } -// maxDeviceLimit 是设备数量的软上限:达到上限后禁止再添加新设备。 -const maxDeviceLimit = 5 - func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) bool { + deviceLimit := developer.DeviceLimit(r.Context(), s.store, s.developerEnabled) switch r.Method { case http.MethodGet: writeJSON(w, http.StatusOK, map[string]any{ "data": map[string]any{ - "device_limit": maxDeviceLimit, + "device_limit": deviceLimit, "devices": s.deviceSummaries(), }, }) @@ -203,6 +205,10 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) bool { writeError(w, http.StatusBadRequest, "invalid_device_id", "device ID must use 1-64 letters, digits, dots, underscores, or hyphens") return true } + if strings.TrimSpace(payload.DeviceType) == "" || store.NormalizeDeviceType(payload.DeviceType) == "" { + writeError(w, http.StatusBadRequest, "invalid_device_type", "select a supported device type") + return true + } if _, err := s.store.Device(r.Context(), payload.ID); err == nil { writeError(w, http.StatusConflict, "device_exists", "a device with this ID already exists") return true @@ -215,8 +221,8 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) bool { s.writeStoreError(w, err) return true } - if len(configured) >= maxDeviceLimit { - writeError(w, http.StatusConflict, "device_limit_reached", i18n.Tf("设备数量已达上限,最多只能添加 %d 台设备", maxDeviceLimit)) + if len(configured) >= deviceLimit { + writeError(w, http.StatusConflict, "device_limit_reached", i18n.Tf("设备数量已达上限,最多只能添加 %d 台设备", deviceLimit)) return true } devices, err := s.devices.Discover(r.Context()) @@ -230,6 +236,9 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) bool { return true } config := payload.toStoreDevice() + if !s.developerActive(r.Context()) { + config.NetworkEnabled = false + } fillConfigFromPhysical(&config, *selected) if err := s.store.UpsertDevice(r.Context(), config); err != nil { s.writeStoreError(w, err) @@ -402,6 +411,9 @@ func (s *Server) handleDevicePath( return true } next := payload.toStoreDevice() + if !s.developerActive(r.Context()) { + next.NetworkEnabled = false + } next.ID = id next.CreatedAt = config.CreatedAt if next.Name == id && strings.TrimSpace(payload.Name) == "" { @@ -485,12 +497,27 @@ func (s *Server) handleDevicePath( s.writeDeviceError(w, err) return true } + s.clearPublicIP(config.ID) writeJSON(w, http.StatusAccepted, map[string]any{"data": map[string]any{"status": "rebooting"}}) case "flight-mode": if !s.requirePhysicalDevice(w, physicalPresent) { return true } return s.handleFlightMode(w, r, physicalID) + case "network": + if !s.requirePhysicalDevice(w, physicalPresent) { + return true + } + return s.handleCellularData(w, r, config, physicalID) + case "network/public-ip": + if !s.requirePhysicalDevice(w, physicalPresent) { + return true + } + iccid := "" + if entry.Snapshot != nil { + iccid = entry.Snapshot.ICCID + } + return s.handleCellularPublicIP(w, r, config, iccid) case "usbnet-mode": if !s.requirePhysicalDevice(w, physicalPresent) { return true @@ -501,6 +528,11 @@ func (s *Server) handleDevicePath( return true } return s.handleOperatorSelection(w, r, physicalID) + case "operator_selection/reregister": + if !s.requirePhysicalDevice(w, physicalPresent) { + return true + } + return s.handleOperatorReRegister(w, r, physicalID) case "operator_selection/scan": if !s.requirePhysicalDevice(w, physicalPresent) { return true @@ -527,6 +559,11 @@ func (s *Server) handleDevicePath( return true } return s.handleCallAction(w, r, config, physicalID, tail[1]) + case "calls/media": + if !s.requirePhysicalDevice(w, physicalPresent) { + return true + } + return s.handleCallMedia(w, r, config) default: return false } @@ -667,6 +704,21 @@ func (s *Server) handleOperatorSelection(w http.ResponseWriter, r *http.Request, return true } +func (s *Server) handleOperatorReRegister(w http.ResponseWriter, r *http.Request, physicalID string) bool { + if !requireMethod(w, r, http.MethodPost) { + return true + } + controller := http.NewResponseController(w) + _ = controller.SetWriteDeadline(time.Time{}) + result, err := s.devices.ReRegisterOperator(r.Context(), physicalID) + if err != nil { + s.writeDeviceError(w, err) + return true + } + writeJSON(w, http.StatusOK, map[string]any{"data": operatorSelectionWire(result)}) + return true +} + func (s *Server) handleVoWiFiEnabled( w http.ResponseWriter, r *http.Request, @@ -691,6 +743,10 @@ func (s *Server) handleVoWiFiEnabled( writeError(w, http.StatusServiceUnavailable, "physical_device_missing", "the configured modem is not present on this Linux host") return true } + if request.Enabled && config.NetworkEnabled { + writeError(w, http.StatusConflict, "cellular_data_active", "disable roaming data before enabling VoWiFi") + return true + } if request.Enabled { entry, _, _ := s.physicalForConfig(config) imsi := snapshotString(entry.Snapshot, func(snapshot *device.Snapshot) string { return snapshot.IMSI }) @@ -943,10 +999,111 @@ func (s *Server) handleFlightMode(w http.ResponseWriter, r *http.Request, id str s.writeDeviceError(w, err) return true } + // Unlike VoWiFi, CFUN airplane state is not represented in the device row. + // Persist it against the live ICCID so a restart can distinguish an + // intentional airplane policy from an interrupted VoWiFi teardown. + if entry, getErr := s.devices.Get(id); getErr == nil && entry.Snapshot != nil { + iccid := strings.TrimSpace(entry.Snapshot.ICCID) + if iccid != "" { + policy, policyErr := s.store.CardPolicy(r.Context(), iccid) + if errors.Is(policyErr, store.ErrNotFound) { + policy = store.CardPolicy{ICCID: iccid, IPVersion: "IPV4V6"} + policyErr = nil + } + if policyErr != nil { + s.writeStoreError(w, policyErr) + return true + } + policy.AirplaneEnabled = request.Enabled + if request.Enabled { + policy.VoWiFiEnabled = false + } + policy.Source = "manual" + if err := s.store.UpsertCardPolicy(r.Context(), policy); err != nil { + s.writeStoreError(w, err) + return true + } + } + } writeJSON(w, http.StatusOK, map[string]any{"data": result}) return true } +func (s *Server) handleCellularData( + w http.ResponseWriter, + r *http.Request, + config store.Device, + physicalID string, +) bool { + if !s.developerActive(r.Context()) { + writeError(w, http.StatusForbidden, "developer_mode_required", "roaming data is available only in developer mode") + return true + } + switch r.Method { + case http.MethodGet: + writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{ + "enabled": config.NetworkEnabled, + "interface": config.Interface, + "apn": config.APN, + "export_proxy_only": true, + }}) + case http.MethodPatch, http.MethodPut: + var request struct { + Enabled bool `json:"enabled"` + APN string `json:"apn"` + } + if err := s.decodeJSON(w, r, &request); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return true + } + if request.Enabled && config.VoWiFiEnabled { + writeError(w, http.StatusConflict, "vowifi_owns_radio", "disable VoWiFi before enabling cellular roaming data") + return true + } + if !request.Enabled && s.exportProxy != nil { + if _, active := s.exportProxy.EnabledConfigForDevice(config.ID); active { + writeError(w, http.StatusConflict, "export_proxy_active", i18n.T("请先禁用该设备已绑定的导出代理,再关闭漫游数据")) + return true + } + } + apn := strings.TrimSpace(request.APN) + if apn == "" { + apn = strings.TrimSpace(config.APN) + } + controller := http.NewResponseController(w) + _ = controller.SetWriteDeadline(time.Time{}) + result, err := s.devices.SetNetwork(r.Context(), physicalID, device.NetworkRequest{ + Enabled: request.Enabled, APN: apn, IPVersion: "IPV4V6", + }) + if err != nil { + s.writeDeviceError(w, err) + return true + } + previous := config.NetworkEnabled + config.NetworkEnabled = request.Enabled + if apn != "" { + config.APN = apn + } + if err := s.store.UpsertDevice(r.Context(), config); err != nil { + rollbackContext, cancel := context.WithTimeout(context.Background(), 20*time.Second) + _, _ = s.devices.SetNetwork(rollbackContext, physicalID, device.NetworkRequest{ + Enabled: previous, APN: config.APN, IPVersion: "IPV4V6", + }) + cancel() + s.writeStoreError(w, err) + return true + } + writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{ + "enabled": result.Enabled, "interface": result.Interface, + "backend": result.Backend, "export_proxy_only": true, + }}) + default: + w.Header().Set("Allow", "GET, PATCH, PUT") + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + } + return true +} + func (s *Server) requirePhysicalDevice(w http.ResponseWriter, present bool) bool { if s.devices == nil { writeError(w, http.StatusServiceUnavailable, "device_manager_unavailable", "device manager is unavailable") @@ -1031,6 +1188,7 @@ func (s *Server) dashboardDevices() []map[string]any { result = append(result, map[string]any{ "id": entry["id"], "name": entry["name"], + "device_type": entry["device_type"], "interface": entry["interface"], "proxy_port": entry["proxy_port"], "public_ip": entry["public_ip"], @@ -1041,7 +1199,7 @@ func (s *Server) dashboardDevices() []map[string]any { "network_duplex": modemStatus["network_duplex"], "vowifi_active": vowifiActive, "vowifi_runtime": runtime, - "network_connected": false, + "network_connected": entry["network_connected"], "model": modemStatus["model"], }) } @@ -1098,24 +1256,50 @@ func (s *Server) configuredDeviceSummary( } result["id"] = config.ID result["name"] = config.Name + result["device_type"] = store.NormalizeDeviceType(config.DeviceType) result["interface"] = config.Interface result["proxy_port"] = config.ProxyPort result["esim_transport"] = config.ESIMTransport result["sms_enabled"] = config.SMSEnabled - result["network_enabled"] = false + result["network_enabled"] = config.NetworkEnabled + result["developer_enabled"] = s.developerActive(context.Background()) + result["network_connected"] = config.NetworkEnabled + result["data_connected"] = config.NetworkEnabled result["vowifi_enabled"] = config.VoWiFiEnabled if runtime, err := s.store.VoWiFiRuntime(context.Background(), config.ID); err == nil { - runtimeResponse := storedVoWiFiRuntime(runtime) + currentICCID := "" + var currentSnapshot *device.Snapshot + if entry != nil { + currentSnapshot = entry.Snapshot + if entry.Snapshot != nil { + currentICCID = strings.TrimSpace(entry.Snapshot.ICCID) + } + } + runtimeMatchesCard := currentICCID == "" || runtime.ICCID == "" || + strings.EqualFold(currentICCID, strings.TrimSpace(runtime.ICCID)) + var runtimeResponse map[string]any + if runtimeMatchesCard { + runtimeResponse = storedVoWiFiRuntime(runtime) + } else { + // The saved IMS session belongs to a different eSIM profile. Never + // project its registration or number onto the currently selected SIM. + runtimeResponse = idleVoWiFiRuntime(config.ID, currentSnapshot) + } result["vowifi_runtime"] = runtimeResponse - result["vowifi_active"] = runtime.TunnelReady - if runtime.LocalPhone != "" { - // The SIM panel reads the top-level local_phone; keep modem.phone_number - // in sync for the summary/overview consumers that read it there. - result["local_phone"] = runtime.LocalPhone - result["phone_number_source"] = runtime.PhoneNumberSource - if modemStatus, ok := result["modem"].(map[string]any); ok { - modemStatus["phone_number"] = runtime.LocalPhone - modemStatus["phone_number_source"] = runtime.PhoneNumberSource + result["vowifi_active"] = config.VoWiFiEnabled && runtimeMatchesCard && runtime.TunnelReady + } + // Numbers are SIM-owned data. Resolve the association by the live ICCID + // instead of reusing the last VoWiFi runtime attached to this device ID. + if entry != nil && entry.Snapshot != nil { + currentICCID := strings.TrimSpace(entry.Snapshot.ICCID) + if currentICCID != "" { + if association, err := s.store.PhoneAssociation(context.Background(), currentICCID); err == nil { + result["local_phone"] = association.Number + result["phone_number_source"] = association.Source + if modemStatus, ok := result["modem"].(map[string]any); ok { + modemStatus["phone_number"] = association.Number + modemStatus["phone_number_source"] = association.Source + } } } } @@ -1127,6 +1311,7 @@ func (s *Server) configuredDeviceOverview( entry device.Device, present bool, ) map[string]any { + developerActive := s.developerActive(context.Background()) var physical *device.Device if present { physical = &entry @@ -1141,12 +1326,34 @@ func (s *Server) configuredDeviceOverview( result["control_device"] = config.ControlDevice result["esim_transport"] = config.ESIMTransport result["sms_enabled"] = config.SMSEnabled - result["network_enabled"] = false + result["network_enabled"] = developerActive && config.NetworkEnabled result["vowifi_enabled"] = config.VoWiFiEnabled result["radio_live_ok"] = present && entry.Snapshot != nil && entry.Snapshot.Responsive - result["traffic"] = map[string]string{} - result["traffic_raw"] = map[string]int64{} - result["traffic_meta"] = map[string]any{} + + // Live network state: on-demand sample of the cellular interface counters, + // kept warm by the 2s overview SSE cadence. Only meaningful when the modem + // data path is enabled and an interface is configured. + if developerActive && config.NetworkEnabled && strings.TrimSpace(config.Interface) != "" { + live := s.netTraffic.sample(config.ID, config.Interface, time.Now()) + result["private_ip"] = live.ipv4 + result["traffic"] = map[string]string{ + "rx": formatLiveBytes(float64(live.minuteRx)), + "tx": formatLiveBytes(float64(live.minuteTx)), + "rate": formatLiveBytes(live.rxRate) + "/s", + "rate_tx": formatLiveBytes(live.txRate) + "/s", + } + result["traffic_raw"] = map[string]int64{ + "rx": live.minuteRx, + "tx": live.minuteTx, + "rate": int64(live.rxRate), + "rate_tx": int64(live.txRate), + } + result["traffic_meta"] = map[string]any{"status": live.status} + } else { + result["traffic"] = map[string]string{} + result["traffic_raw"] = map[string]int64{} + result["traffic_meta"] = map[string]any{} + } return result } @@ -1167,7 +1374,7 @@ func (s *Server) configuredDeviceStatus( result := map[string]any{ "healthy": summary["healthy"], "public_ip": summary["public_ip"], - "network_connected": false, + "network_connected": config.NetworkEnabled, "modem": summary["modem"], "vowifi": summary["vowifi_runtime"], "sim_service_table": map[string]any{}, @@ -1250,7 +1457,7 @@ func deviceSummary(entry device.Device) map[string]any { "physical_present": entry.Discovered, "worker_running": entry.Discovered, "data_connected": false, - "radio_registered": snapshot != nil && snapshot.OperatorName != "", + "radio_registered": snapshot != nil && (snapshot.RegistrationStatus == 1 || snapshot.RegistrationStatus == 5), "lifecycle_phase": lifecyclePhase(entry), "lifecycle_reason": entry.LastError, "public_ip": "", @@ -1307,6 +1514,7 @@ func storedDeviceConfig(config store.Device) map[string]any { return map[string]any{ "id": config.ID, "name": config.Name, + "device_type": store.NormalizeDeviceType(config.DeviceType), "interface": config.Interface, "control_device": config.ControlDevice, "at_port": config.ATPort, @@ -1324,7 +1532,7 @@ func storedDeviceConfig(config store.Device) map[string]any { "qmi_use_proxy": config.QMIUseProxy, "qmi_proxy_path": config.QMIProxyPath, "qmi_proxy_executable": config.QMIProxyExecutable, - "network_enabled": false, + "network_enabled": config.NetworkEnabled, "sms_enabled": config.SMSEnabled, "vowifi_enabled": config.VoWiFiEnabled, } @@ -1358,61 +1566,64 @@ func fillConfigFromPhysical(config *store.Device, entry device.Device) { func modemSummary(snapshot *device.Snapshot, phone string, phoneSource string) map[string]any { if snapshot == nil { return map[string]any{ - "operator": "", - "native_mcc": "", - "native_mnc": "", - "card_mcc": "", - "card_mnc": "", - "card_country": "", - "service_blocked": false, - "blocked_reason": "", - "network_mode": "", - "radio_band": "", - "radio_channel": 0, - "signal_dbm": 0, - "signal_sinr": 0, - "imei": "", - "iccid": "", - "reg_status": 0, - "reg_status_text": "not refreshed", - "sim_inserted": false, - "phone_number": phone, - "phone_number_source": phoneSource, - "model": "", + "operator": "", + "native_mcc": "", + "native_mnc": "", + "operator_country_code": "", + "card_mcc": "", + "card_mnc": "", + "card_country": "", + "service_blocked": false, + "blocked_reason": "", + "network_mode": "", + "radio_band": "", + "radio_channel": 0, + "signal_dbm": 0, + "signal_sinr": 0, + "imei": "", + "iccid": "", + "reg_status": 0, + "reg_status_text": "not refreshed", + "sim_inserted": false, + "phone_number": phone, + "phone_number_source": phoneSource, + "model": "", } } mcc, mnc := splitPLMN(snapshot.OperatorCode) + _, operatorCountryCode, _ := device.CarrierForPLMN(snapshot.OperatorCode) cardMCC, cardMNC := device.CardMCCMNC(snapshot.IMSI) blockedReason := device.RegionBlockReason(snapshot.IMSI) return map[string]any{ - "operator": snapshot.OperatorName, - "native_mcc": mcc, - "native_mnc": mnc, - "card_mcc": cardMCC, - "card_mnc": cardMNC, - "card_country": countryNameForMCC(cardMCC), - "service_blocked": blockedReason != "", - "blocked_reason": blockedReason, - "network_mode": snapshot.AccessTech, - "network_duplex": "", - "radio_band": snapshot.Band, - "radio_channel": parseDecimal(snapshot.Channel), - "signal_dbm": pointerInt(snapshot.RSSIDBm), - "signal_rsrp": pointerInt(snapshot.RSRP), - "signal_rsrq": pointerInt(snapshot.RSRQ), - "signal_sinr": pointerInt(snapshot.SINR), - "imei": snapshot.IMEI, - "iccid": snapshot.ICCID, - "imsi": snapshot.IMSI, - "firmware": snapshot.Firmware, - "model": snapshot.Model, - "reg_status": boolInt(snapshot.OperatorName != ""), - "reg_status_text": registrationText(snapshot), - "ps_attached": false, - "sim_inserted": snapshot.SIMStatus != "", - "operating_mode": snapshot.OperatingMode, - "phone_number": phone, - "phone_number_source": phoneSource, + "operator": snapshot.OperatorName, + "native_mcc": mcc, + "native_mnc": mnc, + "operator_country_code": operatorCountryCode, + "card_mcc": cardMCC, + "card_mnc": cardMNC, + "card_country": countryNameForMCC(cardMCC), + "service_blocked": blockedReason != "", + "blocked_reason": blockedReason, + "network_mode": snapshot.AccessTech, + "network_duplex": "", + "radio_band": snapshot.Band, + "radio_channel": parseDecimal(snapshot.Channel), + "signal_dbm": pointerInt(snapshot.RSSIDBm), + "signal_rsrp": pointerInt(snapshot.RSRP), + "signal_rsrq": pointerInt(snapshot.RSRQ), + "signal_sinr": pointerInt(snapshot.SINR), + "imei": snapshot.IMEI, + "iccid": snapshot.ICCID, + "imsi": snapshot.IMSI, + "firmware": snapshot.Firmware, + "model": snapshot.Model, + "reg_status": snapshot.RegistrationStatus, + "reg_status_text": registrationText(snapshot), + "ps_attached": snapshot.PSAttached, + "sim_inserted": snapshot.SIMStatus != "", + "operating_mode": snapshot.OperatingMode, + "phone_number": phone, + "phone_number_source": phoneSource, } } @@ -1485,17 +1696,39 @@ func lifecyclePhase(entry device.Device) string { } func registrationLabel(snapshot *device.Snapshot) string { - if snapshot == nil || snapshot.OperatorName == "" { + if snapshot == nil { + return "unknown" + } + switch snapshot.RegistrationStatus { + case 1, 5: + return "registered" + case 2: + return "searching" + case 3: + return "denied" + default: return "unknown" } - return "registered" } func registrationText(snapshot *device.Snapshot) string { - if snapshot.OperatorName != "" { + if snapshot == nil { + return "unknown" + } + switch snapshot.RegistrationStatus { + case 1: return "registered" + case 5: + return "registered (roaming)" + case 2: + return "searching" + case 3: + return "registration denied" + case 0: + return "not registered" + default: + return "unknown" } - return "unknown" } func splitPLMN(value string) (string, string) { diff --git a/internal/server/device_features_api.go b/internal/server/device_features_api.go index 83b3702..c30090a 100644 --- a/internal/server/device_features_api.go +++ b/internal/server/device_features_api.go @@ -2,6 +2,7 @@ package server import ( "encoding/json" + "errors" "fmt" "net/http" "time" @@ -10,6 +11,10 @@ import ( "vocat/internal/store" ) +// overviewStreamInterval is the cadence at which the overview SSE stream pushes +// a fresh snapshot. It is a package var so tests can shorten it. +var overviewStreamInterval = 2 * time.Second + // beginSSE prepares a response for Server-Sent Events and returns its response // controller for explicit flushes. func beginSSE(w http.ResponseWriter) *http.ResponseController { @@ -50,13 +55,27 @@ func (s *Server) handleOverviewStream( if err := writeSSEEvent(w, controller, "connected", map[string]any{}); err != nil { return true } - ticker := time.NewTicker(2 * time.Second) + ticker := time.NewTicker(overviewStreamInterval) defer ticker.Stop() for { select { case <-r.Context().Done(): return true case <-ticker.C: + // The config passed in was read when the stream opened. Re-read it on + // every tick so edits made while watching (roaming data, APN, VoWiFi, + // name…) take effect; otherwise the stream keeps replaying the stale + // snapshot and the UI flaps between SSE-old and REST-new values. + fresh, err := s.store.Device(r.Context(), config.ID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + // The device was deleted while streaming; end the stream. + return true + } + // Transient store hiccup: keep the last known config for this tick. + } else { + config = fresh + } currentEntry, _, present := s.physicalForConfig(config) overview := s.configuredDeviceOverview(config, currentEntry, present) if err := writeSSEEvent(w, controller, "overview", overview); err != nil { @@ -81,6 +100,7 @@ func operatorCandidateWire(op device.ScannedOperator) map[string]any { "operatorName": op.Name, "shortName": op.Short, "plmn": op.Numeric, + "countryCode": op.Country, "rats": rats, "includesPcsDigit": false, } diff --git a/internal/server/device_features_api_test.go b/internal/server/device_features_api_test.go index 7675262..3fb80ad 100644 --- a/internal/server/device_features_api_test.go +++ b/internal/server/device_features_api_test.go @@ -1,6 +1,7 @@ package server import ( + "bufio" "context" "encoding/json" "errors" @@ -11,7 +12,9 @@ import ( "testing" "time" + "vocat/internal/developer" "vocat/internal/device" + "vocat/internal/exportproxy" "vocat/internal/modem" "vocat/internal/store" "vocat/internal/update" @@ -467,3 +470,230 @@ func TestE911WebsheetRejectsBadToken(t *testing.T) { t.Fatalf("bad token status = %d, want 403", recorder.Code) } } + +// readSSEEvent reads one Server-Sent-Events frame ("event:"/"data:" lines +// terminated by a blank line) and returns the event name and data payload. +func readSSEEvent(reader *bufio.Reader) (string, []byte, error) { + var event string + var data []byte + for { + line, err := reader.ReadString('\n') + if err != nil { + return "", nil, err + } + line = strings.TrimRight(line, "\r\n") + if line == "" { + if event != "" || data != nil { + return event, data, nil + } + continue + } + if rest, ok := strings.CutPrefix(line, "event: "); ok { + event = rest + } else if rest, ok := strings.CutPrefix(line, "data: "); ok { + data = append(data, rest...) + } + } +} + +// awaitOverviewNetworkEnabled reads overview SSE events until one reports the +// requested network_enabled value, or the stream ends / the request times out. +func awaitOverviewNetworkEnabled(reader *bufio.Reader, want bool) error { + for { + event, data, err := readSSEEvent(reader) + if err != nil { + return err + } + if event != "overview" { + continue + } + var overview struct { + NetworkEnabled bool `json:"network_enabled"` + } + if err := json.Unmarshal(data, &overview); err != nil { + return err + } + if overview.NetworkEnabled == want { + return nil + } + } +} + +// The overview SSE stream must reflect edits made after it opened. Before the +// fix it rebuilt every tick from the config snapshot captured when the stream +// opened, so toggling roaming data off was immediately overwritten by the stale +// "on" snapshot and the switch flapped. This test opens the stream with roaming +// data on, turns it off in the store, and requires the stream to keep reporting +// the new "off" state. +func TestHandleOverviewStreamReflectsConfigChanges(t *testing.T) { + previousInterval := overviewStreamInterval + overviewStreamInterval = 10 * time.Millisecond + t.Cleanup(func() { overviewStreamInterval = previousInterval }) + + ctx := context.Background() + database, err := store.Open(ctx, ":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.UpsertAppSetting(ctx, store.AppSetting{ + Key: developer.EnabledSettingKey, + Value: []byte(`{"enabled":true}`), + }); err != nil { + t.Fatal(err) + } + if err := database.UpsertDevice(ctx, store.Device{ID: "dev1", Name: "Test device", NetworkEnabled: true}); err != nil { + t.Fatal(err) + } + + server := &Server{store: database, logger: regionTestLogger(), developerEnabled: true} + mux := http.NewServeMux() + mux.HandleFunc("/stream", func(w http.ResponseWriter, r *http.Request) { + config, err := database.Device(r.Context(), "dev1") + if err != nil { + writeError(w, http.StatusNotFound, "not_found", err.Error()) + return + } + server.handleOverviewStream(w, r, config, device.Device{}, false) + }) + testServer := httptest.NewServer(mux) + t.Cleanup(testServer.Close) + + requestCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + t.Cleanup(cancel) + request, err := http.NewRequestWithContext(requestCtx, http.MethodGet, testServer.URL+"/stream", nil) + if err != nil { + t.Fatal(err) + } + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = response.Body.Close() }) + if response.StatusCode != http.StatusOK { + t.Fatalf("stream status = %d", response.StatusCode) + } + reader := bufio.NewReader(response.Body) + + // The stream opens with roaming data enabled. + if err := awaitOverviewNetworkEnabled(reader, true); err != nil { + t.Fatalf("initial overview never reported network_enabled=true: %v", err) + } + + // Turn roaming data off; the very next ticks must report the new state + // instead of replaying the stale enabled snapshot. + config, err := database.Device(ctx, "dev1") + if err != nil { + t.Fatal(err) + } + config.NetworkEnabled = false + if err := database.UpsertDevice(ctx, config); err != nil { + t.Fatal(err) + } + if err := awaitOverviewNetworkEnabled(reader, false); err != nil { + t.Fatalf("overview kept replaying stale network_enabled=true after the edit: %v", err) + } +} + +// Turning roaming data off must be refused while an enabled export proxy is +// bound to the device; the user has to disable that binding first. +func TestHandleCellularDataRejectsDisableWhileExportProxyActive(t *testing.T) { + ctx := context.Background() + database, err := store.Open(ctx, ":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.UpsertAppSetting(ctx, store.AppSetting{ + Key: developer.EnabledSettingKey, Value: json.RawMessage(`{"enabled":true}`), + }); err != nil { + t.Fatal(err) + } + deviceConfig := store.Device{ID: "modem-1", Name: "modem-1", Interface: "wwan0", NetworkEnabled: true} + if err := database.UpsertDevice(ctx, deviceConfig); err != nil { + t.Fatal(err) + } + // Seed an already-enabled export proxy bound to the device. New only logs a + // warning when the Linux-only listener cannot start on this platform, so the + // enabled config still loads and the interlock sees it. + seeded, err := json.Marshal([]exportproxy.Config{{ + ID: "proxy-1", Name: "proxy-1", DeviceID: "modem-1", Interface: "wwan0", + Mode: "socks5", ListenHost: "127.0.0.1", ListenPort: 1080, Enabled: true, + }}) + if err != nil { + t.Fatal(err) + } + if err := database.UpsertAppSetting(ctx, store.AppSetting{Key: exportproxy.SettingKey, Value: seeded, Sensitive: true}); err != nil { + t.Fatal(err) + } + proxyManager, err := exportproxy.New(ctx, database, regionTestLogger(), "") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = proxyManager.Close() }) + server := &Server{ + store: database, + logger: regionTestLogger(), + developerEnabled: true, + exportProxy: proxyManager, + devices: fakeDeviceController{}, + maxRequestBodyBytes: 1 << 20, + } + + patchOff := func() *httptest.ResponseRecorder { + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPatch, "/api/devices/modem-1/cellular-data", strings.NewReader(`{"enabled":false}`)) + request.Header.Set("Content-Type", "application/json") + if !server.handleCellularData(recorder, request, deviceConfig, "physical-1") { + t.Fatal("handleCellularData did not handle the request") + } + return recorder + } + + // While the export proxy is enabled, turning roaming data off is rejected and + // the stored config keeps roaming data on. + recorder := patchOff() + if recorder.Code != http.StatusConflict { + t.Fatalf("disable with active proxy status = %d, body = %s", recorder.Code, recorder.Body) + } + var failure struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &failure); err != nil { + t.Fatal(err) + } + if failure.Error.Code != "export_proxy_active" { + t.Fatalf("error code = %q, body = %s", failure.Error.Code, recorder.Body) + } + stored, err := database.Device(ctx, "modem-1") + if err != nil { + t.Fatal(err) + } + if !stored.NetworkEnabled { + t.Fatal("roaming data was turned off despite the active export proxy") + } + + // Once the binding is disabled, the same request goes through. + proxies, err := proxyManager.Configs() + if err != nil || len(proxies) != 1 { + t.Fatalf("configs = %+v, %v", proxies, err) + } + disabled := proxies[0] + disabled.Enabled = false + if _, err := proxyManager.Update(ctx, disabled.ID, disabled); err != nil { + t.Fatal(err) + } + recorder = patchOff() + if recorder.Code != http.StatusOK { + t.Fatalf("disable after proxy off status = %d, body = %s", recorder.Code, recorder.Body) + } + stored, err = database.Device(ctx, "modem-1") + if err != nil { + t.Fatal(err) + } + if stored.NetworkEnabled { + t.Fatal("roaming data was not turned off after the export proxy was disabled") + } +} diff --git a/internal/server/device_summary_test.go b/internal/server/device_summary_test.go new file mode 100644 index 0000000..155b028 --- /dev/null +++ b/internal/server/device_summary_test.go @@ -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"]) + } +} diff --git a/internal/server/export_proxy_api.go b/internal/server/export_proxy_api.go new file mode 100644 index 0000000..19cae8d --- /dev/null +++ b/internal/server/export_proxy_api.go @@ -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()) + } +} diff --git a/internal/server/general_api.go b/internal/server/general_api.go index 03352e0..a2293f1 100644 --- a/internal/server/general_api.go +++ b/internal/server/general_api.go @@ -15,6 +15,7 @@ import ( "vocat/internal/auth" "vocat/internal/buildinfo" + "vocat/internal/developer" "vocat/internal/i18n" "vocat/internal/loghub" "vocat/internal/store" @@ -26,6 +27,9 @@ func (s *Server) routeGeneralAPI(w http.ResponseWriter, r *http.Request) bool { if s.routeExtensionAPI(w, r, cleanPath) { return true } + if s.routeExportProxyAPI(w, r, cleanPath) { + return true + } if s.routeSMSAPI(w, r, cleanPath) { return true } @@ -50,6 +54,12 @@ func (s *Server) routeGeneralAPI(w http.ResponseWriter, r *http.Request) bool { s.handlePasswordChange(w, r) case "settings/preferences": s.handleUIPreferences(w, r) + case "settings/https": + s.handleHTTPSSettings(w, r) + case "settings/https/certificate": + s.handleHTTPSCertificate(w, r) + case "settings/developer": + s.handleDeveloperSettings(w, r) default: return false } @@ -314,11 +324,15 @@ func (s *Server) handleSystemInfo(w http.ResponseWriter, r *http.Request) { "os": runtime.GOOS, "architecture": runtime.GOARCH, "uptime": formatDuration(time.Since(s.startedAt)), - "developer": s.developerEnabled, + "developer": s.developerActive(r.Context()), }, }) } +func (s *Server) developerActive(ctx context.Context) bool { + return s.developerEnabled && developer.Enabled(ctx, s.store) +} + func (s *Server) handleUpdateCheck(w http.ResponseWriter, r *http.Request) { if !requireMethod(w, r, http.MethodGet) { return diff --git a/internal/server/https_settings.go b/internal/server/https_settings.go new file mode 100644 index 0000000..0e38086 --- /dev/null +++ b/internal/server/https_settings.go @@ -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) +} diff --git a/internal/server/live_net.go b/internal/server/live_net.go new file mode 100644 index 0000000..b52c6a8 --- /dev/null +++ b/internal/server/live_net.go @@ -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]) +} diff --git a/internal/server/live_net_test.go b/internal/server/live_net_test.go new file mode 100644 index 0000000..a529d62 --- /dev/null +++ b/internal/server/live_net_test.go @@ -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) + } + } +} diff --git a/internal/server/netif_linux.go b/internal/server/netif_linux.go new file mode 100644 index 0000000..9d2fdde --- /dev/null +++ b/internal/server/netif_linux.go @@ -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 +} diff --git a/internal/server/netif_other.go b/internal/server/netif_other.go new file mode 100644 index 0000000..7d2c07c --- /dev/null +++ b/internal/server/netif_other.go @@ -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") +} diff --git a/internal/server/public_ip_api.go b/internal/server/public_ip_api.go new file mode 100644 index 0000000..8f73100 --- /dev/null +++ b/internal/server/public_ip_api.go @@ -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 +} diff --git a/internal/server/public_ip_api_test.go b/internal/server/public_ip_api_test.go new file mode 100644 index 0000000..dea6b14 --- /dev/null +++ b/internal/server/public_ip_api_test.go @@ -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") + } +} diff --git a/internal/server/region_test.go b/internal/server/region_test.go index dcf24f3..8422140 100644 --- a/internal/server/region_test.go +++ b/internal/server/region_test.go @@ -76,6 +76,10 @@ func (f fakeDeviceController) OperatorSelection(context.Context, string) (device func (f fakeDeviceController) SetOperatorSelection(context.Context, string, bool, string, *int) (device.OperatorSelection, error) { return device.OperatorSelection{}, nil } + +func (f fakeDeviceController) ReRegisterOperator(context.Context, string) (device.OperatorSelection, error) { + return device.OperatorSelection{}, nil +} func (f fakeDeviceController) ScanOperators(context.Context, string) (device.OperatorScanResult, error) { return f.scanResult, f.scanErr } diff --git a/internal/server/server.go b/internal/server/server.go index 7f29bcc..ee0732b 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -18,7 +18,9 @@ import ( "time" "vocat/internal/auth" + "vocat/internal/exportproxy" "vocat/internal/extensions" + "vocat/internal/httpsmode" "vocat/internal/loghub" "vocat/internal/store" "vocat/internal/update" @@ -42,9 +44,11 @@ type Options struct { SecureCookies bool MaxRequestBodyBytes int64 Extensions *extensions.Manager + ExportProxy *exportproxy.Manager DeveloperEnabled bool UpdateRepository string UpdateToken string + HTTPS *httpsmode.Manager } // Server is the single HTTP handler for the JSON API and embedded SPA. @@ -67,6 +71,7 @@ type Server struct { access parsedAccessConfig loginLimiter *loginRateLimiter extensions *extensions.Manager + exportProxy *exportproxy.Manager developerEnabled bool updateRepository string updateToken string @@ -75,6 +80,10 @@ type Server struct { updateRestart func(*slog.Logger) error updateMu sync.Mutex updateApplying bool + https *httpsmode.Manager + netTraffic *liveNetTracker + publicIPMu sync.RWMutex + publicIPs map[string]cachedPublicIP } func New(options Options) (*Server, error) { @@ -117,9 +126,13 @@ func New(options Options) (*Server, error) { websheets: newWebsheetManager(), loginLimiter: newLoginRateLimiter(), extensions: options.Extensions, + exportProxy: options.ExportProxy, developerEnabled: options.DeveloperEnabled, updateRepository: strings.TrimSpace(options.UpdateRepository), updateToken: strings.TrimSpace(options.UpdateToken), + https: options.HTTPS, + netTraffic: newLiveNetTracker(), + publicIPs: make(map[string]cachedPublicIP), updateCheck: update.CheckLatest, updateApply: update.ApplyLatest, updateRestart: update.RestartService, @@ -160,6 +173,10 @@ type VoWiFiCallController interface { HangupCall(context.Context, string, string) error } +type VoWiFiCallMediaController interface { + CallMedia(context.Context, string, string) (vowifi.CallMedia, error) +} + func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.handler.ServeHTTP(w, r) } @@ -571,7 +588,7 @@ func (s *Server) securityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Referrer-Policy", "same-origin") - w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + w.Header().Set("Permissions-Policy", "camera=(), microphone=(self), geolocation=()") if strings.HasPrefix(r.URL.Path, "/websheets/") || strings.HasPrefix(r.URL.Path, "/plugin-assets/") { // The self-hosted E911 websheet is embedded in an iframe by the SPA, so // it must be frameable same-origin. Every other route stays DENY. @@ -593,7 +610,7 @@ func (s *Server) securityHeaders(next http.Handler) http.Handler { "img-src 'self' data:; connect-src 'self'", ) } - if s.secureCookies { + if s.secureCookies && s.https == nil { w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") } next.ServeHTTP(w, r) diff --git a/internal/server/settings_api.go b/internal/server/settings_api.go index 884e252..5768cb5 100644 --- a/internal/server/settings_api.go +++ b/internal/server/settings_api.go @@ -21,6 +21,7 @@ import ( "sort" "strconv" "strings" + "sync" "time" "vocat/internal/store" @@ -932,18 +933,77 @@ func dialRestricted( if err != nil { return nil, err } - dialer := net.Dialer{Timeout: clampNotificationTimeout(timeout)} - var failures []error - for _, ip := range addresses { - connection, err := dialer.DialContext( - ctx, - network, - net.JoinHostPort(ip.String(), port), - ) - if err == nil { - return connection, nil + perAddress := clampNotificationTimeout(timeout) + stagger := 300 * time.Millisecond + if perAddress < stagger { + stagger = perAddress / 2 + } + + raceContext, cancel := context.WithCancel(ctx) + defer cancel() + + type attempt struct { + conn net.Conn + err error + } + resultCh := make(chan attempt, len(addresses)) + var wg sync.WaitGroup + + launcher := time.NewTicker(stagger) + defer launcher.Stop() + for index, ip := range addresses { + if index > 0 { + select { + case <-raceContext.Done(): + break + case <-launcher.C: + } } - failures = append(failures, err) + if raceContext.Err() != nil { + break + } + ip := ip + wg.Add(1) + go func() { + defer wg.Done() + dialer := net.Dialer{Timeout: perAddress} + connection, dialErr := dialer.DialContext( + raceContext, + network, + net.JoinHostPort(ip.String(), port), + ) + if dialErr != nil { + resultCh <- attempt{err: dialErr} + return + } + if raceContext.Err() != nil { + connection.Close() + resultCh <- attempt{err: raceContext.Err()} + return + } + resultCh <- attempt{conn: connection} + }() + } + go func() { + wg.Wait() + close(resultCh) + }() + + var failures []error + for result := range resultCh { + if result.conn != nil { + cancel() + return result.conn, nil + } + if result.err != nil && !errors.Is(result.err, context.Canceled) { + failures = append(failures, result.err) + } + if ctx.Err() != nil { + return nil, ctx.Err() + } + } + if len(failures) == 0 { + return nil, ctx.Err() } return nil, fmt.Errorf("dial public notification destination: %w", errors.Join(failures...)) } @@ -1259,6 +1319,10 @@ func (s *Server) handleTrafficAnalysis(w http.ResponseWriter, r *http.Request) { if !requireMethod(w, r, http.MethodGet) { return } + if !s.developerActive(r.Context()) { + writeError(w, http.StatusForbidden, "developer_mode_required", "traffic analysis is available only in developer mode") + return + } rangeName := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("range"))) if rangeName == "" { rangeName = "day" diff --git a/internal/server/settings_api_test.go b/internal/server/settings_api_test.go index 6e00ec5..679f815 100644 --- a/internal/server/settings_api_test.go +++ b/internal/server/settings_api_test.go @@ -14,6 +14,7 @@ import ( "testing" "time" + "vocat/internal/developer" "vocat/internal/store" ) @@ -465,6 +466,12 @@ func TestCardPolicyDefaultValidationAndPersistence(t *testing.T) { func TestTrafficAnalysisUsesAndAggregatesStoredBuckets(t *testing.T) { test := newSettingsAPITest(t) + test.server.developerEnabled = true + if err := test.database.UpsertAppSetting(context.Background(), store.AppSetting{ + Key: developer.EnabledSettingKey, Value: json.RawMessage(`{"enabled":true}`), + }); err != nil { + t.Fatal(err) + } period := time.Now().UTC().Add(-time.Hour).Truncate(time.Minute) for _, bucket := range []store.TrafficBucket{ { @@ -517,6 +524,14 @@ func TestTrafficAnalysisUsesAndAggregatesStoredBuckets(t *testing.T) { } } +func TestTrafficAnalysisIsUnavailableOutsideDeveloperMode(t *testing.T) { + test := newSettingsAPITest(t) + recorder := test.request(t, http.MethodGet, "/api/traffic/analysis?range=week", "") + if recorder.Code != http.StatusForbidden { + t.Fatalf("traffic status = %d, want %d; body = %s", recorder.Code, http.StatusForbidden, recorder.Body) + } +} + func TestNotificationDestinationAddressPolicy(t *testing.T) { blocked := []string{ "0.0.0.0", "10.0.0.1", "100.100.100.200", "127.0.0.1", diff --git a/internal/server/sms_api.go b/internal/server/sms_api.go index 27bf994..3ca8b04 100644 --- a/internal/server/sms_api.go +++ b/internal/server/sms_api.go @@ -581,6 +581,15 @@ func (s *Server) syncModemSMS(ctx context.Context, onlyDevice string) { message.Index, hex.EncodeToString(digest[:8]), ) + if message.Concat != nil && message.Concat.Total > 1 { + // A segment of a carrier-split long SMS. Address the whole message + // with a stable id so SaveSMSMessage folds every segment into one + // progressively merged row instead of one row per segment. + messageID = store.StableConcatMessageID( + "cellular_at", modemIMEI, config.ID, peer, + message.Concat.Reference, message.Concat.Total, + ) + } extra, _ := json.Marshal(map[string]any{ "modem_index": message.Index, "storage": message.Storage, diff --git a/internal/server/telegram_bot.go b/internal/server/telegram_bot.go index 53040c5..6613660 100644 --- a/internal/server/telegram_bot.go +++ b/internal/server/telegram_bot.go @@ -902,6 +902,15 @@ func (bot *telegramBot) notifyInboundSMS(ctx context.Context) { bot.warn("list Telegram SMS notifications", listErr) } else { for _, message := range messages { + if !store.ConcatSMSReadyToNotify(message.MessageID, message.Extra) { + // A carrier-split long SMS still waiting for segments. Hold + // the notification but advance the cursor so the partial row + // is not reconsidered every poll; when the final segment + // merges, the row re-enters with a fresh id and is pushed + // here as one complete message. + cursor = message.ID + continue + } text := fmt.Sprintf("📩 新短信\n设备:%s\n来自:%s\n时间:%s\n\n%s", message.DeviceID, message.Peer, message.Timestamp.Local().Format("2006-01-02 15:04:05"), message.Body) if sendErr := bot.sendText(ctx, config, 0, text, nil); sendErr != nil { bot.warn("send Telegram SMS notification", sendErr) diff --git a/internal/store/devices.go b/internal/store/devices.go index 1abe4ba..a497582 100644 --- a/internal/store/devices.go +++ b/internal/store/devices.go @@ -13,6 +13,27 @@ type contextExecer interface { ExecContext(context.Context, string, ...any) (sql.Result, error) } +const ( + DeviceTypeWiFi410 = "wifi_410" + DeviceTypeDJI4G = "dji_4g" + DeviceTypePCIeEC20EC25 = "pcie_ec20_ec25" +) + +// NormalizeDeviceType returns a stable persisted device type identifier. +// Empty values use the legacy EC20/EC25 type for backwards compatibility. +func NormalizeDeviceType(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case DeviceTypeWiFi410: + return DeviceTypeWiFi410 + case DeviceTypeDJI4G: + return DeviceTypeDJI4G + case "", DeviceTypePCIeEC20EC25: + return DeviceTypePCIeEC20EC25 + default: + return "" + } +} + func (s *Store) UpsertDevice(ctx context.Context, value Device) error { return upsertDevice(ctx, s.db, value) } @@ -73,6 +94,10 @@ func upsertDevice(ctx context.Context, executor contextExecer, value Device) err if value.Name == "" { return errors.New("device name is required") } + value.DeviceType = NormalizeDeviceType(value.DeviceType) + if value.DeviceType == "" { + return errors.New("unsupported device type") + } if value.ProxyPort < 0 || value.ProxyPort > 65535 { return errors.New("device proxy port must be between 0 and 65535") } @@ -133,15 +158,16 @@ func upsertDevice(ctx context.Context, executor contextExecer, value Device) err _, err = executor.ExecContext(ctx, ` INSERT INTO devices ( - id, name, interface, control_device, at_port, usb_path, + id, name, device_type, interface, control_device, at_port, usb_path, audio_device, modem_imei, apn, proxy_port, baud_rate, data_bits, stop_bits, parity, device_backend, esim_transport, qmi_use_proxy, qmi_proxy_path, qmi_proxy_executable, network_enabled, sms_enabled, vowifi_enabled, extra_json, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, + device_type = excluded.device_type, interface = excluded.interface, control_device = excluded.control_device, at_port = excluded.at_port, @@ -165,7 +191,7 @@ func upsertDevice(ctx context.Context, executor contextExecer, value Device) err extra_json = excluded.extra_json, updated_at = excluded.updated_at `, - value.ID, value.Name, value.Interface, value.ControlDevice, value.ATPort, + value.ID, value.Name, value.DeviceType, value.Interface, value.ControlDevice, value.ATPort, value.USBPath, value.AudioDevice, value.ModemIMEI, value.APN, value.ProxyPort, value.BaudRate, value.DataBits, value.StopBits, value.Parity, value.DeviceBackend, value.ESIMTransport, @@ -255,7 +281,7 @@ func (s *Store) DeleteDevice(ctx context.Context, id string) error { } const deviceSelect = ` - SELECT id, name, interface, control_device, at_port, usb_path, + SELECT id, name, device_type, interface, control_device, at_port, usb_path, audio_device, modem_imei, apn, proxy_port, baud_rate, data_bits, stop_bits, parity, device_backend, esim_transport, qmi_use_proxy, qmi_proxy_path, qmi_proxy_executable, network_enabled, sms_enabled, @@ -268,7 +294,7 @@ func scanDevice(row rowScanner) (Device, error) { var extra string var createdAt, updatedAt int64 err := row.Scan( - &value.ID, &value.Name, &value.Interface, &value.ControlDevice, + &value.ID, &value.Name, &value.DeviceType, &value.Interface, &value.ControlDevice, &value.ATPort, &value.USBPath, &value.AudioDevice, &value.ModemIMEI, &value.APN, &value.ProxyPort, &value.BaudRate, &value.DataBits, &value.StopBits, &value.Parity, &value.DeviceBackend, @@ -286,6 +312,7 @@ func scanDevice(row rowScanner) (Device, error) { value.NetworkEnabled = networkEnabled != 0 value.SMSEnabled = smsEnabled != 0 value.VoWiFiEnabled = vowifiEnabled != 0 + value.DeviceType = NormalizeDeviceType(value.DeviceType) value.Extra = []byte(extra) value.CreatedAt = time.Unix(createdAt, 0).UTC() value.UpdatedAt = time.Unix(updatedAt, 0).UTC() diff --git a/internal/store/domain_test.go b/internal/store/domain_test.go index e86df7d..fe7b839 100644 --- a/internal/store/domain_test.go +++ b/internal/store/domain_test.go @@ -105,6 +105,41 @@ func TestMigration7BackfillsSMSModemIMEI(t *testing.T) { } } +func TestMigration8DefaultsExistingDevicesToPCIeType(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "device-type.db") + raw, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + for version := 1; version <= 7; version++ { + for _, statement := range migrationStatements(version) { + if _, err := raw.ExecContext(ctx, statement); err != nil { + t.Fatalf("create v%d schema: %v", version, err) + } + } + } + if _, err := raw.ExecContext(ctx, ` + INSERT INTO devices (id, name, created_at, updated_at) + VALUES ('legacy', 'Legacy modem', 100, 100); + PRAGMA user_version = 7; + `); err != nil { + t.Fatal(err) + } + if err := raw.Close(); err != nil { + t.Fatal(err) + } + + database := openTestStore(t, path) + got, err := database.Device(ctx, "legacy") + if err != nil { + t.Fatal(err) + } + if got.DeviceType != DeviceTypePCIeEC20EC25 { + t.Fatalf("legacy device type = %q", got.DeviceType) + } +} + func TestMigration4PreservesIMSRedeliveryAndUsesReceiptTime(t *testing.T) { ctx := context.Background() path := filepath.Join(t.TempDir(), "ims-redelivery.db") @@ -170,6 +205,7 @@ func TestDeviceStateRoundTripAndCascade(t *testing.T) { device := Device{ ID: "ec20-1", Name: "EC20 一号", + DeviceType: DeviceTypeDJI4G, Interface: "wwan0", ControlDevice: "/dev/cdc-wdm0", ATPort: "/dev/ttyUSB2", @@ -219,7 +255,7 @@ func TestDeviceStateRoundTripAndCascade(t *testing.T) { if err != nil { t.Fatal(err) } - if gotDevice.BaudRate != 115200 || gotDevice.DataBits != 8 || + if gotDevice.DeviceType != DeviceTypeDJI4G || gotDevice.BaudRate != 115200 || gotDevice.DataBits != 8 || gotDevice.StopBits != 1 || gotDevice.DeviceBackend != "at" { t.Fatalf("device defaults not applied: %+v", gotDevice) } diff --git a/internal/store/migrations.go b/internal/store/migrations.go index 3849700..834233c 100644 --- a/internal/store/migrations.go +++ b/internal/store/migrations.go @@ -106,6 +106,11 @@ func migrationStatements(version int) []string { `CREATE INDEX IF NOT EXISTS sms_messages_hardware_thread_idx ON sms_messages(modem_imei, imsi, peer, message_time DESC, id DESC)`, } + case 8: + return []string{ + `ALTER TABLE devices + ADD COLUMN device_type TEXT NOT NULL DEFAULT 'pcie_ec20_ec25'`, + } default: return nil } diff --git a/internal/store/models.go b/internal/store/models.go index 9ad6118..34cb38c 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -17,6 +17,7 @@ const SecretMask = "********" type Device struct { ID string Name string + DeviceType string Interface string ControlDevice string ATPort string diff --git a/internal/store/sms.go b/internal/store/sms.go index 061d070..b11c12f 100644 --- a/internal/store/sms.go +++ b/internal/store/sms.go @@ -65,6 +65,58 @@ func saveSMSMessage( return SMSMessage{}, fmt.Errorf("normalize SMS extra data: %w", err) } now := time.Now().UTC() + + // Concatenated (long) SMS arrive as one segment per delivery. Ingest points + // address the whole message with a stable "concat:" message id and carry the + // segment text plus its UDH sequence in Extra. Fold each segment into a single + // stored row so history, the web thread, and Telegram show one progressive + // message that fills in as the remaining segments arrive. + if isConcatSMSMessageID(value.MessageID) { + hardwareKey := smsHardwareKey(value.ModemIMEI, value.DeviceID) + existing, existingErr := scanSMSMessage(executor.QueryRowContext( + ctx, + smsMessageSelect+` WHERE + COALESCE(NULLIF(modem_imei, ''), 'device:' || device_id) = ? + AND message_id = ?`, + hardwareKey, + value.MessageID, + )) + if existingErr != nil && !errors.Is(existingErr, ErrNotFound) { + return SMSMessage{}, fmt.Errorf("read existing concatenated SMS: %w", existingErr) + } + var existingExtra json.RawMessage + if existingErr == nil { + existingExtra = existing.Extra + } + mergedBody, mergedExtra, changed, mergeErr := mergeConcatSegment(existingExtra, value.Body, extra) + if mergeErr != nil { + return SMSMessage{}, fmt.Errorf("merge concatenated SMS segment: %w", mergeErr) + } + if existingErr == nil && !changed { + // This segment is already folded into the stored row (a periodic modem + // rescan redelivers every segment). Leave the row untouched so the + // durable id stays put and Telegram does not re-notify. + return existing, nil + } + value.Body = mergedBody + extra = mergedExtra + if existingErr == nil { + // A new segment advanced the message. Replace the stale partial row so + // the merged row receives a fresh durable id; the Telegram id-cursor + // then surfaces the now-more-complete message exactly once. Carry + // forward identity and history fields. + if _, delErr := executor.ExecContext(ctx, `DELETE FROM sms_messages WHERE id = ?`, existing.ID); delErr != nil { + return SMSMessage{}, fmt.Errorf("replace concatenated SMS: %w", delErr) + } + value.ID = 0 + value.CreatedAt = existing.CreatedAt + value.Read = value.Read || existing.Read + if !existing.Timestamp.IsZero() && + (value.Timestamp.IsZero() || existing.Timestamp.Before(value.Timestamp)) { + value.Timestamp = existing.Timestamp + } + } + } if value.Timestamp.IsZero() { value.Timestamp = now } diff --git a/internal/store/sms_reassembly.go b/internal/store/sms_reassembly.go new file mode 100644 index 0000000..573620c --- /dev/null +++ b/internal/store/sms_reassembly.go @@ -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 +} diff --git a/internal/store/sms_reassembly_test.go b/internal/store/sms_reassembly_test.go new file mode 100644 index 0000000..9434050 --- /dev/null +++ b/internal/store/sms_reassembly_test.go @@ -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) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 515691e..a214e89 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -13,7 +13,7 @@ import ( _ "modernc.org/sqlite" ) -const schemaVersion = 7 +const schemaVersion = 8 var ErrNotFound = errors.New("store: not found") @@ -118,11 +118,11 @@ func migrate(ctx context.Context, db *sql.DB) error { for _, statement := range migrationStatements(nextVersion) { if _, err := tx.ExecContext(ctx, statement); err != nil { // A database whose user_version was repaired or rolled back may - // already contain this additive v7 column. The remaining v7 data - // backfill and indexes are still safe and must be applied. - if nextVersion == 7 && - strings.Contains(statement, "ADD COLUMN modem_imei") && - strings.Contains(strings.ToLower(err.Error()), "duplicate column name") { + // already contain an additive column. Remaining statements in the + // migration are still safe and must be applied. + duplicateAdditiveColumn := (nextVersion == 7 && strings.Contains(statement, "ADD COLUMN modem_imei")) || + (nextVersion == 8 && strings.Contains(statement, "ADD COLUMN device_type")) + if duplicateAdditiveColumn && strings.Contains(strings.ToLower(err.Error()), "duplicate column name") { continue } _ = tx.Rollback() diff --git a/internal/vowifi/ims/call_runtime.go b/internal/vowifi/ims/call_runtime.go index 5fcfd7b..648f011 100644 --- a/internal/vowifi/ims/call_runtime.go +++ b/internal/vowifi/ims/call_runtime.go @@ -34,6 +34,7 @@ type imsCall struct { remoteTag string routes []string terminated bool + media *rtpMedia } func (session *Session) Calls() []vowifi.Call { @@ -73,7 +74,11 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal routes := append([]string(nil), session.evidence.ServiceRoute...) securityHeaders := runtimeSecurityHeaders(session.securityActive, session.securityAgreement.verifyValue) session.mu.Unlock() - body := session.inactiveSDP() + media, err := newRTPMedia(session.localMediaIP()) + if err != nil { + return vowifi.Call{}, err + } + body := media.offerSDP(session.localMediaIP()) transportUpper := strings.ToUpper(session.transport) from := "<" + session.identity.public + ">;tag=" + session.fromTag to := "<" + target + ">" @@ -108,6 +113,7 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal session.transactionsMu.Lock() if _, duplicate := session.transactions[key]; duplicate { session.transactionsMu.Unlock() + _ = media.Close() return vowifi.Call{}, errors.New("ims: duplicate call transaction") } session.transactions[key] = responses @@ -115,7 +121,7 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal call := &imsCall{ public: vowifi.Call{ID: callID, Number: number, Direction: "outgoing", State: "dialing", StartedAt: time.Now().UTC()}, callID: callID, target: target, from: from, to: to, branch: branch, cseq: cseq, responses: responses, - routes: routes, + routes: routes, media: media, } session.callMu.Lock() session.calls[callID] = call @@ -124,6 +130,7 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal _, err = session.conn.Write(request) session.writeMu.Unlock() if err != nil { + _ = media.Close() session.transactionsMu.Lock() delete(session.transactions, key) session.transactionsMu.Unlock() @@ -173,7 +180,18 @@ func (session *Session) watchOutgoingCall(call *imsCall, key sipTransactionKey) call.routes = reverseStrings(recordRoutes) } session.callMu.Unlock() + mediaErr := call.media.configureRemote(response.Body) _ = session.sendACK(call) + if mediaErr != nil { + session.finishCall(call.callID, "failed", response.StatusCode, mediaErr.Error()) + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = session.sendDialogRequest(ctx, call, "BYE") + }() + return + } + session.setCallMediaReady(call.callID) session.setCallState(call.callID, "active") } else { session.finishCall(call.callID, "failed", response.StatusCode, response.Reason) @@ -196,7 +214,7 @@ func (session *Session) AnswerCall(_ context.Context, id string) (vowifi.Call, e } request, respond := call.invite, call.respond session.callMu.Unlock() - response, err := buildSIPResponseWithBody(request, 200, session.fromTag, session.inactiveSDP()) + response, err := buildSIPResponseWithBody(request, 200, session.fromTag, call.media.answerSDP(session.localMediaIP())) if err != nil { return vowifi.Call{}, err } @@ -204,6 +222,9 @@ func (session *Session) AnswerCall(_ context.Context, id string) (vowifi.Call, e return vowifi.Call{}, err } session.setCallState(id, "active") + if call.media.ready() { + session.setCallMediaReady(id) + } session.callMu.Lock() result := call.public session.callMu.Unlock() @@ -255,10 +276,26 @@ func (session *Session) handleCallRequest(request *sipRequest, respond func([]by if target == "" { target = request.URI } + media, err := newRTPMedia(session.localMediaIP()) + if err != nil { + if response, buildErr := buildSIPResponseWithBody(request, 488, session.fromTag, nil); buildErr == nil { + _ = respond(response) + } + return true + } + if len(request.Body) > 0 { + if err := media.configureRemote(request.Body); err != nil { + _ = media.Close() + if response, buildErr := buildSIPResponseWithBody(request, 488, session.fromTag, nil); buildErr == nil { + _ = respond(response) + } + return true + } + } call := &imsCall{ public: vowifi.Call{ID: callID, Number: number, Direction: "incoming", State: "ringing", StartedAt: time.Now().UTC()}, callID: callID, target: target, from: request.value("To") + ";tag=" + session.fromTag, - to: request.value("From"), invite: request, respond: respond, routes: request.values("Record-Route"), + to: request.value("From"), invite: request, respond: respond, routes: request.values("Record-Route"), media: media, } session.callMu.Lock() session.calls[callID] = call @@ -269,6 +306,17 @@ func (session *Session) handleCallRequest(request *sipRequest, respond func([]by } return true case "ACK": + callID := strings.TrimSpace(request.value("Call-ID")) + session.callMu.Lock() + call := session.calls[callID] + session.callMu.Unlock() + if call != nil && call.media != nil && !call.media.ready() && len(request.Body) > 0 { + if err := call.media.configureRemote(request.Body); err != nil { + session.finishCall(callID, "failed", 0, err.Error()) + } else { + session.setCallMediaReady(callID) + } + } return true case "CANCEL", "BYE": response, err := buildSIPResponseWithBody(request, 200, session.fromTag, nil) @@ -353,25 +401,16 @@ func (session *Session) buildDialogRequest(call *imsCall, method string, cseq ui return []byte(strings.Join(lines, "\r\n")) } -func (session *Session) inactiveSDP() []byte { +func (session *Session) localMediaIP() net.IP { var localAddress net.Addr if session.conn != nil { localAddress = session.conn.LocalAddr() } - local := addressIP(localAddress) - if local == nil { - local = net.IPv4zero - } - family := "IP4" - if local.To4() == nil { - family = "IP6" - } - text := fmt.Sprintf("v=0\r\no=- %d %d IN %s %s\r\ns=VoCat Calling Test\r\nc=IN %s %s\r\nt=0 0\r\nm=audio 9 RTP/AVP 0 8\r\na=inactive\r\n", time.Now().Unix(), time.Now().Unix(), family, local.String(), family, local.String()) - return []byte(text) + return addressIP(localAddress) } func buildSIPResponseWithBody(request *sipRequest, status int, tag string, body []byte) ([]byte, error) { - reasons := map[int]string{180: "Ringing", 200: "OK", 486: "Busy Here", 487: "Request Terminated"} + reasons := map[int]string{180: "Ringing", 200: "OK", 486: "Busy Here", 487: "Request Terminated", 488: "Not Acceptable Here"} reason := reasons[status] if reason == "" { return nil, errors.New("ims: unsupported call response status") @@ -417,10 +456,34 @@ func (session *Session) setCallDiagnostic(id string, code int, reason string) { session.callMu.Unlock() } +func (session *Session) setCallMediaReady(id string) { + session.callMu.Lock() + if call := session.calls[id]; call != nil && call.media != nil { + call.public.MediaReady = call.media.ready() + call.public.Codec = call.media.Codec() + } + session.callMu.Unlock() +} + +func (session *Session) CallMedia(_ context.Context, id string) (vowifi.CallMedia, error) { + session.callMu.Lock() + defer session.callMu.Unlock() + call := session.calls[id] + if call == nil { + return nil, ErrCallNotFound + } + if call.public.State != "active" || call.media == nil || !call.media.ready() { + return nil, ErrCallState + } + return call.media, nil +} + func (session *Session) finishCall(id, state string, code int, reason string) { now := time.Now().UTC() + var media *rtpMedia session.callMu.Lock() if call := session.calls[id]; call != nil { + media = call.media call.public.State = state if code != 0 { call.public.SIPCode = code @@ -431,6 +494,9 @@ func (session *Session) finishCall(id, state string, code int, reason string) { call.public.EndedAt = &now } session.callMu.Unlock() + if media != nil { + _ = media.Close() + } } func validCallNumber(value string) bool { @@ -500,3 +566,4 @@ func reverseStrings(values []string) []string { } var _ vowifi.CallController = (*Session)(nil) +var _ vowifi.CallMediaController = (*Session)(nil) diff --git a/internal/vowifi/ims/call_runtime_test.go b/internal/vowifi/ims/call_runtime_test.go index f8b34bb..192b286 100644 --- a/internal/vowifi/ims/call_runtime_test.go +++ b/internal/vowifi/ims/call_runtime_test.go @@ -8,7 +8,7 @@ import ( "vocat/internal/vowifi" ) -func TestIncomingCallCanRingAndAnswerWithoutAudio(t *testing.T) { +func TestIncomingCallCanRingAndAnswerWithMediaOffer(t *testing.T) { session := &Session{fromTag: "local-tag", calls: make(map[string]*imsCall)} packet, err := parseSIPPacket([]byte(strings.Join([]string{ "INVITE sip:subscriber@example.test SIP/2.0", @@ -38,7 +38,7 @@ func TestIncomingCallCanRingAndAnswerWithoutAudio(t *testing.T) { if err != nil { t.Fatal(err) } - if answered.State != "active" || len(responses) != 2 || !strings.Contains(string(responses[1]), "a=inactive") { + if answered.State != "active" || len(responses) != 2 || !strings.Contains(string(responses[1]), "a=sendrecv") { t.Fatalf("answered = %#v, response = %q", answered, responses[1]) } } diff --git a/internal/vowifi/ims/provider.go b/internal/vowifi/ims/provider.go index 438fbdc..e30252f 100644 --- a/internal/vowifi/ims/provider.go +++ b/internal/vowifi/ims/provider.go @@ -1141,10 +1141,20 @@ func (session *Session) Close(ctx context.Context) error { session.smsContactConfirmed = false session.clearAuthentication() session.mu.Unlock() + session.callMu.Lock() + for _, call := range session.calls { + if call.media != nil { + _ = call.media.Close() + } + } + session.callMu.Unlock() var cleanupErrors []error if unregisterErr != nil { cleanupErrors = append(cleanupErrors, unregisterErr) } + // Runtime receive loops block in Read/Accept. Close every socket before + // waiting for those goroutines; waiting first deadlocks VoWiFi shutdown and + // leaves the modem permanently in CFUN=4. if err := session.conn.Close(); err != nil { cleanupErrors = append(cleanupErrors, err) } diff --git a/internal/vowifi/ims/rtp_media.go b/internal/vowifi/ims/rtp_media.go new file mode 100644 index 0000000..6a7e0dc --- /dev/null +++ b/internal/vowifi/ims/rtp_media.go @@ -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) +} diff --git a/internal/vowifi/ims/rtp_media_test.go b/internal/vowifi/ims/rtp_media_test.go new file mode 100644 index 0000000..98995a8 --- /dev/null +++ b/internal/vowifi/ims/rtp_media_test.go @@ -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") + } +} diff --git a/internal/vowifi/integration/store.go b/internal/vowifi/integration/store.go index 3626799..9e2f1f7 100644 --- a/internal/vowifi/integration/store.go +++ b/internal/vowifi/integration/store.go @@ -106,6 +106,8 @@ func (projector StateProjector) Save( } runtime := store.VoWiFiRuntime{ DeviceID: state.DeviceID, + ICCID: strings.TrimSpace(state.ICCID), + IMSI: strings.TrimSpace(state.IMSI), Phase: string(state.Phase), DataplaneMode: dataplaneMode(state), SIMReady: state.SIMReady, @@ -125,8 +127,15 @@ func (projector StateProjector) Save( } if projector.Devices != nil { if entry, err := projector.Devices.Get(state.DeviceID); err == nil && entry.Snapshot != nil { - runtime.ICCID = strings.TrimSpace(entry.Snapshot.ICCID) - runtime.IMSI = strings.TrimSpace(entry.Snapshot.IMSI) + // An active VoWiFi session belongs to the identity captured when it + // was established. Do not relabel its phone number with a newly + // selected eSIM profile while teardown is still in progress. + if runtime.ICCID == "" { + runtime.ICCID = strings.TrimSpace(entry.Snapshot.ICCID) + } + if runtime.IMSI == "" { + runtime.IMSI = strings.TrimSpace(entry.Snapshot.IMSI) + } } } if runtime.LocalPhone == "" && runtime.ICCID != "" { diff --git a/internal/vowifi/integration/store_test.go b/internal/vowifi/integration/store_test.go index 26a18de..17fa892 100644 --- a/internal/vowifi/integration/store_test.go +++ b/internal/vowifi/integration/store_test.go @@ -186,6 +186,41 @@ func TestStateProjectorPreservesConcreteDataplaneMode(t *testing.T) { } } +func TestStateProjectorDoesNotAttachOldSessionNumberToNewLiveSIM(t *testing.T) { + database := testStore(t) + if err := database.UpsertDevice(context.Background(), store.Device{ID: "ec20", Name: "EC20"}); err != nil { + t.Fatal(err) + } + projector := StateProjector{ + Store: database, + Devices: staticDeviceReader{ + iccid: "89104100000028106378", + imsi: "310380500712483", + }, + } + if err := projector.Save(context.Background(), vowifi.State{ + DeviceID: "ec20", + ICCID: "89441000400128014257", + IMSI: "234159608751160", + Phase: vowifi.PhaseStopping, + PhoneNumber: "+447386083638", + PhoneNumberSource: vowifi.PhoneSourcePAssociatedURI, + UpdatedAt: time.Now().UTC(), + }); err != nil { + t.Fatal(err) + } + runtime, err := database.VoWiFiRuntime(context.Background(), "ec20") + if err != nil { + t.Fatal(err) + } + if runtime.ICCID != "89441000400128014257" || runtime.IMSI != "234159608751160" { + t.Fatalf("runtime identity = %q/%q", runtime.ICCID, runtime.IMSI) + } + if runtime.LocalPhone != "+447386083638" { + t.Fatalf("runtime phone = %q", runtime.LocalPhone) + } +} + type staticDeviceReader struct { iccid string imsi string diff --git a/internal/vowifi/orchestrator.go b/internal/vowifi/orchestrator.go index 10814f5..2ab939d 100644 --- a/internal/vowifi/orchestrator.go +++ b/internal/vowifi/orchestrator.go @@ -204,6 +204,8 @@ func (orchestrator *Orchestrator) Enable(ctx context.Context) (State, error) { } orchestrator.mutate(func(state *State) { state.Phase = PhaseSIMReady + state.ICCID = strings.TrimSpace(identity.ICCID) + state.IMSI = strings.TrimSpace(identity.IMSI) state.SIMReady = true state.HomeMCC = strings.TrimSpace(identity.HomeMCC) state.HomeMNC = strings.TrimSpace(identity.HomeMNC) @@ -404,6 +406,14 @@ func (orchestrator *Orchestrator) Disable(ctx context.Context) (State, error) { orchestrator.mutate(func(state *State) { state.Phase = PhaseStopping state.Enabled = false + // Stop advertising readiness as soon as disable is accepted. Network + // cleanup is best-effort and can take several seconds, but callers must + // not continue to present the old IMS registration as usable. + state.Active = false + state.TunnelReady = false + state.IMSReady = false + state.SMSReady = false + state.IMSRegistration = "" state.LastReason = "disable_requested" }) if resources != nil && resources.cancel != nil { @@ -546,6 +556,21 @@ func (orchestrator *Orchestrator) HangupCall(ctx context.Context, id string) err return err } +func (orchestrator *Orchestrator) CallMedia(ctx context.Context, id string) (CallMedia, error) { + orchestrator.mu.Lock() + resources := orchestrator.resources + ready := orchestrator.state.IMSReady + orchestrator.mu.Unlock() + if resources == nil || resources.ims == nil || !ready { + return nil, ErrNotRunning + } + controller, ok := resources.ims.(CallMediaController) + if !ok { + return nil, ErrNotRunning + } + return controller.CallMedia(ctx, id) +} + func (orchestrator *Orchestrator) callAction( ctx context.Context, action func(CallController) (Call, error), @@ -684,7 +709,17 @@ func (orchestrator *Orchestrator) cleanup(resources *runtimeResources) []string func (orchestrator *Orchestrator) cleanupCall(call func(context.Context) error) error { ctx, cancel := context.WithTimeout(context.Background(), orchestrator.options.CleanupTimeout) defer cancel() - return call(ctx) + done := make(chan error, 1) + go func() { done <- call(ctx) }() + select { + case err := <-done: + return err + case <-ctx.Done(): + // Providers receive the same deadline and should normally return on it. + // The outer select is a final containment boundary: a defective network + // close must never prevent the following tunnel/radio cleanup. + return ctx.Err() + } } func (orchestrator *Orchestrator) cancelCurrentRuntime() { diff --git a/internal/vowifi/orchestrator_test.go b/internal/vowifi/orchestrator_test.go index 6a41c69..889303b 100644 --- a/internal/vowifi/orchestrator_test.go +++ b/internal/vowifi/orchestrator_test.go @@ -18,6 +18,23 @@ func TestClassifyErrorEAPAuthenticationRejected(t *testing.T) { } } +func TestCleanupCallContainsProviderThatIgnoresContext(t *testing.T) { + orchestrator := &Orchestrator{options: Options{CleanupTimeout: 20 * time.Millisecond}} + release := make(chan struct{}) + defer close(release) + started := time.Now() + err := orchestrator.cleanupCall(func(context.Context) error { + <-release + return nil + }) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("cleanupCall() error = %v", err) + } + if elapsed := time.Since(started); elapsed > 250*time.Millisecond { + t.Fatalf("cleanupCall() took %v", elapsed) + } +} + type fakeEnvironment struct { mu sync.Mutex diff --git a/internal/vowifi/runtime/manager.go b/internal/vowifi/runtime/manager.go index 6989a5a..38878fa 100644 --- a/internal/vowifi/runtime/manager.go +++ b/internal/vowifi/runtime/manager.go @@ -320,6 +320,19 @@ func (manager *Manager) HangupCall(ctx context.Context, deviceID, id string) err return item.orchestrator.HangupCall(ctx, id) } +func (manager *Manager) CallMedia(ctx context.Context, deviceID, id string) (vowifi.CallMedia, error) { + if err := manager.Ensure(ctx, deviceID); err != nil { + return nil, err + } + manager.mu.Lock() + item := manager.entries[deviceID] + manager.mu.Unlock() + if item == nil { + return nil, ErrNotRegistered + } + return item.orchestrator.CallMedia(ctx, id) +} + func (manager *Manager) startOperation( deviceID string, coalesceReconnect bool, diff --git a/internal/vowifi/types.go b/internal/vowifi/types.go index 3e69376..52a1ab4 100644 --- a/internal/vowifi/types.go +++ b/internal/vowifi/types.go @@ -83,6 +83,8 @@ type SecurityAudit struct { // exists. Neither is proof of IMS registration. type State struct { DeviceID string `json:"device_id"` + ICCID string `json:"iccid,omitempty"` + IMSI string `json:"imsi,omitempty"` Phase Phase `json:"phase"` Enabled bool `json:"enabled"` Active bool `json:"active"` @@ -365,21 +367,23 @@ type SMSSender interface { SendSMS(context.Context, SMSSubmitRequest) (SMSSubmitResult, error) } -// Call describes one signalling-only IMS call. VoCat intentionally does not -// open, capture, or relay an RTP media stream for extension call tests. +// Call describes one IMS call and reports whether an RTP media stream is +// available to an authenticated extension. type Call struct { - ID string `json:"id"` - Number string `json:"number"` - Direction string `json:"direction"` - State string `json:"state"` - StartedAt time.Time `json:"started_at"` - SIPCode int `json:"sip_code,omitempty"` - Reason string `json:"reason,omitempty"` - EndedAt *time.Time `json:"ended_at,omitempty"` + ID string `json:"id"` + Number string `json:"number"` + Direction string `json:"direction"` + State string `json:"state"` + StartedAt time.Time `json:"started_at"` + SIPCode int `json:"sip_code,omitempty"` + Reason string `json:"reason,omitempty"` + MediaReady bool `json:"media_ready,omitempty"` + Codec string `json:"codec,omitempty"` + EndedAt *time.Time `json:"ended_at,omitempty"` } -// CallController is an optional capability of an IMS session. Implementations -// manage SIP signalling only; audio handling is explicitly outside this API. +// CallController is an optional capability of an IMS session. Media remains a +// separate optional interface so call signalling does not depend on a codec. type CallController interface { Calls() []Call DialCall(context.Context, string) (Call, error) @@ -387,6 +391,20 @@ type CallController interface { HangupCall(context.Context, string) error } +// CallMedia is a narrow, codec-independent bridge between an IMS RTP stream +// and a trusted local extension. Samples are signed 16-bit mono PCM at 8 kHz. +type CallMedia interface { + Codec() string + ReadPCM(context.Context) ([]int16, error) + WritePCM([]int16) error +} + +// CallMediaController is optional so signalling-only IMS implementations stay +// compatible. Media is only exposed for a specific active call. +type CallMediaController interface { + CallMedia(context.Context, string) (CallMedia, error) +} + // PhoneStore persists a number only after it was explicitly associated by IMS. type PhoneStore interface { SaveAssociatedNumber(context.Context, PhoneRecord) error diff --git a/scripts/install.sh b/scripts/install.sh index 31d49f5..25d6cc4 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -234,7 +234,7 @@ ProtectControlGroups=true # The web/CLI self-updater verifies a release in this directory and atomically # renames it over the running binary. Keep the rest of the host read-only. ReadWritePaths=/opt/vocat/data /opt/vocat/bin -RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK AF_PACKET RestrictRealtime=true LockPersonality=true MemoryDenyWriteExecute=true diff --git a/scripts/update-carriers.py b/scripts/update-carriers.py new file mode 100644 index 0000000..33ca713 --- /dev/null +++ b/scripts/update-carriers.py @@ -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() diff --git a/web/public/410.png b/web/public/410.png new file mode 100644 index 0000000..b171bab Binary files /dev/null and b/web/public/410.png differ diff --git a/web/public/dj.png b/web/public/dj.png new file mode 100644 index 0000000..f57d6ce Binary files /dev/null and b/web/public/dj.png differ diff --git a/web/src/App.tsx b/web/src/App.tsx index a51910c..b6d0ea0 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -12,6 +12,7 @@ import LoginPage from "./pages/LoginPage"; import DashboardPage from "./pages/DashboardPage"; import DevicesPage from "./pages/DevicesPage"; import ProxyPage from "./pages/ProxyPage"; +import ExportProxyPage from "./pages/ExportProxyPage"; import SmsPage from "./pages/SmsPage"; import LogsPage from "./pages/LogsPage"; import SettingsPage from "./pages/SettingsPage"; @@ -110,6 +111,7 @@ function AppRoot() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/components/DeviceCard.tsx b/web/src/components/DeviceCard.tsx index 491b75c..1242337 100644 --- a/web/src/components/DeviceCard.tsx +++ b/web/src/components/DeviceCard.tsx @@ -1,9 +1,10 @@ import { Cellular3GRegular, Cellular4GRegular, Cellular5GRegular, CellularData1Regular, - RouterRegular, Wifi1Regular, + Wifi1Regular, } from "@fluentui/react-icons"; import type { DashboardDevice } from "../types"; -import { cx, signalBars, signalColor, isEC20Model } from "../lib/utils"; +import { cx, signalBars, signalColor } from "../lib/utils"; +import { deviceTypeImage } from "../lib/deviceTypes"; import { StatusDot } from "./ui/StatusDot"; import { useI18n } from "../lib/i18n"; @@ -29,7 +30,6 @@ export function DeviceCard({ device, onOpen }: { device: DashboardDevice; onOpen const second = words.length > 1 ? words[1] : words[0] || ""; const isLte = second.toUpperCase() === "LTE"; const bars = signalBars(device.signalDbm); - const brandImg = isEC20Model(device.model); return ( - ) : ( - - )} + ) : device.developerEnabled ? ( +
+ {t("漫游数据")} + +
+ ) : null} diff --git a/web/src/components/devices/DeviceListItemCard.tsx b/web/src/components/devices/DeviceListItemCard.tsx index ac4525c..e0fea2a 100644 --- a/web/src/components/devices/DeviceListItemCard.tsx +++ b/web/src/components/devices/DeviceListItemCard.tsx @@ -2,6 +2,7 @@ import type { DeviceListItem } from "../../types"; import { cx } from "../../lib/utils"; import { Tag, StatusDot } from "../ui"; import { deviceStatusMeta } from "./shared"; +import { deviceTypeImage } from "../../lib/deviceTypes"; export interface DeviceListItemCardProps { device: DeviceListItem; @@ -25,7 +26,8 @@ export function DeviceListItemCard({ device, selected, statusText, onSelect }: D )} >
-
+ +
{device.name || device.id}
{device.id} · {device.interface || "--"} diff --git a/web/src/components/devices/DeviceOverviewTab.tsx b/web/src/components/devices/DeviceOverviewTab.tsx index 2e5f3cf..895bcb2 100644 --- a/web/src/components/devices/DeviceOverviewTab.tsx +++ b/web/src/components/devices/DeviceOverviewTab.tsx @@ -3,6 +3,7 @@ import { OverviewNetworkCard } from "./OverviewNetworkCard"; import { OverviewVowifiCard } from "./OverviewVowifiCard"; import { OverviewSimPanel } from "./OverviewSimPanel"; import { OverviewNetworkPanel } from "./OverviewNetworkPanel"; +import { OverviewTrafficChart } from "./OverviewTrafficChart"; import { OperatorSelectionDialog } from "./OperatorSelectionDialog"; import type { DeviceDetail } from "./types"; import { useI18n } from "../../lib/i18n"; @@ -24,28 +25,31 @@ export function DeviceOverviewTab(props: DeviceOverviewTabProps) { const [operatorOpen, setOperatorOpen] = useState(false); const { device } = props; return ( -
-
-
{t("运行状态")}
- {device?.vowifiEnabled ? ( - - ) : ( - setOperatorOpen(true)} /> - )} +
+
+
+
{t("运行状态")}
+ {device?.vowifiEnabled ? ( + + ) : ( + setOperatorOpen(true)} /> + )} +
+ +
- - + {device.developerEnabled && device.networkEnabled && device.id ? : null} {device?.id ? ( ) : null}
-
+
+ @@ -239,7 +262,7 @@ export function OperatorSelectionDialog({ open, deviceId, scanBlockedReason = ""
) : null} {candidates.length > 0 ? ( -
+
{candidates.map((c) => ( ))} diff --git a/web/src/components/devices/OverviewNetworkCard.tsx b/web/src/components/devices/OverviewNetworkCard.tsx index 01cc82b..5be425a 100644 --- a/web/src/components/devices/OverviewNetworkCard.tsx +++ b/web/src/components/devices/OverviewNetworkCard.tsx @@ -5,6 +5,7 @@ import { FieldRow } from "./FieldRow"; import { isDeviceOnline, isRegistered, isRecoveringPhase, lifecycleLabel, signalLevel, signalTone } from "./shared"; import type { DeviceDetail } from "./types"; import { useI18n } from "../../lib/i18n"; +import { flagEmoji } from "../../lib/carrier"; const BAR_HEIGHTS = ["h-[28%]", "h-[46%]", "h-[64%]", "h-[82%]", "h-full"]; const TEXT_TONE = { @@ -25,7 +26,9 @@ export function OverviewNetworkCard({ device, onOpenOperatorSelection }: { devic const modem = device.modem; const online = isDeviceOnline(device); const cellularRegistered = isRegistered(device); - const vowifiRegistered = !!(device.vowifiActive || device.vowifiRuntime?.smsReady); + // A persisted runtime may briefly describe the old session while disable is + // being cleaned up. Desired policy is authoritative for the overview badge. + const vowifiRegistered = !!device.vowifiEnabled && !!(device.vowifiActive || device.vowifiRuntime?.smsReady); const registered = cellularRegistered || vowifiRegistered; const radioOffForVowifi = vowifiRegistered && (modem?.operatingMode === 0 || modem?.operatingMode === 4 || device.flightMode); const tone = isRecoveringPhase(device.lifecyclePhase) ? "warning" : online ? (registered ? "success" : "warning") : "danger"; @@ -45,7 +48,16 @@ export function OverviewNetworkCard({ device, onOpenOperatorSelection }: { devic const level = signalLevel(modem?.signalDbm); const sigTone = signalTone(modem?.signalDbm); - const netMode = [modem?.networkDuplex, modem?.networkMode].filter(Boolean).join(" "); + const netMode = [modem?.networkDuplex, modem?.networkMode].filter(Boolean).join(" "); + const cellularRegistrationText = modem?.regStatus === 5 + ? t("已驻网(漫游)") + : modem?.regStatus === 1 + ? t("已驻网") + : device.registrationStateLabel === "searching" + ? t("正在搜索网络") + : device.registrationStateLabel === "denied" + ? t("驻网被拒") + : t("未驻网"); return ( <> @@ -71,7 +83,7 @@ export function OverviewNetworkCard({ device, onOpenOperatorSelection }: { devic <>{t("WiFi Calling 已注册")} ) : registered ? ( <> - {modem?.operator || "--"}{" "} + {modem?.operatorCountryCode ? `${flagEmoji(modem.operatorCountryCode)} ` : ""}{modem?.operator || "--"}{" "} {modem?.networkMode ? · {netMode} : null} ) : ( @@ -116,7 +128,7 @@ export function OverviewNetworkCard({ device, onOpenOperatorSelection }: { devic - +
); diff --git a/web/src/components/devices/OverviewNetworkPanel.tsx b/web/src/components/devices/OverviewNetworkPanel.tsx index 3056e04..cb2625a 100644 --- a/web/src/components/devices/OverviewNetworkPanel.tsx +++ b/web/src/components/devices/OverviewNetworkPanel.tsx @@ -1,6 +1,19 @@ +import { useEffect, useState } from "react"; import { FieldRow } from "./FieldRow"; import type { DeviceDetail } from "./types"; import { useI18n } from "../../lib/i18n"; +import { api, apiMessage } from "../../api"; +import { Button, message } from "../ui"; +import { flagEmoji } from "../../lib/carrier"; + +interface PublicIPInfo { + detected?: boolean; + ip: string; + countryCode: string; + region?: string; + city?: string; + organization?: string; +} export interface OverviewNetworkPanelProps { device: DeviceDetail; @@ -11,32 +24,90 @@ export interface OverviewNetworkPanelProps { } export function OverviewNetworkPanel({ device, trafficMinuteRx, trafficMinuteTx, trafficSpeedRx, trafficSpeedTx }: OverviewNetworkPanelProps) { - const { t } = useI18n(); + const { t, lang } = useI18n(); + const developerActive = !!device.developerEnabled; + const [publicIP, setPublicIP] = useState(null); + const [detectingIP, setDetectingIP] = useState(false); const traffic = device.traffic || {}; const metaStatus = device.trafficMeta?.status; const sampleNote = metaStatus === "waiting_sample" ? t("等待采样") : metaStatus === "stale" ? t("采样中断") : ""; - const off = t("数据未开启"); + const off = !device.networkEnabled; const minuteRx = trafficMinuteRx || sampleNote || traffic.rx; const minuteTx = trafficMinuteTx || sampleNote || traffic.tx; const speedRx = trafficSpeedRx || sampleNote || traffic.rate || "--"; - const speedTx = trafficSpeedTx || sampleNote || "--"; + const speedTx = trafficSpeedTx || sampleNote || traffic.rateTx || "--"; + + useEffect(() => { + let cancelled = false; + setPublicIP(null); + if (!developerActive) return () => { cancelled = true; }; + api(`/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(`/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 ( +
+
{t("网络")}
+
+ ); + } return (
{t("网络")}
- {off ? ( -
{off}
- ) : ( -
+
+
+ {t("公网 IP")} +
+ {publicIP?.ip || "-"} + +
+
+ + {off ? ( +
{t("数据未开启")}
+ ) : ( + <> - -
- )} + + )} +
); } diff --git a/web/src/components/devices/OverviewTrafficChart.tsx b/web/src/components/devices/OverviewTrafficChart.tsx new file mode 100644 index 0000000..540c3ce --- /dev/null +++ b/web/src/components/devices/OverviewTrafficChart.tsx @@ -0,0 +1,176 @@ +import { useEffect, useMemo, useState } from "react"; +import { api, apiMessage } from "../../api"; +import { EChart } from "../EChart"; +import { useI18n } from "../../lib/i18n"; +import { formatBytes } from "../../lib/utils"; + +interface TrafficBucket { + periodStart: string; + rxBytes: number; + txBytes: number; +} + +interface TrafficAnalysis { + status?: string; + range?: string; + buckets?: TrafficBucket[]; +} + +interface DailyTraffic { + key: string; + label: string; + rxBytes: number; + txBytes: number; +} + +function localDayKey(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +function lastSevenDays(buckets: TrafficBucket[]): DailyTraffic[] { + const byDay = new Map(); + for (const bucket of buckets) { + const date = new Date(bucket.periodStart); + if (Number.isNaN(date.getTime())) continue; + const key = localDayKey(date); + const current = byDay.get(key); + byDay.set(key, { + periodStart: bucket.periodStart, + rxBytes: (current?.rxBytes || 0) + (Number(bucket.rxBytes) || 0), + txBytes: (current?.txBytes || 0) + (Number(bucket.txBytes) || 0), + }); + } + + const result: DailyTraffic[] = []; + const today = new Date(); + today.setHours(0, 0, 0, 0); + for (let offset = 6; offset >= 0; offset -= 1) { + const date = new Date(today); + date.setDate(today.getDate() - offset); + const key = localDayKey(date); + const value = byDay.get(key); + result.push({ + key, + label: date.toLocaleDateString(undefined, { month: "2-digit", day: "2-digit" }), + rxBytes: value?.rxBytes || 0, + txBytes: value?.txBytes || 0, + }); + } + return result; +} + +export function OverviewTrafficChart({ deviceId }: { deviceId: string }) { + const { t } = useI18n(); + const [buckets, setBuckets] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + useEffect(() => { + let cancelled = false; + const load = async (initial = false) => { + if (initial) setLoading(true); + try { + const result = await api(`/traffic/analysis?range=week&device_id=${encodeURIComponent(deviceId)}`); + if (!cancelled) { + setBuckets(result.buckets || []); + setError(""); + } + } catch (err) { + if (!cancelled) setError(apiMessage(err) || t("流量分析加载失败")); + } finally { + if (!cancelled && initial) setLoading(false); + } + }; + void load(true); + const timer = window.setInterval(() => void load(), 30_000); + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [deviceId, t]); + + const days = useMemo(() => lastSevenDays(buckets), [buckets]); + const totals = useMemo(() => days.reduce( + (value, day) => ({ rx: value.rx + day.rxBytes, tx: value.tx + day.txBytes }), + { rx: 0, tx: 0 }, + ), [days]); + + const option = useMemo(() => ({ + animationDuration: 350, + color: ["#0ea5e9", "#8b5cf6"], + tooltip: { + trigger: "axis", + formatter: (items: Array<{ marker?: string; seriesName?: string; value?: number; axisValueLabel?: string }>) => { + const title = items[0]?.axisValueLabel || ""; + return [title, ...items.map((item) => `${item.marker || ""}${item.seriesName || ""}: ${formatBytes(Number(item.value) || 0)}`)].join("
"); + }, + }, + legend: { + top: 0, + right: 0, + textStyle: { color: "#64748b" }, + data: [t("下载"), t("上传")], + }, + grid: { top: 42, right: 18, bottom: 24, left: 62 }, + xAxis: { + type: "category", + boundaryGap: false, + data: days.map((day) => day.label), + axisLine: { lineStyle: { color: "#cbd5e1" } }, + axisTick: { show: false }, + axisLabel: { color: "#64748b" }, + }, + yAxis: { + type: "value", + min: 0, + axisLabel: { color: "#64748b", formatter: (value: number) => formatBytes(value) }, + splitLine: { lineStyle: { color: "rgba(148, 163, 184, 0.18)" } }, + }, + series: [ + { + name: t("下载"), + type: "line", + smooth: true, + showSymbol: true, + symbolSize: 6, + data: days.map((day) => day.rxBytes), + lineStyle: { width: 3 }, + areaStyle: { opacity: 0.1 }, + }, + { + name: t("上传"), + type: "line", + smooth: true, + showSymbol: true, + symbolSize: 6, + data: days.map((day) => day.txBytes), + lineStyle: { width: 3 }, + areaStyle: { opacity: 0.08 }, + }, + ], + }), [days, t]); + + return ( +
+
+
+
{t("最近 7 天流量")}
+
{t("按天统计蜂窝数据上传与下载;从启用采样后开始累计")}
+
+
+
{t("下载")}{formatBytes(totals.rx)}
+
{t("上传")}{formatBytes(totals.tx)}
+
+
+ {loading ? ( +
{t("流量图表加载中...")}
+ ) : ( + + )} + {error ?
{error}
: null} +
+ ); +} diff --git a/web/src/components/devices/shared.ts b/web/src/components/devices/shared.ts index f43bbcc..acb4639 100644 --- a/web/src/components/devices/shared.ts +++ b/web/src/components/devices/shared.ts @@ -167,15 +167,18 @@ export function isQmiControl(controlDevice?: string): boolean { /* --------------------------------------------------------------------------- * Signal helpers (5-bar overview variant). + * Thresholds are RSSI-calibrated (AT+CSQ style dBm, range ~-113..-51), NOT + * RSRP — RSSI runs ~20 dB hotter than RSRP on LTE, so RSRP-scaled thresholds + * peg near-full for any real signal and the bars never move with strength. * ------------------------------------------------------------------------- */ export function signalValid(dbm?: number | null): boolean { return typeof dbm === "number" && Number.isFinite(dbm) && dbm !== 0 && dbm !== -999; } -// 0..5 bars +// 0..5 bars. RSSI dBm bands: ≥-70 excellent · -70..-85 good · -85..-100 fair · -100..-110 poor · <-110 edge export function signalLevel(dbm?: number | null): number { if (!signalValid(dbm)) return 0; const d = dbm as number; - return d >= -75 ? 5 : d >= -85 ? 4 : d >= -95 ? 3 : d >= -105 ? 2 : 1; + return d >= -70 ? 5 : d >= -85 ? 4 : d >= -100 ? 3 : d >= -110 ? 2 : 1; } export type SignalTone = "green" | "amber" | "red" | "gray"; export function signalTone(dbm?: number | null): SignalTone { diff --git a/web/src/components/devices/types.ts b/web/src/components/devices/types.ts index 1a0319c..f344101 100644 --- a/web/src/components/devices/types.ts +++ b/web/src/components/devices/types.ts @@ -1,4 +1,4 @@ -import type { DeviceOverview, ModemSummary } from "../../types"; +import type { DeviceOverview, DeviceType, ModemSummary } from "../../types"; // PNN record read from the modem (opl/pnn drive the SIM operator display). export interface ModemPnn { @@ -22,9 +22,9 @@ export interface DeviceModem extends ModemSummary { // Device detail (`/devices/:id/overview` -> devices[0]) with the extra fields // the reference page reads. All camelCase (api auto-converts). export interface DeviceDetail extends Omit { + developerEnabled?: boolean; modem: DeviceModem; localPhone?: string; - privateIpv6?: string; publicIpv6?: string; e911SetupAvailable?: boolean; activeEsimProfileName?: string; @@ -36,6 +36,7 @@ export interface DeviceDetail extends Omit export interface AddDeviceForm { id: string; name: string; + deviceType: DeviceType | ""; interface: string; modemImei: string; usbPath: string; @@ -57,6 +58,7 @@ export interface OperatorCandidate { plmn?: string; operatorName?: string; shortName?: string; + countryCode?: string; status?: string; rats?: Array; includesPcsDigit?: boolean; diff --git a/web/src/components/settings/DeviceQuotaCard.tsx b/web/src/components/settings/DeviceQuotaCard.tsx new file mode 100644 index 0000000..4e5e10e --- /dev/null +++ b/web/src/components/settings/DeviceQuotaCard.tsx @@ -0,0 +1,58 @@ +import { SettingsRegular } from "@fluentui/react-icons"; +import type { DeveloperSettings } from "../../types"; +import { useI18n } from "../../lib/i18n"; +import { Button } from "../ui/Button"; +import { Input } from "../ui/Input"; +import { CardDecor, CardIcon, CardTitle } from "./Cards"; + +export function DeviceQuotaCard({ + value, + limit, + loading, + saving, + onLimitChange, + onSave, +}: { + value: DeveloperSettings | null; + limit: number; + loading: boolean; + saving: boolean; + onLimitChange: (limit: number) => void; + onSave: () => void; +}) { + const { lang } = useI18n(); + const zh = lang === "zh"; + return ( +
+ +
+ + + + +
+
+ onLimitChange(Number(event.target.value))} + suffix={zh ? "台" : "devices"} + /> +

+ {zh + ? `关闭开发者模式后会自动恢复为 ${value?.defaultDeviceLimit ?? 5} 台,不会删除已经添加的设备。` + : `Disabling developer mode restores ${value?.defaultDeviceLimit ?? 5}; existing devices are not deleted.`} +

+ +
+
+ ); +} diff --git a/web/src/components/settings/HTTPSCard.tsx b/web/src/components/settings/HTTPSCard.tsx new file mode 100644 index 0000000..9df0fac --- /dev/null +++ b/web/src/components/settings/HTTPSCard.tsx @@ -0,0 +1,60 @@ +import { LockClosedRegular } from "@fluentui/react-icons"; +import type { HTTPSSettings } from "../../types"; +import { useI18n } from "../../lib/i18n"; +import { Button } from "../ui/Button"; +import { Switch } from "../ui/Switch"; +import { CardDecor, CardIcon, CardTitle } from "./Cards"; + +export function HTTPSCard({ + value, + loading, + saving, + onToggle, +}: { + value: HTTPSSettings | null; + loading: boolean; + saving: boolean; + onToggle: (enabled: boolean) => void; +}) { + const { lang } = useI18n(); + const zh = lang === "zh"; + const enabled = !!value?.enabled; + return ( +
+ +
+
+ + + + +
+ +
+
+

+ {enabled + ? (zh ? "已强制 HTTPS;HTTP 请求会自动跳转。关闭后立即恢复 HTTP。" : "HTTPS is enforced and HTTP redirects automatically. Disable it to return to HTTP immediately.") + : (zh ? "当前使用 HTTP。启用后会生成并持久保存本机自签证书。" : "HTTP is active. Enabling generates and persists a local self-signed certificate.")} +

+ {value?.fingerprint ? ( +
+
SHA-256
+
{value.fingerprint}
+
+ ) : null} +

+ {zh + ? "自签证书需要在系统或浏览器中信任;否则浏览器可能继续拒绝麦克风权限。" + : "Trust the self-signed certificate in the operating system or browser; otherwise microphone access may still be rejected."} +

+ +
+
+ ); +} diff --git a/web/src/components/shell/AuthenticatedShell.tsx b/web/src/components/shell/AuthenticatedShell.tsx index 519df07..f9cb34a 100644 --- a/web/src/components/shell/AuthenticatedShell.tsx +++ b/web/src/components/shell/AuthenticatedShell.tsx @@ -22,6 +22,8 @@ import { cx } from "../../lib/utils"; import { BrandLogo } from "./BrandLogo"; import { VersionBadge } from "./VersionBadge"; import { listPlugins, type InstalledPlugin } from "../../extensions"; +import { api } from "../../api"; +import type { SystemInfo } from "../../types"; const NAV = [ { to: "/", label: "仪表盘", icon: BoardRegular, end: true }, @@ -43,11 +45,24 @@ export function AuthenticatedShell({ const [isMobile, setIsMobile] = useState(false); const [mobileOpen, setMobileOpen] = useState(false); const [plugins, setPlugins] = useState([]); + const [developer, setDeveloper] = useState(false); const { logout, user } = useAuth(); const { t, lang } = useI18n(); const navigate = useNavigate(); const location = useLocation(); + useEffect(() => { + let active = true; + const load = () => api("/system/info").then((info) => { + if (active) setDeveloper(!!info.developer); + }).catch(() => { + if (active) setDeveloper(false); + }); + void load(); + const timer = window.setInterval(load, 10_000); + return () => { active = false; window.clearInterval(timer); }; + }, []); + useEffect(() => { const mq = window.matchMedia("(max-width: 767px)"); const update = () => { @@ -99,15 +114,17 @@ export function AuthenticatedShell({ const navItems: Array<(typeof NAV)[number] | { to: string; label: string; icon: typeof GlobeRegular; pluginLabelZH?: string }> = []; for (const item of NAV) { navItems.push(item); - if (item.to === "/sms") { - for (const extension of sidebarPlugins.filter((entry) => !entry.contribution.after || entry.contribution.after === "sms")) { - navItems.push({ - to: `/extensions/${encodeURIComponent(extension.plugin.id)}/${encodeURIComponent(extension.contribution.id)}`, - label: extension.contribution.label, - pluginLabelZH: extension.contribution.labelZh, - icon: GlobeRegular, - }); - } + if (developer && item.to === "/proxy") { + navItems.push({ to: "/export-proxy", label: "导出代理", icon: GlobeRegular }); + } + const itemKey = item.to.replace(/^\//, "") || "dashboard"; + for (const extension of sidebarPlugins.filter((entry) => (entry.contribution.after || "sms") === itemKey)) { + navItems.push({ + to: `/extensions/${encodeURIComponent(extension.plugin.id)}/${encodeURIComponent(extension.contribution.id)}`, + label: extension.contribution.label, + pluginLabelZH: extension.contribution.labelZh, + icon: GlobeRegular, + }); } } return ( diff --git a/web/src/components/ui/Modal.tsx b/web/src/components/ui/Modal.tsx index d219043..1fa0671 100644 --- a/web/src/components/ui/Modal.tsx +++ b/web/src/components/ui/Modal.tsx @@ -52,13 +52,13 @@ export function Modal({ role="dialog" aria-modal="true" className={cx( - "glass-modal relative w-full rounded-2xl shadow-2xl animate-[fade-slide-in_0.25s_cubic-bezier(0.4,0,0.2,1)]", + "glass-modal relative flex max-h-[calc(100dvh-2rem)] w-full flex-col rounded-2xl shadow-2xl animate-[fade-slide-in_0.25s_cubic-bezier(0.4,0,0.2,1)]", width, className, )} > {(title || showClose) && ( -
+
{title}
{showClose && (
); diff --git a/web/src/lib/carrier.ts b/web/src/lib/carrier.ts index 43aca06..51cfbcc 100644 --- a/web/src/lib/carrier.ts +++ b/web/src/lib/carrier.ts @@ -1,6 +1,7 @@ -// MCC/MNC → carrier lookup. Data is the musalbas/mcc-mnc-table dataset (the same -// one vohive uses), slimmed to { c: plmn→[name,iso], i: mcc→iso, t: 3-digit-MNC MCCs }. -// Source: https://raw.githubusercontent.com/musalbas/mcc-mnc-table/master/mcc-mnc-table.json +// MCC/MNC → carrier lookup. The offline table combines Android's maintained +// carrier ID database with the legacy global table as a fallback. Refresh it +// with scripts/update-carriers.py; runtime registration never depends on an +// external lookup service. import table from "./mccmnc.json"; interface MccMncTable { diff --git a/web/src/lib/deviceTypes.ts b/web/src/lib/deviceTypes.ts new file mode 100644 index 0000000..f179a63 --- /dev/null +++ b/web/src/lib/deviceTypes.ts @@ -0,0 +1,18 @@ +import type { DeviceType } from "../types"; + +export const DEFAULT_DEVICE_TYPE: DeviceType = "pcie_ec20_ec25"; + +export const DEVICE_TYPES: ReadonlyArray<{ value: DeviceType; label: string; image: string }> = [ + { value: "wifi_410", label: "410 WiFi 棒(高通芯片)", image: "/410.png" }, + { value: "dji_4g", label: "大疆 4G 模块(移远芯片)", image: "/dj.png" }, + { value: "pcie_ec20_ec25", label: "PCIe EC20/EC25(移远芯片)", image: "/ec20.png" }, +]; + +export function normalizeDeviceType(value?: string | null): DeviceType { + return DEVICE_TYPES.some((item) => item.value === value) ? (value as DeviceType) : DEFAULT_DEVICE_TYPE; +} + +export function deviceTypeImage(value?: string | null): string { + const normalized = normalizeDeviceType(value); + return DEVICE_TYPES.find((item) => item.value === normalized)?.image || "/ec20.png"; +} diff --git a/web/src/lib/i18n-en.ts b/web/src/lib/i18n-en.ts index 77456bc..33d9a21 100644 --- a/web/src/lib/i18n-en.ts +++ b/web/src/lib/i18n-en.ts @@ -433,6 +433,8 @@ export const EN_DICT: Record = { 本周: "This Week", 本月: "This Month", 流量分析: "Traffic Analysis", + "最近 7 天流量": "Traffic in the Last 7 Days", + "按天统计蜂窝数据上传与下载;从启用采样后开始累计": "Daily cellular upload and download; totals start when metering is enabled", 本周期: "This Period", 当前设备: "Current Device", 流量: "Traffic", @@ -509,7 +511,6 @@ export const EN_DICT: Record = { "全部状态": "All Statuses", "关闭": "Close", "内网 IPv4": "LAN IPv4", - "内网 IPv6": "LAN IPv6", "切换到中文": "切换到中文", "切换浅色模式": "Switch to light mode", "切换深色模式": "Switch to dark mode", @@ -746,6 +747,45 @@ export const EN_DICT: Record = { 删除设备: "Delete Device", 保存配置: "Save Config", "切换 IP": "Rotate IP", + "漫游数据": "Roaming Data", + "公网 IP": "Public IP", + "检测": "Detect", + "国家/地区": "Country / Region", + "公网 IP 检测失败": "Public IP detection failed", + "导出代理": "Export Proxy", + "将模块漫游数据导出为主机 HTTP 或 SOCKS5 代理;仅在开发者模式下可用": "Export modem roaming data as host HTTP or SOCKS5 proxies; available only in developer mode", + "添加代理": "Add Proxy", + "网络接口": "Network Interface", + "协议": "Protocol", + "认证": "Authentication", + "错误": "Error", + "已停用": "Stopped", + "暂无导出代理配置": "No export proxies configured", + "先在设备页面开启漫游数据,再创建代理": "Enable roaming data on the device page before creating a proxy", + "代理出口使用受保护的蜂窝路由和独立 DNS,不会把模块数据设为主机默认网络。关闭开发者模式会停止漫游数据并永久删除这里的全部配置。": "Proxy traffic uses protected cellular routing and isolated DNS without becoming the host default network. Disabling developer mode stops roaming data and permanently deletes every configuration here.", + "编辑导出代理": "Edit Export Proxy", + "添加导出代理": "Add Export Proxy", + "例如:EC20 漫游出口": "For example: EC20 roaming exit", + "代理认证": "Proxy Authentication", + "保存后立即启用": "Enable after saving", + "启用认证后必须填写用户名": "A username is required when authentication is enabled", + "留空则保留原密码": "Leave empty to keep the current password", + "请输入有效端口": "Enter a valid port", + "导出代理已更新": "Export proxy updated", + "导出代理已创建": "Export proxy created", + "导出代理已删除": "Export proxy deleted", + "确定删除这个导出代理配置吗?": "Delete this export proxy configuration?", + "漫游数据已开启,仅供 Export Proxy 使用": "Roaming data enabled for Export Proxy only", + "漫游数据已关闭": "Roaming data disabled", + "开启漫游数据失败": "Failed to enable roaming data", + "关闭漫游数据失败": "Failed to disable roaming data", + "蜂窝数据仅进入 Export Proxy 的受保护路由,不会成为主机默认出口": "Cellular data is routed only to Export Proxy and never becomes the host default route", + "重新驻网": "Re-register", + "正在按当前选网配置重新驻网,请稍候...": "Re-registering with the current network selection...", + "已重新发起驻网": "Network re-registration started", + "重新驻网失败": "Network re-registration failed", + "已驻网": "Registered", + "已驻网(漫游)": "Registered (roaming)", 短信: "SMS", 多轮会话中: "Session Active", 取消会话: "Cancel Session", @@ -783,6 +823,13 @@ export const EN_DICT: Record = { "重启模组并自动复检": "Reboot modem & re-check", "重新检测": "Re-detect", + // ---- Device type ---- + "设备类型": "Device Type", + "请选择设备类型": "Select a device type", + "410 WiFi 棒(高通芯片)": "410 WiFi Dongle (Qualcomm)", + "大疆 4G 模块(移远芯片)": "DJI 4G Module (Quectel)", + "PCIe EC20/EC25(移远芯片)": "PCIe EC20/EC25 (Quectel)", + // ---- AT 快捷指令(按 group · item 分组翻译) ---- 基础: "Basics", 网络控制: "Network Control", diff --git a/web/src/lib/mccmnc.json b/web/src/lib/mccmnc.json index 611bc05..f622126 100644 --- a/web/src/lib/mccmnc.json +++ b/web/src/lib/mccmnc.json @@ -1 +1 @@ -{"c":{"28988":["A-Mobile","ge"],"28968":["A-Mobile","ge"],"28967":["Aquafon","ge"],"41201":["AWCC","af"],"41250":["Etisalat","af"],"41230":["Etisalat","af"],"41280":["Mobifone","af"],"41288":["Mobifone","af"],"41240":["MTN","af"],"41220":["Roshan","af"],"41203":["WaselTelecom (WT)","af"],"27603":["ALBtelecom Mobile / Eagle","al"],"27601":["One / AMC","al"],"27604":["PLUS Communication Sh.a","al"],"27602":["Vodafone","al"],"60302":["Djezzy","dz"],"60301":["Mobilis","dz"],"60303":["Ooredoo","dz"],"544780":["ASTCA Mobile","as"],"54411":["BlueSky","as"],"21303":["Andorra Telecom / Mobiland","ad"],"63104":["MoviCel","ao"],"63102":["Unitel","ao"],"365850":["Digicel","ai"],"365840":["Flow","ai"],"34493":["Digicel","ag"],"344930":["Digicel","ag"],"34492":["Flow","ag"],"344920":["Flow","ag"],"34403":["imobile / APUA","ag"],"344030":["imobile / APUA","ag"],"722310":["Claro","ar"],"722330":["Claro","ar"],"722031":["Claro","ar"],"722320":["Claro","ar"],"722299":["Express","ar"],"722999":["Fix Line","ar"],"722010":["Movistar","ar"],"722007":["Movistar","ar"],"722070":["Movistar","ar"],"722020":["Nextel","ar"],"722034":["Personal","ar"],"722341":["Personal","ar"],"722340":["Personal","ar"],"28301":["Beeline","am"],"28304":["KT","am"],"28310":["Orange","am"],"28305":["Viva-MTS","am"],"36302":["Digicel","aw"],"36320":["Digicel","aw"],"363299":["MIO","aw"],"36301":["SETAR","aw"],"50514":["AAPT Ltd.","au"],"505299":["ACMA","au"],"50524":["Advanced Comm Tech Pty.","au"],"50509":["Airnet Commercial Australia Ltd..","au"],"50530":["Compatel","au"],"50504":["Department of Defense","au"],"505999":["Fix Line","au"],"50512":["H3G Ltd.","au"],"50506":["H3G Ltd.","au"],"50588":["Pivotel Group Ltd","au"],"50519":["Lycamobile","au"],"50535":["MessageBird","au"],"50510":["Norfolk Telecom","au"],"50508":["Railcorp/Vodafone","au"],"50599":["Railcorp/Vodafone","au"],"50590":["Optus","au"],"50550":["Pivotel","au"],"50513":["RailCorp","au"],"50526":["Sinch","au"],"50502":["Optus","au"],"50511":["Telstra","au"],"50572":["Telstra","au"],"50501":["Telstra","au"],"50539":["Telstra","au"],"50571":["Telstra","au"],"50505":["The Ozitel Network Pty.","au"],"50516":["VicTrack","au"],"50503":["Vodafone","au"],"50507":["Vodafone","au"],"23211":["A1 Telekom","at"],"23201":["A1 Telekom","at"],"23209":["A1 Telekom","at"],"23212":["A1 Telekom","at"],"23202":["A1 Telekom","at"],"232299":["ArgoNET","at"],"23215":["T-Mobile / Magenta","at"],"232999":["Fix Line","at"],"23225":["Holding Graz","at"],"23219":["Hutchinson Drei","at"],"23214":["Hutchinson Drei","at"],"23216":["Hutchinson Drei","at"],"23210":["Hutchinson Drei","at"],"23205":["Hutchinson Drei","at"],"23226":["LIWEST Mobil","at"],"23217":["Mass Response Service","at"],"23220":["Mtel","at"],"23291":["OBB Infrastruktur","at"],"23206":["Hutchison Drei / 3","at"],"23222":["Plintron","at"],"23224":["Smartel Services","at"],"23218":["smartspace","at"],"23207":["T-Mobile / Magenta","at"],"23204":["T-Mobile / Magenta","at"],"23203":["T-Mobile / Magenta","at"],"23213":["T-Mobile / Magenta","at"],"23223":["T-Mobile / Magenta","at"],"23208":["A1 Telekom","at"],"23227":["Tismi","at"],"40001":["Azercell","az"],"40002":["Bakcell","az"],"40003":["FONEX","az"],"40004":["Nar Mobile","az"],"40006":["Naxtel","az"],"364490":["Aliv","bs"],"364390":["Cybercell / BaTelCo","bs"],"36430":["Cybercell / BaTelCo","bs"],"36439":["Cybercell / BaTelCo","bs"],"36403":["Smart Communications","bs"],"42601":["Batelco","bh"],"426299":["Failed Calls","bh"],"426999":["Fix Line","bh"],"42605":["Royal Court","bh"],"42604":["VIVA","bh"],"42602":["Zain","bh"],"47007":["Airtel","bd"],"47002":["Airtel","bd"],"47003":["Banglalink","bd"],"47006":["Citycell","bd"],"47005":["Citycell","bd"],"47001":["GrameenPhone","bd"],"47004":["TeleTalk","bd"],"342810":["Cingular Wireless","bb"],"342750":["Digicel","bb"],"342050":["Digicel","bb"],"342299":["Failed Calls","bb"],"342600":["Flow / Lime","bb"],"342820":["Sunbeach","bb"],"25703":["BelCel JV","by"],"25704":["life:)","by"],"25701":["MDC/Velcom","by"],"25702":["MTS","by"],"20620":["Base","be"],"20605":["Base","be"],"20628":["BICS","be"],"20625":["Dense Air","be"],"20623":["Dust Mobile","be"],"20633":["Ericsson","be"],"206299":["FEBO","be"],"206999":["Fix Line","be"],"20602":["Infrabel","be"],"20699":["Lancelot","be"],"20606":["Lycamobile","be"],"20630":["Mobile Vikings","be"],"20610":["Mobistar / Orange","be"],"20634":["onoff","be"],"20601":["Proximus","be"],"20604":["Proximus","be"],"20600":["Proximus","be"],"20607":["Vectone Mobile","be"],"20608":["VOOmobile","be"],"70267":["DigiCell","bz"],"702299":["Failed Calls","bz"],"70268":["International Telco (INTELCO)","bz"],"702099":["Smart","bz"],"70269":["Smart","bz"],"61604":["Bell Benin/BBCOM","bj"],"61605":["GloMobile","bj"],"61601":["Libercom","bj"],"61602":["Moov","bj"],"61603":["MTN","bj"],"350000":["Bermuda Digital Communications Ltd (BDC)","bm"],"35099":["CellOne Ltd","bm"],"35001":["Digicel","bm"],"350299":["Failed Calls","bm"],"35002":["M3 Wireless Ltd","bm"],"40211":["B-Mobile","bt"],"40217":["Bhutan Telecom Ltd (BTL)","bt"],"40277":["TashiCell","bt"],"73602":["Entel Pcs","bo"],"73601":["Viva/Nuevatel","bo"],"73603":["Tigo","bo"],"362999":["Fix Line","bq"],"21890":["BH Mobile","ba"],"21803":["Eronet","ba"],"21805":["m:tel","ba"],"65204":["beMobile","bw"],"65201":["Mascom","bw"],"65202":["Orange","bw"],"72426":["AmericaNet","br"],"72412":["Claro/Albra/America Movil","br"],"72438":["Claro/Albra/America Movil","br"],"72405":["Claro/Albra/America Movil","br"],"72401":["Vivo S.A./Telemig","br"],"72434":["CTBC Celular SA (CTBC)","br"],"72433":["CTBC Celular SA (CTBC)","br"],"72432":["CTBC Celular SA (CTBC)","br"],"72408":["TIM","br"],"72439":["Nextel (Telet)","br"],"72400":["Nextel (Telet)","br"],"72416":["Brazil Telcom","br"],"72424":["Amazonia Celular S/A","br"],"72430":["Oi (TNL PCS / Oi)","br"],"72431":["Oi (TNL PCS / Oi)","br"],"72454":["PORTO SEGURO TELECOMUNICACOES","br"],"72415":["Sercontel Cel","br"],"72407":["CTBC/Triangulo","br"],"72419":["Vivo S.A./Telemig","br"],"72403":["TIM","br"],"72402":["TIM","br"],"72404":["TIM","br"],"72437":["Unicel do Brasil Telecomunicacoes Ltda","br"],"72410":["Vivo S.A./Telemig","br"],"72406":["Vivo S.A./Telemig","br"],"72423":["Vivo S.A./Telemig","br"],"72411":["Vivo S.A./Telemig","br"],"348570":["Caribbean Cellular","vg"],"348770":["Digicel","vg"],"348170":["LIME","vg"],"52802":["b-mobile","bn"],"52811":["Datastream (DTSCom)","bn"],"52801":["Telekom Brunei Bhd (TelBru)","bn"],"28401":["A1","bg"],"28406":["Vivacom","bg"],"28403":["Vivacom","bg"],"28411":["Bulsatcom","bg"],"28413":["T.com","bg"],"28405":["Telenor","bg"],"61302":["Orange","bf"],"61303":["Telecel","bf"],"61301":["Telmob","bf"],"64202":["Africel / Safaris","bi"],"642999":["Fix\tLine","bi"],"64282":["Leo","bi"],"64201":["Leo","bi"],"64208":["Lumitel","bi"],"64203":["ONAMOB","bi"],"64207":["Smart","bi"],"45604":["QB","kh"],"45601":["Cellcard","kh"],"456299":["CooTel","kh"],"45608":["Metfone","kh"],"45618":["Cellcard","kh"],"45603":["QB/Cambodia Adv. Comms.","kh"],"45611":["Seatel","kh"],"45606":["Smart","kh"],"45605":["Smart","kh"],"45602":["Smart","kh"],"45609":["Sotelco/Beeline","kh"],"62401":["MTN","cm"],"62404":["Nexttel","cm"],"62402":["Orange","cm"],"302652":["BC Tel Mobility","ca"],"302630":["Bell Aliant","ca"],"302610":["Bell Mobility","ca"],"302651":["Bell Mobility","ca"],"302670":["CityWest Mobility","ca"],"302361":["Clearnet","ca"],"302360":["Clearnet","ca"],"302380":["DMTS Mobility","ca"],"302710":["Globalstar Canada","ca"],"302640":["Latitude Wireless","ca"],"302370":["FIDO (Rogers AT&T/ Microcell)","ca"],"302320":["mobilicity","ca"],"302702":["MT&T Mobility","ca"],"302660":["MTS Mobility","ca"],"302655":["MTS Mobility","ca"],"302701":["NB Tel Mobility","ca"],"302703":["New Tel Mobility","ca"],"302760":["Public Mobile","ca"],"302657":["Quebectel Mobility","ca"],"302720":["Rogers AT&T Wireless","ca"],"302680":["Sask Tel Mobility","ca"],"302780":["Sask Tel Mobility","ca"],"302654":["Sask Tel Mobility","ca"],"302656":["Tbay Mobility","ca"],"302653":["Telus Mobility","ca"],"302220":["Telus Mobility","ca"],"302500":["Videotron","ca"],"302490":["WIND","ca"],"62501":["CVMovel","cv"],"62502":["Unitel T+","cv"],"346050":["Digicel","ky"],"346006":["Digicel Ltd.","ky"],"346140":["Flow / Lime","ky"],"346001":["Logic","ky"],"62304":["Azur","cf"],"623299":["Failed Calls","cf"],"62301":["Moov","cf"],"62303":["Orange","cf"],"62302":["Telecel","cf"],"62201":["Airtel","td"],"62203":["Moov","td"],"62204":["Salam","td"],"62202":["Tchad Mobile","td"],"73006":["Blue Two Chile SA","cl"],"73011":["Celupago SA","cl"],"73015":["Cibeles Telecom SA","cl"],"73003":["Claro","cl"],"73010":["Entel Telefonia","cl"],"73001":["Entel Telefonia Mov","cl"],"73014":["Netline Telefonica Movil Ltda","cl"],"73009":["Nextel SA","cl"],"73005":["Nextel SA","cl"],"73004":["Nextel SA","cl"],"73019":["Sociedad Falabella Movil SPA","cl"],"73007":["TELEFONICA","cl"],"73002":["TELEFONICA","cl"],"73012":["Telestar Movil SA","cl"],"73000":["TESAM SA","cl"],"73013":["Tribe Mobile SPA","cl"],"73008":["VTR Banda Ancha SA","cl"],"46007":["China Mobile GSM","cn"],"46000":["China Mobile GSM","cn"],"46002":["China Mobile GSM","cn"],"46004":["China Space Mobile Satellite Telecommunications Co. Ltd (China Spacecom)","cn"],"46003":["China Telecom","cn"],"46005":["China Telecom","cn"],"46006":["China Unicom","cn"],"46001":["China Unicom","cn"],"460999":["Fix Line","cn"],"732299":["ATnet","co"],"732130":["Avantel","co"],"732102":["Movistar","co"],"732666":["Claro","co"],"732101":["Claro","co"],"732002":["Edatel","co"],"732187":["eTb","co"],"732999":["Fix Line","co"],"732240":["Flash Mobile","co"],"732220":["Libre Tecnologias","co"],"732123":["Movistar","co"],"732001":["Movistar","co"],"732230":["Setroc Mobile","co"],"732199":["SUMA movil","co"],"732165":["Tigo","co"],"732103":["Tigo","co"],"732111":["Tigo","co"],"732142":["UNE","co"],"732020":["UNE","co"],"732154":["Virgin Mobile","co"],"732360":["WOM","co"],"654299":["Failed Calls","km"],"65401":["HURI","km"],"65402":["Telma","km"],"62901":["Airtel","cg"],"62902":["Azur SA (ETC)","cg"],"62910":["MTN","cg"],"62907":["Warid","cg"],"54801":["Vodafone / Bluesky","ck"],"71203":["Claro","cr"],"712999":["Fix Line","cr"],"71202":["ICE","cr"],"71201":["ICE","cr"],"71204":["Movistar","cr"],"71220":["Virtualis","cr"],"21910":["A1 / VIP","hr"],"219999":["Fix Line","hr"],"21901":["T-Mobile","hr"],"21912":["TELE FOCUS","hr"],"21902":["Telemach / Tele2","hr"],"36801":["CubaCel/C-COM","cu"],"368999":["Fix Line","cu"],"36295":["EOCG Wireless NV","cw"],"36269":["Polycom N.V./ Digicel","cw"],"28022":["Cablenet / Lemontel","cy"],"28002":["Cytamobile-Vodafone","cy"],"28001":["Cytamobile-Vodafone","cy"],"28010":["Epic / MTN","cy"],"280999":["Fix Line","cy"],"28020":["PrimeTel","cy"],"230299":["+4U Mobile","cz"],"23008":["Compatel","cz"],"230999":["Fix Line","cz"],"23004":["Nordic Telecom","cz"],"23002":["O2","cz"],"23005":["PODA","cz"],"23098":["SZDC","cz"],"23007":["T-Mobile","cz"],"23001":["T-Mobile","cz"],"23009":["Uniphone","cz"],"23003":["Vodafone","cz"],"23099":["Vodafone","cz"],"63090":["Africell","cd"],"63002":["Airtel","cd"],"630299":["Failed Calls","cd"],"63086":["Orange","cd"],"63005":["Supercell","cd"],"63089":["TIGO/Oasis","cd"],"63001":["Vodacom","cd"],"63088":["Yozma Timeturns","cd"],"23823":["Banedanmark","dk"],"23815":["Net 1","dk"],"23888":["Cobira","dk"],"23813":["Compatel","dk"],"238999":["Fix Line","dk"],"23817":["Gotanet","dk"],"23842":["Greenwave","dk"],"23806":["3","dk"],"23828":["LINK Mobility","dk"],"23812":["Lycamobile","dk"],"23814":["Monty Mobile","dk"],"23807":["Mundio Mobile","dk"],"23804":["Nexcon.io","dk"],"23873":["Onomondo","dk"],"23830":["Pareteum","dk"],"23803":["Syniverse Technologies","dk"],"23810":["TDC","dk"],"23801":["TDC","dk"],"23877":["Telenor","dk"],"23802":["Telenor","dk"],"23896":["Telia","dk"],"23820":["Telia","dk"],"23816":["Tismi","dk"],"23825":["Viahub","dk"],"23808":["Voxbone / Bandwidth","dk"],"63801":["Evatis","dj"],"366110":["C & W","dm"],"366020":["Cingular Wireless/Digicel","dm"],"366050":["Wireless Ventures (Dominica) Ltd (Digicel Dominica)","dm"],"37002":["Claro","do"],"37001":["Orange","do"],"37003":["TRIcom","do"],"37004":["Viva","do"],"514299":["Failed Calls","tl"],"514999":["Fix Line","tl"],"51403":["Telemor","tl"],"51401":["Telkomcel","tl"],"51402":["Timor Telecom","tl"],"74001":["Claro/Port","ec"],"74002":["CNT Mobile","ec"],"740000":["Failed Call(s)","ec"],"74000":["MOVISTAR/OteCel","ec"],"74003":["Tuenti","ec"],"60203":["Etisalat","eg"],"602299":["Failed Calls","eg"],"60201":["Orange","eg"],"60202":["Vodafone","eg"],"60204":["WE","eg"],"70601":["CLARO/CTE","sv"],"70602":["Digicel","sv"],"70605":["INTELFON SA de CV","sv"],"70604":["Telefonica","sv"],"70603":["Telemovil","sv"],"627299":["Failed Calls","gq"],"62703":["Muni","gq"],"62701":["Orange","gq"],"65701":["Eritel","er"],"24802":["Elisa","ee"],"24803":["Tele2","ee"],"24813":["Telia","ee"],"24801":["Telia","ee"],"24804":["TravelSim","ee"],"63601":["Ethio Mobile","et"],"750001":["Sure","fk"],"28801":["Faroese Telecom","fo"],"28802":["Hey / Kall","fo"],"28803":["Tosa","fo"],"54202":["DigiCell","fj"],"54201":["Vodafone","fj"],"24414":["Alcom","fi"],"244299":["Benemen","fi"],"24426":["Compatel","fi"],"24403":["DNA","fi"],"24412":["DNA","fi"],"24413":["DNA","fi"],"24404":["DNA","fi"],"24421":["Elisa","fi"],"24406":["Elisa","fi"],"24405":["Elisa","fi"],"24482":["interactive digital media / IDM","fi"],"24411":["Viahub","fi"],"24424":["Nord Connect","fi"],"24441":["NSN","fi"],"24409":["NSN","fi"],"24408":["NSN","fi"],"24438":["NSN","fi"],"24439":["NSN","fi"],"24440":["NSN","fi"],"24407":["NSN","fi"],"24410":["TDC Oy Finland","fi"],"24443":["Telavox","fi"],"24436":["Telia","fi"],"24491":["Telia","fi"],"24415":["Telit","fi"],"24437":["Tismi","fi"],"24435":["Ukko Mobile","fi"],"24442":["Viahub","fi"],"24447":["VIRVE","fi"],"24446":["VIRVE","fi"],"24445":["VIRVE","fi"],"24433":["VIRVE","fi"],"24432":["Voxbone / Bandwidth","fi"],"208299":["Add-On Multimedia","fr"],"20828":["Airmob","fr"],"20892":["IP Directions","fr"],"20888":["Bouygues Telecom","fr"],"20821":["Bouygues Telecom","fr"],"20820":["Bouygues Telecom","fr"],"20834":["Cellhire","fr"],"20827":["Coriolis","fr"],"208999":["Fix Line","fr"],"20836":["Free Mobile","fr"],"20815":["Free Mobile","fr"],"20814":["Free Mobile","fr"],"20835":["Free Mobile","fr"],"20816":["Free Mobile","fr"],"20807":["GlobalStar","fr"],"20806":["GlobalStar","fr"],"20805":["GlobalStar","fr"],"20894":["Halys","fr"],"20889":["Hub One","fr"],"20829":["Orange","fr"],"20837":["IP Directions","fr"],"20838":["Lebara","fr"],"20817":["Legos","fr"],"20825":["Lycamobile","fr"],"20824":["MobiquiThings","fr"],"20803":["MobiquiThings","fr"],"20839":["Networth Telecom","fr"],"20826":["NRJ","fr"],"20823":["Hub One","fr"],"20801":["Orange","fr"],"20832":["Orange","fr"],"20891":["Orange","fr"],"20802":["Orange","fr"],"20810":["SFR","fr"],"20811":["SFR","fr"],"20813":["SFR","fr"],"20809":["SFR","fr"],"20808":["SFR","fr"],"20804":["Axialys","fr"],"20830":["Syma Mobile","fr"],"20800":["Tel/Te","fr"],"20822":["Transatel","fr"],"20812":["Truphone","fr"],"20831":["Vectone Mobile","fr"],"34020":["Bouygues/DigiCel","gf"],"34001":["Orange Caribe","gf"],"34002":["Outremer Telecom","gf"],"34011":["TelCell GSM","gf"],"34003":["TelCell GSM","gf"],"54715":["Pacific Mobile Telecom (PMT)","pf"],"54720":["Vini/Tikiphone","pf"],"62803":["Airtel","ga"],"62804":["Azur/Usan S.A.","ga"],"628299":["Failed Calls","ga"],"62801":["Libertis","ga"],"62802":["MOOV/Telecel","ga"],"60702":["Africel","gm"],"60703":["Comium","gm"],"60701":["Gamcel","gm"],"60704":["QCell","gm"],"28204":["Beeline","ge"],"28201":["Geocell","ge"],"28207":["GlobalCell","ge"],"28203":["Iberiatel Ltd.","ge"],"28202":["MagtiCom","ge"],"28211":["Mobilive","ge"],"28222":["MyPhone","ge"],"28210":["Premium Net","ge"],"28205":["Silknet","ge"],"28208":["Silknet","ge"],"28212":["Telecom 1","ge"],"26223":["1&1","de"],"262299":["1&1","de"],"26213":["Bundesamt fr Ausrstung, Informationstechnik und Nutzung der Bundeswehr","de"],"26210":["DB Netz","de"],"26277":["Telefonica / E-Plus","de"],"26220":["Telefonica / E-Plus","de"],"262999":["Fix Line","de"],"26214":["Lebara","de"],"26243":["Lycamobile","de"],"26221":["Multiconnect","de"],"26222":["sipgate","de"],"26233":["sipgate","de"],"26224":["TelcoVillage","de"],"26205":["Telefonica / E-Plus","de"],"26217":["Telefonica / E-Plus","de"],"26212":["Telefonica / E-Plus","de"],"26203":["Telefonica / E-Plus","de"],"26211":["Telefonica / O2","de"],"26208":["Telefonica / O2","de"],"26216":["Telefonica / O2","de"],"26207":["Telefonica / O2","de"],"26278":["Telekom / T-mobile","de"],"26206":["Telekom / T-mobile","de"],"26201":["Telekom / T-mobile","de"],"26209":["Vodafone","de"],"26204":["Vodafone","de"],"26202":["Vodafone","de"],"26242":["Vodafone","de"],"62006":["Airtel","gh"],"62003":["Airtel","gh"],"620299":["Comsys","gh"],"62004":["Expresso Ghana Ltd","gh"],"62007":["Glo","gh"],"62001":["MTN","gh"],"62005":["National Security","gh"],"62008":["Surfline","gh"],"62002":["Vodafone","gh"],"26606":["CTS Mobile","gi"],"26609":["eazi telecom","gi"],"266999":["Fix Line","gi"],"266299":["GibFibreSpeed","gi"],"26601":["Gibtel","gi"],"202299":["AMD Telecom","gr"],"20207":["AMD Telecom","gr"],"20215":["BWS","gr"],"20202":["Cosmote","gr"],"20201":["Cosmote","gr"],"20214":["CyTa Mobile","gr"],"202999":["Fix Line","gr"],"20216":["Inter Telecom","gr"],"20204":["OSE","gr"],"20203":["OTE","gr"],"20210":["Wind","gr"],"20205":["Vodafone","gr"],"20209":["Wind","gr"],"20212":["Yuboto","gr"],"29001":["Tele Greenland","gl"],"352110":["Cable & Wireless","gd"],"352030":["Digicel","gd"],"352050":["Digicel","gd"],"34008":["Dauphin Telecom SU (Guadeloupe Telecom)","gp"],"310370":["Docomo","gu"],"310470":["Docomo","gu"],"310140":["GTA Wireless","gu"],"310033":["Guam Teleph. Auth","gu"],"310032":["IT&E OverSeas","gu"],"311250":["Wave Runner LLC","gu"],"70401":["Claro","gt"],"70403":["Telefonica","gt"],"70402":["TIGO/COMCEL","gt"],"61105":["Cellcom","gn"],"61103":["Intercel","gn"],"61104":["MTN","gn"],"61101":["Orange","gn"],"61102":["SotelGui","gn"],"632999":["Fix\tLine","gw"],"63201":["Guinetel","gw"],"63202":["MTN","gw"],"63203":["Orange","gw"],"73802":["Cellink Plus","gy"],"73801":["DigiCel","gy"],"37201":["Comcel","ht"],"37202":["Digicel","ht"],"37203":["Natcom","ht"],"708040":["Digicel","hn"],"708030":["HonduTel","hn"],"708001":["SERCOM/CLARO","hn"],"708002":["Telefonica/CELTEL","hn"],"45412":["China Mobile/Peoples","hk"],"45428":["China Mobile/Peoples","hk"],"45413":["China Mobile/Peoples","hk"],"45409":["China Motion","hk"],"45407":["China Unicom Ltd","hk"],"45411":["China-HongKong Telecom Ltd (CHKTL)","hk"],"45401":["Citic Telecom Ltd.","hk"],"45402":["CSL Ltd.","hk"],"45400":["CSL Ltd.","hk"],"45418":["CSL Ltd.","hk"],"45410":["CSL/New World PCS Ltd.","hk"],"45431":["CTExcel","hk"],"45414":["H3G/Hutchinson","hk"],"45405":["H3G/Hutchinson","hk"],"45404":["H3G/Hutchinson","hk"],"45403":["H3G/Hutchinson","hk"],"45420":["HKT/PCCW","hk"],"45419":["HKT/PCCW","hk"],"45429":["HKT/PCCW","hk"],"45416":["HKT/PCCW","hk"],"45447":["shared by private TETRA systems","hk"],"45424":["Multibyte Info Technology Ltd","hk"],"45440":["shared by private TETRA systems","hk"],"45408":["Truephone","hk"],"45417":["Vodafone/SmarTone","hk"],"45415":["Vodafone/SmarTone","hk"],"45406":["Vodafone/SmarTone","hk"],"216299":["Antenna","hu"],"21603":["Digi","hu"],"216999":["Fix line","hu"],"21602":["MVM NET","hu"],"21630":["Telekom","hu"],"21601":["Telenor","hu"],"21671":["UPC Magyarorszag Kft.","hu"],"21670":["Vodafone","hu"],"27409":["Amitelo","is"],"27407":["IceCell","is"],"27411":["NOVA","is"],"27431":["Siminn","is"],"27408":["Siminn","is"],"27401":["Siminn","is"],"27416":["Tismi","is"],"27404":["Viking Wireless","is"],"27412":["Vodafone","is"],"27402":["Vodafone","is"],"27403":["Vodafone","is"],"27405":["Vodafone","is"],"40417":["Aircel","in"],"40442":["Aircel","in"],"40433":["Aircel","in"],"40429":["Aircel","in"],"40428":["Aircel","in"],"40425":["Aircel","in"],"40401":["Aircel Digilink India","in"],"40415":["Aircel Digilink India","in"],"40460":["Aircel Digilink India","in"],"40553":["AirTel","in"],"40486":["Barakhamba Sales & Serv.","in"],"40413":["Barakhamba Sales & Serv.","in"],"40458":["BSNL","in"],"40481":["BSNL","in"],"40474":["BSNL","in"],"40438":["BSNL","in"],"40457":["BSNL","in"],"40480":["BSNL","in"],"40473":["BSNL","in"],"40434":["BSNL","in"],"40466":["BSNL","in"],"40455":["BSNL","in"],"40472":["BSNL","in"],"40477":["BSNL","in"],"40464":["BSNL","in"],"40454":["BSNL","in"],"40471":["BSNL","in"],"40476":["BSNL","in"],"40462":["BSNL","in"],"40453":["BSNL","in"],"40459":["BSNL","in"],"40475":["BSNL","in"],"40451":["BSNL","in"],"40410":["Bharti Airtel Limited (Delhi)","in"],"404045":["Bharti Airtel Limited (Karnataka) (India)","in"],"40479":["CellOne A&N","in"],"40487":["Escorts Telecom Ltd.","in"],"40482":["Escorts Telecom Ltd.","in"],"40489":["Escorts Telecom Ltd.","in"],"40488":["Escorts Telecom Ltd.","in"],"40412":["Escotel Mobile Communications","in"],"40419":["Escotel Mobile Communications","in"],"40456":["Escotel Mobile Communications","in"],"40505":["Fascel Limited","in"],"40405":["Fascel","in"],"404998":["Fix Line","in"],"40470":["Hexacom India","in"],"40416":["Hexcom India","in"],"40478":["Idea Cellular Ltd.","in"],"40407":["Idea Cellular Ltd.","in"],"40404":["Idea Cellular Ltd.","in"],"40424":["Idea Cellular Ltd.","in"],"40422":["Idea Cellular Ltd.","in"],"40469":["Mahanagar Telephone Nigam","in"],"40468":["Mahanagar Telephone Nigam","in"],"40483":["Reliable Internet Services","in"],"40450":["Reliance Telecom Private","in"],"40467":["Reliance Telecom Private","in"],"40418":["Reliance Telecom Private","in"],"40485":["Reliance Telecom Private","in"],"40409":["Reliance Telecom Private","in"],"40587":["Reliance Telecom Private","in"],"40436":["Reliance Telecom Private","in"],"40452":["Reliance Telecom Private","in"],"40441":["RPG Cellular","in"],"40414":["Spice","in"],"40444":["Spice","in"],"40411":["Sterling Cellular Ltd.","in"],"405034":["TATA / Karnataka","in"],"40430":["Usha Martin Telecom","in"],"404999":["Various Networks","in"],"40427":["Unknown","in"],"40443":["Vodafone/Essar/Hutch","in"],"40420":["Unknown","in"],"51008":["Axis/Natrindo","id"],"51099":["Esia (PT Bakrie Telecom) (CDMA)","id"],"510999":["Fix Line","id"],"51007":["Flexi (PT Telkom) (CDMA)","id"],"51089":["H3G CP","id"],"51021":["Indosat/Satelindo/M3","id"],"51001":["Indosat/Satelindo/M3","id"],"51000":["PT Pasifik Satelit Nusantara (PSN)","id"],"51027":["PT Sampoerna Telekomunikasi Indonesia (STI)","id"],"51028":["PT Smartfren Telecom Tbk","id"],"51009":["PT Smartfren Telecom Tbk","id"],"51011":["PT. Excelcom","id"],"51010":["Telkomsel","id"],"90113":["Antarctica","n/a"],"432999":["Fix Line","ir"],"43219":["Mobile Telecommunications Company of Esfahan JV-PJS (MTCE)","ir"],"43270":["MTCE","ir"],"43235":["MTN/IranCell","ir"],"43220":["Rightel","ir"],"43232":["Taliya","ir"],"43211":["MCI/TCI","ir"],"43214":["TKC/KFZO","ir"],"41805":["Asia Cell","iq"],"41866":["Fastlink","iq"],"41892":["Itisaluna and Kalemat","iq"],"41882":["Korek","iq"],"41840":["Korek","iq"],"41845":["Mobitel","iq"],"41830":["Orascom Telecom","iq"],"41808":["Sanatel","iq"],"41820":["Zain","iq"],"27204":["Access Telecom Ltd.","ie"],"27203":["Meteor / eir mobile","ie"],"27207":["Meteor / eir mobile","ie"],"27208":["Meteor / eir mobile","ie"],"27213":["Lycamobile","ie"],"27211":["Tesco Mobile","ie"],"27217":["3","ie"],"27202":["3","ie"],"27205":["3","ie"],"27215":["Virgin Media","ie"],"27201":["Vodafone","ie"],"42519":["019 Mobile","il"],"425299":["Annatel Mobile","il"],"42523":["Beezz","il"],"42502":["Cellcom","il"],"42508":["Golan Telecom","il"],"42515":["Home Cellular","il"],"42577":["Hot Mobile","il"],"42507":["Hot Mobile","il"],"42513":["Ituran","il"],"42522":["Maskyoo","il"],"42501":["Orange","il"],"42503":["Pelephone","il"],"42512":["Pelephone","il"],"42516":["Rami Levy Communications","il"],"42517":["Von waves","il"],"42509":["We4G","il"],"42514":["YouPhone","il"],"222299":["A-Tono","it"],"22240":["Agile Telecom","it"],"22234":["BT mobile","it"],"22253":["CoopVoce","it"],"22236":["Digi","it"],"22202":["Elsacom","it"],"22242":["Enel","it"],"22208":["Fastweb","it"],"222999":["Fix Line","it"],"22299":["WindTre / Hi3G","it"],"22250":["Iliad","it"],"22277":["IPSE 2000","it"],"22239":["SMS.it / LINK Mobility","it"],"22235":["Lycamobile","it"],"22207":["Noverca Italia","it"],"22254":["Plintron","it"],"22233":["Poste Mobile","it"],"22200":["Premium Numbers","it"],"22258":["rdcom","it"],"22230":["RFI","it"],"22256":["spusu","it"],"22243":["Telecom Italia Mobile","it"],"22201":["TIM","it"],"22248":["Telecom Italia Mobile","it"],"22244":["Mundio","it"],"22251":["ho.","it"],"22249":["Vianova Mobile","it"],"22210":["Vodafone","it"],"22206":["Vodafone","it"],"22288":["WindTre / WIND","it"],"22237":["WindTre / Hi3G","it"],"61207":["Aircomm SA","ci"],"61204":["Comium","ci"],"61201":["Comstar","ci"],"61202":["Moov","ci"],"61205":["MTN","ci"],"61203":["Orange","ci"],"61206":["OriCell","ci"],"338020":["Cable & Wireless","jm"],"338110":["Cable & Wireless","jm"],"338180":["Cable & Wireless","jm"],"338050":["DIGICEL/Mossel","jm"],"44000":["Y-Mobile","jp"],"44089":["KDDI","jp"],"44051":["KDDI","jp"],"44075":["KDDI","jp"],"44070":["KDDI","jp"],"44056":["KDDI","jp"],"44170":["KDDI","jp"],"44052":["KDDI","jp"],"44076":["KDDI","jp"],"44071":["KDDI","jp"],"44053":["KDDI","jp"],"44077":["KDDI","jp"],"44008":["KDDI","jp"],"44072":["KDDI","jp"],"44054":["KDDI","jp"],"44079":["KDDI","jp"],"44007":["KDDI","jp"],"44073":["KDDI","jp"],"44055":["KDDI","jp"],"44088":["KDDI","jp"],"44050":["KDDI","jp"],"44074":["KDDI","jp"],"44002":["NTT DoCoMo","jp"],"44022":["NTT DoCoMo","jp"],"44143":["NTT DoCoMo","jp"],"44027":["NTT DoCoMo","jp"],"44087":["NTT DoCoMo","jp"],"44017":["NTT DoCoMo","jp"],"44031":["NTT DoCoMo","jp"],"44065":["NTT DoCoMo","jp"],"44036":["NTT DoCoMo","jp"],"44192":["NTT DoCoMo","jp"],"44003":["NTT DoCoMo","jp"],"44012":["NTT DoCoMo","jp"],"44058":["NTT DoCoMo","jp"],"44028":["NTT DoCoMo","jp"],"44061":["NTT DoCoMo","jp"],"44018":["NTT DoCoMo","jp"],"44191":["NTT DoCoMo","jp"],"44032":["NTT DoCoMo","jp"],"44066":["NTT DoCoMo","jp"],"44035":["NTT DoCoMo","jp"],"44193":["NTT DoCoMo","jp"],"44140":["NTT DoCoMo","jp"],"44009":["NTT DoCoMo","jp"],"44049":["NTT DoCoMo","jp"],"44029":["NTT DoCoMo","jp"],"44060":["NTT DoCoMo","jp"],"44019":["NTT DoCoMo","jp"],"44190":["NTT DoCoMo","jp"],"44033":["NTT DoCoMo","jp"],"44067":["NTT DoCoMo","jp"],"44014":["NTT DoCoMo","jp"],"44194":["NTT DoCoMo","jp"],"44141":["NTT DoCoMo","jp"],"44010":["NTT DoCoMo","jp"],"44062":["NTT DoCoMo","jp"],"44039":["NTT DoCoMo","jp"],"44030":["NTT DoCoMo","jp"],"44145":["NTT DoCoMo","jp"],"44001":["NTT DoCoMo","jp"],"44024":["NTT DoCoMo","jp"],"44068":["NTT DoCoMo","jp"],"44015":["NTT DoCoMo","jp"],"44198":["NTT DoCoMo","jp"],"44142":["NTT DoCoMo","jp"],"44011":["NTT DoCoMo","jp"],"44063":["NTT DoCoMo","jp"],"44038":["NTT DoCoMo","jp"],"44026":["NTT DoCoMo","jp"],"44023":["NTT DoCoMo","jp"],"44021":["NTT DoCoMo","jp"],"44144":["NTT DoCoMo","jp"],"44013":["NTT DoCoMo","jp"],"44069":["NTT DoCoMo","jp"],"44016":["NTT DoCoMo","jp"],"44199":["NTT DoCoMo","jp"],"44034":["NTT DoCoMo","jp"],"44064":["NTT DoCoMo","jp"],"44037":["NTT DoCoMo","jp"],"44025":["NTT DoCoMo","jp"],"44099":["NTT DoCoMo","jp"],"44078":["Okinawa Cellular","jp"],"44020":["SoftBank","jp"],"44005":["SoftBank","jp"],"44094":["SoftBank","jp"],"44046":["SoftBank","jp"],"44097":["SoftBank","jp"],"44042":["SoftBank","jp"],"44165":["SoftBank","jp"],"44090":["SoftBank","jp"],"44096":["SoftBank","jp"],"44092":["SoftBank","jp"],"44098":["SoftBank","jp"],"44043":["SoftBank","jp"],"44048":["SoftBank","jp"],"44006":["SoftBank","jp"],"44161":["SoftBank","jp"],"44044":["SoftBank","jp"],"44004":["SoftBank","jp"],"44162":["SoftBank","jp"],"44045":["SoftBank","jp"],"44040":["SoftBank","jp"],"44163":["SoftBank","jp"],"44093":["SoftBank","jp"],"44047":["SoftBank","jp"],"44095":["SoftBank","jp"],"44041":["SoftBank","jp"],"44164":["SoftBank","jp"],"44085":["KDDI","jp"],"44083":["KDDI","jp"],"44080":["KDDI","jp"],"44086":["KDDI","jp"],"44081":["KDDI","jp"],"44084":["KDDI","jp"],"44082":["KDDI","jp"],"416999":["Fix Line","jo"],"41677":["Orange","jo"],"41603":["Umniah","jo"],"41602":["Xpress","jo"],"41601":["Zain","jo"],"40101":["Beeline/KaR-Tel LLP","kz"],"40107":["Dalacom/Altel","kz"],"40102":["K-Cell","kz"],"40177":["Tele2/NEO/MTS","kz"],"63903":["Airtel","ke"],"63905":["Airtel","ke"],"639299":["eferio","ke"],"63906":["Finserve Africa","ke"],"63909":["Homeland Media","ke"],"63912":["Infura","ke"],"63911":["Jambo Telcoms","ke"],"63910":["Jamii Telecommunications","ke"],"63904":["Mobile Pay","ke"],"63901":["Safaricom","ke"],"63902":["Safaricom","ke"],"63907":["Telkom","ke"],"54509":["Kiribati Frigate","ki"],"22107":["D3 mobile","xk"],"22106":["Dardafon.Net LLC","xk"],"22102":["IPKO","xk"],"221299":["MTS","xk"],"22103":["MTS","xk"],"22101":["Vala","xk"],"419999":["Fix Line","kw"],"41902":["Zain","kw"],"41904":["Viva","kw"],"41903":["Ooredoo","kw"],"43701":["Beeline","kg"],"437299":["Failed Calls","kg"],"43702":["KT Mobile","kg"],"43705":["MegaCom","kg"],"43709":["O!","kg"],"43710":["Saima","kg"],"43703":["Sem Mobile","kg"],"45702":["ETL Mobile","la"],"45701":["Lao Tel","la"],"45708":["Beeline/Tigo/Millicom","la"],"45703":["UNITEL/LAT","la"],"24705":["Bite","lv"],"24710":["LMT","lv"],"24701":["LMT","lv"],"247299":["Premium Numbers","lv"],"24707":["SIA Master Telecom","lv"],"24706":["SIA Rigatta","lv"],"24702":["Tele2","lv"],"24704":["Tet","lv"],"24703":["TRIATEL","lv"],"24708":["VENTA Mobile","lv"],"24709":["XOmobile","lv"],"41535":["Cellis","lb"],"41533":["Cellis","lb"],"41532":["Cellis","lb"],"41534":["FTML Cellis","lb"],"41539":["MIC2/LibanCell/MTC","lb"],"41538":["MIC2/LibanCell/MTC","lb"],"41537":["MIC2/LibanCell/MTC","lb"],"41501":["MIC1 (Alfa)","lb"],"41503":["MIC2/LibanCell/MTC","lb"],"41536":["MIC2/LibanCell/MTC","lb"],"65102":["Econet","ls"],"65101":["Vodacom","ls"],"61802":["Libercell","lr"],"61820":["LibTelco","lr"],"61801":["MTN / Lonestar","lr"],"61804":["Novafone","lr"],"61807":["Orange","lr"],"60602":["Al-Madar","ly"],"60601":["Al-Madar","ly"],"60606":["Hatef","ly"],"60600":["Libyana","ly"],"60603":["LibyaPhone Mobile","ly"],"29502":["7acht","li"],"29506":["CUBIC","li"],"295299":["Datamobile","li"],"29509":["EMnify","li"],"29507":["First Mobile AG","li"],"29501":["FL GSM","li"],"29505":["FL1","li"],"29577":["Alpmobile/Tele2","li"],"24602":["Bite","lt"],"24605":["LTG","lt"],"24606":["Mediafon","lt"],"246299":["SkyCall","lt"],"24603":["Tele2","lt"],"24601":["Telia","lt"],"27010":["Blue Communications","lu"],"270299":["Bouygues Telecom","lu"],"27081":["e-LUX Mobile","lu"],"270999":["Fix Line","lu"],"27005":["Luxembourg Online","lu"],"27099":["Orange","lu"],"27001":["Post","lu"],"27077":["Tango","lu"],"45501":["C.T.M. TELEMOVEL+","mo"],"45504":["C.T.M. TELEMOVEL+","mo"],"45502":["China Telecom","mo"],"45505":["Hutchison Telephone Co. Ltd","mo"],"45503":["Hutchison Telephone Co. Ltd","mo"],"45506":["Smartone Mobile","mo"],"45500":["Smartone Mobile","mo"],"64601":["Airtel","mg"],"646299":["Bip","mg"],"64602":["Orange","mg"],"64603":["Sacel","mg"],"64604":["Telma","mg"],"65010":["Airtel","mw"],"65001":["TNM","mw"],"502156":["Altel Communications","my"],"50201":["Art900","my"],"50214":["Telekom Malaysia","my"],"50211":["Telekom Malaysia","my"],"502151":["Baraka Telecom Sdn Bhd","my"],"50219":["Celcom","my"],"50213":["Celcom","my"],"502198":["Celcom","my"],"50210":["DiGi","my"],"50216":["DiGi","my"],"50220":["Electcoms Wireless Sdn Bhd","my"],"502999":["Fix Line","my"],"502299":["MKN","my"],"50217":["Maxis","my"],"50212":["Maxis","my"],"502155":["Samata Communications Sdn Bhd","my"],"502154":["TT dotCom","my"],"502150":["Tune Talk","my"],"50218":["U Mobile","my"],"502153":["Webe Digital","my"],"502195":["XOX Com","my"],"502152":["Yes","my"],"47201":["Dhiraagu/C&W","mv"],"47202":["Ooredo/Wataniya","mv"],"61001":["Malitel","ml"],"61002":["Orange","ml"],"61003":["Telecel","ml"],"27801":["Vodafone","mt"],"278999":["Fix Line","mt"],"27821":["GO Mobile","mt"],"27830":["GO Mobile","mt"],"27877":["Melita","mt"],"551299":["Failed Calls","mh"],"34012":["UTS Caraibe","mq"],"60902":["Chinguitel","mr"],"60901":["Mattel","mr"],"60910":["Mauritel","mr"],"61703":["Chili","mu"],"61702":["Chili","mu"],"61710":["Emtel","mu"],"61701":["my.t mobile","mu"],"64701":["Maore Mobile","yt"],"64710":["SFR","yt"],"334050":["AT&T/IUSACell","mx"],"334040":["AT&T/IUSACell","mx"],"33405":["AT&T/IUSACell","mx"],"33404":["AT&T/IUSACell","mx"],"33450":["AT&T/IUSACell","mx"],"33403":["Movistar/Pegaso","mx"],"334030":["Movistar/Pegaso","mx"],"334090":["NEXTEL","mx"],"334010":["NEXTEL","mx"],"33401":["NEXTEL","mx"],"33409":["NEXTEL","mx"],"334070":["Operadora Unefon SA de CV","mx"],"334080":["Operadora Unefon SA de CV","mx"],"334060":["SAI PCS","mx"],"334020":["TelCel/America Movil","mx"],"33402":["TelCel/America Movil","mx"],"55001":["FSM Telecommunications Corp.","fm"],"25904":["Eventis Mobile","md"],"25903":["Unite","md"],"25902":["Moldcell","md"],"25901":["Orange","md"],"25999":["Unite","md"],"25905":["Unite","md"],"21210":["Monaco Telecom","mc"],"21201":["Monaco Telecom","mc"],"42898":["G-Mobile Corporation Ltd","mn"],"42899":["Mobicom","mn"],"42891":["Skytel Co. Ltd","mn"],"42800":["Skytel Co. Ltd","mn"],"42888":["Unitel","mn"],"29703":["Mtel","me"],"29702":["Telekom / T-mobile","me"],"29701":["Telenor","me"],"354860":["Cable & Wireless","ms"],"60404":["Al Houria Telecom","ma"],"60499":["Al Houria Telecom","ma"],"60401":["IAM","ma"],"60406":["IAM","ma"],"60402":["inwi","ma"],"60405":["inwi","ma"],"60400":["Orange","ma"],"64303":["Movitel","mz"],"64301":["TMCEL","mz"],"64304":["Vodacom","mz"],"414999":["Fix Line (Myanmar","mm"],"41401":["Myanmar Post & Teleco.","mm"],"41409":["Mytel (Myanmar","mm"],"41405":["Oreedoo","mm"],"41406":["Telenor","mm"],"649299":["Demshi","na"],"64901":["MTC","na"],"64902":["Switch/Nam. Telec.","na"],"64903":["TN Mobile","na"],"429999":["Fix Line","np"],"42902":["Ncell","np"],"42901":["NT Mobile / Namaste","np"],"42904":["Smart Cell","np"],"20414":["6GMOBILE BV","nl"],"204299":["88 mobile","nl"],"20430":["ASPIDER Solutions","nl"],"20405":["ElephantTalk","nl"],"204999":["Fix Line","nl"],"20417":["Intercity Mobile Communications BV","nl"],"20400":["Intovoice","nl"],"20423":["KORE","nl"],"20408":["KPN","nl"],"20410":["KPN","nl"],"20469":["KPN","nl"],"20412":["KPN","nl"],"20427":["L-mobi","nl"],"20428":["Lancelot","nl"],"20498":["Lancelot","nl"],"20409":["Lycamobile","nl"],"20463":["MessageBird","nl"],"20407":["Move / Teleena","nl"],"20406":["Vectone Mobile","nl"],"20424":["Private Mobility","nl"],"20421":["ProRail","nl"],"20426":["SpeakUp","nl"],"20402":["T-Mobile","nl"],"20420":["T-Mobile","nl"],"20416":["T-Mobile","nl"],"20429":["Tismi","nl"],"20433":["Truphone","nl"],"20468":["Unify Mobile","nl"],"20404":["Vodafone","nl"],"20403":["Voiceworks Mobile","nl"],"20415":["Ziggo","nl"],"20418":["Ziggo Services","nl"],"362630":["Cingular Wireless","an"],"36251":["TELCELL GSM","an"],"36291":["SETEL GSM","an"],"362951":["UTS Wireless","an"],"54601":["OPT Mobilis","nc"],"53028":["2degrees","nz"],"530999":["Fix Line","nz"],"53005":["Spark Mobile","nz"],"53002":["Spark Mobile","nz"],"53004":["Telstra","nz"],"53024":["2degrees","nz"],"53001":["Vodafone","nz"],"53003":["Walker Wireless Ltd.","nz"],"71021":["Empresa Nicaraguense de Telecomunicaciones SA (ENITEL)","ni"],"710999":["Fix Line","ni"],"71030":["Movistar","ni"],"71073":["Claro","ni"],"61402":["Airtel","ne"],"61403":["Moov","ne"],"61401":["Niger Telecoms","ne"],"61404":["Orange","ne"],"62160":["9mobile","ng"],"62120":["Airtel","ng"],"621299":["Alpha Technologies","ng"],"62150":["Glo Mobile","ng"],"62130":["MTN","ng"],"62140":["ntel","ng"],"62127":["Smile","ng"],"62199":["Starcomms","ng"],"62101":["Visafone","ng"],"62125":["Visafone","ng"],"55501":["Niue Telecom","nu"],"467299":["Failed Calls","kp"],"467192":["Koryolink","kp"],"467193":["Sun Net","kp"],"29402":["A1","mk"],"29403":["A1","mk"],"29475":["A1","mk"],"294299":["Failed Calls","mk"],"29404":["Lycamobile","mk"],"29411":["Mobik","mk"],"29401":["Telekom","mk"],"24222":["Altibox Mobil","no"],"24221":["BANE NOR","no"],"24220":["BANE NOR","no"],"242299":["bigblu","no"],"24209":["Com4","no"],"24215":["eRate","no"],"242999":["Fix Line","no"],"24214":["ICE","no"],"24216":["Iristel","no"],"24223":["Lycamobile","no"],"24205":["Network Norway","no"],"24210":["Nkom","no"],"24206":["ICE","no"],"24208":["TDC Mobil A/S","no"],"24204":["Tele2","no"],"24212":["Telenor","no"],"24201":["Telenor","no"],"24203":["Teletopia","no"],"24202":["Telia / NetCom","no"],"242017":["Ventelo AS","no"],"24207":["Ventelo AS","no"],"42203":["Nawras","om"],"42202":["Oman Mobile/GTO","om"],"410299":["Failed Calls","pk"],"41008":["Instaphone","pk"],"41001":["Jazz","pk"],"41007":["Jazz","pk"],"41005":["SCOM","pk"],"41006":["Telenor","pk"],"41003":["Ufone","pk"],"41004":["Zong","pk"],"55280":["Palau Mobile Corp. (PMC) (Palau","pw"],"55201":["Palau National Communications Corp. (PNCC) (Palau","pw"],"55202":["PECI/PalauTel (Palau","pw"],"42505":["Jawwal","ps"],"42506":["Ooredoo","ps"],"71401":["Cable & W./Mas Movil","pa"],"71403":["Claro","pa"],"71404":["Digicel","pa"],"714999":["Fix Line","pa"],"714020":["Movistar","pa"],"71402":["Movistar","pa"],"53703":["Digicel","pg"],"537999":["Fix Line","pg"],"53702":["GreenCom PNG Ltd","pg"],"53701":["Pacific Mobile","pg"],"74402":["Claro/Hutchison","py"],"74403":["Compa","py"],"74401":["Hola/VOX","py"],"74405":["TIM/Nucleo/Personal","py"],"74404":["Tigo/Telecel","py"],"71620":["Claro /Amer.Mov./TIM","pe"],"71610":["Claro /Amer.Mov./TIM","pe"],"71602":["GlobalStar","pe"],"71601":["GlobalStar","pe"],"71606":["Movistar","pe"],"71607":["Nextel","pe"],"71617":["Nextel","pe"],"71615":["Viettel Mobile","pe"],"515999":["Fix Line","ph"],"51502":["Globe Telecom","ph"],"51501":["Globe Telecom","ph"],"51588":["Next Mobile","ph"],"51518":["RED Mobile/Cure","ph"],"51503":["Smart","ph"],"51505":["SUN/Digitel","ph"],"260299":["3S","pl"],"26004":["Aero2","pl"],"26016":["Aero2","pl"],"26015":["Aero2","pl"],"26017":["Aero2","pl"],"26048":["Agile Telecom","pl"],"26018":["AMD Telecom","pl"],"26038":["CallFreedom Sp. z o.o.","pl"],"26032":["Compatel","pl"],"26012":["Cyfrowy Polsat","pl"],"26008":["e-Telko","pl"],"26041":["EZ Mobile","pl"],"260999":["Fix Line","pl"],"26009":["Lycamobile","pl"],"26049":["Messagebird","pl"],"26042":["MobiWeb","pl"],"26013":["Move","pl"],"26036":["Mundio Mobile Sp. z o.o.","pl"],"26019":["NetBalt","pl"],"26007":["Netia","pl"],"26011":["NORDISK Polska","pl"],"26027":["Ntel Solutions","pl"],"26003":["Orange","pl"],"26005":["Orange","pl"],"26035":["PKP","pl"],"26098":["Play","pl"],"26006":["Play","pl"],"26001":["Plus","pl"],"26097":["Politechnika Lodzka Uczelniane","pl"],"26090":["Polska Spolka Gazownictwa","pl"],"26014":["Move","pl"],"26047":["SMSHIGHWAY","pl"],"26034":["T-Mobile","pl"],"26002":["T-Mobile","pl"],"26010":["T-Mobile","pl"],"26020":["Tismi","pl"],"26022":["Twilio","pl"],"26045":["Virgin Mobile","pl"],"26039":["Voxbone / Bandwidth","pl"],"268999":["Fix Line","pt"],"26804":["Lycamobile","pt"],"26880":["MEO","pt"],"26808":["MEO","pt"],"26806":["MEO","pt"],"26803":["NOS","pt"],"26893":["NOS","pt"],"268299":["NOWO","pt"],"26807":["NOS","pt"],"26891":["Vodafone","pt"],"26801":["Vodafone","pt"],"33011":["Puerto Rico Telephone Company Inc. (PRTC)","pr"],"330110":["Puerto Rico Telephone Company Inc. (PRTC)","pr"],"42701":["Ooredoo/Qtel","qa"],"42702":["Vodafone","qa"],"64703":["Only","re"],"64702":["Only","re"],"64700":["Orange","re"],"64704":["ZEOP Mobile","re"],"22605":["Digi Mobil","ro"],"22611":["Enigma Systems","ro"],"226299":["Iristel","ro"],"22616":["Lycamobile","ro"],"22610":["Orange","ro"],"22602":["Romtelecom SA","ro"],"22603":["Telekom","ro"],"22606":["Telekom Romania","ro"],"22601":["Vodafone","ro"],"22604":["Telekom Romania","ro"],"250299":["A-Mobile","ru"],"25099":["Beeline","ru"],"25028":["BeeLine/VimpelCom","ru"],"25010":["DTC/Don Telecom","ru"],"250999":["Fix Line","ru"],"25048":["Global Telecom","ru"],"25055":["Glonass","ru"],"25034":["Krymtelecom","ru"],"25013":["Kuban GSM","ru"],"25054":["Letai Mobile","ru"],"25057":["Matrix Mobile","ru"],"25002":["Megafon","ru"],"25035":["Motiv","ru"],"25001":["MTS","ru"],"25042":["MTT","ru"],"25003":["NCC","ru"],"25016":["NTC","ru"],"25019":["OJSC Altaysvyaz","ru"],"25092":["Printelefone","ru"],"25033":["SEVTELECOM","ru"],"25004":["Sibchallenge","ru"],"25009":["Skylink","ru"],"25044":["StavTelesot","ru"],"25020":["Tele2","ru"],"25012":["Tele2","ru"],"25093":["Telecom XXL","ru"],"25039":["UralTel","ru"],"25017":["UralTel","ru"],"25077":["Glonass","ru"],"25060":["Volna Mobile","ru"],"25032":["Win Mobile","ru"],"25005":["Tele2/ECC/Volgogr.","ru"],"25011":["Yota","ru"],"25015":["ZAO SMARTS","ru"],"25007":["ZAO SMARTS","ru"],"63513":["Airtel","rw"],"63514":["Airtel","rw"],"63510":["MTN","rw"],"658299":["Failed Calls","sh"],"356110":["Cable & Wireless","kn"],"35650":["Digicel","kn"],"35670":["UTS Cariglobe","kn"],"358110":["Cable & Wireless","lc"],"35830":["Cingular Wireless","lc"],"35850":["Digicel (St Lucia) Limited","lc"],"30801":["Ameris","pm"],"360110":["C & W","vc"],"36010":["Cingular","vc"],"360100":["Cingular","vc"],"360050":["Digicel","vc"],"36070":["Digicel","vc"],"549999":["Fix Line","ws"],"54927":["Samoatel Mobile","ws"],"54901":["Telecom Samoa Cellular Ltd.","ws"],"29201":["Prima","sm"],"292299":["TeleneT","sm"],"62601":["CSTmovel","st"],"62602":["Unitel","st"],"90114":["AeroMobile","n/a"],"90111":["InMarSAT","n/a"],"90112":["Maritime Communications Partner AS","n/a"],"90105":["Thuraya Satellite","n/a"],"42007":["Zain","sa"],"42003":["Etihad/Etisalat/Mobily","sa"],"42006":["Lebara Mobile","sa"],"42001":["STC/Al Jawal","sa"],"42005":["Virgin Mobile","sa"],"42004":["Zain","sa"],"608299":["2s Mobile","sn"],"60803":["Expresso","sn"],"60802":["Free","sn"],"60804":["HAYO","sn"],"60801":["Orange","sn"],"220299":["Failed Calls","rs"],"22011":["Globaltel","rs"],"22003":["MTS","rs"],"22001":["Telenor","rs"],"22002":["Telenor","rs"],"22005":["VIP","rs"],"22020":["VIP","rs"],"63310":["Airtel","sc"],"63301":["Cable & Wireless","sc"],"63305":["Intelvision","sc"],"63302":["Smartcom","sc"],"61903":["Africell","sl"],"61904":["Comium","sl"],"619299":["IPTel","sl"],"61905":["Africell","sl"],"61902":["Tigo/Millicom","sl"],"61925":["Mobitel","sl"],"61901":["Orange","sl"],"61907":["Qcell","sl"],"525999":["Fix Line","sg"],"52512":["GRID Communications Pte Ltd","sg"],"52503":["MobileOne Ltd","sg"],"52502":["Singtel","sg"],"52501":["Singtel","sg"],"52507":["Singtel","sg"],"52506":["Starhub","sg"],"52505":["Starhub","sg"],"23106":["O2","sk"],"23105":["Orange","sk"],"23107":["Orange","sk"],"23101":["Orange","sk"],"23115":["Orange","sk"],"23103":["Swan / 4ka","sk"],"23102":["Telekom","sk"],"23104":["Telekom","sk"],"23150":["Telekom","sk"],"23108":["Uniphone","sk"],"231299":["Vonage","sk"],"23199":["ZSR","sk"],"29340":["A1 / Si.mobil","si"],"29320":["Compatel","si"],"29386":["Elektro Gorenjska","si"],"293999":["Fix Line","si"],"293299":["HOT mobil","si"],"29341":["Mobitel","si"],"29310":["Slovenske zeleznice","si"],"29364":["T-2","si"],"29370":["Telemach / Tusmobil","si"],"54002":["bemobile","sb"],"54010":["BREEZE","sb"],"54001":["BREEZE","sb"],"637299":["AirSom","so"],"63730":["Golis","so"],"63719":["Hormuud","so"],"63750":["Hormuud","so"],"63760":["Nationlink","so"],"63710":["Nationlink","so"],"63770":["Onkod","so"],"63704":["Somafone","so"],"63771":["Somtel","so"],"63782":["Telcom Mobile","so"],"63701":["Telesom","so"],"65521":["Cape Town Metropolitan","za"],"65507":["Cell C","za"],"655299":["Lycamobile","za"],"65510":["MTN","za"],"65512":["MTN","za"],"65538":["Rain","za"],"65519":["Rain","za"],"65573":["Rain","za"],"65574":["Rain","za"],"65506":["Sentech","za"],"65502":["Telkom","za"],"65505":["Telkom","za"],"65501":["Vodacom","za"],"450299":["Failed Calls","kr"],"45002":["olleh / KT","kr"],"45007":["KT Powertel","kr"],"45006":["LG U+","kr"],"45008":["olleh / KT","kr"],"45004":["olleh / KT","kr"],"45003":["SK Telecom","kr"],"45005":["SK Telecom","kr"],"45012":["SK Telecom","kr"],"45011":["SK Telecom","kr"],"659299":["Digitel","ss"],"65903":["Gemtel Ltd (South Sudan","ss"],"65902":["MTN","ss"],"65904":["Network of The World Ltd (NOW) (South Sudan","ss"],"65906":["Zain","ss"],"214299":["ACN","es"],"21436":["Alai","es"],"21402":["Alta Tecnologia en Comunicacions","es"],"21414":["Avatel Movil","es"],"21422":["Digi.Mobil","es"],"21415":["BT Espana SAU","es"],"21418":["Cableuropa SAU (ONO)","es"],"21408":["Euskaltel Movil","es"],"214999":["Fix Line","es"],"21420":["fonYou Wireless SL","es"],"21432":["ION Mobile","es"],"21434":["ION MOBILE","es"],"21421":["Jazz Telecom SAU","es"],"21426":["Lleida","es"],"21425":["Lycamobile","es"],"21417":["mobil R","es"],"21438":["Movistar","es"],"21407":["Movistar","es"],"21405":["Movistar","es"],"21411":["Orange","es"],"21403":["Orange","es"],"21409":["Orange","es"],"21419":["Simyo","es"],"21435":["SUMA movil","es"],"21416":["mobil R","es"],"21427":["Truphone","es"],"21412":["Venus Movil","es"],"21401":["Vodafone","es"],"21437":["Vodafone","es"],"21406":["Vodafone","es"],"21429":["Yoigo","es"],"21404":["Yoigo","es"],"21423":["Yoigo","es"],"21433":["Yoigo","es"],"21410":["Zinnia","es"],"41305":["Airtel","lk"],"41303":["Etisalat/Tigo","lk"],"41308":["H3G Hutchison","lk"],"41301":["Mobitel Ltd.","lk"],"41302":["MTN/Dialog","lk"],"63400":["Canar Telecom","sd"],"634999":["Fix Line","sd"],"63422":["MTN","sd"],"63403":["MTN","sd"],"63402":["MTN","sd"],"63407":["Sudani One","sd"],"63415":["Sudani One","sd"],"63405":["Canar Telecom","sd"],"63408":["Canar Telecom","sd"],"63401":["Zain","sd"],"63406":["Zain","sd"],"74603":["Digicel","sr"],"746999":["Fix Line","sr"],"74601":["Telesur","sr"],"74602":["Telecommunicatiebedrijf Suriname (TELESUR)","sr"],"74604":["UNIQA","sr"],"65302":["Eswatini Mobile","sz"],"65301":["EswatiniTelecom","sz"],"65310":["Swazi MTN","sz"],"24016":["42 Telecom AB","se"],"24035":["42 Telecom","se"],"24013":["A3","se"],"24030":["NextGen Mobile Ltd (CardBoardFish)","se"],"24011":["Com Hem","se"],"24009":["Com4","se"],"24032":["Compatel","se"],"24022":["EUtel","se"],"24063":["Fink Telecom","se"],"240999":["Fix Line","se"],"24018":["Messit / Minicall","se"],"24027":["Globetouch","se"],"24017":["Gotanet","se"],"24002":["3","se"],"24023":["Infobip","se"],"24036":["interactive digital media / IDM","se"],"24028":["LINK Mobility","se"],"24012":["Lycamobile","se"],"24029":["MI Carrier Services","se"],"24033":["Mobile Arts","se"],"24043":["MobiWeb","se"],"24025":["Monty Mobile","se"],"24040":["Netmore","se"],"24039":["Primlight","se"],"24031":["Rebtel","se"],"24020":["Sierra Wireless","se"],"24015":["Sierra Wireless Sweden AB","se"],"24037":["Sinch","se"],"24045":["Spirius","se"],"24010":["Spring Mobil AB","se"],"24007":["Tele2","se"],"24005":["Tele2","se"],"24014":["Tele2","se"],"24044":["Telenabler","se"],"24024":["Telenor","se"],"24006":["Telenor","se"],"24042":["Telenor Connexion","se"],"24008":["Telenor","se"],"24004":["Telenor","se"],"24001":["Telia","se"],"24003":["Net 1","se"],"24048":["Tismi","se"],"24021":["Trafikverket","se"],"24026":["Twilio","se"],"24019":["Vectone Mobile","se"],"24046":["Viahub","se"],"24047":["Viatel","se"],"24038":["Voxbone / Bandwidth","se"],"22858":["Beeone","ch"],"22809":["Comfone","ch"],"22805":["Comfone","ch"],"228999":["Fix Line","ch"],"22807":["Sunrise","ch"],"22866":["Inovia","ch"],"22854":["Lycamobile","ch"],"22869":["MTEL","ch"],"22852":["Mundio Mobile AG","ch"],"22865":["Nexphone","ch"],"22851":["relario","ch"],"22803":["Salt Mobile","ch"],"22806":["SBB","ch"],"22853":["Sunrise","ch"],"22812":["Sunrise","ch"],"22808":["Sunrise","ch"],"22802":["Sunrise","ch"],"22860":["Sunrise","ch"],"22801":["Swisscom","ch"],"22862":["Telecom26","ch"],"22870":["Tismi","ch"],"22859":["Vectone Mobile","ch"],"41702":["MTN/Spacetel","sy"],"41709":["Syriatel Holdings","sy"],"41701":["Syriatel Holdings","sy"],"46668":["ACeS Taiwan - ACeS Taiwan Telecommunications Co Ltd","tw"],"46605":["Asia Pacific Telecom Co. Ltd (APT)","tw"],"46611":["Chunghwa Telecom LDM","tw"],"46692":["Chunghwa Telecom LDM","tw"],"46602":["Far EasTone","tw"],"46607":["Far EasTone","tw"],"46606":["Far EasTone","tw"],"46603":["Far EasTone","tw"],"46601":["Far EasTone","tw"],"46610":["Global Mobile Corp.","tw"],"46656":["International Telecom Co. Ltd (FITEL)","tw"],"46688":["KG Telecom","tw"],"46690":["T-Star/VIBO","tw"],"46699":["TransAsia","tw"],"46697":["Taiwan Cellular","tw"],"46693":["Mobitai","tw"],"46689":["T-Star/VIBO","tw"],"46609":["VMAX Telecom Co. Ltd","tw"],"43604":["Babilon-M","tj"],"43605":["Bee Line","tj"],"43602":["CJSC Indigo Tajikistan","tj"],"43612":["Tcell/JC Somoncom","tj"],"43603":["Megafon","tj"],"43601":["Tcell/JC Somoncom","tj"],"64005":["Airtel","tz"],"64008":["Benson Informatics Ltd","tz"],"64006":["Dovetel (T) Ltd","tz"],"64009":["Halotel / Viettel","tz"],"64099":["Mkulima African Telecommunication","tz"],"64014":["MO Mobile","tz"],"64011":["Smile Communications","tz"],"64007":["Tanzania Telecommunication Corporation","tz"],"64002":["Tigo / MIC","tz"],"64001":["Tri Telecomm. Ltd.","tz"],"64004":["Vodacom","tz"],"64013":["WiAfrica","tz"],"64003":["Zanzibar Telecom / Zantel","tz"],"52020":["ACeS Thailand - ACeS Regional Services Co Ltd","th"],"52015":["ACT Mobile","th"],"52003":["AIS/Advanced Info Service","th"],"52001":["AIS/Advanced Info Service","th"],"52023":["Digital Phone Co.","th"],"520999":["Fix Line","th"],"52000":["Hutch/CAT CDMA","th"],"52005":["Total Access (DTAC)","th"],"52018":["Total Access (DTAC)","th"],"52004":["True Move/Orange","th"],"52099":["True Move/Orange","th"],"61503":["Atlantique Telecom / Moov","tg"],"61502":["Telecel/MOOV","tg"],"61501":["Togo Cellulaire / TogoCel","tg"],"53988":["Digicel","to"],"539999":["Fix Line","to"],"53943":["Shoreline Communication","to"],"53901":["Tonga Communications","to"],"37412":["Bmobile/TSTT","tt"],"374120":["Bmobile/TSTT","tt"],"374130":["Digicel","tt"],"374140":["LaqTel Ltd.","tt"],"605999":["Fix Line","tn"],"60506":["Lycamobile","tn"],"60503":["Ooredoo","tn"],"60501":["Orange","tn"],"60502":["TT Mobile","tn"],"286299":["Asistan Telekom","tr"],"28604":["Avea","tr"],"28603":["Avea","tr"],"286999":["Fix Line","tr"],"28601":["Turkcell","tr"],"28602":["Vodafone","tr"],"43801":["MTS/Barash Communication","tm"],"43802":["Altyn Asyr/TM-Cell","tm"],"376350":["Cable & Wireless (TCI) Ltd","tc"],"376050":["Digicel TCI Ltd","tc"],"376352":["IslandCom Communications Ltd.","tc"],"55301":["Tuvalu Telecommunication Corporation (TTC)","tv"],"64101":["Airtel","ug"],"64122":["Airtel","ug"],"641999":["Fix Line","ug"],"64166":["i-Tel Ltd","ug"],"64130":["K2 Telecom Ltd","ug"],"64104":["Lycamobile","ug"],"64111":["Mango","ug"],"64110":["MTN","ug"],"64114":["Orange","ug"],"64133":["Smile","ug"],"64118":["Suretelecom Uganda Ltd","ug"],"25507":["3Mob","ua"],"25505":["Golden Telecom","ua"],"25539":["Golden Telecom","ua"],"25504":["IT","ua"],"25567":["KyivStar","ua"],"25502":["Kyivstar","ua"],"25503":["Kyivstar","ua"],"25506":["lifecell","ua"],"25521":["PEOPLEnet","ua"],"25599":["Phoenix","ua"],"25550":["Vodafone","ua"],"25501":["Vodafone","ua"],"25568":["Kyivstar","ua"],"42403":["DU","ae"],"42402":["Etisalat","ae"],"43102":["Etisalat","ae"],"43002":["Etisalat","ae"],"23499":["08Direct","gb"],"23478":["Airwave","gb"],"23429":["aql","gb"],"23476":["BT Group","gb"],"23400":["BT Group","gb"],"23408":["BT OnePhone","gb"],"23418":["Cloud9","gb"],"23502":["Everyth. Ev.wh.","gb"],"23432":["T-Mobile","gb"],"23431":["T-Mobile","gb"],"23430":["T-Mobile","gb"],"234999":["Fix Line","gb"],"23417":["FlexTel","gb"],"23404":["FMS Solutions","gb"],"23439":["Gamma Mobile","gb"],"23424":["Greenfone","gb"],"23472":["Hanhaa Mobile","gb"],"23471":["Home Office","gb"],"23420":["3","gb"],"23494":["3","gb"],"23423":["Icron Network","gb"],"23403":["Jersey Airtel","gb"],"23435":["JSC Ingenicum","gb"],"23450":["JT Mobile","gb"],"23414":["LINK Mobility","gb"],"23426":["Lycamobile","gb"],"23458":["Manx Telecom Mobile","gb"],"23428":["Marathon Telecom","gb"],"23475":["Mass Response Service GmbH","gb"],"23456":["NCSC","gb"],"23495":["Network Rail","gb"],"23412":["Network Rail","gb"],"23413":["Network Rail","gb"],"23451":["now broadband","gb"],"23434":["Orange","gb"],"23433":["Orange","gb"],"23474":["Pareteum","gb"],"23457":["Sky","gb"],"23440":["spusu","gb"],"23455":["Sure Guernsey","gb"],"23436":["Sure Isle of Man","gb"],"23437":["Synectiv","gb"],"23416":["Talk Talk","gb"],"23427":["Tata Communications Ltd","gb"],"23402":["O2","gb"],"23411":["O2","gb"],"23410":["O2","gb"],"23422":["Telesign Mobile","gb"],"23419":["TeleWare","gb"],"23409":["Tismi","gb"],"23425":["Truphone","gb"],"23401":["Vectone Mobile","gb"],"234998":["Virgin Mobile","gb"],"23438":["Virgin Mobile","gb"],"23407":["Vodafone","gb"],"23492":["Vodafone","gb"],"23489":["Vodafone","gb"],"23415":["Vodafone","gb"],"23491":["Vodafone","gb"],"23477":["Vodafone","gb"],"310850":["Aeris Comm. Inc.","us"],"310510":["Airtel Wireless LLC","us"],"310190":["Unknown","us"],"312090":["Allied Wireless Communications Corporation","us"],"310710":["Arctic Slope Telephone Association Cooperative Inc.","us"],"310410":["AT&T Wireless Inc.","us"],"310380":["AT&T Wireless Inc.","us"],"310170":["AT&T Wireless Inc.","us"],"310150":["AT&T Wireless Inc.","us"],"310680":["AT&T Wireless Inc.","us"],"310070":["AT&T Wireless Inc.","us"],"310560":["AT&T Wireless Inc.","us"],"310980":["AT&T Wireless Inc.","us"],"311810":["Bluegrass Wireless LLC","us"],"311800":["Bluegrass Wireless LLC","us"],"311440":["Bluegrass Wireless LLC","us"],"310900":["Cable & Communications Corp.","us"],"311590":["California RSA No. 3 Limited Partnership","us"],"311500":["Cambridge Telephone Company Inc.","us"],"310830":["Caprock Cellular Ltd.","us"],"311483":["Verizon Wireless","us"],"311110":["Verizon Wireless","us"],"311285":["Verizon Wireless","us"],"311488":["Verizon Wireless","us"],"311274":["Verizon Wireless","us"],"310010":["Verizon Wireless","us"],"311279":["Verizon Wireless","us"],"311288":["Verizon Wireless","us"],"310910":["Verizon Wireless","us"],"311284":["Verizon Wireless","us"],"311482":["Verizon Wireless","us"],"311487":["Verizon Wireless","us"],"311273":["Verizon Wireless","us"],"310004":["Verizon Wireless","us"],"311278":["Verizon Wireless","us"],"311287":["Verizon Wireless","us"],"310890":["Verizon Wireless","us"],"311283":["Verizon Wireless","us"],"311481":["Verizon Wireless","us"],"311486":["Verizon Wireless","us"],"311272":["Verizon Wireless","us"],"311277":["Verizon Wireless","us"],"310590":["Verizon Wireless","us"],"311282":["Verizon Wireless","us"],"311480":["Verizon Wireless","us"],"311485":["Verizon Wireless","us"],"311271":["Verizon Wireless","us"],"311276":["Verizon Wireless","us"],"310013":["Verizon Wireless","us"],"311281":["Verizon Wireless","us"],"311390":["Verizon Wireless","us"],"311484":["Verizon Wireless","us"],"311270":["Verizon Wireless","us"],"311286":["Verizon Wireless","us"],"311489":["Verizon Wireless","us"],"311275":["Verizon Wireless","us"],"310012":["Verizon Wireless","us"],"311280":["Verizon Wireless","us"],"311289":["Verizon Wireless","us"],"312280":["Cellular Network Partnership LLC","us"],"312270":["Cellular Network Partnership LLC","us"],"310360":["Cellular Network Partnership LLC","us"],"311120":["Choice Phone LLC","us"],"310480":["Choice Phone LLC","us"],"310420":["Cincinnati Bell Wireless LLC","us"],"310180":["Cingular Wireless","us"],"310620":["Coleman County Telco /Trans TX","us"],"31006":["Consolidated Telcom","us"],"31060":["Consolidated Telcom","us"],"310700":["Cross Valliant Cellular Partnership","us"],"312030":["Cross Wireless Telephone Co.","us"],"311140":["Cross Wireless Telephone Co.","us"],"312040":["Custer Telephone Cooperative Inc.","us"],"310440":["Dobson Cellular Systems","us"],"310990":["E.N.M.R. Telephone Coop.","us"],"312130":["East Kentucky Network LLC","us"],"312120":["East Kentucky Network LLC","us"],"310750":["East Kentucky Network LLC","us"],"310090":["Edge Wireless LLC","us"],"310610":["Elkhart TelCo. / Epic Touch Co.","us"],"311311":["Farmers","us"],"311460":["Fisher Wireless Services Inc.","us"],"311370":["GCI Communication Corp.","us"],"310430":["GCI Communication Corp.","us"],"310920":["Get Mobile Inc.","us"],"311340":["Illinois Valley Cellular RSA 2 Partnership","us"],"312170":["Iowa RSA No. 2 Limited Partnership","us"],"311410":["Iowa RSA No. 2 Limited Partnership","us"],"310770":["Iowa Wireless Services LLC","us"],"310650":["Jasper","us"],"310870":["Kaplan Telephone Company Inc.","us"],"312180":["Keystone Wireless LLC","us"],"310690":["Keystone Wireless LLC","us"],"311310":["Lamar County Cellular","us"],"310016":["Leap Wireless International Inc.","us"],"310040":["Matanuska Tel. Assn. Inc.","us"],"310780":["Message Express Co. / Airlink PCS","us"],"311330":["Michigan Wireless LLC","us"],"310400":["Minnesota South. Wirel. Co. / Hickory","us"],"311020":["Missouri RSA No 5 Partnership","us"],"311010":["Missouri RSA No 5 Partnership","us"],"312220":["Missouri RSA No 5 Partnership","us"],"312010":["Missouri RSA No 5 Partnership","us"],"311920":["Missouri RSA No 5 Partnership","us"],"310350":["Mohave Cellular LP","us"],"310570":["MTPCS LLC","us"],"310290":["NEP Cellcorp Inc.","us"],"31034":["Nevada Wireless LLC","us"],"310600":["New-Cell Inc.","us"],"311300":["Nexus Communications Inc.","us"],"310130":["North Carolina RSA 3 Cellular Tel. Co.","us"],"312230":["North Dakota Network Company","us"],"311610":["North Dakota Network Company","us"],"310450":["Northeast Colorado Cellular Inc.","us"],"311710":["Northeast Wireless Networks LLC","us"],"310670":["Northstar","us"],"310011":["Northstar","us"],"311420":["Northwest Missouri Cellular Limited Partnership","us"],"310999":["Various Networks","us"],"310760":["Panhandle Telephone Cooperative Inc.","us"],"310580":["PCS ONE","us"],"311170":["PetroCom","us"],"311670":["Pine Belt Cellular, Inc.","us"],"310100":["Plateau Telecommunications Inc.","us"],"310940":["Poka Lambro Telco Ltd.","us"],"310500":["Public Service Cellular Inc.","us"],"312160":["RSA 1 Limited Partnership","us"],"311430":["RSA 1 Limited Partnership","us"],"311350":["Sagebrush Cellular Inc.","us"],"31046":["SIMMETRY","us"],"311260":["SLO Cellular Inc / Cellular One of San Luis","us"],"310320":["Smith Bagley Inc.","us"],"31015":["Unknown","us"],"316011":["Southern Communications Services Inc.","us"],"312530":["Sprint Spectrum","us"],"310120":["Sprint Spectrum","us"],"316010":["Sprint Spectrum","us"],"312190":["Sprint Spectrum","us"],"311880":["Sprint Spectrum","us"],"311870":["Sprint Spectrum","us"],"311490":["Sprint Spectrum","us"],"310240":["T-Mobile","us"],"310660":["T-Mobile","us"],"310230":["T-Mobile","us"],"31031":["T-Mobile","us"],"310220":["T-Mobile","us"],"310270":["T-Mobile","us"],"310210":["T-Mobile","us"],"310260":["T-Mobile","us"],"310200":["T-Mobile","us"],"310250":["T-Mobile","us"],"310160":["T-Mobile","us"],"310800":["T-Mobile","us"],"310300":["T-Mobile","us"],"310280":["T-Mobile","us"],"310330":["T-Mobile","us"],"310310":["T-Mobile","us"],"310740":["Telemetrix Inc.","us"],"31014":["Testing","us"],"310950":["Unknown","us"],"310860":["Texas RSA 15B2 Limited Partnership","us"],"311830":["Thumb Cellular Limited Partnership","us"],"311050":["Thumb Cellular Limited Partnership","us"],"310460":["TMP Corporation","us"],"310490":["Triton PCS","us"],"311860":["Uintah Basin Electronics Telecommunications Inc.","us"],"310960":["Uintah Basin Electronics Telecommunications Inc.","us"],"312290":["Uintah Basin Electronics Telecommunications Inc.","us"],"310020":["Union Telephone Co.","us"],"311220":["United States Cellular Corp.","us"],"310730":["United States Cellular Corp.","us"],"311650":["United Wireless Communications Inc.","us"],"31038":["USA 3650 AT&T","us"],"310520":["VeriSign","us"],"310003":["Unknown","us"],"31023":["Unknown","us"],"31024":["Unknown","us"],"31025":["Unknown","us"],"310530":["West Virginia Wireless","us"],"31026":["Unknown","us"],"310340":["Westlink Communications, LLC","us"],"311070":["Wisconsin RSA #7 Limited Partnership","us"],"310390":["Yorkville Telephone Cooperative","us"],"74803":["Ancel/Antel","uy"],"74800":["Ancel/Antel","uy"],"74801":["Ancel/Antel","uy"],"74810":["Claro/AM Wireless","uy"],"74807":["MOVISTAR","uy"],"43404":["Bee Line/Unitel","uz"],"43401":["Buztel","uz"],"43407":["MTS/Uzdunrobita","uz"],"43405":["Ucell/Coscom","uz"],"43402":["Uzmacom","uz"],"54105":["DigiCel","vu"],"54101":["SMILE","vu"],"225299":["Failed Calls","va"],"73403":["DigiTel C.A.","ve"],"73402":["DigiTel C.A.","ve"],"73401":["DigiTel C.A.","ve"],"73406":["Movilnet C.A.","ve"],"73404":["Movistar/TelCel","ve"],"45207":["Gmobile","vn"],"45208":["I-Telecom","vn"],"45201":["MobiFone","vn"],"45209":["Reddi","vn"],"45203":["S-Fone/Telecom","vn"],"45205":["Vietnamobile","vn"],"45206":["Viettel","vn"],"45204":["Viettel","vn"],"45202":["VinaPhone","vn"],"37650":["Digicel","vi"],"543299":["Failed Calls","wf"],"54301":["Manuia","wf"],"421999":["Fix Line","ye"],"42104":["HITS/Y Unitel","ye"],"42102":["MTN/Spacetel","ye"],"42101":["Sabaphone","ye"],"42103":["Yemen Mob. CDMA","ye"],"64501":["Airtel","zm"],"645299":["Failed Calls","zm"],"64502":["MTN","zm"],"64503":["Zamtel","zm"],"64804":["Econet","zw"],"64801":["NetOne","zw"],"64803":["Telecel","zw"]},"i":{"289":"ge","412":"af","276":"al","603":"dz","544":"as","213":"ad","631":"ao","365":"ai","344":"ag","722":"ar","283":"am","363":"aw","505":"au","232":"at","400":"az","364":"bs","426":"bh","470":"bd","342":"bb","257":"by","206":"be","702":"bz","616":"bj","350":"bm","402":"bt","736":"bo","362":"bq","218":"ba","652":"bw","724":"br","348":"vg","528":"bn","284":"bg","613":"bf","642":"bi","456":"kh","624":"cm","302":"ca","625":"cv","346":"ky","623":"cf","622":"td","730":"cl","460":"cn","732":"co","654":"km","629":"cg","548":"ck","712":"cr","219":"hr","368":"cu","280":"cy","230":"cz","630":"cd","238":"dk","638":"dj","366":"dm","370":"do","514":"tl","740":"ec","602":"eg","706":"sv","627":"gq","657":"er","248":"ee","636":"et","750":"fk","288":"fo","542":"fj","244":"fi","208":"fr","340":"gf","547":"pf","628":"ga","607":"gm","282":"ge","262":"de","620":"gh","266":"gi","202":"gr","290":"gl","352":"gd","310":"gu","311":"gu","704":"gt","611":"gn","632":"gw","738":"gy","372":"ht","708":"hn","454":"hk","216":"hu","274":"is","404":"in","405":"in","510":"id","901":"n/a","432":"ir","418":"iq","272":"ie","425":"il","222":"it","612":"ci","338":"jm","440":"jp","441":"jp","416":"jo","401":"kz","639":"ke","545":"ki","221":"xk","419":"kw","437":"kg","457":"la","247":"lv","415":"lb","651":"ls","618":"lr","606":"ly","295":"li","246":"lt","270":"lu","455":"mo","646":"mg","650":"mw","502":"my","472":"mv","610":"ml","278":"mt","551":"mh","609":"mr","617":"mu","647":"yt","334":"mx","550":"fm","259":"md","212":"mc","428":"mn","297":"me","354":"ms","604":"ma","643":"mz","414":"mm","649":"na","429":"np","204":"nl","546":"nc","530":"nz","710":"ni","614":"ne","621":"ng","555":"nu","467":"kp","294":"mk","242":"no","422":"om","410":"pk","552":"pw","714":"pa","537":"pg","744":"py","716":"pe","515":"ph","260":"pl","268":"pt","330":"pr","427":"qa","226":"ro","250":"ru","635":"rw","658":"sh","356":"kn","358":"lc","308":"pm","360":"vc","549":"ws","292":"sm","626":"st","420":"sa","608":"sn","220":"rs","633":"sc","619":"sl","525":"sg","231":"sk","293":"si","540":"sb","637":"so","655":"za","450":"kr","659":"ss","214":"es","413":"lk","634":"sd","746":"sr","653":"sz","240":"se","228":"ch","417":"sy","466":"tw","436":"tj","640":"tz","520":"th","615":"tg","539":"to","374":"tt","605":"tn","286":"tr","438":"tm","376":"tc","553":"tv","641":"ug","255":"ua","424":"ae","431":"ae","430":"ae","234":"gb","235":"gb","312":"us","316":"us","748":"uy","434":"uz","541":"vu","225":"va","734":"ve","452":"vn","543":"wf","421":"ye","645":"zm","648":"zw"},"t":["302","310","311","312","313","314","315","316","334","338"]} \ No newline at end of file +{"c":{"00101":["Test Network, Used by GSM test equipment",""],"20201":["Cosmote","gr"],"20202":["Cosmote","gr"],"20203":["OTE","gr"],"20204":["OSE","gr"],"20205":["Vodafone","gr"],"20207":["AMD Telecom","gr"],"20209":["Info Quest S.A.","gr"],"20210":["Telestet","gr"],"20212":["Yuboto","gr"],"20214":["CyTa Mobile","gr"],"20215":["BWS","gr"],"20216":["Inter Telecom","gr"],"202299":["AMD Telecom","gr"],"202999":["Fix Line","gr"],"20400":["Intovoice","nl"],"20402":["T-Mobile","nl"],"20403":["Voiceworks NL","nl"],"20404":["Vodafone","nl"],"20405":["ElephantTalk","nl"],"20406":["Vectone Mobile","nl"],"20407":["Move / Teleena","nl"],"20408":["KPN Mobiel","nl"],"20409":["Lycamobile","nl"],"20410":["KPN","nl"],"20412":["KPN Mobiel","nl"],"20414":["6GMOBILE BV","nl"],"20415":["Ziggo","nl"],"20416":["Odido","nl"],"20417":["Intercity Mobile Communications BV","nl"],"20418":["Ziggo Services","nl"],"20420":["T-Mobile","nl"],"20421":["NS Railinfrabeheer B.V.","nl"],"20423":["KORE","nl"],"20424":["Private Mobility","nl"],"20426":["SpeakUp","nl"],"20427":["L-mobi","nl"],"20428":["Lancelot","nl"],"20429":["Tismi","nl"],"204299":["88 mobile","nl"],"20430":["ASPIDER Solutions","nl"],"20433":["Truphone","nl"],"20463":["MessageBird","nl"],"20465":["AGMS","nl"],"20468":["Unify Mobile","nl"],"20469":["KPN Lab","nl"],"20498":["Lancelot","nl"],"204999":["Fix Line","nl"],"20600":["Proximus","be"],"20601":["Proximus","be"],"20602":["Infrabel","be"],"20604":["Proximus","be"],"20605":["Telenet","be"],"20606":["Lycamobile","be"],"20607":["Vectone Mobile","be"],"20608":["VOOmobile","be"],"20610":["Orange","be"],"20620":["BASE","be"],"20623":["Dust Mobile","be"],"20625":["Dense Air","be"],"20628":["Bics","be"],"206299":["FEBO","be"],"20630":["Unleashed","be"],"20633":["Ericsson","be"],"20634":["onoff","be"],"20699":["Lancelot","be"],"206999":["Fix Line","be"],"20800":["Tel/Te","fr"],"20801":["Orange","fr"],"20802":["Orange","fr"],"20803":["MobiquiThings","fr"],"20804":["Netcom Group","fr"],"20805":["Globalstar Europe","fr"],"20806":["Globalstar Europe","fr"],"20807":["Globalstar Europe","fr"],"20808":["SFR","fr"],"20809":["SFR","fr"],"20810":["SFR","fr"],"20811":["SFR","fr"],"20812":["Truphone","fr"],"20813":["SFR","fr"],"20814":["Free Mobile","fr"],"20815":["Free","fr"],"20816":["Free Mobile","fr"],"20817":["Legos","fr"],"208180":["Private FR","fr"],"20820":["Bouygues Telecom","fr"],"20821":["Bouygues Telecom","fr"],"20822":["Transatel","fr"],"20823":["Virgin","fr"],"20824":["MobiquiThings","fr"],"20825":["Lycamobile","fr"],"20826":["NRJ","fr"],"20827":["Coriolis","fr"],"20828":["Airmob","fr"],"20829":["Orange","fr"],"208299":["Add-On Multimedia","fr"],"20830":["Syma Mobile","fr"],"20831":["Vectone Mobile","fr"],"20832":["Orange","fr"],"20834":["Cellhire","fr"],"20835":["Free Mobile","fr"],"20836":["Free Mobile","fr"],"20837":["IP Directions","fr"],"20838":["Lebara","fr"],"20839":["Networth Telecom","fr"],"208506":["Airbus FR","fr"],"20888":["Bouygues Telecom","fr"],"20889":["Hub One","fr"],"20891":["Orange","fr"],"20892":["IP Directions","fr"],"20894":["Halys","fr"],"208999":["Fix Line","fr"],"21201":["Monaco Telecom","mc"],"21210":["MONACO TELECOM","mc"],"21303":["Mobiland","ad"],"21401":["Vodafone","es"],"21402":["Altecom","es"],"21403":["Orange","es"],"21404":["Yoigo","es"],"21405":["Movistar","es"],"21406":["Euskaltel","es"],"21407":["Movistar","es"],"21408":["Euskaltel","es"],"21409":["Orange","es"],"21410":["Zinnia","es"],"21411":["Orange","es"],"21412":["Venus Movil","es"],"21414":["Avatel Movil","es"],"21415":["BT Espana SAU","es"],"21416":["mobil R","es"],"21417":["mobil R","es"],"21418":["ONO","es"],"21419":["Simyo","es"],"21420":["Fonyou Telecom","es"],"21421":["Jazz Telecom SAU","es"],"21422":["Digi Spain","es"],"21423":["Yoigo","es"],"21425":["Lycamobile","es"],"21426":["Lleida","es"],"21427":["Truphone","es"],"21429":["Yoigo","es"],"214299":["ACN","es"],"21432":["ION Mobile","es"],"21433":["Yoigo","es"],"21434":["ION Mobile","es"],"21435":["SUMA movil","es"],"21436":["Alai","es"],"21437":["Vodafone","es"],"21438":["Movistar","es"],"214999":["Fix Line","es"],"21601":["Yettel","hu"],"21602":["MVM NET","hu"],"21603":["Digi","hu"],"216299":["Antenna","hu"],"21630":["Magyar Telekom","hu"],"21670":["Vodafone","hu"],"21671":["UPC Magyarorszag Kft.","hu"],"216999":["Fix line","hu"],"21803":["Eronet Mobile Communications Ltd.","ba"],"21805":["MOBI'S (Mobilina Srpske)","ba"],"21890":["GSMBIH","ba"],"21901":["Hrvatski Telekom","hr"],"21902":["Telemach","hr"],"21910":["A1/Tomato","hr"],"21912":["TELE FOCUS","hr"],"21920":["Hrvatski Telekom","hr"],"219999":["Fix Line","hr"],"22001":["Yettel","rs"],"22002":["Yettel","rs"],"22003":["Telekom Srbija a.d.","rs"],"22005":["A1 SRB","rs"],"22011":["Globaltel","rs"],"22020":["VIP","rs"],"220299":["Failed Calls","rs"],"22101":["Vala","xk"],"22102":["IPKO","xk"],"22103":["MTS","xk"],"22106":["Dardafon.Net LLC","xk"],"22107":["D3 mobile","xk"],"221299":["MTS","xk"],"22200":["Premium Numbers","it"],"22201":["TIM","it"],"22202":["Elsacom","it"],"22206":["Vodafone","it"],"22207":["Kena","it"],"22208":["Fastweb SpA","it"],"22210":["Vodafone","it"],"222299":["A-Tono","it"],"22230":["RFI","it"],"22233":["Poste Mobile","it"],"22234":["BT mobile","it"],"22235":["Lycamobile","it"],"22236":["Digi Italy","it"],"22237":["WindTre / Hi3G","it"],"22239":["SMS.it / LINK Mobility","it"],"22240":["Agile Telecom","it"],"22242":["Enel","it"],"22243":["Telecom Italia Mobile","it"],"22244":["Mundio","it"],"22248":["Telecom Italia Mobile","it"],"22249":["Vianova Mobile","it"],"22250":["Iliad","it"],"22251":["ho.","it"],"22253":["WEB CoopVoce","it"],"22254":["Plintron","it"],"22256":["Spusu IT","it"],"22258":["rdcom","it"],"22277":["IPSE 2000","it"],"22288":["WINDTRE","it"],"22298":["Blu","it"],"22299":["WINDTRE","it"],"222999":["Fix Line","it"],"225299":["Failed Calls","va"],"22601":["Vodafone","ro"],"22602":["Romtelecom SA","ro"],"22603":["Telekom","ro"],"22604":["Telekom Romania","ro"],"22605":["Digi.Mobil","ro"],"22606":["Telekom Romania","ro"],"22610":["Orange","ro"],"22611":["Enigma Systems","ro"],"22616":["Lycamobile","ro"],"226299":["Iristel","ro"],"22801":["Swisscom","ch"],"22802":["Sunrise","ch"],"22803":["Salt","ch"],"22805":["Comfone AG","ch"],"22806":["SBB AG","ch"],"22807":["IN&Phone SA","ch"],"22808":["Tele2 Telecommunications AG","ch"],"22809":["Comfone","ch"],"22812":["Sunrise","ch"],"22851":["Bebbicell AG","ch"],"22852":["Mundio Mobile AG","ch"],"22853":["Sunrise","ch"],"22854":["Lycamobile","ch"],"22858":["Beeone","ch"],"22859":["Vectone Mobile","ch"],"22860":["Sunrise","ch"],"22862":["Telecom26","ch"],"22865":["Nexphone","ch"],"22866":["Inovia","ch"],"22869":["MTEL","ch"],"22870":["Tismi","ch"],"22871":["Spusu CH","ch"],"228999":["Fix Line","ch"],"23001":["T-Mobile","cz"],"23002":["O2","cz"],"23003":["Vodafone","cz"],"23004":["Mobilkom a.s.","cz"],"23005":["PODA","cz"],"23007":["T-Mobile","cz"],"23008":["Compatel","cz"],"23009":["Uniphone","cz"],"230299":["+4U Mobile","cz"],"23098":["Sprava Zeleznicni Dopravni Cesty","cz"],"23099":["Vodafone","cz"],"230999":["Fix Line","cz"],"23101":["Orange","sk"],"23102":["Slovak Telekom","sk"],"23103":["4ka SK","sk"],"23104":["Eurotel, UMTS","sk"],"23105":["Orange, UMTS","sk"],"23106":["O2","sk"],"23107":["Orange","sk"],"23108":["Uniphone","sk"],"23115":["Orange","sk"],"231299":["Vonage","sk"],"23150":["Telekom","sk"],"23199":["ZSR","sk"],"23201":["A1 Telekom","at"],"23202":["A1 Telekom","at"],"23203":["Magenta Telekom","at"],"23204":["T-Mobile / Magenta","at"],"23205":["Drei","at"],"23206":["Hutchison Drei / 3","at"],"23207":["Magenta Telekom","at"],"23208":["Telefonica Austria","at"],"23209":["A1 Telekom","at"],"23210":["Drei","at"],"23211":["A1 Telekom","at"],"23212":["A1 Telekom","at"],"23213":["T-Mobile / Magenta","at"],"23214":["Hutchinson Drei","at"],"23215":["T-Mobile / Magenta","at"],"23216":["Hutchinson Drei","at"],"23217":["Spusu AT","at"],"23218":["smartspace","at"],"23219":["Hutchinson Drei","at"],"23220":["Mtel","at"],"23222":["Plintron","at"],"23223":["T-Mobile / Magenta","at"],"23224":["Smartel Services","at"],"23225":["Holding Graz","at"],"23226":["LIWEST Mobil","at"],"23227":["Tismi","at"],"232299":["ArgoNET","at"],"23291":["OBB Infrastruktur","at"],"232999":["Fix Line","at"],"23400":["British Telecom","gb"],"23401":["Mapesbury Communications Ltd.","gb"],"23402":["O2","gb"],"23403":["Jersey Telenet Ltd","gb"],"23404":["FMS Solutions Ltd","gb"],"23405":["Spitfire Network Services Ltd","gb"],"23406":["Internet One Ltd","gb"],"23407":["Cable and Wireless plc","gb"],"23408":["BT OnePhone","gb"],"23409":["Wire9 Telecom plc","gb"],"23410":["O2","gb"],"23411":["O2","gb"],"23412":["Ntework Rail Infrastructure Ltd","gb"],"23413":["Ntework Rail Infrastructure Ltd","gb"],"23414":["Hay Systems Ltd","gb"],"23415":["Vodafone","gb"],"23416":["Opal Telecom Ltd","gb"],"23417":["Flextel Ltd","gb"],"23418":["Wire9 Telecom plc","gb"],"23419":["Teleware plc","gb"],"23420":["Three Mobile","gb"],"23422":["Telesign Mobile","gb"],"23423":["Icron Network","gb"],"23424":["Greenfone","gb"],"23425":["Truphone","gb"],"23426":["Lycamobile","gb"],"23427":["Tata Communications Ltd","gb"],"23428":["Marathon Telecom","gb"],"23429":["aql","gb"],"23430":["EE","gb"],"23431":["EE","gb"],"23432":["EE","gb"],"23433":["EE","gb"],"23434":["Orange","gb"],"23435":["JSC Ingenicum","gb"],"23436":["Sure Isle of Man","gb"],"23437":["Synectiv","gb"],"23438":["Virgin Mobile","gb"],"23439":["Gamma","gb"],"23440":["Spusu GB","gb"],"23450":["Jersey Telecom","gb"],"23451":["now broadband","gb"],"23453":["TANGO","gb"],"23455":["Cable and Wireless Guensey Ltd","gb"],"23456":["NCSC","gb"],"23457":["Sky","gb"],"23458":["Manx Telecom","gb"],"23471":["Emergency Services Network","gb"],"23472":["Hanhaa Mobile","gb"],"23474":["Pareteum","gb"],"23475":["Inquam Telecom (Holdings) Ltd.","gb"],"23476":["British Telecom","gb"],"23477":["Vodafone","gb"],"23478":["Airwave mmO2 Ltd","gb"],"23486":["EE","gb"],"23489":["Vodafone","gb"],"23491":["Vodafone","gb"],"23492":["Vodafone","gb"],"23494":["Three Mobile","gb"],"23495":["Network Rail","gb"],"23499":["08Direct","gb"],"234998":["Virgin Mobile","gb"],"234999":["Fix Line","gb"],"23502":["Everyth. Ev.wh.","gb"],"23594":["Three Mobile","gb"],"23801":["TDC Mobil","dk"],"23802":["Telenor","dk"],"23803":["MIGway A/S","dk"],"23804":["Nexcon.io","dk"],"23806":["3","dk"],"23807":["Barablu Mobile Ltd.","dk"],"23808":["Voxbone / Bandwidth","dk"],"23810":["TDC Mobil","dk"],"23812":["Lycamobile","dk"],"23813":["Compatel","dk"],"23814":["Monty Mobile","dk"],"23815":["Net 1","dk"],"23816":["Tismi","dk"],"23817":["Gotanet","dk"],"23820":["Telia","dk"],"23823":["Banedanmark","dk"],"23825":["Viahub","dk"],"23828":["LINK Mobility","dk"],"23830":["Telia","dk"],"23842":["Greenwave","dk"],"23866":["Telenor","dk"],"23873":["Onomondo","dk"],"23877":["Tele2","dk"],"23888":["Cobira","dk"],"23896":["Telia","dk"],"238999":["Fix Line","dk"],"24001":["Telia Sverige AB","se"],"24002":["3 (Hi3G Access AB)","se"],"24003":["Nordisk Mobiltelefon AS","se"],"24004":["3G Infrastructure Services AB","se"],"24005":["Svenska UMTS-Nät AB","se"],"24006":["Vimla","se"],"24007":["Tele2/Comviq Sverige/Com Hem","se"],"24008":["Telenor Sverige AB","se"],"24009":["Telenor Sweden (not used)","se"],"24010":["Spring Mobil AB","se"],"24011":["Linholmen Science Park AB","se"],"24012":["Barablu Mobile Scandinavia Ltd","se"],"24013":["Ventelo Sverige AB","se"],"24014":["TDC Mobil A/S","se"],"24015":["Wireless Maingate Nordic AB","se"],"24016":["42IT AB","se"],"24017":["Gotanet","se"],"24018":["Messit / Minicall","se"],"24019":["Vectone Mobile","se"],"24020":["Wireless Maingate Message Services AB","se"],"24021":["Banverket","se"],"24022":["EUtel","se"],"24023":["Infobip","se"],"24024":["Telenor","se"],"24025":["Monty Mobile","se"],"24026":["Twilio","se"],"24027":["Globetouch","se"],"24028":["LINK Mobility","se"],"24029":["MI Carrier Services","se"],"24030":["NextGen Mobile Ltd (CardBoardFish)","se"],"24031":["Rebtel","se"],"24032":["Compatel","se"],"24033":["Mobile Arts","se"],"24035":["42 Telecom","se"],"24036":["interactive digital media / IDM","se"],"24037":["Sinch","se"],"24038":["Voxbone / Bandwidth","se"],"24039":["Primlight","se"],"24040":["Netmore","se"],"24042":["Telenor Connexion","se"],"24043":["MobiWeb","se"],"24044":["Telenabler","se"],"24045":["Spirius","se"],"24046":["Viahub","se"],"24047":["Viatel","se"],"24048":["Tismi","se"],"24050":["Telavox","se"],"24063":["Fink Telecom","se"],"240999":["Fix Line","se"],"24201":["Telenor","no"],"242017":["Ventelo AS","no"],"24202":["Telia","no"],"24203":["Teletopia Mobile Communications AS","no"],"24204":["Tele2 Norge AS","no"],"24205":["OneCall","no"],"24206":["ICE","no"],"24207":["Ventelo AS","no"],"24208":["TDC Mobil A/S","no"],"24209":["com4","no"],"24210":["Nkom","no"],"24212":["Telenor","no"],"24214":["Ice Norway","no"],"24215":["eRate","no"],"24216":["Iristel","no"],"24220":["BANE NOR","no"],"24221":["BANE NOR","no"],"24222":["Altibox Mobil","no"],"24223":["Lycamobile","no"],"242299":["bigblu","no"],"242999":["Fix Line","no"],"24403":["DNA","fi"],"24404":["Finnet Networks Ltd.","fi"],"24405":["Elisa","fi"],"24406":["Elisa","fi"],"24407":["Nokia Test Network","fi"],"24408":["Unknown","fi"],"24409":["Finnet Group","fi"],"24410":["TDC","fi"],"24411":["Viahub","fi"],"24412":["DNA","fi"],"24413":["DNA","fi"],"24414":["Alands Mobiltelefon AB","fi"],"24415":["Telit","fi"],"24416":["Oy Finland Tele2 AB","fi"],"24421":["Elisa","fi"],"24424":["Nord Connect","fi"],"24426":["Compatel","fi"],"24429":["Scnl Truphone","fi"],"244299":["Benemen","fi"],"24432":["Voxbone / Bandwidth","fi"],"24433":["VIRVE","fi"],"24435":["Ukko Mobile","fi"],"24436":["Telia","fi"],"24437":["Tismi","fi"],"24438":["NSN","fi"],"24439":["NSN","fi"],"24440":["NSN","fi"],"24441":["NSN","fi"],"24442":["Viahub","fi"],"24443":["Telavox","fi"],"24445":["VIRVE","fi"],"24446":["VIRVE","fi"],"24447":["VIRVE","fi"],"24482":["interactive digital media / IDM","fi"],"24491":["Telia","fi"],"24601":["Telia","lt"],"24602":["BITĖ","lt"],"24603":["Tele2","lt"],"24605":["LTG","lt"],"24606":["Mediafon","lt"],"246299":["SkyCall","lt"],"24701":["LMT","lv"],"24702":["Tele2/ZZ","lv"],"24703":["Telekom Baltija","lv"],"24704":["Beta Telecom","lv"],"24705":["Bite","lv"],"24706":["SIA Rigatta","lv"],"24707":["SIA Master Telecom","lv"],"24708":["VENTA Mobile","lv"],"24709":["XOmobile","lv"],"24710":["LMT","lv"],"247299":["Premium Numbers","lv"],"24801":["Telia","ee"],"24802":["Elisa","ee"],"24803":["Tele2","ee"],"24804":["OY Top Connect","ee"],"24805":["AS Bravocom Mobiil","ee"],"24806":["OY ViaTel","ee"],"24807":["Televõrgu AS","ee"],"24813":["Telia","ee"],"24871":["Siseministeerium (Ministry of Interior)","ee"],"25001":["МТС","ru"],"25002":["MegaFon","ru"],"25003":["Tele2","ru"],"25004":["Sibchallenge","ru"],"25005":["Tele2","ru"],"250050":["Sberbank-Telecom","ru"],"25007":["BM Telecom","ru"],"25009":["Skylink","ru"],"25010":["Don Telecom","ru"],"25011":["Orensot","ru"],"25012":["Tele2","ru"],"25013":["Kuban GSM","ru"],"25015":["ZAO SMARTS","ru"],"25016":["New Telephone Company","ru"],"25017":["Tele2","ru"],"25019":["Volgograd Mobile","ru"],"25020":["Tele2","ru"],"25026":["VTB Mobile","ru"],"25028":["Extel","ru"],"250299":["A-Mobile","ru"],"25032":["Win Mobile","ru"],"25033":["SEVTELECOM","ru"],"25034":["Krymtelecom","ru"],"25035":["Motiv","ru"],"25039":["Tele2","ru"],"25042":["MTT","ru"],"25044":["Stuvtelesot","ru"],"25047":["Next Mobile","ru"],"25048":["Global Telecom","ru"],"25050":["Sberbank","ru"],"25054":["Letai Mobile","ru"],"25055":["Glonass","ru"],"25057":["Matrix Mobile","ru"],"25060":["Volna Mobile","ru"],"25062":["Tinkoff","ru"],"25077":["Glonass","ru"],"25092":["Printelefone","ru"],"25093":["Telecom XXI","ru"],"25097":["Phoenix","ru"],"25099":["Билайн","ru"],"250999":["Fix Line","ru"],"25501":["Ukrainian Mobile Communication, UMC","ua"],"25502":["T-Mobile - UA","ua"],"25503":["Kyivstar GSM","ua"],"25504":["International Telecommunications Ltd.","ua"],"25505":["Golden Telecom","ua"],"25506":["Astelit","ua"],"25507":["Ukrtelecom","ua"],"25521":["CJSC - Telesystems of Ukraine","ua"],"25539":["Golden Telecom","ua"],"25550":["Vodafone","ua"],"25567":["KyivStar","ua"],"25568":["Kyivstar","ua"],"25599":["Phoenix","ua"],"25701":["A1 BY","by"],"25702":["MTS","by"],"25703":["BelCel JV","by"],"25704":["life:)","by"],"25901":["Orange Moldova GSM","md"],"25902":["Moldcell","md"],"25903":["Unite","md"],"25904":["Eventis Mobile GSM","md"],"25905":["Unité","md"],"25999":["Unite","md"],"26001":["Plus","pl"],"26002":["T-Mobile","pl"],"26003":["Orange","pl"],"26004":["Tele2 Polska (Tele2 Polska Sp. Z.o.o.)","pl"],"26005":["IDEA (UMTS)/PTK Centertel sp. Z.o.o.","pl"],"26006":["PLAY","pl"],"26007":["Premium internet","pl"],"26008":["E-Telko","pl"],"26009":["Telekomunikacja Kolejowa (GSM-R)","pl"],"26010":["Telefony Opalenickie","pl"],"26011":["NORDISK Polska","pl"],"26012":["Cyfrowy Polsat","pl"],"26013":["Move","pl"],"26014":["Move","pl"],"26015":["Aero2","pl"],"26016":["Aero2","pl"],"26017":["Aero2","pl"],"26018":["AMD Telecom","pl"],"26019":["NetBalt","pl"],"26020":["Tismi","pl"],"26022":["Twilio","pl"],"26027":["Ntel Solutions","pl"],"260299":["3S","pl"],"26032":["Compatel","pl"],"26034":["T-Mobile","pl"],"26035":["PKP","pl"],"26036":["Mundio Mobile Sp. z o.o.","pl"],"26038":["CallFreedom Sp. z o.o.","pl"],"26039":["Voxbone / Bandwidth","pl"],"26041":["EZ Mobile","pl"],"26042":["MobiWeb","pl"],"26044":["Rebtel","pl"],"26045":["Virgin Mobile","pl"],"26047":["SMSHIGHWAY","pl"],"26048":["Agile Telecom","pl"],"26049":["Messagebird","pl"],"26090":["Polska Spolka Gazownictwa","pl"],"26097":["Politechnika Lodzka Uczelniane","pl"],"26098":["Play","pl"],"260999":["Fix Line","pl"],"26201":["Telekom","de"],"26202":["Vodafone","de"],"26203":["O2","de"],"26204":["Vodafone","de"],"26205":["Telefonica / E-Plus","de"],"26206":["Telekom","de"],"26207":["O2","de"],"26208":["Telefonica / O2","de"],"26209":["Vodafone Lab","de"],"26210":["Arcor AG & Co.","de"],"26211":["O2","de"],"26212":["Dolphin Telecom (Deutschland) GmbH","de"],"26213":["Mobilcom Multimedia GmbH","de"],"26214":["Group 3G UMTS GmbH (Quam)","de"],"26215":["Airdata AG","de"],"26216":["Telefonica / O2","de"],"26217":["Telefonica / E-Plus","de"],"26220":["Voiceworks DE","de"],"26221":["Multiconnect","de"],"26222":["sipgate","de"],"26223":["1&1","de"],"26224":["TelcoVillage","de"],"262299":["1&1","de"],"26233":["sipgate","de"],"26242":["Vodafone","de"],"26243":["Lycamobile","de"],"26276":["Siemens AG, ICMNPGUSTA","de"],"26277":["Telefonica / E-Plus","de"],"26278":["Telekom / T-mobile","de"],"262999":["Fix Line","de"],"26601":["Gibtelecom GSM","gi"],"26606":["CTS Mobile","gi"],"26609":["Cloud9 Mobile Communications","gi"],"266299":["GibFibreSpeed","gi"],"266999":["Fix Line","gi"],"26801":["Vodafone","pt"],"26802":["Digi Portugal","pt"],"26803":["NOS","pt"],"26804":["Lycamobile","pt"],"26805":["Oniway - Inforcomunicaçôes, S.A.","pt"],"26806":["MEO","pt"],"26807":["NOS","pt"],"26808":["MEO","pt"],"268299":["NOWO","pt"],"26880":["MEO","pt"],"26891":["Vodafone","pt"],"26893":["NOS","pt"],"268999":["Fix Line","pt"],"27001":["P&T Luxembourg","lu"],"27002":["MTX","lu"],"27005":["Luxembourg Online","lu"],"27010":["Blue Communications","lu"],"270299":["Bouygues Telecom","lu"],"27077":["Tango","lu"],"27081":["e-LUX Mobile","lu"],"27099":["Orange","lu"],"270999":["Fix Line","lu"],"27201":["Vodafone","ie"],"27202":["3","ie"],"27203":["Meteor Mobile Communications Ltd.","ie"],"27204":["Access Telecom","ie"],"27205":["3","ie"],"27207":["Eircom","ie"],"27208":["Meteor / eir mobile","ie"],"27209":["Clever Communications Ltd.","ie"],"27211":["Tesco Mobile","ie"],"27213":["Lycamobile","ie"],"27215":["Virgin Media","ie"],"27217":["3","ie"],"27225":["Sky IE","ie"],"27401":["Iceland Telecom Ltd.","is"],"27402":["Tal hf","is"],"27403":["Islandssimi GSM ehf","is"],"27404":["IMC Islande ehf","is"],"27405":["Vodafone","is"],"27407":["IceCell ehf","is"],"27408":["Siminn","is"],"27409":["Amitelo","is"],"27411":["Nova","is"],"27412":["Vodafone","is"],"27416":["Tismi","is"],"27431":["Siminn","is"],"27601":["One / AMC","al"],"27602":["Vodafone","al"],"27603":["Eagle Mobile","al"],"27604":["PLUS Communication Sh.a","al"],"27801":["Epic","mt"],"27821":["go mobile","mt"],"27830":["GO Mobile","mt"],"27877":["Melita","mt"],"278999":["Fix Line","mt"],"28001":["CYTA","cy"],"28002":["Cytamobile-Vodafone","cy"],"28010":["epic","cy"],"28020":["PrimeTel","cy"],"28022":["Cablenet","cy"],"280999":["Fix Line","cy"],"28201":["Geocell Ltd.","ge"],"28202":["Magti GSM Ltd.","ge"],"28203":["Iberiatel Ltd.","ge"],"28204":["Mobitel Ltd.","ge"],"28205":["Silknet","ge"],"28207":["GlobalCell","ge"],"28208":["Silknet","ge"],"28210":["Premium Net","ge"],"28211":["Mobilive","ge"],"28212":["Telecom 1","ge"],"28222":["MyPhone","ge"],"28301":["ArmenTel","am"],"28304":["Karabakh Telecom","am"],"28305":["K Telecom CJSC","am"],"28310":["Orange","am"],"28401":["A1","bg"],"28403":["VIVACOM","bg"],"28405":["Yettel","bg"],"28406":["Vivacom","bg"],"28411":["bulsatcom","bg"],"28413":["MAX TELECOM","bg"],"28601":["Paycell | Turkcell","tr"],"28602":["Vodafone","tr"],"28603":["Türk Telekom","tr"],"28604":["Türk Telekom","tr"],"286299":["Asistan Telekom","tr"],"286999":["Fix Line","tr"],"28801":["Faroese Telecom - GSM","fo"],"28802":["Kall GSM","fo"],"28803":["Tosa","fo"],"28967":["Aquafon","ge"],"28968":["A-Mobile","ge"],"28988":["A-Mobile","ge"],"29001":["Tele Greenland","gl"],"29201":["SMT - San Marino Telecom","sm"],"292299":["TeleneT","sm"],"29310":["Slovenske zeleznice","si"],"29320":["Compatel","si"],"293299":["HOT mobil","si"],"29340":["SI Mobil","si"],"29341":["Telekom Slovenije","si"],"29364":["T-2 d.o.o.","si"],"29370":["Telemach","si"],"29386":["Elektro Gorenjska","si"],"293999":["Fix Line","si"],"29401":["Mkedonski Telecom AD Skopje","mk"],"29402":["Cosmofon","mk"],"29403":["Nov Operator","mk"],"29404":["Lycamobile","mk"],"29411":["Mobik","mk"],"294299":["Failed Calls","mk"],"29475":["A1","mk"],"29501":["Telecom FL AG","li"],"29502":["Viag Europlatform AG","li"],"29505":["Mobilkom (Liechstein) AG","li"],"29506":["CUBIC","li"],"29507":["First Mobile AG","li"],"29509":["EMnify","li"],"295299":["Datamobile","li"],"29577":["Tele2 AG","li"],"29701":["ONE","me"],"29702":["Crnogorski Telekom","me"],"29703":["MTEL d.o.o. Podgorica","me"],"302130":["Xplornet","ca"],"302131":["Xplornet","ca"],"302220":["Telus Mobility","ca"],"302270":["EastLink","ca"],"302290":["Airtel Wireless","ca"],"302320":["Chatr Mobile","ca"],"30236":["Clearnet","ca"],"302360":["Clearnet","ca"],"302361":["Clearnet","ca"],"302370":["FIDO (Rogers AT&T/ Microcell)","ca"],"302380":["DMTS Mobility","ca"],"302490":["Freedom Mobile","ca"],"302500":["Videotron","ca"],"302510":["Videotron","ca"],"302520":["Videotron","ca"],"302610":["Bell Mobility","ca"],"30262":["Ice Wireless","ca"],"30263":["Aliant Mobility","ca"],"302630":["Bell Mobility","ca"],"30264":["Bell Mobility","ca"],"302640":["Bell Mobility","ca"],"302651":["Bell Mobility","ca"],"302652":["BC Tel Mobility","ca"],"302653":["Telus Mobility","ca"],"302654":["Sask Tel Mobility","ca"],"302655":["MTS Mobility","ca"],"302656":["Tbay Mobility","ca"],"302657":["Quebectel Mobility","ca"],"302660":["MTS Mobility","ca"],"30267":["CityTel Mobility","ca"],"302670":["CityWest Mobility","ca"],"30268":["Sask Tel Mobility","ca"],"302680":["Sask Tel Mobility","ca"],"302681":["Sask Tel Mobility","ca"],"302701":["NB Tel Mobility","ca"],"302702":["MT&T Mobility","ca"],"302703":["New Tel Mobility","ca"],"30271":["Globalstar","ca"],"302710":["Globalstar Canada","ca"],"30272":["Rogers","ca"],"302720":["Rogers","ca"],"302760":["Public Mobile","ca"],"302780":["Sask Tel Mobility","ca"],"302781":["Sask Tel Mobility","ca"],"30801":["St. Pierre-et-Miquelon Télécom","pm"],"30808":["St. Pierre-et-Miquelon Télécom","pm"],"310003":["Unknown","us"],"310004":["Verizon Wireless","us"],"310010":["MCI","us"],"310011":["Northstar","us"],"310012":["Verizon Wireless","us"],"310013":["Mobile Tel Inc.","us"],"310014":["Testing US","us"],"310016":["Leap Wireless International Inc.","us"],"310017":["North Sight Communications Inc.","us"],"310020":["Union Telephone Company","us"],"310023":["C Spire","us"],"310026":["T-Mobile - US","us"],"310028":["ALU Test-SIM","us"],"310030":["AT&T","us"],"310032":["IT&E OverSeas","gu"],"310033":["Guam Teleph. Auth","gu"],"310034":["Nevada Wireless LLC","us"],"310040":["MTA Communications dba MTA Wireless","us"],"310050":["ACS Wireless Inc.","us"],"31006":["Consolidated Telcom","us"],"310060":["Consolidated Telcom","us"],"310070":["AT&T","us"],"310080":["Corr Wireless Communications LLC","us"],"310090":["Edge Wireless LLC","us"],"310100":["New Mexico RSA 4 East Ltd. Partnership","us"],"310110":["Pacific Telecom Inc","us"],"310120":["Sprint","us"],"310130":["Carolina West Wireless","us"],"31014":["Testing","us"],"310140":["GTA Wireless LLC","us"],"31015":["Unknown","us"],"310150":["Cricket Wireless","us"],"310160":["T-Mobile - US","us"],"310170":["AT&T","us"],"310180":["West Central Wireless","us"],"310190":["Alaska Wireless Communications LLC","us"],"310200":["T-Mobile - US","us"],"310210":["T-Mobile - US","us"],"310220":["T-Mobile - US","us"],"31023":["Unknown","us"],"310230":["T-Mobile - US","us"],"31024":["Unknown","us"],"310240":["T-Mobile - US","us"],"31025":["Unknown","us"],"310250":["T-Mobile - US","us"],"31026":["T-Mobile - US","us"],"310260":["T-Mobile - US","us"],"310270":["T-Mobile - US","us"],"310280":["AT&T","us"],"310290":["Nep Cellcorp Inc.","us"],"310300":["T-Mobile - US","us"],"31031":["T-Mobile","us"],"310310":["T-Mobile - US","us"],"310320":["Smith Bagley Inc, dba Cellular One","us"],"310330":["AN Subsidiary LLC","us"],"31034":["Nevada Wireless LLC","us"],"310340":["High Plains Midwest LLC, dba Wetlink Communications","us"],"310350":["Mohave Cellular L.P.","us"],"310360":["Cellular Network Partnership dba Pioneer Cellular","us"],"310370":["Guamcell Cellular and Paging","us"],"31038":["USA 3650 AT&T","us"],"310380":["AT&T","us"],"310390":["TX-11 Acquistion LLC","us"],"310400":["Wave Runner LLC","us"],"310410":["AT&T","us"],"310420":["Cincinnati Bell Wireless LLC","us"],"310430":["Alaska Digitel LLC","us"],"310440":["Numerex Corp.","us"],"310450":["North East Cellular Inc.","us"],"31046":["SIMMETRY","us"],"310460":["TMP Corporation","us"],"310470":["nTelos","us"],"310480":["Choice Phone LLC","us"],"310490":["T-Mobile - US","us"],"310500":["Public Service Cellular, Inc.","us"],"310510":["Airtel Wireless LLC","us"],"310520":["VeriSign","us"],"310530":["T-Mobile - US","us"],"310540":["Oklahoma Western Telephone Company","us"],"310550":["Wireless Solutions International","us"],"310560":["AT&T","us"],"310570":["MTPCS LLC","us"],"310580":["Inland Cellular","us"],"310590":["Verizon Wireless","us"],"310591":["Verizon Wireless","us"],"310592":["Verizon Wireless","us"],"310593":["Verizon Wireless","us"],"310594":["Verizon Wireless","us"],"310595":["Verizon Wireless","us"],"310596":["Verizon Wireless","us"],"310597":["Verizon Wireless","us"],"310598":["Verizon Wireless","us"],"310599":["Verizon Wireless","us"],"31060":["Consolidated Telcom","us"],"310600":["New-Cell Inc.","us"],"310610":["Elkhart Telephone Co. Inc. dba Epic Touch Co.","us"],"310620":["Coleman County Telecommunications Inc. (Trans Texas PCS)","us"],"310640":["T-Mobile - US","us"],"310650":["Jasper Wireless Inc.","us"],"310660":["T-Mobile - US","us"],"310670":["AT&T Mobility Vanguard Services","us"],"310680":["AT&T","us"],"310690":["Limitless Mobile","us"],"310700":["Cross Valiant Cellular Partnership","us"],"310710":["Arctic Slopo Telephone Association Cooperative","us"],"310720":["Wireless Solutions International Inc.","us"],"310730":["Sea Mobile","us"],"310740":["Telemetrix Inc.","us"],"310750":["East Kentucky Network LLC dba Appalachian Wireless","us"],"310760":["Panhandle Telecommunications Systems Inc.","us"],"310770":["Iowa Wireless Services LLC dba I Wireless","us"],"310780":["Connect Net Inc","us"],"310790":["PinPoint Communications Inc.","us"],"310800":["T-Mobile - US","us"],"310810":["Brazos Cellular Communications Ltd.","us"],"310820":["South Canaan Cellular Communications Co. LP","us"],"310830":["Caprock Cellular Ltd. Partnership","us"],"310840":["Edge Mobile LLC","us"],"310850":["Aeris Communications, Inc.","us"],"310860":["TX RSA 15B2, LP dba Five Star Wireless","us"],"310870":["Kaplan Telephone Company Inc.","us"],"310880":["Advantage Cellular Systems, Inc.","us"],"310890":["Verizon Wireless","us"],"310900":["Mid-Rivers","us"],"310910":["Southern IL RSA Partnership dba First Cellular of Southern Illinois","us"],"310920":["James Valley","us"],"310930":["Copper Valley Wireless","us"],"310940":["Poka Lambro Telco Ltd.","us"],"310950":["AT&T","us"],"310960":["UBET Wireless","us"],"310970":["Globalstar USA","us"],"310980":["AT&T Wireless Inc.","us"],"310990":["Evolve","us"],"310995":["Android Emulator","us"],"310999":["Various Networks","us"],"311000":["Mid-Tex Cellular Ltd.","us"],"311010":["Chariton Valley Communications Corp., Inc.","us"],"311020":["Missouri RSA No. 5 Partnership","us"],"311030":["Indigo Wireless, Inc.","us"],"311040":["Commet Wireless, LLC","us"],"311050":["Thumb Cellular Limited Partnership","us"],"311060":["Space Data Corporation","us"],"311070":["Easterbrooke Cellular Corporation","us"],"311080":["Pine Telephone Company dba Pine Cellular","us"],"311090":["Siouxland PCS","us"],"311100":["NexTech Wireless","us"],"311110":["Alltel Communications Inc.","us"],"311120":["Choice Phone LLC","us"],"311140":["MBO Wireless Inc./Cross Telephone Company","us"],"311150":["Wilkes Cellular Inc.","us"],"311170":["PetroCom LLC","us"],"311180":["AT&T","us"],"311190":["Cellular Properties Inc.","us"],"311200":["ARINC","us"],"311210":["Farmers Cellular Telephone","us"],"311220":["U.S. Cellular","us"],"311221":["U.S. Cellular","us"],"311222":["U.S. Cellular","us"],"311223":["U.S. Cellular","us"],"311224":["U.S. Cellular","us"],"311225":["U.S. Cellular","us"],"311226":["U.S. Cellular","us"],"311227":["U.S. Cellular","us"],"311228":["U.S. Cellular","us"],"311229":["U.S. Cellular","us"],"311230":["C Spire","us"],"311240":["Cordova Wireless Communications Inc","us"],"311250":["Wave Runner LLC","us"],"311260":["SLO Cellular Inc. dba CellularOne of San Luis Obispo","us"],"311270":["Verizon Wireless","us"],"311271":["Alltel Communications Inc.","us"],"311272":["Alltel Communications Inc.","us"],"311273":["Alltel Communications Inc.","us"],"311274":["Alltel Communications Inc.","us"],"311275":["Alltel Communications Inc.","us"],"311276":["Alltel Communications Inc.","us"],"311277":["Alltel Communications Inc.","us"],"311278":["Alltel Communications Inc.","us"],"311279":["Alltel Communications Inc.","us"],"311280":["Verizon Wireless","us"],"311281":["Verizon Wireless","us"],"311282":["Verizon Wireless","us"],"311283":["Verizon Wireless","us"],"311284":["Verizon Wireless","us"],"311285":["Verizon Wireless","us"],"311286":["Verizon Wireless","us"],"311287":["Verizon Wireless","us"],"311288":["Verizon Wireless","us"],"311289":["Verizon Wireless","us"],"311290":["Pinpoint Wireless Inc.","us"],"311300":["Rutal Cellular Corporation","us"],"311310":["Leaco Rural Telephone Company Inc","us"],"311311":["Farmers","us"],"311320":["Commnet Wireless LLC","us"],"311330":["Bag Tussel Wireless LLC","us"],"311340":["Illinois Valley Cellular","us"],"311350":["Torrestar Networks Inc","us"],"311360":["Stelera Wireless LLC","us"],"311370":["GCI Communications Corp.","us"],"311380":["GreenFly LLC","us"],"311390":["Midwest Wireless Holdings LLC","us"],"311400":["Testing US","us"],"311410":["Iowa RSA No.2 Ltd Partnership","us"],"311420":["northwestcell","us"],"311430":["Chat Mobility","us"],"311440":["Bluegrass Cellular LLC","us"],"311450":["PTCI","us"],"311460":["Fisher Wireless Services Inc","us"],"311470":["Vitelcom Cellular Inc dba Innovative Wireless","us"],"311480":["Verizon Wireless","us"],"311481":["Verizon Wireless","us"],"311482":["Verizon Wireless","us"],"311483":["Verizon Wireless","us"],"311484":["Verizon Wireless","us"],"311485":["Verizon Wireless","us"],"311486":["Verizon Wireless","us"],"311487":["Verizon Wireless","us"],"311488":["Verizon Wireless","us"],"311489":["Verizon Wireless","us"],"311490":["T-Mobile - US","us"],"311500":["CTC Telecom Inc","us"],"311510":["Benton-Lian Wireless","us"],"311520":["Crossroads Wireless Inc","us"],"311530":["Wireless Communications Venture","us"],"311540":["Keystone Wireless Inc","us"],"311550":["Commnet Midwest LLC","us"],"311580":["U.S. Cellular","us"],"311581":["U.S. Cellular","us"],"311582":["U.S. Cellular","us"],"311583":["U.S. Cellular","us"],"311584":["U.S. Cellular","us"],"311585":["U.S. Cellular","us"],"311586":["U.S. Cellular","us"],"311587":["U.S. Cellular","us"],"311588":["U.S. Cellular","us"],"311589":["U.S. Cellular","us"],"311590":["California RSA No. 3 Limited Partnership","us"],"311600":["COX","us"],"311610":["North Dakota Network Company","us"],"311650":["United Wireless Communications Inc.","us"],"311660":["T-Mobile - Private 5G","us"],"311670":["Pine Belt Cellular, Inc.","us"],"311710":["Northeast Wireless Networks LLC","us"],"311740":["TelAlaska Cellular","us"],"311750":["Cleartalk","us"],"311780":["ASTCA","us"],"311800":["Bluegrass Wireless LLC","us"],"311810":["Bluegrass Wireless LLC","us"],"311830":["Thumb Cellular Limited Partnership","us"],"311860":["Uintah Basin Electronics Telecommunications Inc.","us"],"311870":["Boost","us"],"311880":["Sprint Spectrum","us"],"311882":["T-Mobile - US","us"],"311910":["MobileNation","us"],"311920":["Missouri RSA No 5 Partnership","us"],"311930":["Syringa","us"],"312010":["Missouri RSA No 5 Partnership","us"],"312030":["Cross Wireless Telephone Co.","us"],"312040":["Custer Telephone Cooperative Inc.","us"],"312090":["Allied Wireless Communications Corporation","us"],"312120":["East Kentucky Network LLC","us"],"312130":["East Kentucky Network LLC","us"],"312160":["Chat Mobility","us"],"312170":["Iowa RSA No. 2 Limited Partnership","us"],"312180":["Keystone Wireless LLC","us"],"312190":["Sprint Spectrum","us"],"312220":["Missouri RSA No 5 Partnership","us"],"312230":["North Dakota Network Company","us"],"312250":["T-Mobile - US","us"],"312270":["Cellular Network Partnership LLC","us"],"312280":["Cellular Network Partnership LLC","us"],"312290":["strata","us"],"312380":["Copper Valley Wireless","us"],"312420":["NexTech Ota","us"],"312530":["Sprint","us"],"312570":["Blue Wireless","us"],"312580":["Google CBRS","us"],"312670":["FirstNet (Lab)","us"],"312870":["GigSky","us"],"313100":["FirstNet","us"],"313110":["FirstNet","us"],"313120":["FirstNet","us"],"313130":["FirstNet","us"],"313140":["FirstNet","us"],"313380":["OptimERA Wireless","us"],"313390":["Optimum","us"],"313450":["Spectrum Mobile","us"],"313460":["Mobi","us"],"313770":["TANGO","us"],"313790":["Liberty Mobile","us"],"314020":["Spectrum+","us"],"314200":["Xfinity MSO","us"],"314240":["Xfinity Mobile 2.0","us"],"314420":["Cox MSO","us"],"314720":["OXIO","us"],"314730":["TextNow Wireless","us"],"315010":["CBRS","us"],"316010":["Nextel Communications Inc.","us"],"316011":["Southern Communications Services Inc.","us"],"33000":["Open Mobile","pr"],"33011":["Claro PR","pr"],"330110":["Claro PR","pr"],"33401":["AT&T MX","mx"],"334010":["NEXTEL","mx"],"33402":["Telcel","mx"],"334020":["Telcel","mx"],"33403":["Movistar","mx"],"334030":["Movistar","mx"],"33404":["AT&T/IUSACell","mx"],"334040":["AT&T MX","mx"],"33405":["AT&T/IUSACell","mx"],"334050":["AT&T MX","mx"],"334060":["SAI PCS","mx"],"334070":["AT&T MX","mx"],"334080":["AT&T MX","mx"],"33409":["AT&T MX","mx"],"334090":["AT&T MX","mx"],"334130":["Alestra Servicios Moviles","mx"],"334140":["ALTAN - Internal Use","mx"],"334170":["OXIO","mx"],"33450":["AT&T/IUSACell","mx"],"338020":["Cable & Wireless Jamaica Ltd.","jm"],"33805":["Mossel (Jamaica) Ltd.","jm"],"338050":["Mossel (Jamaica) Ltd.","jm"],"338070":["Claro","jm"],"338110":["Cable & Wireless","jm"],"33818":["Cable & Wireless","jm"],"338180":["Cable & Wireless","jm"],"34001":["Orange Caraïbe Mobiles","gf"],"34002":["Outremer Telecom","gf"],"34003":["Saint Martin et Saint Barthelemy Telcell Sarl","gf"],"34008":["Dauphin Telecom SU (Guadeloupe Telecom)","gp"],"34011":["TelCell GSM","gf"],"34012":["UTS Caraibe","mq"],"34020":["Digicel","gf"],"34080":["Dauphin Telecom","gf"],"342050":["Digicel","bb"],"342299":["Failed Calls","bb"],"342600":["Cable & Wireless (Barbados) Ltd.","bb"],"342750":["Digicel","bb"],"342810":["Cingular Wireless","bb"],"342820":["Sunbeach Communications","bb"],"34403":["APUA PCS","ag"],"344030":["imobile / APUA","ag"],"34492":["Flow","ag"],"344920":["Cable & Wireless (Antigua)","ag"],"344921":["FLOW","ag"],"34493":["Digicel","ag"],"344930":["AT&T Wireless (Antigua)","ag"],"346001":["Logic","ky"],"346006":["Digicel Ltd.","ky"],"346050":["Digicel","ky"],"346140":["Cable & Wireless (Cayman)","ky"],"348170":["Cable & Wireless","vg"],"348570":["Caribbean Cellular Telephone, Boatphone Ltd.","vg"],"34877":["Digicel","vg"],"348770":["Digicel","vg"],"350000":["Bermuda Digital Communications Ltd (BDC)","bm"],"350007":["Paradise Mobile","bm"],"35001":["Digicel","bm"],"35002":["M3 Wireless Ltd","bm"],"350299":["Failed Calls","bm"],"35099":["CellOne Ltd","bm"],"352030":["Digicel","gd"],"352050":["Digicel","gd"],"352110":["Grenada:Lime","gd"],"354860":["Cable & Wireless","ms"],"356110":["FLOW","kn"],"35650":["Digicel","kn"],"35670":["UTS Cariglobe","kn"],"358110":["Cable & Wireless","lc"],"35830":["Cingular Wireless","lc"],"35850":["Digicel (St Lucia) Limited","lc"],"360050":["Digicel","vc"],"36010":["Cingular","vc"],"360100":["Cingular","vc"],"360110":["Cable & Wireless (St. Vincent & the Grenadines) Ltd","vc"],"36070":["Digicel","vc"],"36251":["TELCELL GSM","an"],"362630":["Cingular Wireless","an"],"36269":["CT GSM","cw"],"36291":["SETEL GSM","an"],"36295":["EOCG Wireless NV","cw"],"362951":["UTS Wireless","an"],"362999":["Fix Line","bq"],"36301":["SETAR","aw"],"36302":["Digicel","aw"],"363020":["Digicel","aw"],"36320":["Digicel","aw"],"363299":["MIO","aw"],"36403":["Smart Communications","bs"],"364039":["BTC","bs"],"36430":["Cybercell / BaTelCo","bs"],"36439":["Cybercell / BaTelCo","bs"],"364390":["Bahamas Telecommunications","bs"],"36449":["ALIV BS","bs"],"364490":["Aliv","bs"],"365010":["Weblinks Limited","ai"],"365840":["Cable & Wireless","ai"],"365850":["Digicel","ai"],"366020":["Cingular Wireless/Digicel","dm"],"366050":["Wireless Ventures (Dominica) Ltd (Digicel Dominica)","dm"],"366110":["Cable & Wireless","dm"],"36801":["ETECSA","cu"],"368999":["Fix Line","cu"],"37001":["Altice Dominicana","do"],"37002":["Claro RD","do"],"370020":["Claro RD","do"],"37003":["Tricom S.A.","do"],"37004":["CentennialDominicana","do"],"37005":["Wind Telecom","do"],"37201":["Comcel","ht"],"37202":["Digicel","ht"],"37203":["Rectel","ht"],"37412":["TSTT Mobile","tt"],"374120":["Bmobile/TSTT","tt"],"374122":["TSTT Mobile","tt"],"374123":["TSTT Mobile","tt"],"374124":["TSTT Mobile","tt"],"374125":["TSTT Mobile","tt"],"374126":["TSTT Mobile","tt"],"374127":["TSTT Mobile","tt"],"374128":["TSTT Mobile","tt"],"374129":["TSTT Mobile","tt"],"37413":["Digicel Trinidad and Tobago Ltd.","tt"],"374130":["Digicel Trinidad and Tobago Ltd.","tt"],"374140":["LaqTel Ltd.","tt"],"376050":["Digicel TCI Ltd","tc"],"376350":["Cable & Wireless West Indies Ltd (Turks & Caicos)","tc"],"376352":["IslandCom Communications Ltd.","tc"],"37650":["Digicel","vi"],"40001":["Azercell Limited Liability Joint Venture","az"],"40002":["Bakcell Limited Liabil ity Company","az"],"40003":["Catel JV","az"],"40004":["Azerphone LLC","az"],"40006":["Naxtel","az"],"40101":["Beeline","kz"],"40102":["Kcell/activ","kz"],"40107":["Tele2/Altel","kz"],"40177":["Tele2/Altel","kz"],"40211":["Bhutan Telecom Ltd","bt"],"40217":["B-Mobile of Bhutan Telecom","bt"],"40277":["TashiCell","bt"],"40401":["Vi","in"],"40402":["Airtel","in"],"40403":["Airtel","in"],"40404":["Vi","in"],"404045":["Bharti Airtel Limited (Karnataka) (India)","in"],"40405":["Vi","in"],"40407":["Vi","in"],"40409":["Reliance","in"],"40410":["Airtel","in"],"40411":["Vi","in"],"40412":["Vi","in"],"40413":["Vi","in"],"40414":["Vi","in"],"40415":["Vi","in"],"40416":["Airtel","in"],"40417":["Aircel","in"],"40418":["Reliance","in"],"40419":["Vi","in"],"40420":["Vi","in"],"40421":["BPL Mobile Communications Ltd.","in"],"40422":["Vi","in"],"40424":["Vi","in"],"40425":["Aircel Ltd.","in"],"40427":["Vi","in"],"40428":["Aircel Ltd.","in"],"40429":["Aircel Ltd.","in"],"40430":["Vi","in"],"40431":["Airtel","in"],"40433":["Aircel","in"],"40434":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40436":["Reliance","in"],"40437":["Aircel Ltd.","in"],"40438":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40439":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40440":["Airtel","in"],"40441":["RPG Cellular","in"],"40442":["Aircel Ltd.","in"],"40443":["Vi","in"],"40444":["Vi","in"],"40445":["Airtel","in"],"40446":["Vi","in"],"40448":["Dishnet Wireless","in"],"40449":["Airtel","in"],"40450":["Reliance","in"],"40451":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40452":["Reliance","in"],"40453":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40454":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40455":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40456":["Vi","in"],"40457":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40458":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40459":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40460":["Vi","in"],"40462":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40464":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40465":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40466":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40467":["Reliance","in"],"40468":["Mahanagar Telephone Nigam Ltd.","in"],"40469":["Mahanagar Telephone Nigam Ltd.","in"],"40470":["Airtel","in"],"40471":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40472":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40473":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40474":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40475":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40476":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40477":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40478":["Vi","in"],"40479":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40480":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40481":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40482":["Vi","in"],"40483":["Reliable Internet Services Ltd.","in"],"40484":["Vi","in"],"40485":["Reliance","in"],"40486":["Vi","in"],"40487":["Vi","in"],"40488":["Vi","in"],"40489":["Vi","in"],"40490":["Airtel","in"],"40491":["Aircel Ltd.","in"],"40492":["Airtel","in"],"40493":["Airtel","in"],"40494":["Airtel","in"],"40495":["Airtel","in"],"40496":["Airtel","in"],"40497":["Airtel","in"],"40498":["Airtel","in"],"404998":["Fix Line","in"],"404999":["Various Networks","in"],"40501":["Reliance","in"],"405025":["TATA DOCOMO","in"],"405026":["TATA DOCOMO","in"],"405027":["TATA DOCOMO","in"],"405028":["TATA DOCOMO","in"],"405029":["TATA DOCOMO","in"],"40503":["Reliance","in"],"405030":["TATA DOCOMO","in"],"405031":["TATA DOCOMO","in"],"405032":["TATA DOCOMO","in"],"405033":["TATA DOCOMO","in"],"405034":["TATA DOCOMO","in"],"405035":["TATA DOCOMO","in"],"405036":["TATA DOCOMO","in"],"405037":["TATA DOCOMO","in"],"405038":["TATA DOCOMO","in"],"405039":["TATA DOCOMO","in"],"40504":["Reliance","in"],"405040":["TATA DOCOMO","in"],"405041":["TATA DOCOMO","in"],"405042":["TATA DOCOMO","in"],"405043":["TATA DOCOMO","in"],"405044":["TATA DOCOMO","in"],"405045":["TATA DOCOMO","in"],"405046":["TATA DOCOMO","in"],"405047":["TATA DOCOMO","in"],"40505":["Reliance","in"],"40506":["Reliance","in"],"40507":["Reliance","in"],"40508":["Reliance","in"],"40509":["Reliance","in"],"40510":["Reliance","in"],"40511":["Reliance","in"],"40512":["Reliance","in"],"40513":["Reliance","in"],"40514":["Reliance","in"],"40515":["Reliance","in"],"40517":["Reliance","in"],"40518":["Reliance","in"],"40519":["Reliance","in"],"40520":["Reliance","in"],"40521":["Reliance","in"],"40522":["Reliance","in"],"40523":["Reliance","in"],"40545":["Vi","in"],"40551":["Airtel","in"],"40552":["Airtel","in"],"40553":["Airtel","in"],"40554":["Airtel","in"],"40555":["Airtel","in"],"40556":["Airtel","in"],"40566":["Vi","in"],"40567":["Vi","in"],"40570":["Vi","in"],"405750":["Vi","in"],"405751":["Vi","in"],"405752":["Vi","in"],"405753":["Vi","in"],"405754":["Vi","in"],"405755":["Vi","in"],"405756":["Vi","in"],"405799":["Vi","in"],"405800":["Aircel Ltd.","in"],"405801":["Aircel Ltd.","in"],"405802":["Aircel Ltd.","in"],"405803":["Aircel Ltd.","in"],"405804":["Aircel Ltd.","in"],"405805":["Aircel Ltd.","in"],"405806":["Aircel Ltd.","in"],"405807":["Aircel Ltd.","in"],"405808":["Aircel Ltd.","in"],"405809":["Aircel Ltd.","in"],"405810":["Aircel Ltd.","in"],"405811":["Aircel Ltd.","in"],"405812":["Aircel Ltd.","in"],"405813":["Uninor","in"],"405814":["Uninor","in"],"405815":["Uninor","in"],"405816":["Uninor","in"],"405817":["Uninor","in"],"405818":["Uninor","in"],"405819":["Uninor","in"],"405820":["Uninor","in"],"405821":["Uninor","in"],"405822":["Uninor","in"],"405823":["Videocon","in"],"405824":["Videocon","in"],"405825":["Videocon","in"],"405826":["Videocon","in"],"405827":["Videocon","in"],"405828":["Videocon","in"],"405829":["Videocon","in"],"405830":["Videocon","in"],"405832":["Videocon","in"],"405833":["Videocon","in"],"405834":["Videocon","in"],"405835":["Videocon","in"],"405836":["Videocon","in"],"405837":["Videocon","in"],"405838":["Videocon","in"],"405840":["Reliance Jio","in"],"405841":["Videocon","in"],"405842":["Videocon","in"],"405843":["Videocon","in"],"405844":["Uninor","in"],"405845":["Vi","in"],"405846":["Vi","in"],"405847":["Vi","in"],"405848":["Vi","in"],"405849":["Vi","in"],"405850":["Vi","in"],"405851":["Vi","in"],"405852":["Vi","in"],"405853":["Vi","in"],"405854":["Reliance Jio","in"],"405855":["Reliance Jio","in"],"405856":["Reliance Jio","in"],"405857":["Reliance Jio","in"],"405858":["Reliance Jio","in"],"405859":["Reliance Jio","in"],"405860":["Reliance Jio","in"],"405861":["Reliance Jio","in"],"405862":["Reliance Jio","in"],"405863":["Reliance Jio","in"],"405864":["Reliance Jio","in"],"405865":["Reliance Jio","in"],"405866":["Reliance Jio","in"],"405867":["Reliance Jio","in"],"405868":["Reliance Jio","in"],"405869":["Reliance Jio","in"],"40587":["Reliance Telecom Private","in"],"405870":["Reliance Jio","in"],"405871":["Reliance Jio","in"],"405872":["Reliance Jio","in"],"405873":["Reliance Jio","in"],"405874":["Reliance Jio","in"],"405875":["Uninor","in"],"405876":["Uninor","in"],"405877":["Uninor","in"],"405878":["Uninor","in"],"405879":["Uninor","in"],"405880":["Uninor","in"],"405881":["STEL","in"],"405882":["STEL","in"],"405883":["STEL","in"],"405884":["STEL","in"],"405885":["STEL","in"],"405886":["STEL","in"],"405908":["Vi","in"],"405909":["Vi","in"],"405910":["Vi","in"],"405911":["Vi","in"],"405912":["Cheers","in"],"405913":["Cheers","in"],"405914":["Cheers","in"],"405915":["Cheers","in"],"405916":["Cheers","in"],"405917":["Cheers","in"],"405918":["Cheers","in"],"405919":["Cheers","in"],"405920":["Cheers","in"],"405921":["Cheers","in"],"405922":["Cheers","in"],"405923":["Cheers","in"],"405925":["Uninor","in"],"405926":["Uninor","in"],"405927":["Uninor","in"],"405928":["Uninor","in"],"405929":["Uninor","in"],"405930":["Cheers","in"],"405932":["Videocon","in"],"41001":["Jazz","pk"],"41003":["PAK Telecom Mobile Ltd. (UFONE)","pk"],"41004":["Zong","pk"],"41005":["SCOM","pk"],"41006":["Telenor","pk"],"41007":["Jazz","pk"],"41008":["Instaphone","pk"],"410299":["Failed Calls","pk"],"41201":["AWCC","af"],"41203":["WaselTelecom (WT)","af"],"41220":["Roshan","af"],"41230":["New1","af"],"41240":["Areeba Afghanistan","af"],"41250":["Etisalat","af"],"41280":["Mobifone","af"],"41288":["Afghan Telecom","af"],"41301":["Sri Lanka Telecom Mobitel","lk"],"41302":["Dialog Sri Lanka","lk"],"41303":["Celtel Lanka Ltd.","lk"],"41305":["Airtel Lanka","lk"],"41308":["Hutchison Telecommunications Lanka","lk"],"41401":["Myanmar Post and Telecommunication","mm"],"41405":["Ooredoo Myanmar","mm"],"41406":["Telenor","mm"],"41409":["Mytel","mm"],"414999":["Fix Line (Myanmar","mm"],"41501":["Alfa","lb"],"41503":["MTC Touch","lb"],"41505":["Ogero Mobile","lb"],"41515":["Connect","lb"],"41532":["Cellis","lb"],"41533":["Cellis","lb"],"41534":["Cellis","lb"],"41535":["Cellis","lb"],"41536":["Libancell","lb"],"41537":["Libancell","lb"],"41538":["Libancell","lb"],"41539":["Libancell","lb"],"41601":["Fastlink","jo"],"41602":["Xpress","jo"],"41603":["Umniah","jo"],"41677":["Orange Jordan","jo"],"416770":["Orange Jordan","jo"],"416999":["Fix Line","jo"],"41701":["Syriatel","sy"],"41702":["Spacetel Syria","sy"],"41709":["Syrian Telecom","sy"],"41750":["Rcell","sy"],"41805":["Asiacell","iq"],"41808":["SanaTel","iq"],"41820":["Zain Iraq","iq"],"41830":["Zain Iraq","iq"],"41840":["Korek","iq"],"41845":["Mobitel","iq"],"41862":["Itisaluna","iq"],"41866":["Fastlink","iq"],"41877":["SevenNet Layers","iq"],"41882":["Korek","iq"],"41892":["Omnnea","iq"],"41902":["Zain","kw"],"41903":["Ooredoo","kw"],"41904":["STC","kw"],"419999":["Fix Line","kw"],"42001":["STC","sa"],"42003":["Mobily","sa"],"42004":["Zain Saudi Arabia","sa"],"42005":["Virgin","sa"],"42006":["Lebara Mobile","sa"],"42007":["Zain","sa"],"42101":["SabaFon","ye"],"42102":["Spacetel Yemen","ye"],"42103":["YemenMobile","ye"],"42104":["HiTS-UNITEL","ye"],"42111":["YemenMobile","ye"],"42122":["YemenMobile","ye"],"421999":["Fix Line","ye"],"42202":["Omantel","om"],"42203":["Ooredoo","om"],"42204":["Omantel","om"],"42206":["Vodafone Oman","om"],"42402":["e& UAE","ae"],"42403":["du","ae"],"42501":["Partner Communications Co. Ltd.","il"],"42502":["Cellcom Israel Ltd.","il"],"42503":["Pelephone Communications Ltd.","il"],"42505":["Jawwal","ps"],"42506":["Ooredoo","ps"],"42507":["Hot Mobile","il"],"42508":["Golan Telecom","il"],"42509":["We4G","il"],"42510":["Partner Communications Co. Ltd.","il"],"42512":["Pelephone","il"],"42513":["Ituran","il"],"42514":["Alon Cellular Ltd","il"],"42515":["Home Cellular","il"],"42516":["Rami Levy","il"],"42517":["Von waves","il"],"42519":["019 Mobile","il"],"42522":["Maskyoo","il"],"42523":["Beezz","il"],"42526":["Annatel","il"],"425299":["Annatel Mobile","il"],"42577":["Hot Mobile","il"],"42601":["Batelco","bh"],"42602":["Zain Bahrain","bh"],"42604":["stc BH","bh"],"42605":["Batelco","bh"],"426299":["Failed Calls","bh"],"426999":["Fix Line","bh"],"42701":["Ooredoo","qa"],"42702":["Vodafone","qa"],"42800":["Skytel Co. Ltd","mn"],"42888":["Unitel","mn"],"42891":["Skytel","mn"],"42898":["G.Mobile","mn"],"42899":["Mobicom","mn"],"42901":["Nepal Telecommunications","np"],"42902":["Ncell","np"],"42903":["Nepal Telecommunications","np"],"42904":["Smart Telecom","np"],"429999":["Fix Line","np"],"43002":["Etisalat","ae"],"43102":["Etisalat","ae"],"43211":["IR-MCI (Hamrahe Avval)","ir"],"43214":["Telecommunication Kish Co. (KIFZO)","ir"],"43219":["MTCE (Espadan)","ir"],"43220":["Rightel","ir"],"43232":["Taliya","ir"],"43235":["Irancell","ir"],"43270":["MTCE","ir"],"43293":["Farzanegan Pars","ir"],"432999":["Fix Line","ir"],"43401":["Buztel","uz"],"43402":["Uzmacom","uz"],"43404":["Daewoo Unitel","uz"],"43405":["Coscom","uz"],"43406":["Perfectum Mobile","uz"],"43407":["Uzdunrobita","uz"],"43601":["JC Somoncom","tj"],"43602":["CJSC Indigo Tajikistan","tj"],"43603":["TT mobile","tj"],"43604":["Babilon-Mobile","tj"],"43605":["CTJTHSC Tajik-tel","tj"],"43612":["Tcell","tj"],"43701":["Beeline","kg"],"43702":["KT Mobile","kg"],"43703":["AkTel LLC","kg"],"43705":["MegaCom","kg"],"43709":["O!","kg"],"43710":["Saima","kg"],"437299":["Failed Calls","kg"],"43801":["Barash Communication Technologies (BCTI)","tm"],"43802":["TM-Cell","tm"],"44000":["eMobile","jp"],"44001":["NTT DoCoMo","jp"],"44002":["NTT DoCoMo","jp"],"44003":["IIJmio","jp"],"44004":["SoftBank","jp"],"44005":["SoftBank","jp"],"44006":["SoftBank","jp"],"44007":["KDDI","jp"],"44008":["KDDI","jp"],"44009":["NTT DoCoMo","jp"],"44010":["DOCOMO MVNO","jp"],"44011":["Rakuten Mobile(MNO)","jp"],"44012":["NTT DoCoMo","jp"],"44013":["OCN MOBILE ONE","jp"],"44014":["NTT DoCoMo","jp"],"44015":["NTT DoCoMo","jp"],"44016":["NTT DoCoMo","jp"],"44017":["NTT DoCoMo","jp"],"44018":["NTT DoCoMo","jp"],"44019":["NTT DoCoMo","jp"],"44020":["SoftBank","jp"],"44021":["NTT DoCoMo","jp"],"44022":["NTT DoCoMo","jp"],"44023":["NTT DoCoMo","jp"],"44024":["NTT DoCoMo","jp"],"44025":["NTT DoCoMo","jp"],"44026":["NTT DoCoMo","jp"],"44027":["NTT DoCoMo","jp"],"44028":["NTT DoCoMo","jp"],"44029":["NTT DoCoMo","jp"],"44030":["NTT DoCoMo","jp"],"44031":["NTT DoCoMo","jp"],"44032":["NTT DoCoMo","jp"],"44033":["NTT DoCoMo","jp"],"44034":["NTT DoCoMo","jp"],"44035":["NTT DoCoMo","jp"],"44036":["NTT DoCoMo","jp"],"44037":["NTT DoCoMo","jp"],"44038":["NTT DoCoMo","jp"],"44039":["NTT DoCoMo","jp"],"44040":["SoftBank","jp"],"44041":["SoftBank","jp"],"44042":["SoftBank","jp"],"44043":["SoftBank","jp"],"44044":["SoftBank","jp"],"44045":["SoftBank","jp"],"44046":["SoftBank","jp"],"44047":["SoftBank","jp"],"44048":["SoftBank","jp"],"44049":["NTT DoCoMo","jp"],"44050":["KDDI","jp"],"44051":["KDDI","jp"],"44052":["KDDI","jp"],"44053":["KDDI","jp"],"44054":["KDDI","jp"],"44055":["KDDI","jp"],"44056":["KDDI","jp"],"44058":["NTT DoCoMo","jp"],"44060":["NTT DoCoMo","jp"],"44061":["NTT DoCoMo","jp"],"44062":["NTT DoCoMo","jp"],"44063":["NTT DoCoMo","jp"],"44064":["NTT DoCoMo","jp"],"44065":["NTT DoCoMo","jp"],"44066":["NTT DoCoMo","jp"],"44067":["NTT DoCoMo","jp"],"44068":["NTT DoCoMo","jp"],"44069":["NTT DoCoMo","jp"],"44070":["KDDI","jp"],"44071":["KDDI","jp"],"44072":["KDDI","jp"],"44073":["KDDI","jp"],"44074":["KDDI","jp"],"44075":["KDDI","jp"],"44076":["KDDI","jp"],"44077":["KDDI","jp"],"44078":["Okinawa Cellular","jp"],"44079":["KDDI","jp"],"44080":["KDDI","jp"],"44081":["KDDI","jp"],"44082":["KDDI","jp"],"44083":["KDDI","jp"],"44084":["KDDI","jp"],"44085":["KDDI","jp"],"44086":["KDDI","jp"],"44087":["NTT DoCoMo","jp"],"44088":["KDDI","jp"],"44089":["KDDI","jp"],"44090":["SoftBank","jp"],"44092":["SoftBank","jp"],"44093":["SoftBank","jp"],"44094":["SoftBank","jp"],"44095":["SoftBank","jp"],"44096":["SoftBank","jp"],"44097":["SoftBank","jp"],"44098":["SoftBank","jp"],"44099":["NTT DoCoMo","jp"],"44100":["Wireless City Planning","jp"],"44140":["NTT DoCoMo","jp"],"44141":["NTT DoCoMo","jp"],"44142":["NTT DoCoMo","jp"],"44143":["NTT DoCoMo","jp"],"44144":["NTT DoCoMo","jp"],"44145":["NTT DoCoMo","jp"],"44161":["SoftBank","jp"],"44162":["SoftBank","jp"],"44163":["SoftBank","jp"],"44164":["SoftBank","jp"],"44165":["SoftBank","jp"],"44170":["KDDI","jp"],"44190":["NTT DoCoMo","jp"],"44191":["NTT DoCoMo","jp"],"44192":["NTT DoCoMo","jp"],"44193":["NTT DoCoMo","jp"],"44194":["NTT DoCoMo","jp"],"44198":["NTT DoCoMo","jp"],"44199":["NTT DoCoMo","jp"],"450006":["LG U+","kr"],"45002":["KT","kr"],"45003":["SK Telecom","kr"],"45004":["KT","kr"],"45005":["SK Telecom","kr"],"45006":["LG U+","kr"],"45007":["KT Powertel","kr"],"45008":["KT","kr"],"45011":["SK Telink","kr"],"45012":["SK Telecom","kr"],"450299":["Failed Calls","kr"],"45201":["Mobifone","vn"],"45202":["Vinaphone","vn"],"45203":["S-Fone/Telecom","vn"],"45204":["Viettel Telecom","vn"],"45205":["Vietnamobile","vn"],"45206":["Viettel","vn"],"45207":["Gmobile","vn"],"45208":["Viettel Mobile","vn"],"45209":["Wintel","vn"],"45400":["1O1O / csl / Club Sim","hk"],"45401":["MVNO/CITIC","hk"],"45402":["3G Radio System/HKCSL3G","hk"],"45403":["Hutchison HK","hk"],"45404":["Hutchison 2G","hk"],"45405":["Hutchison 2G","hk"],"45406":["SmarTone HK","hk"],"45407":["MVNO/China Unicom International Ltd.","hk"],"45408":["MVNO/Trident","hk"],"45409":["MVNO/China Motion Telecom (HK) Ltd.","hk"],"45410":["GSM1800New World PCS Ltd.","hk"],"45411":["MVNO/CHKTL","hk"],"45412":["中國移動香港 China Mobile HK","hk"],"45413":["中國移動香港 China Mobile HK","hk"],"45414":["H3G/Hutchinson","hk"],"45415":["SmarTone HK","hk"],"45416":["PCCW","hk"],"45417":["SmarTone HK","hk"],"45418":["GSM7800/Hong Kong CSL Ltd.","hk"],"45419":["1O1O / csl / Club Sim","hk"],"45420":["Public Mobile Networks/Reserved","hk"],"45421":["Public Mobile Networks/Reserved","hk"],"45422":["Public Mobile Networks/Reserved","hk"],"45423":["Public Mobile Networks/Reserved","hk"],"45424":["Public Mobile Networks/Reserved","hk"],"45425":["Public Mobile Networks/Reserved","hk"],"45426":["Public Mobile Networks/Reserved","hk"],"45427":["Public Mobile Networks/Reserved","hk"],"45428":["Public Mobile Networks/Reserved","hk"],"45429":["Public Mobile Networks/Reserved","hk"],"45430":["Public Mobile Networks/Reserved","hk"],"45431":["Public Mobile Networks/Reserved","hk"],"45432":["Public Mobile Networks/Reserved","hk"],"45433":["Public Mobile Networks/Reserved","hk"],"45434":["Public Mobile Networks/Reserved","hk"],"45435":["Public Mobile Networks/Reserved","hk"],"45436":["Public Mobile Networks/Reserved","hk"],"45437":["Public Mobile Networks/Reserved","hk"],"45438":["Public Mobile Networks/Reserved","hk"],"45439":["Public Mobile Networks/Reserved","hk"],"45440":["shared by private TETRA systems","hk"],"45447":["shared by private TETRA systems","hk"],"45500":["Smartone Mobile Communications (Macao) Ltd.","mo"],"45501":["CTM","mo"],"45502":["China Telecom","mo"],"45503":["Hutchison Telecom","mo"],"45504":["CTM","mo"],"45505":["Hutchison Telephone Co. Ltd","mo"],"45506":["Smartone Mobile","mo"],"45601":["Mobitel (Cam GSM)","kh"],"45602":["Smart","kh"],"45603":["S Telecom (CDMA) (reserved)","kh"],"45604":["qb","kh"],"45605":["Smart","kh"],"45606":["Smart","kh"],"45608":["Metfone","kh"],"45609":["Sotelco/Beeline","kh"],"45611":["SEATEL","kh"],"45618":["Camshin (Shinawatra)","kh"],"456299":["CooTel","kh"],"45701":["Lao Telecommunications","la"],"45702":["ETL Mobile","la"],"45703":["Unitel","la"],"45708":["Millicom","la"],"46000":["China Mobile","cn"],"46001":["China Unicom","cn"],"46002":["China Mobile","cn"],"46003":["China Telecom","cn"],"46004":["China Mobile","cn"],"46005":["China Telecom","cn"],"46006":["China Unicom","cn"],"46007":["China Mobile","cn"],"46008":["China Mobile","cn"],"46009":["China Unicom","cn"],"46010":["China Unicom","cn"],"46011":["China Telecom","cn"],"46012":["China Telecom","cn"],"46015":["China Broadnet","cn"],"46020":["China Mobile","cn"],"460999":["Fix Line","cn"],"46601":["遠傳電信 Far EasTone Telecom","tw"],"46602":["遠傳電信 Far EasTone Telecom","tw"],"46603":["遠傳電信 Far EasTone Telecom","tw"],"46605":["遠傳電信Far EasTone Telecom(原亞太電信)","tw"],"46606":["Tuntex Telecom","tw"],"46607":["Far EasTone","tw"],"46609":["Vmax Telecom","tw"],"46610":["Global Mobile Corp.","tw"],"46611":["中華電信_Chunghwa Telecom","tw"],"46656":["International Telecom Co. Ltd (FITEL)","tw"],"46668":["ACeS Taiwan - ACeS Taiwan Telecommunications Co Ltd","tw"],"46688":["KG Telecom","tw"],"46689":["台灣大哥大(原台灣之星) Taiwan Mobile Telecom","tw"],"46690":["T-Star/VIBO","tw"],"46692":["中華電信_Chunghwa Telecom","tw"],"46693":["MobiTai Communications","tw"],"46697":["台灣大哥大 Taiwan Mobile Telecom","tw"],"46699":["TransAsia Telecoms","tw"],"467192":["Koryolink","kp"],"467193":["Sun Net","kp"],"467299":["Failed Calls","kp"],"47001":["Grameenphone","bd"],"47002":["Aktel","bd"],"47003":["Mobile 2000","bd"],"47004":["TeleTalk","bd"],"47005":["Citycell","bd"],"47006":["Citycell","bd"],"47007":["Airtel BD","bd"],"47201":["DhiMobile","mv"],"47202":["Ooredoo","mv"],"50200":["Art900","my"],"50201":["Art900","my"],"50210":["Digi","my"],"50211":["unifi mobile","my"],"50212":["Maxis/Hotlink","my"],"50213":["Celcom","my"],"50214":["Telekom Malaysia","my"],"502143":["Digi","my"],"502146":["Digi","my"],"502150":["Tune Talk","my"],"502151":["Baraka Telecom Sdn Bhd","my"],"502152":["Yes 5G","my"],"502153":["unifi mobile","my"],"502154":["TT dotCom","my"],"502155":["Samata Communications Sdn Bhd","my"],"502156":["Altel Communications","my"],"50216":["Digi","my"],"50217":["TimeCel","my"],"50218":["U Mobile","my"],"50219":["Celcom","my"],"502195":["XOX Com","my"],"502198":["Celcom","my"],"50220":["Electcoms Wireless Sdn Bhd","my"],"502299":["MKN","my"],"502999":["Fix Line","my"],"50501":["Telstra","au"],"50502":["Optus","au"],"50503":["Vodafone","au"],"50504":["Department of Defence","au"],"50505":["The Ozitel Network Pty. Ltd.","au"],"50506":["Hutchison 3G Australia Pty. Ltd.","au"],"50507":["Vodafone","au"],"50508":["One.Tel GSM 1800 Pty. Ltd.","au"],"50509":["Airnet Commercial Australia Ltd.","au"],"50510":["Norfolk Telecom","au"],"50511":["Telstra","au"],"50512":["Hutchison Telecommunications (Australia) Pty. Ltd.","au"],"50513":["RailCorp","au"],"50514":["AAPT Ltd.","au"],"50516":["VicTrack","au"],"50519":["Lycamobile","au"],"50524":["Advanced Communications Technologies Pty. Ltd.","au"],"50526":["Sinch","au"],"505299":["ACMA","au"],"50530":["Compatel","au"],"50535":["MessageBird","au"],"50539":["Telstra","au"],"50550":["Pivotel","au"],"50552":["OptiTel","au"],"50557":["CiFi","au"],"50571":["Telstra","au"],"50572":["Telstra","au"],"50588":["Pivotel","au"],"50590":["Optus","au"],"50599":["One.Tel GSM 1800 Pty. Ltd.","au"],"505999":["Fix Line","au"],"51000":["PSN","id"],"51001":["Indosat","id"],"51007":["Flexi (PT Telkom) (CDMA)","id"],"51008":["XL/AXIS","id"],"51009":["Smartfren","id"],"51010":["Telkomsel","id"],"51011":["XL/AXIS","id"],"51021":["Indosat - M3","id"],"51027":["PT Sampoerna Telekomunikasi Indonesia (STI)","id"],"51028":["Smartfren","id"],"51089":["3","id"],"51099":["Esia (PT Bakrie Telecom) (CDMA)","id"],"510999":["Fix Line","id"],"51401":["Telkomcel","tl"],"51402":["Timor Telecom","tl"],"51403":["Viettel","tl"],"514299":["Failed Calls","tl"],"514999":["Fix Line","tl"],"51501":["Islacom","ph"],"51502":["Globe Telecom","ph"],"51503":["Smart Communications","ph"],"51505":["Smart","ph"],"51518":["Redinternet","ph"],"51588":["Next Mobile","ph"],"515999":["Fix Line","ph"],"52000":["CAT CDMA","th"],"52001":["AIS GSM","th"],"52002":["CAT CDMA","th"],"52003":["AIS","th"],"52004":["TrueMove H 4G LTE","th"],"52005":["dtac","th"],"52015":["ACT Mobile","th"],"52018":["dtac","th"],"52020":["ACeS","th"],"52023":["Digital Phone Co.","th"],"52047":["TOT","th"],"52099":["True Move","th"],"520999":["Fix Line","th"],"52501":["Singtel","sg"],"52502":["Singtel","sg"],"52503":["M1","sg"],"52504":["Sunsurf","sg"],"52505":["StarHub","sg"],"52506":["Starhub","sg"],"52507":["Singtel","sg"],"52512":["Digital Trunked Radio Network","sg"],"525999":["Fix Line","sg"],"52801":["Telekom Brunei Bhd (TelBru)","bn"],"52802":["B-Mobile","bn"],"52811":["DST Com","bn"],"53000":["Reserved for AMPS MIN based IMSI's","nz"],"53001":["Vodafone","nz"],"53002":["Teleom New Zealand CDMA Network","nz"],"53003":["Woosh Wireless - CDMA Network","nz"],"53004":["Telstra","nz"],"53005":["Spark","nz"],"53024":["2degrees","nz"],"53028":["2degrees","nz"],"530999":["Fix Line","nz"],"53701":["Vodafone","pg"],"53702":["Vodafone","pg"],"53703":["Digicel Ltd","pg"],"537999":["Fix Line","pg"],"53901":["Tonga Communications Corporation","to"],"53943":["Shoreline Communication","to"],"53988":["Digicel","to"],"539999":["Fix Line","to"],"54001":["BREEZE","sb"],"54002":["Vodafone","sb"],"54010":["BREEZE","sb"],"54100":["AIL","vu"],"54101":["SMILE","vu"],"54105":["Digicel","vu"],"54201":["Vodafone","fj"],"54202":["Digicel","fj"],"54301":["Manuia","wf"],"543299":["Failed Calls","wf"],"54411":["Bluesky","as"],"544780":["ASTCA Mobile","as"],"54501":["Kiribati - TSKL","ki"],"54509":["Kiribati Frigate","ki"],"54601":["OPT Mobilis","nc"],"54705":["Viti","pf"],"54715":["Pacific Mobile Telecom (PMT)","pf"],"54720":["Tikiphone","pf"],"54801":["Telecom Cook","ck"],"54901":["Telecom Samoa Cellular Ltd.","ws"],"54927":["GoMobile SamoaTel Ltd","ws"],"549999":["Fix Line","ws"],"55001":["FSM Telecom","fm"],"551299":["Failed Calls","mh"],"55201":["Palau National Communications Corp. (a.k.a. PNCC)","pw"],"55202":["PECI/PalauTel (Palau","pw"],"55280":["Palau Mobile","pw"],"55301":["Tuvalu Telecommunication Corporation (TTC)","tv"],"55501":["Niue Telecom","nu"],"60201":["Orange Egypt","eg"],"60202":["Vodafone","eg"],"60203":["Etisalat","eg"],"60204":["WE","eg"],"602299":["Failed Calls","eg"],"60301":["Algérie Telecom","dz"],"60302":["Orascom Telecom Algérie","dz"],"60303":["Ooredoo","dz"],"60400":["Méditélécom","ma"],"60401":["Maroc","ma"],"60402":["inwi","ma"],"60404":["Al Houria Telecom","ma"],"60405":["inwi","ma"],"60406":["IAM","ma"],"60499":["Al Houria Telecom","ma"],"60501":["Orange Tunisie","tn"],"60502":["Tunisie Telecom","tn"],"60503":["Ooredoo Tunisia","tn"],"60506":["Lycamobile","tn"],"605999":["Fix Line","tn"],"60600":["Libyana","ly"],"60601":["Madar","ly"],"60602":["Al-Jeel","ly"],"60603":["Libya Phone","ly"],"60606":["Hatef","ly"],"60701":["Gamcel","gm"],"60702":["Africell","gm"],"60703":["Comium Services Ltd","gm"],"60704":["QCell","gm"],"60801":["Orange Senegal","sn"],"60802":["Sentel GSM","sn"],"60803":["Expresso","sn"],"60804":["HAYO","sn"],"608299":["2s Mobile","sn"],"60901":["Mattel S.A.","mr"],"60902":["Chinguitel S.A.","mr"],"60910":["Mauritel Mobiles","mr"],"61001":["Malitel","ml"],"61002":["Orange Mali","ml"],"61003":["Telecel","ml"],"61101":["Orange","gn"],"61102":["Sotelgui","gn"],"61103":["Intercel","gn"],"61104":["MTN/Areeba","gn"],"61105":["Cellcom Guinée SA","gn"],"61201":["Comstar","ci"],"61202":["Atlantique Cellulaire","ci"],"61203":["Orange Côte d'Ivoire","ci"],"61204":["Comium Côte d'Ivoire","ci"],"61205":["Loteny Telecom","ci"],"61206":["Oricel Côte d'Ivoire","ci"],"61207":["Aircomm Côte d'Ivoire","ci"],"61301":["Onatal (Telmob)","bf"],"61302":["Orange","bf"],"61303":["Telecel","bf"],"61401":["Sahel.Com","ne"],"61402":["Airtel Niger","ne"],"61403":["Telecel","ne"],"61404":["Orange Niger","ne"],"61501":["Togo Telecom","tg"],"61502":["Telecel/MOOV","tg"],"61503":["Moov Togo","tg"],"61601":["Libercom","bj"],"61602":["Telecel","bj"],"61603":["Spacetel Benin","bj"],"61604":["Bell Benin Communications","bj"],"61605":["Glo Communications Benin","bj"],"61701":["Orange Mauritius","mu"],"61702":["Mahanagar Telephone (Mauritius) Ltd.","mu"],"61703":["Chili","mu"],"61710":["Emtel","mu"],"61801":["Lonestar","lr"],"61802":["Libercell","lr"],"61804":["Comium Liberia","lr"],"61807":["Celcom","lr"],"61820":["LIBTELCO","lr"],"61901":["Orange","sl"],"61902":["Millicom","sl"],"61903":["Africell","sl"],"61904":["Comium (Sierra Leone) Ltd.","sl"],"61905":["Lintel (Sierra Leone) Ltd.","sl"],"61907":["Qcell","sl"],"61925":["Mobitel","sl"],"619299":["IPTel","sl"],"61940":["Datatel (SL) Ltd GSM","sl"],"61950":["Dtatel (SL) Ltd CDMA","sl"],"62001":["MTN","gh"],"62002":["Vodafone","gh"],"62003":["AirtelTigo","gh"],"62004":["Kasapa Telecom Ltd.","gh"],"62005":["National Security","gh"],"62006":["AirtelTigo","gh"],"62007":["Globacom","gh"],"62008":["Surfline","gh"],"620299":["Comsys","gh"],"62101":["Visafone","ng"],"62120":["Airtel Nigeria","ng"],"62125":["Visafone","ng"],"62127":["Smile","ng"],"621299":["Alpha Technologies","ng"],"62130":["MTN Nigeria Communications","ng"],"62140":["Nigeria Telecommunications Ltd.","ng"],"62150":["Glo","ng"],"62160":["9Pay","ng"],"62199":["Starcomms","ng"],"62201":["Airtel Chad","td"],"62202":["Tchad Mobile","td"],"62203":["Tigo/Milicom/Tchad Mobile","td"],"62204":["Salam","td"],"62301":["Centrafrique Telecom Plus (CTP)","cf"],"62302":["Telecel Centrafrique (TC)","cf"],"62303":["Orange Centrafricaine","cf"],"62304":["Nationlink","cf"],"623299":["Failed Calls","cf"],"62401":["Mobile Telephone Networks Cameroon","cm"],"62402":["Orange Cameroun","cm"],"62404":["Nexttel","cm"],"62501":["Cabo Verde Telecom","cv"],"62502":["T+Telecomunicaçôes","cv"],"62601":["Companhia Santomese de Telecomunicaçôes","st"],"62602":["Unitel","st"],"62701":["Orange","gq"],"62703":["Hits-GE","gq"],"627299":["Failed Calls","gq"],"62801":["Libertis S.A.","ga"],"62802":["Telecel Gabon S.A.","ga"],"62803":["Airtel Gabon","ga"],"62804":["Azur","ga"],"628299":["Failed Calls","ga"],"62901":["Airtel Congo","cg"],"62902":["Azur SA (ETC)","cg"],"62907":["Warid","cg"],"62910":["Libertis Telecom","cg"],"63001":["Vodacom Congo RDC sprl","cd"],"63002":["Airtel","cd"],"63005":["Supercell Sprl","cd"],"630299":["Failed Calls","cd"],"63086":["Orange RDC","cd"],"63088":["Yozma Timeturns","cd"],"63089":["Tigo","cd"],"63090":["Africell","cd"],"63102":["Unitel","ao"],"63104":["MOVICEL","ao"],"63201":["Guinétel S.A.","gw"],"63202":["Spacetel Guiné-Bissau S.A.","gw"],"63203":["Orange","gw"],"63207":["Guinetel","gw"],"632999":["Fix\tLine","gw"],"63301":["Cable & Wireless (Seychelles) Ltd.","sc"],"63302":["Mediatech International Ltd.","sc"],"63305":["Intelvision","sc"],"63310":["Airtel Seychelles","sc"],"63400":["Canar Telecom","sd"],"63401":["SD Mobitel","sd"],"63402":["Areeba-Sudan","sd"],"63403":["MTN","sd"],"63405":["Canar Telecom","sd"],"63406":["Zain","sd"],"63407":["Sudani","sd"],"63408":["Canar Telecom","sd"],"63409":["Privet","sd"],"63415":["Sudani One","sd"],"63422":["MTN","sd"],"634999":["Fix Line","sd"],"63510":["MTN Rwandacell","rw"],"63512":["Rwandatel","rw"],"63513":["Airtel Rwanda","rw"],"63514":["Airtel Rwanda","rw"],"63601":["ETH MTN","et"],"63602":["Safaricom Telecommunications Ethiopia","et"],"63701":["Telesom","so"],"63704":["Somafone","so"],"63710":["Nationlink","so"],"63719":["Hormuud","so"],"63725":["Hormuud","so"],"637299":["AirSom","so"],"63730":["Golis Telecommunications Company","so"],"63750":["Hormuud","so"],"63757":["Unitel","so"],"63760":["Nationlink","so"],"63770":["Onkod","so"],"63771":["Somtel","so"],"63782":["Telcom","so"],"63801":["Evatis","dj"],"63901":["Safaricom","ke"],"63902":["Safaricom","ke"],"63903":["Airtel Kenya","ke"],"63904":["Mobile Pay","ke"],"63905":["Yu","ke"],"63906":["Finserve Africa","ke"],"63907":["Telkom","ke"],"63909":["Homeland Media","ke"],"63910":["Jamii Telecommunications","ke"],"63911":["Jambo Telcoms","ke"],"63912":["Infura","ke"],"639299":["eferio","ke"],"64001":["Tri Telecomm. Ltd.","tz"],"64002":["TIGO","tz"],"64003":["Zantel","tz"],"64004":["Vodacom","tz"],"64005":["Airtel","tz"],"64006":["Sasatel Tanzania","tz"],"64007":["Life Tanzania","tz"],"64008":["Benson Informatics Ltd","tz"],"64009":["Halotel / Viettel","tz"],"64011":["Smile Communications","tz"],"64013":["WiAfrica","tz"],"64014":["MO Mobile","tz"],"64099":["Mkulima African Telecommunication","tz"],"64101":["Airtel Uganda","ug"],"64104":["Lycamobile","ug"],"64110":["MTN Uganda Ltd.","ug"],"64111":["Uganda Telecom Ltd.","ug"],"64114":["House of Integrated Technology and Systems Uganda Ltd","ug"],"64118":["Suretelecom Uganda Ltd","ug"],"64122":["Airtel Uganda","ug"],"64130":["K2 Telecom Ltd","ug"],"64133":["Smile","ug"],"64166":["i-Tel Ltd","ug"],"641999":["Fix Line","ug"],"64201":["Spacetel Burundi","bi"],"64202":["Safaris","bi"],"64203":["Telecel Burundi Company","bi"],"64207":["Smart Mobile","bi"],"64208":["Lumitel/Viettel","bi"],"64282":["Leo","bi"],"642999":["Fix\tLine","bi"],"64301":["T.D.M. GSM","mz"],"64303":["Movitel","mz"],"64304":["Vodacom","mz"],"64501":["Airtel Zambia","zm"],"64502":["Telecel Zambia Ltd.","zm"],"64503":["Zamtel","zm"],"645299":["Failed Calls","zm"],"64601":["Airtel Madagascar","mg"],"64602":["Orange Madagascar","mg"],"64603":["Sacel","mg"],"64604":["Telecom Malagasy Mobile","mg"],"646299":["Bip","mg"],"64700":["Orange La Réunion","re"],"64701":["Maore Mobile","yt"],"64702":["Telco OI","re"],"64703":["Free RE","re"],"64704":["Zeop_RE","re"],"64710":["Société Réunionnaise du Radiotéléphone","yt"],"64801":["Net One","zw"],"64803":["Telecel","zw"],"64804":["Econet","zw"],"64901":["Mobile Telecommunications Ltd.","na"],"64902":["switch","na"],"64903":["Powercom Pty Ltd","na"],"649299":["Demshi","na"],"65001":["Telekom Network Ltd.","mw"],"65002":["ZERO2","mw"],"65010":["Airtel Malawi","mw"],"65101":["VCL","ls"],"65102":["Econet Ezin-cel","ls"],"65201":["Mascom Wireless (Pty) Ltd.","bw"],"65202":["Orange Botswana (Pty) Ltd.","bw"],"65204":["beMobile","bw"],"65301":["EswatiniTelecom","sz"],"65302":["Eswatini Mobile","sz"],"65310":["Swazi MTN","sz"],"65401":["HURI - SNPT","km"],"65402":["Telma","km"],"654299":["Failed Calls","km"],"65501":["Vodacom","za"],"65502":["Telkom","za"],"65505":["Telkom","za"],"65506":["Sentech (Pty) Ltd.","za"],"65507":["Cell C (Pty) Ltd.","za"],"65510":["MTN","za"],"65511":["SAPS Gauteng","za"],"65512":["MTN","za"],"65519":["rain","za"],"65521":["Cape Town Metropolitan Council","za"],"655299":["Lycamobile","za"],"65530":["Bokamoso Consortium","za"],"65531":["Karabo Telecoms (Pty) Ltd.","za"],"65532":["Ilizwi Telecommunications","za"],"65533":["Thinta Thinta Telecommunications","za"],"65534":["Bokone Telecoms","za"],"65535":["Kingdom Communications","za"],"65536":["Amatole Telecommunication Services","za"],"65538":["rain","za"],"65573":["rain","za"],"65574":["rain","za"],"65701":["Eritel","er"],"658299":["Failed Calls","sh"],"65902":["MTN","ss"],"65903":["Gemtel Ltd (South Sudan","ss"],"65904":["Network of The World Ltd (NOW) (South Sudan","ss"],"65906":["Zain","ss"],"659299":["Digitel","ss"],"702099":["Smart","bz"],"702299":["Failed Calls","bz"],"70267":["Belize Telecommunications Ltd.","bz"],"70268":["International Telecommunications Ltd. (INTELCO)","bz"],"70269":["Smart","bz"],"70299":["Smart","bz"],"70401":["Claro GT","gt"],"70402":["Comunicaciones Celulares S.A.","gt"],"70403":["Movistar","gt"],"704030":["Movistar","gt"],"70601":["Claro SV","sv"],"70602":["Digicel, S.A. de C.V.","sv"],"70603":["Tigo","sv"],"70604":["Movistar","sv"],"706040":["Movistar","sv"],"70605":["INTELFON SA de CV","sv"],"708001":["Claro HN","hn"],"708002":["Celtel","hn"],"70801":["Claro HN","hn"],"70802":["Celtel","hn"],"708020":["Celtel","hn"],"708030":["HonduTel","hn"],"70804":["Digicel","hn"],"708040":["Digicel","hn"],"70830":["Hondutel","hn"],"70840":["Digicel","hn"],"71021":["Claro NI","ni"],"71030":["Movistar (Telefonía Celular de Nicaragua)","ni"],"710300":["Movistar (Telefonía Celular de Nicaragua)","ni"],"71070":["Yota Nicaragua","ni"],"71073":["Servicios de Comunicaciones, S.A. (SERCOM)","ni"],"710730":["Servicios de Comunicaciones, S.A. (SERCOM)","ni"],"710999":["Fix Line","ni"],"71201":["KOLBI ICE","cr"],"712019":["Tuyo","cr"],"71202":["KOLBI ICE","cr"],"71203":["Claro CR","cr"],"71204":["Liberty","cr"],"712190":["Tuyo","cr"],"71220":["Virtualis","cr"],"712999":["Fix Line","cr"],"71401":["Cable & Wireless Panama S.A.","pa"],"71402":["Movistar","pa"],"714020":["Movistar","pa"],"71403":["Claro PA","pa"],"71404":["Digicel","pa"],"714040":["Digicel","pa"],"714999":["Fix Line","pa"],"71601":["GlobalStar","pe"],"71602":["GlobalStar","pe"],"71606":["Movistar","pe"],"71607":["Nextel","pe"],"71610":["Claro PE","pe"],"71615":["Bitel","pe"],"71617":["Entel","pe"],"71620":["Claro /Amer.Mov./TIM","pe"],"722007":["Movistar","ar"],"722010":["Movistar","ar"],"722020":["Nextel Argentina srl","ar"],"722031":["Claro","ar"],"722034":["Personal","ar"],"72207":["Movistar","ar"],"722070":["Movistar","ar"],"722210":["IMOWI","ar"],"722299":["Express","ar"],"72231":["Claro AR","ar"],"722310":["Claro AR","ar"],"722320":["Compañía de Telefonos del Interior Norte S.A.","ar"],"722330":["Compañía de Telefonos del Interior S.A.","ar"],"72234":["Telecom Personal S.A.","ar"],"722340":["Telecom Personal S.A.","ar"],"722341":["Telecom Personal S.A.","ar"],"72236":["Argentina:Nuestro","ar"],"722999":["Fix Line","ar"],"72400":["Nextel","br"],"72401":["CRT Cellular","br"],"72402":["TIM","br"],"72403":["TIM","br"],"72404":["TIM","br"],"72405":["Claro BR","br"],"72406":["Vivo","br"],"72407":["Sercontel Cel","br"],"72408":["Maxitel MG","br"],"72409":["Telepar Cel","br"],"72410":["Vivo","br"],"72411":["Vivo","br"],"72412":["Americel","br"],"72413":["Telesp Cel","br"],"72414":["Maxitel BA","br"],"72415":["Sercomtel","br"],"72416":["Brasil Telecom GSM","br"],"72417":["Ceterp Cel","br"],"72418":["Datora","br"],"72419":["Telemig Cel","br"],"72421":["Telerj Cel","br"],"72423":["Vivo","br"],"72424":["Oi","br"],"72425":["Telebrasilia Cel","br"],"72426":["AmericaNet","br"],"72427":["Telegoias Cel","br"],"72429":["Unifique","br"],"72430":["Oi","br"],"72431":["Oi","br"],"72432":["Algar Telecom","br"],"72433":["Algar Telecom","br"],"72434":["Algar Telecom","br"],"72435":["Telebahia Cel","br"],"72437":["Telergipe Cel","br"],"72438":["Claro BR","br"],"72439":["Nextel","br"],"72441":["Telpe Cel","br"],"72443":["Telepisa Cel","br"],"72445":["Telpa Cel","br"],"72447":["Telern Cel","br"],"72448":["Teleceara Cel","br"],"72451":["Telma Cel","br"],"72453":["Telepara Cel","br"],"72454":["TIM","br"],"72455":["Teleamazon Cel","br"],"72457":["Teleamapa Cel","br"],"72459":["Telaima Cel","br"],"72477":["Brisanet","br"],"73000":["TESAM SA","cl"],"73001":["Entel","cl"],"73002":["Movistar","cl"],"73003":["Claro CL","cl"],"73004":["WOM","cl"],"73005":["Multikom S.A.","cl"],"73006":["Blue Two Chile SA","cl"],"73007":["Movistar","cl"],"73008":["VTR Banda Ancha SA","cl"],"73009":["WOM","cl"],"73010":["Entel","cl"],"73011":["Celupago SA","cl"],"73012":["Telestar Movil SA","cl"],"73013":["Tribe Mobile SPA","cl"],"73014":["Netline Telefonica Movil Ltda","cl"],"73015":["Cibeles Telecom SA","cl"],"73019":["Sociedad Falabella Movil SPA","cl"],"73026":["Entel","cl"],"732001":["Colombia Telecomunicaciones S.A. - Telecom","co"],"732002":["Edatel S.A.","co"],"732020":["Emtelsa","co"],"732099":["Emcali","co"],"732101":["Claro CO","co"],"732102":["Bellsouth Colombia S.A.","co"],"732103":["Colombia Móvil S.A.","co"],"732111":["Colombia Móvil S.A.","co"],"732123":["Movistar","co"],"732130":["WOM","co"],"732142":["UNE","co"],"732154":["Virgin Mobile","co"],"732165":["Tigo","co"],"732187":["ETB 4G","co"],"732199":["SUMA movil","co"],"732220":["Libre Tecnologias","co"],"732230":["Setroc Mobile","co"],"732240":["Flash Mobile","co"],"732299":["ATnet","co"],"732360":["WOM","co"],"732666":["Claro","co"],"732999":["Fix Line","co"],"73401":["Infonet","ve"],"73402":["Corporación Digitel","ve"],"73403":["Digicel","ve"],"73404":["Movistar","ve"],"73406":["Telecomunicaciones Movilnet, C.A.","ve"],"73601":["Nuevatel S.A.","bo"],"73602":["ENTEL S.A.","bo"],"73603":["Telecel S.A.","bo"],"738002":["GT&T Cellink Plus","gy"],"73801":["Cel*Star (Guyana) Inc.","gy"],"73802":["GT&T Cellink Plus","gy"],"74000":["Movistar","ec"],"740000":["Failed Call(s)","ec"],"74001":["Claro EC","ec"],"740010":["Claro EC","ec"],"74002":["Telecsa S.A.","ec"],"74003":["Tuenti","ec"],"74401":["Hola Paraguay S.A.","py"],"74402":["Claro PY","py"],"74403":["Compañia Privada de Comunicaciones S.A.","py"],"74404":["Telecel","py"],"74405":["Personal","py"],"74406":["Hola Paraguay S.A.","py"],"74601":["Telesur","sr"],"74602":["Telesur","sr"],"74603":["Digicel","sr"],"74604":["Intelsur","sr"],"746999":["Fix Line","sr"],"74800":["Ancel","uy"],"74801":["Ancel","uy"],"74803":["Ancel","uy"],"74807":["Movistar","uy"],"74810":["Claro UY","uy"],"750001":["Sure","fk"],"90101":["ICO Global Communications","n/a"],"90102":["Sense Communications International AS","n/a"],"90103":["Iridium Satellite, LLC (GMSS)","n/a"],"90104":["Globalstar","n/a"],"90105":["Thuraya RMSS Network","n/a"],"90106":["Thuraya Satellite Telecommunications Company","n/a"],"90107":["Ellipso","n/a"],"90109":["Tele1 Europe","n/a"],"90110":["Asia Cellular Satellite (AceS)","n/a"],"90111":["Inmarsat Ltd.","n/a"],"90112":["Maritime Communications Partner AS (MCP network)","n/a"],"90113":["Global Networks, Inc.","n/a"],"90114":["Telenor GSM - services in aircraft","n/a"],"90115":["SITA GSM services in aircraft (On Air)","n/a"],"90116":["Jasper Systems, Inc.","n/a"],"90117":["Jersey Telecom","n/a"],"90118":["AT&T Mobility (Wireless Maritime Services)","n/a"],"90119":["Vodafone","n/a"],"90120":["Intermatica","n/a"],"90121":["Seanet Maritime Communications","n/a"],"90122":["Denver Consultants Ltd","n/a"],"90128":["Vodafone GDSP","n/a"],"90137":["Transatel","n/a"],"90158":["Bics","n/a"],"90188":["Telecommunications for Disaster Relief (TDR) (OCHA)","n/a"],"90198":["Skylo","n/a"]},"i":{"202":"gr","204":"nl","206":"be","208":"fr","212":"mc","213":"ad","214":"es","216":"hu","218":"ba","219":"hr","220":"rs","221":"xk","222":"it","225":"va","226":"ro","228":"ch","230":"cz","231":"sk","232":"at","234":"gb","235":"gb","238":"dk","240":"se","242":"no","244":"fi","246":"lt","247":"lv","248":"ee","250":"ru","255":"ua","257":"by","259":"md","260":"pl","262":"de","266":"gi","268":"pt","270":"lu","272":"ie","274":"is","276":"al","278":"mt","280":"cy","282":"ge","283":"am","284":"bg","286":"tr","288":"fo","289":"ge","290":"gl","292":"sm","293":"si","294":"mk","295":"li","297":"me","302":"ca","308":"pm","310":"us","311":"us","312":"us","313":"us","314":"us","315":"us","316":"us","330":"pr","334":"mx","338":"jm","340":"gf","342":"bb","344":"ag","346":"ky","348":"vg","350":"bm","352":"gd","354":"ms","356":"kn","358":"lc","360":"vc","362":"bq","363":"aw","364":"bs","365":"ai","366":"dm","368":"cu","370":"do","372":"ht","374":"tt","376":"tc","400":"az","401":"kz","402":"bt","404":"in","405":"in","406":"in","410":"pk","412":"af","413":"lk","414":"mm","415":"lb","416":"jo","417":"sy","418":"iq","419":"kw","420":"sa","421":"ye","422":"om","424":"ae","425":"il","426":"bh","427":"qa","428":"mn","429":"np","430":"ae","431":"ae","432":"ir","434":"uz","436":"tj","437":"kg","438":"tm","440":"jp","441":"jp","450":"kr","452":"vn","454":"hk","455":"mo","456":"kh","457":"la","460":"cn","461":"cn","466":"tw","467":"kp","470":"bd","472":"mv","502":"my","505":"au","510":"id","514":"tl","515":"ph","520":"th","525":"sg","528":"bn","530":"nz","537":"pg","539":"to","540":"sb","541":"vu","542":"fj","543":"wf","544":"as","545":"ki","546":"nc","547":"pf","548":"ck","549":"ws","550":"fm","551":"mh","552":"pw","553":"tv","555":"nu","602":"eg","603":"dz","604":"ma","605":"tn","606":"ly","607":"gm","608":"sn","609":"mr","610":"ml","611":"gn","612":"ci","613":"bf","614":"ne","615":"tg","616":"bj","617":"mu","618":"lr","619":"sl","620":"gh","621":"ng","622":"td","623":"cf","624":"cm","625":"cv","626":"st","627":"gq","628":"ga","629":"cg","630":"cd","631":"ao","632":"gw","633":"sc","634":"sd","635":"rw","636":"et","637":"so","638":"dj","639":"ke","640":"tz","641":"ug","642":"bi","643":"mz","645":"zm","646":"mg","647":"yt","648":"zw","649":"na","650":"mw","651":"ls","652":"bw","653":"sz","654":"km","655":"za","657":"er","658":"sh","659":"ss","702":"bz","704":"gt","706":"sv","708":"hn","710":"ni","712":"cr","714":"pa","716":"pe","722":"ar","724":"br","730":"cl","732":"co","734":"ve","736":"bo","738":"gy","740":"ec","744":"py","746":"sr","748":"uy","750":"fk","901":"n/a"},"t":["302","310","311","312","313","314","315","316","334","338"],"meta":{"source":"Android Open Source Project carrier_list.textpb","source_url":"https://android.googlesource.com/platform/packages/providers/TelephonyProvider/+/master/assets/latest_carrier_id/carrier_list.textpb","aosp_version":"134217771","aosp_generic_records":1672}} diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index c34f38c..3638e60 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -18,10 +18,12 @@ export function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } -// Signal strength (dBm) -> 0..4 bars, matching VoHive thresholds. +// Signal strength (RSSI dBm, AT+CSQ) -> 0..4 bars, matching VoHive thresholds. +// RSSI-calibrated (not RSRP): RSSI sits ~20 dB above RSRP on LTE, so RSRP-scaled +// bands peg at full for any real signal and the bars never reflect strength. export function signalBars(dbm?: number | null): number { if (typeof dbm !== "number" || !Number.isFinite(dbm) || dbm === 0 || dbm === -999) return 0; - return dbm > -70 ? 4 : dbm > -85 ? 3 : dbm > -100 ? 2 : 1; + return dbm >= -70 ? 4 : dbm >= -85 ? 3 : dbm >= -100 ? 2 : 1; } export function signalValid(dbm?: number | null): boolean { @@ -30,7 +32,7 @@ export function signalValid(dbm?: number | null): boolean { export function signalColor(dbm?: number | null): string { if (!signalValid(dbm)) return "bg-gray-300 dark:bg-gray-600"; - return dbm! > -70 ? "bg-green-500" : dbm! > -90 ? "bg-yellow-500" : "bg-red-500"; + return dbm! >= -85 ? "bg-green-500" : dbm! >= -100 ? "bg-yellow-500" : "bg-red-500"; } export function formatBytes(value?: number | null): string { diff --git a/web/src/pages/DevicesPage.tsx b/web/src/pages/DevicesPage.tsx index 97a6c01..8cbe0df 100644 --- a/web/src/pages/DevicesPage.tsx +++ b/web/src/pages/DevicesPage.tsx @@ -24,6 +24,7 @@ const VALID_TABS = new Set(["overview", "esim", "at", "ussd", "config", "card"]) const EMPTY_ADD: AddDeviceForm = { id: "", name: "", + deviceType: "", interface: "", modemImei: "", usbPath: "", @@ -49,7 +50,7 @@ export default function DevicesPage() { const [configSnapshot, setConfigSnapshot] = useState(""); const [saving, setSaving] = useState(false); const [deleting, setDeleting] = useState(false); - const [rotating, setRotating] = useState(false); + const [dataToggling, setDataToggling] = useState(false); const [rebooting, setRebooting] = useState(false); const [reconnectingVoWiFi, setReconnectingVoWiFi] = useState(false); const [rescanning, setRescanning] = useState(false); @@ -227,33 +228,19 @@ export default function DevicesPage() { }, [setSearchParams], ); - const listRef = useRef(list); - listRef.current = list; - - const handleRotateIp = useCallback(async () => { + const handleToggleRoamingData = useCallback(async (enabled: boolean) => { const id = selectedIdRef.current.trim(); if (!id) return; - const item = listRef.current.find((d) => d.id === id); - if (!item?.networkConnected) { - message.warning(t("设备网络未连接,请先启动网络")); - return; - } - const ok = await confirmDialog(tf("确定对设备 {id} 执行 IP 轮换?", { id }), t("确认操作"), { - confirmText: t("立即轮换"), - cancelText: t("取消"), - type: "warning", - }); - if (!ok) return; - setRotating(true); + setDataToggling(true); try { - await api("/rotateip", { method: "POST", body: { deviceId: id } }); - message.success(t("轮换请求已发送")); + await api(`/devices/${id}/network`, { method: "PATCH", body: { enabled } }); + message.success(enabled ? t("漫游数据已开启,仅供 Export Proxy 使用") : t("漫游数据已关闭")); await refreshAll(); refreshSoon(1500); } catch (e) { - message.error(apiMessage(e) || t("轮换失败")); + message.error(apiMessage(e) || (enabled ? t("开启漫游数据失败") : t("关闭漫游数据失败"))); } finally { - setRotating(false); + setDataToggling(false); } }, [refreshAll, refreshSoon]); @@ -406,6 +393,10 @@ export default function DevicesPage() { message.warning(t("请选择一个未配置设备")); return; } + if (!addConfig.deviceType) { + message.warning(t("请选择设备类型")); + return; + } const res = await api<{ warning?: string; started?: boolean }>("/devices", { method: "POST", body: { config: addConfig } }); if (res?.warning) message.warning(res.warning); else if (res?.started === true) message.success(t("设备已添加并开始接管")); @@ -673,11 +664,11 @@ export default function DevicesPage() { <> ({ + id: "", + name: "", + deviceId: "", + interface: "", + mode: "socks5", + listenHost: "0.0.0.0", + listenPort: 1080, + enabled: true, + authEnabled: false, + username: "", + password: "", +}); + +export default function ExportProxyPage() { + const { t } = useI18n(); + const [configs, setConfigs] = useState([]); + const [statuses, setStatuses] = useState([]); + const [devices, setDevices] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [open, setOpen] = useState(false); + const [form, setForm] = useState(emptyConfig); + const [saving, setSaving] = useState(false); + const [busy, setBusy] = useState(""); + + const load = useCallback(async (initial = false) => { + if (initial) setLoading(true); + try { + const [configData, statusData, deviceData] = await Promise.all([ + api<{ configs?: ExportProxyConfig[] }>("/export-proxies"), + api<{ configs?: ExportProxyStatus[] }>("/export-proxies/status"), + api("/devices"), + ]); + setConfigs(configData.configs || []); + setStatuses(statusData.configs || []); + setDevices((deviceData.devices || []).filter((device) => !!device.interface)); + setError(""); + } catch (err) { + setError(apiMessage(err)); + } finally { + if (initial) setLoading(false); + } + }, []); + + useEffect(() => { + void load(true); + const timer = window.setInterval(() => void load(), 5000); + return () => window.clearInterval(timer); + }, [load]); + + const statusByID = useMemo(() => new Map(statuses.map((status) => [status.id, status])), [statuses]); + const deviceByID = useMemo(() => new Map(devices.map((device) => [device.id, device])), [devices]); + + const edit = (config?: ExportProxyConfig) => { + if (config) { + setForm({ ...config, password: "" }); + } else { + const first = devices[0]; + setForm({ ...emptyConfig(), deviceId: first?.id || "", interface: first?.interface || "" }); + } + setOpen(true); + }; + + const chooseDevice = (deviceId: string) => { + const device = deviceByID.get(deviceId); + setForm((current) => ({ ...current, deviceId, interface: device?.interface || "" })); + }; + + const save = async () => { + if (!form.deviceId) return message.warning(t("请选择设备")); + if (!form.listenPort || form.listenPort < 1 || form.listenPort > 65535) return message.warning(t("请输入有效端口")); + if (form.authEnabled && !form.username.trim()) return message.warning(t("启用认证后必须填写用户名")); + setSaving(true); + try { + if (form.id) { + await api(`/export-proxies/${encodeURIComponent(form.id)}`, { method: "PUT", body: form }); + message.success(t("导出代理已更新")); + } else { + await api("/export-proxies", { method: "POST", body: form }); + message.success(t("导出代理已创建")); + } + setOpen(false); + await load(); + } catch (err) { + message.error(apiMessage(err) || t("保存失败")); + } finally { + setSaving(false); + } + }; + + const toggle = async (config: ExportProxyConfig) => { + setBusy(config.id); + try { + await api(`/export-proxies/${encodeURIComponent(config.id)}`, { + method: "PUT", + body: { ...config, enabled: !config.enabled }, + }); + await load(); + } catch (err) { + message.error(apiMessage(err)); + } finally { + setBusy(""); + } + }; + + const remove = async (config: ExportProxyConfig) => { + if (!await confirmDialog(t("确定删除这个导出代理配置吗?"), t("确认删除"), { type: "warning", confirmText: t("删除"), cancelText: t("取消") })) return; + setBusy(config.id); + try { + await api(`/export-proxies/${encodeURIComponent(config.id)}`, { method: "DELETE" }); + message.success(t("导出代理已删除")); + await load(); + } catch (err) { + message.error(apiMessage(err)); + } finally { + setBusy(""); + } + }; + + return ( +
+ } onClick={() => edit()} disabled={!devices.length}>{t("添加代理")}} + /> + +
+ {error ?
{error}
: null} +
+ + + + + + + + + + + + + + + {configs.map((config) => { + const status = statusByID.get(config.id); + const device = deviceByID.get(config.deviceId); + return ( + + + + + + + + + + + ); + })} + +
{t("名称")}{t("设备")}{t("网络接口")}{t("协议")}{t("监听地址")}{t("认证")}{t("状态")}{t("操作")}
{config.name}{device?.name || config.deviceId}{config.interface}{config.mode.toUpperCase()}{status?.listen || `${config.listenHost}:${config.listenPort}`}{config.authEnabled ? config.username : t("无")} +
+ void toggle(config)} size="small" /> + + {status?.running ? t("运行中") : status?.error ? t("错误") : t("已停用")} + +
+ {status?.error ?
{status.error}
: null} +
+
+ + +
+
+
+ {!loading && !configs.length ? ( +
+ +
{t("暂无导出代理配置")}
+
{t("先在设备页面开启漫游数据,再创建代理")}
+
+ ) : null} + {loading ?
{t("加载中...")}
: null} +
+ +
+ {t("代理出口使用受保护的蜂窝路由和独立 DNS,不会把模块数据设为主机默认网络。关闭开发者模式会停止漫游数据并永久删除这里的全部配置。")} +
+ + setOpen(false)} + title={form.id ? t("编辑导出代理") : t("添加导出代理")} + width="max-w-2xl" + footer={<>} + > +
+ + + + +
{t("代理认证")} setForm({ ...form, authEnabled })} />
+
{t("保存后立即启用")} setForm({ ...form, enabled })} />
+ {form.authEnabled ? <> + + + : null} +
+
+
+ ); +} diff --git a/web/src/pages/ExtensionPage.tsx b/web/src/pages/ExtensionPage.tsx index afd9fdd..9a3f821 100644 --- a/web/src/pages/ExtensionPage.tsx +++ b/web/src/pages/ExtensionPage.tsx @@ -40,6 +40,7 @@ export default function ExtensionPage() { src={pluginAssetURL(selected.plugin, selected.contribution)} className="h-[calc(100vh-10rem)] min-h-[560px] w-full rounded-xl border border-gray-200 bg-white dark:border-white/10 dark:bg-[#15151a]" sandbox="allow-scripts allow-forms allow-same-origin" + allow="microphone; autoplay" />
); diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index 01f0070..7cf07fe 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import { AlertRegular, CheckmarkRegular } from "@fluentui/react-icons"; import { api, apiMessage, getSecuritySettings, updateSecuritySettings } from "../api"; -import type { NotificationSettings, SecuritySettings, SystemInfo } from "../types"; +import type { DeveloperSettings, HTTPSSettings, NotificationSettings, SecuritySettings, SystemInfo } from "../types"; import { Button, PageHeader, confirmDialog, message } from "../components/ui"; import { CardDecor, CardIcon, CardTitle, SecurityCard, SystemInfoCard } from "../components/settings/Cards"; import type { PasswordForm, UpdateInfo } from "../components/settings/Cards"; @@ -22,6 +22,8 @@ import { import { PushplusTab, TelegramTab } from "../components/settings/BotTabs"; import { BarkTab, EmailTab, WebhookTab } from "../components/settings/PushTabs"; import { PluginsCard } from "../components/settings/PluginsCard"; +import { HTTPSCard } from "../components/settings/HTTPSCard"; +import { DeviceQuotaCard } from "../components/settings/DeviceQuotaCard"; const EMPTY_PASSWORD: PasswordForm = { oldPassword: "", newPassword: "", confirmPassword: "" }; @@ -58,6 +60,13 @@ export default function SettingsPage() { const [clientAllowed, setClientAllowed] = useState(true); const [loadingSecurity, setLoadingSecurity] = useState(false); const [savingSecurity, setSavingSecurity] = useState(false); + const [httpsSettings, setHTTPSSettings] = useState(null); + const [loadingHTTPS, setLoadingHTTPS] = useState(false); + const [savingHTTPS, setSavingHTTPS] = useState(false); + const [developerSettings, setDeveloperSettings] = useState(null); + const [deviceLimit, setDeviceLimit] = useState(5); + const [loadingDeveloper, setLoadingDeveloper] = useState(false); + const [savingDeveloper, setSavingDeveloper] = useState(false); const updateChannel = useCallback((key: K, patch: Partial) => { setForms((prev) => ({ ...prev, [key]: { ...prev[key], ...patch } })); @@ -105,12 +114,80 @@ export default function SettingsPage() { } }, [applySecurity]); + const fetchHTTPS = useCallback(async () => { + setLoadingHTTPS(true); + try { + setHTTPSSettings(await api("/settings/https")); + } catch (error) { + message.error(apiMessage(error) || (lang === "zh" ? "HTTPS 配置加载失败" : "Failed to load HTTPS settings")); + } finally { + setLoadingHTTPS(false); + } + }, [lang]); + + const fetchDeveloperSettings = useCallback(async () => { + setLoadingDeveloper(true); + try { + const data = await api("/settings/developer"); + setDeveloperSettings(data); + setDeviceLimit(data.deviceLimit); + } catch (error) { + message.error(apiMessage(error) || (lang === "zh" ? "开发者配置加载失败" : "Failed to load developer settings")); + } finally { + setLoadingDeveloper(false); + } + }, [lang]); + useEffect(() => { void fetchSystemInfo(); void fetchNotifications(); void fetchSecurity(); }, [fetchSystemInfo, fetchNotifications, fetchSecurity]); + useEffect(() => { + if (systemInfo.developer) { + void fetchHTTPS(); + void fetchDeveloperSettings(); + } else { + setHTTPSSettings(null); + setDeveloperSettings(null); + setDeviceLimit(5); + } + }, [systemInfo.developer, fetchHTTPS, fetchDeveloperSettings]); + + const onToggleHTTPS = useCallback(async (enabled: boolean) => { + setSavingHTTPS(true); + try { + const data = await api("/settings/https", { method: "PUT", body: { enabled } }); + setHTTPSSettings(data); + message.success(lang === "zh" ? (enabled ? "HTTPS 已开启,正在切换连接" : "HTTPS 已关闭,正在恢复 HTTP") : (enabled ? "HTTPS enabled; reconnecting" : "HTTPS disabled; returning to HTTP")); + const target = enabled ? data.httpsUrl : data.httpUrl; + window.setTimeout(() => window.location.replace(target + window.location.pathname + window.location.search + window.location.hash), 700); + } catch (error) { + message.error(apiMessage(error) || (lang === "zh" ? "HTTPS 配置保存失败" : "Failed to save HTTPS settings")); + setSavingHTTPS(false); + } + }, [lang]); + + const onSaveDeviceLimit = useCallback(async () => { + const maximum = developerSettings?.maxDeviceLimit ?? 128; + if (!Number.isInteger(deviceLimit) || deviceLimit < 1 || deviceLimit > maximum) { + message.error(lang === "zh" ? `设备配额必须是 1 到 ${maximum} 的整数` : `Device quota must be an integer between 1 and ${maximum}`); + return; + } + setSavingDeveloper(true); + try { + const data = await api("/settings/developer", { method: "PUT", body: { deviceLimit } }); + setDeveloperSettings(data); + setDeviceLimit(data.deviceLimit); + message.success(lang === "zh" ? "设备配额已保存" : "Device quota saved"); + } catch (error) { + message.error(apiMessage(error) || (lang === "zh" ? "设备配额保存失败" : "Failed to save device quota")); + } finally { + setSavingDeveloper(false); + } + }, [developerSettings, deviceLimit, lang]); + const onSaveSecurity = useCallback(async () => { setSavingSecurity(true); try { @@ -313,7 +390,25 @@ export default function SettingsPage() { onSave={onSaveSecurity} /> - {systemInfo.developer ? : null} + {systemInfo.developer ? ( + <> + + + + + ) : null}
diff --git a/web/src/types.ts b/web/src/types.ts index 6dc3767..1a2cff6 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -1,5 +1,7 @@ export type ApiStatus = "ok" | "error"; +export type DeviceType = "wifi_410" | "dji_4g" | "pcie_ec20_ec25"; + export interface Session { authenticated: boolean; username: string; @@ -55,6 +57,7 @@ export interface ModemSummary { operator: string; nativeMcc: string; nativeMnc: string; + operatorCountryCode?: string; nativeSpn?: string; cardMcc?: string; cardMnc?: string; @@ -86,6 +89,7 @@ export interface ModemSummary { export interface DeviceListItem { id: string; name: string; + deviceType: DeviceType; running: boolean; healthy: boolean; controlOnline: boolean; @@ -100,6 +104,7 @@ export interface DeviceListItem { interface: string; esimTransport: string; smsEnabled: boolean; + networkEnabled: boolean; vowifiEnabled: boolean; vowifiActive?: boolean; vowifiRuntime: VoWiFiRuntime; @@ -117,6 +122,7 @@ export interface DevicesResponse { export interface DashboardDevice { id: string; name: string; + deviceType: DeviceType; interface: string; proxyPort: number; publicIp: string; @@ -181,6 +187,7 @@ export interface DiscoveredDevice { export interface DeviceConfig { id: string; name: string; + deviceType: DeviceType; interface: string; controlDevice: string; atPort: string; @@ -382,6 +389,20 @@ export interface SystemInfo { developer?: boolean; } +export interface HTTPSSettings { + enabled: boolean; + httpUrl: string; + httpsUrl: string; + fingerprint?: string; + notAfter?: string; +} + +export interface DeveloperSettings { + deviceLimit: number; + defaultDeviceLimit: number; + maxDeviceLimit: number; +} + export type Notice = { kind: "success" | "error" | "warning" | "info"; title: string;