Files
will 4927383faa
CI / Test (push) Has been cancelled
CI / Build (amd64, linux) (push) Has been cancelled
CI / Build (arm64, linux) (push) Has been cancelled
CI / Build & Push Docker Image (push) Has been cancelled
CI / Release (push) Has been cancelled
fix: compile structured custom_routes with match+action into sing-box format
The panel sends structured route rules via custom_routes (not custom_route_rules).
Previously these were passed raw to sing-box, causing unmarshal errors when the
action field is an object. Now detect and compile them through the same path as
custom_route_rules.
2026-06-30 20:34:07 +09:00

1102 lines
26 KiB
Go

package singbox
import (
"encoding/base64"
"fmt"
"net"
"path/filepath"
"strconv"
"strings"
"github.com/cedar2025/xboard-node/internal/config"
"github.com/cedar2025/xboard-node/internal/kernel"
"github.com/cedar2025/xboard-node/internal/model"
"github.com/cedar2025/xboard-node/internal/nlog"
"github.com/go-viper/mapstructure/v2"
)
// M is a shorthand for building JSON-like maps
type M = map[string]interface{}
func buildConfig(kcfg config.KernelConfig, nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
var outbounds []M
tags := make(map[string]bool)
// Panel-defined custom outbounds (structured, converted to sing-box native)
for _, co := range nc.CustomOutbounds {
outbounds = append(outbounds, outboundConfigToSingbox(co))
tags[strings.ToLower(co.Tag)] = true
}
// Static outbounds from local config file
for _, co := range kcfg.CustomOutbound {
tag, _ := co["tag"].(string)
if tag != "" {
tags[strings.ToLower(tag)] = true
}
outbounds = append(outbounds, M(co))
}
// Add default outbounds only if not already defined
if !tags["direct"] {
outbounds = append([]M{{"type": "direct", "tag": "direct"}}, outbounds...)
}
if !tags["block"] {
outbounds = append(outbounds, M{"type": "block", "tag": "block"})
}
cfg := M{
"log": M{
"level": kcfg.LogLevel,
"timestamp": true,
},
"outbounds": outbounds,
}
inbound := buildInbound(nc, users, tc)
if inbound != nil {
cfg["inbounds"] = []M{inbound}
}
// Merge panel routes and static config routes
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) || kernel.NeedsGeoIPRules(nc.CustomRouteRules) || kernel.NeedsGeoSiteRules(nc.CustomRouteRules) {
cfg["experimental"] = M{
"cache_file": M{
"enabled": true,
"path": filepath.Join(kcfg.ConfigDir, "cache.db"),
},
}
}
mergeCustomSingbox(cfg, kcfg)
return cfg
}
// outboundConfigToSingbox converts a structured OutboundConfig (from the panel)
// into a sing-box outbound object. sing-box uses a flat layout where all
// protocol-specific fields sit at the top level alongside "type" and "tag".
func outboundConfigToSingbox(oc model.OutboundConfig) M {
m := M{
"type": oc.Protocol,
"tag": oc.Tag,
}
// Transform common protocol keys to sing-box native format
// WireGuard: secret_key -> private_key; peers: endpoint -> address+port
if oc.Protocol == "wireguard" {
if sk, ok := oc.Settings["secret_key"]; ok {
m["private_key"] = sk
}
if peers, ok := oc.Settings["peers"].([]any); ok {
var wgPeers []M
for _, p := range peers {
if peerMap, ok := p.(map[string]any); ok {
newPeer := M{}
for k, v := range peerMap {
if k == "endpoint" {
if ep, ok := v.(string); ok {
host, portStr, err := net.SplitHostPort(ep)
if err == nil {
newPeer["address"] = host
port, _ := strconv.Atoi(portStr)
newPeer["port"] = port
} else {
newPeer["address"] = ep
}
}
} else {
newPeer[k] = v
}
}
wgPeers = append(wgPeers, newPeer)
}
}
m["peers"] = wgPeers
}
// Copy any other top-level settings not handled above
for k, v := range oc.Settings {
if k != "secret_key" && k != "peers" {
m[k] = v
}
}
} else {
for k, v := range oc.Settings {
m[k] = v
}
}
if oc.ProxyTag != "" {
m["proxy_tag"] = oc.ProxyTag
}
return m
}
func mergeRouteList(a, b []map[string]any) []map[string]any {
res := make([]map[string]any, 0, len(a)+len(b))
res = append(res, a...)
res = append(res, b...)
return res
}
func buildRoutes(panelRoutes []model.RouteRule, customRules []model.CustomRouteRule, custom []map[string]any) M {
var rules []M
// 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: detect structured format (match+action) and compile them,
// otherwise pass through as raw sing-box rules.
for _, cr := range custom {
if _, hasMatch := cr["match"]; hasMatch {
if parsed, ok := tryParseAsCustomRouteRule(cr); ok {
rules = append(rules, compileCustomRouteRule(parsed)...)
continue
}
}
rules = append(rules, M(cr))
}
// Standard blocks for private IPv4 and IPv6 ranges to prevent SSRF.
rules = append(rules,
M{
"outbound": "block",
"ip_cidr": []string{
"10.0.0.0/8",
"100.64.0.0/10",
"127.0.0.0/8",
"169.254.0.0/16",
"172.16.0.0/12",
"192.0.0.0/24",
"192.168.0.0/16",
"198.18.0.0/15",
},
},
M{
"outbound": "block",
"ip_cidr": []string{
"fc00::/7",
"fe80::/10",
"::1/128",
},
},
)
for _, pr := range panelRoutes {
rules = append(rules, compilePanelRouteRule(pr)...)
}
return M{
"final": "direct",
"rules": rules,
}
}
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
}
// tryParseAsCustomRouteRule attempts to convert a raw map (from custom_routes)
// into a structured CustomRouteRule. Returns false if the map doesn't match
// the structured format.
func tryParseAsCustomRouteRule(cr map[string]any) (model.CustomRouteRule, bool) {
var rule model.CustomRouteRule
// Parse name
if name, ok := cr["name"].(string); ok {
rule.Name = name
}
if disabled, ok := cr["disabled"].(bool); ok {
rule.Disabled = disabled
}
// Parse match
matchRaw, ok := cr["match"].(map[string]any)
if !ok {
return rule, false
}
rule.Match = parseRouteMatch(matchRaw)
// Parse action
switch action := cr["action"].(type) {
case string:
rule.Action = model.RouteAction{Type: action}
case map[string]any:
if t, ok := action["type"].(string); ok {
rule.Action.Type = t
}
if target, ok := action["target"].(string); ok {
rule.Action.Target = target
}
default:
return rule, false
}
return rule, true
}
func parseRouteMatch(m map[string]any) model.RouteMatch {
var match model.RouteMatch
if v, ok := m["domains"].([]any); ok {
match.Domains = toStringSlice(v)
}
if v, ok := m["domain_suffixes"].([]any); ok {
match.DomainSuffixes = toStringSlice(v)
}
if v, ok := m["ip_cidrs"].([]any); ok {
match.IPCIDRs = toStringSlice(v)
}
if v, ok := m["ports"].([]any); ok {
match.Ports = toStringSlice(v)
}
if v, ok := m["networks"].([]any); ok {
match.Networks = toStringSlice(v)
}
if v, ok := m["source_cidrs"].([]any); ok {
match.SourceCIDRs = toStringSlice(v)
}
if v, ok := m["source_ports"].([]any); ok {
match.SourcePorts = toStringSlice(v)
}
return match
}
func toStringSlice(arr []any) []string {
result := make([]string, 0, len(arr))
for _, v := range arr {
if s, ok := v.(string); ok {
result = append(result, s)
}
}
return result
}
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 {
nlog.Core().Error("failed to load custom sing-box config", "error", err)
return
}
if custom == nil {
return
}
// dns — custom replaces
if v, ok := custom["dns"]; ok {
cfg["dns"] = v
}
// experimental — custom replaces
if v, ok := custom["experimental"]; ok {
cfg["experimental"] = v
}
// outbounds — append custom entries
if v, ok := custom["outbounds"]; ok {
if existing, ok := cfg["outbounds"].([]M); ok {
cfg["outbounds"] = kernel.MergeAppendList(existing, v)
}
}
// endpoints (sing-box 1.11+ wireguard etc.) — append
if v, ok := custom["endpoints"]; ok {
if existing, ok := cfg["endpoints"].([]M); ok {
cfg["endpoints"] = kernel.MergeAppendList(existing, v)
} else {
if items := kernel.MergeAppendList(nil, v); len(items) > 0 {
cfg["endpoints"] = items
}
}
}
// route — merge sub-fields
if customRoute, ok := custom["route"]; ok {
if customRouteMap, ok := customRoute.(map[string]any); ok {
mergeCustomSingboxRoute(cfg, customRouteMap)
}
}
}
func mergeCustomSingboxRoute(cfg M, customRoute map[string]any) {
route, ok := cfg["route"].(M)
if !ok {
route = M{}
cfg["route"] = route
}
// rules — custom rules prepended (so they match before panel rules)
if v, ok := customRoute["rules"]; ok {
if existing, ok := route["rules"].([]M); ok {
route["rules"] = kernel.MergePrependList(existing, v)
}
}
// rule_set — custom rule_sets appended
if v, ok := customRoute["rule_set"]; ok {
if existing, ok := route["rule_set"].([]M); ok {
route["rule_set"] = kernel.MergeAppendList(existing, v)
} else {
route["rule_set"] = kernel.MergeAppendList(nil, v)
}
}
// final, auto_detect_interface, default_interface, etc. — custom overrides
for k, v := range customRoute {
if k == "rules" || k == "rule_set" {
continue
}
route[k] = v
}
}
func buildInbound(nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
base := M{
"tag": nc.Protocol + "-in",
"listen": "::",
"listen_port": nc.ServerPort,
}
switch nc.Protocol {
case "shadowsocks":
return buildShadowsocks(base, nc, users)
case "vmess":
return buildVMess(base, nc, users, tc)
case "vless":
return buildVLESS(base, nc, users, tc)
case "trojan":
return buildTrojan(base, nc, users, tc)
case "hysteria":
return buildHysteria(base, nc, users, tc)
case "tuic":
return buildTUIC(base, nc, users, tc)
case "anytls":
return buildAnyTLS(base, nc, users, tc)
case "naive":
return buildNaive(base, nc, users, tc)
case "socks":
return buildSocks(base, users)
case "http":
return buildHTTP(base, nc, users, tc)
case "mieru":
return buildMieru(base, nc, users)
default:
return nil
}
}
func buildMieru(base M, nc *model.NodeSpec, users []model.UserSpec) M {
base["type"] = "mieru"
if nc.Transport != "" {
base["transport"] = nc.Transport
}
if nc.TrafficPattern != "" {
base["traffic_pattern"] = nc.TrafficPattern
}
userList := make([]M, 0, len(users))
for _, u := range users {
userList = append(userList, M{
"name": u.UUID,
"password": u.UUID,
})
}
base["users"] = userList
return base
}
type ss2022Config struct {
method string
size int
}
var ss2022Methods = map[string]ss2022Config{
"2022-blake3-aes-128-gcm": {"2022-blake3-aes-128-gcm", 16},
"2022-blake3-aes-256-gcm": {"2022-blake3-aes-256-gcm", 32},
"2022-blake3-chacha20-poly1305": {"2022-blake3-chacha20-poly1305", 32},
}
func buildShadowsocks(base M, nc *model.NodeSpec, users []model.UserSpec) M {
base["type"] = "shadowsocks"
base["method"] = nc.Cipher
ss2022, isSS2022 := ss2022Methods[nc.Cipher]
if isSS2022 {
base["password"] = nc.ServerKey
}
userList := make([]M, len(users))
var rawBuf []byte
if isSS2022 {
rawBuf = make([]byte, ss2022.size)
}
for i := range users {
u := &users[i]
user := M{
"name": u.UUID,
"password": u.UUID,
}
if isSS2022 {
// Reuse buffer and clear it to maintain SS2022 key integrity
for j := range rawBuf {
rawBuf[j] = 0
}
copy(rawBuf, u.UUID)
user["password"] = base64.StdEncoding.EncodeToString(rawBuf)
}
userList[i] = user
}
base["users"] = userList
if nc.Plugin != "" {
nlog.Core().Warn("sing-box shadowsocks inbound does not support plugin, ignoring", "plugin", nc.Plugin)
}
return base
}
func buildVMess(base M, nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
base["type"] = "vmess"
userList := make([]M, 0, len(users))
for _, u := range users {
userList = append(userList, M{
"name": u.UUID,
"uuid": u.UUID,
"alterId": 0,
})
}
base["users"] = userList
applyTransport(base, nc)
applyProxyProtocol(base, nc)
applyMultiplex(base, nc)
if nc.TLS == 1 {
if tls := buildTLSConfig(nc, tc); tls != nil {
base["tls"] = tls
}
}
return base
}
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")
}
base["type"] = "vless"
userList := make([]M, 0, len(users))
for _, u := range users {
user := M{
"name": u.UUID,
"uuid": u.UUID,
}
if nc.Flow != "" {
user["flow"] = nc.Flow
}
userList = append(userList, user)
}
base["users"] = userList
applyTransport(base, nc)
applyProxyProtocol(base, nc)
applyMultiplex(base, nc)
if nc.TLS == 1 {
if tls := buildTLSConfig(nc, tc); tls != nil {
base["tls"] = tls
}
} else if nc.TLS == 2 {
base["tls"] = buildRealityConfig(nc)
}
return base
}
func buildTrojan(base M, nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
base["type"] = "trojan"
userList := make([]M, len(users))
for i := range users {
u := &users[i]
userList[i] = M{
"name": u.UUID,
"password": u.UUID,
}
}
base["users"] = userList
applyTransport(base, nc)
applyProxyProtocol(base, nc)
applyMultiplex(base, nc)
if nc.TLS == 1 {
if tls := buildTLSConfig(nc, tc); tls != nil {
base["tls"] = tls
}
} else if nc.TLS == 2 {
base["tls"] = buildRealityConfig(nc)
}
// 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, tc); tls != nil {
base["tls"] = tls
}
}
if base["tls"] == nil {
nlog.Core().Warn("trojan inbound has no TLS (no certificate paths and no Reality); sing-box may refuse to start until cert_mode provides cert/key or Reality is configured")
}
return base
}
func buildHysteria(base M, nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
if nc.Version == 2 {
base["type"] = "hysteria2"
userList := make([]M, 0, len(users))
for _, u := range users {
userList = append(userList, M{
"name": u.UUID,
"password": u.UUID,
})
}
base["users"] = userList
if nc.Obfs != "" {
base["obfs"] = M{
"type": nc.Obfs,
"password": nc.ObfsPassword,
}
}
} else {
base["type"] = "hysteria"
userList := make([]M, 0, len(users))
for _, u := range users {
userList = append(userList, M{
"name": u.UUID,
"auth_str": u.UUID,
})
}
base["users"] = userList
base["up_mbps"] = nc.UpMbps
base["down_mbps"] = nc.DownMbps
if nc.Obfs != "" {
base["obfs"] = nc.Obfs
}
}
tls := buildTLSConfig(nc, 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
}
// Hysteria/Hysteria2 uses QUIC and requires ALPN; default to h3 if not set.
if _, ok := tls["alpn"]; !ok {
tls["alpn"] = []string{"h3"}
}
base["tls"] = tls
return base
}
func buildTUIC(base M, nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
base["type"] = "tuic"
userList := make([]M, 0, len(users))
for _, u := range users {
userList = append(userList, M{
"name": u.UUID,
"uuid": u.UUID,
"password": u.UUID,
})
}
base["users"] = userList
if nc.CongestionControl != "" {
base["congestion_control"] = nc.CongestionControl
}
tls := buildTLSConfig(nc, 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
}
// TUIC requires ALPN for QUIC negotiation; default to h3 if not set by panel.
if _, ok := tls["alpn"]; !ok {
tls["alpn"] = []string{"h3"}
}
base["tls"] = tls
return base
}
func buildAnyTLS(base M, nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
base["type"] = "anytls"
userList := make([]M, 0, len(users))
for _, u := range users {
userList = append(userList, M{
"name": u.UUID,
"password": u.UUID,
})
}
base["users"] = userList
if nc.PaddingScheme != "" {
base["padding_scheme"] = nc.PaddingScheme
}
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.")
}
return base
}
func buildNaive(base M, nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
base["type"] = "naive"
userList := make([]M, 0, len(users))
for _, u := range users {
userList = append(userList, M{
"username": strconv.Itoa(u.ID),
"password": u.UUID,
})
}
base["users"] = userList
if nc.TLS == 1 {
if tls := buildTLSConfig(nc, tc); tls != nil {
base["tls"] = tls
}
}
return base
}
func buildSocks(base M, users []model.UserSpec) M {
base["type"] = "socks"
userList := make([]M, 0, len(users))
for _, u := range users {
userList = append(userList, M{
"username": u.UUID,
"password": u.UUID,
})
}
base["users"] = userList
return base
}
func buildHTTP(base M, nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
base["type"] = "http"
userList := make([]M, 0, len(users))
for _, u := range users {
userList = append(userList, M{
"username": u.UUID,
"password": u.UUID,
})
}
base["users"] = userList
applyProxyProtocol(base, nc)
if nc.TLS == 1 {
if tls := buildTLSConfig(nc, tc); tls != nil {
base["tls"] = tls
}
}
return base
}
func applyTransport(base M, nc *model.NodeSpec) {
if nc.Network == "" || nc.Network == "tcp" {
return
}
transport := M{"type": nc.Network}
if nc.NetworkSettings != nil {
switch nc.Network {
case "ws":
if v, ok := nc.NetworkSettings["path"]; ok {
transport["path"] = v
}
if v, ok := nc.NetworkSettings["headers"]; ok {
transport["headers"] = v
}
if v, ok := nc.NetworkSettings["host"]; ok {
if transport["headers"] == nil {
transport["headers"] = M{"Host": v}
}
}
if v, ok := nc.NetworkSettings["max_early_data"]; ok {
transport["max_early_data"] = v
}
if v, ok := nc.NetworkSettings["early_data_header_name"]; ok {
transport["early_data_header_name"] = v
}
case "grpc":
if v, ok := nc.NetworkSettings["serviceName"]; ok {
transport["service_name"] = v
} else if v, ok := nc.NetworkSettings["service_name"]; ok {
transport["service_name"] = v
}
case "httpupgrade":
if v, ok := nc.NetworkSettings["path"]; ok {
transport["path"] = v
}
if v, ok := nc.NetworkSettings["host"]; ok {
transport["host"] = v
}
case "h2", "http":
transport["type"] = "http"
if v, ok := nc.NetworkSettings["path"]; ok {
transport["path"] = v
}
if v, ok := nc.NetworkSettings["host"]; ok {
transport["host"] = v
}
}
}
base["transport"] = transport
}
// 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
}
t := M{"enabled": true}
serverName := nc.ServerName
if serverName == "" && nc.Host != "" {
serverName = nc.Host
}
if serverName != "" {
t["server_name"] = serverName
}
if nc.TLSSettings != nil {
if sn, ok := nc.TLSSettings["server_name"]; ok && sn != "" {
t["server_name"] = sn
}
if alpn, ok := nc.TLSSettings["alpn"]; ok {
t["alpn"] = alpn
}
// ECH server-side: only key/key_path needed for inbound
if ech := extractECHInbound(nc.TLSSettings); ech != nil {
t["ech"] = ech
}
}
t["certificate"] = []string{string(tc.CertPEM)}
t["key"] = []string{string(tc.KeyPEM)}
return t
}
func buildRealityConfig(nc *model.NodeSpec) M {
tls := M{"enabled": true}
if nc.TLSSettings == nil {
return tls
}
reality := M{"enabled": true}
var settings struct {
PrivateKey string `mapstructure:"private_key"`
ShortID any `mapstructure:"short_id"`
Dest string `mapstructure:"dest"`
ServerName string `mapstructure:"server_name"`
ServerPort int `mapstructure:"server_port"`
}
decoderConfig := &mapstructure.DecoderConfig{
Metadata: nil,
Result: &settings,
WeaklyTypedInput: true,
}
decoder, _ := mapstructure.NewDecoder(decoderConfig)
_ = decoder.Decode(nc.TLSSettings)
if settings.PrivateKey != "" {
reality["private_key"] = settings.PrivateKey
}
switch v := settings.ShortID.(type) {
case string:
reality["short_id"] = []string{v}
case []any:
ids := make([]string, 0, len(v))
for _, item := range v {
ids = append(ids, fmt.Sprintf("%v", item))
}
reality["short_id"] = ids
case []string:
reality["short_id"] = v
}
dest := settings.Dest
if dest == "" {
dest = settings.ServerName
}
if dest != "" {
handshake := M{"server": dest, "server_port": 443}
if parts := strings.SplitN(dest, ":", 2); len(parts) == 2 {
handshake["server"] = parts[0]
if p, err := strconv.Atoi(parts[1]); err == nil {
handshake["server_port"] = p
}
} else if settings.ServerPort > 0 {
handshake["server_port"] = settings.ServerPort
}
reality["handshake"] = handshake
}
if settings.ServerName != "" {
tls["server_name"] = settings.ServerName
}
tls["reality"] = reality
return tls
}
func applyMultiplex(base M, nc *model.NodeSpec) {
if nc.Multiplex == nil || !nc.Multiplex.Enabled {
return
}
mux := M{
"enabled": true,
}
if nc.Multiplex.Padding {
mux["padding"] = true
}
if nc.Multiplex.Brutal != nil && nc.Multiplex.Brutal.Enabled {
brutal := M{
"enabled": true,
}
if nc.Multiplex.Brutal.UpMbps > 0 {
brutal["up_mbps"] = nc.Multiplex.Brutal.UpMbps
}
if nc.Multiplex.Brutal.DownMbps > 0 {
brutal["down_mbps"] = nc.Multiplex.Brutal.DownMbps
}
mux["brutal"] = brutal
}
base["multiplex"] = mux
}
func applyProxyProtocol(base M, nc *model.NodeSpec) {
// if !nc.GetProxyProtocol() {
// return
// }
// 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
}