单二进制三角色 + Dock 适配器 + Swarm stack 达到可部署态;mgr1 实测订阅经 central-proxy bootstrap,探活 alive=41/52。 Co-authored-by: Cursor <cursoragent@cursor.com>
253 lines
7.6 KiB
Go
253 lines
7.6 KiB
Go
// parse_test.go:订阅解析/占位剔除/区域归类/分池/provider 生成/配置重写单测
|
||
// (无外网;样例 YAML 参考 lib.py 测试逻辑构造,凭据均为假数据)。
|
||
package proxymanager
|
||
|
||
import (
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// sampleSub 构造样例订阅:2 信息节点 + vless 多区域 + hy2 + 重名节点(凭据假数据)。
|
||
func sampleSub() string {
|
||
return `mixed-port: 7890
|
||
allow-lan: false
|
||
external-controller: '127.0.0.1:9090'
|
||
secret: sub_secret_should_be_stripped
|
||
proxies:
|
||
- name: "剩余流量:100GB"
|
||
type: ss
|
||
server: info.example.com
|
||
port: 443
|
||
- name: "套餐到期:2026-12-31"
|
||
type: ss
|
||
server: info2.example.com
|
||
port: 443
|
||
- name: "🇺🇸 圣何塞 02 三网推荐"
|
||
type: vless
|
||
server: sjc02.example.com
|
||
port: 443
|
||
uuid: 00000000-0000-0000-0000-000000000001
|
||
tls: true
|
||
network: ws
|
||
- name: "🇺🇸 洛杉矶 03 0.01倍"
|
||
type: vless
|
||
server: lax03.example.com
|
||
port: 443
|
||
uuid: 00000000-0000-0000-0000-000000000002
|
||
tls: true
|
||
- name: "🇯🇵 东京 06 高速"
|
||
type: vless
|
||
server: tyo06.example.com
|
||
port: 443
|
||
uuid: 00000000-0000-0000-0000-000000000003
|
||
tls: true
|
||
- name: "🇸🇬 狮城 01"
|
||
type: vless
|
||
server: sgp01.example.com
|
||
port: 443
|
||
uuid: 00000000-0000-0000-0000-000000000004
|
||
tls: true
|
||
- name: "🇺🇸 圣何塞 hy2 05"
|
||
type: hysteria2
|
||
server: sjc05.example.com
|
||
port: 8443
|
||
password: hy2pass
|
||
- name: "🇺🇸 圣何塞 02 三网推荐"
|
||
type: vless
|
||
server: dup.example.com
|
||
port: 443
|
||
uuid: dup
|
||
rules:
|
||
- MATCH,🚀节点选择
|
||
proxy-groups:
|
||
- name: 🚀节点选择
|
||
type: select
|
||
`
|
||
}
|
||
|
||
func TestParseSubscription(t *testing.T) {
|
||
p, err := ParseSubscription(sampleSub())
|
||
if err != nil {
|
||
t.Fatalf("解析失败: %v", err)
|
||
}
|
||
if p.Format != "clash-yaml" {
|
||
t.Errorf("format = %q, want clash-yaml", p.Format)
|
||
}
|
||
// 原始 8 节点:2 信息 + 5 真实(vless 4 + hy2 1)+ 1 重名剔除
|
||
if p.RawCount != 8 {
|
||
t.Errorf("RawCount = %d, want 8", p.RawCount)
|
||
}
|
||
if len(p.InfoNodes) != 2 {
|
||
t.Errorf("InfoNodes = %v, want 2 条占位", p.InfoNodes)
|
||
}
|
||
if len(p.Pool[poolVless]) != 4 {
|
||
t.Errorf("vless 池 = %d, want 4(重名剔除后)", len(p.Pool[poolVless]))
|
||
}
|
||
if len(p.Pool[poolUDP]) != 1 {
|
||
t.Errorf("hy2 池 = %d, want 1", len(p.Pool[poolUDP]))
|
||
}
|
||
// 区域归类(lib.py REGION_RULES 十二组)
|
||
wantRegion := map[string]string{
|
||
"🇺🇸 圣何塞 02 三网推荐": "美国",
|
||
"🇺🇸 洛杉矶 03 0.01倍": "美国",
|
||
"🇯🇵 东京 06 高速": "日本",
|
||
"🇸🇬 狮城 01": "新加坡",
|
||
"🇺🇸 圣何塞 hy2 05": "美国",
|
||
}
|
||
for _, m := range p.ProxiesMeta {
|
||
if wantRegion[m.Name] != m.Region {
|
||
t.Errorf("region(%q) = %q, want %q", m.Name, m.Region, wantRegion[m.Name])
|
||
}
|
||
}
|
||
// 占位节点必须不在任何池
|
||
for _, pool := range []string{poolVless, poolUDP} {
|
||
for _, rp := range p.Pool[pool] {
|
||
if isInfoNode(rp.Name) {
|
||
t.Errorf("占位节点 %q 泄入池 %s", rp.Name, pool)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestInferRegionKeywords(t *testing.T) {
|
||
cases := map[string]string{
|
||
"香港 IEPL 01": "香港",
|
||
"台湾 Hinet": "台湾",
|
||
"东京 07": "日本",
|
||
"首尔 01": "韩国",
|
||
"伦敦 01": "英国",
|
||
"法兰克福 01": "德国",
|
||
"巴黎 01": "法国",
|
||
"加拿大 01": "加拿大",
|
||
"悉尼 澳大利亚": "澳大利亚",
|
||
"澳门 01": "澳门",
|
||
"中国 回国专线": "中国",
|
||
"unknown-node": unknownRegion,
|
||
}
|
||
for name, want := range cases {
|
||
if got := inferRegion(name); got != want {
|
||
t.Errorf("inferRegion(%q) = %q, want %q", name, got, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestIsInfoNode(t *testing.T) {
|
||
// lib.py INFO_NAME_RE = ^(?剩余流量|套餐到期|到期|流量)——前缀匹配语义,
|
||
// 「流量转发」等以「流量」开头的名称同样命中(与 lib.py 语义一致)。
|
||
yes := []string{"剩余流量:10GB", "套餐到期:2026", "到期时间", "流量无限", "流量转发 01"}
|
||
no := []string{"🇺🇸 圣何塞 02", "剩余/套餐提示位 01"}
|
||
for _, n := range yes {
|
||
if !isInfoNode(n) {
|
||
t.Errorf("isInfoNode(%q) = false, want true", n)
|
||
}
|
||
}
|
||
for _, n := range no {
|
||
if isInfoNode(n) {
|
||
t.Errorf("isInfoNode(%q) = true, want false", n)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestDetectFormat(t *testing.T) {
|
||
if got := detectFormat(sampleSub()); got != "clash-yaml" {
|
||
t.Errorf("clash yaml 探测 = %q", got)
|
||
}
|
||
if got := detectFormat("ss://abc\nvmess://def"); got != "uri-list" {
|
||
t.Errorf("uri list 探测 = %q", got)
|
||
}
|
||
if got := detectFormat(strings.Repeat("QUJDREVGRw==", 20)); got != "base64-maybe" {
|
||
t.Errorf("base64 探测 = %q", got)
|
||
}
|
||
}
|
||
|
||
func TestParseRejectsNonClash(t *testing.T) {
|
||
if _, err := ParseSubscription("ss://abc"); err == nil {
|
||
t.Fatal("非 clash-yaml 应返回错误")
|
||
}
|
||
}
|
||
|
||
func TestBuildProviderConfig(t *testing.T) {
|
||
cfg, err := BuildProviderConfig(sampleSub())
|
||
if err != nil {
|
||
t.Fatalf("生成失败: %v", err)
|
||
}
|
||
// 凭据段保留(uuid 原样在配置中——凭据只进运行时配置文件)
|
||
if !strings.Contains(cfg, "00000000-0000-0000-0000-000000000001") {
|
||
t.Error("provider 配置丢失 vless uuid 凭据段")
|
||
}
|
||
// 端口重写(up.sh 惯例:mixed 17890 / controller 19090)
|
||
if !strings.Contains(cfg, "mixed-port: 17890") {
|
||
t.Error("provider 未重写 mixed-port=17890")
|
||
}
|
||
if !strings.Contains(cfg, "0.0.0.0:19090") {
|
||
t.Error("provider 未重写 external-controller")
|
||
}
|
||
// 订阅自带 secret/端口/rules/proxy-groups 剥离
|
||
if strings.Contains(cfg, "sub_secret_should_be_stripped") {
|
||
t.Error("provider 保留了订阅自带 secret")
|
||
}
|
||
if !strings.Contains(cfg, "- MATCH,EXIT-POOL") {
|
||
t.Error("provider 缺 MATCH 规则")
|
||
}
|
||
if strings.Contains(cfg, "mixed-port: 7890") || strings.Contains(cfg, "port: 7890") {
|
||
t.Error("provider 保留了订阅原端口 7890")
|
||
}
|
||
if strings.Contains(cfg, "🚀节点选择") {
|
||
t.Error("provider 保留了订阅自带策略组")
|
||
}
|
||
// 订阅自带 dns/geosite 必须剥离(W6:保留会打爆 64m)
|
||
fat := "dns:\n enable: true\n fallback-filter:\n geosite:\n - gfw\ngeox-url:\n geosite: http://example.invalid/geosite.dat\n" + sampleSub()
|
||
slim, err := BuildProviderConfig(fat)
|
||
if err != nil {
|
||
t.Fatalf("含 dns 的订阅应仍能生成: %v", err)
|
||
}
|
||
if strings.Contains(slim, "geosite") || strings.Contains(slim, "geox-url") {
|
||
t.Error("provider 不应保留 geosite/geox-url")
|
||
}
|
||
|
||
// 内置组存在
|
||
for _, g := range []string{"EXIT-POOL", "VLESS-MAIN", "UDP-OPTIONAL"} {
|
||
if !strings.Contains(cfg, "name: "+g) {
|
||
t.Errorf("provider 缺内置组 %s", g)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestWriteProviderPerms(t *testing.T) {
|
||
dir := t.TempDir()
|
||
cfg := "mixed-port: 17890\n"
|
||
if err := WriteProvider(dir, cfg); err != nil {
|
||
t.Fatalf("落盘失败: %v", err)
|
||
}
|
||
fi, err := os.Stat(ProviderPath(dir))
|
||
if err != nil {
|
||
t.Fatalf("stat 失败: %v", err)
|
||
}
|
||
if fi.Mode().Perm() != 0o600 {
|
||
t.Errorf("provider.yaml 权限 = %o, want 600", fi.Mode().Perm())
|
||
}
|
||
if _, err := os.Stat(filepath.Join(dir, "config.yaml")); err != nil {
|
||
t.Fatalf("应同时写 config.yaml(mihomo 启动名): %v", err)
|
||
}
|
||
if p := MihomoReloadPath(); p != "/root/.config/mihomo/config.yaml" {
|
||
t.Errorf("缺省热载路径 = %s", p)
|
||
}
|
||
di, _ := os.Stat(dir)
|
||
_ = di // t.TempDir 本身 0700,MkdirAll 后 Chmod(0700) 兜底
|
||
}
|
||
|
||
func TestWriteProviderDirPerm(t *testing.T) {
|
||
dir := filepath.Join(t.TempDir(), "mihomo")
|
||
if err := WriteProvider(dir, "x: 1\n"); err != nil {
|
||
t.Fatalf("落盘失败: %v", err)
|
||
}
|
||
fi, err := os.Stat(dir)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if fi.Mode().Perm() != 0o700 {
|
||
t.Errorf("配置目录权限 = %o, want 700", fi.Mode().Perm())
|
||
}
|
||
}
|