单二进制三角色 + Dock 适配器 + Swarm stack 达到可部署态;mgr1 实测订阅经 central-proxy bootstrap,探活 alive=41/52。 Co-authored-by: Cursor <cursoragent@cursor.com>
287 lines
9.6 KiB
Go
287 lines
9.6 KiB
Go
// 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"
|
||
}
|