- 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)
789 lines
25 KiB
Go
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
|
|
}
|