Files
2026-07-26 09:39:17 +00:00

458 lines
14 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 netdiag
// This file answers one question: can traffic from this machine reach the
// wider internet, and does the answer change depending on how it leaves?
//
// Every probe is run twice-ish over deliberately different paths — forced
// IPv4, forced IPv6, and through whatever HTTP proxy the environment
// advertises. The divergence between those paths is the signal: a user running
// a proxy tool wants to see that the direct path is dead and the proxied one
// works (or the reverse), not have the two averaged into one green tick.
//
// The mainland-China targets are baselines. They separate "this machine has no
// internet at all" from "this machine has internet but cannot leave the
// country", which are two completely different things to fix.
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"sort"
"strings"
"sync"
"time"
)
// diagUserAgent identifies our probes to the servers we poke. Some captive
// portals and CDNs behave differently for an empty UA, and an honest one makes
// the traffic recognisable in a packet capture.
const diagUserAgent = "tslink-netdiag/1.0"
// diagMaxRedirects is the hard cap on redirects followed by any diagnostic
// client. A redirect chain is usually a portal bouncing us around; three hops
// is enough to land on it and few enough to stay inside the probe timeout.
const diagMaxRedirects = 3
const (
// rchTimeout bounds a single reachability probe end to end.
rchTimeout = 5 * time.Second
// rchMaxInflight bounds concurrent reachability probes.
rchMaxInflight = 6
// rchMaxBody caps how much of a response body we read. The targets answer
// 204 with no body at all; the cap only exists so a hijacking portal
// serving a huge page cannot stall the probe.
rchMaxBody = 64 << 10
)
func rchLog(logger *slog.Logger) *slog.Logger {
if logger == nil {
logger = slog.Default()
}
return logger.With(slog.String("from", "netdiag/reach"))
}
// ---------------------------------------------------------------------------
// Shared HTTP plumbing (used by reach.go, egress.go and geo.go)
// ---------------------------------------------------------------------------
// uaTransport stamps [diagUserAgent] onto every request that does not already
// carry one. RoundTrippers must not mutate the request they are handed, so the
// request is cloned first.
type uaTransport struct {
base http.RoundTripper
}
func (t uaTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if req.Header.Get("User-Agent") != "" {
return t.base.RoundTrip(req)
}
clone := req.Clone(req.Context())
clone.Header.Set("User-Agent", diagUserAgent)
return t.base.RoundTrip(clone)
}
// newDiagClient builds a single-use HTTP client for one diagnostic probe.
//
// network forces the dial family: "tcp4", "tcp6", or "" to let the resolver
// and the kernel pick. Forcing the family is what makes an IPv4-only failure
// distinguishable from an IPv6-only one.
//
// useProxy selects [http.ProxyFromEnvironment] when true and no proxy at all
// when false. The false case is an explicit bypass, not a default: running the
// same target both ways is how proxy interference becomes visible.
//
// timeout bounds the whole request, including dial, TLS handshake and body
// read. Redirects are capped at [diagMaxRedirects] and the User-Agent is set
// to [diagUserAgent].
//
// The client keeps no idle connections; callers may still call
// CloseIdleConnections when they are done with it.
func newDiagClient(network string, useProxy bool, timeout time.Duration) *http.Client {
dialer := &net.Dialer{Timeout: timeout}
var proxy func(*http.Request) (*url.URL, error)
if useProxy {
proxy = http.ProxyFromEnvironment
}
tr := &http.Transport{
Proxy: proxy,
DialContext: func(ctx context.Context, defaultNetwork, addr string) (net.Conn, error) {
if network != "" {
defaultNetwork = network
}
return dialer.DialContext(ctx, defaultNetwork, addr)
},
DisableKeepAlives: true,
ForceAttemptHTTP2: true,
TLSHandshakeTimeout: timeout,
ResponseHeaderTimeout: timeout,
ExpectContinueTimeout: time.Second,
}
return &http.Client{
Transport: uaTransport{base: tr},
Timeout: timeout,
CheckRedirect: func(_ *http.Request, via []*http.Request) error {
if len(via) >= diagMaxRedirects {
return fmt.Errorf("stopped after %d redirects", diagMaxRedirects)
}
return nil
},
}
}
// diagGet performs one GET and returns the status code, at most maxBody bytes
// of the body, and the time to a complete response. ctx must already carry the
// caller's deadline; nothing here blocks past it.
func diagGet(ctx context.Context, client *http.Client, target string, maxBody int64, header http.Header) (int, []byte, time.Duration, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return 0, nil, 0, err
}
for k, vs := range header {
for _, v := range vs {
req.Header.Add(k, v)
}
}
start := time.Now()
resp, err := client.Do(req)
if err != nil {
return 0, nil, time.Since(start), err
}
defer resp.Body.Close()
body, readErr := io.ReadAll(io.LimitReader(resp.Body, maxBody))
rtt := time.Since(start)
if readErr != nil && !errors.Is(readErr, io.EOF) {
return resp.StatusCode, body, rtt, readErr
}
return resp.StatusCode, body, rtt, nil
}
// diagProxyConfigured reports whether the environment advertises a proxy for
// target. Only the boolean is ever surfaced: a proxy URL may embed credentials
// and must never reach a log line or a report field.
func diagProxyConfigured(target string) bool {
u, err := url.Parse(target)
if err != nil {
return false
}
req := &http.Request{URL: u, Header: http.Header{}}
p, err := http.ProxyFromEnvironment(req)
return err == nil && p != nil
}
// ---------------------------------------------------------------------------
// Overseas reachability
// ---------------------------------------------------------------------------
// rchTarget is one reachability probe definition.
type rchTarget struct {
name string
url string
region Region
network string // "tcp4", "tcp6" or "" for unforced
viaProxy bool
want int // expected status code
}
// rchTargets is the probe list. Cloudflare's generate_204 is hit four ways
// because the paths fail independently.
//
// The direct and proxied Cloudflare probes deliberately use the same scheme:
// the point of running one target both ways is that the proxy setting is the
// only variable. Comparing plaintext-direct against TLS-proxied would let a
// middlebox that hijacks HTTP but passes HTTPS masquerade as "only the proxy
// works". The plaintext probe is kept separately, because that is exactly the
// signal a captive portal produces.
//
// The last two entries are mainland-China baselines.
func rchTargets() []rchTarget {
return []rchTarget{
{name: "Cloudflare 204 (IPv4)", url: "https://cp.cloudflare.com/generate_204", region: RegionIntl, network: "tcp4", want: http.StatusNoContent},
{name: "Cloudflare 204 (IPv6)", url: "https://cp.cloudflare.com/generate_204", region: RegionIntl, network: "tcp6", want: http.StatusNoContent},
{name: "Cloudflare 204 (代理)", url: "https://cp.cloudflare.com/generate_204", region: RegionIntl, viaProxy: true, want: http.StatusNoContent},
{name: "Cloudflare 204 (明文/门户检测)", url: "http://cp.cloudflare.com/generate_204", region: RegionIntl, network: "tcp4", want: http.StatusNoContent},
{name: "Gstatic 204", url: "http://www.gstatic.com/generate_204", region: RegionIntl, want: http.StatusNoContent},
{name: "Google 204", url: "https://www.google.com/generate_204", region: RegionIntl, want: http.StatusNoContent},
{name: "小米 204(国内基准)", url: "http://connect.rom.miui.com/generate_204", region: RegionCN, want: http.StatusNoContent},
{name: "百度(国内基准)", url: "https://www.baidu.com", region: RegionCN, want: http.StatusOK},
}
}
// ProbeOverseas checks whether traffic can leave for the wider internet.
//
// Every target is probed concurrently with its own ~5s budget, so the whole
// section finishes in about that time no matter how many probes hang. Probes
// against mainland-China targets act as a baseline: when they succeed and the
// international ones do not, the line is up but egress is filtered, which is a
// warning rather than a failure.
//
// A response that arrives with an unexpected status is recorded, not
// discarded — a captive portal or an injected block page is precisely what the
// user needs to see.
func ProbeOverseas(ctx context.Context, logger *slog.Logger) OverseasReport {
log := rchLog(logger)
targets := rchTargets()
var (
mu sync.Mutex
probes = make([]ReachProbe, 0, len(targets))
wg sync.WaitGroup
sem = make(chan struct{}, rchMaxInflight)
)
for _, t := range targets {
wg.Add(1)
go func(t rchTarget) {
defer wg.Done()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
mu.Lock()
probes = append(probes, rchCancelled(t, ctx.Err()))
mu.Unlock()
return
}
p := rchProbeOne(ctx, t, log)
mu.Lock()
probes = append(probes, p)
mu.Unlock()
}(t)
}
wg.Wait()
rchSortProbes(probes)
rep := OverseasReport{Probes: probes}
rchSummarize(&rep)
log.With(
slog.Int("probes", len(rep.Probes)),
slog.String("status", rep.Status.String()),
).Debug("finished overseas reachability probes")
return rep
}
// rchCancelled builds the placeholder entry for a probe that never started
// because the run was cancelled. The row still renders, which is better than a
// silently shorter table.
func rchCancelled(t rchTarget, err error) ReachProbe {
msg := "cancelled"
if err != nil {
msg = err.Error()
}
return ReachProbe{
Name: t.name,
URL: t.url,
Region: t.region,
ViaProxy: t.viaProxy,
Network: t.network,
Err: msg,
}
}
// rchProbeOne runs a single probe. It never returns an error: a failure is a
// datapoint, recorded in the probe's Err field.
func rchProbeOne(ctx context.Context, t rchTarget, log *slog.Logger) ReachProbe {
// ViaProxy must record what happened, not what was intended. A client
// built with http.ProxyFromEnvironment sends the request direct when no
// proxy is configured, and counting that as proof the proxy path works is
// how the summary ends up asserting "only the proxy link is usable" on a
// machine with no proxy at all.
usedProxy := t.viaProxy && diagProxyConfigured(t.url)
p := ReachProbe{
Name: t.name,
URL: t.url,
Region: t.region,
ViaProxy: usedProxy,
Network: t.network,
}
if t.viaProxy && !usedProxy {
p.Name = t.name + "(环境未配置代理,实际直连)"
}
pctx, cancel := context.WithTimeout(ctx, rchTimeout)
defer cancel()
client := newDiagClient(t.network, usedProxy, rchTimeout)
defer client.CloseIdleConnections()
code, _, rtt, err := diagGet(pctx, client, t.url, rchMaxBody, nil)
p.RTT = rtt
p.StatusCode = code
switch {
case err != nil:
p.Err = rchErrText(err)
case code == t.want:
p.OK = true
default:
// Reachable, but something answered on the target's behalf.
p.Err = fmt.Sprintf("unexpected status %d (want %d), 可能存在门户劫持或内容注入", code, t.want)
}
log.With(
slog.String("name", t.name),
slog.String("network", rchNetworkText(t.network)),
slog.Bool("via_proxy", t.viaProxy),
slog.Int("status", p.StatusCode),
slog.Duration("rtt", p.RTT),
slog.Bool("ok", p.OK),
).Debug("reachability probe done")
return p
}
// rchErrText flattens a transport error into a short message. The URL is
// stripped because url.Error embeds the full target (and, for a proxied
// request, potentially proxy credentials) into its Error string.
func rchErrText(err error) string {
var ue *url.Error
if errors.As(err, &ue) && ue.Err != nil {
err = ue.Err
}
msg := err.Error()
switch {
case errors.Is(err, context.DeadlineExceeded):
return "timeout"
case errors.Is(err, context.Canceled):
return "cancelled"
}
if i := strings.IndexByte(msg, '\n'); i >= 0 {
msg = msg[:i]
}
return msg
}
func rchNetworkText(n string) string {
if n == "" {
return "auto"
}
return n
}
// rchSortProbes orders international probes before the CN baselines and is
// otherwise stable on URL/network/proxy, so consecutive refreshes render in
// exactly the same order.
func rchSortProbes(ps []ReachProbe) {
rank := func(r Region) int {
if r == RegionIntl {
return 0
}
return 1
}
sort.Slice(ps, func(i, j int) bool {
x, y := ps[i], ps[j]
if rank(x.Region) != rank(y.Region) {
return rank(x.Region) < rank(y.Region)
}
if x.URL != y.URL {
return x.URL < y.URL
}
if x.Network != y.Network {
return x.Network < y.Network
}
if x.ViaProxy != y.ViaProxy {
return !x.ViaProxy
}
return x.Name < y.Name
})
}
// rchSummarize derives Status and a one-line Chinese Summary.
//
// Any single successful international probe is enough for [StatusOK]: hosts
// without IPv6 are the norm, so a failed v6 probe alongside a working v4 one
// must not drag the verdict down. Only the CN baselines succeeding means the
// local network is fine but the wider internet is not reachable
// ([StatusWarn]); nothing succeeding at all is [StatusFail].
func rchSummarize(rep *OverseasReport) {
var (
intlOK, intlTotal int
cnOK, cnTotal int
proxyOK bool
directIntlOK bool
v6OK bool
hijacked int
)
for _, p := range rep.Probes {
if p.Region == RegionIntl {
intlTotal++
if p.OK {
intlOK++
if p.ViaProxy {
proxyOK = true
} else {
directIntlOK = true
}
if p.Network == "tcp6" {
v6OK = true
}
}
} else {
cnTotal++
if p.OK {
cnOK++
}
}
if !p.OK && p.StatusCode > 0 {
hijacked++
}
}
var b strings.Builder
switch {
case intlOK > 0:
rep.Status = StatusOK
fmt.Fprintf(&b, "境外可达(%d/%d 个境外目标成功)", intlOK, intlTotal)
switch {
case proxyOK && !directIntlOK:
b.WriteString(",仅代理链路可用,直连被阻断")
case directIntlOK && !proxyOK && diagProxyConfigured("https://cp.cloudflare.com/generate_204"):
b.WriteString(",直连可用但代理链路失败")
}
if !v6OK {
b.WriteString("IPv6 不可用(不影响判定)")
}
case cnOK > 0:
rep.Status = StatusWarn
fmt.Fprintf(&b, "境外不可达(0/%d),但本地网络正常:国内基准 %d/%d 通过,问题在跨境链路而非本机网络", intlTotal, cnOK, cnTotal)
default:
rep.Status = StatusFail
fmt.Fprintf(&b, "境内外目标均无法访问(0/%d),本机可能完全没有网络", intlTotal+cnTotal)
}
if hijacked > 0 {
fmt.Fprintf(&b, ";%d 个目标返回了非预期状态码,疑似门户或注入", hijacked)
}
rep.Summary = b.String()
}