Files

517 lines
18 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
"regexp"
"strings"
"syscall"
"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
}
// validateProbeURL 只放行指向"非本机、非链路本地"的绝对 http(s) 地址。
//
// 这个接口要登录,但仍要挡:agent 可能被目标页面诱导去调它,而"本机"在容器里就是 ARTEX
// 自己(管理端口、云元数据 169.254.169.254)。**私网地址是刻意放行的**——自定义探测服务
// 的主用途就是内网回显,挡掉 192.168/10/172.16 等于废掉这个功能。
func validateProbeURL(raw string) (string, error) {
raw = strings.TrimSpace(raw)
u, err := url.Parse(raw)
if err != nil {
return "", errors.New("probe_url 不是合法 URL")
}
if !strings.EqualFold(u.Scheme, "http") && !strings.EqualFold(u.Scheme, "https") {
return "", errors.New("probe_url 只支持 http / https")
}
if u.Host == "" || u.Hostname() == "" {
return "", errors.New("probe_url 缺少主机名")
}
if err := probeCheckURL(u); err != nil {
return "", err
}
return raw, nil
}
// probeCheckRedirect 逐跳校验,挡住"公网地址 302 到 127.0.0.1"这种绕法。
func probeCheckRedirect(next *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return errors.New("重定向次数过多")
}
if len(via) > 0 && next.URL.Host != via[0].URL.Host {
return fmt.Errorf("拒绝跨主机重定向(%s → %s)", via[0].URL.Host, next.URL.Host)
}
return probeCheckURL(next.URL)
}
// probeCheckURL rejects only loopback, link-local, unspecified, and multicast
// destinations. RFC1918 addresses remain valid for intranet echo services.
func probeCheckURL(u *url.URL) error {
host := strings.TrimSuffix(strings.TrimSpace(u.Hostname()), ".")
if zone := strings.IndexByte(host, '%'); zone >= 0 {
host = host[:zone]
}
ip := net.ParseIP(host)
if ip == nil && repeaterLooksNumericHost(host) {
var ok bool
ip, ok = repeaterParseLegacyIPv4(host)
if !ok {
return fmt.Errorf("拒绝无法解析的数字主机地址 %q", host)
}
}
if strings.EqualFold(host, "localhost") || (ip != nil && probeIsBlockedDialIP(ip)) {
return errors.New("拒绝访问本机/链路本地地址")
}
return nil
}
func probeIsBlockedDialIP(ip net.IP) bool {
if ip == nil {
return true
}
if v4 := ip.To4(); v4 != nil {
ip = v4
}
return ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
ip.IsInterfaceLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast()
}
// probeBlockSensitiveDial is always enabled for direct probes. Unlike the
// repeater, probe_url has no local-target escape hatch.
func probeBlockSensitiveDial(_, address string, _ syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return err
}
ip := net.ParseIP(host)
if ip == nil {
return fmt.Errorf("无法解析目标地址 %q", host)
}
if probeIsBlockedDialIP(ip) {
return fmt.Errorf("拒绝访问本机/链路本地地址 %s", ip)
}
return nil
}
// userinfoRe 匹配 URL 里的 user:pass@。
var userinfoRe = regexp.MustCompile(`://[^/@\s]+@`)
// maskSecrets 去掉 URL 里的用户名密码。它们会进 Configured / Path / probes[].error,
// 而这三处都会被界面渲染或写进日志。
func maskSecrets(s string) string { return userinfoRe.ReplaceAllString(s, "://***@") }
// 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: maskSecrets(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,
CheckRedirect: probeCheckRedirect,
}
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: maskSecrets(target), LatencyMS: time.Since(t0).Milliseconds()}
if err != nil {
a.Error = maskSecrets(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)
} else {
// 直连时在拨号阶段再查一次:挡住"域名解析后才变成 127.0.0.1"(DNS rebinding)。
// 走代理时不装——那时拨的是代理地址,用户配 127.0.0.1 的本地代理是允许的。
tr.DialContext = (&net.Dialer{Timeout: 10 * time.Second, Control: probeBlockSensitiveDial}).DialContext
}
// 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"` // 可选:覆盖默认探测地址
}
// 请求体只有一个地址,给个上限,免得被塞大 payload。
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
// 空 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 != "" {
checked, err := validateProbeURL(u)
if err != nil {
writeErr(w, 400, err.Error())
return
}
probes = []string{checked}
}
// 这三个调用决定 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()
configuredProxy := strings.TrimSpace(req.GlobalProxy)
rep := egressReport{
Capture: capture,
Configured: maskSecrets(configuredProxy),
AgentProxy: maskSecrets(agentProxy),
}
rep.Checks = append(rep.Checks, egressCheck{
Key: "direct",
Label: "① 直连 IP(不经过代理)",
Result: probeEgress(ctx, "", "", probes, 10*time.Second),
})
if configuredProxy != "" {
probeAddr, probeCA, viaMITM := egressTarget(configuredProxy, 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: maskSecrets(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
safeSavedProxy := maskSecrets(savedProxy)
// W1 agent 走的是已保存配置,不说清会被当成"测通了=配好了"。
if pending {
switch {
case rep.Configured == "":
out = append(out, "此地址尚未保存,Agent 当前仍使用 "+safeSavedProxy+"。保存后即改为直连。")
case savedProxy == "":
out = append(out, "此地址尚未保存,Agent 当前仍为直连。"+
"上方结果对应输入框中的值,保存后生效。")
default:
out = append(out, "此地址尚未保存,Agent 当前仍使用 "+safeSavedProxy+
"。上方结果对应输入框中的值,保存后生效。")
}
}
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()
}