feat: add standalone controlplane support
This commit is contained in:
+175
-239
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user