- 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)
329 lines
7.3 KiB
Go
329 lines
7.3 KiB
Go
package render
|
|
|
|
import (
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/peterqiu0516/sub-store/internal/model"
|
|
"github.com/peterqiu0516/sub-store/internal/util"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// RenderMihomoYaml renders proxies + template into a Mihomo/stash YAML document.
|
|
func RenderMihomoYaml(proxies []model.ProxyNode, requestUrl string, template map[string]any) string {
|
|
cfg := template
|
|
if cfg == nil {
|
|
cfg = map[string]any{}
|
|
}
|
|
|
|
mixedPort := firstInt(cfg, "mixedPort", "mixed-port")
|
|
if mixedPort == 0 {
|
|
mixedPort = 7890
|
|
}
|
|
allowLan := firstBool(cfg, "allowLan", "allow-lan") // default false
|
|
logLevel := firstStr(cfg, "logLevel", "log-level")
|
|
if logLevel == "" {
|
|
logLevel = "info"
|
|
}
|
|
mode := firstStr(cfg, "mode")
|
|
if mode == "" {
|
|
mode = "rule"
|
|
}
|
|
|
|
groupTemplates := extractGroupTemplates(cfg)
|
|
if len(groupTemplates) == 0 {
|
|
groupTemplates = DefaultProxyGroups()
|
|
}
|
|
|
|
ruleProviders := firstAny(cfg, "ruleProviders", "rule-providers")
|
|
|
|
rules, _ := cfg["rules"].([]any)
|
|
var rulesList []string
|
|
for _, r := range rules {
|
|
if s, ok := r.(string); ok && s != "" {
|
|
rulesList = append(rulesList, s)
|
|
}
|
|
}
|
|
if len(rulesList) == 0 {
|
|
rulesList = []string{"MATCH,🚀 节点选择"}
|
|
}
|
|
|
|
// Build proxies list with undefined stripped
|
|
var proxiesList []map[string]any
|
|
for _, p := range proxies {
|
|
proxiesList = append(proxiesList, util.StripUndefined(p))
|
|
}
|
|
|
|
doc := map[string]any{
|
|
"mixed-port": mixedPort,
|
|
"allow-lan": allowLan,
|
|
"mode": mode,
|
|
"log-level": logLevel,
|
|
"proxies": proxiesList,
|
|
"proxy-groups": RenderTemplateProxyGroups(proxies, groupTemplates),
|
|
"rules": rulesList,
|
|
}
|
|
if dns := cfg["dns"]; dns != nil {
|
|
doc["dns"] = dns
|
|
}
|
|
if sniffer := cfg["sniffer"]; sniffer != nil {
|
|
doc["sniffer"] = sniffer
|
|
}
|
|
if ruleProviders != nil {
|
|
doc["rule-providers"] = ruleProviders
|
|
}
|
|
|
|
var sb strings.Builder
|
|
sb.WriteString("# Generated by Sub-Store\n")
|
|
sb.WriteString("# Source: " + sourcePath(requestUrl) + "\n")
|
|
data, err := yaml.Marshal(doc)
|
|
if err != nil {
|
|
sb.WriteString("# yaml marshal error: " + err.Error() + "\n")
|
|
return sb.String()
|
|
}
|
|
sb.Write(data)
|
|
return sb.String()
|
|
}
|
|
|
|
// DefaultProxyGroups returns the 3 default proxy groups per review-resolution #41.
|
|
func DefaultProxyGroups() []map[string]any {
|
|
return []map[string]any{
|
|
{
|
|
"name": "🚀 节点选择",
|
|
"type": "select",
|
|
"proxies": []string{"♻️ 自动选择", "🚀 手动切换", "DIRECT"},
|
|
},
|
|
{
|
|
"name": "♻️ 自动选择",
|
|
"type": "url-test",
|
|
"proxies": []string{"$all"},
|
|
"url": model.TestURL,
|
|
"interval": 300,
|
|
"tolerance": 50,
|
|
},
|
|
{
|
|
"name": "🚀 手动切换",
|
|
"type": "select",
|
|
"proxies": []string{"$all"},
|
|
},
|
|
}
|
|
}
|
|
|
|
// RenderTemplateProxyGroups expands group templates, filters by regex, removes
|
|
// empty groups, and filters out references to non-existent nodes/groups.
|
|
func RenderTemplateProxyGroups(proxies []model.ProxyNode, groupTemplates []map[string]any) []map[string]any {
|
|
nodeNames := make([]string, 0, len(proxies))
|
|
for _, p := range proxies {
|
|
nodeNames = append(nodeNames, getString(p, "name"))
|
|
}
|
|
|
|
// Expand proxies for each group
|
|
type expandedGroup struct {
|
|
template map[string]any
|
|
proxies []string
|
|
}
|
|
var expanded []expandedGroup
|
|
for _, group := range groupTemplates {
|
|
expanded = append(expanded, expandedGroup{
|
|
template: group,
|
|
proxies: ExpandGroupProxies(group, nodeNames),
|
|
})
|
|
}
|
|
|
|
// Collect names of groups that have at least one proxy entry
|
|
includedGroupNames := make(map[string]bool)
|
|
for _, g := range expanded {
|
|
if len(g.proxies) > 0 {
|
|
includedGroupNames[getString(g.template, "name")] = true
|
|
}
|
|
}
|
|
|
|
allowedLiterals := map[string]bool{"DIRECT": true, "REJECT": true, "PASS": true}
|
|
|
|
var result []map[string]any
|
|
for _, g := range expanded {
|
|
var proxyEntries []string
|
|
seen := make(map[string]bool)
|
|
for _, name := range g.proxies {
|
|
if seen[name] {
|
|
continue
|
|
}
|
|
seen[name] = true
|
|
// keep if it's a node name, a valid group name, or an allowed literal
|
|
if containsString(nodeNames, name) || includedGroupNames[name] || allowedLiterals[name] {
|
|
proxyEntries = append(proxyEntries, name)
|
|
}
|
|
}
|
|
if len(proxyEntries) == 0 {
|
|
continue
|
|
}
|
|
out := map[string]any{}
|
|
for k, v := range g.template {
|
|
if k == "filter" || k == "proxies" {
|
|
continue
|
|
}
|
|
if v == nil {
|
|
continue
|
|
}
|
|
if s, ok := v.(string); ok && s == "" {
|
|
continue
|
|
}
|
|
out[k] = v
|
|
}
|
|
out["proxies"] = proxyEntries
|
|
result = append(result, out)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// ExpandGroupProxies expands $all to all node names and applies the filter regex.
|
|
func ExpandGroupProxies(group map[string]any, nodeNames []string) []string {
|
|
var entries []string
|
|
if filterStr, ok := group["filter"].(string); ok && filterStr != "" {
|
|
entries = append(entries, findNamesByRegex(nodeNames, filterStr)...)
|
|
}
|
|
if rawProxies, ok := group["proxies"].([]any); ok {
|
|
for _, item := range rawProxies {
|
|
s, _ := item.(string)
|
|
if s == "" {
|
|
continue
|
|
}
|
|
if s == "$all" {
|
|
entries = append(entries, nodeNames...)
|
|
} else {
|
|
entries = append(entries, s)
|
|
}
|
|
}
|
|
}
|
|
return uniqueStrings(entries)
|
|
}
|
|
|
|
func findNamesByRegex(names []string, pattern string) []string {
|
|
re := compileRegex(pattern)
|
|
if re == nil {
|
|
return nil
|
|
}
|
|
var result []string
|
|
for _, name := range names {
|
|
if re.MatchString(name) {
|
|
result = append(result, name)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func compileRegex(input string) *regexp.Regexp {
|
|
pattern := input
|
|
flags := ""
|
|
if strings.HasPrefix(input, "(?i)") {
|
|
pattern = input[4:]
|
|
flags = "i"
|
|
}
|
|
re, err := regexp.Compile(flags + pattern)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return re
|
|
}
|
|
|
|
// --- template config helpers ---
|
|
|
|
func extractGroupTemplates(cfg map[string]any) []map[string]any {
|
|
if v, ok := cfg["proxyGroups"]; ok {
|
|
return toGroupList(v)
|
|
}
|
|
if v, ok := cfg["proxy-groups"]; ok {
|
|
return toGroupList(v)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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
|
|
}
|
|
var result []map[string]any
|
|
for _, item := range arr {
|
|
if m, ok := item.(map[string]any); ok {
|
|
result = append(result, m)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func firstStr(cfg map[string]any, keys ...string) string {
|
|
for _, k := range keys {
|
|
if v, ok := cfg[k]; ok {
|
|
if s, ok := v.(string); ok && s != "" {
|
|
return s
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func firstInt(cfg map[string]any, keys ...string) int {
|
|
for _, k := range keys {
|
|
if v, ok := cfg[k]; ok {
|
|
switch n := v.(type) {
|
|
case int:
|
|
return n
|
|
case int64:
|
|
return int(n)
|
|
case float64:
|
|
return int(n)
|
|
}
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func firstBool(cfg map[string]any, keys ...string) bool {
|
|
for _, k := range keys {
|
|
if v, ok := cfg[k]; ok {
|
|
if b, ok := v.(bool); ok {
|
|
return b
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func firstAny(cfg map[string]any, keys ...string) any {
|
|
for _, k := range keys {
|
|
if v, ok := cfg[k]; ok && v != nil {
|
|
return v
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func containsString(list []string, s string) bool {
|
|
for _, item := range list {
|
|
if item == s {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func uniqueStrings(values []string) []string {
|
|
seen := make(map[string]bool, len(values))
|
|
var result []string
|
|
for _, v := range values {
|
|
if v == "" {
|
|
continue
|
|
}
|
|
if seen[v] {
|
|
continue
|
|
}
|
|
seen[v] = true
|
|
result = append(result, v)
|
|
}
|
|
return result
|
|
}
|