Files
ctrld/cmd/cli/network_snapshot_prog_test.go
T

352 lines
12 KiB
Go

package cli
import (
"context"
"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(context.Background(), origV4)
ctrld.SetDefaultLocalIPv6(context.Background(), origV6)
})
ctrld.SetDefaultLocalIPv4(context.Background(), net.ParseIP(v4))
ctrld.SetDefaultLocalIPv6(context.Background(), 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.logger.Store(mainLog.Load())
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{}
p.logger.Store(mainLog.Load())
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)
}
})
}
}