init xboard node

This commit is contained in:
xboard
2026-03-15 09:27:24 +08:00
commit 37e6c016cb
40 changed files with 11011 additions and 0 deletions
+710
View File
@@ -0,0 +1,710 @@
package singbox
import (
"fmt"
"log/slog"
"net"
"path/filepath"
"strconv"
"strings"
"github.com/cedar2025/xboard-node/internal/config"
"github.com/cedar2025/xboard-node/internal/kernel"
"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 {
outbounds := []M{
{"type": "direct", "tag": "direct"},
{"type": "block", "tag": "block"},
}
// Panel-defined custom outbounds (structured, converted to sing-box native)
for _, co := range nc.CustomOutbounds {
outbounds = append(outbounds, outboundConfigToSingbox(co))
}
// Static outbounds from local config file (already kernel-native format)
for _, co := range kcfg.CustomOutbound {
outbounds = append(outbounds, M(co))
}
cfg := M{
"log": M{
"level": kcfg.LogLevel,
"timestamp": true,
},
"outbounds": outbounds,
}
inbound := buildInbound(nc, users, certFile, keyFile)
if inbound != nil {
cfg["inbounds"] = []M{inbound}
}
// Merge panel routes and static config routes
cfg["route"] = buildRoutes(nc.Routes, mergeRouteList(nc.CustomRoutes, kcfg.CustomRoute))
// Automatically enable rule_set caching (cache_file) when panel routes
// reference geoip:/geosite: entries so that the downloaded .srs rule_set
// files survive across process restarts.
if kernel.NeedsGeoIP(nc.Routes) || kernel.NeedsGeoSite(nc.Routes) {
cfg["experimental"] = M{
"cache_file": M{
"enabled": true,
"path": filepath.Join(kcfg.ConfigDir, "cache.db"),
},
}
}
mergeCustomSingbox(cfg, kcfg)
return cfg
}
// 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 {
m := M{
"type": oc.Protocol,
"tag": oc.Tag,
}
// Transform common protocol keys to sing-box native format
// WireGuard: secret_key -> private_key; peers: endpoint -> address+port
if oc.Protocol == "wireguard" {
if sk, ok := oc.Settings["secret_key"]; ok {
m["private_key"] = sk
}
if peers, ok := oc.Settings["peers"].([]any); ok {
var wgPeers []M
for _, p := range peers {
if peerMap, ok := p.(map[string]any); ok {
newPeer := M{}
for k, v := range peerMap {
if k == "endpoint" {
if ep, ok := v.(string); ok {
host, portStr, err := net.SplitHostPort(ep)
if err == nil {
newPeer["address"] = host
port, _ := strconv.Atoi(portStr)
newPeer["port"] = port
} else {
newPeer["address"] = ep
}
}
} else {
newPeer[k] = v
}
}
wgPeers = append(wgPeers, newPeer)
}
}
m["peers"] = wgPeers
}
// Copy any other top-level settings not handled above
for k, v := range oc.Settings {
if k != "secret_key" && k != "peers" {
m[k] = v
}
}
} else {
for k, v := range oc.Settings {
m[k] = v
}
}
if oc.ProxyTag != "" {
m["proxy_tag"] = oc.ProxyTag
}
return m
}
func mergeRouteList(a, b []map[string]any) []map[string]any {
res := make([]map[string]any, 0, len(a)+len(b))
res = append(res, a...)
res = append(res, b...)
return res
}
func buildRoutes(panelRoutes []panel.RouteRule, custom []map[string]any) M {
var rules []M
// Custom Routes (Panel-pushed or Local) go FIRST to have highest priority
for _, cr := range custom {
rules = append(rules, M(cr))
}
// Standard blocks for private/loopback
rules = append(rules, M{
"outbound": "block",
"ip_cidr": []string{
"10.0.0.0/8",
"100.64.0.0/10",
"127.0.0.0/8",
"169.254.0.0/16",
"172.16.0.0/12",
"192.0.0.0/24",
"192.168.0.0/16",
"198.18.0.0/15",
"fc00::/7",
"fe80::/10",
"::1/128",
},
}, M{
"outbound": "block",
"domain": []string{
"geoip:private",
},
})
// Panel-defined routes (usually specific blocks/proxies)
for _, pr := range panelRoutes {
if len(pr.Match) == 0 {
continue
}
var domains, cidrs []string
for _, m := range pr.Match {
m = strings.TrimSpace(m)
if m == "" {
continue
}
// Strip leading wildcard for domain matching
if strings.HasPrefix(m, "*.") {
m = strings.TrimPrefix(m, "*.")
}
// Check if it's a CIDR block (contains /)
if strings.Contains(m, "/") {
cidrs = append(cidrs, m)
} else {
// Otherwise treat as domain
domains = append(domains, m)
}
}
// Determine outbound tag based on action
outbound := "block"
if pr.Action == "direct" {
outbound = "direct"
} else if pr.Action == "dns" {
// Special case for sing-box DNS routing:
// If action is "dns" and action_value is provided, we use it as the server name.
// Otherwise it defaults to "dns-out".
server := "dns-out"
if pr.ActionValue != "" {
server = pr.ActionValue
}
outbound = server
} else if pr.Action == "proxy" {
// If panel provides a specific outbound tag in action_value, use it.
// This allows routing to WARP_JP etc. via normal routes.
if pr.ActionValue != "" {
outbound = pr.ActionValue
}
}
// Create separate rule for domains
if len(domains) > 0 {
rule := M{
"domain_suffix": domains,
"outbound": outbound,
}
rules = append(rules, rule)
}
// Create separate rule for CIDRs
if len(cidrs) > 0 {
rule := M{
"ip_cidr": cidrs,
"outbound": outbound,
}
rules = append(rules, rule)
}
}
return M{
"final": "direct",
"rules": rules,
}
}
func mergeCustomSingbox(cfg M, kcfg config.KernelConfig) {
custom, err := kernel.LoadCustomConfig(kcfg.CustomConfig)
if err != nil {
slog.Error("failed to load custom sing-box config", "error", err)
return
}
if custom == nil {
return
}
// dns — custom replaces
if v, ok := custom["dns"]; ok {
cfg["dns"] = v
}
// experimental — custom replaces
if v, ok := custom["experimental"]; ok {
cfg["experimental"] = v
}
// outbounds — append custom entries
if v, ok := custom["outbounds"]; ok {
if existing, ok := cfg["outbounds"].([]M); ok {
cfg["outbounds"] = kernel.MergeAppendList(existing, v)
}
}
// endpoints (sing-box 1.11+ wireguard etc.) — append
if v, ok := custom["endpoints"]; ok {
if existing, ok := cfg["endpoints"].([]M); ok {
cfg["endpoints"] = kernel.MergeAppendList(existing, v)
} else {
if items := kernel.MergeAppendList(nil, v); len(items) > 0 {
cfg["endpoints"] = items
}
}
}
// route — merge sub-fields
if customRoute, ok := custom["route"]; ok {
if customRouteMap, ok := customRoute.(map[string]any); ok {
mergeCustomSingboxRoute(cfg, customRouteMap)
}
}
}
func mergeCustomSingboxRoute(cfg M, customRoute map[string]any) {
route, ok := cfg["route"].(M)
if !ok {
route = M{}
cfg["route"] = route
}
// rules — custom rules prepended (so they match before panel rules)
if v, ok := customRoute["rules"]; ok {
if existing, ok := route["rules"].([]M); ok {
route["rules"] = kernel.MergePrependList(existing, v)
}
}
// rule_set — custom rule_sets appended
if v, ok := customRoute["rule_set"]; ok {
if existing, ok := route["rule_set"].([]M); ok {
route["rule_set"] = kernel.MergeAppendList(existing, v)
} else {
route["rule_set"] = kernel.MergeAppendList(nil, v)
}
}
// final, auto_detect_interface, default_interface, etc. — custom overrides
for k, v := range customRoute {
if k == "rules" || k == "rule_set" {
continue
}
route[k] = v
}
}
func buildInbound(nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
base := M{
"tag": nc.Protocol + "-in",
"listen": "::",
"listen_port": nc.ServerPort,
}
switch nc.Protocol {
case "shadowsocks":
return buildShadowsocks(base, nc, users)
case "vmess":
return buildVMess(base, nc, users, certFile, keyFile)
case "vless":
return buildVLESS(base, nc, users, certFile, keyFile)
case "trojan":
return buildTrojan(base, nc, users, certFile, keyFile)
case "hysteria":
return buildHysteria(base, nc, users, certFile, keyFile)
case "tuic":
return buildTUIC(base, nc, users, certFile, keyFile)
case "anytls":
return buildAnyTLS(base, nc, users, certFile, keyFile)
case "naive":
return buildNaive(base, nc, users, certFile, keyFile)
case "socks":
return buildSocks(base, users)
case "http":
return buildHTTP(base, nc, users, certFile, keyFile)
default:
return nil
}
}
func buildShadowsocks(base M, nc *panel.NodeConfig, users []panel.User) M {
base["type"] = "shadowsocks"
base["method"] = nc.Cipher
if strings.HasPrefix(nc.Cipher, "2022-blake3-") {
base["password"] = nc.ServerKey
}
userList := make([]M, 0, len(users))
for _, u := range users {
userList = append(userList, M{
"name": u.UUID,
"password": u.UUID,
})
}
base["users"] = userList
if nc.Plugin != "" {
base["plugin"] = nc.Plugin
base["plugin_opts"] = nc.PluginOpt
}
return base
}
func buildVMess(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
base["type"] = "vmess"
userList := make([]M, 0, len(users))
for _, u := range users {
userList = append(userList, M{
"name": u.UUID,
"uuid": u.UUID,
"alterId": 0,
})
}
base["users"] = userList
applyTransport(base, nc)
if nc.TLS == 1 {
base["tls"] = buildTLSConfig(nc, certFile, keyFile)
}
return base
}
func buildVLESS(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
base["type"] = "vless"
userList := make([]M, 0, len(users))
for _, u := range users {
user := M{
"name": u.UUID,
"uuid": u.UUID,
}
if nc.Flow != "" {
user["flow"] = nc.Flow
}
userList = append(userList, user)
}
base["users"] = userList
applyTransport(base, nc)
if nc.TLS == 1 {
base["tls"] = buildTLSConfig(nc, certFile, keyFile)
} else if nc.TLS == 2 {
base["tls"] = buildRealityConfig(nc)
}
return base
}
func buildTrojan(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
base["type"] = "trojan"
userList := make([]M, 0, len(users))
for _, u := range users {
userList = append(userList, M{
"name": u.UUID,
"password": u.UUID,
})
}
base["users"] = userList
applyTransport(base, nc)
if nc.TLS == 1 {
base["tls"] = buildTLSConfig(nc, certFile, keyFile)
} else if nc.TLS == 2 {
base["tls"] = buildRealityConfig(nc)
}
return base
}
func buildHysteria(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
if nc.Version == 2 {
base["type"] = "hysteria2"
userList := make([]M, 0, len(users))
for _, u := range users {
userList = append(userList, M{
"name": u.UUID,
"password": u.UUID,
})
}
base["users"] = userList
if nc.Obfs != "" {
base["obfs"] = M{
"type": nc.Obfs,
"password": nc.ObfsPassword,
}
}
} else {
base["type"] = "hysteria"
userList := make([]M, 0, len(users))
for _, u := range users {
userList = append(userList, M{
"name": u.UUID,
"auth_str": u.UUID,
})
}
base["users"] = userList
base["up_mbps"] = nc.UpMbps
base["down_mbps"] = nc.DownMbps
if nc.Obfs != "" {
base["obfs"] = nc.Obfs
}
}
tls := buildTLSConfig(nc, certFile, keyFile)
// Hysteria/Hysteria2 uses QUIC and requires ALPN; default to h3 if not set.
if _, ok := tls["alpn"]; !ok {
tls["alpn"] = []string{"h3"}
}
base["tls"] = tls
return base
}
func buildTUIC(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
base["type"] = "tuic"
userList := make([]M, 0, len(users))
for _, u := range users {
userList = append(userList, M{
"name": u.UUID,
"uuid": u.UUID,
"password": u.UUID,
})
}
base["users"] = userList
if nc.CongestionControl != "" {
base["congestion_control"] = nc.CongestionControl
}
tls := buildTLSConfig(nc, certFile, keyFile)
// TUIC requires ALPN for QUIC negotiation; default to h3 if not set by panel.
if _, ok := tls["alpn"]; !ok {
tls["alpn"] = []string{"h3"}
}
base["tls"] = tls
return base
}
func buildAnyTLS(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
base["type"] = "anytls"
userList := make([]M, 0, len(users))
for _, u := range users {
userList = append(userList, M{
"name": u.UUID,
"password": u.UUID,
})
}
base["users"] = userList
if nc.PaddingScheme != "" {
base["padding_scheme"] = string(nc.PaddingScheme)
}
base["tls"] = buildTLSConfig(nc, certFile, keyFile)
return base
}
func buildNaive(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
base["type"] = "naive"
userList := make([]M, 0, len(users))
for _, u := range users {
userList = append(userList, M{
"username": strconv.Itoa(u.ID),
"password": u.UUID,
})
}
base["users"] = userList
if nc.TLS == 1 {
base["tls"] = buildTLSConfig(nc, certFile, keyFile)
}
return base
}
func buildSocks(base M, users []panel.User) M {
base["type"] = "socks"
userList := make([]M, 0, len(users))
for _, u := range users {
userList = append(userList, M{
"username": u.UUID,
"password": u.UUID,
})
}
base["users"] = userList
return base
}
func buildHTTP(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
base["type"] = "http"
userList := make([]M, 0, len(users))
for _, u := range users {
userList = append(userList, M{
"username": u.UUID,
"password": u.UUID,
})
}
base["users"] = userList
if nc.TLS == 1 {
base["tls"] = buildTLSConfig(nc, certFile, keyFile)
}
return base
}
func applyTransport(base M, nc *panel.NodeConfig) {
if nc.Network == "" || nc.Network == "tcp" {
return
}
transport := M{"type": nc.Network}
if nc.NetworkSettings != nil {
switch nc.Network {
case "ws":
if v, ok := nc.NetworkSettings["path"]; ok {
transport["path"] = v
}
if v, ok := nc.NetworkSettings["headers"]; ok {
transport["headers"] = v
}
if v, ok := nc.NetworkSettings["host"]; ok {
if transport["headers"] == nil {
transport["headers"] = M{"Host": v}
}
}
if v, ok := nc.NetworkSettings["max_early_data"]; ok {
transport["max_early_data"] = v
}
if v, ok := nc.NetworkSettings["early_data_header_name"]; ok {
transport["early_data_header_name"] = v
}
case "grpc":
if v, ok := nc.NetworkSettings["service_name"]; ok {
transport["service_name"] = v
}
case "httpupgrade":
if v, ok := nc.NetworkSettings["path"]; ok {
transport["path"] = v
}
if v, ok := nc.NetworkSettings["host"]; ok {
transport["host"] = v
}
case "h2", "http":
transport["type"] = "http"
if v, ok := nc.NetworkSettings["path"]; ok {
transport["path"] = v
}
if v, ok := nc.NetworkSettings["host"]; ok {
transport["host"] = v
}
}
}
base["transport"] = transport
}
func buildTLSConfig(nc *panel.NodeConfig, certFile, keyFile string) M {
tls := M{"enabled": true}
serverName := nc.ServerName
if serverName == "" && nc.Host != "" {
serverName = nc.Host
}
if serverName != "" {
tls["server_name"] = serverName
}
if nc.TLSSettings != nil {
if sn, ok := nc.TLSSettings["server_name"]; ok && sn != "" {
tls["server_name"] = sn
}
if alpn, ok := nc.TLSSettings["alpn"]; ok {
tls["alpn"] = alpn
}
}
if certFile != "" && keyFile != "" {
tls["certificate_path"] = certFile
tls["key_path"] = keyFile
}
return tls
}
func buildRealityConfig(nc *panel.NodeConfig) M {
tls := M{"enabled": true}
if nc.TLSSettings == nil {
return tls
}
reality := M{"enabled": true}
if pk, ok := nc.TLSSettings["private_key"]; ok {
reality["private_key"] = pk
}
if sid, ok := nc.TLSSettings["short_id"]; ok {
switch v := sid.(type) {
case string:
reality["short_id"] = []string{v}
case []interface{}:
reality["short_id"] = v
}
}
handshake := M{}
if dest, ok := nc.TLSSettings["dest"]; ok {
destStr := fmt.Sprintf("%v", dest)
parts := strings.SplitN(destStr, ":", 2)
handshake["server"] = parts[0]
if len(parts) == 2 {
if port, err := strconv.Atoi(parts[1]); err == nil {
handshake["server_port"] = port
}
} else {
handshake["server_port"] = 443
}
}
if sn, ok := nc.TLSSettings["server_name"]; ok {
handshake["server"] = sn
if _, exists := handshake["server_port"]; !exists {
handshake["server_port"] = 443
}
}
if len(handshake) > 0 {
reality["handshake"] = handshake
}
tls["reality"] = reality
return tls
}
+694
View File
@@ -0,0 +1,694 @@
package singbox
import (
"encoding/json"
"testing"
"github.com/cedar2025/xboard-node/internal/config"
"github.com/cedar2025/xboard-node/internal/panel"
)
var testUsers = []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 ---
func TestBuildInbound_Shadowsocks(t *testing.T) {
nc := &panel.NodeConfig{
Protocol: "shadowsocks",
ServerPort: 111,
Cipher: "aes-128-gcm",
}
inbound := buildInbound(nc, testUsers, "", "")
assertMapValue(t, inbound, "type", "shadowsocks")
assertMapValue(t, inbound, "method", "aes-128-gcm")
assertMapValue(t, inbound, "listen_port", 111)
users := inbound["users"].([]M)
if len(users) != 2 {
t.Fatalf("users: got %d, want 2", len(users))
}
assertMapValue(t, users[0], "name", "aaaaaaaa-1111-2222-3333-444444444444")
assertMapValue(t, users[0], "password", "aaaaaaaa-1111-2222-3333-444444444444")
}
func TestBuildInbound_Shadowsocks2022(t *testing.T) {
nc := &panel.NodeConfig{
Protocol: "shadowsocks",
ServerPort: 222,
Cipher: "2022-blake3-aes-128-gcm",
ServerKey: "base64serverkey==",
}
inbound := buildInbound(nc, testUsers, "", "")
assertMapValue(t, inbound, "method", "2022-blake3-aes-128-gcm")
assertMapValue(t, inbound, "password", "base64serverkey==")
}
func TestBuildInbound_ShadowsocksWithPlugin(t *testing.T) {
nc := &panel.NodeConfig{
Protocol: "shadowsocks",
ServerPort: 333,
Cipher: "aes-128-gcm",
Plugin: "obfs-local",
PluginOpt: "obfs=http;obfs-host=example.com",
}
inbound := buildInbound(nc, testUsers, "", "")
assertMapValue(t, inbound, "plugin", "obfs-local")
assertMapValue(t, inbound, "plugin_opts", "obfs=http;obfs-host=example.com")
}
// --- VMess ---
func TestBuildInbound_VMess(t *testing.T) {
nc := &panel.NodeConfig{
Protocol: "vmess",
ServerPort: 443,
}
inbound := buildInbound(nc, testUsers, "", "")
assertMapValue(t, inbound, "type", "vmess")
assertMapValue(t, inbound, "tag", "vmess-in")
users := inbound["users"].([]M)
assertMapValue(t, users[0], "uuid", "aaaaaaaa-1111-2222-3333-444444444444")
assertMapValue(t, users[0], "alterId", 0)
}
func TestBuildInbound_VMess_WithTLS(t *testing.T) {
nc := &panel.NodeConfig{
Protocol: "vmess",
ServerPort: 443,
TLS: 1,
ServerName: "example.com",
}
inbound := buildInbound(nc, testUsers, "/path/cert.pem", "/path/key.pem")
tls := inbound["tls"].(M)
assertMapValue(t, tls, "enabled", true)
assertMapValue(t, tls, "server_name", "example.com")
assertMapValue(t, tls, "certificate_path", "/path/cert.pem")
assertMapValue(t, tls, "key_path", "/path/key.pem")
}
func TestBuildInbound_VMess_NoTLS(t *testing.T) {
nc := &panel.NodeConfig{
Protocol: "vmess",
ServerPort: 80,
TLS: 0,
}
inbound := buildInbound(nc, testUsers, "", "")
if _, exists := inbound["tls"]; exists {
t.Error("should not have TLS when tls=0")
}
}
func TestBuildInbound_VMess_WithWebSocket(t *testing.T) {
nc := &panel.NodeConfig{
Protocol: "vmess",
ServerPort: 80,
Network: "ws",
NetworkSettings: map[string]interface{}{
"path": "/ws",
"headers": map[string]interface{}{
"Host": "example.com",
},
},
}
inbound := buildInbound(nc, testUsers, "", "")
transport := inbound["transport"].(M)
assertMapValue(t, transport, "type", "ws")
assertMapValue(t, transport, "path", "/ws")
}
func TestBuildInbound_VMess_WithGRPC(t *testing.T) {
nc := &panel.NodeConfig{
Protocol: "vmess",
ServerPort: 443,
Network: "grpc",
NetworkSettings: map[string]interface{}{
"service_name": "mygrpc",
},
}
inbound := buildInbound(nc, testUsers, "", "")
transport := inbound["transport"].(M)
assertMapValue(t, transport, "type", "grpc")
assertMapValue(t, transport, "service_name", "mygrpc")
}
func TestBuildInbound_VMess_WithH2(t *testing.T) {
nc := &panel.NodeConfig{
Protocol: "vmess",
ServerPort: 443,
Network: "h2",
NetworkSettings: map[string]interface{}{
"path": "/h2path",
"host": "example.com",
},
}
inbound := buildInbound(nc, testUsers, "", "")
transport := inbound["transport"].(M)
assertMapValue(t, transport, "type", "http")
assertMapValue(t, transport, "path", "/h2path")
}
func TestBuildInbound_VMess_WithHTTPUpgrade(t *testing.T) {
nc := &panel.NodeConfig{
Protocol: "vmess",
ServerPort: 443,
Network: "httpupgrade",
NetworkSettings: map[string]interface{}{
"path": "/upgrade",
"host": "example.com",
},
}
inbound := buildInbound(nc, testUsers, "", "")
transport := inbound["transport"].(M)
assertMapValue(t, transport, "type", "httpupgrade")
assertMapValue(t, transport, "path", "/upgrade")
assertMapValue(t, transport, "host", "example.com")
}
// --- VLESS ---
func TestBuildInbound_VLESS(t *testing.T) {
nc := &panel.NodeConfig{
Protocol: "vless",
ServerPort: 443,
}
inbound := buildInbound(nc, testUsers, "", "")
assertMapValue(t, inbound, "type", "vless")
users := inbound["users"].([]M)
assertMapValue(t, users[0], "uuid", "aaaaaaaa-1111-2222-3333-444444444444")
}
func TestBuildInbound_VLESS_WithFlow(t *testing.T) {
nc := &panel.NodeConfig{
Protocol: "vless",
ServerPort: 443,
Flow: "xtls-rprx-vision",
}
inbound := buildInbound(nc, testUsers, "", "")
users := inbound["users"].([]M)
assertMapValue(t, users[0], "flow", "xtls-rprx-vision")
}
func TestBuildInbound_VLESS_Reality(t *testing.T) {
nc := &panel.NodeConfig{
Protocol: "vless",
ServerPort: 443,
TLS: 2,
TLSSettings: map[string]interface{}{
"private_key": "test-private-key",
"short_id": "abc123",
"dest": "www.example.com:443",
"server_name": "www.example.com",
},
}
inbound := buildInbound(nc, testUsers, "", "")
tls := inbound["tls"].(M)
assertMapValue(t, tls, "enabled", true)
reality := tls["reality"].(M)
assertMapValue(t, reality, "enabled", true)
assertMapValue(t, reality, "private_key", "test-private-key")
handshake := reality["handshake"].(M)
assertMapValue(t, handshake, "server", "www.example.com")
assertMapValue(t, handshake, "server_port", 443)
}
func TestBuildInbound_VLESS_Reality_ShortIDArray(t *testing.T) {
nc := &panel.NodeConfig{
Protocol: "vless",
ServerPort: 443,
TLS: 2,
TLSSettings: map[string]interface{}{
"private_key": "pk",
"short_id": []interface{}{"id1", "id2"},
"dest": "example.com",
},
}
inbound := buildInbound(nc, testUsers, "", "")
tls := inbound["tls"].(M)
reality := tls["reality"].(M)
ids := reality["short_id"].([]interface{})
if len(ids) != 2 {
t.Errorf("short_id: got %d entries, want 2", len(ids))
}
}
// --- Trojan ---
func TestBuildInbound_Trojan(t *testing.T) {
nc := &panel.NodeConfig{
Protocol: "trojan",
ServerPort: 443,
}
inbound := buildInbound(nc, testUsers, "", "")
assertMapValue(t, inbound, "type", "trojan")
users := inbound["users"].([]M)
assertMapValue(t, users[0], "password", "aaaaaaaa-1111-2222-3333-444444444444")
}
func TestBuildInbound_Trojan_NoTLS(t *testing.T) {
nc := &panel.NodeConfig{
Protocol: "trojan",
ServerPort: 80,
TLS: 0,
}
inbound := buildInbound(nc, testUsers, "", "")
if _, exists := inbound["tls"]; exists {
t.Error("trojan with tls=0 should not have TLS config")
}
}
func TestBuildInbound_Trojan_WithTLS(t *testing.T) {
nc := &panel.NodeConfig{
Protocol: "trojan",
ServerPort: 443,
TLS: 1,
ServerName: "example.com",
}
inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem")
tls := inbound["tls"].(M)
assertMapValue(t, tls, "enabled", true)
}
// --- Hysteria ---
func TestBuildInbound_Hysteria2(t *testing.T) {
nc := &panel.NodeConfig{
Protocol: "hysteria",
ServerPort: 444,
Version: 2,
}
inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem")
assertMapValue(t, inbound, "type", "hysteria2")
users := inbound["users"].([]M)
assertMapValue(t, users[0], "password", "aaaaaaaa-1111-2222-3333-444444444444")
tls := inbound["tls"].(M)
assertMapValue(t, tls, "enabled", true)
}
func TestBuildInbound_Hysteria2_WithObfs(t *testing.T) {
nc := &panel.NodeConfig{
Protocol: "hysteria",
ServerPort: 444,
Version: 2,
Obfs: "salamander",
ObfsPassword: "secret",
}
inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem")
obfs := inbound["obfs"].(M)
assertMapValue(t, obfs, "type", "salamander")
assertMapValue(t, obfs, "password", "secret")
}
func TestBuildInbound_Hysteria1(t *testing.T) {
nc := &panel.NodeConfig{
Protocol: "hysteria",
ServerPort: 444,
Version: 0,
UpMbps: 100,
DownMbps: 200,
}
inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem")
assertMapValue(t, inbound, "type", "hysteria")
assertMapValue(t, inbound, "up_mbps", 100)
assertMapValue(t, inbound, "down_mbps", 200)
users := inbound["users"].([]M)
assertMapValue(t, users[0], "auth_str", "aaaaaaaa-1111-2222-3333-444444444444")
}
// --- TUIC ---
func TestBuildInbound_TUIC(t *testing.T) {
nc := &panel.NodeConfig{
Protocol: "tuic",
ServerPort: 555,
CongestionControl: "bbr",
}
inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem")
assertMapValue(t, inbound, "type", "tuic")
assertMapValue(t, inbound, "congestion_control", "bbr")
users := inbound["users"].([]M)
assertMapValue(t, users[0], "uuid", "aaaaaaaa-1111-2222-3333-444444444444")
assertMapValue(t, users[0], "password", "aaaaaaaa-1111-2222-3333-444444444444")
}
// --- AnyTLS ---
func TestBuildInbound_AnyTLS(t *testing.T) {
nc := &panel.NodeConfig{
Protocol: "anytls",
ServerPort: 443,
PaddingScheme: "stop=8\n0=30-30",
}
inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem")
assertMapValue(t, inbound, "type", "anytls")
assertMapValue(t, inbound, "padding_scheme", "stop=8\n0=30-30")
users := inbound["users"].([]M)
assertMapValue(t, users[0], "password", "aaaaaaaa-1111-2222-3333-444444444444")
}
func TestBuildInbound_AnyTLS_NoPaddingScheme(t *testing.T) {
nc := &panel.NodeConfig{
Protocol: "anytls",
ServerPort: 443,
}
inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem")
if _, exists := inbound["padding_scheme"]; exists {
t.Error("should not have padding_scheme when empty")
}
}
// --- Naive ---
func TestBuildInbound_Naive(t *testing.T) {
nc := &panel.NodeConfig{
Protocol: "naive",
ServerPort: 443,
TLS: 1,
}
inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem")
assertMapValue(t, inbound, "type", "naive")
users := inbound["users"].([]M)
assertMapValue(t, users[0], "username", "1")
assertMapValue(t, users[0], "password", "aaaaaaaa-1111-2222-3333-444444444444")
tls := inbound["tls"].(M)
assertMapValue(t, tls, "enabled", true)
}
// --- Socks ---
func TestBuildInbound_Socks(t *testing.T) {
nc := &panel.NodeConfig{
Protocol: "socks",
ServerPort: 1080,
}
inbound := buildInbound(nc, testUsers, "", "")
assertMapValue(t, inbound, "type", "socks")
users := inbound["users"].([]M)
assertMapValue(t, users[0], "username", "aaaaaaaa-1111-2222-3333-444444444444")
assertMapValue(t, users[0], "password", "aaaaaaaa-1111-2222-3333-444444444444")
if _, exists := inbound["tls"]; exists {
t.Error("socks should not have TLS")
}
}
// --- HTTP ---
func TestBuildInbound_HTTP(t *testing.T) {
nc := &panel.NodeConfig{
Protocol: "http",
ServerPort: 8080,
}
inbound := buildInbound(nc, testUsers, "", "")
assertMapValue(t, inbound, "type", "http")
users := inbound["users"].([]M)
assertMapValue(t, users[0], "username", "aaaaaaaa-1111-2222-3333-444444444444")
}
func TestBuildInbound_HTTP_WithTLS(t *testing.T) {
nc := &panel.NodeConfig{
Protocol: "http",
ServerPort: 443,
TLS: 1,
}
inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem")
tls := inbound["tls"].(M)
assertMapValue(t, tls, "enabled", true)
}
// --- Unknown protocol ---
func TestBuildInbound_Unknown(t *testing.T) {
nc := &panel.NodeConfig{
Protocol: "unknown-proto",
ServerPort: 999,
}
inbound := buildInbound(nc, testUsers, "", "")
if inbound != nil {
t.Errorf("unknown protocol should return nil, got %v", inbound)
}
}
// --- buildConfig ---
func TestBuildConfig(t *testing.T) {
kcfg := config.KernelConfig{LogLevel: "info"}
nc := &panel.NodeConfig{
Protocol: "shadowsocks",
ServerPort: 111,
Cipher: "aes-128-gcm",
}
cfg := buildConfig(kcfg, nc, testUsers, "", "")
data, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal config: %v", err)
}
if len(data) == 0 {
t.Error("empty JSON output")
}
logCfg := cfg["log"].(M)
assertMapValue(t, logCfg, "level", "info")
assertMapValue(t, logCfg, "timestamp", true)
outbounds := cfg["outbounds"].([]M)
if len(outbounds) != 2 {
t.Errorf("outbounds: got %d, want 2", len(outbounds))
}
assertMapValue(t, outbounds[0], "type", "direct")
assertMapValue(t, outbounds[1], "type", "block")
inbounds := cfg["inbounds"].([]M)
if len(inbounds) != 1 {
t.Fatalf("inbounds: got %d, want 1", len(inbounds))
}
assertMapValue(t, inbounds[0], "type", "shadowsocks")
}
func TestBuildConfig_AllProtocols_ValidJSON(t *testing.T) {
protocols := []struct {
name string
nc *panel.NodeConfig
}{
{"shadowsocks", &panel.NodeConfig{Protocol: "shadowsocks", ServerPort: 111, Cipher: "aes-128-gcm"}},
{"vmess", &panel.NodeConfig{Protocol: "vmess", ServerPort: 443}},
{"vless", &panel.NodeConfig{Protocol: "vless", ServerPort: 443}},
{"trojan", &panel.NodeConfig{Protocol: "trojan", ServerPort: 443}},
{"hysteria2", &panel.NodeConfig{Protocol: "hysteria", ServerPort: 444, Version: 2}},
{"hysteria1", &panel.NodeConfig{Protocol: "hysteria", ServerPort: 444, Version: 1}},
{"tuic", &panel.NodeConfig{Protocol: "tuic", ServerPort: 555}},
{"anytls", &panel.NodeConfig{Protocol: "anytls", ServerPort: 443}},
{"naive", &panel.NodeConfig{Protocol: "naive", ServerPort: 443}},
{"socks", &panel.NodeConfig{Protocol: "socks", ServerPort: 1080}},
{"http", &panel.NodeConfig{Protocol: "http", ServerPort: 8080}},
}
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")
data, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal %s: %v", tc.name, err)
}
var parsed map[string]interface{}
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("round-trip %s: %v", tc.name, err)
}
})
}
}
// --- Routes ---
func TestBuildRoutes_Default(t *testing.T) {
route := buildRoutes(nil, nil)
assertMapValue(t, route, "final", "direct")
rules := route["rules"].([]M)
if len(rules) < 2 {
t.Fatalf("expected at least 2 default rules, got %d", len(rules))
}
assertMapValue(t, rules[0], "outbound", "block")
assertMapValue(t, rules[1], "outbound", "block")
}
func TestBuildRoutes_WithCustomRules(t *testing.T) {
rules := []panel.RouteRule{
{ID: 1, Match: []string{"blocked.com"}, Action: "block"},
{ID: 2, Match: []string{"10.0.0.0/8"}, Action: "block"},
{ID: 3, Match: []string{"allowed.com"}, Action: "direct"},
}
route := buildRoutes(rules, nil)
allRules := route["rules"].([]M)
if len(allRules) != 5 {
t.Fatalf("rules count: got %d, want 5", len(allRules))
}
assertMapValue(t, allRules[2], "outbound", "block")
if _, ok := allRules[2]["domain_suffix"]; !ok {
t.Error("domain rule should use domain_suffix")
}
assertMapValue(t, allRules[3], "outbound", "block")
if _, ok := allRules[3]["ip_cidr"]; !ok {
t.Error("IP rule should use ip_cidr")
}
}
func TestBuildRoutes_MultiMatch(t *testing.T) {
// A single route with mixed domain + CIDR matches should produce separate rules
// Wildcard *.evil.com should become evil.com for domain_suffix
rules := []panel.RouteRule{
{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)
allRules := route["rules"].([]M)
// 2 default private-IP rules + 1 domain rule + 1 CIDR rule + 1 domain rule = 5
if len(allRules) != 5 {
t.Fatalf("rules count: got %d, want 5", len(allRules))
}
// Rule #2 (index 2): domains from first route (wildcards stripped)
domains := allRules[2]["domain_suffix"].([]string)
if len(domains) != 2 || domains[0] != "evil.com" || domains[1] != "bad.org" {
t.Errorf("domain_suffix: got %v, want [evil.com bad.org]", domains)
}
assertMapValue(t, allRules[2], "outbound", "block")
// Rule #3 (index 3): CIDRs from first route
cidrs := allRules[3]["ip_cidr"].([]string)
if len(cidrs) != 1 || cidrs[0] != "192.168.1.0/24" {
t.Errorf("ip_cidr: got %v, want [192.168.1.0/24]", cidrs)
}
assertMapValue(t, allRules[3], "outbound", "block")
// Rule #4 (index 4): direct rule (wildcard stripped)
directDomains := allRules[4]["domain_suffix"].([]string)
if len(directDomains) != 1 || directDomains[0] != "bypass.com" {
t.Errorf("direct domain_suffix: got %v, want [bypass.com]", directDomains)
}
assertMapValue(t, allRules[4], "outbound", "direct")
}
// --- TLS Config ---
func TestBuildTLSConfig_WithCert(t *testing.T) {
nc := &panel.NodeConfig{ServerName: "example.com"}
tls := buildTLSConfig(nc, "/cert.pem", "/key.pem")
assertMapValue(t, tls, "enabled", true)
assertMapValue(t, tls, "server_name", "example.com")
assertMapValue(t, tls, "certificate_path", "/cert.pem")
assertMapValue(t, tls, "key_path", "/key.pem")
}
func TestBuildTLSConfig_NoCert(t *testing.T) {
nc := &panel.NodeConfig{ServerName: "example.com"}
tls := buildTLSConfig(nc, "", "")
assertMapValue(t, tls, "enabled", true)
if _, exists := tls["certificate_path"]; exists {
t.Error("should not have certificate_path when empty")
}
}
func TestBuildTLSConfig_FallbackToHost(t *testing.T) {
nc := &panel.NodeConfig{Host: "fallback.com"}
tls := buildTLSConfig(nc, "", "")
assertMapValue(t, tls, "server_name", "fallback.com")
}
func TestBuildTLSConfig_TLSSettingsOverride(t *testing.T) {
nc := &panel.NodeConfig{
ServerName: "original.com",
TLSSettings: map[string]interface{}{
"server_name": "override.com",
},
}
tls := buildTLSConfig(nc, "", "")
assertMapValue(t, tls, "server_name", "override.com")
}
// --- Transport ---
func TestApplyTransport_TCP(t *testing.T) {
base := M{}
nc := &panel.NodeConfig{Network: "tcp"}
applyTransport(base, nc)
if _, exists := base["transport"]; exists {
t.Error("tcp should not add transport")
}
}
func TestApplyTransport_Empty(t *testing.T) {
base := M{}
nc := &panel.NodeConfig{Network: ""}
applyTransport(base, nc)
if _, exists := base["transport"]; exists {
t.Error("empty network should not add transport")
}
}
func TestApplyTransport_WS_MaxEarlyData(t *testing.T) {
base := M{}
nc := &panel.NodeConfig{
Network: "ws",
NetworkSettings: map[string]interface{}{
"path": "/ws",
"max_early_data": 2048,
"early_data_header_name": "Sec-WebSocket-Protocol",
},
}
applyTransport(base, nc)
transport := base["transport"].(M)
assertMapValue(t, transport, "max_early_data", 2048)
assertMapValue(t, transport, "early_data_header_name", "Sec-WebSocket-Protocol")
}
// --- helpers ---
func assertMapValue(t *testing.T, m M, key string, expected interface{}) {
t.Helper()
val, ok := m[key]
if !ok {
t.Errorf("key %q not found in map", key)
return
}
switch e := expected.(type) {
case int:
switch v := val.(type) {
case int:
if v != e {
t.Errorf("%s: got %d, want %d", key, v, e)
}
case float64:
if int(v) != e {
t.Errorf("%s: got %v, want %d", key, v, e)
}
default:
t.Errorf("%s: got %v (type %T), want %d", key, val, val, e)
}
default:
if val != expected {
t.Errorf("%s: got %v, want %v", key, val, expected)
}
}
}
+402
View File
@@ -0,0 +1,402 @@
package singbox
import (
"context"
"io"
"log/slog"
"net"
"strconv"
"sync"
"sync/atomic"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing/common/buf"
singM "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
"golang.org/x/time/rate"
"github.com/cedar2025/xboard-node/internal/kernel"
)
// connRecord holds the minimal per-connection state we need.
// Each field is accessed by multiple goroutines; upload/download use atomics.
type connRecord struct {
id string
userID int
sourceIP string
upload atomic.Int64
download atomic.Int64
conn net.Conn // kept for force-close via CloseByID
}
func (r *connRecord) toConnection() kernel.Connection {
return kernel.Connection{
ID: r.id,
UserID: r.userID,
SourceIP: r.sourceIP,
Upload: r.upload.Load(),
Download: r.download.Load(),
}
}
// ConnTracker is a lightweight in-process connection tracker that replaces
// the Clash API TrafficManager. It handles:
// - per-connection byte counting (upload / download)
// - optional per-user speed limiting (when SetSpeedLimitFunc is configured)
// - source IP tracking for device-limit enforcement
// - force-close a connection by ID
//
// Both byte counting and speed limiting are implemented via sing's CountFunc
// callback mechanism (ReadCounter / WriteCounter interfaces). This allows
// sing's copy pipeline to use splice / ReadWaiter zero-copy paths while
// still counting bytes and enforcing bandwidth limits through post-transfer
// callbacks.
//
// Memory model:
// - active: live connections, looked up by ID for snapshots and CloseByID
// - pending: closed connections accumulated since the last Snapshot call,
// drained atomically so the Tracker sees every connection's final bytes
//
// Thread safety: active is guarded by mu; pending by pendingMu.
type ConnTracker struct {
mu sync.RWMutex
active map[string]*connRecord
// idCounter generates unique connection IDs without crypto/rand.
idCounter atomic.Int64
pendingMu sync.Mutex
pending []*connRecord
maxPending int
userMapMu sync.RWMutex
userMap map[string]int // UUID → userID
// speedLimitFunc resolves a user UUID to a *rate.Limiter. When set,
// new connections for users with a speed limit are automatically
// rate-limited without needing a second ConnectionTracker wrapper.
// The function must be safe for concurrent calls.
speedLimitFunc func(uuid string) *rate.Limiter
}
// NewConnTracker creates a tracker.
func NewConnTracker(maxPending int) *ConnTracker {
if maxPending <= 0 {
maxPending = 50000
}
return &ConnTracker{
active: make(map[string]*connRecord),
maxPending: maxPending,
userMap: make(map[string]int),
}
}
// SetSpeedLimitFunc configures the per-user speed limit lookup.
// fn is called for every new connection; it must return nil for unlimited users.
// Thread-safe: fn itself must be safe for concurrent calls.
func (t *ConnTracker) SetSpeedLimitFunc(fn func(uuid string) *rate.Limiter) {
t.speedLimitFunc = fn
}
// SetUserMap replaces the UUID→userID mapping. Must be called whenever the
// user list changes so that new connections are attributed to the correct user.
func (t *ConnTracker) SetUserMap(m map[string]int) {
t.userMapMu.Lock()
t.userMap = m
t.userMapMu.Unlock()
}
// ─── adapter.ConnectionTracker ───────────────────────────────────────────────
// RoutedConnection wraps a TCP conn to count bytes, track lifecycle,
// and optionally rate-limit per-user bandwidth.
func (t *ConnTracker) RoutedConnection(
ctx context.Context, conn net.Conn,
metadata adapter.InboundContext,
_ adapter.Rule, _ adapter.Outbound,
) net.Conn {
rec := t.allocRecord(metadata, conn)
var lim *rate.Limiter
if t.speedLimitFunc != nil {
lim = t.speedLimitFunc(metadata.User)
}
return &trackedConn{Conn: conn, rec: rec, tracker: t, limiter: lim, ctx: ctx}
}
// RoutedPacketConnection wraps a UDP PacketConn to count bytes, track lifecycle,
// and optionally rate-limit per-user bandwidth.
func (t *ConnTracker) RoutedPacketConnection(
ctx context.Context, conn N.PacketConn,
metadata adapter.InboundContext,
_ adapter.Rule, _ adapter.Outbound,
) N.PacketConn {
rec := t.allocRecord(metadata, nil)
var lim *rate.Limiter
if t.speedLimitFunc != nil {
lim = t.speedLimitFunc(metadata.User)
}
return &trackedPacketConn{PacketConn: conn, rec: rec, tracker: t, limiter: lim, ctx: ctx}
}
// ─── Internal helpers ────────────────────────────────────────────────────────
func (t *ConnTracker) allocRecord(metadata adapter.InboundContext, conn net.Conn) *connRecord {
// Use a fast atomic counter instead of crypto/rand UUID generation.
// Under connection floods (thousands of concurrent handshakes), uuid.NewV4()
// becomes a syscall bottleneck; a counter is allocation-free and lock-free.
id := strconv.FormatInt(t.idCounter.Add(1), 36)
t.userMapMu.RLock()
uid := t.userMap[metadata.User]
t.userMapMu.RUnlock()
rec := &connRecord{
id: id,
userID: uid,
sourceIP: metadata.Source.Addr.String(),
conn: conn,
}
t.mu.Lock()
t.active[rec.id] = rec
t.mu.Unlock()
return rec
}
// closeRecord is idempotent: if the record has already been moved to pending,
// the second call is a no-op.
func (t *ConnTracker) closeRecord(rec *connRecord) {
t.mu.Lock()
_, present := t.active[rec.id]
if present {
delete(t.active, rec.id)
}
t.mu.Unlock()
if !present {
return
}
t.pendingMu.Lock()
if len(t.pending) < t.maxPending {
t.pending = append(t.pending, rec)
} else {
slog.Warn("conntracker: pending buffer full, dropping closed connection",
"max_pending", t.maxPending, "user_id", rec.userID)
}
t.pendingMu.Unlock()
}
// ─── Public API ──────────────────────────────────────────────────────────────
// Snapshot returns a point-in-time view of all active connections plus all
// connections that closed since the previous Snapshot call. The pending
// (closed) list is drained atomically so callers never miss a connection.
func (t *ConnTracker) Snapshot() []kernel.Connection {
// Drain pending under its own lock (hot path for closed connections).
t.pendingMu.Lock()
pending := t.pending
t.pending = nil
t.pendingMu.Unlock()
// Two-phase read to minimise RLock hold time at high connection counts:
// Phase 1 (under RLock): collect record *pointers* — a pointer copy is
// ~8 bytes vs ~80 bytes for a full Connection struct, and no atomic loads.
// Phase 2 (lock-free): call toConnection() which atomically reads the
// upload/download counters. Atomics are safe without any mutex.
// This shrinks the RLock window proportionally as connection count grows,
// reducing serialisation against concurrent allocRecord/closeRecord calls.
t.mu.RLock()
ptrs := make([]*connRecord, 0, len(t.active))
for _, rec := range t.active {
ptrs = append(ptrs, rec)
}
t.mu.RUnlock()
result := make([]kernel.Connection, 0, len(ptrs)+len(pending))
for _, rec := range ptrs {
result = append(result, rec.toConnection())
}
for _, rec := range pending {
result = append(result, rec.toConnection())
}
return result
}
// CloseByID force-closes a live connection by its ID.
// Returns true if the connection was found and closed.
func (t *ConnTracker) CloseByID(id string) bool {
t.mu.RLock()
rec, ok := t.active[id]
t.mu.RUnlock()
if !ok {
return false
}
if rec.conn != nil {
rec.conn.Close()
}
return true
}
// ActiveCount returns the number of currently tracked live connections.
func (t *ConnTracker) ActiveCount() int {
t.mu.RLock()
defer t.mu.RUnlock()
return len(t.active)
}
// ─── trackedConn (TCP) ───────────────────────────────────────────────────────
type trackedConn struct {
net.Conn
rec *connRecord
tracker *ConnTracker
limiter *rate.Limiter // nil = unlimited
ctx context.Context // for rate limiter WaitN cancellation
}
// Read is the fallback path used when the copy pipeline cannot unwrap this
// wrapper (should not happen with current code, but kept for robustness).
func (c *trackedConn) Read(b []byte) (int, error) {
if c.limiter != nil {
if burst := c.limiter.Burst(); len(b) > burst {
b = b[:burst]
}
}
n, err := c.Conn.Read(b)
if n > 0 {
c.rec.download.Add(int64(n))
if c.limiter != nil {
waitN := min(n, c.limiter.Burst())
_ = c.limiter.WaitN(c.ctx, waitN)
}
}
return n, err
}
// Write is the fallback path.
func (c *trackedConn) Write(b []byte) (int, error) {
if c.limiter != nil {
burst := c.limiter.Burst()
var n int
for n < len(b) {
chunk := min(len(b)-n, burst)
_ = c.limiter.WaitN(c.ctx, chunk)
written, writeErr := c.Conn.Write(b[n : n+chunk])
n += written
c.rec.upload.Add(int64(written))
if writeErr != nil {
return n, writeErr
}
}
return n, nil
}
n, err := c.Conn.Write(b)
if n > 0 {
c.rec.upload.Add(int64(n))
}
return n, err
}
func (c *trackedConn) Close() error {
c.tracker.closeRecord(c.rec)
return c.Conn.Close()
}
// makeCountFunc builds a CountFunc that does byte accounting and optional
// rate limiting in a single callback. The direction is selected by the
// counter pointer (download or upload).
func (c *trackedConn) makeCountFunc(counter *atomic.Int64) N.CountFunc {
if c.limiter == nil {
// Fast path: no rate limiting, just atomic add.
return func(n int64) { counter.Add(n) }
}
return func(n int64) {
counter.Add(n)
waitN := int(n)
if burst := c.limiter.Burst(); waitN > burst {
waitN = burst
}
_ = c.limiter.WaitN(c.ctx, waitN)
}
}
// UnwrapReader implements N.ReadCounter — lets the copy pipeline strip this
// wrapper, collect a counter callback, and reach the inner conn for
// splice / ReadWaiter zero-copy transfer.
func (c *trackedConn) UnwrapReader() (io.Reader, []N.CountFunc) {
return c.Conn, []N.CountFunc{c.makeCountFunc(&c.rec.download)}
}
// UnwrapWriter implements N.WriteCounter.
func (c *trackedConn) UnwrapWriter() (io.Writer, []N.CountFunc) {
return c.Conn, []N.CountFunc{c.makeCountFunc(&c.rec.upload)}
}
func (c *trackedConn) Upstream() any { return c.Conn }
func (c *trackedConn) ReaderReplaceable() bool { return true }
func (c *trackedConn) WriterReplaceable() bool { return true }
// ─── trackedPacketConn (UDP / QUIC) ──────────────────────────────────────────
type trackedPacketConn struct {
N.PacketConn
rec *connRecord
tracker *ConnTracker
limiter *rate.Limiter
ctx context.Context
}
func (c *trackedPacketConn) ReadPacket(buffer *buf.Buffer) (singM.Socksaddr, error) {
dest, err := c.PacketConn.ReadPacket(buffer)
if err == nil {
n := int64(buffer.Len())
c.rec.download.Add(n)
if c.limiter != nil {
waitN := int(min(n, int64(c.limiter.Burst())))
_ = c.limiter.WaitN(c.ctx, waitN)
}
}
return dest, err
}
func (c *trackedPacketConn) WritePacket(buffer *buf.Buffer, dest singM.Socksaddr) error {
n := int64(buffer.Len())
err := c.PacketConn.WritePacket(buffer, dest)
if err == nil {
c.rec.upload.Add(n)
if c.limiter != nil {
waitN := int(min(n, int64(c.limiter.Burst())))
_ = c.limiter.WaitN(c.ctx, waitN)
}
}
return err
}
func (c *trackedPacketConn) Close() error {
c.tracker.closeRecord(c.rec)
return c.PacketConn.Close()
}
func (c *trackedPacketConn) makeCountFunc(counter *atomic.Int64) N.CountFunc {
if c.limiter == nil {
return func(n int64) { counter.Add(n) }
}
return func(n int64) {
counter.Add(n)
waitN := int(n)
if burst := c.limiter.Burst(); waitN > burst {
waitN = burst
}
_ = c.limiter.WaitN(c.ctx, waitN)
}
}
func (c *trackedPacketConn) UnwrapPacketReader() (N.PacketReader, []N.CountFunc) {
return c.PacketConn, []N.CountFunc{c.makeCountFunc(&c.rec.download)}
}
func (c *trackedPacketConn) UnwrapPacketWriter() (N.PacketWriter, []N.CountFunc) {
return c.PacketConn, []N.CountFunc{c.makeCountFunc(&c.rec.upload)}
}
func (c *trackedPacketConn) Upstream() any { return c.PacketConn }
func (c *trackedPacketConn) ReaderReplaceable() bool { return true }
func (c *trackedPacketConn) WriterReplaceable() bool { return true }
+348
View File
@@ -0,0 +1,348 @@
package singbox
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"sync"
"time"
box "github.com/sagernet/sing-box"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/include"
singLog "github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing/common/auth"
singJSON "github.com/sagernet/sing/common/json"
"github.com/sagernet/sing/service"
"golang.org/x/time/rate"
"github.com/cedar2025/xboard-node/internal/config"
"github.com/cedar2025/xboard-node/internal/kernel"
"github.com/cedar2025/xboard-node/internal/panel"
)
// drainTimeout is how long stop() waits for in-flight connections to finish
// naturally after closing all listen sockets, before hard-killing them.
const drainTimeout = 5 * time.Second
// SingBox implements kernel.Kernel by embedding sing-box as a Go library.
//
// Connection tracking is done entirely in-process via ConnTracker, which is
// registered as an adapter.ConnectionTracker with sing-box's router. This
// avoids the HTTP/JSON overhead of the Clash API (saves 5-15 MB at 500+
// concurrent connections).
type SingBox struct {
cfg config.KernelConfig
mu sync.Mutex
box *box.Box
ctx context.Context
cancel context.CancelFunc
// connTracker is our lightweight in-process byte/IP tracker.
// Created fresh on every ApplyConfig (full restart).
// Survives ReloadUsers (hot-swap) since live connections persist.
connTracker *ConnTracker
// speedLimitFunc resolves a user UUID to a *rate.Limiter.
// Set once by SetSpeedLimitFunc and forwarded to every new ConnTracker.
speedLimitFunc func(string) *rate.Limiter
// trackerRegistered prevents duplicate AppendTracker calls on the same
// Router instance during ReloadUsers. Reset to false on full restart.
trackerRegistered bool
}
func New(cfg config.KernelConfig) *SingBox {
return &SingBox{cfg: cfg}
}
func (s *SingBox) Name() string { return "sing-box" }
func (s *SingBox) ApplyConfig(nodeConfig *panel.NodeConfig, users []panel.User, certFile, keyFile string) error {
s.mu.Lock()
defer s.mu.Unlock()
cfgMap := buildConfig(s.cfg, nodeConfig, users, certFile, keyFile)
data, err := json.Marshal(cfgMap)
if err != nil {
return fmt.Errorf("marshal config: %w", err)
}
slog.Debug("sing-box config generated", "len", len(data))
ctx, cancel := context.WithCancel(context.Background())
ctx = include.Context(ctx)
opts, err := singJSON.UnmarshalExtendedContext[option.Options](ctx, data)
if err != nil {
cancel()
return fmt.Errorf("parse sing-box options: %w", err)
}
s.stop()
instance, err := box.New(box.Options{
Context: ctx,
Options: opts,
})
if err != nil {
cancel()
return fmt.Errorf("create sing-box instance: %w", err)
}
if err := instance.Start(); err != nil {
instance.Close()
cancel()
return fmt.Errorf("start sing-box: %w", err)
}
s.box = instance
s.ctx = ctx
s.cancel = cancel
// Fresh tracker on full restart (all connections were terminated by s.stop).
s.connTracker = NewConnTracker(0)
s.connTracker.SetUserMap(buildUserMap(users))
if s.speedLimitFunc != nil {
s.connTracker.SetSpeedLimitFunc(s.speedLimitFunc)
}
s.trackerRegistered = false
s.registerTracker(ctx)
slog.Info("sing-box started (in-process)", "users", len(users))
return nil
}
// 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 {
s.mu.Lock()
defer s.mu.Unlock()
if s.box == nil {
return fmt.Errorf("not running")
}
cfgMap := buildConfig(s.cfg, nodeConfig, users, certFile, keyFile)
data, err := json.Marshal(cfgMap)
if err != nil {
return fmt.Errorf("marshal config: %w", err)
}
opts, err := singJSON.UnmarshalExtendedContext[option.Options](s.ctx, data)
if err != nil {
return fmt.Errorf("parse options: %w", err)
}
im := service.FromContext[adapter.InboundManager](s.ctx)
if im == nil {
return fmt.Errorf("inbound manager not available")
}
router := service.FromContext[adapter.Router](s.ctx)
if router == nil {
return fmt.Errorf("router not available")
}
// Update routing rules
if err := router.UpdateRules(opts.Route.Rules, opts.Route.RuleSet); err != nil {
slog.Warn("routing reload failed", "error", err)
} else {
slog.Info("sing-box routing reloaded")
}
nopFactory := singLog.NewNOPFactory()
for _, inb := range opts.Inbounds {
tag := inb.Tag
if existing, ok := im.Get(tag); ok && existing.Type() == inb.Type {
var err error
switch v := existing.(type) {
case adapter.UpdatableInbound[option.VMessUser]:
if opts, ok := inb.Options.(*option.VMessInboundOptions); ok {
err = v.UpdateUsers(opts.Users)
}
case adapter.UpdatableInbound[option.VLESSUser]:
if opts, ok := inb.Options.(*option.VLESSInboundOptions); ok {
err = v.UpdateUsers(opts.Users)
}
case adapter.UpdatableInbound[option.TrojanUser]:
if opts, ok := inb.Options.(*option.TrojanInboundOptions); ok {
err = v.UpdateUsers(opts.Users)
}
case adapter.UpdatableInbound[option.Hysteria2User]:
if opts, ok := inb.Options.(*option.Hysteria2InboundOptions); ok {
err = v.UpdateUsers(opts.Users)
}
case adapter.UpdatableShadowsocksInbound:
if opts, ok := inb.Options.(*option.ShadowsocksInboundOptions); ok {
err = v.UpdateUsersByOptions(opts.Users)
}
case adapter.UpdatableInbound[option.TUICUser]:
if opts, ok := inb.Options.(*option.TUICInboundOptions); ok {
err = v.UpdateUsers(opts.Users)
}
case adapter.UpdatableInbound[option.AnyTLSUser]:
if opts, ok := inb.Options.(*option.AnyTLSInboundOptions); ok {
err = v.UpdateUsers(opts.Users)
}
case adapter.UpdatableInbound[auth.User]:
switch opts := inb.Options.(type) {
case *option.NaiveInboundOptions:
err = v.UpdateUsers(opts.Users)
case *option.SocksInboundOptions:
err = v.UpdateUsers(opts.Users)
case *option.HTTPMixedInboundOptions:
err = v.UpdateUsers(opts.Users)
}
}
if err == nil {
continue
}
slog.Warn("incremental update failed, falling back to recreate", "tag", tag, "error", err)
}
// Remove the existing inbound first so im.Create() can bind the same port.
// im.Create() cannot atomically swap a TCP listener — it tries to start the
// new socket before the old one is closed, causing "address already in use".
// The brief listen gap (< 1 ms) is far less disruptive than a full restart.
_ = im.Remove(tag) // ignore error when tag doesn't exist yet
logger := nopFactory.NewLogger(fmt.Sprintf("inbound/%s[%s]", inb.Type, tag))
if err := im.Create(s.ctx, router, logger, tag, inb.Type, inb.Options); err != nil {
return fmt.Errorf("recreate inbound %s: %w", tag, err)
}
}
// Trackers remain registered on the Router (which survives ReloadUsers).
// Only update the user map — do NOT re-register or traffic is double-counted.
if s.connTracker != nil {
s.connTracker.SetUserMap(buildUserMap(users))
}
slog.Info("sing-box reloaded (users and routes hot-swapped)", "users", len(users))
return nil
}
// registerTracker wires the ConnTracker to the current Router exactly once.
// The ConnTracker handles both byte counting and optional rate limiting
// in a single wrapper, so no additional trackers are needed.
func (s *SingBox) registerTracker(ctx context.Context) {
if s.trackerRegistered {
return
}
router := service.FromContext[adapter.Router](ctx)
if router == nil {
return
}
if s.connTracker != nil {
router.AppendTracker(s.connTracker)
}
s.trackerRegistered = true
}
func (s *SingBox) Stop() {
s.mu.Lock()
defer s.mu.Unlock()
s.stop()
}
func (s *SingBox) stop() {
if s.box == nil {
return
}
// Step 1: close all listen sockets so no new connections are accepted.
// im.Close() sets m.started=false so the subsequent box.Close() call is a no-op
// for inbounds (avoids double-close), but box.Close() still tears down routers
// and outbounds.
if im := service.FromContext[adapter.InboundManager](s.ctx); im != nil {
_ = im.Close()
}
// Step 2: wait for in-flight connections to drain naturally (best-effort).
if s.connTracker != nil {
deadline := time.Now().Add(drainTimeout)
for time.Now().Before(deadline) {
if s.connTracker.ActiveCount() == 0 {
break
}
time.Sleep(100 * time.Millisecond)
}
}
// Step 3: hard-close everything that is still open.
s.box.Close()
s.box = nil
if s.cancel != nil {
s.cancel()
s.cancel = nil
}
s.ctx = nil
}
func (s *SingBox) IsRunning() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.box != nil
}
// connTrackerLocked returns the connTracker under a read-optimised lock.
func (s *SingBox) connTrackerSafe() *ConnTracker {
s.mu.Lock()
ct := s.connTracker
s.mu.Unlock()
return ct
}
// SetSpeedLimitFunc configures per-user bandwidth throttling.
// The function is forwarded to the ConnTracker, which embeds the rate limiter
// in each tracked connection wrapper. This merges byte counting and rate limiting
// into a single CountFunc callback, allowing splice/zero-copy paths.
func (s *SingBox) SetSpeedLimitFunc(fn func(uuid string) *rate.Limiter) {
s.mu.Lock()
defer s.mu.Unlock()
s.speedLimitFunc = fn
if s.connTracker != nil {
s.connTracker.SetSpeedLimitFunc(fn)
}
}
// GetConnections returns a snapshot of all active and recently-closed
// connections directly from the in-process ConnTracker.
func (s *SingBox) GetConnections(_ context.Context) ([]kernel.Connection, error) {
ct := s.connTrackerSafe()
if ct == nil {
return nil, nil
}
conns := ct.Snapshot()
slog.Debug("GetConnections snapshot", "count", len(conns))
return conns, nil
}
// CloseConnection force-closes a specific connection by its ID.
func (s *SingBox) CloseConnection(_ context.Context, connID string) error {
ct := s.connTrackerSafe()
if ct == nil {
return fmt.Errorf("not running")
}
if !ct.CloseByID(connID) {
slog.Debug("CloseConnection: connection not found (already closed?)", "id", connID)
}
return nil
}
// 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 {
m := make(map[string]int, len(users))
for _, u := range users {
m[u.UUID] = u.ID
}
return m
}