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

558 lines
21 KiB
Go

package cli
import (
"encoding/json"
"fmt"
"net"
"net/netip"
"slices"
"strings"
"testing"
"github.com/rs/zerolog"
"tailscale.com/net/netmon"
)
// snapshotTestInterface names one interface of a test network state.
type snapshotTestInterface struct {
name string
up bool
ips []string
}
// snapshotTestState builds a netmon state with several interfaces, because the
// shared helper of the transition tests holds one interface only.
func snapshotTestState(route string, interfaces ...snapshotTestInterface) *netmon.State {
state := &netmon.State{
DefaultRouteInterface: route,
Interface: map[string]netmon.Interface{},
InterfaceIPs: map[string][]netip.Prefix{},
}
for _, iface := range interfaces {
flags := net.Flags(0)
if iface.up {
flags = net.FlagUp
}
state.Interface[iface.name] = netmon.Interface{Interface: &net.Interface{Name: iface.name, Flags: flags}}
for _, ip := range iface.ips {
state.InterfaceIPs[iface.name] = append(state.InterfaceIPs[iface.name], netip.MustParsePrefix(ip))
}
}
return state
}
func snapshotTestMeta(meta map[string]interfaceMeta) interfaceMetaFunc {
return func(name string) interfaceMeta { return meta[name] }
}
func Test_tetheredNetwork(t *testing.T) {
for _, tt := range []struct {
name string
gatewayV4 string
hardwarePort string
hostIPs []string
want bool
}{
{name: "hotspot_gateway", gatewayV4: "172.20.10.1", hardwarePort: "Wi-Fi", want: true},
{name: "hotspot_range_end", gatewayV4: "172.20.10.15", hardwarePort: "Wi-Fi", want: true},
{name: "above_hotspot_range", gatewayV4: "172.20.10.16", hardwarePort: "Wi-Fi"},
{name: "below_hotspot_range", gatewayV4: "172.20.9.255", hardwarePort: "Wi-Fi"},
{name: "home_gateway", gatewayV4: "192.168.1.1", hardwarePort: "Wi-Fi"},
{name: "iphone_usb_port", gatewayV4: "192.168.1.1", hardwarePort: "iPhone USB", want: true},
{name: "no_gateway_no_port"},
{name: "unparsable_gateway", gatewayV4: "not-an-address"},
{name: "v6_gateway", gatewayV4: "fe80::1", hardwarePort: "Ethernet"},
{name: "hotspot_host_address_without_a_gateway", hostIPs: []string{"172.20.10.4/28"}, want: true},
{name: "hotspot_host_address_of_a_second_address", hostIPs: []string{"fe80::1/64", "172.20.10.4/28"}, want: true},
{name: "home_host_address_without_a_gateway", hostIPs: []string{"192.168.1.20/24"}},
} {
t.Run(tt.name, func(t *testing.T) {
hostIPs := make([]netip.Prefix, 0, len(tt.hostIPs))
for _, ip := range tt.hostIPs {
hostIPs = append(hostIPs, netip.MustParsePrefix(ip))
}
if got := tetheredNetwork(tt.gatewayV4, tt.hardwarePort, hostIPs); got != tt.want {
t.Fatalf("tetheredNetwork(%q, %q, %v) = %v, want %v", tt.gatewayV4, tt.hardwarePort, tt.hostIPs, got, tt.want)
}
})
}
}
// Test_buildNetworkSnapshot_tetheredWithoutAGateway covers Linux and Windows.
// Both read no route table in this release, so the address of the host is the
// only sign of a phone hotspot.
func Test_buildNetworkSnapshot_tetheredWithoutAGateway(t *testing.T) {
state := snapshotTestState("wlan0", snapshotTestInterface{"wlan0", true, []string{"172.20.10.4/28"}})
snapshot := buildNetworkSnapshot(snapshotInputs{State: state})
if !snapshot.Tethered {
t.Error("tethered = false, want true for a host address of the hotspot range")
}
}
// Test_usableV6 pins the grade of a v6 address to the rule of the network
// monitor. A fresh read and a monitor callback must report the same have_v6.
func Test_usableV6(t *testing.T) {
for _, tt := range []struct {
address string
want bool
}{
{"2001:db8::1", true},
{"2606:4700:4700::1111", true},
{"fd00::1", true},
{"fd7a:115c:a1e0::1", false},
{"fe80::1", false},
{"::1", false},
{"192.0.2.10", false},
} {
t.Run(tt.address, func(t *testing.T) {
if got := usableV6(netip.MustParseAddr(tt.address).Unmap()); got != tt.want {
t.Errorf("usableV6(%q) = %v, want %v", tt.address, got, tt.want)
}
})
}
}
func Test_linkTypeFor(t *testing.T) {
for _, tt := range []struct {
name string
hardwarePort string
class string
want string
}{
{"wifi_port", "Wi-Fi", "hardware", "wifi"},
{"ethernet_port", "Ethernet", "hardware", "ethernet"},
{"thunderbolt_port", "Thunderbolt Ethernet Slot 1", "hardware", "ethernet"},
{"usb_lan_port", "USB 10/100/1000 LAN", "hardware", "ethernet"},
{"thunderbolt_bridge", "Thunderbolt Bridge", "hardware", "ethernet"},
{"iphone_usb_port", "iPhone USB", "hardware", "usb_tether"},
{"tunnel_class", "", "tunnel", "tunnel"},
{"no_port_no_class", "", "", "unknown"},
{"unknown_port", "Bluetooth PAN", "hardware", "unknown"},
} {
t.Run(tt.name, func(t *testing.T) {
if got := linkTypeFor(tt.hardwarePort, tt.class); got != tt.want {
t.Fatalf("linkTypeFor(%q, %q) = %q, want %q", tt.hardwarePort, tt.class, got, tt.want)
}
})
}
}
func Test_buildNetworkSnapshot_tetheredWiFiHotspot(t *testing.T) {
state := snapshotTestState("en0", snapshotTestInterface{"en0", true, []string{"172.20.10.2/28"}})
state.HaveV4 = true
snapshot := buildNetworkSnapshot(snapshotInputs{
State: state,
RouteV4: defaultRoute{Gateway: "172.20.10.1", Interface: "en0"},
Meta: snapshotTestMeta(map[string]interfaceMeta{"en0": {Class: "hardware", HardwarePort: "Wi-Fi", Service: "Wi-Fi"}}),
})
if !snapshot.Tethered {
t.Error("tethered = false, want true for the hotspot gateway")
}
if snapshot.LinkType != "wifi" {
t.Errorf("link_type = %q, want %q", snapshot.LinkType, "wifi")
}
if snapshot.DefaultRouteV4 != "en0" || snapshot.DefaultRouteV6 != "" {
t.Errorf("default routes = %q/%q, want en0 and empty", snapshot.DefaultRouteV4, snapshot.DefaultRouteV6)
}
if !snapshot.HaveV4 || snapshot.HaveV6 {
t.Errorf("have_v4/have_v6 = %v/%v, want true/false", snapshot.HaveV4, snapshot.HaveV6)
}
want := []snapshotInterface{{Name: "en0", Class: "hardware", Up: true, IPs: []string{"172.20.10.2/28"}, HardwarePort: "Wi-Fi", Service: "Wi-Fi"}}
if !slices.EqualFunc(snapshot.Interfaces, want, sameSnapshotInterface) {
t.Errorf("interfaces = %+v, want %+v", snapshot.Interfaces, want)
}
}
func Test_buildNetworkSnapshot_usbTether(t *testing.T) {
state := snapshotTestState("en7", snapshotTestInterface{"en7", true, []string{"172.20.10.3/28"}})
snapshot := buildNetworkSnapshot(snapshotInputs{
State: state,
Meta: snapshotTestMeta(map[string]interfaceMeta{"en7": {Class: "hardware", HardwarePort: "iPhone USB", Service: "iPhone USB"}}),
})
if snapshot.LinkType != "usb_tether" {
t.Errorf("link_type = %q, want %q", snapshot.LinkType, "usb_tether")
}
if !snapshot.Tethered {
t.Error("tethered = false, want true for the iPhone USB port")
}
}
func Test_buildNetworkSnapshot_tunnelDefaultRoute(t *testing.T) {
state := snapshotTestState("utun3", snapshotTestInterface{"utun3", true, []string{"100.64.0.2/32"}})
snapshot := buildNetworkSnapshot(snapshotInputs{
State: state,
Meta: snapshotTestMeta(map[string]interfaceMeta{"utun3": {Class: "tunnel"}}),
})
if snapshot.LinkType != "tunnel" {
t.Errorf("link_type = %q, want %q", snapshot.LinkType, "tunnel")
}
if snapshot.Tethered {
t.Error("tethered = true, want false without a hotspot gateway")
}
}
// Test_buildNetworkSnapshot_defaultRouteV4WithoutARouteTable covers the
// platforms that read no route table. The v4 route falls back to the interface
// of the network monitor, and that interface must hold a usable v4 address.
func Test_buildNetworkSnapshot_defaultRouteV4WithoutARouteTable(t *testing.T) {
for _, tt := range []struct {
name string
ips []string
wantV4 string
otherIPs []string
}{
{name: "dual_stack", ips: []string{"192.0.2.10/24", "2001:db8::10/64"}, wantV4: "en0"},
{name: "v4_only", ips: []string{"192.0.2.10/24", "fe80::1/64"}, wantV4: "en0"},
{name: "v6_only", ips: []string{"2001:db8::10/64"}},
{name: "link_local_only", ips: []string{"169.254.1.2/16", "fe80::1/64"}},
{name: "loopback_only", ips: []string{"127.0.0.1/8"}},
{name: "address_on_another_interface", ips: nil, otherIPs: []string{"192.0.2.20/24", "2001:db8::20/64"}},
} {
t.Run(tt.name, func(t *testing.T) {
state := snapshotTestState("en0",
snapshotTestInterface{"en0", true, tt.ips},
snapshotTestInterface{"en1", true, tt.otherIPs})
snapshot := buildNetworkSnapshot(snapshotInputs{State: state})
if snapshot.DefaultRouteV4 != tt.wantV4 {
t.Errorf("default_route_v4 = %q, want %q", snapshot.DefaultRouteV4, tt.wantV4)
}
if snapshot.DefaultRouteV6 != "" {
t.Errorf("default_route_v6 = %q, want an empty value without a route table", snapshot.DefaultRouteV6)
}
})
}
}
// Test_buildNetworkSnapshot_defaultRoutesComeFromTheRouteTable covers a host
// whose v6 default route leaves over a tunnel. The v6 route must name that
// tunnel, not the interface of the v4 route.
func Test_buildNetworkSnapshot_defaultRoutesComeFromTheRouteTable(t *testing.T) {
state := snapshotTestState("en0",
snapshotTestInterface{"en0", true, []string{"192.0.2.10/24", "2001:db8::10/64"}},
snapshotTestInterface{"utun4", true, []string{"2001:db8:1::2/64"}})
snapshot := buildNetworkSnapshot(snapshotInputs{
State: state,
RouteV4: defaultRoute{Gateway: "192.0.2.1", Interface: "en0"},
RouteV6: defaultRoute{Gateway: "fe80::1%utun4", Interface: "utun4"},
})
if snapshot.DefaultRouteV4 != "en0" || snapshot.DefaultRouteV6 != "utun4" {
t.Errorf("default routes = %q/%q, want en0 and utun4", snapshot.DefaultRouteV4, snapshot.DefaultRouteV6)
}
if snapshot.GatewayV4 != "192.0.2.1" || snapshot.GatewayV6 != "fe80::1%utun4" {
t.Errorf("gateways = %q/%q, want the gateways of the route table", snapshot.GatewayV4, snapshot.GatewayV6)
}
}
func Test_buildNetworkSnapshot_sortsInterfacesAndTakesMeta(t *testing.T) {
state := snapshotTestState("en0",
snapshotTestInterface{"en1", false, []string{"192.0.2.11/24"}},
snapshotTestInterface{"awdl0", true, []string{"fe80::2/64"}},
snapshotTestInterface{"en0", true, []string{"192.0.2.10/24"}})
snapshot := buildNetworkSnapshot(snapshotInputs{
State: state,
Meta: snapshotTestMeta(map[string]interfaceMeta{
"en0": {Class: "hardware", HardwarePort: "Wi-Fi", Service: "Wi-Fi"},
"awdl0": {Class: "airdrop"},
"en1": {Class: "hardware", HardwarePort: "Ethernet", Service: "Ethernet"},
}),
})
want := []snapshotInterface{
{Name: "awdl0", Class: "airdrop", Up: true, IPs: []string{"fe80::2/64"}},
{Name: "en0", Class: "hardware", Up: true, IPs: []string{"192.0.2.10/24"}, HardwarePort: "Wi-Fi", Service: "Wi-Fi"},
{Name: "en1", Class: "hardware", Up: false, IPs: []string{"192.0.2.11/24"}, HardwarePort: "Ethernet", Service: "Ethernet"},
}
if !slices.EqualFunc(snapshot.Interfaces, want, sameSnapshotInterface) {
t.Fatalf("interfaces = %+v, want %+v", snapshot.Interfaces, want)
}
}
// Test_buildNetworkSnapshot_capsTheInterfaceList covers a container host. Such
// a host holds hundreds of interfaces, and a snapshot of all of them fills the
// journal.
func Test_buildNetworkSnapshot_capsTheInterfaceList(t *testing.T) {
interfaces := make([]snapshotTestInterface, 0, 40)
for i := range 36 {
interfaces = append(interfaces, snapshotTestInterface{fmt.Sprintf("veth%02d", i), true, nil})
}
for i := range 4 {
interfaces = append(interfaces, snapshotTestInterface{fmt.Sprintf("down%d", i), false, nil})
}
state := snapshotTestState("veth00", interfaces...)
snapshot := buildNetworkSnapshot(snapshotInputs{State: state})
if len(snapshot.Interfaces) != snapshotInterfaceLimit {
t.Fatalf("interfaces = %d entries, want %d", len(snapshot.Interfaces), snapshotInterfaceLimit)
}
if snapshot.InterfacesOmitted != 8 {
t.Errorf("interfaces_omitted = %d, want 8", snapshot.InterfacesOmitted)
}
if first, last := snapshot.Interfaces[0].Name, snapshot.Interfaces[snapshotInterfaceLimit-1].Name; first != "veth00" || last != "veth31" {
t.Errorf("interfaces run from %q to %q, want veth00 to veth31", first, last)
}
}
// Test_buildNetworkSnapshot_keepsUpOrAddressedInterfaces covers the rule that
// selects the interfaces of the list.
func Test_buildNetworkSnapshot_keepsUpOrAddressedInterfaces(t *testing.T) {
state := snapshotTestState("en0",
snapshotTestInterface{"en0", true, []string{"192.0.2.10/24"}},
snapshotTestInterface{"en1", true, nil},
snapshotTestInterface{"en2", false, []string{"192.0.2.20/24"}},
snapshotTestInterface{"en3", false, nil})
snapshot := buildNetworkSnapshot(snapshotInputs{State: state})
var names []string
for _, iface := range snapshot.Interfaces {
names = append(names, iface.Name)
}
if !slices.Equal(names, []string{"en0", "en1", "en2"}) {
t.Errorf("interfaces = %v, want en0, en1, and en2", names)
}
if snapshot.InterfacesOmitted != 1 {
t.Errorf("interfaces_omitted = %d, want 1", snapshot.InterfacesOmitted)
}
}
func Test_buildNetworkSnapshot_classesByNameWithoutMeta(t *testing.T) {
state := snapshotTestState("utun3",
snapshotTestInterface{"utun3", true, []string{"100.64.0.2/32"}},
snapshotTestInterface{"en0", true, []string{"192.0.2.10/24"}})
snapshot := buildNetworkSnapshot(snapshotInputs{State: state})
classes := map[string]string{}
for _, iface := range snapshot.Interfaces {
classes[iface.Name] = iface.Class
}
if classes["utun3"] != "tunnel" || classes["en0"] != "hardware" {
t.Fatalf("classes = %v, want utun3 tunnel and en0 hardware", classes)
}
if snapshot.LinkType != "tunnel" {
t.Errorf("link_type = %q, want %q", snapshot.LinkType, "tunnel")
}
}
func Test_buildNetworkSnapshot_fillsTheEmptyClassOfTheMeta(t *testing.T) {
state := snapshotTestState("utun3",
snapshotTestInterface{"utun3", true, []string{"100.64.0.2/32"}},
snapshotTestInterface{"awdl0", true, []string{"fe80::2/64"}})
snapshot := buildNetworkSnapshot(snapshotInputs{
State: state,
Meta: snapshotTestMeta(map[string]interfaceMeta{"utun3": {HardwarePort: ""}}),
})
classes := map[string]string{}
for _, iface := range snapshot.Interfaces {
classes[iface.Name] = iface.Class
}
if classes["utun3"] != "tunnel" || classes["awdl0"] != "airdrop" {
t.Fatalf("classes = %v, want utun3 tunnel and awdl0 airdrop", classes)
}
if snapshot.LinkType != "tunnel" {
t.Errorf("link_type = %q, want %q", snapshot.LinkType, "tunnel")
}
}
func Test_buildNetworkSnapshot_nilStateKeepsFlags(t *testing.T) {
snapshot := buildNetworkSnapshot(snapshotInputs{
RouteV4: defaultRoute{Gateway: "192.168.1.1"},
RouteV6: defaultRoute{Gateway: "fe80::1"},
Resolvers: []string{"192.168.1.1:53"},
SourceIPv4: "192.168.1.20",
SourceIPv6: "2001:db8::20",
InterceptTarget: "127.0.0.1",
BypassActive: true,
RecoveryRunning: true,
PFStabilizing: true,
NAT64Prefix: "64:ff9b::/96",
CLATPresent: true,
})
if len(snapshot.Interfaces) != 0 {
t.Errorf("interfaces = %+v, want none", snapshot.Interfaces)
}
if snapshot.DefaultRouteV4 != "" || snapshot.DefaultRouteV6 != "" || snapshot.HaveV4 || snapshot.HaveV6 {
t.Errorf("state fields are set without a state: %+v", snapshot)
}
if snapshot.LinkType != "unknown" {
t.Errorf("link_type = %q, want %q", snapshot.LinkType, "unknown")
}
if !snapshot.BypassActive || !snapshot.RecoveryRunning || !snapshot.PFStabilizing || !snapshot.CLATPresent {
t.Errorf("flags dropped: %+v", snapshot)
}
if !snapshot.DNSLessTarget {
t.Error("dns_less_target = false, want true with an intercept target")
}
if snapshot.GatewayV4 != "192.168.1.1" || snapshot.GatewayV6 != "fe80::1" {
t.Errorf("gateways = %q/%q, want the input values", snapshot.GatewayV4, snapshot.GatewayV6)
}
if snapshot.SourceIPv4 != "192.168.1.20" || snapshot.SourceIPv6 != "2001:db8::20" {
t.Errorf("sources = %q/%q, want the input values", snapshot.SourceIPv4, snapshot.SourceIPv6)
}
if snapshot.NAT64Prefix != "64:ff9b::/96" || !slices.Equal(snapshot.Resolvers, []string{"192.168.1.1:53"}) {
t.Errorf("prefix and resolvers dropped: %+v", snapshot)
}
}
func Test_buildNetworkSnapshot_noInterceptTargetIsNotDNSLess(t *testing.T) {
snapshot := buildNetworkSnapshot(snapshotInputs{State: snapshotTestState("en0", snapshotTestInterface{"en0", true, nil})})
if snapshot.DNSLessTarget {
t.Error("dns_less_target = true, want false without an intercept target")
}
}
// snapshotDictKeys are the field names that spec R2.2 names for the snapshot,
// plus the count of the interfaces that the list leaves out.
var snapshotDictKeys = []string{
"default_route_v4", "default_route_v6", "gateway_v4", "gateway_v6",
"have_v4", "have_v6", "interfaces", "interfaces_omitted", "resolvers",
"source_ipv4", "source_ipv6", "intercept_target", "bypass_active",
"recovery_running", "pf_stabilizing", "link_type", "tethered",
"clat_present", "nat64_prefix", "dns_less_target",
}
var snapshotInterfaceDictKeys = []string{"class", "hardware_port", "ips", "name", "service", "up"}
// renderSnapshotDict writes the dict under one field and parses the line, so
// the test reads the keys that a reader of the journal sees.
func renderSnapshotDict(t *testing.T, snapshot networkSnapshot) (string, map[string]any) {
t.Helper()
buf := &syncBuffer{}
logger := zerolog.New(buf)
logger.Log().Dict("network", snapshotDict(snapshot)).Msg("Network snapshot")
line := strings.TrimSpace(buf.String())
event := map[string]any{}
if err := json.Unmarshal([]byte(line), &event); err != nil {
t.Fatalf("log line is not JSON: %q: %v", line, err)
}
network, ok := event["network"].(map[string]any)
if !ok {
t.Fatalf("line has no network object: %q", line)
}
return line, network
}
func Test_snapshotDict_rendersEveryKeyOnce(t *testing.T) {
line, network := renderSnapshotDict(t, buildNetworkSnapshot(snapshotInputs{}))
if got := sortedKeys(network); !slices.Equal(got, slices.Sorted(slices.Values(snapshotDictKeys))) {
t.Fatalf("keys = %v, want %v", got, snapshotDictKeys)
}
for _, key := range snapshotDictKeys {
if count := strings.Count(line, `"`+key+`":`); count != 1 {
t.Errorf("key %q appears %d times, want 1: %s", key, count, line)
}
}
if network["interfaces"] == nil || len(network["interfaces"].([]any)) != 0 {
t.Errorf("interfaces = %v, want an empty array", network["interfaces"])
}
if network["resolvers"] == nil || len(network["resolvers"].([]any)) != 0 {
t.Errorf("resolvers = %v, want an empty array", network["resolvers"])
}
}
func Test_snapshotDict_rendersSortedInterfaces(t *testing.T) {
state := snapshotTestState("en0",
snapshotTestInterface{"en1", true, nil},
snapshotTestInterface{"awdl0", true, []string{"fe80::2/64"}},
snapshotTestInterface{"en0", true, []string{"192.0.2.10/24"}})
snapshot := buildNetworkSnapshot(snapshotInputs{
State: state,
RouteV4: defaultRoute{Gateway: "192.0.2.1", Interface: "en0"},
Resolvers: []string{"192.0.2.1:53"},
Meta: snapshotTestMeta(map[string]interfaceMeta{"en0": {Class: "hardware", HardwarePort: "Wi-Fi", Service: "Wi-Fi"}}),
})
_, network := renderSnapshotDict(t, snapshot)
interfaces, ok := network["interfaces"].([]any)
if !ok || len(interfaces) != 3 {
t.Fatalf("interfaces = %v, want three entries", network["interfaces"])
}
var names []string
for _, entry := range interfaces {
object, ok := entry.(map[string]any)
if !ok {
t.Fatalf("interface entry is not an object: %v", entry)
}
if got := sortedKeys(object); !slices.Equal(got, snapshotInterfaceDictKeys) {
t.Fatalf("interface keys = %v, want %v", got, snapshotInterfaceDictKeys)
}
names = append(names, object["name"].(string))
}
if !slices.Equal(names, []string{"awdl0", "en0", "en1"}) {
t.Errorf("interface order = %v, want awdl0, en0, en1", names)
}
if ips, ok := interfaces[2].(map[string]any)["ips"].([]any); !ok || len(ips) != 0 {
t.Errorf("ips of en1 = %v, want an empty array", interfaces[2].(map[string]any)["ips"])
}
wantValues := map[string]any{"gateway_v4": "192.0.2.1", "link_type": "wifi", "default_route_v4": "en0", "tethered": false}
for key, want := range wantValues {
wantField(t, network, key, want)
}
if resolvers, ok := network["resolvers"].([]any); !ok || len(resolvers) != 1 || resolvers[0] != "192.0.2.1:53" {
t.Errorf("resolvers = %v, want one entry", network["resolvers"])
}
}
func Test_defaultRoutes_returnAddressesOrNothing(t *testing.T) {
v4, v6 := defaultRoutes()
// A host without a route of a family gives an empty value, so the test
// logs the pair and holds every value it gets to the format.
t.Logf("default routes: v4 %+v, v6 %+v", v4, v6)
if v4.Gateway != "" {
address, err := netip.ParseAddr(v4.Gateway)
if err != nil || !address.Is4() {
t.Errorf("gateway_v4 = %q, want an IPv4 address or nothing: %v", v4.Gateway, err)
}
}
if v6.Gateway != "" {
address, err := netip.ParseAddr(v6.Gateway)
if err != nil || !address.Is6() {
t.Errorf("gateway_v6 = %q, want an IPv6 address or nothing: %v", v6.Gateway, err)
}
}
// A tunnel default has no next hop, so an interface without a gateway is a
// valid pair. A gateway without an interface is not.
if v4.Gateway != "" && v4.Interface == "" {
t.Errorf("gateway_v4 = %q without an interface, want the interface of the route", v4.Gateway)
}
if v6.Gateway != "" && v6.Interface == "" {
t.Errorf("gateway_v6 = %q without an interface, want the interface of the route", v6.Gateway)
}
}
func sameSnapshotInterface(a, b snapshotInterface) bool {
return a.Name == b.Name && a.Class == b.Class && a.Up == b.Up &&
a.HardwarePort == b.HardwarePort && a.Service == b.Service &&
slices.Equal(a.IPs, b.IPs)
}
func sortedKeys(object map[string]any) []string {
keys := make([]string, 0, len(object))
for key := range object {
keys = append(keys, key)
}
slices.Sort(keys)
return keys
}