Files
portmaster/service/netenv/interfaces_test.go
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

472 lines
13 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 (
"net"
"testing"
"github.com/safing/portmaster/service/network/netutils"
)
// isRoutableIP returns true for IPs that the cache keeps: site-local or global.
// Matches the isRoutableUnicastIP predicate used in production code.
func isRoutableIP(ip net.IP) bool {
if ip == nil {
return false
}
if ip4 := ip.To4(); ip4 != nil {
ip = ip4
}
scope := netutils.GetIPScope(ip)
return scope == netutils.SiteLocal || scope == netutils.Global
}
// 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)
}
for i := range ifaces {
iface := ifaces[i]
if iface.Flags&net.FlagUp == 0 {
continue
}
// Mirror the cache filter: loopback is excluded.
if iface.Flags&net.FlagLoopback != 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
}
return iface
}
t.Skip("no usable non-loopback 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
}
// firstRoutableIPv4 returns the first routable IPv4 address on iface, or nil.
func firstRoutableIPv4(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) {
if ip4 := ip.To4(); ip4 != nil {
return ip4
}
}
}
return nil
}
// firstRoutableIPv6 returns the first routable IPv6 address on iface, or nil.
func firstRoutableIPv6(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) && ip.To4() == nil {
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.Interface.Name != want.Name {
t.Errorf("GetInterfaceByName(%q): got %q", want.Name, got.Interface.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.Interface.Name != iface.Name {
t.Errorf("GetInterfaceByIP(%s): got interface %q, want %q", ip, got.Interface.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.Interface.Name != iface.Name {
t.Errorf("GetInterfaceByMAC(%s): got interface %q, want %q",
iface.HardwareAddr, got.Interface.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.Interface.Name != want.Name {
t.Errorf("GetInterface(%q): got %q", want.Name, got.Interface.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.Interface.Name != iface.Name {
t.Errorf("GetInterface(%q): got %q, want %q", ipStr, got.Interface.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.Interface.Name != iface.Name {
t.Errorf("GetInterface(%q): got %q, want %q", macStr, got.Interface.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.Interface.Name != got2.Interface.Name {
t.Errorf("inconsistent results: got %q then %q", got1.Interface.Name, got2.Interface.Name)
}
}
// ---- InterfaceInfo bind-address fields ---------------------------------------
// TestGetInterfaceByIP_MatchedIPv4InInfo verifies that when an interface is
// found by an IPv4 address, that exact IP is returned in InterfaceInfo.IPv4.
func TestGetInterfaceByIP_MatchedIPv4InInfo(t *testing.T) {
t.Parallel()
iface := getTestInterface(t)
ip := firstRoutableIPv4(iface)
if ip == nil {
t.Skipf("interface %q has no routable IPv4 address – skipping", iface.Name)
}
info, err := GetInterfaceByIP(ip)
if err != nil {
t.Fatalf("GetInterfaceByIP(%s): unexpected error: %v", ip, err)
}
if !info.IPv4.Equal(ip) {
t.Errorf("InterfaceInfo.IPv4: got %s, want %s", info.IPv4, ip)
}
}
// TestGetInterfaceByIP_MatchedIPv6InInfo verifies that when an interface is
// found by an IPv6 address, that exact IP is returned in InterfaceInfo.IPv6.
func TestGetInterfaceByIP_MatchedIPv6InInfo(t *testing.T) {
t.Parallel()
iface := getTestInterface(t)
ip := firstRoutableIPv6(iface)
if ip == nil {
t.Skipf("interface %q has no routable IPv6 address – skipping", iface.Name)
}
info, err := GetInterfaceByIP(ip)
if err != nil {
t.Fatalf("GetInterfaceByIP(%s): unexpected error: %v", ip, err)
}
if !info.IPv6.Equal(ip) {
t.Errorf("InterfaceInfo.IPv6: got %s, want %s", info.IPv6, ip)
}
}
// TestGetInterfaceByName_IPv4InInfo verifies that when an interface is found
// by name, InterfaceInfo.IPv4 is populated with the first routable IPv4 address.
func TestGetInterfaceByName_IPv4InInfo(t *testing.T) {
t.Parallel()
iface := getTestInterface(t)
expectedIPv4 := firstRoutableIPv4(iface)
if expectedIPv4 == nil {
t.Skipf("interface %q has no routable IPv4 address – skipping", iface.Name)
}
info, err := GetInterfaceByName(iface.Name)
if err != nil {
t.Fatalf("GetInterfaceByName(%q): unexpected error: %v", iface.Name, err)
}
if !info.IPv4.Equal(expectedIPv4) {
t.Errorf("InterfaceInfo.IPv4: got %s, want %s", info.IPv4, expectedIPv4)
}
}
// ---- Helper functions for logging -------------------------------------------------------
// logInterfaceInfo logs IPv4 and IPv6 interface info from PhysicalDefaultInterfaces.
func logInterfaceInfo(t *testing.T, label string, result PhysicalDefaultInterfaces) {
logIP := func(version string, info *InterfaceInfo) {
if info == nil {
t.Logf("%s - %s: <nil>", label, version)
return
}
var ip net.IP
if version == "IPv4" {
ip = info.IPv4
} else {
ip = info.IPv6
}
name := info.Interface.Name
if ip != nil {
t.Logf("%s - %s: %s (%s)", label, version, name, ip)
} else {
t.Logf("%s - %s: %s", label, version, name)
}
}
logIP("IPv4", result.ForIPv4)
logIP("IPv6", result.ForIPv6)
}
// ---- GetBestPhysicalDefaultInterfaces() -----------------------------------------------------
// TestGetBestPhysicalDefaultInterfaces verifies that GetBestPhysicalDefaultInterfaces
// returns at least one valid physical interface and that each non-nil result
// has a routable address for its respective family.
func TestGetBestPhysicalDefaultInterfaces(t *testing.T) {
t.Parallel()
result, err := GetBestPhysicalDefaultInterfaces()
if err != nil {
t.Fatalf("GetBestPhysicalDefaultInterfaces: unexpected error: %v", err)
}
// Print found interfaces
logInterfaceInfo(t, "Result", PhysicalDefaultInterfaces{ForIPv4: result.ForIPv4, ForIPv6: result.ForIPv6})
// At least one family must be resolved on any connected machine.
if result.ForIPv4 == nil && result.ForIPv6 == nil {
t.Fatal("GetBestPhysicalDefaultInterfaces: both ForIPv4 and ForIPv6 are nil; expected at least one")
}
if result.ForIPv4 != nil && !hasRoutableIPv4(result.ForIPv4.Interface) {
t.Errorf("GetBestPhysicalDefaultInterfaces: ForIPv4 interface %q has no routable IPv4 address", result.ForIPv4.Interface.Name)
}
if result.ForIPv6 != nil && !hasRoutableIPv6(result.ForIPv6.Interface) {
t.Errorf("GetBestPhysicalDefaultInterfaces: ForIPv6 interface %q has no routable IPv6 address", result.ForIPv6.Interface.Name)
}
}
// TestGetBestPhysicalDefaultInterfaces_Repeated verifies that repeated calls
// return consistent results (exercises the cache fast-path).
func TestGetBestPhysicalDefaultInterfaces_Repeated(t *testing.T) {
t.Parallel()
first, err := GetBestPhysicalDefaultInterfaces()
if err != nil {
t.Fatalf("first call: %v", err)
}
second, err := GetBestPhysicalDefaultInterfaces()
if err != nil {
t.Fatalf("second call: %v", err)
}
firstName4 := ifaceName(first.ForIPv4)
firstName6 := ifaceName(first.ForIPv6)
secondName4 := ifaceName(second.ForIPv4)
secondName6 := ifaceName(second.ForIPv6)
// Print found interfaces from both calls
logInterfaceInfo(t, "First call", first)
logInterfaceInfo(t, "Second call", second)
if firstName4 != secondName4 {
t.Errorf("IPv4: inconsistent results across calls: %q then %q", firstName4, secondName4)
}
if firstName6 != secondName6 {
t.Errorf("IPv6: inconsistent results across calls: %q then %q", firstName6, secondName6)
}
}
// ifaceName returns the interface name or "<nil>" for a nil InterfaceInfo.
// Used to produce readable test failure messages.
func ifaceName(info *InterfaceInfo) string {
if info == nil {
return "<nil>"
}
return info.Interface.Name
}