Files
tslink/netdiag/types.go
T
2026-07-26 09:39:17 +00:00

478 lines
13 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 {
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 }