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() }