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