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.useState(null); + // 探测地址:留空用公网回显;隔离网填内网回显服务。 + const [egressProbeUrl, setEgressProbeUrl] = React.useState(""); const [testing, setTesting] = React.useState(false); const [loaded, setLoaded] = React.useState(false); const [settingsLoaded, setSettingsLoaded] = React.useState(false); @@ -178,7 +183,11 @@ export default function SystemSettingsPage() { setSaving(true); api .setSettings({ traffic_capture: v }) - .then(apply) + .then((s) => { + apply(s); + // 捕获开关一变,② 的链路就变了,旧结果不能留。 + setEgressReport(null); + }) .catch(() => setTrafficCapture(!v)) // revert on failure .finally(() => setSaving(false)); }; @@ -292,12 +301,40 @@ export default function SystemSettingsPage() { .setSettings({ global_proxy: globalProxyInput.trim() }) .then((s) => { apply(s); + // 保存后链路变了,旧结果(尤其"尚未保存")立刻失效。 + setEgressReport(null); toast.success(globalProxyInput.trim() ? "已保存全局代理" : "已清除全局代理(改为直连)"); }) .catch((e) => toast.error("保存失败:" + (e as Error).message)) .finally(() => setSavingGlobalProxy(false)); }; + // 测表单里当前的值,不要求先保存;后端返回直连 / 经代理两条。 + const testEgress = () => { + setEgressTesting(true); + setEgressReport(null); + api + .testEgressProxy({ + global_proxy: globalProxyInput.trim(), + + ...(egressProbeUrl.trim() ? { probe_url: egressProbeUrl.trim() } : {}), + }) + .then((rep) => { + setEgressReport(rep); + if (rep.ok) { + // 先取 ②:直连那行永远成功,先命中它会把直连 IP 当成出口报出去。 + const hit = + rep.checks.find((c) => c.key === "global" && !c.skipped && c.result.ok) ?? + rep.checks.find((c) => !c.skipped && c.result.ok); + toast.success(hit?.result.exit_ip ? `出口 IP ${hit.result.exit_ip}` : "探测通过"); + } else { + toast.error("出口探测失败"); + } + }) + .catch((e) => toast.error("探测失败:" + (e as Error).message)) + .finally(() => setEgressTesting(false)); + }; + // Run a real "test" search ("test") against the CURRENT form values (backend + // proxy + entered key), falling back to saved values server-side. Toasts result. const runTest = () => { @@ -417,8 +454,8 @@ export default function SystemSettingsPage() { 开启流量捕获时,它作为记录代理的上游(流量仍全量落库,再经此代理出网);关闭捕获时,直接注入 Agent 的 bash / WebFetch 出网。与网络搜索代理、LLM 代理相互独立。
- 提示:socks5 在关闭捕获时依赖各命令行工具对 ALL_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 探测服务(可选) + +
+ { + setEgressProbeUrl(e.target.value); + setEgressReport(null); + }} + /> + + 自定义 IP 探测服务需返回纯文本 IP 或{" "} + {'{"ip":"1.2.3.4"}'} 格式的 JSON。 + 若内网环境下无法访问公网,或本机和内网代理共用同一个公网出口, + 则可替换为内网 IP 探测服务,此时 ① 和 ② 的 IP 不同,就说明流量走了内网代理。 + +
+
+ {egressReport && ( +
+ {egressReport.checks.map((c) => { + // 经 MITM 时 via 是 HTTP(MITM 自己是代理),标出来会让人以为填错了。 + const viaCapture = !!c.path && c.path !== egressReport.configured; + return ( +
+ + {c.skipped ? "—" : c.result.ok ? "✓" : "✗"} + + + + {c.label} + + {c.skipped ? ( + {c.skip_reason} + ) : c.result.ok ? ( + + + {c.result.exit_ip} + {" "} + ·{" "} + + {c.result.latency_ms}ms + + {c.result.via && c.result.via !== "direct" && !viaCapture && ( + + {" "} + · 经 {c.result.via_label ?? c.result.via} + + )} + + ) : ( + // 只显示第一个地址的原因:refused=代理没开,timeout=出不了网。 + + {c.result.probes?.find((p) => p.error)?.error ?? c.result.error} + + )} +
+ ); + })} + {egressReport.warnings?.map((w) => ( +

+ ⚠ {w} +

+ ))} +
+ )} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index e4d7428..9dc4700 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -33,6 +33,7 @@ import type { DeleteTaskOptions, DeleteTaskResult, Edge, + EgressReport, EvidenceBodyPreview, ExplorationNodePage, ExplorationNodeQuery, @@ -952,6 +953,10 @@ export const api = { tavily_search_api_key?: string; }) => post<{ ok: boolean; error?: string; count?: number; backend?: string }>(`/settings/web-search/test`, patch), + // global_proxy 传空 = 测直连;probe_url 可覆盖默认探测地址(隔离网用)。 + testEgressProxy: (patch: { global_proxy?: string; probe_url?: string }) => + post(`/settings/proxy/test`, patch), + // ---- 漏洞 IM 推送 ---- // 渠道是多实例资源(同一类型可配多个机器人、各有过滤规则),因此独立成组, // 不塞进扁平的 settings 键值里。 diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index be5a730..4bf2605 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -1860,3 +1860,41 @@ export interface IntelligencePage { counts: Record; enabled: boolean; } + +// ---- 出口代理连通性测试(POST /api/settings/proxy/test)---- +export interface EgressProbeResult { + ok: boolean; + exit_ip?: string; + via?: "direct" | "http" | "https" | "socks5"; + via_label?: string; + latency_ms: number; + error?: string; + // 每个地址的明细:区分"连不上代理"和"代理通但出不了网"。 + probes: { + url: string; + ok: boolean; + ip?: string; + latency_ms: number; + error?: string; + }[]; +} + +// 报告里的一行:① 直连 / ② 经全局代理。 +export interface EgressCheck { + key: "direct" | "global"; + label: string; + path?: string; + skipped?: boolean; + skip_reason?: string; + result: EgressProbeResult; +} + +export interface EgressReport { + ok: boolean; + capture: boolean; + configured: string; + // agent_proxy 是 Agent 实际使用的代理地址:捕获开时是流量捕获的地址,不是输入框里的值。 + agent_proxy: string; + checks: EgressCheck[]; + warnings?: string[]; +} From 251dccb921b43a3db8a912870fe8a80917ed6b60 Mon Sep 17 00:00:00 2001 From: RuoJi6 <79234113+RuoJi6@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:29:37 +0800 Subject: [PATCH 2/3] feat(web): add global egress IP top banner --- .../(main)/_components/egress-ip-banner.tsx | 179 ++++++++++++++++++ web/src/app/(main)/layout.tsx | 46 +++-- web/src/app/(main)/system/settings/page.tsx | 37 +++- 3 files changed, 239 insertions(+), 23 deletions(-) create mode 100644 web/src/app/(main)/_components/egress-ip-banner.tsx diff --git a/web/src/app/(main)/_components/egress-ip-banner.tsx b/web/src/app/(main)/_components/egress-ip-banner.tsx new file mode 100644 index 0000000..485c657 --- /dev/null +++ b/web/src/app/(main)/_components/egress-ip-banner.tsx @@ -0,0 +1,179 @@ +"use client"; + +import * as React from "react"; + +import { Globe2Icon, Loader2Icon, 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; + isLoading: boolean; + report: EgressReport | null; + error: string | null; + dismissed: boolean; + dismiss: () => void; +}; + +const EgressIpBannerContext = React.createContext(null); + +function readDismissed() { + if (typeof window === "undefined") return false; + try { + return window.sessionStorage.getItem(DISMISSED_STORAGE_KEY) === "true"; + } catch { + return false; + } +} + +function setDismissedStorage(dismissed: boolean) { + try { + if (dismissed) window.sessionStorage.setItem(DISMISSED_STORAGE_KEY, "true"); + else window.sessionStorage.removeItem(DISMISSED_STORAGE_KEY); + } catch { + // sessionStorage can be unavailable in privacy-restricted browsers. + } +} + +function readStoredReport(): EgressReport | null { + if (typeof window === "undefined") return null; + try { + const raw = window.sessionStorage.getItem(REPORT_STORAGE_KEY); + if (!raw) return null; + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || !Array.isArray((parsed as { checks?: unknown }).checks)) { + return null; + } + return parsed as EgressReport; + } catch { + return null; + } +} + +function setStoredReport(report: EgressReport | null) { + try { + if (report) window.sessionStorage.setItem(REPORT_STORAGE_KEY, JSON.stringify(report)); + else window.sessionStorage.removeItem(REPORT_STORAGE_KEY); + } catch { + // sessionStorage can be unavailable in privacy-restricted browsers. + } +} + +export function EgressIpBannerProvider({ children }: { children: React.ReactNode }) { + const [report, setReport] = React.useState(readStoredReport); + const [error, setError] = React.useState(null); + const [isLoading, setIsLoading] = React.useState(false); + const [dismissed, setDismissed] = React.useState(readDismissed); + + const probeEgress = React.useCallback(async ({ globalProxy, probeUrl }: ProbeOptions) => { + setDismissed(false); + setDismissedStorage(false); + setError(null); + setIsLoading(true); + try { + const nextReport = await api.testEgressProxy({ + global_proxy: globalProxy, + ...(probeUrl ? { probe_url: probeUrl } : {}), + }); + setReport(nextReport); + setStoredReport(nextReport); + return nextReport; + } catch (cause) { + setReport(null); + setStoredReport(null); + const message = cause instanceof Error ? cause.message : "未知错误"; + setError(message); + throw cause; + } finally { + setIsLoading(false); + } + }, []); + + const dismiss = React.useCallback(() => { + setDismissed(true); + setDismissedStorage(true); + }, []); + + const value = React.useMemo( + () => ({ probeEgress, isLoading, report, error, dismissed, dismiss }), + [dismiss, dismissed, error, isLoading, probeEgress, report], + ); + + return {children}; +} + +export function useEgressIpBanner() { + const context = React.useContext(EgressIpBannerContext); + if (!context) throw new Error("useEgressIpBanner must be used within EgressIpBannerProvider"); + return context; +} + +function checkMessage(check: EgressCheck | undefined) { + if (!check) return "未返回结果"; + if (check.skipped) return "未配置 / 已跳过"; + if (check.result.ok) return check.result.exit_ip || "已连通,但未返回 IP"; + return `探测失败:${check.result.probes.find((probe) => probe.error)?.error || check.result.error || "未知错误"}`; +} + +function IpRow({ label, check }: { label: string; check: EgressCheck | undefined }) { + const failed = !!check && !check.skipped && !check.result.ok; + return ( +
+ {label} + {checkMessage(check)} +
+ ); +} + +export function EgressIpBanner() { + const { report, error, isLoading, dismissed, dismiss } = useEgressIpBanner(); + if (dismissed) return null; + if (!isLoading && !report && !error) return null; + + const direct = report?.checks.find((check) => check.key === "direct"); + const global = report?.checks.find((check) => check.key === "global"); + let statusContent: React.ReactNode; + if (isLoading) { + statusContent = ( +
+
+ ); + } else if (error) { + statusContent =
探测失败:{error}
; + } else { + statusContent = ( +
+ + +
+ ); + } + + return ( +
+
+
+
+ ); +} diff --git a/web/src/app/(main)/layout.tsx b/web/src/app/(main)/layout.tsx index 1022dfc..6b803d1 100644 --- a/web/src/app/(main)/layout.tsx +++ b/web/src/app/(main)/layout.tsx @@ -16,6 +16,7 @@ import { import { PREFERENCE_DEFAULTS } from "@/lib/preferences/preferences-config"; import { cn } from "@/lib/utils"; +import { EgressIpBanner, EgressIpBannerProvider } from "./_components/egress-ip-banner"; import { MainContent } from "./_components/main-content"; // Reads a layout-critical preference from the browser cookie (falls back to the @@ -60,27 +61,30 @@ export default function Layout({ children }: Readonly<{ children: ReactNode }>) if (!authed) return null; return ( - - - *]:mx-auto", - "[html[data-content-layout=centered]_&>*]:w-full", - "[html[data-content-layout=centered]_&>*]:max-w-screen-2xl", - "peer-data-[variant=inset]:border", - "[--dashboard-header-height:--spacing(12)]", - "min-w-0 overflow-x-hidden", - )} + + - {children} - - + + *]:mx-auto", + "[html[data-content-layout=centered]_&>*]:w-full", + "[html[data-content-layout=centered]_&>*]:max-w-screen-2xl", + "peer-data-[variant=inset]:border", + "[--dashboard-header-height:--spacing(12)]", + "min-w-0 overflow-x-hidden", + )} + > + + {children} + + + ); } diff --git a/web/src/app/(main)/system/settings/page.tsx b/web/src/app/(main)/system/settings/page.tsx index e4646f3..a2e2ac0 100644 --- a/web/src/app/(main)/system/settings/page.tsx +++ b/web/src/app/(main)/system/settings/page.tsx @@ -16,6 +16,7 @@ import { api } from "@/lib/api"; import { CHAT_SEND_MODE_OPTIONS, type ChatSendMode, setChatSendMode, useChatSendMode } from "@/lib/chat-send-mode"; import type { EgressReport, LLMProfile, Settings, WorkerPool } from "@/lib/types"; +import { useEgressIpBanner } from "../../_components/egress-ip-banner"; import { BasicAuthCard } from "./_components/basic-auth-card"; import { UpdateCard } from "./_components/update-card"; @@ -38,6 +39,7 @@ export default function SystemSettingsPage() { const [savingProxy, setSavingProxy] = React.useState(false); const [globalProxyInput, setGlobalProxyInput] = React.useState(""); const [savingGlobalProxy, setSavingGlobalProxy] = React.useState(false); + const [savedGlobalProxy, setSavedGlobalProxy] = React.useState(""); // 出口探测:测一次真实请求,拿到"直连 / 经全局代理"两条出口 IP。 const [egressTesting, setEgressTesting] = React.useState(false); const [egressReport, setEgressReport] = React.useState(null); @@ -60,6 +62,7 @@ export default function SystemSettingsPage() { const [noaCompaction, setNoaCompaction] = React.useState(false); // 纯前端偏好:不走 /api/settings,直接读写 localStorage。 const sendMode = useChatSendMode(); + const { probeEgress: probeEgressForBanner, isLoading: egressBannerLoading } = useEgressIpBanner(); const apply = React.useCallback((s: Settings) => { setCredentialAgent(!!s.credential_agent_enabled); @@ -72,7 +75,9 @@ export default function SystemSettingsPage() { setBraveKeySet(!!s.brave_key_set); setTavilyKeySet(!!s.tavily_key_set); setProxyInput(s.web_search_proxy ?? ""); - setGlobalProxyInput(s.global_proxy ?? ""); + const savedProxy = s.global_proxy ?? ""; + setGlobalProxyInput(savedProxy); + setSavedGlobalProxy(savedProxy); setPyInterp(s.python_interpreter ?? ""); setWorkers(String(s.workers ?? 3)); setWorkerPools(s.worker_pools ?? []); @@ -335,6 +340,25 @@ export default function SystemSettingsPage() { .finally(() => setEgressTesting(false)); }; + // 发布到站点顶栏时始终使用已保存的 global_proxy,避免把输入框里的草稿 + // 当成 Agent 当前实际使用的代理。 + const showEgressIp = async () => { + if (!settingsLoaded) { + toast.error("设置尚未加载完成,请稍后再试"); + return; + } + try { + const rep = await probeEgressForBanner({ + globalProxy: savedGlobalProxy.trim(), + ...(egressProbeUrl.trim() ? { probeUrl: egressProbeUrl.trim() } : {}), + }); + if (rep.ok) toast.success("已在顶栏显示出口 IP"); + else toast.error("出口探测完成,但部分结果不可用"); + } catch (e) { + toast.error("出口探测失败:" + (e as Error).message); + } + }; + // Run a real "test" search ("test") against the CURRENT form values (backend + // proxy + entered key), falling back to saved values server-side. Toasts result. const runTest = () => { @@ -463,9 +487,10 @@ export default function SystemSettingsPage() { -
+
{egressTesting ? "测试中…" : "测试"} +

{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; +type EgressIpContextValue = { + enabled: boolean; isLoading: boolean; report: EgressReport | null; error: string | null; - dismissed: boolean; - dismiss: () => void; + setEnabled: (enabled: boolean) => Promise; + probeEgress: () => Promise; + dismiss: () => Promise; }; -const EgressIpBannerContext = React.createContext(null); +const EgressIpContext = React.createContext(null); -function readDismissed() { - if (typeof window === "undefined") return false; - try { - return window.sessionStorage.getItem(DISMISSED_STORAGE_KEY) === "true"; - } catch { - return false; - } -} - -function setDismissedStorage(dismissed: boolean) { - try { - if (dismissed) window.sessionStorage.setItem(DISMISSED_STORAGE_KEY, "true"); - else window.sessionStorage.removeItem(DISMISSED_STORAGE_KEY); - } catch { - // sessionStorage can be unavailable in privacy-restricted browsers. - } -} - -function readStoredReport(): EgressReport | null { - if (typeof window === "undefined") return null; - try { - const raw = window.sessionStorage.getItem(REPORT_STORAGE_KEY); - if (!raw) return null; - const parsed: unknown = JSON.parse(raw); - if (!parsed || typeof parsed !== "object" || !Array.isArray((parsed as { checks?: unknown }).checks)) { - return null; - } - return parsed as EgressReport; - } catch { - return null; - } -} - -function setStoredReport(report: EgressReport | null) { - try { - if (report) window.sessionStorage.setItem(REPORT_STORAGE_KEY, JSON.stringify(report)); - else window.sessionStorage.removeItem(REPORT_STORAGE_KEY); - } catch { - // sessionStorage can be unavailable in privacy-restricted browsers. - } -} - -export function EgressIpBannerProvider({ children }: { children: React.ReactNode }) { - const [report, setReport] = React.useState(readStoredReport); +export function EgressIpProvider({ children }: { children: React.ReactNode }) { + const [enabled, setEnabledState] = React.useState(false); + const [report, setReport] = React.useState(null); const [error, setError] = React.useState(null); const [isLoading, setIsLoading] = React.useState(false); - const [dismissed, setDismissed] = React.useState(readDismissed); - const probeEgress = React.useCallback(async ({ globalProxy, probeUrl }: ProbeOptions) => { - setDismissed(false); - setDismissedStorage(false); + const probeForProxy = React.useCallback(async (globalProxy: string) => { setError(null); setIsLoading(true); try { - const nextReport = await api.testEgressProxy({ - global_proxy: globalProxy, - ...(probeUrl ? { probe_url: probeUrl } : {}), - }); + const nextReport = await api.testEgressProxy({ global_proxy: globalProxy }); setReport(nextReport); - setStoredReport(nextReport); return nextReport; } catch (cause) { setReport(null); - setStoredReport(null); const message = cause instanceof Error ? cause.message : "未知错误"; setError(message); throw cause; @@ -100,80 +44,117 @@ export function EgressIpBannerProvider({ children }: { children: React.ReactNode } }, []); - const dismiss = React.useCallback(() => { - setDismissed(true); - setDismissedStorage(true); - }, []); + React.useEffect(() => { + let cancelled = false; + api + .settings() + .then((settings) => { + if (cancelled) return; + const nextEnabled = !!settings.show_egress_ip; + const nextProxy = settings.global_proxy ?? ""; + setEnabledState(nextEnabled); + if (nextEnabled) void probeForProxy(nextProxy); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [probeForProxy]); - const value = React.useMemo( - () => ({ probeEgress, isLoading, report, error, dismissed, dismiss }), - [dismiss, dismissed, error, isLoading, probeEgress, report], + const setEnabled = React.useCallback( + async (nextEnabled: boolean) => { + const settings = await api.setSettings({ show_egress_ip: nextEnabled }); + const nextProxy = settings.global_proxy ?? ""; + setEnabledState(nextEnabled); + if (nextEnabled) { + await probeForProxy(nextProxy); + } else { + setReport(null); + setError(null); + } + }, + [probeForProxy], ); - return {children}; + // Refresh from settings first so a proxy saved on the settings page is always + // the one used for the navbar probe. + const probeEgress = React.useCallback(async () => { + const settings = await api.settings(); + const nextProxy = settings.global_proxy ?? ""; + return probeForProxy(nextProxy); + }, [probeForProxy]); + + const dismiss = React.useCallback(() => setEnabled(false), [setEnabled]); + + const value = React.useMemo( + () => ({ enabled, isLoading, report, error, setEnabled, probeEgress, dismiss }), + [dismiss, enabled, error, isLoading, probeEgress, report, setEnabled], + ); + + return {children}; } -export function useEgressIpBanner() { - const context = React.useContext(EgressIpBannerContext); - if (!context) throw new Error("useEgressIpBanner must be used within EgressIpBannerProvider"); +export function useEgressIp() { + const context = React.useContext(EgressIpContext); + if (!context) throw new Error("useEgressIp must be used within EgressIpProvider"); return context; } function checkMessage(check: EgressCheck | undefined) { if (!check) return "未返回结果"; - if (check.skipped) return "未配置 / 已跳过"; - if (check.result.ok) return check.result.exit_ip || "已连通,但未返回 IP"; - return `探测失败:${check.result.probes.find((probe) => probe.error)?.error || check.result.error || "未知错误"}`; + if (check.skipped) return "未配置"; + if (check.result.ok) return check.result.exit_ip || "已连通"; + return "探测失败"; } -function IpRow({ label, check }: { label: string; check: EgressCheck | undefined }) { +function IpValue({ label, check }: { label: string; check: EgressCheck | undefined }) { const failed = !!check && !check.skipped && !check.result.ok; return ( -

- {label} - {checkMessage(check)} -
+ + {label} {"· "} + {checkMessage(check)} + ); } -export function EgressIpBanner() { - const { report, error, isLoading, dismissed, dismiss } = useEgressIpBanner(); - if (dismissed) return null; - if (!isLoading && !report && !error) return null; +export function EgressIpNavbar() { + const { enabled, report, error, isLoading, probeEgress, dismiss } = useEgressIp(); + if (!enabled) return null; const direct = report?.checks.find((check) => check.key === "direct"); const global = report?.checks.find((check) => check.key === "global"); - let statusContent: React.ReactNode; - if (isLoading) { - statusContent = ( -
-
- ); - } else if (error) { - statusContent =
探测失败:{error}
; - } else { - statusContent = ( -
- - -
- ); - } - return ( -
-
-