From 5feddf296d3277cd141aa6776342f559765a45af Mon Sep 17 00:00:00 2001 From: shinypolaris <88910096+shinypolaris@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:00:15 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E5=85=A8=E5=B1=80=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E6=94=AF=E6=8C=81=E8=BF=9E=E9=80=9A=E6=80=A7=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E4=B8=8E=E5=87=BA=E5=8F=A3=20IP=20=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/egress_probe.go | 410 ++++++++++++++++++++ server/egress_probe_test.go | 363 +++++++++++++++++ server/server.go | 2 + web/src/app/(main)/system/settings/page.tsx | 154 +++++++- web/src/lib/api.ts | 5 + web/src/lib/types.ts | 38 ++ 6 files changed, 967 insertions(+), 5 deletions(-) create mode 100644 server/egress_probe.go create mode 100644 server/egress_probe_test.go diff --git a/server/egress_probe.go b/server/egress_probe.go new file mode 100644 index 0000000..2145bab --- /dev/null +++ b/server/egress_probe.go @@ -0,0 +1,410 @@ +package server + +// egress_probe.go —— 全局出口代理的连通性探测。 +// +// settings.global_proxy 保存时只做格式校验、一个字节都不发,所以死代理也能保存成功。 +// 这里真发探测请求:① 直连的出口 IP,② 经这个代理的出口 IP。 +// ② 发往哪个地址由 egressTarget 决定——那是本文件唯一需要判断的地方。 + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/Autumn-27/artex/traffic" +) + +// defaultEgressProbes 按顺序尝试,第一个成功即停;probe_url 可覆盖,供隔离网使用。 +var defaultEgressProbes = []string{ + "https://api.ipify.org?format=json", // 返回 {"ip":"1.2.3.4"} + "https://ifconfig.me/ip", // 返回纯文本 1.2.3.4 +} + +// egressAttempt 保留每个地址的失败原因,才能区分"连不上代理"和"代理通但出不了网"。 +type egressAttempt struct { + URL string `json:"url"` + OK bool `json:"ok"` + IP string `json:"ip,omitempty"` + LatencyMS int64 `json:"latency_ms"` + Error string `json:"error,omitempty"` +} + +// egressProbeResult 是一个链路形态的探测汇总。 +type egressProbeResult struct { + OK bool `json:"ok"` + ExitIP string `json:"exit_ip,omitempty"` // 外界看到的出口 IP + Via string `json:"via,omitempty"` // direct / http / https / socks5 + ViaLabel string `json:"via_label,omitempty"` + LatencyMS int64 `json:"latency_ms"` + Error string `json:"error,omitempty"` + Probes []egressAttempt `json:"probes"` // 每个地址的明细,便于排查 +} + +// egressCheck 是报告里的一行,对应一个链路形态。 +type egressCheck struct { + Key string `json:"key"` // direct | global + Label string `json:"label"` // 中文说明,前端直接显示 + Path string `json:"path"` // 实际使用的代理地址(空 = 直连) + Skipped bool `json:"skipped,omitempty"` + SkipReason string `json:"skip_reason,omitempty"` + Result egressProbeResult `json:"result"` +} + +// egressReport 是 POST /api/settings/proxy/test 的返回体。 +type egressReport struct { + OK bool `json:"ok"` // 以"② 经全局代理"为准;未配代理时看"① 直连" + Capture bool `json:"capture"` // 当前是否开启流量捕获 + Configured string `json:"configured"` // 表单传上来的 global_proxy + AgentProxy string `json:"agent_proxy"` // ProxyAddr() 的实际返回值 + Checks []egressCheck `json:"checks"` + Warnings []string `json:"warnings,omitempty"` +} + +// probeEgress 不碰数据库、不碰 Server,所以测试里可以直接调、完全离线。 +func probeEgress(ctx context.Context, proxyRaw, caPEMPath string, probes []string, timeout time.Duration) egressProbeResult { + start := time.Now() + + // 复用 traffic 包的校验,保证"什么算合法代理"全项目只有一处定义。 + var proxyURL *url.URL + if strings.TrimSpace(proxyRaw) != "" { + u, err := traffic.ValidateProxyURL(proxyRaw) + if err != nil { + return egressProbeResult{ + OK: false, + Error: err.Error(), + LatencyMS: time.Since(start).Milliseconds(), + // 空数组不是 nil:nil 会序列化成 JSON null,前端按数组处理会炸。 + Probes: []egressAttempt{}, + } + } + proxyURL = u + } + + tr, caErr := buildProbeTransport(proxyURL, caPEMPath) + + client := &http.Client{ + Transport: tr, + // 比 ctx 宽 2 秒,让 ctx 先到期,错误里才是 "context deadline exceeded"。 + Timeout: timeout + 2*time.Second, + } + + attempts := make([]egressAttempt, 0, len(probes)) + for _, target := range probes { + reqCtx, cancel := context.WithTimeout(ctx, timeout) + t0 := time.Now() + ip, err := probeOne(reqCtx, client, target) + cancel() + + a := egressAttempt{URL: target, LatencyMS: time.Since(t0).Milliseconds()} + if err != nil { + a.Error = err.Error() + // 只在证书类错误上补一句,否则连不上代理也会挂上不相干的 CA 说明。 + // 必须写进 probes[].error——界面渲染的是它,不是 result.error。 + if caErr != nil && strings.Contains(a.Error, "certificate") { + a.Error = "流量捕获的 CA 不可用(" + caErr.Error() + "):证书错误可能由此而来。" + a.Error + } + } else { + a.OK, a.IP = true, ip + } + attempts = append(attempts, a) + + if a.OK { + // via 说的是代理协议,不是出口的网络类型。 + via, viaLabel := "direct", "直连" + if proxyURL != nil { + via, viaLabel = proxyURL.Scheme, schemeLabel(proxyURL.Scheme) + } + return egressProbeResult{ + OK: true, + ExitIP: ip, + Via: via, + ViaLabel: viaLabel, + LatencyMS: time.Since(start).Milliseconds(), + Probes: attempts, + } + } + } + + return egressProbeResult{ + OK: false, + Error: "全部探测地址均失败", + LatencyMS: time.Since(start).Milliseconds(), + Probes: attempts, + } +} + +// schemeLabel 把协议名翻译成人话("http" → "HTTP 代理")。 +func schemeLabel(scheme string) string { + switch strings.ToLower(scheme) { + case "http": + return "HTTP 代理" + case "https": + return "HTTPS 代理" + case "socks5", "socks5h": + return "SOCKS5 代理" + default: + return strings.ToUpper(scheme) + " 代理" + } +} + +// buildProbeTransport 不能用 http.DefaultTransport:它的 Proxy 会读容器里的 HTTP_PROXY, +// 那样"测直连"其实走了代理。这里 Proxy 留 nil 才是真直连。 +func buildProbeTransport(proxyURL *url.URL, caPEMPath string) (*http.Transport, error) { + tr := &http.Transport{ + DisableKeepAlives: true, + TLSHandshakeTimeout: 8 * time.Second, + } + if proxyURL != nil { + tr.Proxy = http.ProxyURL(proxyURL) + } + // CA 不可用不 panic,把原因带回去拼进失败原因——否则只会看到 x509,看不出真实原因。 + if path := strings.TrimSpace(caPEMPath); path != "" { + pemBytes, err := os.ReadFile(path) + if err != nil { + return tr, fmt.Errorf("读不到 %s", path) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(pemBytes) { + return tr, fmt.Errorf("%s 不是有效的 PEM", path) + } + tr.TLSClientConfig = &tls.Config{RootCAs: pool} + } + return tr, nil +} + +// probeOne 支持 JSON({"ip":"..."})和纯文本;认不出就报错,绝不猜一个 IP。 +func probeOne(ctx context.Context, client *http.Client, target string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", "artex-egress-probe/1.0") + + resp, err := client.Do(req) + if err != nil { + // 原样返回:dial refused / deadline exceeded / x509 各自对应不同故障。 + return "", err + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<10)) + if err != nil { + return "", err + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("HTTP %d", resp.StatusCode) + } + + var v struct { + IP string `json:"ip"` + } + if json.Unmarshal(body, &v) == nil && v.IP != "" { + if net.ParseIP(strings.TrimSpace(v.IP)) == nil { + return "", errors.New("响应里的 ip 字段不是合法 IP") + } + return strings.TrimSpace(v.IP), nil + } + + // net.ParseIP 顺便挡掉 HTML(反爬页、代理错误页)。 + if s := strings.TrimSpace(string(body)); net.ParseIP(s) != nil { + return s, nil + } + return "", fmt.Errorf("无法从响应中解析出 IP(收到 %d 字节,可能是反爬页或代理错误页)", len(body)) +} + +// egressTarget 决定 ② 把请求发到哪: +// +// 捕获关 → 表单值;捕获开+已保存 → MITM;捕获开+未保存 → 表单值(跳过 MITM) +// +// 第三种不能走 MITM:MITM 的上游只由 SetGlobalProxy 更新,读不到请求体里的新值, +// 走它只会测出旧代理。viaMITM 供调用方放宽超时。 +func egressTarget(cfg string, capture, pending bool, agentProxy, agentCA string) (addr, ca string, viaMITM bool) { + if cfg == "" { + return "", "", false + } + if capture && !pending && agentProxy != "" { + return agentProxy, agentCA, true + } + return cfg, "", false +} + +// testEgressProxy 的语义对齐 testWebSearch:地址取自请求体(未保存也能测)、 +// 空 = 测直连、永远返回 200 用 ok 表达结果。 +func (s *Server) testEgressProxy(w http.ResponseWriter, r *http.Request) { + var req struct { + GlobalProxy string `json:"global_proxy"` + ProbeURL string `json:"probe_url"` // 可选:覆盖默认探测地址 + } + // 空 body 等价于测直连。 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) { + writeErr(w, 400, err.Error()) + return + } + + probes := defaultEgressProbes + if u := strings.TrimSpace(req.ProbeURL); u != "" { + probes = []string{u} + } + + // 这三个调用决定 agent 到底会走哪条路。 + capture := s.m.TrafficEnabled() // 流量捕获开关 + agentProxy := s.m.ProxyAddr() // agent 实际使用的代理地址 + agentCA := s.m.ProxyCACert() // 捕获开时是 MITM 的 CA 路径;关时为空 + savedProxy := s.m.GlobalProxy() // 已保存的 global_proxy + + // 表单值 ≠ 已保存值时,② 仍按表单值测,但 agent 走的是已保存值——必须提示。 + // 归一化再比:尾斜杠不算"未保存"。 + pending := normalizeProxy(req.GlobalProxy) != normalizeProxy(savedProxy) + + // 整体上限 40s:单次 Timeout 只兜一次,不兜总量;前端 fetch 没有超时。 + ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second) + defer cancel() + + rep := egressReport{ + Capture: capture, + Configured: strings.TrimSpace(req.GlobalProxy), + AgentProxy: agentProxy, + } + + rep.Checks = append(rep.Checks, egressCheck{ + Key: "direct", + Label: "① 直连 IP(不经过代理)", + Result: probeEgress(ctx, "", "", probes, 10*time.Second), + }) + + if cfg := rep.Configured; cfg != "" { + probeAddr, probeCA, viaMITM := egressTarget(cfg, capture, pending, agentProxy, agentCA) + timeout := 10 * time.Second + if viaMITM { + timeout = 15 * time.Second // 多了一跳 TLS 解密 + 重签 + } + rep.Checks = append(rep.Checks, egressCheck{ + Key: "global", + Label: "② 经全局代理的出口 IP", + // Path 是实际探测地址(经 MITM 时是 8788),排查时看这个。 + Path: probeAddr, + Result: probeEgress(ctx, probeAddr, probeCA, probes, timeout), + }) + } else { + skipReason := "输入框为空,未配置代理" + if savedProxy != "" { + // 说"未配置代理"会和 W1("Agent 仍使用 xxx")互相否定。 + skipReason = "输入框为空;已保存的代理仍在生效" + } + rep.Checks = append(rep.Checks, egressCheck{ + Key: "global", + Label: "② 经全局代理的出口 IP", + Skipped: true, + SkipReason: skipReason, + Result: egressProbeResult{Probes: []egressAttempt{}}, + }) + } + + rep.OK = judgeEgress(rep) + rep.Warnings = egressWarnings(rep, pending, savedProxy) + writeJSON(w, 200, rep) +} + +// normalizeProxy 只归一写法,不做校验;空串和解析失败原样返回。 +func normalizeProxy(raw string) string { + return strings.TrimRight(strings.TrimSpace(raw), "/") +} + +// judgeEgress 以 ② 为准;② 被跳过才看 ①。不能"任一行成功就算成功"。 +func judgeEgress(rep egressReport) bool { + for _, c := range rep.Checks { + if c.Key == "global" && !c.Skipped { + return c.Result.OK + } + } + for _, c := range rep.Checks { + if c.Key == "direct" { + return c.Result.OK + } + } + return false +} + +// egressWarnings 生成"看数字容易忽略"的提醒。 +func egressWarnings(rep egressReport, pending bool, savedProxy string) []string { + var out []string + + // W1 agent 走的是已保存配置,不说清会被当成"测通了=配好了"。 + if pending { + switch { + case rep.Configured == "": + out = append(out, "此地址尚未保存,Agent 当前仍使用 "+savedProxy+"。保存后即改为直连。") + case savedProxy == "": + out = append(out, "此地址尚未保存,Agent 当前仍为直连。"+ + "上方结果对应输入框中的值,保存后生效。") + default: + out = append(out, "此地址尚未保存,Agent 当前仍使用 "+savedProxy+ + "。上方结果对应输入框中的值,保存后生效。") + } + } + + var directIP, proxyIP string + proxyOK := false + for _, c := range rep.Checks { + switch c.Key { + case "direct": + directIP = c.Result.ExitIP + case "global": + if !c.Skipped { + proxyIP = c.Result.ExitIP + proxyOK = c.Result.OK + } + } + } + + // W2 看已保存值;只在 ② 通过时提示——没通的话"这里通不代表"本身就不成立。 + if !rep.Capture && proxyOK && strings.HasPrefix(strings.ToLower(savedProxy), "socks5") { + out = append(out, "当前是「关闭捕获 + socks5」:本测试用的是 Go 客户端(一定遵守代理),"+ + "但 agent 的工具不一定——curl 能走,python urllib 会直接报错。"+ + "所以这里通不代表工具都能走;要稳定生效请开启流量捕获(由 MITM 亲自拨号)。") + } + + // W3 两行相同 → 代理可能没生效,或对端只是透明转发。 + if directIP != "" && proxyIP != "" && directIP == proxyIP { + out = append(out, fmt.Sprintf("直连与代理的出口 IP 相同(%s):可能代理未生效、只是透明转发,"+ + "也可能跳板机与本机在同一 NAT 后面(出口本来就相同)。"+ + "要区分:在「自定义 IP 探测服务」里填一台内网回显服务,看它看到的源 IP 是否变成了跳板机。", directIP)) + } + + // W4 同样看已保存值,否则会和 W1 自相矛盾。 + if rep.Capture && savedProxy == "" { + out = append(out, "已开启流量捕获,但全局代理为空:捕获只负责记录,出口仍是直连。") + } + + // W5 容器里的 127.0.0.1 指容器自己,连不到宿主机上的代理。 + if isLoopbackProxy(rep.Configured) { + out = append(out, "代理地址是回环地址(127.0.0.1 / localhost)。"+ + "如果 ARTEX 跑在容器里,这里的 127.0.0.1 指容器自身,连不到宿主机上的代理;"+ + "请改用宿主机的可达 IP(如 http://192.168.x.x:端口)。") + } + + return out +} + +func isLoopbackProxy(raw string) bool { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || u.Hostname() == "" { + return false + } + h := strings.ToLower(u.Hostname()) + if h == "localhost" { + return true + } + ip := net.ParseIP(h) + return ip != nil && ip.IsLoopback() +} diff --git a/server/egress_probe_test.go b/server/egress_probe_test.go new file mode 100644 index 0000000..b802056 --- /dev/null +++ b/server/egress_probe_test.go @@ -0,0 +1,363 @@ +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 +} diff --git a/server/server.go b/server/server.go index 672069e..3f20bb7 100644 --- a/server/server.go +++ b/server/server.go @@ -897,6 +897,8 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/notify/deliveries", s.notifyListDeliveries) mux.HandleFunc("POST /api/notify/deliveries/{id}/retry", s.notifyRetryDelivery) mux.HandleFunc("POST /api/settings/web-search/test", s.testWebSearch) + // 出口代理连通性测试:发真实请求验证 global_proxy 是否可用,并回显出口 IP。 + mux.HandleFunc("POST /api/settings/proxy/test", s.testEgressProxy) mux.HandleFunc("GET /api/report", s.getReport) mux.HandleFunc("GET /api/chat/mentions", s.searchChatMentions) mux.HandleFunc("POST /api/chat", s.chat) diff --git a/web/src/app/(main)/system/settings/page.tsx b/web/src/app/(main)/system/settings/page.tsx index 535e9a0..e4646f3 100644 --- a/web/src/app/(main)/system/settings/page.tsx +++ b/web/src/app/(main)/system/settings/page.tsx @@ -14,7 +14,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Switch } from "@/components/ui/switch"; import { api } from "@/lib/api"; import { CHAT_SEND_MODE_OPTIONS, type ChatSendMode, setChatSendMode, useChatSendMode } from "@/lib/chat-send-mode"; -import type { LLMProfile, Settings, WorkerPool } from "@/lib/types"; +import type { EgressReport, LLMProfile, Settings, WorkerPool } from "@/lib/types"; import { BasicAuthCard } from "./_components/basic-auth-card"; import { UpdateCard } from "./_components/update-card"; @@ -38,6 +38,11 @@ export default function SystemSettingsPage() { const [savingProxy, setSavingProxy] = React.useState(false); const [globalProxyInput, setGlobalProxyInput] = React.useState(""); const [savingGlobalProxy, setSavingGlobalProxy] = React.useState(false); + // 出口探测:测一次真实请求,拿到"直连 / 经全局代理"两条出口 IP。 + const [egressTesting, setEgressTesting] = React.useState(false); + const [egressReport, setEgressReport] = React.useStateALL_PROXY 的支持(curl
- 可用,部分工具可能忽略); 若主要用 socks5,建议开启流量捕获——此路径由 MITM
+ 提示:socks5 在关闭捕获时依赖各命令行工具对 ALL_PROXY 的支持——curl
+ 可用,python urllib 等会直接报错; 若主要用 socks5,建议开启流量捕获——此路径由 MITM
亲自拨号,工具无感知、稳定生效。
@@ -433,15 +470,122 @@ export default function SystemSettingsPage() {
placeholder="socks5://user:pass@host:1080 或 http://host:port(留空=直连)"
value={globalProxyInput}
disabled={!loaded || savingGlobalProxy}
- onChange={(e) => setGlobalProxyInput(e.target.value)}
+ onChange={(e) => {
+ setGlobalProxyInput(e.target.value);
+
+ setEgressReport(null);
+ }}
/>
+ {/* 测表单里当前的值,不要求先保存。 */}
+
{globalProxyInput.trim() ? "已配置 · 所有目标流量经此代理出网" : "未配置 · 目标流量直连出网"}
+ {/* 紧贴结果区:用户看两行时正好需要它。细节放文档。 */} +
+ 点测试会访问 IP 回显服务(默认 api.ipify.org,失败自动换{" "}
+ ifconfig.me),它返回"这个请求从哪个 IP 来"。
+ ① 是不走代理时的 IP,② 是经全局代理出网时的 IP。改完不用先保存,直接测。
+
{'{"ip":"1.2.3.4"}'} 格式的 JSON。
+ 若内网环境下无法访问公网,或本机和内网代理共用同一个公网出口,
+ 则可替换为内网 IP 探测服务,此时 ① 和 ② 的 IP 不同,就说明流量走了内网代理。
+
+ + ⚠ {w} +
+ ))} +
{globalProxyInput.trim() ? "已配置 · 所有目标流量经此代理出网" : "未配置 · 目标流量直连出网"}
From 7e5c828fc2bcd4cee448aa5ce56fbb2bd6fe2fdb Mon Sep 17 00:00:00 2001
From: RuoJi6 <79234113+RuoJi6@users.noreply.github.com>
Date: Tue, 27 Oct 2026 13:46:34 +0800
Subject: [PATCH 3/3] fix(web): show egress IPs in navbar middle via settings
toggle
---
server/manager.go | 1 +
server/server.go | 8 +
.../(main)/_components/egress-ip-banner.tsx | 221 ++++++++----------
.../app/(main)/_components/main-content.tsx | 18 +-
web/src/app/(main)/layout.tsx | 46 ++--
web/src/app/(main)/system/settings/page.tsx | 58 ++---
web/src/lib/mock/data.ts | 1 +
web/src/lib/types.ts | 1 +
8 files changed, 180 insertions(+), 174 deletions(-)
diff --git a/server/manager.go b/server/manager.go
index 9b61e13..5718eb3 100644
--- a/server/manager.go
+++ b/server/manager.go
@@ -266,6 +266,7 @@ const (
// (http/https/socks5). Empty = direct. Distinct from web_search_proxy (which
// only routes the search backend) and the per-profile LLM proxy.
settingGlobalProxy = "global_proxy"
+ settingShowEgressIP = "show_egress_ip"
settingWorkers = "workers"
settingWorkerPools = "worker_pools"
settingLLMRecord = "llm_record"
diff --git a/server/server.go b/server/server.go
index 3f20bb7..cffbd48 100644
--- a/server/server.go
+++ b/server/server.go
@@ -3454,6 +3454,7 @@ func (s *Server) settingsPayload() map[string]any {
"tavily_key_set": strings.TrimSpace(tavilyKey) != "",
"web_search_proxy": proxy, // 独立出口代理(http/https/socks5),空=直连
"global_proxy": s.m.GlobalProxy(), // 全局出口代理(http/https/socks5),所有目标流量走它,空=直连
+ "show_egress_ip": s.m.pg.GetBool(settingShowEgressIP, false),
"python_interpreter": strings.TrimSpace(pyStored), // 用户/自动设的值(空=用运行时检测)
"workers": s.m.Workers(), // 并发工作 agent 数(默认3);对之后启动的任务生效
"worker_pools": s.m.WorkerPools(), // 非空时按池顺序绑定 worker 会话
@@ -3530,6 +3531,7 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
TavilyKey *string `json:"tavily_search_api_key"`
WebSearchProxy *string `json:"web_search_proxy"` // 独立出口代理(http/https/socks5);null=不改,""=清空
GlobalProxy *string `json:"global_proxy"` // 全局出口代理(http/https/socks5);null=不改,""=清空(直连)
+ ShowEgressIP *bool `json:"show_egress_ip"` // 在顶栏显示直连/全局代理出口 IP;默认关
PythonInterp *string `json:"python_interpreter"` // 自定义脚本工具的 python 解释器路径
Workers *int `json:"workers"` // 并发工作 agent 数(>0);对之后启动的任务生效
WorkerPools *[]WorkerPool `json:"worker_pools"`
@@ -3712,6 +3714,12 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
}
changed = true // capture-off egress is baked into agents at build time → rebuild
}
+ if req.ShowEgressIP != nil {
+ if err := s.m.pg.SetBool(settingShowEgressIP, *req.ShowEgressIP); err != nil {
+ writeErr(w, 500, err.Error())
+ return
+ }
+ }
if req.WebSearchEnabled != nil || req.WebSearchBackend != nil || req.BraveKey != nil || req.TavilyKey != nil || req.WebSearchProxy != nil {
// Fill unspecified fields from current state so a partial PUT doesn't reset them.
on, backend, _, _, _ := s.m.WebSearch()
diff --git a/web/src/app/(main)/_components/egress-ip-banner.tsx b/web/src/app/(main)/_components/egress-ip-banner.tsx
index 485c657..1197246 100644
--- a/web/src/app/(main)/_components/egress-ip-banner.tsx
+++ b/web/src/app/(main)/_components/egress-ip-banner.tsx
@@ -2,96 +2,40 @@
import * as React from "react";
-import { Globe2Icon, Loader2Icon, XIcon } from "lucide-react";
+import { Globe2Icon, Loader2Icon, RefreshCwIcon, XIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { api } from "@/lib/api";
import type { EgressCheck, EgressReport } from "@/lib/types";
import { cn } from "@/lib/utils";
-const DISMISSED_STORAGE_KEY = "artex-egress-ip-banner-dismissed";
-const REPORT_STORAGE_KEY = "artex-egress-ip-banner-report";
-
-type ProbeOptions = {
- globalProxy: string;
- probeUrl?: string;
-};
-
-type EgressIpBannerContextValue = {
- probeEgress: (options: ProbeOptions) => Promise
{globalProxyInput.trim() ? "已配置 · 所有目标流量经此代理出网" : "未配置 · 目标流量直连出网"}
+ 开启后显示直连 IP 和全局代理 IP,使用已保存的全局代理探测。
+
点测试会访问 IP 回显服务(默认 api.ipify.org,失败自动换{" "}
diff --git a/web/src/lib/mock/data.ts b/web/src/lib/mock/data.ts
index 9b8a69c..d1dd1e6 100644
--- a/web/src/lib/mock/data.ts
+++ b/web/src/lib/mock/data.ts
@@ -3336,6 +3336,7 @@ export const settings: Settings = {
tavily_key_set: true,
web_search_proxy: "",
global_proxy: "",
+ show_egress_ip: false,
python_interpreter: "/usr/bin/python3",
workers: 3,
worker_pools: [],
diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts
index 4bf2605..c7a74d2 100644
--- a/web/src/lib/types.ts
+++ b/web/src/lib/types.ts
@@ -985,6 +985,7 @@ export interface Settings {
// 全局出口代理(http/https/socks5,可带 user:pass),所有目标流量走它。开启流量捕获时作为
// MITM 上游;关闭捕获时直接注入 agent 的 bash/WebFetch。空=直连。
global_proxy?: string;
+ show_egress_ip?: boolean; // 在顶栏显示直连 / 全局代理出口 IP;默认关闭
python_interpreter?: string; // 自定义脚本工具的 python 解释器路径(空=运行时检测)
workers?: number; // 并发工作 agent 数(默认3);对之后启动的任务生效
worker_pools: WorkerPool[];