Files
ctrld/cmd/cli/network_events_test.go
T

810 lines
32 KiB
Go

package cli
import (
"context"
"fmt"
"net"
"net/netip"
"slices"
"testing"
"time"
"github.com/Control-D-Inc/ctrld"
"tailscale.com/net/netmon"
)
// hostWokeTestState names the network that a wake event reports.
func hostWokeTestState() *netmon.State {
return &netmon.State{
DefaultRouteInterface: "en0",
Interface: map[string]netmon.Interface{
"en0": {Interface: &net.Interface{Name: "en0", Flags: net.FlagUp}},
"en5": {Interface: &net.Interface{Name: "en5"}},
"utun": {Interface: &net.Interface{Name: "utun", Flags: net.FlagUp}},
},
InterfaceIPs: map[string][]netip.Prefix{},
}
}
// hostWokeTestClock hands the wake events a clock that the test moves.
func hostWokeTestClock(t *testing.T, clock *time.Time) {
t.Helper()
original := networkEventsNowFn
t.Cleanup(func() { networkEventsNowFn = original })
networkEventsNowFn = func() time.Time { return *clock }
}
// hostWokeTestHold captures the hold timer of the wake reporter, so a test
// decides when the hold ends.
func hostWokeTestHold(p *prog) *[]func() {
var armed []func()
p.wake.after = func(_ time.Duration, fn func()) { armed = append(armed, fn) }
return &armed
}
// Test_noteHostWokeNetmonFirstWaitsForTheDetectorGap covers the order of the
// 2026-09-14 capture. netmon sees the time jump first and measures no gap, so
// its report waits for the detector, and the journal gets one event with the
// gap.
func Test_noteHostWokeNetmonFirstWaitsForTheDetectorGap(t *testing.T) {
logs := captureDebugMainLog(t)
clock := time.Date(2026, 9, 17, 7, 14, 0, 0, time.UTC)
hostWokeTestClock(t, &clock)
p := &prog{dnsConfig: newDNSConfigPoller(func() ([]dnsResolverEntry, error) { return nil, nil })}
p.logger.Store(mainLog.Load())
armed := hostWokeTestHold(p)
p.noteHostWoke("netmon", 0, hostWokeTestState())
if events := jsonLogEvents(t, logs, hostWokeMessage); len(events) != 0 {
t.Fatalf("got %d Host woke events before the hold ended, want 0", len(events))
}
if got := p.dnsConfig.nextDelay(clock); got != dnsConfigFastInterval {
t.Fatalf("DNS configuration poll delay after a wake = %s, want %s", got, dnsConfigFastInterval)
}
clock = clock.Add(2 * time.Second)
p.noteHostWoke("detector", 90*time.Second, hostWokeTestState())
for _, fire := range *armed {
fire()
}
events := jsonLogEvents(t, logs, hostWokeMessage)
if len(events) != 1 {
t.Fatalf("got %d Host woke events, want one report with the gap of the detector", len(events))
}
wantField(t, events[0], "source", "detector")
wantField(t, events[0], "gap_known", true)
wantField(t, events[0], "gap_ms", float64(90000))
wantField(t, events[0], "default_route", "en0")
wantField(t, events[0], "interfaces_up", float64(2))
wantField(t, events[0], "journal", true)
clock = clock.Add(5 * time.Second)
p.noteHostWoke("netmon", 0, hostWokeTestState())
if events = jsonLogEvents(t, logs, hostWokeMessage); len(events) != 1 {
t.Fatalf("got %d Host woke events, want no second report inside the window", len(events))
}
}
// Test_noteHostWokeNetmonAloneReportsAfterTheHold covers a host without the
// detector. The netmon report goes out without a gap when the hold ends.
func Test_noteHostWokeNetmonAloneReportsAfterTheHold(t *testing.T) {
logs := captureDebugMainLog(t)
clock := time.Date(2026, 9, 17, 7, 14, 0, 0, time.UTC)
hostWokeTestClock(t, &clock)
p := &prog{}
p.logger.Store(mainLog.Load())
armed := hostWokeTestHold(p)
p.noteHostWoke("netmon", 0, hostWokeTestState())
if len(*armed) != 1 {
t.Fatalf("holds armed = %d, want 1", len(*armed))
}
(*armed)[0]()
events := jsonLogEvents(t, logs, hostWokeMessage)
if len(events) != 1 {
t.Fatalf("got %d Host woke events after the hold, want 1", len(events))
}
wantField(t, events[0], "source", "netmon")
wantField(t, events[0], "gap_known", false)
if _, measured := events[0]["gap_ms"]; measured {
t.Fatalf("the netmon report carries a gap that it cannot measure: %v", events[0])
}
}
// Test_noteHostWokeDetectorFirstReportsOneEvent covers the other order. The
// gap is known with the first report, so netmon adds nothing.
func Test_noteHostWokeDetectorFirstReportsOneEvent(t *testing.T) {
logs := captureDebugMainLog(t)
resume := time.Date(2026, 9, 17, 7, 14, 0, 0, time.UTC)
clock := resume
hostWokeTestClock(t, &clock)
p := &prog{}
p.logger.Store(mainLog.Load())
armed := hostWokeTestHold(p)
p.noteHostWoke("detector", 90*time.Second, hostWokeTestState())
clock = resume.Add(2 * time.Second)
p.noteHostWoke("netmon", 0, hostWokeTestState())
events := jsonLogEvents(t, logs, hostWokeMessage)
if len(events) != 1 || len(*armed) != 0 {
t.Fatalf("got %d Host woke events and %d holds inside the window, want 1 and 0", len(events), len(*armed))
}
wantField(t, events[0], "source", "detector")
wantField(t, events[0], "gap_ms", float64(90000))
// netmon polls every 15 s, so the window has to outlast one poll.
clock = resume.Add(29 * time.Second)
p.noteHostWoke("netmon", 0, hostWokeTestState())
if events = jsonLogEvents(t, logs, hostWokeMessage); len(events) != 1 || len(*armed) != 0 {
t.Fatalf("got %d Host woke events and %d holds, want one report for a wake that netmon reports late", len(events), len(*armed))
}
clock = resume.Add(31 * time.Second)
p.noteHostWoke("netmon", 0, hostWokeTestState())
for _, fire := range *armed {
fire()
}
if events = jsonLogEvents(t, logs, hostWokeMessage); len(events) != 2 {
t.Fatalf("got %d Host woke events after the window, want 2", len(events))
}
}
// networkEventsHarness drives the network callback on a clock that the test
// owns. Every host read stays behind a stub, so no test starts networksetup or
// reads the route table of the machine.
type networkEventsHarness struct {
prog *prog
logs *syncBuffer
now time.Time
reconciled int
ignored int
}
func newNetworkEventsHarness(t *testing.T, valid ...string) *networkEventsHarness {
t.Helper()
sourceTestGlobals(t)
stubHeaderSnapshotSources(t)
stubSnapshotVirtualSet(t)
h := &networkEventsHarness{
prog: &prog{cfg: &ctrld.Config{}, dnsInterceptState: &interceptStateStub{}},
logs: captureDebugMainLog(t),
now: time.Date(2026, 9, 17, 7, 14, 0, 0, time.UTC),
}
h.prog.logger.Store(mainLog.Load())
oldNow := networkEventsNowFn
networkEventsNowFn = func() time.Time { return h.now }
t.Cleanup(func() { networkEventsNowFn = oldNow })
validSet := make(map[string]struct{}, len(valid))
for _, name := range valid {
validSet[name] = struct{}{}
}
networkChangeValidInterfacesFn = func(context.Context) map[string]struct{} { return validSet }
networkChangeReconcileFn = func(_ *prog, _ context.Context, _ uint64) { h.reconciled++ }
networkChangeIgnoredInterceptFn = func(_ *prog, _ *netmon.ChangeDelta, _ time.Time) { h.ignored++ }
return h
}
func (h *networkEventsHarness) handle(before, after *netmon.State, timeJumped bool) {
h.prog.handleNetworkChange(context.Background(), &netmon.ChangeDelta{Old: before, New: after, TimeJumped: timeJumped}, true)
}
// stubNetmonCache gives every callback of the harness one cache snapshot.
// netmon caches major snapshots only, so the epoch of a minor callback stays
// at that snapshot.
func (h *networkEventsHarness) stubNetmonCache(t *testing.T, cache *netmon.State) {
t.Helper()
oldCurrent, oldRead := networkChangeCurrentStateFn, readNetworkSourceStateFn
t.Cleanup(func() { networkChangeCurrentStateFn, readNetworkSourceStateFn = oldCurrent, oldRead })
networkChangeCurrentStateFn = func(*netmon.ChangeDelta) *netmon.State { return cache }
readNetworkSourceStateFn = func() (*netmon.State, error) { return cache, nil }
}
// handleMinor drives one minor callback. netmon puts the cached major snapshot
// in Old, whatever the callback before it reported.
func (h *networkEventsHarness) handleMinor(cache, after *netmon.State) {
h.prog.handleNetworkChange(context.Background(), &netmon.ChangeDelta{Monitor: netmon.NewStatic(), Old: cache, New: after}, false)
}
// interfaceEventsFor counts the journal events of one interface.
func (h *networkEventsHarness) interfaceEventsFor(t *testing.T, name string) int {
t.Helper()
count := 0
for _, event := range jsonLogEvents(t, h.logs, interfaceChangedMessage) {
if event["interface"] == name {
count++
}
}
return count
}
// eventsAtLevel selects the captured events of one message and one level.
func (h *networkEventsHarness) eventsAtLevel(t *testing.T, message, level string) []map[string]any {
t.Helper()
var selected []map[string]any
for _, event := range jsonLogEvents(t, h.logs, message) {
if event["level"] == level {
selected = append(selected, event)
}
}
return selected
}
var networkEventsHardwareIface = deltaTestInterface{
name: "en0", up: true, mac: "aa:bb:cc:dd:ee:01", mtu: 1500, ips: []string{"192.0.2.10/24"},
}
// networkEventsAddressChange returns a delta that moves the address of en0.
func networkEventsAddressChange(t *testing.T) (before, after *netmon.State) {
t.Helper()
return deltaTestState(t, "en0", networkEventsHardwareIface),
deltaTestState(t, "en0", deltaTestInterface{
name: "en0", up: true, mac: "aa:bb:cc:dd:ee:01", mtu: 1500, ips: []string{"192.0.2.20/24"},
})
}
// transitionEvent returns the one transition event of a callback.
func (h *networkEventsHarness) transitionEvent(t *testing.T) map[string]any {
t.Helper()
events := jsonLogEvents(t, h.logs, networkTransitionMessage)
if len(events) != 1 {
t.Fatalf("got %d %q events, want 1", len(events), networkTransitionMessage)
}
return events[0]
}
// wantNoField fails when an event carries a field that it must not.
func wantNoField(t *testing.T, event map[string]any, field string) {
t.Helper()
if value, carried := event[field]; carried {
t.Fatalf("field %q: got %v, want no field", field, value)
}
}
// noiseDeltaStates returns a delta that moves the AirDrop link-local address.
func noiseDeltaStates(t *testing.T, step int) (before, after *netmon.State) {
t.Helper()
airdrop := func(address int) deltaTestInterface {
return deltaTestInterface{name: "awdl0", up: true, mtu: 1484, ips: []string{fmt.Sprintf("fe80::%d/64", address)}}
}
return deltaTestState(t, "en0", networkEventsHardwareIface, airdrop(step)),
deltaTestState(t, "en0", networkEventsHardwareIface, airdrop(step+1))
}
func wantStringsField(t *testing.T, event map[string]any, field string, want ...string) {
t.Helper()
values, ok := event[field].([]any)
if !ok {
t.Fatalf("field %q: got %v, want a list", field, event[field])
}
got := make([]string, 0, len(values))
for _, value := range values {
text, isText := value.(string)
if !isText {
t.Fatalf("field %q: got %v, want strings", field, event[field])
}
got = append(got, text)
}
if !slices.Equal(got, want) {
t.Fatalf("field %q: got %v, want %v", field, got, want)
}
}
func Test_networkEventsNoiseDeltaSkipsTheHandler(t *testing.T) {
h := newNetworkEventsHarness(t, "en0")
before, after := noiseDeltaStates(t, 1)
h.handle(before, after, false)
if h.reconciled != 0 || h.ignored != 0 {
t.Fatalf("noise delta ran the handler: reconciled=%d ignored=%d, want 0/0", h.reconciled, h.ignored)
}
debugLines := h.eventsAtLevel(t, noiseDeltaMessage, "debug")
if len(debugLines) != 1 {
t.Fatalf("got %d debug %q lines, want 1", len(debugLines), noiseDeltaMessage)
}
wantStringsField(t, debugLines[0], "interfaces", "awdl0")
if events := jsonLogEvents(t, h.logs, networkTransitionMessage); len(events) != 0 {
t.Fatalf("got %d %q events for a noise delta, want 0", len(events), networkTransitionMessage)
}
}
func Test_networkEventsNoiseStormEmitsOneSummaryPerWindow(t *testing.T) {
h := newNetworkEventsHarness(t, "en0")
start := h.now
for step := 0; step <= 10; step++ {
h.now = start.Add(time.Duration(step) * time.Minute)
before, after := noiseDeltaStates(t, step)
h.handle(before, after, false)
}
summaries := h.eventsAtLevel(t, noiseDeltaMessage, "info")
if len(summaries) != 2 {
t.Fatalf("got %d journal %q events, want 2", len(summaries), noiseDeltaMessage)
}
wantField(t, summaries[0], "journal", true)
wantField(t, summaries[0], "count", float64(1))
wantField(t, summaries[1], "count", float64(10))
wantField(t, summaries[1], "first_at", start.Add(time.Minute).Format(time.RFC3339))
wantField(t, summaries[1], "last_at", start.Add(10*time.Minute).Format(time.RFC3339))
wantStringsField(t, summaries[1], "interfaces", "awdl0")
}
func Test_networkEventsLogsEveryChangedInterface(t *testing.T) {
h := newNetworkEventsHarness(t, "en0", "en5")
before := deltaTestState(t, "en0",
deltaTestInterface{name: "en0", up: true, mtu: 1500, ips: []string{"192.0.2.10/24", "2001:db8::10/64"}},
deltaTestInterface{name: "en5", up: true, mtu: 1500, ips: []string{"198.51.100.10/24"}},
)
before.HaveV4, before.HaveV6 = true, true
after := deltaTestState(t, "en0",
deltaTestInterface{name: "en0", up: true, mtu: 1500, ips: []string{"192.0.2.20/24"}},
deltaTestInterface{name: "en5", up: true, mtu: 1500, ips: []string{"198.51.100.20/24"}},
)
after.HaveV4, after.HaveV6 = true, false
h.handle(before, after, false)
changes := jsonLogEvents(t, h.logs, interfaceChangedMessage)
if len(changes) != 2 {
t.Fatalf("got %d %q events, want 2", len(changes), interfaceChangedMessage)
}
for index, name := range []string{"en0", "en5"} {
wantField(t, changes[index], "journal", true)
wantField(t, changes[index], "level", "info")
wantField(t, changes[index], "interface", name)
wantField(t, changes[index], "action", "ip_removed")
wantField(t, changes[index], "class", "hardware")
wantField(t, changes[index], "transition_id", float64(1))
wantField(t, changes[index], "mtu", float64(1500))
}
wantField(t, changes[0], "is_default_route", true)
wantField(t, changes[1], "is_default_route", false)
wantStringsField(t, changes[0], "ips_before", "192.0.2.10/24", "2001:db8::10/64")
wantStringsField(t, changes[0], "ips_after", "192.0.2.20/24")
transitions := jsonLogEvents(t, h.logs, networkTransitionMessage)
if len(transitions) != 1 {
t.Fatalf("got %d %q events, want 1", len(transitions), networkTransitionMessage)
}
wantField(t, transitions[0], "journal", true)
wantField(t, transitions[0], "level", "info")
wantField(t, transitions[0], "outcome", "accepted")
wantField(t, transitions[0], "time_jumped", false)
wantField(t, transitions[0], "have_v4_before", true)
wantField(t, transitions[0], "have_v4_after", true)
wantField(t, transitions[0], "have_v6_before", true)
wantField(t, transitions[0], "have_v6_after", false)
wantStringsField(t, transitions[0], "changed_interfaces", "en0", "en5")
if h.reconciled != 1 {
t.Fatalf("reconciled = %d, want 1", h.reconciled)
}
if snapshots := jsonLogEvents(t, h.logs, networkSnapshotMessage); len(snapshots) != 1 {
t.Fatalf("got %d %q events, want 1", len(snapshots), networkSnapshotMessage)
}
}
func Test_networkEventsTimeJumpReportsTheWake(t *testing.T) {
h := newNetworkEventsHarness(t, "en0")
armed := hostWokeTestHold(h.prog)
before, after := noiseDeltaStates(t, 1)
h.handle(before, after, true)
for _, fire := range *armed {
fire()
}
wakes := jsonLogEvents(t, h.logs, hostWokeMessage)
if len(wakes) != 1 {
t.Fatalf("got %d %q events, want 1", len(wakes), hostWokeMessage)
}
wantField(t, wakes[0], "journal", true)
wantField(t, wakes[0], "source", "netmon")
wantField(t, wakes[0], "gap_known", false)
wantField(t, wakes[0], "default_route", "en0")
if _, measured := wakes[0]["gap_ms"]; measured {
t.Fatalf("the netmon report carries a gap that it cannot measure: %v", wakes[0])
}
}
func Test_networkEventsStoreTheIPv6Flag(t *testing.T) {
h := newNetworkEventsHarness(t, "en0")
// The one-time probe wins over the first store, so consume it first.
available := ctrld.HasIPv6(context.Background())
t.Cleanup(func() { ctrld.SetIPv6Available(available) })
ctrld.SetIPv6Available(true)
before := deltaTestState(t, "en0", networkEventsHardwareIface)
before.HaveV6 = true
after := deltaTestState(t, "en0", deltaTestInterface{
name: "en0", up: true, mac: "aa:bb:cc:dd:ee:01", mtu: 1500, ips: []string{"192.0.2.20/24"},
})
after.HaveV6 = false
h.handle(before, after, false)
if ctrld.HasIPv6(context.Background()) {
t.Fatal("HasIPv6 = true after a callback with have_v6 false, want false")
}
}
// Test_networkEventsMinorDeltaDiffsAgainstTheStateBeforeIt covers a difference
// that lasts. The cached major snapshot holds no link-local address of en0, so
// a diff against the cache reports the same change in every minor callback.
func Test_networkEventsMinorDeltaDiffsAgainstTheStateBeforeIt(t *testing.T) {
h := newNetworkEventsHarness(t, "en0")
en0WithLinkLocal := deltaTestInterface{
name: "en0", up: true, mac: "aa:bb:cc:dd:ee:01", mtu: 1500,
ips: []string{"192.0.2.10/24", "fe80::a/64"},
}
airdrop := func(address int) deltaTestInterface {
return deltaTestInterface{name: "awdl0", up: true, mtu: 1484, ips: []string{fmt.Sprintf("fe80::%d/64", address)}}
}
cache := deltaTestState(t, "en0", networkEventsHardwareIface, airdrop(1))
h.stubNetmonCache(t, cache)
h.handleMinor(cache, deltaTestState(t, "en0", en0WithLinkLocal, airdrop(2)))
h.handleMinor(cache, deltaTestState(t, "en0", en0WithLinkLocal, airdrop(3)))
if got := h.interfaceEventsFor(t, "en0"); got != 1 {
t.Fatalf("got %d %q events for en0, want 1", got, interfaceChangedMessage)
}
if noise := h.eventsAtLevel(t, noiseDeltaMessage, "debug"); len(noise) != 1 {
t.Fatalf("got %d debug %q lines, want 1 for the second delta", len(noise), noiseDeltaMessage)
}
}
// Test_networkEventsNoiseDeltaReadsNoPlatformMeta pins the class-first order.
// The macOS meta read starts two subprocesses per call, so a storm of noise
// deltas must not reach it.
func Test_networkEventsNoiseDeltaReadsNoPlatformMeta(t *testing.T) {
h := newNetworkEventsHarness(t, "en0")
reads := countPlatformMetaReads(t)
before, after := noiseDeltaStates(t, 1)
h.handle(before, after, false)
if *reads != 0 {
t.Fatalf("a noise delta read the platform meta %d times, want 0", *reads)
}
}
// countPlatformMetaReads counts the platform meta reads of a test. One read
// starts two subprocesses on macOS, so the count is the cost of a delta.
func countPlatformMetaReads(t *testing.T) *int {
t.Helper()
reads := 0
original := platformInterfaceMetaFn
t.Cleanup(func() { platformInterfaceMetaFn = original })
platformInterfaceMetaFn = func(string) (class, hardwarePort, service string) {
reads++
return "", "", ""
}
return &reads
}
// Test_networkEventsNoiseStormEndsWithTheNextTransition covers the last window
// of a storm. The count of the deltas that a real change follows must not wait
// for the next storm.
func Test_networkEventsNoiseStormEndsWithTheNextTransition(t *testing.T) {
h := newNetworkEventsHarness(t, "en0")
start := h.now
for step := 0; step < 4; step++ {
h.now = start.Add(time.Duration(step) * time.Minute)
before, after := noiseDeltaStates(t, step)
h.handle(before, after, false)
}
h.now = start.Add(5 * time.Minute)
before, after := networkEventsAddressChange(t)
h.handle(before, after, false)
summaries := h.eventsAtLevel(t, noiseDeltaMessage, "info")
if len(summaries) != 2 {
t.Fatalf("got %d journal %q events, want the first delta and the end of the storm", len(summaries), noiseDeltaMessage)
}
wantField(t, summaries[1], "count", float64(3))
wantField(t, summaries[1], "first_at", start.Add(time.Minute).Format(time.RFC3339))
wantField(t, summaries[1], "last_at", start.Add(3*time.Minute).Format(time.RFC3339))
wantStringsField(t, summaries[1], "interfaces", "awdl0")
}
// Test_networkEventsStoreTheNetworkBeforeTheHeaderRender proves that the
// callback stores its network under the source lock and renders the header
// after the unlock, so the header read of the hardware ports starts no
// process under the lock.
func Test_networkEventsStoreTheNetworkBeforeTheHeaderRender(t *testing.T) {
h := newNetworkEventsHarness(t, "en0")
lockHeld, renders := false, 0
original := defaultRoutesFn
t.Cleanup(func() { defaultRoutesFn = original })
// The header render reads the routes, so this seam runs where the
// callback renders the header.
defaultRoutesFn = func() (defaultRoute, defaultRoute) {
renders++
if h.prog.networkSourceMu.TryLock() {
h.prog.networkSourceMu.Unlock()
} else {
lockHeld = true
}
return defaultRoute{Gateway: "192.0.2.1"}, defaultRoute{}
}
before, after := networkEventsAddressChange(t)
h.handle(before, after, false)
if renders == 0 {
t.Fatal("the callback rendered no header")
}
if lockHeld {
t.Fatal("the header render ran under the source lock")
}
if h.prog.lastNetworkState.Load() != after {
t.Fatal("the callback did not store its network")
}
}
// Test_networkEventsIgnoredTransitionStaysOutOfTheJournal pins the level of an
// ignored delta. Such a delta arrives many times per minute, so it belongs in
// the debug stream and not in the journal.
func Test_networkEventsIgnoredTransitionStaysOutOfTheJournal(t *testing.T) {
h := newNetworkEventsHarness(t, "en0")
before := deltaTestState(t, "en0", networkEventsHardwareIface,
deltaTestInterface{name: "en5", up: true, mtu: 1500, ips: []string{"198.51.100.10/24"}})
after := deltaTestState(t, "en0", networkEventsHardwareIface,
deltaTestInterface{name: "en5", up: true, mtu: 1500, ips: []string{"198.51.100.20/24"}})
h.handle(before, after, false)
transition := h.transitionEvent(t)
wantField(t, transition, "outcome", transitionOutcomeIgnored)
wantField(t, transition, "level", "debug")
wantNoField(t, transition, "journal")
if snapshots := jsonLogEvents(t, h.logs, networkSnapshotMessage); len(snapshots) != 0 {
t.Fatalf("got %d %q events for an ignored delta, want 0", len(snapshots), networkSnapshotMessage)
}
if h.ignored != 1 || h.reconciled != 0 {
t.Fatalf("ignored delta: ignored=%d reconciled=%d, want 1/0", h.ignored, h.reconciled)
}
}
// Test_networkEventsSupersededTransitionEntersTheJournal pins the level of a
// delta that a newer accepted delta replaced. Support reads it to tell a lost
// reconcile from one that never started.
func Test_networkEventsSupersededTransitionEntersTheJournal(t *testing.T) {
h := newNetworkEventsHarness(t, "en0")
h.prog.networkAcceptedGen.Store(5)
before, after := networkEventsAddressChange(t)
h.handle(before, after, false)
transition := h.transitionEvent(t)
wantField(t, transition, "outcome", "superseded")
wantField(t, transition, "level", "info")
wantField(t, transition, "journal", true)
if h.reconciled != 0 {
t.Fatalf("a superseded delta reconciled %d times, want 0", h.reconciled)
}
}
// Test_networkEventsSupersededSnapshotReportsTheOutcome covers the delta whose
// snapshot netmon already replaced. The callback logged nothing at all, so the
// journal showed a silent gap between two deltas.
func Test_networkEventsSupersededSnapshotReportsTheOutcome(t *testing.T) {
h := newNetworkEventsHarness(t, "en0")
cache := deltaTestState(t, "en0", networkEventsHardwareIface)
h.stubNetmonCache(t, cache)
reads := countPlatformMetaReads(t)
stale, after := networkEventsAddressChange(t)
// netmon holds a newer snapshot than the one that this callback carries.
h.prog.handleNetworkChange(context.Background(), &netmon.ChangeDelta{Monitor: netmon.NewStatic(), Old: stale, New: after}, false)
transition := h.transitionEvent(t)
wantField(t, transition, "outcome", "snapshot_superseded")
wantField(t, transition, "level", "debug")
wantNoField(t, transition, "journal")
wantStringsField(t, transition, "changed_interfaces", "en0")
if events := jsonLogEvents(t, h.logs, interfaceChangedMessage); len(events) != 0 {
t.Fatalf("got %d %q events for a superseded snapshot, want 0", len(events), interfaceChangedMessage)
}
if h.reconciled != 0 || h.ignored != 0 {
t.Fatalf("superseded snapshot ran the handler: reconciled=%d ignored=%d, want 0/0", h.reconciled, h.ignored)
}
if *reads != 0 {
t.Fatalf("a superseded snapshot read the platform meta %d times, want 0", *reads)
}
}
// Test_networkEventsLogsTheInterfacesOutsideTheNoiseClass covers a mixed
// delta. An AirDrop change beside a real one must not fill the journal, but
// the transition still names every interface that changed.
func Test_networkEventsLogsTheInterfacesOutsideTheNoiseClass(t *testing.T) {
h := newNetworkEventsHarness(t, "en0")
before := deltaTestState(t, "en0", networkEventsHardwareIface,
deltaTestInterface{name: "awdl0", up: true, mtu: 1484, ips: []string{"fe80::1/64"}})
after := deltaTestState(t, "en0",
deltaTestInterface{name: "en0", up: true, mac: "aa:bb:cc:dd:ee:01", mtu: 1500, ips: []string{"192.0.2.20/24"}},
deltaTestInterface{name: "awdl0", up: true, mtu: 1484, ips: []string{"fe80::2/64"}})
h.handle(before, after, false)
events := jsonLogEvents(t, h.logs, interfaceChangedMessage)
if len(events) != 1 {
t.Fatalf("got %d %q events, want one for the hardware port", len(events), interfaceChangedMessage)
}
wantField(t, events[0], "interface", "en0")
wantField(t, events[0], "class", "hardware")
wantStringsField(t, h.transitionEvent(t), "changed_interfaces", "awdl0", "en0")
}
// reorderTestState builds a state with en0 on one address and awdl0 on one
// link-local address, so a test can replay callbacks out of order.
func reorderTestState(en0 string, awdl string) *netmon.State {
return &netmon.State{
Interface: map[string]netmon.Interface{
"en0": {Interface: &net.Interface{Name: "en0", Flags: net.FlagUp}},
"awdl0": {Interface: &net.Interface{Name: "awdl0", Flags: net.FlagUp}},
},
InterfaceIPs: map[string][]netip.Prefix{
"en0": {netip.MustParsePrefix(en0)},
"awdl0": {netip.MustParsePrefix(awdl)},
},
DefaultRouteInterface: "en0",
HaveV4: true,
}
}
// firstV4Of returns the first IPv4 address of one interface of a state.
func firstV4Of(state *netmon.State, name string) net.IP {
for _, prefix := range state.InterfaceIPs[name] {
if prefix.Addr().Is4() {
return net.IP(prefix.Addr().AsSlice())
}
}
return nil
}
// Test_networkEventsSupersededCallbackKeepsTheBaseline replays the callbacks
// out of order: B runs, a late A arrives while the monitor already caches C,
// then C runs. The late A must not become the baseline of C, or the address
// change from B to C looks like AirDrop noise and the stale source stays.
func Test_networkEventsSupersededCallbackKeepsTheBaseline(t *testing.T) {
h := newNetworkEventsHarness(t, "en0")
stateA := reorderTestState("192.0.2.20/24", "fe80::1/64")
stateB := reorderTestState("192.0.2.10/24", "fe80::1/64")
stateC := reorderTestState("192.0.2.20/24", "fe80::2/64")
cache := stateB
oldCurrent := networkChangeCurrentStateFn
t.Cleanup(func() { networkChangeCurrentStateFn = oldCurrent })
networkChangeCurrentStateFn = func(*netmon.ChangeDelta) *netmon.State { return cache }
networkChangeReconcileFn = func(p *prog, _ context.Context, _ uint64) {
h.reconciled++
ctrld.SetDefaultLocalIPv4(context.Background(), firstV4Of(p.networkSourceState, "en0"))
}
monitor := netmon.NewStatic()
h.prog.handleNetworkChange(context.Background(), &netmon.ChangeDelta{Monitor: monitor, Old: stateA, New: stateB}, true)
cache = stateC
h.prog.handleNetworkChange(context.Background(), &netmon.ChangeDelta{Monitor: monitor, Old: &netmon.State{}, New: stateA}, true)
h.prog.handleNetworkChange(context.Background(), &netmon.ChangeDelta{Monitor: monitor, Old: stateB, New: stateC}, true)
if h.reconciled != 2 {
t.Fatalf("reconciles = %d, want 2: the late callback must not hide the change from B to C", h.reconciled)
}
if got := ctrld.GetDefaultLocalIPv4().String(); got != "192.0.2.20" {
t.Fatalf("resolver source = %s, want 192.0.2.20", got)
}
}
// orderTestState builds a state for the callback order tests: en0 on one
// address, plus extra interfaces with their addresses and their up flag.
func orderTestState(route, en0 string, extra map[string][]string, down map[string]bool) *netmon.State {
state := &netmon.State{
Interface: map[string]netmon.Interface{},
InterfaceIPs: map[string][]netip.Prefix{},
DefaultRouteInterface: route,
HaveV4: true,
}
add := func(name string, up bool, ips []string) {
flags := net.Flags(0)
if up {
flags = net.FlagUp
}
state.Interface[name] = netmon.Interface{Interface: &net.Interface{Name: name, Flags: flags}}
for _, ip := range ips {
state.InterfaceIPs[name] = append(state.InterfaceIPs[name], netip.MustParsePrefix(ip))
}
}
if en0 != "" {
add("en0", true, []string{en0})
}
for name, ips := range extra {
add(name, !down[name], ips)
}
return state
}
// Test_networkEventsMajorAfterItsMinorKeepsItsChanges runs a minor callback
// of the new epoch before the major callback that opened the epoch. netmon
// starts one goroutine per callback, so this order happens. The major must
// still report the address change and reconcile.
func Test_networkEventsMajorAfterItsMinorKeepsItsChanges(t *testing.T) {
for _, order := range []string{"in_order", "minor_first", "minor_first_time_jumped"} {
t.Run(order, func(t *testing.T) {
h := newNetworkEventsHarness(t, "en0")
hostWokeTestHold(h.prog)
s0 := orderTestState("en0", "192.0.2.10/24", map[string][]string{"awdl0": {"fe80::1/64"}}, nil)
s2 := orderTestState("en0", "192.0.2.20/24", map[string][]string{"awdl0": {"fe80::1/64"}}, nil)
s3 := orderTestState("en0", "192.0.2.20/24", map[string][]string{"awdl0": {"fe80::2/64"}}, nil)
h.stubNetmonCache(t, s2)
monitor := netmon.NewStatic()
major := &netmon.ChangeDelta{Monitor: monitor, Old: s0, New: s2, TimeJumped: order == "minor_first_time_jumped"}
minor := &netmon.ChangeDelta{Monitor: monitor, Old: s2, New: s3}
if order == "in_order" {
h.prog.handleNetworkChange(context.Background(), major, true)
h.prog.handleNetworkChange(context.Background(), minor, false)
} else {
h.prog.handleNetworkChange(context.Background(), minor, false)
h.prog.handleNetworkChange(context.Background(), major, true)
}
if h.reconciled != 1 {
t.Fatalf("reconciled = %d, want 1 for the move of en0 from .10 to .20", h.reconciled)
}
if got := h.interfaceEventsFor(t, "en0"); got != 1 {
t.Fatalf("en0 interface events = %d, want 1", got)
}
if h.prog.networkDeltaState != s3 {
t.Fatal("the baseline must stay at the newest state of the epoch")
}
})
}
}
// Test_networkEventsEpochChangeReportsAMinorChangeOnce closes an epoch with a
// major callback whose Old is the cache of the epoch. The interface that a
// minor callback of that epoch reported must not appear again.
func Test_networkEventsEpochChangeReportsAMinorChangeOnce(t *testing.T) {
h := newNetworkEventsHarness(t, "en0")
s0 := orderTestState("en0", "192.0.2.10/24", nil, nil)
s1 := orderTestState("en0", "192.0.2.10/24", map[string][]string{"ipsec0": {"10.0.0.2/32"}}, nil)
s2 := orderTestState("en0", "192.0.2.20/24", map[string][]string{"ipsec0": {"10.0.0.2/32"}}, nil)
cache := s0
oldCurrent, oldRead := networkChangeCurrentStateFn, readNetworkSourceStateFn
t.Cleanup(func() { networkChangeCurrentStateFn, readNetworkSourceStateFn = oldCurrent, oldRead })
networkChangeCurrentStateFn = func(*netmon.ChangeDelta) *netmon.State { return cache }
readNetworkSourceStateFn = func() (*netmon.State, error) { return cache, nil }
monitor := netmon.NewStatic()
h.prog.handleNetworkChange(context.Background(), &netmon.ChangeDelta{Monitor: monitor, Old: s0, New: s1}, false)
cache = s2
h.prog.handleNetworkChange(context.Background(), &netmon.ChangeDelta{Monitor: monitor, Old: s0, New: s2}, true)
if got := h.interfaceEventsFor(t, "ipsec0"); got != 1 {
t.Fatalf("ipsec0 interface events = %d, want 1", got)
}
if got := h.interfaceEventsFor(t, "en0"); got != 1 {
t.Fatalf("en0 interface events = %d, want 1", got)
}
}
// Test_networkEventsNewValidInterfaceReconciles plugs a valid interface in
// with its address in one delta while the other valid interface is down. The
// new interface is active, so the change must reconcile.
func Test_networkEventsNewValidInterfaceReconciles(t *testing.T) {
h := newNetworkEventsHarness(t, "en0", "en7")
before := orderTestState("", "", map[string][]string{"en0": nil}, map[string]bool{"en0": true})
before.HaveV4 = false
after := orderTestState("en7", "", map[string][]string{"en0": nil, "en7": {"192.0.2.30/24"}}, map[string]bool{"en0": true})
h.handle(before, after, false)
if h.reconciled != 1 {
event := h.transitionEvent(t)
t.Fatalf("reconciled = %d, want 1; outcome %v", h.reconciled, event["outcome"])
}
}