From e6da798e8c43d69d685b0afcbf4e4ad3c3c96548 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Tue, 20 Oct 2026 16:18:56 +0000 Subject: [PATCH] servertest: require a map on each stream an expiry replay opens The cached netmap outlives a stream, so a refused re-poll passed. --- hscontrol/servertest/expiry_compat_test.go | 58 +++++++++++++++++++--- 1 file changed, 51 insertions(+), 7 deletions(-) diff --git a/hscontrol/servertest/expiry_compat_test.go b/hscontrol/servertest/expiry_compat_test.go index 2176e9650..54e387ba1 100644 --- a/hscontrol/servertest/expiry_compat_test.go +++ b/hscontrol/servertest/expiry_compat_test.go @@ -179,12 +179,13 @@ func runNodeExpiryCompat(t *testing.T, id, observer string) { require.Equal(t, "observed", step.Result, "%s: the replay follows every step", step.Name) start := map[string]*netmap.NetworkMap{} - historyStart, attempts := map[string]int{}, map[string]int{} + historyStart, attempts, streams := map[string]int{}, map[string]int{}, map[string]int{} for alias, cl := range r.clients { start[alias] = cl.Netmap() historyStart[alias] = max(len(cl.History())-1, 0) attempts[alias] = len(cl.RegisterAttempts()) + streams[alias], _ = cl.Stream() if v := r.hsViews(alias); len(v) == 1 { hsPrev[alias] = v[0] @@ -195,7 +196,7 @@ func runNodeExpiryCompat(t *testing.T, id, observer string) { e := &expiryStep{ r: r, c: c, step: step, at: stepCaptureTime(step), saasNames: r.saasAddrNames(step), - prevCaps: prevCaps, start: start, historyStart: historyStart, + prevCaps: prevCaps, start: start, historyStart: historyStart, streams: streams, } var checks []stepCheck @@ -259,17 +260,19 @@ type expiryStep struct { // SaaS addresses. saasNames map[netip.Addr]string - // prevCaps holds SaaS's captures of the step before; start and - // historyStart headscale's netmaps when the step began. + // prevCaps holds SaaS's captures of the step before; start, historyStart + // and streams headscale's netmaps and map streams when the step began. prevCaps map[string]testcapture.Node start map[string]*netmap.NetworkMap historyStart map[string]int + streams map[string]int } // selfChecks compares x's own view: its key expiry flags, its peers among -// the capture's nodes, its packet filter and SSH rules and, when the step -// before captured x too, whether it lost any peer during the step and -// whether its DNS, packet filter and SSH policy changed. +// the capture's nodes, its packet filter and SSH rules, whether a map stream +// the step opened was answered and, when the step before captured x too, +// whether it lost any peer during the step and whether its DNS, packet +// filter and SSH policy changed. func (e *expiryStep) selfChecks(t *testing.T, x string) []stepCheck { t.Helper() @@ -293,6 +296,20 @@ func (e *expiryStep) selfChecks(t *testing.T, x string) []stepCheck { {x + "/ssh-rules", renderSSHRules(saas, e.saasNames), func() string { return renderSSHRules(cl.Netmap(), e.r.addrNames()) }}, } + // Only nodes whose stream SaaS recorded; opening one logs its request. + if opensStream(e.step, x) && len(e.step.Frames[x]) > 0 { + checks = append(checks, stepCheck{x + "/stream", saasStreamAnswer(e.step.Frames[x]), func() string { + switch opened, maps := cl.Stream(); { + case opened == e.streams[x]: + return "not opened" + case maps == 0: + return "unanswered" + default: + return "answered" + } + }}) + } + prev := e.prevCaps[x].Netmap if prev == nil || e.start[x] == nil { return checks @@ -709,6 +726,33 @@ func renderSSHRules(nm *netmap.NetworkMap, names map[netip.Addr]string) string { return strings.Join(out, "\n") } +// opensStream reports whether the step's actions open a new map stream for +// x: a login, a restart or a reconnect. +func opensStream(step testcapture.Step, x string) bool { + return slices.ContainsFunc(step.Actions, func(a testcapture.Action) bool { + return a.Node == x && slices.Contains([]string{ + "join", "relogin", "login", "restart", "start", "break_tcp_conns", "rotate_disco_key", + }, a.Kind) + }) +} + +// saasStreamAnswer says whether SaaS answered the last map stream the frames +// show opening with a full map. +func saasStreamAnswer(frames []testcapture.Frame) string { + answer := "not opened" + + for _, f := range frames { + switch { + case f.Request != nil && f.Request.Stream: + answer = "unanswered" + case answer == "unanswered" && f.Response != nil && f.Response.Node != nil: + answer = "answered" + } + } + + return answer +} + func changed(before, after string) string { if before == after { return "unchanged"