init xboard node

This commit is contained in:
xboard
2026-03-15 09:27:24 +08:00
commit 37e6c016cb
40 changed files with 11011 additions and 0 deletions
+874
View File
@@ -0,0 +1,874 @@
package service
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"log/slog"
"math"
"sort"
"time"
"github.com/cedar2025/xboard-node/internal/cert"
"github.com/cedar2025/xboard-node/internal/config"
"github.com/cedar2025/xboard-node/internal/kernel"
"github.com/cedar2025/xboard-node/internal/kernel/singbox"
"github.com/cedar2025/xboard-node/internal/kernel/xray"
"github.com/cedar2025/xboard-node/internal/limiter"
"github.com/cedar2025/xboard-node/internal/monitor"
"github.com/cedar2025/xboard-node/internal/panel"
"github.com/cedar2025/xboard-node/internal/tracker"
)
type Service struct {
cfg *config.Config
panel *panel.Client
kernel kernel.Kernel
tracker *tracker.Tracker
limiter *limiter.Limiter
speedTracker *limiter.SpeedTracker
cert *cert.Manager
lastConfig *panel.NodeConfig
lastUsers []panel.User
lastAppliedPort int // the port currently bound by the kernel
lastAppliedIP string // the listen IP currently bound by the kernel
pushInterval int // seconds
pullInterval int // seconds
lastUserHash string // hash of user list for change detection
lastConfigHash string // hash of full config for change detection
pullBackoff apiBackoff // backoff for panel pull failures
pushBackoff apiBackoff // backoff for panel push failures
wsClient *panel.WSClient // WebSocket client (nil if WS not enabled)
wsEvents chan panel.WSEvent // receives data events from WS client
wsStatusCh chan panel.WSStatusChange // receives WS connect/disconnect notifications
wsCancel context.CancelFunc // cancels the WS client goroutine
wsDisconnectAt time.Time // when WS last disconnected (zero if connected)
}
// apiBackoff implements simple exponential backoff for API failures.
type apiBackoff struct {
skipRemaining int
}
func (b *apiBackoff) shouldSkip() bool {
if b.skipRemaining > 0 {
b.skipRemaining--
return true
}
return false
}
func (b *apiBackoff) onSuccess() { b.skipRemaining = 0 }
func (b *apiBackoff) onFailure() {
if b.skipRemaining <= 0 {
b.skipRemaining = 1
} else if b.skipRemaining < 8 {
b.skipRemaining *= 2
}
}
func New(cfg *config.Config) *Service {
panelClient := panel.NewClient(cfg.Panel)
certMgr := cert.NewManager(cfg.Cert)
var k kernel.Kernel
switch cfg.Kernel.Type {
case "singbox":
k = singbox.New(cfg.Kernel)
case "xray":
k = xray.New(cfg.Kernel)
default:
slog.Error("unsupported kernel type, defaulting to sing-box", "type", cfg.Kernel.Type)
k = singbox.New(cfg.Kernel)
}
l := limiter.New()
st := limiter.NewSpeedTracker(l)
return &Service{
cfg: cfg,
panel: panelClient,
kernel: k,
tracker: tracker.New(),
limiter: l,
speedTracker: st,
cert: certMgr,
wsEvents: make(chan panel.WSEvent, 16),
wsStatusCh: make(chan panel.WSStatusChange, 4),
}
}
func (s *Service) Run(ctx context.Context) error {
// Start cert manager (handles auto-TLS or manual cert verification)
if err := s.cert.Start(ctx); err != nil {
return fmt.Errorf("cert manager: %w", err)
}
defer s.cert.Stop()
// Handshake: get WS config + initial data in one call
if err := s.initialSetup(ctx); err != nil {
return fmt.Errorf("initial setup: %w", err)
}
defer s.kernel.Stop()
// Set up tickers
trackTicker := time.NewTicker(10 * time.Second)
pushInterval := time.Duration(math.Max(float64(s.pushInterval), 5)) * time.Second
pullInterval := time.Duration(s.pullInterval) * time.Second
reportTicker := time.NewTicker(pushInterval)
pullTicker := time.NewTicker(pullInterval)
// WS discovery: when in REST-only mode, periodically re-handshake to check
// if WS has been enabled. When WS is disconnected for too long, re-check
// if it's still available.
wsDiscoveryTicker := time.NewTicker(5 * time.Minute)
defer trackTicker.Stop()
defer reportTicker.Stop()
defer pullTicker.Stop()
defer wsDiscoveryTicker.Stop()
s.startWSClient(ctx)
sMode := "disabled"
if s.wsClient != nil {
sMode = "connecting"
}
slog.Info("service started",
"kernel", s.kernel.Name(),
"push_interval", s.pushInterval,
"pull_interval", s.pullInterval,
"websocket", sMode,
)
for {
select {
case <-ctx.Done():
slog.Info("service shutting down")
s.pushReport()
return nil
case <-trackTicker.C:
s.trackAndEnforce(ctx)
case <-reportTicker.C:
s.pushReport()
case <-pullTicker.C:
// When WebSocket is connected, skip REST polling entirely.
// Config/user updates arrive via WS push.
if s.wsClient != nil && s.wsClient.IsConnected() {
slog.Debug("ws connected, skipping REST pull")
continue
}
slog.Info("ws not connected or disabled, polling from API")
s.pullViaAPI(ctx)
case <-wsDiscoveryTicker.C:
s.wsDiscovery(ctx)
case status := <-s.wsStatusCh:
s.handleWSStatus(ctx, status)
case event := <-s.wsEvents:
s.handleWSEvent(ctx, event)
}
}
}
func (s *Service) initialSetup(ctx context.Context) error {
// Register speed limit lookup with kernel unconditionally (before WS/V1 branch).
// This ensures speedLimitFunc is set regardless of which code path applies config.
s.kernel.SetSpeedLimitFunc(s.speedTracker.GetLimiter)
slog.Info("performing handshake with panel")
var hs *panel.HandshakeResponse
var err error
// Retry loop for initial handshake
for attempt := 1; ; attempt++ {
hs, err = s.panel.Handshake()
if err != nil {
slog.Error("handshake failed", "attempt", attempt, "error", err)
if attempt >= 5 {
return fmt.Errorf("handshake failed after %d attempts: %w", attempt, err)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(time.Duration(attempt*5) * time.Second):
continue
}
}
break
}
// Apply settings from panel handshake (Handshake provides default intervals)
if s.cfg.Node.PushInterval == 0 && hs.Settings.PushInterval > 0 {
s.pushInterval = hs.Settings.PushInterval
} else {
s.pushInterval = s.cfg.Node.PushInterval
}
if s.pushInterval == 0 {
s.pushInterval = 60
}
if s.cfg.Node.PullInterval == 0 && hs.Settings.PullInterval > 0 {
s.pullInterval = hs.Settings.PullInterval
} else {
s.pullInterval = s.cfg.Node.PullInterval
}
if s.pullInterval == 0 {
s.pullInterval = 60
}
// If WebSocket is enabled, skip initial V1 APIs fetch and wait for推送
if hs.WebSocket.Enabled && hs.WebSocket.WSURL != "" {
s.wsClient = s.newWSClient(hs.WebSocket.WSURL)
slog.Info("handshake complete (websocket enabled), waiting for data push...",
"ws_url", hs.WebSocket.WSURL,
)
return nil
}
// Falls back to V1 APIs only if WebSocket is disabled in panel
slog.Info("websocket disabled or missing, falling back to V1 APIs")
// Fetch initial config and users via V1 APIs
nodeConfig, err := s.panel.GetConfig()
if err != nil {
return fmt.Errorf("initial config fetch: %w", err)
}
if nodeConfig == nil {
return fmt.Errorf("initial config is nil")
}
users, err := s.panel.GetUsers()
if err != nil {
return fmt.Errorf("initial user fetch: %w", err)
}
// Apply initial data
s.lastConfig = nodeConfig
s.lastUsers = users
s.lastUserHash = computeUserHash(users)
s.lastConfigHash = computeConfigHash(nodeConfig)
s.limiter.UpdateUsers(users)
s.speedTracker.UpdateBuckets()
slog.Info("handshake complete (V1 fallback)",
"protocol", nodeConfig.Protocol,
"port", nodeConfig.ServerPort,
"users", len(users),
)
if len(users) == 0 {
slog.Warn("no users, kernel will not start until users are available")
return nil
}
// Update service-level config overrides from remote NodeConfig if present
s.applyRemoteOverrides(ctx, nodeConfig)
s.lastAppliedPort = nodeConfig.ServerPort
s.lastAppliedIP = nodeConfig.ListenIP
certFile, keyFile := s.cert.CertFile(), s.cert.KeyFile()
if err := s.kernel.ApplyConfig(nodeConfig, users, certFile, keyFile); err != nil {
return fmt.Errorf("apply config: %w", err)
}
return nil
}
// applyRemoteOverrides updates service-level settings (log level, cert config)
// from the panel's NodeConfig. Returns true if cert paths changed (kernel restart needed).
func (s *Service) applyRemoteOverrides(ctx context.Context, nc *panel.NodeConfig) bool {
if nc == nil {
return false
}
// Dynamic Log Level (Kernel)
if nc.KernelLogLevel != "" && nc.KernelLogLevel != s.cfg.Kernel.LogLevel {
slog.Info("cert: kernel log level override", "old", s.cfg.Kernel.LogLevel, "new", nc.KernelLogLevel)
s.cfg.Kernel.LogLevel = nc.KernelLogLevel
}
// Certificate configuration from panel (panel-first: takes precedence over local config)
if nc.CertConfig != nil {
return s.applyPanelCert(ctx, nc.CertConfig)
}
// Legacy fields (deprecated: prefer cert_config)
if nc.AutoTLS != s.cfg.Cert.AutoTLS {
slog.Info("cert: auto_tls policy changed (deprecated field)", "new", nc.AutoTLS)
s.cfg.Cert.AutoTLS = nc.AutoTLS
}
if nc.Domain != "" && nc.Domain != s.cfg.Cert.Domain {
s.cfg.Cert.Domain = nc.Domain
}
return false
}
// applyPanelCert converts a panel CertConfig into the local config format and
// reconfigures the cert manager. Reports whether cert paths changed.
func (s *Service) applyPanelCert(ctx context.Context, pc *panel.CertConfig) bool {
newCfg := config.CertConfig{
CertMode: pc.CertMode,
Domain: pc.Domain,
Email: pc.Email,
DNSProvider: pc.DNSProvider,
DNSEnv: pc.DNSEnv,
HTTPPort: pc.HTTPPort,
CertFile: pc.CertFile,
KeyFile: pc.KeyFile,
CertContent: pc.CertContent,
KeyContent: pc.KeyContent,
// Preserve local storage dir — only the operator controls where certs live.
CertDir: s.cfg.Cert.CertDir,
}
changed, err := s.cert.Reconfigure(ctx, newCfg)
if err != nil {
slog.Error("failed to apply panel cert config", "mode", pc.CertMode, "error", err)
return false
}
s.cfg.Cert = newCfg
if changed {
slog.Info("cert: paths updated from panel", "cert", s.cert.CertFile(), "key", s.cert.KeyFile())
}
return changed
}
// startWSClient starts the WS client goroutine if a client is configured.
func (s *Service) startWSClient(ctx context.Context) {
if s.wsClient == nil {
return
}
wsCtx, wsCancel := context.WithCancel(ctx)
s.wsCancel = wsCancel
go s.wsClient.Run(wsCtx)
}
// newWSClient creates a WSClient with standard event/status callbacks.
func (s *Service) newWSClient(wsURL string) *panel.WSClient {
return panel.NewWSClient(
wsURL,
s.cfg.Panel.Token,
s.cfg.Panel.NodeID,
func(event panel.WSEvent) {
select {
case s.wsEvents <- event:
default:
slog.Warn("ws event channel full, dropping event", "type", event.Type)
}
},
func(status panel.WSStatusChange) {
select {
case s.wsStatusCh <- status:
default:
}
},
)
}
// handleWSStatus reacts to WS connectivity changes.
//
// - On disconnect: record timestamp, immediately REST poll.
// - On reconnect: clear disconnect timestamp, REST poll to catch missed events.
func (s *Service) handleWSStatus(ctx context.Context, status panel.WSStatusChange) {
if status.Connected {
s.wsDisconnectAt = time.Time{}
slog.Info("ws connected, waiting for server-side full sync push")
// No REST pull needed — server detects new connection and pushes
// full config + users automatically on auth success.
} else {
if s.wsDisconnectAt.IsZero() {
s.wsDisconnectAt = time.Now()
}
slog.Info("ws disconnected, falling back to REST polling")
s.pullViaAPI(ctx)
}
}
// wsDiscovery periodically checks WS availability:
//
// 1. REST-only mode (wsClient == nil): Re-handshake to check if panel now has
// WS enabled. If so, create and start a WS client. This handles the case
// where WS was not enabled at startup but enabled later.
//
// 2. WS disconnected for >10 min: Re-handshake to check if WS config changed.
// If WS is now disabled, stop the WS client and switch to REST-only.
// If WS config changed (different URL/channel), restart with new config.
func (s *Service) wsDiscovery(ctx context.Context) {
needsCheck := false
if s.wsClient == nil {
needsCheck = true
slog.Debug("ws discovery: no WS client, checking if panel enabled WS")
} else if !s.wsDisconnectAt.IsZero() && time.Since(s.wsDisconnectAt) > 10*time.Minute {
needsCheck = true
slog.Debug("ws discovery: WS disconnected for >10min, re-checking WS config")
}
if !needsCheck {
return
}
hs, err := s.panel.Handshake()
if err != nil {
slog.Debug("ws discovery: handshake failed", "error", err)
return
}
// Apply any latest config/user changes via dedicated APIs
s.pullViaAPI(ctx)
if hs.WebSocket.Enabled && hs.WebSocket.WSURL != "" {
if s.wsClient == nil {
slog.Info("ws discovery: panel has WS enabled, creating WS client")
s.wsClient = s.newWSClient(hs.WebSocket.WSURL)
s.wsDisconnectAt = time.Time{}
s.startWSClient(ctx)
}
} else if s.wsClient != nil {
slog.Info("ws discovery: panel disabled WS, switching to REST-only")
if s.wsCancel != nil {
s.wsCancel()
}
s.wsClient = nil
s.wsCancel = nil
s.wsDisconnectAt = time.Time{}
}
}
// handleWSEvent processes data events received via WebSocket
func (s *Service) handleWSEvent(ctx context.Context, event panel.WSEvent) {
switch event.Type {
case panel.WSEventSyncConfig:
if event.Config == nil {
slog.Warn("ws: sync.config event with nil config, ignoring")
return
}
newConfigHash := computeConfigHash(event.Config)
if newConfigHash == s.lastConfigHash {
slog.Debug("ws: config unchanged (same hash), skipping")
return
}
slog.Info("ws: applying config update", "protocol", event.Config.Protocol)
s.lastConfig = event.Config
s.lastConfigHash = newConfigHash
s.applyRemoteOverrides(ctx, event.Config)
s.applyChanges(ctx, true, false)
case panel.WSEventSyncUsers:
if event.Users == nil {
slog.Warn("ws: sync.users event with nil users, ignoring")
return
}
newHash := computeUserHash(event.Users)
if newHash == s.lastUserHash {
slog.Debug("ws: users unchanged (same hash), skipping")
return
}
slog.Info("ws: applying user update", "count", len(event.Users))
s.applyUserUpdate(ctx, event.Users, newHash)
case panel.WSEventSyncUserDelta:
if len(event.DeltaUsers) == 0 {
slog.Warn("ws: sync.user.delta event with empty users, ignoring")
return
}
slog.Info("ws: applying user delta", "action", event.DeltaAction, "count", len(event.DeltaUsers))
s.applyUserDelta(ctx, event.DeltaAction, event.DeltaUsers)
default:
slog.Debug("ws: unknown event type", "type", event.Type)
}
}
// pullViaAPI re-calls the config and user APIs for polling fallback.
// Also opportunistically detects WS enablement when in REST-only mode.
func (s *Service) pullViaAPI(ctx context.Context) {
if s.pullBackoff.shouldSkip() {
slog.Debug("skipping pull due to backoff")
return
}
config, err := s.panel.GetConfig()
if err != nil {
slog.Error("poll config failed", "error", err)
s.pullBackoff.onFailure()
return
}
users, err := s.panel.GetUsers()
if err != nil {
slog.Error("poll users failed", "error", err)
s.pullBackoff.onFailure()
return
}
s.pullBackoff.onSuccess()
configChanged := false
if s.cert.CertRenewed() {
slog.Info("certificate renewed, kernel restart needed")
configChanged = true
}
if config != nil {
newConfigHash := computeConfigHash(config)
if s.lastConfigHash != newConfigHash {
configChanged = true
slog.Info("config updated from panel",
"protocol", config.Protocol, "port", config.ServerPort)
s.lastConfig = config
s.lastConfigHash = newConfigHash
// Apply kernel overrides and cert updates from REST pull
if s.applyRemoteOverrides(ctx, config) {
configChanged = true // Restart kernel if cert paths changed
}
}
}
if users != nil {
newHash := computeUserHash(users)
usersChanged := newHash != s.lastUserHash
if usersChanged && !configChanged {
s.applyUserUpdate(ctx, users, newHash)
} else if usersChanged {
// Config is also changing — ApplyConfig will restart the kernel with the
// updated user list, so skip the intermediate ReloadUsers.
s.syncUserState(ctx, users, newHash, false)
}
}
if configChanged {
s.applyChanges(ctx, true, false)
}
}
// syncUserState updates user tracking state (limiter, speedTracker, lastUsers)
// without touching the kernel. Set kickRemoved=false when a full kernel restart
// is about to happen anyway — removed users' connections will be killed then.
func (s *Service) syncUserState(ctx context.Context, users []panel.User, newHash string, kickRemoved bool) {
removedIDs := s.limiter.UpdateUsers(users)
s.speedTracker.UpdateBuckets()
if kickRemoved && len(removedIDs) > 0 && s.kernel.IsRunning() {
conns, err := s.kernel.GetConnections(ctx)
if err == nil {
kicks := s.limiter.KickUsers(conns, removedIDs)
s.executeKicks(ctx, kicks)
slog.Info("kicked removed users", "removed_ids", removedIDs, "kicked_conns", len(kicks))
}
}
s.lastUsers = users
s.lastUserHash = newHash
slog.Info("users updated", "count", len(users))
}
// applyUserUpdate updates user state and hot-reloads the kernel inbounds.
func (s *Service) applyUserUpdate(ctx context.Context, users []panel.User, newHash string) {
s.syncUserState(ctx, users, newHash, true)
s.applyChanges(ctx, false, true)
}
// applyUserDelta applies an incremental user change (add or remove).
func (s *Service) applyUserDelta(ctx context.Context, action string, deltaUsers []panel.User) {
switch action {
case "add":
// Build a map of current users for fast lookup
userMap := make(map[int]panel.User, len(s.lastUsers))
for _, u := range s.lastUsers {
userMap[u.ID] = u
}
for _, u := range deltaUsers {
userMap[u.ID] = u // add or update
}
merged := make([]panel.User, 0, len(userMap))
for _, u := range userMap {
merged = append(merged, u)
}
newHash := computeUserHash(merged)
s.applyUserUpdate(ctx, merged, newHash)
case "remove":
removeSet := make(map[int]struct{}, len(deltaUsers))
for _, u := range deltaUsers {
removeSet[u.ID] = struct{}{}
}
filtered := make([]panel.User, 0, len(s.lastUsers))
for _, u := range s.lastUsers {
if _, ok := removeSet[u.ID]; !ok {
filtered = append(filtered, u)
}
}
newHash := computeUserHash(filtered)
s.applyUserUpdate(ctx, filtered, newHash)
default:
slog.Warn("ws: unknown user delta action", "action", action)
}
}
// applyChanges applies config and/or user changes to the kernel
func (s *Service) applyChanges(ctx context.Context, configChanged, usersChanged bool) {
certFile, keyFile := s.cert.CertFile(), s.cert.KeyFile()
if configChanged {
if s.lastConfig != nil && len(s.lastUsers) > 0 {
// If the port or listen IP has changed, we MUST perform a full restart
// because hot-reload (s.kernel.Reload) typically only updates users/rules
// and doesn't re-bind existing listeners in most kernels.
needsRestart := s.kernel.IsRunning() && (s.lastConfig.ServerPort != s.lastAppliedPort || s.lastConfig.ListenIP != s.lastAppliedIP)
if !needsRestart {
// Try hot-reload for other config changes (could be route/user changes).
if err := s.kernel.Reload(s.lastConfig, s.lastUsers, certFile, keyFile); err != nil {
slog.Warn("reload failed, falling back to full restart", "error", err)
needsRestart = true
}
}
if needsRestart || !s.kernel.IsRunning() {
if err := s.kernel.ApplyConfig(s.lastConfig, s.lastUsers, certFile, keyFile); err != nil {
slog.Error("failed to apply config", "error", err)
} else {
// Update tracking state for next change
s.lastAppliedPort = s.lastConfig.ServerPort
s.lastAppliedIP = s.lastConfig.ListenIP
}
}
} else if len(s.lastUsers) == 0 {
slog.Warn("no users, stopping kernel")
s.kernel.Stop()
}
} else if usersChanged && s.kernel.IsRunning() {
// Hot-reload for user-only changes
if err := s.kernel.Reload(s.lastConfig, s.lastUsers, certFile, keyFile); err != nil {
slog.Warn("inbound reload failed, falling back to full restart", "error", err)
if err := s.kernel.ApplyConfig(s.lastConfig, s.lastUsers, certFile, keyFile); err != nil {
slog.Error("failed to apply config (fallback)", "error", err)
} else {
s.lastAppliedPort = s.lastConfig.ServerPort
s.lastAppliedIP = s.lastConfig.ListenIP
}
}
} else if usersChanged && !s.kernel.IsRunning() && len(s.lastUsers) > 0 {
// Kernel was stopped (no users), now we have users — start it
if err := s.kernel.ApplyConfig(s.lastConfig, s.lastUsers, certFile, keyFile); err != nil {
slog.Error("failed to start kernel with new users", "error", err)
}
}
}
func (s *Service) trackAndEnforce(ctx context.Context) {
if !s.kernel.IsRunning() {
return
}
conns, err := s.kernel.GetConnections(ctx)
if err != nil {
slog.Debug("get connections failed", "error", err)
return
}
s.tracker.Process(conns)
s.tracker.LogStats()
kicks := s.limiter.Check(conns)
s.executeKicks(ctx, kicks)
}
func (s *Service) executeKicks(ctx context.Context, kicks []limiter.KickAction) {
for _, kick := range kicks {
slog.Info("kicking connection",
"user_id", kick.UserID,
"conn_id", kick.ConnID,
"reason", kick.Reason,
)
if err := s.kernel.CloseConnection(ctx, kick.ConnID); err != nil {
slog.Error("failed to close connection", "conn_id", kick.ConnID, "error", err)
}
}
}
// pushReport sends consolidated traffic + alive + status to the panel
func (s *Service) pushReport() {
if s.pushBackoff.shouldSkip() {
slog.Debug("skipping report due to backoff")
return
}
traffic := s.tracker.FlushTraffic()
aliveIPs := s.tracker.FlushAliveIPs()
online := s.tracker.CurrentOnline()
status := monitor.Collect()
metrics := s.buildMetrics(status)
if len(traffic) == 0 && len(aliveIPs) == 0 && len(online) == 0 {
// Still send status periodically
if err := s.panel.Report(
nil, nil, nil,
status.CPU,
[2]uint64{status.MemTotal, status.MemUsed},
[2]uint64{status.SwapTotal, status.SwapUsed},
[2]uint64{status.DiskTotal, status.DiskUsed},
metrics,
); err != nil {
slog.Error("failed to push status report", "error", err)
s.pushBackoff.onFailure()
return
}
s.pushBackoff.onSuccess()
return
}
if err := s.panel.Report(
traffic, aliveIPs, online,
status.CPU,
[2]uint64{status.MemTotal, status.MemUsed},
[2]uint64{status.SwapTotal, status.SwapUsed},
[2]uint64{status.DiskTotal, status.DiskUsed},
metrics,
); err != nil {
slog.Error("failed to push report", "error", err)
s.tracker.RestoreTraffic(traffic)
s.tracker.RestoreAliveIPs(aliveIPs)
s.pushBackoff.onFailure()
return
}
s.pushBackoff.onSuccess()
slog.Info("report pushed", "users", len(traffic))
}
// buildMetrics aggregates node-level metrics to be reported to the panel.
// This includes active connections, per-core CPU, GC stats, API call stats,
// WebSocket status, and limiter hit counts.
func (s *Service) buildMetrics(status monitor.Status) map[string]interface{} {
m := make(map[string]interface{})
m["uptime"] = status.Uptime
// Active connections (last measured during tracker.Process()).
m["active_connections"] = s.tracker.ActiveConnections()
m["total_connections"] = s.tracker.TotalConnections()
// Speed
m["inbound_speed"] = s.tracker.InboundSpeed()
m["outbound_speed"] = s.tracker.OutboundSpeed()
// Per-core CPU usage (if available).
if len(status.CPUPerCore) > 0 {
m["cpu_per_core"] = status.CPUPerCore
}
// Speed Limiter metrics
m["speed_limiter"] = map[string]interface{}{
"has_limits": s.speedTracker.HasLimits(),
"limited_users": s.speedTracker.LimitedUserCount(),
}
// GC metrics.
m["gc"] = map[string]interface{}{
"num_gc": status.NumGC,
"last_pause_ms": status.LastPauseMS,
}
// API metrics.
api := s.panel.SnapshotMetrics()
m["api"] = map[string]interface{}{
"success": api.Success,
"failure": api.Failure,
}
// WebSocket status.
wsEnabled := s.wsClient != nil
wsConnected := wsEnabled && s.wsClient.IsConnected()
m["ws"] = map[string]interface{}{
"enabled": wsEnabled,
"connected": wsConnected,
}
// Limiter metrics.
lm := s.limiter.SnapshotMetrics()
m["limits"] = map[string]interface{}{
"device_limit_events": lm.DeviceLimitEvents,
"speed_limited_users": s.speedTracker.LimitedUserCount(),
}
return m
}
// computeConfigHash returns a SHA-256 hash of the kernel-relevant node config
// fields. BaseConfig (PushInterval/PullInterval) is excluded because it only
// affects service-layer polling intervals and must not trigger a kernel restart.
func computeConfigHash(cfg *panel.NodeConfig) string {
if cfg == nil {
return ""
}
tmp := *cfg
tmp.BaseConfig = panel.BaseConfig{}
data, err := json.Marshal(&tmp)
if err != nil {
return ""
}
h := sha256.Sum256(data)
return fmt.Sprintf("%x", h)
}
// computeUserHash returns a deterministic hash of the user list for change detection.
// Uses direct byte encoding instead of binary.Write to avoid reflection overhead.
func computeUserHash(users []panel.User) string {
sorted := make([]panel.User, len(users))
copy(sorted, users)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].ID < sorted[j].ID })
h := sha256.New()
var buf [8]byte
for _, u := range sorted {
buf[0] = byte(u.ID)
buf[1] = byte(u.ID >> 8)
buf[2] = byte(u.ID >> 16)
buf[3] = byte(u.ID >> 24)
buf[4] = byte(u.ID >> 32)
buf[5] = byte(u.ID >> 40)
buf[6] = byte(u.ID >> 48)
buf[7] = byte(u.ID >> 56)
h.Write(buf[:])
h.Write([]byte(u.UUID))
buf[0] = byte(u.SpeedLimit)
buf[1] = byte(u.SpeedLimit >> 8)
buf[2] = byte(u.SpeedLimit >> 16)
buf[3] = byte(u.SpeedLimit >> 24)
buf[4] = byte(u.SpeedLimit >> 32)
buf[5] = byte(u.SpeedLimit >> 40)
buf[6] = byte(u.SpeedLimit >> 48)
buf[7] = byte(u.SpeedLimit >> 56)
h.Write(buf[:])
buf[0] = byte(u.DeviceLimit)
buf[1] = byte(u.DeviceLimit >> 8)
buf[2] = byte(u.DeviceLimit >> 16)
buf[3] = byte(u.DeviceLimit >> 24)
buf[4] = byte(u.DeviceLimit >> 32)
buf[5] = byte(u.DeviceLimit >> 40)
buf[6] = byte(u.DeviceLimit >> 48)
buf[7] = byte(u.DeviceLimit >> 56)
h.Write(buf[:])
}
return fmt.Sprintf("%x", h.Sum(nil))
}