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

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
}