Files
ctrld/cmd/cli/route_darwin_test.go
T
Anthony Wong 8ae09ac98e feat(cli): put the network state of the host in the log journal
The journal records the network state of the host and each change of
that state. The support team reads one file to see the interfaces, the
routes, the resolvers, and the recoveries around an incident. The same
file shows the health of the query path. A change that touches only
AirDrop or virtual interfaces runs no DNS work.
2026-09-28 16:49:05 +07:00

292 lines
8.9 KiB
Go

package cli
import (
"net/netip"
"syscall"
"testing"
"golang.org/x/net/route"
)
// stubRouteInterfaceNames names the interface of each index, because the
// interfaces of the host are not a fixture.
func stubRouteInterfaceNames(t *testing.T, names map[int]string) {
t.Helper()
orig := routeInterfaceNameFn
t.Cleanup(func() { routeInterfaceNameFn = orig })
routeInterfaceNameFn = func(index int) string { return names[index] }
}
// routeAddrs puts one destination, one netmask, and one gateway at the places
// of the route message that the kernel uses. A nil netmask leaves the slot
// empty, the shape of a host route.
func routeAddrs(destination, netmask, gateway route.Addr) []route.Addr {
addrs := make([]route.Addr, syscall.RTAX_NETMASK+1)
addrs[syscall.RTAX_DST] = destination
addrs[syscall.RTAX_GATEWAY] = gateway
addrs[syscall.RTAX_NETMASK] = netmask
return addrs
}
func inet4(a, b, c, d byte) *route.Inet4Addr {
return &route.Inet4Addr{IP: [4]byte{a, b, c, d}}
}
func inet6(address string, zoneID int) *route.Inet6Addr {
addr := netip.MustParseAddr(address)
return &route.Inet6Addr{IP: addr.As16(), ZoneID: zoneID}
}
func Test_defaultRouteGateway(t *testing.T) {
stubRouteInterfaceNames(t, map[int]string{8: "utun4"})
for _, tt := range []struct {
name string
message route.Message
wantGateway string
wantIndex int
wantOK bool
}{
{
name: "v4_gateway",
message: &route.RouteMessage{
Flags: syscall.RTF_GATEWAY,
Index: 5,
Addrs: routeAddrs(inet4(0, 0, 0, 0), inet4(0, 0, 0, 0), inet4(192, 168, 1, 1)),
},
wantGateway: "192.168.1.1",
wantIndex: 5,
wantOK: true,
},
{
name: "v6_link_local_gateway_with_a_zone",
message: &route.RouteMessage{
Flags: syscall.RTF_GATEWAY,
Index: 8,
Addrs: routeAddrs(inet6("::", 0), inet6("::", 0), inet6("fe80::1", 8)),
},
wantGateway: "fe80::1%utun4",
wantIndex: 8,
wantOK: true,
},
{
name: "destination_is_not_the_default_route",
message: &route.RouteMessage{
Flags: syscall.RTF_GATEWAY,
Index: 5,
Addrs: routeAddrs(inet4(192, 168, 1, 0), inet4(255, 255, 255, 0), inet4(192, 168, 1, 1)),
},
},
{
name: "gateway_names_a_link",
message: &route.RouteMessage{
Flags: syscall.RTF_GATEWAY,
Index: 5,
Addrs: routeAddrs(inet4(0, 0, 0, 0), inet4(0, 0, 0, 0), &route.LinkAddr{Index: 5, Name: "en0"}),
},
},
{
name: "gateway_family_differs_from_the_destination",
message: &route.RouteMessage{
Flags: syscall.RTF_GATEWAY,
Index: 5,
Addrs: routeAddrs(inet4(0, 0, 0, 0), inet4(0, 0, 0, 0), inet6("fe80::1", 8)),
},
},
{
name: "address_list_holds_no_gateway",
message: &route.RouteMessage{
Flags: syscall.RTF_GATEWAY,
Index: 5,
Addrs: []route.Addr{inet4(0, 0, 0, 0)},
},
},
{
name: "message_is_not_a_route",
message: &route.InterfaceMessage{Index: 5},
},
{
name: "route_has_no_gateway_flag",
message: &route.RouteMessage{
Index: 5,
Addrs: routeAddrs(inet4(0, 0, 0, 0), inet4(0, 0, 0, 0), inet4(192, 168, 1, 1)),
},
},
{
name: "route_is_bound_to_one_interface",
message: &route.RouteMessage{
Flags: syscall.RTF_GATEWAY | syscall.RTF_IFSCOPE,
Index: 5,
Addrs: routeAddrs(inet4(0, 0, 0, 0), inet4(0, 0, 0, 0), inet4(192, 168, 1, 1)),
},
},
} {
t.Run(tt.name, func(t *testing.T) {
gateway, index, ok := defaultRouteGateway(tt.message)
if ok != tt.wantOK {
t.Fatalf("ok = %v, want %v", ok, tt.wantOK)
}
if !ok {
return
}
if gateway.String() != tt.wantGateway {
t.Errorf("gateway = %q, want %q", gateway.String(), tt.wantGateway)
}
if index != tt.wantIndex {
t.Errorf("index = %d, want %d", index, tt.wantIndex)
}
})
}
}
// Test_defaultRoutesFromMessages covers the table of a host with a v4 default
// route and a v6 default route on another interface. The v6 route must not
// take the interface of the v4 route.
func Test_defaultRoutesFromMessages(t *testing.T) {
stubRouteInterfaceNames(t, map[int]string{4: "en0", 8: "utun4"})
v4, v6 := defaultRoutesFromMessages([]route.Message{
&route.RouteMessage{
Flags: syscall.RTF_GATEWAY | syscall.RTF_IFSCOPE,
Index: 9,
Addrs: routeAddrs(inet4(0, 0, 0, 0), inet4(0, 0, 0, 0), inet4(10, 0, 0, 1)),
},
&route.RouteMessage{
Flags: syscall.RTF_GATEWAY,
Index: 4,
Addrs: routeAddrs(inet4(0, 0, 0, 0), inet4(0, 0, 0, 0), inet4(192, 168, 1, 1)),
},
&route.RouteMessage{
Flags: syscall.RTF_GATEWAY,
Index: 8,
Addrs: routeAddrs(inet6("::", 0), inet6("::", 0), inet6("fe80::1", 8)),
},
&route.RouteMessage{
Flags: syscall.RTF_GATEWAY,
Index: 5,
Addrs: routeAddrs(inet4(0, 0, 0, 0), inet4(0, 0, 0, 0), inet4(172, 16, 0, 1)),
},
})
if want := (defaultRoute{Gateway: "192.168.1.1", Interface: "en0"}); v4 != want {
t.Errorf("v4 route = %+v, want %+v", v4, want)
}
if want := (defaultRoute{Gateway: "fe80::1%utun4", Interface: "utun4"}); v6 != want {
t.Errorf("v6 route = %+v, want %+v", v6, want)
}
}
// Test_defaultRoutesFromMessagesWithoutADefaultRoute covers a table that holds
// no default route, for example a host with no link.
func Test_defaultRoutesFromMessagesWithoutADefaultRoute(t *testing.T) {
stubRouteInterfaceNames(t, map[int]string{4: "en0"})
v4, v6 := defaultRoutesFromMessages([]route.Message{
&route.RouteMessage{
Flags: syscall.RTF_GATEWAY,
Index: 4,
Addrs: routeAddrs(inet4(192, 168, 1, 0), inet4(255, 255, 255, 0), inet4(192, 168, 1, 1)),
},
})
if v4 != (defaultRoute{}) || v6 != (defaultRoute{}) {
t.Errorf("routes = %+v / %+v, want two empty routes", v4, v6)
}
}
// Test_defaultRouteGatewayNeedsAZeroNetmask covers routes whose destination is
// the zero address but whose prefix is not /0. OpenVPN splits the default into
// two /1 routes, and a host route to 0.0.0.0 carries no mask at all.
func Test_defaultRouteGatewayNeedsAZeroNetmask(t *testing.T) {
for _, tt := range []struct {
name string
message route.Message
}{
{
name: "half of the v4 space",
message: &route.RouteMessage{
Flags: syscall.RTF_GATEWAY,
Index: 8,
Addrs: routeAddrs(inet4(0, 0, 0, 0), inet4(128, 0, 0, 0), inet4(10, 8, 0, 1)),
},
},
{
name: "host route to the zero address",
message: &route.RouteMessage{
Flags: syscall.RTF_GATEWAY | syscall.RTF_HOST,
Index: 8,
Addrs: routeAddrs(inet4(0, 0, 0, 0), nil, inet4(10, 8, 0, 1)),
},
},
{
name: "one eighth of the v6 space",
message: &route.RouteMessage{
Flags: syscall.RTF_GATEWAY,
Index: 8,
Addrs: routeAddrs(inet6("::", 0), inet6("e000::", 0), inet6("fe80::1", 8)),
},
},
} {
t.Run(tt.name, func(t *testing.T) {
if gateway, _, ok := defaultRouteGateway(tt.message); ok {
t.Fatalf("a %s route counted as the default route with gateway %s", tt.name, gateway)
}
})
}
v4, _ := defaultRoutesFromMessages([]route.Message{&route.RouteMessage{
Flags: syscall.RTF_GATEWAY,
Index: 8,
Addrs: routeAddrs(inet4(0, 0, 0, 0), inet4(128, 0, 0, 0), inet4(10, 8, 0, 1)),
}})
if v4.Gateway != "" || v4.Interface != "" {
t.Fatalf("a table with one /1 route reported the default route %+v, want none", v4)
}
}
// Test_defaultRoutesTakeALinkDefault covers a VPN that owns the default
// route. Its default has no next hop: the gateway is the link itself and the
// gateway flag is off. The snapshot must name that interface, with an empty
// gateway, instead of the interface of the network monitor.
func Test_defaultRoutesTakeALinkDefault(t *testing.T) {
stubRouteInterfaceNames(t, map[int]string{28: "utun4", 16: "en0"})
linkDefault := &route.RouteMessage{
Flags: syscall.RTF_UP | syscall.RTF_STATIC,
Index: 28,
Addrs: routeAddrs(inet4(0, 0, 0, 0), inet4(0, 0, 0, 0), &route.LinkAddr{Index: 28, Name: "utun4"}),
}
scopedEn0 := &route.RouteMessage{
Flags: syscall.RTF_UP | syscall.RTF_GATEWAY | syscall.RTF_IFSCOPE,
Index: 16,
Addrs: routeAddrs(inet4(0, 0, 0, 0), inet4(0, 0, 0, 0), inet4(192, 168, 50, 1)),
}
v4, _ := defaultRoutesFromMessages([]route.Message{linkDefault, scopedEn0})
if v4.Interface != "utun4" || v4.Gateway != "" {
t.Fatalf("default route = %+v, want utun4 with no gateway", v4)
}
}
// Test_defaultRouteGatewaySkipsRejectAndBlackholeRoutes covers a kill switch
// that installs a default route to nowhere. Such a route carries the gateway
// flag, but it delivers nothing, so it is not the default route of the host.
func Test_defaultRouteGatewaySkipsRejectAndBlackholeRoutes(t *testing.T) {
for _, tt := range []struct {
name string
flags int
}{
{"reject", syscall.RTF_UP | syscall.RTF_GATEWAY | syscall.RTF_REJECT},
{"blackhole", syscall.RTF_UP | syscall.RTF_GATEWAY | syscall.RTF_BLACKHOLE},
} {
t.Run(tt.name, func(t *testing.T) {
message := &route.RouteMessage{
Flags: tt.flags,
Index: 8,
Addrs: routeAddrs(inet4(0, 0, 0, 0), inet4(0, 0, 0, 0), inet4(10, 8, 0, 1)),
}
if gateway, _, ok := defaultRouteGateway(message); ok {
t.Fatalf("a %s route counted as the default route with gateway %s", tt.name, gateway)
}
})
}
}