382 lines
10 KiB
Go
382 lines
10 KiB
Go
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()
|
||
}
|