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
+324
View File
@@ -0,0 +1,324 @@
package netdiag
// This file collects every public address the machine appears to use, from as
// many different exits as possible.
//
// The methods are not redundant. STUN rides raw UDP, so it sees the address a
// peer would see and no HTTP proxy can touch it — that makes it the ground
// truth. The HTTP echo services are queried three ways: forced IPv4 with the
// proxy bypassed, forced IPv6 with the proxy bypassed, and through whatever
// proxy the environment advertises. When those answers disagree, traffic is
// being split across paths, and the address peers will actually connect back
// to is whichever path carries the tunnel — which is exactly the surprise this
// section exists to expose.
import (
"context"
"fmt"
"log/slog"
"net/netip"
"sort"
"strings"
"sync"
"time"
)
const (
// egTimeout bounds one HTTP echo query.
egTimeout = 5 * time.Second
// egMaxInflight bounds concurrent echo queries.
egMaxInflight = 6
// egMaxBody caps the echo response read. The services answer with a bare
// IP; anything larger is a portal or an error page.
egMaxBody = 4 << 10
)
func egLog(logger *slog.Logger) *slog.Logger {
if logger == nil {
logger = slog.Default()
}
return logger.With(slog.String("from", "netdiag/egress"))
}
// egTarget is one HTTP echo service, queried over one specific path.
type egTarget struct {
method EgressMethod
url string
region Region
network string // "tcp4", "tcp6" or "" for unforced
useProxy bool
}
// egTargets lists the echo services. All of them return a bare IP address in
// the body. The CN-hosted ones (ipw.cn) are kept because they stay reachable
// when the international ones are not, and their answer is what a domestic
// peer would see.
func egTargets() []egTarget {
return []egTarget{
// Forced IPv4, proxy explicitly bypassed.
{method: MethodHTTPv4, url: "https://api.ipify.org", region: RegionIntl, network: "tcp4"},
{method: MethodHTTPv4, url: "https://icanhazip.com", region: RegionIntl, network: "tcp4"},
{method: MethodHTTPv4, url: "https://4.ipw.cn", region: RegionCN, network: "tcp4"},
{method: MethodHTTPv4, url: "https://ipinfo.io/ip", region: RegionIntl, network: "tcp4"},
// Forced IPv6, proxy explicitly bypassed.
{method: MethodHTTPv6, url: "https://api6.ipify.org", region: RegionIntl, network: "tcp6"},
{method: MethodHTTPv6, url: "https://6.ipw.cn", region: RegionCN, network: "tcp6"},
// Unforced network, honouring HTTP(S)_PROXY.
{method: MethodHTTPProxy, url: "https://api.ipify.org", region: RegionIntl, useProxy: true},
{method: MethodHTTPProxy, url: "https://4.ipw.cn", region: RegionCN, useProxy: true},
}
}
// ProbeEgress reports every public address this machine appears to use.
//
// stunResults are the already-collected STUN observations; STUN is not re-run
// here. Successful ones become [MethodSTUN] observations and serve as the
// proxy-immune reference the HTTP answers are compared against.
//
// The HTTP echo services are queried concurrently with a ~5s budget each.
// Geo and Countries are deliberately left empty; [AnnotateGeo] fills them so
// the caller can skip the third-party lookups entirely.
func ProbeEgress(ctx context.Context, stunResults []STUNResult, logger *slog.Logger) EgressReport {
log := egLog(logger)
var (
mu sync.Mutex
obs []EgressObservation
wg sync.WaitGroup
sem = make(chan struct{}, egMaxInflight)
tgts = egTargets()
)
for _, r := range stunResults {
if !r.OK {
continue
}
ip := r.Mapped.Addr().Unmap().WithZone("")
if !ip.IsValid() {
continue
}
obs = append(obs, EgressObservation{
Method: MethodSTUN,
Source: r.Server,
Region: r.Region,
IP: ip,
RTT: r.RTT,
})
}
for _, t := range tgts {
wg.Add(1)
go func(t egTarget) {
defer wg.Done()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
return
}
o := egQuery(ctx, t, log)
mu.Lock()
obs = append(obs, o)
mu.Unlock()
}(t)
}
wg.Wait()
rep := EgressReport{Observations: obs}
egSortObservations(rep.Observations)
rep.UniqueIPs = egUniqueIPs(rep.Observations)
rep.Divergent = egDivergent(rep.UniqueIPs)
egFinish(&rep)
log.With(
slog.Int("observations", len(rep.Observations)),
slog.Int("unique_ips", len(rep.UniqueIPs)),
slog.Bool("divergent", rep.Divergent),
slog.String("status", rep.Status.String()),
).Debug("finished egress probes")
return rep
}
// egQuery asks one echo service for our address. Failures are recorded in the
// observation's Err field rather than returned, so a dead service still shows
// up as a row instead of vanishing.
func egQuery(ctx context.Context, t egTarget, log *slog.Logger) EgressObservation {
o := EgressObservation{
Method: t.method,
Source: t.url,
Region: t.region,
}
// Label the row by the path actually taken. Reporting a direct request as
// MethodHTTPProxy would make the egress table claim a proxy was exercised
// when none is configured.
usedProxy := t.useProxy && diagProxyConfigured(t.url)
if t.useProxy && !usedProxy {
o.Source = t.url + "(未配置代理,实际直连)"
}
qctx, cancel := context.WithTimeout(ctx, egTimeout)
defer cancel()
client := newDiagClient(t.network, usedProxy, egTimeout)
defer client.CloseIdleConnections()
code, body, rtt, err := diagGet(qctx, client, t.url, egMaxBody, nil)
o.RTT = rtt
switch {
case err != nil:
o.Err = rchErrText(err)
case code < 200 || code > 299:
o.Err = fmt.Sprintf("unexpected status %d", code)
default:
text := strings.TrimSpace(string(body))
ip, perr := netip.ParseAddr(text)
if perr != nil {
o.Err = fmt.Sprintf("unparseable response %q", egEllipsis(text, 48))
break
}
o.IP = ip.Unmap().WithZone("")
}
log.With(
slog.String("method", string(t.method)),
slog.String("source", t.url),
slog.String("ip", o.IP.String()),
slog.Duration("rtt", o.RTT),
slog.String("error", o.Err),
).Debug("egress echo query done")
return o
}
// egEllipsis truncates s for safe inclusion in an error string, so a hijacked
// response cannot dump a whole HTML page into the UI.
func egEllipsis(s string, n int) string {
s = strings.Join(strings.Fields(s), " ")
if len(s) <= n {
return s
}
return s[:n] + "…"
}
// egSortObservations orders by method, then source, then address, so the table
// does not jitter between refreshes.
func egSortObservations(os []EgressObservation) {
sort.Slice(os, func(i, j int) bool {
x, y := os[i], os[j]
if x.Method != y.Method {
return x.Method < y.Method
}
if x.Source != y.Source {
return x.Source < y.Source
}
return x.IP.Compare(y.IP) < 0
})
}
// egUniqueIPs returns the deduplicated, sorted set of valid addresses.
func egUniqueIPs(os []EgressObservation) []netip.Addr {
seen := make(map[netip.Addr]struct{}, len(os))
var out []netip.Addr
for _, o := range os {
if !o.IP.IsValid() {
continue
}
if _, dup := seen[o.IP]; dup {
continue
}
seen[o.IP] = struct{}{}
out = append(out, o.IP)
}
sort.Slice(out, func(i, j int) bool { return out[i].Compare(out[j]) < 0 })
return out
}
// egSplitFamilies partitions addresses into IPv4 and IPv6 sets.
func egSplitFamilies(ips []netip.Addr) (v4, v6 []netip.Addr) {
for _, ip := range ips {
if ip.Is4() || ip.Is4In6() {
v4 = append(v4, ip)
} else {
v6 = append(v6, ip)
}
}
return v4, v6
}
// egDivergent reports whether the probes disagreed about our public address
// *within* an address family.
//
// A plain dual-stack host answers with one IPv4 and one IPv6 address, which is
// two distinct entries in UniqueIPs and entirely healthy. Treating that as
// disagreement would flag every dual-stack machine as proxied and bury the
// real signal — two different IPv4 addresses — in the noise.
func egDivergent(ips []netip.Addr) bool {
v4, v6 := egSplitFamilies(ips)
return len(v4) > 1 || len(v6) > 1
}
// egFinish derives Status and the one-line Chinese Summary from the collected
// addresses. It is called again by [AnnotateGeo] once geolocation is known, so
// it must stay idempotent.
func egFinish(rep *EgressReport) {
v4, v6 := egSplitFamilies(rep.UniqueIPs)
switch {
case len(rep.UniqueIPs) == 0:
rep.Status = StatusFail
case rep.Divergent:
rep.Status = StatusWarn
default:
rep.Status = StatusOK
}
var b strings.Builder
switch {
case len(rep.UniqueIPs) == 0:
b.WriteString("未能取得任何出口 IP:所有探测都失败了")
case rep.Divergent:
// Name the family that actually diverged, so a dual-stack host with a
// split IPv4 path does not read as "everything is inconsistent".
var parts []string
if len(v4) > 1 {
parts = append(parts, fmt.Sprintf("IPv4 有 %d 个(%s", len(v4), egJoinAddrs(v4, 4)))
}
if len(v6) > 1 {
parts = append(parts, fmt.Sprintf("IPv6 有 %d 个(%s", len(v6), egJoinAddrs(v6, 4)))
}
fmt.Fprintf(&b, "出口 IP 不一致:%s,代理、VPN 或多线接入正在拆分流量,对端看到的地址取决于走哪条链路",
strings.Join(parts, ""))
default:
var parts []string
if len(v4) == 1 {
parts = append(parts, "IPv4 "+v4[0].String())
}
if len(v6) == 1 {
parts = append(parts, "IPv6 "+v6[0].String())
}
fmt.Fprintf(&b, "出口 IP 唯一:%s", strings.Join(parts, ""))
}
if len(rep.Countries) > 0 {
fmt.Fprintf(&b, ",归属地 %s", strings.Join(rep.Countries, "、"))
}
rep.Summary = b.String()
}
// egJoinAddrs renders at most limit addresses for a summary line.
func egJoinAddrs(as []netip.Addr, limit int) string {
parts := make([]string, 0, limit+1)
for i, a := range as {
if i >= limit {
parts = append(parts, fmt.Sprintf("等 %d 个", len(as)))
break
}
parts = append(parts, a.String())
}
return strings.Join(parts, "、")
}
+470
View File
@@ -0,0 +1,470 @@
package netdiag
// This file resolves public IP addresses to a rough location and network
// operator.
//
// PRIVACY: every lookup here sends the user's own public IP address to a
// third-party API (ipinfo.io, ip-api.com, api.ip.sb). Those services see the
// address, the timestamp and our source IP — which for a direct query is that
// very same address. Nothing else is sent: no hostname, no tailnet identity,
// no credentials. Callers who are not comfortable with that must set
// [Options.SkipGeo], which exists precisely for this reason, and no request in
// this file will be made. The ipinfo token, when configured, is passed as a
// query parameter to that provider only and is never logged or stored in a
// report.
//
// Providers are tried in order and the first usable answer wins. The order is
// not arbitrary: ipinfo.io is the most accurate but rate-limits hard without a
// token, ip-api.com is HTTP-only on the free tier yet stays reachable from
// mainland China, and api.ip.sb is the last resort.
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/netip"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"time"
)
const (
// geoTimeout bounds one provider query.
geoTimeout = 4 * time.Second
// geoMaxBody caps a provider response. Well-behaved answers are a few
// hundred bytes; the cap guards against a hijacked or error page.
geoMaxBody = 64 << 10
// geoMaxInflight bounds concurrent lookups in [AnnotateGeo]. Kept low
// because the free tiers of these APIs rate-limit per source IP.
geoMaxInflight = 4
)
// geoCGNAT is RFC 6598 shared address space. Tailscale also allocates node
// addresses out of it, and either way no geolocation provider can say anything
// useful about such an address.
var geoCGNAT = netip.MustParsePrefix("100.64.0.0/10")
// geoSkipErr is reported for addresses that are not globally routable.
const geoSkipErr = "私有地址,跳过查询"
func geoLog(logger *slog.Logger) *slog.Logger {
if logger == nil {
logger = slog.Default()
}
return logger.With(slog.String("from", "netdiag/geo"))
}
// geoSkippable reports whether ip is not worth (or not safe to) look up:
// loopback, RFC1918/ULA private, link-local, CGNAT, multicast or unspecified.
func geoSkippable(ip netip.Addr) bool {
ip = ip.Unmap()
if !ip.IsValid() {
return true
}
if ip.Is4() && geoCGNAT.Contains(ip) {
return true
}
return ip.IsLoopback() ||
ip.IsPrivate() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsMulticast() ||
ip.IsUnspecified()
}
// geoProvider is one geolocation backend.
type geoProvider struct {
name string
// fetch fills a GeoInfo from the provider, or returns an error so the next
// provider is tried.
fetch func(ctx context.Context, ip netip.Addr, token string) (GeoInfo, error)
}
// geoProviders returns the backends in the order they are tried.
func geoProviders() []geoProvider {
return []geoProvider{
{name: "ipinfo.io", fetch: geoFetchIPInfo},
{name: "ip-api.com", fetch: geoFetchIPAPI},
{name: "ip.sb", fetch: geoFetchIPSB},
}
}
// LookupGeo resolves one address to a location and operator.
//
// Providers are tried in order until one answers; the returned GeoInfo names
// the provider that did in its Provider field. When all of them fail, Provider
// is empty and Err holds the last error. Addresses that are not globally
// routable are never sent anywhere: they come back immediately with Err set to
// "私有地址,跳过查询".
//
// token is an optional ipinfo.io API token; it raises that provider's rate
// limit and is never logged.
//
// Each provider gets its own ~4s budget, so the whole call is bounded even if
// every backend hangs. See the privacy note at the top of this file.
func LookupGeo(ctx context.Context, ip netip.Addr, token string, logger *slog.Logger) GeoInfo {
log := geoLog(logger)
ip = ip.Unmap().WithZone("")
if geoSkippable(ip) {
return GeoInfo{IP: ip, Err: geoSkipErr}
}
var lastErr string
for _, p := range geoProviders() {
if ctx.Err() != nil {
return GeoInfo{IP: ip, Err: rchErrText(ctx.Err())}
}
pctx, cancel := context.WithTimeout(ctx, geoTimeout)
info, err := p.fetch(pctx, ip, token)
cancel()
if err != nil {
lastErr = fmt.Sprintf("%s: %s", p.name, rchErrText(err))
log.With(
slog.String("ip", ip.String()),
slog.String("provider", p.name),
slog.String("error", rchErrText(err)),
).Debug("geo provider failed")
continue
}
info.IP = ip
info.Provider = p.name
log.With(
slog.String("ip", ip.String()),
slog.String("provider", p.name),
slog.String("country", info.Country),
slog.String("asn", info.ASN),
).Debug("resolved ip location")
return info
}
if lastErr == "" {
lastErr = "no geolocation provider answered"
}
return GeoInfo{IP: ip, Err: lastErr}
}
// AnnotateGeo fills rep.Geo and rep.Countries for every address in
// rep.UniqueIPs and recomputes rep.Summary. It is a no-op when the report has
// no addresses.
//
// Lookups run concurrently but at most [geoMaxInflight] at a time, since the
// free tiers rate-limit per source IP. Results are sorted by address and the
// country list is deduplicated, so repeated runs render identically.
//
// This function performs third-party network requests; see the privacy note at
// the top of this file and [Options.SkipGeo].
func AnnotateGeo(ctx context.Context, rep *EgressReport, token string, logger *slog.Logger) {
if rep == nil || len(rep.UniqueIPs) == 0 {
return
}
log := geoLog(logger)
var (
mu sync.Mutex
out = make([]GeoInfo, 0, len(rep.UniqueIPs))
wg sync.WaitGroup
sem = make(chan struct{}, geoMaxInflight)
)
for _, ip := range rep.UniqueIPs {
wg.Add(1)
go func(ip netip.Addr) {
defer wg.Done()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
mu.Lock()
out = append(out, GeoInfo{IP: ip, Err: rchErrText(ctx.Err())})
mu.Unlock()
return
}
info := LookupGeo(ctx, ip, token, log)
mu.Lock()
out = append(out, info)
mu.Unlock()
}(ip)
}
wg.Wait()
sort.Slice(out, func(i, j int) bool { return out[i].IP.Compare(out[j].IP) < 0 })
rep.Geo = out
rep.Countries = geoCountries(out)
egFinish(rep)
log.With(
slog.Int("addrs", len(rep.Geo)),
slog.String("countries", strings.Join(rep.Countries, ",")),
).Debug("annotated egress addresses with geolocation")
}
// geoCountries returns the sorted, deduplicated set of countries seen,
// preferring the ISO code and falling back to the localised name when a
// provider only supplied that.
func geoCountries(gs []GeoInfo) []string {
seen := make(map[string]struct{}, len(gs))
var out []string
for _, g := range gs {
name := strings.TrimSpace(g.Country)
if name == "" {
name = strings.TrimSpace(g.CountryName)
}
if name == "" {
continue
}
if _, dup := seen[name]; dup {
continue
}
seen[name] = struct{}{}
out = append(out, name)
}
sort.Strings(out)
return out
}
// ---------------------------------------------------------------------------
// Provider implementations
// ---------------------------------------------------------------------------
// geoGetJSON fetches url and decodes the body into v. The client uses the
// unforced network and honours the environment proxy: unlike the egress
// probes, we do not care which path the query takes, only that it succeeds.
func geoGetJSON(ctx context.Context, target string, v any) error {
client := newDiagClient("", true, geoTimeout)
defer client.CloseIdleConnections()
hdr := http.Header{}
hdr.Set("Accept", "application/json")
code, body, _, err := diagGet(ctx, client, target, geoMaxBody, hdr)
if err != nil {
return err
}
if code < 200 || code > 299 {
return fmt.Errorf("unexpected status %d", code)
}
if err := json.Unmarshal(body, v); err != nil {
return fmt.Errorf("bad json: %w", err)
}
return nil
}
// geoIPInfoResp is the subset of ipinfo.io's answer we use.
type geoIPInfoResp struct {
IP string `json:"ip"`
City string `json:"city"`
Region string `json:"region"`
Country string `json:"country"`
CountryName string `json:"country_name"` // only on paid plans
Loc string `json:"loc"`
Org string `json:"org"`
Timezone string `json:"timezone"`
Bogon bool `json:"bogon"`
}
// geoFetchIPInfo queries ipinfo.io. The token, when non-empty, only raises the
// rate limit; it is appended as a query parameter and never logged.
func geoFetchIPInfo(ctx context.Context, ip netip.Addr, token string) (GeoInfo, error) {
target := "https://ipinfo.io/" + url.PathEscape(ip.String()) + "/json"
if token != "" {
target += "?token=" + url.QueryEscape(token)
}
var r geoIPInfoResp
if err := geoGetJSON(ctx, target, &r); err != nil {
return GeoInfo{}, err
}
if r.Bogon {
return GeoInfo{}, fmt.Errorf("provider reports bogon address")
}
if r.Country == "" && r.Org == "" && r.City == "" {
return GeoInfo{}, fmt.Errorf("empty answer")
}
asn, org := geoSplitOrg(r.Org)
return GeoInfo{
Country: strings.TrimSpace(r.Country),
CountryName: strings.TrimSpace(r.CountryName),
Region: strings.TrimSpace(r.Region),
City: strings.TrimSpace(r.City),
Org: org,
ASN: asn,
Loc: strings.TrimSpace(r.Loc),
Timezone: strings.TrimSpace(r.Timezone),
}, nil
}
// geoIPAPIResp is ip-api.com's answer for the field set we request.
type geoIPAPIResp struct {
Status string `json:"status"`
Message string `json:"message"`
Country string `json:"country"`
CountryCode string `json:"countryCode"`
RegionName string `json:"regionName"`
City string `json:"city"`
ISP string `json:"isp"`
Org string `json:"org"`
AS string `json:"as"`
Timezone string `json:"timezone"`
}
// geoFetchIPAPI queries ip-api.com. The free tier is HTTP-only, which is also
// why it keeps working from mainland China where the HTTPS providers often do
// not. Answers are requested in Chinese to match the rest of the UI.
func geoFetchIPAPI(ctx context.Context, ip netip.Addr, _ string) (GeoInfo, error) {
target := "http://ip-api.com/json/" + url.PathEscape(ip.String()) +
"?lang=zh-CN&fields=status,message,country,countryCode,regionName,city,isp,org,as,timezone"
var r geoIPAPIResp
if err := geoGetJSON(ctx, target, &r); err != nil {
return GeoInfo{}, err
}
if !strings.EqualFold(r.Status, "success") {
msg := strings.TrimSpace(r.Message)
if msg == "" {
msg = r.Status
}
return GeoInfo{}, fmt.Errorf("query failed: %s", msg)
}
asn, asOrg := geoSplitOrg(r.AS)
org := strings.TrimSpace(r.Org)
if org == "" {
org = strings.TrimSpace(r.ISP)
}
if org == "" {
org = asOrg
}
return GeoInfo{
Country: strings.TrimSpace(r.CountryCode),
CountryName: strings.TrimSpace(r.Country),
Region: strings.TrimSpace(r.RegionName),
City: strings.TrimSpace(r.City),
Org: org,
ASN: asn,
Timezone: strings.TrimSpace(r.Timezone),
}, nil
}
// geoIPSBResp is api.ip.sb's answer. ASN comes back as a bare number, so it is
// decoded loosely and normalised by [geoASNText].
type geoIPSBResp struct {
Country string `json:"country"`
CountryCode string `json:"country_code"`
Region string `json:"region"`
City string `json:"city"`
ISP string `json:"isp"`
ASN any `json:"asn"`
ASNOrg string `json:"asn_organization"`
Timezone string `json:"timezone"`
Latitude any `json:"latitude"`
Longitude any `json:"longitude"`
}
// geoFetchIPSB queries api.ip.sb, the last-resort provider.
func geoFetchIPSB(ctx context.Context, ip netip.Addr, _ string) (GeoInfo, error) {
target := "https://api.ip.sb/geoip/" + url.PathEscape(ip.String())
var r geoIPSBResp
if err := geoGetJSON(ctx, target, &r); err != nil {
return GeoInfo{}, err
}
if r.CountryCode == "" && r.Country == "" && r.ISP == "" {
return GeoInfo{}, fmt.Errorf("empty answer")
}
org := strings.TrimSpace(r.ISP)
if org == "" {
org = strings.TrimSpace(r.ASNOrg)
}
return GeoInfo{
Country: strings.TrimSpace(r.CountryCode),
CountryName: strings.TrimSpace(r.Country),
Region: strings.TrimSpace(r.Region),
City: strings.TrimSpace(r.City),
Org: org,
ASN: geoASNText(r.ASN),
Loc: geoLocText(r.Latitude, r.Longitude),
Timezone: strings.TrimSpace(r.Timezone),
}, nil
}
// geoSplitOrg splits an "AS4134 Chinanet" style string into the ASN and the
// operator name. Either half may be missing, in which case the whole string is
// treated as the operator name.
func geoSplitOrg(s string) (asn, org string) {
s = strings.TrimSpace(s)
if s == "" {
return "", ""
}
head, rest, _ := strings.Cut(s, " ")
if geoLooksLikeASN(head) {
return head, strings.TrimSpace(rest)
}
return "", s
}
// geoLooksLikeASN reports whether s is an "AS####" token.
func geoLooksLikeASN(s string) bool {
if len(s) < 3 || !strings.EqualFold(s[:2], "AS") {
return false
}
_, err := strconv.ParseUint(s[2:], 10, 32)
return err == nil
}
// geoASNText normalises a JSON asn field (number or string) to "AS####".
func geoASNText(v any) string {
var s string
switch n := v.(type) {
case nil:
return ""
case float64:
if n <= 0 {
return ""
}
s = strconv.FormatFloat(n, 'f', -1, 64)
case string:
s = strings.TrimSpace(n)
default:
return ""
}
if s == "" || s == "0" {
return ""
}
if geoLooksLikeASN(s) {
return strings.ToUpper(s[:2]) + s[2:]
}
if _, err := strconv.ParseUint(s, 10, 32); err != nil {
return ""
}
return "AS" + s
}
// geoLocText renders a latitude/longitude pair in ipinfo's "lat,lon" form so
// the Loc field means the same thing whichever provider answered.
func geoLocText(lat, lon any) string {
f := func(v any) (string, bool) {
switch n := v.(type) {
case float64:
return strconv.FormatFloat(n, 'f', -1, 64), true
case string:
s := strings.TrimSpace(n)
return s, s != ""
default:
return "", false
}
}
a, okA := f(lat)
b, okB := f(lon)
if !okA || !okB {
return ""
}
return a + "," + b
}
+381
View File
@@ -0,0 +1,381 @@
package netdiag
import (
"context"
"fmt"
"log/slog"
"net"
"net/netip"
"sort"
"strings"
"sync"
"time"
)
// ifDialTimeout bounds each source-address discovery dial. The dial is to a
// UDP address, so no packet leaves the machine and the kernel answers from its
// routing table immediately; the timeout only guards against a pathological
// resolver or a wedged network stack.
const ifDialTimeout = 2 * time.Second
// ifMaxInflight bounds how many interfaces are inspected concurrently.
const ifMaxInflight = 8
// ifDefaultV4Target and ifDefaultV6Target are well-known anycast resolvers used
// purely as "somewhere on the default route" destinations.
const (
ifDefaultV4Target = "8.8.8.8:80"
ifDefaultV6Target = "[2001:4860:4860::8888]:80"
)
// tailscaleV6Prefix is the ULA range Tailscale assigns to every node.
var tailscaleV6Prefix = netip.MustParsePrefix("fd7a:115c:a1e0::/48")
// cgnatPrefix is RFC 6598 shared address space. Tailscale allocates its IPv4
// node addresses out of 100.64.0.0/10 as well, which is why an address here
// needs the interface name to be classified precisely; see [classifyOnIface].
var cgnatPrefix = netip.MustParsePrefix("100.64.0.0/10")
var (
ulaPrefix = netip.MustParsePrefix("fc00::/7")
linkLocalV4Pfx = netip.MustParsePrefix("169.254.0.0/16")
rfc1918Prefixes = []netip.Prefix{
netip.MustParsePrefix("10.0.0.0/8"),
netip.MustParsePrefix("172.16.0.0/12"),
netip.MustParsePrefix("192.168.0.0/16"),
}
)
// ifTailscaleIfaceNames are the interface-name prefixes Tailscale (and the
// wireguard/utun devices it rides on) uses across platforms.
var ifTailscaleIfaceNames = []string{"tailscale", "ts", "utun", "wg"}
func ifLog(logger *slog.Logger) *slog.Logger {
if logger == nil {
logger = slog.Default()
}
return logger.With(slog.String("from", "netdiag/iface"))
}
// ClassifyAddr buckets an address by reachable scope.
//
// Addresses in 100.64.0.0/10 are reported as [AddrCGNAT] because the range
// alone cannot distinguish a carrier-grade NAT lease from a Tailscale node
// address. [EnumerateInterfaces] refines that verdict using the interface name.
func ClassifyAddr(a netip.Addr) AddrKind {
a = a.Unmap()
switch {
case !a.IsValid():
return AddrLinkLocal // degenerate input; never reachable
case a.IsLoopback():
return AddrLoopback
case a.Is4() && cgnatPrefix.Contains(a):
return AddrCGNAT
case a.Is6() && tailscaleV6Prefix.Contains(a):
return AddrTailscale
case a.IsLinkLocalUnicast() || a.IsLinkLocalMulticast() || (a.Is4() && linkLocalV4Pfx.Contains(a)):
return AddrLinkLocal
case a.Is4() && isRFC1918(a):
return AddrPrivateV4
case a.Is6() && ulaPrefix.Contains(a):
return AddrULA
case a.Is4():
return AddrGlobalV4
default:
return AddrGlobalV6
}
}
func isRFC1918(a netip.Addr) bool {
for _, p := range rfc1918Prefixes {
if p.Contains(a) {
return true
}
}
return false
}
// classifyOnIface applies [ClassifyAddr] and then corrects the one case the
// address alone cannot decide: Tailscale hands out IPv4 addresses from the
// CGNAT range 100.64.0.0/10, so a 100.x address sitting on an interface named
// tailscale*/ts*/utun*/wg* is a tailnet address rather than a carrier NAT
// lease. The heuristic is name-based because the alternative (asking tailscaled)
// would make this package depend on tailscale.com.
func classifyOnIface(a netip.Addr, iface string) AddrKind {
kind := ClassifyAddr(a)
if kind == AddrCGNAT && isTailscaleIfaceName(iface) {
return AddrTailscale
}
return kind
}
func isTailscaleIfaceName(name string) bool {
n := strings.ToLower(name)
for _, p := range ifTailscaleIfaceNames {
if strings.HasPrefix(n, p) {
return true
}
}
return false
}
// EnumerateInterfaces lists every address bound to every local interface and
// determines which source addresses the kernel would use for default routes.
func EnumerateInterfaces(ctx context.Context, logger *slog.Logger) InterfaceReport {
log := ifLog(logger)
var rep InterfaceReport
ifaces, err := net.Interfaces()
if err != nil {
log.With(slog.String("error", err.Error())).Error("failed to enumerate interfaces")
rep.Err = err.Error()
rep.Status = StatusFail
rep.Summary = "无法枚举本机网络接口"
return rep
}
var (
mu sync.Mutex
addrs []LocalAddr
nIface int
)
sem := make(chan struct{}, ifMaxInflight)
var wg sync.WaitGroup
// Source discovery is independent of enumeration, so run both in parallel.
var v4Src, v6Src netip.Addr
wg.Add(2)
go func() {
defer wg.Done()
v4Src = defaultSource(ctx, "udp4", ifDefaultV4Target, log)
}()
go func() {
defer wg.Done()
v6Src = defaultSource(ctx, "udp6", ifDefaultV6Target, log)
}()
for _, iface := range ifaces {
if ctx.Err() != nil {
break
}
wg.Add(1)
go func(iface net.Interface) {
defer wg.Done()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
return
}
got := ifaceAddrs(iface, log)
mu.Lock()
if len(got) > 0 {
nIface++
}
addrs = append(addrs, got...)
mu.Unlock()
}(iface)
}
wg.Wait()
rep.Addrs = addrs
rep.DefaultV4Src = v4Src
rep.DefaultV6Src = v6Src
sortLocalAddrs(rep.Addrs)
hasGlobalV6Addr := false
for i := range rep.Addrs {
a := &rep.Addrs[i]
if a.Kind == AddrGlobalV6 {
hasGlobalV6Addr = true
}
if (v4Src.IsValid() && a.Addr == v4Src) || (v6Src.IsValid() && a.Addr == v6Src) {
a.IsDefaultSrc = true
}
}
rep.HasGlobalV6 = hasGlobalV6Addr && v6Src.IsValid() && ClassifyAddr(v6Src) == AddrGlobalV6
finishInterfaceReport(&rep, nIface)
log.With(
slog.Int("interfaces", nIface),
slog.Int("addrs", len(rep.Addrs)),
slog.String("v4_src", addrText(rep.DefaultV4Src)),
slog.String("v6_src", addrText(rep.DefaultV6Src)),
slog.String("status", rep.Status.String()),
).Debug("enumerated local interfaces")
return rep
}
// ifaceAddrs converts one interface's bound addresses into [LocalAddr] entries.
// Errors are logged and swallowed: one unreadable interface must not blank the
// whole panel.
func ifaceAddrs(iface net.Interface, log *slog.Logger) []LocalAddr {
raw, err := iface.Addrs()
if err != nil {
log.With(
slog.String("iface", iface.Name),
slog.String("error", err.Error()),
).Debug("failed to read interface addresses")
return nil
}
hw := ""
if len(iface.HardwareAddr) > 0 {
hw = strings.ToLower(iface.HardwareAddr.String())
}
up := iface.Flags&net.FlagUp != 0
out := make([]LocalAddr, 0, len(raw))
for _, a := range raw {
pfx, ok := toPrefix(a)
if !ok {
continue
}
addr := pfx.Addr().Unmap()
// Keep the zone off the reported address so equality against the
// default-source lookup and the sort order stay stable.
addr = addr.WithZone("")
out = append(out, LocalAddr{
Iface: iface.Name,
Addr: addr,
Prefix: netip.PrefixFrom(addr, pfx.Bits()),
Kind: classifyOnIface(addr, iface.Name),
Up: up,
MTU: iface.MTU,
Hardware: hw,
})
}
return out
}
// toPrefix normalises the net.Addr values iface.Addrs returns (*net.IPNet on
// every supported platform, *net.IPAddr on a few).
func toPrefix(a net.Addr) (netip.Prefix, bool) {
switch v := a.(type) {
case *net.IPNet:
addr, ok := netip.AddrFromSlice(v.IP)
if !ok {
return netip.Prefix{}, false
}
addr = addr.Unmap()
ones, _ := v.Mask.Size()
if ones <= 0 || ones > addr.BitLen() {
ones = addr.BitLen()
}
return netip.PrefixFrom(addr, ones), true
case *net.IPAddr:
addr, ok := netip.AddrFromSlice(v.IP)
if !ok {
return netip.Prefix{}, false
}
addr = addr.Unmap()
return netip.PrefixFrom(addr, addr.BitLen()), true
default:
addr, err := netip.ParsePrefix(a.String())
if err != nil {
return netip.Prefix{}, false
}
return addr, true
}
}
// defaultSource asks the kernel which local address it would use to reach a
// destination on the default route. Dialling a UDP address only installs a
// route lookup on the socket; nothing is transmitted. Failure is expected and
// normal (notably for udp6 on IPv4-only hosts) and never populates Err.
func defaultSource(ctx context.Context, network, target string, log *slog.Logger) netip.Addr {
dctx, cancel := context.WithTimeout(ctx, ifDialTimeout)
defer cancel()
var d net.Dialer
conn, err := d.DialContext(dctx, network, target)
if err != nil {
log.With(
slog.String("network", network),
slog.String("error", err.Error()),
).Debug("no default source address")
return netip.Addr{}
}
defer conn.Close()
ua, ok := conn.LocalAddr().(*net.UDPAddr)
if !ok {
return netip.Addr{}
}
addr, ok := netip.AddrFromSlice(ua.IP)
if !ok {
return netip.Addr{}
}
return addr.Unmap().WithZone("")
}
// sortLocalAddrs orders entries by interface name, then IPv4 before IPv6, then
// by address, so repeated refreshes render identically.
func sortLocalAddrs(as []LocalAddr) {
sort.Slice(as, func(i, j int) bool {
x, y := as[i], as[j]
if x.Iface != y.Iface {
return x.Iface < y.Iface
}
if x.Addr.Is4() != y.Addr.Is4() {
return x.Addr.Is4()
}
return x.Addr.Compare(y.Addr) < 0
})
}
// finishInterfaceReport derives Status and Summary from the collected data.
func finishInterfaceReport(rep *InterfaceReport, nIface int) {
if len(rep.Addrs) == 0 {
rep.Status = StatusFail
if rep.Err == "" {
rep.Err = "no local addresses found"
}
rep.Summary = "未发现任何本机地址"
return
}
// A private v4 address still routes out through NAT, but only if the kernel
// actually picked a default source for it.
var globalCapable bool
for _, a := range rep.Addrs {
switch a.Kind {
case AddrGlobalV4, AddrGlobalV6, AddrCGNAT, AddrTailscale:
globalCapable = true
case AddrPrivateV4:
globalCapable = globalCapable || rep.DefaultV4Src.IsValid()
}
}
if globalCapable {
rep.Status = StatusOK
} else {
rep.Status = StatusWarn
}
var b strings.Builder
fmt.Fprintf(&b, "%d 个接口 / %d 个地址", nIface, len(rep.Addrs))
if rep.DefaultV4Src.IsValid() {
fmt.Fprintf(&b, "IPv4 出口 %s", rep.DefaultV4Src)
} else {
b.WriteString(",无 IPv4 出口")
}
if rep.DefaultV6Src.IsValid() {
fmt.Fprintf(&b, "IPv6 出口 %s", rep.DefaultV6Src)
} else {
b.WriteString(",无 IPv6 出口")
}
rep.Summary = b.String()
}
// addrText renders an address for logging, using "-" for the invalid zero
// value so log lines stay readable.
func addrText(a netip.Addr) string {
if !a.IsValid() {
return "-"
}
return a.String()
}
+389
View File
@@ -0,0 +1,389 @@
package netdiag
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log/slog"
"mime/multipart"
"net"
"net/http"
"net/url"
"strings"
"time"
)
// pasteUserAgent identifies tslink to the paste services. 0x0.st rejects the
// Go default user agent with 403, so this is not merely cosmetic.
const pasteUserAgent = "tslink/1.0 (+diagnostics)"
// pasteTimeout bounds a single upload attempt, including connect, write and
// the read of the response body.
const pasteTimeout = 20 * time.Second
// MaxPasteBytes is the largest payload accepted by [Upload]. Bigger dumps are
// rejected rather than truncated: the tail of a log is usually the part the
// helper needs, and silently dropping it wastes everyone's time.
const MaxPasteBytes = 1 << 20
// pasteReadLimit caps how much of a response body is read back. A URL is a
// couple hundred bytes; anything larger is an error page.
const pasteReadLimit = 64 << 10
// PasteTarget is one supported paste service.
type PasteTarget struct {
Key string // stable id used by the UI
Name string // human label
Note string // short caveat: retention, region reachability
}
// pasteService is a target plus the code that performs the upload.
type pasteService struct {
PasteTarget
upload func(ctx context.Context, text string) (string, error)
}
// pasteServices is the internal registry, in preference order.
var pasteServices = []pasteService{
{
PasteTarget: PasteTarget{
Key: "0x0",
Name: "0x0.st",
Note: "保留 30 天以上(按大小递减),境内访问可能较慢",
},
upload: uploadNullPointer,
},
{
PasteTarget: PasteTarget{
Key: "paste_rs",
Name: "paste.rs",
Note: "无固定保留期,容量满后自动淘汰旧内容",
},
upload: uploadPasteRS,
},
{
PasteTarget: PasteTarget{
Key: "dpaste",
Name: "dpaste.org",
Note: "保留 7 天后自动删除",
},
upload: uploadDpaste,
},
{
PasteTarget: PasteTarget{
Key: "termbin",
Name: "termbin.com",
Note: "纯 TCP (9999)HTTPS 被墙时仍可用;保留约 1 个月",
},
upload: uploadTermbin,
},
}
// PasteTargets lists the supported services in preference order.
func PasteTargets() []PasteTarget {
out := make([]PasteTarget, 0, len(pasteServices))
for _, s := range pasteServices {
out = append(out, s.PasteTarget)
}
return out
}
// PasteResult is a successful upload.
type PasteResult struct {
URL string
Target string
Bytes int
Uploaded time.Time
}
// ErrPasteEmpty is returned when there is nothing to upload.
var ErrPasteEmpty = errors.New("netdiag: refusing to upload empty text")
// ErrPasteTooLarge is returned when the payload exceeds [MaxPasteBytes].
var ErrPasteTooLarge = fmt.Errorf("netdiag: text exceeds the %d byte paste limit", MaxPasteBytes)
// Upload sends text to the named target and returns the resulting public URL.
// An empty targetKey tries every target in [PasteTargets] order and returns the
// first success; when all of them fail the returned error names each failure.
//
// The caller MUST redact the text before calling: uploading is an outbound
// publication of user data to a third party. core.LogBuffer.ExportText performs
// that redaction (leave ExportOptions.NoRedact false). The resulting paste is
// PUBLIC — anyone holding the URL can read it, and most of these services offer
// no way to delete it afterwards.
//
// Every attempt is bounded by a ~20s timeout and honours ctx.
func Upload(ctx context.Context, targetKey, text string, logger *slog.Logger) (*PasteResult, error) {
if logger == nil {
logger = slog.Default()
}
logger = logger.With(slog.String("from", "paste"))
if strings.TrimSpace(text) == "" {
return nil, ErrPasteEmpty
}
if len(text) > MaxPasteBytes {
return nil, fmt.Errorf("%w (got %d bytes); filter the log before sharing", ErrPasteTooLarge, len(text))
}
candidates := pasteServices
if targetKey != "" {
svc, ok := lookupPasteService(targetKey)
if !ok {
return nil, fmt.Errorf("netdiag: unknown paste target %q", targetKey)
}
candidates = []pasteService{svc}
}
var failures []string
for _, svc := range candidates {
if err := ctx.Err(); err != nil {
return nil, err
}
res, err := attemptPaste(ctx, svc, text)
if err != nil {
logger.With(
slog.String("target", svc.Key),
slog.String("error", err.Error()),
).Debug("paste upload failed")
failures = append(failures, fmt.Sprintf("%s: %v", svc.Key, err))
continue
}
logger.With(
slog.String("target", res.Target),
slog.String("url", res.URL),
slog.Int("bytes", res.Bytes),
).Info("uploaded diagnostic paste")
return res, nil
}
if len(candidates) == 1 {
return nil, fmt.Errorf("netdiag: upload to %s failed: %s", candidates[0].Key, strings.TrimPrefix(failures[0], candidates[0].Key+": "))
}
return nil, fmt.Errorf("netdiag: every paste target failed: %s", strings.Join(failures, "; "))
}
// attemptPaste runs one upload under its own timeout and validates the URL the
// service handed back.
func attemptPaste(ctx context.Context, svc pasteService, text string) (*PasteResult, error) {
ctx, cancel := context.WithTimeout(ctx, pasteTimeout)
defer cancel()
raw, err := svc.upload(ctx, text)
if err != nil {
return nil, err
}
clean, err := normalisePasteURL(raw)
if err != nil {
return nil, err
}
return &PasteResult{
URL: clean,
Target: svc.Key,
Bytes: len(text),
Uploaded: time.Now(),
}, nil
}
// lookupPasteService finds a service by its stable key.
func lookupPasteService(key string) (pasteService, bool) {
for _, s := range pasteServices {
if s.Key == key {
return s, true
}
}
return pasteService{}, false
}
// ---------------------------------------------------------------------------
// Response validation
// ---------------------------------------------------------------------------
// normalisePasteURL trims a service response down to a single http(s) URL.
// termbin answers with a bare host such as "termbin.com/abcd", so a missing
// scheme is tolerated and upgraded to https. Anything that smells like an HTML
// error page is rejected outright.
func normalisePasteURL(raw string) (string, error) {
s := strings.TrimSpace(raw)
// termbin pads its reply with NULs and terminal escapes.
s = strings.Trim(s, "\x00\r\n\t ")
if s == "" {
return "", errors.New("empty response")
}
if i := strings.IndexAny(s, "\r\n"); i >= 0 {
s = strings.TrimSpace(s[:i])
}
if looksLikeHTML(s) {
return "", fmt.Errorf("service returned an error page: %s", snippet(s))
}
if len(s) > 512 {
return "", fmt.Errorf("response is not a URL: %s", snippet(s))
}
if !strings.Contains(s, "://") {
s = "https://" + s
}
u, err := url.Parse(s)
if err != nil {
return "", fmt.Errorf("response is not a URL: %s", snippet(raw))
}
if u.Scheme != "http" && u.Scheme != "https" {
return "", fmt.Errorf("response has unexpected scheme %q", u.Scheme)
}
if u.Host == "" || !strings.Contains(u.Host, ".") {
return "", fmt.Errorf("response has no usable host: %s", snippet(s))
}
return u.String(), nil
}
// looksLikeHTML reports whether s is the beginning of an HTML document rather
// than a URL.
func looksLikeHTML(s string) bool {
lower := strings.ToLower(strings.TrimSpace(s))
return strings.HasPrefix(lower, "<") ||
strings.Contains(lower, "<html") ||
strings.Contains(lower, "<!doctype")
}
// snippet shortens an untrusted response for inclusion in an error message.
func snippet(s string) string {
s = strings.TrimSpace(s)
s = strings.ReplaceAll(s, "\n", " ")
if len(s) > 120 {
return s[:120] + "…"
}
return s
}
// ---------------------------------------------------------------------------
// HTTP plumbing
// ---------------------------------------------------------------------------
// pasteHTTPClient is shared by the HTTP-based targets. The per-attempt context
// timeout is the real deadline; the client timeout is a backstop.
var pasteHTTPClient = &http.Client{
Timeout: pasteTimeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return errors.New("too many redirects")
}
return nil
},
}
// doPaste issues one request and returns the (size-limited) response body.
func doPaste(ctx context.Context, method, endpoint, contentType string, body []byte) (string, error) {
req, err := http.NewRequestWithContext(ctx, method, endpoint, bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("User-Agent", pasteUserAgent)
req.Header.Set("Accept", "text/plain, */*")
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
req.ContentLength = int64(len(body))
resp, err := pasteHTTPClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, pasteReadLimit))
if err != nil {
return "", fmt.Errorf("reading response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("http %d: %s", resp.StatusCode, snippet(string(data)))
}
return string(data), nil
}
// uploadNullPointer posts to 0x0.st as multipart/form-data.
func uploadNullPointer(ctx context.Context, text string) (string, error) {
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
part, err := mw.CreateFormFile("file", "tslink-log.txt")
if err != nil {
return "", err
}
if _, err := io.WriteString(part, text); err != nil {
return "", err
}
if err := mw.Close(); err != nil {
return "", err
}
return doPaste(ctx, http.MethodPost, "https://0x0.st/", mw.FormDataContentType(), buf.Bytes())
}
// uploadPasteRS posts the raw text to paste.rs.
func uploadPasteRS(ctx context.Context, text string) (string, error) {
return doPaste(ctx, http.MethodPost, "https://paste.rs/", "text/plain; charset=utf-8", []byte(text))
}
// uploadDpaste posts a urlencoded form to dpaste.org and asks for a bare URL
// back rather than the JSON representation.
func uploadDpaste(ctx context.Context, text string) (string, error) {
form := url.Values{
"content": {text},
"lexer": {"text"},
"format": {"url"},
"expires": {"604800"},
}
return doPaste(ctx, http.MethodPost, "https://dpaste.org/api/",
"application/x-www-form-urlencoded", []byte(form.Encode()))
}
// ---------------------------------------------------------------------------
// termbin (raw TCP)
// ---------------------------------------------------------------------------
// termbinAddr is the netcat-style endpoint termbin.com exposes.
const termbinAddr = "termbin.com:9999"
// uploadTermbin writes the text over a plain TCP connection, half-closes the
// write side so the server knows the paste is complete, then reads the URL it
// replies with. No TLS is involved, which is exactly why this target survives
// environments where the HTTPS paste sites are unreachable.
func uploadTermbin(ctx context.Context, text string) (string, error) {
var d net.Dialer
conn, err := d.DialContext(ctx, "tcp", termbinAddr)
if err != nil {
return "", err
}
defer conn.Close()
// Bound the whole exchange, and make sure a hung read is interrupted when
// the caller cancels: a blocked socket must never freeze the GUI.
if dl, ok := ctx.Deadline(); ok {
_ = conn.SetDeadline(dl)
} else {
_ = conn.SetDeadline(time.Now().Add(pasteTimeout))
}
stop := context.AfterFunc(ctx, func() { _ = conn.Close() })
defer stop()
tcp, ok := conn.(*net.TCPConn)
if !ok {
return "", errors.New("termbin: connection is not tcp")
}
if _, err := io.WriteString(tcp, text); err != nil {
return "", fmt.Errorf("termbin: write: %w", err)
}
// Half-close: termbin only answers once it sees EOF on its read side.
if err := tcp.CloseWrite(); err != nil {
return "", fmt.Errorf("termbin: close write: %w", err)
}
data, err := io.ReadAll(io.LimitReader(tcp, pasteReadLimit))
if err != nil {
return "", fmt.Errorf("termbin: read: %w", err)
}
if ctxErr := ctx.Err(); ctxErr != nil {
return "", ctxErr
}
return string(data), nil
}
+1294
View File
File diff suppressed because it is too large Load Diff
+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()
}
+484
View File
@@ -0,0 +1,484 @@
package netdiag
import (
"context"
"fmt"
"sort"
"strings"
"sync"
"time"
)
// Run executes the full diagnostic suite and returns a populated report.
//
// The phases are deliberately not all parallel. Interface enumeration, port
// mapping, overseas reachability and tailscale's own netcheck are independent
// and run together. The STUN-derived phases are staged: a burst of binding
// requests first, then NAT classification on its own socket, then egress
// discovery reusing the results we already have. Running all of them at once
// would triple the load on a handful of public STUN servers and make the
// mapping tests race each other's sockets.
//
// Run always returns a report, even when everything failed; partial results
// are the normal case on a broken network and are exactly what the user needs
// to see.
func Run(ctx context.Context, opt Options) *Report {
timeout := opt.Timeout
if timeout <= 0 {
timeout = DefaultTimeout
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
logger := opt.logger()
servers := opt.STUNServers
if len(servers) == 0 {
servers = DefaultSTUNServers()
}
rep := &Report{StartedAt: time.Now()}
total := len(Steps)
if opt.Tailscale == nil {
total--
}
if opt.SkipGeo {
total--
}
// Phases run concurrently, so each one's index has to be captured when it
// starts. Re-reading the shared counter on completion would make the
// "step N of M" label jump around and even count backwards.
type stepStart struct {
idx int
at time.Time
}
var (
mu sync.Mutex
index int
started = make(map[string]stepStart)
)
begin := func(key string) {
mu.Lock()
index++
s := stepStart{idx: index, at: time.Now()}
started[key] = s
mu.Unlock()
opt.progress(Progress{Key: key, Title: stepTitle(key), Index: s.idx, Total: total})
}
finish := func(key, errText string) {
mu.Lock()
s := started[key]
mu.Unlock()
opt.progress(Progress{
Key: key, Title: stepTitle(key), Index: s.idx, Total: total,
Done: true, Err: errText, Elapsed: time.Since(s.at),
})
}
var wg sync.WaitGroup
// --- independent probes -------------------------------------------------
wg.Add(1)
go func() {
defer wg.Done()
begin("iface")
r := EnumerateInterfaces(ctx, logger)
mu.Lock()
rep.Interfaces = r
mu.Unlock()
finish("iface", r.Err)
}()
wg.Add(1)
go func() {
defer wg.Done()
begin("portmap")
r := ProbePortMapping(ctx, logger)
mu.Lock()
rep.PortMap = r
mu.Unlock()
finish("portmap", "")
}()
wg.Add(1)
go func() {
defer wg.Done()
begin("overseas")
r := ProbeOverseas(ctx, logger)
mu.Lock()
rep.Overseas = r
mu.Unlock()
finish("overseas", "")
}()
if opt.Tailscale != nil {
wg.Add(1)
go func() {
defer wg.Done()
begin("tailscale")
tsCtx, tsCancel := context.WithTimeout(ctx, 20*time.Second)
defer tsCancel()
r, err := opt.Tailscale.Netcheck(tsCtx)
errText := ""
mu.Lock()
switch {
case r != nil:
rep.Tailscale = *r
case err != nil:
rep.Tailscale = TailscaleReport{Status: StatusSkipped, Err: err.Error()}
default:
rep.Tailscale = TailscaleReport{Status: StatusSkipped}
}
if err != nil {
errText = err.Error()
}
mu.Unlock()
finish("tailscale", errText)
}()
} else {
rep.Tailscale = TailscaleReport{
Status: StatusSkipped,
Summary: "Tailscale 未运行,跳过内部状态检查",
}
}
// --- STUN-derived chain -------------------------------------------------
wg.Add(1)
go func() {
defer wg.Done()
begin("udp")
var (
stunResults []STUNResult
udpReport UDPReport
inner sync.WaitGroup
)
inner.Add(2)
go func() {
defer inner.Done()
stunResults = ProbeSTUN(ctx, servers, logger)
}()
go func() {
defer inner.Done()
udpReport = ProbeUDP(ctx, servers, logger)
}()
inner.Wait()
mu.Lock()
rep.UDP = udpReport
mu.Unlock()
finish("udp", "")
begin("nat")
nat := ClassifyNAT(ctx, servers, logger)
mu.Lock()
rep.NAT = nat
mu.Unlock()
finish("nat", "")
begin("egress")
egress := ProbeEgress(ctx, stunResults, logger)
mu.Lock()
rep.Egress = egress
mu.Unlock()
finish("egress", "")
if opt.SkipGeo {
mu.Lock()
rep.Egress.Summary = strings.TrimSpace(rep.Egress.Summary + " 已跳过归属地查询。")
mu.Unlock()
return
}
begin("geo")
mu.Lock()
target := rep.Egress
mu.Unlock()
AnnotateGeo(ctx, &target, opt.IPInfoToken, logger)
mu.Lock()
rep.Egress = target
mu.Unlock()
finish("geo", "")
}()
wg.Wait()
rep.FinishedAt = time.Now()
rep.Duration = rep.FinishedAt.Sub(rep.StartedAt)
rep.Status = worstStatus(
rep.Interfaces.Status,
rep.UDP.Status,
rep.NAT.Status,
rep.PortMap.Status,
rep.Overseas.Status,
rep.Egress.Status,
rep.Tailscale.Status,
)
rep.Headline = headline(rep)
logger.Info("diagnostics finished",
"took", rep.Duration.Round(time.Millisecond),
"status", rep.Status.String(),
"headline", rep.Headline,
)
return rep
}
func stepTitle(key string) string {
for _, s := range Steps {
if s.Key == key {
return s.Title
}
}
return key
}
// headline picks the single most consequential finding. The ordering is by how
// badly each condition breaks the thing this app exists to do — carry game
// traffic between peers — not by section order.
func headline(r *Report) string {
switch {
case r.NAT.Type == NATUDPBlocked:
return "UDP 被完全阻断,无法建立直连,所有流量都会走 DERP 中继"
case !r.UDP.V4OK && !r.UDP.V6OK:
return "UDP 探测全部失败,请检查防火墙或网络策略"
case r.NAT.Type == NATSymmetric:
return "对称型 NAT:与同样受限的对端难以打洞,连接多半会退回中继"
case r.Overseas.Status == StatusFail:
return "无法访问任何外部网络"
case r.Overseas.Status == StatusWarn:
return "境外网络不可达,Tailscale 控制面与 DERP 可能受影响"
case r.Egress.Divergent:
return "检测到多个出口 IP,代理或分流工具正在影响连接"
case r.PortMap.Status == StatusWarn && r.NAT.Type == NATPortRestrict:
return "路由器未提供端口映射,NAT 为端口限制型,打洞成功率一般"
case r.Status == StatusOK:
return "网络状况良好,具备直连条件"
default:
return "诊断完成,存在若干需要注意的项目"
}
}
// ---------------------------------------------------------------------------
// Text report
// ---------------------------------------------------------------------------
// Text renders the report as a plain-text block suitable for pasting into an
// issue or a paste service. It contains no credentials, but it does contain
// the machine's public and private addresses, which is unavoidable for a
// network diagnostic and worth telling the user before they share it.
func (r *Report) Text() string {
if r == nil {
return ""
}
var b strings.Builder
w := func(format string, args ...any) { fmt.Fprintf(&b, format, args...) }
w("=== tslink 网络诊断报告 ===\n")
w("时间: %s\n", r.StartedAt.Format(time.RFC3339))
w("耗时: %s\n", r.Duration.Round(time.Millisecond))
w("总评: [%s] %s\n\n", strings.ToUpper(r.Status.String()), r.Headline)
// --- interfaces --------------------------------------------------------
w("--- 本机地址 [%s] ---\n", r.Interfaces.Status)
if r.Interfaces.Summary != "" {
w("%s\n", r.Interfaces.Summary)
}
if r.Interfaces.DefaultV4Src.IsValid() {
w("默认 IPv4 源: %s\n", r.Interfaces.DefaultV4Src)
}
if r.Interfaces.DefaultV6Src.IsValid() {
w("默认 IPv6 源: %s\n", r.Interfaces.DefaultV6Src)
}
for _, a := range r.Interfaces.Addrs {
flag := ""
if a.IsDefaultSrc {
flag = " *默认出口"
}
w(" %-14s %-40s %-10s%s\n", a.Iface, a.Addr.String(), a.Kind, flag)
}
if r.Interfaces.Err != "" {
w("错误: %s\n", r.Interfaces.Err)
}
b.WriteByte('\n')
// --- udp ---------------------------------------------------------------
w("--- UDP 连通性 [%s] ---\n", r.UDP.Status)
if r.UDP.Summary != "" {
w("%s\n", r.UDP.Summary)
}
w("IPv4: %v IPv6: %v 国内 %d/%d 国外 %d/%d\n",
r.UDP.V4OK, r.UDP.V6OK,
r.UDP.CNReachable, r.UDP.CNTotal, r.UDP.IntlReachabl, r.UDP.IntlTotal)
if len(r.UDP.BlockedPorts) > 0 {
w("疑似被封端口: %v\n", r.UDP.BlockedPorts)
}
for _, p := range r.UDP.Probes {
status := "FAIL"
detail := p.Err
if p.OK {
status = "OK"
detail = p.Mapped.String() + " " + p.RTT.Round(time.Millisecond).String()
}
w(" %-4s %-34s %-5s %s\n", status, p.Target, p.Region, detail)
}
b.WriteByte('\n')
// --- nat ---------------------------------------------------------------
w("--- NAT 类型 [%s] ---\n", r.NAT.Status)
w("类型: %s\n", r.NAT.Type)
w("映射行为: %s\n", r.NAT.Mapping)
w("过滤行为: %s\n", r.NAT.Filtering)
w("发夹回环: %s\n", triState(r.NAT.Hairpin))
w("端口保持: %s\n", triState(r.NAT.PortPreserving))
if len(r.NAT.MappedAddrs) > 0 {
addrs := make([]string, 0, len(r.NAT.MappedAddrs))
for _, a := range r.NAT.MappedAddrs {
addrs = append(addrs, a.String())
}
w("观测到的映射地址: %s\n", strings.Join(addrs, ", "))
}
if r.NAT.Summary != "" {
w("%s\n", r.NAT.Summary)
}
for _, n := range r.NAT.Notes {
w("注: %s\n", n)
}
b.WriteByte('\n')
// --- port mapping ------------------------------------------------------
w("--- 端口映射 [%s] ---\n", r.PortMap.Status)
if r.PortMap.Gateway.IsValid() {
w("网关: %s\n", r.PortMap.Gateway)
}
writeService(&b, "UPnP IGD", r.PortMap.UPnP)
writeService(&b, "NAT-PMP ", r.PortMap.NATPMP)
writeService(&b, "PCP ", r.PortMap.PCP)
if r.PortMap.Summary != "" {
w("%s\n", r.PortMap.Summary)
}
b.WriteByte('\n')
// --- overseas ----------------------------------------------------------
w("--- 境外连通性 [%s] ---\n", r.Overseas.Status)
if r.Overseas.Summary != "" {
w("%s\n", r.Overseas.Summary)
}
for _, p := range r.Overseas.Probes {
status := "FAIL"
if p.OK {
status = "OK"
}
via := "direct"
if p.ViaProxy {
via = "proxy"
}
net := p.Network
if net == "" {
net = "auto"
}
detail := p.RTT.Round(time.Millisecond).String()
if p.Err != "" {
detail = p.Err
}
w(" %-4s %-3d %-6s %-5s %-46s %s\n", status, p.StatusCode, via, net, p.URL, detail)
}
b.WriteByte('\n')
// --- egress ------------------------------------------------------------
w("--- 出口 IP [%s] ---\n", r.Egress.Status)
if r.Egress.Summary != "" {
w("%s\n", r.Egress.Summary)
}
if r.Egress.Divergent {
w("!! 不同探测方式得到了不同的公网 IP,通常说明有代理或分流在生效\n")
}
for _, o := range r.Egress.Observations {
val := o.IP.String()
if !o.IP.IsValid() {
val = "(" + o.Err + ")"
}
w(" %-11s %-5s %-40s %s\n", o.Method, o.Region, o.Source, val)
}
for _, g := range r.Egress.Geo {
if g.Err != "" && g.Provider == "" {
w(" %-40s %s\n", g.IP.String(), g.Err)
continue
}
parts := []string{}
for _, p := range []string{g.CountryName, g.Country, g.Region, g.City} {
if p != "" {
parts = append(parts, p)
}
}
w(" %-40s %s | %s %s (via %s)\n",
g.IP.String(), strings.Join(parts, " "), g.ASN, g.Org, g.Provider)
}
b.WriteByte('\n')
// --- tailscale ---------------------------------------------------------
w("--- Tailscale 内部状态 [%s] ---\n", r.Tailscale.Status)
if r.Tailscale.Summary != "" {
w("%s\n", r.Tailscale.Summary)
}
if r.Tailscale.Available {
w("UDP: %v IPv4: %v IPv6: %v ICMPv4: %v\n",
r.Tailscale.UDP, r.Tailscale.IPv4, r.Tailscale.IPv6, r.Tailscale.ICMPv4)
w("UPnP: %s PMP: %s PCP: %s\n",
triState(r.Tailscale.UPnP), triState(r.Tailscale.PMP), triState(r.Tailscale.PCP))
w("映射随目标变化: %s 门户劫持: %s\n",
triState(r.Tailscale.MappingVariesByDestIP), triState(r.Tailscale.CaptivePortal))
if r.Tailscale.GlobalV4 != "" {
w("GlobalV4: %s\n", r.Tailscale.GlobalV4)
}
if r.Tailscale.GlobalV6 != "" {
w("GlobalV6: %s\n", r.Tailscale.GlobalV6)
}
w("首选 DERP: %s\n", r.Tailscale.PreferredDERP)
derp := append([]DERPLatency(nil), r.Tailscale.DERP...)
sort.Slice(derp, func(i, j int) bool { return derp[i].Latency < derp[j].Latency })
for i, d := range derp {
if i >= 8 {
break
}
mark := " "
if d.Preferred {
mark = "*"
}
w(" %s %-6s %-24s %s\n", mark, d.RegionCode, d.Name,
d.Latency.Round(time.Millisecond))
}
}
if r.Tailscale.Err != "" {
w("错误: %s\n", r.Tailscale.Err)
}
return b.String()
}
func writeService(b *strings.Builder, name string, s ServiceProbe) {
status := "不支持"
if s.Available {
status = "支持"
}
line := " " + name + ": " + status
if s.ExternalIP.IsValid() {
line += " 外部地址 " + s.ExternalIP.String()
}
if s.Detail != "" {
line += " " + s.Detail
}
if s.Err != "" {
line += " (" + s.Err + ")"
}
b.WriteString(line + "\n")
}
func triState(v *bool) string {
if v == nil {
return "未知"
}
if *v {
return "是"
}
return "否"
}
+55
View File
@@ -0,0 +1,55 @@
package netdiag
// This file holds the STUN probe target lists. The default list deliberately
// mixes mainland-China and international servers: when a proxy, a split tunnel
// or the GFW is in play the two groups disagree, and that disagreement is the
// diagnostic signal we are after. Probing only one side would hide it.
// DefaultSTUNServers returns the built-in probe list, covering both mainland
// China (RegionCN) and international (RegionIntl) targets.
//
// A fresh slice is returned on every call so callers may reorder or trim it
// without affecting anyone else.
func DefaultSTUNServers() []STUNServer {
return []STUNServer{
// Mainland China. These answer fast from inside the country and are the
// baseline for "does UDP work at all on this line".
{Host: "stun.miwifi.com:3478", Name: "小米", Region: RegionCN},
{Host: "stun.chat.bilibili.com:3478", Name: "哔哩哔哩", Region: RegionCN},
{Host: "stun.qq.com:3478", Name: "腾讯", Region: RegionCN},
{Host: "stun.hitv.com:3478", Name: "芒果TV", Region: RegionCN},
// Anycast: usually lands on an in-country PoP, so it is grouped with CN
// even though the operator is not Chinese.
{Host: "turn.cloudflare.com:3478", Name: "Cloudflare(任播)", Region: RegionCN},
// International. Failures here while the CN group succeeds mean egress
// to the wider internet is filtered rather than UDP being dead.
{Host: "stun.l.google.com:19302", Name: "Google", Region: RegionIntl},
{Host: "stun.cloudflare.com:3478", Name: "Cloudflare", Region: RegionIntl},
{Host: "stun.nextcloud.com:3478", Name: "Nextcloud", Region: RegionIntl},
{Host: "stun.voip.blackberry.com:3478", Name: "BlackBerry", Region: RegionIntl},
{Host: "stun.sipnet.net:3478", Name: "SipNet", Region: RegionIntl},
{Host: "stun.stunprotocol.org:3478", Name: "StunProtocol", Region: RegionIntl},
{Host: "stun.voipgate.com:3478", Name: "VoIPGate", Region: RegionIntl},
}
}
// RFC5780Servers returns the subset of targets known to implement RFC 5780
// behaviour discovery, i.e. they advertise OTHER-ADDRESS and actually honour
// CHANGE-REQUEST by answering from a second IP and/or port.
//
// Only these servers can drive the filtering-behaviour test in [ClassifyNAT].
// Most large providers — Google and Cloudflare among them — answer plain
// binding requests perfectly well but silently ignore CHANGE-REQUEST and never
// send OTHER-ADDRESS, so a probe against them looks identical to a firewall
// dropping the reply. Classification therefore has to degrade gracefully: when
// none of these servers answers, filtering behaviour stays
// [BehaviorUnknown] and the NAT type is reported as [NATUnknown] with an
// explanatory note rather than being guessed.
func RFC5780Servers() []STUNServer {
return []STUNServer{
{Host: "stun.stunprotocol.org:3478", Name: "StunProtocol", Region: RegionIntl},
{Host: "stun.sipnet.net:3478", Name: "SipNet", Region: RegionIntl},
{Host: "stun.voipgate.com:3478", Name: "VoIPGate", Region: RegionIntl},
}
}
+1367
View File
File diff suppressed because it is too large Load Diff
+347
View File
@@ -0,0 +1,347 @@
package netdiag
import (
"bytes"
"encoding/binary"
"encoding/hex"
"net/netip"
"testing"
)
// rfc5769TxID is the transaction ID from the RFC 5769 sample messages; the
// hand-computed XOR-MAPPED-ADDRESS vectors below are derived from it.
var rfc5769TxID = [12]byte{0xb7, 0xe7, 0xa7, 0x01, 0xbc, 0x34, 0xd6, 0x86, 0xfa, 0x87, 0xdf, 0xae}
// stunTestTLV encodes one attribute with its 4-byte alignment padding.
func stunTestTLV(typ uint16, val []byte) []byte {
out := make([]byte, 4, 4+len(val)+3)
binary.BigEndian.PutUint16(out[0:2], typ)
binary.BigEndian.PutUint16(out[2:4], uint16(len(val)))
out = append(out, val...)
if pad := (4 - len(val)%4) % 4; pad > 0 {
out = append(out, make([]byte, pad)...)
}
return out
}
// stunTestRaw frames body as a STUN message with a correct length field.
func stunTestRaw(typ uint16, txid [12]byte, body []byte) []byte {
out := make([]byte, stunHeaderSize, stunHeaderSize+len(body))
binary.BigEndian.PutUint16(out[0:2], typ)
binary.BigEndian.PutUint16(out[2:4], uint16(len(body)))
binary.BigEndian.PutUint32(out[4:8], stunMagicCookie)
copy(out[8:20], txid[:])
return append(out, body...)
}
func mustHex(t *testing.T, s string) []byte {
t.Helper()
b, err := hex.DecodeString(s)
if err != nil {
t.Fatalf("bad hex %q: %v", s, err)
}
return b
}
func TestSTUNEncodeParseRoundTrip(t *testing.T) {
tests := []struct {
name string
msg stunMessage
attrs int
}{
{
name: "bare request",
msg: stunMessage{Type: stunBindingRequest, TxID: rfc5769TxID},
attrs: 0,
},
{
name: "change request",
msg: stunMessage{Type: stunBindingRequest, TxID: rfc5769TxID, Attrs: []stunAttr{
{Type: stunAttrChangeRequest, Value: []byte{0, 0, 0, stunChangeIP | stunChangePort}},
}},
attrs: 1,
},
{
name: "response with odd-length software",
msg: stunMessage{Type: stunBindingSuccess, TxID: rfc5769TxID, Attrs: []stunAttr{
{Type: stunAttrSoftware, Value: []byte("tslink/1")},
{Type: stunAttrXORMappedAddress, Value: stunEncodeAddr(netip.MustParseAddrPort("192.0.2.1:32853"), true, rfc5769TxID)},
{Type: stunAttrOtherAddress, Value: stunEncodeAddr(netip.MustParseAddrPort("198.51.100.7:3479"), false, rfc5769TxID)},
{Type: 0x7fff, Value: []byte{1, 2, 3, 4, 5}}, // unknown, needs padding
}},
attrs: 4,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
raw := tc.msg.encode()
if len(raw)%4 != 0 {
t.Fatalf("encoded message is not 4-byte aligned: %d", len(raw))
}
if got := binary.BigEndian.Uint32(raw[4:8]); got != stunMagicCookie {
t.Fatalf("magic cookie = %#x", got)
}
got, err := parseSTUNMessage(raw)
if err != nil {
t.Fatalf("parse: %v", err)
}
if got.Type != tc.msg.Type || got.TxID != tc.msg.TxID {
t.Fatalf("header mismatch: got %#x/%x", got.Type, got.TxID)
}
if len(got.Attrs) != tc.attrs {
t.Fatalf("attrs = %d, want %d", len(got.Attrs), tc.attrs)
}
for i, a := range tc.msg.Attrs {
if got.Attrs[i].Type != a.Type {
t.Errorf("attr %d type = %#x, want %#x", i, got.Attrs[i].Type, a.Type)
}
if !bytes.Equal(got.Attrs[i].Value, a.Value) {
t.Errorf("attr %d value = %x, want %x", i, got.Attrs[i].Value, a.Value)
}
}
})
}
}
func TestSTUNDecodeXORMappedAddress(t *testing.T) {
tests := []struct {
name string
attr uint16
// hand-computed payload: reserved, family, xor-port, xor-address
payload string
want string
}{
{
// 192.0.2.1 ^ 2112a442 = e112a643, port 32853 ^ 0x2112 = 0xa147
name: "v4",
attr: stunAttrXORMappedAddress,
payload: "0001a147e112a643",
want: "192.0.2.1:32853",
},
{
// same, delivered under the legacy 0x8020 attribute type
name: "v4 legacy attr",
attr: stunAttrXORMappedAddrAlt,
payload: "0001a147e112a643",
want: "192.0.2.1:32853",
},
{
// 2001:db8:1234:5678:11:2233:4455:6677 ^ (cookie || txid)
name: "v6",
attr: stunAttrXORMappedAddress,
payload: "0002a1470113a9faa5d3f179bc25f4b5bed2b9d9",
want: "[2001:db8:1234:5678:11:2233:4455:6677]:32853",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
payload := mustHex(t, tc.payload)
raw := stunTestRaw(stunBindingSuccess, rfc5769TxID, stunTestTLV(tc.attr, payload))
msg, err := parseSTUNMessage(raw)
if err != nil {
t.Fatalf("parse: %v", err)
}
got, ok := msg.mappedAddr()
if !ok {
t.Fatal("no mapped address decoded")
}
if got.String() != tc.want {
t.Fatalf("mapped = %s, want %s", got, tc.want)
}
// encoding it again must reproduce the same bytes
if back := stunEncodeAddr(got, true, rfc5769TxID); !bytes.Equal(back, payload) {
t.Fatalf("re-encoded = %x, want %x", back, payload)
}
})
}
}
func TestSTUNDecodePlainMappedAddress(t *testing.T) {
payload := mustHex(t, "00010d96c0000201") // 192.0.2.1:3478, no XOR
raw := stunTestRaw(stunBindingSuccess, rfc5769TxID, stunTestTLV(stunAttrMappedAddress, payload))
msg, err := parseSTUNMessage(raw)
if err != nil {
t.Fatalf("parse: %v", err)
}
got, ok := msg.mappedAddr()
if !ok || got.String() != "192.0.2.1:3478" {
t.Fatalf("mapped = %v (ok=%v), want 192.0.2.1:3478", got, ok)
}
}
func TestSTUNParseTolerance(t *testing.T) {
good := stunTestTLV(stunAttrXORMappedAddress, mustHex(t, "0001a147e112a643"))
tests := []struct {
name string
raw []byte
wantErr bool
wantAttrs int
wantMap string
}{
{
name: "unknown attributes are skipped",
raw: stunTestRaw(stunBindingSuccess, rfc5769TxID, concat(stunTestTLV(0x7f01, []byte{9}), good, stunTestTLV(0xfffe, []byte("xyz")))),
wantAttrs: 3,
wantMap: "192.0.2.1:32853",
},
{
name: "missing trailing padding tolerated",
raw: stunTestRaw(stunBindingSuccess, rfc5769TxID, concat(good, []byte{0x80, 0x22, 0x00, 0x03, 'a', 'b', 'c'})),
wantAttrs: 2,
wantMap: "192.0.2.1:32853",
},
{
name: "fingerprint after mapped address",
raw: stunTestRaw(stunBindingSuccess, rfc5769TxID, concat(good, stunTestTLV(stunAttrFingerprint, []byte{1, 2, 3, 4}))),
wantAttrs: 2,
wantMap: "192.0.2.1:32853",
},
{
name: "header shorter than 20 bytes",
raw: []byte{0x01, 0x01, 0x00, 0x00},
wantErr: true,
},
{
name: "trailing bytes beyond declared length ignored",
raw: append(stunTestRaw(stunBindingSuccess, rfc5769TxID, nil), 0x00),
wantErr: false,
},
{
name: "truncated attribute value",
raw: func() []byte {
b := stunTestRaw(stunBindingSuccess, rfc5769TxID, []byte{0x00, 0x20, 0x00, 0x10, 0x00, 0x01})
return b
}(),
wantErr: true,
},
{
name: "truncated attribute header",
raw: stunTestRaw(stunBindingSuccess, rfc5769TxID, []byte{0x00, 0x20, 0x00}),
wantErr: true,
},
{
name: "address attribute shorter than its family requires",
raw: stunTestRaw(stunBindingSuccess, rfc5769TxID, stunTestTLV(stunAttrXORMappedAddress, mustHex(t, "0002a1470113a9fa"))),
wantAttrs: 1,
wantMap: "", // v6 payload truncated: reported as absent, not fatal
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
msg, err := parseSTUNMessage(tc.raw)
if tc.wantErr {
if err == nil {
t.Fatal("expected an error, got none")
}
return
}
if err != nil {
t.Fatalf("parse: %v", err)
}
if tc.wantAttrs != 0 && len(msg.Attrs) != tc.wantAttrs {
t.Fatalf("attrs = %d, want %d", len(msg.Attrs), tc.wantAttrs)
}
got, ok := msg.mappedAddr()
if tc.wantMap == "" {
if ok {
t.Fatalf("expected no mapped address, got %s", got)
}
return
}
if !ok || got.String() != tc.wantMap {
t.Fatalf("mapped = %v (ok=%v), want %s", got, ok, tc.wantMap)
}
})
}
}
func TestSTUNResponseFor(t *testing.T) {
other := [12]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12}
body := stunTestTLV(stunAttrXORMappedAddress, mustHex(t, "0001a147e112a643"))
tests := []struct {
name string
raw []byte
txid [12]byte
want bool
}{
{"matching success", stunTestRaw(stunBindingSuccess, rfc5769TxID, body), rfc5769TxID, true},
{"matching error response", stunTestRaw(stunBindingError, rfc5769TxID, nil), rfc5769TxID, true},
{"txid mismatch", stunTestRaw(stunBindingSuccess, other, body), rfc5769TxID, false},
{"request is not a response", stunTestRaw(stunBindingRequest, rfc5769TxID, nil), rfc5769TxID, false},
{"garbage", []byte("not a stun packet"), rfc5769TxID, false},
{"empty", nil, rfc5769TxID, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
msg, ok := stunResponseFor(tc.raw, tc.txid)
if ok != tc.want {
t.Fatalf("ok = %v, want %v", ok, tc.want)
}
if ok && msg == nil {
t.Fatal("accepted response but returned nil message")
}
})
}
}
func TestSTUNBindingRequestMsg(t *testing.T) {
plain := stunBindingRequestMsg(0)
if len(plain.Attrs) != 0 {
t.Fatalf("plain request carries %d attributes", len(plain.Attrs))
}
if plain.TxID == ([12]byte{}) {
t.Fatal("transaction id was not randomised")
}
if other := stunBindingRequestMsg(0); other.TxID == plain.TxID {
t.Fatal("two requests share a transaction id")
}
cr := stunBindingRequestMsg(stunChangeIP | stunChangePort)
v, ok := cr.attr(stunAttrChangeRequest)
if !ok || len(v) != 4 || v[3] != 0x06 {
t.Fatalf("change-request attribute = %x (ok=%v)", v, ok)
}
}
func TestSTUNServerLists(t *testing.T) {
var cn, intl int
hosts := map[string]bool{}
for _, s := range DefaultSTUNServers() {
if hosts[s.Host] {
t.Errorf("duplicate host %s", s.Host)
}
hosts[s.Host] = true
if s.Name == "" {
t.Errorf("%s has no name", s.Host)
}
switch s.Region {
case RegionCN:
cn++
case RegionIntl:
intl++
default:
t.Errorf("%s has unknown region %q", s.Host, s.Region)
}
}
if cn == 0 || intl == 0 {
t.Fatalf("default list must span both regions, got cn=%d intl=%d", cn, intl)
}
for _, s := range RFC5780Servers() {
if !hosts[s.Host] {
t.Errorf("rfc5780 server %s missing from the default list", s.Host)
}
}
}
func concat(parts ...[]byte) []byte {
var out []byte
for _, p := range parts {
out = append(out, p...)
}
return out
}
+477
View File
@@ -0,0 +1,477 @@
// Package netdiag runs network diagnostics: NAT classification via STUN, UDP
// reachability, local address enumeration, router port-mapping support
// (UPnP/NAT-PMP/PCP), overseas reachability, and public egress IP discovery
// with geolocation.
//
// The package deliberately avoids depending on tailscale.com so it stays
// usable (and testable) on its own. Tailscale's own view of the network is
// injected through the [TailscaleSource] interface.
package netdiag
import (
"context"
"log/slog"
"net/netip"
"time"
)
// Status is a coarse traffic-light verdict attached to each section of a
// [Report] so the UI can rank what deserves the user's attention.
type Status int
const (
StatusUnknown Status = iota
StatusOK
StatusWarn
StatusFail
StatusSkipped
)
func (s Status) String() string {
switch s {
case StatusOK:
return "ok"
case StatusWarn:
return "warn"
case StatusFail:
return "fail"
case StatusSkipped:
return "skipped"
default:
return "unknown"
}
}
// Region distinguishes probe targets inside mainland China from targets
// outside it. Egress results routinely differ between the two when a proxy is
// in play, and that difference is itself a diagnostic signal.
type Region string
const (
RegionCN Region = "cn"
RegionIntl Region = "intl"
)
func (r Region) String() string { return string(r) }
// ---------------------------------------------------------------------------
// Local addresses
// ---------------------------------------------------------------------------
// AddrKind classifies a local address by the scope it can reach.
type AddrKind string
const (
AddrGlobalV4 AddrKind = "global4"
AddrPrivateV4 AddrKind = "private4"
AddrCGNAT AddrKind = "cgnat"
AddrGlobalV6 AddrKind = "global6"
AddrULA AddrKind = "ula"
AddrLinkLocal AddrKind = "link-local"
AddrLoopback AddrKind = "loopback"
AddrTailscale AddrKind = "tailscale"
)
// LocalAddr is one address bound to one local interface.
type LocalAddr struct {
Iface string
Addr netip.Addr
Prefix netip.Prefix
Kind AddrKind
Up bool
MTU int
Hardware string // MAC, empty for virtual interfaces
// IsDefaultSrc reports whether the kernel picks this address as the source
// for a default-route destination.
IsDefaultSrc bool
}
// InterfaceReport enumerates every local address, so the user can see all
// IPv4/IPv6 exits the machine has.
type InterfaceReport struct {
Addrs []LocalAddr
DefaultV4Src netip.Addr
DefaultV6Src netip.Addr
HasGlobalV6 bool
Status Status
Summary string
Err string
}
// ---------------------------------------------------------------------------
// STUN / UDP / NAT
// ---------------------------------------------------------------------------
// STUNServer is one probe target.
type STUNServer struct {
Host string // "stun.miwifi.com:3478"
Name string // human label, e.g. "小米"
Region Region
}
// STUNResult records the outcome of a single binding transaction.
type STUNResult struct {
Server string
Name string
Region Region
OK bool
RTT time.Duration
// Mapped is the server-reflexive address the server saw.
Mapped netip.AddrPort
// Other is the OTHER-ADDRESS (RFC 5780) or CHANGED-ADDRESS (RFC 3489)
// alternate transport address, when advertised.
Other netip.AddrPort
// SupportsChangeReq reports whether the server honoured a CHANGE-REQUEST,
// which is required for filtering-behaviour discovery.
SupportsChangeReq bool
Software string
Err string
}
// UDPProbe is a plain "can I send and receive UDP here" datapoint.
type UDPProbe struct {
Target string
Name string
Region Region
Port int
OK bool
RTT time.Duration
Mapped netip.AddrPort
Err string
}
// UDPReport summarises UDP reachability across regions and ports.
type UDPReport struct {
V4OK bool
V6OK bool
Probes []UDPProbe
OKPorts []int
// BlockedPorts are ports where every probe failed while some other port
// succeeded — a strong hint of egress filtering rather than no UDP at all.
BlockedPorts []int
CNReachable int
CNTotal int
IntlReachabl int
IntlTotal int
Status Status
Summary string
}
// Behavior is the RFC 5780 mapping/filtering behaviour classification.
type Behavior int
const (
BehaviorUnknown Behavior = iota
BehaviorEndpointIndependent
BehaviorAddressDependent
BehaviorAddressAndPortDependent
)
func (b Behavior) String() string {
switch b {
case BehaviorEndpointIndependent:
return "endpoint-independent"
case BehaviorAddressDependent:
return "address-dependent"
case BehaviorAddressAndPortDependent:
return "address-and-port-dependent"
default:
return "unknown"
}
}
// NATType is the classic RFC 3489 name for the detected NAT, kept because it
// is what users recognise (and what game/P2P docs talk about).
type NATType string
const (
NATUnknown NATType = "unknown"
NATOpen NATType = "open" // no NAT, reflexive == local
NATFullCone NATType = "full-cone" // NAT type 1-ish
NATRestricted NATType = "restricted" // address-restricted cone
NATPortRestrict NATType = "port-restricted"
NATSymmetric NATType = "symmetric" // worst case for P2P
NATUDPBlocked NATType = "udp-blocked"
NATSymmetricFW NATType = "symmetric-firewall" // no NAT but stateful firewall
)
// NATReport is the NAT classification result.
type NATReport struct {
Type NATType
Mapping Behavior
Filtering Behavior
// Hairpin reports whether the NAT loops packets sent to its own external
// address back inside. nil when untested.
Hairpin *bool
// PortPreserving reports whether the external port equals the local port.
PortPreserving *bool
// MappedAddrs is every distinct reflexive address observed. More than one
// means the mapping varies by destination (symmetric).
MappedAddrs []netip.AddrPort
Results []STUNResult
Status Status
Summary string
Notes []string
}
// ---------------------------------------------------------------------------
// Router port mapping
// ---------------------------------------------------------------------------
// ServiceProbe is the result of probing one port-mapping protocol.
type ServiceProbe struct {
Available bool
Detail string // device name / protocol version / control URL
ExternalIP netip.Addr
RTT time.Duration
Err string
}
// PortMapReport covers UPnP IGD, NAT-PMP and PCP.
type PortMapReport struct {
Gateway netip.Addr
UPnP ServiceProbe
NATPMP ServiceProbe
PCP ServiceProbe
Status Status
Summary string
}
// ---------------------------------------------------------------------------
// Reachability
// ---------------------------------------------------------------------------
// ReachProbe is one HTTP/TCP reachability datapoint.
type ReachProbe struct {
Name string
URL string
Region Region
OK bool
StatusCode int
RTT time.Duration
// ViaProxy reports whether the request honoured the environment's proxy
// settings. Running the same target both ways reveals proxy interference.
ViaProxy bool
Network string // "tcp4", "tcp6" or "" for unforced
Err string
}
// OverseasReport captures whether traffic can leave for the wider internet,
// primarily via cp.cloudflare.com.
type OverseasReport struct {
Probes []ReachProbe
Status Status
Summary string
}
// ---------------------------------------------------------------------------
// Egress IP + geolocation
// ---------------------------------------------------------------------------
// EgressMethod is how a public address was observed. Different methods take
// different paths out of the machine, so they legitimately disagree when a
// proxy or split tunnel is active.
type EgressMethod string
const (
MethodSTUN EgressMethod = "stun" // raw UDP, bypasses HTTP proxies
MethodHTTPv4 EgressMethod = "http4" // forced IPv4, proxy bypassed
MethodHTTPv6 EgressMethod = "http6" // forced IPv6, proxy bypassed
MethodHTTPProxy EgressMethod = "http-proxy" // honours HTTP(S)_PROXY
MethodTailscale EgressMethod = "tailscale" // as seen by the tailnet
)
// EgressObservation is one "what is my public IP" answer.
type EgressObservation struct {
Method EgressMethod
Source string // server or URL that answered
Region Region
IP netip.Addr
RTT time.Duration
Err string
}
// GeoInfo is the geolocation of one public IP.
type GeoInfo struct {
IP netip.Addr
Country string // ISO code
CountryName string
Region string
City string
Org string
ASN string
Loc string
Timezone string
Provider string // which API answered
Err string
}
// EgressReport lists every public address the machine appears to use.
type EgressReport struct {
Observations []EgressObservation
Geo []GeoInfo
// UniqueIPs is the deduplicated set across all methods.
UniqueIPs []netip.Addr
// Divergent is true when the probes disagreed about our public address
// within one address family, which usually means a proxy or VPN is
// intercepting part of the traffic. Having both an IPv4 and an IPv6 egress
// is ordinary dual stack and does not set this.
Divergent bool
// Countries is the set of distinct countries seen, sorted.
Countries []string
Status Status
Summary string
}
// ---------------------------------------------------------------------------
// Tailscale's own view
// ---------------------------------------------------------------------------
// DERPLatency is the round-trip time to one DERP region.
type DERPLatency struct {
RegionID int
RegionCode string
Name string
Latency time.Duration
Preferred bool
}
// TailscaleReport mirrors the parts of tailscale's netcheck report that are
// useful here. Tri-state fields are nil when tailscale could not determine
// them.
type TailscaleReport struct {
Available bool
UDP bool
IPv4 bool
IPv6 bool
ICMPv4 bool
OSHasIPv6 bool
MappingVariesByDestIP *bool
UPnP *bool
PMP *bool
PCP *bool
CaptivePortal *bool
GlobalV4 string
GlobalV6 string
PreferredDERP string
DERP []DERPLatency
Status Status
Summary string
Err string
}
// TailscaleSource supplies tailscale's internal network view. The GUI wires
// this to a live tsnet server; it is nil when tailscale is not running yet.
type TailscaleSource interface {
Netcheck(ctx context.Context) (*TailscaleReport, error)
}
// ---------------------------------------------------------------------------
// Report + runner
// ---------------------------------------------------------------------------
// Report is the complete diagnostic result.
type Report struct {
StartedAt time.Time
FinishedAt time.Time
Duration time.Duration
Interfaces InterfaceReport
UDP UDPReport
NAT NATReport
PortMap PortMapReport
Overseas OverseasReport
Egress EgressReport
Tailscale TailscaleReport
// Headline is the single most important sentence about this report.
Headline string
// Status is the worst status across all sections.
Status Status
}
// Step identifies one unit of diagnostic work. The GUI renders these as a
// checklist while the run is in flight.
type Step struct {
Key string
Title string
}
// Steps lists every phase in execution order.
var Steps = []Step{
{Key: "iface", Title: "本机网络接口"},
{Key: "udp", Title: "UDP 连通性"},
{Key: "nat", Title: "NAT 类型"},
{Key: "portmap", Title: "UPnP / NAT-PMP / PCP"},
{Key: "overseas", Title: "境外连通性"},
{Key: "egress", Title: "出口 IP"},
{Key: "geo", Title: "IP 归属地"},
{Key: "tailscale", Title: "Tailscale 内部状态"},
}
// Progress is emitted as each step starts and finishes.
type Progress struct {
Key string
Title string
Index int
Total int
Done bool
Err string
Elapsed time.Duration
}
// Options configures a diagnostic run.
type Options struct {
Logger *slog.Logger
// OnProgress is called from the runner's goroutines; implementations must
// be safe for concurrent use.
OnProgress func(Progress)
// Tailscale is optional; when nil the tailscale section is skipped.
Tailscale TailscaleSource
// STUNServers overrides the default CN + international server list.
STUNServers []STUNServer
// IPInfoToken is an optional ipinfo.io token, raising the rate limit.
IPInfoToken string
// Timeout bounds the whole run. Zero means DefaultTimeout.
Timeout time.Duration
// SkipGeo disables outbound geolocation lookups (they leak the user's IP
// to a third party).
SkipGeo bool
}
// DefaultTimeout bounds a full diagnostic run.
const DefaultTimeout = 45 * time.Second
func (o *Options) logger() *slog.Logger {
if o.Logger != nil {
return o.Logger
}
return slog.Default()
}
func (o *Options) progress(p Progress) {
if o.OnProgress != nil {
o.OnProgress(p)
}
}
// worstStatus returns the most severe status in ss, treating StatusSkipped and
// StatusUnknown as less severe than StatusWarn.
func worstStatus(ss ...Status) Status {
rank := map[Status]int{
StatusOK: 0,
StatusSkipped: 1,
StatusUnknown: 2,
StatusWarn: 3,
StatusFail: 4,
}
worst := StatusOK
for _, s := range ss {
if rank[s] > rank[worst] {
worst = s
}
}
return worst
}
func boolPtr(b bool) *bool { return &b }