add: gui and tsdiag

This commit is contained in:
iceBear67
2026-07-26 09:39:17 +00:00
parent 5dc1759d80
commit 5b3a7e147c
41 changed files with 16451 additions and 8 deletions
+457
View File
@@ -0,0 +1,457 @@
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()
}