Files
tslink/netdiag/stun.go
T
2026-07-26 20:44:49 +08:00

1370 lines
42 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package netdiag
// A minimal STUN implementation (RFC 5389) plus the behaviour-discovery bits of
// RFC 5780. tailscale.com/net/stun is deliberately not used: it parses binding
// responses only and has no CHANGE-REQUEST or OTHER-ADDRESS support, which are
// exactly what NAT filtering classification needs.
//
// Everything here parses hostile input from the open internet, so the parser
// skips attributes it does not know and returns errors — never panics — on
// truncated ones.
import (
"context"
"crypto/rand"
"encoding/binary"
"errors"
"fmt"
"log/slog"
"net"
"net/netip"
"os"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// ---------------------------------------------------------------------------
// wire format
// ---------------------------------------------------------------------------
const (
stunHeaderSize = 20
stunMaxMessage = 1500
// stunMagicCookie is the fixed cookie every RFC 5389 message carries; it is
// also the XOR key for XOR-MAPPED-ADDRESS.
stunMagicCookie uint32 = 0x2112A442
)
// message types
const (
stunBindingRequest uint16 = 0x0001
stunBindingSuccess uint16 = 0x0101
stunBindingError uint16 = 0x0111
)
// attribute types
const (
stunAttrMappedAddress uint16 = 0x0001
stunAttrChangeRequest uint16 = 0x0003
stunAttrSourceAddress uint16 = 0x0004
stunAttrChangedAddress uint16 = 0x0005
stunAttrErrorCode uint16 = 0x0009
stunAttrXORMappedAddress uint16 = 0x0020
stunAttrXORMappedAddrAlt uint16 = 0x8020 // legacy/vendor duplicate
stunAttrSoftware uint16 = 0x8022
stunAttrFingerprint uint16 = 0x8028
stunAttrResponseOrigin uint16 = 0x802b
stunAttrOtherAddress uint16 = 0x802c
)
// CHANGE-REQUEST flags.
const (
stunChangeIP byte = 0x04
stunChangePort byte = 0x02
)
// address families used by address attributes
const (
stunFamilyV4 byte = 0x01
stunFamilyV6 byte = 0x02
)
// timing and concurrency limits. Nothing here may block a GUI panel, so every
// wait is bounded twice: by these constants and by the caller's context.
const (
stunAttempts = 3
stunInterval = 700 * time.Millisecond
stunQuickAttempts = 2
stunQuickInterval = 600 * time.Millisecond
stunProbeTimeout = 3 * time.Second
stunResolveTimeout = 3 * time.Second
stunHairpinTimeout = 1500 * time.Millisecond
stunMaxInFlight = 8
natTotalBudget = 20 * time.Second
)
var (
errSTUNTruncated = errors.New("stun: truncated message")
errSTUNBadCookie = errors.New("stun: bad magic cookie")
errSTUNTimeout = errors.New("stun: no response")
errSTUNFamily = errors.New("stun: unsupported address family")
errSTUNNoAddr = errors.New("stun: host resolved to no addresses")
)
// stunAttr is one type-length-value attribute. Value excludes the padding that
// aligns the attribute to a 4-byte boundary on the wire.
type stunAttr struct {
Type uint16
Value []byte
}
// stunMessage is a decoded STUN message.
type stunMessage struct {
Type uint16
TxID [12]byte
Attrs []stunAttr
}
// encode serialises the message, padding every attribute to a 4-byte boundary
// with zeroes while keeping the declared length free of that padding.
func (m *stunMessage) encode() []byte {
body := make([]byte, 0, 64)
var hdr [4]byte
for _, a := range m.Attrs {
binary.BigEndian.PutUint16(hdr[0:2], a.Type)
binary.BigEndian.PutUint16(hdr[2:4], uint16(len(a.Value)))
body = append(body, hdr[:]...)
body = append(body, a.Value...)
if pad := (4 - len(a.Value)%4) % 4; pad > 0 {
body = append(body, make([]byte, pad)...)
}
}
out := make([]byte, stunHeaderSize, stunHeaderSize+len(body))
binary.BigEndian.PutUint16(out[0:2], m.Type)
binary.BigEndian.PutUint16(out[2:4], uint16(len(body)))
binary.BigEndian.PutUint32(out[4:8], stunMagicCookie)
copy(out[8:20], m.TxID[:])
return append(out, body...)
}
// parseSTUNMessage decodes raw. Unknown attributes are kept but never
// interpreted; a truncated header or attribute is an error rather than a panic,
// because this runs on unauthenticated packets from the internet.
func parseSTUNMessage(raw []byte) (*stunMessage, error) {
if len(raw) < stunHeaderSize {
return nil, errSTUNTruncated
}
msgLen := int(binary.BigEndian.Uint16(raw[2:4]))
if len(raw)-stunHeaderSize < msgLen {
return nil, errSTUNTruncated
}
// RFC 5389 §6 requires the magic cookie. Without this check any 20-byte
// datagram whose bytes 8..20 happen to match our transaction ID would be
// accepted, and its XOR-MAPPED-ADDRESS would be de-XORed with a key the
// sender never used — producing a silently wrong reflexive address rather
// than an error.
if binary.BigEndian.Uint32(raw[4:8]) != stunMagicCookie {
return nil, errSTUNBadCookie
}
m := &stunMessage{Type: binary.BigEndian.Uint16(raw[0:2])}
copy(m.TxID[:], raw[8:20])
body := raw[stunHeaderSize : stunHeaderSize+msgLen]
for off := 0; off < len(body); {
if len(body)-off < 4 {
return nil, errSTUNTruncated
}
typ := binary.BigEndian.Uint16(body[off : off+2])
vlen := int(binary.BigEndian.Uint16(body[off+2 : off+4]))
off += 4
if len(body)-off < vlen {
return nil, errSTUNTruncated
}
val := make([]byte, vlen)
copy(val, body[off:off+vlen])
m.Attrs = append(m.Attrs, stunAttr{Type: typ, Value: val})
off += vlen
// Missing trailing padding on the last attribute is tolerated: some
// servers omit it and the message is still perfectly usable.
if pad := (4 - vlen%4) % 4; pad > 0 {
if len(body)-off < pad {
break
}
off += pad
}
}
return m, nil
}
// attr returns the value of the first attribute of type t.
func (m *stunMessage) attr(t uint16) ([]byte, bool) {
for _, a := range m.Attrs {
if a.Type == t {
return a.Value, true
}
}
return nil, false
}
// mappedAddr returns the server-reflexive address, preferring the XOR forms
// (which survive NATs that rewrite payloads) over the plain one.
func (m *stunMessage) mappedAddr() (netip.AddrPort, bool) {
for _, t := range []uint16{stunAttrXORMappedAddress, stunAttrXORMappedAddrAlt} {
if v, ok := m.attr(t); ok {
if ap, err := stunDecodeAddr(v, true, m.TxID); err == nil {
return ap, true
}
}
}
if v, ok := m.attr(stunAttrMappedAddress); ok {
if ap, err := stunDecodeAddr(v, false, m.TxID); err == nil {
return ap, true
}
}
return netip.AddrPort{}, false
}
// otherAddr returns the alternate transport address the server advertises:
// OTHER-ADDRESS (RFC 5780) or, for older servers, CHANGED-ADDRESS (RFC 3489).
func (m *stunMessage) otherAddr() (netip.AddrPort, bool) {
for _, t := range []uint16{stunAttrOtherAddress, stunAttrChangedAddress} {
if v, ok := m.attr(t); ok {
if ap, err := stunDecodeAddr(v, false, m.TxID); err == nil && ap.IsValid() {
return ap, true
}
}
}
return netip.AddrPort{}, false
}
// software returns the SOFTWARE attribute, trimmed, or "".
func (m *stunMessage) software() string {
v, ok := m.attr(stunAttrSoftware)
if !ok {
return ""
}
return strings.TrimSpace(strings.ToValidUTF8(string(v), ""))
}
// errorCode decodes ERROR-CODE from an error response.
func (m *stunMessage) errorCode() (int, string, bool) {
v, ok := m.attr(stunAttrErrorCode)
if !ok || len(v) < 4 {
return 0, "", false
}
code := int(v[2]&0x07)*100 + int(v[3])
return code, strings.TrimSpace(strings.ToValidUTF8(string(v[4:]), "")), true
}
// stunDecodeAddr decodes an address attribute payload:
// reserved byte, family, port, address. When xor is set the port is XORed with
// the high half of the magic cookie and the address with the cookie (IPv4) or
// the cookie followed by the transaction ID (IPv6).
func stunDecodeAddr(v []byte, xor bool, txid [12]byte) (netip.AddrPort, error) {
if len(v) < 4 {
return netip.AddrPort{}, errSTUNTruncated
}
var n int
switch v[1] {
case stunFamilyV4:
n = 4
case stunFamilyV6:
n = 16
default:
return netip.AddrPort{}, errSTUNFamily
}
if len(v) < 4+n {
return netip.AddrPort{}, errSTUNTruncated
}
port := binary.BigEndian.Uint16(v[2:4])
raw := make([]byte, n)
copy(raw, v[4:4+n])
if xor {
port ^= uint16(stunMagicCookie >> 16)
var mask [16]byte
binary.BigEndian.PutUint32(mask[0:4], stunMagicCookie)
copy(mask[4:], txid[:])
for i := range raw {
raw[i] ^= mask[i]
}
}
addr, ok := netip.AddrFromSlice(raw)
if !ok {
return netip.AddrPort{}, errSTUNFamily
}
return netip.AddrPortFrom(addr.Unmap(), port), nil
}
// stunEncodeAddr is the inverse of [stunDecodeAddr].
func stunEncodeAddr(ap netip.AddrPort, xor bool, txid [12]byte) []byte {
addr := ap.Addr().Unmap()
raw := addr.AsSlice()
fam := stunFamilyV6
if addr.Is4() {
fam = stunFamilyV4
}
port := ap.Port()
if xor {
port ^= uint16(stunMagicCookie >> 16)
var mask [16]byte
binary.BigEndian.PutUint32(mask[0:4], stunMagicCookie)
copy(mask[4:], txid[:])
for i := range raw {
raw[i] ^= mask[i]
}
}
out := make([]byte, 4, 4+len(raw))
out[1] = fam
binary.BigEndian.PutUint16(out[2:4], port)
return append(out, raw...)
}
// stunBindingRequestMsg builds a binding request with a fresh transaction ID,
// optionally carrying a CHANGE-REQUEST attribute built from the given flags.
func stunBindingRequestMsg(change byte) *stunMessage {
m := &stunMessage{Type: stunBindingRequest}
// crypto/rand.Read is documented never to fail.
_, _ = rand.Read(m.TxID[:])
if change != 0 {
v := make([]byte, 4)
v[3] = change
m.Attrs = append(m.Attrs, stunAttr{Type: stunAttrChangeRequest, Value: v})
}
return m
}
// stunResponseFor parses raw and reports whether it is a binding response
// belonging to txid. Anything else — garbage, a stray packet, or a response to
// a different transaction — is rejected so the transactor keeps waiting.
func stunResponseFor(raw []byte, txid [12]byte) (*stunMessage, bool) {
msg, err := parseSTUNMessage(raw)
if err != nil {
return nil, false
}
if msg.TxID != txid {
return nil, false
}
if msg.Type != stunBindingSuccess && msg.Type != stunBindingError {
return nil, false
}
return msg, true
}
// ---------------------------------------------------------------------------
// transport
// ---------------------------------------------------------------------------
// stunLog falls back to the default logger when the caller passed nil.
func stunLog(l *slog.Logger) *slog.Logger {
if l == nil {
return slog.Default()
}
return l
}
// stunListen binds an unconnected wildcard UDP socket of the requested family.
func stunListen(v4 bool) (*net.UDPConn, error) {
if v4 {
return net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
}
return net.ListenUDP("udp6", &net.UDPAddr{IP: net.IPv6unspecified, Port: 0})
}
func stunUnmap(ap netip.AddrPort) netip.AddrPort {
return netip.AddrPortFrom(ap.Addr().Unmap(), ap.Port())
}
// stunTransact sends req on conn and waits for the matching response,
// retransmitting up to attempts times roughly interval apart. Read deadlines
// are clamped to the context deadline and the context also aborts an in-flight
// read, so this can never outlive its caller.
func stunTransact(ctx context.Context, conn *net.UDPConn, dst netip.AddrPort, req *stunMessage, attempts int, interval time.Duration) (*stunMessage, netip.AddrPort, time.Duration, error) {
if attempts <= 0 {
attempts = 1
}
if interval <= 0 {
interval = stunInterval
}
raw := req.encode()
buf := make([]byte, stunMaxMessage)
stop := context.AfterFunc(ctx, func() { _ = conn.SetReadDeadline(time.Now()) })
defer stop()
defer func() { _ = conn.SetReadDeadline(time.Time{}) }()
for i := 0; i < attempts; i++ {
if err := ctx.Err(); err != nil {
return nil, netip.AddrPort{}, 0, err
}
sent := time.Now()
if _, err := conn.WriteToUDPAddrPort(raw, dst); err != nil {
return nil, netip.AddrPort{}, 0, err
}
deadline := sent.Add(interval)
if d, ok := ctx.Deadline(); ok && d.Before(deadline) {
deadline = d
}
for time.Now().Before(deadline) {
if err := conn.SetReadDeadline(deadline); err != nil {
return nil, netip.AddrPort{}, 0, err
}
n, from, err := conn.ReadFromUDPAddrPort(buf)
if err != nil {
if errors.Is(err, os.ErrDeadlineExceeded) {
break
}
return nil, netip.AddrPort{}, 0, err
}
msg, ok := stunResponseFor(buf[:n], req.TxID)
if !ok {
continue // stray packet or foreign transaction id
}
return msg, stunUnmap(from), time.Since(sent), nil
}
if ctx.Err() != nil {
break
}
}
if err := ctx.Err(); err != nil {
return nil, netip.AddrPort{}, 0, err
}
return nil, netip.AddrPort{}, 0, errSTUNTimeout
}
// stunQuery runs one transaction against dst on a throwaway socket of the
// destination's own family.
func stunQuery(ctx context.Context, dst netip.AddrPort, change byte, attempts int, interval time.Duration) (*stunMessage, netip.AddrPort, time.Duration, error) {
conn, err := stunListen(dst.Addr().Is4())
if err != nil {
return nil, netip.AddrPort{}, 0, err
}
defer conn.Close()
return stunTransact(ctx, conn, dst, stunBindingRequestMsg(change), attempts, interval)
}
// stunResolve splits a "host:port" target and resolves the host to IP
// addresses, IPv4 first. Both families are returned so a dead AAAA record can
// never mask a working A record, and so callers can report which family worked.
func stunResolve(ctx context.Context, hostport string) ([]netip.Addr, uint16, error) {
host, portStr, err := net.SplitHostPort(hostport)
if err != nil {
return nil, 0, err
}
portNum, err := strconv.Atoi(portStr)
if err != nil || portNum <= 0 || portNum > 65535 {
return nil, 0, fmt.Errorf("stun: invalid port %q", portStr)
}
port := uint16(portNum)
if a, err := netip.ParseAddr(host); err == nil {
return []netip.Addr{a.Unmap()}, port, nil
}
rctx, cancel := context.WithTimeout(ctx, stunResolveTimeout)
defer cancel()
addrs, err := net.DefaultResolver.LookupNetIP(rctx, "ip", host)
if err != nil {
return nil, port, err
}
var v4, v6 []netip.Addr
for _, a := range addrs {
a = a.Unmap()
if a.Is4() {
v4 = append(v4, a)
} else {
v6 = append(v6, a)
}
}
// Sort so repeated runs probe the same address despite DNS round-robin.
sort.Slice(v4, func(i, j int) bool { return v4[i].Compare(v4[j]) < 0 })
sort.Slice(v6, func(i, j int) bool { return v6[i].Compare(v6[j]) < 0 })
out := append(v4, v6...)
if len(out) == 0 {
return nil, port, errSTUNNoAddr
}
return out, port, nil
}
// stunHasLocalIP reports whether a is bound to one of this machine's
// interfaces, i.e. the reflexive address is not translated at all.
func stunHasLocalIP(a netip.Addr) bool {
if !a.IsValid() {
return false
}
addrs, err := net.InterfaceAddrs()
if err != nil {
return false
}
for _, ia := range addrs {
var ip net.IP
switch v := ia.(type) {
case *net.IPNet:
ip = v.IP
case *net.IPAddr:
ip = v.IP
default:
continue
}
if got, ok := netip.AddrFromSlice(ip); ok && got.Unmap() == a {
return true
}
}
return false
}
func sortAddrPorts(s []netip.AddrPort) {
sort.Slice(s, func(i, j int) bool {
if c := s[i].Addr().Compare(s[j].Addr()); c != 0 {
return c < 0
}
return s[i].Port() < s[j].Port()
})
}
// ---------------------------------------------------------------------------
// ProbeSTUN
// ---------------------------------------------------------------------------
// ProbeSTUN runs one binding transaction against each server, in parallel.
//
// Results are returned in the same order as servers so the UI does not jitter
// between refreshes. An empty servers list falls back to [DefaultSTUNServers].
func ProbeSTUN(ctx context.Context, servers []STUNServer, logger *slog.Logger) []STUNResult {
log := stunLog(logger).With(slog.String("from", "netdiag/stun"))
if len(servers) == 0 {
servers = DefaultSTUNServers()
}
out := make([]STUNResult, len(servers))
sem := make(chan struct{}, stunMaxInFlight)
var mu sync.Mutex
var wg sync.WaitGroup
for i, srv := range servers {
wg.Add(1)
go func() {
defer wg.Done()
res := STUNResult{Server: srv.Host, Name: srv.Name, Region: srv.Region}
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
res.Err = ctx.Err().Error()
mu.Lock()
out[i] = res
mu.Unlock()
return
}
res = stunProbeServer(ctx, srv, log)
mu.Lock()
out[i] = res
mu.Unlock()
}()
}
wg.Wait()
return out
}
// stunProbeServer probes one server, trying every resolved address (IPv4 first)
// until one answers.
func stunProbeServer(ctx context.Context, srv STUNServer, log *slog.Logger) STUNResult {
res := STUNResult{Server: srv.Host, Name: srv.Name, Region: srv.Region}
addrs, port, err := stunResolve(ctx, srv.Host)
if err != nil {
res.Err = err.Error()
log.With(slog.String("server", srv.Host), slog.String("error", err.Error())).Debug("stun resolve failed")
return res
}
for _, a := range addrs {
dst := netip.AddrPortFrom(a, port)
pctx, cancel := context.WithTimeout(ctx, stunProbeTimeout)
msg, _, rtt, err := stunQuery(pctx, dst, 0, stunAttempts, stunInterval)
cancel()
if err != nil {
res.Err = err.Error()
continue
}
if code, reason, ok := msg.errorCode(); ok {
res.Err = fmt.Sprintf("stun error %d %s", code, reason)
continue
}
mapped, hasMapped := msg.mappedAddr()
if !hasMapped {
res.Err = "stun: response without mapped address"
continue
}
res.OK = true
res.Err = ""
res.RTT = rtt
res.Mapped = mapped
res.Software = msg.software()
if other, ok := msg.otherAddr(); ok {
res.Other = other
// Only worth asking for a CHANGE-REQUEST when the server has an
// alternate address to answer from; a silent drop otherwise costs a
// full timeout and tells us nothing.
cctx, ccancel := context.WithTimeout(ctx, stunQuickInterval*time.Duration(stunQuickAttempts+1))
crMsg, crFrom, _, crErr := stunQuery(cctx, dst, stunChangeIP|stunChangePort, stunQuickAttempts, stunQuickInterval)
ccancel()
res.SupportsChangeReq = crErr == nil && crMsg != nil && crFrom != dst
}
log.With(
slog.String("server", srv.Host),
slog.String("mapped", mapped.String()),
slog.Duration("rtt", rtt),
).Debug("stun binding ok")
break
}
return res
}
// ---------------------------------------------------------------------------
// ProbeUDP
// ---------------------------------------------------------------------------
// udpAttempt pairs a probe with the address family it used; the contract's
// UDPProbe has no family field, so it is tracked alongside.
type udpAttempt struct {
probe UDPProbe
v6 bool
// resolveFailed marks a probe that never reached the send stage because
// the hostname would not resolve. Its port must not count towards the
// blocked-port analysis: a failed A-record lookup says nothing about
// whether UDP on that port can leave the machine.
resolveFailed bool
}
// ProbeUDP reports whether UDP can leave the machine at all, over v4 and v6,
// and on which destination ports.
//
// Each server is resolved to both A and AAAA records and probed once per
// family, so a broken IPv6 path is visible instead of being hidden behind a
// working IPv4 one. Probes run in parallel; the report is ordered by the input
// server list.
func ProbeUDP(ctx context.Context, servers []STUNServer, logger *slog.Logger) UDPReport {
log := stunLog(logger).With(slog.String("from", "netdiag/udp"))
if len(servers) == 0 {
servers = DefaultSTUNServers()
}
var rep UDPReport
per := make([][]udpAttempt, len(servers))
sem := make(chan struct{}, stunMaxInFlight)
var mu sync.Mutex
var wg sync.WaitGroup
for i, srv := range servers {
wg.Add(1)
go func() {
defer wg.Done()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
// Record the cancellation instead of dropping the server:
// leaving per[i] nil would still count it towards the
// CN/international totals while producing no evidence row, so
// the report would claim servers were unreachable that were
// never contacted.
mu.Lock()
per[i] = []udpAttempt{{
probe: UDPProbe{
Host: srv.Host,
Target: srv.Host,
Name: srv.Name,
Region: srv.Region,
Err: ctx.Err().Error(),
},
resolveFailed: true,
}}
mu.Unlock()
return
}
got := stunProbeUDPServer(ctx, srv, log)
mu.Lock()
per[i] = got
mu.Unlock()
}()
}
wg.Wait()
portOK := map[int]bool{}
portSeen := map[int]bool{}
for i, srv := range servers {
serverOK := false
for _, at := range per[i] {
rep.Probes = append(rep.Probes, at.probe)
if at.probe.Port > 0 && !at.resolveFailed {
portSeen[at.probe.Port] = true
}
if !at.probe.OK {
continue
}
serverOK = true
if at.probe.Port > 0 {
portOK[at.probe.Port] = true
}
if at.v6 {
rep.V6OK = true
} else {
rep.V4OK = true
}
}
switch srv.Region {
case RegionCN:
rep.CNTotal++
if serverOK {
rep.CNReachable++
}
default:
rep.IntlTotal++
if serverOK {
rep.IntlReachabl++
}
}
}
for p := range portSeen {
if portOK[p] {
rep.OKPorts = append(rep.OKPorts, p)
}
}
// A port only counts as blocked when some other port worked; otherwise UDP
// as a whole is down and singling out ports would be misleading.
if len(rep.OKPorts) > 0 {
for p := range portSeen {
if !portOK[p] {
rep.BlockedPorts = append(rep.BlockedPorts, p)
}
}
}
sort.Ints(rep.OKPorts)
sort.Ints(rep.BlockedPorts)
rep.Status, rep.Summary = stunUDPVerdict(&rep)
log.With(
slog.Bool("v4", rep.V4OK),
slog.Bool("v6", rep.V6OK),
slog.Int("cn", rep.CNReachable),
slog.Int("intl", rep.IntlReachabl),
).Info("udp reachability probed")
return rep
}
// stunProbeUDPServer probes one server once per resolved address family.
func stunProbeUDPServer(ctx context.Context, srv STUNServer, log *slog.Logger) []udpAttempt {
addrs, port, err := stunResolve(ctx, srv.Host)
if err != nil {
return []udpAttempt{{
probe: UDPProbe{
Host: srv.Host,
Target: srv.Host,
Name: srv.Name,
Region: srv.Region,
Port: int(port),
Err: err.Error(),
},
resolveFailed: true,
}}
}
var out []udpAttempt
var doneV4, doneV6 bool
for _, a := range addrs {
v6 := !a.Is4()
if (v6 && doneV6) || (!v6 && doneV4) {
continue
}
if v6 {
doneV6 = true
} else {
doneV4 = true
}
dst := netip.AddrPortFrom(a, port)
p := UDPProbe{Host: srv.Host, Target: dst.String(), Name: srv.Name, Region: srv.Region, Port: int(port)}
pctx, cancel := context.WithTimeout(ctx, stunProbeTimeout)
msg, _, rtt, err := stunQuery(pctx, dst, 0, stunAttempts, stunInterval)
cancel()
switch {
case err != nil:
p.Err = err.Error()
default:
p.OK = true
p.RTT = rtt
if m, ok := msg.mappedAddr(); ok {
p.Mapped = m
}
}
out = append(out, udpAttempt{probe: p, v6: v6})
}
return out
}
// stunUDPVerdict turns the counters into a traffic light and one Chinese line.
func stunUDPVerdict(rep *UDPReport) (Status, string) {
if !rep.V4OK && !rep.V6OK {
return StatusFail, fmt.Sprintf("UDP 完全不通:%d 次探测全部失败,P2P 打洞不可用,只能走 TCP/DERP 中继", len(rep.Probes))
}
var b strings.Builder
fmt.Fprintf(&b, "UDP 可用(IPv4 %sIPv6 %s);境内 %d/%d,境外 %d/%d",
stunOKText(rep.V4OK), stunOKText(rep.V6OK),
rep.CNReachable, rep.CNTotal, rep.IntlReachabl, rep.IntlTotal)
status := StatusOK
if len(rep.BlockedPorts) > 0 {
parts := make([]string, 0, len(rep.BlockedPorts))
for _, p := range rep.BlockedPorts {
parts = append(parts, strconv.Itoa(p))
}
fmt.Fprintf(&b, ";端口 %s 疑似被封锁", strings.Join(parts, "/"))
status = StatusWarn
}
if !rep.V4OK {
b.WriteString("IPv4 UDP 不通,多数对端将无法直连")
status = StatusWarn
}
if rep.IntlTotal > 0 && rep.IntlReachabl == 0 {
b.WriteString(";境外 STUN 全部不可达,出境 UDP 可能被拦截")
status = StatusWarn
}
return status, b.String()
}
func stunOKText(ok bool) string {
if ok {
return "通"
}
return "不通"
}
// ---------------------------------------------------------------------------
// ClassifyNAT
// ---------------------------------------------------------------------------
// stunTarget is one resolved probe endpoint.
type stunTarget struct {
srv STUNServer
dst netip.AddrPort
}
// natClassifier holds the state of one classification run. Every mapping test
// reuses the same socket, because the mapping is a property of that socket.
type natClassifier struct {
ctx context.Context
log *slog.Logger
conn *net.UDPConn
localPort uint16
rep *NATReport
seen map[netip.AddrPort]bool
}
// ClassifyNAT performs RFC 5780 behaviour discovery and maps the result onto
// the classic RFC 3489 NAT names.
//
// The whole run is bounded by the caller's context and by an internal budget,
// and every individual failure degrades into a note instead of aborting: a
// partial classification still renders.
func ClassifyNAT(ctx context.Context, servers []STUNServer, logger *slog.Logger) NATReport {
log := stunLog(logger).With(slog.String("from", "netdiag/nat"))
if len(servers) == 0 {
servers = DefaultSTUNServers()
}
rep := NATReport{Type: NATUnknown, Status: StatusUnknown}
ctx, cancel := context.WithTimeout(ctx, natTotalBudget)
defer cancel()
// 1. one socket for every mapping test.
conn, err := stunListen(true)
if err != nil {
rep.Status = StatusFail
rep.Summary = "无法创建 UDP 套接字,NAT 检测中止:" + err.Error()
rep.Notes = append(rep.Notes, err.Error())
return rep
}
defer conn.Close()
c := &natClassifier{ctx: ctx, log: log, conn: conn, rep: &rep, seen: map[netip.AddrPort]bool{}}
if la, ok := conn.LocalAddr().(*net.UDPAddr); ok {
c.localPort = uint16(la.Port)
}
targets := stunResolveTargets(ctx, servers, log)
if len(targets) == 0 {
rep.Status = StatusFail
rep.Summary = "没有可用的 STUN 服务器地址(DNS 解析全部失败)"
rep.Notes = append(rep.Notes, "所有 STUN 服务器的 IPv4 地址解析失败")
return rep
}
// 2. test I: first server that answers at all.
primary, firstMsg, ok := c.firstAnswer(targets, nil)
if !ok {
rep.Type = NATUDPBlocked
rep.Status = StatusFail
rep.Summary = "UDP 出站被完全阻断:所有 STUN 服务器均无响应,P2P 打洞不可用,连接将全程回退到 DERP 中继"
rep.Notes = append(rep.Notes, "检查防火墙是否放行 UDP 出站,或运营商是否封锁 UDP")
rep.MappedAddrs = c.mappedList()
log.Warn("no stun server answered, udp appears blocked")
return rep
}
firstMapped, _ := firstMsg.mappedAddr()
rep.PortPreserving = boolPtr(firstMapped.Port() == c.localPort)
// 3. is there a NAT at all?
noNAT := firstMapped.Port() == c.localPort && stunHasLocalIP(firstMapped.Addr())
if noNAT {
rep.Mapping = BehaviorEndpointIndependent
rep.Notes = append(rep.Notes, "反射地址与本机地址一致,链路上没有 NAT")
} else {
// 4. mapping behaviour, from the same socket to a different server IP.
rep.Mapping = c.mappingBehavior(targets, primary, firstMsg, firstMapped)
}
// 5. filtering behaviour; needs a real RFC 5780 server.
rep.Filtering = c.filteringBehavior()
if rep.Filtering == BehaviorUnknown {
rep.Notes = append(rep.Notes,
"没有 STUN 服务器响应 CHANGE-REQUESTGoogle/Cloudflare 等只支持基本绑定请求),无法判定过滤行为")
}
// 6. legacy name.
rep.Type = stunLegacyNATType(noNAT, rep.Mapping, rep.Filtering)
if rep.Type == NATUnknown && rep.Mapping == BehaviorEndpointIndependent {
rep.Notes = append(rep.Notes,
"映射行为为端点无关,但过滤行为未知,无法安全地断言为端口限制锥型 NAT")
}
// 7. hairpinning, best effort. Left nil when the test could not run.
if firstMapped.IsValid() {
if got, ok := c.hairpin(firstMapped); ok {
rep.Hairpin = boolPtr(got)
} else {
rep.Notes = append(rep.Notes, "发夹回环测试未能执行(时间预算已用尽或无法创建探测套接字)")
}
}
// 9. distinct reflexive addresses, sorted.
rep.MappedAddrs = c.mappedList()
rep.Status, rep.Summary = stunNATVerdict(rep.Type, rep.Mapping, rep.Filtering)
log.With(
slog.String("type", string(rep.Type)),
slog.String("mapping", rep.Mapping.String()),
slog.String("filtering", rep.Filtering.String()),
slog.Int("mapped", len(rep.MappedAddrs)),
).Info("nat classified")
return rep
}
// stunResolveTargets resolves each server to a single IPv4 endpoint, in
// parallel. IPv6 is skipped here: the classifier binds one IPv4 socket so the
// mapping under test is well defined.
func stunResolveTargets(ctx context.Context, servers []STUNServer, log *slog.Logger) []stunTarget {
out := make([]stunTarget, len(servers))
found := make([]bool, len(servers))
sem := make(chan struct{}, stunMaxInFlight)
var mu sync.Mutex
var wg sync.WaitGroup
for i, srv := range servers {
wg.Add(1)
go func() {
defer wg.Done()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
return
}
addrs, port, err := stunResolve(ctx, srv.Host)
if err != nil {
log.With(slog.String("server", srv.Host), slog.String("error", err.Error())).Debug("resolve failed")
return
}
for _, a := range addrs {
if !a.Is4() {
continue
}
mu.Lock()
out[i] = stunTarget{srv: srv, dst: netip.AddrPortFrom(a, port)}
found[i] = true
mu.Unlock()
return
}
}()
}
wg.Wait()
res := make([]stunTarget, 0, len(servers))
for i := range out {
if found[i] {
res = append(res, out[i])
}
}
return res
}
// query runs one transaction on the shared socket and records a STUNResult, so
// even failed steps show up in the report.
func (c *natClassifier) query(t stunTarget, change byte, attempts int, interval time.Duration) (*stunMessage, netip.AddrPort, bool) {
res := STUNResult{Server: t.dst.String(), Name: t.srv.Name, Region: t.srv.Region}
msg, from, rtt, err := stunTransact(c.ctx, c.conn, t.dst, stunBindingRequestMsg(change), attempts, interval)
if err != nil {
res.Err = err.Error()
c.rep.Results = append(c.rep.Results, res)
return nil, netip.AddrPort{}, false
}
res.RTT = rtt
res.Software = msg.software()
if code, reason, ok := msg.errorCode(); ok {
res.Err = fmt.Sprintf("stun error %d %s", code, reason)
c.rep.Results = append(c.rep.Results, res)
return nil, from, false
}
res.OK = true
if m, ok := msg.mappedAddr(); ok {
res.Mapped = m
c.seen[m] = true
}
if o, ok := msg.otherAddr(); ok {
res.Other = o
}
// A CHANGE-REQUEST only counts as honoured when the answer really came back
// from another transport address.
res.SupportsChangeReq = change != 0 && from != t.dst
c.rep.Results = append(c.rep.Results, res)
return msg, from, true
}
// firstAnswer queries targets in order, skipping any whose IP is in skip, and
// returns the first one that answers with a mapped address.
func (c *natClassifier) firstAnswer(targets []stunTarget, skip []netip.Addr) (stunTarget, *stunMessage, bool) {
for _, t := range targets {
if c.ctx.Err() != nil {
break
}
skipped := false
for _, s := range skip {
if t.dst.Addr() == s {
skipped = true
break
}
}
if skipped {
continue
}
msg, _, ok := c.query(t, 0, stunAttempts, stunInterval)
if !ok {
continue
}
if _, has := msg.mappedAddr(); !has {
continue
}
return t, msg, true
}
return stunTarget{}, nil, false
}
// mappingBehavior implements RFC 5780 §4.3 over the shared socket.
//
// The three tests must vary exactly one thing at a time:
//
// Test I primaryIP:primaryPort -> mapped1
// Test II alternateIP:primaryPort -> mapped2 (destination IP changed)
// Test III alternateIP:alternatePort -> mapped3 (destination port changed)
//
// mapped2 == mapped1 means endpoint-independent. Otherwise mapped3 == mapped2
// means address-dependent, and a third distinct mapping means
// address-and-port-dependent.
//
// That sequence needs one server advertising an OTHER-ADDRESS, because an
// RFC 5780 server listens on all four combinations of its primary/alternate
// address and port. Using two *different* servers for tests II and III would
// change the IP and the port at once, which makes address-dependent
// unreachable — every address-dependent NAT would be reported as
// address-and-port-dependent.
func (c *natClassifier) mappingBehavior(targets []stunTarget, primary stunTarget, firstMsg *stunMessage, firstMapped netip.AddrPort) Behavior {
// Preferred: run the full sequence against a server that advertises an
// alternate transport address. Try the primary first, then the others.
if b, ok := c.mappingViaAlternate(primary, firstMsg, firstMapped); ok {
return b
}
for _, t := range targets {
if c.ctx.Err() != nil {
break
}
if t.dst == primary.dst {
continue
}
msg, _, ok := c.query(t, 0, stunQuickAttempts, stunInterval)
if !ok {
continue
}
mapped, has := msg.mappedAddr()
if !has {
continue
}
if b, ok := c.mappingViaAlternate(t, msg, mapped); ok {
return b
}
}
// No server offered an alternate address. A second server still separates
// endpoint-independent from the rest, which is the distinction that
// actually decides whether hole punching can work.
second, secondMsg, ok := c.firstAnswer(targets, []netip.Addr{primary.dst.Addr()})
if !ok {
c.rep.Notes = append(c.rep.Notes, "只有一台 STUN 服务器可达,无法判定映射行为")
return BehaviorUnknown
}
secondMapped, _ := secondMsg.mappedAddr()
if secondMapped == firstMapped {
return BehaviorEndpointIndependent
}
_ = second
c.rep.Notes = append(c.rep.Notes,
"映射随目标地址变化,但没有 STUN 服务器提供备用端口,无法区分地址相关与地址端口相关映射")
return BehaviorUnknown
}
// mappingViaAlternate runs RFC 5780 tests II and III against one server's
// alternate transport address. It reports ok=false when the server advertises
// no usable OTHER-ADDRESS or stops answering, so the caller can try another
// server rather than record a guess.
func (c *natClassifier) mappingViaAlternate(base stunTarget, baseMsg *stunMessage, baseMapped netip.AddrPort) (Behavior, bool) {
alt, has := baseMsg.otherAddr()
if !has || !alt.Addr().Is4() {
return BehaviorUnknown, false
}
// Both coordinates must actually differ, otherwise there is no second
// dimension to test.
if alt.Addr() == base.dst.Addr() || alt.Port() == base.dst.Port() {
return BehaviorUnknown, false
}
name := func(suffix string) STUNServer {
srv := base.srv
srv.Name = srv.Name + suffix
return srv
}
// Test II: alternate IP, same port. Only the destination IP changed.
t2 := stunTarget{
srv: name("(备用地址)"),
dst: netip.AddrPortFrom(alt.Addr(), base.dst.Port()),
}
msg2, _, ok := c.query(t2, 0, stunQuickAttempts, stunInterval)
if !ok {
return BehaviorUnknown, false
}
mapped2, has := msg2.mappedAddr()
if !has {
return BehaviorUnknown, false
}
if mapped2 == baseMapped {
return BehaviorEndpointIndependent, true
}
// Test III: same alternate IP, alternate port. Only the port changed
// relative to test II.
t3 := stunTarget{srv: name("(备用地址+端口)"), dst: alt}
msg3, _, ok := c.query(t3, 0, stunQuickAttempts, stunInterval)
if !ok {
return BehaviorUnknown, false
}
mapped3, has := msg3.mappedAddr()
if !has {
return BehaviorUnknown, false
}
if mapped3 == mapped2 {
return BehaviorAddressDependent, true
}
return BehaviorAddressAndPortDependent, true
}
// filteringBehavior runs RFC 5780 tests II and III (CHANGE-REQUEST) against the
// first server that both answers a plain binding request and advertises
// OTHER-ADDRESS. Servers that ignore CHANGE-REQUEST are skipped rather than
// interpreted, because a silent drop is indistinguishable from filtering.
func (c *natClassifier) filteringBehavior() Behavior {
var (
ignoring []string // servers that answered but ignored CHANGE-REQUEST
silent string // first server that answered plain bindings but no change requests
)
for _, srv := range RFC5780Servers() {
if c.ctx.Err() != nil {
break
}
addrs, port, err := stunResolve(c.ctx, srv.Host)
if err != nil {
continue
}
var dst netip.AddrPort
for _, a := range addrs {
if a.Is4() {
dst = netip.AddrPortFrom(a, port)
break
}
}
if !dst.IsValid() {
continue
}
t := stunTarget{srv: srv, dst: dst}
msg, _, ok := c.query(t, 0, stunQuickAttempts, stunInterval)
if !ok {
continue
}
if _, has := msg.otherAddr(); !has {
continue // no alternate address: cannot answer a CHANGE-REQUEST
}
// Test II: ask for a reply from another IP *and* port.
//
// Three outcomes have to be told apart. A reply from a different
// transport address proves the server honoured the request and that
// nothing filtered it. A reply from the address we asked proves the
// server ignored the attribute, which says nothing about filtering —
// treating it as "filtered" is how a full-cone NAT ends up reported as
// port-restricted. No reply at all is only meaningful once we know the
// server honours CHANGE-REQUEST.
_, from, ok := c.query(t, stunChangeIP|stunChangePort, stunQuickAttempts, stunQuickInterval)
if ok && from != dst {
return BehaviorEndpointIndependent
}
if ok {
ignoring = append(ignoring, srv.Host)
continue
}
// Test III: another port on the same IP. A reply here also proves the
// server implements CHANGE-REQUEST, which retroactively makes the
// silence in test II real evidence of address-dependent filtering.
_, from, ok = c.query(t, stunChangePort, stunQuickAttempts, stunQuickInterval)
if ok && from != dst {
return BehaviorAddressDependent
}
if ok {
ignoring = append(ignoring, srv.Host)
continue
}
// Silence on both. This is what a port-restricted NAT looks like, but
// it is also what a server that silently drops CHANGE-REQUEST looks
// like. Remember it and keep looking for a server that demonstrably
// honours the attribute; only fall back to this if none does.
if silent == "" {
silent = srv.Host
}
}
if silent != "" {
c.rep.Notes = append(c.rep.Notes, fmt.Sprintf(
"过滤行为依据 %s 对 CHANGE-REQUEST 的静默推断:该服务器通告了备用地址且能回应普通绑定请求,"+
"但两次改址请求均无回应,最可能是本地 NAT 拦截", silent))
return BehaviorAddressAndPortDependent
}
if len(ignoring) > 0 {
c.rep.Notes = append(c.rep.Notes, fmt.Sprintf(
"%s 忽略了 CHANGE-REQUEST(仍从原地址回包),无法判定过滤行为",
strings.Join(ignoring, "、")))
} else {
c.rep.Notes = append(c.rep.Notes,
"没有可用的 RFC 5780 服务器,无法判定过滤行为")
}
return BehaviorUnknown
}
// hairpin sends a binding request to our own reflexive address from a second
// socket and checks whether the first socket sees it. Best effort: any failure
// simply means "no hairpinning observed" and never fails the classification.
// It returns ok=false when the test could not be performed at all, so the
// caller can leave NATReport.Hairpin nil. That distinction matters: this test
// runs last, after up to 20s of serial STUN transactions, so an exhausted
// budget is the common case — and reporting "hairpinning not supported" for a
// probe that never sent a packet is a wrong answer, not a cautious one.
func (c *natClassifier) hairpin(mapped netip.AddrPort) (result bool, ok bool) {
if !mapped.IsValid() || !mapped.Addr().Is4() {
return false, false
}
deadline := time.Now().Add(stunHairpinTimeout)
if d, dok := c.ctx.Deadline(); dok && d.Before(deadline) {
deadline = d
}
if !time.Now().Before(deadline) {
return false, false // no budget left to send anything
}
probe, err := stunListen(true)
if err != nil {
return false, false
}
defer probe.Close()
req := stunBindingRequestMsg(0)
if _, err := probe.WriteToUDPAddrPort(req.encode(), mapped); err != nil {
return false, false
}
// From here on the probe was actually sent, so silence is a real answer.
defer func() { _ = c.conn.SetReadDeadline(time.Time{}) }()
buf := make([]byte, stunMaxMessage)
for time.Now().Before(deadline) {
if err := c.conn.SetReadDeadline(deadline); err != nil {
return false, true
}
n, _, err := c.conn.ReadFromUDPAddrPort(buf)
if err != nil {
return false, true
}
msg, err := parseSTUNMessage(buf[:n])
if err != nil {
continue
}
if msg.Type == stunBindingRequest && msg.TxID == req.TxID {
return true, true
}
}
return false, true
}
// mappedList returns every distinct reflexive address observed, sorted.
func (c *natClassifier) mappedList() []netip.AddrPort {
out := make([]netip.AddrPort, 0, len(c.seen))
for ap := range c.seen {
out = append(out, ap)
}
sortAddrPorts(out)
return out
}
// stunLegacyNATType maps RFC 5780 behaviours onto the RFC 3489 names users
// recognise. Unknown filtering never gets guessed away.
func stunLegacyNATType(noNAT bool, mapping, filtering Behavior) NATType {
switch {
case mapping == BehaviorAddressDependent || mapping == BehaviorAddressAndPortDependent:
return NATSymmetric
case noNAT:
switch filtering {
case BehaviorEndpointIndependent:
return NATOpen
case BehaviorUnknown:
return NATUnknown
default:
return NATSymmetricFW
}
case mapping == BehaviorEndpointIndependent:
switch filtering {
case BehaviorEndpointIndependent:
return NATFullCone
case BehaviorAddressDependent:
return NATRestricted
case BehaviorAddressAndPortDependent:
return NATPortRestrict
default:
return NATUnknown
}
default:
return NATUnknown
}
}
// stunNATVerdict turns the NAT type into a traffic light plus a one-line
// Chinese explanation of what it means for P2P.
func stunNATVerdict(t NATType, mapping, filtering Behavior) (Status, string) {
switch t {
case NATOpen:
return StatusOK, "公网直连,没有 NATP2P 打洞不受限制"
case NATFullCone:
return StatusOK, "全锥型 NAT,打洞成功率很高,通常可以直连"
case NATRestricted:
return StatusOK, "地址限制锥型 NAT,双方同时发包即可打洞,直连通常成功"
case NATPortRestrict:
return StatusWarn, "端口限制锥型 NAT,多数情况下能打洞成功,偶尔会回退到 DERP 中继"
case NATSymmetric:
return StatusFail, "对称型 NAT 会导致打洞失败,连接将回退到 DERP 中继,延迟和带宽都会变差"
case NATUDPBlocked:
return StatusFail, "UDP 被阻断,无法打洞,连接将全程走 DERP 中继"
case NATSymmetricFW:
return StatusWarn, "没有 NAT 但存在有状态防火墙,需要本机先发包,对端才能回连"
default:
return StatusWarn, fmt.Sprintf("无法确定 NAT 类型(映射行为:%s,过滤行为:%s),打洞结果不可预测", mapping, filtering)
}
}