Files
rogee 9ec8c956c1 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)
2026-07-27 21:56:19 +08:00

789 lines
25 KiB
Go

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
}