mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-20 23:03:44 +08:00
feat: expand device networking and management
This commit is contained in:
+8
-16
@@ -6,11 +6,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vocat/internal/config"
|
||||
"vocat/internal/developer"
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
// plugin/extension system. When absent the developer mode defaults to off, so
|
||||
// a fresh install exposes no plugin surface until an operator explicitly turns
|
||||
// it on with `vocat develop on` and restarts the service.
|
||||
const developerEnabledSettingKey = "developer.enabled"
|
||||
const developerEnabledSettingKey = developer.EnabledSettingKey
|
||||
|
||||
// runDevelop handles the hidden `vocat develop on|off` subcommand. It is
|
||||
// intentionally excluded from printUsage and the interactive menu: the plugin
|
||||
@@ -65,6 +65,11 @@ func runDevelop(args []string, logger *slog.Logger) error {
|
||||
}); err != nil {
|
||||
return fmt.Errorf("persist developer flag: %w", err)
|
||||
}
|
||||
if !enabled {
|
||||
if err := developer.ResetExperimental(ctx, database); err != nil {
|
||||
return fmt.Errorf("reset developer settings: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if enabled {
|
||||
fmt.Printf("开发者模式已开启。重启 vocat 服务后插件功能生效。\n数据库:%s\n", cfg.DatabasePath)
|
||||
@@ -91,18 +96,5 @@ func parseDevelopArg(arg string) (bool, bool) {
|
||||
// or an unparseable value resolves to false — the system defaults closed, so
|
||||
// any read failure keeps plugins off rather than exposing them by accident.
|
||||
func isDeveloperEnabled(ctx context.Context, database *store.Store) bool {
|
||||
setting, err := database.AppSetting(ctx, developerEnabledSettingKey)
|
||||
if err != nil {
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
fmt.Fprintf(os.Stderr, "vocat: read developer flag failed; plugin system stays off: %v\n", err)
|
||||
}
|
||||
return false
|
||||
}
|
||||
var document struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := json.Unmarshal(setting.Value, &document); err != nil {
|
||||
return false
|
||||
}
|
||||
return document.Enabled
|
||||
return developer.Enabled(ctx, database)
|
||||
}
|
||||
|
||||
+246
-18
@@ -2,10 +2,12 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
@@ -18,8 +20,11 @@ import (
|
||||
|
||||
"vocat/internal/auth"
|
||||
"vocat/internal/config"
|
||||
"vocat/internal/developer"
|
||||
"vocat/internal/device"
|
||||
"vocat/internal/exportproxy"
|
||||
"vocat/internal/extensions"
|
||||
"vocat/internal/httpsmode"
|
||||
"vocat/internal/loghub"
|
||||
"vocat/internal/server"
|
||||
"vocat/internal/store"
|
||||
@@ -119,16 +124,41 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
||||
return err
|
||||
}
|
||||
defer database.Close()
|
||||
developerEnabled := isDeveloperEnabled(startupContext, database)
|
||||
pluginRoot := filepath.Join(filepath.Dir(cfg.DatabasePath), "plugins")
|
||||
legacyExportProxyConfig := filepath.Join(pluginRoot, exportproxy.ReservedID, "data", "configs.json")
|
||||
if !developerEnabled {
|
||||
if err := developer.ResetExperimental(startupContext, database); err != nil {
|
||||
return fmt.Errorf("reset disabled developer settings: %w", err)
|
||||
}
|
||||
if err := exportproxy.RemoveLegacyConfig(legacyExportProxyConfig); err != nil {
|
||||
return fmt.Errorf("remove legacy export proxy configuration: %w", err)
|
||||
}
|
||||
}
|
||||
httpsManager, err := httpsmode.New(
|
||||
startupContext,
|
||||
database,
|
||||
filepath.Join(filepath.Dir(cfg.DatabasePath), "tls"),
|
||||
cfg.Address,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("configure self-signed HTTPS: %w", err)
|
||||
}
|
||||
|
||||
// The plugin/extension system is gated behind a hidden developer-mode flag.
|
||||
// When off (the default) the manager is never created and the server receives
|
||||
// a nil Extensions handle, so every /extensions* and /plugin-assets/* route
|
||||
// returns 503/404 and the SPA hides the plugin surface.
|
||||
developerEnabled := isDeveloperEnabled(startupContext, database)
|
||||
var extensionManager *extensions.Manager
|
||||
var exportProxyManager *exportproxy.Manager
|
||||
if developerEnabled {
|
||||
exportProxyManager, err = exportproxy.New(startupContext, database, logger, legacyExportProxyConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create built-in export proxy: %w", err)
|
||||
}
|
||||
defer exportProxyManager.Close()
|
||||
extensionManager, err = extensions.NewManager(
|
||||
filepath.Join(filepath.Dir(cfg.DatabasePath), "plugins"),
|
||||
pluginRoot,
|
||||
logger,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -163,6 +193,7 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
||||
if err := provisionDiscoveredDevices(startupContext, database, deviceManager); err != nil {
|
||||
logger.Warn("automatic first-run device provisioning failed", "error", err)
|
||||
}
|
||||
restoreDefaultCellularRadios(startupContext, logger, database, deviceManager)
|
||||
defer func() {
|
||||
stopContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
@@ -173,7 +204,14 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
||||
pollContext, cancelPolling := context.WithCancel(context.Background())
|
||||
defer cancelPolling()
|
||||
go pollDeviceSnapshots(pollContext, logger, database, deviceManager)
|
||||
go restoreConfiguredCellularData(pollContext, logger, database, deviceManager)
|
||||
go collectCellularTraffic(pollContext, logger, database)
|
||||
go persistLogsToStore(pollContext, logger, logs, database)
|
||||
if !developerEnabled {
|
||||
go disableAllDeveloperCellularData(pollContext, logger, database, deviceManager)
|
||||
} else {
|
||||
go watchDeveloperDisable(pollContext, logger, database, deviceManager, exportProxyManager, legacyExportProxyConfig)
|
||||
}
|
||||
|
||||
vowifiManager, err := configureVoWiFiRuntime(
|
||||
startupContext,
|
||||
@@ -203,9 +241,11 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
||||
SecureCookies: cfg.SecureCookies,
|
||||
MaxRequestBodyBytes: cfg.MaxRequestBodyBytes,
|
||||
Extensions: extensionManager,
|
||||
ExportProxy: exportProxyManager,
|
||||
DeveloperEnabled: developerEnabled,
|
||||
UpdateRepository: strings.TrimSpace(os.Getenv("VOCAT_REPO")),
|
||||
UpdateToken: strings.TrimSpace(os.Getenv("GITHUB_TOKEN")),
|
||||
HTTPS: httpsManager,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -215,15 +255,35 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
||||
handler.StartTelegramBot(pollContext)
|
||||
handler.StartSMSNotificationDispatchers(pollContext)
|
||||
|
||||
httpServer := &http.Server{
|
||||
Addr: cfg.Address,
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 90 * time.Second,
|
||||
MaxHeaderBytes: 1 << 20,
|
||||
serverConfig := func(handler http.Handler) *http.Server {
|
||||
return &http.Server{
|
||||
Addr: cfg.Address,
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 90 * time.Second,
|
||||
MaxHeaderBytes: 1 << 20,
|
||||
}
|
||||
}
|
||||
plainHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if httpsManager.Enabled() {
|
||||
host := strings.TrimSpace(r.Host)
|
||||
if host == "" {
|
||||
host = cfg.Address
|
||||
}
|
||||
http.Redirect(w, r, "https://"+host+r.URL.RequestURI(), http.StatusPermanentRedirect)
|
||||
return
|
||||
}
|
||||
handler.ServeHTTP(w, r)
|
||||
})
|
||||
plainServer := serverConfig(plainHandler)
|
||||
tlsServer := serverConfig(handler)
|
||||
baseListener, err := net.Listen("tcp", cfg.Address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen on %s: %w", cfg.Address, err)
|
||||
}
|
||||
protocolMux := httpsmode.NewMultiplexer(baseListener, httpsManager)
|
||||
|
||||
signalContext, stopSignals := signal.NotifyContext(
|
||||
context.Background(),
|
||||
@@ -232,10 +292,17 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
||||
)
|
||||
defer stopSignals()
|
||||
|
||||
serverError := make(chan error, 1)
|
||||
serverError := make(chan error, 2)
|
||||
go func() {
|
||||
logger.Info("HTTP server listening", "address", cfg.Address)
|
||||
err := httpServer.ListenAndServe()
|
||||
logger.Info("HTTP server listening", "address", cfg.Address, "self_signed_https", httpsManager.Enabled())
|
||||
err := plainServer.Serve(protocolMux.Plain())
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
err = nil
|
||||
}
|
||||
serverError <- err
|
||||
}()
|
||||
go func() {
|
||||
err := tlsServer.Serve(tls.NewListener(protocolMux.TLS(), httpsManager.TLSConfig()))
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
err = nil
|
||||
}
|
||||
@@ -244,6 +311,7 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
||||
|
||||
select {
|
||||
case err := <-serverError:
|
||||
_ = protocolMux.Close()
|
||||
return err
|
||||
case <-signalContext.Done():
|
||||
logger.Info("shutdown signal received")
|
||||
@@ -258,11 +326,161 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
||||
cfg.ShutdownTimeout,
|
||||
)
|
||||
defer cancelShutdown()
|
||||
if err := httpServer.Shutdown(shutdownContext); err != nil {
|
||||
_ = httpServer.Close()
|
||||
return fmt.Errorf("graceful HTTP shutdown: %w", err)
|
||||
shutdownErrors := make(chan error, 2)
|
||||
go func() { shutdownErrors <- plainServer.Shutdown(shutdownContext) }()
|
||||
go func() { shutdownErrors <- tlsServer.Shutdown(shutdownContext) }()
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
_ = protocolMux.Close()
|
||||
for range 2 {
|
||||
if err := <-shutdownErrors; err != nil {
|
||||
_ = plainServer.Close()
|
||||
_ = tlsServer.Close()
|
||||
return fmt.Errorf("graceful HTTP shutdown: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// restoreDefaultCellularRadios repairs an interrupted VoWiFi teardown. CFUN=4
|
||||
// survives process restarts, while the in-memory radio checkpoint does not. If
|
||||
// VoWiFi is disabled and the current SIM has no explicit airplane policy, the
|
||||
// automatic/default policy is cellular service and the modem must return to
|
||||
// CFUN=1.
|
||||
func restoreDefaultCellularRadios(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
database *store.Store,
|
||||
manager *device.Manager,
|
||||
) {
|
||||
configs, err := database.ListDevices(ctx)
|
||||
if err != nil {
|
||||
logger.Warn("startup cellular recovery: list devices", "error", err)
|
||||
return
|
||||
}
|
||||
mapper := integration.ATMapper{Store: database, Devices: manager}
|
||||
for _, config := range configs {
|
||||
if config.VoWiFiEnabled {
|
||||
continue
|
||||
}
|
||||
entry, err := mapper.Get(config.ID)
|
||||
if err != nil || entry.Snapshot == nil || !entry.Snapshot.FlightMode {
|
||||
continue
|
||||
}
|
||||
iccid := strings.TrimSpace(entry.Snapshot.ICCID)
|
||||
if iccid != "" {
|
||||
policy, policyErr := database.CardPolicy(ctx, iccid)
|
||||
switch {
|
||||
case policyErr == nil && policy.AirplaneEnabled:
|
||||
continue
|
||||
case policyErr != nil && !errors.Is(policyErr, store.ErrNotFound):
|
||||
logger.Warn("startup cellular recovery: read card policy", "device_id", config.ID, "error", policyErr)
|
||||
continue
|
||||
}
|
||||
}
|
||||
restoreContext, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
_, err = manager.SetFlight(restoreContext, entry.ID, false)
|
||||
cancel()
|
||||
if err != nil {
|
||||
logger.Warn("startup cellular recovery failed", "device_id", config.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
logger.Info("restored cellular radio after disabled VoWiFi", "device_id", config.ID, "iccid", iccid)
|
||||
}
|
||||
}
|
||||
|
||||
func restoreConfiguredCellularData(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
database *store.Store,
|
||||
manager *device.Manager,
|
||||
) {
|
||||
configs, err := database.ListDevices(ctx)
|
||||
if err != nil {
|
||||
logger.Warn("startup cellular data recovery: list devices", "error", err)
|
||||
return
|
||||
}
|
||||
mapper := integration.ATMapper{Store: database, Devices: manager}
|
||||
for _, config := range configs {
|
||||
if !config.NetworkEnabled || config.VoWiFiEnabled {
|
||||
continue
|
||||
}
|
||||
entry, err := mapper.Get(config.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dataContext, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
_, err = manager.SetNetwork(dataContext, entry.ID, device.NetworkRequest{
|
||||
Enabled: true, APN: config.APN, IPVersion: "IPV4V6",
|
||||
})
|
||||
cancel()
|
||||
if err != nil {
|
||||
logger.Warn("startup cellular data recovery failed", "device_id", config.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
logger.Info("restored protected cellular data route", "device_id", config.ID, "interface", config.Interface)
|
||||
}
|
||||
}
|
||||
|
||||
func disableAllDeveloperCellularData(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
database *store.Store,
|
||||
manager *device.Manager,
|
||||
) {
|
||||
configs, err := database.ListDevices(ctx)
|
||||
if err != nil {
|
||||
logger.Warn("developer cleanup: list devices", "error", err)
|
||||
return
|
||||
}
|
||||
mapper := integration.ATMapper{Store: database, Devices: manager}
|
||||
for _, config := range configs {
|
||||
entry, err := mapper.Get(config.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
disableContext, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
_, err = manager.SetNetwork(disableContext, entry.ID, device.NetworkRequest{Enabled: false})
|
||||
cancel()
|
||||
if err != nil && ctx.Err() == nil {
|
||||
logger.Warn("developer cleanup: stop cellular data", "device_id", config.ID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func watchDeveloperDisable(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
database *store.Store,
|
||||
manager *device.Manager,
|
||||
exportProxy *exportproxy.Manager,
|
||||
legacyConfigPath string,
|
||||
) {
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if developer.Enabled(ctx, database) {
|
||||
continue
|
||||
}
|
||||
if exportProxy != nil {
|
||||
if err := exportProxy.DeleteAllAndDisable(ctx); err != nil && ctx.Err() == nil {
|
||||
logger.Warn("developer cleanup: delete export proxies", "error", err)
|
||||
}
|
||||
}
|
||||
if err := exportproxy.RemoveLegacyConfig(legacyConfigPath); err != nil {
|
||||
logger.Warn("developer cleanup: remove legacy export proxy configuration", "error", err)
|
||||
}
|
||||
if err := developer.ResetExperimental(ctx, database); err != nil && ctx.Err() == nil {
|
||||
logger.Warn("developer cleanup: reset settings", "error", err)
|
||||
}
|
||||
disableAllDeveloperCellularData(ctx, logger, database, manager)
|
||||
logger.Info("developer mode disabled; roaming data and export proxies were removed")
|
||||
return
|
||||
}
|
||||
}
|
||||
return <-serverError
|
||||
}
|
||||
|
||||
func configureVoWiFiRuntime(
|
||||
@@ -355,8 +573,18 @@ func newVoWiFiOrchestrator(
|
||||
if message.Concat != nil && message.Concat.Total > 0 {
|
||||
partsTotal = message.Concat.Total
|
||||
}
|
||||
messageID := message.MessageID
|
||||
if message.Concat != nil && message.Concat.Total > 1 {
|
||||
// A segment of a carrier-split long SMS over IMS. Address the whole
|
||||
// message with a stable id so SaveSMSMessage folds every segment
|
||||
// into one progressively merged row instead of one row per segment.
|
||||
messageID = store.StableConcatMessageID(
|
||||
"ims", deviceConfig.ModemIMEI, message.DeviceID, message.From,
|
||||
message.Concat.Reference, message.Concat.Total,
|
||||
)
|
||||
}
|
||||
_, saveErr := database.SaveSMSMessage(ctx, store.SMSMessage{
|
||||
MessageID: message.MessageID,
|
||||
MessageID: messageID,
|
||||
DeviceID: message.DeviceID,
|
||||
ModemIMEI: deviceConfig.ModemIMEI,
|
||||
IMSI: message.IMSI,
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
const cellularTrafficSampleInterval = 30 * time.Second
|
||||
|
||||
type interfaceTrafficSample struct {
|
||||
interfaceName string
|
||||
rxBytes uint64
|
||||
txBytes uint64
|
||||
}
|
||||
|
||||
func collectCellularTraffic(ctx context.Context, logger *slog.Logger, database *store.Store) {
|
||||
previous := make(map[string]interfaceTrafficSample)
|
||||
var lastPrune time.Time
|
||||
collect := func() {
|
||||
now := time.Now()
|
||||
if lastPrune.IsZero() || now.Sub(lastPrune) >= 24*time.Hour {
|
||||
lastPrune = now
|
||||
if _, err := database.DeleteTrafficBefore(ctx, now.Add(-35*24*time.Hour)); err != nil && ctx.Err() == nil {
|
||||
logger.Warn("prune old cellular traffic", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
configs, err := database.ListDevices(ctx)
|
||||
if err != nil {
|
||||
if ctx.Err() == nil {
|
||||
logger.Warn("list devices for cellular traffic collection", "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
active := make(map[string]struct{}, len(configs))
|
||||
for _, config := range configs {
|
||||
interfaceName := strings.TrimSpace(config.Interface)
|
||||
if !config.NetworkEnabled || interfaceName == "" {
|
||||
delete(previous, config.ID)
|
||||
continue
|
||||
}
|
||||
active[config.ID] = struct{}{}
|
||||
|
||||
rxBytes, txBytes, err := readInterfaceTrafficCounters(interfaceName)
|
||||
if err != nil {
|
||||
// Interfaces can briefly disappear while QMI reconnects. The next
|
||||
// successful read establishes a fresh baseline, so no reconnect
|
||||
// traffic is accidentally counted twice.
|
||||
delete(previous, config.ID)
|
||||
continue
|
||||
}
|
||||
rxDelta, txDelta, ok := trafficCounterDelta(previous[config.ID], interfaceName, rxBytes, txBytes)
|
||||
previous[config.ID] = interfaceTrafficSample{
|
||||
interfaceName: interfaceName,
|
||||
rxBytes: rxBytes,
|
||||
txBytes: txBytes,
|
||||
}
|
||||
if !ok || (rxDelta == 0 && txDelta == 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
for bucket, periodStart := range trafficBucketPeriods(time.Now()) {
|
||||
if err := database.AddTrafficBucket(ctx, store.TrafficBucket{
|
||||
DeviceID: config.ID,
|
||||
Bucket: bucket,
|
||||
PeriodStart: periodStart,
|
||||
RXBytes: rxDelta,
|
||||
TXBytes: txDelta,
|
||||
}); err != nil && ctx.Err() == nil {
|
||||
logger.Warn("record cellular traffic", "device", config.ID, "bucket", bucket, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for deviceID := range previous {
|
||||
if _, ok := active[deviceID]; !ok {
|
||||
delete(previous, deviceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collect()
|
||||
ticker := time.NewTicker(cellularTrafficSampleInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func trafficCounterDelta(previous interfaceTrafficSample, interfaceName string, rxBytes, txBytes uint64) (int64, int64, bool) {
|
||||
if previous.interfaceName == "" || previous.interfaceName != interfaceName || rxBytes < previous.rxBytes || txBytes < previous.txBytes {
|
||||
return 0, 0, false
|
||||
}
|
||||
rxDelta := rxBytes - previous.rxBytes
|
||||
txDelta := txBytes - previous.txBytes
|
||||
if rxDelta > math.MaxInt64 || txDelta > math.MaxInt64 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return int64(rxDelta), int64(txDelta), true
|
||||
}
|
||||
|
||||
func trafficBucketPeriods(now time.Time) map[string]time.Time {
|
||||
local := now.In(time.Local)
|
||||
year, month, day := local.Date()
|
||||
dayStart := time.Date(year, month, day, 0, 0, 0, 0, time.Local).UTC()
|
||||
return map[string]time.Time{
|
||||
"hour": now.UTC().Truncate(time.Minute),
|
||||
"day": now.UTC().Truncate(time.Hour),
|
||||
"week": dayStart,
|
||||
"month": dayStart,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func readInterfaceTrafficCounters(interfaceName string) (uint64, uint64, error) {
|
||||
iface, err := net.InterfaceByName(interfaceName)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
read := func(counter string) (uint64, error) {
|
||||
value, err := os.ReadFile(filepath.Join("/sys/class/net", iface.Name, "statistics", counter))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
parsed, err := strconv.ParseUint(strings.TrimSpace(string(value)), 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parse %s %s counter: %w", iface.Name, counter, err)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
rxBytes, err := read("rx_bytes")
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
txBytes, err := read("tx_bytes")
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return rxBytes, txBytes, nil
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !linux
|
||||
|
||||
package main
|
||||
|
||||
import "errors"
|
||||
|
||||
func readInterfaceTrafficCounters(string) (uint64, uint64, error) {
|
||||
return 0, 0, errors.New("interface traffic counters are only available on Linux")
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTrafficCounterDelta(t *testing.T) {
|
||||
previous := interfaceTrafficSample{interfaceName: "wwan0", rxBytes: 100, txBytes: 50}
|
||||
rx, tx, ok := trafficCounterDelta(previous, "wwan0", 175, 90)
|
||||
if !ok || rx != 75 || tx != 40 {
|
||||
t.Fatalf("delta = (%d, %d, %v), want (75, 40, true)", rx, tx, ok)
|
||||
}
|
||||
if _, _, ok := trafficCounterDelta(previous, "wwan1", 175, 90); ok {
|
||||
t.Fatal("interface change must establish a new baseline")
|
||||
}
|
||||
if _, _, ok := trafficCounterDelta(previous, "wwan0", 90, 40); ok {
|
||||
t.Fatal("counter reset must establish a new baseline")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrafficBucketPeriods(t *testing.T) {
|
||||
now := time.Date(2026, 8, 10, 12, 34, 56, 0, time.Local)
|
||||
periods := trafficBucketPeriods(now)
|
||||
if got := periods["hour"]; !got.Equal(now.UTC().Truncate(time.Minute)) {
|
||||
t.Fatalf("hour period = %s", got)
|
||||
}
|
||||
if got := periods["day"]; !got.Equal(now.UTC().Truncate(time.Hour)) {
|
||||
t.Fatalf("day period = %s", got)
|
||||
}
|
||||
localDay := periods["week"].In(time.Local)
|
||||
if localDay.Hour() != 0 || localDay.Minute() != 0 || localDay.Day() != 10 {
|
||||
t.Fatalf("week period = %s, want local day start", periods["week"])
|
||||
}
|
||||
if !periods["month"].Equal(periods["week"]) {
|
||||
t.Fatal("week and month should share daily periods")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user