refactor: rework limiter, traffic accounting, and logging (nlog)

This commit is contained in:
xboard
2026-03-23 23:01:13 +08:00
parent 4676caac96
commit 3f1234a05c
20 changed files with 1721 additions and 1333 deletions
+1 -1
View File
@@ -499,7 +499,7 @@ func applyStreamSettings(base M, nc *panel.NodeConfig, certFile, keyFile string)
}
// Proxy Protocol
if nc.AcceptProxyProtocol {
if nc.GetProxyProtocol() {
sockopt, ok := base["streamSettings"].(M)["sockopt"].(M)
if !ok {
sockopt = M{}
+216 -106
View File
@@ -6,7 +6,6 @@ import (
"log/slog"
"reflect"
"sort"
"strconv"
"sync"
"sync/atomic"
_ "unsafe"
@@ -19,8 +18,6 @@ import (
"github.com/xtls/xray-core/features/routing"
"github.com/xtls/xray-core/transport"
"golang.org/x/time/rate"
"github.com/cedar2025/xboard-node/internal/kernel"
)
// Access xray's internal config creator registry so we can replace the
@@ -52,10 +49,9 @@ func limitDispatcherFactory(ctx context.Context, config interface{}) (interface{
return orig, nil
}
ld := &LimitDispatcher{
inner: orig,
innerDisp: inner,
conns: make(map[string]*dispatchedConn),
userIPs: make(map[string]map[string]int),
inner: orig,
innerDisp: inner,
limitedIPs: make(map[string]map[string]int),
}
globalLimitDispatcher.Store(ld)
slog.Debug("xray: limit dispatcher installed")
@@ -63,32 +59,56 @@ func limitDispatcherFactory(ctx context.Context, config interface{}) (interface{
}
// LimitDispatcher wraps xray's DefaultDispatcher to add per-user device
// limit enforcement, speed limiting, and per-connection source IP tracking.
// It hooks into Dispatch/DispatchLink which are called AFTER the protocol
// handler has identified the user, so it works for ALL protocols.
// limit enforcement, speed limiting, and per-user traffic counting.
//
// Architecture: traffic bytes are accumulated in per-user atomic counters
// (not per-connection maps). IP tracking uses sync.Map for unlimited users
// (lock-free) and RWMutex-protected map for limited users (deterministic).
// This means GetUserTraffic() is O(users), not O(connections).
type LimitDispatcher struct {
inner interface{} // original DefaultDispatcher (Feature + Dispatcher)
innerDisp routing.Dispatcher // same object, typed as Dispatcher
// limitedUsers: users with device limit > 0, protected by mu.
// Needs deterministic IP ordering for kick decisions.
mu sync.RWMutex
conns map[string]*dispatchedConn // connID → conn
userIPs map[string]map[string]int // email → sourceIP → count
deviceLimits map[string]int // email → max devices
speedLimits map[string]int // email → Mbps
speedBuckets sync.Map // email → *rate.Limiter
emailToUID map[string]int // email → panel user ID
limitedIPs map[string]map[string]int // email → sourceIP → refcount
deviceLimits map[string]int // email → max devices
speedLimits map[string]int // email → Mbps
emailToUID map[string]int // email → panel user ID
connIDSeq atomic.Uint64
// unlimitedIPs: users without device limit — sync.Map for lock-free access.
// Each entry is *ipCounter{count atomic.Int64, ips sync.Map}.
unlimitedIPs sync.Map // email → *ipCounter
speedBuckets sync.Map // email → *rate.Limiter
// Per-user traffic counters — lock-free atomics for the data path.
userTraffic sync.Map // email → *userTrafficCounter
connCount atomic.Int64 // total active connections (for metrics)
}
type dispatchedConn struct {
id string
email string
sourceIP string
userID int
// ipCounter tracks IPs for unlimited users without any lock.
type ipCounter struct {
count atomic.Int64 // total TCP connections (for connCount reporting)
ips sync.Map // sourceIP → *atomic.Int64 (refcount)
}
// aliveIPs returns a snapshot of distinct IPs.
func (ic *ipCounter) aliveIPs() map[string]bool {
result := make(map[string]bool)
ic.ips.Range(func(key, _ interface{}) bool {
result[key.(string)] = true
return true
})
return result
}
// userTrafficCounter holds per-user cumulative traffic counters.
type userTrafficCounter struct {
upload atomic.Int64
download atomic.Int64
closed atomic.Bool
}
// ─── routing.Dispatcher ──────────────────────────────────────────────────────
@@ -144,43 +164,33 @@ func (d *LimitDispatcher) identifyAndCheck(ctx context.Context, dest net.Destina
return email, sourceIP, isTCP, nil
}
// wrapLink instruments a transport.Link with per-connection byte counting,
// wrapLink instruments a transport.Link with per-user byte counting,
// rate limiting, and lifecycle tracking. Must only be called when email != "".
func (d *LimitDispatcher) wrapLink(ctx context.Context, link *transport.Link, email, sourceIP string, isTCP bool) {
connID := "xd-" + strconv.FormatUint(d.connIDSeq.Add(1), 36)
dc := &dispatchedConn{
id: connID,
email: email,
sourceIP: sourceIP,
userID: d.getUID(email),
}
// Get or create per-user traffic counter
v, _ := d.userTraffic.LoadOrStore(email, &userTrafficCounter{})
utc := v.(*userTrafficCounter)
d.mu.Lock()
d.conns[connID] = dc
d.mu.Unlock()
d.connCount.Add(1)
onClose := func() {
if dc.closed.CompareAndSwap(false, true) {
if isTCP {
d.delConn(email, sourceIP)
}
d.mu.Lock()
delete(d.conns, connID)
d.mu.Unlock()
if isTCP {
d.delConn(email, sourceIP)
}
d.connCount.Add(-1)
}
limiter := d.getBucket(email)
link.Reader = &statsCloseReader{
Reader: link.Reader,
counter: &dc.upload,
counter: &utc.upload,
limiter: limiter,
ctx: ctx,
}
link.Writer = &statsCloseWriter{
Writer: link.Writer,
counter: &dc.download,
counter: &utc.download,
limiter: limiter,
ctx: ctx,
onClose: onClose,
@@ -224,69 +234,153 @@ func (d *LimitDispatcher) UpdateLimits(emailToUID map[string]int, deviceLimits,
func (d *LimitDispatcher) ResetConns() {
d.mu.Lock()
d.conns = make(map[string]*dispatchedConn)
d.userIPs = make(map[string]map[string]int)
d.limitedIPs = make(map[string]map[string]int)
d.mu.Unlock()
// Clear unlimited IPs
d.unlimitedIPs.Range(func(key, _ interface{}) bool {
d.unlimitedIPs.Delete(key)
return true
})
d.userTraffic.Range(func(key, _ interface{}) bool {
d.userTraffic.Delete(key)
return true
})
d.connCount.Store(0)
d.speedBuckets.Range(func(key, _ interface{}) bool {
d.speedBuckets.Delete(key)
return true
})
}
func (d *LimitDispatcher) Snapshot() []kernel.Connection {
// GetUserTraffic returns per-user cumulative traffic and alive IPs.
// O(users), not O(connections).
func (d *LimitDispatcher) GetUserTraffic() (traffic map[int][2]int64, aliveIPs map[int]map[string]bool, connCount int) {
d.mu.RLock()
defer d.mu.RUnlock()
result := make([]kernel.Connection, 0, len(d.conns))
for _, c := range d.conns {
result = append(result, kernel.Connection{
ID: c.id,
UserID: c.userID,
SourceIP: c.sourceIP,
Upload: c.upload.Load(),
Download: c.download.Load(),
})
}
return result
}
func (d *LimitDispatcher) CloseConn(id string) bool {
d.mu.RLock()
c, ok := d.conns[id]
emailToUID := d.emailToUID
limitedIPs := d.limitedIPs
d.mu.RUnlock()
if !ok {
return false
aliveIPs = make(map[int]map[string]bool)
// Collect IPs from limited users (under RLock snapshot).
for email, ipsMap := range limitedIPs {
uid := emailToUID[email]
if uid == 0 {
continue
}
ipSet := make(map[string]bool, len(ipsMap))
for ip := range ipsMap {
ipSet[ip] = true
}
if len(ipSet) > 0 {
aliveIPs[uid] = ipSet
}
}
c.closed.Store(true)
d.delConn(c.email, c.sourceIP)
d.mu.Lock()
delete(d.conns, id)
d.mu.Unlock()
return true
// Collect IPs from unlimited users (lock-free).
d.unlimitedIPs.Range(func(key, value interface{}) bool {
email := key.(string)
uid := emailToUID[email]
if uid == 0 {
return true
}
ic := value.(*ipCounter)
if ips := ic.aliveIPs(); len(ips) > 0 {
// Merge with limited IPs if any
if existing, ok := aliveIPs[uid]; ok {
for ip := range ips {
existing[ip] = true
}
} else {
aliveIPs[uid] = ips
}
}
return true
})
traffic = make(map[int][2]int64)
d.userTraffic.Range(func(key, value interface{}) bool {
email := key.(string)
utc := value.(*userTrafficCounter)
uid := emailToUID[email]
if uid == 0 {
return true
}
up := utc.upload.Load()
down := utc.download.Load()
if up > 0 || down > 0 {
traffic[uid] = [2]int64{up, down}
}
return true
})
connCount = int(d.connCount.Load())
return
}
// ─── Internal helpers ───────────────────────────────────────────────────────
// checkDeviceLimit enforces per-user device limits.
// Fast path: unlimited users use lock-free sync.Map.
// Slow path: limited users use RWMutex with deterministic IP ordering.
func (d *LimitDispatcher) checkDeviceLimit(email, sourceIP string, isTCP bool) bool {
d.mu.Lock()
defer d.mu.Unlock()
d.mu.RLock()
limit, hasLimit := d.deviceLimits[email]
d.mu.RUnlock()
// Fast path: no device limit — use lock-free sync.Map.
if !hasLimit || limit <= 0 {
if isTCP {
if d.userIPs[email] == nil {
d.userIPs[email] = make(map[string]int)
}
d.userIPs[email][sourceIP]++
v, _ := d.unlimitedIPs.LoadOrStore(email, &ipCounter{})
ic := v.(*ipCounter)
ic.count.Add(1)
// Increment IP refcount atomically.
rv, _ := ic.ips.LoadOrStore(sourceIP, &atomic.Int64{})
rv.(*atomic.Int64).Add(1)
}
return false
}
ips := d.userIPs[email]
// Slow path: user has device limit — need deterministic ordering.
d.mu.RLock()
ips := d.limitedIPs[email]
if ips != nil && ips[sourceIP] > 0 {
d.mu.RUnlock()
if isTCP {
d.mu.Lock()
d.limitedIPs[email][sourceIP]++
d.mu.Unlock()
}
return false
}
if ips != nil && len(ips) < limit {
d.mu.RUnlock()
if isTCP {
d.mu.Lock()
if d.limitedIPs[email] == nil {
d.limitedIPs[email] = make(map[string]int)
}
d.limitedIPs[email][sourceIP]++
d.mu.Unlock()
}
return false
}
d.mu.RUnlock()
// Over limit — need write lock for deterministic check.
d.mu.Lock()
defer d.mu.Unlock()
// Re-check under write lock.
ips = d.limitedIPs[email]
if ips == nil {
ips = make(map[string]int)
d.userIPs[email] = ips
d.limitedIPs[email] = ips
}
if ips[sourceIP] > 0 {
@@ -303,7 +397,7 @@ func (d *LimitDispatcher) checkDeviceLimit(email, sourceIP string, isTCP bool) b
return false
}
// Over limit — deterministic: allow lowest IPs lexicographically
// Over limit — deterministic: allow lowest IPs lexicographically.
ipList := make([]string, 0, len(ips)+1)
for ip := range ips {
ipList = append(ipList, ip)
@@ -322,26 +416,34 @@ func (d *LimitDispatcher) checkDeviceLimit(email, sourceIP string, isTCP bool) b
return true
}
// delConn decrements the IP refcount when a connection closes.
func (d *LimitDispatcher) delConn(email, sourceIP string) {
// Check if this is an unlimited user first (lock-free).
if v, ok := d.unlimitedIPs.Load(email); ok {
ic := v.(*ipCounter)
if rv, ok := ic.ips.Load(sourceIP); ok {
counter := rv.(*atomic.Int64)
if counter.Add(-1) <= 0 {
ic.ips.Delete(sourceIP)
}
}
return
}
// Limited user — use write lock.
d.mu.Lock()
defer d.mu.Unlock()
if ips, ok := d.userIPs[email]; ok {
if ips, ok := d.limitedIPs[email]; ok {
ips[sourceIP]--
if ips[sourceIP] <= 0 {
delete(ips, sourceIP)
}
if len(ips) == 0 {
delete(d.userIPs, email)
delete(d.limitedIPs, email)
}
}
}
func (d *LimitDispatcher) getUID(email string) int {
d.mu.RLock()
defer d.mu.RUnlock()
return d.emailToUID[email]
}
func (d *LimitDispatcher) getBucket(email string) *rate.Limiter {
d.mu.RLock()
mbps, ok := d.speedLimits[email]
@@ -366,11 +468,14 @@ func (d *LimitDispatcher) getBucket(email string) *rate.Limiter {
// ─── I/O wrappers ───────────────────────────────────────────────────────────
const xrayRateLimitThreshold = 64 * 1024
type statsCloseReader struct {
buf.Reader
counter *atomic.Int64
limiter *rate.Limiter
ctx context.Context
counter *atomic.Int64
limiter *rate.Limiter
ctx context.Context
pendingTokens atomic.Int64
}
func (r *statsCloseReader) ReadMultiBuffer() (buf.MultiBuffer, error) {
@@ -378,11 +483,13 @@ func (r *statsCloseReader) ReadMultiBuffer() (buf.MultiBuffer, error) {
if n := int64(mb.Len()); n > 0 {
r.counter.Add(n)
if r.limiter != nil {
waitN := int(n)
if burst := r.limiter.Burst(); waitN > burst {
waitN = burst
if pending := r.pendingTokens.Add(n); pending >= xrayRateLimitThreshold {
tokens := int(r.pendingTokens.Swap(0))
if burst := r.limiter.Burst(); tokens > burst {
tokens = burst
}
_ = r.limiter.WaitN(r.ctx, tokens)
}
_ = r.limiter.WaitN(r.ctx, waitN)
}
}
return mb, err
@@ -393,21 +500,24 @@ func (r *statsCloseReader) Interrupt() { common.Interrupt(r.Reader) }
type statsCloseWriter struct {
buf.Writer
counter *atomic.Int64
limiter *rate.Limiter
ctx context.Context
onClose func()
closed atomic.Bool
counter *atomic.Int64
limiter *rate.Limiter
ctx context.Context
onClose func()
closed atomic.Bool
pendingTokens atomic.Int64
}
func (w *statsCloseWriter) WriteMultiBuffer(mb buf.MultiBuffer) error {
n := int64(mb.Len())
if w.limiter != nil {
waitN := int(n)
if burst := w.limiter.Burst(); waitN > burst {
waitN = burst
if pending := w.pendingTokens.Add(n); pending >= xrayRateLimitThreshold {
tokens := int(w.pendingTokens.Swap(0))
if burst := w.limiter.Burst(); tokens > burst {
tokens = burst
}
_ = w.limiter.WaitN(w.ctx, tokens)
}
_ = w.limiter.WaitN(w.ctx, waitN)
}
w.counter.Add(n)
return w.Writer.WriteMultiBuffer(mb)
+101 -73
View File
@@ -1,16 +1,20 @@
package xray
import (
"sync/atomic"
"testing"
"github.com/cedar2025/xboard-node/internal/panel"
)
func TestLimitDispatcher_DeviceLimitCheck(t *testing.T) {
ld := &LimitDispatcher{
conns: make(map[string]*dispatchedConn),
userIPs: make(map[string]map[string]int),
func newTestDispatcher() *LimitDispatcher {
return &LimitDispatcher{
limitedIPs: make(map[string]map[string]int),
}
}
func TestLimitDispatcher_DeviceLimitCheck(t *testing.T) {
ld := newTestDispatcher()
users := []panel.User{
{ID: 1, UUID: "uuid-1", DeviceLimit: 2, SpeedLimit: 0},
@@ -65,10 +69,7 @@ func TestLimitDispatcher_DeviceLimitCheck(t *testing.T) {
}
func TestLimitDispatcher_DelConn(t *testing.T) {
ld := &LimitDispatcher{
conns: make(map[string]*dispatchedConn),
userIPs: make(map[string]map[string]int),
}
ld := newTestDispatcher()
email := userEmail(1)
deviceLimits := map[string]int{email: 2}
@@ -93,10 +94,7 @@ func TestLimitDispatcher_DelConn(t *testing.T) {
}
func TestLimitDispatcher_SpeedBucket(t *testing.T) {
ld := &LimitDispatcher{
conns: make(map[string]*dispatchedConn),
userIPs: make(map[string]map[string]int),
}
ld := newTestDispatcher()
email1 := userEmail(1)
email2 := userEmail(2)
@@ -123,82 +121,112 @@ func TestLimitDispatcher_SpeedBucket(t *testing.T) {
}
}
func TestLimitDispatcher_Snapshot(t *testing.T) {
ld := &LimitDispatcher{
conns: make(map[string]*dispatchedConn),
userIPs: make(map[string]map[string]int),
}
func TestLimitDispatcher_GetUserTraffic(t *testing.T) {
ld := newTestDispatcher()
// Add some connections
ld.conns["conn-1"] = &dispatchedConn{
id: "conn-1", email: "user@1", sourceIP: "1.1.1.1", userID: 1,
}
ld.conns["conn-2"] = &dispatchedConn{
id: "conn-2", email: "user@2", sourceIP: "2.2.2.2", userID: 2,
}
ld.conns["conn-1"].upload.Store(1000)
ld.conns["conn-1"].download.Store(2000)
email1 := userEmail(1)
email2 := userEmail(2)
ld.UpdateLimits(map[string]int{email1: 1, email2: 2}, nil, nil)
snapshot := ld.Snapshot()
if len(snapshot) != 2 {
t.Fatalf("expected 2 connections, got %d", len(snapshot))
}
// Simulate traffic by storing counters directly
tc1 := &userTrafficCounter{}
tc1.upload.Store(1000)
tc1.download.Store(2000)
ld.userTraffic.Store(email1, tc1)
found := false
for _, c := range snapshot {
if c.ID == "conn-1" {
found = true
if c.UserID != 1 || c.SourceIP != "1.1.1.1" || c.Upload != 1000 || c.Download != 2000 {
t.Errorf("unexpected connection data: %+v", c)
}
}
}
if !found {
t.Error("conn-1 not found in snapshot")
}
}
tc2 := &userTrafficCounter{}
tc2.upload.Store(500)
tc2.download.Store(800)
ld.userTraffic.Store(email2, tc2)
func TestLimitDispatcher_CloseConn(t *testing.T) {
ld := &LimitDispatcher{
conns: make(map[string]*dispatchedConn),
userIPs: make(map[string]map[string]int),
}
// Simulate IPs (use unlimitedIPs for users without device limit)
ic1 := &ipCounter{}
ic1.ips.Store("1.1.1.1", &atomic.Int64{})
ic1.ips.Store("2.2.2.2", &atomic.Int64{})
ld.unlimitedIPs.Store(email1, ic1)
email := userEmail(1)
ld.userIPs[email] = map[string]int{"1.1.1.1": 1}
ld.conns["conn-1"] = &dispatchedConn{
id: "conn-1", email: email, sourceIP: "1.1.1.1", userID: 1,
}
ic2 := &ipCounter{}
ic2.ips.Store("3.3.3.3", &atomic.Int64{})
ld.unlimitedIPs.Store(email2, ic2)
ok := ld.CloseConn("conn-1")
if !ok {
t.Error("CloseConn should return true for existing connection")
}
ld.connCount.Store(5)
if len(ld.conns) != 0 {
t.Error("connection should be removed after close")
}
traffic, aliveIPs, connCount := ld.GetUserTraffic()
ok = ld.CloseConn("nonexistent")
if ok {
t.Error("CloseConn should return false for nonexistent connection")
if connCount != 5 {
t.Errorf("expected connCount=5, got %d", connCount)
}
if traffic[1] != [2]int64{1000, 2000} {
t.Errorf("user 1 traffic: got %v", traffic[1])
}
if traffic[2] != [2]int64{500, 800} {
t.Errorf("user 2 traffic: got %v", traffic[2])
}
if len(aliveIPs[1]) != 2 {
t.Errorf("user 1 IPs: got %d, want 2", len(aliveIPs[1]))
}
if len(aliveIPs[2]) != 1 {
t.Errorf("user 2 IPs: got %d, want 1", len(aliveIPs[2]))
}
}
func TestLimitDispatcher_ResetConns(t *testing.T) {
ld := &LimitDispatcher{
conns: make(map[string]*dispatchedConn),
userIPs: make(map[string]map[string]int),
}
ld.conns["c1"] = &dispatchedConn{id: "c1"}
ld.userIPs["user@1"] = map[string]int{"1.1.1.1": 1}
ld := newTestDispatcher()
// Add some state
ld.mu.Lock()
ld.limitedIPs["user@1"] = map[string]int{"1.1.1.1": 1}
ld.mu.Unlock()
ld.userTraffic.Store("user@1", &userTrafficCounter{})
ld.connCount.Store(3)
ld.ResetConns()
if len(ld.conns) != 0 {
t.Error("conns should be empty after reset")
ld.mu.RLock()
ipCount := len(ld.limitedIPs)
ld.mu.RUnlock()
if ipCount != 0 {
t.Error("limitedIPs should be empty after reset")
}
if len(ld.userIPs) != 0 {
t.Error("userIPs should be empty after reset")
// Verify userTraffic is cleared
count := 0
ld.userTraffic.Range(func(_, _ interface{}) bool {
count++
return true
})
if count != 0 {
t.Error("userTraffic should be empty after reset")
}
if ld.connCount.Load() != 0 {
t.Error("connCount should be 0 after reset")
}
}
func TestLimitDispatcher_UnlimitedUserFastPath(t *testing.T) {
ld := newTestDispatcher()
email := userEmail(1)
// No device limit set for this user
ld.UpdateLimits(map[string]int{email: 1}, nil, nil)
// Should use fast path (sync.Map), no lock needed
for i := 0; i < 100; i++ {
ip := "10.0.0." + string(rune('0'+i%10))
if ld.checkDeviceLimit(email, ip, true) {
t.Errorf("unlimited user should always be allowed (ip=%s)", ip)
}
}
// Verify IPs are tracked in unlimitedIPs
v, ok := ld.unlimitedIPs.Load(email)
if !ok {
t.Error("unlimited user should have entry in unlimitedIPs")
}
ic := v.(*ipCounter)
ips := ic.aliveIPs()
if len(ips) == 0 {
t.Error("should have tracked some IPs")
}
}
+47 -56
View File
@@ -92,14 +92,16 @@ func (x *Xray) Protocols() []string {
// ─── 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
// one. The method is organised in five non-overlapping phases so that the
// kernel mutex is never held during slow operations (Start / Close).
// Crucially, the old instance stays alive until the new one is confirmed
// running — if StartNew fails, the old instance is untouched.
//
// 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)
// Phase 1 – Build: generate protobuf config (no lock, pure computation)
// Phase 2 – Create: xrayCore.New + capture LD (brief global lock)
// Phase 3 – StartNew: instance.Start (no lock, potentially slow)
// Phase 4 – Swap: store new, extract old (brief kernel lock)
// Phase 5 – RecycleOld: close old in background (non-blocking)
func (x *Xray) Start(nodeConfig *panel.NodeConfig, users []panel.User, certFile, keyFile string) error {
// ── Phase 1: Build config (no shared state) ─────────────────────────
x.ensureGeoData(nodeConfig)
@@ -123,25 +125,16 @@ func (x *Xray) Start(nodeConfig *panel.NodeConfig, users []panel.User, certFile,
return fmt.Errorf("create xray: %w", err)
}
// ── 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) ──────────────────
// ── Phase 3: Start new (no lock, potentially slow) ──────────────────
if err := startWithTimeout(inst, startTimeout); err != nil {
inst.Close()
return err
}
// ── Phase 5: Commit (brief kernel lock) ─────────────────────────────
// ── Phase 4: Swap old → new (brief kernel lock) ─────────────────────
x.mu.Lock()
old := x.instance
oldLD := x.limitDispatcher
x.instance = inst
x.limitDispatcher = ld
x.users = users
@@ -155,6 +148,9 @@ func (x *Xray) Start(nodeConfig *panel.NodeConfig, users []panel.User, certFile,
x.running.Store(true)
x.mu.Unlock()
// ── Phase 5: Recycle old (background, non-blocking) ─────────────────
closeOld(old, oldLD)
x.updateDispatcherLimits(users)
slog.Info("xray started",
@@ -206,43 +202,46 @@ func (x *Xray) Stop() {
func (x *Xray) IsRunning() bool { return x.running.Load() }
// ─── Connection queries ─────────────────────────────────────────────────────
// ─── Observability ──────────────────────────────────────────────────────────
// 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) {
// GetUserTraffic returns per-user cumulative traffic and alive IPs.
// Reads from LimitDispatcher's per-user atomic counters — O(users).
func (x *Xray) GetUserTraffic(_ context.Context) (traffic map[int][2]int64, aliveIPs map[int]map[string]bool, connCount int, err error) {
if !x.running.Load() {
return nil, nil
return nil, nil, 0, nil
}
x.mu.Lock()
defer x.mu.Unlock()
if x.instance == nil {
return nil, nil
}
if x.limitDispatcher != nil {
if conns := x.limitDispatcher.Snapshot(); len(conns) > 0 {
return conns, nil
}
}
return x.aggregateStats()
}
func (x *Xray) CloseConnection(_ context.Context, connID string) error {
x.mu.Lock()
ld := x.limitDispatcher
x.mu.Unlock()
if ld != nil {
ld.CloseConn(connID)
traffic, aliveIPs, connCount = ld.GetUserTraffic()
return traffic, aliveIPs, connCount, nil
}
// Fallback: read xray's native stats counters
traffic, err = x.aggregateStats()
return traffic, nil, 0, err
}
func (x *Xray) CloseConnection(_ context.Context, _ string) error {
// No-op: xray doesn't support force-closing individual connections.
// User removal goes through RemoveUsers which removes the inbound user.
return nil
}
func (x *Xray) CloseUserConnections(_ context.Context, _ string) error {
// No-op: handled by RemoveUsers at the xray core level.
return nil
}
func (x *Xray) SetSpeedLimitFunc(_ func(string) *rate.Limiter) {}
// SetDeviceLimitFunc is a no-op for xray — device limits are already
// gate-kept by LimitDispatcher.checkDeviceLimit at Dispatch time.
func (x *Xray) SetDeviceLimitFunc(_ func(string) (int, bool)) {}
// ─── User management (non-disruptive where possible) ────────────────────────
// AddUsers adds new users to the running kernel via xray's UserManager API.
@@ -658,10 +657,7 @@ func closeOld(inst *xrayCore.Instance, ld *LimitDispatcher) {
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 {
if ld.connCount.Load() == 0 {
return
}
time.Sleep(100 * time.Millisecond)
@@ -669,9 +665,9 @@ func drainConns(ld *LimitDispatcher, timeout time.Duration) {
}
// 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).
// when LimitDispatcher is not available. Returns per-user cumulative traffic.
// Must be called with x.mu held.
func (x *Xray) aggregateStats() ([]kernel.Connection, error) {
func (x *Xray) aggregateStats() (map[int][2]int64, error) {
sm := x.instance.GetFeature(stats.ManagerType())
if sm == nil {
return nil, nil
@@ -681,7 +677,7 @@ func (x *Xray) aggregateStats() ([]kernel.Connection, error) {
return nil, nil
}
var conns []kernel.Connection
traffic := make(map[int][2]int64)
for _, u := range x.users {
email := userEmail(u.ID)
@@ -701,15 +697,10 @@ func (x *Xray) aggregateStats() ([]kernel.Connection, error) {
}
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],
})
traffic[u.ID] = cum
}
}
return conns, nil
return traffic, nil
}
// updateDispatcherLimits configures the LimitDispatcher with per-user