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
+225 -80
View File
@@ -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)
}
+126 -10
View File
@@ -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)
}
})
}
}
+20 -18
View File
@@ -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)