// provider.go:从订阅原文生成 mihomo 运行时配置(provider.yaml)。 // // 复用声明:重写思路移植 bench/proxy/lib.py rewrite_runtime_config—— // 保留订阅原文 proxies 全量凭据段(yaml.Node 原样保真,不脱敏), // 剥离订阅自带 rules/proxy-groups/rule-providers/script/tun/secret, // 重写端口与控制器;端口对齐 bench/proxy/up.sh 惯例 // (mixed 17890 / controller 19090)。 // // 凭据纪律:凭据只进运行时配置文件(目录 0700、文件 0600,不入库不入 git); // 订阅 URL 与凭据永不入日志。 package proxymanager import ( "errors" "fmt" "os" "path/filepath" "strings" "syscall" "gopkg.in/yaml.v3" ) // mihomo 端口与选择组名(design §5 / brief A4:MATCH 全局组 + vless select 组)。 const ( mihomoMixedPort = 17890 mihomoController = "0.0.0.0:19090" selectorMain = "EXIT-POOL" // MATCH 全局选择组 selectorVless = "VLESS-MAIN" selectorUDP = "UDP-OPTIONAL" ) // BuildProviderConfig 生成 mihomo 运行时配置文本。 // // 实现:yaml.Node 解码订阅原文 → 保留 proxies 凭据段(剔除占位节点与 // 重名节点,防 mihomo 同名冲突)→ 重写端口/控制器 → 内置组 // EXIT-POOL(select)→VLESS-MAIN(select=vless 全部节点名);hy2 归入 // UDP-OPTIONAL(不在默认链上,不调度,proxy-probe §6.2)→ // 规则段仅 MATCH→EXIT-POOL(精细域名路由由 PM 自身 API 承担)。 func BuildProviderConfig(subText string) (string, error) { var doc yaml.Node if err := yaml.Unmarshal([]byte(subText), &doc); err != nil { return "", fmt.Errorf("provider: 订阅 YAML 解析失败: %w", err) } if doc.Kind != yaml.DocumentNode || len(doc.Content) == 0 { return "", fmt.Errorf("provider: 订阅 YAML 为空文档") } root := doc.Content[0] if root.Kind != yaml.MappingNode { return "", fmt.Errorf("provider: 订阅顶层非 mapping") } var ( out yaml.Node vlessNames []string hy2Names []string seen = map[string]bool{} ) out.Kind = yaml.MappingNode // 逐顶层键处理:proxies 保真+过滤;配置键重写;组/规则剥离。 for i := 0; i+1 < len(root.Content); i += 2 { key := root.Content[i].Value val := root.Content[i+1] switch key { case "proxies": if val.Kind != yaml.SequenceNode { return "", fmt.Errorf("provider: proxies 非 sequence") } seq := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} for _, item := range val.Content { name := nodeFieldString(item, "name") server := nodeFieldString(item, "server") ptype := nodeFieldString(item, "type") // 过滤:占位节点 / 缺关键字段 / 重名(mihomo 同名冲突)。 if isInfoNode(name) || name == "" || server == "" || ptype == "" { continue } if seen[name] { continue } seen[name] = true if ptype == nodeTypeVless { vlessNames = append(vlessNames, name) } if ptype == nodeTypeHysteria2 { hy2Names = append(hy2Names, name) } seq.Content = append(seq.Content, item) } appendKV(&out, "proxies", seq) case "mixed-port": appendScalarKV(&out, "mixed-port", fmt.Sprintf("%d", mihomoMixedPort)) case "external-controller": appendScalarKV(&out, "external-controller", mihomoController) case "bind-address": appendScalarKV(&out, "bind-address", "*") case "allow-lan": appendScalarKV(&out, "allow-lan", "true") case "proxy-groups", "rules", "rule-providers", "script", "tun", "secret", "dns", "sniffer", "geox-url", "geodata-mode", "geo-auto-update": // 剥离:组/规则换内置;dns/geosite 会把 geoip 打进 RSS, // 64m 档实测 OOM(W6 mgr1)。规则只留 MATCH→EXIT-POOL。 case "port", "socks-port", "redir-port", "tproxy-port": // 其它监听端口丢弃(只保留 mixed 单出口)。 default: // 其余顶层键(dns/log-level/mode 等)原样保留。 out.Content = append(out.Content, root.Content[i], val) } } // 兜底:订阅缺失时补齐关键键。 if !hasKey(&out, "mixed-port") { appendScalarKV(&out, "mixed-port", fmt.Sprintf("%d", mihomoMixedPort)) } if !hasKey(&out, "external-controller") { appendScalarKV(&out, "external-controller", mihomoController) } if !hasKey(&out, "allow-lan") { appendScalarKV(&out, "allow-lan", "true") } if !hasKey(&out, "mode") { appendScalarKV(&out, "mode", "rule") } if !hasKey(&out, "log-level") { appendScalarKV(&out, "log-level", "warning") } // 内置组 + 规则。 appendProxyGroups(&out, vlessNames, hy2Names) rulesSeq := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} rulesSeq.Content = append(rulesSeq.Content, scalarNode(fmt.Sprintf("MATCH,%s", selectorMain))) appendKV(&out, "rules", rulesSeq) var buf strings.Builder enc := yaml.NewEncoder(&buf) enc.SetIndent(2) if err := enc.Encode(&out); err != nil { return "", fmt.Errorf("provider: 配置序列化失败: %w", err) } _ = enc.Close() return buf.String(), nil } // nodeFieldString 从 mapping 节点取标量字段值(缺省空串)。 func nodeFieldString(item *yaml.Node, field string) string { if item.Kind != yaml.MappingNode { return "" } for i := 0; i+1 < len(item.Content); i += 2 { if item.Content[i].Value == field { return strings.TrimSpace(item.Content[i+1].Value) } } return "" } func scalarNode(v string) *yaml.Node { return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: v} } // styleScalarInt 标量节点按原文语义设置 style:数字/裸词不加重引号 // (yaml.v3 对显式构造的节点默认加引号,会导致 `mixed-port: "17890"`)。 func styleScalar(v string) *yaml.Node { n := scalarNode(v) // 纯数字/布尔/普通串保持 plain style(Tag 决定类型,无引号)。 n.Style = yaml.TaggedStyle n.Tag = "!!str" n.Style = 0 return n } func appendKV(m *yaml.Node, key string, val *yaml.Node) { m.Content = append(m.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, val) } func appendScalarKV(m *yaml.Node, key, val string) { k := scalarNode(key) // 值按语义定 tag:整数→!!int,true/false→!!bool,其余→!!str(plain style 无引号)。 v := scalarNode(val) switch val { case "true", "false": v.Tag = "!!bool" default: if isAllDigits(val) { v.Tag = "!!int" } else { v.Tag = "!!str" } } v.Style = 0 // plain style:不加重引号 m.Content = append(m.Content, k, v) } // isAllDigits 纯数字判定(端口号等)。 func isAllDigits(s string) bool { if s == "" { return false } for _, c := range s { if c < '0' || c > '9' { return false } } return true } func hasKey(m *yaml.Node, key string) bool { for i := 0; i+1 < len(m.Content); i += 2 { if m.Content[i].Value == key { return true } } return false } // appendProxyGroups 内置策略组: // EXIT-POOL(MATCH 默认)→ VLESS-MAIN(vless 全量,hy2 不入默认链); // UDP-OPTIONAL 单列(仅手工调试)。空组以 DIRECT 占位防 mihomo 校验失败。 func appendProxyGroups(m *yaml.Node, vlessNames, hy2Names []string) { nameSeq := func(names []string, fallback string) *yaml.Node { seq := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} for _, n := range names { seq.Content = append(seq.Content, scalarNode(n)) } if len(names) == 0 { seq.Content = append(seq.Content, scalarNode(fallback)) } return seq } group := func(name string, proxies *yaml.Node) *yaml.Node { g := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} appendScalarKV(g, "name", name) appendScalarKV(g, "type", "select") appendKV(g, "proxies", proxies) return g } seq := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} seq.Content = append(seq.Content, group(selectorMain, nameSeq([]string{selectorVless}, "DIRECT")), group(selectorVless, nameSeq(vlessNames, "DIRECT")), group(selectorUDP, nameSeq(hy2Names, "DIRECT")), ) appendKV(m, "proxy-groups", seq) } // WriteProvider 把配置落盘:目录 0700、文件 0600(凭据只进运行时目录)。 // 同时写 config.yaml(mihomo 启动默认名)与 provider.yaml(既有测试/热载别名)。 func WriteProvider(dir, content string) error { if dir == "" { return fmt.Errorf("provider: 配置目录为空") } if err := os.MkdirAll(dir, 0o700); err != nil { return fmt.Errorf("provider: 创建目录 %s 失败: %w", dir, err) } // Swarm named volume 挂载点常由 mihomo(root) 占属主;nonroot chmod // 得 EPERM。能写文件即视为可用,chmod 尽力而为。 if err := os.Chmod(dir, 0o700); err != nil && !isChmodBestEffort(err) { return fmt.Errorf("provider: 收紧目录权限失败: %w", err) } for _, name := range []string{"config.yaml", "provider.yaml"} { path := filepath.Join(dir, name) if err := os.WriteFile(path, []byte(content), 0o600); err != nil { return fmt.Errorf("provider: 写入 %s 失败: %w", name, err) } if err := os.Chmod(path, 0o600); err != nil && !isChmodBestEffort(err) { return fmt.Errorf("provider: 收紧文件权限失败: %w", err) } } return nil } // isChmodBestEffort 目录/文件非本进程属主时 chmod 失败可忽略。 func isChmodBestEffort(err error) bool { if os.IsPermission(err) { return true } var errno syscall.Errno return errors.As(err, &errno) && (errno == syscall.EPERM || errno == syscall.EACCES) } // ProviderPath 返回 provider.yaml 完整路径(测试与日志挂点;不含凭据内容)。 func ProviderPath(dir string) string { return filepath.Join(dir, "provider.yaml") } // MihomoReloadPath mihomo 容器内配置路径(同卷不同挂载点;禁用 PM 侧 /data/mihomo)。 func MihomoReloadPath() string { if p := strings.TrimSpace(os.Getenv("BROWSER_SERVER_MIHOMO_RELOAD_PATH")); p != "" { return p } return "/root/.config/mihomo/config.yaml" }