Files
xboard-node/internal/kernel/singbox/config.go
T

745 lines
17 KiB
Go

package singbox
import (
"fmt"
"log/slog"
"net"
"path/filepath"
"strconv"
"strings"
"github.com/cedar2025/xboard-node/internal/config"
"github.com/cedar2025/xboard-node/internal/kernel"
"github.com/cedar2025/xboard-node/internal/panel"
"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 *panel.NodeConfig, users []panel.User, certFile, keyFile string) 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, certFile, keyFile)
if inbound != nil {
cfg["inbounds"] = []M{inbound}
}
// Merge panel routes and static config routes
cfg["route"] = buildRoutes(nc.Routes, mergeRouteList(nc.CustomRoutes, kcfg.CustomRoute))
// Automatically enable rule_set caching (cache_file) when panel routes
// reference geoip:/geosite: entries so that the downloaded .srs rule_set
// files survive across process restarts.
if kernel.NeedsGeoIP(nc.Routes) || kernel.NeedsGeoSite(nc.Routes) {
cfg["experimental"] = M{
"cache_file": M{
"enabled": true,
"path": filepath.Join(kcfg.ConfigDir, "cache.db"),
},
}
}
mergeCustomSingbox(cfg, kcfg)
return cfg
}
// outboundConfigToSingbox converts a structured OutboundConfig (from the panel)
// into a sing-box outbound object. sing-box uses a flat layout where all
// protocol-specific fields sit at the top level alongside "type" and "tag".
func outboundConfigToSingbox(oc panel.OutboundConfig) M {
m := M{
"type": oc.Protocol,
"tag": oc.Tag,
}
// Transform common protocol keys to sing-box native format
// WireGuard: secret_key -> private_key; peers: endpoint -> address+port
if oc.Protocol == "wireguard" {
if sk, ok := oc.Settings["secret_key"]; ok {
m["private_key"] = sk
}
if peers, ok := oc.Settings["peers"].([]any); ok {
var wgPeers []M
for _, p := range peers {
if peerMap, ok := p.(map[string]any); ok {
newPeer := M{}
for k, v := range peerMap {
if k == "endpoint" {
if ep, ok := v.(string); ok {
host, portStr, err := net.SplitHostPort(ep)
if err == nil {
newPeer["address"] = host
port, _ := strconv.Atoi(portStr)
newPeer["port"] = port
} else {
newPeer["address"] = ep
}
}
} else {
newPeer[k] = v
}
}
wgPeers = append(wgPeers, newPeer)
}
}
m["peers"] = wgPeers
}
// Copy any other top-level settings not handled above
for k, v := range oc.Settings {
if k != "secret_key" && k != "peers" {
m[k] = v
}
}
} else {
for k, v := range oc.Settings {
m[k] = v
}
}
if oc.ProxyTag != "" {
m["proxy_tag"] = oc.ProxyTag
}
return m
}
func mergeRouteList(a, b []map[string]any) []map[string]any {
res := make([]map[string]any, 0, len(a)+len(b))
res = append(res, a...)
res = append(res, b...)
return res
}
func buildRoutes(panelRoutes []panel.RouteRule, custom []map[string]any) M {
var rules []M
// Custom Routes (Panel-pushed or Local) go FIRST to have highest priority
for _, cr := range custom {
rules = append(rules, M(cr))
}
// Standard blocks for private/loopback
rules = append(rules, M{
"outbound": "block",
"ip_cidr": []string{
"10.0.0.0/8",
"100.64.0.0/10",
"127.0.0.0/8",
"169.254.0.0/16",
"172.16.0.0/12",
"192.0.0.0/24",
"192.168.0.0/16",
"198.18.0.0/15",
"fc00::/7",
"fe80::/10",
"::1/128",
},
}, M{
"outbound": "block",
"domain": []string{
"geoip:private",
},
})
// Panel-defined routes (usually specific blocks/proxies)
for _, pr := range panelRoutes {
if len(pr.Match) == 0 {
continue
}
var domains, cidrs []string
for _, m := range pr.Match {
m = strings.TrimSpace(m)
if m == "" {
continue
}
// Strip leading wildcard for domain matching
if strings.HasPrefix(m, "*.") {
m = strings.TrimPrefix(m, "*.")
}
// Check if it's a CIDR block (contains /)
if strings.Contains(m, "/") {
cidrs = append(cidrs, m)
} else {
// Otherwise treat as domain
domains = append(domains, m)
}
}
// Determine outbound tag based on action
outbound := "block"
if pr.Action == "direct" {
outbound = "direct"
} else if pr.Action == "dns" {
// Special case for sing-box DNS routing:
// If action is "dns" and action_value is provided, we use it as the server name.
// Otherwise it defaults to "dns-out".
server := "dns-out"
if pr.ActionValue != "" {
server = pr.ActionValue
}
outbound = server
} else if pr.Action == "proxy" {
// If panel provides a specific outbound tag in action_value, use it.
// This allows routing to WARP_JP etc. via normal routes.
if pr.ActionValue != "" {
outbound = pr.ActionValue
}
}
// Create separate rule for domains
if len(domains) > 0 {
rule := M{
"domain_suffix": domains,
"outbound": outbound,
}
rules = append(rules, rule)
}
// Create separate rule for CIDRs
if len(cidrs) > 0 {
rule := M{
"ip_cidr": cidrs,
"outbound": outbound,
}
rules = append(rules, rule)
}
}
return M{
"final": "direct",
"rules": rules,
}
}
func mergeCustomSingbox(cfg M, kcfg config.KernelConfig) {
custom, err := kernel.LoadCustomConfig(kcfg.CustomConfig)
if err != nil {
slog.Error("failed to load custom sing-box config", "error", err)
return
}
if custom == nil {
return
}
// dns — custom replaces
if v, ok := custom["dns"]; ok {
cfg["dns"] = v
}
// experimental — custom replaces
if v, ok := custom["experimental"]; ok {
cfg["experimental"] = v
}
// outbounds — append custom entries
if v, ok := custom["outbounds"]; ok {
if existing, ok := cfg["outbounds"].([]M); ok {
cfg["outbounds"] = kernel.MergeAppendList(existing, v)
}
}
// endpoints (sing-box 1.11+ wireguard etc.) — append
if v, ok := custom["endpoints"]; ok {
if existing, ok := cfg["endpoints"].([]M); ok {
cfg["endpoints"] = kernel.MergeAppendList(existing, v)
} else {
if items := kernel.MergeAppendList(nil, v); len(items) > 0 {
cfg["endpoints"] = items
}
}
}
// route — merge sub-fields
if customRoute, ok := custom["route"]; ok {
if customRouteMap, ok := customRoute.(map[string]any); ok {
mergeCustomSingboxRoute(cfg, customRouteMap)
}
}
}
func mergeCustomSingboxRoute(cfg M, customRoute map[string]any) {
route, ok := cfg["route"].(M)
if !ok {
route = M{}
cfg["route"] = route
}
// rules — custom rules prepended (so they match before panel rules)
if v, ok := customRoute["rules"]; ok {
if existing, ok := route["rules"].([]M); ok {
route["rules"] = kernel.MergePrependList(existing, v)
}
}
// rule_set — custom rule_sets appended
if v, ok := customRoute["rule_set"]; ok {
if existing, ok := route["rule_set"].([]M); ok {
route["rule_set"] = kernel.MergeAppendList(existing, v)
} else {
route["rule_set"] = kernel.MergeAppendList(nil, v)
}
}
// final, auto_detect_interface, default_interface, etc. — custom overrides
for k, v := range customRoute {
if k == "rules" || k == "rule_set" {
continue
}
route[k] = v
}
}
func buildInbound(nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
base := M{
"tag": nc.Protocol + "-in",
"listen": "::",
"listen_port": nc.ServerPort,
}
switch nc.Protocol {
case "shadowsocks":
return buildShadowsocks(base, nc, users)
case "vmess":
return buildVMess(base, nc, users, certFile, keyFile)
case "vless":
return buildVLESS(base, nc, users, certFile, keyFile)
case "trojan":
return buildTrojan(base, nc, users, certFile, keyFile)
case "hysteria":
return buildHysteria(base, nc, users, certFile, keyFile)
case "tuic":
return buildTUIC(base, nc, users, certFile, keyFile)
case "anytls":
return buildAnyTLS(base, nc, users, certFile, keyFile)
case "naive":
return buildNaive(base, nc, users, certFile, keyFile)
case "socks":
return buildSocks(base, users)
case "http":
return buildHTTP(base, nc, users, certFile, keyFile)
default:
return nil
}
}
func buildShadowsocks(base M, nc *panel.NodeConfig, users []panel.User) M {
base["type"] = "shadowsocks"
base["method"] = nc.Cipher
if strings.HasPrefix(nc.Cipher, "2022-blake3-") {
base["password"] = nc.ServerKey
}
userList := make([]M, 0, len(users))
for _, u := range users {
userList = append(userList, M{
"name": u.UUID,
"password": u.UUID,
})
}
base["users"] = userList
if nc.Plugin != "" {
slog.Warn("sing-box shadowsocks inbound does not support plugin, ignoring", "plugin", nc.Plugin)
}
return base
}
func buildVMess(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
base["type"] = "vmess"
userList := make([]M, 0, len(users))
for _, u := range users {
userList = append(userList, M{
"name": u.UUID,
"uuid": u.UUID,
"alterId": 0,
})
}
base["users"] = userList
applyTransport(base, nc)
if nc.TLS == 1 {
base["tls"] = buildTLSConfig(nc, certFile, keyFile)
}
return base
}
func buildVLESS(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
base["type"] = "vless"
userList := make([]M, 0, len(users))
for _, u := range users {
user := M{
"name": u.UUID,
"uuid": u.UUID,
}
if nc.Flow != "" {
user["flow"] = nc.Flow
}
userList = append(userList, user)
}
base["users"] = userList
applyTransport(base, nc)
if nc.TLS == 1 {
base["tls"] = buildTLSConfig(nc, certFile, keyFile)
} else if nc.TLS == 2 {
base["tls"] = buildRealityConfig(nc)
}
return base
}
func buildTrojan(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
base["type"] = "trojan"
userList := make([]M, 0, len(users))
for _, u := range users {
userList = append(userList, M{
"name": u.UUID,
"password": u.UUID,
})
}
base["users"] = userList
applyTransport(base, nc)
if nc.TLS == 1 {
base["tls"] = buildTLSConfig(nc, certFile, keyFile)
} else if nc.TLS == 2 {
base["tls"] = buildRealityConfig(nc)
}
return base
}
func buildHysteria(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
if nc.Version == 2 {
base["type"] = "hysteria2"
userList := make([]M, 0, len(users))
for _, u := range users {
userList = append(userList, M{
"name": u.UUID,
"password": u.UUID,
})
}
base["users"] = userList
if nc.Obfs != "" {
base["obfs"] = M{
"type": nc.Obfs,
"password": nc.ObfsPassword,
}
}
} else {
base["type"] = "hysteria"
userList := make([]M, 0, len(users))
for _, u := range users {
userList = append(userList, M{
"name": u.UUID,
"auth_str": u.UUID,
})
}
base["users"] = userList
base["up_mbps"] = nc.UpMbps
base["down_mbps"] = nc.DownMbps
if nc.Obfs != "" {
base["obfs"] = nc.Obfs
}
}
tls := buildTLSConfig(nc, certFile, keyFile)
// Hysteria/Hysteria2 uses QUIC and requires ALPN; default to h3 if not set.
if _, ok := tls["alpn"]; !ok {
tls["alpn"] = []string{"h3"}
}
base["tls"] = tls
return base
}
func buildTUIC(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
base["type"] = "tuic"
userList := make([]M, 0, len(users))
for _, u := range users {
userList = append(userList, M{
"name": u.UUID,
"uuid": u.UUID,
"password": u.UUID,
})
}
base["users"] = userList
if nc.CongestionControl != "" {
base["congestion_control"] = nc.CongestionControl
}
tls := buildTLSConfig(nc, certFile, keyFile)
// TUIC requires ALPN for QUIC negotiation; default to h3 if not set by panel.
if _, ok := tls["alpn"]; !ok {
tls["alpn"] = []string{"h3"}
}
base["tls"] = tls
return base
}
func buildAnyTLS(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
base["type"] = "anytls"
userList := make([]M, 0, len(users))
for _, u := range users {
userList = append(userList, M{
"name": u.UUID,
"password": u.UUID,
})
}
base["users"] = userList
if nc.PaddingScheme != "" {
base["padding_scheme"] = string(nc.PaddingScheme)
}
base["tls"] = buildTLSConfig(nc, certFile, keyFile)
return base
}
func buildNaive(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
base["type"] = "naive"
userList := make([]M, 0, len(users))
for _, u := range users {
userList = append(userList, M{
"username": strconv.Itoa(u.ID),
"password": u.UUID,
})
}
base["users"] = userList
if nc.TLS == 1 {
base["tls"] = buildTLSConfig(nc, certFile, keyFile)
}
return base
}
func buildSocks(base M, users []panel.User) M {
base["type"] = "socks"
userList := make([]M, 0, len(users))
for _, u := range users {
userList = append(userList, M{
"username": u.UUID,
"password": u.UUID,
})
}
base["users"] = userList
return base
}
func buildHTTP(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
base["type"] = "http"
userList := make([]M, 0, len(users))
for _, u := range users {
userList = append(userList, M{
"username": u.UUID,
"password": u.UUID,
})
}
base["users"] = userList
if nc.TLS == 1 {
base["tls"] = buildTLSConfig(nc, certFile, keyFile)
}
return base
}
func applyTransport(base M, nc *panel.NodeConfig) {
if nc.Network == "" || nc.Network == "tcp" {
return
}
transport := M{"type": nc.Network}
if nc.NetworkSettings != nil {
switch nc.Network {
case "ws":
if v, ok := nc.NetworkSettings["path"]; ok {
transport["path"] = v
}
if v, ok := nc.NetworkSettings["headers"]; ok {
transport["headers"] = v
}
if v, ok := nc.NetworkSettings["host"]; ok {
if transport["headers"] == nil {
transport["headers"] = M{"Host": v}
}
}
if v, ok := nc.NetworkSettings["max_early_data"]; ok {
transport["max_early_data"] = v
}
if v, ok := nc.NetworkSettings["early_data_header_name"]; ok {
transport["early_data_header_name"] = v
}
case "grpc":
if v, ok := nc.NetworkSettings["service_name"]; ok {
transport["service_name"] = v
}
case "httpupgrade":
if v, ok := nc.NetworkSettings["path"]; ok {
transport["path"] = v
}
if v, ok := nc.NetworkSettings["host"]; ok {
transport["host"] = v
}
case "h2", "http":
transport["type"] = "http"
if v, ok := nc.NetworkSettings["path"]; ok {
transport["path"] = v
}
if v, ok := nc.NetworkSettings["host"]; ok {
transport["host"] = v
}
}
}
base["transport"] = transport
}
func buildTLSConfig(nc *panel.NodeConfig, certFile, keyFile string) M {
tls := M{"enabled": true}
serverName := nc.ServerName
if serverName == "" && nc.Host != "" {
serverName = nc.Host
}
if serverName != "" {
tls["server_name"] = serverName
}
if nc.TLSSettings != nil {
if sn, ok := nc.TLSSettings["server_name"]; ok && sn != "" {
tls["server_name"] = sn
}
if alpn, ok := nc.TLSSettings["alpn"]; ok {
tls["alpn"] = alpn
}
}
if certFile != "" && keyFile != "" {
tls["certificate_path"] = certFile
tls["key_path"] = keyFile
} else {
// If no real certificates are provided, but TLS is requested,
// use a self-signed certificate as fallback to prevent sing-box
// from failing with "missing certificate".
tls["certificate_path"] = "self-signed"
}
return tls
}
func buildRealityConfig(nc *panel.NodeConfig) 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
}