mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-10-12 01:58:46 +02:00
828 lines
33 KiB
Go
828 lines
33 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"net/netip"
|
|
"slices"
|
|
"strings"
|
|
"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()
|
|
return noiseDeltaStatesFor(t, step, "awdl0")
|
|
}
|
|
|
|
// noiseDeltaStatesFor returns a delta that moves the link-local address of
|
|
// each named noise interface.
|
|
func noiseDeltaStatesFor(t *testing.T, step int, names ...string) (before, after *netmon.State) {
|
|
t.Helper()
|
|
noise := func(address int) []deltaTestInterface {
|
|
interfaces := []deltaTestInterface{networkEventsHardwareIface}
|
|
for _, name := range names {
|
|
interfaces = append(interfaces, deltaTestInterface{name: name, up: true, mtu: 1484, ips: []string{fmt.Sprintf("fe80::%d/64", address)}})
|
|
}
|
|
return interfaces
|
|
}
|
|
return deltaTestState(t, "en0", noise(step)...), deltaTestState(t, "en0", noise(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)
|
|
}
|
|
}
|
|
|
|
// Test_networkEventsNoiseDeltaSkipsTheHandler covers the AirDrop link, the
|
|
// low-latency Wi-Fi link, and both in one callback, as an AirDrop transfer
|
|
// gives them.
|
|
func Test_networkEventsNoiseDeltaSkipsTheHandler(t *testing.T) {
|
|
for _, names := range [][]string{{"awdl0"}, {"llw0"}, {"awdl0", "llw0"}} {
|
|
t.Run(strings.Join(names, "+"), func(t *testing.T) {
|
|
h := newNetworkEventsHarness(t, "en0")
|
|
|
|
before, after := noiseDeltaStatesFor(t, 1, names...)
|
|
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", names...)
|
|
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"])
|
|
}
|
|
}
|