feat: add standalone controlplane support
This commit is contained in:
@@ -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
|
||||
// }
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user