netenv: add cached network interface lookup

Add interfaces.go with GetInterface, GetInterfaceByIP, GetInterfaceByMAC
and GetInterfaceByName for resolving local network interfaces by IP, MAC,
or name.

- Lazy init: no work until first call
- sync.RWMutex with double-checked locking for concurrent read throughput
- Refresh throttled to once per second to absorb rapid interface churn
  (same NetworkChangedFlag pattern used across netenv)
- Only live, routable interfaces cached: FlagUp required; link-local and
  address-less interfaces excluded as unsuitable for TCP/UDP tunneling
This commit is contained in:
Alexandr Stelnykovych
2026-04-23 17:32:31 +03:00
parent 933323d5f9
commit fdd04e1dd0
2 changed files with 564 additions and 0 deletions
+288
View File
@@ -0,0 +1,288 @@
package netenv
import (
"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
}
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 (210). 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
}
entry := cachedNetInterface{iface: ifaces[i]}
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
}
// Skip link-local addresses (169.254.x.x / fe80::) — they are
// non-routable and cannot be used for tunneling to remote hosts.
if netutils.GetIPScope(ip) == netutils.LinkLocal {
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
}
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()
}
// 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) (*net.Interface, error) {
// Fast path: concurrent reads when the cache is already valid.
ifaceCacheLock.RLock()
if cacheReady() {
iface := searchByIfinfo(ifinfo)
ifaceCacheLock.RUnlock()
if iface == nil {
return nil, fmt.Errorf("no interface found for %q", ifinfo)
}
return iface, nil
}
ifaceCacheLock.RUnlock()
// Slow path: refresh the cache, then search.
ifaceCacheLock.Lock()
defer ifaceCacheLock.Unlock()
if err := ensureIfaceCache(); err != nil {
return nil, err
}
iface := searchByIfinfo(ifinfo)
if iface == nil {
return nil, fmt.Errorf("no interface found for %q", ifinfo)
}
return iface, nil
}
// searchByIfinfo searches ifaceCache in priority order: IP → MAC → name.
// The caller must hold ifaceCacheLock (for reading or writing).
func searchByIfinfo(ifinfo string) *net.Interface {
if ip := net.ParseIP(ifinfo); ip != nil {
return searchIfaceByIP(normalizeIP(ip))
}
if mac, err := net.ParseMAC(ifinfo); err == nil {
return searchIfaceByMAC(mac.String())
}
return searchIfaceByName(ifinfo)
}
// GetInterfaceByIP returns the local network interface that has ip assigned.
func GetInterfaceByIP(ip net.IP) (*net.Interface, error) {
if ip == nil {
return nil, fmt.Errorf("GetInterfaceByIP called with nil IP")
}
normalized := normalizeIP(ip)
ifaceCacheLock.RLock()
if cacheReady() {
iface := searchIfaceByIP(normalized)
ifaceCacheLock.RUnlock()
if iface == nil {
return nil, fmt.Errorf("no interface found with IP %s", ip)
}
return iface, nil
}
ifaceCacheLock.RUnlock()
ifaceCacheLock.Lock()
defer ifaceCacheLock.Unlock()
if err := ensureIfaceCache(); err != nil {
return nil, err
}
if iface := searchIfaceByIP(normalized); iface != nil {
return iface, 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) (*net.Interface, error) {
macStr := mac.String()
ifaceCacheLock.RLock()
if cacheReady() {
iface := searchIfaceByMAC(macStr)
ifaceCacheLock.RUnlock()
if iface == nil {
return nil, fmt.Errorf("no interface found with MAC %s", mac)
}
return iface, nil
}
ifaceCacheLock.RUnlock()
ifaceCacheLock.Lock()
defer ifaceCacheLock.Unlock()
if err := ensureIfaceCache(); err != nil {
return nil, err
}
if iface := searchIfaceByMAC(macStr); iface != nil {
return iface, 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) (*net.Interface, error) {
ifaceCacheLock.RLock()
if cacheReady() {
iface := searchIfaceByName(name)
ifaceCacheLock.RUnlock()
if iface == nil {
return nil, fmt.Errorf("no interface found with name %q", name)
}
return iface, nil
}
ifaceCacheLock.RUnlock()
ifaceCacheLock.Lock()
defer ifaceCacheLock.Unlock()
if err := ensureIfaceCache(); err != nil {
return nil, err
}
if iface := searchIfaceByName(name); iface != nil {
return iface, 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
}
// searchIfaceByIP returns the interface that owns ip, or nil.
// The caller must hold ifaceCacheLock for reading or writing.
func searchIfaceByIP(ip net.IP) *net.Interface {
for i := range ifaceCache {
for _, addr := range ifaceCache[i].addrs {
if ip.Equal(addr) {
return &ifaceCache[i].iface
}
}
}
return nil
}
// searchIfaceByMAC returns the interface 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) *net.Interface {
for i := range ifaceCache {
if ifaceCache[i].iface.HardwareAddr.String() == macStr {
return &ifaceCache[i].iface
}
}
return nil
}
// searchIfaceByName returns the interface with the given name, or nil.
// The caller must hold ifaceCacheLock for reading or writing.
func searchIfaceByName(name string) *net.Interface {
for i := range ifaceCache {
if ifaceCache[i].iface.Name == name {
return &ifaceCache[i].iface
}
}
return nil
}
+276
View File
@@ -0,0 +1,276 @@
package netenv
import (
"net"
"testing"
"github.com/safing/portmaster/service/network/netutils"
)
// isRoutableIP returns true for IPs that the cache keeps: non-nil, non-link-local.
func isRoutableIP(ip net.IP) bool {
if ip == nil {
return false
}
if ip4 := ip.To4(); ip4 != nil {
ip = ip4
}
return netutils.GetIPScope(ip) != netutils.LinkLocal
}
// getTestInterface picks the first network interface that matches the same
// criteria as the cache: FlagUp and at least one routable (non-link-local)
// unicast address. Falls back to loopback if no other candidate is found.
func getTestInterface(t *testing.T) net.Interface {
t.Helper()
ifaces, err := net.Interfaces()
if err != nil {
t.Fatalf("net.Interfaces() failed: %v", err)
}
var loopback *net.Interface
for i := range ifaces {
iface := ifaces[i]
if iface.Flags&net.FlagUp == 0 {
continue
}
addrs, _ := iface.Addrs()
hasRoutable := 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
}
if isRoutableIP(ip) {
hasRoutable = true
break
}
}
if !hasRoutable {
continue
}
if iface.Flags&net.FlagLoopback != 0 {
if loopback == nil {
loopback = &iface
}
continue
}
return iface
}
if loopback != nil {
return *loopback
}
t.Skip("no usable network interface found skipping test")
panic("unreachable")
}
// firstRoutableIP returns the first routable (non-link-local) unicast IP
// assigned to iface, or nil if none exists.
func firstRoutableIP(iface net.Interface) net.IP {
addrs, _ := iface.Addrs()
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 isRoutableIP(ip) {
return ip
}
}
return nil
}
// ---- GetInterfaceByName -------------------------------------------------------
func TestGetInterfaceByName(t *testing.T) {
t.Parallel()
want := getTestInterface(t)
got, err := GetInterfaceByName(want.Name)
if err != nil {
t.Fatalf("GetInterfaceByName(%q): unexpected error: %v", want.Name, err)
}
if got.Name != want.Name {
t.Errorf("GetInterfaceByName(%q): got %q", want.Name, got.Name)
}
}
func TestGetInterfaceByName_NotFound(t *testing.T) {
t.Parallel()
_, err := GetInterfaceByName("__no_such_interface__")
if err == nil {
t.Fatal("expected error for unknown interface name, got nil")
}
}
// ---- GetInterfaceByIP --------------------------------------------------------
func TestGetInterfaceByIP(t *testing.T) {
t.Parallel()
iface := getTestInterface(t)
ip := firstRoutableIP(iface)
if ip == nil {
t.Skipf("interface %q has no routable address skipping", iface.Name)
}
got, err := GetInterfaceByIP(ip)
if err != nil {
t.Fatalf("GetInterfaceByIP(%s): unexpected error: %v", ip, err)
}
if got.Name != iface.Name {
t.Errorf("GetInterfaceByIP(%s): got interface %q, want %q", ip, got.Name, iface.Name)
}
}
func TestGetInterfaceByIP_NotFound(t *testing.T) {
t.Parallel()
// 192.0.2.0/24 is TEST-NET-1 (RFC 5737) never assigned on a real host.
ip := net.ParseIP("192.0.2.1")
_, err := GetInterfaceByIP(ip)
if err == nil {
t.Fatal("expected error for unassigned IP, got nil")
}
}
// ---- GetInterfaceByMAC -------------------------------------------------------
func TestGetInterfaceByMAC(t *testing.T) {
t.Parallel()
iface := getTestInterface(t)
if len(iface.HardwareAddr) == 0 {
t.Skipf("interface %q has no hardware address skipping", iface.Name)
}
got, err := GetInterfaceByMAC(iface.HardwareAddr)
if err != nil {
t.Fatalf("GetInterfaceByMAC(%s): unexpected error: %v", iface.HardwareAddr, err)
}
if got.Name != iface.Name {
t.Errorf("GetInterfaceByMAC(%s): got interface %q, want %q",
iface.HardwareAddr, got.Name, iface.Name)
}
}
// ---- GetInterface (multi-mode) -----------------------------------------------
func TestGetInterface_ByName(t *testing.T) {
t.Parallel()
want := getTestInterface(t)
got, err := GetInterface(want.Name)
if err != nil {
t.Fatalf("GetInterface(%q) by name: unexpected error: %v", want.Name, err)
}
if got.Name != want.Name {
t.Errorf("GetInterface(%q): got %q", want.Name, got.Name)
}
}
func TestGetInterface_ByIP(t *testing.T) {
t.Parallel()
iface := getTestInterface(t)
ip := firstRoutableIP(iface)
if ip == nil {
t.Skipf("interface %q has no routable address skipping", iface.Name)
}
ipStr := ip.String()
got, err := GetInterface(ipStr)
if err != nil {
t.Fatalf("GetInterface(%q) by IP: unexpected error: %v", ipStr, err)
}
if got.Name != iface.Name {
t.Errorf("GetInterface(%q): got %q, want %q", ipStr, got.Name, iface.Name)
}
}
func TestGetInterface_ByMAC(t *testing.T) {
t.Parallel()
iface := getTestInterface(t)
if len(iface.HardwareAddr) == 0 {
t.Skipf("interface %q has no hardware address skipping", iface.Name)
}
macStr := iface.HardwareAddr.String()
got, err := GetInterface(macStr)
if err != nil {
t.Fatalf("GetInterface(%q) by MAC: unexpected error: %v", macStr, err)
}
if got.Name != iface.Name {
t.Errorf("GetInterface(%q): got %q, want %q", macStr, got.Name, iface.Name)
}
}
func TestGetInterface_NotFound(t *testing.T) {
t.Parallel()
_, err := GetInterface("__no_such_interface__")
if err == nil {
t.Fatal("expected error for unrecognised ifinfo, got nil")
}
}
// TestGetInterfaceByIP_LinkLocalIPv6 verifies that IPv6 link-local addresses
// are filtered out of the cache and therefore never match a lookup.
func TestGetInterfaceByIP_LinkLocalIPv6(t *testing.T) {
t.Parallel()
ip := net.ParseIP("fe80::1")
_, err := GetInterfaceByIP(ip)
if err == nil {
t.Error("expected error for link-local IP fe80::1, got nil")
}
}
// TestGetInterfaceByIP_LinkLocalIPv4 verifies that IPv4 link-local addresses
// (APIPA range 169.254.x.x) are filtered out of the cache.
func TestGetInterfaceByIP_LinkLocalIPv4(t *testing.T) {
t.Parallel()
ip := net.ParseIP("169.254.0.1")
_, err := GetInterfaceByIP(ip)
if err == nil {
t.Error("expected error for link-local IP 169.254.0.1, got nil")
}
}
// TestGetInterface_RepeatedCall verifies that repeated calls with the same
// argument succeed consistently (exercises the list cache path).
func TestGetInterface_RepeatedCall(t *testing.T) {
t.Parallel()
want := getTestInterface(t)
got1, err := GetInterface(want.Name)
if err != nil {
t.Fatalf("first GetInterface(%q): %v", want.Name, err)
}
got2, err := GetInterface(want.Name)
if err != nil {
t.Fatalf("second GetInterface(%q): %v", want.Name, err)
}
if got1.Name != got2.Name {
t.Errorf("inconsistent results: got %q then %q", got1.Name, got2.Name)
}
}