mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-10-12 01:58:46 +02:00
352 lines
12 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|