Files
Alexandr Stelnykovych 194d903675 netenv: restrict interface address selection to routable unicast IPs
Replaces scattered link-local exclusion checks with the new
isRoutableUnicastIP predicate (site-local or global scope only),
consistently applied in refreshIfaceCache, buildInterfaceInfoDirect,
hasRoutableIPv4, and hasRoutableIPv6. Updates tests accordingly.
2026-04-29 17:37:51 +03:00

587 lines
20 KiB
Go
Raw Permalink 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 netenv
import (
"errors"
"fmt"
"net"
"sync"
"time"
"github.com/safing/portmaster/base/log"
"github.com/safing/portmaster/service/network/netutils"
)
// cachedNetInterface holds a network interface with its pre-parsed IP addresses.
type cachedNetInterface struct {
iface net.Interface
addrs []net.IP // all routable addresses; used for IP-based lookup
macStr string // HardwareAddr.String() result, cached to avoid per-search allocations
firstIPv4 net.IP // first routable IPv4, or nil; cached to avoid scanning addrs on every call
firstIPv6 net.IP // first routable IPv6, or nil; cached to avoid scanning addrs on every call
}
// InterfaceInfo holds a matched network interface with the preferred
// IP addresses for each address family.
//
// When the interface was found by a specific IP, that IP is used as the
// preferred address for its address family; the first address of the
// other family (if any) is populated from the interface's address list.
// When the interface was found by name or MAC, the first address of each
// family from the address list is used.
type InterfaceInfo struct {
Interface *net.Interface
IPv4 net.IP // first routable IPv4 address for this interface; nil if none
IPv6 net.IP // first routable IPv6 address for this interface; nil if none
}
var (
// ifaceCache stores the latest enumerated network interfaces as a slice.
// A slice is used instead of maps because a typical host has only a handful
// of interfaces (2–10). Linear scans over such small slices are faster than
// map lookups: no hashing, no bucket pointer chasing, and the data fits
// entirely in a few cache lines. Maps would also require three separate
// structures (by name, IP, MAC), adding allocation and maintenance cost with
// no measurable benefit at real-world sizes.
// It is nil until the first call to any GetInterface* function (lazy init).
ifaceCache []cachedNetInterface
ifaceCacheLock sync.RWMutex
ifaceCacheChangedFlag = GetNetworkChangedFlag()
ifaceCacheRefreshError error //nolint:errname // Not what the linter thinks this is for.
ifaceCacheDontRefreshUntil time.Time
)
// refreshIfaceCache re-enumerates all network interfaces and stores them in ifaceCache.
// It also resets the network-changed flag.
// Refreshes are throttled to at most once per second to avoid redundant
// re-enumerations during rapid interface churn (e.g. network reconnects).
// The caller must hold ifaceCacheLock for writing.
func refreshIfaceCache() error {
// Throttle: return early if we refreshed very recently; the existing cache remains valid.
if time.Now().Before(ifaceCacheDontRefreshUntil) {
if ifaceCacheRefreshError != nil {
return fmt.Errorf("failed to previously refresh interface cache: %w", ifaceCacheRefreshError)
}
return nil
}
ifaceCacheRefreshError = nil
ifaceCacheDontRefreshUntil = time.Now().Add(1 * time.Second)
ifaces, err := net.Interfaces()
if err != nil {
ifaceCacheRefreshError = err
return fmt.Errorf("failed to enumerate network interfaces: %w", err)
}
newCache := make([]cachedNetInterface, 0, len(ifaces))
for i := range ifaces {
// Skip interfaces that are down — they have no usable IP connectivity.
if ifaces[i].Flags&net.FlagUp == 0 {
continue
}
// Skip loopback — it is not useful for cross-host communication.
if ifaces[i].Flags&net.FlagLoopback != 0 {
continue
}
entry := cachedNetInterface{
iface: ifaces[i],
macStr: ifaces[i].HardwareAddr.String(),
}
addrs, addrErr := ifaces[i].Addrs()
if addrErr != nil {
log.Warningf("netenv: failed to get addresses for interface %s: %v", ifaces[i].Name, addrErr)
} else {
for _, addr := range addrs {
var ip net.IP
switch v := addr.(type) {
case *net.IPNet:
ip = v.IP
case *net.IPAddr:
ip = v.IP
}
// Skip addresses of unexpected types (switch default left ip nil).
if ip == nil {
continue
}
// Use the 4-byte form for IPv4 so it matches what was stored during cache build.
if ip4 := ip.To4(); ip4 != nil {
ip = ip4
}
// Keep only routable unicast addresses (site-local or global).
if !isRoutableUnicastIP(ip) {
continue
}
entry.addrs = append(entry.addrs, ip)
}
}
// Skip interfaces with no usable unicast addresses — they cannot
// participate in normal IP connectivity and are not searchable by IP.
if len(entry.addrs) == 0 {
continue
}
// Pre-cache the first address of each family so buildInterfaceInfo
// can return them with two field reads instead of scanning addrs.
for _, ip := range entry.addrs {
if ip.To4() != nil {
if entry.firstIPv4 == nil {
entry.firstIPv4 = ip
}
} else {
if entry.firstIPv6 == nil {
entry.firstIPv6 = ip
}
}
if entry.firstIPv4 != nil && entry.firstIPv6 != nil {
break
}
}
newCache = append(newCache, entry)
}
ifaceCache = newCache
ifaceCacheChangedFlag.Refresh()
return nil
}
// ensureIfaceCache guarantees the cache is populated and up to date.
// The caller must hold ifaceCacheLock for writing.
func ensureIfaceCache() error {
if ifaceCache == nil || ifaceCacheChangedFlag.IsSet() {
return refreshIfaceCache()
}
return nil
}
// cacheReady reports whether the cache is populated and current.
// The caller must hold at least ifaceCacheLock for reading.
func cacheReady() bool {
return ifaceCache != nil && !ifaceCacheChangedFlag.IsSet()
}
// buildInterfaceInfo constructs an InterfaceInfo from a cache entry.
// If knownIP is non-nil it is used as the preferred address for its
// address family; the pre-cached first address of the other family fills
// the remaining field. Both fields are read directly from the entry — no
// scan of addrs is needed.
func buildInterfaceInfo(entry *cachedNetInterface, knownIP net.IP) *InterfaceInfo {
info := &InterfaceInfo{
Interface: &entry.iface,
IPv4: entry.firstIPv4,
IPv6: entry.firstIPv6,
}
// Override the matched family with the exact IP used to find this entry.
if knownIP != nil {
if knownIP.To4() != nil {
info.IPv4 = knownIP
} else {
info.IPv6 = knownIP
}
}
return info
}
// GetInterface returns the local network interface identified by ifinfo.
// ifinfo may be an IP address, a MAC address, or an interface name; they are
// tried in that order. An error is returned when no interface matches.
func GetInterface(ifinfo string) (*InterfaceInfo, error) {
// Fast path: concurrent reads when the cache is already valid.
ifaceCacheLock.RLock()
if cacheReady() {
entry, matchedIP := searchByIfinfo(ifinfo)
ifaceCacheLock.RUnlock()
if entry == nil {
return nil, fmt.Errorf("no interface found %q", ifinfo)
}
return buildInterfaceInfo(entry, matchedIP), nil
}
ifaceCacheLock.RUnlock()
// Slow path: refresh the cache, then search.
ifaceCacheLock.Lock()
defer ifaceCacheLock.Unlock()
if err := ensureIfaceCache(); err != nil {
return nil, err
}
entry, matchedIP := searchByIfinfo(ifinfo)
if entry == nil {
return nil, fmt.Errorf("no interface found %q", ifinfo)
}
return buildInterfaceInfo(entry, matchedIP), nil
}
// searchByIfinfo searches ifaceCache in priority order: IP → MAC → name.
// It returns the matched cache entry and, when the match was by IP, the
// normalised IP that was used (so the caller can pin it as the preferred
// address for that family). The IP return value is nil for name/MAC matches.
// The caller must hold ifaceCacheLock (for reading or writing).
func searchByIfinfo(ifinfo string) (*cachedNetInterface, net.IP) {
if ip := net.ParseIP(ifinfo); ip != nil {
normalized := normalizeIP(ip)
return searchIfaceByIP(normalized), normalized
}
if mac, err := net.ParseMAC(ifinfo); err == nil {
return searchIfaceByMAC(mac.String()), nil
}
return searchIfaceByName(ifinfo), nil
}
// GetInterfaceByIP returns the local network interface that has ip assigned.
func GetInterfaceByIP(ip net.IP) (*InterfaceInfo, error) {
if ip == nil {
return nil, fmt.Errorf("GetInterfaceByIP called with nil IP")
}
normalized := normalizeIP(ip)
ifaceCacheLock.RLock()
if cacheReady() {
entry := searchIfaceByIP(normalized)
ifaceCacheLock.RUnlock()
if entry == nil {
return nil, fmt.Errorf("no interface found with IP %s", ip)
}
return buildInterfaceInfo(entry, normalized), nil
}
ifaceCacheLock.RUnlock()
ifaceCacheLock.Lock()
defer ifaceCacheLock.Unlock()
if err := ensureIfaceCache(); err != nil {
return nil, err
}
if entry := searchIfaceByIP(normalized); entry != nil {
return buildInterfaceInfo(entry, normalized), nil
}
return nil, fmt.Errorf("no interface found with IP %s", ip)
}
// GetInterfaceByMAC returns the local network interface with the given hardware address.
func GetInterfaceByMAC(mac net.HardwareAddr) (*InterfaceInfo, error) {
macStr := mac.String()
ifaceCacheLock.RLock()
if cacheReady() {
entry := searchIfaceByMAC(macStr)
ifaceCacheLock.RUnlock()
if entry == nil {
return nil, fmt.Errorf("no interface found with MAC %s", mac)
}
return buildInterfaceInfo(entry, nil), nil
}
ifaceCacheLock.RUnlock()
ifaceCacheLock.Lock()
defer ifaceCacheLock.Unlock()
if err := ensureIfaceCache(); err != nil {
return nil, err
}
if entry := searchIfaceByMAC(macStr); entry != nil {
return buildInterfaceInfo(entry, nil), nil
}
return nil, fmt.Errorf("no interface found with MAC %s", mac)
}
// GetInterfaceByName returns the local network interface with the given name.
func GetInterfaceByName(name string) (*InterfaceInfo, error) {
ifaceCacheLock.RLock()
if cacheReady() {
entry := searchIfaceByName(name)
ifaceCacheLock.RUnlock()
if entry == nil {
return nil, fmt.Errorf("no interface found with name %q", name)
}
return buildInterfaceInfo(entry, nil), nil
}
ifaceCacheLock.RUnlock()
ifaceCacheLock.Lock()
defer ifaceCacheLock.Unlock()
if err := ensureIfaceCache(); err != nil {
return nil, err
}
if entry := searchIfaceByName(name); entry != nil {
return buildInterfaceInfo(entry, nil), nil
}
return nil, fmt.Errorf("no interface found with name %q", name)
}
// normalizeIP returns the 4-byte form of an IPv4 address, or the IP unchanged
// for IPv6. This matches the form stored in cachedNetInterface.addrs.
func normalizeIP(ip net.IP) net.IP {
if ip4 := ip.To4(); ip4 != nil {
return ip4
}
return ip
}
// isRoutableUnicastIP reports whether ip is a routable unicast address useful
// for real network communication: site-local (RFC 1918 / ULA) or globally
// routable. All other scopes — link-local, loopback, unspecified, multicast,
// and documentation/test ranges — are excluded.
// ip must already be in its canonical form (4-byte for IPv4, see normalizeIP).
func isRoutableUnicastIP(ip net.IP) bool {
scope := netutils.GetIPScope(ip)
return scope == netutils.SiteLocal || scope == netutils.Global
}
// searchIfaceByIP returns the cache entry whose address list contains ip, or nil.
// The caller must hold ifaceCacheLock for reading or writing.
func searchIfaceByIP(ip net.IP) *cachedNetInterface {
for i := range ifaceCache {
for _, addr := range ifaceCache[i].addrs {
if ip.Equal(addr) {
return &ifaceCache[i]
}
}
}
return nil
}
// searchIfaceByMAC returns the cache entry whose hardware address matches
// macStr (in canonical net.HardwareAddr.String() form), or nil.
// The caller must hold ifaceCacheLock for reading or writing.
func searchIfaceByMAC(macStr string) *cachedNetInterface {
for i := range ifaceCache {
if ifaceCache[i].macStr == macStr {
return &ifaceCache[i]
}
}
return nil
}
// searchIfaceByName returns the cache entry with the given name, or nil.
// The caller must hold ifaceCacheLock for reading or writing.
func searchIfaceByName(name string) *cachedNetInterface {
for i := range ifaceCache {
if ifaceCache[i].iface.Name == name {
return &ifaceCache[i]
}
}
return nil
}
// ---- Physical default interface ----
// PhysicalDefaultInterfaces holds the best physical network adapter per IP
// family, together with its preferred bind addresses. IPv4 and IPv6 may
// resolve to different interfaces — for example when a VPN tunnels only IPv4
// and IPv6 traffic exits directly on Ethernet, or when Ethernet serves IPv4
// and a mobile hotspot provides IPv6.
// A nil field means no physical interface with a default route for that family
// was found (e.g. IPv6 is simply not configured on this host).
type PhysicalDefaultInterfaces struct {
ForIPv4 *InterfaceInfo // best physical adapter handling the default IPv4 route; nil if none
ForIPv6 *InterfaceInfo // best physical adapter handling the default IPv6 route; nil if none
}
var (
physicalDefaultIfacesCache PhysicalDefaultInterfaces
physicalDefaultIfacesCacheValid bool
physicalDefaultIfacesLock sync.RWMutex
physicalDefaultIfacesChangedFlag = GetNetworkChangedFlag()
physicalDefaultIfacesDontRefreshUntil time.Time
)
// GetBestPhysicalDefaultInterfaces returns the physical network adapters
// (Ethernet, WiFi, mobile broadband) currently used for internet traffic,
// one per IP family. VPN, tunnel, and other virtual interfaces are explicitly
// excluded, making the result safe to use as split-tunnel bypass targets.
//
// Selection criteria per family (all must be satisfied):
// - Adapter type is physical hardware (Ethernet, WiFi, mobile broadband).
// - Has at least one routable unicast address for that family (not link-local).
// - Has a default route (0.0.0.0/0 or ::/0) in the routing table.
// - When multiple candidates qualify, the one with the lowest route metric wins.
//
// The result is cached and refreshed only when a network change is detected,
// making it safe to call on every new connection without performance overhead.
func GetBestPhysicalDefaultInterfaces() (PhysicalDefaultInterfaces, error) {
// Fast path: concurrent reads when cache is valid.
physicalDefaultIfacesLock.RLock()
if physicalDefaultIfacesCacheValid && !physicalDefaultIfacesChangedFlag.IsSet() {
result := physicalDefaultIfacesCache
physicalDefaultIfacesLock.RUnlock()
return result, nil
}
physicalDefaultIfacesLock.RUnlock()
// Slow path: refresh under write lock.
physicalDefaultIfacesLock.Lock()
defer physicalDefaultIfacesLock.Unlock()
// Re-check: another goroutine may have refreshed while we waited.
if physicalDefaultIfacesCacheValid && !physicalDefaultIfacesChangedFlag.IsSet() {
return physicalDefaultIfacesCache, nil
}
// Throttle: if a refresh just ran, return the cached result even if the
// change flag fired again — avoids hammering the OS during interface churn.
if physicalDefaultIfacesCacheValid && time.Now().Before(physicalDefaultIfacesDontRefreshUntil) {
return physicalDefaultIfacesCache, nil
}
physicalDefaultIfacesDontRefreshUntil = time.Now().Add(1 * time.Second)
// Consume the change flag before the (potentially slow) platform call so
// any change that arrives during the call will trigger a re-evaluation.
physicalDefaultIfacesChangedFlag.Refresh()
ipv4Iface, ipv6Iface, err := selectPhysicalDefaultInterfaces()
if err != nil {
physicalDefaultIfacesCacheValid = false
return PhysicalDefaultInterfaces{}, err
}
result := PhysicalDefaultInterfaces{
ForIPv4: interfaceToInfo(ipv4Iface),
ForIPv6: interfaceToInfo(ipv6Iface),
}
physicalDefaultIfacesCache = result
physicalDefaultIfacesCacheValid = true
return result, nil
}
// interfaceToInfo looks up iface in the interface cache and returns an
// InterfaceInfo populated with the first routable address per family.
// Falls back to scanning iface.Addrs() directly when the cache is unavailable
// (e.g. a transient failure during network churn), so IPv4/IPv6 are always
// populated when addresses exist on the interface.
// The caller must NOT hold ifaceCacheLock.
func interfaceToInfo(iface *net.Interface) *InterfaceInfo {
if iface == nil {
return nil
}
ifaceCacheLock.RLock()
if cacheReady() {
entry := searchIfaceByName(iface.Name)
ifaceCacheLock.RUnlock()
if entry != nil {
return buildInterfaceInfo(entry, nil)
}
// Interface not in cache yet (added after last refresh); fall through.
} else {
ifaceCacheLock.RUnlock()
ifaceCacheLock.Lock()
if err := ensureIfaceCache(); err == nil {
if entry := searchIfaceByName(iface.Name); entry != nil {
result := buildInterfaceInfo(entry, nil)
ifaceCacheLock.Unlock()
return result
}
}
ifaceCacheLock.Unlock()
}
// Cache unavailable or interface not present in it — populate addresses
// directly from the kernel so IPv4/IPv6 are never silently nil.
return buildInterfaceInfoDirect(iface)
}
// buildInterfaceInfoDirect constructs an InterfaceInfo by calling iface.Addrs()
// directly, without using the cache. Used as a fallback when the cache is
// unavailable. Uses the same isRoutableUnicastIP predicate as refreshIfaceCache.
func buildInterfaceInfoDirect(iface *net.Interface) *InterfaceInfo {
info := &InterfaceInfo{Interface: iface}
addrs, err := iface.Addrs()
if err != nil {
return info
}
for _, addr := range addrs {
var ip net.IP
switch v := addr.(type) {
case *net.IPNet:
ip = v.IP
case *net.IPAddr:
ip = v.IP
}
if ip == nil {
continue
}
// Normalize to 4-byte form so isRoutableUnicastIP and family checks are consistent.
if ip4 := ip.To4(); ip4 != nil {
ip = ip4
}
if !isRoutableUnicastIP(ip) {
continue
}
if ip.To4() != nil {
if info.IPv4 == nil {
info.IPv4 = ip
}
} else {
if info.IPv6 == nil {
info.IPv6 = ip
}
}
if info.IPv4 != nil && info.IPv6 != nil {
break
}
}
return info
}
// hasRoutableIPv4 reports whether iface has at least one unicast IPv4 address
// that is globally routable — not unspecified (0.0.0.0), loopback (127.x.x.x),
// or link-local/APIPA (169.254.x.x).
//
// An interface may be physically present and have a default route while still
// lacking a usable IP (DHCP not completed, cable just reconnected, etc.).
// Checking the address is the final confirmation that the interface can
// actually forward packets.
func hasRoutableIPv4(iface *net.Interface) bool {
addrs, err := iface.Addrs()
if err != nil {
return false
}
for _, addr := range addrs {
var ip net.IP
switch v := addr.(type) {
case *net.IPNet:
ip = v.IP
case *net.IPAddr:
ip = v.IP
}
ip4 := ip.To4()
if ip4 == nil {
continue
}
if isRoutableUnicastIP(ip4) {
return true
}
}
return false
}
// hasRoutableIPv6 reports whether iface has at least one unicast IPv6 address
// that is globally routable — not unspecified (::), loopback (::1),
// or link-local (fe80::/10).
func hasRoutableIPv6(iface *net.Interface) bool {
addrs, err := iface.Addrs()
if err != nil {
return false
}
for _, addr := range addrs {
var ip net.IP
switch v := addr.(type) {
case *net.IPNet:
ip = v.IP
case *net.IPAddr:
ip = v.IP
}
// Skip IPv4 addresses and nil.
if ip == nil || ip.To4() != nil {
continue
}
if isRoutableUnicastIP(ip) {
return true
}
}
return false
}
// errNoPhysicalDefaultInterface is returned by unsupported platform stubs.
var errNoPhysicalDefaultInterface = errors.New("physical network interface detection is not supported on this platform")