feat: machine mode, PEM-only certs, ECH support, multi-instance config, batch ops, xbctl
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
package kernel
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/model"
|
||||
)
|
||||
|
||||
func NeedsGeoIPRules(rules []model.CustomRouteRule) bool {
|
||||
for _, r := range rules {
|
||||
for _, v := range r.Match.IPCIDRs {
|
||||
if strings.HasPrefix(v, "geoip:") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, v := range r.Match.SourceCIDRs {
|
||||
if strings.HasPrefix(v, "geoip:") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func NeedsGeoSiteRules(rules []model.CustomRouteRule) bool {
|
||||
for _, r := range rules {
|
||||
for _, v := range r.Match.Domains {
|
||||
if strings.HasPrefix(v, "geosite:") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, v := range r.Match.DomainSuffixes {
|
||||
if strings.HasPrefix(v, "geosite:") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -20,6 +20,19 @@ type Capabilities struct {
|
||||
ForceCloseUser bool
|
||||
}
|
||||
|
||||
// TLSCert holds PEM-encoded TLS certificate material.
|
||||
// All cert modes (self-signed, file, ACME, panel-content) normalise to PEM
|
||||
// so kernels never need to deal with file paths.
|
||||
type TLSCert struct {
|
||||
CertPEM []byte
|
||||
KeyPEM []byte
|
||||
}
|
||||
|
||||
// HasCert reports whether a valid cert+key pair is available.
|
||||
func (t TLSCert) HasCert() bool {
|
||||
return len(t.CertPEM) > 0 && len(t.KeyPEM) > 0
|
||||
}
|
||||
|
||||
// Kernel is the interface for proxy kernel backends (sing-box, xray, etc.).
|
||||
//
|
||||
// The interface is split into lifecycle, user management, and observability
|
||||
@@ -41,7 +54,7 @@ type Kernel interface {
|
||||
// Start initialises the kernel with the given node config and initial
|
||||
// user set, binds listeners, and begins accepting connections.
|
||||
// Calling Start on an already-running kernel stops the old instance first.
|
||||
Start(nodeConfig *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) error
|
||||
Start(nodeConfig *model.NodeSpec, users []model.UserSpec, tls TLSCert) error
|
||||
// Stop gracefully shuts down the kernel, draining active connections.
|
||||
Stop()
|
||||
// IsRunning returns whether the kernel is currently accepting connections.
|
||||
@@ -49,7 +62,7 @@ type Kernel interface {
|
||||
// Reload re-generates the full config and hot-swaps listeners/routes.
|
||||
// Use this when port, protocol, or TLS settings change.
|
||||
// Existing connections MAY be briefly interrupted.
|
||||
Reload(nodeConfig *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) error
|
||||
Reload(nodeConfig *model.NodeSpec, users []model.UserSpec, tls TLSCert) error
|
||||
|
||||
// ─── User management (non-disruptive) ───────────────────────────────
|
||||
// AddUsers registers new users with the running kernel.
|
||||
|
||||
+243
-107
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
+225
-80
@@ -2,7 +2,9 @@ package xray
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/config"
|
||||
@@ -14,7 +16,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)
|
||||
|
||||
@@ -66,7 +68,7 @@ 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}
|
||||
} else {
|
||||
@@ -76,7 +78,7 @@ func buildConfig(kcfg config.KernelConfig, nc *model.NodeSpec, users []model.Use
|
||||
}
|
||||
|
||||
// Merge panel routes and static config routes
|
||||
cfg["routing"] = buildRouting(nc.Routes, mergeRouteList(nc.CustomRoutes, kcfg.CustomRoute))
|
||||
cfg["routing"] = buildRouting(nc.Routes, nc.CustomRouteRules, mergeRouteList(nc.CustomRoutes, kcfg.CustomRoute))
|
||||
|
||||
mergeCustomXray(cfg, kcfg)
|
||||
return cfg
|
||||
@@ -195,7 +197,7 @@ func xrayLogLevel(singboxLevel string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func buildInbound(nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M {
|
||||
func buildInbound(nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
|
||||
listenAddr := "::"
|
||||
if nc.ListenIP != "" {
|
||||
listenAddr = nc.ListenIP
|
||||
@@ -214,19 +216,19 @@ func buildInbound(nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile
|
||||
|
||||
switch nc.Protocol {
|
||||
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 "shadowsocks":
|
||||
return buildShadowsocks(base, nc, users)
|
||||
case "socks":
|
||||
return buildSocks(base, users)
|
||||
case "http":
|
||||
return buildHTTP(base, nc, users, certFile, keyFile)
|
||||
return buildHTTP(base, nc, users, tc)
|
||||
case "hysteria":
|
||||
return buildHysteria(base, nc, users, certFile, keyFile)
|
||||
return buildHysteria(base, nc, users, tc)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
@@ -238,7 +240,7 @@ func userEmail(userID int) string {
|
||||
return fmt.Sprintf("user@%d", userID)
|
||||
}
|
||||
|
||||
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 {
|
||||
clients := make([]M, 0, len(users))
|
||||
for _, u := range users {
|
||||
clients = append(clients, M{
|
||||
@@ -249,11 +251,11 @@ func buildVMess(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, ke
|
||||
}
|
||||
base["settings"] = M{"clients": clients}
|
||||
|
||||
applyStreamSettings(base, nc, certFile, keyFile)
|
||||
applyStreamSettings(base, nc, tc)
|
||||
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 {
|
||||
clients := make([]M, 0, len(users))
|
||||
for _, u := range users {
|
||||
client := M{
|
||||
@@ -274,11 +276,11 @@ func buildVLESS(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, ke
|
||||
"decryption": decryption,
|
||||
}
|
||||
|
||||
applyStreamSettings(base, nc, certFile, keyFile)
|
||||
applyStreamSettings(base, nc, tc)
|
||||
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 {
|
||||
clients := make([]M, len(users))
|
||||
for i := range users {
|
||||
u := &users[i]
|
||||
@@ -289,7 +291,7 @@ func buildTrojan(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, k
|
||||
}
|
||||
base["settings"] = M{"clients": clients}
|
||||
|
||||
applyStreamSettings(base, nc, certFile, keyFile)
|
||||
applyStreamSettings(base, nc, tc)
|
||||
|
||||
// Trojan requires TLS or Reality to be enabled.
|
||||
// If the panel didn't explicitly set TLS=1 or TLS=2, but we have certs,
|
||||
@@ -297,7 +299,7 @@ func buildTrojan(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, k
|
||||
ss, _ := base["streamSettings"].(M)
|
||||
if security, ok := ss["security"].(string); !ok || (security != "tls" && security != "reality") {
|
||||
nc.TLS = 1 // Force internal state to trigger TLS build in applyStreamSettings
|
||||
applyStreamSettings(base, nc, certFile, keyFile)
|
||||
applyStreamSettings(base, nc, tc)
|
||||
}
|
||||
|
||||
return base
|
||||
@@ -378,7 +380,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["protocol"] = "http"
|
||||
accounts := make([]M, 0, len(users))
|
||||
for _, u := range users {
|
||||
@@ -393,13 +395,13 @@ func buildHTTP(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, key
|
||||
}
|
||||
|
||||
if nc.TLS == 1 {
|
||||
applyStreamSettings(base, nc, certFile, keyFile)
|
||||
applyStreamSettings(base, nc, tc)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// buildHysteria creates a Hysteria v2 inbound for xray-core.
|
||||
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 {
|
||||
nlog.Core().Warn("xray: only supports hysteria v2, skipping v1 inbound")
|
||||
return nil
|
||||
@@ -435,16 +437,15 @@ func buildHysteria(base M, nc *model.NodeSpec, users []model.UserSpec, certFile,
|
||||
}
|
||||
}
|
||||
|
||||
if certFile != "" && keyFile != "" {
|
||||
if tc.HasCert() {
|
||||
ss["security"] = "tls"
|
||||
tlsCert := M{
|
||||
"certificate": []string{string(tc.CertPEM)},
|
||||
"key": []string{string(tc.KeyPEM)},
|
||||
}
|
||||
ss["tlsSettings"] = M{
|
||||
"certificates": []M{
|
||||
{
|
||||
"certificateFile": certFile,
|
||||
"keyFile": keyFile,
|
||||
},
|
||||
},
|
||||
"alpn": []string{"h3"},
|
||||
"certificates": []M{tlsCert},
|
||||
"alpn": []string{"h3"},
|
||||
}
|
||||
} else {
|
||||
nlog.Core().Warn("hysteria requires TLS certificate files; configure cert_mode (self, file, http, dns, or content)")
|
||||
@@ -458,7 +459,7 @@ func buildHysteria(base M, nc *model.NodeSpec, users []model.UserSpec, certFile,
|
||||
return base
|
||||
}
|
||||
|
||||
func applyStreamSettings(base M, nc *model.NodeSpec, certFile, keyFile string) {
|
||||
func applyStreamSettings(base M, nc *model.NodeSpec, tc kernel.TLSCert) {
|
||||
ss := M{}
|
||||
|
||||
// Network / transport
|
||||
@@ -546,14 +547,17 @@ func applyStreamSettings(base M, nc *model.NodeSpec, certFile, keyFile string) {
|
||||
if sn, ok := nc.TLSSettings["server_name"]; ok && sn != "" {
|
||||
tlsSettings["serverName"] = sn
|
||||
}
|
||||
}
|
||||
if certFile != "" && keyFile != "" {
|
||||
tlsSettings["certificates"] = []M{
|
||||
{
|
||||
"certificateFile": certFile,
|
||||
"keyFile": keyFile,
|
||||
},
|
||||
// ECH server-side: extract key from tls_settings.ech
|
||||
if echKeys := extractECHServerKeys(nc.TLSSettings); echKeys != "" {
|
||||
tlsSettings["echServerKeys"] = echKeys
|
||||
}
|
||||
}
|
||||
if tc.HasCert() {
|
||||
tlsCert := M{
|
||||
"certificate": []string{string(tc.CertPEM)},
|
||||
"key": []string{string(tc.KeyPEM)},
|
||||
}
|
||||
tlsSettings["certificates"] = []M{tlsCert}
|
||||
} else {
|
||||
// Fallback placeholder for auto-TLS environments.
|
||||
// Xray allows empty certificates array in more cases than sing-box,
|
||||
@@ -616,10 +620,18 @@ func buildRealitySettings(nc *model.NodeSpec) M {
|
||||
return reality
|
||||
}
|
||||
|
||||
func buildRouting(rules []model.RouteRule, customRules []map[string]any) M {
|
||||
func buildRouting(rules []model.RouteRule, customRouteRules []model.CustomRouteRule, customRules []map[string]any) M {
|
||||
var xrayRules []M
|
||||
|
||||
// Custom route rules (from Panel CustomRoutes or local config) have HIGHEST priority
|
||||
// Structured custom routes now take the highest priority for panel-managed overrides.
|
||||
for _, rule := range customRouteRules {
|
||||
if rule.Disabled {
|
||||
continue
|
||||
}
|
||||
xrayRules = append(xrayRules, compileCustomRouteRule(rule)...)
|
||||
}
|
||||
|
||||
// Raw custom routes remain the escape hatch, but no longer outrank structured rules.
|
||||
for _, cr := range customRules {
|
||||
xrayRules = append(xrayRules, M(cr))
|
||||
}
|
||||
@@ -643,49 +655,7 @@ func buildRouting(rules []model.RouteRule, customRules []map[string]any) M {
|
||||
})
|
||||
|
||||
for _, rule := range rules {
|
||||
if len(rule.Match) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var ips, domains []string
|
||||
for _, m := range rule.Match {
|
||||
m = strings.TrimSpace(m)
|
||||
if m == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(m, "geoip:") {
|
||||
ips = append(ips, m) // "geoip:cn" → ip field, passed as-is
|
||||
} else if strings.HasPrefix(m, "geosite:") {
|
||||
domains = append(domains, m) // "geosite:google" → domain field, passed as-is
|
||||
} else if strings.Contains(m, "/") {
|
||||
ips = append(ips, m)
|
||||
} else {
|
||||
m = strings.TrimPrefix(m, "*.")
|
||||
domains = append(domains, "domain:"+m)
|
||||
}
|
||||
}
|
||||
|
||||
outbound := "block"
|
||||
if rule.Action == "direct" {
|
||||
outbound = "direct"
|
||||
} else if rule.Action == "proxy" && rule.ActionValue != "" {
|
||||
outbound = rule.ActionValue
|
||||
}
|
||||
|
||||
if len(domains) > 0 {
|
||||
xrayRules = append(xrayRules, M{
|
||||
"type": "field",
|
||||
"domain": domains,
|
||||
"outboundTag": outbound,
|
||||
})
|
||||
}
|
||||
if len(ips) > 0 {
|
||||
xrayRules = append(xrayRules, M{
|
||||
"type": "field",
|
||||
"ip": ips,
|
||||
"outboundTag": outbound,
|
||||
})
|
||||
}
|
||||
xrayRules = append(xrayRules, compilePanelRouteRule(rule)...)
|
||||
}
|
||||
|
||||
return M{
|
||||
@@ -693,3 +663,178 @@ func buildRouting(rules []model.RouteRule, customRules []map[string]any) M {
|
||||
"rules": xrayRules,
|
||||
}
|
||||
}
|
||||
|
||||
func compilePanelRouteRule(rule model.RouteRule) []M {
|
||||
if len(rule.Match) == 0 {
|
||||
return nil
|
||||
}
|
||||
match := model.RouteMatch{}
|
||||
for _, item := range rule.Match {
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(item, "geoip:") || strings.Contains(item, "/") {
|
||||
match.IPCIDRs = append(match.IPCIDRs, item)
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(item, "geosite:") {
|
||||
match.Domains = append(match.Domains, item)
|
||||
continue
|
||||
}
|
||||
match.DomainSuffixes = append(match.DomainSuffixes, strings.TrimPrefix(item, "*."))
|
||||
}
|
||||
action := model.RouteAction{Type: "block"}
|
||||
switch rule.Action {
|
||||
case "direct":
|
||||
action.Type = "direct"
|
||||
case "proxy":
|
||||
action.Type = "route"
|
||||
action.Target = rule.ActionValue
|
||||
}
|
||||
return compileCustomRouteRule(model.CustomRouteRule{Match: match, Action: action})
|
||||
}
|
||||
|
||||
func compileCustomRouteRule(rule model.CustomRouteRule) []M {
|
||||
outbound := xrayOutboundForAction(rule.Action)
|
||||
var compiled []M
|
||||
|
||||
if len(rule.Match.Domains) > 0 || len(rule.Match.DomainSuffixes) > 0 {
|
||||
domains := make([]string, 0, len(rule.Match.Domains)+len(rule.Match.DomainSuffixes))
|
||||
for _, value := range rule.Match.Domains {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
domains = append(domains, value)
|
||||
}
|
||||
for _, value := range rule.Match.DomainSuffixes {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
domains = append(domains, "domain:"+value)
|
||||
}
|
||||
if len(domains) > 0 {
|
||||
compiled = append(compiled, M{
|
||||
"type": "field",
|
||||
"domain": domains,
|
||||
"outboundTag": outbound,
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(rule.Match.IPCIDRs) > 0 {
|
||||
compiled = append(compiled, M{
|
||||
"type": "field",
|
||||
"ip": copyStrings(rule.Match.IPCIDRs),
|
||||
"outboundTag": outbound,
|
||||
})
|
||||
}
|
||||
if len(rule.Match.Ports) > 0 {
|
||||
compiled = append(compiled, M{
|
||||
"type": "field",
|
||||
"port": strings.Join(rule.Match.Ports, ","),
|
||||
"outboundTag": outbound,
|
||||
})
|
||||
}
|
||||
if len(rule.Match.Networks) > 0 {
|
||||
compiled = append(compiled, M{
|
||||
"type": "field",
|
||||
"network": strings.Join(rule.Match.Networks, ","),
|
||||
"outboundTag": outbound,
|
||||
})
|
||||
}
|
||||
if len(rule.Match.SourceCIDRs) > 0 {
|
||||
compiled = append(compiled, M{
|
||||
"type": "field",
|
||||
"source": copyStrings(rule.Match.SourceCIDRs),
|
||||
"outboundTag": outbound,
|
||||
})
|
||||
}
|
||||
if len(rule.Match.SourcePorts) > 0 {
|
||||
compiled = append(compiled, M{
|
||||
"type": "field",
|
||||
"sourcePort": strings.Join(rule.Match.SourcePorts, ","),
|
||||
"outboundTag": outbound,
|
||||
})
|
||||
}
|
||||
return compiled
|
||||
}
|
||||
|
||||
func xrayOutboundForAction(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 copyStrings(src []string) []string {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, len(src))
|
||||
copy(out, src)
|
||||
return out
|
||||
}
|
||||
|
||||
// extractECHServerKeys extracts the ECH private key from tls_settings.ech
|
||||
// and converts it from PEM to base64 for xray-core's echServerKeys field.
|
||||
func extractECHServerKeys(tlsSettings map[string]interface{}) string {
|
||||
echRaw, ok := tlsSettings["ech"]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
echMap, ok := echRaw.(map[string]interface{})
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
enabled, _ := echMap["enabled"].(bool)
|
||||
if !enabled {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Try inline key first, then key_path
|
||||
var pemData []byte
|
||||
if key, _ := echMap["key"].(string); key != "" {
|
||||
pemData = []byte(key)
|
||||
} else if keyPath, _ := echMap["key_path"].(string); keyPath != "" {
|
||||
data, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
nlog.Core().Warn("ECH enabled but key_path read failed", "error", err)
|
||||
return ""
|
||||
}
|
||||
pemData = data
|
||||
} else {
|
||||
nlog.Core().Warn("ECH enabled but no key or key_path provided")
|
||||
return ""
|
||||
}
|
||||
|
||||
return echPEMToBase64(pemData)
|
||||
}
|
||||
|
||||
// echPEMToBase64 parses an "ECH KEYS" PEM block and returns base64 of the raw bytes.
|
||||
// If the input is not valid PEM or has the wrong type, it returns empty string.
|
||||
func echPEMToBase64(data []byte) string {
|
||||
trimmed := strings.TrimSpace(string(data))
|
||||
if !strings.Contains(trimmed, "-----") {
|
||||
// Assume already raw base64
|
||||
return trimmed
|
||||
}
|
||||
block, _ := pem.Decode(data)
|
||||
if block == nil {
|
||||
nlog.Core().Warn("ECH key: invalid PEM data")
|
||||
return ""
|
||||
}
|
||||
if block.Type != "ECH KEYS" {
|
||||
nlog.Core().Warn("ECH key: unexpected PEM type", "expected", "ECH KEYS", "got", block.Type)
|
||||
return ""
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(block.Bytes)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package xray
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"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"
|
||||
)
|
||||
@@ -46,7 +49,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)
|
||||
|
||||
// Since we overrode both 'direct' and 'block', the result should contain
|
||||
@@ -161,7 +164,7 @@ func TestBuildConfig_AllProtocols_ValidJSON(t *testing.T) {
|
||||
|
||||
for _, tc := range protocols {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg := buildConfig(testKernelCfg, testNodeSpec(&tc.nc), testUsers, "/cert.pem", "/key.pem")
|
||||
cfg := buildConfig(testKernelCfg, testNodeSpec(&tc.nc), testUsers, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
|
||||
|
||||
data, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
@@ -194,7 +197,7 @@ func TestBuildConfig_VMess_Users(t *testing.T) {
|
||||
Protocol: "vmess",
|
||||
ServerPort: 10086,
|
||||
}
|
||||
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, "", "")
|
||||
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, kernel.TLSCert{})
|
||||
data, _ := json.Marshal(cfg)
|
||||
|
||||
var parsed map[string]interface{}
|
||||
@@ -234,7 +237,7 @@ func TestBuildConfig_VLESS_Flow(t *testing.T) {
|
||||
"server_name": "example.com",
|
||||
},
|
||||
}
|
||||
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, "", "")
|
||||
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, kernel.TLSCert{})
|
||||
data, _ := json.Marshal(cfg)
|
||||
|
||||
var parsed map[string]interface{}
|
||||
@@ -257,7 +260,7 @@ func TestBuildConfig_VLESS_Flow(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildRouting_Default(t *testing.T) {
|
||||
routing := buildRouting(nil, nil)
|
||||
routing := buildRouting(nil, nil, nil)
|
||||
rules := routing["rules"].([]M)
|
||||
|
||||
if len(rules) != 1 {
|
||||
@@ -287,7 +290,7 @@ func TestBuildRouting_WithRules(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
routing := buildRouting(testRouteRules(rules), nil)
|
||||
routing := buildRouting(testRouteRules(rules), nil, nil)
|
||||
xrayRules := routing["rules"].([]M)
|
||||
|
||||
// 1 default + 2 domain rules + 1 IP rule = 4
|
||||
@@ -322,6 +325,63 @@ func TestBuildRouting_WithRules(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRouting_WithCustomRouteRules(t *testing.T) {
|
||||
customRules := []model.CustomRouteRule{
|
||||
{
|
||||
Name: "proxy-web",
|
||||
Match: model.RouteMatch{
|
||||
Domains: []string{"full.example.com"},
|
||||
DomainSuffixes: []string{"example.org"},
|
||||
Ports: []string{"80", "443-445"},
|
||||
Networks: []string{"tcp", "udp"},
|
||||
SourceCIDRs: []string{"192.168.1.0/24"},
|
||||
SourcePorts: []string{"1000-1002"},
|
||||
},
|
||||
Action: model.RouteAction{Type: "route", Target: "warp-jp"},
|
||||
},
|
||||
}
|
||||
|
||||
routing := buildRouting(nil, customRules, nil)
|
||||
xrayRules := routing["rules"].([]M)
|
||||
if len(xrayRules) != 6 {
|
||||
t.Fatalf("expected 6 rules, got %d", len(xrayRules))
|
||||
}
|
||||
if xrayRules[0]["outboundTag"] != "warp-jp" {
|
||||
t.Fatalf("expected first custom outbound warp-jp, got %v", xrayRules[0]["outboundTag"])
|
||||
}
|
||||
if got := xrayRules[0]["domain"].([]string); len(got) != 2 || got[0] != "full.example.com" || got[1] != "domain:example.org" {
|
||||
t.Fatalf("unexpected custom domains: %v", got)
|
||||
}
|
||||
if got := xrayRules[1]["port"]; got != "80,443-445" {
|
||||
t.Fatalf("unexpected port matcher: %v", got)
|
||||
}
|
||||
if got := xrayRules[2]["network"]; got != "tcp,udp" {
|
||||
t.Fatalf("unexpected network matcher: %v", got)
|
||||
}
|
||||
if got := xrayRules[3]["source"].([]string); len(got) != 1 || got[0] != "192.168.1.0/24" {
|
||||
t.Fatalf("unexpected source cidr matcher: %v", got)
|
||||
}
|
||||
if got := xrayRules[4]["sourcePort"]; got != "1000-1002" {
|
||||
t.Fatalf("unexpected source port matcher: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRouting_StructuredCustomRulesRemainFirst(t *testing.T) {
|
||||
raw := []map[string]any{{"type": "field", "domain": []string{"keyword:raw"}, "outboundTag": "raw-tag"}}
|
||||
custom := []model.CustomRouteRule{{
|
||||
Match: model.RouteMatch{DomainSuffixes: []string{"structured.example"}},
|
||||
Action: model.RouteAction{Type: "direct"},
|
||||
}}
|
||||
routing := buildRouting(nil, custom, raw)
|
||||
xrayRules := routing["rules"].([]M)
|
||||
if xrayRules[0]["outboundTag"] != "direct" {
|
||||
t.Fatalf("expected structured rule first, got %v", xrayRules[0]["outboundTag"])
|
||||
}
|
||||
if xrayRules[1]["outboundTag"] != "raw-tag" {
|
||||
t.Fatalf("expected raw custom rule second, got %v", xrayRules[1]["outboundTag"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildConfig_LogLevel(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
@@ -349,7 +409,7 @@ func TestBuildConfig_StatsEnabled(t *testing.T) {
|
||||
Protocol: "vmess",
|
||||
ServerPort: 10086,
|
||||
}
|
||||
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, "", "")
|
||||
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, kernel.TLSCert{})
|
||||
data, _ := json.Marshal(cfg)
|
||||
|
||||
var parsed map[string]interface{}
|
||||
@@ -379,7 +439,7 @@ func TestBuildConfig_Shadowsocks_MultiUserTraditional(t *testing.T) {
|
||||
ServerPort: 8388,
|
||||
Cipher: "aes-128-gcm",
|
||||
}
|
||||
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, "", "")
|
||||
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, kernel.TLSCert{})
|
||||
data, _ := json.Marshal(cfg)
|
||||
|
||||
var parsed map[string]interface{}
|
||||
@@ -412,7 +472,7 @@ func TestBuildConfig_Shadowsocks_MultiUser(t *testing.T) {
|
||||
Cipher: "2022-blake3-aes-128-gcm",
|
||||
ServerKey: "server-key",
|
||||
}
|
||||
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, "", "")
|
||||
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, kernel.TLSCert{})
|
||||
data, _ := json.Marshal(cfg)
|
||||
|
||||
var parsed map[string]interface{}
|
||||
@@ -436,7 +496,7 @@ func TestBuildConfig_SocksStats(t *testing.T) {
|
||||
Protocol: "socks",
|
||||
ServerPort: 1080,
|
||||
}
|
||||
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, "", "")
|
||||
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, kernel.TLSCert{})
|
||||
data, _ := json.Marshal(cfg)
|
||||
|
||||
var parsed map[string]interface{}
|
||||
@@ -456,3 +516,59 @@ func TestBuildConfig_SocksStats(t *testing.T) {
|
||||
t.Errorf("expected email user@1 for socks account, got %v", a1["email"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEchPEMToBase64(t *testing.T) {
|
||||
// Valid ECH KEYS PEM
|
||||
rawBytes := []byte{0x00, 0x04, 0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x03, 0xCA, 0xFE, 0x00}
|
||||
pemBlock := &pem.Block{Type: "ECH KEYS", Bytes: rawBytes}
|
||||
pemStr := string(pem.EncodeToMemory(pemBlock))
|
||||
|
||||
result := echPEMToBase64([]byte(pemStr))
|
||||
expected := base64.StdEncoding.EncodeToString(rawBytes)
|
||||
if result != expected {
|
||||
t.Errorf("echPEMToBase64 got %q, want %q", result, expected)
|
||||
}
|
||||
|
||||
// Wrong PEM type should return empty
|
||||
wrongBlock := &pem.Block{Type: "ECH CONFIGS", Bytes: rawBytes}
|
||||
wrongPEM := string(pem.EncodeToMemory(wrongBlock))
|
||||
if got := echPEMToBase64([]byte(wrongPEM)); got != "" {
|
||||
t.Errorf("echPEMToBase64 should reject ECH CONFIGS, got %q", got)
|
||||
}
|
||||
|
||||
// Invalid PEM should return empty
|
||||
if got := echPEMToBase64([]byte("-----BEGIN GARBAGE-----\nwhat\n-----END GARBAGE-----")); got != "" {
|
||||
t.Errorf("echPEMToBase64 should reject invalid PEM, got %q", got)
|
||||
}
|
||||
|
||||
// Raw base64 passthrough
|
||||
raw := "AAQDQKDIAAMD"
|
||||
if got := echPEMToBase64([]byte(raw)); got != raw {
|
||||
t.Errorf("echPEMToBase64 raw passthrough: got %q, want %q", got, raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractECHServerKeys(t *testing.T) {
|
||||
rawBytes := []byte{0x00, 0x04, 0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x03, 0xCA, 0xFE, 0x00}
|
||||
pemBlock := &pem.Block{Type: "ECH KEYS", Bytes: rawBytes}
|
||||
pemStr := string(pem.EncodeToMemory(pemBlock))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
tls map[string]interface{}
|
||||
expect string
|
||||
}{
|
||||
{"no ech", map[string]interface{}{}, ""},
|
||||
{"disabled", map[string]interface{}{"ech": map[string]interface{}{"enabled": false, "key": pemStr}}, ""},
|
||||
{"enabled with key", map[string]interface{}{"ech": map[string]interface{}{"enabled": true, "key": pemStr}}, base64.StdEncoding.EncodeToString(rawBytes)},
|
||||
{"enabled no key", map[string]interface{}{"ech": map[string]interface{}{"enabled": true}}, ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := extractECHServerKeys(tt.tls)
|
||||
if got != tt.expect {
|
||||
t.Errorf("got %q, want %q", got, tt.expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,8 +62,7 @@ type Xray struct {
|
||||
limitDispatcher *LimitDispatcher
|
||||
users []model.UserSpec
|
||||
nodeConfig *model.NodeSpec
|
||||
certFile string
|
||||
keyFile string
|
||||
tls kernel.TLSCert
|
||||
protocol string
|
||||
inboundTag string
|
||||
lastKernelHash string
|
||||
@@ -98,7 +97,7 @@ func (x *Xray) Capabilities() kernel.Capabilities {
|
||||
func (x *Xray) Protocols() []string {
|
||||
return []string{
|
||||
"vmess", "vless", "trojan", "shadowsocks",
|
||||
"hysteria", "socks", "http", "dokodemo-door",
|
||||
"hysteria",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,11 +114,11 @@ func (x *Xray) Protocols() []string {
|
||||
// Phase 3 – StartNew: instance.Start (no lock, potentially slow)
|
||||
// Phase 4 – Swap: store new, extract old (brief kernel lock)
|
||||
// Phase 5 – RecycleOld: close old in background (non-blocking)
|
||||
func (x *Xray) Start(nodeConfig *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) error {
|
||||
func (x *Xray) Start(nodeConfig *model.NodeSpec, users []model.UserSpec, tls kernel.TLSCert) error {
|
||||
// ── Phase 1: Build config (no shared state) ─────────────────────────
|
||||
x.ensureGeoData(nodeConfig)
|
||||
|
||||
data, err := marshalConfig(x.cfg, nodeConfig, users, certFile, keyFile)
|
||||
data, err := marshalConfig(x.cfg, nodeConfig, users, tls)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -152,8 +151,7 @@ func (x *Xray) Start(nodeConfig *model.NodeSpec, users []model.UserSpec, certFil
|
||||
x.limitDispatcher = ld
|
||||
x.users = users
|
||||
x.nodeConfig = nodeConfig
|
||||
x.certFile = certFile
|
||||
x.keyFile = keyFile
|
||||
x.tls = tls
|
||||
x.protocol = nodeConfig.Protocol
|
||||
x.inboundTag = nodeConfig.Protocol + "-in"
|
||||
x.cumTraffic = make(map[int][2]int64)
|
||||
@@ -178,14 +176,14 @@ func (x *Xray) Start(nodeConfig *model.NodeSpec, users []model.UserSpec, certFil
|
||||
// Since Xray does not support granular inbound reconstruction without an
|
||||
// instance restart for most transport/TLS settings, it triggers a full restart
|
||||
// if any kernel-affecting fields (hash mismatch) have changed.
|
||||
func (x *Xray) Reload(nodeConfig *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) error {
|
||||
func (x *Xray) Reload(nodeConfig *model.NodeSpec, users []model.UserSpec, tls kernel.TLSCert) error {
|
||||
x.updateDispatcherLimits(users)
|
||||
x.updateBandwidthLimits(users)
|
||||
|
||||
newHash := kernel.ComputeHash(nodeConfig, users)
|
||||
|
||||
x.mu.Lock()
|
||||
same := x.lastKernelHash == newHash
|
||||
same := x.lastKernelHash == newHash && tlsEqual(x.tls, tls)
|
||||
if same {
|
||||
x.users = users
|
||||
x.mu.Unlock()
|
||||
@@ -195,7 +193,7 @@ func (x *Xray) Reload(nodeConfig *model.NodeSpec, users []model.UserSpec, certFi
|
||||
x.mu.Unlock()
|
||||
|
||||
nlog.Core().Info("xray: configuration changed, performing full restart")
|
||||
return x.Start(nodeConfig, users, certFile, keyFile)
|
||||
return x.Start(nodeConfig, users, tls)
|
||||
}
|
||||
|
||||
// Stop gracefully shuts down the kernel, draining active connections first.
|
||||
@@ -217,6 +215,10 @@ func (x *Xray) Stop() {
|
||||
|
||||
func (x *Xray) IsRunning() bool { return x.running.Load() }
|
||||
|
||||
func tlsEqual(a, b kernel.TLSCert) bool {
|
||||
return bytes.Equal(a.CertPEM, b.CertPEM) && bytes.Equal(a.KeyPEM, b.KeyPEM)
|
||||
}
|
||||
|
||||
// ─── Observability ──────────────────────────────────────────────────────────
|
||||
|
||||
// GetUserTraffic returns per-user cumulative traffic from xray's built-in
|
||||
@@ -319,10 +321,10 @@ func (x *Xray) AddUsers(users []model.UserSpec) (int, error) {
|
||||
um, err := x.getUserManager()
|
||||
if err != nil {
|
||||
// Protocol doesn't support UserManager → full restart
|
||||
nc, cf, kf := x.nodeConfig, x.certFile, x.keyFile
|
||||
nc, t := x.nodeConfig, x.tls
|
||||
x.mu.Unlock()
|
||||
nlog.Core().Debug("xray: AddUsers fallback to restart", "reason", err)
|
||||
if err := x.Start(nc, merged, cf, kf); err != nil {
|
||||
if err := x.Start(nc, merged, t); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(toAdd), nil
|
||||
@@ -392,10 +394,10 @@ func (x *Xray) RemoveUsers(users []model.UserSpec) (int, error) {
|
||||
|
||||
um, err := x.getUserManager()
|
||||
if err != nil {
|
||||
nc, cf, kf := x.nodeConfig, x.certFile, x.keyFile
|
||||
nc, t := x.nodeConfig, x.tls
|
||||
x.mu.Unlock()
|
||||
nlog.Core().Debug("xray: RemoveUsers fallback to restart", "reason", err)
|
||||
if err := x.Start(nc, kept, cf, kf); err != nil {
|
||||
if err := x.Start(nc, kept, t); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return removed, nil
|
||||
@@ -448,10 +450,10 @@ func (x *Xray) UpdateUsers(users []model.UserSpec) (added, removed int, err erro
|
||||
um, umErr := x.getUserManager()
|
||||
if umErr != nil {
|
||||
// Protocol doesn't support UserManager → full restart
|
||||
nc, cf, kf := x.nodeConfig, x.certFile, x.keyFile
|
||||
nc, t := x.nodeConfig, x.tls
|
||||
x.mu.Unlock()
|
||||
nlog.Core().Debug("xray: UpdateUsers fallback to restart", "reason", umErr)
|
||||
if err = x.Start(nc, users, cf, kf); err != nil {
|
||||
if err = x.Start(nc, users, t); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return
|
||||
@@ -639,8 +641,8 @@ func (x *Xray) ensureGeoData(nc *model.NodeSpec) {
|
||||
}
|
||||
|
||||
// marshalConfig builds the xray JSON config and returns the raw bytes.
|
||||
func marshalConfig(cfg config.KernelConfig, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) ([]byte, error) {
|
||||
cfgMap := buildConfig(cfg, nc, users, certFile, keyFile)
|
||||
func marshalConfig(cfg config.KernelConfig, nc *model.NodeSpec, users []model.UserSpec, tls kernel.TLSCert) ([]byte, error) {
|
||||
cfgMap := buildConfig(cfg, nc, users, tls)
|
||||
data, err := json.MarshalIndent(cfgMap, "", " ")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal config: %w", err)
|
||||
|
||||
Reference in New Issue
Block a user