add: gui and tsdiag
This commit is contained in:
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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 "否"
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
Reference in New Issue
Block a user