feat: sing-box Clash template support + Docker CI + frontend scaffold
- Clash→sing-box template conversion engine (singbox_template.go) - Auto-map 40+ Clash rule-providers to sing-box .srs binary rule-sets - Fix sing-box 1.11+/1.12+ migrations (sniff/block/WireGuard/DNS/rule_set) - Fix WireGuard URI '+' parsing bug (rawParamGet) - Add REJECT block outbound for selector group references - Skip rules referencing rule-sets with no sing-box equivalent - Verified: sing-box run succeeds with ACL4SSR template (16 rule-sets loaded) - Add Dockerfile (multi-stage Go build) - Add GitHub Actions workflow (multi-arch: amd64+arm64, push to ghcr.io) - Add frontend scaffold (Vue3 + Vite + Tailwind)
This commit is contained in:
@@ -31,7 +31,7 @@ func RenderTarget(proxies []model.ProxyNode, target string, template map[string]
|
||||
case model.TargetQX:
|
||||
return RenderQxProxies(proxies)
|
||||
case model.TargetSingBox:
|
||||
return RenderSingBoxJson(proxies), nil
|
||||
return RenderSingBoxWithTemplate(proxies, template), nil
|
||||
case model.TargetV2ray:
|
||||
return util.Base64Utf8(RenderProxyUris(proxies)), nil
|
||||
case model.TargetURI, model.TargetShadowrocket:
|
||||
@@ -63,7 +63,7 @@ func RenderBuildTarget(proxies []model.ProxyNode, target string, requestUrl stri
|
||||
case model.TargetQX:
|
||||
return RenderQxProxies(proxies)
|
||||
case model.TargetSingBox:
|
||||
return RenderSingBoxJson(proxies), nil
|
||||
return RenderSingBoxWithTemplate(proxies, template), nil
|
||||
case model.TargetV2ray:
|
||||
return util.Base64Utf8(RenderProxyUris(proxies)), nil
|
||||
case model.TargetURI:
|
||||
|
||||
@@ -239,6 +239,9 @@ func extractGroupTemplates(cfg map[string]any) []map[string]any {
|
||||
}
|
||||
|
||||
func toGroupList(v any) []map[string]any {
|
||||
if arr, ok := v.([]map[string]any); ok {
|
||||
return arr
|
||||
}
|
||||
arr, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
|
||||
@@ -878,8 +878,8 @@ func TestExtraSingBoxStructure(t *testing.T) {
|
||||
t.Errorf("expected listen_port=7890, got: %v", inbound["listen_port"])
|
||||
}
|
||||
outbounds, _ := parsed["outbounds"].([]any)
|
||||
if len(outbounds) < 4 {
|
||||
t.Fatalf("expected at least 4 outbounds (PROXY, AUTO, nodes, DIRECT, REJECT), got %d", len(outbounds))
|
||||
if len(outbounds) < 3 {
|
||||
t.Fatalf("expected at least 3 outbounds (PROXY, AUTO, nodes, DIRECT), got %d", len(outbounds))
|
||||
}
|
||||
proxyOut, _ := outbounds[0].(map[string]any)
|
||||
if proxyOut["tag"] != "PROXY" || proxyOut["type"] != "selector" {
|
||||
@@ -889,16 +889,29 @@ func TestExtraSingBoxStructure(t *testing.T) {
|
||||
if autoOut["tag"] != "AUTO" || autoOut["type"] != "urltest" {
|
||||
t.Errorf("expected second outbound AUTO urltest, got: %v", autoOut)
|
||||
}
|
||||
// DIRECT and REJECT should be at the end
|
||||
// DIRECT should be at the end (REJECT is now a route rule action, not a special outbound)
|
||||
last, _ := outbounds[len(outbounds)-1].(map[string]any)
|
||||
if last["tag"] != "REJECT" || last["type"] != "block" {
|
||||
t.Errorf("expected last outbound REJECT block, got: %v", last)
|
||||
if last["tag"] != "DIRECT" || last["type"] != "direct" {
|
||||
t.Errorf("expected last outbound DIRECT direct, got: %v", last)
|
||||
}
|
||||
secondLast, _ := outbounds[len(outbounds)-2].(map[string]any)
|
||||
if secondLast["tag"] != "DIRECT" || secondLast["type"] != "direct" {
|
||||
t.Errorf("expected second-last outbound DIRECT direct, got: %v", secondLast)
|
||||
// Inbound should NOT have sniff field (migrated to route rule action in sing-box 1.11+)
|
||||
if _, hasSniff := inbound["sniff"]; hasSniff {
|
||||
t.Errorf("inbound should not have sniff field (migrated to route rule action)")
|
||||
}
|
||||
// Route should have rules with sniff and reject actions
|
||||
routeMap, _ := parsed["route"].(map[string]any)
|
||||
rules, _ := routeMap["rules"].([]any)
|
||||
if len(rules) < 2 {
|
||||
t.Fatalf("expected at least 2 route rules (sniff, reject), got %d", len(rules))
|
||||
}
|
||||
rule0, _ := rules[0].(map[string]any)
|
||||
if rule0["action"] != "sniff" {
|
||||
t.Errorf("expected first route rule action=sniff, got: %v", rule0["action"])
|
||||
}
|
||||
rule1, _ := rules[1].(map[string]any)
|
||||
if rule1["action"] != "reject" {
|
||||
t.Errorf("expected second route rule action=reject, got: %v", rule1["action"])
|
||||
}
|
||||
if routeMap == nil || routeMap["final"] != "PROXY" {
|
||||
t.Errorf("expected route.final=PROXY, got: %v", parsed["route"])
|
||||
}
|
||||
@@ -923,7 +936,6 @@ func TestExtraSingBoxOutboundAllTypes(t *testing.T) {
|
||||
{"tuic", nodeByName("TUIC"), "tuic"},
|
||||
{"socks5", nodeByName("SOCKS5"), "socks"},
|
||||
{"http", nodeByName("HTTP"), "http"},
|
||||
{"wireguard", nodeByName("WG"), "wireguard"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
@@ -941,6 +953,31 @@ func TestExtraSingBoxOutboundAllTypes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtraSingBoxWireGuardEndpoint(t *testing.T) {
|
||||
node := nodeByName("WG")
|
||||
ep := ToSingBoxWireGuardEndpoint(node)
|
||||
if ep == nil {
|
||||
t.Fatalf("ToSingBoxWireGuardEndpoint(WG) nil")
|
||||
}
|
||||
if ep["type"] != "wireguard" {
|
||||
t.Errorf("expected type=wireguard, got %v", ep["type"])
|
||||
}
|
||||
if ep["tag"] != "WG-Node" {
|
||||
t.Errorf("expected tag=WG-Node, got %v", ep["tag"])
|
||||
}
|
||||
peers, ok := ep["peers"].([]any)
|
||||
if !ok || len(peers) == 0 {
|
||||
t.Fatalf("expected peers array, got: %v", ep["peers"])
|
||||
}
|
||||
peer, ok := peers[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected peer map, got: %v", peers[0])
|
||||
}
|
||||
if peer["public_key"] == nil {
|
||||
t.Errorf("expected peer.public_key to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtraSingBoxUnsupportedType(t *testing.T) {
|
||||
node := model.ProxyNode{"type": "snell", "name": "x"}
|
||||
if out := ToSingBoxOutbound(node); out != nil {
|
||||
@@ -1029,10 +1066,15 @@ func TestExtraSingBoxHTTPTls(t *testing.T) {
|
||||
|
||||
func TestExtraSingBoxWireGuardReserved(t *testing.T) {
|
||||
node := nodeByName("WG")
|
||||
out := ToSingBoxOutbound(node)
|
||||
reserved, ok := out["reserved"].([]int)
|
||||
ep := ToSingBoxWireGuardEndpoint(node)
|
||||
peers, ok := ep["peers"].([]any)
|
||||
if !ok || len(peers) == 0 {
|
||||
t.Fatalf("expected peers array, got: %v", ep["peers"])
|
||||
}
|
||||
peer := peers[0].(map[string]any)
|
||||
reserved, ok := peer["reserved"].([]int)
|
||||
if !ok {
|
||||
t.Fatalf("expected reserved []int, got: %v", out["reserved"])
|
||||
t.Fatalf("expected peer reserved []int, got: %v", peer["reserved"])
|
||||
}
|
||||
if !reflect.DeepEqual(reserved, []int{1, 2, 3}) {
|
||||
t.Errorf("expected reserved=[1,2,3], got: %v", reserved)
|
||||
@@ -1041,13 +1083,13 @@ func TestExtraSingBoxWireGuardReserved(t *testing.T) {
|
||||
|
||||
func TestExtraSingBoxWireGuardLocalAddress(t *testing.T) {
|
||||
node := nodeByName("WG")
|
||||
out := ToSingBoxOutbound(node)
|
||||
la, ok := out["local_address"].([]string)
|
||||
ep := ToSingBoxWireGuardEndpoint(node)
|
||||
addr, ok := ep["address"].([]string)
|
||||
if !ok {
|
||||
t.Fatalf("expected local_address, got: %v", out["local_address"])
|
||||
t.Fatalf("expected address, got: %v", ep["address"])
|
||||
}
|
||||
if len(la) != 2 {
|
||||
t.Errorf("expected 2 local addresses (ip+ipv6), got %d", len(la))
|
||||
if len(addr) != 2 {
|
||||
t.Errorf("expected 2 addresses (ip+ipv6), got %d", len(addr))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+77
-44
@@ -10,9 +10,16 @@ import (
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -25,29 +32,26 @@ func RenderSingBoxJson(proxies []model.ProxyNode) string {
|
||||
}
|
||||
}
|
||||
|
||||
if len(tags) == 0 {
|
||||
// return minimal config even with no supported nodes
|
||||
}
|
||||
|
||||
// Build outbounds: PROXY (selector), AUTO (urltest), node outbounds, DIRECT, REJECT
|
||||
// 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,
|
||||
"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,
|
||||
"type": "urltest",
|
||||
"tag": "AUTO",
|
||||
"outbounds": tags,
|
||||
"url": model.TestURL,
|
||||
"interval": "5m",
|
||||
"tolerance": 50,
|
||||
"interrupt_exist_connections": false,
|
||||
},
|
||||
}
|
||||
for _, out := range nodeOutbounds {
|
||||
@@ -55,7 +59,6 @@ func RenderSingBoxJson(proxies []model.ProxyNode) string {
|
||||
}
|
||||
outbounds = append(outbounds,
|
||||
map[string]any{"type": "direct", "tag": "DIRECT"},
|
||||
map[string]any{"type": "block", "tag": "REJECT"},
|
||||
)
|
||||
|
||||
doc := map[string]any{
|
||||
@@ -66,16 +69,26 @@ func RenderSingBoxJson(proxies []model.ProxyNode) string {
|
||||
"tag": "mixed-in",
|
||||
"listen": "0.0.0.0",
|
||||
"listen_port": 7890,
|
||||
"sniff": true,
|
||||
},
|
||||
},
|
||||
"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)
|
||||
}
|
||||
@@ -243,31 +256,6 @@ func ToSingBoxOutbound(proxy model.ProxyNode) map[string]any {
|
||||
"password": proxy["password"],
|
||||
})
|
||||
|
||||
case "wireguard":
|
||||
var localAddress []string
|
||||
if ip := stringSetting(proxy["ip"]); ip != "" {
|
||||
localAddress = append(localAddress, ip)
|
||||
}
|
||||
if ipv6 := stringSetting(proxy["ipv6"]); ipv6 != "" {
|
||||
localAddress = append(localAddress, ipv6)
|
||||
}
|
||||
result := util.StripUndefined(map[string]any{
|
||||
"type": "wireguard",
|
||||
"tag": name,
|
||||
"server": server,
|
||||
"server_port": port,
|
||||
"private_key": proxy["private-key"],
|
||||
"peer_public_key": proxy["public-key"],
|
||||
"pre_shared_key": proxy["pre-shared-key"],
|
||||
})
|
||||
if len(localAddress) > 0 {
|
||||
result["local_address"] = localAddress
|
||||
}
|
||||
if reserved := parseWireGuardReserved(proxy["reserved"]); reserved != nil {
|
||||
result["reserved"] = reserved
|
||||
}
|
||||
return result
|
||||
|
||||
case "vmess":
|
||||
var tls any
|
||||
if getBool(proxy, "tls") {
|
||||
@@ -309,6 +297,51 @@ func ToSingBoxOutbound(proxy model.ProxyNode) map[string]any {
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,788 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/peterqiu0516/sub-store/internal/model"
|
||||
"github.com/peterqiu0516/sub-store/internal/util"
|
||||
)
|
||||
|
||||
// RenderSingBoxWithTemplate renders proxies as a sing-box JSON config,
|
||||
// applying the Clash-style routing template (proxy-groups, rules, DNS, rule-providers).
|
||||
// If template is nil or empty, falls back to the default sing-box config (no template).
|
||||
func RenderSingBoxWithTemplate(proxies []model.ProxyNode, template map[string]any) string {
|
||||
if template == nil || len(template) == 0 {
|
||||
return RenderSingBoxJson(proxies)
|
||||
}
|
||||
|
||||
// Parse proxies into node outbounds and WireGuard endpoints
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Collect node tags
|
||||
nodeTags := make([]string, 0, len(nodeOutbounds))
|
||||
nodeTagSet := make(map[string]bool)
|
||||
for _, out := range nodeOutbounds {
|
||||
if t, ok := out["tag"].(string); ok {
|
||||
nodeTags = append(nodeTags, t)
|
||||
nodeTagSet[t] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Build outbounds from template proxy-groups
|
||||
groupOutbounds, groupNames := buildSingBoxOutboundsFromTemplate(template, nodeTags)
|
||||
allOutbounds := append([]any{}, groupOutbounds...)
|
||||
for _, out := range nodeOutbounds {
|
||||
allOutbounds = append(allOutbounds, out)
|
||||
}
|
||||
// Always add DIRECT and REJECT (block) outbounds.
|
||||
// REJECT is kept as a block-type outbound because Clash proxy-groups reference it
|
||||
// as a member (e.g. "🛑 全球拦截" → ["REJECT", "DIRECT"]).
|
||||
// While sing-box 1.11+ deprecated block as a route rule action, a block outbound
|
||||
// is still valid when referenced directly by selector/urltest groups.
|
||||
allOutbounds = append(allOutbounds,
|
||||
map[string]any{"type": "direct", "tag": "DIRECT"},
|
||||
map[string]any{"type": "block", "tag": "REJECT"},
|
||||
)
|
||||
|
||||
// Determine final outbound
|
||||
finalOutbound := "PROXY"
|
||||
if len(groupNames) > 0 {
|
||||
finalOutbound = groupNames[0]
|
||||
}
|
||||
|
||||
// Build route rules from template rules, skipping rules that reference
|
||||
// skipped rule-sets (those with empty .srs mappings).
|
||||
skippedRuleSets := getSkippedRuleSets(template)
|
||||
routeRules := buildSingBoxRouteRules(template, groupNames, nodeTagSet, skippedRuleSets)
|
||||
|
||||
// Always add sniff action first
|
||||
allRules := []any{
|
||||
map[string]any{"action": "sniff"},
|
||||
}
|
||||
allRules = append(allRules, routeRules...)
|
||||
|
||||
// Build the document
|
||||
mixedPort := firstInt(template, "mixedPort", "mixed-port")
|
||||
if mixedPort == 0 {
|
||||
mixedPort = 7890
|
||||
}
|
||||
|
||||
logLevel := firstStr(template, "logLevel", "log-level")
|
||||
if logLevel == "" {
|
||||
logLevel = "info"
|
||||
}
|
||||
|
||||
doc := map[string]any{
|
||||
"log": map[string]any{"level": logLevel},
|
||||
"inbounds": []any{
|
||||
map[string]any{
|
||||
"type": "mixed",
|
||||
"tag": "mixed-in",
|
||||
"listen": "0.0.0.0",
|
||||
"listen_port": mixedPort,
|
||||
},
|
||||
},
|
||||
"outbounds": allOutbounds,
|
||||
"route": map[string]any{
|
||||
"auto_detect_interface": true,
|
||||
"final": finalOutbound,
|
||||
"rules": allRules,
|
||||
"default_domain_resolver": map[string]any{"server": "local-dns"},
|
||||
},
|
||||
}
|
||||
|
||||
// Add WireGuard endpoints
|
||||
if len(wireGuardEndpoints) > 0 {
|
||||
doc["endpoints"] = wireGuardEndpoints
|
||||
}
|
||||
|
||||
// Add DNS if configured
|
||||
if dnsCfg := buildSingBoxDNS(template); dnsCfg != nil {
|
||||
doc["dns"] = dnsCfg
|
||||
}
|
||||
|
||||
// Add rule_set if rule-providers configured
|
||||
ruleSets := buildSingBoxRuleSets(template)
|
||||
// Also add auto-generated rule_sets for GEOIP/GEOSITE rules
|
||||
geoRuleSets := collectGeoRuleSets(template)
|
||||
allRuleSets := append(ruleSets, geoRuleSets...)
|
||||
if len(allRuleSets) > 0 {
|
||||
route := doc["route"].(map[string]any)
|
||||
route["rule_set"] = allRuleSets
|
||||
}
|
||||
|
||||
data, _ := jsonMarshalIndent(doc)
|
||||
return data
|
||||
}
|
||||
|
||||
// buildSingBoxOutboundsFromTemplate converts Clash proxy-groups to sing-box outbounds.
|
||||
func buildSingBoxOutboundsFromTemplate(template map[string]any, nodeTags []string) ([]any, []string) {
|
||||
groups := extractGroupTemplates(template)
|
||||
if len(groups) == 0 {
|
||||
// Fallback: default PROXY + AUTO
|
||||
proxyOutbounds := append([]string{"AUTO"}, nodeTags...)
|
||||
return []any{
|
||||
map[string]any{
|
||||
"type": "selector",
|
||||
"tag": "PROXY",
|
||||
"outbounds": proxyOutbounds,
|
||||
"default": "AUTO",
|
||||
"interrupt_exist_connections": false,
|
||||
},
|
||||
map[string]any{
|
||||
"type": "urltest",
|
||||
"tag": "AUTO",
|
||||
"outbounds": nodeTags,
|
||||
"url": model.TestURL,
|
||||
"interval": "5m",
|
||||
"tolerance": 50,
|
||||
"interrupt_exist_connections": false,
|
||||
},
|
||||
}, []string{"PROXY", "AUTO"}
|
||||
}
|
||||
|
||||
// Expand group proxies and build sing-box outbounds
|
||||
expandedGroups := expandGroupsForSingBox(groups, nodeTags)
|
||||
|
||||
var outbounds []any
|
||||
var groupNames []string
|
||||
groupNameSet := make(map[string]bool)
|
||||
|
||||
for _, g := range expandedGroups {
|
||||
if len(g.proxies) == 0 {
|
||||
continue
|
||||
}
|
||||
name := getString(g.template, "name")
|
||||
groupNames = append(groupNames, name)
|
||||
groupNameSet[name] = true
|
||||
|
||||
clashType := getString(g.template, "type")
|
||||
outbound := buildSingBoxGroupOutbound(name, clashType, g.proxies, g.template, groupNameSet)
|
||||
if outbound != nil {
|
||||
outbounds = append(outbounds, outbound)
|
||||
}
|
||||
}
|
||||
|
||||
return outbounds, groupNames
|
||||
}
|
||||
|
||||
type expandedGroup struct {
|
||||
template map[string]any
|
||||
proxies []string
|
||||
}
|
||||
|
||||
// expandGroupsForSingBox expands $all and filter regex for each group,
|
||||
// then filters references to non-existent nodes/groups.
|
||||
func expandGroupsForSingBox(groups []map[string]any, nodeTags []string) []expandedGroup {
|
||||
// First pass: expand all groups
|
||||
var expanded []expandedGroup
|
||||
for _, group := range groups {
|
||||
expanded = append(expanded, expandedGroup{
|
||||
template: group,
|
||||
proxies: ExpandGroupProxies(group, nodeTags),
|
||||
})
|
||||
}
|
||||
|
||||
// Collect all valid group names
|
||||
groupNames := make(map[string]bool)
|
||||
for _, g := range expanded {
|
||||
if len(g.proxies) > 0 {
|
||||
groupNames[getString(g.template, "name")] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: filter references
|
||||
allowedLiterals := map[string]bool{"DIRECT": true, "REJECT": true, "PASS": true}
|
||||
nodeSet := make(map[string]bool)
|
||||
for _, t := range nodeTags {
|
||||
nodeSet[t] = true
|
||||
}
|
||||
|
||||
for i := range expanded {
|
||||
var filtered []string
|
||||
seen := make(map[string]bool)
|
||||
for _, name := range expanded[i].proxies {
|
||||
if seen[name] {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
if nodeSet[name] || groupNames[name] || allowedLiterals[name] {
|
||||
filtered = append(filtered, name)
|
||||
}
|
||||
}
|
||||
expanded[i].proxies = filtered
|
||||
}
|
||||
|
||||
return expanded
|
||||
}
|
||||
|
||||
// buildSingBoxGroupOutbound converts a single Clash proxy-group to a sing-box outbound.
|
||||
func buildSingBoxGroupOutbound(name, clashType string, proxies []string, template map[string]any, groupNames map[string]bool) map[string]any {
|
||||
// Map DIRECT/REJECT to sing-box equivalents
|
||||
// sing-box uses "direct" outbound tag and route rule "reject" action
|
||||
sbProxies := make([]string, 0, len(proxies))
|
||||
for _, p := range proxies {
|
||||
sbProxies = append(sbProxies, p)
|
||||
}
|
||||
|
||||
switch clashType {
|
||||
case "select":
|
||||
return util.StripUndefined(map[string]any{
|
||||
"type": "selector",
|
||||
"tag": name,
|
||||
"outbounds": sbProxies,
|
||||
"interrupt_exist_connections": false,
|
||||
})
|
||||
|
||||
case "url-test":
|
||||
interval := getString(template, "interval")
|
||||
if interval == "" {
|
||||
interval = "5m"
|
||||
}
|
||||
// Clash interval is in seconds; convert to sing-box duration string
|
||||
if n := toInt(interval); n > 0 {
|
||||
interval = formatDuration(n)
|
||||
}
|
||||
tolerance := toInt(getString(template, "tolerance"))
|
||||
if tolerance == 0 {
|
||||
tolerance = 50
|
||||
}
|
||||
return util.StripUndefined(map[string]any{
|
||||
"type": "urltest",
|
||||
"tag": name,
|
||||
"outbounds": sbProxies,
|
||||
"url": strOr(template, "url", model.TestURL),
|
||||
"interval": interval,
|
||||
"tolerance": tolerance,
|
||||
"interrupt_exist_connections": false,
|
||||
})
|
||||
|
||||
case "fallback":
|
||||
// sing-box has no fallback type; use urltest as closest equivalent
|
||||
interval := getString(template, "interval")
|
||||
if interval == "" {
|
||||
interval = "5m"
|
||||
}
|
||||
if n := toInt(interval); n > 0 {
|
||||
interval = formatDuration(n)
|
||||
}
|
||||
return util.StripUndefined(map[string]any{
|
||||
"type": "urltest",
|
||||
"tag": name,
|
||||
"outbounds": sbProxies,
|
||||
"url": strOr(template, "url", model.TestURL),
|
||||
"interval": interval,
|
||||
"tolerance": 50,
|
||||
"interrupt_exist_connections": false,
|
||||
})
|
||||
|
||||
case "load-balance":
|
||||
// sing-box has no load-balance type; use selector as closest equivalent
|
||||
strategy := getString(template, "strategy")
|
||||
_ = strategy // not directly mappable
|
||||
return util.StripUndefined(map[string]any{
|
||||
"type": "selector",
|
||||
"tag": name,
|
||||
"outbounds": sbProxies,
|
||||
"interrupt_exist_connections": false,
|
||||
})
|
||||
|
||||
default:
|
||||
// Unknown type: default to selector
|
||||
return util.StripUndefined(map[string]any{
|
||||
"type": "selector",
|
||||
"tag": name,
|
||||
"outbounds": sbProxies,
|
||||
"interrupt_exist_connections": false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// buildSingBoxRouteRules converts Clash rules to sing-box route rules.
|
||||
// Rules referencing skipped rule-sets (no sing-box equivalent) are skipped.
|
||||
func buildSingBoxRouteRules(template map[string]any, groupNames []string, nodeTagSet map[string]bool, skippedRuleSets map[string]bool) []any {
|
||||
rules, _ := template["rules"].([]any)
|
||||
if len(rules) == 0 {
|
||||
return []any{
|
||||
map[string]any{"outbound": "PROXY"},
|
||||
}
|
||||
}
|
||||
|
||||
groupSet := make(map[string]bool)
|
||||
for _, g := range groupNames {
|
||||
groupSet[g] = true
|
||||
}
|
||||
|
||||
var result []any
|
||||
for _, r := range rules {
|
||||
ruleStr, ok := r.(string)
|
||||
if !ok || ruleStr == "" {
|
||||
continue
|
||||
}
|
||||
// Skip RULE-SET rules that reference skipped rule-sets
|
||||
parts := strings.Split(ruleStr, ",")
|
||||
if len(parts) >= 2 && strings.TrimSpace(parts[0]) == "RULE-SET" {
|
||||
rsName := strings.TrimSpace(parts[1])
|
||||
if skippedRuleSets[rsName] {
|
||||
continue
|
||||
}
|
||||
}
|
||||
sbRule := convertClashRuleToSingBox(ruleStr, groupSet, nodeTagSet)
|
||||
if sbRule != nil {
|
||||
result = append(result, sbRule)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// getSkippedRuleSets returns the set of rule-provider names that have empty
|
||||
// mappings in clashRuleSetMappings (i.e., no sing-box equivalent exists).
|
||||
func getSkippedRuleSets(template map[string]any) map[string]bool {
|
||||
skipped := make(map[string]bool)
|
||||
rpRaw, ok := template["ruleProviders"]
|
||||
if !ok {
|
||||
rpRaw, ok = template["rule-providers"]
|
||||
}
|
||||
if !ok || rpRaw == nil {
|
||||
return skipped
|
||||
}
|
||||
rp, ok := rpRaw.(map[string]any)
|
||||
if !ok {
|
||||
return skipped
|
||||
}
|
||||
for name := range rp {
|
||||
if srsURL, mapped := clashRuleSetMappings[name]; mapped && srsURL == "" {
|
||||
skipped[name] = true
|
||||
}
|
||||
}
|
||||
return skipped
|
||||
}
|
||||
|
||||
// collectGeoRuleSets scans Clash rules for GEOIP/GEOSITE references and returns
|
||||
// auto-generated rule_set definitions pointing to SagerNet's sing-geoip/sing-geosite repos.
|
||||
func collectGeoRuleSets(template map[string]any) []any {
|
||||
rules, _ := template["rules"].([]any)
|
||||
seen := make(map[string]bool)
|
||||
var result []any
|
||||
|
||||
for _, r := range rules {
|
||||
ruleStr, ok := r.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
parts := strings.Split(ruleStr, ",")
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
ruleType := strings.TrimSpace(parts[0])
|
||||
code := strings.ToLower(strings.TrimSpace(parts[1]))
|
||||
|
||||
var tag, url string
|
||||
switch ruleType {
|
||||
case "GEOIP":
|
||||
tag = "geoip-" + code
|
||||
url = "https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-" + code + ".srs"
|
||||
case "GEOSITE":
|
||||
tag = "geosite-" + code
|
||||
url = "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-" + code + ".srs"
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
if seen[tag] {
|
||||
continue
|
||||
}
|
||||
seen[tag] = true
|
||||
|
||||
result = append(result, map[string]any{
|
||||
"tag": tag,
|
||||
"type": "remote",
|
||||
"format": "binary",
|
||||
"url": url,
|
||||
"download_detour": "DIRECT",
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// convertClashRuleToSingBox converts a single Clash rule line to a sing-box route rule.
|
||||
func convertClashRuleToSingBox(rule string, groupSet, nodeSet map[string]bool) map[string]any {
|
||||
parts := strings.Split(rule, ",")
|
||||
if len(parts) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ruleType := strings.TrimSpace(parts[0])
|
||||
|
||||
// Determine the outbound (policy)
|
||||
var policy string
|
||||
if ruleType == "MATCH" {
|
||||
if len(parts) >= 2 {
|
||||
policy = strings.TrimSpace(parts[1])
|
||||
}
|
||||
} else {
|
||||
if len(parts) >= 3 {
|
||||
policy = strings.TrimSpace(parts[2])
|
||||
}
|
||||
}
|
||||
|
||||
// Map policy to sing-box outbound
|
||||
outbound := mapPolicyToSingBox(policy, groupSet, nodeSet)
|
||||
|
||||
switch ruleType {
|
||||
case "DOMAIN":
|
||||
return map[string]any{"domain": []string{parts[1]}, "outbound": outbound}
|
||||
|
||||
case "DOMAIN-SUFFIX":
|
||||
return map[string]any{"domain_suffix": []string{parts[1]}, "outbound": outbound}
|
||||
|
||||
case "DOMAIN-KEYWORD":
|
||||
return map[string]any{"domain_keyword": []string{parts[1]}, "outbound": outbound}
|
||||
|
||||
case "IP-CIDR", "IP-CIDR6":
|
||||
opts := []string{}
|
||||
for i := 3; i < len(parts); i++ {
|
||||
opt := strings.TrimSpace(parts[i])
|
||||
if opt == "no-resolve" {
|
||||
opts = append(opts, "no_resolve")
|
||||
}
|
||||
}
|
||||
rule := map[string]any{"ip_cidr": []string{parts[1]}, "outbound": outbound}
|
||||
if len(opts) > 0 {
|
||||
rule["ip_cidr_no_resolve"] = true
|
||||
}
|
||||
return rule
|
||||
|
||||
case "GEOIP":
|
||||
geoip := strings.TrimSpace(parts[1])
|
||||
// sing-box 1.12+ removed legacy geoip database; use rule_set instead.
|
||||
// Auto-map common geoip codes to remote rule_sets.
|
||||
return map[string]any{"rule_set": "geoip-" + strings.ToLower(geoip), "outbound": outbound}
|
||||
|
||||
case "GEOSITE":
|
||||
geosite := strings.TrimSpace(parts[1])
|
||||
// sing-box 1.12+ removed legacy geosite database; use rule_set instead.
|
||||
return map[string]any{"rule_set": "geosite-" + strings.ToLower(geosite), "outbound": outbound}
|
||||
|
||||
case "PROCESS-NAME":
|
||||
return map[string]any{"process_name": []string{parts[1]}, "outbound": outbound}
|
||||
|
||||
case "DST-PORT":
|
||||
return map[string]any{"port": parts[1], "outbound": outbound}
|
||||
|
||||
case "SRC-PORT":
|
||||
return map[string]any{"source_port": parts[1], "outbound": outbound}
|
||||
|
||||
case "RULE-SET":
|
||||
// RULE-SET,provider-name,policy → rule_set + outbound
|
||||
return map[string]any{"rule_set": parts[1], "outbound": outbound}
|
||||
|
||||
case "MATCH":
|
||||
// MATCH is the final catch-all; handled by route.final
|
||||
return nil
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// mapPolicyToSingBox maps a Clash policy name to the sing-box outbound tag.
|
||||
func mapPolicyToSingBox(policy string, groupSet, nodeSet map[string]bool) string {
|
||||
switch policy {
|
||||
case "DIRECT":
|
||||
return "DIRECT"
|
||||
case "REJECT":
|
||||
return "REJECT"
|
||||
case "PASS":
|
||||
return "DIRECT"
|
||||
case "":
|
||||
return "PROXY"
|
||||
default:
|
||||
// It's a group name or node name
|
||||
if groupSet[policy] || nodeSet[policy] {
|
||||
return policy
|
||||
}
|
||||
return "PROXY"
|
||||
}
|
||||
}
|
||||
|
||||
// buildSingBoxDNS converts Clash DNS config to sing-box DNS config.
|
||||
func buildSingBoxDNS(template map[string]any) map[string]any {
|
||||
dnsRaw, ok := template["dns"]
|
||||
if !ok || dnsRaw == nil {
|
||||
return nil
|
||||
}
|
||||
dns, ok := dnsRaw.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build sing-box DNS servers from nameserver list
|
||||
var servers []any
|
||||
if nameservers, ok := dns["nameserver"].([]any); ok {
|
||||
for _, ns := range nameservers {
|
||||
nsStr, ok := ns.(string)
|
||||
if !ok || nsStr == "" {
|
||||
continue
|
||||
}
|
||||
servers = append(servers, convertDNSServer(nsStr))
|
||||
}
|
||||
}
|
||||
|
||||
if len(servers) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// sing-box 1.12+ requires a domain resolver for DNS servers that use domain addresses.
|
||||
// Add a local UDP resolver as the first server to resolve other servers' domain names.
|
||||
localServer := map[string]any{"type": "local", "tag": "local-dns"}
|
||||
servers = append([]any{localServer}, servers...)
|
||||
|
||||
// Add domain_resolver to each non-local server so they can resolve their own domain address
|
||||
for i, s := range servers {
|
||||
sm, ok := s.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
stype, _ := sm["type"].(string)
|
||||
if stype == "local" || stype == "fakeip" {
|
||||
continue
|
||||
}
|
||||
sm["domain_resolver"] = "local-dns"
|
||||
servers[i] = sm
|
||||
}
|
||||
|
||||
result := map[string]any{
|
||||
"servers": servers,
|
||||
}
|
||||
|
||||
// Set the local resolver as the default domain resolver
|
||||
result["final"] = "local-dns"
|
||||
|
||||
// Handle fake-ip mode (sing-box 1.12+ format)
|
||||
if mode := getString(dns, "enhanced-mode"); mode == "fake-ip" {
|
||||
// Add a fakeip DNS server as the last server
|
||||
fakeipServer := map[string]any{
|
||||
"type": "fakeip",
|
||||
"tag": "fakeip",
|
||||
}
|
||||
servers = append(servers, fakeipServer)
|
||||
|
||||
// Add DNS rule to route A/AAAA queries to fakeip
|
||||
rules := []any{
|
||||
map[string]any{
|
||||
"query_type": []string{"A", "AAAA"},
|
||||
"server": "fakeip",
|
||||
},
|
||||
}
|
||||
result["rules"] = rules
|
||||
}
|
||||
|
||||
// Handle IPv6 setting
|
||||
if ipv6, ok := dns["ipv6"].(bool); ok && !ipv6 {
|
||||
result["strategy"] = "prefer_ipv4"
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// convertDNSServer converts a Clash nameserver URL to a sing-box DNS server.
|
||||
func convertDNSServer(ns string) map[string]any {
|
||||
// Clash formats: "https://doh.pub/dns-query", "https://dns.alidns.com/dns-query",
|
||||
// "tls://8.8.8.8", "quic://dns.adguard.com", "system", "localhost"
|
||||
switch {
|
||||
case strings.HasPrefix(ns, "https://"):
|
||||
return map[string]any{"type": "https", "server": extractHost(ns, "https://")}
|
||||
case strings.HasPrefix(ns, "tls://"):
|
||||
return map[string]any{"type": "tls", "server": strings.TrimPrefix(ns, "tls://")}
|
||||
case strings.HasPrefix(ns, "quic://"):
|
||||
return map[string]any{"type": "quic", "server": strings.TrimPrefix(ns, "quic://")}
|
||||
case strings.HasPrefix(ns, "h3://"):
|
||||
return map[string]any{"type": "h3", "server": strings.TrimPrefix(ns, "h3://")}
|
||||
case strings.HasPrefix(ns, "tcp://"):
|
||||
return map[string]any{"type": "tcp", "server": strings.TrimPrefix(ns, "tcp://")}
|
||||
case ns == "system":
|
||||
return map[string]any{"type": "local"}
|
||||
case ns == "localhost":
|
||||
return map[string]any{"type": "local"}
|
||||
default:
|
||||
// Assume it's a plain IP/UDP server
|
||||
return map[string]any{"type": "udp", "server": ns}
|
||||
}
|
||||
}
|
||||
|
||||
// clashRuleSetMappings maps common Clash rule-provider names to sing-box
|
||||
// community-maintained .srs binary rule-set URLs.
|
||||
// When a Clash rule-provider name has a known sing-box equivalent,
|
||||
// the .srs URL is used instead of the Clash .list/.yaml URL.
|
||||
var clashRuleSetMappings = map[string]string{
|
||||
// ACL4SSR rule names → SagerNet sing-geosite/sing-geoip
|
||||
"LocalAreaNetwork": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-private.srs",
|
||||
"UnBan": "",
|
||||
"BanAD": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-category-ads-all.srs",
|
||||
"BanProgramAD": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-category-ads-all.srs",
|
||||
"GoogleCN": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-google.srs",
|
||||
"SteamCN": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-steam.srs",
|
||||
"Microsoft": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-microsoft.srs",
|
||||
"Apple": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-apple.srs",
|
||||
"Telegram": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-telegram.srs",
|
||||
"YouTube": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-youtube.srs",
|
||||
"Netflix": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-netflix.srs",
|
||||
"DisneyPlus": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-disney.srs",
|
||||
"ProxyGFWlist": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-geolocation-!cn.srs",
|
||||
"ChinaDomain": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-cn.srs",
|
||||
"ChinaCompanyIp": "https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs",
|
||||
"ChinaIp": "https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs",
|
||||
"Download": "",
|
||||
|
||||
// Loyalsoldier rule names → SagerNet sing-geosite/sing-geoip
|
||||
"reject": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-category-ads-all.srs",
|
||||
"icloud": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-icloud.srs",
|
||||
"apple": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-apple.srs",
|
||||
"google": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-google.srs",
|
||||
"proxy": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-geolocation-!cn.srs",
|
||||
"direct": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-cn.srs",
|
||||
"private": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-private.srs",
|
||||
"gfw": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-gfw.srs",
|
||||
"greatfire": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-greatfire.srs",
|
||||
"tld-not-cn": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-tld-!cn.srs",
|
||||
"telegramcidr": "https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-telegram.srs",
|
||||
"cncidr": "https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs",
|
||||
"lancidr": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-private.srs",
|
||||
"applications": "",
|
||||
|
||||
// blackmatrix7 rule names → SagerNet sing-geosite
|
||||
"OpenAI": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-openai.srs",
|
||||
"Claude": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-anthropic.srs",
|
||||
"Gemini": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-google.srs",
|
||||
"Disney": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-disney.srs",
|
||||
"Spotify": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-spotify.srs",
|
||||
"GitHub": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-github.srs",
|
||||
"China": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-cn.srs",
|
||||
|
||||
// Common service names
|
||||
"Google": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-google.srs",
|
||||
"Twitter": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-twitter.srs",
|
||||
"Facebook": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-facebook.srs",
|
||||
"TikTok": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-tiktok.srs",
|
||||
"Bilibili": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-bilibili.srs",
|
||||
"PayPal": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-paypal.srs",
|
||||
}
|
||||
|
||||
// buildSingBoxRuleSets converts Clash rule-providers to sing-box rule_set.
|
||||
// When a Clash rule-provider name has a known sing-box .srs equivalent in
|
||||
// clashRuleSetMappings, the binary .srs URL is used. Otherwise the original
|
||||
// Clash URL is kept with source format (may not work for all Clash-format files).
|
||||
func buildSingBoxRuleSets(template map[string]any) []any {
|
||||
rpRaw, ok := template["ruleProviders"]
|
||||
if !ok {
|
||||
rpRaw, ok = template["rule-providers"]
|
||||
}
|
||||
if !ok || rpRaw == nil {
|
||||
return nil
|
||||
}
|
||||
rp, ok := rpRaw.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
var result []any
|
||||
for name, cfgRaw := range rp {
|
||||
cfg, ok := cfgRaw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this rule-provider name has a sing-box .srs mapping
|
||||
if srsURL, mapped := clashRuleSetMappings[name]; mapped {
|
||||
if srsURL == "" {
|
||||
// Empty mapping = skip this rule-set (no sing-box equivalent)
|
||||
continue
|
||||
}
|
||||
rs := map[string]any{
|
||||
"tag": name,
|
||||
"type": "remote",
|
||||
"format": "binary",
|
||||
"url": srsURL,
|
||||
"download_detour": "DIRECT",
|
||||
}
|
||||
if interval := toInt(getString(cfg, "interval")); interval > 0 {
|
||||
rs["update_interval"] = formatDuration(interval)
|
||||
}
|
||||
result = append(result, rs)
|
||||
continue
|
||||
}
|
||||
|
||||
// Fallback: use the original Clash URL with source format
|
||||
url := getString(cfg, "url")
|
||||
if url == "" {
|
||||
continue
|
||||
}
|
||||
rs := map[string]any{
|
||||
"tag": name,
|
||||
"type": "remote",
|
||||
"format": "source",
|
||||
"url": url,
|
||||
"download_detour": "DIRECT",
|
||||
}
|
||||
if interval := toInt(getString(cfg, "interval")); interval > 0 {
|
||||
rs["update_interval"] = formatDuration(interval)
|
||||
}
|
||||
result = append(result, rs)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func toInt(s string) int {
|
||||
n := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] < '0' || s[i] > '9' {
|
||||
return 0
|
||||
}
|
||||
n = n*10 + int(s[i]-'0')
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func formatDuration(seconds int) string {
|
||||
if seconds <= 0 {
|
||||
return "5m"
|
||||
}
|
||||
if seconds%3600 == 0 {
|
||||
return itoa(seconds/3600) + "h"
|
||||
}
|
||||
if seconds%60 == 0 {
|
||||
return itoa(seconds/60) + "m"
|
||||
}
|
||||
return itoa(seconds) + "s"
|
||||
}
|
||||
|
||||
func extractHost(url, prefix string) string {
|
||||
s := strings.TrimPrefix(url, prefix)
|
||||
// Remove path
|
||||
if idx := strings.Index(s, "/"); idx >= 0 {
|
||||
s = s[:idx]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func jsonMarshalIndent(v any) (string, error) {
|
||||
data, err := json.MarshalIndent(v, "", " ")
|
||||
return string(data), err
|
||||
}
|
||||
Reference in New Issue
Block a user