feat: add standalone controlplane support

This commit is contained in:
xboard
2026-04-11 05:36:59 +08:00
parent e9672fbdbe
commit 598b6b531b
34 changed files with 2383 additions and 707 deletions
+175 -239
View File
@@ -15,27 +15,29 @@ import (
"github.com/cedar2025/xboard-node/internal/cert"
"github.com/cedar2025/xboard-node/internal/config"
"github.com/cedar2025/xboard-node/internal/controlplane"
"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/model"
"github.com/cedar2025/xboard-node/internal/monitor"
"github.com/cedar2025/xboard-node/internal/nlog"
"github.com/cedar2025/xboard-node/internal/panel"
"github.com/cedar2025/xboard-node/internal/tracker"
)
type Service struct {
cfg *config.Config
panel *panel.Client
source controlplane.Source
sink controlplane.Sink
kernel kernel.Kernel
tracker *tracker.Tracker
limiter *limiter.Limiter
speedTracker *limiter.SpeedTracker
cert *cert.Manager
lastConfig *panel.NodeConfig
lastUsers []panel.User
lastConfig *model.NodeSpec
lastUsers []model.UserSpec
// nodeLog is the logger with node context for this service instance.
nodeLog *nlog.NodeLog
@@ -43,8 +45,8 @@ type Service struct {
// appliedState tracks the configuration and users that are currently
// successfully running in the kernel.
appliedState struct {
Config *panel.NodeConfig
Users []panel.User
Config *model.NodeSpec
Users []model.UserSpec
}
pushInterval int // seconds
@@ -61,9 +63,9 @@ type Service struct {
// pullResults delivers async pullViaAPI results back to the main goroutine.
pullResults chan pullResult
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
wsClient controlplane.PushClient // Push client (nil if push is not enabled)
wsEvents chan controlplane.Event // receives data events from push transport
wsStatusCh chan controlplane.StatusChange // receives push connectivity notifications
wsCancel context.CancelFunc // cancels the WS client goroutine
wsDisconnectAt time.Time // when WS last disconnected (zero if connected)
@@ -73,8 +75,8 @@ type Service struct {
// pullResult carries the outcome of an async pullViaAPI back to the main goroutine.
type pullResult struct {
config *panel.NodeConfig
users []panel.User
config *model.NodeSpec
users []model.UserSpec
configHash string
userHash string
certChanged bool
@@ -113,8 +115,7 @@ func (b *apiBackoff) onFailure() {
}
func New(cfg *config.Config) *Service {
panelClient := panel.NewClient(cfg.Panel)
certMgr := cert.NewManager(cfg.Cert)
certMgr := cert.NewManager(cfg.Cert)
var k kernel.Kernel
switch cfg.Kernel.Type {
@@ -130,16 +131,24 @@ func New(cfg *config.Config) *Service {
l := limiter.New()
st := limiter.NewSpeedTracker(l)
var cp controlplane.ControlPlane
if cfg.IsStandalone() {
cp = controlplane.NewLocalControlPlane(cfg)
} else {
cp = controlplane.NewPanelControlPlane(cfg.Panel, cfg.WS)
}
return &Service{
cfg: cfg,
panel: panelClient,
source: cp,
sink: cp,
kernel: k,
tracker: tracker.New(),
limiter: l,
speedTracker: st,
cert: certMgr,
wsEvents: make(chan panel.WSEvent, 16),
wsStatusCh: make(chan panel.WSStatusChange, 4),
wsEvents: make(chan controlplane.Event, 16),
wsStatusCh: make(chan controlplane.StatusChange, 4),
pullResults: make(chan pullResult, 1),
}
}
@@ -157,6 +166,7 @@ func (s *Service) Run(ctx context.Context) error {
}
defer s.kernel.Stop()
// Set up tickers
trackTicker := time.NewTicker(time.Duration(s.cfg.Node.TrackInterval) * time.Second)
pushInterval := time.Duration(math.Max(float64(s.pushInterval), 5)) * time.Second
@@ -218,35 +228,18 @@ func (s *Service) Run(ctx context.Context) error {
}
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.
// Register speed limit lookup with kernel unconditionally (before push/poll branch).
s.kernel.SetSpeedLimitFunc(s.speedTracker.GetLimiter)
s.kernel.SetDeviceLimitFunc(s.limiter.GetDeviceLimitByUUID)
var hs *panel.HandshakeResponse
var err error
// Retry loop for initial handshake
for attempt := 1; ; attempt++ {
hs, err = s.panel.Handshake()
if err != nil {
nlog.Core().Error(fmt.Sprintf("handshake failed (attempt %d): %v", attempt, 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
bootstrap, err := s.source.Initial(ctx, s.wsMetrics, s.wsEvents, s.wsStatusCh)
if err != nil {
return err
}
// Apply settings from panel handshake (Handshake provides default intervals)
if s.cfg.Node.PushInterval == 0 && hs.Settings.PushInterval > 0 {
s.pushInterval = hs.Settings.PushInterval
if s.cfg.Node.PushInterval == 0 && bootstrap.PushInterval > 0 {
s.pushInterval = bootstrap.PushInterval
} else {
s.pushInterval = s.cfg.Node.PushInterval
}
@@ -254,8 +247,8 @@ func (s *Service) initialSetup(ctx context.Context) error {
s.pushInterval = 60
}
if s.cfg.Node.PullInterval == 0 && hs.Settings.PullInterval > 0 {
s.pullInterval = hs.Settings.PullInterval
if s.cfg.Node.PullInterval == 0 && bootstrap.PullInterval > 0 {
s.pullInterval = bootstrap.PullInterval
} else {
s.pullInterval = s.cfg.Node.PullInterval
}
@@ -263,65 +256,41 @@ func (s *Service) initialSetup(ctx context.Context) error {
s.pullInterval = 60
}
// If WebSocket is enabled, skip initial V1 APIs fetch and wait for push
if hs.WebSocket.Enabled && hs.WebSocket.WSURL != "" {
s.wsClient = s.newWSClient(hs.WebSocket.WSURL)
if bootstrap.Push != nil {
s.wsClient = bootstrap.Push
return nil
}
// Falls back to V1 APIs only if WebSocket is disabled in panel
nlog.Core().Info("websocket disabled, using REST API")
// 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 {
if bootstrap.Config == nil {
return fmt.Errorf("initial config is nil")
}
users, err := s.panel.GetUsers()
if err != nil {
return fmt.Errorf("initial user fetch: %w", err)
}
s.metricsMu.Lock()
s.lastConfig = bootstrap.Config
s.metricsMu.Unlock()
s.lastConfigHash = computeConfigHash(bootstrap.Config)
s.updateUserState(bootstrap.Users)
// Apply initial data
s.lastConfig = nodeConfig
s.lastUsers = users
s.lastUserHash = computeUserHash(users)
s.lastConfigHash = computeConfigHash(nodeConfig)
s.limiter.UpdateUsers(users)
s.speedTracker.UpdateBuckets()
nlog.Core().Info("handshake complete (V1 fallback)",
"protocol", nodeConfig.Protocol,
"port", nodeConfig.ServerPort,
"users", len(users),
nlog.Core().Info("initial snapshot ready",
"protocol", bootstrap.Config.Protocol,
"port", bootstrap.Config.ServerPort,
"users", len(bootstrap.Users),
)
if len(users) == 0 {
if len(bootstrap.Users) == 0 {
nlog.Core().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)
if err := s.kernel.Start(nodeConfig, users, s.cert.CertFile(), s.cert.KeyFile()); err != nil {
return fmt.Errorf("start kernel: %w", err)
s.applyRemoteOverrides(ctx, bootstrap.Config)
if !s.startKernel(bootstrap.Config, bootstrap.Users) {
return fmt.Errorf("start kernel")
}
// record applied state on success
s.appliedState.Config = nodeConfig
s.appliedState.Users = users
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 {
func (s *Service) applyRemoteOverrides(ctx context.Context, nc *model.NodeSpec) bool {
if nc == nil {
return false
}
@@ -334,7 +303,7 @@ func (s *Service) applyRemoteOverrides(ctx context.Context, nc *panel.NodeConfig
// Certificate configuration from panel (panel-first: takes precedence over local config)
if nc.CertConfig != nil {
return s.applyPanelCert(ctx, nc.CertConfig)
return s.applyNodeCert(ctx, nc.CertConfig)
}
// Legacy fields (deprecated: prefer cert_config)
@@ -351,30 +320,21 @@ func (s *Service) applyRemoteOverrides(ctx context.Context, nc *panel.NodeConfig
// 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 {
nlog.Core().Error("failed to apply panel cert config", "mode", pc.CertMode, "error", err)
func (s *Service) applyNodeCert(ctx context.Context, newCfg *config.CertConfig) bool {
if newCfg == nil {
return false
}
s.cfg.Cert = newCfg
cfgCopy := *newCfg
cfgCopy.CertDir = s.cfg.Cert.CertDir
changed, err := s.cert.Reconfigure(ctx, cfgCopy)
if err != nil {
nlog.Core().Error("failed to apply runtime cert config", "mode", cfgCopy.CertMode, "error", err)
return false
}
s.cfg.Cert = cfgCopy
if changed {
msg := fmt.Sprintf("cert: paths updated from panel cert=%s key=%s", s.cert.CertFile(), s.cert.KeyFile())
msg := fmt.Sprintf("cert: paths updated cert=%s key=%s", s.cert.CertFile(), s.cert.KeyFile())
if s.nodeLog != nil {
s.nodeLog.Info(msg)
} else {
@@ -384,7 +344,7 @@ func (s *Service) applyPanelCert(ctx context.Context, pc *panel.CertConfig) bool
return changed
}
// startWSClient starts the WS client goroutine if a client is configured.
// startWSClient starts the push client goroutine if a client is configured.
func (s *Service) startWSClient(ctx context.Context) {
if s.wsClient == nil {
return
@@ -394,46 +354,19 @@ func (s *Service) startWSClient(ctx context.Context) {
go s.wsClient.Run(wsCtx)
}
// newWSClient creates a WSClient with standard event/status callbacks.
func (s *Service) newWSClient(wsURL string) *panel.WSClient {
wsCfg := panel.WSClientConfig{
StatusInterval: time.Duration(s.cfg.WS.StatusInterval) * time.Second,
HandshakeTimeout: time.Duration(s.cfg.WS.HandshakeTimeout) * time.Second,
BackoffInitial: time.Duration(s.cfg.WS.BackoffInitial) * time.Second,
BackoffMax: time.Duration(s.cfg.WS.BackoffMax) * time.Second,
}
return panel.NewWSClient(
wsURL,
s.cfg.Panel.Token,
s.cfg.Panel.NodeID,
wsCfg,
func(event panel.WSEvent) {
select {
case s.wsEvents <- event:
default:
nlog.Core().Warn("ws event channel full, dropping event", "type", event.Type)
}
},
func(status panel.WSStatusChange) {
select {
case s.wsStatusCh <- status:
default:
}
},
func() map[string]interface{} {
status := monitor.Collect()
m := s.buildMetrics(status)
m["kernel_status"] = s.kernel.IsRunning()
return m
},
)
func (s *Service) wsMetrics() map[string]interface{} {
status := monitor.Collect()
m := s.buildMetrics(status)
m["kernel_status"] = s.kernel.IsRunning()
return m
}
// 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) {
func (s *Service) handleWSStatus(ctx context.Context, status controlplane.StatusChange) {
if status.Connected {
s.metricsMu.Lock()
s.wsDisconnectAt = time.Time{}
@@ -474,41 +407,42 @@ func (s *Service) handleWSStatus(ctx context.Context, status panel.WSStatusChang
// 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
nlog.Core().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
nlog.Core().Debug("ws discovery: WS disconnected for >10min, re-checking WS config")
if !s.source.SupportsDiscovery() {
return
}
needsCheck := false
if s.wsClient == nil {
needsCheck = true
nlog.Core().Debug("push discovery: no push client, checking if control plane enabled push")
} else if !s.wsDisconnectAt.IsZero() && time.Since(s.wsDisconnectAt) > 10*time.Minute {
needsCheck = true
nlog.Core().Debug("push discovery: push disconnected for >10min, re-checking")
}
if !needsCheck {
return
}
hs, err := s.panel.Handshake()
pushClient, err := s.source.Discover(ctx, s.wsMetrics, s.wsEvents, s.wsStatusCh)
if err != nil {
nlog.Core().Debug("ws discovery: handshake failed", "error", err)
nlog.Core().Debug("push discovery failed", "error", err)
return
}
if s.source.SupportsPolling() {
s.pullViaAPIAsync(ctx)
}
// Apply any latest config/user changes via dedicated APIs
s.pullViaAPIAsync(ctx)
if hs.WebSocket.Enabled && hs.WebSocket.WSURL != "" {
if pushClient != nil {
if s.wsClient == nil {
nlog.Core().Info("ws discovery: panel has WS enabled, creating WS client")
wsc := s.newWSClient(hs.WebSocket.WSURL)
nlog.Core().Info("push discovery: control plane enabled push, creating client")
s.metricsMu.Lock()
s.wsClient = wsc
s.wsClient = pushClient
s.wsDisconnectAt = time.Time{}
s.metricsMu.Unlock()
s.startWSClient(ctx)
}
} else if s.wsClient != nil {
nlog.Core().Info("ws discovery: panel disabled WS, switching to REST-only")
nlog.Core().Info("push discovery: control plane disabled push, switching to polling")
if s.wsCancel != nil {
s.wsCancel()
}
@@ -521,9 +455,9 @@ func (s *Service) wsDiscovery(ctx context.Context) {
}
// handleWSEvent processes data events received via WebSocket
func (s *Service) handleWSEvent(ctx context.Context, event panel.WSEvent) {
func (s *Service) handleWSEvent(ctx context.Context, event controlplane.Event) {
switch event.Type {
case panel.WSEventSyncConfig:
case controlplane.EventSyncConfig:
if event.Config == nil {
return
}
@@ -543,7 +477,7 @@ func (s *Service) handleWSEvent(ctx context.Context, event panel.WSEvent) {
s.applyRemoteOverrides(ctx, event.Config)
s.applyChanges(ctx, true, false)
case panel.WSEventSyncUsers:
case controlplane.EventSyncUsers:
if event.Users == nil {
return
}
@@ -556,7 +490,7 @@ func (s *Service) handleWSEvent(ctx context.Context, event panel.WSEvent) {
}
s.applyUserUpdate(ctx, event.Users, newHash)
case panel.WSEventSyncUserDelta:
case controlplane.EventSyncUserDelta:
if len(event.DeltaUsers) == 0 {
return
}
@@ -565,7 +499,7 @@ func (s *Service) handleWSEvent(ctx context.Context, event panel.WSEvent) {
}
s.applyUserDelta(ctx, event.DeltaAction, event.DeltaUsers)
case panel.WSEventSyncDevices:
case controlplane.EventSyncDevices:
// Sync global device state
if event.DeviceUsers != nil {
s.kernel.UpdateGlobalDevices(event.DeviceUsers)
@@ -579,55 +513,43 @@ func (s *Service) handleWSEvent(ctx context.Context, event panel.WSEvent) {
// pullViaAPIAsync fetches config/users from the panel API in a background
// goroutine and sends the result to pullResults for the main goroutine to apply.
func (s *Service) pullViaAPIAsync(ctx context.Context) {
if !s.source.SupportsPolling() {
return
}
if !s.pullActive.CompareAndSwap(false, true) {
nlog.Core().Debug("pull already in progress, skipping")
return
}
if s.pullBackoff.shouldSkip() {
nlog.Core().Debug("skipping pull due to backoff")
s.pullActive.Store(false)
return
}
// Capture current hashes for comparison in the goroutine.
currentConfigHash := s.lastConfigHash
certChanged := s.cert.CertRenewed()
go func() {
defer s.pullActive.Store(false)
config, err := s.panel.GetConfig()
snapshot, err := s.source.Poll(ctx)
if err != nil {
nlog.Core().Error("poll config failed", "error", err)
nlog.Core().Error("poll control plane failed", "error", err)
s.pullBackoff.onFailure()
return
}
users, err := s.panel.GetUsers()
if err != nil {
nlog.Core().Error("poll users failed", "error", err)
s.pullBackoff.onFailure()
return
}
s.pullBackoff.onSuccess()
result := pullResult{
config: config,
users: users,
certChanged: certChanged,
result := pullResult{certChanged: certChanged}
if snapshot.Config != nil {
result.config = snapshot.Config
result.configHash = computeConfigHash(snapshot.Config)
if result.configHash == currentConfigHash && !certChanged {
result.config = nil
}
}
if config != nil {
result.configHash = computeConfigHash(config)
}
if users != nil {
result.userHash = computeUserHash(users)
}
// Detect config change using captured hash.
if config != nil && result.configHash == currentConfigHash && !certChanged {
result.config = nil // signal no change
if snapshot.Users != nil {
result.users = snapshot.Users
result.userHash = computeUserHash(snapshot.Users)
}
select {
@@ -679,25 +601,48 @@ func (s *Service) applyPullResult(ctx context.Context, result pullResult) {
// ─── User state helpers ─────────────────────────────────────────────────────
func (s *Service) updateUserState(users []panel.User) {
// Defensive nil check
func (s *Service) updateUserState(users []model.UserSpec) {
if users == nil {
users = []panel.User{}
users = []model.UserSpec{}
}
_, _ = s.prepareUserState(users)
}
func (s *Service) prepareUserState(users []model.UserSpec) (prevUsers []model.UserSpec, prevHash string) {
if users == nil {
users = []model.UserSpec{}
}
s.metricsMu.RLock()
prevUsers = append([]model.UserSpec(nil), s.lastUsers...)
s.metricsMu.RUnlock()
prevHash = s.lastUserHash
s.limiter.UpdateUsers(users)
s.speedTracker.UpdateBuckets()
s.metricsMu.Lock()
s.lastUsers = users
s.lastUsers = append([]model.UserSpec(nil), users...)
s.metricsMu.Unlock()
s.lastUserHash = computeUserHash(users)
return prevUsers, prevHash
}
func (s *Service) restoreUserState(users []model.UserSpec, hash string) {
if users == nil {
users = []model.UserSpec{}
}
s.limiter.UpdateUsers(users)
s.speedTracker.UpdateBuckets()
s.metricsMu.Lock()
s.lastUsers = append([]model.UserSpec(nil), users...)
s.metricsMu.Unlock()
s.lastUserHash = hash
}
// startKernel starts (or restarts) the kernel with the given config/users and
// records the successfully applied state. Returns false on error.
func (s *Service) startKernel(nc *panel.NodeConfig, users []panel.User) bool {
func (s *Service) startKernel(nc *model.NodeSpec, users []model.UserSpec) bool {
if err := s.kernel.Start(nc, users, s.cert.CertFile(), s.cert.KeyFile()); err != nil {
nlog.Core().Error("failed to start kernel", "error", err)
return false
@@ -734,18 +679,23 @@ func (s *Service) ensureRunning() bool {
// applyUserUpdate replaces the full user set and hot-swaps the kernel.
// Called from WS sync.users and REST polling.
func (s *Service) applyUserUpdate(ctx context.Context, users []panel.User, newHash string) {
func (s *Service) applyUserUpdate(ctx context.Context, users []model.UserSpec, newHash string) {
if !s.ensureRunning() {
return
}
prevUsers, prevHash := s.prepareUserState(users)
added, removed, err := s.kernel.UpdateUsers(users)
if err != nil {
nlog.Core().Warn(fmt.Sprintf("UpdateUsers failed, restarting kernel: %v", err))
s.startKernel(s.lastConfig, users)
if !s.startKernel(s.lastConfig, users) {
s.restoreUserState(prevUsers, prevHash)
}
return
}
s.updateUserState(users)
if newHash != "" {
s.lastUserHash = newHash
}
if s.nodeLog != nil && (added > 0 || removed > 0) {
s.nodeLog.Info(fmt.Sprintf("users updated: +%d -%d", added, removed))
}
@@ -753,7 +703,7 @@ func (s *Service) applyUserUpdate(ctx context.Context, users []panel.User, newHa
// applyUserDelta applies an incremental user change (add or remove) directly
// via the kernel's atomic user API. Kernel updates run before updateUserState.
func (s *Service) applyUserDelta(ctx context.Context, action string, deltaUsers []panel.User) {
func (s *Service) applyUserDelta(ctx context.Context, action string, deltaUsers []model.UserSpec) {
switch action {
case "add":
// Defensive check for empty or nil deltaUsers
@@ -769,21 +719,22 @@ func (s *Service) applyUserDelta(ctx context.Context, action string, deltaUsers
for _, delta := range deltaUsers {
for _, old := range s.lastUsers {
if old.ID == delta.ID && old.UUID != delta.UUID {
s.kernel.RemoveUsers([]panel.User{old})
s.kernel.RemoveUsers([]model.UserSpec{old})
break
}
}
}
prevUsers, prevHash := s.prepareUserState(merged)
added, err := s.kernel.AddUsers(deltaUsers)
if err != nil {
nlog.Core().Warn(fmt.Sprintf("AddUsers failed: %v, falling back to UpdateUsers", err))
if _, _, err := s.kernel.UpdateUsers(merged); err != nil {
nlog.Core().Error(fmt.Sprintf("UpdateUsers fallback failed: %v", err))
s.restoreUserState(prevUsers, prevHash)
return
}
}
s.updateUserState(merged)
if s.nodeLog != nil && added > 0 {
s.nodeLog.Info(fmt.Sprintf("users added: +%d", added))
}
@@ -799,15 +750,16 @@ func (s *Service) applyUserDelta(ctx context.Context, action string, deltaUsers
return
}
prevUsers, prevHash := s.prepareUserState(filtered)
removed, err := s.kernel.RemoveUsers(deltaUsers)
if err != nil {
nlog.Core().Warn(fmt.Sprintf("RemoveUsers failed: %v, falling back to UpdateUsers", err))
if _, _, err := s.kernel.UpdateUsers(filtered); err != nil {
nlog.Core().Error(fmt.Sprintf("UpdateUsers fallback failed: %v", err))
s.restoreUserState(prevUsers, prevHash)
return
}
}
s.updateUserState(filtered)
if s.nodeLog != nil && removed > 0 {
s.nodeLog.Info(fmt.Sprintf("users removed: -%d", removed))
}
@@ -819,23 +771,23 @@ func (s *Service) applyUserDelta(ctx context.Context, action string, deltaUsers
// mergeUsers overlays deltaUsers onto base (keyed by ID). New users are
// appended, existing users have their properties overwritten.
func mergeUsers(base, delta []panel.User) []panel.User {
func mergeUsers(base, delta []model.UserSpec) []model.UserSpec {
// Handle nil slices
if base == nil {
base = []panel.User{}
base = []model.UserSpec{}
}
if delta == nil {
return base
}
m := make(map[int]panel.User, len(base))
m := make(map[int]model.UserSpec, len(base))
for _, u := range base {
m[u.ID] = u
}
for _, u := range delta {
m[u.ID] = u
}
out := make([]panel.User, 0, len(m))
out := make([]model.UserSpec, 0, len(m))
for _, u := range m {
out = append(out, u)
}
@@ -843,7 +795,7 @@ func mergeUsers(base, delta []panel.User) []panel.User {
}
// subtractUsers returns base with all users in delta removed.
func subtractUsers(base, delta []panel.User) []panel.User {
func subtractUsers(base, delta []model.UserSpec) []model.UserSpec {
if base == nil {
return nil
}
@@ -854,7 +806,7 @@ func subtractUsers(base, delta []panel.User) []panel.User {
for _, u := range delta {
removeSet[u.ID] = struct{}{}
}
out := make([]panel.User, 0, len(base))
out := make([]model.UserSpec, 0, len(base))
for _, u := range base {
if _, ok := removeSet[u.ID]; !ok {
out = append(out, u)
@@ -922,18 +874,19 @@ func (s *Service) trackAndEnforce(ctx context.Context) {
// pushReportAsync sends the report in a background goroutine so the select
// loop is never blocked by slow HTTP. Only one push runs at a time.
func (s *Service) pushReportAsync() {
if !s.sink.SupportsReporting() {
return
}
if !s.pushActive.CompareAndSwap(false, true) {
nlog.Core().Debug("push already in progress, skipping")
return
}
if s.pushBackoff.shouldSkip() {
nlog.Core().Debug("skipping report due to backoff")
s.pushActive.Store(false)
return
}
// Snapshot data under the select goroutine (fast, mutex-only).
traffic := s.tracker.FlushTraffic()
aliveIPs := s.tracker.FlushAliveIPs()
online := s.tracker.CurrentOnline()
@@ -943,26 +896,13 @@ func (s *Service) pushReportAsync() {
go func() {
defer s.pushActive.Store(false)
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 {
if err := s.sink.Report(controlplane.ReportPayload{Traffic: traffic, Alive: aliveIPs, Online: online, CPU: status.CPU, Mem: [2]uint64{status.MemTotal, status.MemUsed}, Swap: [2]uint64{status.SwapTotal, status.SwapUsed}, Disk: [2]uint64{status.DiskTotal, status.DiskUsed}, Metrics: metrics}); err != nil {
nlog.Core().Error("failed to push report", "error", err)
if len(traffic) > 0 {
s.tracker.RestoreTraffic(traffic)
}
if len(aliveIPs) > 0 {
s.tracker.RestoreAliveIPs(aliveIPs)
}
if len(traffic) > 0 { s.tracker.RestoreTraffic(traffic) }
if len(aliveIPs) > 0 { s.tracker.RestoreAliveIPs(aliveIPs) }
s.pushBackoff.onFailure()
return
}
s.pushBackoff.onSuccess()
nlog.ReportPushed(len(traffic), len(online))
}()
@@ -970,6 +910,9 @@ func (s *Service) pushReportAsync() {
// pushReportSync is used only during shutdown to ensure final data is sent.
func (s *Service) pushReportSync() {
if !s.sink.SupportsReporting() {
return
}
traffic := s.tracker.FlushTraffic()
aliveIPs := s.tracker.FlushAliveIPs()
online := s.tracker.CurrentOnline()
@@ -977,14 +920,7 @@ func (s *Service) pushReportSync() {
metrics := s.buildMetrics(status)
metrics["kernel_status"] = s.kernel.IsRunning()
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 {
if err := s.sink.Report(controlplane.ReportPayload{Traffic: traffic, Alive: aliveIPs, Online: online, CPU: status.CPU, Mem: [2]uint64{status.MemTotal, status.MemUsed}, Swap: [2]uint64{status.SwapTotal, status.SwapUsed}, Disk: [2]uint64{status.DiskTotal, status.DiskUsed}, Metrics: metrics}); err != nil {
nlog.Core().Error("failed to push final report", "error", err)
}
}
@@ -1038,7 +974,7 @@ func (s *Service) buildMetrics(status monitor.Status) map[string]interface{} {
}
// API metrics.
api := s.panel.SnapshotMetrics()
api := s.source.Metrics()
m["api"] = map[string]interface{}{
"success": api.Success,
"failure": api.Failure,
@@ -1065,7 +1001,7 @@ func (s *Service) buildMetrics(status monitor.Status) map[string]interface{} {
// computeConfigHash returns a deterministic hash of the node config.
// It uses JSON marshaling to ensure all fields are captured, ensuring that
// any configuration change correctly triggers a kernel reload.
func computeConfigHash(cfg *panel.NodeConfig) string {
func computeConfigHash(cfg *model.NodeSpec) string {
if cfg == nil {
return ""
}
@@ -1079,8 +1015,8 @@ func computeConfigHash(cfg *panel.NodeConfig) string {
// 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))
func computeUserHash(users []model.UserSpec) string {
sorted := make([]model.UserSpec, len(users))
copy(sorted, users)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].ID < sorted[j].ID })
@@ -1112,7 +1048,7 @@ func (s *Service) sendDeviceBatch() {
nlog.Core().Debug("device snapshot unchanged, skipping")
return
}
s.wsClient.SendDeviceReport(devices)
s.sink.ReportDevices(s.wsClient, devices)
nlog.Core().Debug("device snapshot sent", "users", len(devices))
}
+241
View File
@@ -0,0 +1,241 @@
package service
import (
"context"
"errors"
"testing"
"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/limiter"
"github.com/cedar2025/xboard-node/internal/model"
"golang.org/x/time/rate"
)
type fakeKernel struct {
running bool
startErr error
updateErr error
addErr error
startCalls int
updateCalls int
addCalls int
removeCalls int
onUpdateUsers func([]model.UserSpec)
onAddUsers func([]model.UserSpec)
onRemoveUsers func([]model.UserSpec)
speedLimitFunc func(string) *rate.Limiter
deviceLimitFunc func(string) (int, bool)
}
func (f *fakeKernel) Name() string { return "fake" }
func (f *fakeKernel) Protocols() []string { return []string{"vless"} }
func (f *fakeKernel) Capabilities() kernel.Capabilities { return kernel.Capabilities{} }
func (f *fakeKernel) Start(nodeConfig *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) error {
_, _, _, _ = nodeConfig, users, certFile, keyFile
f.startCalls++
if f.startErr != nil {
return f.startErr
}
f.running = true
return nil
}
func (f *fakeKernel) Stop() { f.running = false }
func (f *fakeKernel) IsRunning() bool { return f.running }
func (f *fakeKernel) Reload(nodeConfig *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) error {
_, _, _, _ = nodeConfig, users, certFile, keyFile
return nil
}
func (f *fakeKernel) AddUsers(users []model.UserSpec) (int, error) {
f.addCalls++
if f.onAddUsers != nil {
f.onAddUsers(users)
}
if f.addErr != nil {
return 0, f.addErr
}
return len(users), nil
}
func (f *fakeKernel) RemoveUsers(users []model.UserSpec) (int, error) {
f.removeCalls++
if f.onRemoveUsers != nil {
f.onRemoveUsers(users)
}
return len(users), nil
}
func (f *fakeKernel) UpdateUsers(users []model.UserSpec) (int, int, error) {
f.updateCalls++
if f.onUpdateUsers != nil {
f.onUpdateUsers(users)
}
if f.updateErr != nil {
return 0, 0, f.updateErr
}
return len(users), 0, nil
}
func (f *fakeKernel) GetUserTraffic(ctx context.Context) (map[int][2]int64, map[int]map[string]bool, int, error) {
_ = ctx
return nil, nil, 0, nil
}
func (f *fakeKernel) CloseConnection(ctx context.Context, connID string) error {
_, _ = ctx, connID
return nil
}
func (f *fakeKernel) CloseUserConnections(ctx context.Context, uuid string) error {
_, _ = ctx, uuid
return nil
}
func (f *fakeKernel) SetSpeedLimitFunc(fn func(uuid string) *rate.Limiter) { f.speedLimitFunc = fn }
func (f *fakeKernel) SetDeviceLimitFunc(fn func(uuid string) (int, bool)) { f.deviceLimitFunc = fn }
func (f *fakeKernel) UpdateGlobalDevices(users map[int][]string) { _ = users }
func (f *fakeKernel) ClearGlobalDevices() {}
func newTestService(k *fakeKernel) *Service {
s := &Service{
kernel: k,
limiter: limiter.New(),
speedTracker: limiter.NewSpeedTracker(limiter.New()),
cert: cert.NewManager(config.CertConfig{}),
}
// Use a shared limiter instance for device + speed bookkeeping.
s.limiter = limiter.New()
s.speedTracker = limiter.NewSpeedTracker(s.limiter)
k.SetSpeedLimitFunc(s.speedTracker.GetLimiter)
k.SetDeviceLimitFunc(s.limiter.GetDeviceLimitByUUID)
return s
}
func TestApplyUserUpdatePreparesLimiterBeforeKernelUpdate(t *testing.T) {
k := &fakeKernel{running: true}
s := newTestService(k)
s.lastConfig = &model.NodeSpec{Protocol: "vless"}
oldUsers := []model.UserSpec{{ID: 1, UUID: "uuid-old", SpeedLimit: 4}}
s.updateUserState(oldUsers)
newUsers := []model.UserSpec{{ID: 2, UUID: "uuid-new", SpeedLimit: 8}}
k.onUpdateUsers = func(users []model.UserSpec) {
if len(users) != 1 || users[0].UUID != "uuid-new" {
t.Fatalf("unexpected users passed to UpdateUsers: %#v", users)
}
if got := k.speedLimitFunc("uuid-new"); got == nil {
t.Fatal("expected new user's limiter to be visible before kernel UpdateUsers")
}
}
s.applyUserUpdate(context.Background(), newUsers, computeUserHash(newUsers))
if got := k.updateCalls; got != 1 {
t.Fatalf("UpdateUsers call count = %d, want 1", got)
}
if len(s.lastUsers) != 1 || s.lastUsers[0].UUID != "uuid-new" {
t.Fatalf("lastUsers = %#v, want new users", s.lastUsers)
}
if s.speedTracker.GetLimiter("uuid-new") == nil {
t.Fatal("expected limiter for new user after successful update")
}
}
func TestApplyUserUpdateRestoresStateWhenKernelAndRestartFail(t *testing.T) {
k := &fakeKernel{
running: true,
updateErr: errors.New("update failed"),
startErr: errors.New("restart failed"),
}
s := newTestService(k)
s.lastConfig = &model.NodeSpec{Protocol: "vless"}
oldUsers := []model.UserSpec{{ID: 1, UUID: "uuid-old", SpeedLimit: 4}}
s.updateUserState(oldUsers)
oldHash := s.lastUserHash
newUsers := []model.UserSpec{{ID: 2, UUID: "uuid-new", SpeedLimit: 8}}
s.applyUserUpdate(context.Background(), newUsers, computeUserHash(newUsers))
if got := k.startCalls; got != 1 {
t.Fatalf("Start call count = %d, want 1", got)
}
if len(s.lastUsers) != 1 || s.lastUsers[0].UUID != "uuid-old" {
t.Fatalf("lastUsers = %#v, want restored old users", s.lastUsers)
}
if s.lastUserHash != oldHash {
t.Fatalf("lastUserHash = %q, want %q", s.lastUserHash, oldHash)
}
if s.speedTracker.GetLimiter("uuid-old") == nil {
t.Fatal("expected old limiter to be restored after rollback")
}
if s.speedTracker.GetLimiter("uuid-new") != nil {
t.Fatal("expected new limiter to be removed after rollback")
}
}
func TestApplyUserDeltaAddPreparesLimiterBeforeKernelUpdate(t *testing.T) {
k := &fakeKernel{running: true}
s := newTestService(k)
s.lastConfig = &model.NodeSpec{Protocol: "vless"}
oldUsers := []model.UserSpec{{ID: 1, UUID: "uuid-old", SpeedLimit: 4}}
s.updateUserState(oldUsers)
delta := []model.UserSpec{{ID: 2, UUID: "uuid-new", SpeedLimit: 8}}
k.onAddUsers = func(users []model.UserSpec) {
if len(users) != 1 || users[0].UUID != "uuid-new" {
t.Fatalf("unexpected users passed to AddUsers: %#v", users)
}
if got := k.speedLimitFunc("uuid-new"); got == nil {
t.Fatal("expected delta user's limiter to be visible before kernel AddUsers")
}
}
s.applyUserDelta(context.Background(), "add", delta)
if got := k.addCalls; got != 1 {
t.Fatalf("AddUsers call count = %d, want 1", got)
}
if s.speedTracker.GetLimiter("uuid-new") == nil {
t.Fatal("expected limiter for delta-added user after successful update")
}
}
func TestApplyUserDeltaAddRemovesOldUUIDBeforeAdd(t *testing.T) {
k := &fakeKernel{running: true}
s := newTestService(k)
s.lastConfig = &model.NodeSpec{Protocol: "vless"}
oldUsers := []model.UserSpec{{ID: 1, UUID: "uuid-old", SpeedLimit: 4}}
s.updateUserState(oldUsers)
delta := []model.UserSpec{{ID: 1, UUID: "uuid-new", SpeedLimit: 8}}
removalsBeforeAdd := 0
k.onRemoveUsers = func(users []model.UserSpec) {
removalsBeforeAdd = k.addCalls
if len(users) != 1 || users[0].UUID != "uuid-old" {
t.Fatalf("unexpected users passed to RemoveUsers: %#v", users)
}
}
k.onAddUsers = func(users []model.UserSpec) {
if len(users) != 1 || users[0].UUID != "uuid-new" {
t.Fatalf("unexpected users passed to AddUsers: %#v", users)
}
if k.removeCalls != 1 || removalsBeforeAdd != 0 {
t.Fatalf("expected RemoveUsers before AddUsers, removeCalls=%d removalsBeforeAdd=%d", k.removeCalls, removalsBeforeAdd)
}
if got := k.speedLimitFunc("uuid-new"); got == nil {
t.Fatal("expected replacement user's limiter to be visible before kernel AddUsers")
}
}
s.applyUserDelta(context.Background(), "add", delta)
if len(s.lastUsers) != 1 || s.lastUsers[0].UUID != "uuid-new" {
t.Fatalf("lastUsers = %#v, want replacement user", s.lastUsers)
}
if s.speedTracker.GetLimiter("uuid-old") != nil {
t.Fatal("expected old UUID limiter to be removed after replacement")
}
if s.speedTracker.GetLimiter("uuid-new") == nil {
t.Fatal("expected new UUID limiter to be installed after replacement")
}
}