feat: machine mode, PEM-only certs, ECH support, multi-instance config, batch ops, xbctl

This commit is contained in:
xboard
2026-04-16 23:06:58 +08:00
parent 598b6b531b
commit c5bcbc3299
52 changed files with 6676 additions and 1348 deletions
+243 -107
View File
@@ -18,7 +18,7 @@ import (
// M is a shorthand for building JSON-like maps
type M = map[string]interface{}
func buildConfig(kcfg config.KernelConfig, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M {
func buildConfig(kcfg config.KernelConfig, nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
var outbounds []M
tags := make(map[string]bool)
@@ -53,18 +53,18 @@ func buildConfig(kcfg config.KernelConfig, nc *model.NodeSpec, users []model.Use
"outbounds": outbounds,
}
inbound := buildInbound(nc, users, certFile, keyFile)
inbound := buildInbound(nc, users, tc)
if inbound != nil {
cfg["inbounds"] = []M{inbound}
}
// Merge panel routes and static config routes
cfg["route"] = buildRoutes(nc.Routes, mergeRouteList(nc.CustomRoutes, kcfg.CustomRoute))
cfg["route"] = buildRoutes(nc.Routes, nc.CustomRouteRules, 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) {
if kernel.NeedsGeoIP(nc.Routes) || kernel.NeedsGeoSite(nc.Routes) || kernel.NeedsGeoIPRules(nc.CustomRouteRules) || kernel.NeedsGeoSiteRules(nc.CustomRouteRules) {
cfg["experimental"] = M{
"cache_file": M{
"enabled": true,
@@ -144,10 +144,18 @@ func mergeRouteList(a, b []map[string]any) []map[string]any {
return res
}
func buildRoutes(panelRoutes []model.RouteRule, custom []map[string]any) M {
func buildRoutes(panelRoutes []model.RouteRule, customRules []model.CustomRouteRule, custom []map[string]any) M {
var rules []M
// Custom Routes (Panel-pushed or Local) go FIRST to have highest priority
// Structured custom routes now take the highest priority for panel-managed overrides.
for _, rule := range customRules {
if rule.Disabled {
continue
}
rules = append(rules, compileCustomRouteRule(rule)...)
}
// Raw custom routes remain the escape hatch, but no longer outrank structured rules.
for _, cr := range custom {
rules = append(rules, M(cr))
}
@@ -177,69 +185,8 @@ func buildRoutes(panelRoutes []model.RouteRule, custom []map[string]any) M {
},
)
// 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)
}
rules = append(rules, compilePanelRouteRule(pr)...)
}
return M{
@@ -248,6 +195,162 @@ func buildRoutes(panelRoutes []model.RouteRule, custom []map[string]any) M {
}
}
func compilePanelRouteRule(pr model.RouteRule) []M {
if len(pr.Match) == 0 {
return nil
}
var domains, cidrs []string
for _, item := range pr.Match {
item = strings.TrimSpace(item)
if item == "" {
continue
}
item = strings.TrimPrefix(item, "*.")
if strings.Contains(item, "/") {
cidrs = append(cidrs, item)
continue
}
domains = append(domains, item)
}
outbound := "block"
switch pr.Action {
case "direct":
outbound = "direct"
case "dns":
if pr.ActionValue != "" {
outbound = pr.ActionValue
} else {
outbound = "dns-out"
}
case "proxy":
if pr.ActionValue != "" {
outbound = pr.ActionValue
}
}
var compiled []M
if len(domains) > 0 {
compiled = append(compiled, M{
"domain_suffix": copyStrings(domains),
"outbound": outbound,
})
}
if len(cidrs) > 0 {
compiled = append(compiled, M{
"ip_cidr": copyStrings(cidrs),
"outbound": outbound,
})
}
return compiled
}
func compileCustomRouteRule(rule model.CustomRouteRule) []M {
if rule.Disabled {
return nil
}
outbound := singboxOutboundForAction(rule.Action)
var compiled []M
if len(rule.Match.Domains) > 0 {
compiled = append(compiled, M{
"domain": copyStrings(rule.Match.Domains),
"outbound": outbound,
})
}
if len(rule.Match.DomainSuffixes) > 0 {
compiled = append(compiled, M{
"domain_suffix": copyStrings(rule.Match.DomainSuffixes),
"outbound": outbound,
})
}
if len(rule.Match.IPCIDRs) > 0 {
compiled = append(compiled, M{
"ip_cidr": copyStrings(rule.Match.IPCIDRs),
"outbound": outbound,
})
}
if len(rule.Match.Ports) > 0 {
ports, portRanges := splitPorts(rule.Match.Ports)
entry := M{"outbound": outbound}
if len(ports) > 0 {
entry["port"] = ports
}
if len(portRanges) > 0 {
entry["port_range"] = portRanges
}
compiled = append(compiled, entry)
}
if len(rule.Match.Networks) > 0 {
compiled = append(compiled, M{
"network": copyStrings(rule.Match.Networks),
"outbound": outbound,
})
}
if len(rule.Match.SourceCIDRs) > 0 {
compiled = append(compiled, M{
"source_ip_cidr": copyStrings(rule.Match.SourceCIDRs),
"outbound": outbound,
})
}
if len(rule.Match.SourcePorts) > 0 {
ports, portRanges := splitPorts(rule.Match.SourcePorts)
entry := M{"outbound": outbound}
if len(ports) > 0 {
entry["source_port"] = ports
}
if len(portRanges) > 0 {
entry["source_port_range"] = portRanges
}
compiled = append(compiled, entry)
}
return compiled
}
func singboxOutboundForAction(action model.RouteAction) string {
switch action.Type {
case "direct":
return "direct"
case "route":
if action.Target != "" {
return action.Target
}
return "block"
default:
return "block"
}
}
func splitPorts(values []string) ([]int, []string) {
var ports []int
var ranges []string
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if strings.ContainsAny(value, ":-") {
ranges = append(ranges, strings.ReplaceAll(value, "-", ":"))
continue
}
if port, err := strconv.Atoi(value); err == nil {
ports = append(ports, port)
}
}
return ports, ranges
}
func copyStrings(src []string) []string {
if len(src) == 0 {
return nil
}
out := make([]string, len(src))
copy(out, src)
return out
}
func mergeCustomSingbox(cfg M, kcfg config.KernelConfig) {
custom, err := kernel.LoadCustomConfig(kcfg.CustomConfig)
if err != nil {
@@ -326,7 +429,7 @@ func mergeCustomSingboxRoute(cfg M, customRoute map[string]any) {
}
}
func buildInbound(nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M {
func buildInbound(nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
base := M{
"tag": nc.Protocol + "-in",
"listen": "::",
@@ -337,23 +440,23 @@ func buildInbound(nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile
case "shadowsocks":
return buildShadowsocks(base, nc, users)
case "vmess":
return buildVMess(base, nc, users, certFile, keyFile)
return buildVMess(base, nc, users, tc)
case "vless":
return buildVLESS(base, nc, users, certFile, keyFile)
return buildVLESS(base, nc, users, tc)
case "trojan":
return buildTrojan(base, nc, users, certFile, keyFile)
return buildTrojan(base, nc, users, tc)
case "hysteria":
return buildHysteria(base, nc, users, certFile, keyFile)
return buildHysteria(base, nc, users, tc)
case "tuic":
return buildTUIC(base, nc, users, certFile, keyFile)
return buildTUIC(base, nc, users, tc)
case "anytls":
return buildAnyTLS(base, nc, users, certFile, keyFile)
return buildAnyTLS(base, nc, users, tc)
case "naive":
return buildNaive(base, nc, users, certFile, keyFile)
return buildNaive(base, nc, users, tc)
case "socks":
return buildSocks(base, users)
case "http":
return buildHTTP(base, nc, users, certFile, keyFile)
return buildHTTP(base, nc, users, tc)
case "mieru":
return buildMieru(base, nc, users)
default:
@@ -433,7 +536,7 @@ func buildShadowsocks(base M, nc *model.NodeSpec, users []model.UserSpec) M {
return base
}
func buildVMess(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M {
func buildVMess(base M, nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
base["type"] = "vmess"
userList := make([]M, 0, len(users))
@@ -451,7 +554,7 @@ func buildVMess(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, ke
applyMultiplex(base, nc)
if nc.TLS == 1 {
if tls := buildTLSConfig(nc, certFile, keyFile); tls != nil {
if tls := buildTLSConfig(nc, tc); tls != nil {
base["tls"] = tls
}
}
@@ -459,7 +562,7 @@ func buildVMess(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, ke
return base
}
func buildVLESS(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M {
func buildVLESS(base M, nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
if nc.Decryption != "" && nc.Decryption != "none" {
nlog.Core().Warn("sing-box does not support VLESS encryption (decryption), use xray kernel for this feature")
}
@@ -484,7 +587,7 @@ func buildVLESS(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, ke
applyMultiplex(base, nc)
if nc.TLS == 1 {
if tls := buildTLSConfig(nc, certFile, keyFile); tls != nil {
if tls := buildTLSConfig(nc, tc); tls != nil {
base["tls"] = tls
}
} else if nc.TLS == 2 {
@@ -494,7 +597,7 @@ func buildVLESS(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, ke
return base
}
func buildTrojan(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M {
func buildTrojan(base M, nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
base["type"] = "trojan"
userList := make([]M, len(users))
@@ -512,7 +615,7 @@ func buildTrojan(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, k
applyMultiplex(base, nc)
if nc.TLS == 1 {
if tls := buildTLSConfig(nc, certFile, keyFile); tls != nil {
if tls := buildTLSConfig(nc, tc); tls != nil {
base["tls"] = tls
}
} else if nc.TLS == 2 {
@@ -521,7 +624,7 @@ func buildTrojan(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, k
// If still no TLS/Reality but cert paths exist, enable TLS (panel may omit tls=1).
if _, ok := base["tls"]; !ok {
if tls := buildTLSConfig(nc, certFile, keyFile); tls != nil {
if tls := buildTLSConfig(nc, tc); tls != nil {
base["tls"] = tls
}
}
@@ -533,7 +636,7 @@ func buildTrojan(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, k
return base
}
func buildHysteria(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M {
func buildHysteria(base M, nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
if nc.Version == 2 {
base["type"] = "hysteria2"
@@ -571,7 +674,7 @@ func buildHysteria(base M, nc *model.NodeSpec, users []model.UserSpec, certFile,
}
}
tls := buildTLSConfig(nc, certFile, keyFile)
tls := buildTLSConfig(nc, tc)
if tls == nil {
nlog.Core().Warn("hysteria requires TLS certificate files on disk; configure cert_mode (self, file, http, dns, or content). Sing-box will not start this inbound without tls.")
return base
@@ -584,7 +687,7 @@ func buildHysteria(base M, nc *model.NodeSpec, users []model.UserSpec, certFile,
return base
}
func buildTUIC(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M {
func buildTUIC(base M, nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
base["type"] = "tuic"
userList := make([]M, 0, len(users))
@@ -601,7 +704,7 @@ func buildTUIC(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, key
base["congestion_control"] = nc.CongestionControl
}
tls := buildTLSConfig(nc, certFile, keyFile)
tls := buildTLSConfig(nc, tc)
if tls == nil {
nlog.Core().Warn("tuic requires TLS certificate files on disk; configure cert_mode (self, file, http, dns, or content). Sing-box will not start this inbound without tls.")
return base
@@ -614,7 +717,7 @@ func buildTUIC(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, key
return base
}
func buildAnyTLS(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M {
func buildAnyTLS(base M, nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
base["type"] = "anytls"
userList := make([]M, 0, len(users))
@@ -630,7 +733,7 @@ func buildAnyTLS(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, k
base["padding_scheme"] = nc.PaddingScheme
}
if tls := buildTLSConfig(nc, certFile, keyFile); tls != nil {
if tls := buildTLSConfig(nc, tc); tls != nil {
base["tls"] = tls
} else {
nlog.Core().Warn("anytls requires TLS certificate files on disk; configure cert_mode (self, file, http, dns, or content). Sing-box will not start this inbound without tls.")
@@ -638,7 +741,7 @@ func buildAnyTLS(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, k
return base
}
func buildNaive(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M {
func buildNaive(base M, nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
base["type"] = "naive"
userList := make([]M, 0, len(users))
@@ -651,7 +754,7 @@ func buildNaive(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, ke
base["users"] = userList
if nc.TLS == 1 {
if tls := buildTLSConfig(nc, certFile, keyFile); tls != nil {
if tls := buildTLSConfig(nc, tc); tls != nil {
base["tls"] = tls
}
}
@@ -672,7 +775,7 @@ func buildSocks(base M, users []model.UserSpec) M {
return base
}
func buildHTTP(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M {
func buildHTTP(base M, nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
base["type"] = "http"
userList := make([]M, 0, len(users))
@@ -686,7 +789,7 @@ func buildHTTP(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, key
applyProxyProtocol(base, nc)
if nc.TLS == 1 {
if tls := buildTLSConfig(nc, certFile, keyFile); tls != nil {
if tls := buildTLSConfig(nc, tc); tls != nil {
base["tls"] = tls
}
}
@@ -747,38 +850,40 @@ func applyTransport(base M, nc *model.NodeSpec) {
base["transport"] = transport
}
// buildTLSConfig returns sing-box inbound TLS options when certificate and key paths exist.
// 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 *model.NodeSpec, certFile, keyFile string) M {
if certFile == "" || keyFile == "" {
// buildTLSConfig returns sing-box inbound TLS options when certificate material
// is available. Returns nil if no TLS material is provided.
func buildTLSConfig(nc *model.NodeSpec, tc kernel.TLSCert) M {
if !tc.HasCert() {
return nil
}
tls := M{"enabled": true}
t := M{"enabled": true}
serverName := nc.ServerName
if serverName == "" && nc.Host != "" {
serverName = nc.Host
}
if serverName != "" {
tls["server_name"] = serverName
t["server_name"] = serverName
}
if nc.TLSSettings != nil {
if sn, ok := nc.TLSSettings["server_name"]; ok && sn != "" {
tls["server_name"] = sn
t["server_name"] = sn
}
if alpn, ok := nc.TLSSettings["alpn"]; ok {
tls["alpn"] = alpn
t["alpn"] = alpn
}
// ECH server-side: only key/key_path needed for inbound
if ech := extractECHInbound(nc.TLSSettings); ech != nil {
t["ech"] = ech
}
}
tls["certificate_path"] = certFile
tls["key_path"] = keyFile
t["certificate"] = []string{string(tc.CertPEM)}
t["key"] = []string{string(tc.KeyPEM)}
return tls
return t
}
func buildRealityConfig(nc *model.NodeSpec) M {
@@ -881,3 +986,34 @@ func applyProxyProtocol(base M, nc *model.NodeSpec) {
// }
// base["proxy_protocol"] = true
}
// extractECHInbound extracts ECH config for sing-box server (inbound).
// sing-box InboundECHOptions needs: enabled, key (PEM array), key_path.
func extractECHInbound(tlsSettings map[string]interface{}) M {
echRaw, ok := tlsSettings["ech"]
if !ok {
return nil
}
echMap, ok := echRaw.(map[string]interface{})
if !ok {
return nil
}
enabled, _ := echMap["enabled"].(bool)
if !enabled {
return nil
}
ech := M{"enabled": true}
hasSource := false
if key, _ := echMap["key"].(string); key != "" {
ech["key"] = []string{key} // sing-box expects PEM as-is
hasSource = true
}
if keyPath, _ := echMap["key_path"].(string); keyPath != "" {
ech["key_path"] = keyPath
hasSource = true
}
if !hasSource {
nlog.Core().Warn("ECH enabled but no key or key_path provided")
}
return ech // let sing-box report a clear error if key is missing
}
+166 -48
View File
@@ -2,9 +2,11 @@ package singbox
import (
"encoding/json"
"reflect"
"testing"
"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/panel"
)
@@ -33,7 +35,7 @@ func TestBuildInbound_Shadowsocks(t *testing.T) {
ServerPort: 111,
Cipher: "aes-128-gcm",
}
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
assertMapValue(t, inbound, "type", "shadowsocks")
assertMapValue(t, inbound, "method", "aes-128-gcm")
assertMapValue(t, inbound, "listen_port", 111)
@@ -53,7 +55,7 @@ func TestBuildInbound_Shadowsocks2022(t *testing.T) {
Cipher: "2022-blake3-aes-128-gcm",
ServerKey: "base64serverkey==",
}
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
assertMapValue(t, inbound, "method", "2022-blake3-aes-128-gcm")
assertMapValue(t, inbound, "password", "base64serverkey==")
}
@@ -65,7 +67,7 @@ func TestBuildInbound_VMess(t *testing.T) {
Protocol: "vmess",
ServerPort: 443,
}
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
assertMapValue(t, inbound, "type", "vmess")
assertMapValue(t, inbound, "tag", "vmess-in")
@@ -81,12 +83,12 @@ func TestBuildInbound_VMess_WithTLS(t *testing.T) {
TLS: 1,
ServerName: "example.com",
}
inbound := buildInbound(testNodeSpec(nc), testUsers, "/path/cert.pem", "/path/key.pem")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
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")
assertMapValue(t, tls, "certificate", []string{"CERT"})
assertMapValue(t, tls, "key", []string{"KEY"})
}
// Panel tls=1 with cert_mode none (no paths): inbound must be plain — TLS terminated at nginx/CDN.
@@ -98,7 +100,7 @@ func TestBuildInbound_VMess_TLS_PanelOn_NoCertFiles(t *testing.T) {
TLS: 1,
ServerName: "example.com",
}
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
if _, exists := inbound["tls"]; exists {
t.Fatal("expected no tls block when cert/key paths are empty (nginx offload)")
}
@@ -110,7 +112,7 @@ func TestBuildInbound_VMess_NoTLS(t *testing.T) {
ServerPort: 80,
TLS: 0,
}
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
if _, exists := inbound["tls"]; exists {
t.Error("should not have TLS when tls=0")
}
@@ -128,7 +130,7 @@ func TestBuildInbound_VMess_WithWebSocket(t *testing.T) {
},
},
}
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
transport := inbound["transport"].(M)
assertMapValue(t, transport, "type", "ws")
assertMapValue(t, transport, "path", "/ws")
@@ -143,7 +145,7 @@ func TestBuildInbound_VMess_WithGRPC(t *testing.T) {
"serviceName": "mygrpc",
},
}
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
transport := inbound["transport"].(M)
assertMapValue(t, transport, "type", "grpc")
assertMapValue(t, transport, "service_name", "mygrpc")
@@ -158,7 +160,7 @@ func TestBuildInbound_VMess_WithGRPCSnakeCaseServiceName(t *testing.T) {
"service_name": "mygrpc-snake",
},
}
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
transport := inbound["transport"].(M)
assertMapValue(t, transport, "type", "grpc")
assertMapValue(t, transport, "service_name", "mygrpc-snake")
@@ -174,7 +176,7 @@ func TestBuildInbound_VMess_WithH2(t *testing.T) {
"host": "example.com",
},
}
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
transport := inbound["transport"].(M)
assertMapValue(t, transport, "type", "http")
assertMapValue(t, transport, "path", "/h2path")
@@ -190,7 +192,7 @@ func TestBuildInbound_VMess_WithHTTPUpgrade(t *testing.T) {
"host": "example.com",
},
}
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
transport := inbound["transport"].(M)
assertMapValue(t, transport, "type", "httpupgrade")
assertMapValue(t, transport, "path", "/upgrade")
@@ -204,7 +206,7 @@ func TestBuildInbound_VLESS(t *testing.T) {
Protocol: "vless",
ServerPort: 443,
}
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
assertMapValue(t, inbound, "type", "vless")
users := inbound["users"].([]M)
@@ -217,7 +219,7 @@ func TestBuildInbound_VLESS_WithFlow(t *testing.T) {
ServerPort: 443,
Flow: "xtls-rprx-vision",
}
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
users := inbound["users"].([]M)
assertMapValue(t, users[0], "flow", "xtls-rprx-vision")
}
@@ -234,7 +236,7 @@ func TestBuildInbound_VLESS_Reality(t *testing.T) {
"server_name": "www.example.com",
},
}
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
tls := inbound["tls"].(M)
assertMapValue(t, tls, "enabled", true)
@@ -258,7 +260,7 @@ func TestBuildInbound_VLESS_Reality_ShortIDArray(t *testing.T) {
"dest": "example.com",
},
}
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
reality := inbound["tls"].(M)["reality"].(M)
ids := reality["short_id"].([]string)
if len(ids) != 2 {
@@ -268,16 +270,29 @@ func TestBuildInbound_VLESS_Reality_ShortIDArray(t *testing.T) {
// --- Trojan ---
func TestBuildInbound_Trojan(t *testing.T) {
func TestBuildInbound_Trojan_Reality(t *testing.T) {
nc := &panel.NodeConfig{
Protocol: "trojan",
ServerPort: 443,
TLS: 2,
TLSSettings: map[string]interface{}{
"private_key": "test-private-key",
"dest": "gateway.example.com:8443",
"server_name": "cdn.example.com",
},
}
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
assertMapValue(t, inbound, "type", "trojan")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
tls := inbound["tls"].(M)
assertMapValue(t, tls, "enabled", true)
assertMapValue(t, tls, "server_name", "cdn.example.com")
users := inbound["users"].([]M)
assertMapValue(t, users[0], "password", "aaaaaaaa-1111-2222-3333-444444444444")
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", "gateway.example.com")
assertMapValue(t, handshake, "server_port", 8443)
}
func TestBuildInbound_Trojan_NoTLS(t *testing.T) {
@@ -286,7 +301,7 @@ func TestBuildInbound_Trojan_NoTLS(t *testing.T) {
ServerPort: 80,
TLS: 0,
}
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
// 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")
@@ -300,7 +315,7 @@ func TestBuildInbound_Trojan_WithTLS(t *testing.T) {
TLS: 1,
ServerName: "example.com",
}
inbound := buildInbound(testNodeSpec(nc), testUsers, "/c.pem", "/k.pem")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
tls := inbound["tls"].(M)
assertMapValue(t, tls, "enabled", true)
}
@@ -313,7 +328,7 @@ func TestBuildInbound_Hysteria2(t *testing.T) {
ServerPort: 444,
Version: 2,
}
inbound := buildInbound(testNodeSpec(nc), testUsers, "/c.pem", "/k.pem")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
assertMapValue(t, inbound, "type", "hysteria2")
users := inbound["users"].([]M)
@@ -331,7 +346,7 @@ func TestBuildInbound_Hysteria2_WithObfs(t *testing.T) {
Obfs: "salamander",
ObfsPassword: "secret",
}
inbound := buildInbound(testNodeSpec(nc), testUsers, "/c.pem", "/k.pem")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
obfs := inbound["obfs"].(M)
assertMapValue(t, obfs, "type", "salamander")
assertMapValue(t, obfs, "password", "secret")
@@ -345,7 +360,7 @@ func TestBuildInbound_Hysteria1(t *testing.T) {
UpMbps: 100,
DownMbps: 200,
}
inbound := buildInbound(testNodeSpec(nc), testUsers, "/c.pem", "/k.pem")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
assertMapValue(t, inbound, "type", "hysteria")
assertMapValue(t, inbound, "up_mbps", 100)
assertMapValue(t, inbound, "down_mbps", 200)
@@ -362,7 +377,7 @@ func TestBuildInbound_TUIC(t *testing.T) {
ServerPort: 555,
CongestionControl: "bbr",
}
inbound := buildInbound(testNodeSpec(nc), testUsers, "/c.pem", "/k.pem")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
assertMapValue(t, inbound, "type", "tuic")
assertMapValue(t, inbound, "congestion_control", "bbr")
@@ -379,7 +394,7 @@ func TestBuildInbound_AnyTLS(t *testing.T) {
ServerPort: 443,
PaddingScheme: "stop=8\n0=30-30",
}
inbound := buildInbound(testNodeSpec(nc), testUsers, "/c.pem", "/k.pem")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
assertMapValue(t, inbound, "type", "anytls")
assertMapValue(t, inbound, "padding_scheme", "stop=8\n0=30-30")
@@ -392,7 +407,7 @@ func TestBuildInbound_AnyTLS_NoPaddingScheme(t *testing.T) {
Protocol: "anytls",
ServerPort: 443,
}
inbound := buildInbound(testNodeSpec(nc), testUsers, "/c.pem", "/k.pem")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
if _, exists := inbound["padding_scheme"]; exists {
t.Error("should not have padding_scheme when empty")
}
@@ -406,7 +421,7 @@ func TestBuildInbound_Naive(t *testing.T) {
ServerPort: 443,
TLS: 1,
}
inbound := buildInbound(testNodeSpec(nc), testUsers, "/c.pem", "/k.pem")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
assertMapValue(t, inbound, "type", "naive")
users := inbound["users"].([]M)
@@ -424,7 +439,7 @@ func TestBuildInbound_Socks(t *testing.T) {
Protocol: "socks",
ServerPort: 1080,
}
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
assertMapValue(t, inbound, "type", "socks")
users := inbound["users"].([]M)
@@ -443,7 +458,7 @@ func TestBuildInbound_HTTP(t *testing.T) {
Protocol: "http",
ServerPort: 8080,
}
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
assertMapValue(t, inbound, "type", "http")
users := inbound["users"].([]M)
@@ -456,7 +471,7 @@ func TestBuildInbound_HTTP_WithTLS(t *testing.T) {
ServerPort: 443,
TLS: 1,
}
inbound := buildInbound(testNodeSpec(nc), testUsers, "/c.pem", "/k.pem")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
tls := inbound["tls"].(M)
assertMapValue(t, tls, "enabled", true)
}
@@ -468,7 +483,7 @@ func TestBuildInbound_Unknown(t *testing.T) {
Protocol: "unknown-proto",
ServerPort: 999,
}
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
if inbound != nil {
t.Errorf("unknown protocol should return nil, got %v", inbound)
}
@@ -483,7 +498,7 @@ func TestBuildConfig(t *testing.T) {
ServerPort: 111,
Cipher: "aes-128-gcm",
}
cfg := buildConfig(kcfg, testNodeSpec(nc), testUsers, "", "")
cfg := buildConfig(kcfg, testNodeSpec(nc), testUsers, kernel.TLSCert{})
data, err := json.Marshal(cfg)
if err != nil {
@@ -527,7 +542,7 @@ func TestBuildConfig_OutboundPriority(t *testing.T) {
},
}
cfg := buildConfig(kcfg, testNodeSpec(nc), testUsers, "", "")
cfg := buildConfig(kcfg, testNodeSpec(nc), testUsers, kernel.TLSCert{})
outbounds := cfg["outbounds"].([]M)
// We have 2 overrides in input, so we should have exactly 2 outbounds total
@@ -580,7 +595,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"}, testNodeSpec(tc.nc), testUsers, "/c.pem", "/k.pem")
cfg := buildConfig(config.KernelConfig{LogLevel: "warn"}, testNodeSpec(tc.nc), testUsers, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
data, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal %s: %v", tc.name, err)
@@ -596,7 +611,7 @@ func TestBuildConfig_AllProtocols_ValidJSON(t *testing.T) {
// --- Routes ---
func TestBuildRoutes_Default(t *testing.T) {
route := buildRoutes(nil, nil)
route := buildRoutes(nil, nil, nil)
assertMapValue(t, route, "final", "direct")
rules := route["rules"].([]M)
@@ -613,7 +628,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(testRouteRules(rules), nil)
route := buildRoutes(testRouteRules(rules), nil, nil)
allRules := route["rules"].([]M)
if len(allRules) != 5 {
@@ -638,7 +653,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(testRouteRules(rules), nil)
route := buildRoutes(testRouteRules(rules), nil, nil)
allRules := route["rules"].([]M)
// 2 default private-IP rules + 1 domain rule + 1 CIDR rule + 1 domain rule = 5
@@ -668,20 +683,80 @@ func TestBuildRoutes_MultiMatch(t *testing.T) {
assertMapValue(t, allRules[4], "outbound", "direct")
}
func TestBuildRoutes_WithCustomRouteRules(t *testing.T) {
customRules := []model.CustomRouteRule{
{
Name: "direct-mixed",
Match: model.RouteMatch{
Domains: []string{"full.example.com"},
DomainSuffixes: []string{"example.org"},
IPCIDRs: []string{"1.1.1.0/24"},
Ports: []string{"53", "1000-1002"},
Networks: []string{"tcp"},
SourceCIDRs: []string{"10.10.0.0/16"},
SourcePorts: []string{"2000-2001"},
},
Action: model.RouteAction{Type: "direct"},
},
}
route := buildRoutes(nil, customRules, nil)
allRules := route["rules"].([]M)
if len(allRules) != 9 {
t.Fatalf("rules count: got %d, want 9", len(allRules))
}
if allRules[0]["domain"].([]string)[0] != "full.example.com" {
t.Fatalf("unexpected exact domain rule: %v", allRules[0])
}
if allRules[1]["domain_suffix"].([]string)[0] != "example.org" {
t.Fatalf("unexpected domain suffix rule: %v", allRules[1])
}
if allRules[2]["ip_cidr"].([]string)[0] != "1.1.1.0/24" {
t.Fatalf("unexpected ip cidr rule: %v", allRules[2])
}
if allRules[3]["port"].([]int)[0] != 53 {
t.Fatalf("unexpected port rule: %v", allRules[3])
}
if allRules[4]["network"].([]string)[0] != "tcp" {
t.Fatalf("unexpected network rule: %v", allRules[4])
}
if allRules[5]["source_ip_cidr"].([]string)[0] != "10.10.0.0/16" {
t.Fatalf("unexpected source cidr rule: %v", allRules[5])
}
if allRules[6]["source_port_range"].([]string)[0] != "2000:2001" {
t.Fatalf("unexpected source port rule: %v", allRules[6])
}
}
func TestBuildRoutes_StructuredCustomRulesRemainFirst(t *testing.T) {
raw := []map[string]any{{"domain": []string{"raw.example.com"}, "outbound": "raw-tag"}}
custom := []model.CustomRouteRule{{
Match: model.RouteMatch{DomainSuffixes: []string{"structured.example"}},
Action: model.RouteAction{Type: "direct"},
}}
route := buildRoutes(nil, custom, raw)
allRules := route["rules"].([]M)
if allRules[0]["outbound"] != "direct" {
t.Fatalf("expected structured route first, got %v", allRules[0]["outbound"])
}
if allRules[1]["outbound"] != "raw-tag" {
t.Fatalf("expected raw custom route second, got %v", allRules[1]["outbound"])
}
}
// --- TLS Config ---
func TestBuildTLSConfig_WithCert(t *testing.T) {
nc := &panel.NodeConfig{ServerName: "example.com"}
tls := buildTLSConfig(testNodeSpec(nc), "/cert.pem", "/key.pem")
tls := buildTLSConfig(testNodeSpec(nc), kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
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")
assertMapValue(t, tls, "certificate", []string{"CERT"})
assertMapValue(t, tls, "key", []string{"KEY"})
}
func TestBuildTLSConfig_NoCert(t *testing.T) {
nc := &panel.NodeConfig{ServerName: "example.com"}
tls := buildTLSConfig(testNodeSpec(nc), "", "")
tls := buildTLSConfig(testNodeSpec(nc), kernel.TLSCert{})
if tls != nil {
t.Fatalf("expected nil tls config when cert paths are empty, got %+v", tls)
}
@@ -689,7 +764,7 @@ func TestBuildTLSConfig_NoCert(t *testing.T) {
func TestBuildTLSConfig_FallbackToHost(t *testing.T) {
nc := &panel.NodeConfig{Host: "fallback.com"}
tls := buildTLSConfig(testNodeSpec(nc), "/c.pem", "/k.pem")
tls := buildTLSConfig(testNodeSpec(nc), kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
assertMapValue(t, tls, "server_name", "fallback.com")
}
@@ -700,7 +775,7 @@ func TestBuildTLSConfig_TLSSettingsOverride(t *testing.T) {
"server_name": "override.com",
},
}
tls := buildTLSConfig(testNodeSpec(nc), "/c.pem", "/k.pem")
tls := buildTLSConfig(testNodeSpec(nc), kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
assertMapValue(t, tls, "server_name", "override.com")
}
@@ -764,8 +839,51 @@ func assertMapValue(t *testing.T, m M, key string, expected interface{}) {
t.Errorf("%s: got %v (type %T), want %d", key, val, val, e)
}
default:
if val != expected {
if !reflect.DeepEqual(val, expected) {
t.Errorf("%s: got %v, want %v", key, val, expected)
}
}
}
func TestExtractECHInbound(t *testing.T) {
tests := []struct {
name string
tls map[string]interface{}
expectNil bool
expectKey []string
}{
{"no ech", map[string]interface{}{}, true, nil},
{"disabled", map[string]interface{}{"ech": map[string]interface{}{"enabled": false}}, true, nil},
{"enabled with key", map[string]interface{}{"ech": map[string]interface{}{
"enabled": true,
"key": "-----BEGIN ECH KEYS-----\nABCD\n-----END ECH KEYS-----\n",
}}, false, []string{"-----BEGIN ECH KEYS-----\nABCD\n-----END ECH KEYS-----\n"}},
{"enabled with key_path", map[string]interface{}{"ech": map[string]interface{}{
"enabled": true,
"key_path": "/etc/ech/keys.pem",
}}, false, nil},
{"enabled no key", map[string]interface{}{"ech": map[string]interface{}{"enabled": true}}, false, nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := extractECHInbound(tt.tls)
if tt.expectNil {
if result != nil {
t.Errorf("expected nil, got %v", result)
}
return
}
if result == nil {
t.Fatal("expected non-nil result")
}
if result["enabled"] != true {
t.Errorf("expected enabled=true, got %v", result["enabled"])
}
if tt.expectKey != nil {
if !reflect.DeepEqual(result["key"], tt.expectKey) {
t.Errorf("key: got %v, want %v", result["key"], tt.expectKey)
}
}
})
}
}
+12 -13
View File
@@ -1,6 +1,7 @@
package singbox
import (
"bytes"
"context"
"encoding/json"
"fmt"
@@ -42,8 +43,7 @@ type SingBox struct {
users []model.UserSpec
nodeConfig *model.NodeSpec
certFile string
keyFile string
tls kernel.TLSCert
// connTracker is our lightweight in-process byte/IP tracker.
// Created fresh on every Start (full restart).
@@ -85,15 +85,15 @@ func (s *SingBox) Capabilities() kernel.Capabilities {
func (s *SingBox) Protocols() []string {
return []string{
"vmess", "vless", "trojan", "shadowsocks",
"hysteria2", "tuic", "naive", "socks", "http", "anytls", "mieru",
"hysteria", "hysteria2", "tuic", "naive", "socks", "http", "anytls", "mieru",
}
}
func (s *SingBox) Start(nodeConfig *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) error {
func (s *SingBox) Start(nodeConfig *model.NodeSpec, users []model.UserSpec, tls kernel.TLSCert) error {
s.mu.Lock()
defer s.mu.Unlock()
cfgMap := buildConfig(s.cfg, nodeConfig, users, certFile, keyFile)
cfgMap := buildConfig(s.cfg, nodeConfig, users, tls)
data, err := json.Marshal(cfgMap)
if err != nil {
return fmt.Errorf("marshal config: %w", err)
@@ -137,8 +137,7 @@ func (s *SingBox) Start(nodeConfig *model.NodeSpec, users []model.UserSpec, cert
s.cancel = cancel
s.users = users
s.nodeConfig = nodeConfig
s.certFile = certFile
s.keyFile = keyFile
s.tls = tls
// Fresh tracker on full restart.
s.connTracker = NewConnTracker(0)
@@ -193,7 +192,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 *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) error {
func (s *SingBox) Reload(nodeConfig *model.NodeSpec, users []model.UserSpec, tls kernel.TLSCert) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -201,7 +200,7 @@ func (s *SingBox) Reload(nodeConfig *model.NodeSpec, users []model.UserSpec, cer
return fmt.Errorf("not running")
}
cfgMap := buildConfig(s.cfg, nodeConfig, users, certFile, keyFile)
cfgMap := buildConfig(s.cfg, nodeConfig, users, tls)
data, err := json.Marshal(cfgMap)
if err != nil {
return fmt.Errorf("marshal config: %w", err)
@@ -232,7 +231,8 @@ func (s *SingBox) Reload(nodeConfig *model.NodeSpec, users []model.UserSpec, cer
nopFactory := singLog.NewNOPFactory()
// Configuration hash check for inbound reconstruction
configChanged := s.nodeConfig == nil || kernel.ComputeHash(nodeConfig, users) != kernel.ComputeHash(s.nodeConfig, s.users)
tlsChanged := !bytes.Equal(s.tls.CertPEM, tls.CertPEM) || !bytes.Equal(s.tls.KeyPEM, tls.KeyPEM)
configChanged := tlsChanged || s.nodeConfig == nil || kernel.ComputeHash(nodeConfig, users) != kernel.ComputeHash(s.nodeConfig, s.users)
for _, inb := range opts.Inbounds {
tag := inb.Tag
@@ -310,8 +310,7 @@ func (s *SingBox) Reload(nodeConfig *model.NodeSpec, users []model.UserSpec, cer
nlog.Core().Debug("sing-box reloaded", "users", len(users))
s.users = users
s.nodeConfig = nodeConfig
s.certFile = certFile
s.keyFile = keyFile
s.tls = tls
return nil
}
@@ -523,7 +522,7 @@ func (s *SingBox) UpdateUsers(users []model.UserSpec) (added, removed int, err e
// reloadInboundsLocked hot-swaps inbound users using UpdatableInbound.
// Must be called with s.mu held.
func (s *SingBox) reloadInboundsLocked(users []model.UserSpec) error {
cfgMap := buildConfig(s.cfg, s.nodeConfig, users, s.certFile, s.keyFile)
cfgMap := buildConfig(s.cfg, s.nodeConfig, users, s.tls)
data, err := json.Marshal(cfgMap)
if err != nil {
return fmt.Errorf("marshal config: %w", err)
+11
View File
@@ -23,6 +23,17 @@ func TestSingBoxCapabilities(t *testing.T) {
if caps.BuiltInTrafficStats || caps.ForceCloseConnection {
t.Fatalf("unexpected sing-box capabilities: %+v", caps)
}
protocols := s.Protocols()
found := false
for _, protocol := range protocols {
if protocol == "hysteria" {
found = true
break
}
}
if !found {
t.Fatalf("Protocols() missing hysteria: %v", protocols)
}
}