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:
2026-07-27 21:56:19 +08:00
parent cec95ff4e8
commit 9ec8c956c1
40 changed files with 5887 additions and 74 deletions
+2 -2
View File
@@ -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:
+3
View File
@@ -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
+59 -17
View File
@@ -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
View File
@@ -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:
+788
View File
@@ -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
}