mirror of
https://github.com/safing/portmaster.git
synced 2026-05-20 20:40:36 +00:00
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:
@@ -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 (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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user