package httpapi import ( "encoding/json" "net/http/httptest" "os" "path/filepath" "strings" "testing" "github.com/go-chi/chi/v5" "github.com/wangjia/pangolin/server/internal/nodes" ) func testNode() *nodes.NodeRow { return &nodes.NodeRow{ UUID: "n1", Endpoint: "1.2.3.4:443", RealityPBK: "pbk", RealityShortID: "sid", RealitySNI: "www.apple.com", } } func routeOf(t *testing.T, cfg []byte) map[string]any { t.Helper() var m map[string]any if err := json.Unmarshal(cfg, &m); err != nil { t.Fatalf("unmarshal config: %v", err) } return m["route"].(map[string]any) } func TestBuildClientConfigSplitCN(t *testing.T) { // 开启分流 + base → geoip-cn/geosite-cn rule_set + cn 直连规则;URL 自托管。 cfg, err := BuildClientConfig(testNode(), "uuid-1", "k", ClientConfigOpts{SplitCN: true, RulesBaseURL: "http://node:8080/"}) if err != nil { t.Fatal(err) } route := routeOf(t, cfg) if rs, ok := route["rule_set"].([]any); !ok || len(rs) != 2 { t.Fatalf("expected 2 rule_set, got %v", route["rule_set"]) } s := string(cfg) for _, want := range []string{ "geoip-cn", "geosite-cn", "http://node:8080/v1/rules/geoip-cn.srs", "http://node:8080/v1/rules/geosite-cn.srs", "download_detour", } { if !strings.Contains(s, want) { t.Errorf("config missing %q", want) } } foundCN := false for _, r := range route["rules"].([]any) { rm := r.(map[string]any) if rm["outbound"] == "direct" && rm["rule_set"] != nil { foundCN = true } } if !foundCN { t.Error("missing cn-direct route rule (rule_set→direct)") } // DNS 面分流:开分流时国内域名(geosite-cn)用 local 解析,不走 remote(隧道)。 var m map[string]any _ = json.Unmarshal(cfg, &m) dnsRules, ok := m["dns"].(map[string]any)["rules"].([]any) if !ok || len(dnsRules) == 0 { t.Fatalf("split on should have dns.rules (geosite-cn→local), got %v", m["dns"]) } dr := dnsRules[0].(map[string]any) if dr["server"] != "local" || dr["rule_set"] == nil { t.Errorf("dns rule should route geosite-cn → local, got %v", dr) } // 关闭分流 → 无 rule_set,且 DNS 无分流规则(全量 remote)。 cfg2, _ := BuildClientConfig(testNode(), "uuid-1", "k", ClientConfigOpts{}) if _, ok := routeOf(t, cfg2)["rule_set"]; ok { t.Error("split off should have no rule_set") } var m2 map[string]any _ = json.Unmarshal(cfg2, &m2) if _, ok := m2["dns"].(map[string]any)["rules"]; ok { t.Error("split off should have no dns.rules") } // 开启但缺 base → 静默不分流(避免渲染出无效 rule_set URL)。 cfg3, _ := BuildClientConfig(testNode(), "uuid-1", "k", ClientConfigOpts{SplitCN: true}) if _, ok := routeOf(t, cfg3)["rule_set"]; ok { t.Error("split with empty base should have no rule_set") } } func TestBuildClientConfigPrivateSplit(t *testing.T) { domains := []string{"nas.yanmeiai.com", "git.yanmeiai.com", "win.yanmeiai.com"} // 私有分流 + 国内分流同时开:验证规则齐全且顺序正确 // (LAN 直连 → 私有域名强制走隧道 → 国内直连;私有规则必须在国内直连之前, // 否则锚点是国内 IP 会被分流成直连、被 frps 侧安全组限源拦截)。 cfg, err := BuildClientConfig(testNode(), "uuid-1", "k", ClientConfigOpts{SplitCN: true, RulesBaseURL: "http://node:8080", PrivateSplitDomains: domains}) if err != nil { t.Fatal(err) } var m map[string]any if err := json.Unmarshal(cfg, &m); err != nil { t.Fatal(err) } // ① dns.servers 含系统解析器(type=local):在家吃到局域网 DNS 覆盖(私网IP), // 在外用所在网络 DNS 解析出公网锚点。 dnsm := m["dns"].(map[string]any) foundSystem := false for _, s := range dnsm["servers"].([]any) { sm := s.(map[string]any) if sm["tag"] == "dns-system" && sm["type"] == "local" { foundSystem = true } } if !foundSystem { t.Error("missing dns-system (type=local) dns server") } // ② dns.rules 首条 = 私有域名→dns-system(须排在 geosite-cn→local 之前)。 dnsRules := dnsm["rules"].([]any) dr := dnsRules[0].(map[string]any) if dr["server"] != "dns-system" || dr["domain"] == nil { t.Errorf("dns.rules[0] should be private domains → dns-system, got %v", dr) } // ③ reverse_mapping 开启:应用自行解析后按 IP 连接,回映射补回域名元数据, // 路由的 domain 规则才有效。 if dnsm["reverse_mapping"] != true { t.Error("reverse_mapping should be true when private split is on") } // ④ 路由顺序:LAN 直连 < 私有域名→auto < 国内 rule_set→direct。 rules := m["route"].(map[string]any)["rules"].([]any) lanIdx, privIdx, cnIdx := -1, -1, -1 for i, r := range rules { rm := r.(map[string]any) if rm["ip_cidr"] != nil && rm["outbound"] == "direct" { lanIdx = i } if rm["domain"] != nil && rm["outbound"] == "auto" { privIdx = i } if rm["rule_set"] != nil && rm["outbound"] == "direct" { cnIdx = i } } if lanIdx < 0 || privIdx < 0 || cnIdx < 0 { t.Fatalf("missing rules: lan=%d priv=%d cn=%d", lanIdx, privIdx, cnIdx) } if !(lanIdx < privIdx && privIdx < cnIdx) { t.Errorf("rule order wrong: lan=%d < priv=%d < cn=%d expected", lanIdx, privIdx, cnIdx) } // ⑤ 不配置 → 全部不出现(行为与旧版完全一致)。 cfg2, _ := BuildClientConfig(testNode(), "uuid-1", "k", ClientConfigOpts{}) var m2 map[string]any _ = json.Unmarshal(cfg2, &m2) dnsm2 := m2["dns"].(map[string]any) if _, ok := dnsm2["reverse_mapping"]; ok { t.Error("private split off: reverse_mapping should be absent") } if strings.Contains(string(cfg2), "dns-system") { t.Error("private split off: dns-system should be absent") } } func TestRulesHandler(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "geoip-cn.srs"), []byte("SRS"), 0o644); err != nil { t.Fatal(err) } r := chi.NewRouter() r.Get("/v1/rules/{name}", NewRulesHandler(dir).Serve) get := func(path string) (int, string) { rec := httptest.NewRecorder() r.ServeHTTP(rec, httptest.NewRequest("GET", path, nil)) return rec.Code, rec.Body.String() } if code, body := get("/v1/rules/geoip-cn.srs"); code != 200 || body != "SRS" { t.Fatalf("allowed file: code=%d body=%q, want 200/SRS", code, body) } if code, _ := get("/v1/rules/passwd"); code != 404 { t.Errorf("disallowed name: code=%d, want 404", code) } if code, _ := get("/v1/rules/geosite-cn.srs"); code != 404 { t.Errorf("allowed but missing file: code=%d, want 404", code) } }