onesvm-browser-server/server/internal/proxymanager/provider.go
chii eb972dfa93 feat: 落地 browser-server 控制面并打通 mgr1 海外订阅
单二进制三角色 + Dock 适配器 + Swarm stack 达到可部署态;mgr1 实测订阅经 central-proxy bootstrap,探活 alive=41/52。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 15:05:12 +08:00

287 lines
9.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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"
}