497 lines
14 KiB
Go
497 lines
14 KiB
Go
// 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 {
|
|
// Host is the configured "hostname:port", kept alongside the resolved
|
|
// Target so the UI can name the server rather than an anonymous address.
|
|
Host string
|
|
// Target is the address actually probed, "ip:port". A server reachable over
|
|
// both families yields one probe per family, and only this tells them apart.
|
|
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
|
|
// DivergentSTUN narrows Divergent to the case that actually breaks NAT
|
|
// traversal: STUN itself — plain UDP, the same path Tailscale punches
|
|
// through — saw more than one address in a family. That means the UDP
|
|
// egress genuinely varies per flow.
|
|
//
|
|
// Divergence seen only by the HTTP probes is a weaker signal. An HTTP proxy
|
|
// or split-tunnel rule can rewrite web traffic while leaving UDP alone, so
|
|
// it warrants a warning, not a verdict.
|
|
DivergentSTUN 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
|
|
// HeadlineStatus is the severity of Headline specifically, which is not
|
|
// always Status. Status is the worst of every section, so a report with an
|
|
// unrelated failure elsewhere would otherwise paint a merely-cautionary
|
|
// headline in alarm red and overstate what was actually found.
|
|
HeadlineStatus Status
|
|
// 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 }
|