feat: add standalone controlplane support
This commit is contained in:
@@ -7,14 +7,14 @@ import (
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/config"
|
||||
"github.com/cedar2025/xboard-node/internal/kernel"
|
||||
"github.com/cedar2025/xboard-node/internal/model"
|
||||
"github.com/cedar2025/xboard-node/internal/nlog"
|
||||
"github.com/cedar2025/xboard-node/internal/panel"
|
||||
)
|
||||
|
||||
// M is a shorthand for building JSON-like maps
|
||||
type M = map[string]interface{}
|
||||
|
||||
func buildConfig(kcfg config.KernelConfig, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
|
||||
func buildConfig(kcfg config.KernelConfig, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M {
|
||||
var outbounds []M
|
||||
tags := make(map[string]bool)
|
||||
|
||||
@@ -85,7 +85,7 @@ func buildConfig(kcfg config.KernelConfig, nc *panel.NodeConfig, users []panel.U
|
||||
// outboundConfigToXray converts a structured OutboundConfig (from the panel)
|
||||
// into an Xray outbound object. Xray uses a nested layout where protocol-
|
||||
// specific fields go inside a "settings" key, and chain proxying uses "proxySettings".
|
||||
func outboundConfigToXray(oc panel.OutboundConfig) M {
|
||||
func outboundConfigToXray(oc model.OutboundConfig) M {
|
||||
m := M{
|
||||
"protocol": oc.Protocol,
|
||||
"tag": oc.Tag,
|
||||
@@ -195,7 +195,7 @@ func xrayLogLevel(singboxLevel string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func buildInbound(nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
|
||||
func buildInbound(nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M {
|
||||
listenAddr := "::"
|
||||
if nc.ListenIP != "" {
|
||||
listenAddr = nc.ListenIP
|
||||
@@ -238,7 +238,7 @@ func userEmail(userID int) string {
|
||||
return fmt.Sprintf("user@%d", userID)
|
||||
}
|
||||
|
||||
func buildVMess(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
|
||||
func buildVMess(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M {
|
||||
clients := make([]M, 0, len(users))
|
||||
for _, u := range users {
|
||||
clients = append(clients, M{
|
||||
@@ -253,7 +253,7 @@ func buildVMess(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyF
|
||||
return base
|
||||
}
|
||||
|
||||
func buildVLESS(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
|
||||
func buildVLESS(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M {
|
||||
clients := make([]M, 0, len(users))
|
||||
for _, u := range users {
|
||||
client := M{
|
||||
@@ -278,7 +278,7 @@ func buildVLESS(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyF
|
||||
return base
|
||||
}
|
||||
|
||||
func buildTrojan(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
|
||||
func buildTrojan(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M {
|
||||
clients := make([]M, len(users))
|
||||
for i := range users {
|
||||
u := &users[i]
|
||||
@@ -314,7 +314,7 @@ var ss2022Methods = map[string]ss2022Config{
|
||||
"2022-blake3-chacha20-poly1305": {"2022-blake3-chacha20-poly1305", 32},
|
||||
}
|
||||
|
||||
func buildShadowsocks(base M, nc *panel.NodeConfig, users []panel.User) M {
|
||||
func buildShadowsocks(base M, nc *model.NodeSpec, users []model.UserSpec) M {
|
||||
ss2022, isSS2022 := ss2022Methods[nc.Cipher]
|
||||
|
||||
clients := make([]M, 0, len(users))
|
||||
@@ -360,7 +360,7 @@ func buildShadowsocks(base M, nc *panel.NodeConfig, users []panel.User) M {
|
||||
return base
|
||||
}
|
||||
|
||||
func buildSocks(base M, users []panel.User) M {
|
||||
func buildSocks(base M, users []model.UserSpec) M {
|
||||
base["protocol"] = "socks"
|
||||
accounts := make([]M, 0, len(users))
|
||||
for _, u := range users {
|
||||
@@ -378,7 +378,7 @@ func buildSocks(base M, users []panel.User) M {
|
||||
return base
|
||||
}
|
||||
|
||||
func buildHTTP(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
|
||||
func buildHTTP(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M {
|
||||
base["protocol"] = "http"
|
||||
accounts := make([]M, 0, len(users))
|
||||
for _, u := range users {
|
||||
@@ -399,7 +399,7 @@ func buildHTTP(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFi
|
||||
}
|
||||
|
||||
// buildHysteria creates a Hysteria v2 inbound for xray-core.
|
||||
func buildHysteria(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
|
||||
func buildHysteria(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M {
|
||||
if nc.Version != 2 {
|
||||
nlog.Core().Warn("xray: only supports hysteria v2, skipping v1 inbound")
|
||||
return nil
|
||||
@@ -458,7 +458,7 @@ func buildHysteria(base M, nc *panel.NodeConfig, users []panel.User, certFile, k
|
||||
return base
|
||||
}
|
||||
|
||||
func applyStreamSettings(base M, nc *panel.NodeConfig, certFile, keyFile string) {
|
||||
func applyStreamSettings(base M, nc *model.NodeSpec, certFile, keyFile string) {
|
||||
ss := M{}
|
||||
|
||||
// Network / transport
|
||||
@@ -497,6 +497,8 @@ func applyStreamSettings(base M, nc *panel.NodeConfig, certFile, keyFile string)
|
||||
if nc.NetworkSettings != nil {
|
||||
if v, ok := nc.NetworkSettings["serviceName"]; ok {
|
||||
grpcSettings["serviceName"] = v
|
||||
} else if v, ok := nc.NetworkSettings["service_name"]; ok {
|
||||
grpcSettings["serviceName"] = v
|
||||
}
|
||||
}
|
||||
ss["grpcSettings"] = grpcSettings
|
||||
@@ -577,7 +579,7 @@ func applyStreamSettings(base M, nc *panel.NodeConfig, certFile, keyFile string)
|
||||
base["streamSettings"] = ss
|
||||
}
|
||||
|
||||
func buildRealitySettings(nc *panel.NodeConfig) M {
|
||||
func buildRealitySettings(nc *model.NodeSpec) M {
|
||||
reality := M{"show": false}
|
||||
|
||||
if nc.TLSSettings == nil {
|
||||
@@ -614,7 +616,7 @@ func buildRealitySettings(nc *panel.NodeConfig) M {
|
||||
return reality
|
||||
}
|
||||
|
||||
func buildRouting(rules []panel.RouteRule, customRules []map[string]any) M {
|
||||
func buildRouting(rules []model.RouteRule, customRules []map[string]any) M {
|
||||
var xrayRules []M
|
||||
|
||||
// Custom route rules (from Panel CustomRoutes or local config) have HIGHEST priority
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/config"
|
||||
"github.com/cedar2025/xboard-node/internal/model"
|
||||
"github.com/cedar2025/xboard-node/internal/panel"
|
||||
)
|
||||
|
||||
@@ -13,11 +14,22 @@ var testKernelCfg = config.KernelConfig{
|
||||
LogLevel: "warn",
|
||||
}
|
||||
|
||||
var testUsers = []panel.User{
|
||||
var testUsersPanel = []panel.User{
|
||||
{ID: 1, UUID: "279d4f89-3a2c-488d-a67c-2d39a72acdde"},
|
||||
{ID: 5, UUID: "4d5965c8-a60c-452a-a943-af83ec0bb0db"},
|
||||
}
|
||||
|
||||
var testUsers = model.UserSpecsFromPanel(testUsersPanel)
|
||||
|
||||
func testNodeSpec(nc *panel.NodeConfig) *model.NodeSpec { return model.NodeSpecFromPanel(nc) }
|
||||
|
||||
func testRouteRules(r []panel.RouteRule) []model.RouteRule {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return model.NodeSpecFromPanel(&panel.NodeConfig{Routes: r}).Routes
|
||||
}
|
||||
|
||||
func TestBuildConfig_OutboundPriority(t *testing.T) {
|
||||
kcfg := config.KernelConfig{
|
||||
LogLevel: "info",
|
||||
@@ -34,7 +46,7 @@ func TestBuildConfig_OutboundPriority(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
cfg := buildConfig(kcfg, nc, testUsers, "", "")
|
||||
cfg := buildConfig(kcfg, testNodeSpec(nc), testUsers, "", "")
|
||||
outbounds := cfg["outbounds"].([]M)
|
||||
|
||||
// Since we overrode both 'direct' and 'block', the result should contain
|
||||
@@ -149,7 +161,7 @@ func TestBuildConfig_AllProtocols_ValidJSON(t *testing.T) {
|
||||
|
||||
for _, tc := range protocols {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg := buildConfig(testKernelCfg, &tc.nc, testUsers, "/cert.pem", "/key.pem")
|
||||
cfg := buildConfig(testKernelCfg, testNodeSpec(&tc.nc), testUsers, "/cert.pem", "/key.pem")
|
||||
|
||||
data, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
@@ -182,7 +194,7 @@ func TestBuildConfig_VMess_Users(t *testing.T) {
|
||||
Protocol: "vmess",
|
||||
ServerPort: 10086,
|
||||
}
|
||||
cfg := buildConfig(testKernelCfg, &nc, testUsers, "", "")
|
||||
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, "", "")
|
||||
data, _ := json.Marshal(cfg)
|
||||
|
||||
var parsed map[string]interface{}
|
||||
@@ -222,7 +234,7 @@ func TestBuildConfig_VLESS_Flow(t *testing.T) {
|
||||
"server_name": "example.com",
|
||||
},
|
||||
}
|
||||
cfg := buildConfig(testKernelCfg, &nc, testUsers, "", "")
|
||||
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, "", "")
|
||||
data, _ := json.Marshal(cfg)
|
||||
|
||||
var parsed map[string]interface{}
|
||||
@@ -275,7 +287,7 @@ func TestBuildRouting_WithRules(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
routing := buildRouting(rules, nil)
|
||||
routing := buildRouting(testRouteRules(rules), nil)
|
||||
xrayRules := routing["rules"].([]M)
|
||||
|
||||
// 1 default + 2 domain rules + 1 IP rule = 4
|
||||
@@ -337,7 +349,7 @@ func TestBuildConfig_StatsEnabled(t *testing.T) {
|
||||
Protocol: "vmess",
|
||||
ServerPort: 10086,
|
||||
}
|
||||
cfg := buildConfig(testKernelCfg, &nc, testUsers, "", "")
|
||||
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, "", "")
|
||||
data, _ := json.Marshal(cfg)
|
||||
|
||||
var parsed map[string]interface{}
|
||||
@@ -367,7 +379,7 @@ func TestBuildConfig_Shadowsocks_MultiUserTraditional(t *testing.T) {
|
||||
ServerPort: 8388,
|
||||
Cipher: "aes-128-gcm",
|
||||
}
|
||||
cfg := buildConfig(testKernelCfg, &nc, testUsers, "", "")
|
||||
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, "", "")
|
||||
data, _ := json.Marshal(cfg)
|
||||
|
||||
var parsed map[string]interface{}
|
||||
@@ -400,7 +412,7 @@ func TestBuildConfig_Shadowsocks_MultiUser(t *testing.T) {
|
||||
Cipher: "2022-blake3-aes-128-gcm",
|
||||
ServerKey: "server-key",
|
||||
}
|
||||
cfg := buildConfig(testKernelCfg, &nc, testUsers, "", "")
|
||||
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, "", "")
|
||||
data, _ := json.Marshal(cfg)
|
||||
|
||||
var parsed map[string]interface{}
|
||||
@@ -424,7 +436,7 @@ func TestBuildConfig_SocksStats(t *testing.T) {
|
||||
Protocol: "socks",
|
||||
ServerPort: 1080,
|
||||
}
|
||||
cfg := buildConfig(testKernelCfg, &nc, testUsers, "", "")
|
||||
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, "", "")
|
||||
data, _ := json.Marshal(cfg)
|
||||
|
||||
var parsed map[string]interface{}
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"github.com/xtls/xray-core/common/session"
|
||||
"github.com/xtls/xray-core/features/routing"
|
||||
"github.com/xtls/xray-core/transport"
|
||||
"golang.org/x/time/rate"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/nlog"
|
||||
)
|
||||
@@ -59,13 +58,13 @@ func limitDispatcherFactory(ctx context.Context, config interface{}) (interface{
|
||||
return ld, nil
|
||||
}
|
||||
|
||||
// LimitDispatcher wraps xray's DefaultDispatcher to add per-user device
|
||||
// limit enforcement, speed limiting, and per-user traffic counting.
|
||||
// LimitDispatcher wraps xray's DefaultDispatcher to enforce per-user
|
||||
// admission checks before a request is dispatched into xray-core.
|
||||
//
|
||||
// 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).
|
||||
// It intentionally does NOT mutate transport.Link.Reader/Writer. Xray's
|
||||
// mux/XUDP close path requires the original concrete *pipe.Reader to remain
|
||||
// intact, so the dispatcher is limited to gate-keeping and safe connection
|
||||
// lifecycle bookkeeping.
|
||||
type LimitDispatcher struct {
|
||||
inner interface{} // original DefaultDispatcher (Feature + Dispatcher)
|
||||
innerDisp routing.Dispatcher // same object, typed as Dispatcher
|
||||
@@ -75,43 +74,32 @@ type LimitDispatcher struct {
|
||||
mu sync.RWMutex
|
||||
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
|
||||
|
||||
// unlimitedIPs: users without device limit — sync.Map for lock-free access.
|
||||
// Each entry is *ipCounter{count atomic.Int64, ips sync.Map}.
|
||||
// Each entry is *ipCounter{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)
|
||||
connCount atomic.Int64 // total active connections tracked by dispatcher
|
||||
}
|
||||
|
||||
// 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)
|
||||
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
|
||||
if rv, ok := ic.ips.Load(key); ok && rv.(*atomic.Int64).Load() > 0 {
|
||||
result[key.(string)] = true
|
||||
}
|
||||
return true
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// userTrafficCounter holds per-user cumulative traffic counters.
|
||||
type userTrafficCounter struct {
|
||||
upload atomic.Int64
|
||||
download atomic.Int64
|
||||
}
|
||||
|
||||
// ─── routing.Dispatcher ──────────────────────────────────────────────────────
|
||||
|
||||
func (d *LimitDispatcher) Dispatch(ctx context.Context, dest net.Destination) (*transport.Link, error) {
|
||||
@@ -129,7 +117,7 @@ func (d *LimitDispatcher) Dispatch(ctx context.Context, dest net.Destination) (*
|
||||
}
|
||||
|
||||
if email != "" {
|
||||
d.wrapLink(ctx, link, email, sourceIP, isTCP)
|
||||
d.trackLink(link, email, sourceIP, isTCP)
|
||||
}
|
||||
return link, nil
|
||||
}
|
||||
@@ -141,7 +129,7 @@ func (d *LimitDispatcher) DispatchLink(ctx context.Context, dest net.Destination
|
||||
}
|
||||
|
||||
if email != "" {
|
||||
d.wrapLink(ctx, link, email, sourceIP, isTCP)
|
||||
d.trackLink(link, email, sourceIP, isTCP)
|
||||
}
|
||||
return d.innerDisp.DispatchLink(ctx, dest, link)
|
||||
}
|
||||
@@ -165,13 +153,10 @@ func (d *LimitDispatcher) identifyAndCheck(ctx context.Context, dest net.Destina
|
||||
return email, sourceIP, isTCP, nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Get or create per-user traffic counter
|
||||
v, _ := d.userTraffic.LoadOrStore(email, &userTrafficCounter{})
|
||||
utc := v.(*userTrafficCounter)
|
||||
|
||||
// trackLink records connection lifecycle without mutating xray-core owned
|
||||
// transport primitives. This keeps mux/XUDP compatible while still allowing
|
||||
// the dispatcher to release device-limit state when the link closes.
|
||||
func (d *LimitDispatcher) trackLink(link *transport.Link, email, sourceIP string, isTCP bool) {
|
||||
d.connCount.Add(1)
|
||||
|
||||
onClose := func() {
|
||||
@@ -181,19 +166,8 @@ func (d *LimitDispatcher) wrapLink(ctx context.Context, link *transport.Link, em
|
||||
d.connCount.Add(-1)
|
||||
}
|
||||
|
||||
limiter := d.getBucket(email)
|
||||
|
||||
link.Reader = &statsCloseReader{
|
||||
Reader: link.Reader,
|
||||
counter: &utc.upload,
|
||||
limiter: limiter,
|
||||
ctx: ctx,
|
||||
}
|
||||
link.Writer = &statsCloseWriter{
|
||||
link.Writer = &closeTrackingWriter{
|
||||
Writer: link.Writer,
|
||||
counter: &utc.download,
|
||||
limiter: limiter,
|
||||
ctx: ctx,
|
||||
onClose: onClose,
|
||||
}
|
||||
}
|
||||
@@ -218,19 +192,12 @@ func (d *LimitDispatcher) Close() error {
|
||||
|
||||
// ─── Limit management (called by Xray kernel) ──────────────────────────────
|
||||
|
||||
func (d *LimitDispatcher) UpdateLimits(emailToUID map[string]int, deviceLimits, speedLimits map[string]int) {
|
||||
func (d *LimitDispatcher) UpdateLimits(emailToUID map[string]int, deviceLimits, _ map[string]int) {
|
||||
d.mu.Lock()
|
||||
d.emailToUID = emailToUID
|
||||
d.deviceLimits = deviceLimits
|
||||
d.speedLimits = speedLimits
|
||||
d.mu.Unlock()
|
||||
|
||||
d.speedBuckets.Range(func(key, _ interface{}) bool {
|
||||
if _, ok := speedLimits[key.(string)]; !ok {
|
||||
d.speedBuckets.Delete(key)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func (d *LimitDispatcher) ResetConns() {
|
||||
@@ -244,21 +211,12 @@ func (d *LimitDispatcher) ResetConns() {
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// GetConnectionState returns dispatcher-tracked alive IPs and connection count.
|
||||
// Traffic bytes are intentionally left to xray's built-in stats pipeline.
|
||||
func (d *LimitDispatcher) GetConnectionState() (aliveIPs map[int]map[string]bool, connCount int) {
|
||||
d.mu.RLock()
|
||||
emailToUID := d.emailToUID
|
||||
limitedIPs := d.limitedIPs
|
||||
@@ -302,22 +260,6 @@ func (d *LimitDispatcher) GetUserTraffic() (traffic map[int][2]int64, aliveIPs m
|
||||
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
|
||||
}
|
||||
@@ -337,7 +279,6 @@ func (d *LimitDispatcher) checkDeviceLimit(email, sourceIP string, isTCP bool) b
|
||||
if isTCP {
|
||||
v, _ := d.unlimitedIPs.LoadOrStore(email, &ipCounter{})
|
||||
ic := v.(*ipCounter)
|
||||
ic.count.Add(1)
|
||||
|
||||
// Increment IP refcount atomically.
|
||||
rv, _ := ic.ips.LoadOrStore(sourceIP, &atomic.Int64{})
|
||||
@@ -445,93 +386,20 @@ func (d *LimitDispatcher) delConn(email, sourceIP string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (d *LimitDispatcher) getBucket(email string) *rate.Limiter {
|
||||
d.mu.RLock()
|
||||
mbps, ok := d.speedLimits[email]
|
||||
d.mu.RUnlock()
|
||||
if !ok || mbps <= 0 {
|
||||
return nil
|
||||
}
|
||||
bytesPerSec := int(mbps) * 1_000_000 / 8
|
||||
burst := bytesPerSec
|
||||
if burst < 64*1024 {
|
||||
burst = 64 * 1024
|
||||
}
|
||||
v, loaded := d.speedBuckets.LoadOrStore(email, rate.NewLimiter(rate.Limit(bytesPerSec), burst))
|
||||
lim := v.(*rate.Limiter)
|
||||
if loaded {
|
||||
// Update existing limiter in case speed limit changed.
|
||||
lim.SetLimit(rate.Limit(bytesPerSec))
|
||||
lim.SetBurst(burst)
|
||||
}
|
||||
return lim
|
||||
}
|
||||
|
||||
// ─── I/O wrappers ───────────────────────────────────────────────────────────
|
||||
|
||||
const xrayRateLimitThreshold = 64 * 1024
|
||||
|
||||
type statsCloseReader struct {
|
||||
buf.Reader
|
||||
counter *atomic.Int64
|
||||
limiter *rate.Limiter
|
||||
ctx context.Context
|
||||
pendingTokens atomic.Int64
|
||||
}
|
||||
|
||||
func (r *statsCloseReader) ReadMultiBuffer() (buf.MultiBuffer, error) {
|
||||
mb, err := r.Reader.ReadMultiBuffer()
|
||||
if n := int64(mb.Len()); n > 0 {
|
||||
r.counter.Add(n)
|
||||
if r.limiter != nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
return mb, err
|
||||
}
|
||||
|
||||
func (r *statsCloseReader) Close() error { return common.Close(r.Reader) }
|
||||
func (r *statsCloseReader) Interrupt() { common.Interrupt(r.Reader) }
|
||||
|
||||
type statsCloseWriter struct {
|
||||
type closeTrackingWriter struct {
|
||||
buf.Writer
|
||||
counter *atomic.Int64
|
||||
limiter *rate.Limiter
|
||||
ctx context.Context
|
||||
onClose func()
|
||||
closed atomic.Bool
|
||||
pendingTokens atomic.Int64
|
||||
onClose func()
|
||||
closed atomic.Bool
|
||||
}
|
||||
|
||||
func (w *statsCloseWriter) WriteMultiBuffer(mb buf.MultiBuffer) error {
|
||||
n := int64(mb.Len())
|
||||
if w.limiter != nil {
|
||||
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.counter.Add(n)
|
||||
return w.Writer.WriteMultiBuffer(mb)
|
||||
}
|
||||
|
||||
func (w *statsCloseWriter) Close() error {
|
||||
func (w *closeTrackingWriter) Close() error {
|
||||
if w.closed.CompareAndSwap(false, true) {
|
||||
w.onClose()
|
||||
}
|
||||
return common.Close(w.Writer)
|
||||
}
|
||||
|
||||
func (w *statsCloseWriter) Interrupt() {
|
||||
func (w *closeTrackingWriter) Interrupt() {
|
||||
if w.closed.CompareAndSwap(false, true) {
|
||||
w.onClose()
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/panel"
|
||||
"github.com/cedar2025/xboard-node/internal/model"
|
||||
"github.com/xtls/xray-core/common/buf"
|
||||
"github.com/xtls/xray-core/transport"
|
||||
)
|
||||
|
||||
func newTestDispatcher() *LimitDispatcher {
|
||||
@@ -13,10 +15,14 @@ func newTestDispatcher() *LimitDispatcher {
|
||||
}
|
||||
}
|
||||
|
||||
type nopReader struct{}
|
||||
|
||||
func (nopReader) ReadMultiBuffer() (buf.MultiBuffer, error) { return nil, nil }
|
||||
|
||||
func TestLimitDispatcher_DeviceLimitCheck(t *testing.T) {
|
||||
ld := newTestDispatcher()
|
||||
|
||||
users := []panel.User{
|
||||
users := []model.UserSpec{
|
||||
{ID: 1, UUID: "uuid-1", DeviceLimit: 2, SpeedLimit: 0},
|
||||
{ID: 2, UUID: "uuid-2", DeviceLimit: 0, SpeedLimit: 10},
|
||||
}
|
||||
@@ -93,75 +99,35 @@ func TestLimitDispatcher_DelConn(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimitDispatcher_SpeedBucket(t *testing.T) {
|
||||
ld := newTestDispatcher()
|
||||
|
||||
email1 := userEmail(1)
|
||||
email2 := userEmail(2)
|
||||
|
||||
speedLimits := map[string]int{email1: 10} // 10 Mbps
|
||||
ld.UpdateLimits(map[string]int{email1: 1, email2: 2}, nil, speedLimits)
|
||||
|
||||
// User 1 has speed limit — should get a limiter
|
||||
limiter := ld.getBucket(email1)
|
||||
if limiter == nil {
|
||||
t.Error("user with speed limit should get a limiter")
|
||||
}
|
||||
|
||||
// Same user should get the same limiter (cached)
|
||||
limiter2 := ld.getBucket(email1)
|
||||
if limiter != limiter2 {
|
||||
t.Error("same user should get cached limiter")
|
||||
}
|
||||
|
||||
// User 2 has no speed limit — should get nil
|
||||
limiter3 := ld.getBucket(email2)
|
||||
if limiter3 != nil {
|
||||
t.Error("user without speed limit should get nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimitDispatcher_GetUserTraffic(t *testing.T) {
|
||||
func TestLimitDispatcher_GetConnectionState(t *testing.T) {
|
||||
ld := newTestDispatcher()
|
||||
|
||||
email1 := userEmail(1)
|
||||
email2 := userEmail(2)
|
||||
ld.UpdateLimits(map[string]int{email1: 1, email2: 2}, nil, nil)
|
||||
|
||||
// Simulate traffic by storing counters directly
|
||||
tc1 := &userTrafficCounter{}
|
||||
tc1.upload.Store(1000)
|
||||
tc1.download.Store(2000)
|
||||
ld.userTraffic.Store(email1, tc1)
|
||||
|
||||
tc2 := &userTrafficCounter{}
|
||||
tc2.upload.Store(500)
|
||||
tc2.download.Store(800)
|
||||
ld.userTraffic.Store(email2, tc2)
|
||||
|
||||
// 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{})
|
||||
r1 := &atomic.Int64{}
|
||||
r1.Store(1)
|
||||
ic1.ips.Store("1.1.1.1", r1)
|
||||
r2 := &atomic.Int64{}
|
||||
r2.Store(1)
|
||||
ic1.ips.Store("2.2.2.2", r2)
|
||||
ld.unlimitedIPs.Store(email1, ic1)
|
||||
|
||||
ic2 := &ipCounter{}
|
||||
ic2.ips.Store("3.3.3.3", &atomic.Int64{})
|
||||
r3 := &atomic.Int64{}
|
||||
r3.Store(1)
|
||||
ic2.ips.Store("3.3.3.3", r3)
|
||||
ld.unlimitedIPs.Store(email2, ic2)
|
||||
|
||||
ld.connCount.Store(5)
|
||||
|
||||
traffic, aliveIPs, connCount := ld.GetUserTraffic()
|
||||
aliveIPs, connCount := ld.GetConnectionState()
|
||||
|
||||
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]))
|
||||
}
|
||||
@@ -173,11 +139,9 @@ func TestLimitDispatcher_GetUserTraffic(t *testing.T) {
|
||||
func TestLimitDispatcher_ResetConns(t *testing.T) {
|
||||
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()
|
||||
@@ -189,16 +153,6 @@ func TestLimitDispatcher_ResetConns(t *testing.T) {
|
||||
t.Error("limitedIPs 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")
|
||||
}
|
||||
@@ -230,3 +184,52 @@ func TestLimitDispatcher_UnlimitedUserFastPath(t *testing.T) {
|
||||
t.Error("should have tracked some IPs")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func TestLimitDispatcher_TrackLinkPreservesReader(t *testing.T) {
|
||||
ld := newTestDispatcher()
|
||||
email := userEmail(1)
|
||||
ld.UpdateLimits(map[string]int{email: 1}, map[string]int{email: 1}, nil)
|
||||
|
||||
origReader := nopReader{}
|
||||
origWriter := &closeTrackingWriter{Writer: buf.Discard, onClose: func() {}}
|
||||
link := &transport.Link{Reader: origReader, Writer: origWriter}
|
||||
|
||||
ld.trackLink(link, email, "1.1.1.1", true)
|
||||
|
||||
if link.Reader != origReader {
|
||||
t.Fatal("trackLink must not replace link.Reader")
|
||||
}
|
||||
if link.Writer == origWriter {
|
||||
t.Fatal("trackLink should wrap link.Writer for lifecycle callbacks")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimitDispatcher_CloseTrackingWriterReleasesConn(t *testing.T) {
|
||||
ld := newTestDispatcher()
|
||||
email := userEmail(1)
|
||||
ld.UpdateLimits(map[string]int{email: 1}, map[string]int{email: 1}, nil)
|
||||
if ld.checkDeviceLimit(email, "1.1.1.1", true) {
|
||||
t.Fatal("first connection should be allowed")
|
||||
}
|
||||
|
||||
link := &transport.Link{Reader: nopReader{}, Writer: buf.Discard}
|
||||
ld.trackLink(link, email, "1.1.1.1", true)
|
||||
|
||||
if got := ld.connCount.Load(); got != 1 {
|
||||
t.Fatalf("expected connCount=1 after tracking, got %d", got)
|
||||
}
|
||||
cw, ok := link.Writer.(*closeTrackingWriter)
|
||||
if !ok {
|
||||
t.Fatal("expected closeTrackingWriter wrapper")
|
||||
}
|
||||
if err := cw.Close(); err != nil {
|
||||
t.Fatalf("closeTrackingWriter.Close() error = %v", err)
|
||||
}
|
||||
if got := ld.connCount.Load(); got != 0 {
|
||||
t.Fatalf("expected connCount=0 after close, got %d", got)
|
||||
}
|
||||
if ld.checkDeviceLimit(email, "2.2.2.2", true) {
|
||||
t.Fatal("device slot should be released after writer close")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
xrayCore "github.com/xtls/xray-core/core"
|
||||
"github.com/xtls/xray-core/features/inbound"
|
||||
"github.com/xtls/xray-core/features/stats"
|
||||
featurebandwidth "github.com/xtls/xray-core/features/bandwidth"
|
||||
"github.com/xtls/xray-core/infra/conf/serial"
|
||||
xrayProxy "github.com/xtls/xray-core/proxy"
|
||||
"github.com/xtls/xray-core/proxy/shadowsocks"
|
||||
@@ -32,7 +33,7 @@ import (
|
||||
"github.com/cedar2025/xboard-node/internal/kernel"
|
||||
"github.com/cedar2025/xboard-node/internal/kernel/geodata"
|
||||
"github.com/cedar2025/xboard-node/internal/nlog"
|
||||
"github.com/cedar2025/xboard-node/internal/panel"
|
||||
"github.com/cedar2025/xboard-node/internal/model"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -59,14 +60,15 @@ type Xray struct {
|
||||
mu sync.Mutex
|
||||
instance *xrayCore.Instance
|
||||
limitDispatcher *LimitDispatcher
|
||||
users []panel.User
|
||||
nodeConfig *panel.NodeConfig
|
||||
users []model.UserSpec
|
||||
nodeConfig *model.NodeSpec
|
||||
certFile string
|
||||
keyFile string
|
||||
protocol string
|
||||
inboundTag string
|
||||
lastKernelHash string
|
||||
cumTraffic map[int][2]int64
|
||||
speedLimitFunc func(string) *rate.Limiter
|
||||
|
||||
// running is set after a successful Start and cleared before shutdown.
|
||||
// Atomic so IsRunning / GetConnections never block.
|
||||
@@ -82,6 +84,17 @@ func New(cfg config.KernelConfig) *Xray {
|
||||
|
||||
func (x *Xray) Name() string { return "xray" }
|
||||
|
||||
func (x *Xray) Capabilities() kernel.Capabilities {
|
||||
return kernel.Capabilities{
|
||||
PerUserSpeedLimit: true,
|
||||
DeviceLimit: true,
|
||||
BuiltInTrafficStats: true,
|
||||
AliveIPTracking: true,
|
||||
ForceCloseConnection: false,
|
||||
ForceCloseUser: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (x *Xray) Protocols() []string {
|
||||
return []string{
|
||||
"vmess", "vless", "trojan", "shadowsocks",
|
||||
@@ -102,7 +115,7 @@ func (x *Xray) Protocols() []string {
|
||||
// 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 {
|
||||
func (x *Xray) Start(nodeConfig *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) error {
|
||||
// ── Phase 1: Build config (no shared state) ─────────────────────────
|
||||
x.ensureGeoData(nodeConfig)
|
||||
|
||||
@@ -152,6 +165,7 @@ func (x *Xray) Start(nodeConfig *panel.NodeConfig, users []panel.User, certFile,
|
||||
closeOld(old, oldLD)
|
||||
|
||||
x.updateDispatcherLimits(users)
|
||||
x.updateBandwidthLimits(users)
|
||||
|
||||
nlog.Core().Info("xray started",
|
||||
"users", len(users),
|
||||
@@ -164,8 +178,9 @@ func (x *Xray) Start(nodeConfig *panel.NodeConfig, users []panel.User, certFile,
|
||||
// Since Xray does not support granular inbound reconstruction without an
|
||||
// instance restart for most transport/TLS settings, it triggers a full restart
|
||||
// if any kernel-affecting fields (hash mismatch) have changed.
|
||||
func (x *Xray) Reload(nodeConfig *panel.NodeConfig, users []panel.User, certFile, keyFile string) error {
|
||||
func (x *Xray) Reload(nodeConfig *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) error {
|
||||
x.updateDispatcherLimits(users)
|
||||
x.updateBandwidthLimits(users)
|
||||
|
||||
newHash := kernel.ComputeHash(nodeConfig, users)
|
||||
|
||||
@@ -204,8 +219,10 @@ func (x *Xray) IsRunning() bool { return x.running.Load() }
|
||||
|
||||
// ─── Observability ──────────────────────────────────────────────────────────
|
||||
|
||||
// GetUserTraffic returns per-user cumulative traffic and alive IPs.
|
||||
// Reads from LimitDispatcher's per-user atomic counters — O(users).
|
||||
// GetUserTraffic returns per-user cumulative traffic from xray's built-in
|
||||
// stats pipeline and connection state from the admission dispatcher when
|
||||
// available. The dispatcher intentionally no longer wraps transport links,
|
||||
// so traffic accounting must come from xray-core itself.
|
||||
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, 0, nil
|
||||
@@ -213,18 +230,16 @@ func (x *Xray) GetUserTraffic(_ context.Context) (traffic map[int][2]int64, aliv
|
||||
|
||||
x.mu.Lock()
|
||||
ld := x.limitDispatcher
|
||||
x.mu.Unlock()
|
||||
|
||||
if ld != nil {
|
||||
traffic, aliveIPs, connCount = ld.GetUserTraffic()
|
||||
return traffic, aliveIPs, connCount, nil
|
||||
}
|
||||
|
||||
// Fallback path: aggregateStats needs x.mu.
|
||||
x.mu.Lock()
|
||||
traffic, err = x.aggregateStats()
|
||||
x.mu.Unlock()
|
||||
return traffic, nil, 0, err
|
||||
if err != nil {
|
||||
return nil, nil, 0, err
|
||||
}
|
||||
|
||||
if ld != nil {
|
||||
aliveIPs, connCount = ld.GetConnectionState()
|
||||
}
|
||||
return traffic, aliveIPs, connCount, nil
|
||||
}
|
||||
|
||||
func (x *Xray) CloseConnection(_ context.Context, _ string) error {
|
||||
@@ -238,7 +253,15 @@ func (x *Xray) CloseUserConnections(_ context.Context, _ string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *Xray) SetSpeedLimitFunc(_ func(string) *rate.Limiter) {}
|
||||
// SetSpeedLimitFunc wires xray's patched bandwidth feature to the shared
|
||||
// per-user limiter callback used by the service layer. Unlike the old no-op
|
||||
// behavior, xray now consumes this callback to support dynamic speed updates.
|
||||
func (x *Xray) SetSpeedLimitFunc(fn func(string) *rate.Limiter) {
|
||||
x.mu.Lock()
|
||||
x.speedLimitFunc = fn
|
||||
x.mu.Unlock()
|
||||
x.updateBandwidthLimits(nil)
|
||||
}
|
||||
|
||||
// SetDeviceLimitFunc is a no-op for xray — device limits are already
|
||||
// gate-kept by LimitDispatcher.checkDeviceLimit at Dispatch time.
|
||||
@@ -260,7 +283,7 @@ func (x *Xray) ClearGlobalDevices() {}
|
||||
// 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) {
|
||||
func (x *Xray) AddUsers(users []model.UserSpec) (int, error) {
|
||||
x.mu.Lock()
|
||||
if x.instance == nil {
|
||||
x.mu.Unlock()
|
||||
@@ -268,18 +291,18 @@ func (x *Xray) AddUsers(users []panel.User) (int, error) {
|
||||
}
|
||||
|
||||
// Merge: overwrite existing users' properties, collect truly new ones.
|
||||
userMap := make(map[int]panel.User, len(x.users))
|
||||
userMap := make(map[int]model.UserSpec, len(x.users))
|
||||
for _, u := range x.users {
|
||||
userMap[u.ID] = u
|
||||
}
|
||||
var toAdd []panel.User
|
||||
var toAdd []model.UserSpec
|
||||
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))
|
||||
merged := make([]model.UserSpec, 0, len(userMap))
|
||||
for _, u := range userMap {
|
||||
merged = append(merged, u)
|
||||
}
|
||||
@@ -289,6 +312,7 @@ func (x *Xray) AddUsers(users []panel.User) (int, error) {
|
||||
x.users = merged
|
||||
x.mu.Unlock()
|
||||
x.updateDispatcherLimits(merged)
|
||||
x.updateBandwidthLimits(merged)
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
@@ -328,6 +352,7 @@ func (x *Xray) AddUsers(users []panel.User) (int, error) {
|
||||
x.users = merged
|
||||
x.mu.Unlock()
|
||||
x.updateDispatcherLimits(merged)
|
||||
x.updateBandwidthLimits(merged)
|
||||
|
||||
nlog.Core().Info("xray: users added via UserManager", "added", added, "total", len(merged))
|
||||
return added, nil
|
||||
@@ -335,7 +360,7 @@ func (x *Xray) AddUsers(users []panel.User) (int, error) {
|
||||
|
||||
// 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) {
|
||||
func (x *Xray) RemoveUsers(users []model.UserSpec) (int, error) {
|
||||
x.mu.Lock()
|
||||
if x.instance == nil {
|
||||
x.mu.Unlock()
|
||||
@@ -345,7 +370,7 @@ func (x *Xray) RemoveUsers(users []panel.User) (int, error) {
|
||||
for _, u := range users {
|
||||
removeSet[u.ID] = struct{}{}
|
||||
}
|
||||
var kept []panel.User
|
||||
var kept []model.UserSpec
|
||||
removed := 0
|
||||
for _, u := range x.users {
|
||||
if _, rm := removeSet[u.ID]; rm {
|
||||
@@ -392,6 +417,7 @@ func (x *Xray) RemoveUsers(users []panel.User) (int, error) {
|
||||
x.users = kept
|
||||
x.mu.Unlock()
|
||||
x.updateDispatcherLimits(kept)
|
||||
x.updateBandwidthLimits(kept)
|
||||
|
||||
nlog.Core().Info("xray: users removed via UserManager", "removed", actualRemoved, "total", len(kept))
|
||||
return actualRemoved, nil
|
||||
@@ -400,7 +426,7 @@ func (x *Xray) RemoveUsers(users []panel.User) (int, error) {
|
||||
// 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) {
|
||||
func (x *Xray) UpdateUsers(users []model.UserSpec) (added, removed int, err error) {
|
||||
x.mu.Lock()
|
||||
if x.instance == nil {
|
||||
x.mu.Unlock()
|
||||
@@ -414,6 +440,7 @@ func (x *Xray) UpdateUsers(users []panel.User) (added, removed int, err error) {
|
||||
x.users = users
|
||||
x.mu.Unlock()
|
||||
x.updateDispatcherLimits(users)
|
||||
x.updateBandwidthLimits(users)
|
||||
nlog.Core().Info("xray: limits updated, kernel unchanged")
|
||||
return 0, 0, nil
|
||||
}
|
||||
@@ -458,6 +485,7 @@ func (x *Xray) UpdateUsers(users []panel.User) (added, removed int, err error) {
|
||||
x.users = users
|
||||
x.mu.Unlock()
|
||||
x.updateDispatcherLimits(users)
|
||||
x.updateBandwidthLimits(users)
|
||||
|
||||
nlog.Core().Info("xray: users updated via UserManager", "added", added, "removed", removed, "total", len(users))
|
||||
return
|
||||
@@ -502,9 +530,9 @@ func (x *Xray) getUserManager() (xrayProxy.UserManager, error) {
|
||||
return um, nil
|
||||
}
|
||||
|
||||
// toMemoryUser converts a panel.User into an xray protocol.MemoryUser for the
|
||||
// toMemoryUser converts a model.UserSpec 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) {
|
||||
func toMemoryUser(proto string, nc *model.NodeSpec, u model.UserSpec) (*protocol.MemoryUser, error) {
|
||||
email := userEmail(u.ID)
|
||||
mu := &protocol.MemoryUser{Email: email, Level: 0}
|
||||
|
||||
@@ -598,7 +626,7 @@ func hexEncode(dst, src []byte) {
|
||||
}
|
||||
|
||||
// ensureGeoData downloads geo databases when routes reference geoip/geosite.
|
||||
func (x *Xray) ensureGeoData(nc *panel.NodeConfig) {
|
||||
func (x *Xray) ensureGeoData(nc *model.NodeSpec) {
|
||||
needIP, needSite := kernel.NeedsGeoIP(nc.Routes), kernel.NeedsGeoSite(nc.Routes)
|
||||
if !needIP && !needSite {
|
||||
return
|
||||
@@ -611,7 +639,7 @@ func (x *Xray) ensureGeoData(nc *panel.NodeConfig) {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
func marshalConfig(cfg config.KernelConfig, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) ([]byte, error) {
|
||||
cfgMap := buildConfig(cfg, nc, users, certFile, keyFile)
|
||||
data, err := json.MarshalIndent(cfgMap, "", " ")
|
||||
if err != nil {
|
||||
@@ -712,9 +740,44 @@ func (x *Xray) aggregateStats() (map[int][2]int64, error) {
|
||||
return traffic, nil
|
||||
}
|
||||
|
||||
// updateBandwidthLimits configures the patched xray-core bandwidth feature
|
||||
// with per-user speed limits in bytes per second.
|
||||
func (x *Xray) updateBandwidthLimits(users []model.UserSpec) {
|
||||
x.mu.Lock()
|
||||
inst := x.instance
|
||||
fn := x.speedLimitFunc
|
||||
currentUsers := x.users
|
||||
x.mu.Unlock()
|
||||
if inst == nil {
|
||||
return
|
||||
}
|
||||
feat := inst.GetFeature(featurebandwidth.ManagerType())
|
||||
if feat == nil {
|
||||
return
|
||||
}
|
||||
bm, ok := feat.(featurebandwidth.Manager)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
bm.Reset()
|
||||
if users == nil {
|
||||
users = currentUsers
|
||||
}
|
||||
for _, u := range users {
|
||||
email := userEmail(u.ID)
|
||||
if fn != nil {
|
||||
bm.SetUserLimiter(email, fn(u.UUID))
|
||||
continue
|
||||
}
|
||||
bps := int64(u.SpeedLimit) * 1_000_000 / 8
|
||||
bm.SetUserLimit(email, bps)
|
||||
}
|
||||
}
|
||||
|
||||
// updateDispatcherLimits configures the LimitDispatcher with per-user
|
||||
// device and speed limits.
|
||||
func (x *Xray) updateDispatcherLimits(users []panel.User) {
|
||||
// device-limit admission metadata only. Speed limits are enforced by the
|
||||
// patched xray-core bandwidth feature.
|
||||
func (x *Xray) updateDispatcherLimits(users []model.UserSpec) {
|
||||
x.mu.Lock()
|
||||
ld := x.limitDispatcher
|
||||
x.mu.Unlock()
|
||||
@@ -724,7 +787,6 @@ func (x *Xray) updateDispatcherLimits(users []panel.User) {
|
||||
|
||||
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)
|
||||
@@ -734,13 +796,9 @@ func (x *Xray) updateDispatcherLimits(users []panel.User) {
|
||||
deviceLimits[email] = u.DeviceLimit
|
||||
deviceLimits[u.UUID] = u.DeviceLimit
|
||||
}
|
||||
if u.SpeedLimit > 0 {
|
||||
speedLimits[email] = u.SpeedLimit
|
||||
speedLimits[u.UUID] = u.SpeedLimit
|
||||
}
|
||||
}
|
||||
|
||||
ld.UpdateLimits(emailToUID, deviceLimits, speedLimits)
|
||||
ld.UpdateLimits(emailToUID, deviceLimits, nil)
|
||||
}
|
||||
|
||||
// xrayCreationMu serialises xrayCore.New() + globalLimitDispatcher capture
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
package xray
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/config"
|
||||
"github.com/cedar2025/xboard-node/internal/model"
|
||||
appstats "github.com/xtls/xray-core/app/stats"
|
||||
xrayCore "github.com/xtls/xray-core/core"
|
||||
featurebandwidth "github.com/xtls/xray-core/features/bandwidth"
|
||||
xraystats "github.com/xtls/xray-core/features/stats"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
func newStatsManager(t *testing.T) xraystats.Manager {
|
||||
t.Helper()
|
||||
mgr, err := appstats.NewManager(context.Background(), &appstats.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("stats.NewManager() error = %v", err)
|
||||
}
|
||||
return mgr
|
||||
}
|
||||
|
||||
func mustRegisterCounter(t *testing.T, mgr xraystats.Manager, name string) xraystats.Counter {
|
||||
t.Helper()
|
||||
counter, err := mgr.RegisterCounter(name)
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterCounter(%q) error = %v", name, err)
|
||||
}
|
||||
return counter
|
||||
}
|
||||
|
||||
func counterName(userID int, direction string) string {
|
||||
return fmt.Sprintf("user>>>%s>>>traffic>>>%s", userEmail(userID), direction)
|
||||
}
|
||||
|
||||
func newStatsBackedXray(t *testing.T, users []model.UserSpec, ld *LimitDispatcher) (*Xray, xraystats.Manager) {
|
||||
t.Helper()
|
||||
mgr := newStatsManager(t)
|
||||
inst := new(xrayCore.Instance)
|
||||
if err := inst.AddFeature(mgr); err != nil {
|
||||
t.Fatalf("Instance.AddFeature(stats) error = %v", err)
|
||||
}
|
||||
x := New(config.KernelConfig{Type: "xray"})
|
||||
x.instance = inst
|
||||
x.limitDispatcher = ld
|
||||
x.users = users
|
||||
x.cumTraffic = make(map[int][2]int64)
|
||||
x.running.Store(true)
|
||||
return x, mgr
|
||||
}
|
||||
|
||||
func TestXrayGetUserTrafficUsesBuiltInStatsAndDispatcherState(t *testing.T) {
|
||||
users := []model.UserSpec{
|
||||
{ID: 1, UUID: "uuid-1"},
|
||||
{ID: 2, UUID: "uuid-2"},
|
||||
}
|
||||
|
||||
ld := newTestDispatcher()
|
||||
email1 := userEmail(1)
|
||||
email2 := userEmail(2)
|
||||
ld.UpdateLimits(map[string]int{email1: 1, email2: 2}, nil, nil)
|
||||
ld.mu.Lock()
|
||||
ld.limitedIPs[email1] = map[string]int{"1.1.1.1": 1}
|
||||
ld.mu.Unlock()
|
||||
ref := &atomic.Int64{}
|
||||
ref.Store(1)
|
||||
ic := &ipCounter{}
|
||||
ic.ips.Store("2.2.2.2", ref)
|
||||
ld.unlimitedIPs.Store(email2, ic)
|
||||
ld.connCount.Store(7)
|
||||
|
||||
x, mgr := newStatsBackedXray(t, users, ld)
|
||||
up1 := mustRegisterCounter(t, mgr, counterName(1, "uplink"))
|
||||
down1 := mustRegisterCounter(t, mgr, counterName(1, "downlink"))
|
||||
up2 := mustRegisterCounter(t, mgr, counterName(2, "uplink"))
|
||||
down2 := mustRegisterCounter(t, mgr, counterName(2, "downlink"))
|
||||
up1.Add(100)
|
||||
down1.Add(200)
|
||||
up2.Add(50)
|
||||
down2.Add(80)
|
||||
|
||||
traffic, aliveIPs, connCount, err := x.GetUserTraffic(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserTraffic() error = %v", err)
|
||||
}
|
||||
if connCount != 7 {
|
||||
t.Fatalf("connCount = %d, want 7", connCount)
|
||||
}
|
||||
if got := traffic[1]; got != [2]int64{100, 200} {
|
||||
t.Fatalf("traffic[1] = %v, want [100 200]", got)
|
||||
}
|
||||
if got := traffic[2]; got != [2]int64{50, 80} {
|
||||
t.Fatalf("traffic[2] = %v, want [50 80]", got)
|
||||
}
|
||||
if !aliveIPs[1]["1.1.1.1"] {
|
||||
t.Fatal("expected limited-user IP to be reported")
|
||||
}
|
||||
if !aliveIPs[2]["2.2.2.2"] {
|
||||
t.Fatal("expected unlimited-user IP to be reported")
|
||||
}
|
||||
|
||||
up1.Add(30)
|
||||
down1.Add(40)
|
||||
traffic, _, _, err = x.GetUserTraffic(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("second GetUserTraffic() error = %v", err)
|
||||
}
|
||||
if got := traffic[1]; got != [2]int64{130, 240} {
|
||||
t.Fatalf("traffic[1] after second poll = %v, want [130 240]", got)
|
||||
}
|
||||
if got := traffic[2]; got != [2]int64{50, 80} {
|
||||
t.Fatalf("traffic[2] after second poll = %v, want [50 80]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestXrayUpdateDispatcherLimitsPropagatesDeviceMetadata(t *testing.T) {
|
||||
x := New(config.KernelConfig{Type: "xray"})
|
||||
ld := newTestDispatcher()
|
||||
x.limitDispatcher = ld
|
||||
|
||||
users := []model.UserSpec{
|
||||
{ID: 7, UUID: "uuid-7", DeviceLimit: 2, SpeedLimit: 12},
|
||||
{ID: 9, UUID: "uuid-9", DeviceLimit: 0, SpeedLimit: 0},
|
||||
}
|
||||
|
||||
x.updateDispatcherLimits(users)
|
||||
|
||||
if got := ld.deviceLimits[userEmail(7)]; got != 2 {
|
||||
t.Fatalf("deviceLimits[email] = %d, want 2", got)
|
||||
}
|
||||
if got := ld.deviceLimits["uuid-7"]; got != 2 {
|
||||
t.Fatalf("deviceLimits[uuid] = %d, want 2", got)
|
||||
}
|
||||
if _, ok := ld.deviceLimits[userEmail(9)]; ok {
|
||||
t.Fatal("unexpected device limit entry for unlimited user")
|
||||
}
|
||||
}
|
||||
|
||||
func TestXraySetSpeedLimitFuncUsesPatchedCorePath(t *testing.T) {
|
||||
x := New(config.KernelConfig{Type: "xray"})
|
||||
called := false
|
||||
|
||||
x.SetSpeedLimitFunc(func(string) *rate.Limiter {
|
||||
called = true
|
||||
return rate.NewLimiter(rate.Limit(1), 1)
|
||||
})
|
||||
|
||||
if called {
|
||||
t.Fatal("patched xray path should not consult external speed-limit callbacks")
|
||||
}
|
||||
|
||||
mu, err := toMemoryUser("vless", &model.NodeSpec{Protocol: "vless"}, model.UserSpec{ID: 1, UUID: "11111111-1111-1111-1111-111111111111", SpeedLimit: 8})
|
||||
if err != nil {
|
||||
t.Fatalf("toMemoryUser() error = %v", err)
|
||||
}
|
||||
if mu.Level != 0 {
|
||||
t.Fatalf("MemoryUser.Level = %d, want 0", mu.Level)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func TestXrayCapabilities(t *testing.T) {
|
||||
x := New(config.KernelConfig{Type: "xray"})
|
||||
caps := x.Capabilities()
|
||||
if !caps.PerUserSpeedLimit || !caps.DeviceLimit || !caps.BuiltInTrafficStats || !caps.AliveIPTracking {
|
||||
t.Fatalf("unexpected positive xray capabilities: %+v", caps)
|
||||
}
|
||||
if caps.ForceCloseConnection || caps.ForceCloseUser {
|
||||
t.Fatalf("unexpected force-close xray capabilities: %+v", caps)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func TestXrayUpdateBandwidthLimitsWritesPatchedCoreFeature(t *testing.T) {
|
||||
inst := new(xrayCore.Instance)
|
||||
bm := featurebandwidth.New()
|
||||
if err := inst.AddFeature(bm); err != nil {
|
||||
t.Fatalf("Instance.AddFeature(bandwidth) error = %v", err)
|
||||
}
|
||||
x := New(config.KernelConfig{Type: "xray"})
|
||||
x.instance = inst
|
||||
x.updateBandwidthLimits([]model.UserSpec{{ID: 1, UUID: "uuid-1", SpeedLimit: 8}})
|
||||
lim := bm.GetUserLimiter(userEmail(1))
|
||||
if lim == nil {
|
||||
t.Fatal("expected patched bandwidth feature to receive user limiter")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func TestXrayUpdateBandwidthLimitsUsesSpeedLimitFunc(t *testing.T) {
|
||||
inst := new(xrayCore.Instance)
|
||||
bm := featurebandwidth.New()
|
||||
if err := inst.AddFeature(bm); err != nil {
|
||||
t.Fatalf("Instance.AddFeature(bandwidth) error = %v", err)
|
||||
}
|
||||
x := New(config.KernelConfig{Type: "xray"})
|
||||
x.instance = inst
|
||||
x.users = []model.UserSpec{{ID: 1, UUID: "uuid-1", SpeedLimit: 8}}
|
||||
shared := rate.NewLimiter(7, 7)
|
||||
x.SetSpeedLimitFunc(func(uuid string) *rate.Limiter {
|
||||
if uuid != "uuid-1" {
|
||||
t.Fatalf("unexpected uuid: %s", uuid)
|
||||
}
|
||||
return shared
|
||||
})
|
||||
lim := bm.GetUserLimiter(userEmail(1))
|
||||
if lim != shared {
|
||||
t.Fatal("expected patched bandwidth feature to consume shared limiter from callback")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func TestXrayUpdateBandwidthLimitsFallsBackToUserSpeed(t *testing.T) {
|
||||
inst := new(xrayCore.Instance)
|
||||
bm := featurebandwidth.New()
|
||||
if err := inst.AddFeature(bm); err != nil {
|
||||
t.Fatalf("Instance.AddFeature(bandwidth) error = %v", err)
|
||||
}
|
||||
x := New(config.KernelConfig{Type: "xray"})
|
||||
x.instance = inst
|
||||
x.users = []model.UserSpec{{ID: 2, UUID: "uuid-2", SpeedLimit: 16}}
|
||||
x.updateBandwidthLimits(nil)
|
||||
if bm.GetUserLimiter(userEmail(2)) == nil {
|
||||
t.Fatal("expected fallback limiter derived from user speed")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func TestXrayUpdateUsersLimitOnlyRefreshesDispatcherAndBandwidth(t *testing.T) {
|
||||
x := New(config.KernelConfig{Type: "xray"})
|
||||
ld := newTestDispatcher()
|
||||
inst := new(xrayCore.Instance)
|
||||
bm := featurebandwidth.New()
|
||||
if err := inst.AddFeature(bm); err != nil {
|
||||
t.Fatalf("Instance.AddFeature(bandwidth) error = %v", err)
|
||||
}
|
||||
x.instance = inst
|
||||
x.limitDispatcher = ld
|
||||
x.users = []model.UserSpec{{ID: 1, UUID: "uuid-1", DeviceLimit: 1, SpeedLimit: 8}}
|
||||
|
||||
added, removed, err := x.UpdateUsers([]model.UserSpec{{ID: 1, UUID: "uuid-1", DeviceLimit: 3, SpeedLimit: 16}})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateUsers() error = %v", err)
|
||||
}
|
||||
if added != 0 || removed != 0 {
|
||||
t.Fatalf("UpdateUsers() counts = (%d, %d), want (0, 0)", added, removed)
|
||||
}
|
||||
if got := ld.deviceLimits[userEmail(1)]; got != 3 {
|
||||
t.Fatalf("device limit after refresh = %d, want 3", got)
|
||||
}
|
||||
if bm.GetUserLimiter(userEmail(1)) == nil {
|
||||
t.Fatal("expected bandwidth limiter to be refreshed for unchanged user set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestXrayRemoveUsersStopsKernelWhenLastUserRemoved(t *testing.T) {
|
||||
x := New(config.KernelConfig{Type: "xray"})
|
||||
x.instance = new(xrayCore.Instance)
|
||||
ld := newTestDispatcher()
|
||||
x.limitDispatcher = ld
|
||||
x.users = []model.UserSpec{{ID: 1, UUID: "uuid-1"}}
|
||||
x.running.Store(true)
|
||||
|
||||
removed, err := x.RemoveUsers([]model.UserSpec{{ID: 1, UUID: "uuid-1"}})
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveUsers() error = %v", err)
|
||||
}
|
||||
if removed != 1 {
|
||||
t.Fatalf("removed = %d, want 1", removed)
|
||||
}
|
||||
if x.IsRunning() {
|
||||
t.Fatal("expected xray to stop when last user is removed")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user