mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-10-12 01:58:46 +02:00
The journal records the network state of the host and each change of that state. The support team reads one file to see the interfaces, the routes, the resolvers, and the recoveries around an incident. The same file shows the health of the query path. A change that touches only AirDrop or virtual interfaces runs no DNS work.
805 lines
31 KiB
Go
805 lines
31 KiB
Go
package cli
|
|
|
|
import (
|
|
"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 })}
|
|
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{}
|
|
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{}
|
|
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),
|
|
}
|
|
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() map[string]struct{} { return validSet }
|
|
networkChangeReconcileFn = func(_ *prog, _ 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(&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(&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()
|
|
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() {
|
|
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(&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, _ uint64) {
|
|
h.reconciled++
|
|
ctrld.SetDefaultLocalIPv4(firstV4Of(p.networkSourceState, "en0"))
|
|
}
|
|
monitor := netmon.NewStatic()
|
|
|
|
h.prog.handleNetworkChange(&netmon.ChangeDelta{Monitor: monitor, Old: stateA, New: stateB}, true)
|
|
cache = stateC
|
|
h.prog.handleNetworkChange(&netmon.ChangeDelta{Monitor: monitor, Old: &netmon.State{}, New: stateA}, true)
|
|
h.prog.handleNetworkChange(&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(major, true)
|
|
h.prog.handleNetworkChange(minor, false)
|
|
} else {
|
|
h.prog.handleNetworkChange(minor, false)
|
|
h.prog.handleNetworkChange(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(&netmon.ChangeDelta{Monitor: monitor, Old: s0, New: s1}, false)
|
|
cache = s2
|
|
h.prog.handleNetworkChange(&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"])
|
|
}
|
|
}
|