Files
ctrld/cmd/cli/network_delta_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

489 lines
16 KiB
Go

package cli
import (
"net"
"net/netip"
"slices"
"testing"
"github.com/Control-D-Inc/ctrld"
"tailscale.com/net/netmon"
)
// deltaTestInterface describes one interface of a test network state.
type deltaTestInterface struct {
name string
up bool
mac string
mtu int
ips []string
}
var (
deltaTestUpFlags = net.FlagUp | net.FlagBroadcast | net.FlagMulticast
deltaTestDownFlags = net.Flags(0)
)
func deltaTestState(t *testing.T, route string, ifaces ...deltaTestInterface) *netmon.State {
t.Helper()
state := &netmon.State{
DefaultRouteInterface: route,
Interface: map[string]netmon.Interface{},
InterfaceIPs: map[string][]netip.Prefix{},
}
for _, iface := range ifaces {
flags := deltaTestDownFlags
if iface.up {
flags = deltaTestUpFlags
}
var mac net.HardwareAddr
if iface.mac != "" {
parsed, err := net.ParseMAC(iface.mac)
if err != nil {
t.Fatalf("ParseMAC(%q) error: %v", iface.mac, err)
}
mac = parsed
}
state.Interface[iface.name] = netmon.Interface{Interface: &net.Interface{
Name: iface.name,
Flags: flags,
MTU: iface.mtu,
HardwareAddr: mac,
}}
for _, ip := range iface.ips {
state.InterfaceIPs[iface.name] = append(state.InterfaceIPs[iface.name], netip.MustParsePrefix(ip))
}
}
return state
}
func sameInterfaceChange(got, want interfaceChange) bool {
return got.Name == want.Name &&
got.Action == want.Action &&
got.Class == want.Class &&
got.HardwarePort == want.HardwarePort &&
got.Service == want.Service &&
slices.Equal(got.IPsBefore, want.IPsBefore) &&
slices.Equal(got.IPsAfter, want.IPsAfter) &&
got.Flags == want.Flags &&
got.MTU == want.MTU &&
got.IsDefaultRoute == want.IsDefaultRoute
}
func assertInterfaceChanges(t *testing.T, got, want []interfaceChange) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("changes = %+v, want %+v", got, want)
}
for i := range want {
if !sameInterfaceChange(got[i], want[i]) {
t.Errorf("change %d = %+v, want %+v", i, got[i], want[i])
}
}
}
func Test_diffNetworkDelta(t *testing.T) {
en0 := deltaTestInterface{name: "en0", up: true, mac: "aa:bb:cc:dd:ee:01", mtu: 1500, ips: []string{"192.0.2.10/24"}}
awdl0 := deltaTestInterface{name: "awdl0", up: true, mac: "aa:bb:cc:dd:ee:02", mtu: 1484, ips: []string{"fe80::1/64"}}
awdl0NewMAC := deltaTestInterface{name: "awdl0", up: true, mac: "aa:bb:cc:dd:ee:03", mtu: 1484, ips: []string{"fe80::1/64"}}
en5 := deltaTestInterface{name: "en5", up: true, mac: "aa:bb:cc:dd:ee:05", mtu: 1500, ips: []string{"192.0.2.50/24"}}
for _, tt := range []struct {
name string
routeOld string
routeNew string
nilBefore bool
before []deltaTestInterface
after []deltaTestInterface
meta interfaceMetaFunc
want []interfaceChange
}{
{
name: "airdrop_mac_change_with_link_local_only",
routeOld: "en0", routeNew: "en0",
before: []deltaTestInterface{en0, awdl0},
after: []deltaTestInterface{en0, awdl0NewMAC},
want: []interfaceChange{{
Name: "awdl0", Action: "mac_changed", Class: "airdrop",
IPsBefore: []string{"fe80::1/64"}, IPsAfter: []string{"fe80::1/64"},
Flags: deltaTestUpFlags.String(), MTU: 1484,
}},
},
{
name: "airdrop_mac_and_link_local_change",
routeOld: "en0", routeNew: "en0",
before: []deltaTestInterface{en0, awdl0},
after: []deltaTestInterface{en0, {
name: "awdl0", up: true, mac: "aa:bb:cc:dd:ee:03", mtu: 1484, ips: []string{"fe80::2/64"},
}},
want: []interfaceChange{{
Name: "awdl0", Action: "ip_removed", Class: "airdrop",
IPsBefore: []string{"fe80::1/64"}, IPsAfter: []string{"fe80::2/64"},
Flags: deltaTestUpFlags.String(), MTU: 1484,
}},
},
{
name: "hardware_address_added_with_airdrop_change",
routeOld: "en0", routeNew: "en0",
before: []deltaTestInterface{en0, awdl0},
after: []deltaTestInterface{{
name: "en0", up: true, mac: "aa:bb:cc:dd:ee:01", mtu: 1500,
ips: []string{"192.0.2.10/24", "192.0.2.11/24"},
}, awdl0NewMAC},
want: []interfaceChange{
{
Name: "awdl0", Action: "mac_changed", Class: "airdrop",
IPsBefore: []string{"fe80::1/64"}, IPsAfter: []string{"fe80::1/64"},
Flags: deltaTestUpFlags.String(), MTU: 1484,
},
{
Name: "en0", Action: "ip_added", Class: "hardware",
IPsBefore: []string{"192.0.2.10/24"}, IPsAfter: []string{"192.0.2.10/24", "192.0.2.11/24"},
Flags: deltaTestUpFlags.String(), MTU: 1500, IsDefaultRoute: true,
},
},
},
{
name: "interface_removed",
routeOld: "en0", routeNew: "en0",
before: []deltaTestInterface{en0, en5},
after: []deltaTestInterface{en0},
want: []interfaceChange{{
Name: "en5", Action: "removed", Class: "hardware",
IPsBefore: []string{"192.0.2.50/24"}, IPsAfter: nil,
Flags: deltaTestUpFlags.String(), MTU: 1500,
}},
},
{
name: "interface_added",
routeOld: "en0", routeNew: "en0",
before: []deltaTestInterface{en0},
after: []deltaTestInterface{en0, en5},
want: []interfaceChange{{
Name: "en5", Action: "added", Class: "hardware",
IPsBefore: nil, IPsAfter: []string{"192.0.2.50/24"},
Flags: deltaTestUpFlags.String(), MTU: 1500,
}},
},
{
name: "address_removed",
routeOld: "en0", routeNew: "en0",
before: []deltaTestInterface{{
name: "en0", up: true, mac: "aa:bb:cc:dd:ee:01", mtu: 1500,
ips: []string{"192.0.2.10/24", "192.0.2.11/24"},
}},
after: []deltaTestInterface{en0},
want: []interfaceChange{{
Name: "en0", Action: "ip_removed", Class: "hardware",
IPsBefore: []string{"192.0.2.10/24", "192.0.2.11/24"}, IPsAfter: []string{"192.0.2.10/24"},
Flags: deltaTestUpFlags.String(), MTU: 1500, IsDefaultRoute: true,
}},
},
{
name: "interface_down",
routeOld: "en0", routeNew: "en0",
before: []deltaTestInterface{en0},
after: []deltaTestInterface{{
name: "en0", up: false, mac: "aa:bb:cc:dd:ee:01", mtu: 1500, ips: []string{"192.0.2.10/24"},
}},
want: []interfaceChange{{
Name: "en0", Action: "down", Class: "hardware",
IPsBefore: []string{"192.0.2.10/24"}, IPsAfter: []string{"192.0.2.10/24"},
Flags: deltaTestDownFlags.String(), MTU: 1500, IsDefaultRoute: true,
}},
},
{
name: "interface_up",
routeOld: "en0", routeNew: "en0",
before: []deltaTestInterface{{
name: "en0", up: false, mac: "aa:bb:cc:dd:ee:01", mtu: 1500, ips: []string{"192.0.2.10/24"},
}},
after: []deltaTestInterface{en0},
want: []interfaceChange{{
Name: "en0", Action: "up", Class: "hardware",
IPsBefore: []string{"192.0.2.10/24"}, IPsAfter: []string{"192.0.2.10/24"},
Flags: deltaTestUpFlags.String(), MTU: 1500, IsDefaultRoute: true,
}},
},
{
name: "mtu_changed",
routeOld: "en0", routeNew: "en0",
before: []deltaTestInterface{en0},
after: []deltaTestInterface{{
name: "en0", up: true, mac: "aa:bb:cc:dd:ee:01", mtu: 1400, ips: []string{"192.0.2.10/24"},
}},
want: []interfaceChange{{
Name: "en0", Action: "mtu_changed", Class: "hardware",
IPsBefore: []string{"192.0.2.10/24"}, IPsAfter: []string{"192.0.2.10/24"},
Flags: deltaTestUpFlags.String(), MTU: 1400, IsDefaultRoute: true,
}},
},
{
name: "default_route_moves_to_the_new_interface",
routeOld: "en0", routeNew: "en5",
before: []deltaTestInterface{en0},
after: []deltaTestInterface{{
name: "en0", up: false, mac: "aa:bb:cc:dd:ee:01", mtu: 1500, ips: []string{"192.0.2.10/24"},
}, en5},
want: []interfaceChange{
{
Name: "en0", Action: "down", Class: "hardware",
IPsBefore: []string{"192.0.2.10/24"}, IPsAfter: []string{"192.0.2.10/24"},
Flags: deltaTestDownFlags.String(), MTU: 1500,
},
{
Name: "en5", Action: "added", Class: "hardware",
IPsBefore: nil, IPsAfter: []string{"192.0.2.50/24"},
Flags: deltaTestUpFlags.String(), MTU: 1500, IsDefaultRoute: true,
},
},
},
{
name: "nil_old_state_adds_every_interface",
routeNew: "en0",
nilBefore: true,
after: []deltaTestInterface{en0},
want: []interfaceChange{{
Name: "en0", Action: "added", Class: "hardware",
IPsBefore: nil, IPsAfter: []string{"192.0.2.10/24"},
Flags: deltaTestUpFlags.String(), MTU: 1500, IsDefaultRoute: true,
}},
},
{
name: "no_change",
routeOld: "en0", routeNew: "en0",
before: []deltaTestInterface{en0, awdl0},
after: []deltaTestInterface{en0, awdl0},
want: nil,
},
{
name: "meta_fills_the_platform_fields",
routeOld: "en0", routeNew: "en0",
before: []deltaTestInterface{en0, {name: "utun3", up: true, mtu: 1400}},
after: []deltaTestInterface{{
name: "en0", up: true, mac: "aa:bb:cc:dd:ee:01", mtu: 1400, ips: []string{"192.0.2.10/24"},
}, {name: "utun3", up: true, mtu: 1380}},
meta: func(name string) interfaceMeta {
if name == "utun3" {
return interfaceMeta{Class: "virtual"}
}
return interfaceMeta{HardwarePort: "Wi-Fi", Service: "Wi-Fi"}
},
want: []interfaceChange{
{
Name: "en0", Action: "mtu_changed", Class: "hardware",
HardwarePort: "Wi-Fi", Service: "Wi-Fi",
IPsBefore: []string{"192.0.2.10/24"}, IPsAfter: []string{"192.0.2.10/24"},
Flags: deltaTestUpFlags.String(), MTU: 1400, IsDefaultRoute: true,
},
{
Name: "utun3", Action: "mtu_changed", Class: "virtual",
Flags: deltaTestUpFlags.String(), MTU: 1380,
},
},
},
} {
t.Run(tt.name, func(t *testing.T) {
var before *netmon.State
if !tt.nilBefore {
before = deltaTestState(t, tt.routeOld, tt.before...)
}
changes := diffNetworkDelta(before, deltaTestState(t, tt.routeNew, tt.after...))
describeInterfaceChanges(changes, tt.meta)
assertInterfaceChanges(t, changes, tt.want)
})
}
}
func Test_diffNetworkDeltaVirtualSet(t *testing.T) {
virtual := virtualInterfaceSetFn
t.Cleanup(func() { virtualInterfaceSetFn = virtual })
virtualInterfaceSetFn = func() map[string]struct{} { return map[string]struct{}{"docker0": {}} }
before := deltaTestState(t, "en0",
deltaTestInterface{name: "en0", up: true, mtu: 1500, ips: []string{"192.0.2.10/24"}},
deltaTestInterface{name: "docker0", up: true, mtu: 1500},
)
after := deltaTestState(t, "en0",
deltaTestInterface{name: "en0", up: true, mtu: 1400, ips: []string{"192.0.2.10/24"}},
deltaTestInterface{name: "docker0", up: true, mtu: 1400},
)
assertInterfaceChanges(t, diffNetworkDelta(before, after), []interfaceChange{
{
Name: "docker0", Action: "mtu_changed", Class: "virtual",
Flags: deltaTestUpFlags.String(), MTU: 1400,
},
{
Name: "en0", Action: "mtu_changed", Class: "hardware",
IPsBefore: []string{"192.0.2.10/24"}, IPsAfter: []string{"192.0.2.10/24"},
Flags: deltaTestUpFlags.String(), MTU: 1400, IsDefaultRoute: true,
},
})
}
func Test_interfaceClass(t *testing.T) {
for _, tt := range []struct {
name string
virtual bool
want string
}{
{name: "lo0", want: "loopback"},
{name: "awdl0", want: "airdrop"},
{name: "llw0", want: "airdrop"},
{name: "utun3", want: "tunnel"},
{name: "tun0", want: "tunnel"},
{name: "tap0", want: "tunnel"},
{name: "wg0", want: "tunnel"},
{name: "ipsec0", want: "tunnel"},
{name: "ppp0", want: "tunnel"},
{name: "docker0", virtual: true, want: "virtual"},
{name: "docker0", want: "hardware"},
{name: "en0", want: "hardware"},
} {
t.Run(tt.name+"_"+tt.want, func(t *testing.T) {
if got := interfaceClass(tt.name, tt.virtual); got != tt.want {
t.Errorf("interfaceClass(%q, %v) = %q, want %q", tt.name, tt.virtual, got, tt.want)
}
})
}
}
func Test_noiseDelta(t *testing.T) {
en0 := deltaTestInterface{name: "en0", up: true, mac: "aa:bb:cc:dd:ee:01", mtu: 1500, ips: []string{"192.0.2.10/24"}}
en5 := deltaTestInterface{name: "en5", up: true, mac: "aa:bb:cc:dd:ee:05", mtu: 1500, ips: []string{"192.0.2.50/24"}}
awdl0 := deltaTestInterface{name: "awdl0", up: true, mac: "aa:bb:cc:dd:ee:02", mtu: 1484, ips: []string{"fe80::1/64"}}
awdl0NewMAC := deltaTestInterface{name: "awdl0", up: true, mac: "aa:bb:cc:dd:ee:03", mtu: 1484, ips: []string{"fe80::1/64"}}
en0Extra := deltaTestInterface{
name: "en0", up: true, mac: "aa:bb:cc:dd:ee:01", mtu: 1500,
ips: []string{"192.0.2.10/24", "192.0.2.11/24"},
}
docker0 := deltaTestInterface{name: "docker0", up: true, mtu: 1500}
docker0NewMTU := deltaTestInterface{name: "docker0", up: true, mtu: 1400}
for _, tt := range []struct {
name string
routeOld string
routeNew string
before []deltaTestInterface
after []deltaTestInterface
virtual []string
timeJumped bool
beforeV4, beforeV6 bool
afterV4, afterV6 bool
source4 string
want bool
}{
{
name: "airdrop_only",
routeOld: "en0", routeNew: "en0",
before: []deltaTestInterface{en0, awdl0},
after: []deltaTestInterface{en0, awdl0NewMAC},
want: true,
},
{
name: "hardware_and_airdrop",
routeOld: "en0", routeNew: "en0",
before: []deltaTestInterface{en0, awdl0},
after: []deltaTestInterface{en0Extra, awdl0NewMAC},
want: false,
},
{
name: "no_change",
routeOld: "en0", routeNew: "en0",
before: []deltaTestInterface{en0, awdl0},
after: []deltaTestInterface{en0, awdl0},
want: false,
},
{
name: "default_route_moved",
routeOld: "en0", routeNew: "en5",
before: []deltaTestInterface{en0, en5, awdl0},
after: []deltaTestInterface{en0, en5, awdl0NewMAC},
want: false,
},
{
name: "virtual_only",
routeOld: "en0", routeNew: "en0",
before: []deltaTestInterface{en0, docker0},
after: []deltaTestInterface{en0, docker0NewMTU},
virtual: []string{"docker0"},
want: true,
},
{
name: "a_time_jump_is_never_noise",
routeOld: "en0", routeNew: "en0",
before: []deltaTestInterface{en0, awdl0},
after: []deltaTestInterface{en0, awdl0NewMAC},
timeJumped: true,
want: false,
},
{
name: "the_default_route_runs_over_a_virtual_interface",
routeOld: "docker0", routeNew: "docker0",
before: []deltaTestInterface{en0, docker0},
after: []deltaTestInterface{en0, docker0NewMTU},
virtual: []string{"docker0"},
want: false,
},
{
name: "a_virtual_interface_holds_a_routable_address",
routeOld: "en0", routeNew: "en0",
before: []deltaTestInterface{en0, {name: "docker0", up: true, mtu: 1500, ips: []string{"172.17.0.1/16"}}},
after: []deltaTestInterface{en0, {name: "docker0", up: true, mtu: 1400, ips: []string{"172.17.0.1/16"}}},
virtual: []string{"docker0"},
want: false,
},
{
name: "an_airdrop_interface_holds_the_resolver_source",
routeOld: "en0", routeNew: "en0",
before: []deltaTestInterface{en0, {name: "awdl0", up: true, mtu: 1484, ips: []string{"169.254.10.5/16"}}},
after: []deltaTestInterface{en0, {name: "awdl0", up: true, mtu: 1400, ips: []string{"169.254.10.5/16"}}},
source4: "169.254.10.5",
want: false,
},
{
name: "have_v4_flips",
routeOld: "en0", routeNew: "en0",
before: []deltaTestInterface{en0, awdl0},
after: []deltaTestInterface{en0, awdl0NewMAC},
beforeV4: true,
want: false,
},
{
name: "have_v6_flips",
routeOld: "en0", routeNew: "en0",
before: []deltaTestInterface{en0, awdl0},
after: []deltaTestInterface{en0, awdl0NewMAC},
afterV6: true,
want: false,
},
} {
t.Run(tt.name, func(t *testing.T) {
stubSnapshotVirtualSet(t, tt.virtual...)
stubResolverSource(t, tt.source4)
before := deltaTestState(t, tt.routeOld, tt.before...)
after := deltaTestState(t, tt.routeNew, tt.after...)
before.HaveV4, before.HaveV6 = tt.beforeV4, tt.beforeV6
after.HaveV4, after.HaveV6 = tt.afterV4, tt.afterV6
changes := diffNetworkDelta(before, after)
if got := noiseDelta(before, after, tt.timeJumped, changes); got != tt.want {
t.Errorf("noiseDelta = %v, want %v, changes %+v", got, tt.want, changes)
}
})
}
}
// stubResolverSource sets the address that the resolver sends its queries
// from. The tests own that global, so each case starts from a known source.
func stubResolverSource(t *testing.T, source4 string) {
t.Helper()
original4, original6 := ctrld.GetDefaultLocalIPv4(), ctrld.GetDefaultLocalIPv6()
t.Cleanup(func() {
ctrld.SetDefaultLocalIPv4(original4)
ctrld.SetDefaultLocalIPv6(original6)
})
ctrld.SetDefaultLocalIPv4(net.ParseIP(source4))
ctrld.SetDefaultLocalIPv6(nil)
}