vless outbound was passing Clash's network field (ws/grpc/h2) directly to sing-box's network field (which only accepts tcp/udp). Extract clashToSingBoxTransport() to convert ws/grpc/h2/quic/httpupgrade into sing-box transport objects. Apply to both vless and vmess outbounds.
449 lines
12 KiB
Go
449 lines
12 KiB
Go
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
|