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

349 lines
12 KiB
Go

package cli
import (
"net"
"net/netip"
"reflect"
"slices"
"testing"
"time"
"github.com/Control-D-Inc/ctrld"
"tailscale.com/net/netmon"
)
// stubSnapshotPlatformMeta makes the platform names of each interface a
// fixture, so no test depends on the ports of the host.
func stubSnapshotPlatformMeta(t *testing.T, meta map[string]interfaceMeta) {
t.Helper()
orig := platformInterfaceMetaFn
t.Cleanup(func() { platformInterfaceMetaFn = orig })
platformInterfaceMetaFn = func(name string) (class, hardwarePort, service string) {
info := meta[name]
return info.Class, info.HardwarePort, info.Service
}
}
// stubSnapshotVirtualSet names the virtual adapters of the fixture host.
func stubSnapshotVirtualSet(t *testing.T, names ...string) {
t.Helper()
orig := virtualInterfaceSetFn
t.Cleanup(func() { virtualInterfaceSetFn = orig })
virtual := make(map[string]struct{}, len(names))
for _, name := range names {
virtual[name] = struct{}{}
}
virtualInterfaceSetFn = func() map[string]struct{} { return virtual }
}
// stubSnapshotGateways replaces the route table read, because the gateways of
// the host are not a fixture.
func stubSnapshotGateways(t *testing.T, v4, v6 string) {
t.Helper()
stubSnapshotRoutes(t, defaultRoute{Gateway: v4}, defaultRoute{Gateway: v6})
}
// stubSnapshotRoutes replaces the route table read with one default route per
// family.
func stubSnapshotRoutes(t *testing.T, v4, v6 defaultRoute) {
t.Helper()
orig := defaultRoutesFn
t.Cleanup(func() { defaultRoutesFn = orig })
defaultRoutesFn = func() (defaultRoute, defaultRoute) { return v4, v6 }
}
// stubSnapshotDNS64 reports a dual-stack host, so the NAT64 check starts no
// discovery and touches no name server.
func stubSnapshotDNS64(t *testing.T) {
t.Helper()
orig := dns64NetworkClassFn
t.Cleanup(func() { dns64NetworkClassFn = orig })
dns64NetworkClassFn = func() (hasUsableIPv4, hasCLAT bool, err error) { return true, false, nil }
}
// stubSnapshotSources sets the local source addresses and puts the values of
// the host back when the test ends.
func stubSnapshotSources(t *testing.T, v4, v6 string) {
t.Helper()
origV4, origV6 := ctrld.GetDefaultLocalIPv4(), ctrld.GetDefaultLocalIPv6()
t.Cleanup(func() {
ctrld.SetDefaultLocalIPv4(origV4)
ctrld.SetDefaultLocalIPv6(origV6)
})
ctrld.SetDefaultLocalIPv4(net.ParseIP(v4))
ctrld.SetDefaultLocalIPv6(net.ParseIP(v6))
}
// tetheredSnapshotProg returns a prog on a phone hotspot over the cable.
func tetheredSnapshotProg(t *testing.T) *prog {
t.Helper()
stubSnapshotDNS64(t)
stubSnapshotGateways(t, "172.20.10.1", "")
stubSnapshotPlatformMeta(t, map[string]interfaceMeta{
"en7": {HardwarePort: hardwarePortIPhoneUSB, Service: "iPhone USB"},
})
stubSnapshotVirtualSet(t)
stubSnapshotSources(t, "172.20.10.5", "")
p := &prog{}
p.lastNetworkState.Store(&netmon.State{
DefaultRouteInterface: "en7",
HaveV4: true,
Interface: map[string]netmon.Interface{
"en7": {Interface: &net.Interface{Name: "en7", Flags: net.FlagUp}},
"lo0": {Interface: &net.Interface{Name: "lo0", Flags: net.FlagUp}},
},
InterfaceIPs: map[string][]netip.Prefix{
"en7": {netip.MustParsePrefix("172.20.10.5/28")},
"lo0": {netip.MustParsePrefix("127.0.0.1/8")},
},
})
p.recoveryBypass.Store(true)
p.pfStabilizing.Store(true)
p.interceptDNSTargetSetValue = "127.0.0.1"
p.publishInterceptTarget("127.0.0.1")
return p
}
func Test_networkSnapshotFromTheStoredState(t *testing.T) {
p := tetheredSnapshotProg(t)
snapshot := p.networkSnapshot()
checks := []struct {
field string
got, want any
}{
{"default_route_v4", snapshot.DefaultRouteV4, "en7"},
{"default_route_v6", snapshot.DefaultRouteV6, ""},
{"gateway_v4", snapshot.GatewayV4, "172.20.10.1"},
{"have_v4", snapshot.HaveV4, true},
{"source_ipv4", snapshot.SourceIPv4, "172.20.10.5"},
{"source_ipv6", snapshot.SourceIPv6, ""},
{"intercept_target", snapshot.InterceptTarget, "127.0.0.1"},
{"dns_less_target", snapshot.DNSLessTarget, true},
{"bypass_active", snapshot.BypassActive, true},
{"recovery_running", snapshot.RecoveryRunning, false},
{"pf_stabilizing", snapshot.PFStabilizing, true},
{"link_type", snapshot.LinkType, "usb_tether"},
{"tethered", snapshot.Tethered, true},
{"nat64_prefix", snapshot.NAT64Prefix, ""},
{"clat_present", snapshot.CLATPresent, false},
}
for _, c := range checks {
if c.got != c.want {
t.Errorf("%s = %v, want %v", c.field, c.got, c.want)
}
}
want := []snapshotInterface{
{Name: "en7", Class: "hardware", Up: true, IPs: []string{"172.20.10.5/28"}, HardwarePort: hardwarePortIPhoneUSB, Service: "iPhone USB"},
{Name: "lo0", Class: "loopback", Up: true, IPs: []string{"127.0.0.1/8"}},
}
if !reflect.DeepEqual(snapshot.Interfaces, want) {
t.Errorf("interfaces = %+v, want %+v", snapshot.Interfaces, want)
}
}
// Test_networkSnapshotReadsTheNetworkWithoutAStoredState covers the first
// header of a run. No network callback reached ctrld yet, so the snapshot must
// read the interfaces itself and name the families it finds.
func Test_networkSnapshotReadsTheNetworkWithoutAStoredState(t *testing.T) {
stubSnapshotDNS64(t)
stubSnapshotGateways(t, "192.0.2.1", "")
stubSnapshotPlatformMeta(t, nil)
stubSnapshotSources(t, "", "")
stubLogHeaderNetworkRead(t, "en9")
p := &prog{}
snapshot := p.networkSnapshot()
if len(snapshot.Interfaces) != 1 || snapshot.Interfaces[0].Name != "en9" || !snapshot.Interfaces[0].Up {
t.Fatalf("interfaces = %+v, want one up en9", snapshot.Interfaces)
}
if !snapshot.HaveV4 {
t.Error("have_v4 = false, want true for a global unicast v4 address")
}
if snapshot.HaveV6 {
t.Error("have_v6 = true, want false without a v6 address")
}
}
// Test_reachableAddressFamiliesGradesTheV6AddressLikeNetmon covers a host on a
// unique local address. The network monitor counts such an address as usable,
// so a fresh read must count it too.
func Test_reachableAddressFamiliesGradesTheV6AddressLikeNetmon(t *testing.T) {
for _, tt := range []struct {
name string
ips []string
wantV6 bool
}{
{name: "unique_local", ips: []string{"fd00::2/64"}, wantV6: true},
{name: "tailscale_range", ips: []string{"fd7a:115c:a1e0::2/128"}},
{name: "global", ips: []string{"2001:db8::2/64"}, wantV6: true},
{name: "link_local", ips: []string{"fe80::2/64"}},
} {
t.Run(tt.name, func(t *testing.T) {
state := snapshotTestState("en0", snapshotTestInterface{"en0", true, tt.ips})
_, haveV6 := reachableAddressFamilies(state)
if haveV6 != tt.wantV6 {
t.Errorf("have_v6 = %v, want %v for %v", haveV6, tt.wantV6, tt.ips)
}
})
}
}
func Test_networkSnapshotLogsOneJournalLine(t *testing.T) {
logs := captureDebugMainLog(t)
p := tetheredSnapshotProg(t)
p.logNetworkSnapshot("start")
events := jsonLogEvents(t, logs, networkSnapshotMessage)
if len(events) != 1 {
t.Fatalf("%q lines = %d, want 1", networkSnapshotMessage, len(events))
}
wantField(t, events[0], "trigger", "start")
wantField(t, events[0], journalField, true)
wantField(t, events[0], "level", "info")
network, ok := events[0]["network"].(map[string]any)
if !ok {
t.Fatalf("network = %v, want an object", events[0]["network"])
}
wantField(t, network, "default_route_v4", "en7")
wantField(t, network, "tethered", true)
wantField(t, network, "link_type", "usb_tether")
}
// snapshotTestClock hands out the time that a test sets, because the snapshot
// limit counts seconds between two lines.
type snapshotTestClock struct {
now time.Time
}
// stubSnapshotClock puts the clock of the snapshot limit under the control of
// one test.
func stubSnapshotClock(t *testing.T) *snapshotTestClock {
t.Helper()
clock := &snapshotTestClock{now: time.Date(2026, 9, 17, 10, 0, 0, 0, time.UTC)}
orig := snapshotNowFn
t.Cleanup(func() { snapshotNowFn = orig })
snapshotNowFn = func() time.Time { return clock.now }
return clock
}
func (c *snapshotTestClock) advance(d time.Duration) { c.now = c.now.Add(d) }
// snapshotTriggerLines names the trigger of every snapshot line, in order.
func snapshotTriggerLines(t *testing.T, logs *syncBuffer) []string {
t.Helper()
var triggers []string
for _, event := range jsonLogEvents(t, logs, networkSnapshotMessage) {
trigger, _ := event["trigger"].(string)
triggers = append(triggers, trigger)
}
return triggers
}
// Test_logNetworkSnapshotSkipsAnEqualSnapshot covers a recovery on a network
// that did not change. The begin and the end of that recovery render the same
// object, so the second line carries nothing.
func Test_logNetworkSnapshotSkipsAnEqualSnapshot(t *testing.T) {
logs := captureDebugMainLog(t)
clock := stubSnapshotClock(t)
p := tetheredSnapshotProg(t)
p.logNetworkSnapshot("recovery_begin")
clock.advance(5 * time.Minute)
p.logNetworkSnapshot("recovery_end")
if got := snapshotTriggerLines(t, logs); !slices.Equal(got, []string{"recovery_begin"}) {
t.Errorf("snapshot triggers = %v, want the first line alone", got)
}
}
// Test_logNetworkSnapshotCapsTheRate covers a flapping interface. Two
// snapshots that differ in no route and in no resolver give one line per
// minute.
func Test_logNetworkSnapshotCapsTheRate(t *testing.T) {
logs := captureDebugMainLog(t)
clock := stubSnapshotClock(t)
p := tetheredSnapshotProg(t)
p.logNetworkSnapshot("transition")
clock.advance(30 * time.Second)
p.recoveryRunning.Store(true)
p.logNetworkSnapshot("transition")
clock.advance(30 * time.Second)
p.logNetworkSnapshot("transition")
if got := snapshotTriggerLines(t, logs); len(got) != 2 {
t.Errorf("snapshot lines = %d, want 2: %v", len(got), got)
}
}
// Test_logNetworkSnapshotWritesARouteChangeAtOnce covers the change that
// explains an outage. The cap must not hold it back.
func Test_logNetworkSnapshotWritesARouteChangeAtOnce(t *testing.T) {
logs := captureDebugMainLog(t)
clock := stubSnapshotClock(t)
p := tetheredSnapshotProg(t)
p.logNetworkSnapshot("transition")
clock.advance(time.Second)
stubSnapshotRoutes(t, defaultRoute{Gateway: "192.168.1.1", Interface: "en0"}, defaultRoute{})
p.logNetworkSnapshot("transition")
clock.advance(time.Second)
p.recoveryRunning.Store(true)
p.logNetworkSnapshot("recovery_begin")
if got := snapshotTriggerLines(t, logs); !slices.Equal(got, []string{"transition", "transition"}) {
t.Errorf("snapshot triggers = %v, want the two transitions", got)
}
}
// Test_logNetworkSnapshotAlwaysWritesTheStartLine covers the first line of a
// run. A reader needs the network of the start, whatever came before it.
func Test_logNetworkSnapshotAlwaysWritesTheStartLine(t *testing.T) {
logs := captureDebugMainLog(t)
clock := stubSnapshotClock(t)
p := tetheredSnapshotProg(t)
p.logNetworkSnapshot("transition")
clock.advance(time.Second)
p.logNetworkSnapshot(snapshotTriggerStart)
if got := snapshotTriggerLines(t, logs); !slices.Equal(got, []string{"transition", "start"}) {
t.Errorf("snapshot triggers = %v, want the transition and the start", got)
}
}
func Test_interfaceMetaFor(t *testing.T) {
stubSnapshotVirtualSet(t, "bridge100")
stubSnapshotPlatformMeta(t, map[string]interfaceMeta{
"lo0": {Class: "hardware"},
"en0": {HardwarePort: "Wi-Fi", Service: "Wi-Fi"},
"eth0": {Class: "hardware", HardwarePort: "Intel I219-V"},
})
for _, tt := range []struct {
name string
want interfaceMeta
}{
{"lo0", interfaceMeta{Class: "loopback", LinkType: "unknown"}},
{"awdl0", interfaceMeta{Class: "airdrop", LinkType: "unknown"}},
{"utun3", interfaceMeta{Class: "tunnel", LinkType: "tunnel"}},
{"en0", interfaceMeta{Class: "hardware", HardwarePort: "Wi-Fi", Service: "Wi-Fi", LinkType: "wifi"}},
{"bridge100", interfaceMeta{Class: "virtual", LinkType: "unknown"}},
{"eth0", interfaceMeta{Class: "hardware", HardwarePort: "Intel I219-V", LinkType: "unknown"}},
} {
t.Run(tt.name, func(t *testing.T) {
if got := interfaceMetaFor(tt.name); got != tt.want {
t.Errorf("interfaceMetaFor(%q) = %+v, want %+v", tt.name, got, tt.want)
}
})
}
}