onesvm-browser-server/server/internal/proxymanager/parse.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

224 lines
7.2 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.

// Package proxymanager 实现代理控制面角色(design-arch §5):
// 订阅解析(Clash YAML)→ mihomo provider 配置生成与热载 →
// 探活状态机 → P2C+sticky 轮换 → 域名路由 → overlay HTTP API。
//
// 复用声明:订阅解析 / 占位节点剔除 / 区域归类 / 配置重写逻辑
// 移植自 bench/proxy/lib.py(Go 版语义等价,含 INFO_NAME_RE 与 REGION_RULES 十二组);
// mihomo 启动方式与 mixed 17890 / controller 端口惯例对齐 bench/proxy/up.sh。
package proxymanager
import (
"fmt"
"regexp"
"sort"
"strings"
"gopkg.in/yaml.v3"
)
// 节点类型分池(proxy-probe §6.2 结论:vless 实测 52/52 可用;
// hysteria2 走 UDP/QUIC,容器 UDP 通路未验证前不调度)。
const (
nodeTypeVless = "vless"
nodeTypeHysteria2 = "hysteria2"
)
// poolVless 默认调度池;poolUDP hy2 备池(不参与 P2C 候选)。
const (
poolVless = "vless"
poolUDP = "udp_optional"
)
// infoNameRE 占位/信息节点名称匹配(lib.py INFO_NAME_RE 移植)。
var infoNameRE = regexp.MustCompile(`^(剩余流量|套餐到期|到期|流量)`)
// regionRules 区域归类规则(lib.py REGION_RULES 十二组顺序移植,
// 组序即优先级:先命中先归类)。
var regionRules = []struct {
Label string
Keys []string
}{
{"香港", []string{"香港", "HK", "🇭🇰"}},
{"台湾", []string{"台湾", "臺灣", "🇹🇼"}},
{"日本", []string{"日本", "东京", "大阪", "🇯🇵"}},
{"韩国", []string{"韩国", "韓國", "首尔", "🇰🇷"}},
{"新加坡", []string{"新加坡", "狮城", "🇸🇬"}},
{"美国", []string{"美国", "美國", "圣何塞", "洛杉矶", "硅谷", "西雅图", "芝加哥", "纽约", "🇺🇸"}},
{"英国", []string{"英国", "英國", "伦敦", "🇬🇧"}},
{"德国", []string{"德国", "德國", "法兰克福", "🇩🇪"}},
{"法国", []string{"法国", "法國", "巴黎", "🇫🇷"}},
{"加拿大", []string{"加拿大", "🇨🇦"}},
{"澳大利亚", []string{"澳大利亚", "澳洲", "🇦🇺"}},
{"澳门", []string{"澳门", "澳門", "🇲🇴"}},
{"中国", []string{"中国", "回国"}},
}
// unknownRegion 未归类区域标签(lib.py「其他/未知」)。
const unknownRegion = "其他/未知"
// inferRegion 节点名区域归类(lib.py infer_region 移植)。
func inferRegion(name string) string {
for _, rr := range regionRules {
for _, key := range rr.Keys {
if strings.Contains(name, key) {
return rr.Label
}
}
}
return unknownRegion
}
// isInfoNode 占位节点判定(lib.py is_info_node 移植)。
func isInfoNode(name string) bool { return infoNameRE.MatchString(name) }
// rawProxy 订阅原文中一条 proxies 节点(凭据原样保留,仅内存持有)。
type rawProxy struct {
Name string `yaml:"name"`
Type string `yaml:"type"`
Server string `yaml:"server"`
// Rest 其余凭据字段(port/uuid/password/tls/ws-opts 等)原样保留,
// 只进运行时 provider.yaml,禁止入日志/库/git。
Rest map[string]any `yaml:",inline"`
}
// clashSub 订阅 YAML 的最小解析形状。
type clashSub struct {
Proxies []rawProxy `yaml:"proxies"`
}
// RegionPrefixPool 主备区域组策略(design §5.2:优先美国组、备日本组;
// 节点名不硬编码,按区域关键词选组)。
var RegionPrefixPool = []string{"美国", "日本"}
// Parsed 一次订阅解析结果(脱敏视图 + 原文节点分池)。
type Parsed struct {
Format string
RawCount int
InfoNodes []string
Pool map[string][]rawProxy // poolVless(可调度)与 poolUDP(不调度)
ProxiesMeta []ProxyMeta // 脱敏元数据(名称/类型/区域/池)
}
// ProxyMeta 节点脱敏元数据(可入日志/artifacts;无任何凭据字段)。
type ProxyMeta struct {
Name string `json:"name"`
Type string `json:"type"`
Region string `json:"region"`
Pool string `json:"pool"`
Server string `json:"server"` // server 主机名用于主备不同前缀约束;日志禁记端口/凭据
}
// detectFormat 格式探测(lib.py detect_format 语义移植:Clash YAML 优先)。
func detectFormat(text string) string {
head := strings.TrimSpace(text)
if len(head) > 400 {
head = head[:400]
}
if strings.Contains(text[:minLen(len(text), 4000)], "proxies:") {
if strings.Contains(head, "mixed-port:") || strings.Contains(head, "port:") ||
strings.HasPrefix(strings.TrimSpace(text), "proxies:") {
return "clash-yaml"
}
}
for _, p := range []string{"ss://", "vmess://", "trojan://", "vless://"} {
if strings.HasPrefix(head, p) {
return "uri-list"
}
}
compact := regexp.MustCompile(`\s+`).ReplaceAllString(text, "")
check := compact
if len(check) > 80 {
check = check[:80]
}
if check != "" && regexp.MustCompile(`^[A-Za-z0-9+/=_-]+$`).MatchString(check) {
return "base64-maybe"
}
return "other"
}
func minLen(a, b int) int {
if a < b {
return a
}
return b
}
// ParseSubscription 解析订阅原文:探测格式 → 解析 proxies → 剔除占位 → 分池。
// 仅支持 clash-yaml(实测 SUB-1 即该格式,proxy-probe §1);其余格式返回错误。
func ParseSubscription(text string) (*Parsed, error) {
format := detectFormat(text)
if format != "clash-yaml" {
return nil, fmt.Errorf("proxymanager: 订阅格式 %q 非 clash-yaml,首版不支持", format)
}
var sub clashSub
if err := yaml.Unmarshal([]byte(text), &sub); err != nil {
return nil, fmt.Errorf("proxymanager: Clash YAML 解析失败: %w", err)
}
p := &Parsed{Format: format, Pool: map[string][]rawProxy{
poolVless: {},
poolUDP: {},
}}
seen := map[string]bool{}
for _, rp := range sub.Proxies {
p.RawCount++
if isInfoNode(rp.Name) {
p.InfoNodes = append(p.InfoNodes, rp.Name)
continue
}
if rp.Name == "" || rp.Server == "" || rp.Type == "" {
continue // 缺关键字段的节点不可用,跳过
}
if seen[rp.Name] {
continue // 重名节点去重(mihomo 同名会冲突)
}
seen[rp.Name] = true
pool := poolUDP
if rp.Type == nodeTypeVless {
pool = poolVless
}
p.Pool[pool] = append(p.Pool[pool], rp)
p.ProxiesMeta = append(p.ProxiesMeta, ProxyMeta{
Name: rp.Name, Type: rp.Type, Region: inferRegion(rp.Name), Pool: pool, Server: rp.Server,
})
}
return p, nil
}
// Summary 脱敏统计(可落 artifacts:仅名称/类型/区域/计数,无凭据无 server)。
func (p *Parsed) Summary() map[string]any {
types := map[string]int{}
regions := map[string]int{}
for _, m := range p.ProxiesMeta {
types[m.Type]++
regions[m.Region]++
}
return map[string]any{
"format": p.Format,
"proxy_count_raw": p.RawCount,
"proxy_count_real": len(p.ProxiesMeta),
"info_nodes": p.InfoNodes,
"types": types,
"regions": regions,
}
}
// RegionCount 统计某池内各区域节点数。
func (p *Parsed) RegionCount(pool string) map[string]int {
out := map[string]int{}
for _, m := range p.ProxiesMeta {
if m.Pool == pool {
out[m.Region]++
}
}
return out
}
// SortedRegions 区域名排序(稳定输出便于测试)。
func SortedRegions(regions map[string]int) []string {
out := make([]string, 0, len(regions))
for r := range regions {
out = append(out, r)
}
sort.Strings(out)
return out
}