package server // egress_probe_test.go —— 出口探测的离线测试。 // 所有服务都由 httptest 在本地起,不访问真实外网——否则 CI 会因网络抖动随机失败。 import ( "context" "encoding/pem" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync/atomic" "testing" "time" ) // fakeIPServer 起一个假的 IP 回显服务。用文档专用网段,避免与真实地址混淆。 func fakeIPServer(ip string) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"ip":"` + ip + `"}`)) })) } // TestProbeEgress 覆盖 probeEgress 的输入/响应形态:成功、非法协议、死代理、坏响应、回落。 // 每条用例自带服务端与断言,失败时 t.Run 的名字直接指出是哪一条。 func TestProbeEgress(t *testing.T) { okSrv := fakeIPServer("203.0.113.7") defer okSrv.Close() htmlSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("access denied")) })) defer htmlSrv.Close() deniedSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "nope", http.StatusForbidden) })) defer deniedSrv.Close() fallbackSrv := fakeIPServer("203.0.113.42") defer fallbackSrv.Close() cases := []struct { name string proxy string probes []string timeout time.Duration check func(t *testing.T, res egressProbeResult) }{ { name: "直连拿到 IP", proxy: "", probes: []string{okSrv.URL}, timeout: 5 * time.Second, check: func(t *testing.T, res egressProbeResult) { if !res.OK || res.ExitIP != "203.0.113.7" || res.Via != "direct" { t.Fatalf("得到 %+v,期望成功且 via=direct、IP=203.0.113.7", res) } }, }, { // 用耗时断言确认它没有真的去连网络。 name: "非法协议在本地就被拒", proxy: "ftp://127.0.0.1:21", probes: nil, timeout: time.Second, check: func(t *testing.T, res egressProbeResult) { if res.OK || res.Error == "" { t.Fatalf("非法协议应失败并带回原因,得到 %+v", res) } if res.LatencyMS > 500 { t.Fatalf("格式校验应在本地完成,耗时 %dms 偏大", res.LatencyMS) } // nil slice 会序列化成 JSON null,前端按数组处理会崩。 if res.Probes == nil { t.Fatal("probes 不应为 nil(序列化后是 null)") } }, }, { // 本次贡献的核心回归:格式合法但端口没开,必须能识别出来。 name: "死代理带回原因", proxy: "socks5://127.0.0.1:1", probes: []string{"http://example.invalid"}, timeout: 2 * time.Second, check: func(t *testing.T, res egressProbeResult) { if res.OK { t.Fatal("死代理不该成功") } if len(res.Probes) == 0 || res.Probes[0].Error == "" { t.Fatalf("应至少尝试一次并带回原因,得到 %+v", res.Probes) } }, }, { name: "HTML 不被当成 IP", proxy: "", probes: []string{htmlSrv.URL}, timeout: 5 * time.Second, check: func(t *testing.T, res egressProbeResult) { if res.OK { t.Fatalf("反爬页/错误页不该被当成 IP,实际 exit_ip=%q", res.ExitIP) } }, }, { name: "第一个地址失败自动换下一个", proxy: "", probes: []string{deniedSrv.URL, fallbackSrv.URL}, timeout: 5 * time.Second, check: func(t *testing.T, res egressProbeResult) { if !res.OK || res.ExitIP != "203.0.113.42" { t.Fatalf("应回落到第二个地址并成功,得到 %+v", res) } if len(res.Probes) != 2 { t.Fatalf("应记录两次尝试,实际 %d 次", len(res.Probes)) } }, }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { c.check(t, probeEgress(context.Background(), c.proxy, "", c.probes, c.timeout)) }) } } // TestProbeEgressThroughProxy 验证请求**确实经过了代理**,并顺带确认 via / via_label 一起填好。 // // 断言的是"代理收到了请求",不是"结果对不对":如果忘了设 Transport.Proxy, // 探测照样成功(直连拿到 IP),形成"看起来走了代理其实没走"的假象。 // 这里的假代理不转发,直接回一个 IP——明文 HTTP 走代理时 Go 会把绝对 URI 发给它。 func TestProbeEgressThroughProxy(t *testing.T) { var proxied int32 proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt32(&proxied, 1) _, _ = w.Write([]byte(`{"ip":"198.51.100.9"}`)) })) defer proxy.Close() res := probeEgress(context.Background(), proxy.URL, "", []string{"http://example.invalid/ip"}, 5*time.Second) if !res.OK { t.Fatalf("经代理探测应成功: %s / %+v", res.Error, res.Probes) } if atomic.LoadInt32(&proxied) == 0 { t.Fatal("代理没有收到任何请求 —— 说明 Transport 没走代理") } if res.ExitIP != "198.51.100.9" { t.Fatalf("出口 IP = %q,期望代理返回的 198.51.100.9", res.ExitIP) } if res.Via != "http" || res.ViaLabel != "HTTP 代理" { t.Fatalf("via/via_label 不正确:%q / %q", res.Via, res.ViaLabel) } } // TestProbeEgressTrustsCustomCA 模拟"经 MITM"的关键路径: // 不传 CA 必须失败(证明信任这一步是必需的),传了才成功。 func TestProbeEgressTrustsCustomCA(t *testing.T) { srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"ip":"192.0.2.5"}`)) })) defer srv.Close() caPath := filepath.Join(t.TempDir(), "ca.pem") pemBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: srv.Certificate().Raw}) if err := os.WriteFile(caPath, pemBytes, 0o600); err != nil { t.Fatal(err) } if res := probeEgress(context.Background(), "", "", []string{srv.URL}, 5*time.Second); res.OK { t.Fatal("不信任自签 CA 时不该成功") } res := probeEgress(context.Background(), "", caPath, []string{srv.URL}, 5*time.Second) if !res.OK || res.ExitIP != "192.0.2.5" { t.Fatalf("信任 CA 后应拿到 IP,得到 %+v / %+v", res.Error, res.Probes) } } // TestJudgeEgress 验证"整体通过"的判定口径:配了代理以 ② 为准,② 被跳过才看 ①。 func TestJudgeEgress(t *testing.T) { ok := egressProbeResult{OK: true, ExitIP: "1.2.3.4"} bad := egressProbeResult{OK: false, Error: "x"} cases := []struct { name string rep egressReport want bool }{ {"没配代理:只有 ① 直连", egressReport{Checks: []egressCheck{ {Key: "direct", Result: ok}, {Key: "global", Skipped: true}, }}, true}, {"配了代理且通", egressReport{Checks: []egressCheck{ {Key: "direct", Result: ok}, {Key: "global", Result: ok}, }}, true}, // 关键口径:直连通、代理不通,整体必须不通过,否则会显示绿灯。 {"直连通但代理不通", egressReport{Checks: []egressCheck{ {Key: "direct", Result: ok}, {Key: "global", Result: bad}, }}, false}, {"代理通、直连不通", egressReport{Checks: []egressCheck{ {Key: "direct", Result: bad}, {Key: "global", Result: ok}, }}, true}, {"两行都失败", egressReport{Checks: []egressCheck{ {Key: "direct", Result: bad}, {Key: "global", Result: bad}, }}, false}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { if got := judgeEgress(c.rep); got != c.want { t.Fatalf("got %v want %v", got, c.want) } }) } } // TestEgressWarnings 覆盖五条提醒的触发与"不该触发"。 // 每条用例声明"必须出现"和"必须不出现"的子串,正反两面一起锁。 func TestEgressWarnings(t *testing.T) { okChecks := []egressCheck{ {Key: "direct", Result: egressProbeResult{OK: true, ExitIP: "9.9.9.9"}}, {Key: "global", Result: egressProbeResult{OK: true, ExitIP: "8.8.8.8"}}, } sameIP := []egressCheck{ {Key: "direct", Result: egressProbeResult{OK: true, ExitIP: "9.9.9.9"}}, {Key: "global", Result: egressProbeResult{OK: true, ExitIP: "9.9.9.9"}}, } proxyDown := []egressCheck{ {Key: "direct", Result: egressProbeResult{OK: true, ExitIP: "9.9.9.9"}}, {Key: "global", Result: egressProbeResult{OK: false, Error: "connection refused"}}, } cases := []struct { name string rep egressReport pending bool saved string want string // 必须出现的子串(空 = 不检查) wantNot string // 必须不出现的子串 }{ {"W2 关捕获 + socks5", egressReport{Capture: false, Configured: "socks5://1.2.3.4:1080", Checks: okChecks}, false, "socks5://1.2.3.4:1080", "python urllib", ""}, {"W2 不误报:已保存走的是 http", egressReport{Capture: false, Configured: "socks5://1.2.3.4:1080", Checks: okChecks}, true, "http://1.2.3.4:8080", "", "python urllib"}, {"W2 不漏报:已保存才是 socks5", egressReport{Capture: false, Configured: "http://1.2.3.4:8080", Checks: okChecks}, true, "socks5://1.2.3.4:1080", "python urllib", ""}, {"W2 ② 没通时不提", egressReport{Capture: false, Configured: "socks5://1.2.3.4:1080", Checks: proxyDown}, false, "socks5://1.2.3.4:1080", "", "python urllib"}, {"W3 两行 IP 相同", egressReport{Configured: "http://1.2.3.4:8080", Checks: sameIP}, false, "", "出口 IP 相同", ""}, {"W3 不误报:IP 不同", egressReport{Configured: "http://1.2.3.4:8080", Checks: okChecks}, false, "", "", "出口 IP 相同"}, {"W4 捕获开 + 无代理", egressReport{Capture: true, Configured: ""}, false, "", "直连", ""}, {"W4 不误报:已保存还有代理", egressReport{Capture: true, Configured: ""}, true, "http://1.2.3.4:8080", "", "全局代理为空"}, {"W1 表单值未保存", egressReport{Capture: false, Configured: "socks5://1.2.3.4:1080"}, true, "", "尚未保存", ""}, {"W5 回环 127.0.0.1", egressReport{Configured: "http://127.0.0.1:7897"}, false, "", "回环地址", ""}, {"W5 回环 localhost", egressReport{Configured: "socks5://localhost:1080"}, false, "", "回环地址", ""}, {"W5 不误报:局域网地址", egressReport{Configured: "http://192.168.1.5:8080"}, false, "", "", "回环地址"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { w := egressWarnings(c.rep, c.pending, c.saved) if c.want != "" && !hasSubstr(w, c.want) { t.Fatalf("应包含 %q,实际 %v", c.want, w) } if c.wantNot != "" && hasSubstr(w, c.wantNot) { t.Fatalf("不该包含 %q,实际 %v", c.wantNot, w) } }) } } // TestSchemeLabel 保证界面不会出现"经 http"这种看不懂的片段。 func TestSchemeLabel(t *testing.T) { cases := map[string]string{ "http": "HTTP 代理", "HTTPS": "HTTPS 代理", // 大小写不敏感 "socks5": "SOCKS5 代理", "socks5h": "SOCKS5 代理", "socks4": "SOCKS4 代理", // 未知协议兜底:至少可读 } for in, want := range cases { if got := schemeLabel(in); got != want { t.Errorf("schemeLabel(%q)=%q,期望 %q", in, got, want) } } } // TestEgressTarget 锁住核心决策:② 这一行到底发往哪里。 // 第三种(捕获开 + 未保存)最容易写错——想当然地走 MITM 会测出旧代理。 func TestEgressTarget(t *testing.T) { const ( cfg = "http://10.0.0.9:8080" mitm = "http://127.0.0.1:8788" ca = "/app/data/traffic/_ca/mitmproxy-ca-cert.pem" ) cases := []struct { name string cfg string capture bool pending bool agentPrxy string wantAddr string wantCA string wantViaM bool }{ {"捕获关:直连表单值", cfg, false, false, mitm, cfg, "", false}, {"捕获关 + 未保存:仍直连表单值", cfg, false, true, mitm, cfg, "", false}, {"捕获开 + 已保存:走 MITM 并带 CA", cfg, true, false, mitm, mitm, ca, true}, {"捕获开 + 未保存:跳过 MITM", cfg, true, true, mitm, cfg, "", false}, {"空代理:不发请求", "", true, false, mitm, "", "", false}, // 边界:捕获开着但拿不到 MITM 地址时,退回表单值,不能返回空地址。 {"捕获开但拿不到 MITM 地址", cfg, true, false, "", cfg, "", false}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { addr, gotCA, viaM := egressTarget(c.cfg, c.capture, c.pending, c.agentPrxy, ca) if addr != c.wantAddr || gotCA != c.wantCA || viaM != c.wantViaM { t.Fatalf("得到 (addr=%q ca=%q viaMITM=%v),期望 (%q %q %v)", addr, gotCA, viaM, c.wantAddr, c.wantCA, c.wantViaM) } }) } } // TestBuildProbeTransport 覆盖 Transport 的三条不变量: // 直连不读环境变量、CA 文件不存在要报错、CA 内容非法也要报错。 func TestBuildProbeTransport(t *testing.T) { // 不能靠 t.Setenv 后发请求来测:ProxyFromEnvironment 在进程内只初始化一次, // 前一个用例跑过之后环境变量就不生效了,测出来是假绿。这里直接断言 Proxy 为 nil。 t.Run("直连不读 HTTP_PROXY", func(t *testing.T) { t.Setenv("HTTP_PROXY", "http://127.0.0.1:9") t.Setenv("HTTPS_PROXY", "http://127.0.0.1:9") tr, err := buildProbeTransport(nil, "") if err != nil { t.Fatalf("空 CA 不该报错:%v", err) } if tr.Proxy != nil { t.Fatal("直连的 Transport 不该设置 Proxy,否则会读到 HTTP_PROXY") } }) t.Run("CA 文件不存在", func(t *testing.T) { if _, err := buildProbeTransport(nil, "/nonexistent/ca.pem"); err == nil { t.Fatal("应返回错误,而不是静默降级成会报 x509 的 Transport") } }) t.Run("CA 内容不是 PEM", func(t *testing.T) { bad := filepath.Join(t.TempDir(), "bad.pem") if err := os.WriteFile(bad, []byte("not a pem"), 0o600); err != nil { t.Fatal(err) } if _, err := buildProbeTransport(nil, bad); err == nil { t.Fatal("应返回错误") } }) } // hasSubstr 判断警告列表里是否有某条包含指定子串。 func hasSubstr(list []string, sub string) bool { for _, s := range list { if strings.Contains(s, sub) { return true } } return false }