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 remain the escape hatch, but no longer outrank structured rules. for _, cr := range custom { 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 } 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 }