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