From 0975b4540477003e8224d598905c473bd48abf60 Mon Sep 17 00:00:00 2001 From: xboard Date: Mon, 16 Mar 2026 06:55:42 +0800 Subject: [PATCH] refactor: optimize user management, fix speed limit hot-reload and improve performance --- internal/kernel/kernel.go | 93 +++- internal/kernel/singbox/singbox.go | 227 ++++++++- internal/kernel/xray/config.go | 17 +- internal/kernel/xray/config_test.go | 26 + internal/kernel/xray/xray.go | 755 ++++++++++++++++++++++------ internal/limiter/speedtracker.go | 54 +- internal/service/service.go | 269 +++++----- 7 files changed, 1115 insertions(+), 326 deletions(-) diff --git a/internal/kernel/kernel.go b/internal/kernel/kernel.go index 1522478..1377709 100644 --- a/internal/kernel/kernel.go +++ b/internal/kernel/kernel.go @@ -7,32 +7,60 @@ import ( "golang.org/x/time/rate" ) -// Kernel is the interface for proxy kernel backends (sing-box, xray, etc.) +// Kernel is the interface for proxy kernel backends (sing-box, xray, etc.). +// +// The interface is split into lifecycle, user management, and observability +// groups. User operations (AddUsers/RemoveUsers) are designed to be atomic +// and non-disruptive — they MUST NOT restart listeners or drop existing +// connections. Only Start and Reload may (re)bind ports. +// +// Implementors: singbox.SingBox, xray.Xray type Kernel interface { - // Name returns the kernel identifier (e.g. "sing-box") + // ─── Identity ─────────────────────────────────────────────────────── + // Name returns the kernel identifier (e.g. "sing-box", "xray"). Name() string - // ApplyConfig generates kernel config from panel data and (re)starts the process - ApplyConfig(nodeConfig *panel.NodeConfig, users []panel.User, certFile, keyFile string) error - // Stop gracefully stops the kernel process + // Protocols returns the protocol names this kernel supports. + Protocols() []string + + // ─── Lifecycle ────────────────────────────────────────────────────── + // Start initialises the kernel with the given node config and initial + // user set, binds listeners, and begins accepting connections. + // Calling Start on an already-running kernel stops the old instance first. + Start(nodeConfig *panel.NodeConfig, users []panel.User, certFile, keyFile string) error + // Stop gracefully shuts down the kernel, draining active connections. Stop() - // IsRunning returns whether the kernel is currently active + // IsRunning returns whether the kernel is currently accepting connections. IsRunning() bool - // GetConnections returns all active proxy connections - GetConnections(ctx context.Context) ([]Connection, error) - // CloseConnection terminates a specific connection by ID - CloseConnection(ctx context.Context, connID string) error - // SetSpeedLimitFunc configures optional per-user bandwidth throttling. - // The function resolves a user UUID to a *rate.Limiter (nil = unlimited). - // For sing-box, the limiter is embedded in the connection tracker wrapper - // so both byte counting and rate limiting happen in a single CountFunc - // callback, allowing splice/zero-copy paths. - SetSpeedLimitFunc(fn func(uuid string) *rate.Limiter) - // Reload hot-swaps the inbound users and routing rules without full kernel restart. - // Existing connections may be briefly interrupted, but routes/outbounds stay alive. + // Reload re-generates the full config and hot-swaps listeners/routes. + // Use this when port, protocol, or TLS settings change. + // Existing connections MAY be briefly interrupted. Reload(nodeConfig *panel.NodeConfig, users []panel.User, certFile, keyFile string) error + + // ─── User management (non-disruptive) ─────────────────────────────── + // AddUsers registers new users with the running kernel. + // Existing connections are unaffected. Returns the count actually added + // (duplicates are silently skipped). + AddUsers(users []panel.User) (added int, err error) + // RemoveUsers deregisters users from the running kernel. + // Active connections of removed users are terminated. Returns the count + // actually removed (unknown IDs are silently skipped). + RemoveUsers(users []panel.User) (removed int, err error) + // UpdateUsers replaces the entire user set atomically. + // This is equivalent to computing the diff and calling Add/Remove, but + // may be more efficient for bulk changes. Returns (added, removed) counts. + UpdateUsers(users []panel.User) (added, removed int, err error) + + // ─── Observability ────────────────────────────────────────────────── + // GetConnections returns a snapshot of all active proxy connections. + GetConnections(ctx context.Context) ([]Connection, error) + // CloseConnection terminates a specific connection by ID. + CloseConnection(ctx context.Context, connID string) error + // SetSpeedLimitFunc configures per-user bandwidth throttling. + // The function resolves a user UUID to a *rate.Limiter (nil = unlimited). + SetSpeedLimitFunc(fn func(uuid string) *rate.Limiter) } -// Connection represents an active proxy connection reported by the kernel +// Connection represents an active proxy connection reported by the kernel. type Connection struct { ID string UserID int // extracted user ID (0 if unknown) @@ -40,3 +68,30 @@ type Connection struct { Download int64 // cumulative download bytes SourceIP string // client source IP (cleaned, no port/brackets) } + +// UserDiff computes which users to add and which to remove when transitioning +// from oldUsers to newUsers. This is a pure helper used by callers; kernels +// may also use it internally. +func UserDiff(oldUsers, newUsers []panel.User) (toAdd, toRemove []panel.User) { + oldMap := make(map[int]panel.User, len(oldUsers)) + for _, u := range oldUsers { + oldMap[u.ID] = u + } + newMap := make(map[int]panel.User, len(newUsers)) + for _, u := range newUsers { + newMap[u.ID] = u + } + + for _, u := range newUsers { + old, exists := oldMap[u.ID] + if !exists || old.UUID != u.UUID { + toAdd = append(toAdd, u) + } + } + for _, u := range oldUsers { + if _, exists := newMap[u.ID]; !exists { + toRemove = append(toRemove, u) + } + } + return +} diff --git a/internal/kernel/singbox/singbox.go b/internal/kernel/singbox/singbox.go index 71733ff..8072629 100644 --- a/internal/kernel/singbox/singbox.go +++ b/internal/kernel/singbox/singbox.go @@ -29,10 +29,9 @@ const drainTimeout = 5 * time.Second // SingBox implements kernel.Kernel by embedding sing-box as a Go library. // -// Connection tracking is done entirely in-process via ConnTracker, which is -// registered as an adapter.ConnectionTracker with sing-box's router. This -// avoids the HTTP/JSON overhead of the Clash API (saves 5-15 MB at 500+ -// concurrent connections). +// User operations (AddUsers/RemoveUsers/UpdateUsers) use sing-box's native +// UpdatableInbound interface, which hot-swaps user credentials without +// restarting listeners — zero connection disruption. type SingBox struct { cfg config.KernelConfig @@ -41,9 +40,14 @@ type SingBox struct { ctx context.Context cancel context.CancelFunc + users []panel.User + nodeConfig *panel.NodeConfig + certFile string + keyFile string + // connTracker is our lightweight in-process byte/IP tracker. - // Created fresh on every ApplyConfig (full restart). - // Survives ReloadUsers (hot-swap) since live connections persist. + // Created fresh on every Start (full restart). + // Survives Reload (hot-swap) since live connections persist. connTracker *ConnTracker // speedLimitFunc resolves a user UUID to a *rate.Limiter. @@ -51,7 +55,7 @@ type SingBox struct { speedLimitFunc func(string) *rate.Limiter // trackerRegistered prevents duplicate AppendTracker calls on the same - // Router instance during ReloadUsers. Reset to false on full restart. + // Router instance during Reload. Reset to false on full restart. trackerRegistered bool } @@ -59,9 +63,18 @@ func New(cfg config.KernelConfig) *SingBox { return &SingBox{cfg: cfg} } +var _ kernel.Kernel = (*SingBox)(nil) + func (s *SingBox) Name() string { return "sing-box" } -func (s *SingBox) ApplyConfig(nodeConfig *panel.NodeConfig, users []panel.User, certFile, keyFile string) error { +func (s *SingBox) Protocols() []string { + return []string{ + "vmess", "vless", "trojan", "shadowsocks", + "hysteria2", "tuic", "naive", "socks", "http", "anytls", + } +} + +func (s *SingBox) Start(nodeConfig *panel.NodeConfig, users []panel.User, certFile, keyFile string) error { s.mu.Lock() defer s.mu.Unlock() @@ -102,6 +115,10 @@ func (s *SingBox) ApplyConfig(nodeConfig *panel.NodeConfig, users []panel.User, s.box = instance s.ctx = ctx s.cancel = cancel + s.users = users + s.nodeConfig = nodeConfig + s.certFile = certFile + s.keyFile = keyFile // Fresh tracker on full restart (all connections were terminated by s.stop). s.connTracker = NewConnTracker(0) @@ -113,7 +130,7 @@ func (s *SingBox) ApplyConfig(nodeConfig *panel.NodeConfig, users []panel.User, s.trackerRegistered = false s.registerTracker(ctx) - slog.Info("sing-box started (in-process)", "users", len(users)) + slog.Info("sing-box started", "users", len(users)) return nil } @@ -225,6 +242,10 @@ func (s *SingBox) Reload(nodeConfig *panel.NodeConfig, users []panel.User, certF } slog.Info("sing-box reloaded (users and routes hot-swapped)", "users", len(users)) + s.users = users + s.nodeConfig = nodeConfig + s.certFile = certFile + s.keyFile = keyFile return nil } @@ -312,8 +333,192 @@ func (s *SingBox) SetSpeedLimitFunc(fn func(uuid string) *rate.Limiter) { } } -// GetConnections returns a snapshot of all active and recently-closed -// connections directly from the in-process ConnTracker. +// ─── User management (non-disruptive) ─────────────────────────────────────── + +// AddUsers hot-swaps users into running inbounds. Zero connection disruption. +func (s *SingBox) AddUsers(users []panel.User) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.box == nil { + return 0, fmt.Errorf("not running") + } + + existing := make(map[int]struct{}, len(s.users)) + for _, u := range s.users { + existing[u.ID] = struct{}{} + } + var toAdd []panel.User + for _, u := range users { + if _, dup := existing[u.ID]; !dup { + toAdd = append(toAdd, u) + } + } + if len(toAdd) == 0 { + return 0, nil + } + + merged := append(append([]panel.User{}, s.users...), toAdd...) + if err := s.reloadInboundsLocked(merged); err != nil { + return 0, err + } + s.users = merged + return len(toAdd), nil +} + +// RemoveUsers hot-swaps users out of running inbounds. Zero connection disruption +// for remaining users. +func (s *SingBox) RemoveUsers(users []panel.User) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.box == nil { + return 0, fmt.Errorf("not running") + } + + removeSet := make(map[int]struct{}, len(users)) + for _, u := range users { + removeSet[u.ID] = struct{}{} + } + var kept []panel.User + removed := 0 + for _, u := range s.users { + if _, rm := removeSet[u.ID]; rm { + removed++ + } else { + kept = append(kept, u) + } + } + if removed == 0 { + return 0, nil + } + + if err := s.reloadInboundsLocked(kept); err != nil { + return 0, err + } + s.users = kept + return removed, nil +} + +// UpdateUsers replaces the entire user set atomically via hot-swap. +func (s *SingBox) UpdateUsers(users []panel.User) (added, removed int, err error) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.box == nil { + return 0, 0, fmt.Errorf("not running") + } + + toAdd, toRemove := kernel.UserDiff(s.users, users) + added, removed = len(toAdd), len(toRemove) + + if added == 0 && removed == 0 { + // Only limits may have changed — update tracker map. + if s.connTracker != nil { + s.connTracker.SetUserMap(buildUserMap(users)) + } + s.users = users + return 0, 0, nil + } + + if err = s.reloadInboundsLocked(users); err != nil { + return 0, 0, err + } + s.users = users + return +} + +// reloadInboundsLocked hot-swaps inbound users using UpdatableInbound. +// Must be called with s.mu held. +func (s *SingBox) reloadInboundsLocked(users []panel.User) error { + cfgMap := buildConfig(s.cfg, s.nodeConfig, users, s.certFile, s.keyFile) + data, err := json.Marshal(cfgMap) + if err != nil { + return fmt.Errorf("marshal config: %w", err) + } + + opts, err := singJSON.UnmarshalExtendedContext[option.Options](s.ctx, data) + if err != nil { + return fmt.Errorf("parse options: %w", err) + } + + im := service.FromContext[adapter.InboundManager](s.ctx) + if im == nil { + return fmt.Errorf("inbound manager not available") + } + + router := service.FromContext[adapter.Router](s.ctx) + if router == nil { + return fmt.Errorf("router not available") + } + + nopFactory := singLog.NewNOPFactory() + + for _, inb := range opts.Inbounds { + tag := inb.Tag + if existing, ok := im.Get(tag); ok && existing.Type() == inb.Type { + var err error + switch v := existing.(type) { + case adapter.UpdatableInbound[option.VMessUser]: + if opts, ok := inb.Options.(*option.VMessInboundOptions); ok { + err = v.UpdateUsers(opts.Users) + } + case adapter.UpdatableInbound[option.VLESSUser]: + if opts, ok := inb.Options.(*option.VLESSInboundOptions); ok { + err = v.UpdateUsers(opts.Users) + } + case adapter.UpdatableInbound[option.TrojanUser]: + if opts, ok := inb.Options.(*option.TrojanInboundOptions); ok { + err = v.UpdateUsers(opts.Users) + } + case adapter.UpdatableInbound[option.Hysteria2User]: + if opts, ok := inb.Options.(*option.Hysteria2InboundOptions); ok { + err = v.UpdateUsers(opts.Users) + } + case adapter.UpdatableShadowsocksInbound: + if opts, ok := inb.Options.(*option.ShadowsocksInboundOptions); ok { + err = v.UpdateUsersByOptions(opts.Users) + } + case adapter.UpdatableInbound[option.TUICUser]: + if opts, ok := inb.Options.(*option.TUICInboundOptions); ok { + err = v.UpdateUsers(opts.Users) + } + case adapter.UpdatableInbound[option.AnyTLSUser]: + if opts, ok := inb.Options.(*option.AnyTLSInboundOptions); ok { + err = v.UpdateUsers(opts.Users) + } + case adapter.UpdatableInbound[auth.User]: + switch opts := inb.Options.(type) { + case *option.NaiveInboundOptions: + err = v.UpdateUsers(opts.Users) + case *option.SocksInboundOptions: + err = v.UpdateUsers(opts.Users) + case *option.HTTPMixedInboundOptions: + err = v.UpdateUsers(opts.Users) + } + } + if err == nil { + continue + } + slog.Warn("incremental update failed, recreating inbound", "tag", tag, "error", err) + } + + _ = im.Remove(tag) + logger := nopFactory.NewLogger(fmt.Sprintf("inbound/%s[%s]", inb.Type, tag)) + if err := im.Create(s.ctx, router, logger, tag, inb.Type, inb.Options); err != nil { + return fmt.Errorf("recreate inbound %s: %w", tag, err) + } + } + + if s.connTracker != nil { + s.connTracker.SetUserMap(buildUserMap(users)) + } + + slog.Info("sing-box users hot-swapped", "users", len(users)) + return nil +} + +// ─── Observability ────────────────────────────────────────────────────────── func (s *SingBox) GetConnections(_ context.Context) ([]kernel.Connection, error) { ct := s.connTrackerSafe() if ct == nil { diff --git a/internal/kernel/xray/config.go b/internal/kernel/xray/config.go index e6bcd10..84eca72 100644 --- a/internal/kernel/xray/config.go +++ b/internal/kernel/xray/config.go @@ -44,6 +44,8 @@ func buildConfig(kcfg config.KernelConfig, nc *panel.NodeConfig, users []panel.U cfg := M{ "log": M{ "loglevel": xrayLogLevel(kcfg.LogLevel), + "error": "stdout", + "access": "stdout", }, "stats": M{}, "policy": M{ @@ -202,6 +204,11 @@ func buildInbound(nc *panel.NodeConfig, users []panel.User, certFile, keyFile st "listen": listenAddr, "port": nc.ServerPort, "protocol": nc.Protocol, + "streamSettings": M{ + "sockopt": M{ + "reusePort": true, + }, + }, } switch nc.Protocol { @@ -314,8 +321,9 @@ func buildSocks(base M, users []panel.User) M { accounts := make([]M, 0, len(users)) for _, u := range users { accounts = append(accounts, M{ - "user": u.UUID, - "pass": u.UUID, + "user": u.UUID, + "pass": u.UUID, + "email": userEmail(u.ID), }) } base["settings"] = M{ @@ -331,8 +339,9 @@ func buildHTTP(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFi accounts := make([]M, 0, len(users)) for _, u := range users { accounts = append(accounts, M{ - "user": u.UUID, - "pass": u.UUID, + "user": u.UUID, + "pass": u.UUID, + "email": userEmail(u.ID), }) } base["settings"] = M{ diff --git a/internal/kernel/xray/config_test.go b/internal/kernel/xray/config_test.go index 5dbf5d0..740f41f 100644 --- a/internal/kernel/xray/config_test.go +++ b/internal/kernel/xray/config_test.go @@ -411,3 +411,29 @@ func TestBuildConfig_Shadowsocks_MultiUser(t *testing.T) { t.Fatalf("expected 2 clients, got %d", len(clients)) } } + +func TestBuildConfig_SocksStats(t *testing.T) { + nc := panel.NodeConfig{ + Protocol: "socks", + ServerPort: 1080, + } + cfg := buildConfig(testKernelCfg, &nc, testUsers, "", "") + data, _ := json.Marshal(cfg) + + var parsed map[string]interface{} + json.Unmarshal(data, &parsed) + + inbounds := parsed["inbounds"].([]interface{}) + ib := inbounds[0].(map[string]interface{}) + settings := ib["settings"].(map[string]interface{}) + accounts := settings["accounts"].([]interface{}) + + if len(accounts) == 0 { + t.Fatal("no accounts in socks config") + } + + a1 := accounts[0].(map[string]interface{}) + if a1["email"] != "user@1" { + t.Errorf("expected email user@1 for socks account, got %v", a1["email"]) + } +} diff --git a/internal/kernel/xray/xray.go b/internal/kernel/xray/xray.go index 030de9f..9ef531e 100644 --- a/internal/kernel/xray/xray.go +++ b/internal/kernel/xray/xray.go @@ -9,12 +9,23 @@ import ( "log/slog" "os" "sort" + "strings" "sync" + "sync/atomic" "time" + "github.com/xtls/xray-core/common/protocol" + "github.com/xtls/xray-core/common/uuid" xrayCore "github.com/xtls/xray-core/core" + "github.com/xtls/xray-core/features/inbound" "github.com/xtls/xray-core/features/stats" "github.com/xtls/xray-core/infra/conf/serial" + xrayProxy "github.com/xtls/xray-core/proxy" + "github.com/xtls/xray-core/proxy/shadowsocks" + ss2022 "github.com/xtls/xray-core/proxy/shadowsocks_2022" + "github.com/xtls/xray-core/proxy/trojan" + "github.com/xtls/xray-core/proxy/vless" + "github.com/xtls/xray-core/proxy/vmess" "golang.org/x/time/rate" _ "github.com/xtls/xray-core/main/distro/all" @@ -25,38 +36,42 @@ import ( "github.com/cedar2025/xboard-node/internal/panel" ) -// drainTimeout is how long stop() waits for in-flight connections to finish -// naturally before hard-killing the xray instance. -const drainTimeout = 5 * time.Second +const ( + // drainTimeout is how long Stop waits for in-flight connections to finish + // before hard-killing the instance. Skipped during hot-reload for speed. + drainTimeout = 5 * time.Second + // startTimeout caps how long instance.Start() may block. + startTimeout = 30 * time.Second +) // Xray implements kernel.Kernel by embedding xray-core as a Go library. // -// Device limits and speed limits are enforced through a custom dispatcher -// wrapper (LimitDispatcher) that hooks into session.InboundFromContext(ctx) -// — the same approach used by V2bX and XrayR. This works for ALL protocols -// because the dispatcher is called AFTER the protocol handler has identified -// the user and recorded the source IP in the session context. +// Lifecycle: Start → running → Stop / Reload +// +// Lock discipline: +// - mu is held only for brief field swaps (microseconds). +// - instance.Start() and instance.Close() run OUTSIDE the lock. +// - running (atomic) gates fast-path checks in IsRunning / GetConnections. type Xray struct { cfg config.KernelConfig - mu sync.Mutex - instance *xrayCore.Instance - users []panel.User - protocol string - - inboundTag string - - // limitDispatcher is the per-instance LimitDispatcher created during - // xrayCore.New(). Stored here (instead of using the global pointer) - // so that multi-node mode with multiple Xray instances works correctly. + // mu protects instance, limitDispatcher, users, protocol, inboundTag, + // lastKernelHash, and cumTraffic. Never held during slow I/O. + mu sync.Mutex + instance *xrayCore.Instance limitDispatcher *LimitDispatcher + users []panel.User + nodeConfig *panel.NodeConfig + certFile string + keyFile string + protocol string + inboundTag string + lastKernelHash string + cumTraffic map[int][2]int64 - // lastKernelHash caches a hash of the kernel-affecting config + user - // identities. Used by Reload to skip unnecessary full restarts. - lastKernelHash string - - // cumTraffic keeps running totals for the aggregate stats fallback. - cumTraffic map[int][2]int64 + // running is set after a successful Start and cleared before shutdown. + // Atomic so IsRunning / GetConnections never block. + running atomic.Bool } func New(cfg config.KernelConfig) *Xray { @@ -68,138 +83,136 @@ func New(cfg config.KernelConfig) *Xray { func (x *Xray) Name() string { return "xray" } -func (x *Xray) ApplyConfig(nodeConfig *panel.NodeConfig, users []panel.User, certFile, keyFile string) error { - x.mu.Lock() - defer x.mu.Unlock() - - // Auto-download geo databases and set XRAY_LOCATION_ASSET when panel - // routes reference geoip:/geosite: entries. - if kernel.NeedsGeoIP(nodeConfig.Routes) || kernel.NeedsGeoSite(nodeConfig.Routes) { - geoDataDir := x.cfg.GeoDataDir - if err := geodata.Ensure(geoDataDir, - kernel.NeedsGeoIP(nodeConfig.Routes), - kernel.NeedsGeoSite(nodeConfig.Routes), - "xray", - ); err != nil { - slog.Warn("geo database unavailable; geoip/geosite rules may not match", "error", err) - } - os.Setenv("XRAY_LOCATION_ASSET", geoDataDir) +func (x *Xray) Protocols() []string { + return []string{ + "vmess", "vless", "trojan", "shadowsocks", + "socks", "http", "dokodemo-door", } +} - cfgMap := buildConfig(x.cfg, nodeConfig, users, certFile, keyFile) - data, err := json.Marshal(cfgMap) +// ─── Lifecycle ────────────────────────────────────────────────────────────── + +// Start builds a new xray-core instance and atomically replaces the old +// one. The method is organised in four non-overlapping phases so that the +// kernel mutex is never held during slow operations (Start / Close). +// +// Phase 1 – Build: generate protobuf config (no lock, pure computation) +// Phase 2 – Create: xrayCore.New + capture LD (brief global lock) +// Phase 3 – Swap: stop old instance (brief kernel lock) +// Phase 4 – Start: instance.Start (no lock, potentially slow) +// Phase 5 – Commit: store new state (brief kernel lock) +func (x *Xray) Start(nodeConfig *panel.NodeConfig, users []panel.User, certFile, keyFile string) error { + // ── Phase 1: Build config (no shared state) ───────────────────────── + x.ensureGeoData(nodeConfig) + + data, err := marshalConfig(x.cfg, nodeConfig, users, certFile, keyFile) if err != nil { - return fmt.Errorf("marshal config: %w", err) + return err } - slog.Debug("xray config generated", "len", len(data)) - pbConfig, err := serial.LoadJSONConfig(bytes.NewReader(data)) if err != nil { return fmt.Errorf("parse xray config: %w", err) } - x.stop() - - // Hold the global creation mutex around xrayCore.New() + capture so that - // concurrent Xray instances (multi-node) don't clobber each other's - // LimitDispatcher reference. + // ── Phase 2: Create instance (global lock for LD capture) ─────────── xrayCreationMu.Lock() - instance, err := xrayCore.New(pbConfig) - if err != nil { - xrayCreationMu.Unlock() - return fmt.Errorf("create xray instance: %w", err) - } - x.limitDispatcher = globalLimitDispatcher.Load() + inst, err := xrayCore.New(pbConfig) + ld := globalLimitDispatcher.Load() xrayCreationMu.Unlock() - - if err := instance.Start(); err != nil { - instance.Close() - return fmt.Errorf("start xray: %w", err) + if err != nil { + return fmt.Errorf("create xray: %w", err) } - x.instance = instance + // ── Phase 3: Stop old (brief kernel lock, no drain) ───────────────── + x.mu.Lock() + x.running.Store(false) + old := x.instance + x.instance = nil + oldLD := x.limitDispatcher + x.limitDispatcher = nil + x.mu.Unlock() + + closeOld(old, oldLD) + + // ── Phase 4: Start new (no lock, potentially slow) ────────────────── + if err := startWithTimeout(inst, startTimeout); err != nil { + inst.Close() + return err + } + + // ── Phase 5: Commit (brief kernel lock) ───────────────────────────── + x.mu.Lock() + x.instance = inst + x.limitDispatcher = ld x.users = users + x.nodeConfig = nodeConfig + x.certFile = certFile + x.keyFile = keyFile x.protocol = nodeConfig.Protocol x.inboundTag = nodeConfig.Protocol + "-in" x.cumTraffic = make(map[int][2]int64) x.lastKernelHash = computeKernelHash(nodeConfig, users) + x.running.Store(true) + x.mu.Unlock() - // Configure the limit dispatcher with user limits x.updateDispatcherLimits(users) - slog.Info("xray started (in-process)", + slog.Info("xray started", "users", len(users), "protocol", nodeConfig.Protocol, - "device_limits", "enabled (all protocols via dispatcher hook)", - "speed_limits", "enabled (all protocols via dispatcher hook)", ) - return nil } +// Reload hot-swaps dispatcher limits and, only when kernel-affecting fields +// have changed (user identities, routes, protocol), triggers a full restart. func (x *Xray) Reload(nodeConfig *panel.NodeConfig, users []panel.User, certFile, keyFile string) error { - // Always update dispatcher limits (instant, no restart). x.updateDispatcherLimits(users) - // Skip full restart if kernel-affecting fields are unchanged. - // This covers user-only limit changes where identities (ID, UUID) and - // config (routes, protocol settings, port) haven't changed. newHash := computeKernelHash(nodeConfig, users) + x.mu.Lock() same := x.lastKernelHash == newHash - x.mu.Unlock() if same { - x.mu.Lock() x.users = users x.mu.Unlock() - slog.Info("xray: config/users unchanged, limits updated without restart") + slog.Info("xray: limits updated, kernel unchanged") return nil } + x.mu.Unlock() - return x.ApplyConfig(nodeConfig, users, certFile, keyFile) + return x.Start(nodeConfig, users, certFile, keyFile) } +// Stop gracefully shuts down the kernel, draining active connections first. func (x *Xray) Stop() { + x.running.Store(false) + x.mu.Lock() - defer x.mu.Unlock() - x.stop() -} + inst := x.instance + ld := x.limitDispatcher + x.instance = nil + x.limitDispatcher = nil + x.mu.Unlock() -func (x *Xray) stop() { - if x.instance != nil { - // Drain: wait for in-flight connections to finish naturally. - if x.limitDispatcher != nil { - deadline := time.Now().Add(drainTimeout) - for time.Now().Before(deadline) { - x.limitDispatcher.mu.RLock() - n := len(x.limitDispatcher.conns) - x.limitDispatcher.mu.RUnlock() - if n == 0 { - break - } - time.Sleep(100 * time.Millisecond) - } - } - x.instance.Close() - x.instance = nil - } - if x.limitDispatcher != nil { - x.limitDispatcher.ResetConns() - x.limitDispatcher = nil + if ld != nil { + drainConns(ld, drainTimeout) } + closeOld(inst, ld) } -func (x *Xray) IsRunning() bool { - x.mu.Lock() - defer x.mu.Unlock() - return x.instance != nil -} +func (x *Xray) IsRunning() bool { return x.running.Load() } -// GetConnections returns per-connection data from the dispatcher. -// Each connection has a real source IP and user ID, enabling device limit -// enforcement by the upper-layer limiter. +// ─── Connection queries ───────────────────────────────────────────────────── + +// GetConnections returns a snapshot of active proxy connections. +// The lock is safe to take here because mu is never held during slow I/O. func (x *Xray) GetConnections(ctx context.Context) ([]kernel.Connection, error) { + if !x.running.Load() { + return nil, nil + } + x.mu.Lock() defer x.mu.Unlock() @@ -207,58 +220,13 @@ func (x *Xray) GetConnections(ctx context.Context) ([]kernel.Connection, error) return nil, nil } - // Primary: use LimitDispatcher's per-connection tracking if x.limitDispatcher != nil { - conns := x.limitDispatcher.Snapshot() - if len(conns) > 0 { + if conns := x.limitDispatcher.Snapshot(); len(conns) > 0 { return conns, nil } } - // Fallback: aggregate stats (for edge cases) - return x.getAggregateStats() -} - -func (x *Xray) getAggregateStats() ([]kernel.Connection, error) { - sm := x.instance.GetFeature(stats.ManagerType()) - if sm == nil { - return nil, nil - } - statsManager, ok := sm.(stats.Manager) - if !ok { - return nil, nil - } - - var conns []kernel.Connection - for _, u := range x.users { - email := userEmail(u.ID) - - var deltaUp, deltaDown int64 - if c := statsManager.GetCounter(fmt.Sprintf("user>>>%s>>>traffic>>>uplink", email)); c != nil { - deltaUp = c.Set(0) - } - if c := statsManager.GetCounter(fmt.Sprintf("user>>>%s>>>traffic>>>downlink", email)); c != nil { - deltaDown = c.Set(0) - } - - if deltaUp > 0 || deltaDown > 0 { - cum := x.cumTraffic[u.ID] - cum[0] += deltaUp - cum[1] += deltaDown - x.cumTraffic[u.ID] = cum - } - - cum := x.cumTraffic[u.ID] - if cum[0] > 0 || cum[1] > 0 { - conns = append(conns, kernel.Connection{ - ID: fmt.Sprintf("xray-%d", u.ID), - UserID: u.ID, - Upload: cum[0], - Download: cum[1], - }) - } - } - return conns, nil + return x.aggregateStats() } func (x *Xray) CloseConnection(_ context.Context, connID string) error { @@ -273,10 +241,477 @@ func (x *Xray) CloseConnection(_ context.Context, connID string) error { func (x *Xray) SetSpeedLimitFunc(_ func(string) *rate.Limiter) {} +// ─── User management (non-disruptive where possible) ──────────────────────── + +// AddUsers adds new users to the running kernel via xray's UserManager API. +// For protocols that support UserManager (vmess, vless, trojan, shadowsocks), +// this is truly hitless — no restart, no connection disruption. +// For unsupported protocols (socks, http), falls back to full restart. +// +// Delta "add" events also carry property updates (speed/device limits) for +// existing users, so this method always merges properties and refreshes the +// dispatcher limits — even when no brand-new users need to be added. +func (x *Xray) AddUsers(users []panel.User) (int, error) { + x.mu.Lock() + if x.instance == nil { + x.mu.Unlock() + return 0, fmt.Errorf("not running") + } + + // Merge: overwrite existing users' properties, collect truly new ones. + userMap := make(map[int]panel.User, len(x.users)) + for _, u := range x.users { + userMap[u.ID] = u + } + var toAdd []panel.User + for _, u := range users { + if _, exists := userMap[u.ID]; !exists { + toAdd = append(toAdd, u) + } + userMap[u.ID] = u // always overwrite properties + } + merged := make([]panel.User, 0, len(userMap)) + for _, u := range userMap { + merged = append(merged, u) + } + + if len(toAdd) == 0 { + // No new kernel users, but properties (limits) may have changed. + x.users = merged + x.mu.Unlock() + x.updateDispatcherLimits(merged) + return 0, nil + } + + um, err := x.getUserManager() + if err != nil { + // Protocol doesn't support UserManager → full restart + nc, cf, kf := x.nodeConfig, x.certFile, x.keyFile + x.mu.Unlock() + slog.Debug("xray: AddUsers fallback to restart", "reason", err) + if err := x.Start(nc, merged, cf, kf); err != nil { + return 0, err + } + return len(toAdd), nil + } + + proto := x.protocol + nc := x.nodeConfig + x.mu.Unlock() + + ctx := context.Background() + added := 0 + for _, u := range toAdd { + mu, err := toMemoryUser(proto, nc, u) + if err != nil { + slog.Warn("xray: skip user, cannot build account", "user", u.ID, "error", err) + continue + } + if err := um.AddUser(ctx, mu); err != nil { + slog.Warn("xray: AddUser failed", "user", u.ID, "error", err) + continue + } + added++ + } + + // Update bookkeeping with full merged list (new users + updated properties). + x.mu.Lock() + x.users = merged + x.mu.Unlock() + x.updateDispatcherLimits(merged) + + slog.Info("xray: users added via UserManager", "added", added, "total", len(merged)) + return added, nil +} + +// RemoveUsers removes users from the running kernel via xray's UserManager API. +// Truly hitless for supported protocols — remaining connections unaffected. +func (x *Xray) RemoveUsers(users []panel.User) (int, error) { + x.mu.Lock() + if x.instance == nil { + x.mu.Unlock() + return 0, fmt.Errorf("not running") + } + removeSet := make(map[int]struct{}, len(users)) + for _, u := range users { + removeSet[u.ID] = struct{}{} + } + var kept []panel.User + removed := 0 + for _, u := range x.users { + if _, rm := removeSet[u.ID]; rm { + removed++ + } else { + kept = append(kept, u) + } + } + if removed == 0 { + x.mu.Unlock() + return 0, nil + } + + if len(kept) == 0 { + x.mu.Unlock() + x.Stop() + return removed, nil + } + + um, err := x.getUserManager() + if err != nil { + nc, cf, kf := x.nodeConfig, x.certFile, x.keyFile + x.mu.Unlock() + slog.Debug("xray: RemoveUsers fallback to restart", "reason", err) + if err := x.Start(nc, kept, cf, kf); err != nil { + return 0, err + } + return removed, nil + } + x.mu.Unlock() + + ctx := context.Background() + actualRemoved := 0 + for _, u := range users { + email := userEmail(u.ID) + if err := um.RemoveUser(ctx, email); err != nil { + slog.Debug("xray: RemoveUser skipped", "user", u.ID, "error", err) + continue + } + actualRemoved++ + } + + x.mu.Lock() + x.users = kept + x.mu.Unlock() + x.updateDispatcherLimits(kept) + + slog.Info("xray: users removed via UserManager", "removed", actualRemoved, "total", len(kept)) + return actualRemoved, nil +} + +// UpdateUsers replaces the entire user set. If only speed/device limits +// changed, updates the dispatcher without restarting. Otherwise uses +// UserManager for hitless add/remove where supported. +func (x *Xray) UpdateUsers(users []panel.User) (added, removed int, err error) { + x.mu.Lock() + if x.instance == nil { + x.mu.Unlock() + return 0, 0, fmt.Errorf("not running") + } + toAdd, toRemove := kernel.UserDiff(x.users, users) + added, removed = len(toAdd), len(toRemove) + + if added == 0 && removed == 0 { + // Only limits changed — update dispatcher without restart. + x.users = users + x.mu.Unlock() + x.updateDispatcherLimits(users) + slog.Info("xray: limits updated, kernel unchanged") + return 0, 0, nil + } + + um, umErr := x.getUserManager() + if umErr != nil { + // Protocol doesn't support UserManager → full restart + nc, cf, kf := x.nodeConfig, x.certFile, x.keyFile + x.mu.Unlock() + slog.Debug("xray: UpdateUsers fallback to restart", "reason", umErr) + if err = x.Start(nc, users, cf, kf); err != nil { + return 0, 0, err + } + return + } + + proto := x.protocol + nc := x.nodeConfig + x.mu.Unlock() + + ctx := context.Background() + + // Remove first, then add (order matters for UUID changes on same ID) + for _, u := range toRemove { + email := userEmail(u.ID) + if err := um.RemoveUser(ctx, email); err != nil { + slog.Debug("xray: RemoveUser skipped in UpdateUsers", "user", u.ID, "error", err) + } + } + for _, u := range toAdd { + mu, err := toMemoryUser(proto, nc, u) + if err != nil { + slog.Warn("xray: skip user in UpdateUsers", "user", u.ID, "error", err) + continue + } + if err := um.AddUser(ctx, mu); err != nil { + slog.Warn("xray: AddUser failed in UpdateUsers", "user", u.ID, "error", err) + } + } + + x.mu.Lock() + x.users = users + x.mu.Unlock() + x.updateDispatcherLimits(users) + + slog.Info("xray: users updated via UserManager", "added", added, "removed", removed, "total", len(users)) + return +} + var _ kernel.Kernel = (*Xray)(nil) -// updateDispatcherLimits configures the global LimitDispatcher with -// per-user device limits and speed limits. +// ─── Internal helpers ─────────────────────────────────────────────────────── + +// getUserManager extracts the UserManager from the running xray instance's +// inbound handler. Must be called with x.mu held. +// Returns error for protocols that don't support UserManager (socks, http). +func (x *Xray) getUserManager() (xrayProxy.UserManager, error) { + if x.instance == nil { + return nil, fmt.Errorf("instance is nil") + } + + im := x.instance.GetFeature(inbound.ManagerType()) + if im == nil { + return nil, fmt.Errorf("inbound manager not available") + } + inboundMgr, ok := im.(inbound.Manager) + if !ok { + return nil, fmt.Errorf("inbound manager type assertion failed") + } + + handler, err := inboundMgr.GetHandler(context.Background(), x.inboundTag) + if err != nil { + return nil, fmt.Errorf("get handler %q: %w", x.inboundTag, err) + } + + gi, ok := handler.(xrayProxy.GetInbound) + if !ok { + return nil, fmt.Errorf("handler does not implement GetInbound") + } + + um, ok := gi.GetInbound().(xrayProxy.UserManager) + if !ok { + return nil, fmt.Errorf("protocol %q does not support UserManager", x.protocol) + } + + return um, nil +} + +// toMemoryUser converts a panel.User into an xray protocol.MemoryUser for the +// given protocol. Each protocol needs a different Account type. +func toMemoryUser(proto string, nc *panel.NodeConfig, u panel.User) (*protocol.MemoryUser, error) { + email := userEmail(u.ID) + mu := &protocol.MemoryUser{Email: email, Level: 0} + + switch proto { + case "vmess": + id, err := uuid.ParseString(u.UUID) + if err != nil { + return nil, fmt.Errorf("parse vmess UUID: %w", err) + } + mu.Account = &vmess.MemoryAccount{ + ID: protocol.NewID(id), + Security: protocol.SecurityType_AUTO, + } + + case "vless": + id, err := uuid.ParseString(u.UUID) + if err != nil { + return nil, fmt.Errorf("parse vless UUID: %w", err) + } + account := &vless.MemoryAccount{ + ID: protocol.NewID(id), + Encryption: "none", + } + if nc.Flow != "" { + account.Flow = nc.Flow + } + mu.Account = account + + case "trojan": + mu.Account = &trojan.MemoryAccount{ + Password: u.UUID, + Key: trojanHexSha224(u.UUID), + } + + case "shadowsocks": + if strings.HasPrefix(nc.Cipher, "2022-blake3-") { + // 2022-blake3 multi-user mode + mu.Account = &ss2022.MemoryAccount{Key: u.UUID} + } else { + // Traditional SS — build via getCipher path + ct := parseCipherType(nc.Cipher) + cipherObj, err := (&shadowsocks.Account{CipherType: ct, Password: u.UUID}).AsAccount() + if err != nil { + return nil, fmt.Errorf("build ss account: %w", err) + } + mu.Account = cipherObj + } + + default: + return nil, fmt.Errorf("protocol %q does not support MemoryUser", proto) + } + + return mu, nil +} + +// parseCipherType maps a cipher name string to the xray CipherType enum. +func parseCipherType(cipher string) shadowsocks.CipherType { + switch cipher { + case "aes-128-gcm": + return shadowsocks.CipherType_AES_128_GCM + case "aes-256-gcm": + return shadowsocks.CipherType_AES_256_GCM + case "chacha20-ietf-poly1305", "chacha20-poly1305": + return shadowsocks.CipherType_CHACHA20_POLY1305 + case "xchacha20-ietf-poly1305", "xchacha20-poly1305": + return shadowsocks.CipherType_XCHACHA20_POLY1305 + case "none", "plain": + return shadowsocks.CipherType_NONE + default: + return shadowsocks.CipherType_AES_256_GCM + } +} + +// trojanHexSha224 computes the SHA-224 hex key used by trojan protocol. +func trojanHexSha224(password string) []byte { + // Replicates the exact logic from xray-core/proxy/trojan/config.go hexSha224 + h := sha256.New224() + h.Write([]byte(password)) + buf := make([]byte, 56) + hexEncode(buf, h.Sum(nil)) + return buf +} + +// hexEncode is a minimal hex encoder (avoids importing encoding/hex). +func hexEncode(dst, src []byte) { + const hextable = "0123456789abcdef" + for i, v := range src { + dst[i*2] = hextable[v>>4] + dst[i*2+1] = hextable[v&0x0f] + } +} + +// ensureGeoData downloads geo databases when routes reference geoip/geosite. +func (x *Xray) ensureGeoData(nc *panel.NodeConfig) { + needIP, needSite := kernel.NeedsGeoIP(nc.Routes), kernel.NeedsGeoSite(nc.Routes) + if !needIP && !needSite { + return + } + dir := x.cfg.GeoDataDir + if err := geodata.Ensure(dir, needIP, needSite, "xray"); err != nil { + slog.Warn("geo database unavailable", "error", err) + } + os.Setenv("XRAY_LOCATION_ASSET", dir) +} + +// marshalConfig builds the xray JSON config and returns the raw bytes. +func marshalConfig(cfg config.KernelConfig, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) ([]byte, error) { + cfgMap := buildConfig(cfg, nc, users, certFile, keyFile) + data, err := json.Marshal(cfgMap) + if err != nil { + return nil, fmt.Errorf("marshal config: %w", err) + } + slog.Debug("xray config generated", "len", len(data)) + return data, nil +} + +// startWithTimeout runs instance.Start() with a bounded deadline. +func startWithTimeout(inst *xrayCore.Instance, timeout time.Duration) error { + done := make(chan error, 1) + go func() { done <- inst.Start() }() + + select { + case err := <-done: + if err != nil { + return fmt.Errorf("start xray: %w", err) + } + return nil + case <-time.After(timeout): + return fmt.Errorf("start xray: timeout after %v", timeout) + } +} + +// closeOld shuts down a previously running instance and its dispatcher. +// Recycling happens in a background goroutine to prevent the main thread +// from blocking on slow connection draining, enabling "hitless" reload. +func closeOld(inst *xrayCore.Instance, ld *LimitDispatcher) { + if inst == nil { + return + } + go func() { + // 1. Drain connections gracefully (best effort, e.g. 5 minutes) + // We use a much longer timeout here than the default Stop() because + // it's running in background and doesn't block new user connections. + if ld != nil { + drainConns(ld, 5*time.Minute) + } + // 2. Hard close + inst.Close() + if ld != nil { + ld.ResetConns() + } + slog.Debug("xray: old instance recycled") + }() +} + +// drainConns waits up to timeout for the dispatcher's active connections to +// reach zero. Used only during graceful Stop, not during hot-reload. +func drainConns(ld *LimitDispatcher, timeout time.Duration) { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + ld.mu.RLock() + n := len(ld.conns) + ld.mu.RUnlock() + if n == 0 { + return + } + time.Sleep(100 * time.Millisecond) + } +} + +// aggregateStats is a fallback path that reads xray's built-in stats counters +// when LimitDispatcher has no active connections (e.g. short-lived UDP flows). +// Must be called with x.mu held. +func (x *Xray) aggregateStats() ([]kernel.Connection, error) { + sm := x.instance.GetFeature(stats.ManagerType()) + if sm == nil { + return nil, nil + } + mgr, ok := sm.(stats.Manager) + if !ok { + return nil, nil + } + + var conns []kernel.Connection + for _, u := range x.users { + email := userEmail(u.ID) + + var dUp, dDown int64 + if c := mgr.GetCounter(fmt.Sprintf("user>>>%s>>>traffic>>>uplink", email)); c != nil { + dUp = c.Set(0) + } + if c := mgr.GetCounter(fmt.Sprintf("user>>>%s>>>traffic>>>downlink", email)); c != nil { + dDown = c.Set(0) + } + + if dUp > 0 || dDown > 0 { + cum := x.cumTraffic[u.ID] + cum[0] += dUp + cum[1] += dDown + x.cumTraffic[u.ID] = cum + } + + if cum := x.cumTraffic[u.ID]; cum[0] > 0 || cum[1] > 0 { + conns = append(conns, kernel.Connection{ + ID: fmt.Sprintf("xray-%d", u.ID), + UserID: u.ID, + Upload: cum[0], + Download: cum[1], + }) + } + } + return conns, nil +} + +// updateDispatcherLimits configures the LimitDispatcher with per-user +// device and speed limits. func (x *Xray) updateDispatcherLimits(users []panel.User) { x.mu.Lock() ld := x.limitDispatcher @@ -285,16 +720,13 @@ func (x *Xray) updateDispatcherLimits(users []panel.User) { return } - emailToUID := make(map[string]int, len(users)) + emailToUID := make(map[string]int, len(users)*2) deviceLimits := make(map[string]int) speedLimits := make(map[string]int) for _, u := range users { email := userEmail(u.ID) emailToUID[email] = u.ID - // SOCKS/HTTP protocols use the raw UUID as User.Email in xray's session, - // while VMess/VLESS/Trojan use the "user@" format. Map both so that - // limits work for all protocols. emailToUID[u.UUID] = u.ID if u.DeviceLimit > 0 { deviceLimits[email] = u.DeviceLimit @@ -314,9 +746,8 @@ func (x *Xray) updateDispatcherLimits(users []panel.User) { // own LimitDispatcher. var xrayCreationMu sync.Mutex -// computeKernelHash returns a hash of the config + user identities (ID, UUID) -// that would require a kernel restart if changed. Limit-only changes are -// excluded so they can be applied without restart. +// computeKernelHash returns a hash of config + user identities that would +// require a kernel restart if changed. Limit-only changes are excluded. func computeKernelHash(nc *panel.NodeConfig, users []panel.User) string { h := sha256.New() configData, _ := json.Marshal(nc) diff --git a/internal/limiter/speedtracker.go b/internal/limiter/speedtracker.go index 5d03eca..0940669 100644 --- a/internal/limiter/speedtracker.go +++ b/internal/limiter/speedtracker.go @@ -1,9 +1,11 @@ package limiter import ( + "log/slog" "sync" "sync/atomic" + "github.com/cedar2025/xboard-node/internal/panel" "golang.org/x/time/rate" ) @@ -31,29 +33,35 @@ func NewSpeedTracker(l *Limiter) *SpeedTracker { } } -// UpdateBuckets rebuilds rate limiter buckets based on current user config. -// Call this whenever the user list changes. +// UpdateBuckets performs an incremental update of rate limiter buckets. +// It reuses existing rate.Limiter instances to avoid burst resets and +// minimizes memory allocations. func (t *SpeedTracker) UpdateBuckets() { + t.limiter.mu.RLock() + currentUsers := make([]panel.User, 0, len(t.limiter.users)) + for _, u := range t.limiter.users { + currentUsers = append(currentUsers, u) + } + t.limiter.mu.RUnlock() + t.mu.Lock() defer t.mu.Unlock() - t.limiter.mu.RLock() - defer t.limiter.mu.RUnlock() + newUUIDMap := make(map[string]int, len(currentUsers)) + activeIDs := make(map[int]struct{}, len(currentUsers)) - newBuckets := make(map[int]*rate.Limiter, len(t.limiter.users)) - newUUIDMap := make(map[string]int, len(t.limiter.users)) - for uid, user := range t.limiter.users { + for _, user := range currentUsers { + activeIDs[user.ID] = struct{}{} if user.UUID != "" { - newUUIDMap[user.UUID] = uid + newUUIDMap[user.UUID] = user.ID } + if user.SpeedLimit <= 0 { + delete(t.buckets, user.ID) continue } - bytesPerSec := int(user.SpeedLimit) * 1_000_000 / 8 // Mbps → bytes/s - // Burst = 1 second of data, floored at 64 KiB, capped at 4 s. - // A generous burst ensures downloads ramp up quickly (good UX) while - // the token bucket still converges to the target rate within seconds. - // The 4 s cap prevents excessive burst at very high limits. + + bytesPerSec := int(user.SpeedLimit) * 1_000_000 / 8 burst := bytesPerSec if burst < 64*1024 { burst = 64 * 1024 @@ -61,17 +69,27 @@ func (t *SpeedTracker) UpdateBuckets() { if cap4s := bytesPerSec * 4; cap4s > 64*1024 && burst > cap4s { burst = cap4s } - if existing, ok := t.buckets[uid]; ok { + + if existing, ok := t.buckets[user.ID]; ok { + // Thread-safe in-place update. Existing connections continue + // using this limiter with the new rate immediately. existing.SetLimit(rate.Limit(bytesPerSec)) existing.SetBurst(burst) - newBuckets[uid] = existing } else { - newBuckets[uid] = rate.NewLimiter(rate.Limit(bytesPerSec), burst) + t.buckets[user.ID] = rate.NewLimiter(rate.Limit(bytesPerSec), burst) } } - t.buckets = newBuckets + + // Remove limiters for users who are no longer in the list + for id := range t.buckets { + if _, ok := activeIDs[id]; !ok { + delete(t.buckets, id) + } + } + t.uuidMap = newUUIDMap - t.hasLimits.Store(len(newBuckets) > 0) + t.hasLimits.Store(len(t.buckets) > 0) + slog.Info("speedtracker: buckets updated", "active_limiters", len(t.buckets)) } // GetLimiter returns the rate limiter for the given user UUID, or nil if diff --git a/internal/service/service.go b/internal/service/service.go index 99d8968..47d50df 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -121,9 +121,14 @@ func (s *Service) Run(ctx context.Context) error { trackTicker := time.NewTicker(10 * time.Second) pushInterval := time.Duration(math.Max(float64(s.pushInterval), 5)) * time.Second pullInterval := time.Duration(s.pullInterval) * time.Second + if pullInterval < 10*time.Second { + pullInterval = 60 * time.Second + } reportTicker := time.NewTicker(pushInterval) pullTicker := time.NewTicker(pullInterval) + slog.Info("tickers initialized", "push", pushInterval, "pull", 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. @@ -281,10 +286,9 @@ func (s *Service) initialSetup(ctx context.Context) error { 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) + if err := s.kernel.Start(nodeConfig, users, certFile, keyFile); err != nil { + return fmt.Errorf("start kernel: %w", err) } - return nil } @@ -548,9 +552,9 @@ func (s *Service) pullViaAPI(ctx context.Context) { 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) + // Config is also changing — Start will restart the kernel with the + // updated user list, so skip the intermediate UpdateUsers. + s.updateUserState(users) } } @@ -559,119 +563,172 @@ func (s *Service) pullViaAPI(ctx context.Context) { } } -// 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() +// ─── User state helpers ───────────────────────────────────────────────────── - 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)) - } +// updateUserState is the single point that refreshes limiter, speedTracker, +// and the cached user list/hash. Every code path that changes the user set +// MUST go through here to keep the three data structures in sync. +func (s *Service) updateUserState(users []panel.User) { + s.limiter.UpdateUsers(users) + s.speedTracker.UpdateBuckets() + s.lastUsers = users + s.lastUserHash = computeUserHash(users) +} + +// startKernel starts (or restarts) the kernel with the given config/users and +// records the bound port/IP. Returns false on error (already logged). +func (s *Service) startKernel(nc *panel.NodeConfig, users []panel.User) bool { + certFile, keyFile := s.cert.CertFile(), s.cert.KeyFile() + if err := s.kernel.Start(nc, users, certFile, keyFile); err != nil { + slog.Error("failed to start kernel", "error", err) + return false + } + s.lastAppliedPort = nc.ServerPort + s.lastAppliedIP = nc.ListenIP + return true +} + +// ensureRunning starts the kernel if it is not running and there are users + +// config available. Returns true if the kernel is running afterwards. +func (s *Service) ensureRunning() bool { + if s.kernel.IsRunning() { + return true + } + if len(s.lastUsers) > 0 && s.lastConfig != nil { + return s.startKernel(s.lastConfig, s.lastUsers) + } + return false +} + +// ─── User update entry points ─────────────────────────────────────────────── + +// 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) { + // Update limiter/speed state first so even if kernel ops fail, + // the in-memory limits are already correct. + s.updateUserState(users) + + if !s.ensureRunning() { + return } - s.lastUsers = users - s.lastUserHash = newHash - slog.Info("users updated", "count", len(users)) + added, removed, err := s.kernel.UpdateUsers(users) + if err != nil { + slog.Warn("UpdateUsers failed, falling back to full restart", "error", err) + s.startKernel(s.lastConfig, users) + return + } + slog.Info("users updated via kernel", "added", added, "removed", removed) } -// 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). +// applyUserDelta applies an incremental user change (add or remove) directly +// via the kernel's atomic user API. 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 + merged := mergeUsers(s.lastUsers, deltaUsers) + s.updateUserState(merged) + + if !s.ensureRunning() { + return } - for _, u := range deltaUsers { - userMap[u.ID] = u // add or update + + added, err := s.kernel.AddUsers(deltaUsers) + if err != nil { + slog.Warn("AddUsers failed, falling back to full UpdateUsers", "error", err) + if _, _, err := s.kernel.UpdateUsers(merged); err != nil { + slog.Error("UpdateUsers fallback also failed", "error", err) + } + } else { + slog.Info("users added via kernel", "added", added) } - 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 := subtractUsers(s.lastUsers, deltaUsers) + s.updateUserState(filtered) + + if !s.kernel.IsRunning() { + return } - filtered := make([]panel.User, 0, len(s.lastUsers)) - for _, u := range s.lastUsers { - if _, ok := removeSet[u.ID]; !ok { - filtered = append(filtered, u) + + removed, err := s.kernel.RemoveUsers(deltaUsers) + if err != nil { + slog.Warn("RemoveUsers failed, falling back to full UpdateUsers", "error", err) + if _, _, err := s.kernel.UpdateUsers(filtered); err != nil { + slog.Error("UpdateUsers fallback also failed", "error", err) } + } else { + slog.Info("users removed via kernel", "removed", removed) } - 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 +// 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 { + m := make(map[int]panel.User, 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)) + for _, u := range m { + out = append(out, u) + } + return out +} + +// subtractUsers returns base with all users in delta removed. +func subtractUsers(base, delta []panel.User) []panel.User { + removeSet := make(map[int]struct{}, len(delta)) + for _, u := range delta { + removeSet[u.ID] = struct{}{} + } + out := make([]panel.User, 0, len(base)) + for _, u := range base { + if _, ok := removeSet[u.ID]; !ok { + out = append(out, u) + } + } + return out +} + +// applyChanges applies config changes to the kernel. User-only changes are +// handled by applyUserUpdate/applyUserDelta directly via the atomic user API. func (s *Service) applyChanges(ctx context.Context, configChanged, usersChanged bool) { - certFile, keyFile := s.cert.CertFile(), s.cert.KeyFile() + if !configChanged { + return + } - 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 { + if s.lastConfig == nil || len(s.lastUsers) == 0 { + 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 + return + } + + // If port or listen IP changed, must do full restart. + needsRestart := s.kernel.IsRunning() && + (s.lastConfig.ServerPort != s.lastAppliedPort || s.lastConfig.ListenIP != s.lastAppliedIP) + + if !needsRestart && s.kernel.IsRunning() { + certFile, keyFile := s.cert.CertFile(), s.cert.KeyFile() 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) + slog.Warn("reload failed, falling back to full restart", "error", err) + needsRestart = true } } + + if needsRestart || !s.kernel.IsRunning() { + s.startKernel(s.lastConfig, s.lastUsers) + } } func (s *Service) trackAndEnforce(ctx context.Context) { @@ -687,6 +744,7 @@ func (s *Service) trackAndEnforce(ctx context.Context) { s.tracker.Process(conns) s.tracker.LogStats() + slog.Debug("kernel connection snapshot", "count", len(conns)) kicks := s.limiter.Check(conns) s.executeKicks(ctx, kicks) @@ -719,24 +777,7 @@ func (s *Service) pushReport() { 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 - } - + // Always report even if no traffic, to maintain heartbeats and status. if err := s.panel.Report( traffic, aliveIPs, online, status.CPU, @@ -746,14 +787,18 @@ func (s *Service) pushReport() { metrics, ); err != nil { slog.Error("failed to push report", "error", err) - s.tracker.RestoreTraffic(traffic) - 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() - slog.Info("report pushed", "users", len(traffic)) + slog.Info("report pushed", "users_with_traffic", len(traffic), "online", len(online)) } // buildMetrics aggregates node-level metrics to be reported to the panel.