Files
ctrld/cmd/cli/network_transition_test.go
T
Anthony Wong 8ae09ac98e feat(cli): put the network state of the host in the log journal
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.
2026-09-28 16:49:05 +07:00

325 lines
13 KiB
Go

package cli
import (
"context"
"encoding/json"
"errors"
"net"
"net/netip"
"strings"
"sync"
"testing"
"time"
"github.com/rs/zerolog"
"github.com/Control-D-Inc/ctrld"
"tailscale.com/net/netmon"
)
func sourceTestState(route string, up bool, ips ...string) *netmon.State {
flags := net.Flags(0)
if up {
flags = net.FlagUp
}
s := &netmon.State{
DefaultRouteInterface: route,
Interface: map[string]netmon.Interface{"en0": {Interface: &net.Interface{Name: "en0", Flags: flags}}},
InterfaceIPs: map[string][]netip.Prefix{},
}
for _, ip := range ips {
s.InterfaceIPs["en0"] = append(s.InterfaceIPs["en0"], netip.MustParsePrefix(ip))
}
return s
}
func captureTransitionLogs(t *testing.T) *syncBuffer {
t.Helper()
old := mainLog.Load()
buf := &syncBuffer{}
logger := zerolog.New(buf)
mainLog.Store(&logger)
t.Cleanup(func() { mainLog.Store(old) })
return buf
}
func sourceTestGlobals(t *testing.T) {
t.Helper()
// A transition reads the hardware ports and the virtual adapters, and no
// test may start the commands behind them.
stubHeaderSnapshotSources(t)
stubSnapshotVirtualSet(t)
v4, v6 := ctrld.GetDefaultLocalIPv4(), ctrld.GetDefaultLocalIPv6()
valid, route := networkChangeValidInterfacesFn, networkChangeDefaultRouteIPFn
reconcile, ignored := networkChangeReconcileFn, networkChangeIgnoredInterceptFn
intercept := dnsIntercept
dnsIntercept = true
networkChangeValidInterfacesFn = func() map[string]struct{} { return map[string]struct{}{"en0": {}} }
networkChangeDefaultRouteIPFn = func() string { return "" }
t.Cleanup(func() {
ctrld.SetDefaultLocalIPv4(v4)
ctrld.SetDefaultLocalIPv6(v6)
networkChangeValidInterfacesFn, networkChangeDefaultRouteIPFn = valid, route
networkChangeReconcileFn, networkChangeIgnoredInterceptFn = reconcile, ignored
dnsIntercept = intercept
})
}
func TestNetworkChangeSourceLifecycle(t *testing.T) {
const v4, v6 = "192.0.2.10", "2001:db8::10"
for _, tt := range []struct {
name string
newState *netmon.State
preferred string
want4, want6 string
outcome, reason4, reason6 string
}{
{"dhcp_lease_replacement_keeps_ipv6", sourceTestState("en0", true, "192.0.2.20/24", v6+"/64"), "", "192.0.2.20", v6, "accepted", "address_removed", ""},
{"dhcp_lease_replacement", sourceTestState("en0", true, "192.0.2.20/24"), "", "192.0.2.20", "", "accepted", "address_removed", "address_removed"},
{"accepted_ipv4_loss", sourceTestState("en0", true, v6+"/64"), "", "", v6, "accepted", "address_removed", ""},
{"accepted_ipv6_loss", sourceTestState("en0", true, v4+"/24"), v4, v4, "", "accepted", "", "address_removed"},
{"stale_route_discovery", sourceTestState("en0", true, v6+"/64"), v4, "", v6, "accepted", "address_removed", ""},
{"ignored_down_retains_addresses", sourceTestState("en0", false, v4+"/24", v6+"/64"), "", "", "", "ignored", "interface_down", "interface_down"},
{"ignored_address_loss", sourceTestState("en0", true), "", "", "", "ignored", "address_removed", "address_removed"},
{"skipped_no_active_interface", sourceTestState("", false, v4+"/24", v6+"/64"), "", "", "", "no_active_interface", "interface_down", "interface_down"},
{"ignored_valid_sources", sourceTestState("en0", true, v4+"/24", v6+"/64"), "", v4, v6, "ignored", "", ""},
{"accepted_valid_preferred_source", sourceTestState("en0", true, "192.0.2.20/24", v4+"/24", v6+"/64"), v4, v4, v6, "accepted", "", ""},
} {
t.Run(tt.name, func(t *testing.T) {
sourceTestGlobals(t)
logs := captureTransitionLogs(t)
ctrld.SetDefaultLocalIPv4(net.ParseIP(v4))
ctrld.SetDefaultLocalIPv6(net.ParseIP(v6))
networkChangeDefaultRouteIPFn = func() string { return tt.preferred }
p := &prog{cfg: &ctrld.Config{}, dnsInterceptState: &interceptStateStub{}}
accepted, ignored := 0, 0
assertSources := func() {
t.Helper()
if got := ctrld.GetDefaultLocalIPv4(); !got.Equal(net.ParseIP(tt.want4)) {
t.Errorf("IPv4 = %v, want %s", got, tt.want4)
}
if got := ctrld.GetDefaultLocalIPv6(); !got.Equal(net.ParseIP(tt.want6)) {
t.Errorf("IPv6 = %v, want %s", got, tt.want6)
}
}
// These seams precede all DNS/PF writes and recovery timers, on every OS.
networkChangeReconcileFn = func(_ *prog, id uint64) {
accepted++
if id != 1 {
t.Errorf("transition ID = %d", id)
}
assertSources()
}
networkChangeIgnoredInterceptFn = func(_ *prog, _ *netmon.ChangeDelta, _ time.Time) { ignored++; assertSources() }
p.handleNetworkChange(&netmon.ChangeDelta{Old: sourceTestState("en0", true, v4+"/24", v6+"/64"), New: tt.newState}, true)
assertSources()
wantAccepted, wantIgnored := 0, 0
if tt.outcome == "accepted" {
wantAccepted = 1
}
if tt.outcome == "ignored" {
wantIgnored = 1
}
if accepted != wantAccepted || ignored != wantIgnored {
t.Fatalf("mutation boundaries: accepted=%d ignored=%d; want %d/%d", accepted, ignored, wantAccepted, wantIgnored)
}
var transition, warning map[string]any
for _, line := range strings.Split(strings.TrimSpace(logs.String()), "\n") {
var event map[string]any
if err := json.Unmarshal([]byte(line), &event); err != nil {
t.Fatal(err)
}
switch event["message"] {
case "Network transition":
transition = event
case "Removed stale resolver source":
warning = event
}
}
if transition["outcome"] != tt.outcome || transition["transition_id"] != float64(1) {
t.Fatalf("transition: %v", transition)
}
wantWarning := tt.reason4 != "" || tt.reason6 != ""
if (warning != nil) != wantWarning {
t.Fatalf("warning = %v, want warning %v", warning, wantWarning)
}
if wantWarning && (warning["ipv4_clear_reason"] != tt.reason4 || warning["ipv6_clear_reason"] != tt.reason6 || warning["level"] != "warn") {
t.Fatalf("warning reasons/level: %v", warning)
}
})
}
}
func TestNetworkChangeDoesNotRestoreDownDefaultRouteSources(t *testing.T) {
sourceTestGlobals(t)
const v4, v6 = "192.0.2.10", "2001:db8::10"
ctrld.SetDefaultLocalIPv4(net.ParseIP(v4))
ctrld.SetDefaultLocalIPv6(net.ParseIP(v6))
networkChangeDefaultRouteIPFn = func() string { return v4 }
old := sourceTestState("en1", true, "2001:db8:1::10/64")
old.Interface["en1"] = netmon.Interface{Interface: &net.Interface{Name: "en1", Flags: net.FlagUp}}
old.InterfaceIPs["en1"] = []netip.Prefix{netip.MustParsePrefix(v4 + "/24"), netip.MustParsePrefix(v6 + "/64")}
newState := sourceTestState("en1", true, "2001:db8:1::20/64")
newState.Interface["en1"] = netmon.Interface{Interface: &net.Interface{Name: "en1"}}
newState.InterfaceIPs["en1"] = old.InterfaceIPs["en1"]
called := 0
networkChangeReconcileFn = func(_ *prog, _ uint64) {
called++
if ctrld.GetDefaultLocalIPv4() != nil || ctrld.GetDefaultLocalIPv6() != nil {
t.Error("down-interface sources reintroduced before recovery")
}
}
networkChangeIgnoredInterceptFn = func(_ *prog, _ *netmon.ChangeDelta, _ time.Time) { t.Fatal("accepted delta was ignored") }
(&prog{cfg: &ctrld.Config{}, dnsInterceptState: &interceptStateStub{}}).handleNetworkChange(&netmon.ChangeDelta{Old: old, New: newState}, true)
if called != 1 {
t.Fatalf("reconciliation called %d times", called)
}
}
func TestNetworkChangeSourceMovedToUpInterface(t *testing.T) {
sourceTestGlobals(t)
ctrld.SetDefaultLocalIPv4(net.ParseIP("192.0.2.10"))
ctrld.SetDefaultLocalIPv6(nil)
state := sourceTestState("en0", false, "192.0.2.10/24")
state.Interface["utun0"] = netmon.Interface{Interface: &net.Interface{Name: "utun0", Flags: net.FlagUp}}
state.InterfaceIPs["utun0"] = state.InterfaceIPs["en0"]
called := 0
networkChangeReconcileFn = func(_ *prog, _ uint64) { t.Fatal("unexpected recovery") }
networkChangeIgnoredInterceptFn = func(_ *prog, _ *netmon.ChangeDelta, _ time.Time) { called++ }
(&prog{cfg: &ctrld.Config{}, dnsInterceptState: &interceptStateStub{}}).handleNetworkChange(&netmon.ChangeDelta{Old: state, New: state}, false)
if called != 1 || !ctrld.GetDefaultLocalIPv4().Equal(net.ParseIP("192.0.2.10")) {
t.Fatal("valid source on another up interface was cleared")
}
}
func TestRecoveryTransitionDiagnostics(t *testing.T) {
logs := captureTransitionLogs(t)
stubHeaderSnapshotSources(t)
original, intercept := recoveryResetDNSFn, dnsIntercept
dnsIntercept = false
t.Cleanup(func() { recoveryResetDNSFn = original; dnsIntercept = intercept })
for _, superseded := range []bool{false, true} {
p := &prog{cfg: &ctrld.Config{}}
calls := 0
recoveryResetDNSFn = func(p *prog, start, restore bool) {
calls++
if start || restore {
t.Error("changed reset flags")
}
if superseded {
p.beginRecovery(RecoveryReasonNetworkChange)
} else {
p.recoveryCancel()
}
}
p.networkAcceptedGen.Store(42)
p.handleRecoveryForTransition(RecoveryReasonNetworkChange, 42)
if calls != 1 {
t.Fatalf("resetDNS seam called %d times", calls)
}
if p.recoveryCancel != nil {
p.recoveryCancel()
}
}
for _, expected := range []string{`"transition_id":42`, `"recovery_generation":1`, `"message":"Recovery begin"`, `"outcome":"canceled"`, `"outcome":"superseded"`, `"message":"Recovery end"`} {
if !strings.Contains(logs.String(), expected) {
t.Errorf("missing %s in %s", expected, logs.String())
}
}
}
func TestRecoveryDebouncePreservesTransitionID(t *testing.T) {
original := handleRecoveryForTransitionFn
t.Cleanup(func() { handleRecoveryForTransitionFn = original })
ids := make(chan uint64, 2)
handleRecoveryForTransitionFn = func(_ *prog, reason RecoveryReason, id uint64) {
if reason != RecoveryReasonNetworkChange {
t.Error("wrong recovery reason")
}
ids <- id
}
p := &prog{cfg: &ctrld.Config{}}
p.networkAcceptedGen.Store(11)
p.debounceRecovery(11)
p.networkAcceptedGen.Store(12)
p.debounceRecovery(12)
p.debounceRecovery(11) // a delayed older callback must not replace the new timer
select {
case id := <-ids:
if id != 12 {
t.Fatalf("recovery transition = %d, want 12", id)
}
case <-time.After(3 * time.Second):
t.Fatal("debounced recovery did not run")
}
}
func TestTransitionWarningsSurviveDebugRotation(t *testing.T) {
sourceTestGlobals(t)
level := zerolog.GlobalLevel()
zerolog.SetGlobalLevel(zerolog.DebugLevel)
t.Cleanup(func() { zerolog.SetGlobalLevel(level) })
old := mainLog.Load()
debug, warnings := newLogWriterWithSize(2048), newSmallLogWriter()
// Runtime summaries occur after initialization, outside the preserved prefix.
debug.Write([]byte(logWriterInitEndMarker))
logger := zerolog.New(zerolog.MultiLevelWriter(debug, newJournalLevelWriter(warnings)))
mainLog.Store(&logger)
t.Cleanup(func() { mainLog.Store(old) })
ctrld.SetDefaultLocalIPv4(net.ParseIP("192.0.2.10"))
ctrld.SetDefaultLocalIPv6(nil)
state := sourceTestState("en0", false, "192.0.2.10/24")
for range 100 {
validateDefaultLocalIPsFromDelta(state, 7)
}
d := &recoveryDiagnostic{transitionID: 7, generation: 9, started: time.Now()}
var wg sync.WaitGroup
for range 100 {
wg.Add(1)
go func() { defer wg.Done(); d.failure(errors.New("private.customer.example secret payload")) }()
}
wg.Wait()
d.end("completed")
(&recoveryDiagnostic{transitionID: 8, generation: 10, started: time.Now()}).end("completed")
journal(logger.Info()).Str("interface", "en0").Msg("Network snapshot")
logger.Info().Msg("plain info line")
for range 100 {
logger.Debug().Msg(strings.Repeat("noise", 100))
}
text := warnings.buf.String()
for _, msg := range []string{"Removed stale resolver source", "Recovery waiting for upstream; first failure"} {
if strings.Count(text, msg) != 1 {
t.Fatalf("expected one retained %q: %s", msg, text)
}
if strings.Contains(debug.buf.String(), msg) {
t.Fatalf("debug buffer did not rotate %q", msg)
}
}
if strings.Count(text, "Recovery end") != 2 || !strings.Contains(text, `"outcome":"completed"`) {
t.Fatal("recovery completion must survive rotation beside its retained failure")
}
if strings.Contains(text, "private.customer") || strings.Contains(text, "secret payload") {
t.Fatalf("raw payload or normal debug elevated: %s", text)
}
if strings.Count(text, "Network snapshot") != 1 {
t.Fatalf("expected one retained marked info line: %s", text)
}
if strings.Contains(text, "plain info line") {
t.Fatalf("unmarked info line must not be retained: %s", text)
}
}
func TestRecoveryWaitLogsFirstFailure(t *testing.T) {
logs := captureTransitionLogs(t)
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
d := &recoveryDiagnostic{transitionID: 8, generation: 2, started: time.Now()}
// Invalid resolver type fails construction before any network access.
_, err := (&prog{}).waitForUpstreamRecovery(ctx, map[string]*ctrld.UpstreamConfig{"invalid": {Type: "invalid"}}, d)
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("wait result = %v", err)
}
if strings.Count(logs.String(), "Recovery waiting for upstream; first failure") != 1 {
t.Fatalf("missing first-failure summary: %s", logs.String())
}
}