diff --git a/service/netenv/interfaces.go b/service/netenv/interfaces.go new file mode 100644 index 00000000..057976a1 --- /dev/null +++ b/service/netenv/interfaces.go @@ -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 +} diff --git a/service/netenv/interfaces_test.go b/service/netenv/interfaces_test.go new file mode 100644 index 00000000..f4e18216 --- /dev/null +++ b/service/netenv/interfaces_test.go @@ -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) + } +}