Files
portmaster/service/netenv/interfaces_linux.go
Alexandr Stelnykovych 52a3b9256a netenv: enhance interface detection with physical adapter selection
- Refactor GetInterface* functions to return InterfaceInfo with IPv4/IPv6
  addresses instead of just net.Interface
- Add pre-caching of first routable IPv4/IPv6 per interface to avoid repeated
  address list scans
- Skip loopback interfaces in cache refresh
- Add GetBestPhysicalDefaultInterfaces() to detect which physical adapters
  carry the default route per IP family, excluding VPNs/tunnels
- Implement platform-specific physical interface detection:
  * Linux: reads /proc/net/route and /proc/net/ipv6_route, uses
    /sys/class/net/*/device to identify real hardware
  * Windows: uses GetAdaptersAddresses with IfType filtering
  * Other platforms: returns not-supported error
- Add helper functions: buildInterfaceInfo, interfaceToInfo, buildInterfaceInfoDirect,
  hasRoutableIPv4, hasRoutableIPv6
- Update tests to work with new InterfaceInfo return type and add coverage
  for new features
2026-04-24 17:55:32 +03:00

158 lines
4.9 KiB
Go

//go:build linux
package netenv
import (
"bufio"
"encoding/binary"
"encoding/hex"
"fmt"
"net"
"os"
"strconv"
"strings"
)
// selectPhysicalDefaultInterfaces finds the best physical adapter per IP family
// that carries the default route, excluding all virtual and tunnel interfaces.
//
// Physical detection: the kernel creates /sys/class/net/<name>/device only for
// adapters bound to a real hardware driver. Virtual interfaces (tun, tap,
// bridge, veth, wireguard) never have this entry — this is the most reliable
// VPN-exclusion signal available without elevated privileges.
//
// IPv4 routes: /proc/net/route — always readable without root; provides
// destination, mask, and metric (decimal) for every IPv4 route.
//
// IPv6 routes: /proc/net/ipv6_route — same access requirements; provides
// destination, prefix length, next hop, and metric (hex) for every IPv6 route.
func selectPhysicalDefaultInterfaces() (*net.Interface, *net.Interface, error) {
type candidate struct {
name string
metric uint32
}
var v4candidates, v6candidates []candidate
// --- IPv4: read /proc/net/route ---
// Columns: Iface Dest Gateway Flags RefCnt Use Metric Mask MTU Window IRTT
// Dest and Mask are 4-byte values in 8 hex chars, little-endian. Metric is decimal.
f4, err := os.Open("/proc/net/route")
if err != nil {
return nil, nil, fmt.Errorf("reading IPv4 routing table: %w", err)
}
defer f4.Close() //nolint:errcheck
scanner4 := bufio.NewScanner(f4)
scanner4.Scan() // skip header
for scanner4.Scan() {
fields := strings.Fields(scanner4.Text())
if len(fields) < 8 {
continue
}
dest, err := hex.DecodeString(fields[1])
if err != nil || len(dest) != 4 {
continue
}
mask, err := hex.DecodeString(fields[7])
if err != nil || len(mask) != 4 {
continue
}
// Default route: 0.0.0.0/0
if binary.LittleEndian.Uint32(dest) != 0 || binary.LittleEndian.Uint32(mask) != 0 {
continue
}
name := fields[0]
if !isSysfsPhysical(name) {
continue
}
// Metric column is decimal.
metric, err := strconv.ParseUint(fields[6], 10, 32)
if err != nil {
continue
}
v4candidates = append(v4candidates, candidate{name, uint32(metric)})
}
if err := scanner4.Err(); err != nil {
return nil, nil, fmt.Errorf("scanning IPv4 routing table: %w", err)
}
// --- IPv6: read /proc/net/ipv6_route ---
// Columns: dest destpfxlen src srcpfxlen nexthop metric refcnt use flags iface
// All addresses are 32 hex chars (no colons). Metric is hex. Iface is last.
f6, err := os.Open("/proc/net/ipv6_route")
if err == nil {
defer f6.Close() //nolint:errcheck
scanner6 := bufio.NewScanner(f6)
for scanner6.Scan() {
fields := strings.Fields(scanner6.Text())
if len(fields) < 10 {
continue
}
// Default route: destination = ::/0
if fields[0] != "00000000000000000000000000000000" {
continue
}
pfxLen, err := strconv.ParseUint(fields[1], 16, 8)
if err != nil || pfxLen != 0 {
continue
}
// Skip on-link entries that have no actual gateway.
if fields[4] == "00000000000000000000000000000000" {
continue
}
name := fields[len(fields)-1]
if !isSysfsPhysical(name) {
continue
}
// Metric is hex in ipv6_route (unlike decimal in /proc/net/route).
metric, err := strconv.ParseUint(fields[5], 16, 32)
if err != nil {
continue
}
v6candidates = append(v6candidates, candidate{name, uint32(metric)})
}
// IPv6 scanner errors are non-fatal — leave result.IPv6 as nil.
}
// If /proc/net/ipv6_route is absent, IPv6 is not configured; that is not an error.
// Pick the lowest-metric candidate per family that also has a routable address,
// confirming DHCP/SLAAC has completed and the interface is actively communicating.
var ipv4Iface, ipv6Iface *net.Interface
var bestV4Metric, bestV6Metric uint32
for _, c := range v4candidates {
iface, err := net.InterfaceByName(c.name)
if err != nil || !hasRoutableIPv4(iface) {
continue
}
if ipv4Iface == nil || c.metric < bestV4Metric {
ipv4Iface = iface
bestV4Metric = c.metric
}
}
for _, c := range v6candidates {
iface, err := net.InterfaceByName(c.name)
if err != nil || !hasRoutableIPv6(iface) {
continue
}
if ipv6Iface == nil || c.metric < bestV6Metric {
ipv6Iface = iface
bestV6Metric = c.metric
}
}
return ipv4Iface, ipv6Iface, nil
}
// isSysfsPhysical reports whether the named interface is backed by a real
// hardware driver. The kernel creates /sys/class/net/<name>/device only for
// adapters bound to an actual device driver (PCI/USB Ethernet, wireless card).
// Virtual interfaces — tun, tap, bridge, veth, wireguard, loopback — never
// have this sysfs entry.
func isSysfsPhysical(name string) bool {
_, err := os.Stat("/sys/class/net/" + name + "/device")
return err == nil
}