package render import ( "encoding/json" "fmt" "github.com/peterqiu0516/sub-store/internal/model" "github.com/peterqiu0516/sub-store/internal/util" ) // RenderSingBoxJson renders proxies as a sing-box JSON config. // Per review-resolution #42: full structure with log, inbounds, outbounds, route. // Updated for sing-box 1.11+ migration: legacy inbound fields (sniff) → route rule actions, // legacy special outbound (block) → route rule action (reject), WireGuard outbound → endpoint. func RenderSingBoxJson(proxies []model.ProxyNode) string { var nodeOutbounds []map[string]any var wireGuardEndpoints []map[string]any for _, p := range proxies { if wg := ToSingBoxWireGuardEndpoint(p); wg != nil { wireGuardEndpoints = append(wireGuardEndpoints, wg) continue } if out := ToSingBoxOutbound(p); out != nil { nodeOutbounds = append(nodeOutbounds, out) } } tags := make([]string, 0, len(nodeOutbounds)) for _, out := range nodeOutbounds { if t, ok := out["tag"].(string); ok { tags = append(tags, t) } } // Build outbounds: PROXY (selector), AUTO (urltest), node outbounds, DIRECT // (REJECT is now a route rule action, not a special outbound) proxyOutbounds := append([]string{"AUTO"}, tags...) outbounds := []any{ map[string]any{ "type": "selector", "tag": "PROXY", "outbounds": proxyOutbounds, "default": "AUTO", "interrupt_exist_connections": false, }, map[string]any{ "type": "urltest", "tag": "AUTO", "outbounds": tags, "url": model.TestURL, "interval": "5m", "tolerance": 50, "interrupt_exist_connections": false, }, } for _, out := range nodeOutbounds { outbounds = append(outbounds, out) } outbounds = append(outbounds, map[string]any{"type": "direct", "tag": "DIRECT"}, ) doc := map[string]any{ "log": map[string]any{"level": "info"}, "inbounds": []any{ map[string]any{ "type": "mixed", "tag": "mixed-in", "listen": "0.0.0.0", "listen_port": 7890, }, }, "outbounds": outbounds, "route": map[string]any{ "auto_detect_interface": true, "final": "PROXY", "rules": []any{ // Migrated from legacy inbound sniff field (sing-box 1.11+) map[string]any{"action": "sniff"}, // Migrated from legacy block outbound (sing-box 1.11+) map[string]any{"action": "reject", "outbound": "REJECT"}, }, }, } // Add WireGuard endpoints if any (sing-box 1.11+ migration) if len(wireGuardEndpoints) > 0 { doc["endpoints"] = wireGuardEndpoints } data, _ := json.MarshalIndent(doc, "", " ") return string(data) } // clashToSingBoxTransport converts a Clash proxy node's network/transport // fields into a sing-box transport object. Returns nil for tcp (no transport). func clashToSingBoxTransport(proxy model.ProxyNode) any { network := getString(proxy, "network") switch network { case "ws": wsOpts, _ := proxy["ws-opts"].(map[string]any) path := "/" if wsOpts != nil { if p, ok := wsOpts["path"].(string); ok && p != "" { path = p } } transportMap := map[string]any{ "type": "ws", "path": path, } if wsOpts != nil { if h, ok := wsOpts["headers"]; ok && h != nil { transportMap["headers"] = h } } return transportMap case "grpc": grpcOpts, _ := proxy["grpc-opts"].(map[string]any) serviceName := "" if grpcOpts != nil { if s, ok := grpcOpts["grpc-service-name"].(string); ok { serviceName = s } } return map[string]any{ "type": "grpc", "service_name": serviceName, } case "h2", "http": h2Opts, _ := proxy["h2-opts"].(map[string]any) transportMap := map[string]any{ "type": "http", } if h2Opts != nil { if host, ok := h2Opts["host"].([]any); ok && len(host) > 0 { hosts := make([]string, 0, len(host)) for _, h := range host { if s, ok := h.(string); ok && s != "" { hosts = append(hosts, s) } } transportMap["host"] = hosts } if p, ok := h2Opts["path"].(string); ok && p != "" { transportMap["path"] = p } } return transportMap case "quic": return map[string]any{ "type": "quic", } case "httpupgrade": wsOpts, _ := proxy["ws-opts"].(map[string]any) path := "/" if wsOpts != nil { if p, ok := wsOpts["path"].(string); ok && p != "" { path = p } } return map[string]any{ "type": "httpupgrade", "path": path, } } return nil } // ToSingBoxOutbound converts a proxy node to a sing-box outbound map. // Returns nil for unsupported proxy types. func ToSingBoxOutbound(proxy model.ProxyNode) map[string]any { ptype := getString(proxy, "type") server := getString(proxy, "server") port := getInt(proxy, "port") name := getString(proxy, "name") switch ptype { case "vless": realityOpts, _ := proxy["reality-opts"].(map[string]any) var tls any if getBool(proxy, "tls") { tlsMap := map[string]any{ "enabled": true, "server_name": proxy["servername"], "utls": map[string]any{"enabled": true, "fingerprint": strOr(proxy, "client-fingerprint", "chrome")}, } if realityOpts != nil { tlsMap["reality"] = util.StripUndefined(map[string]any{ "enabled": true, "public_key": realityOpts["public-key"], "short_id": realityOpts["short-id"], }) } tls = tlsMap } return util.StripUndefined(map[string]any{ "type": "vless", "tag": name, "server": server, "server_port": port, "uuid": proxy["uuid"], "flow": proxy["flow"], "packet_encoding": "xudp", "tls": tls, "transport": clashToSingBoxTransport(proxy), }) case "hysteria2": var obfs any if o := proxy["obfs"]; o != nil { obfs = map[string]any{"type": o, "password": proxy["obfs-password"]} } return util.StripUndefined(map[string]any{ "type": "hysteria2", "tag": name, "server": server, "server_port": port, "password": proxy["password"], "obfs": obfs, "tls": map[string]any{ "enabled": true, "server_name": proxy["sni"], "insecure": getBool(proxy, "skip-cert-verify"), }, }) case "hysteria": return util.StripUndefined(map[string]any{ "type": "hysteria", "tag": name, "server": server, "server_port": port, "auth_str": proxy["auth_str"], "up_mbps": numberOrUndefined(proxy["up"]), "down_mbps": numberOrUndefined(proxy["down"]), "obfs": stringSetting(proxy["obfs"]), "tls": map[string]any{ "enabled": true, "server_name": proxy["sni"], "insecure": getBool(proxy, "skip-cert-verify"), }, }) case "anytls": return util.StripUndefined(map[string]any{ "type": "anytls", "tag": name, "server": server, "server_port": port, "password": proxy["password"], "tls": map[string]any{ "enabled": true, "server_name": sniOr(proxy, "sni", "servername"), "insecure": getBool(proxy, "skip-cert-verify"), "utls": map[string]any{"enabled": true, "fingerprint": strOr(proxy, "client-fingerprint", "chrome")}, }, }) case "tuic": return util.StripUndefined(map[string]any{ "type": "tuic", "tag": name, "server": server, "server_port": port, "uuid": proxy["uuid"], "password": proxy["password"], "congestion_control": proxy["congestion-controller"], "udp_relay_mode": proxy["udp-relay-mode"], "zero_rtt_handshake": proxy["reduce-rtt"], "tls": map[string]any{ "enabled": true, "server_name": proxy["sni"], "insecure": getBool(proxy, "skip-cert-verify"), }, }) case "trojan": return util.StripUndefined(map[string]any{ "type": "trojan", "tag": name, "server": server, "server_port": port, "password": proxy["password"], "tls": map[string]any{ "enabled": true, "server_name": proxy["sni"], "insecure": getBool(proxy, "skip-cert-verify"), }, }) case "socks5": var tls any if getBool(proxy, "tls") { tls = map[string]any{"enabled": true} } return util.StripUndefined(map[string]any{ "type": "socks", "tag": name, "server": server, "server_port": port, "version": "5", "username": proxy["username"], "password": proxy["password"], "tls": tls, }) case "http": var tls any if getBool(proxy, "tls") { tls = map[string]any{"enabled": true} } return util.StripUndefined(map[string]any{ "type": "http", "tag": name, "server": server, "server_port": port, "username": proxy["username"], "password": proxy["password"], "tls": tls, }) case "ss": return util.StripUndefined(map[string]any{ "type": "shadowsocks", "tag": name, "server": server, "server_port": port, "method": proxy["cipher"], "password": proxy["password"], }) case "vmess": var tls any if getBool(proxy, "tls") { tls = map[string]any{"enabled": true, "server_name": proxy["servername"]} } return util.StripUndefined(map[string]any{ "type": "vmess", "tag": name, "server": server, "server_port": port, "uuid": proxy["uuid"], "security": strOr(proxy, "cipher", "auto"), "alter_id": proxy["alterId"], "tls": tls, "transport": clashToSingBoxTransport(proxy), }) } return nil } // ToSingBoxWireGuardEndpoint converts a WireGuard proxy node to a sing-box endpoint. // Per sing-box 1.11+ migration: WireGuard outbound → endpoint format. // Returns nil for non-wireguard proxies. func ToSingBoxWireGuardEndpoint(proxy model.ProxyNode) map[string]any { ptype := getString(proxy, "type") if ptype != "wireguard" { return nil } server := getString(proxy, "server") port := getInt(proxy, "port") name := getString(proxy, "name") var addresses []string if ip := stringSetting(proxy["ip"]); ip != "" { addresses = append(addresses, ip) } if ipv6 := stringSetting(proxy["ipv6"]); ipv6 != "" { addresses = append(addresses, ipv6) } peer := util.StripUndefined(map[string]any{ "address": server, "port": port, "public_key": proxy["public-key"], "allowed_ips": []string{"0.0.0.0/0", "::/0"}, }) if psk := stringSetting(proxy["pre-shared-key"]); psk != "" { peer["pre_shared_key"] = psk } if reserved := parseWireGuardReserved(proxy["reserved"]); reserved != nil { peer["reserved"] = reserved } endpoint := util.StripUndefined(map[string]any{ "type": "wireguard", "tag": name, "private_key": proxy["private-key"], "peers": []any{peer}, }) if len(addresses) > 0 { endpoint["address"] = addresses } return endpoint } func parseWireGuardReserved(value any) []int { switch v := value.(type) { case []any: var result []int for _, item := range v { n := numberOrUndefined(item) if n != 0 { result = append(result, n) } } if len(result) > 0 { return result } return nil case []int: return v case string: if v == "" { return nil } var result []int for _, part := range splitComma(v) { n := numberOrUndefined(part) result = append(result, n) } return result } return nil } func splitComma(s string) []string { var result []string start := 0 for i := 0; i < len(s); i++ { if s[i] == ',' { result = append(result, s[start:i]) start = i + 1 } } result = append(result, s[start:]) return result } // Ensure fmt is used var _ = fmt.Sprintf