mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-28 01:03:50 +08:00
feat: 全局代理支持连通性测试与出口 IP 显示
This commit is contained in:
1 parent
e740334b90
commit
5feddf296d
6 files changed
+967
-5
No files matched your search
@@ -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()
|
||||
}
|
||||
@@ -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("<html><body>access denied</body></html>"))
|
||||
}))
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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<EgressReport | null>(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() {
|
||||
开启<b>流量捕获</b>时,它作为记录代理的<b>上游</b>(流量仍全量落库,再经此代理出网);关闭捕获时,直接注入
|
||||
Agent 的 bash / WebFetch 出网。与网络搜索代理、LLM 代理相互独立。
|
||||
<br />
|
||||
<b>提示</b>:socks5 在<b>关闭捕获</b>时依赖各命令行工具对 <code>ALL_PROXY</code> 的支持(curl
|
||||
可用,部分工具可能忽略); 若主要用 socks5,建议开启流量捕获——此路径由 MITM
|
||||
<b>提示</b>:socks5 在<b>关闭捕获</b>时依赖各命令行工具对 <code>ALL_PROXY</code> 的支持——curl
|
||||
可用,python urllib 等会直接报错; 若主要用 socks5,建议开启流量捕获——此路径由 MITM
|
||||
亲自拨号,工具无感知、稳定生效。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
<Button type="button" onClick={saveGlobalProxy} disabled={!loaded || savingGlobalProxy}>
|
||||
保存
|
||||
</Button>
|
||||
{/* 测表单里当前的值,不要求先保存。 */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={testEgress}
|
||||
disabled={!loaded || egressTesting}
|
||||
>
|
||||
{egressTesting ? "测试中…" : "测试"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{globalProxyInput.trim() ? "已配置 · 所有目标流量经此代理出网" : "未配置 · 目标流量直连出网"}
|
||||
</p>
|
||||
{/* 紧贴结果区:用户看两行时正好需要它。细节放文档。 */}
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
点<b>测试</b>会访问 IP 回显服务(默认 <code>api.ipify.org</code>,失败自动换{" "}
|
||||
<code>ifconfig.me</code>),它返回"这个请求从哪个 IP 来"。
|
||||
① 是不走代理时的 IP,② 是经全局代理出网时的 IP。改完不用先保存,直接测。
|
||||
</p>
|
||||
{/* 内网里公网回显不可达,两行都会失败——不是代理坏了。 */}
|
||||
<details className="text-xs">
|
||||
<summary className="text-muted-foreground cursor-pointer select-none">
|
||||
自定义 IP 探测服务(可选)
|
||||
</summary>
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
<Input
|
||||
autoComplete="off"
|
||||
placeholder="留空 = 使用默认的公共 IP 回显服务;内网可填 http://<内网IP>:<端口>/"
|
||||
value={egressProbeUrl}
|
||||
onChange={(e) => {
|
||||
setEgressProbeUrl(e.target.value);
|
||||
setEgressReport(null);
|
||||
}}
|
||||
/>
|
||||
<span className="text-muted-foreground leading-relaxed">
|
||||
自定义 IP 探测服务需返回纯文本 IP 或{" "}
|
||||
<code>{'{"ip":"1.2.3.4"}'}</code> 格式的 JSON。
|
||||
若内网环境下无法访问公网,或本机和内网代理共用同一个公网出口,
|
||||
则可替换为内网 IP 探测服务,此时 ① 和 ② 的 IP 不同,就说明流量走了内网代理。
|
||||
</span>
|
||||
</div>
|
||||
</details>
|
||||
{egressReport && (
|
||||
<div className="flex flex-col gap-1 text-xs">
|
||||
{egressReport.checks.map((c) => {
|
||||
// 经 MITM 时 via 是 HTTP(MITM 自己是代理),标出来会让人以为填错了。
|
||||
const viaCapture = !!c.path && c.path !== egressReport.configured;
|
||||
return (
|
||||
<div key={c.key} className="flex flex-wrap items-center gap-x-2">
|
||||
<span
|
||||
className={
|
||||
c.skipped
|
||||
? "text-muted-foreground"
|
||||
: c.result.ok
|
||||
? "text-emerald-600"
|
||||
: "text-destructive"
|
||||
}
|
||||
>
|
||||
{c.skipped ? "—" : c.result.ok ? "✓" : "✗"}
|
||||
</span>
|
||||
|
||||
<span
|
||||
className="w-56 shrink-0"
|
||||
title={
|
||||
c.key === "direct"
|
||||
? "不经过任何代理,用来和 ② 对比"
|
||||
: viaCapture
|
||||
? `实际探测地址:${c.path}(请求经流量捕获转发)`
|
||||
: "经这个代理出去,不需要先保存"
|
||||
}
|
||||
>
|
||||
{c.label}
|
||||
</span>
|
||||
{c.skipped ? (
|
||||
<span className="text-muted-foreground">{c.skip_reason}</span>
|
||||
) : c.result.ok ? (
|
||||
<span className="font-mono">
|
||||
<span title="外界看到的出口 IP(探测站点回显的客户端地址)">
|
||||
{c.result.exit_ip}
|
||||
</span>{" "}
|
||||
·{" "}
|
||||
<span title="从 ARTEX 发出请求到拿到响应的完整往返耗时;经代理时包含代理那一跳">
|
||||
{c.result.latency_ms}ms
|
||||
</span>
|
||||
{c.result.via && c.result.via !== "direct" && !viaCapture && (
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
· 经 {c.result.via_label ?? c.result.via}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
// 只显示第一个地址的原因:refused=代理没开,timeout=出不了网。
|
||||
<span className="text-destructive">
|
||||
{c.result.probes?.find((p) => p.error)?.error ?? c.result.error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{egressReport.warnings?.map((w) => (
|
||||
<p key={w} className="text-amber-700">
|
||||
⚠ {w}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -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<EgressReport>(`/settings/proxy/test`, patch),
|
||||
|
||||
// ---- 漏洞 IM 推送 ----
|
||||
// 渠道是多实例资源(同一类型可配多个机器人、各有过滤规则),因此独立成组,
|
||||
// 不塞进扁平的 settings 键值里。
|
||||
|
||||
@@ -1860,3 +1860,41 @@ export interface IntelligencePage {
|
||||
counts: Record<IntelligenceKind, number>;
|
||||
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[];
|
||||
}
|
||||
Reference in new issue
Block a user