Every Go-identifier reference in // and /* */ comments now uses
godoc's [Name] linking syntax so pkg.go.dev and `go doc` render
them as clickable cross-references. No behaviour change.
Pattern applied across the tree:
In-package [Foo], [Foo.Bar]
Cross-package [pkg.Foo], [pkg.Foo.Bar]
Stdlib [netip.Prefix], [errors.Is], [context.Context]
Tailscale [tailcfg.MapResponse], [tailcfg.Node.CapMap],
[tailcfg.NodeAttrSuggestExitNode]
Skip rules:
- File:line refs left as plain text
- HuJSON wire keys inside backtick raw strings untouched
- ACL/policy syntax tokens (tag:foo, autogroup:self, ...) not Go
symbols, left as plain text
- JSON/OIDC wire keys, gorm tags, RFC IPv6 placeholders, markdown
link tags, decorative dividers — all left as-is
151 lines
5.0 KiB
Go
151 lines
5.0 KiB
Go
package servertest_test
|
|
|
|
import (
|
|
"context"
|
|
"net/netip"
|
|
"slices"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/juanfont/headscale/hscontrol/servertest"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"tailscale.com/tailcfg"
|
|
)
|
|
|
|
// TestConnectDisconnectRace targets the residual TOCTOU window in
|
|
// [state.State.Disconnect]: the connectGeneration check at state.go:644 is not
|
|
// atomic with the subsequent [state.NodeStore.UpdateNode] and
|
|
// primaryRoutes.SetRoutes calls. A new [state.State.Connect] that runs between the
|
|
// gen check and the mutations can have its effects overwritten by the
|
|
// stale [state.State.Disconnect]'s SetRoutes(empty).
|
|
//
|
|
// The poll.go grace-period flow protects against the most common case
|
|
// ([state.State.RemoveNode] + stillConnected). Connect/Disconnect on [state.State] directly
|
|
// bypasses that protection and should still leave the state consistent
|
|
// — if it doesn't, that is the bug behind the original race issue.
|
|
//
|
|
// Run with -race to also catch any data race exposed.
|
|
func TestConnectDisconnectRace(t *testing.T) {
|
|
srv := servertest.NewServer(t)
|
|
user := srv.CreateUser(t, "race-user")
|
|
|
|
route := netip.MustParsePrefix("10.0.0.0/24")
|
|
|
|
// Use [servertest.NewClient] to get a node fully registered + Connected via the
|
|
// real noise/poll path. After this, [state.NodeStore] + primaryRoutes already
|
|
// have the node, and [state.State.Connect] has been called once.
|
|
//
|
|
// Only c2 advertises the route. [tailcfg.NodeView.PrimaryRoutes] preserves a current
|
|
// primary across changes (anti-flap, see primary.go), so if both
|
|
// nodes were advertising, c1 (lower NodeID) would stay primary and
|
|
// the test could never observe the route slipping out of c2's
|
|
// [tailcfg.NodeView.PrimaryRoutes] — it would never have been there in the first place.
|
|
c1 := servertest.NewClient(t, srv, "race-r1", servertest.WithUser(user))
|
|
c2 := servertest.NewClient(t, srv, "race-r2", servertest.WithUser(user))
|
|
|
|
c1.WaitForPeers(t, 1, 10*time.Second)
|
|
|
|
c2.Direct().SetHostinfo(&tailcfg.Hostinfo{
|
|
BackendLogID: "servertest-race-r2",
|
|
Hostname: "race-r2",
|
|
RoutableIPs: []netip.Prefix{route},
|
|
})
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
_ = c2.Direct().SendUpdate(ctx)
|
|
|
|
cancel()
|
|
|
|
r2ID := findNodeID(t, srv, "race-r2")
|
|
|
|
_, ch, err := srv.State().SetApprovedRoutes(r2ID, []netip.Prefix{route})
|
|
require.NoError(t, err)
|
|
srv.App.Change(ch)
|
|
|
|
// Wait for advertisement + approval to be reflected as a primary
|
|
// route assignment in [tailcfg.NodeView.PrimaryRoutes]; otherwise we'd be racing the
|
|
// initial steady-state setup, not the [state.State.Connect]/[state.State.Disconnect] window.
|
|
require.Eventually(t, func() bool {
|
|
return slices.Contains(srv.State().GetNodePrimaryRoutes(r2ID), route)
|
|
}, 10*time.Second, 50*time.Millisecond,
|
|
"primary route should be assigned to r2 before driving the race")
|
|
|
|
// Drive the race repeatedly. Each iteration:
|
|
// 1. Call [state.State.Connect](id) to obtain a fresh gen — this stands in for
|
|
// a session that "owns" the node.
|
|
// 2. Spawn a goroutine that issues [state.State.Disconnect](id, gen) — the
|
|
// stale deferred disconnect.
|
|
// 3. Concurrently spawn a goroutine that issues [state.State.Connect](id) —
|
|
// the new session arriving.
|
|
// 4. After both finish, check the state is consistent: the node
|
|
// should be online and primaryRoutes should hold the approved
|
|
// route for it.
|
|
//
|
|
// The two goroutines synchronise on a barrier so they start
|
|
// approximately simultaneously, maximising the chance of hitting the
|
|
// TOCTOU window.
|
|
const iterations = 100
|
|
|
|
for i := range iterations {
|
|
// Establish a "current session" with a known gen for r2.
|
|
_, gen := srv.State().Connect(r2ID)
|
|
|
|
var wg sync.WaitGroup
|
|
|
|
start := make(chan struct{})
|
|
|
|
wg.Add(2)
|
|
|
|
go func() {
|
|
defer wg.Done()
|
|
|
|
<-start
|
|
|
|
_, _ = srv.State().Disconnect(r2ID, gen)
|
|
}()
|
|
go func() {
|
|
defer wg.Done()
|
|
|
|
<-start
|
|
|
|
_, _ = srv.State().Connect(r2ID)
|
|
}()
|
|
|
|
close(start)
|
|
wg.Wait()
|
|
|
|
// Post-condition: the node should be ONLINE (the new Connect's
|
|
// effect must dominate, because the stale Disconnect ran with
|
|
// an older gen and should have been a no-op — or its effects
|
|
// must not have overtaken the new Connect's writes).
|
|
nv, ok := srv.State().GetNodeByID(r2ID)
|
|
if !assert.True(t, ok, "iteration %d: node should exist", i) {
|
|
continue
|
|
}
|
|
|
|
online, known := nv.IsOnline().GetOk()
|
|
if !assert.True(t, known, "iteration %d: online status should be known", i) {
|
|
continue
|
|
}
|
|
|
|
assert.True(t, online,
|
|
"iteration %d: node should be ONLINE after concurrent Connect+Disconnect (gen=%d)",
|
|
i, gen)
|
|
|
|
// The approved route must still be reflected as a primary for r2.
|
|
primary := srv.State().GetNodePrimaryRoutes(r2ID)
|
|
assert.True(t, slices.Contains(primary, route),
|
|
"iteration %d: r2 should hold primary for %s after concurrent Connect+Disconnect, got %v",
|
|
i, route, primary)
|
|
|
|
if t.Failed() {
|
|
t.Logf("primaryRoutes state at failure:\n%s",
|
|
srv.State().PrimaryRoutesString())
|
|
|
|
return
|
|
}
|
|
}
|
|
}
|