feat: add standalone controlplane support

This commit is contained in:
xboard
2026-04-11 05:36:59 +08:00
parent e9672fbdbe
commit 598b6b531b
34 changed files with 2383 additions and 707 deletions
+3 -3
View File
@@ -3,11 +3,11 @@ package kernel
import (
"strings"
"github.com/cedar2025/xboard-node/internal/panel"
"github.com/cedar2025/xboard-node/internal/model"
)
// NeedsGeoIP returns true when any panel route rule contains a "geoip:" match entry.
func NeedsGeoIP(routes []panel.RouteRule) bool {
func NeedsGeoIP(routes []model.RouteRule) bool {
for _, r := range routes {
for _, m := range r.Match {
if strings.HasPrefix(m, "geoip:") {
@@ -19,7 +19,7 @@ func NeedsGeoIP(routes []panel.RouteRule) bool {
}
// NeedsGeoSite returns true when any panel route rule contains a "geosite:" match entry.
func NeedsGeoSite(routes []panel.RouteRule) bool {
func NeedsGeoSite(routes []model.RouteRule) bool {
for _, r := range routes {
for _, m := range r.Match {
if strings.HasPrefix(m, "geosite:") {
+22 -11
View File
@@ -7,10 +7,19 @@ import (
"fmt"
"sort"
"github.com/cedar2025/xboard-node/internal/panel"
"github.com/cedar2025/xboard-node/internal/model"
"golang.org/x/time/rate"
)
type Capabilities struct {
PerUserSpeedLimit bool
DeviceLimit bool
BuiltInTrafficStats bool
AliveIPTracking bool
ForceCloseConnection bool
ForceCloseUser bool
}
// Kernel is the interface for proxy kernel backends (sing-box, xray, etc.).
//
// The interface is split into lifecycle, user management, and observability
@@ -25,12 +34,14 @@ type Kernel interface {
Name() string
// Protocols returns the protocol names this kernel supports.
Protocols() []string
// Capabilities returns the explicit runtime semantics supported by the kernel.
Capabilities() Capabilities
// ─── 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
Start(nodeConfig *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) error
// Stop gracefully shuts down the kernel, draining active connections.
Stop()
// IsRunning returns whether the kernel is currently accepting connections.
@@ -38,21 +49,21 @@ type Kernel interface {
// 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
Reload(nodeConfig *model.NodeSpec, users []model.UserSpec, 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)
AddUsers(users []model.UserSpec) (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)
RemoveUsers(users []model.UserSpec) (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)
UpdateUsers(users []model.UserSpec) (added, removed int, err error)
// ─── Observability ──────────────────────────────────────────────────
// GetUserTraffic returns per-user cumulative traffic counters and
@@ -81,11 +92,11 @@ type Kernel interface {
// ComputeHash returns a hash of config + user identities that would
// require a kernel restart/reconstruction if changed.
func ComputeHash(nc *panel.NodeConfig, users []panel.User) string {
func ComputeHash(nc *model.NodeSpec, users []model.UserSpec) string {
h := sha256.New()
configData, _ := json.Marshal(nc)
h.Write(configData)
sorted := make([]panel.User, len(users))
sorted := make([]model.UserSpec, len(users))
copy(sorted, users)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].ID < sorted[j].ID })
for _, u := range sorted {
@@ -97,12 +108,12 @@ func ComputeHash(nc *panel.NodeConfig, users []panel.User) string {
// 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))
func UserDiff(oldUsers, newUsers []model.UserSpec) (toAdd, toRemove []model.UserSpec) {
oldMap := make(map[int]model.UserSpec, len(oldUsers))
for _, u := range oldUsers {
oldMap[u.ID] = u
}
newMap := make(map[int]panel.User, len(newUsers))
newMap := make(map[int]model.UserSpec, len(newUsers))
for _, u := range newUsers {
newMap[u.ID] = u
}
+24 -22
View File
@@ -10,15 +10,15 @@ 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"
"github.com/go-viper/mapstructure/v2"
)
// 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)
@@ -80,7 +80,7 @@ func buildConfig(kcfg config.KernelConfig, nc *panel.NodeConfig, users []panel.U
// outboundConfigToSingbox converts a structured OutboundConfig (from the panel)
// into a sing-box outbound object. sing-box uses a flat layout where all
// protocol-specific fields sit at the top level alongside "type" and "tag".
func outboundConfigToSingbox(oc panel.OutboundConfig) M {
func outboundConfigToSingbox(oc model.OutboundConfig) M {
m := M{
"type": oc.Protocol,
"tag": oc.Tag,
@@ -144,7 +144,7 @@ func mergeRouteList(a, b []map[string]any) []map[string]any {
return res
}
func buildRoutes(panelRoutes []panel.RouteRule, custom []map[string]any) M {
func buildRoutes(panelRoutes []model.RouteRule, custom []map[string]any) M {
var rules []M
// Custom Routes (Panel-pushed or Local) go FIRST to have highest priority
@@ -326,7 +326,7 @@ func mergeCustomSingboxRoute(cfg M, customRoute map[string]any) {
}
}
func buildInbound(nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
func buildInbound(nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M {
base := M{
"tag": nc.Protocol + "-in",
"listen": "::",
@@ -361,7 +361,7 @@ func buildInbound(nc *panel.NodeConfig, users []panel.User, certFile, keyFile st
}
}
func buildMieru(base M, nc *panel.NodeConfig, users []panel.User) M {
func buildMieru(base M, nc *model.NodeSpec, users []model.UserSpec) M {
base["type"] = "mieru"
if nc.Transport != "" {
base["transport"] = nc.Transport
@@ -392,7 +392,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 {
base["type"] = "shadowsocks"
base["method"] = nc.Cipher
@@ -433,7 +433,7 @@ func buildShadowsocks(base M, nc *panel.NodeConfig, users []panel.User) M {
return base
}
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 {
base["type"] = "vmess"
userList := make([]M, 0, len(users))
@@ -459,7 +459,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 {
if nc.Decryption != "" && nc.Decryption != "none" {
nlog.Core().Warn("sing-box does not support VLESS encryption (decryption), use xray kernel for this feature")
}
@@ -494,7 +494,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 {
base["type"] = "trojan"
userList := make([]M, len(users))
@@ -533,7 +533,7 @@ func buildTrojan(base M, nc *panel.NodeConfig, users []panel.User, certFile, key
return base
}
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 {
base["type"] = "hysteria2"
@@ -584,7 +584,7 @@ func buildHysteria(base M, nc *panel.NodeConfig, users []panel.User, certFile, k
return base
}
func buildTUIC(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
func buildTUIC(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M {
base["type"] = "tuic"
userList := make([]M, 0, len(users))
@@ -614,7 +614,7 @@ func buildTUIC(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFi
return base
}
func buildAnyTLS(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
func buildAnyTLS(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M {
base["type"] = "anytls"
userList := make([]M, 0, len(users))
@@ -627,7 +627,7 @@ func buildAnyTLS(base M, nc *panel.NodeConfig, users []panel.User, certFile, key
base["users"] = userList
if nc.PaddingScheme != "" {
base["padding_scheme"] = string(nc.PaddingScheme)
base["padding_scheme"] = nc.PaddingScheme
}
if tls := buildTLSConfig(nc, certFile, keyFile); tls != nil {
@@ -638,7 +638,7 @@ func buildAnyTLS(base M, nc *panel.NodeConfig, users []panel.User, certFile, key
return base
}
func buildNaive(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
func buildNaive(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M {
base["type"] = "naive"
userList := make([]M, 0, len(users))
@@ -658,7 +658,7 @@ func buildNaive(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyF
return base
}
func buildSocks(base M, users []panel.User) M {
func buildSocks(base M, users []model.UserSpec) M {
base["type"] = "socks"
userList := make([]M, 0, len(users))
@@ -672,7 +672,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["type"] = "http"
userList := make([]M, 0, len(users))
@@ -693,7 +693,7 @@ func buildHTTP(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFi
return base
}
func applyTransport(base M, nc *panel.NodeConfig) {
func applyTransport(base M, nc *model.NodeSpec) {
if nc.Network == "" || nc.Network == "tcp" {
return
}
@@ -723,6 +723,8 @@ func applyTransport(base M, nc *panel.NodeConfig) {
case "grpc":
if v, ok := nc.NetworkSettings["serviceName"]; ok {
transport["service_name"] = v
} else if v, ok := nc.NetworkSettings["service_name"]; ok {
transport["service_name"] = v
}
case "httpupgrade":
if v, ok := nc.NetworkSettings["path"]; ok {
@@ -749,7 +751,7 @@ func applyTransport(base M, nc *panel.NodeConfig) {
// If either path is missing, it returns nil: the inbound should run without a TLS layer
// (e.g. TLS terminated at nginx/CDN while the panel still shows tls=1).
// Sing-box does not accept a magic "self-signed" path — it tries to open that name as a file.
func buildTLSConfig(nc *panel.NodeConfig, certFile, keyFile string) M {
func buildTLSConfig(nc *model.NodeSpec, certFile, keyFile string) M {
if certFile == "" || keyFile == "" {
return nil
}
@@ -779,7 +781,7 @@ func buildTLSConfig(nc *panel.NodeConfig, certFile, keyFile string) M {
return tls
}
func buildRealityConfig(nc *panel.NodeConfig) M {
func buildRealityConfig(nc *model.NodeSpec) M {
tls := M{"enabled": true}
if nc.TLSSettings == nil {
return tls
@@ -845,7 +847,7 @@ func buildRealityConfig(nc *panel.NodeConfig) M {
return tls
}
func applyMultiplex(base M, nc *panel.NodeConfig) {
func applyMultiplex(base M, nc *model.NodeSpec) {
if nc.Multiplex == nil || !nc.Multiplex.Enabled {
return
}
@@ -873,7 +875,7 @@ func applyMultiplex(base M, nc *panel.NodeConfig) {
base["multiplex"] = mux
}
func applyProxyProtocol(base M, nc *panel.NodeConfig) {
func applyProxyProtocol(base M, nc *model.NodeSpec) {
// if !nc.GetProxyProtocol() {
// return
// }
+68 -41
View File
@@ -5,23 +5,35 @@ import (
"testing"
"github.com/cedar2025/xboard-node/internal/config"
"github.com/cedar2025/xboard-node/internal/model"
"github.com/cedar2025/xboard-node/internal/panel"
)
var testUsers = []panel.User{
var testUsersPanel = []panel.User{
{ID: 1, UUID: "aaaaaaaa-1111-2222-3333-444444444444", SpeedLimit: 0, DeviceLimit: 0},
{ID: 2, UUID: "bbbbbbbb-5555-6666-7777-888888888888", SpeedLimit: 3, DeviceLimit: 2},
}
// --- Shadowsocks ---
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 TestBuildInbound_Shadowsocks(t *testing.T) {
nc := &panel.NodeConfig{
Protocol: "shadowsocks",
ServerPort: 111,
Cipher: "aes-128-gcm",
}
inbound := buildInbound(nc, testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
assertMapValue(t, inbound, "type", "shadowsocks")
assertMapValue(t, inbound, "method", "aes-128-gcm")
assertMapValue(t, inbound, "listen_port", 111)
@@ -41,7 +53,7 @@ func TestBuildInbound_Shadowsocks2022(t *testing.T) {
Cipher: "2022-blake3-aes-128-gcm",
ServerKey: "base64serverkey==",
}
inbound := buildInbound(nc, testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
assertMapValue(t, inbound, "method", "2022-blake3-aes-128-gcm")
assertMapValue(t, inbound, "password", "base64serverkey==")
}
@@ -53,7 +65,7 @@ func TestBuildInbound_VMess(t *testing.T) {
Protocol: "vmess",
ServerPort: 443,
}
inbound := buildInbound(nc, testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
assertMapValue(t, inbound, "type", "vmess")
assertMapValue(t, inbound, "tag", "vmess-in")
@@ -69,7 +81,7 @@ func TestBuildInbound_VMess_WithTLS(t *testing.T) {
TLS: 1,
ServerName: "example.com",
}
inbound := buildInbound(nc, testUsers, "/path/cert.pem", "/path/key.pem")
inbound := buildInbound(testNodeSpec(nc), testUsers, "/path/cert.pem", "/path/key.pem")
tls := inbound["tls"].(M)
assertMapValue(t, tls, "enabled", true)
assertMapValue(t, tls, "server_name", "example.com")
@@ -86,7 +98,7 @@ func TestBuildInbound_VMess_TLS_PanelOn_NoCertFiles(t *testing.T) {
TLS: 1,
ServerName: "example.com",
}
inbound := buildInbound(nc, testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
if _, exists := inbound["tls"]; exists {
t.Fatal("expected no tls block when cert/key paths are empty (nginx offload)")
}
@@ -98,7 +110,7 @@ func TestBuildInbound_VMess_NoTLS(t *testing.T) {
ServerPort: 80,
TLS: 0,
}
inbound := buildInbound(nc, testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
if _, exists := inbound["tls"]; exists {
t.Error("should not have TLS when tls=0")
}
@@ -116,7 +128,7 @@ func TestBuildInbound_VMess_WithWebSocket(t *testing.T) {
},
},
}
inbound := buildInbound(nc, testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
transport := inbound["transport"].(M)
assertMapValue(t, transport, "type", "ws")
assertMapValue(t, transport, "path", "/ws")
@@ -131,12 +143,27 @@ func TestBuildInbound_VMess_WithGRPC(t *testing.T) {
"serviceName": "mygrpc",
},
}
inbound := buildInbound(nc, testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
transport := inbound["transport"].(M)
assertMapValue(t, transport, "type", "grpc")
assertMapValue(t, transport, "service_name", "mygrpc")
}
func TestBuildInbound_VMess_WithGRPCSnakeCaseServiceName(t *testing.T) {
nc := &panel.NodeConfig{
Protocol: "vmess",
ServerPort: 443,
Network: "grpc",
NetworkSettings: map[string]interface{}{
"service_name": "mygrpc-snake",
},
}
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
transport := inbound["transport"].(M)
assertMapValue(t, transport, "type", "grpc")
assertMapValue(t, transport, "service_name", "mygrpc-snake")
}
func TestBuildInbound_VMess_WithH2(t *testing.T) {
nc := &panel.NodeConfig{
Protocol: "vmess",
@@ -147,7 +174,7 @@ func TestBuildInbound_VMess_WithH2(t *testing.T) {
"host": "example.com",
},
}
inbound := buildInbound(nc, testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
transport := inbound["transport"].(M)
assertMapValue(t, transport, "type", "http")
assertMapValue(t, transport, "path", "/h2path")
@@ -163,7 +190,7 @@ func TestBuildInbound_VMess_WithHTTPUpgrade(t *testing.T) {
"host": "example.com",
},
}
inbound := buildInbound(nc, testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
transport := inbound["transport"].(M)
assertMapValue(t, transport, "type", "httpupgrade")
assertMapValue(t, transport, "path", "/upgrade")
@@ -177,7 +204,7 @@ func TestBuildInbound_VLESS(t *testing.T) {
Protocol: "vless",
ServerPort: 443,
}
inbound := buildInbound(nc, testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
assertMapValue(t, inbound, "type", "vless")
users := inbound["users"].([]M)
@@ -190,7 +217,7 @@ func TestBuildInbound_VLESS_WithFlow(t *testing.T) {
ServerPort: 443,
Flow: "xtls-rprx-vision",
}
inbound := buildInbound(nc, testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
users := inbound["users"].([]M)
assertMapValue(t, users[0], "flow", "xtls-rprx-vision")
}
@@ -207,7 +234,7 @@ func TestBuildInbound_VLESS_Reality(t *testing.T) {
"server_name": "www.example.com",
},
}
inbound := buildInbound(nc, testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
tls := inbound["tls"].(M)
assertMapValue(t, tls, "enabled", true)
@@ -231,7 +258,7 @@ func TestBuildInbound_VLESS_Reality_ShortIDArray(t *testing.T) {
"dest": "example.com",
},
}
inbound := buildInbound(nc, testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
reality := inbound["tls"].(M)["reality"].(M)
ids := reality["short_id"].([]string)
if len(ids) != 2 {
@@ -246,7 +273,7 @@ func TestBuildInbound_Trojan(t *testing.T) {
Protocol: "trojan",
ServerPort: 443,
}
inbound := buildInbound(nc, testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
assertMapValue(t, inbound, "type", "trojan")
users := inbound["users"].([]M)
@@ -259,7 +286,7 @@ func TestBuildInbound_Trojan_NoTLS(t *testing.T) {
ServerPort: 80,
TLS: 0,
}
inbound := buildInbound(nc, testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
// Without certificate paths there is no TLS layer; sing-box trojan needs certs or Reality.
if _, exists := inbound["tls"]; exists {
t.Fatal("expected no tls when cert paths are empty and tls=0")
@@ -273,7 +300,7 @@ func TestBuildInbound_Trojan_WithTLS(t *testing.T) {
TLS: 1,
ServerName: "example.com",
}
inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem")
inbound := buildInbound(testNodeSpec(nc), testUsers, "/c.pem", "/k.pem")
tls := inbound["tls"].(M)
assertMapValue(t, tls, "enabled", true)
}
@@ -286,7 +313,7 @@ func TestBuildInbound_Hysteria2(t *testing.T) {
ServerPort: 444,
Version: 2,
}
inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem")
inbound := buildInbound(testNodeSpec(nc), testUsers, "/c.pem", "/k.pem")
assertMapValue(t, inbound, "type", "hysteria2")
users := inbound["users"].([]M)
@@ -304,7 +331,7 @@ func TestBuildInbound_Hysteria2_WithObfs(t *testing.T) {
Obfs: "salamander",
ObfsPassword: "secret",
}
inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem")
inbound := buildInbound(testNodeSpec(nc), testUsers, "/c.pem", "/k.pem")
obfs := inbound["obfs"].(M)
assertMapValue(t, obfs, "type", "salamander")
assertMapValue(t, obfs, "password", "secret")
@@ -318,7 +345,7 @@ func TestBuildInbound_Hysteria1(t *testing.T) {
UpMbps: 100,
DownMbps: 200,
}
inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem")
inbound := buildInbound(testNodeSpec(nc), testUsers, "/c.pem", "/k.pem")
assertMapValue(t, inbound, "type", "hysteria")
assertMapValue(t, inbound, "up_mbps", 100)
assertMapValue(t, inbound, "down_mbps", 200)
@@ -335,7 +362,7 @@ func TestBuildInbound_TUIC(t *testing.T) {
ServerPort: 555,
CongestionControl: "bbr",
}
inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem")
inbound := buildInbound(testNodeSpec(nc), testUsers, "/c.pem", "/k.pem")
assertMapValue(t, inbound, "type", "tuic")
assertMapValue(t, inbound, "congestion_control", "bbr")
@@ -352,7 +379,7 @@ func TestBuildInbound_AnyTLS(t *testing.T) {
ServerPort: 443,
PaddingScheme: "stop=8\n0=30-30",
}
inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem")
inbound := buildInbound(testNodeSpec(nc), testUsers, "/c.pem", "/k.pem")
assertMapValue(t, inbound, "type", "anytls")
assertMapValue(t, inbound, "padding_scheme", "stop=8\n0=30-30")
@@ -365,7 +392,7 @@ func TestBuildInbound_AnyTLS_NoPaddingScheme(t *testing.T) {
Protocol: "anytls",
ServerPort: 443,
}
inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem")
inbound := buildInbound(testNodeSpec(nc), testUsers, "/c.pem", "/k.pem")
if _, exists := inbound["padding_scheme"]; exists {
t.Error("should not have padding_scheme when empty")
}
@@ -379,7 +406,7 @@ func TestBuildInbound_Naive(t *testing.T) {
ServerPort: 443,
TLS: 1,
}
inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem")
inbound := buildInbound(testNodeSpec(nc), testUsers, "/c.pem", "/k.pem")
assertMapValue(t, inbound, "type", "naive")
users := inbound["users"].([]M)
@@ -397,7 +424,7 @@ func TestBuildInbound_Socks(t *testing.T) {
Protocol: "socks",
ServerPort: 1080,
}
inbound := buildInbound(nc, testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
assertMapValue(t, inbound, "type", "socks")
users := inbound["users"].([]M)
@@ -416,7 +443,7 @@ func TestBuildInbound_HTTP(t *testing.T) {
Protocol: "http",
ServerPort: 8080,
}
inbound := buildInbound(nc, testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
assertMapValue(t, inbound, "type", "http")
users := inbound["users"].([]M)
@@ -429,7 +456,7 @@ func TestBuildInbound_HTTP_WithTLS(t *testing.T) {
ServerPort: 443,
TLS: 1,
}
inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem")
inbound := buildInbound(testNodeSpec(nc), testUsers, "/c.pem", "/k.pem")
tls := inbound["tls"].(M)
assertMapValue(t, tls, "enabled", true)
}
@@ -441,7 +468,7 @@ func TestBuildInbound_Unknown(t *testing.T) {
Protocol: "unknown-proto",
ServerPort: 999,
}
inbound := buildInbound(nc, testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
if inbound != nil {
t.Errorf("unknown protocol should return nil, got %v", inbound)
}
@@ -456,7 +483,7 @@ func TestBuildConfig(t *testing.T) {
ServerPort: 111,
Cipher: "aes-128-gcm",
}
cfg := buildConfig(kcfg, nc, testUsers, "", "")
cfg := buildConfig(kcfg, testNodeSpec(nc), testUsers, "", "")
data, err := json.Marshal(cfg)
if err != nil {
@@ -500,7 +527,7 @@ func TestBuildConfig_OutboundPriority(t *testing.T) {
},
}
cfg := buildConfig(kcfg, nc, testUsers, "", "")
cfg := buildConfig(kcfg, testNodeSpec(nc), testUsers, "", "")
outbounds := cfg["outbounds"].([]M)
// We have 2 overrides in input, so we should have exactly 2 outbounds total
@@ -553,7 +580,7 @@ func TestBuildConfig_AllProtocols_ValidJSON(t *testing.T) {
for _, tc := range protocols {
t.Run(tc.name, func(t *testing.T) {
cfg := buildConfig(config.KernelConfig{LogLevel: "warn"}, tc.nc, testUsers, "/c.pem", "/k.pem")
cfg := buildConfig(config.KernelConfig{LogLevel: "warn"}, testNodeSpec(tc.nc), testUsers, "/c.pem", "/k.pem")
data, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal %s: %v", tc.name, err)
@@ -586,7 +613,7 @@ func TestBuildRoutes_WithCustomRules(t *testing.T) {
{ID: 2, Match: []string{"10.0.0.0/8"}, Action: "block"},
{ID: 3, Match: []string{"allowed.com"}, Action: "direct"},
}
route := buildRoutes(rules, nil)
route := buildRoutes(testRouteRules(rules), nil)
allRules := route["rules"].([]M)
if len(allRules) != 5 {
@@ -611,7 +638,7 @@ func TestBuildRoutes_MultiMatch(t *testing.T) {
{ID: 1, Match: []string{"*.evil.com", "bad.org", "192.168.1.0/24"}, Action: "block"},
{ID: 2, Match: []string{"*.bypass.com"}, Action: "direct"},
}
route := buildRoutes(rules, nil)
route := buildRoutes(testRouteRules(rules), nil)
allRules := route["rules"].([]M)
// 2 default private-IP rules + 1 domain rule + 1 CIDR rule + 1 domain rule = 5
@@ -645,7 +672,7 @@ func TestBuildRoutes_MultiMatch(t *testing.T) {
func TestBuildTLSConfig_WithCert(t *testing.T) {
nc := &panel.NodeConfig{ServerName: "example.com"}
tls := buildTLSConfig(nc, "/cert.pem", "/key.pem")
tls := buildTLSConfig(testNodeSpec(nc), "/cert.pem", "/key.pem")
assertMapValue(t, tls, "enabled", true)
assertMapValue(t, tls, "server_name", "example.com")
assertMapValue(t, tls, "certificate_path", "/cert.pem")
@@ -654,7 +681,7 @@ func TestBuildTLSConfig_WithCert(t *testing.T) {
func TestBuildTLSConfig_NoCert(t *testing.T) {
nc := &panel.NodeConfig{ServerName: "example.com"}
tls := buildTLSConfig(nc, "", "")
tls := buildTLSConfig(testNodeSpec(nc), "", "")
if tls != nil {
t.Fatalf("expected nil tls config when cert paths are empty, got %+v", tls)
}
@@ -662,7 +689,7 @@ func TestBuildTLSConfig_NoCert(t *testing.T) {
func TestBuildTLSConfig_FallbackToHost(t *testing.T) {
nc := &panel.NodeConfig{Host: "fallback.com"}
tls := buildTLSConfig(nc, "/c.pem", "/k.pem")
tls := buildTLSConfig(testNodeSpec(nc), "/c.pem", "/k.pem")
assertMapValue(t, tls, "server_name", "fallback.com")
}
@@ -673,7 +700,7 @@ func TestBuildTLSConfig_TLSSettingsOverride(t *testing.T) {
"server_name": "override.com",
},
}
tls := buildTLSConfig(nc, "/c.pem", "/k.pem")
tls := buildTLSConfig(testNodeSpec(nc), "/c.pem", "/k.pem")
assertMapValue(t, tls, "server_name", "override.com")
}
@@ -682,7 +709,7 @@ func TestBuildTLSConfig_TLSSettingsOverride(t *testing.T) {
func TestApplyTransport_TCP(t *testing.T) {
base := M{}
nc := &panel.NodeConfig{Network: "tcp"}
applyTransport(base, nc)
applyTransport(base, testNodeSpec(nc))
if _, exists := base["transport"]; exists {
t.Error("tcp should not add transport")
}
@@ -691,7 +718,7 @@ func TestApplyTransport_TCP(t *testing.T) {
func TestApplyTransport_Empty(t *testing.T) {
base := M{}
nc := &panel.NodeConfig{Network: ""}
applyTransport(base, nc)
applyTransport(base, testNodeSpec(nc))
if _, exists := base["transport"]; exists {
t.Error("empty network should not add transport")
}
@@ -707,7 +734,7 @@ func TestApplyTransport_WS_MaxEarlyData(t *testing.T) {
"early_data_header_name": "Sec-WebSocket-Protocol",
},
}
applyTransport(base, nc)
applyTransport(base, testNodeSpec(nc))
transport := base["transport"].(M)
assertMapValue(t, transport, "max_early_data", 2048)
assertMapValue(t, transport, "early_data_header_name", "Sec-WebSocket-Protocol")
+32 -13
View File
@@ -19,8 +19,8 @@ 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"
)
// drainTimeout is how long stop() waits for in-flight connections to finish
@@ -40,8 +40,8 @@ type SingBox struct {
ctx context.Context
cancel context.CancelFunc
users []panel.User
nodeConfig *panel.NodeConfig
users []model.UserSpec
nodeConfig *model.NodeSpec
certFile string
keyFile string
@@ -71,6 +71,17 @@ var _ kernel.Kernel = (*SingBox)(nil)
func (s *SingBox) Name() string { return "sing-box" }
func (s *SingBox) Capabilities() kernel.Capabilities {
return kernel.Capabilities{
PerUserSpeedLimit: true,
DeviceLimit: true,
BuiltInTrafficStats: false,
AliveIPTracking: true,
ForceCloseConnection: false,
ForceCloseUser: true,
}
}
func (s *SingBox) Protocols() []string {
return []string{
"vmess", "vless", "trojan", "shadowsocks",
@@ -78,7 +89,7 @@ func (s *SingBox) Protocols() []string {
}
}
func (s *SingBox) Start(nodeConfig *panel.NodeConfig, users []panel.User, certFile, keyFile string) error {
func (s *SingBox) Start(nodeConfig *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -182,7 +193,7 @@ func recycleOldBox(oldBox *box.Box, oldCancel context.CancelFunc, oldCtx context
// Reload hot-swaps the inbound users and routing rules without restarting the box.
// Routes, outbounds, and the connTracker stay alive so in-flight connections
// continue to be tracked correctly.
func (s *SingBox) Reload(nodeConfig *panel.NodeConfig, users []panel.User, certFile, keyFile string) error {
func (s *SingBox) Reload(nodeConfig *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -257,6 +268,10 @@ func (s *SingBox) Reload(nodeConfig *panel.NodeConfig, users []panel.User, certF
if opts, ok := inb.Options.(*option.AnyTLSInboundOptions); ok {
err = v.UpdateUsers(opts.Users)
}
case adapter.UpdatableInbound[option.MieruUser]:
if opts, ok := inb.Options.(*option.MieruInboundOptions); ok {
err = v.UpdateUsers(opts.Users)
}
case adapter.UpdatableInbound[auth.User]:
switch opts := inb.Options.(type) {
case *option.NaiveInboundOptions:
@@ -413,7 +428,7 @@ func (s *SingBox) ClearGlobalDevices() {
// ─── User management (non-disruptive) ───────────────────────────────────────
// AddUsers hot-swaps users into running inbounds. Zero connection disruption.
func (s *SingBox) AddUsers(users []panel.User) (int, error) {
func (s *SingBox) AddUsers(users []model.UserSpec) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -425,7 +440,7 @@ func (s *SingBox) AddUsers(users []panel.User) (int, error) {
for _, u := range s.users {
existing[u.ID] = struct{}{}
}
var toAdd []panel.User
var toAdd []model.UserSpec
for _, u := range users {
if _, dup := existing[u.ID]; !dup {
toAdd = append(toAdd, u)
@@ -435,7 +450,7 @@ func (s *SingBox) AddUsers(users []panel.User) (int, error) {
return 0, nil
}
merged := append(append([]panel.User{}, s.users...), toAdd...)
merged := append(append([]model.UserSpec{}, s.users...), toAdd...)
if err := s.reloadInboundsLocked(merged); err != nil {
return 0, err
}
@@ -445,7 +460,7 @@ func (s *SingBox) AddUsers(users []panel.User) (int, error) {
// RemoveUsers hot-swaps users out of running inbounds. Zero connection disruption
// for remaining users.
func (s *SingBox) RemoveUsers(users []panel.User) (int, error) {
func (s *SingBox) RemoveUsers(users []model.UserSpec) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -457,7 +472,7 @@ func (s *SingBox) 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 s.users {
if _, rm := removeSet[u.ID]; rm {
@@ -478,7 +493,7 @@ func (s *SingBox) RemoveUsers(users []panel.User) (int, error) {
}
// UpdateUsers replaces the entire user set atomically via hot-swap.
func (s *SingBox) UpdateUsers(users []panel.User) (added, removed int, err error) {
func (s *SingBox) UpdateUsers(users []model.UserSpec) (added, removed int, err error) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -507,7 +522,7 @@ func (s *SingBox) UpdateUsers(users []panel.User) (added, removed int, err error
// reloadInboundsLocked hot-swaps inbound users using UpdatableInbound.
// Must be called with s.mu held.
func (s *SingBox) reloadInboundsLocked(users []panel.User) error {
func (s *SingBox) reloadInboundsLocked(users []model.UserSpec) error {
cfgMap := buildConfig(s.cfg, s.nodeConfig, users, s.certFile, s.keyFile)
data, err := json.Marshal(cfgMap)
if err != nil {
@@ -564,6 +579,10 @@ func (s *SingBox) reloadInboundsLocked(users []panel.User) error {
if opts, ok := inb.Options.(*option.AnyTLSInboundOptions); ok {
err = v.UpdateUsers(opts.Users)
}
case adapter.UpdatableInbound[option.MieruUser]:
if opts, ok := inb.Options.(*option.MieruInboundOptions); ok {
err = v.UpdateUsers(opts.Users)
}
case adapter.UpdatableInbound[auth.User]:
switch opts := inb.Options.(type) {
case *option.NaiveInboundOptions:
@@ -630,7 +649,7 @@ func (s *SingBox) CloseUserConnections(_ context.Context, uuid string) error {
// buildUserMap creates a UUID→userID mapping used by ConnTracker to attribute
// connections to the correct user. All sing-box protocols use the user's UUID
// as the inbound name/username, so this covers every protocol.
func buildUserMap(users []panel.User) map[string]int {
func buildUserMap(users []model.UserSpec) map[string]int {
m := make(map[string]int, len(users))
for _, u := range users {
m[u.UUID] = u.ID
+204
View File
@@ -0,0 +1,204 @@
package singbox
import (
"context"
"errors"
"net"
"net/netip"
"testing"
"time"
"github.com/cedar2025/xboard-node/internal/config"
"github.com/sagernet/sing-box/adapter"
singM "github.com/sagernet/sing/common/metadata"
"golang.org/x/time/rate"
)
func TestSingBoxCapabilities(t *testing.T) {
s := New(config.KernelConfig{Type: "sing-box"})
caps := s.Capabilities()
if !caps.PerUserSpeedLimit || !caps.DeviceLimit || !caps.AliveIPTracking || !caps.ForceCloseUser {
t.Fatalf("unexpected sing-box capabilities: %+v", caps)
}
if caps.BuiltInTrafficStats || caps.ForceCloseConnection {
t.Fatalf("unexpected sing-box capabilities: %+v", caps)
}
}
type testConn struct {
closed bool
reads [][]byte
writes [][]byte
}
func (c *testConn) Read(b []byte) (int, error) {
if len(c.reads) == 0 {
return 0, errors.New("eof")
}
chunk := c.reads[0]
c.reads = c.reads[1:]
n := copy(b, chunk)
return n, nil
}
func (c *testConn) Write(b []byte) (int, error) {
cp := append([]byte(nil), b...)
c.writes = append(c.writes, cp)
return len(b), nil
}
func (c *testConn) Close() error { c.closed = true; return nil }
func (c *testConn) LocalAddr() net.Addr { return &net.TCPAddr{} }
func (c *testConn) RemoteAddr() net.Addr { return &net.TCPAddr{} }
func (c *testConn) SetDeadline(time.Time) error { return nil }
func (c *testConn) SetReadDeadline(time.Time) error { return nil }
func (c *testConn) SetWriteDeadline(time.Time) error { return nil }
func testInboundContext(uuid, ip string) adapter.InboundContext {
return adapter.InboundContext{
User: uuid,
Source: singM.Socksaddr{Addr: netip.MustParseAddr(ip)},
}
}
func TestConnTrackerRoutedConnectionTracksTrafficAndAliveIPs(t *testing.T) {
tracker := NewConnTracker(0)
tracker.SetUserMap(map[string]int{"uuid-1": 1})
base := &testConn{reads: [][]byte{[]byte("hello")}}
wrapped := tracker.RoutedConnection(context.Background(), base, testInboundContext("uuid-1", "1.1.1.1"), nil, nil)
buf := make([]byte, 16)
n, err := wrapped.Read(buf)
if err != nil {
t.Fatalf("Read() error = %v", err)
}
if n != 5 {
t.Fatalf("Read() bytes = %d, want 5", n)
}
if _, err := wrapped.Write([]byte("bye")); err != nil {
t.Fatalf("Write() error = %v", err)
}
traffic, aliveIPs, connCount := tracker.GetUserTraffic()
if got := traffic[1]; got != [2]int64{5, 3} {
t.Fatalf("traffic[1] = %v, want [5 3]", got)
}
if !aliveIPs[1]["1.1.1.1"] {
t.Fatal("expected alive IP to include source address")
}
if connCount != 1 {
t.Fatalf("connCount = %d, want 1", connCount)
}
if err := wrapped.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
_, aliveIPs, connCount = tracker.GetUserTraffic()
if aliveIPs[1] != nil {
t.Fatalf("aliveIPs after close = %v, want nil", aliveIPs[1])
}
if connCount != 0 {
t.Fatalf("connCount after close = %d, want 0", connCount)
}
}
func TestConnTrackerRoutedConnectionRejectsWhenDeviceLimitExceeded(t *testing.T) {
tracker := NewConnTracker(0)
tracker.SetUserMap(map[string]int{"uuid-1": 1})
tracker.SetDeviceLimitFunc(func(uuid string) (int, bool) {
if uuid != "uuid-1" {
return 0, false
}
return 1, true
})
first := &testConn{}
wrapped1 := tracker.RoutedConnection(context.Background(), first, testInboundContext("uuid-1", "1.1.1.1"), nil, nil)
if wrapped1 == first {
t.Fatal("expected first connection to be wrapped")
}
second := &testConn{}
wrapped2 := tracker.RoutedConnection(context.Background(), second, testInboundContext("uuid-1", "2.2.2.2"), nil, nil)
if wrapped2 != second {
t.Fatal("expected rejected connection to be returned unwrapped")
}
if !second.closed {
t.Fatal("expected rejected connection to be closed")
}
}
func TestConnTrackerCheckDeviceGateMergesFreshGlobalDevices(t *testing.T) {
tracker := NewConnTracker(0)
tracker.SetUserMap(map[string]int{"uuid-1": 1})
us := tracker.users[1]
us.addConn("1.1.1.1")
tracker.UpdateGlobalDevices(map[int][]string{1: {"9.9.9.9"}})
if tracker.checkDeviceGate(us, 1, "2.2.2.2", 2) {
t.Fatal("expected lexicographically earlier candidate to remain allowed")
}
if !tracker.checkDeviceGate(us, 1, "99.99.99.99", 2) {
t.Fatal("expected merged local+global device state to reject lexicographically later third device")
}
if tracker.checkDeviceGate(us, 1, "1.1.1.1", 2) {
t.Fatal("existing local IP should still be allowed")
}
if tracker.checkDeviceGate(us, 1, "9.9.9.9", 2) {
t.Fatal("existing global IP should still be allowed")
}
}
func TestConnTrackerCloseByIDClosesTrackedConnection(t *testing.T) {
tracker := NewConnTracker(0)
tracker.SetUserMap(map[string]int{"uuid-1": 1})
base := &testConn{}
wrapped := tracker.RoutedConnection(context.Background(), base, testInboundContext("uuid-1", "1.1.1.1"), nil, nil)
tracked, ok := wrapped.(*trackedConn)
if !ok {
t.Fatalf("wrapped type = %T, want *trackedConn", wrapped)
}
if !tracker.CloseByID(tracked.connID) {
t.Fatal("CloseByID() = false, want true")
}
if !base.closed {
t.Fatal("expected underlying connection to be closed")
}
}
func TestConnTrackerRateLimitHonorsContextCancellation(t *testing.T) {
tracker := NewConnTracker(0)
tracker.SetUserMap(map[string]int{"uuid-1": 1})
tracker.SetSpeedLimitFunc(func(uuid string) *rate.Limiter {
if uuid != "uuid-1" {
return nil
}
return rate.NewLimiter(rate.Limit(1), 1)
})
readCtx, cancelRead := context.WithCancel(context.Background())
readBase := &testConn{reads: [][]byte{[]byte("a"), []byte("a")}}
readTracked := tracker.RoutedConnection(readCtx, readBase, testInboundContext("uuid-1", "1.1.1.1"), nil, nil).(*trackedConn)
buf := make([]byte, 8)
if n, err := readTracked.Read(buf); err != nil || n != 1 {
t.Fatalf("first Read() = (%d, %v), want (1, nil)", n, err)
}
cancelRead()
if n, err := readTracked.Read(buf); !errors.Is(err, context.Canceled) || n != 1 {
t.Fatalf("second Read() = (%d, %v), want (1, context canceled)", n, err)
}
writeCtx, cancelWrite := context.WithCancel(context.Background())
writeBase := &testConn{}
writeTracked := tracker.RoutedConnection(writeCtx, writeBase, testInboundContext("uuid-1", "1.1.1.2"), nil, nil).(*trackedConn)
if n, err := writeTracked.Write([]byte("a")); err != nil || n != 1 {
t.Fatalf("first Write() = (%d, %v), want (1, nil)", n, err)
}
cancelWrite()
if n, err := writeTracked.Write([]byte("a")); !errors.Is(err, context.Canceled) || n != 0 {
t.Fatalf("second Write() = (%d, %v), want (0, context canceled)", n, err)
}
}
+16 -14
View File
@@ -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
+22 -10
View File
@@ -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{}
+28 -160
View File
@@ -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()
}
+68 -65
View File
@@ -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")
}
}
+95 -37
View File
@@ -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
+278
View File
@@ -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")
}
}