refactor: optimize user management, fix speed limit hot-reload and improve performance
This commit is contained in:
+74
-19
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
@@ -316,6 +323,7 @@ func buildSocks(base M, users []panel.User) M {
|
||||
accounts = append(accounts, M{
|
||||
"user": u.UUID,
|
||||
"pass": u.UUID,
|
||||
"email": userEmail(u.ID),
|
||||
})
|
||||
}
|
||||
base["settings"] = M{
|
||||
@@ -333,6 +341,7 @@ func buildHTTP(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFi
|
||||
accounts = append(accounts, M{
|
||||
"user": u.UUID,
|
||||
"pass": u.UUID,
|
||||
"email": userEmail(u.ID),
|
||||
})
|
||||
}
|
||||
base["settings"] = M{
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
+588
-157
@@ -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 protects instance, limitDispatcher, users, protocol, inboundTag,
|
||||
// lastKernelHash, and cumTraffic. Never held during slow I/O.
|
||||
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.
|
||||
limitDispatcher *LimitDispatcher
|
||||
|
||||
// lastKernelHash caches a hash of the kernel-affecting config + user
|
||||
// identities. Used by Reload to skip unnecessary full restarts.
|
||||
users []panel.User
|
||||
nodeConfig *panel.NodeConfig
|
||||
certFile string
|
||||
keyFile string
|
||||
protocol string
|
||||
inboundTag string
|
||||
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)
|
||||
func (x *Xray) Protocols() []string {
|
||||
return []string{
|
||||
"vmess", "vless", "trojan", "shadowsocks",
|
||||
"socks", "http", "dokodemo-door",
|
||||
}
|
||||
os.Setenv("XRAY_LOCATION_ASSET", geoDataDir)
|
||||
}
|
||||
|
||||
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)
|
||||
inst, err := xrayCore.New(pbConfig)
|
||||
ld := globalLimitDispatcher.Load()
|
||||
xrayCreationMu.Unlock()
|
||||
if err != nil {
|
||||
xrayCreationMu.Unlock()
|
||||
return fmt.Errorf("create xray instance: %w", err)
|
||||
}
|
||||
x.limitDispatcher = globalLimitDispatcher.Load()
|
||||
xrayCreationMu.Unlock()
|
||||
|
||||
if err := instance.Start(); err != nil {
|
||||
instance.Close()
|
||||
return fmt.Errorf("start xray: %w", err)
|
||||
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.mu.Lock()
|
||||
defer x.mu.Unlock()
|
||||
x.stop()
|
||||
}
|
||||
x.running.Store(false)
|
||||
|
||||
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.mu.Lock()
|
||||
inst := x.instance
|
||||
ld := x.limitDispatcher
|
||||
x.instance = nil
|
||||
}
|
||||
if x.limitDispatcher != nil {
|
||||
x.limitDispatcher.ResetConns()
|
||||
x.limitDispatcher = nil
|
||||
x.mu.Unlock()
|
||||
|
||||
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@<id>" 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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+150
-105
@@ -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,83 +563,163 @@ 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)
|
||||
// ─── User state helpers ─────────────────────────────────────────────────────
|
||||
|
||||
// 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()
|
||||
|
||||
if kickRemoved && len(removedIDs) > 0 && s.kernel.IsRunning() {
|
||||
conns, err := s.kernel.GetConnections(ctx)
|
||||
if err == nil {
|
||||
kicks := s.limiter.KickUsers(conns, removedIDs)
|
||||
s.executeKicks(ctx, kicks)
|
||||
slog.Info("kicked removed users", "removed_ids", removedIDs, "kicked_conns", len(kicks))
|
||||
}
|
||||
}
|
||||
|
||||
s.lastUsers = users
|
||||
s.lastUserHash = newHash
|
||||
slog.Info("users updated", "count", len(users))
|
||||
s.lastUserHash = computeUserHash(users)
|
||||
}
|
||||
|
||||
// applyUserUpdate updates user state and hot-reloads the kernel inbounds.
|
||||
// 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) {
|
||||
s.syncUserState(ctx, users, newHash, true)
|
||||
s.applyChanges(ctx, false, true)
|
||||
// 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
|
||||
}
|
||||
|
||||
// applyUserDelta applies an incremental user change (add or remove).
|
||||
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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
merged := make([]panel.User, 0, len(userMap))
|
||||
for _, u := range userMap {
|
||||
merged = append(merged, u)
|
||||
} else {
|
||||
slog.Info("users added via kernel", "added", added)
|
||||
}
|
||||
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) {
|
||||
if !configChanged {
|
||||
return
|
||||
}
|
||||
|
||||
if s.lastConfig == nil || len(s.lastUsers) == 0 {
|
||||
if len(s.lastUsers) == 0 {
|
||||
slog.Warn("no users, stopping kernel")
|
||||
s.kernel.Stop()
|
||||
}
|
||||
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 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
|
||||
@@ -643,34 +727,7 @@ func (s *Service) applyChanges(ctx context.Context, configChanged, usersChanged
|
||||
}
|
||||
|
||||
if needsRestart || !s.kernel.IsRunning() {
|
||||
if err := s.kernel.ApplyConfig(s.lastConfig, s.lastUsers, certFile, keyFile); err != nil {
|
||||
slog.Error("failed to apply config", "error", err)
|
||||
} else {
|
||||
// Update tracking state for next change
|
||||
s.lastAppliedPort = s.lastConfig.ServerPort
|
||||
s.lastAppliedIP = s.lastConfig.ListenIP
|
||||
}
|
||||
}
|
||||
} else if len(s.lastUsers) == 0 {
|
||||
slog.Warn("no users, stopping kernel")
|
||||
s.kernel.Stop()
|
||||
}
|
||||
} else if usersChanged && s.kernel.IsRunning() {
|
||||
// Hot-reload for user-only changes
|
||||
if err := s.kernel.Reload(s.lastConfig, s.lastUsers, certFile, keyFile); err != nil {
|
||||
slog.Warn("inbound reload failed, falling back to full restart", "error", err)
|
||||
if err := s.kernel.ApplyConfig(s.lastConfig, s.lastUsers, certFile, keyFile); err != nil {
|
||||
slog.Error("failed to apply config (fallback)", "error", err)
|
||||
} else {
|
||||
s.lastAppliedPort = s.lastConfig.ServerPort
|
||||
s.lastAppliedIP = s.lastConfig.ListenIP
|
||||
}
|
||||
}
|
||||
} else if usersChanged && !s.kernel.IsRunning() && len(s.lastUsers) > 0 {
|
||||
// Kernel was stopped (no users), now we have users — start it
|
||||
if err := s.kernel.ApplyConfig(s.lastConfig, s.lastUsers, certFile, keyFile); err != nil {
|
||||
slog.Error("failed to start kernel with new users", "error", err)
|
||||
}
|
||||
s.startKernel(s.lastConfig, s.lastUsers)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user